diff --git a/src/app/.well-known/openswarm-hub.json/route.ts b/src/app/.well-known/openswarm-hub.json/route.ts new file mode 100644 index 0000000..c9774c8 --- /dev/null +++ b/src/app/.well-known/openswarm-hub.json/route.ts @@ -0,0 +1,10 @@ +/** @route GET /.well-known/openswarm-hub.json — the hub record, where clients look first. */ +import { NextRequest } from 'next/server'; +import { hubRecord } from '@/lib/openswarm/service'; +import { json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + return json(hubRecord(new URL(request.url).origin)); +} diff --git a/src/app/api/openswarm/hub/route.ts b/src/app/api/openswarm/hub/route.ts new file mode 100644 index 0000000..601be0c --- /dev/null +++ b/src/app/api/openswarm/hub/route.ts @@ -0,0 +1,16 @@ +/** + * @route GET /api/openswarm/hub — the hub record. + * + * Also served at /.well-known/openswarm-hub.json, which is where the spec says + * a client looks. It says who this hub is, what it charges, which consent + * bases it will list, and that it carries all four lanes. + */ +import { NextRequest } from 'next/server'; +import { hubRecord } from '@/lib/openswarm/service'; +import { json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + return json(hubRecord(new URL(request.url).origin)); +} diff --git a/src/app/api/openswarm/pay2seed/attestations/[id]/route.ts b/src/app/api/openswarm/pay2seed/attestations/[id]/route.ts new file mode 100644 index 0000000..a711e62 --- /dev/null +++ b/src/app/api/openswarm/pay2seed/attestations/[id]/route.ts @@ -0,0 +1,17 @@ +/** @route GET /api/openswarm/pay2seed/attestations/[id] — the record and where it stands. */ +import { NextRequest } from 'next/server'; +import { getAttestation } from '@/lib/openswarm/service'; +import { failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const attestation = await getAttestation(decodeURIComponent(id)); + if (!attestation) return json({ error: 'no such attestation' }, 404); + return json({ attestation }); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/attestations/route.ts b/src/app/api/openswarm/pay2seed/attestations/route.ts new file mode 100644 index 0000000..b0277f4 --- /dev/null +++ b/src/app/api/openswarm/pay2seed/attestations/route.ts @@ -0,0 +1,23 @@ +/** + * @route POST /api/openswarm/pay2seed/attestations — register consent. + * + * Nothing is listed on this hub without one. The record is signed by the key + * that claims it, carries a README, and says on what basis it may be shared. + * A public claim is visible for a window first, so a notice can void it before + * anyone is paid to seed something the requester had no right to. + */ +import { NextRequest } from 'next/server'; +import { registerAttestation } from '@/lib/openswarm/service'; +import { body, failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const record = await body(request); + const { attestation, id } = await registerAttestation(record); + return json({ id, attestation }, 201); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/leases/[id]/route.ts b/src/app/api/openswarm/pay2seed/leases/[id]/route.ts new file mode 100644 index 0000000..9350e54 --- /dev/null +++ b/src/app/api/openswarm/pay2seed/leases/[id]/route.ts @@ -0,0 +1,17 @@ +/** @route GET /api/openswarm/pay2seed/leases/[id] — a lease, what it has earned, where it stands. */ +import { NextRequest } from 'next/server'; +import { getLease } from '@/lib/openswarm/service'; +import { failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const lease = await getLease(decodeURIComponent(id)); + if (!lease) return json({ error: 'no such lease' }, 404); + return json({ lease }); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/leases/route.ts b/src/app/api/openswarm/pay2seed/leases/route.ts new file mode 100644 index 0000000..77a480f --- /dev/null +++ b/src/app/api/openswarm/pay2seed/leases/route.ts @@ -0,0 +1,26 @@ +/** + * @route POST /api/openswarm/pay2seed/leases — take a slot on an offer. + * + * The seeder proves it holds the key by signing the offer id. It must already + * be a payee, and clear the hub's standing floor. The lane is stamped here, at + * the moment the money is agreed, so it survives either party later changing + * kind. + */ +import { NextRequest } from 'next/server'; +import { takeLease } from '@/lib/openswarm/service'; +import { body, failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const input = await body<{ offer?: string; seeder?: string; sig?: string }>(request); + if (!input.offer || !input.seeder || !input.sig) { + return json({ error: 'offer, seeder and sig are required' }, 400); + } + const lease = await takeLease({ offerId: input.offer, seederKey: input.seeder, sig: input.sig }); + return json({ lease }, 201); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/notices/route.ts b/src/app/api/openswarm/pay2seed/notices/route.ts new file mode 100644 index 0000000..0ef9d85 --- /dev/null +++ b/src/app/api/openswarm/pay2seed/notices/route.ts @@ -0,0 +1,47 @@ +/** + * @route POST /api/openswarm/pay2seed/notices — a claim against an attestation. + * + * Anyone may file one. The hub records it and forwards it to the requester's + * notice endpoint; whether the attestation is voided or stands is reviewed, + * and a hub that voided on nothing at all would not be conformant either way. + */ +import { NextRequest } from 'next/server'; +import { fileNotice } from '@/lib/openswarm/service'; +import { body, failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const input = await body<{ + attestation?: string; + kind?: 'rights' | 'illegal' | 'personal-data' | 'other'; + statement?: string; + claimant?: { name?: string; contact?: string }; + record?: unknown; + }>(request); + if (!input.attestation || !input.statement) { + return json({ error: 'attestation and statement are required' }, 400); + } + const result = await fileNotice({ + attestationId: input.attestation, + kind: input.kind ?? 'other', + statement: input.statement, + claimantName: input.claimant?.name ?? null, + claimantContact: input.claimant?.contact ?? null, + record: input.record, + }); + return json( + { + notice: result.noticeId, + attestation: result.attestation.id, + // Say plainly what happens next rather than implying it is already done. + outcome: 'received', + noticeEndpoint: result.attestation.noticeEndpoint, + }, + 201 + ); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/offers/[id]/route.ts b/src/app/api/openswarm/pay2seed/offers/[id]/route.ts new file mode 100644 index 0000000..d2c253d --- /dev/null +++ b/src/app/api/openswarm/pay2seed/offers/[id]/route.ts @@ -0,0 +1,32 @@ +/** @route GET /api/openswarm/pay2seed/offers/[id] — an offer, its attestation and its leases. */ +import { NextRequest } from 'next/server'; +import { getAttestation, getOffer } from '@/lib/openswarm/service'; +import { failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const offer = await getOffer(decodeURIComponent(id)); + if (!offer) return json({ error: 'no such offer' }, 404); + const attestation = await getAttestation(offer.attestationId); + return json({ + offer, + attestation: attestation + ? { + id: attestation.id, + basis: attestation.basis, + visibility: attestation.visibility, + license: attestation.license, + description: attestation.description, + subject: attestation.subject, + status: attestation.status, + readme: attestation.readme, + } + : null, + }); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/offers/route.ts b/src/app/api/openswarm/pay2seed/offers/route.ts new file mode 100644 index 0000000..6876966 --- /dev/null +++ b/src/app/api/openswarm/pay2seed/offers/route.ts @@ -0,0 +1,48 @@ +/** + * @route GET /api/openswarm/pay2seed/offers — the market a seeder picks work from. + * @route POST /api/openswarm/pay2seed/offers — post an offer against an attestation. + * + * The market only shows offers whose attestation is honoured, which is what + * the claim window is for. Every row carries the basis and the visibility, so a + * seeder knows before accepting whether it would be holding a stranger's + * ciphertext or seeding an openly licensed dataset in the clear. + */ +import { NextRequest } from 'next/server'; +import { createOffer, listMarket, quoteOffer, type OfferDraft } from '@/lib/openswarm/service'; +import { body, failed, json } from '@/lib/openswarm/route-helpers'; +import type { Basis, Visibility } from '@/lib/openswarm/types'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + try { + const url = new URL(request.url); + const rows = await listMarket({ + visibility: (url.searchParams.get('visibility') as Visibility) ?? undefined, + basis: (url.searchParams.get('basis') as Basis) ?? undefined, + limit: Number(url.searchParams.get('limit') ?? 50), + }); + return json({ offers: rows }); + } catch (error) { + return failed(error); + } +} + +export async function POST(request: NextRequest) { + try { + const input = await body(request); + if (!input.requester) return json({ error: 'requester is required' }, 400); + if (!input.attestation) return json({ error: 'attestation is required' }, 400); + + // A quote is free and pure, so a page can show the price without + // committing anyone to it. + if (input.quoteOnly) return json({ quote: quoteOffer(input) }); + + const { offer, quote } = await createOffer(input, input.requester); + // Unpaid until the money settles: a budget that is not escrowed is not a + // promise anybody should seed against. + return json({ offer, quote, status: 'unpaid' }, 201); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/parties/[key]/route.ts b/src/app/api/openswarm/pay2seed/parties/[key]/route.ts new file mode 100644 index 0000000..0d0c443 --- /dev/null +++ b/src/app/api/openswarm/pay2seed/parties/[key]/route.ts @@ -0,0 +1,17 @@ +/** @route GET /api/openswarm/pay2seed/parties/[key] — a key's standing and balance. */ +import { NextRequest } from 'next/server'; +import { getParty } from '@/lib/openswarm/service'; +import { failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(_request: NextRequest, { params }: { params: Promise<{ key: string }> }) { + try { + const { key } = await params; + const party = await getParty(decodeURIComponent(key)); + if (!party) return json({ error: 'no such key here' }, 404); + return json({ party }); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/parties/route.ts b/src/app/api/openswarm/pay2seed/parties/route.ts new file mode 100644 index 0000000..9d3fc7e --- /dev/null +++ b/src/app/api/openswarm/pay2seed/parties/route.ts @@ -0,0 +1,32 @@ +/** + * @route POST /api/openswarm/pay2seed/parties — register a key. + * + * A party says what it is: a human, or a bit (an autonomous agent). An agent + * that wants to sell in public names the operator answerable for it. A payout + * address is what makes a key a payee, and without one it cannot take a lease, + * because there would be nowhere to pay. + */ +import { NextRequest } from 'next/server'; +import { registerParty } from '@/lib/openswarm/service'; +import { body, failed, json } from '@/lib/openswarm/route-helpers'; +import type { PartyKind } from '@/lib/openswarm/lanes'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const input = await body<{ + key?: string; + kind?: PartyKind; + operatorKey?: string | null; + label?: string | null; + payoutAddress?: string | null; + payoutNetwork?: string | null; + }>(request); + if (!input.key) return json({ error: 'key is required' }, 400); + const party = await registerParty({ ...input, key: input.key }); + return json({ party }, 201); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/proofs/route.ts b/src/app/api/openswarm/pay2seed/proofs/route.ts new file mode 100644 index 0000000..a912076 --- /dev/null +++ b/src/app/api/openswarm/pay2seed/proofs/route.ts @@ -0,0 +1,56 @@ +/** + * @route POST /api/openswarm/pay2seed/proofs — report a period's verdict. + * + * A passed period is paid at once; the receipt's unique (lease, period) is the + * whole of the idempotency, so a verifier that reports twice pays once. Two + * consecutive failures end the lease and reopen the slot. + * + * Only the hub's own verifier may report today. A seeder cannot mark its own + * period proven, which is the point of a proof. + */ +import { NextRequest } from 'next/server'; +import { reportProof } from '@/lib/openswarm/service'; +import { body, failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +function authorised(request: NextRequest): boolean { + const expected = process.env.OPENSWARM_VERIFIER_TOKEN; + if (!expected) return false; + const header = request.headers.get('authorization') ?? ''; + return header === `Bearer ${expected}`; +} + +export async function POST(request: NextRequest) { + try { + if (!authorised(request)) return json({ error: 'a verifier token is required to report a proof' }, 401); + const input = await body<{ + lease?: string; + period?: number; + kind?: 'challenge' | 'probe'; + passed?: boolean; + verifier?: string | null; + detail?: string | null; + record?: unknown; + }>(request); + if (!input.lease || typeof input.period !== 'number' || typeof input.passed !== 'boolean') { + return json({ error: 'lease, period and passed are required' }, 400); + } + const result = await reportProof({ + leaseId: input.lease, + period: input.period, + kind: input.kind ?? 'probe', + passed: input.passed, + verifierKey: input.verifier ?? null, + detail: input.detail ?? null, + record: input.record, + }); + return json({ + lease: result.lease, + earnedUsd: result.earnedUsd, + alreadyRecorded: result.alreadyRecorded, + }); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/api/openswarm/pay2seed/seeders/[key]/route.ts b/src/app/api/openswarm/pay2seed/seeders/[key]/route.ts new file mode 100644 index 0000000..6a61e46 --- /dev/null +++ b/src/app/api/openswarm/pay2seed/seeders/[key]/route.ts @@ -0,0 +1,28 @@ +/** @route GET /api/openswarm/pay2seed/seeders/[key] — a seeder's standing, balance and leases. */ +import { NextRequest } from 'next/server'; +import { getParty, listLeasesFor } from '@/lib/openswarm/service'; +import { failed, json } from '@/lib/openswarm/route-helpers'; + +export const dynamic = 'force-dynamic'; + +export async function GET(_request: NextRequest, { params }: { params: Promise<{ key: string }> }) { + try { + const { key } = await params; + const seeder = decodeURIComponent(key); + const party = await getParty(seeder); + if (!party) return json({ error: 'no such key here' }, 404); + const leases = await listLeasesFor(seeder); + return json({ + key: party.key, + kind: party.kind, + standing: party.seederStanding, + proven: party.proven, + failed: party.failed, + abandoned: party.abandoned, + balanceUsd: party.balanceUsd, + leases, + }); + } catch (error) { + return failed(error); + } +} diff --git a/src/app/swarm/[id]/page.tsx b/src/app/swarm/[id]/page.tsx new file mode 100644 index 0000000..bc5ca1e --- /dev/null +++ b/src/app/swarm/[id]/page.tsx @@ -0,0 +1,139 @@ +/** + * /swarm/[id] — one swarm's page, built from its README. + * + * This is what a consented swarm looks like beside a bare infohash. The README + * is rendered from the attestation, so the page works without a key even when + * the swarm itself is ciphertext nobody here can read. + */ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import DOMPurify from 'isomorphic-dompurify'; +import { MainLayout } from '@/components/layout'; +import { getAttestation, getOffer } from '@/lib/openswarm/service'; +import { readmeSummary, renderReadme } from '@/lib/openswarm/markdown'; + +export const dynamic = 'force-dynamic'; + +const BASIS_WORDS: Record = { + own: 'Their own work', + licensed: 'Licensed for redistribution', + 'open-license': 'Open licence', + 'public-domain': 'Public domain', + personal: 'Personal backup', +}; + +async function load(id: string) { + const offer = await getOffer(id); + if (!offer) return null; + const attestation = await getAttestation(offer.attestationId); + return attestation ? { offer, attestation } : null; +} + +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + const found = await load(decodeURIComponent(id)).catch(() => null); + if (!found) return { title: 'Swarm | BitTorrented' }; + const title = found.attestation.description || readmeSummary(found.attestation.readme, 60) || 'A swarm'; + return { + title: `${title} | BitTorrented`, + description: readmeSummary(found.attestation.readme), + alternates: { canonical: `/swarm/${id}` }, + }; +} + +export default async function SwarmPage({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + const found = await load(decodeURIComponent(id)).catch(() => null); + if (!found) notFound(); + const { offer, attestation } = found; + + // The renderer emits no raw HTML from the source; sanitising as well is the + // second cheap defence rather than the only one. + const html = DOMPurify.sanitize(renderReadme(attestation.readme), { USE_PROFILES: { html: true } }); + const subject = + attestation.subject.infohashV1 || attestation.subject.infohashV2 || attestation.subject.file || attestation.subject.channel; + + return ( + +
+ + +
+

+ {attestation.description || readmeSummary(attestation.readme, 70) || 'A swarm'} +

+
+
+
Basis:
+
+ {BASIS_WORDS[attestation.basis] ?? attestation.basis} + {attestation.license ? ` (${attestation.license})` : ''} +
+
+
+
Visibility:
+
+ {attestation.visibility === 'public' ? 'public' : 'private, ciphertext only'} +
+
+
+
Status:
+
{attestation.status}
+
+
+
+ +
+ +
+

What is on offer

+
+
+
Kept for
+
{offer.days} days
+
+
+
Seeders wanted
+
+ {offer.seedersMin} to {offer.seedersMax} +
+
+
+
Per GiB-month
+
${offer.priceUsdPerGibMonth}
+
+
+
Budget
+
${offer.budgetUsd}
+
+
+
Paid out so far
+
${offer.spentUsd}
+
+
+
Offer status
+
{offer.status}
+
+
+ {subject ?

Subject: {subject}

: null} +
+ +

+ Something wrong with this listing?{' '} + + File a notice + + . An attestation that is voided takes every offer and lease on it with it. +

+
+
+ ); +} diff --git a/src/app/swarm/page.tsx b/src/app/swarm/page.tsx new file mode 100644 index 0000000..98ac52a --- /dev/null +++ b/src/app/swarm/page.tsx @@ -0,0 +1,138 @@ +/** + * /swarm — the market. + * + * What a seeder picks work from, and what a reader sees instead of a bare + * infohash. Every row carries the basis and the visibility in words, because + * that is the difference between being asked to hold a stranger's ciphertext + * and being asked to seed an openly licensed dataset in the clear. + */ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { MainLayout } from '@/components/layout'; +import { listMarket } from '@/lib/openswarm/service'; +import { describeLane } from '@/lib/openswarm/lanes'; +import type { MarketRow } from '@/lib/openswarm/types'; + +export const dynamic = 'force-dynamic'; + +export const metadata: Metadata = { + title: 'The swarm market | BitTorrented', + description: + 'Data people are paying to keep alive, and what a seeder earns for holding it. Consent is signed at upload; public swarms are free to fetch.', + alternates: { canonical: '/swarm' }, +}; + +const BASIS_WORDS: Record = { + own: 'Their own work', + licensed: 'Licensed for redistribution', + 'open-license': 'Open licence', + 'public-domain': 'Public domain', + personal: 'Personal backup', +}; + +function gib(bytes: number): string { + const value = bytes / 1024 ** 3; + return value >= 10 ? `${Math.round(value)} GB` : `${value.toFixed(1)} GB`; +} + +function Row({ row }: { row: MarketRow }): React.ReactElement { + const { offer } = row; + return ( +
  • +
    + + {row.description || row.summary || 'An unnamed swarm'} + + + ${row.projectedUsd} for {offer.days} days + +
    +

    {row.summary}

    +
    +
    +
    Basis:
    +
    + {BASIS_WORDS[row.basis] ?? row.basis} + {row.license ? ` (${row.license})` : ''} +
    +
    +
    +
    Visibility:
    +
    + {row.visibility === 'public' ? 'public, readable by anyone' : 'private, ciphertext only'} +
    +
    +
    +
    Size:
    +
    {gib(offer.sizeBytes)}
    +
    +
    +
    Slots:
    +
    + {row.slotsFree} free of {offer.seedersMax} +
    +
    +
    +
    Posted by:
    +
    {row.requesterKind === 'bit' ? 'an agent' : 'a person'}
    +
    +
    +
  • + ); +} + +export default async function SwarmMarketPage(): Promise { + let rows: MarketRow[] = []; + let error: string | null = null; + try { + rows = await listMarket({ limit: 50 }); + } catch { + error = 'The market could not be read just now.'; + } + + return ( + +
    +
    +

    The swarm market

    +

    + Data somebody is paying to keep alive, and what a seeder earns for holding it. Consent is signed at + upload, so every swarm here says who put it there and on what basis. Public swarms are free to fetch; + private ones are ciphertext a seeder holds without ever reading. +

    +
    + +
    +

    + Both sides earn. Rent out disk you already have, or pay to keep something online. The hub takes 1 + percent of what crosses it, charged to whoever is paying and never taken out of a seeder's floor. + It carries every lane: {describeLane('h2h')}, {describeLane('h2b')}, {describeLane('b2h')} and{' '} + {describeLane('b2b')}. +

    +

    + The protocol is open:{' '} + + pay2seed, paid2seed, pay2stream and paid2stream + + . +

    +
    + + {error ?

    {error}

    : null} + + {rows.length ? ( +
      + {rows.map((row) => ( + + ))} +
    + ) : ( +

    + Nothing is listed yet. A public claim waits out its window before it appears here, so that a notice can + void it before anyone is paid to seed it. +

    + )} +
    +
    + ); +} diff --git a/src/components/layout/sidebar.test.tsx b/src/components/layout/sidebar.test.tsx index ac34d23..c94d532 100644 --- a/src/components/layout/sidebar.test.tsx +++ b/src/components/layout/sidebar.test.tsx @@ -349,7 +349,7 @@ describe('Sidebar Navigation', () => { // Pricing, Settings = 2 account // Logo = 1 // External: The Pirate Bay, LimeTorrents, 1337x, IMDB = 4 - expect(links.length).toBe(23); + expect(links.length).toBe(24); }); it('should show all nav items when logged in', () => { @@ -357,7 +357,7 @@ describe('Sidebar Navigation', () => { const links = screen.getAllByRole('link'); // Logo link + 14 main nav items + 2 account items + 4 external sites = 21 - expect(links.length).toBe(23); + expect(links.length).toBe(24); }); }); diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 462d2f0..fbd8a3e 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -61,6 +61,7 @@ const mainNavItems: NavItem[] = [ { href: '/upcoming', label: 'Upcoming', icon: MovieIcon, requiresPaid: true }, { href: '/torrents', label: 'Torrents', icon: MagnetIcon }, { href: '/seedboxes', label: 'Seedboxes', icon: DownloadIcon, requiresAuth: true }, + { href: '/swarm', label: 'Swarm Market', icon: MagnetIcon }, { href: '/vod/manage', label: 'VOD Providers', icon: MovieIcon, requiresAuth: true }, { href: '/news', label: 'News', icon: NewsIcon, requiresPaid: true }, { href: '/finance', label: 'Finance', icon: FinanceIcon, requiresPaid: true }, diff --git a/src/lib/openswarm/index.ts b/src/lib/openswarm/index.ts new file mode 100644 index 0000000..fb4ae74 --- /dev/null +++ b/src/lib/openswarm/index.ts @@ -0,0 +1,6 @@ +/** The pay2seed / paid2seed hub: bittorrented.com's OpenSwarm surface. */ +export * from './lanes'; +export * from './markdown'; +export * from './records'; +export * from './service'; +export * from './types'; diff --git a/src/lib/openswarm/lanes.ts b/src/lib/openswarm/lanes.ts new file mode 100644 index 0000000..f702744 --- /dev/null +++ b/src/lib/openswarm/lanes.ts @@ -0,0 +1,109 @@ +/** + * Lanes, and the hub's cut. + * + * Every party on this layer is a key, and a key is either a **human** or a + * **bit**: an autonomous agent that holds its own key, earns its own money and + * answers for its own consent. Because both sides of a payment can be either, + * there are exactly four lanes: + * + * h2h a person pays a person someone rents a stranger's disk + * h2b a person pays an agent you buy access to what an agent made + * b2h an agent pays a person an agent keeps its archive on your box + * b2b an agent pays an agent the one nobody else is serving + * + * The lane is stamped on a lease and on every receipt at the moment the money + * is agreed, so it survives a party later changing kind, and so we can see + * which lane is actually growing rather than guessing. + * + * The hub takes **1 percent** of what crosses it. It is charged to whoever is + * paying, on top of what they quoted, and is never taken out of a seeder's or + * a relay's earnings: a promised floor is what the seeder is paid. + */ +import { fromMicros, toMicros } from './records'; + +export type PartyKind = 'human' | 'bit'; +export type Lane = 'h2h' | 'h2b' | 'b2h' | 'b2b'; + +export const LANES: readonly Lane[] = ['h2h', 'h2b', 'b2h', 'b2b']; + +/** The reference hub's cut, in basis points. 100 bps is 1 percent. */ +export const HUB_FEE_BPS = 100; + +export function isPartyKind(value: unknown): value is PartyKind { + return value === 'human' || value === 'bit'; +} + +/** The lane a payment runs on: who pays, then who is paid. */ +export function laneFor(payer: PartyKind, payee: PartyKind): Lane { + return `${payer === 'bit' ? 'b' : 'h'}2${payee === 'bit' ? 'b' : 'h'}` as Lane; +} + +export function describeLane(lane: Lane): string { + switch (lane) { + case 'h2h': + return 'person to person'; + case 'h2b': + return 'person to agent'; + case 'b2h': + return 'agent to person'; + case 'b2b': + return 'agent to agent'; + } +} + +/** + * The hub's fee on an amount, rounded UP to the micro so a fraction of a cent + * is never quietly the hub's loss, and never more than a micro of anyone's. + */ +export function feeOn(amount: string, bps = HUB_FEE_BPS): string { + const micros = toMicros(amount); + const fee = (micros * BigInt(bps) + 9_999n) / 10_000n; + return fromMicros(fee); +} + +/** What a requester actually pays: the budget they set, plus the hub's cut. */ +export function totalWithFee(budget: string, bps = HUB_FEE_BPS): string { + return fromMicros(toMicros(budget) + toMicros(feeOn(budget, bps))); +} + +/** + * What one proven period earns (paid2seed §5.1): + * + * price per GiB-month × (bytes / 2^30) × (hours / 720) + * + * All of it in integer micro-USD, rounding down, so a hub and a seeder + * computing it separately always agree and the hub never over-pays itself out + * of a rounding difference. + */ +export function periodEarnings(args: { + priceUsdPerGibMonth: string; + sizeBytes: number | bigint; + everyHours: number; +}): string { + const price = toMicros(args.priceUsdPerGibMonth); + const bytes = BigInt(args.sizeBytes); + const hours = BigInt(Math.max(0, Math.floor(args.everyHours))); + const GIB = 1_073_741_824n; + return fromMicros((price * bytes * hours) / (GIB * 720n)); +} + +/** + * The budget an offer must escrow to cover every slot for its whole term. + * Rounded up, so an offer can never run out mid-period and leave a proven + * seeder unpaid. + */ +export function requiredBudget(args: { + priceUsdPerGibMonth: string; + sizeBytes: number | bigint; + days: number; + seedersMax: number; +}): string { + const price = toMicros(args.priceUsdPerGibMonth); + const bytes = BigInt(args.sizeBytes); + const days = BigInt(Math.max(1, Math.floor(args.days))); + const seats = BigInt(Math.max(1, Math.floor(args.seedersMax))); + const GIB = 1_073_741_824n; + const denominator = GIB * 30n; + const numerator = price * bytes * days * seats; + return fromMicros((numerator + denominator - 1n) / denominator); +} diff --git a/src/lib/openswarm/markdown.ts b/src/lib/openswarm/markdown.ts new file mode 100644 index 0000000..09312fc Binary files /dev/null and b/src/lib/openswarm/markdown.ts differ diff --git a/src/lib/openswarm/openswarm.test.ts b/src/lib/openswarm/openswarm.test.ts new file mode 100644 index 0000000..b85645b --- /dev/null +++ b/src/lib/openswarm/openswarm.test.ts @@ -0,0 +1,248 @@ +/** + * The hub's arithmetic and its record rules. + * + * These are the parts that carry money and consent, so they are tested without + * a database: canonical bytes and signatures, the four lanes, the 1 percent, + * what a proven period earns, and a README renderer that must never emit HTML + * somebody put in their own README. + */ +import { describe, expect, it } from 'vitest'; +import { generateKeyPairSync, sign as signBytes } from 'node:crypto'; +import { + HUB_FEE_BPS, + LANES, + feeOn, + laneFor, + periodEarnings, + requiredBudget, + totalWithFee, +} from './lanes'; +import { renderReadme, readmeSummary, escapeHtml } from './markdown'; +import { + canonicalize, + canonicalBytes, + fromMicros, + isKey, + recordId, + signingMessage, + signers, + toMicros, + usd, + verifiedBy, + type SignedRecord, +} from './records'; + +/* ------------------------------------------------------------- key fixtures */ + +function keypair(): { key: string; sign: (record: SignedRecord) => string } { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32); + const key = `ed25519:${raw.toString('hex')}`; + return { + key, + sign: (record) => signBytes(null, signingMessage(record.type, record), privateKey).toString('base64'), + }; +} + +const record = (type: string, fields: Record): SignedRecord => + ({ openswarm: '0.1', type, createdAt: '2026-09-06T00:00:00.000Z', ...fields, sigs: [] }) as SignedRecord; + +/* ----------------------------------------------------------------- records */ + +describe('canonical form', () => { + it('sorts keys and drops insignificant whitespace', () => { + expect(canonicalize({ b: 1, a: 'x' })).toBe('{"a":"x","b":1}'); + expect(canonicalize([1, { z: true, y: null }])).toBe('[1,{"y":null,"z":true}]'); + }); + + it('refuses a fractional number, because money is a string here', () => { + // A float in the canonical bytes is exactly the ambiguity six-decimal + // strings exist to avoid. + expect(() => canonicalize({ amount: 0.1 })).toThrow(/fractional/); + }); + + it('computes the id over everything but the signatures', () => { + const unsigned = record('pay2seed.attestation', { basis: 'own' }); + const signed = { ...unsigned, sigs: [{ alg: 'ed25519', key: 'ed25519:' + 'a'.repeat(64), sig: 'x' }] }; + expect(canonicalBytes(unsigned)).toBe(canonicalBytes(signed)); + expect(recordId(unsigned)).toBe(recordId(signed)); + expect(recordId(unsigned)).toMatch(/^sha256:[0-9a-f]{64}$/); + }); +}); + +describe('signatures', () => { + it('verifies a record signed by its key', () => { + const alice = keypair(); + const draft = record('pay2seed.attestation', { requester: alice.key, basis: 'own' }); + const signed = { ...draft, sigs: [{ alg: 'ed25519', key: alice.key, sig: alice.sign(draft) }] }; + expect(verifiedBy(signed, alice.key)).toBe(true); + expect(signers(signed)).toEqual([alice.key]); + }); + + it('refuses a signature from another key, and one over a changed body', () => { + const alice = keypair(); + const mallory = keypair(); + const draft = record('pay2seed.attestation', { requester: alice.key, basis: 'own' }); + const signed = { ...draft, sigs: [{ alg: 'ed25519', key: alice.key, sig: alice.sign(draft) }] }; + + expect(verifiedBy(signed, mallory.key)).toBe(false); + // Editing the body after signing must not still verify. + expect(verifiedBy({ ...signed, basis: 'licensed' } as SignedRecord, alice.key)).toBe(false); + }); + + it('will not let a signature be replayed as another record type', () => { + // The domain prefix is the whole defence here: same body, different type. + const alice = keypair(); + const draft = record('pay2seed.attestation', { amount: '1.000000' }); + const signature = alice.sign(draft); + const asAttestation = { ...draft, sigs: [{ alg: 'ed25519', key: alice.key, sig: signature }] }; + const asReceipt = { ...draft, type: 'paid2seed.receipt', sigs: [{ alg: 'ed25519', key: alice.key, sig: signature }] }; + expect(verifiedBy(asAttestation, alice.key)).toBe(true); + expect(verifiedBy(asReceipt as SignedRecord, alice.key)).toBe(false); + }); + + it('ignores an algorithm it does not implement rather than failing', () => { + const alice = keypair(); + const draft = record('pay2seed.attestation', { basis: 'own' }); + const signed = { + ...draft, + sigs: [ + { alg: 'mldsa65', key: 'mldsa65:beef', sig: 'whatever' }, + { alg: 'ed25519', key: alice.key, sig: alice.sign(draft) }, + ], + }; + expect(verifiedBy(signed, alice.key)).toBe(true); + }); + + it('knows a key when it sees one', () => { + expect(isKey(`ed25519:${'a'.repeat(64)}`)).toBe(true); + expect(isKey(`ed25519:${'A'.repeat(64)}`)).toBe(false); + expect(isKey('ed25519:tooshort')).toBe(false); + expect(isKey(42)).toBe(false); + }); +}); + +/* ------------------------------------------------------------------- money */ + +describe('money', () => { + it('round-trips six-decimal strings through integer micros', () => { + expect(toMicros('1.500000')).toBe(1_500_000n); + expect(fromMicros(1_500_000n)).toBe('1.500000'); + expect(fromMicros(1n)).toBe('0.000001'); + expect(usd(0.1 + 0.2)).toBe('0.300000'); + }); + + it('refuses an amount that is not exactly six decimals', () => { + expect(() => toMicros('1.5')).toThrow(); + expect(() => toMicros('1')).toThrow(); + }); +}); + +describe('the hub takes one percent', () => { + it('is 100 basis points, rounded up to the micro', () => { + expect(HUB_FEE_BPS).toBe(100); + expect(feeOn('100.000000')).toBe('1.000000'); + expect(totalWithFee('100.000000')).toBe('101.000000'); + // Rounded up, so a fraction of a micro is never quietly the hub's loss. + expect(feeOn('0.000050')).toBe('0.000001'); + }); + + it('is charged on top, so a quoted budget is what reaches the seeders', () => { + const budget = '12.000000'; + expect(totalWithFee(budget)).toBe('12.120000'); + // The budget itself is untouched: the fee is the payer's, not the seeder's. + expect(budget).toBe('12.000000'); + }); +}); + +describe('lanes', () => { + it('carries all four, and names who pays whom', () => { + expect(LANES).toEqual(['h2h', 'h2b', 'b2h', 'b2b']); + expect(laneFor('human', 'human')).toBe('h2h'); + expect(laneFor('human', 'bit')).toBe('h2b'); + expect(laneFor('bit', 'human')).toBe('b2h'); + expect(laneFor('bit', 'bit')).toBe('b2b'); + }); +}); + +describe('what a period earns', () => { + const GIB = 1024 ** 3; + + it('is price × size × time, and a whole GiB-month is the price', () => { + // One GiB, one month of six-hour periods: 120 periods make the full price. + const perPeriod = periodEarnings({ priceUsdPerGibMonth: '0.150000', sizeBytes: GIB, everyHours: 6 }); + expect(perPeriod).toBe('0.001250'); + expect(toMicros(perPeriod) * 120n).toBe(toMicros('0.150000')); + }); + + it('rounds down, so a hub and a seeder computing it apart always agree', () => { + const earned = periodEarnings({ priceUsdPerGibMonth: '0.000001', sizeBytes: 1, everyHours: 1 }); + expect(earned).toBe('0.000000'); + }); + + it('escrows enough for every slot for the whole term, rounded up', () => { + const budget = requiredBudget({ priceUsdPerGibMonth: '0.150000', sizeBytes: GIB, days: 30, seedersMax: 3 }); + expect(budget).toBe('0.450000'); + // A proven period can never exceed what was escrowed for it. + const perPeriod = periodEarnings({ priceUsdPerGibMonth: '0.150000', sizeBytes: GIB, everyHours: 6 }); + expect(toMicros(perPeriod) * 120n * 3n).toBeLessThanOrEqual(toMicros(budget)); + }); +}); + +/* ---------------------------------------------------------------- markdown */ + +describe('the README renderer', () => { + it('renders the subset a listing needs', () => { + const html = renderReadme('# Title\n\nA line with **bold** and `code`.\n\n- one\n- two\n'); + // A README's own h1 steps down, so the page keeps one h1 of its own. + expect(html).toContain('

    Title

    '); + expect(html).toContain('bold'); + expect(html).toContain('code'); + expect(html).toContain('
  • one
  • '); + }); + + it('renders GFM tables and fenced code', () => { + const html = renderReadme('| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```sh\necho hi\n```'); + expect(html).toContain('a'); + expect(html).toContain('1'); + expect(html).toContain('
    echo hi
    '); + }); + + it('never emits HTML somebody put in their own README', () => { + const html = renderReadme('\n\n'); + // The words survive as text; what must not survive is a tag or an + // attribute, so assert on the markup rather than on the string. + expect(html).not.toContain(' { + const bad = renderReadme('[click](javascript:alert(1))'); + expect(bad).not.toContain('href="javascript'); + + // A relative link resolves into the swarm and is gated as the file is. + const relative = renderReadme('[the data](data/set.csv)'); + expect(relative).toContain('href="data/set.csv"'); + expect(relative).not.toContain('target="_blank"'); + + // An absolute one leaves the site, so it is marked up as such. + const absolute = renderReadme('[home](https://example.com)'); + expect(absolute).toContain('rel="noreferrer nofollow ugc"'); + expect(absolute).toContain('target="_blank"'); + }); + + it('will not let a link climb out of the swarm', () => { + expect(renderReadme('[up](../../etc/passwd)')).not.toContain('href="../'); + }); + + it('summarises the first real line, skipping headings and fences', () => { + expect(readmeSummary('# Heading\n\n```\ncode\n```\n\nThe actual summary.')).toBe('The actual summary.'); + expect(readmeSummary('# Only a heading')).toBe(''); + }); + + it('escapes the five characters that matter', () => { + expect(escapeHtml(`<&>"'`)).toBe('<&>"''); + }); +}); diff --git a/src/lib/openswarm/records.ts b/src/lib/openswarm/records.ts new file mode 100644 index 0000000..acb0d7e --- /dev/null +++ b/src/lib/openswarm/records.ts @@ -0,0 +1,188 @@ +/** + * OpenSwarm records: canonical form, ids and signatures. + * + * Every object in the family is a signed JSON record (core §3). The rules are + * short and none of them are ours to reinterpret: + * + * canonical bytes = RFC 8785 JCS of the record with `sigs` removed + * id = "sha256:" + lowercase hex of SHA-256 over those bytes + * signed message = "openswarm:sig:v1:" + type + "\n" + canonical bytes + * + * The domain prefix is what stops a signature over an attestation being + * replayed as a signature over a receipt with the same body. + * + * Money is a decimal string with exactly six fractional digits, never a float, + * so JCS never has to serialise one and rounding is always ours to decide. + */ +import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto'; + +export const OPENSWARM_VERSION = '0.1'; + +/** `:`; ed25519 public keys are 32 bytes, so 64 hex characters. */ +const KEY_PATTERN = /^ed25519:[0-9a-f]{64}$/; +/** SPKI DER prefix for an Ed25519 public key, so node can import raw bytes. */ +const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); + +export interface Signature { + alg: string; + key: string; + sig: string; +} + +export interface SignedRecord { + openswarm: string; + type: string; + createdAt: string; + sigs: Signature[]; + [field: string]: unknown; +} + +export function isKey(value: unknown): value is string { + return typeof value === 'string' && KEY_PATTERN.test(value); +} + +/** + * RFC 8785 canonical JSON: object keys sorted by their UTF-16 code units, no + * insignificant whitespace. Numbers are rejected outright rather than + * serialised — this family puts every amount in a string precisely so the + * float-formatting half of JCS never runs. + */ +export function canonicalize(value: unknown): string { + if (value === null) return 'null'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number') { + if (!Number.isInteger(value)) { + throw new OpenSwarmRecordError('a record may not carry a fractional number; amounts are strings'); + } + if (!Number.isSafeInteger(value)) { + throw new OpenSwarmRecordError('a record may not carry an integer beyond 2^53'); + } + return String(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`; + if (typeof value === 'object') { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(',')}}`; + } + throw new OpenSwarmRecordError(`a record may not carry a ${typeof value}`); +} + +export class OpenSwarmRecordError extends Error { + constructor( + message: string, + readonly status = 400 + ) { + super(message); + this.name = 'OpenSwarmRecordError'; + } +} + +/** The bytes a record's id and signatures are computed over: everything but `sigs`. */ +export function canonicalBytes(record: Record): string { + const { sigs: _sigs, ...rest } = record; + return canonicalize(rest); +} + +export function recordId(record: Record): string { + return `sha256:${createHash('sha256').update(canonicalBytes(record), 'utf8').digest('hex')}`; +} + +export function sha256Hex(input: string | Buffer): string { + return createHash('sha256').update(input).digest('hex'); +} + +function publicKeyFrom(key: string) { + if (!isKey(key)) throw new OpenSwarmRecordError(`not an ed25519 key: ${key}`); + const raw = Buffer.from(key.slice('ed25519:'.length), 'hex'); + return createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki' }); +} + +/** The exact bytes a signer signs for this record type. */ +export function signingMessage(type: string, record: Record): Buffer { + return Buffer.from(`openswarm:sig:v1:${type}\n${canonicalBytes(record)}`, 'utf8'); +} + +/** + * Is this record signed by `key`? + * + * A verifier that does not implement an algorithm MUST ignore those entries + * rather than fail, so an mldsa65 signature alongside ed25519 is not an error + * here; it simply is not what satisfies the check. + */ +export function verifiedBy(record: SignedRecord, key: string): boolean { + if (!Array.isArray(record.sigs)) return false; + const message = signingMessage(record.type, record); + return record.sigs.some((entry) => { + if (!entry || entry.alg !== 'ed25519' || entry.key !== key) return false; + try { + return verifySignature(null, message, publicKeyFrom(entry.key), Buffer.from(entry.sig, 'base64')); + } catch { + return false; + } + }); +} + +/** Every key that has a good ed25519 signature over this record. */ +export function signers(record: SignedRecord): string[] { + if (!Array.isArray(record.sigs)) return []; + const seen = new Set(); + for (const entry of record.sigs) { + if (entry?.alg === 'ed25519' && isKey(entry.key) && !seen.has(entry.key) && verifiedBy(record, entry.key)) { + seen.add(entry.key); + } + } + return [...seen]; +} + +/** Shape and envelope checks every record must pass before anything else looks at it. */ +export function assertEnvelope(value: unknown, type: string): SignedRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new OpenSwarmRecordError('a record must be a JSON object'); + } + const record = value as SignedRecord; + if (record.openswarm !== OPENSWARM_VERSION) { + throw new OpenSwarmRecordError(`this hub speaks openswarm ${OPENSWARM_VERSION}`); + } + if (record.type !== type) { + throw new OpenSwarmRecordError(`expected a ${type} record, got ${String(record.type)}`); + } + if (typeof record.createdAt !== 'string' || Number.isNaN(Date.parse(record.createdAt))) { + throw new OpenSwarmRecordError('createdAt must be an RFC 3339 timestamp'); + } + if (!Array.isArray(record.sigs) || record.sigs.length === 0) { + throw new OpenSwarmRecordError('a record must carry at least one signature'); + } + return record; +} + +/* -------------------------------------------------------------- money ---- */ + +const SCALE = 1_000_000n; + +/** Parse a six-decimal money string into integer micro-USD. */ +export function toMicros(amount: string): bigint { + if (!/^\d{1,12}\.\d{6}$/.test(amount)) { + throw new OpenSwarmRecordError(`an amount is a decimal string with six fractional digits, not "${amount}"`); + } + const [whole, fraction] = amount.split('.'); + return BigInt(whole) * SCALE + BigInt(fraction); +} + +/** Format integer micro-USD back into the wire's six-decimal string. */ +export function fromMicros(micros: bigint): string { + const negative = micros < 0n; + const value = negative ? -micros : micros; + const whole = value / SCALE; + const fraction = (value % SCALE).toString().padStart(6, '0'); + return `${negative ? '-' : ''}${whole}.${fraction}`; +} + +/** A number of USD (a DB numeric, say) as the wire's money string. */ +export function usd(amount: number | string): string { + if (typeof amount === 'string') return toMicros(amount) === 0n ? '0.000000' : amount; + if (!Number.isFinite(amount)) throw new OpenSwarmRecordError('an amount must be finite'); + return (Math.round(amount * 1e6) / 1e6).toFixed(6); +} diff --git a/src/lib/openswarm/route-helpers.ts b/src/lib/openswarm/route-helpers.ts new file mode 100644 index 0000000..d0d494f --- /dev/null +++ b/src/lib/openswarm/route-helpers.ts @@ -0,0 +1,22 @@ +/** Turning a hub decision into an HTTP answer, the same way on every route. */ +import { NextResponse } from 'next/server'; +import { HubError } from './service'; +import { OpenSwarmRecordError } from './records'; + +export const json = (body: unknown, status = 200): NextResponse => + NextResponse.json(body, { status, headers: { 'cache-control': 'no-store' } }); + +/** + * A refusal is information, not a stack trace: the caller is a program signing + * records, and the message is what tells it which rule it broke. + */ +export function failed(error: unknown): NextResponse { + if (error instanceof HubError) return json({ error: error.message }, error.status); + if (error instanceof OpenSwarmRecordError) return json({ error: error.message }, error.status); + console.error('[openswarm]', error); + return json({ error: 'the hub could not complete that' }, 500); +} + +export async function body(request: Request): Promise { + return (await request.json().catch(() => ({}))) as T; +} diff --git a/src/lib/openswarm/service.ts b/src/lib/openswarm/service.ts new file mode 100644 index 0000000..15e2315 --- /dev/null +++ b/src/lib/openswarm/service.ts @@ -0,0 +1,921 @@ +/** + * The pay2seed / paid2seed hub. + * + * bittorrented.com is the reference hub for the OpenSwarm payment family. This + * file is the whole of the hub's decision-making: what it will list, what it + * charges, who may take a lease, what a proven period earns, and what a notice + * does. The API routes are thin over it and the tests drive it directly. + * + * Two rules run through everything: + * + * Nothing is listed without consent. An attestation says who put the data + * there and on what basis, it is signed by the key that claims it, and a + * public claim waits out a window in which a notice can void it. + * + * A promised floor is what the seeder is paid. The hub's 1 percent is + * charged to whoever is paying, on top of what they quoted, and never comes + * out of a seeder's earnings. + */ +import { createServerClient } from '@/lib/supabase'; +import type { Database, Json } from '@/lib/supabase/types'; +import { + HUB_FEE_BPS, + feeOn, + isPartyKind, + laneFor, + periodEarnings, + requiredBudget, + totalWithFee, + type Lane, + type PartyKind, +} from './lanes'; +import { readmeSummary } from './markdown'; +import { + OPENSWARM_VERSION, + OpenSwarmRecordError, + assertEnvelope, + fromMicros, + isKey, + recordId, + sha256Hex, + signers, + toMicros, + usd, + verifiedBy, + type SignedRecord, +} from './records'; +import { BASES, type Attestation, type Basis, type Lease, type MarketRow, type Offer, type Party, type Subject, type Visibility } from './types'; + +export class HubError extends Error { + constructor( + message: string, + readonly status = 400 + ) { + super(message); + this.name = 'HubError'; + } +} + +/** Hours a public claim is visible before it can be listed (pay2seed §3.3). */ +export const CLAIM_WINDOW_HOURS = Number(process.env.OPENSWARM_CLAIM_HOURS ?? 24); +/** A seeder needs at least this standing to take a lease. */ +export const MIN_SEEDER_STANDING = Number(process.env.OPENSWARM_MIN_STANDING ?? 0); +export const MAX_OFFER_DAYS = 365; +const READMES_MAX = 65_536; + +const db = () => createServerClient(); + +type Tables = Database['public']['Tables']; +type PartyInsert = Tables['openswarm_parties']['Insert']; +type PartyUpdate = Tables['openswarm_parties']['Update']; + +/** + * Money crosses into Postgres as NUMERIC(14,6). Everything above this line is + * an exact six-decimal string over integer micros; a double holds those + * magnitudes exactly (a micro of 10^14 is well inside 2^53), so the conversion + * is lossless and only happens at the boundary. + */ +const num = (money: string): number => Number(money); + +/** A signed record on its way into a JSONB column. */ +const asJson = (record: unknown): Json => record as unknown as Json; + +/* --------------------------------------------------------------- parties -- */ + +function standing(row: Record): { seeder: number; requester: number } { + return { + seeder: Math.max(0, (row.proven ?? 0) - 2 * (row.failed ?? 0) - 3 * (row.abandoned ?? 0)), + requester: Math.max(0, (row.honoured ?? 0) - 3 * (row.voided ?? 0)), + }; +} + +function toParty(row: Record): Party { + const counters = row as unknown as Record; + const marks = standing(counters); + return { + key: String(row.key), + kind: (row.kind as PartyKind) ?? 'human', + operatorKey: (row.operator_key as string) ?? null, + label: (row.label as string) ?? null, + payoutAddress: (row.payout_address as string) ?? null, + payoutNetwork: (row.payout_network as string) ?? null, + balanceUsd: usd(Number(row.balance_usd ?? 0)), + paidOutUsd: usd(Number(row.paid_out_usd ?? 0)), + proven: counters.proven ?? 0, + failed: counters.failed ?? 0, + abandoned: counters.abandoned ?? 0, + honoured: counters.honoured ?? 0, + voided: counters.voided ?? 0, + seederStanding: marks.seeder, + requesterStanding: marks.requester, + }; +} + +export async function getParty(key: string): Promise { + const { data } = await db().from('openswarm_parties').select('*').eq('key', key).maybeSingle(); + return data ? toParty(data) : null; +} + +/** + * Register or update a key. A party declares what it is; a bit (an agent) may + * name the human answerable for it, which its public attestations require. + */ +export async function registerParty(input: { + key: string; + kind?: PartyKind; + operatorKey?: string | null; + label?: string | null; + payoutAddress?: string | null; + payoutNetwork?: string | null; + accountId?: string | null; +}): Promise { + if (!isKey(input.key)) throw new HubError('a party key is ed25519:<64 hex>'); + if (input.kind !== undefined && !isPartyKind(input.kind)) throw new HubError('kind is "human" or "bit"'); + // An address without a network is not a payee. + if (Boolean(input.payoutAddress) !== Boolean(input.payoutNetwork)) { + throw new HubError('a payout address needs its network, and a network needs its address'); + } + if (input.operatorKey && !isKey(input.operatorKey)) throw new HubError('operatorKey is ed25519:<64 hex>'); + if (input.operatorKey) { + const operator = await getParty(input.operatorKey); + if (!operator) throw new HubError('that operator key is not registered here', 404); + } + + const existing = await getParty(input.key); + const patch: PartyInsert = { key: input.key }; + if (input.kind !== undefined) patch.kind = input.kind; + if (input.operatorKey !== undefined) patch.operator_key = input.operatorKey; + if (input.label !== undefined) patch.label = input.label; + if (input.accountId !== undefined) patch.account_id = input.accountId; + if (input.payoutAddress !== undefined) { + patch.payout_address = input.payoutAddress; + patch.payout_network = input.payoutNetwork; + } + + const { data, error } = await db() + .from('openswarm_parties') + .upsert(existing ? patch : { kind: 'human', ...patch }, { onConflict: 'key' }) + .select('*') + .single(); + if (error) throw new HubError(`could not register the key: ${error.message}`, 500); + return toParty(data); +} + +/** A party row, created as a plain human if this key has never been seen. */ +async function ensureParty(key: string): Promise { + return (await getParty(key)) ?? (await registerParty({ key })); +} + +/* ---------------------------------------------------------- attestations -- */ + +function subjectOf(record: SignedRecord): Subject { + const raw = (record.subject ?? {}) as Record; + const subject: Subject = { + infohashV1: typeof raw.infohashV1 === 'string' ? raw.infohashV1.toLowerCase() : null, + infohashV2: typeof raw.infohashV2 === 'string' ? raw.infohashV2.toLowerCase() : null, + file: typeof raw.file === 'string' ? raw.file : null, + channel: typeof raw.channel === 'string' ? raw.channel : null, + }; + const named = [subject.infohashV1 || subject.infohashV2, subject.file, subject.channel].filter(Boolean).length; + if (named !== 1) throw new HubError('an attestation names exactly one subject: an infohash, a file key, or a channel key'); + if (subject.infohashV1 && !/^[0-9a-f]{40}$/.test(subject.infohashV1)) throw new HubError('infohashV1 is 40 hex characters'); + if (subject.infohashV2 && !/^[0-9a-f]{64}$/.test(subject.infohashV2)) throw new HubError('infohashV2 is 64 hex characters'); + if (subject.file && !isKey(subject.file)) throw new HubError('a file key is ed25519:<64 hex>'); + if (subject.channel && !isKey(subject.channel)) throw new HubError('a channel key is ed25519:<64 hex>'); + return subject; +} + +function toAttestation(row: Record): Attestation { + return { + id: String(row.id), + requesterKey: String(row.requester_key), + visibility: row.visibility as Visibility, + basis: row.basis as Basis, + license: (row.license as string) ?? null, + description: (row.description as string) ?? null, + noticeEndpoint: (row.notice_endpoint as string) ?? null, + subject: { + infohashV1: (row.infohash_v1 as string) ?? null, + infohashV2: (row.infohash_v2 as string) ?? null, + file: (row.file_key as string) ?? null, + channel: (row.channel_key as string) ?? null, + }, + readme: String(row.readme), + readmeSha256: String(row.readme_sha256), + status: row.status as Attestation['status'], + claimWindowEndsAt: (row.claim_window_ends_at as string) ?? null, + createdAt: String(row.created_at), + }; +} + +/** + * Register consent. + * + * The record must be signed by the requester; a private `ipfile` subject must + * ALSO be signed by the file's publisher key, which is what stops a stranger + * attesting somebody else's swarm. A public infohash has no such proof, so it + * gets the claim window instead. + */ +export async function registerAttestation(body: unknown): Promise<{ attestation: Attestation; id: string }> { + const record = assertEnvelope(body, 'pay2seed.attestation'); + + const requester = typeof record.requester === 'string' ? record.requester : signers(record)[0]; + if (!requester || !isKey(requester)) throw new HubError('the attestation must be signed by an ed25519 key'); + if (!verifiedBy(record, requester)) throw new HubError('the signature does not verify against the requester key', 403); + + const visibility = record.visibility as Visibility; + if (visibility !== 'public' && visibility !== 'private') throw new HubError('visibility is "public" or "private"'); + + const basis = record.basis as Basis; + if (!BASES.includes(basis)) throw new HubError(`basis is one of ${BASES.join(', ')}`); + if (basis === 'personal' && visibility !== 'private') throw new HubError('a personal basis is for a private swarm'); + if (basis === 'open-license' && typeof record.license !== 'string') throw new HubError('an open-license basis names its SPDX licence'); + if (record.acceptsTakedown !== true) throw new HubError('acceptsTakedown must be true, and must be in the signed bytes'); + + const subject = subjectOf(record); + if ((subject.infohashV1 || subject.infohashV2) && visibility !== 'public') throw new HubError('an infohash subject is public'); + if (subject.file && visibility !== 'private') throw new HubError('a file-key subject is private'); + + const readme = typeof record.readme === 'string' ? record.readme : ''; + if (!readme.trim()) throw new HubError('every swarm on this market carries a README; there is no exception'); + if (readme.length > READMES_MAX) throw new HubError('a README is at most 64 KiB'); + const readmeSha256 = typeof record.readmeSha256 === 'string' ? record.readmeSha256.toLowerCase() : ''; + if (!/^[0-9a-f]{64}$/.test(readmeSha256)) throw new HubError('readmeSha256 is the SHA-256 of the copy inside the swarm'); + + const noticeEndpoint = typeof record.notice === 'string' ? record.notice : null; + if (visibility === 'public' && !noticeEndpoint) throw new HubError('a public swarm says where a notice goes'); + if (noticeEndpoint && !/^(https:\/\/|mailto:)/i.test(noticeEndpoint)) { + throw new HubError('a notice endpoint is an https URL or a mailto: address'); + } + + // A private swarm's publisher must have signed too. + if (subject.file && !verifiedBy(record, subject.file)) { + throw new HubError("a private swarm's attestation must also be signed by the file's publisher key", 403); + } + + const party = await ensureParty(requester); + + // An agent selling in public names the human answerable for it. + if (party.kind === 'bit' && visibility === 'public' && !party.operatorKey) { + throw new HubError('an agent\'s public attestation names a responsible operator key; register one first', 403); + } + + // Standing: a requester who keeps having claims voided stops being listed. + if (party.voided >= 3) throw new HubError('this key has three voided attestations in the last year', 403); + if (visibility === 'public' && party.voided > party.honoured) { + throw new HubError('this key has more voided claims than honoured ones', 403); + } + + // Somebody else already holds this public infohash, unless the new basis is + // one that does not depend on who is asking. + if (subject.infohashV1 && basis !== 'open-license' && basis !== 'public-domain') { + const { data: held } = await db() + .from('openswarm_attestations') + .select('requester_key') + .eq('infohash_v1', subject.infohashV1) + .eq('status', 'honoured') + .maybeSingle(); + if (held && held.requester_key !== requester) { + throw new HubError('another key already holds an honoured claim on this infohash', 409); + } + } + + const id = recordId(record); + const now = Date.now(); + // A private swarm is the requester's own business and is honoured at once. + // A public claim waits, in the open, where a notice can void it. + const isPublic = visibility === 'public'; + const row = { + id, + requester_key: requester, + visibility, + basis, + license: typeof record.license === 'string' ? record.license : null, + description: typeof record.description === 'string' ? record.description.slice(0, 280) : null, + notice_endpoint: noticeEndpoint, + infohash_v1: subject.infohashV1, + infohash_v2: subject.infohashV2, + file_key: subject.file, + channel_key: subject.channel, + readme, + readme_sha256: readmeSha256, + record: asJson(record), + status: isPublic ? 'claimed' : 'honoured', + claim_window_ends_at: isPublic ? new Date(now + CLAIM_WINDOW_HOURS * 3_600_000).toISOString() : null, + honoured_at: isPublic ? null : new Date(now).toISOString(), + }; + + const { data, error } = await db().from('openswarm_attestations').upsert(row, { onConflict: 'id' }).select('*').single(); + if (error) throw new HubError(`could not register the attestation: ${error.message}`, 500); + if (!isPublic) await bumpParty(requester, { honoured: 1 }); + return { attestation: toAttestation(data), id }; +} + +export async function getAttestation(id: string): Promise { + const { data } = await db().from('openswarm_attestations').select('*').eq('id', id).maybeSingle(); + return data ? toAttestation(data) : null; +} + +/** Move any public claim whose window has run out to honoured. */ +export async function honourDueClaims(now = new Date()): Promise { + const { data, error } = await db() + .from('openswarm_attestations') + .update({ status: 'honoured', honoured_at: now.toISOString() }) + .eq('status', 'claimed') + .lt('claim_window_ends_at', now.toISOString()) + .select('requester_key'); + if (error) return 0; + for (const row of data ?? []) await bumpParty(String(row.requester_key), { honoured: 1 }); + return (data ?? []).length; +} + +async function bumpParty(key: string, deltas: Partial>): Promise { + const party = await getParty(key); + if (!party) return; + const patch: PartyUpdate = {}; + for (const [field, delta] of Object.entries(deltas)) { + patch[field as keyof typeof deltas] = (party[field as 'proven'] ?? 0) + (delta ?? 0); + } + await db().from('openswarm_parties').update(patch).eq('key', key); +} + +/* ----------------------------------------------------------------- offers -- */ + +function toOffer(row: Record): Offer { + return { + id: String(row.id), + attestationId: String(row.attestation_id), + requesterKey: String(row.requester_key), + visibility: row.visibility as Visibility, + sizeBytes: Number(row.size_bytes), + days: Number(row.days), + seedersMin: Number(row.seeders_min), + seedersMax: Number(row.seeders_max), + priceUsdPerGibMonth: usd(Number(row.price_usd_per_gib_month)), + budgetUsd: usd(Number(row.budget_usd)), + spentUsd: usd(Number(row.spent_usd)), + feeUsd: usd(Number(row.fee_usd)), + proofEveryHours: Number(row.proof_every_hours), + trackers: Array.isArray(row.trackers) ? (row.trackers as string[]) : [], + status: row.status as Offer['status'], + startsAt: String(row.starts_at), + expiresAt: String(row.expires_at), + createdAt: String(row.created_at), + }; +} + +export interface OfferDraft { + attestation: string; + sizeBytes: number; + days: number; + seeders?: { min?: number; max?: number }; + priceUsdPerGibMonth: string; + proof?: { everyHours?: number }; + trackers?: string[]; + pass?: unknown; +} + +/** + * Quote an offer: what it will cost, and what the hub takes. Pure, so the page + * and the checkout can never disagree about the number. + */ +export function quoteOffer(draft: OfferDraft): { budgetUsd: string; feeUsd: string; totalUsd: string; projectedPerSeederUsd: string } { + const seedersMax = Math.max(1, Math.floor(draft.seeders?.max ?? draft.seeders?.min ?? 1)); + const budget = requiredBudget({ + priceUsdPerGibMonth: draft.priceUsdPerGibMonth, + sizeBytes: draft.sizeBytes, + days: draft.days, + seedersMax, + }); + return { + budgetUsd: budget, + feeUsd: feeOn(budget), + totalUsd: totalWithFee(budget), + projectedPerSeederUsd: requiredBudget({ + priceUsdPerGibMonth: draft.priceUsdPerGibMonth, + sizeBytes: draft.sizeBytes, + days: draft.days, + seedersMax: 1, + }), + }; +} + +/** + * Create an offer against an honoured attestation. It is `unpaid` until the + * money settles: a budget that is not escrowed is not a promise anyone should + * seed against. + */ +export async function createOffer(draft: OfferDraft, requesterKey: string): Promise<{ offer: Offer; quote: ReturnType }> { + if (!isKey(requesterKey)) throw new HubError('a requester key is ed25519:<64 hex>'); + const attestation = await getAttestation(draft.attestation); + if (!attestation) throw new HubError('no such attestation', 404); + if (attestation.requesterKey !== requesterKey) throw new HubError('that attestation belongs to another key', 403); + if (attestation.status === 'voided') throw new HubError('that attestation was voided', 409); + + const days = Math.floor(draft.days); + if (!Number.isFinite(days) || days < 1 || days > MAX_OFFER_DAYS) throw new HubError(`days is 1 to ${MAX_OFFER_DAYS}`); + const sizeBytes = Math.floor(draft.sizeBytes); + if (!Number.isFinite(sizeBytes) || sizeBytes <= 0) throw new HubError('sizeBytes must be a positive integer'); + const seedersMin = Math.max(1, Math.floor(draft.seeders?.min ?? 1)); + const seedersMax = Math.max(seedersMin, Math.floor(draft.seeders?.max ?? seedersMin)); + const everyHours = Math.min(168, Math.max(1, Math.floor(draft.proof?.everyHours ?? 6))); + // Throws when the price is not a six-decimal string, which is the point. + toMicros(draft.priceUsdPerGibMonth); + + const quote = quoteOffer({ ...draft, days, sizeBytes, seeders: { min: seedersMin, max: seedersMax } }); + const startsAt = new Date(); + const expiresAt = new Date(startsAt.getTime() + days * 86_400_000); + + const record = { + openswarm: OPENSWARM_VERSION, + type: 'pay2seed.offer', + hub: hubKey(), + requester: requesterKey, + attestation: attestation.id, + subject: attestation.subject, + visibility: attestation.visibility, + sizeBytes, + days, + seeders: { min: seedersMin, max: seedersMax }, + priceUsdPerGibMonth: draft.priceUsdPerGibMonth, + budgetUsd: quote.budgetUsd, + proof: { everyHours, verifiers: ['hub'] }, + trackers: draft.trackers ?? [], + startsAt: startsAt.toISOString(), + expiresAt: expiresAt.toISOString(), + createdAt: startsAt.toISOString(), + sigs: [] as unknown[], + }; + const id = recordId(record); + + const { data, error } = await db() + .from('openswarm_offers') + .insert({ + id, + attestation_id: attestation.id, + requester_key: requesterKey, + visibility: attestation.visibility, + size_bytes: sizeBytes, + days, + seeders_min: seedersMin, + seeders_max: seedersMax, + price_usd_per_gib_month: num(draft.priceUsdPerGibMonth), + budget_usd: num(quote.budgetUsd), + fee_usd: num(quote.feeUsd), + proof_every_hours: everyHours, + trackers: draft.trackers ?? [], + pass: asJson(draft.pass ?? null), + record: asJson(record), + status: 'unpaid', + starts_at: startsAt.toISOString(), + expires_at: expiresAt.toISOString(), + }) + .select('*') + .single(); + if (error) throw new HubError(`could not create the offer: ${error.message}`, 500); + return { offer: toOffer(data), quote }; +} + +/** The money settled. An offer becomes listable, and seeders can take slots. */ +export async function markOfferPaid(offerId: string, paymentId: string): Promise { + const { data, error } = await db() + .from('openswarm_offers') + .update({ status: 'pending', payment_id: paymentId, paid_at: new Date().toISOString() }) + .eq('id', offerId) + .eq('status', 'unpaid') + .select('*') + .maybeSingle(); + // A retried webhook finds nothing to update, which is the idempotency. + if (error || !data) return null; + return toOffer(data); +} + +export async function getOffer(id: string): Promise { + const { data } = await db().from('openswarm_offers').select('*').eq('id', id).maybeSingle(); + return data ? toOffer(data) : null; +} + +/** + * The market: offers a seeder can actually take work on. An offer whose + * attestation is not honoured yet is not here, which is what the claim window + * is for. + */ +export async function listMarket(filter: { visibility?: Visibility; basis?: Basis; limit?: number } = {}): Promise { + const limit = Math.min(100, Math.max(1, filter.limit ?? 50)); + let query = db() + .from('openswarm_offers') + .select('*, openswarm_attestations!inner(*), openswarm_parties!openswarm_offers_requester_key_fkey(kind)') + .in('status', ['pending', 'active']) + .eq('openswarm_attestations.status', 'honoured') + .gt('expires_at', new Date().toISOString()) + .order('created_at', { ascending: false }) + .limit(limit); + if (filter.visibility) query = query.eq('visibility', filter.visibility); + if (filter.basis) query = query.eq('openswarm_attestations.basis', filter.basis); + + const { data, error } = await query; + if (error) throw new HubError(`could not read the market: ${error.message}`, 500); + + const rows = (data ?? []) as unknown as Array>; + const offerIds = rows.map((row) => String(row.id)); + const taken = await slotsTaken(offerIds); + + return rows.map((row) => { + const attestation = toAttestation(row.openswarm_attestations as Record); + const offer = toOffer(row); + const party = (row.openswarm_parties ?? {}) as { kind?: PartyKind }; + return { + offer, + basis: attestation.basis, + visibility: attestation.visibility, + description: attestation.description, + license: attestation.license, + summary: readmeSummary(attestation.readme), + subject: attestation.subject, + slotsFree: Math.max(0, offer.seedersMax - (taken.get(offer.id) ?? 0)), + projectedUsd: requiredBudget({ + priceUsdPerGibMonth: offer.priceUsdPerGibMonth, + sizeBytes: offer.sizeBytes, + days: offer.days, + seedersMax: 1, + }), + requesterKind: party.kind ?? 'human', + }; + }); +} + +async function slotsTaken(offerIds: string[]): Promise> { + const taken = new Map(); + if (!offerIds.length) return taken; + const { data } = await db() + .from('openswarm_leases') + .select('offer_id') + .in('offer_id', offerIds) + .not('status', 'in', '("abandoned","voided","ended")'); + for (const row of data ?? []) { + const id = String(row.offer_id); + taken.set(id, (taken.get(id) ?? 0) + 1); + } + return taken; +} + +/* ----------------------------------------------------------------- leases -- */ + +function toLease(row: Record): Lease { + return { + id: String(row.id), + offerId: String(row.offer_id), + seederKey: String(row.seeder_key), + slot: Number(row.slot), + priceUsdPerGibMonth: usd(Number(row.price_usd_per_gib_month)), + lane: row.lane as Lane, + earnedUsd: usd(Number(row.earned_usd ?? 0)), + periodsProven: Number(row.periods_proven ?? 0), + periodsFailed: Number(row.periods_failed ?? 0), + status: row.status as Lease['status'], + startsAt: String(row.starts_at), + endsAt: String(row.ends_at), + }; +} + +/** + * Take a slot on an offer. + * + * The seeder proves it holds the key by signing the offer id, must already be + * a payee (there would otherwise be nowhere to pay), and must clear the + * standing floor. + */ +export async function takeLease(input: { offerId: string; seederKey: string; sig: string }): Promise { + if (!isKey(input.seederKey)) throw new HubError('a seeder key is ed25519:<64 hex>'); + + const offer = await getOffer(input.offerId); + if (!offer) throw new HubError('no such offer', 404); + if (offer.status === 'unpaid') throw new HubError('that offer is not funded yet', 409); + if (offer.status === 'voided' || offer.status === 'settled') throw new HubError('that offer is closed', 409); + if (new Date(offer.expiresAt).getTime() <= Date.now()) throw new HubError('that offer has expired', 409); + + // The signature is over a record shaped like the wire's, so one verifier serves both. + const proof = { + openswarm: OPENSWARM_VERSION, + type: 'paid2seed.lease.request', + offer: offer.id, + seeder: input.seederKey, + createdAt: new Date().toISOString(), + sigs: [{ alg: 'ed25519', key: input.seederKey, sig: input.sig }], + } as SignedRecord; + // The signed bytes must not include a timestamp the caller did not sign, so + // verify over the stable part only. + const stable = { openswarm: OPENSWARM_VERSION, type: 'paid2seed.lease.request', offer: offer.id, seeder: input.seederKey }; + const signable = { ...stable, sigs: proof.sigs } as unknown as SignedRecord; + if (!verifiedBy(signable, input.seederKey)) { + throw new HubError('sign the offer id with the seeder key to take a lease', 403); + } + + const seeder = await getParty(input.seederKey); + if (!seeder) throw new HubError('register the seeder key first', 404); + if (!seeder.payoutAddress) throw new HubError('register a payout address first; there would be nowhere to pay', 409); + if (seeder.seederStanding < MIN_SEEDER_STANDING) throw new HubError('this key is below the hub\'s standing floor', 403); + + const { data: mine } = await db() + .from('openswarm_leases') + .select('*') + .eq('offer_id', offer.id) + .eq('seeder_key', input.seederKey) + .maybeSingle(); + if (mine) throw new HubError('this key already holds a lease on that offer', 409); + + const taken = await slotsTaken([offer.id]); + const used = taken.get(offer.id) ?? 0; + if (used >= offer.seedersMax) throw new HubError('that offer has no free slot', 409); + + const requester = await getParty(offer.requesterKey); + const lane = laneFor(requester?.kind ?? 'human', seeder.kind); + + const startsAt = new Date(); + const record = { + openswarm: OPENSWARM_VERSION, + type: 'paid2seed.lease', + hub: hubKey(), + offer: offer.id, + seeder: input.seederKey, + slot: used + 1, + priceUsdPerGibMonth: offer.priceUsdPerGibMonth, + startsAt: startsAt.toISOString(), + endsAt: offer.expiresAt, + graceHours: 24, + createdAt: startsAt.toISOString(), + sigs: [] as unknown[], + }; + + const { data, error } = await db() + .from('openswarm_leases') + .insert({ + id: recordId(record), + offer_id: offer.id, + seeder_key: input.seederKey, + slot: used + 1, + price_usd_per_gib_month: num(offer.priceUsdPerGibMonth), + lane, + status: 'fetching', + grace_hours: 24, + starts_at: startsAt.toISOString(), + ends_at: offer.expiresAt, + record: asJson(record), + }) + .select('*') + .single(); + if (error) throw new HubError(`could not take the lease: ${error.message}`, 500); + + // Enough seeders now hold slots for the offer to be live. + if (used + 1 >= offer.seedersMin && offer.status === 'pending') { + await db().from('openswarm_offers').update({ status: 'active' }).eq('id', offer.id); + } + return toLease(data); +} + +export async function getLease(id: string): Promise { + const { data } = await db().from('openswarm_leases').select('*').eq('id', id).maybeSingle(); + return data ? toLease(data) : null; +} + +export async function listLeasesFor(seederKey: string): Promise { + const { data } = await db().from('openswarm_leases').select('*').eq('seeder_key', seederKey).order('created_at', { ascending: false }); + return (data ?? []).map((row) => toLease(row as Record)); +} + +/* ------------------------------------------------------ proofs & receipts -- */ + +/** + * Record a period's verdict and pay for it. + * + * The receipt's unique `(lease, period)` is the whole of the idempotency: a + * verifier that reports twice pays once. Two consecutive failures end the + * lease and reopen the slot. + */ +export async function reportProof(input: { + leaseId: string; + period: number; + kind: 'challenge' | 'probe'; + passed: boolean; + verifierKey?: string | null; + detail?: string | null; + record?: unknown; +}): Promise<{ lease: Lease; earnedUsd: string; alreadyRecorded: boolean }> { + const lease = await getLease(input.leaseId); + if (!lease) throw new HubError('no such lease', 404); + if (lease.status === 'voided') throw new HubError('that lease was voided', 409); + + const offer = await getOffer(lease.offerId); + if (!offer) throw new HubError('that lease has no offer', 404); + + const period = Math.floor(input.period); + if (!Number.isFinite(period) || period < 0) throw new HubError('period is a non-negative integer'); + + const proofId = recordId({ + openswarm: OPENSWARM_VERSION, + type: 'paid2seed.proof', + lease: lease.id, + period, + kind: input.kind, + passed: input.passed, + }); + + const { error: proofError } = await db().from('openswarm_proofs').insert({ + id: proofId, + lease_id: lease.id, + period, + kind: input.kind, + verifier_key: input.verifierKey ?? null, + passed: input.passed, + detail: input.detail ?? null, + record: asJson(input.record ?? {}), + }); + // A duplicate period is not an error; it simply pays nothing more. + const duplicate = Boolean(proofError && /duplicate|unique/i.test(proofError.message)); + if (proofError && !duplicate) throw new HubError(`could not record the proof: ${proofError.message}`, 500); + if (duplicate) return { lease, earnedUsd: '0.000000', alreadyRecorded: true }; + + if (!input.passed) { + const consecutive = await failLease(lease, offer); + return { lease: consecutive, earnedUsd: '0.000000', alreadyRecorded: false }; + } + + const earned = periodEarnings({ + priceUsdPerGibMonth: lease.priceUsdPerGibMonth, + sizeBytes: offer.sizeBytes, + everyHours: offer.proofEveryHours, + }); + + // Never pay past what was escrowed: the last period of a rounded-down budget + // pays what is left rather than going negative. + const remaining = toMicros(offer.budgetUsd) - toMicros(offer.spentUsd); + const payable = fromMicros(remaining <= 0n ? 0n : remaining < toMicros(earned) ? remaining : toMicros(earned)); + + const seeder = await getParty(lease.seederKey); + const balance = fromMicros(toMicros(seeder?.balanceUsd ?? '0.000000') + toMicros(payable)); + + await db().from('openswarm_receipts').insert({ + id: recordId({ openswarm: OPENSWARM_VERSION, type: 'paid2seed.receipt', lease: lease.id, period }), + lease_id: lease.id, + proof_id: proofId, + period, + earned_usd: num(payable), + balance_usd: num(balance), + lane: lease.lane, + record: asJson({ + openswarm: OPENSWARM_VERSION, + type: 'paid2seed.receipt', + lease: lease.id, + period, + earnedUsd: payable, + balanceUsd: balance, + createdAt: new Date().toISOString(), + sigs: [], + }), + }); + + await db() + .from('openswarm_leases') + .update({ + earned_usd: num(fromMicros(toMicros(lease.earnedUsd) + toMicros(payable))), + periods_proven: lease.periodsProven + 1, + consecutive_failures: 0, + status: 'proven', + last_proof_at: new Date().toISOString(), + }) + .eq('id', lease.id); + + await db() + .from('openswarm_offers') + .update({ spent_usd: num(fromMicros(toMicros(offer.spentUsd) + toMicros(payable))) }) + .eq('id', offer.id); + + if (seeder) { + await db() + .from('openswarm_parties') + .update({ balance_usd: num(balance), proven: seeder.proven + 1 }) + .eq('key', lease.seederKey); + } + + const updated = await getLease(lease.id); + return { lease: updated ?? lease, earnedUsd: payable, alreadyRecorded: false }; +} + +async function failLease(lease: Lease, _offer: Offer): Promise { + const { data } = await db().from('openswarm_leases').select('consecutive_failures').eq('id', lease.id).maybeSingle(); + const consecutive = Number(data?.consecutive_failures ?? 0) + 1; + // Two in a row ends it and reopens the slot for somebody who will hold it. + const status = consecutive >= 2 ? 'ended' : 'lapsed'; + await db() + .from('openswarm_leases') + .update({ periods_failed: lease.periodsFailed + 1, consecutive_failures: consecutive, status }) + .eq('id', lease.id); + await bumpParty(lease.seederKey, { failed: 1 }); + return { ...lease, periodsFailed: lease.periodsFailed + 1, status }; +} + +/* ---------------------------------------------------------------- notices -- */ + +/** + * A claim against an attestation. The hub forwards it and then either voids + * the attestation or records that it was reviewed and stands. Voiding takes + * every offer and lease with it and refunds what has not been earned. + */ +export async function fileNotice(input: { + attestationId: string; + kind: 'rights' | 'illegal' | 'personal-data' | 'other'; + statement: string; + claimantName?: string | null; + claimantContact?: string | null; + record?: unknown; +}): Promise<{ noticeId: string; attestation: Attestation }> { + const attestation = await getAttestation(input.attestationId); + if (!attestation) throw new HubError('no such attestation', 404); + if (!input.statement?.trim()) throw new HubError('a notice says what the claim is'); + + const noticeId = recordId({ + openswarm: OPENSWARM_VERSION, + type: 'pay2seed.notice', + attestation: attestation.id, + kind: input.kind, + statement: input.statement, + createdAt: new Date().toISOString(), + }); + + await db().from('openswarm_notices').insert({ + id: noticeId, + attestation_id: attestation.id, + kind: input.kind, + claimant_name: input.claimantName ?? null, + claimant_contact: input.claimantContact ?? null, + statement: input.statement.slice(0, 4000), + record: asJson(input.record ?? {}), + outcome: 'received', + }); + + return { noticeId, attestation }; +} + +/** Void an attestation: every offer on it closes and every lease ends. */ +export async function voidAttestation(attestationId: string, reason: string): Promise<{ offers: number; leases: number }> { + const attestation = await getAttestation(attestationId); + if (!attestation) throw new HubError('no such attestation', 404); + + await db() + .from('openswarm_attestations') + .update({ status: 'voided', voided_at: new Date().toISOString(), void_reason: reason }) + .eq('id', attestationId); + + const { data: offers } = await db().from('openswarm_offers').select('id').eq('attestation_id', attestationId); + const offerIds = (offers ?? []).map((row) => String(row.id)); + if (offerIds.length) { + await db().from('openswarm_offers').update({ status: 'voided' }).in('id', offerIds); + await db().from('openswarm_leases').update({ status: 'voided' }).in('offer_id', offerIds); + } + await db().from('openswarm_notices').update({ outcome: 'voided', resolved_at: new Date().toISOString() }).eq('attestation_id', attestationId); + await bumpParty(attestation.requesterKey, { voided: 1 }); + + const { count } = await db() + .from('openswarm_leases') + .select('id', { count: 'exact', head: true }) + .in('offer_id', offerIds.length ? offerIds : ['none']); + return { offers: offerIds.length, leases: count ?? 0 }; +} + +/* ------------------------------------------------------------- hub record -- */ + +/** The hub's own signing key, when one is configured. */ +export function hubKey(): string { + const key = process.env.OPENSWARM_HUB_KEY ?? ''; + return isKey(key) ? key : 'ed25519:0000000000000000000000000000000000000000000000000000000000000000'; +} + +/** `GET /.well-known/openswarm-hub.json` (ippay §5.1, pay2seed §6.1). */ +export function hubRecord(origin: string): Record { + return { + openswarm: OPENSWARM_VERSION, + type: 'ippay.hub', + key: hubKey(), + name: 'BitTorrented', + base: `${origin}/api/openswarm`, + networks: ['eip155:8453', 'eip155:137', 'eip155:1'], + // 1 percent, and it is charged to whoever is paying. + minHubBps: HUB_FEE_BPS, + payout: { minUsd: '1.000000', schedule: 'daily' }, + passDays: 30, + pay2seed: { + base: `${origin}/api/openswarm/pay2seed`, + bases: BASES, + claimHours: CLAIM_WINDOW_HOURS, + minStanding: MIN_SEEDER_STANDING, + verifiers: ['hub'], + maxDays: MAX_OFFER_DAYS, + // Every party here is a human or a bit, and all four lanes are carried. + lanes: ['h2h', 'h2b', 'b2h', 'b2b'], + }, + createdAt: '2026-09-06T00:00:00.000Z', + sigs: [], + }; +} + +export { sha256Hex, OpenSwarmRecordError }; diff --git a/src/lib/openswarm/types.ts b/src/lib/openswarm/types.ts new file mode 100644 index 0000000..4ce247e --- /dev/null +++ b/src/lib/openswarm/types.ts @@ -0,0 +1,103 @@ +/** Shapes the hub stores and returns. The wire records live in `records.ts`. */ +import type { Lane, PartyKind } from './lanes'; + +export type Visibility = 'public' | 'private'; +export type Basis = 'own' | 'licensed' | 'open-license' | 'public-domain' | 'personal'; +export type AttestationStatus = 'claimed' | 'honoured' | 'voided'; +export type OfferStatus = 'unpaid' | 'pending' | 'active' | 'settled' | 'voided'; +export type LeaseStatus = 'fetching' | 'proven' | 'lapsed' | 'abandoned' | 'ended' | 'voided'; + +export const BASES: readonly Basis[] = ['own', 'licensed', 'open-license', 'public-domain', 'personal']; + +export interface Party { + key: string; + kind: PartyKind; + operatorKey: string | null; + label: string | null; + payoutAddress: string | null; + payoutNetwork: string | null; + balanceUsd: string; + paidOutUsd: string; + proven: number; + failed: number; + abandoned: number; + honoured: number; + voided: number; + /** paid2seed §6.3: proven - 2·failed - 3·abandoned, floored at zero. */ + seederStanding: number; + /** honoured - 3·voided, floored at zero. */ + requesterStanding: number; +} + +export interface Subject { + infohashV1?: string | null; + infohashV2?: string | null; + file?: string | null; + channel?: string | null; +} + +export interface Attestation { + id: string; + requesterKey: string; + visibility: Visibility; + basis: Basis; + license: string | null; + description: string | null; + noticeEndpoint: string | null; + subject: Subject; + readme: string; + readmeSha256: string; + status: AttestationStatus; + claimWindowEndsAt: string | null; + createdAt: string; +} + +export interface Offer { + id: string; + attestationId: string; + requesterKey: string; + visibility: Visibility; + sizeBytes: number; + days: number; + seedersMin: number; + seedersMax: number; + priceUsdPerGibMonth: string; + budgetUsd: string; + spentUsd: string; + feeUsd: string; + proofEveryHours: number; + trackers: string[]; + status: OfferStatus; + startsAt: string; + expiresAt: string; + createdAt: string; +} + +export interface Lease { + id: string; + offerId: string; + seederKey: string; + slot: number; + priceUsdPerGibMonth: string; + lane: Lane; + earnedUsd: string; + periodsProven: number; + periodsFailed: number; + status: LeaseStatus; + startsAt: string; + endsAt: string; +} + +export interface MarketRow { + offer: Offer; + basis: Basis; + visibility: Visibility; + description: string | null; + license: string | null; + summary: string; + subject: Subject; + slotsFree: number; + /** What a seeder would earn holding this for the whole term. */ + projectedUsd: string; + requesterKind: PartyKind; +} diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index 4cfa804..e6bdf4e 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -15,6 +15,611 @@ export type Json = export type Database = { public: { Tables: { + openswarm_attestations: { + Row: { + basis: string + channel_key: string | null + claim_window_ends_at: string | null + created_at: string + description: string | null + file_key: string | null + honoured_at: string | null + id: string + infohash_v1: string | null + infohash_v2: string | null + license: string | null + notice_endpoint: string | null + readme: string + readme_sha256: string + record: Json + requester_key: string + status: string + updated_at: string + visibility: string + void_reason: string | null + voided_at: string | null + } + Insert: { + basis: string + channel_key?: string | null + claim_window_ends_at?: string | null + created_at?: string + description?: string | null + file_key?: string | null + honoured_at?: string | null + id: string + infohash_v1?: string | null + infohash_v2?: string | null + license?: string | null + notice_endpoint?: string | null + readme: string + readme_sha256: string + record: Json + requester_key: string + status?: string + updated_at?: string + visibility: string + void_reason?: string | null + voided_at?: string | null + } + Update: { + basis?: string + channel_key?: string | null + claim_window_ends_at?: string | null + created_at?: string + description?: string | null + file_key?: string | null + honoured_at?: string | null + id?: string + infohash_v1?: string | null + infohash_v2?: string | null + license?: string | null + notice_endpoint?: string | null + readme?: string + readme_sha256?: string + record?: Json + requester_key?: string + status?: string + updated_at?: string + visibility?: string + void_reason?: string | null + voided_at?: string | null + } + Relationships: [ + { + foreignKeyName: "openswarm_attestations_requester_key_fkey" + columns: ["requester_key"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + ] + } + openswarm_leases: { + Row: { + consecutive_failures: number + created_at: string + earned_usd: number + ends_at: string + grace_hours: number + id: string + lane: string + last_proof_at: string | null + offer_id: string + periods_failed: number + periods_proven: number + price_usd_per_gib_month: number + record: Json + seeder_key: string + slot: number + starts_at: string + status: string + updated_at: string + } + Insert: { + consecutive_failures?: number + created_at?: string + earned_usd?: number + ends_at: string + grace_hours?: number + id: string + lane: string + last_proof_at?: string | null + offer_id: string + periods_failed?: number + periods_proven?: number + price_usd_per_gib_month: number + record: Json + seeder_key: string + slot: number + starts_at?: string + status?: string + updated_at?: string + } + Update: { + consecutive_failures?: number + created_at?: string + earned_usd?: number + ends_at?: string + grace_hours?: number + id?: string + lane?: string + last_proof_at?: string | null + offer_id?: string + periods_failed?: number + periods_proven?: number + price_usd_per_gib_month?: number + record?: Json + seeder_key?: string + slot?: number + starts_at?: string + status?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "openswarm_leases_offer_id_fkey" + columns: ["offer_id"] + isOneToOne: false + referencedRelation: "openswarm_offers" + referencedColumns: ["id"] + }, + { + foreignKeyName: "openswarm_leases_seeder_key_fkey" + columns: ["seeder_key"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + ] + } + openswarm_notices: { + Row: { + attestation_id: string + claimant_contact: string | null + claimant_name: string | null + created_at: string + id: string + kind: string + outcome: string + record: Json + resolved_at: string | null + statement: string + } + Insert: { + attestation_id: string + claimant_contact?: string | null + claimant_name?: string | null + created_at?: string + id: string + kind: string + outcome?: string + record: Json + resolved_at?: string | null + statement: string + } + Update: { + attestation_id?: string + claimant_contact?: string | null + claimant_name?: string | null + created_at?: string + id?: string + kind?: string + outcome?: string + record?: Json + resolved_at?: string | null + statement?: string + } + Relationships: [ + { + foreignKeyName: "openswarm_notices_attestation_id_fkey" + columns: ["attestation_id"] + isOneToOne: false + referencedRelation: "openswarm_attestations" + referencedColumns: ["id"] + }, + ] + } + openswarm_offers: { + Row: { + attestation_id: string + budget_usd: number + created_at: string + days: number + expires_at: string + fee_usd: number + id: string + paid_at: string | null + pass: Json | null + payment_id: string | null + price_usd_per_gib_month: number + proof_every_hours: number + record: Json + requester_key: string + seeders_max: number + seeders_min: number + size_bytes: number + spent_usd: number + starts_at: string + status: string + trackers: Json + updated_at: string + visibility: string + } + Insert: { + attestation_id: string + budget_usd: number + created_at?: string + days: number + expires_at: string + fee_usd?: number + id: string + paid_at?: string | null + pass?: Json | null + payment_id?: string | null + price_usd_per_gib_month: number + proof_every_hours?: number + record: Json + requester_key: string + seeders_max?: number + seeders_min?: number + size_bytes: number + spent_usd?: number + starts_at?: string + status?: string + trackers?: Json + updated_at?: string + visibility: string + } + Update: { + attestation_id?: string + budget_usd?: number + created_at?: string + days?: number + expires_at?: string + fee_usd?: number + id?: string + paid_at?: string | null + pass?: Json | null + payment_id?: string | null + price_usd_per_gib_month?: number + proof_every_hours?: number + record?: Json + requester_key?: string + seeders_max?: number + seeders_min?: number + size_bytes?: number + spent_usd?: number + starts_at?: string + status?: string + trackers?: Json + updated_at?: string + visibility?: string + } + Relationships: [ + { + foreignKeyName: "openswarm_offers_attestation_id_fkey" + columns: ["attestation_id"] + isOneToOne: false + referencedRelation: "openswarm_attestations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "openswarm_offers_requester_key_fkey" + columns: ["requester_key"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + ] + } + openswarm_parties: { + Row: { + abandoned: number + account_id: string | null + balance_usd: number + created_at: string + failed: number + honoured: number + key: string + kind: string + label: string | null + operator_key: string | null + paid_out_usd: number + payout_address: string | null + payout_network: string | null + proven: number + updated_at: string + voided: number + } + Insert: { + abandoned?: number + account_id?: string | null + balance_usd?: number + created_at?: string + failed?: number + honoured?: number + key: string + kind?: string + label?: string | null + operator_key?: string | null + paid_out_usd?: number + payout_address?: string | null + payout_network?: string | null + proven?: number + updated_at?: string + voided?: number + } + Update: { + abandoned?: number + account_id?: string | null + balance_usd?: number + created_at?: string + failed?: number + honoured?: number + key?: string + kind?: string + label?: string | null + operator_key?: string | null + paid_out_usd?: number + payout_address?: string | null + payout_network?: string | null + proven?: number + updated_at?: string + voided?: number + } + Relationships: [ + { + foreignKeyName: "openswarm_parties_operator_key_fkey" + columns: ["operator_key"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + ] + } + openswarm_proofs: { + Row: { + created_at: string + detail: string | null + id: string + kind: string + lease_id: string + passed: boolean + period: number + record: Json + verifier_key: string | null + } + Insert: { + created_at?: string + detail?: string | null + id: string + kind: string + lease_id: string + passed: boolean + period: number + record: Json + verifier_key?: string | null + } + Update: { + created_at?: string + detail?: string | null + id?: string + kind?: string + lease_id?: string + passed?: boolean + period?: number + record?: Json + verifier_key?: string | null + } + Relationships: [ + { + foreignKeyName: "openswarm_proofs_lease_id_fkey" + columns: ["lease_id"] + isOneToOne: false + referencedRelation: "openswarm_leases" + referencedColumns: ["id"] + }, + ] + } + openswarm_receipts: { + Row: { + balance_usd: number + created_at: string + earned_usd: number + id: string + lane: string + lease_id: string + period: number + proof_id: string | null + record: Json + } + Insert: { + balance_usd: number + created_at?: string + earned_usd: number + id: string + lane: string + lease_id: string + period: number + proof_id?: string | null + record: Json + } + Update: { + balance_usd?: number + created_at?: string + earned_usd?: number + id?: string + lane?: string + lease_id?: string + period?: number + proof_id?: string | null + record?: Json + } + Relationships: [ + { + foreignKeyName: "openswarm_receipts_lease_id_fkey" + columns: ["lease_id"] + isOneToOne: false + referencedRelation: "openswarm_leases" + referencedColumns: ["id"] + }, + { + foreignKeyName: "openswarm_receipts_proof_id_fkey" + columns: ["proof_id"] + isOneToOne: false + referencedRelation: "openswarm_proofs" + referencedColumns: ["id"] + }, + ] + } + openswarm_team_invites: { + Row: { + created_at: string + expires_at: string + id: string + invited_by: string + invitee: string + redeemed_at: string | null + redeemed_by: string | null + role: string + team_id: string + token_hash: string + } + Insert: { + created_at?: string + expires_at: string + id?: string + invited_by: string + invitee: string + redeemed_at?: string | null + redeemed_by?: string | null + role?: string + team_id: string + token_hash: string + } + Update: { + created_at?: string + expires_at?: string + id?: string + invited_by?: string + invitee?: string + redeemed_at?: string | null + redeemed_by?: string | null + role?: string + team_id?: string + token_hash?: string + } + Relationships: [ + { + foreignKeyName: "openswarm_team_invites_invited_by_fkey" + columns: ["invited_by"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + { + foreignKeyName: "openswarm_team_invites_redeemed_by_fkey" + columns: ["redeemed_by"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + { + foreignKeyName: "openswarm_team_invites_team_id_fkey" + columns: ["team_id"] + isOneToOne: false + referencedRelation: "openswarm_teams" + referencedColumns: ["id"] + }, + ] + } + openswarm_team_members: { + Row: { + box_key: string | null + created_at: string + id: string + member_key: string + removed_at: string | null + role: string + team_id: string + } + Insert: { + box_key?: string | null + created_at?: string + id?: string + member_key: string + removed_at?: string | null + role?: string + team_id: string + } + Update: { + box_key?: string | null + created_at?: string + id?: string + member_key?: string + removed_at?: string | null + role?: string + team_id?: string + } + Relationships: [ + { + foreignKeyName: "openswarm_team_members_member_key_fkey" + columns: ["member_key"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + { + foreignKeyName: "openswarm_team_members_team_id_fkey" + columns: ["team_id"] + isOneToOne: false + referencedRelation: "openswarm_teams" + referencedColumns: ["id"] + }, + ] + } + openswarm_teams: { + Row: { + created_at: string + id: string + name: string + owner_key: string + rotate_on_remove: boolean + scope: Json + seats_paid: number + updated_at: string + } + Insert: { + created_at?: string + id?: string + name: string + owner_key: string + rotate_on_remove?: boolean + scope?: Json + seats_paid?: number + updated_at?: string + } + Update: { + created_at?: string + id?: string + name?: string + owner_key?: string + rotate_on_remove?: boolean + scope?: Json + seats_paid?: number + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "openswarm_teams_owner_key_fkey" + columns: ["owner_key"] + isOneToOne: false + referencedRelation: "openswarm_parties" + referencedColumns: ["key"] + }, + ] + } email_accounts: { Row: { id: string; diff --git a/supabase/migrations/20260906030000_pay2seed_hub.sql b/supabase/migrations/20260906030000_pay2seed_hub.sql new file mode 100644 index 0000000..8a2d75e --- /dev/null +++ b/supabase/migrations/20260906030000_pay2seed_hub.sql @@ -0,0 +1,407 @@ +-- The pay2seed / paid2seed hub. +-- +-- bittorrented.com is the reference hub for the OpenSwarm payment family +-- (logicsrc.com/openswarm). A requester attests what they are putting on a +-- swarm and why they may, escrows a budget for it to be kept alive, and +-- seeders take leases, prove every period that they hold and serve it, and are +-- paid per GiB-month. The hub takes 1 percent of what crosses it and never +-- touches a seeder's floor. +-- +-- Every party here is a KEY, and a key is either a human or a bit (an +-- autonomous agent). That is the whole point of the lane column: this layer +-- carries h2h, h2b, b2h and b2b alike, and we want to see which is growing. +-- +-- openswarm_parties — a key, its kind, its payout address, its standing +-- openswarm_attestations — consent: what this is and why you may share it +-- openswarm_offers — money escrowed for a swarm to be kept +-- openswarm_leases — one seeder's slot on an offer +-- openswarm_receipts — one proven period's earnings +-- openswarm_proofs — the challenge answer or probe behind a receipt +-- openswarm_notices — a claim against an attestation +-- openswarm_teams — who may decrypt a private swarm +-- openswarm_team_members — the member keys, with roles +-- openswarm_team_invites — an invitation, until it is redeemed or expires + +CREATE OR REPLACE FUNCTION update_openswarm_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SET search_path = ''; + +-- --------------------------------------------------------------------------- +-- Parties: every key the hub knows, human or bit. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS openswarm_parties ( + key TEXT PRIMARY KEY, + -- 'human' or 'bit'. A bit is an autonomous agent; it sells and earns on the + -- same terms and under the same consent rules. + kind TEXT NOT NULL DEFAULT 'human', + -- An agent names the human answerable for it. Required for a bit's PUBLIC + -- attestations, so a notice always reaches somebody. + operator_key TEXT REFERENCES openswarm_parties(key) ON DELETE SET NULL, + label TEXT, + -- Linked to a site account when the key was minted here; null for a stranger. + account_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + payout_address TEXT, + payout_network TEXT, + balance_usd NUMERIC(14, 6) NOT NULL DEFAULT 0, + paid_out_usd NUMERIC(14, 6) NOT NULL DEFAULT 0, + -- Standing counters (paid2seed §6.3). Read, never trusted from the client. + proven INTEGER NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + abandoned INTEGER NOT NULL DEFAULT 0, + honoured INTEGER NOT NULL DEFAULT 0, + voided INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_parties_kind_check CHECK (kind IN ('human', 'bit')), + CONSTRAINT openswarm_parties_key_check CHECK (key ~ '^ed25519:[0-9a-f]{64}$'), + -- An address without a network is not a payee (coinpay-payee-always-required). + CONSTRAINT openswarm_parties_payout_check + CHECK ((payout_address IS NULL) = (payout_network IS NULL)) +); + +CREATE INDEX IF NOT EXISTS idx_openswarm_parties_account ON openswarm_parties(account_id); +CREATE INDEX IF NOT EXISTS idx_openswarm_parties_kind ON openswarm_parties(kind); +CREATE INDEX IF NOT EXISTS idx_openswarm_parties_payout ON openswarm_parties(payout_address); + +-- --------------------------------------------------------------------------- +-- Attestations: consent, signed, before anything is listed. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS openswarm_attestations ( + id TEXT PRIMARY KEY, + requester_key TEXT NOT NULL REFERENCES openswarm_parties(key) ON DELETE CASCADE, + visibility TEXT NOT NULL, + basis TEXT NOT NULL, + license TEXT, + description TEXT, + notice_endpoint TEXT, + -- Exactly one subject: a v1/v2 infohash (public), a file key (private) or a + -- channel key (a live stream). Enforced below. + infohash_v1 TEXT, + infohash_v2 TEXT, + file_key TEXT, + channel_key TEXT, + -- Every swarm on the market carries a README. No README, no listing. + readme TEXT NOT NULL, + readme_sha256 TEXT NOT NULL, + -- The signed record exactly as received, so anyone can re-verify it. + record JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'claimed', + -- A public claim is only listed once this passes (pay2seed §3.3). + claim_window_ends_at TIMESTAMPTZ, + honoured_at TIMESTAMPTZ, + voided_at TIMESTAMPTZ, + void_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_attestations_visibility_check CHECK (visibility IN ('public', 'private')), + CONSTRAINT openswarm_attestations_basis_check + CHECK (basis IN ('own', 'licensed', 'open-license', 'public-domain', 'personal')), + CONSTRAINT openswarm_attestations_status_check + CHECK (status IN ('claimed', 'honoured', 'voided')), + -- personal is for a private backup nobody else is meant to read. + CONSTRAINT openswarm_attestations_personal_check + CHECK (basis <> 'personal' OR visibility = 'private'), + CONSTRAINT openswarm_attestations_license_check + CHECK (basis <> 'open-license' OR license IS NOT NULL), + -- A public swarm must say where a notice goes. + CONSTRAINT openswarm_attestations_notice_check + CHECK (visibility = 'private' OR notice_endpoint IS NOT NULL), + CONSTRAINT openswarm_attestations_readme_check CHECK (length(readme) BETWEEN 1 AND 65536), + CONSTRAINT openswarm_attestations_subject_check CHECK ( + (CASE WHEN infohash_v1 IS NOT NULL OR infohash_v2 IS NOT NULL THEN 1 ELSE 0 END) + + (CASE WHEN file_key IS NOT NULL THEN 1 ELSE 0 END) + + (CASE WHEN channel_key IS NOT NULL THEN 1 ELSE 0 END) = 1 + ), + -- An infohash subject is public; a file key is private. + CONSTRAINT openswarm_attestations_public_check + CHECK (NOT (infohash_v1 IS NOT NULL OR infohash_v2 IS NOT NULL) OR visibility = 'public'), + CONSTRAINT openswarm_attestations_private_check + CHECK (file_key IS NULL OR visibility = 'private') +); + +CREATE INDEX IF NOT EXISTS idx_openswarm_attestations_requester ON openswarm_attestations(requester_key); +CREATE INDEX IF NOT EXISTS idx_openswarm_attestations_status ON openswarm_attestations(status); +CREATE INDEX IF NOT EXISTS idx_openswarm_attestations_v1 ON openswarm_attestations(infohash_v1); +CREATE INDEX IF NOT EXISTS idx_openswarm_attestations_file ON openswarm_attestations(file_key); +CREATE INDEX IF NOT EXISTS idx_openswarm_attestations_channel ON openswarm_attestations(channel_key); +-- One honoured claim per public infohash: the second requester is refused +-- unless their basis does not depend on who is asking (pay2seed §3.3). +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_attestations_one_honoured_v1 + ON openswarm_attestations(infohash_v1) + WHERE infohash_v1 IS NOT NULL AND status = 'honoured'; + +DROP TRIGGER IF EXISTS trigger_openswarm_attestations_updated_at ON openswarm_attestations; +CREATE TRIGGER trigger_openswarm_attestations_updated_at + BEFORE UPDATE ON openswarm_attestations + FOR EACH ROW EXECUTE FUNCTION update_openswarm_updated_at(); + +-- --------------------------------------------------------------------------- +-- Offers: a budget escrowed for a swarm to be held. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS openswarm_offers ( + id TEXT PRIMARY KEY, + attestation_id TEXT NOT NULL REFERENCES openswarm_attestations(id) ON DELETE CASCADE, + requester_key TEXT NOT NULL REFERENCES openswarm_parties(key) ON DELETE CASCADE, + visibility TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + days INTEGER NOT NULL, + seeders_min INTEGER NOT NULL DEFAULT 1, + seeders_max INTEGER NOT NULL DEFAULT 1, + price_usd_per_gib_month NUMERIC(12, 6) NOT NULL, + budget_usd NUMERIC(14, 6) NOT NULL, + -- What leases have earned so far; the rest is refundable. + spent_usd NUMERIC(14, 6) NOT NULL DEFAULT 0, + -- The hub's 1 percent, charged to the requester on top of the budget. + fee_usd NUMERIC(14, 6) NOT NULL DEFAULT 0, + proof_every_hours INTEGER NOT NULL DEFAULT 6, + trackers JSONB NOT NULL DEFAULT '[]'::JSONB, + -- A pass so a leased seeder can pull a paid private swarm as a paying peer. + pass JSONB, + record JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + payment_id TEXT, + paid_at TIMESTAMPTZ, + starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_offers_status_check + CHECK (status IN ('unpaid', 'pending', 'active', 'settled', 'voided')), + CONSTRAINT openswarm_offers_visibility_check CHECK (visibility IN ('public', 'private')), + CONSTRAINT openswarm_offers_seeders_check CHECK (seeders_min >= 1 AND seeders_max >= seeders_min), + CONSTRAINT openswarm_offers_days_check CHECK (days > 0), + CONSTRAINT openswarm_offers_size_check CHECK (size_bytes > 0), + CONSTRAINT openswarm_offers_price_check CHECK (price_usd_per_gib_month >= 0), + CONSTRAINT openswarm_offers_budget_check CHECK (budget_usd >= 0 AND spent_usd >= 0), + CONSTRAINT openswarm_offers_proof_check CHECK (proof_every_hours BETWEEN 1 AND 168) +); + +CREATE INDEX IF NOT EXISTS idx_openswarm_offers_status ON openswarm_offers(status); +CREATE INDEX IF NOT EXISTS idx_openswarm_offers_requester ON openswarm_offers(requester_key); +CREATE INDEX IF NOT EXISTS idx_openswarm_offers_attestation ON openswarm_offers(attestation_id); +CREATE INDEX IF NOT EXISTS idx_openswarm_offers_expires ON openswarm_offers(expires_at); +-- Unique so a retried webhook cannot pay the same offer twice. +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_offers_payment + ON openswarm_offers(payment_id) WHERE payment_id IS NOT NULL; + +DROP TRIGGER IF EXISTS trigger_openswarm_offers_updated_at ON openswarm_offers; +CREATE TRIGGER trigger_openswarm_offers_updated_at + BEFORE UPDATE ON openswarm_offers + FOR EACH ROW EXECUTE FUNCTION update_openswarm_updated_at(); + +-- --------------------------------------------------------------------------- +-- Leases: one seeder's slot on an offer. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS openswarm_leases ( + id TEXT PRIMARY KEY, + offer_id TEXT NOT NULL REFERENCES openswarm_offers(id) ON DELETE CASCADE, + seeder_key TEXT NOT NULL REFERENCES openswarm_parties(key) ON DELETE CASCADE, + slot INTEGER NOT NULL, + price_usd_per_gib_month NUMERIC(12, 6) NOT NULL, + -- The payer and payee kinds at lease time, so the lane survives a party + -- later changing kind. h2h, h2b, b2h or b2b. + lane TEXT NOT NULL, + earned_usd NUMERIC(14, 6) NOT NULL DEFAULT 0, + periods_proven INTEGER NOT NULL DEFAULT 0, + periods_failed INTEGER NOT NULL DEFAULT 0, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'fetching', + grace_hours INTEGER NOT NULL DEFAULT 24, + last_proof_at TIMESTAMPTZ, + starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ends_at TIMESTAMPTZ NOT NULL, + record JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_leases_status_check + CHECK (status IN ('fetching', 'proven', 'lapsed', 'abandoned', 'ended', 'voided')), + CONSTRAINT openswarm_leases_lane_check CHECK (lane IN ('h2h', 'h2b', 'b2h', 'b2b')), + CONSTRAINT openswarm_leases_slot_check CHECK (slot >= 1) +); + +-- One lease per seeder per offer, and one seeder per slot. +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_leases_one_per_seeder + ON openswarm_leases(offer_id, seeder_key); +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_leases_slot + ON openswarm_leases(offer_id, slot) + WHERE status <> 'abandoned' AND status <> 'voided'; +CREATE INDEX IF NOT EXISTS idx_openswarm_leases_seeder ON openswarm_leases(seeder_key); +CREATE INDEX IF NOT EXISTS idx_openswarm_leases_status ON openswarm_leases(status); +CREATE INDEX IF NOT EXISTS idx_openswarm_leases_lane ON openswarm_leases(lane); + +DROP TRIGGER IF EXISTS trigger_openswarm_leases_updated_at ON openswarm_leases; +CREATE TRIGGER trigger_openswarm_leases_updated_at + BEFORE UPDATE ON openswarm_leases + FOR EACH ROW EXECUTE FUNCTION update_openswarm_updated_at(); + +-- --------------------------------------------------------------------------- +-- Proofs and receipts: what a period was paid for. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS openswarm_proofs ( + id TEXT PRIMARY KEY, + lease_id TEXT NOT NULL REFERENCES openswarm_leases(id) ON DELETE CASCADE, + period INTEGER NOT NULL, + kind TEXT NOT NULL, + verifier_key TEXT, + passed BOOLEAN NOT NULL, + detail TEXT, + record JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_proofs_kind_check CHECK (kind IN ('challenge', 'probe')), + CONSTRAINT openswarm_proofs_period_check CHECK (period >= 0) +); + +-- One verdict per period per lease: a retried report updates, never doubles. +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_proofs_period + ON openswarm_proofs(lease_id, period); + +CREATE TABLE IF NOT EXISTS openswarm_receipts ( + id TEXT PRIMARY KEY, + lease_id TEXT NOT NULL REFERENCES openswarm_leases(id) ON DELETE CASCADE, + proof_id TEXT REFERENCES openswarm_proofs(id) ON DELETE SET NULL, + period INTEGER NOT NULL, + earned_usd NUMERIC(14, 6) NOT NULL, + balance_usd NUMERIC(14, 6) NOT NULL, + lane TEXT NOT NULL, + record JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_receipts_lane_check CHECK (lane IN ('h2h', 'h2b', 'b2h', 'b2b')), + CONSTRAINT openswarm_receipts_earned_check CHECK (earned_usd >= 0) +); + +-- A period is paid once. This unique index is the whole of the idempotency. +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_receipts_period + ON openswarm_receipts(lease_id, period); +CREATE INDEX IF NOT EXISTS idx_openswarm_receipts_lane ON openswarm_receipts(lane); + +-- --------------------------------------------------------------------------- +-- Notices: anyone may claim against an attestation. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS openswarm_notices ( + id TEXT PRIMARY KEY, + attestation_id TEXT NOT NULL REFERENCES openswarm_attestations(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + claimant_name TEXT, + claimant_contact TEXT, + statement TEXT NOT NULL, + record JSONB NOT NULL, + outcome TEXT NOT NULL DEFAULT 'received', + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_notices_kind_check + CHECK (kind IN ('rights', 'illegal', 'personal-data', 'other')), + CONSTRAINT openswarm_notices_outcome_check + CHECK (outcome IN ('received', 'voided', 'stands')) +); + +CREATE INDEX IF NOT EXISTS idx_openswarm_notices_attestation ON openswarm_notices(attestation_id); +CREATE INDEX IF NOT EXISTS idx_openswarm_notices_outcome ON openswarm_notices(outcome); + +-- --------------------------------------------------------------------------- +-- Teams: who may decrypt a private swarm. This is what the hub sells. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS openswarm_teams ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_key TEXT NOT NULL REFERENCES openswarm_parties(key) ON DELETE CASCADE, + name TEXT NOT NULL, + -- File keys or publisher keys members may be granted; ["*"] under the owner. + scope JSONB NOT NULL DEFAULT '{"files":["*"],"publishers":[]}'::JSONB, + rotate_on_remove BOOLEAN NOT NULL DEFAULT TRUE, + seats_paid INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_teams_name_check CHECK (length(name) BETWEEN 1 AND 120) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_teams_owner_name ON openswarm_teams(owner_key, name); + +DROP TRIGGER IF EXISTS trigger_openswarm_teams_updated_at ON openswarm_teams; +CREATE TRIGGER trigger_openswarm_teams_updated_at + BEFORE UPDATE ON openswarm_teams + FOR EACH ROW EXECUTE FUNCTION update_openswarm_updated_at(); + +CREATE TABLE IF NOT EXISTS openswarm_team_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES openswarm_teams(id) ON DELETE CASCADE, + member_key TEXT NOT NULL REFERENCES openswarm_parties(key) ON DELETE CASCADE, + box_key TEXT, + role TEXT NOT NULL DEFAULT 'member', + removed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_team_members_role_check CHECK (role IN ('admin', 'member', 'readonly')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_team_members_unique + ON openswarm_team_members(team_id, member_key) WHERE removed_at IS NULL; + +CREATE TABLE IF NOT EXISTS openswarm_team_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES openswarm_teams(id) ON DELETE CASCADE, + invited_by TEXT NOT NULL REFERENCES openswarm_parties(key) ON DELETE CASCADE, + -- An email, a handle or a key. The invitee brings a key when they redeem. + invitee TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + token_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + redeemed_at TIMESTAMPTZ, + redeemed_by TEXT REFERENCES openswarm_parties(key) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT openswarm_team_invites_role_check CHECK (role IN ('admin', 'member', 'readonly')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_openswarm_team_invites_token ON openswarm_team_invites(token_hash); +CREATE INDEX IF NOT EXISTS idx_openswarm_team_invites_team ON openswarm_team_invites(team_id); + +-- --------------------------------------------------------------------------- +-- Row level security. Everything here is written by the service role: the +-- records are signed by keys, not by site sessions, so a browser session is +-- never the authority. Reads of the public market go through the API, which +-- decides what a stranger may see. +-- --------------------------------------------------------------------------- +ALTER TABLE openswarm_parties ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_attestations ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_offers ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_leases ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_proofs ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_receipts ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_notices ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_teams ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_team_members ENABLE ROW LEVEL SECURITY; +ALTER TABLE openswarm_team_invites ENABLE ROW LEVEL SECURITY; + +DO $$ +DECLARE t TEXT; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'openswarm_parties', 'openswarm_attestations', 'openswarm_offers', 'openswarm_leases', + 'openswarm_proofs', 'openswarm_receipts', 'openswarm_notices', 'openswarm_teams', + 'openswarm_team_members', 'openswarm_team_invites' + ] LOOP + EXECUTE format('DROP POLICY IF EXISTS "Service role manages %1$s" ON %1$s', t); + EXECUTE format( + 'CREATE POLICY "Service role manages %1$s" ON %1$s FOR ALL USING (auth.jwt() ->> ''role'' = ''service_role'')', + t + ); + END LOOP; +END $$; + +-- A signed-in account can read the parties it owns, so the site can show a +-- person their own keys, balance and standing without the service role. +DROP POLICY IF EXISTS "Owners read own parties" ON openswarm_parties; +CREATE POLICY "Owners read own parties" + ON openswarm_parties FOR SELECT + USING (auth.uid() = account_id);