diff --git a/apps/logicsrc-web/src/app/openswarm/page.tsx b/apps/logicsrc-web/src/app/openswarm/page.tsx new file mode 100644 index 0000000..b9b83be --- /dev/null +++ b/apps/logicsrc-web/src/app/openswarm/page.tsx @@ -0,0 +1,237 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; +import { SiteShell } from "@/components/site-shell"; +import { card, mono, pre, table, td, th } from "../openontology/ui"; + +export const metadata: Metadata = { + title: "OpenSwarm · LogicSRC", + description: + "OpenSwarm is an open specification family for paid, encrypted, peer-to-peer distribution of files and media: BitTorrent plus payment plus encryption as extension messages, with audio, video, live streams and a replicated catalogue on top, and c0mpute.com nodes that seed, relay and index for money.", + alternates: { canonical: "/openswarm" } +}; + +const PROTOCOLS: Array<[name: string, line: string]> = [ + ["ipfile", "Paid, encrypted file swarms as BitTorrent extension messages"], + ["ippay", "Passes and vouchers over x402 and CoinPay: how a leecher pays and a seeder is paid"], + ["ipdb", "A replicated, append-only catalogue of manifests and metadata, discoverable over the DHT"], + ["ipaudio", "Audio releases and tracks on ipfile swarms: renditions, seek tables, gapless, royalties"], + ["ipvideo", "Video on demand: CMAF renditions, segment index, subtitles, thumbnails, an HLS bridge"], + ["iplive", "Live streams: segment fan-out over paid relays with backpressure"], + ["ipname", "How a Moshpit name or a domain resolves to a publisher key and a catalogue"] +]; + +const ROLES: Array<[role: string, work: string, paid: string]> = [ + ["Storage", "Pin an ipfile swarm for a period and seed it", "Pin job plus vouchers per byte served"], + ["Relay", "Fan out iplive segments to viewers", "Vouchers per byte served"], + ["Transcode", "Produce ipaudio and ipvideo renditions as ipfile swarms", "Job price"], + ["Index", "Replicate ipdb feeds, answer queries, serve a gateway", "Job price plus x402 per query"], + ["Keeper", "Hold a content key and grant it to paying peers", "Share of each key grant"] +]; + +const DOCS: Array<[slug: string, title: string, blurb: string]> = [ + ["openswarm", "Overview", "What the family defines, how the pieces stack, and what already exists."], + ["opencreds", "OpenCreds", "Where the publisher seed should live."] +]; + +export default function OpenSwarmPage(): ReactNode { + return ( + +
+
+

LogicSRC standards surface

+

OpenSwarm

+

+ An open specification family for paid, encrypted, peer-to-peer distribution{" "} + of files and media. It is an add-on to BitTorrent, not a new transport. A swarm carries + ciphertext, the tracker and the DHT learn nothing about the content, every peer that + serves a verified piece gets paid for it, and the same primitives carry files, audio, + video, live streams and the catalogue that describes them. +

+
+

+ It exists because BitTorrent solved distribution and never solved the two things that + keep it from being a product: nobody is paid to seed, and nothing in it is private. Every + Profullstack media property has rebuilt the same paid-access layer on top of a central + HTTP proxy because the swarm could not carry the payment. +

+

+ Status: 0.1 draft. No reference implementation yet. The full documents + live in the repo under docs/openswarm/. +

+
+ +
+
+

The family

+

Seven protocols over one core. Reuse is the default posture: a primitive is specified once.

+
+
+ + + + + + + + + {PROTOCOLS.map(([name, line]) => ( + + + + + ))} + +
ProtocolOne line
{name}{line}
+
+
{`  +---------------------------------------------------------------+
+  |  ipaudio      ipvideo      iplive         ipdb (catalogue)    |
+  +---------------------------------------------------------------+
+  |  ipfile: manifest, per-file key pair, encrypted pieces,       |
+  |          key grants, credit window, vouchers per served piece |
+  +-------------------------------+-------------------------------+
+  |  ippay: passes and vouchers   |  ipname: Moshpit name -> key  |
+  +-------------------------------+-------------------------------+
+  |  BitTorrent wire (BEP 3) + extension protocol (BEP 10)        |
+  |  DHT (BEP 5, 44, 46), trackers, WebRTC peers, webseeds,       |
+  |  optional Moshpit MTP/1 tunnel                                |
+  +---------------------------------------------------------------+`}
+
+ +
+
+

One swarm shape, one key model, one payment loop

+
+
+
+ The swarm carries ciphertext. +

+ A hybrid BEP 52 torrent whose single file is AES-256-CTR ciphertext. The v1 infohash + makes it reachable from WebTorrent in a browser, the v2 merkle tree makes every 16 KiB + block verifiable, and the plaintext root in the signed manifest makes the decrypted + result verifiable. A vanilla client can join and, if the publisher allows, download + bytes it cannot read. +

+
+
+ Adding a file mints a key pair for it. +

+ Derived from one publisher seed by default, so there is one thing to back up. The + public half is the file’s identity and the BEP 46 key under which the latest + manifest is published; the private half signs the manifest and authorises key grants + and payout changes. A separate random content key encrypts the bytes and is sealed to + paying peers. +

+
+
+ Seeders are paid per verified piece. +

+ A leecher buys a pass over x402 in USDC. A seeder serves inside a bounded credit + window. After verifying each batch the leecher signs a cumulative voucher. The seeder + redeems the latest voucher at a hub, which splits it between publisher, seeder and + itself. Nobody pays for bytes they did not verify, and nobody serves more than the + window unpaid. +

+
+
+
{`leecher                                   seeder
+  |  hello                                  |
+  |---------------------------------------->|
+  |<------------------------------- hello   |
+  |  pass                                   |
+  |---------------------------------------->|   verifies hub signature, scope, cap
+  |<----------------------------- unchoke   |
+  |  request / piece ... (vanilla)          |
+  |<=======================================>|   up to the credit window unpaid
+  |<------------------------------ credit   |
+  |  voucher (cumulative, signed)           |
+  |---------------------------------------->|   unpaid resets
+  |  key_req                                |
+  |---------------------------------------->|
+  |<------------------------------- grant   |   content key sealed to the leecher`}
+
+ +
+
+

How c0mpute.com nodes take part

+

+ A node already advertises roles and takes work through a gossipsub auction. OpenSwarm + adds workload types to bid on and a per-byte income that needs no auction at all. +

+
+
+ + + + + + + + + + {ROLES.map(([role, work, paid]) => ( + + + + + + ))} + +
RoleWorkPaid by
{role}{work}{paid}
+
+
+ +
+
+

Using it (proposed CLI)

+
+
{`# One publisher identity, reused for every file
+ip init
+
+# Add a file: derives the file key pair, encrypts, builds the hybrid torrent,
+# signs the manifest, starts seeding
+ip file add ./interview.flac --per-gib 0.01 --key-price 0.50
+
+# Publish an audio release that references it
+ip audio publish ./release.json
+
+# Fetch as a paying peer (buys a pass, gets the key, verifies, decrypts)
+ip file get ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d --out ./interview.flac
+
+# Ask the catalogue
+ip db query --type ipaudio.track --where 'record.artist == "Ada"' --limit 20`}
+
+ +
+
+

What already exists

+
+
+
+ bittorrented.com runs the browser WebTorrent player, a hybrid seeder on + wss trackers, and a DHT crawl. It speaks the vanilla wire this family extends. +
+
+ x402-gateway and CoinPay sell passes with the x402 v2 offer and verify + and settle the proof. ippay reuses that exchange unchanged. +
+
+ c0mpute has the blake3 chunk store, erasure coding, hardware transcode, + a gateway, and a live-stream design that already swarms segments. +
+
+ moshpit-transport and the Moshpit registry provide the post-quantum + tunnel and the name pins. +
+
+
+ {DOCS.map(([slug, title, blurb]) => ( + + {title} +

{blurb}

+
+ ))} +
+
+
+ ); +} diff --git a/apps/logicsrc-web/src/app/sitemap.ts b/apps/logicsrc-web/src/app/sitemap.ts index 46b9682..77dc0e5 100644 --- a/apps/logicsrc-web/src/app/sitemap.ts +++ b/apps/logicsrc-web/src/app/sitemap.ts @@ -18,6 +18,7 @@ const STATIC_ROUTES: Array<{ { path: "/docs", changeFrequency: "weekly", priority: 0.9 }, { path: "/openontology", changeFrequency: "weekly", priority: 0.9 }, { path: "/opencreds", changeFrequency: "weekly", priority: 0.9 }, + { path: "/openswarm", changeFrequency: "weekly", priority: 0.9 }, { path: "/openprd", changeFrequency: "weekly", priority: 0.9 }, { path: "/openontology/explore", changeFrequency: "daily", priority: 0.7 }, { path: "/openspec", changeFrequency: "weekly", priority: 0.8 }, diff --git a/apps/logicsrc-web/src/components/site-shell.tsx b/apps/logicsrc-web/src/components/site-shell.tsx index 9375896..582355d 100644 --- a/apps/logicsrc-web/src/components/site-shell.tsx +++ b/apps/logicsrc-web/src/components/site-shell.tsx @@ -11,6 +11,7 @@ const NAV: Array<{ href: string; label: string; external?: boolean }> = [ { href: "/credential-sharing", label: "Credentials" }, { href: "/openontology", label: "OpenOntology" }, { href: "/opencreds", label: "OpenCreds" }, + { href: "/openswarm", label: "OpenSwarm" }, { href: "/openprd", label: "OpenPRD" }, { href: "/#cli", label: "CLI" }, { href: "/docs", label: "Docs" }, diff --git a/apps/logicsrc-web/src/lib/docs.ts b/apps/logicsrc-web/src/lib/docs.ts index 1c02bb7..6db3b35 100644 --- a/apps/logicsrc-web/src/lib/docs.ts +++ b/apps/logicsrc-web/src/lib/docs.ts @@ -8,6 +8,7 @@ const DOCS_DIR = resolve(process.cwd(), "../../docs"); // Curated, public-facing reference docs. Internal notes (roadmap, positioning, // arcade) are intentionally excluded. export const DOC_SLUGS = [ + "openswarm", "opencreds", "openprd", "openontology", diff --git a/docs/openswarm.md b/docs/openswarm.md new file mode 100644 index 0000000..61393a4 --- /dev/null +++ b/docs/openswarm.md @@ -0,0 +1,190 @@ +# OpenSwarm + +Status: 0.1 draft. No reference implementation yet; see "What already exists" below. + +Slug: `openswarm` + +OpenSwarm is a LogicSRC OpenSpec family for **paid, encrypted, peer-to-peer +distribution of files and media**. It is an add-on to BitTorrent, not a new +transport. A swarm carries ciphertext, the tracker and the DHT learn nothing +about the content, every peer that serves a verified piece gets paid for it, +and the same swarm primitives carry files, audio, video, live streams and the +catalogue that describes them. + +It exists because BitTorrent solved distribution and never solved the two +things that keep it from being a product: nobody is paid to seed, and nothing +in it is private. Every Profullstack media property (bittorrented.com, +tipoffwatch, genrewatch, p0dcasters) has rebuilt the same paid-access layer on +top of central HTTP proxies because the swarm could not carry the payment. + +Working name during design was "IPMedia". The family is called OpenSwarm +because the swarm is the one object every member shares, because "media" +undersells `ipfile` and `ipdb`, and because "IP media" reads as IPTV, which is +a different product. The member protocols keep their `ip` names. + +## The family + +| Protocol | One line | Document | +| --- | --- | --- | +| Core | Records, identifiers, keys, signatures, transports, discovery, events | [`spec.md`](./openswarm/spec.md) | +| `ipfile` | Paid, encrypted file swarms as BitTorrent extension messages | [`ipfile.md`](./openswarm/ipfile.md) | +| `ippay` | Passes and vouchers: how a leecher pays and a seeder is paid, over x402 and CoinPay | [`ippay.md`](./openswarm/ippay.md) | +| `ipdb` | A replicated, append-only catalogue of manifests and metadata, discoverable over the DHT | [`ipdb.md`](./openswarm/ipdb.md) | +| `ipaudio` | Audio releases and tracks on `ipfile` swarms: renditions, seek tables, gapless, royalties | [`ipaudio.md`](./openswarm/ipaudio.md) | +| `ipvideo` | Video on demand on `ipfile` swarms: CMAF renditions, segment index, subtitles, thumbnails | [`ipvideo.md`](./openswarm/ipvideo.md) | +| `iplive` | Live streams: segment fan-out over peers, paid relays, backpressure | [`iplive.md`](./openswarm/iplive.md) | +| `ipname` | How a Moshpit name resolves to a publisher key and a catalogue | [`ipname.md`](./openswarm/ipname.md) | + +Supporting documents: + +- c0mpute.com integration and use cases: [`c0mpute.md`](./openswarm/c0mpute.md) +- Proposed `ip` CLI: [`cli.md`](./openswarm/cli.md) +- Conformance: [`conformance.md`](./openswarm/conformance.md) +- Security model: [`security.md`](./openswarm/security.md) +- FAQ: [`faq.md`](./openswarm/faq.md) + +## How the pieces stack + +``` + +---------------------------------------------------------------+ + | ipaudio ipvideo iplive ipdb (catalogue) | + | releases titles channels feeds, entries | + +---------------------------------------------------------------+ + | ipfile: manifest, per-file key pair, encrypted pieces, | + | key grants, credit window, vouchers per served piece | + +-------------------------------+-------------------------------+ + | ippay: passes and vouchers | ipname: Moshpit name -> key | + | x402 purchase, CoinPay hub | and catalogue | + +-------------------------------+-------------------------------+ + | BitTorrent wire (BEP 3) + extension protocol (BEP 10) | + | DHT (BEP 5, 44, 46), trackers, WebRTC peers (WebTorrent), | + | webseeds (BEP 19), optional Moshpit MTP/1 tunnel | + +---------------------------------------------------------------+ +``` + +Reading it upward: a video title is a set of `ipfile` swarms plus an index. +An `ipfile` swarm is an ordinary hybrid v1/v2 torrent whose payload is +ciphertext. Payment and key delivery ride on BEP 10 extension messages that a +vanilla client never sees. The catalogue that says what a swarm contains is +itself replicated as `ipfile` swarms and pointed at from the DHT. + +## Reuse is the default posture + +Every building block is specified once, with a stable interface, and reused: + +- **Chunking and hashing** are BitTorrent v2 (BEP 52): 16 KiB blocks, SHA-256 + merkle trees, power-of-two piece lengths. `ipaudio`, `ipvideo` and `iplive` + do not define their own. +- **Manifests** are one record shape (core §3) with a `type`. A track, a + title and a channel are records that reference `ipfile` manifests. +- **Keys** are one identity model (core §4). A publisher key is reused for + every file that publisher adds; per-file keys are derived from it and can be + recovered from it. A fresh unrelated pair is minted only on request. +- **Swarm encryption** is `ipfile`'s AES-256-CTR over pieces, keyed per file. + Audio and video renditions are encrypted the same way, by being files. +- **Payment** is `ippay`'s pass and voucher pair. A relay serving live + segments is paid with the same voucher a seeder gets for a piece. +- **Relay** is one role. A c0mpute node that relays `iplive` segments uses + the same credit window and the same voucher as a seeder. + +A member protocol that needs something the core lacks adds a record type or +an extension message. It does not redefine a primitive. + +## How c0mpute.com nodes participate + +A c0mpute node already advertises roles (`storage`, `transcode`, `gateway`, +`verifier`) and takes work through a gossipsub auction. OpenSwarm adds +workload types the node can bid on and a second income path that needs no +auction at all: + +| Role | Work | Paid by | +| --- | --- | --- | +| Storage | Pin an `ipfile` swarm for a period, seed it | Job price (pinning) plus vouchers per byte served | +| Relay | Fan out `iplive` segments to viewers | Vouchers per byte served | +| Transcode | Produce `ipaudio` / `ipvideo` renditions, publish them as `ipfile` swarms | Job price | +| Index | Replicate `ipdb` feeds, answer queries, serve a gateway | Job price plus x402 per query | +| Keeper | Hold a file's content key and grant it to paying peers | Share of each key grant | + +Details, thirteen worked use cases and the proposed CLI invocations are in +[`c0mpute.md`](./openswarm/c0mpute.md). + +## What it defines + +**One swarm shape.** A hybrid BEP 52 torrent whose single payload file is +AES-256-CTR ciphertext. The v1 infohash makes it reachable from WebTorrent in a +browser, the v2 merkle tree makes every 16 KiB block verifiable, and the +plaintext root in the signed manifest makes the decrypted result verifiable. + +**One key model.** A publisher key pair (Ed25519). Adding a file mints a +per-file pair derived from it: the public half is the file's identity and the +BEP 46 key under which the latest manifest is published; the private half +signs the manifest and authorises key grants and payout changes. A separate +random content key encrypts the bytes and is wrapped to paying peers. + +**One payment loop.** A leecher buys a pass (x402, USDC, CoinPay as the +reference hub). A seeder serves pieces inside a bounded credit window. After +verifying each piece the leecher signs a cumulative voucher. The seeder +redeems the latest voucher at the hub, which splits it between seeder, +publisher and hub. Nobody pays for bytes they did not verify, and nobody +serves more than the window unpaid. + +**One catalogue.** An `ipdb` feed is a signed, hash-linked log of records. +Its head is a BEP 44 mutable item under the feed key, so any DHT node can find +the latest catalogue of any publisher with one `get`. + +## What it does not define + +A media player. Transcoding settings beyond what a manifest must declare. A +token. A blockchain. Content moderation. A search ranking. DRM in the sense +of controlling a device after the key is delivered; a paying peer holds the +key and the spec says so. + +## What already exists + +| Piece | Where | Relationship | +| --- | --- | --- | +| Browser WebTorrent player, hybrid Node seeder, wss trackers | `profullstack/media-streamer` (bittorrented.com) | Speaks the vanilla wire this family extends; needs the `ipfile` extension to pay | +| DHT crawl (bitmagnet) and `/dht` browse | `profullstack/media-streamer`, `dht-infohash-crawler` | Observes `ipfile` swarms as opaque infohashes; `ipdb` is how it would learn what they are | +| Pay-per-pass grants, HLS manifest sealing | `media-streamer` IPTV and seedbox rails | The central-proxy version of what `ippay` moves into the swarm | +| x402 v2 offer, `X-PAYMENT` proof, verify and settle | `profullstack/x402-gateway`, CoinPay | `ippay` pass purchase reuses it unchanged | +| CloudEvents 1.0 + Standard Webhooks signing | `profullstack/autoblog` | Every OpenSwarm event uses the same envelope and headers | +| MTP/1 post-quantum transport, name pins | `profullstack/moshpit-transport`, `moshpit-proxy` | Optional tunnel for native peer links; `ipname` pin kind | +| blake3 chunk store, RS 10/14 erasure, ffmpeg transcode, live-stream DIP 0019 | `profullstack/c0mpute` | The node that seeds, relays, transcodes and indexes | + +New in this family: the extension messages, the manifest and key model, the +pass and voucher protocol, the catalogue log, and the media record types. + +## Quick start (proposed CLI) + +```bash +# One publisher identity, reused for every file +ip init + +# Add a file: derives the file key pair, encrypts, builds the hybrid torrent, +# signs the manifest, starts seeding +ip file add ./interview.flac --per-gib 0.01 --key-price 0.50 + +# Publish an audio release that references it +ip audio publish ./release.json + +# Fetch as a paying peer (buys a pass, gets the key, verifies, decrypts) +ip file get ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d --out ./interview.flac + +# Ask the catalogue +ip db query --type ipaudio.track --where 'record.artist == "Ada"' --limit 20 +``` + +## Relationship to the other LogicSRC specs + +- **OpenCreds** ([opencreds.md](./opencreds.md)) stores the publisher seed as + a `key` item. Losing that seed is the one unrecoverable event in this family, + and OpenCreds is where it should live. +- **OpenContext** ([opencontext.md](./opencontext.md)) governs what an agent + may read. An agent holding a pass for one swarm is entitled to that swarm's + key and nothing else; the pass scope is the OpenContext decision. +- **OpenOntology** ([openontology.md](./openontology.md)) names the entities + an `ipdb` entry refers to. An `ipaudio.track`'s `artist` is an ontology + entity where one is in use. +- **Credential Sharing** ([credential-sharing.md](./credential-sharing.md)) + moves a hub API key or a payout address between providers; OpenSwarm never + stores either. diff --git a/docs/openswarm/c0mpute.md b/docs/openswarm/c0mpute.md new file mode 100644 index 0000000..ac99e55 --- /dev/null +++ b/docs/openswarm/c0mpute.md @@ -0,0 +1,390 @@ +# OpenSwarm on c0mpute.com + +Version: **0.1** (draft) +Status: draft. Describes how a c0mpute node takes part in OpenSwarm swarms +and how it is paid. The node facts below are from the `profullstack/c0mpute` +repo as of 2026-09-05; the workload types and flows are proposed. + +## 1. What c0mpute is, in the terms this document needs + +A c0mpute node is a machine running the `c0mpute` Rust binary as a worker. +Its identity is a libp2p Ed25519 keypair at `~/.config/c0mpute/identity.key`. +Nodes join a Kademlia DHT under `/c0mpute/kad/1.0.0` from a bootstrap list +at `https://c0mpute.com/bootstrap.json`, and advertise capability tags +(`c0mpute:role:storage`, `c0mpute:role:transcode`, `c0mpute:role:gateway`, +`c0mpute:role:verifier`, one of `c0mpute:gpu:nvidia|amd|apple` or +`c0mpute:cpu`) on gossipsub every 60 seconds. + +Work is a gossipsub auction. A buyer publishes a `JobOffer` on +`c0mpute/jobs/` with `required_capabilities`, +`max_price_usd` and a `deadline_unix_ms`; workers publish `JobBid`s; after +an 8 second window the buyer publishes `JobAccept`; the winner runs it and +publishes a `JobReceipt` with `output_hash` and `status`. Payment is +CoinPay escrow in USD-equivalent (USDC among others); workers keep the full +agreed price. Storage today is a blake3-addressed chunk store with +Reed-Solomon 10 data + 4 parity shards, priced at $0.008 per GB-month and +$0.005 per GB of internet egress. Transcode is in-process ffmpeg with +hardware encoders. A gateway serves `/chunks/` on port 7777. The +live-stream DIP 0019 already describes RTMP/SRT ingest to 4 second segments +swarmed BitTorrent-style. + +OpenSwarm adds nothing to how a node joins or bids. It adds workload types +the node can bid on, a second identity the node registers as a payee, and +per-byte income that arrives without any auction. + +## 2. Node identity and payout + +A node derives an OpenSwarm identity from its libp2p key: + +``` +seed = HKDF-SHA256(ikm = libp2p Ed25519 private key bytes, salt = "", info = "openswarm:node:v1") +``` + +From that seed the node has a publisher identity (core §4.1): a signing key +for `hello` messages, vouchers it signs as a payer when it pulls from other +peers, and a box key for grants. At first start with OpenSwarm enabled it +registers as a payee (`ippay` §6.1) at every hub in its configuration, +with the same payout address `c0mpute worker register` already holds for +CoinPay. `c0mpute worker start --openswarm` is the proposed flag; the node +then adds `c0mpute:openswarm:seed`, `c0mpute:openswarm:relay`, +`c0mpute:openswarm:keeper`, `c0mpute:openswarm:index` and +`c0mpute:openswarm:gateway` to its capability tags according to which +roles it runs. + +## 3. Workload types + +Each is a `module.toml` `[module.workloads]` entry in a proposed `openswarm` +plugin. `spec_inline` shapes are given as the buyer sends them in +`JobOffer`; every `spec_inline` carries the records the worker needs, so a +worker never has to resolve anything before bidding. + +| Workload type | Capability | What the worker does | Priced by | +| --- | --- | --- | --- | +| `ipfile.pin` | `c0mpute:role:storage`, `c0mpute:openswarm:seed` | Fetch a swarm, hold it for a period, seed it, answer storage challenges | GB-month, plus vouchers while serving | +| `ipfile.keep` | `c0mpute:openswarm:keeper` | Hold a content key under a delegating grant, answer `key_req` and the key URL | Flat per period, plus `keeperBps` per grant | +| `ipfile.gateway` | `c0mpute:role:gateway`, `c0mpute:openswarm:gateway` | Serve a swarm as a BEP 19 webseed and the manifest and layer routes | Flat per period, plus vouchers | +| `ipvideo.transcode` | `c0mpute:role:transcode` | Produce a CMAF ladder from a source `ipfile`, publish each rendition as an `ipfile`, return the segment indexes | Per job, as `ffmpeg.transcode` | +| `ipaudio.transcode` | `c0mpute:role:transcode` | Produce audio renditions with gapless values and seek indexes | Per job | +| `iplive.relay` | `c0mpute:openswarm:relay` | Join a channel as a relay for a period with a guaranteed `maxDownstream` | Per hour floor, plus vouchers | +| `ipdb.index` | `c0mpute:openswarm:index` | Follow named feeds, keep a replica current, answer `POST /db/query` | Per feed-month, plus x402 per query if the buyer sets a price | + +### 3.1 `ipfile.pin` + +```json +{ + "job_id": "5b8c1f3e-8a7d-4e2b-9c11-0f2a6d3e4b5c", + "workload_type": "ipfile.pin", + "buyer_peer_id": "12D3KooWQYhTNQdmr3ArTeUHRYzFg94BKuRMVuA2v6LzCDXkQmEA", + "buyer_did": "did:coinpay:buyer:5d292428", + "spec_hash": "blake3:9f4c2a1b7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b", + "spec_inline": { + "manifest": { "openswarm": "0.1", "type": "ipfile.manifest", "sigs": [] }, + "days": 30, + "minPeersSeen": 1, + "erasure": { "k": 10, "parity": 4 }, + "challenge": { "everyHours": 6 } + }, + "required_capabilities": ["c0mpute:role:storage", "c0mpute:openswarm:seed"], + "max_price_usd": 0.18, + "deadline_unix_ms": 1757104800000, + "published_at_ms": 1757101200000 +} +``` + +(`manifest` is the full record in a real offer.) The price is for holding +734 MB for 30 days at the storage rate plus a margin. The worker fetches +the swarm as a paying peer if it has no free source (it needs a pass; the +buyer includes one scoped to the file in `spec_inline.pass` when +`pricing.perGib` is non-zero), stores the ciphertext however it likes, and +seeds. A `c0mpute:role:verifier` node runs the existing storage challenge +against random pieces; a failed challenge is a `JobReceipt` with `status: +"failed"` and a reputation slash, as today. + +The receipt's `output_hash` is the swarm's `infohashV2`. Vouchers the node +earns while seeding are separate from the job and go through the hub. + +### 3.2 `iplive.relay` + +```json +{ + "workload_type": "iplive.relay", + "spec_inline": { + "channel": { "openswarm": "0.1", "type": "iplive.channel", "sigs": [] }, + "hours": 3, + "maxDownstream": 16, + "regions": ["us-west"] + }, + "required_capabilities": ["c0mpute:openswarm:relay"], + "max_price_usd": 0.60 +} +``` + +(Other `JobOffer` fields as in §3.1.) The floor pays the relay to be there +for the whole event even if nobody watches; vouchers pay per byte when they +do. The origin adds accepted relays to `relays.allow` and to the head's +`relays` list. + +### 3.3 `ipdb.index` + +```json +{ + "workload_type": "ipdb.index", + "spec_inline": { + "feeds": ["ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e/default"], + "days": 30, + "query": { "public": true, "priceUsd": "0.000000" }, + "gateway": true + }, + "required_capabilities": ["c0mpute:openswarm:index", "c0mpute:role:gateway"], + "max_price_usd": 0.50 +} +``` + +The node follows the feeds, serves `POST /db/query` and the head route, and +publishes its gateway URL in its `hello` and capability ad so clients and +`ipname` records can point at it. + +## 4. How a node gets paid + +Two paths, both landing in the node's CoinPay payout: + +1. **Jobs.** As today: `JobAccept` names the price, CoinPay escrow releases + on a `completed` receipt. Pins, keeps, gateways, transcodes, relays and + index jobs all pay this way for the commitment. +2. **Vouchers.** For every byte the node serves as a seeder, relay, keeper + gateway or query gateway, the paying peer signs a voucher to the node's + OpenSwarm identity. The node redeems the latest voucher per (pass, + payee, swarm) at the hub when a session ends or every 15 minutes, + whichever is sooner, and the hub pays out per its schedule. + +A node that runs no jobs at all, only seeding swarms it chose to hold, is +still paid by vouchers. That is the "plug in and earn" path and needs no +buyer. + +## 5. Use cases + +Each use case is a flow plus the CLI invocations, with the `ip` CLI as +proposed in [cli.md](./cli.md) and the existing `c0mpute` CLI as it is. + +### 5.1 A podcast episode, seeded by the network + +Publisher: p0dcasters.com on behalf of a show. Goal: listeners pay per +episode, seeders are paid, the show gets 70 percent. + +```bash +ip audio publish ./ep42.json --renditions opus-64,opus-96 --per-gib 0.005 --key-price 0.25 +ip file pin ed25519: --days 90 --min-peers 3 --max-price 0.40 +``` + +`ip audio publish` transcodes locally (or with `--on c0mpute`), adds two +`ipfile`s, writes the track record, appends all three to the feed. `ip +file pin` publishes an `ipfile.pin` offer; three storage nodes win and +seed. A listener's app buys a pass scoped to the publisher for 2 USD once, +then streams every episode against it. p0dcasters renders the RSS feed from +the release record through a keeper gateway for apps that only speak HTTP. + +### 5.2 A film, pay-per-view, no server in the path + +Publisher: an independent distributor on bittorrented.com. Goal: viewers +watch in the browser, nobody proxies the bytes. + +```bash +ip video publish ./film.mov --ladder default --on c0mpute --max-price 6.00 +ip file pin --title sha256: --days 180 --min-peers 5 +``` + +Four `ipvideo.transcode` jobs run on GPU nodes; each rendition is added as +an `ipfile` from the worker and seeded there. The bittorrented.com player +(WebTorrent plus the `ipfile` extension plus MSE) buys a pass in the page +with CoinPay Wallet, fetches pieces from WebRTC-capable seeders, decrypts, +plays. The distributor's share arrives daily. + +### 5.3 A live game with paid relays + +Publisher: tipoffwatch.com carrying a stream it has the rights to relay. + +```bash +ip live create tipoff-court1 --latency low --per-gib 0.02 --key-price 1.00 +ip live relay-hire ed25519:<channel key> --hours 3 --relays 6 --max-downstream 16 --max-price 4.00 +ip live start tipoff-court1 --input srt://:9000 --record +``` + +Six `iplive.relay` jobs win. Viewers buy a 1 USD key and stream; each +viewer pulls from two relays; relays earn 35 percent of vouchers. When +the game ends the recording is published as an `ipvideo.title` +automatically and the same pass, scoped to the publisher, plays it. + +### 5.4 Software release distribution, free to download + +Publisher: a vendor shipping a 4 GB installer. Goal: downloads free, +seeding paid by the vendor, nothing on a CDN. + +```bash +ip file add ./installer-4.2.0.dmg --public --per-gib 0 --key-price 0 +ip file pin ed25519:<file key> --days 365 --min-peers 10 --erasure 10,4 +``` + +`--public` sets `keys.public` so any peer decrypts. The vendor pays ten +pins at the storage rate; downloaders pay nothing; vanilla BitTorrent +clients with the manifest's key can also decrypt with a small tool. The +BEP 46 pointer under the file key is a permanent "latest version" link: +`ip file get ed25519:<file key>` always fetches the current release. + +### 5.5 Encrypted off-site backup + +Publisher: a small company. Goal: 200 GB held on three independent nodes, +readable by nobody else. + +```bash +ip file add ./backup-2026-09.tar --standalone-key --private --per-gib 0.01 --key-price 0 +ip file pin ed25519:<file key> --days 30 --min-peers 3 --erasure 10,4 +``` + +`--private` sets `swarm.private` (no DHT, no PEX) and `visibility: pass`. +No grant is ever issued to anyone but the owner; `keys.owner` in the +manifest plus the standalone seed in the company's OpenCreds vault is the +recovery path. Pins are verified by storage challenges. Restore is `ip +file get` with the seed loaded. + +### 5.6 A dataset sold to agents by the gigabyte + +Publisher: a data vendor. Buyer: a crawler or an AI agent with an x402 +wallet. Goal: sell to bots, the same motivation as the crawl paywall. + +```bash +ip file add ./corpus-2026q3.parquet --per-gib 0.50 --key-price 25.00 +``` + +```bash +# the buyer, unattended +x402 pay "https://coinpayportal.com/api/openswarm/passes?payer=<key>&file=<file key>&cap=60&grant=1" +ip file get ed25519:<file key> --pass ./pass.json --out ./corpus.parquet +``` + +`@profullstack/x402-client` buys the pass exactly as it buys a crawl pass. +The agent then fetches 100 GB from whichever nodes pinned it, paying 50 USD +in vouchers as it goes. Seeders that pinned the dataset earn 25 percent of +that; the vendor earns 70. + +### 5.7 An album with a royalty split + +Publisher: a label. Goal: three contributors paid automatically. + +```bash +ip audio publish ./album/*.flac --release ./album.json --renditions flac,opus-160 \ + --royalty 0xCC3b...41E5:6000 --royalty 0x7E5F...5Bdf:3000 --royalty 0x1234...abcd:1000 +``` + +Every voucher's publisher share is split 60/30/10 by the hub after it looks +up the track record in `ipdb`. No play counts, no statements: a payout per +day per address. + +### 5.8 Running an index for a label's catalogue + +Operator: a c0mpute node with a public IP. Buyer: the label. + +```bash +c0mpute worker start --roles gateway --openswarm +ip db index-hire ed25519:<label key>/default ed25519:<label key>/releases --days 30 --max-price 1.00 +``` + +The node follows both feeds and answers queries at +`https://<node>/db/query`. The label's `ipname` record names the node as +its gateway. bittorrented.com's `/dht` page can query it to show what an +`ipfile` infohash it crawled actually is. + +### 5.9 Browser-only listening on bittorrented.com + +No node, no install. The site loads WebTorrent, `wire.use(ipfile)`, a +WebCrypto decryptor and an `<audio>` element fed by MSE. The listener buys +a pass with CoinPay Wallet through the x402 browser flow. Everything else +is peers. The hybrid Node seeder media-streamer already runs is the one +peer that is always there. + +### 5.10 A creator seeding their own work from a laptop + +```bash +ip init +ip file add ./set.mp3 --per-gib 0.01 --key-price 0.50 +ip file seed --all +``` + +No c0mpute at all. The laptop seeds, gets paid by vouchers, and goes to +sleep at night. Listeners who fetched it keep seeding if they leave the +app open, and they are paid too. Pins on c0mpute are what the creator buys +when the laptop is not enough. + +### 5.11 Two organisations exchanging files over a post-quantum link + +Both run native nodes with Moshpit identities and names. + +```bash +ip name set chovy.hacker --gateway https://gw.example --mtp-pin <pin> +ip file add ./contract-bundle.zip --private --per-gib 0 +ip file share ed25519:<file key> --to ip://preshy.nigeria --grant +ip file get ip://chovy.hacker/default/file:ed25519:<file key> --tunnel mtp +``` + +`ip file share` seals a grant to the counterparty's box key from its +`ipname` record and sends it out of band. `--tunnel mtp` runs the +BitTorrent connection through an MTP/1 session pinned to the counterparty's +`mtp` pin. Nothing in the transfer is decryptable by a future quantum +adversary who recorded the link. + +### 5.12 Enriching the DHT crawl + +bitmagnet behind bittorrented.com/dht records every infohash it sees. An +`ipfile` swarm appears as a one-file torrent named by a hex string. With an +`ipdb` replica, `/dht/<infohash>` can look up `swarm.infohashV1` across +followed feeds and render the manifest's `name`, the track or title record, +the price and a play button, instead of "unknown, 734 MB". A feed the crawl +has never seen stays opaque, which is the privacy property working as +designed. + +### 5.13 A keeper for a publisher who is not always online + +```bash +ip file keep-hire ed25519:<file key> --days 90 --max-price 0.30 +``` + +The winning node receives a delegating grant sealed to its box key, +answers `key_req` in the swarm and serves the key URL. It earns +`keeperBps` of every `keyUsd`. The publisher's laptop can be off; buyers +still get keys. + +## 6. What changes in c0mpute + +Proposed, not built: + +1. An `openswarm` plugin (`plugins/openswarm/module.toml`) declaring the + seven workload types in §3 with `mode = "in-process"` for pin, keep, + gateway, relay and index, and `mode = "subprocess"` wrapping the `ip` + CLI for the two transcodes until they are ported. +2. Capability tags `c0mpute:openswarm:*` in `capabilities.rs`, gated by + `--openswarm` flags on `worker start`. +3. A BitTorrent peer in the node: a Rust client that speaks BEP 3, 10, 52, + BEP 44 and the `ipfile`, `ipdb`, `iplive` extensions, with WebRTC via + the same `node-datachannel` approach media-streamer uses in Node, so + browsers can reach it. +4. The chunk store gains a piece index: `(infohashV2, pieceIndex) -> + blake3 chunk`, so a swarm piece is served from the existing store and + erasure shards are reconstructed on demand. +5. `c0mpute-gateway` gains `/swarm/<infohashV2>/{manifest,layer,data}`, + `/db/query`, `/audio/...` and `/video/...` bridge routes with the + `ippay` §7 headers, next to the existing `/chunks/<hash>`. +6. The verifier's storage challenge accepts an `ipfile` manifest and + challenges random pieces by SHA-256 piece root rather than blake3. +7. The status contract (`/api/status`) gains `openswarm: { swarms_seeded, + bytes_served_24h, vouchers_redeemed_24h }`. + +None of this changes the auction, escrow, DID or reputation formula. + +## 7. Open questions + +- Whether `ipfile.pin` should be priced by the existing $0.008 per GB-month + rate automatically, with the buyer setting only `days`. +- Whether the verifier should also spot-check that a pinned node actually + serves (a paid fetch by the verifier, refunded by the hub) rather than + only that it holds. +- Whether c0mpute wants to be a hub itself, so a node's job income and + voucher income arrive on one statement. diff --git a/docs/openswarm/cli.md b/docs/openswarm/cli.md new file mode 100644 index 0000000..b145352 --- /dev/null +++ b/docs/openswarm/cli.md @@ -0,0 +1,170 @@ +# The `ip` CLI (proposed) + +Version: **0.1** (draft) +Status: proposal. No binary exists. Names and flags here are the contract +an implementation would meet; they are not a promise that this is the +final surface. + +## 1. Shape + +One binary, `ip`, with a noun-verb tree. Every command accepts `--json` +for machine output and returns exit code 0 on success, 2 on a usage error, +3 on a verification failure (signature, hash, chain), 4 on a payment +failure (no pass, cap reached, hub refused), 5 on a network failure. + +Configuration lives at `~/.config/ip/config.json`; the keystore at +`~/.config/ip/keys/` with mode 0600, or in an OpenCreds vault when +`ip init --vault` is used. The local `ipdb` replica is +`~/.local/share/ip/db.sqlite`. + +## 2. Commands + +### 2.1 Identity + +``` +ip init [--vault] [--name <label>] +ip key show [--box] +ip key export --out <file> # the seed, encrypted with a passphrase +ip key import <file> +ip key rotate # mints a new seed, revises every file (core §4.5) +ip key bind-pq <mldsa65 key file> # publishes an openswarm.binding +``` + +### 2.2 Files + +``` +ip file add <path> [--per-gib <usd>] [--key-price <usd>] [--public] [--private] + [--standalone-key] [--piece-length <bytes>] [--tracker <url>]... [--webseed <url>]... + [--hub <url>]... [--pay-to <network>:<address>] [--split <pub>,<seed>,<hub>] + [--keeper <ed25519:...>]... [--meta.<k> <v>]... [--feed <name>] +ip file get <file key | infohash | ip:// url | magnet> --out <path> [--pass <file>] [--tunnel mtp] [--stream] +ip file revise <file key> [--per-gib ...] [--tracker ...] [--pay-to ...] # metadata-only revision +ip file reencrypt <file key> # new content key, new swarm +ip file rotate <file key> # successor file key +ip file seed [<file key>... | --all] [--vanilla ciphertext|deny] +ip file share <file key> --to <ip:// name | x25519 key> [--grant] [--delegate --expires <days>] +ip file pin <file key> --days <n> [--min-peers <n>] [--erasure <k>,<p>] [--max-price <usd>] +ip file keep-hire <file key> --days <n> [--max-price <usd>] +ip file status <file key> # manifest, revs, peers seen, bytes served, earnings +``` + +`ip file add` prints the file key, both infohashes, the manifest id and +the `magnet:` and `ip://` forms. With `--json` it prints the manifest. + +### 2.3 Payment + +``` +ip pass buy --hub <url> (--file <key> | --publisher <key>) --cap <usd> [--grant] [--days <n>] +ip pass list +ip pass show <pass id> +ip payee register --hub <url> --pay-to <network>:<address> +ip payee balance [--hub <url>] +ip voucher redeem [--all | --swarm <infohash>] # normally automatic; manual for audit +ip hub info <url> +``` + +`ip pass buy` performs the x402 exchange with the key from +`X402_PRIVATE_KEY` or `--key-file`, the same as `x402 pay`. + +### 2.4 Audio + +``` +ip audio publish <track.json | source files...> [--release <release.json>] [--renditions <ids>] + [--on c0mpute --max-price <usd>] [--royalty <address>:<bps>]... [file add flags] +ip audio play <track id | ip:// url> [--rendition <id>] +ip audio rss <release id> --gateway <url> --out <feed.xml> +``` + +### 2.5 Video + +``` +ip video publish <source> [--ladder default | <ids>] [--subtitle <lang>:<file.vtt>]... + [--thumbnails] [--chapters <file>] [--on c0mpute --max-price <usd>] [file add flags] +ip video play <title id | ip:// url> +ip video hls <title id> --gateway <url> # prints the master playlist URL +``` + +### 2.6 Live + +``` +ip live create <name> [--latency normal|low] [--segment-ms <n>] [--per-gib <usd>] [--key-price <usd>] + [--rendition <id>:<codecs>:<w>x<h>:<kbps>]... [--relays any | <keys>] +ip live start <name> --input <rtmp:// | srt:// | file> [--record] [--max-downstream <n>] +ip live stop <name> +ip live watch <channel key | ip:// url> [--rendition <id>] [--out <file>] +ip live relay <channel key> [--max-downstream <n>] +ip live relay-hire <channel key> --hours <n> --relays <n> [--max-downstream <n>] [--max-price <usd>] +``` + +### 2.7 Catalogue + +``` +ip db put <key> <record.json> [--feed <name>] +ip db del <key> [--feed <name>] +ip db get <ip:// url | feed/key> +ip db query [--type <t>] [--feed <ref>]... [--where '<path> <op> <value>']... [--order <path>:asc|desc] [--limit <n>] +ip db follow <feed ref> +ip db unfollow <feed ref> +ip db head [<feed ref>] +ip db segment [--feed <name>] # seal a segment now +ip db index-hire <feed ref>... --days <n> [--max-price <usd>] +``` + +`--where` takes one clause per flag; the value is JSON (`'"Ada"'`, `120000`). + +### 2.8 Names + +``` +ip name set <name> [--gateway <url>] [--hub <url>]... [--mtp-pin <pin>] [--feed <ref>]... +ip name resolve <name> +ip name pin <name> # prints the registry pin value and the TXT record +``` + +### 2.9 Node + +``` +ip node status +ip node hello <peer address> # diagnostic: handshake and print the peer's hello +``` + +A c0mpute worker embeds the same library; `c0mpute worker start --openswarm` +is the daemon form and `ip` is the operator's tool. + +## 3. Output examples + +``` +$ ip file add ./interview.flac --per-gib 0.01 --key-price 0.50 +file ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d +manifest sha256:41d10f45e705e0526c9eeedf62b32dda3daaf552dd9b6a7b09e01776b05813bb +infohash v1 a3ce2180413415d7cf4268fb892b8ffd539e8459 + v2 4b74eb43677e4d03af5fb0856333f9aa21d9a5a3bbf944b13aa7eef379c7a342 +size 734003200 (700 pieces of 1048576) +magnet magnet:?xs=urn:btpk:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d&s=ipfile +url ip://ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d?file +seeding yes (2 trackers, dht) +``` + +``` +$ ip file get ed25519:0d87e0...295d --out ./interview.flac +pass reused sha256:39655d...02fd (cap 2.000000, spent 0.000000) +peers 3 (2 paid, 1 gateway) +progress 734003200 / 734003200 verified plainRoot ok +vouchers 3 signed, 0.006836 USD +key granted by ed25519:d2d05f...6c94 (keeper) +``` + +## 4. Environment + +| Variable | Meaning | +| --- | --- | +| `IP_HOME` | Overrides the config directory. | +| `IP_HUB` | Default hub URL. | +| `X402_PRIVATE_KEY` | EVM key used to buy passes, shared with `x402-client`. | +| `IP_TRACKERS` | Comma list of default trackers for `file add`. | +| `MOSHPIT_RESOLVE_MODE` | Passed through to name resolution (`ipname` §4.2). | + +## 5. Conformance + +A CLI claiming this contract implements every command in §2 with these +exit codes, prints the fields in §3 for `add` and `get`, and never writes a +seed or a content key to stdout unless asked with an explicit `--reveal`. diff --git a/docs/openswarm/conformance.md b/docs/openswarm/conformance.md new file mode 100644 index 0000000..089d2cc --- /dev/null +++ b/docs/openswarm/conformance.md @@ -0,0 +1,157 @@ +# OpenSwarm Conformance + +Version: **0.1** (draft) + +Conformance is claimed per **profile**. An implementation names the +profiles it meets; it does not have to meet all of them. Each profile is +a checklist over the member documents plus the fixture suite that will +ship with the reference implementation. + +## 1. Profiles + +| Profile | Who | Documents | +| --- | --- | --- | +| `core` | Anything that reads or writes records | spec.md | +| `publisher` | Creates manifests, tracks, titles, channels, feeds | spec, ipfile §3 and §8, ipdb §4, plus the media documents it emits | +| `seeder` | Serves `ipfile` pieces for payment | spec, ipfile §5 and §6, ippay §4.4 | +| `leecher` | Fetches and pays | spec, ipfile §5 and §10, ippay §3 and §4 | +| `keeper` | Grants keys | ipfile §5.5, ippay §5.4 | +| `gateway` | HTTP webseed, bridges, queries | ipfile §6.4, ippay §7, ipaudio §7, ipvideo §5, ipdb §6.3 | +| `hub` | Sells passes, redeems vouchers, pays out | ippay §3, §5, §6 | +| `origin` | Runs a live channel | iplive §3, §4, §6 | +| `relay` | Relays live segments | iplive §4, §5 | +| `viewer` | Watches live | iplive §7 | +| `replica` | Holds and queries feeds | ipdb §3, §5, §6 | +| `resolver` | Resolves names | ipname §4 | +| `cli` | The `ip` command | cli.md | + +## 2. Checklists + +### 2.1 `core` + +- [ ] C1. Canonicalises with RFC 8785 and computes record ids per spec §3.3. +- [ ] C2. Verifies `ed25519` signatures with the domain prefix of spec §3.4. +- [ ] C3. Ignores `mldsa65` entries it cannot verify without failing the record. +- [ ] C4. Preserves unknown fields on round trip. +- [ ] C5. Derives file, feed, channel and box keys per spec §4 so that the + fixture seed produces the fixture public keys. +- [ ] C6. Computes BEP 52 pieces roots and hybrid infohashes per spec §5 + matching the fixture torrent. +- [ ] C7. Reads and writes the BEP 44 pointer of spec §7.2. + +### 2.2 `publisher` + +- [ ] P1. Emits `ipfile.manifest` with both signatures, correct roots and a + `keys.owner` the fixture seed can open. +- [ ] P2. Revisions increase `rev`, keep `fileId`, and metadata-only + revisions keep `cipherRoot` and `keyId`. +- [ ] P3. Appends an `ipdb.entry` for every record it creates, with no + sequence gap. +- [ ] P4. Media records reference only files it also published, with + matching `size`. +- [ ] P5. Audio and video renditions are aligned (ipaudio §4.3, ipvideo §3.1). + +### 2.3 `seeder` + +- [ ] S1. Sends `hello` before any other `ipfile` message and refuses + messages from a peer that has not. +- [ ] S2. Keeps a peer choked until a valid pass arrives, or treats it as + vanilla per `policy.vanilla`. +- [ ] S3. Never exceeds the credit window unpaid; sends `credit` when it + chokes for that reason. +- [ ] S4. Validates vouchers per ippay §4.4 including recomputing `usd`. +- [ ] S5. Rejects a voucher not signed to its own identity. +- [ ] S6. Serves the plaintext piece layer on `layer_req`. +- [ ] S7. Redeems the latest voucher per triple at the hub. + +### 2.4 `leecher` + +- [ ] L1. Verifies the manifest against the file key it started from. +- [ ] L2. Buys or reuses a pass in scope before sending `pass`. +- [ ] L3. Signs vouchers only for verified bytes, on the interval, with + cumulative `bytes` and correct `usd`. +- [ ] L4. Verifies the piece layer against `plainRoot` before trusting an + entry; verifies every decrypted piece. +- [ ] L5. Checks `sha256(key) == keyId` on every grant. +- [ ] L6. Sends `bye done`. + +### 2.5 `keeper` + +- [ ] K1. Grants only against a valid pass with the file in `grants`, or a + delegating grant. +- [ ] K2. Seals to the requester's `box` from its `hello`. +- [ ] K3. Reports every grant to the hub. + +### 2.6 `gateway` + +- [ ] G1. Serves `/swarm/<infohashV2>/{manifest,layer,data}` with Range. +- [ ] G2. Applies the pass and voucher headers of ippay §7 and returns + `X-OpenSwarm-Credit` on every 206. +- [ ] G3. If a keeper: audio and video bridges decrypt only for a valid pass. +- [ ] G4. If a replica: answers `POST /db/query` with `asOf`. + +### 2.7 `hub` + +- [ ] H1. Publishes a signed hub record at the well-known path. +- [ ] H2. Sells passes over x402 with the CoinPay v2 offer, binds the payer + by `X-OpenSwarm-Payer`, and records nonce to resource. +- [ ] H3. Redeems vouchers with the arithmetic of ippay §4.2 and §5.3 + exactly; the fixture receipts match byte for byte. +- [ ] H4. Enforces the cap per pass across payees and returns `partial` and + `cap-exceeded` correctly. +- [ ] H5. Records grants once per pass and file. +- [ ] H6. Pays out on schedule and emits the events of ippay §8. + +### 2.8 `origin`, `relay`, `viewer` + +- [ ] O1. Signs a head per announced unit, chained by `prev`. +- [ ] O2. Rotates the content key per epoch and announces one segment early. +- [ ] O3. Writes a final head with `live: false`. +- [ ] R1. Verifies a segment root against a signed head before forwarding. +- [ ] R2. Honours `window` and never serves below `live - buffer`. +- [ ] R3. Answers `deny rate` with the current head when full. +- [ ] V1. Drops to the live edge rather than stalling. +- [ ] V2. Vouchers every peer that served it. +- [ ] V3. Requests a new grant on `keyId` change. + +### 2.9 `replica` + +- [ ] D1. Verifies chains and embedded signatures; refuses a `subject` + mismatch. +- [ ] D2. Detects forks and freezes state until resolved. +- [ ] D3. Replicates by segments and gossip. +- [ ] D4. Implements every query operator of ipdb §6.1. + +### 2.10 `resolver` + +- [ ] N1. Applies clearnet-first before any registry call. +- [ ] N2. Verifies the pin, the signature and the `name` match. +- [ ] N3. Reports the anchor kind. + +## 3. Fixture suite + +The reference implementation will publish, under `packages/openswarm/ +fixtures/`, a deterministic set built from one seed: + +| Fixture | Contents | +| --- | --- | +| `seed.json` | A 32-byte seed and every public key derived from it. | +| `file-small/` | A 3 MiB plaintext, its content key, ciphertext, piece layers, hybrid `.torrent`, and manifest. | +| `records/` | One valid record of every registered type, plus one invalid variant each (bad signature, bad chain, bad `usd`). | +| `session.jsonl` | A scripted `ipfile` session: handshake dicts, `hello`s, pass, three vouchers, grant, in order. | +| `receipts.json` | The receipts a hub must return for `session.jsonl`. | +| `queries.json` | Queries over `records/` and their exact results. | +| `names.json` | Registry responses and the expected resolutions, including a pin mismatch. | + +A profile is conformant when every checklist item is met and the relevant +fixtures pass. Until the reference implementation exists, the checklists +are the whole of conformance, and a claim is a claim. + +## 4. Interoperability + +As with OpenCreds, a round trip inside one implementation proves only that +the code agrees with itself. The interop test that matters is: a swarm +seeded by implementation A, paid for by a leecher from implementation B, +with vouchers redeemed at hub C, decrypts to `plainRoot`. The first three +implementations to exist should be tested that way before any of them +claims 1.0. diff --git a/docs/openswarm/faq.md b/docs/openswarm/faq.md new file mode 100644 index 0000000..ee23321 --- /dev/null +++ b/docs/openswarm/faq.md @@ -0,0 +1,89 @@ +# OpenSwarm FAQ + +**Is this IPFS?** +No. IPFS is content addressing plus a DHT plus its own wire protocol, with +no payment and no encryption. OpenSwarm is BitTorrent plus payment plus +encryption, carried as BEP 10 extension messages, so every existing +torrent client, tracker and crawler still understands the swarm. A file's +identifier is a BEP 52 pieces root rather than a CID because that is the +tree a BitTorrent v2 client already verifies. + +**Why not a blockchain, a token or a payment channel?** +Because a 16 KiB block at the reference price is worth 0.00000015 USD and +no chain settles that. Passes are bought on chain once (x402, USDC); +vouchers are off-chain IOUs redeemed at a hub; payouts are batched. The +hub is trusted like a payment processor is trusted, and the spec says so +rather than pretending otherwise. + +**Who runs a hub?** +Anyone. CoinPay is the proposed reference because it already does the x402 +verify and settle half. A manifest lists the hubs its publisher accepts, +and a seeder refuses passes from any other. Two hubs on a manifest is the +recommended posture. + +**Can a seeder get paid for bytes it did not send?** +No. A voucher is signed by the payer for bytes the payer already verified. + +**Can a leecher get bytes without paying?** +One credit window's worth, per identity, per session. Then it is choked. +That is the design: bounded loss instead of a trustless protocol nobody +would implement. + +**What happens to a vanilla client that finds the swarm?** +It sees a one-file torrent named by a hex string. If the publisher set +`policy.vanilla` to `ciphertext`, it can download the ciphertext for free +and cannot read it. If `deny`, it is choked. It never receives an +extension message. + +**Does this work in a browser?** +Yes. WebTorrent supports BEP 10 extensions, WebCrypto has Ed25519, X25519 +and AES-CTR, and MSE plays CMAF. bittorrented.com's player plus three +additions is the reference browser peer. A browser can only reach WebRTC +peers, so a swarm needs one hybrid seeder that announces to a wss tracker. + +**Why derive file keys from one publisher key?** +So there is one thing to back up. A publisher seed plus any manifest +recovers that file's private key and its content key. A standalone key is +available for a file that will change hands. + +**What if I lose the seed?** +Nothing becomes unreadable and nothing can be changed: prices, payout +address and trackers are frozen at the last signed manifest. Store the +seed in an OpenCreds vault. + +**Can a key be revoked after delivery?** +No. A delivered key is held by the peer. Re-encryption under a new key +protects future readers only. Live streams rotate keys every epoch for +exactly this reason. + +**Why AES-CTR and not an AEAD?** +Length-preserving and seekable: a block decrypts without the rest of the +piece. Integrity comes from two signed merkle trees, which is stronger than +a per-piece tag because it is bound to the publisher's signature. + +**How does a Moshpit name fit?** +A name's owner registers a pin of kind `openswarm` over its publisher key +and serves a signed record at `/.well-known/openswarm.json`. A resolver +checks the pin, then the signature. Ordinary domains use a TXT record. +Clearnet wins by default, as everywhere in Moshpit. + +**How does c0mpute fit?** +A node bids on pin, keep, gateway, transcode, relay and index jobs, and is +paid by vouchers for every byte it serves regardless of jobs. See +[c0mpute.md](./c0mpute.md). + +**Is the catalogue private?** +A feed is public by construction. A private catalogue is a feed segment +published as a paid `ipfile`, readable only by pass holders. + +**Why is the family called OpenSwarm and the protocols `ip*`?** +The family name follows the LogicSRC convention and names the shared +object. The protocol names are the ones the tools will be called by. The +working name was IPMedia; see the overview for why it changed. + +**What exists today?** +The x402 sale and CoinPay settlement, the browser player and hybrid seeder, +the DHT crawl, CloudEvents and Standard Webhooks, the MTP/1 transport, the +Moshpit registry, and c0mpute's store, transcode, gateway and live-stream +design. Everything that mentions a pass, a voucher, a grant, a manifest, a +feed or a head is new. diff --git a/docs/openswarm/ipaudio.md b/docs/openswarm/ipaudio.md new file mode 100644 index 0000000..aaefc24 --- /dev/null +++ b/docs/openswarm/ipaudio.md @@ -0,0 +1,313 @@ +# ipaudio: Audio on ipfile Swarms + +Version: **0.1** (draft) +Status: draft. Extends the OpenSwarm core ([spec.md](./spec.md)) and rides +on [ipfile](./ipfile.md), [ippay](./ippay.md) and [ipdb](./ipdb.md). +Nothing below is implemented. + +## 1. Scope + +`ipaudio` defines the records that make an `ipfile` playable as music or +speech: + +1. A **track**: renditions (each one an `ipfile`), a seek index, gapless + trim values, contributors and royalty split. +2. A **release**: an ordered set of tracks with artwork and release metadata. +3. **Streaming rules**: how a player maps time to pieces, buffers, switches + bitrate and plays two tracks without a gap. +4. A **gateway bridge** for players that only speak HTTP. + +Everything about bytes, keys, payment and discovery is `ipfile` and `ippay`. +This document adds no wire messages. + +### Non-goals + +A player. Loudness normalisation. A recommendation system. DRM beyond +`ipfile` key delivery. Radio (a continuous stream is `iplive`). + +## 2. Terminology + +| Term | Meaning | +| --- | --- | +| **rendition** | One encoding of a track, stored as one `ipfile`. | +| **anchor** | A `[timeMs, byteOffset]` pair at which decoding can start. | +| **seek index** | The list of anchors for a rendition. | +| **gapless trim** | Encoder delay and end padding in samples, so tracks join without silence. | + +## 3. Data model + +### 3.1 Track + +```json +{ + "openswarm": "0.1", + "type": "ipaudio.track", + "publisher": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "title": "Interview with Ada", + "artist": "Ada", + "artists": [{ "name": "Ada", "role": "primary" }], + "durationMs": 2941200, + "isrc": null, + "language": "en", + "artwork": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94", + "renditions": [ + { + "id": "opus-96", + "codec": "opus", + "container": "ogg", + "bitrateKbps": 96, + "sampleRate": 48000, + "channels": 2, + "file": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "size": 35294400, + "gapless": { "encoderDelay": 312, "endPadding": 1216 }, + "index": [[0, 0], [2000, 24012], [4000, 48160], [6000, 72301]] + }, + { + "id": "flac", + "codec": "flac", + "container": "flac", + "bitrateKbps": 0, + "sampleRate": 48000, + "channels": 2, + "file": "ed25519:00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be", + "size": 734003200, + "gapless": { "encoderDelay": 0, "endPadding": 0 }, + "index": [[0, 8192], [2000, 507904], [4000, 1007616], [6000, 1503232]] + } + ], + "royalties": [ + { "label": "Ada", "network": "eip155:8453", "address": "0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5", "bps": 8000 }, + { "label": "Producer", "network": "eip155:8453", "address": "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", "bps": 2000 } + ], + "createdAt": "2026-09-05T18:05:00.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +(`index` is truncated to four anchors here. A real one has one anchor per +two seconds of audio.) + +| Field | Rule | +| --- | --- | +| `publisher` | Signs the record. MUST equal the `publisher` of every referenced `ipfile` manifest. | +| `durationMs` | Of the plaintext audio. | +| `artwork` | File key of an `ipfile` holding a JPEG or PNG. MAY be null. | +| `renditions[].codec` | One of `opus`, `aac`, `flac`, `mp3`. | +| `renditions[].container` | `ogg` for Opus, `mp4` for AAC (fragmented, CMAF), `flac`, `mp3`. | +| `renditions[].bitrateKbps` | Target bitrate; `0` for lossless. | +| `renditions[].file` | The `ipfile` manifest's file key. | +| `renditions[].size` | MUST equal that manifest's `size`. | +| `renditions[].gapless` | Samples to trim at the start and end, as the encoder reported. `0` for FLAC. | +| `renditions[].index` | Anchors, ascending by time, first at `[0, o]` where `o` is the byte offset of the first decodable unit after headers. | +| `royalties` | How the publisher's share of every voucher is split (§5). | + +A track MUST have at least one rendition. Every rendition of a track MUST +have anchors at the same set of `timeMs` values (§4.3). + +### 3.2 Release + +```json +{ + "openswarm": "0.1", + "type": "ipaudio.release", + "publisher": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "title": "Conversations, Volume 1", + "artist": "Ada", + "kind": "podcast", + "releasedAt": "2026-09-05", + "upc": null, + "artwork": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94", + "tracks": [ + { + "position": 1, + "subject": "sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff", + "feed": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e/default" + } + ], + "description": "Long-form interviews.", + "createdAt": "2026-09-05T18:06:00.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +`kind` is one of `album`, `ep`, `single`, `podcast`, `audiobook`, +`compilation`. `tracks[].subject` is the record id of the track's first +revision, which is also its `ipdb` key (`track:<id>`), so a track can be +revised without breaking the release. + +### 3.3 Where records live + +Both records are `ipdb` entries in the publisher's feed under +`track:<id>` and `release:<id>`. The renditions are `ipfile` manifests under +`file:<file key>` in the same feed. A client that finds a release has +everything it needs in one feed. + +## 4. Streaming + +### 4.1 Time to pieces + +To play from time `t` on rendition `r`: + +``` +anchor = the last index entry with timeMs <= t +offset = anchor.byteOffset +piece = floor(offset / manifest.pieceLength) +``` + +Request pieces from `piece` onward in order. Decrypt each as it completes +(`ipfile` §4.1), verify against the plaintext piece layer, and feed the +decoder from `offset`. The first decodable unit is guaranteed at the anchor +because the index was built from container boundaries (Ogg page, MP4 +fragment, MP3 frame, FLAC frame). + +### 4.2 Buffering + +A player SHOULD hold the pieces covering the next 30 seconds and request +the next piece when the buffer drops below 15 seconds. It SHOULD send +vouchers on the `ipfile` schedule regardless of playback state; a paused +player that has received bytes still owes for them. + +With 256 KiB pieces and a 96 kbps rendition a piece is about 21 seconds of +audio. That is why `ipaudio` recommends 256 KiB: one piece is one buffer +step. + +### 4.3 Adaptive bitrate + +Because every rendition shares the same anchor times, a switch is: finish +the current anchor interval on rendition A, start the next interval on +rendition B from its anchor at the same `timeMs`. The publisher guarantees +alignment by encoding every rendition with the same segment duration +(2000 ms recommended). A player picks the highest `bitrateKbps` whose piece +download rate over the last 10 seconds exceeds 1.5 times the bitrate. + +Switching renditions means joining a second swarm. The pass is per file, so +a pass scoped by `publishers` rather than `files` is what a player wants +(`ippay` §3.1). + +### 4.4 Gapless + +When track A ends and track B begins, the player trims `endPadding` samples +from A's decoded tail and `encoderDelay` samples from B's decoded head, and +concatenates the sample streams. It MUST have B's first piece decrypted +before A's last anchor interval starts, which means it holds a grant for B +already. A release player therefore requests the grant for track `n + 1` +when track `n` starts. + +## 5. Royalties + +An `ipfile` manifest names one `pricing.payTo` for the publisher's share. +`ipaudio` splits that share further with `royalties`: a list of recipients +and basis points summing to 10000. A hub applies the split when it can see +the track record for a file; the `ipfile` manifest's `meta.track` field +carries the track's record id so the hub can look it up in `ipdb`. Where +the hub cannot find the track, the whole publisher share goes to `payTo` +and a `com.logicsrc.openswarm.ippay.split.unresolved.v1` event is emitted. + +A contributor is paid per byte served of any rendition of the track. That +is the whole royalty model: no plays counted, no per-stream rate, no +minimum. It is a consequence of `ippay`, not a separate system. + +## 6. Publishing procedure + +`ip audio publish <track.json>` where the input names source files: + +1. For each rendition requested (`--renditions opus-96,flac`), transcode + with ffmpeg or submit an `ipaudio.transcode` job to c0mpute + (`c0mpute.md` §3). Record `encoderDelay` and `endPadding` from the + encoder. +2. Build the seek index by walking container boundaries at 2000 ms steps. +3. `ip file add` each rendition with `--piece-length 262144`, + `--meta.track <placeholder>`; collect file keys. +4. Write the track record, sign, `ip db put track:<id>`. Revise each + rendition manifest's `meta.track` to the track id (metadata-only revision). +5. Optionally write or update a release. + +## 7. Gateway bridge + +A gateway that is also a keeper (holds the content key under a delegating +grant) MAY serve decrypted audio to pass holders: + +``` +GET /audio/<track record id>/<rendition id> +Authorization: Bearer <base64url pass> +Range: bytes=0- +``` + +It returns `206` with the plaintext bytes, `Content-Type` from the +container, `Accept-Ranges: bytes`, and `X-OpenSwarm-Credit` per `ippay` §7. +This is what lets a podcast app, a car, or bittorrented.com's existing +`<AudioPlayer>` (which plays a plain URL) consume an `ipaudio` track. The +gateway is the payee for those bytes and is paid by voucher like any +seeder; the client is the pass holder's HTTP library, which sends vouchers +in `X-OpenSwarm-Voucher`. + +A gateway MAY also render an RSS 2.0 feed for a `podcast` release with +`<enclosure>` URLs of the form above, so a release is subscribable in any +podcast client that can send a bearer header, and previewable in any +client at all if the publisher sets `perGib` and `keyUsd` to zero. + +## 8. Recommended encodings + +| Rendition id | Codec | Container | Settings | +| --- | --- | --- | --- | +| `opus-64` | Opus | Ogg | 64 kbps VBR, 48 kHz, speech | +| `opus-96` | Opus | Ogg | 96 kbps VBR, 48 kHz | +| `opus-160` | Opus | Ogg | 160 kbps VBR, 48 kHz, music | +| `aac-128` | AAC-LC | fragmented MP4 | 128 kbps, for Safari and CarPlay | +| `flac` | FLAC | FLAC | Lossless, source sample rate | + +Ogg Opus and FLAC carry their own seek structure; the index is still +REQUIRED so a player never has to read the container to find a piece. + +## 9. Security and privacy + +Nothing beyond `ipfile`. Two notes: a rendition list reveals bitrates and +sizes to anyone who has the track record, and a seek index reveals the +container's page structure; neither reveals audio. A publisher who does +not want the catalogue public uses a paid `ipdb` segment (`ipdb` §9). + +## 10. Events + +| Type | Emitted by | +| --- | --- | +| `com.logicsrc.openswarm.ipaudio.track.published.v1` | publisher | +| `com.logicsrc.openswarm.ipaudio.release.published.v1` | publisher | + +Play counts are not events in this family. A client MAY report them to a +publisher's webhook as `com.logicsrc.openswarm.ipaudio.play.v1` with +`{ "track", "rendition", "ms" }`; nothing in the payment loop depends on it. + +## 11. Implementations + +| Piece | Exists | Where | What is new | +| --- | --- | --- | --- | +| Plain-URL audio player, range streaming, HLS fallback | Yes | `media-streamer` `<AudioPlayer>`, `/api/stream`, `/api/stream/hls` | The gateway bridge is what it would point at | +| Podcast catalogue and RSS | Yes | `p0dcasters.com`, `media-streamer` podcasts lib | Rendering RSS from a release record | +| Per-rendition transcode on the network | Yes (video) | `c0mpute` `ffmpeg.transcode` | An audio preset with gapless values in the result | +| Track and release records, seek index, gapless join, royalty split | No | | All of it | + +## 12. Conformance + +An `ipaudio` player maps time to pieces per §4.1, switches only at shared +anchors per §4.3, trims per §4.4, and holds a valid pass for every rendition +it fetches. A publisher emits records per §3 with aligned anchors. A gateway +implementing §7 applies `ippay` §7 headers. + +## 13. Version history + +| Version | Date | Change | +| --- | --- | --- | +| 0.1 | 2026-09-05 | Initial draft. | diff --git a/docs/openswarm/ipdb.md b/docs/openswarm/ipdb.md new file mode 100644 index 0000000..75292c7 --- /dev/null +++ b/docs/openswarm/ipdb.md @@ -0,0 +1,386 @@ +# ipdb: The Replicated Catalogue + +Version: **0.1** (draft) +Status: draft. Extends the OpenSwarm core ([spec.md](./spec.md)). Nothing +below is implemented. + +## 1. Scope + +`ipdb` is the catalogue: the place a client asks "what is this swarm", "what +has this publisher released", "what is in this playlist". It defines: + +1. A **feed**: a signed, hash-linked, append-only log owned by one key. +2. An **entry**: one log item that puts or deletes a record under a key. +3. **Heads**: how the latest state of a feed is announced on the DHT. +4. **Replication**: how feeds move between peers, as `ipfile` swarms and as + gossip on swarm connections. +5. **Queries**: a JSON query shape a local replica and a gateway both answer. + +It is a database of records, not of bytes. A manifest is in `ipdb`; the +file it describes is in an `ipfile` swarm. + +### Non-goals + +Multi-writer documents. A feed has one writer. Two people editing one +playlist is two feeds and a view over both, or a future CRDT record type. +Global search ranking. Consistency across feeds; each feed is consistent +with itself and that is all that is promised. + +## 2. Terminology + +| Term | Meaning | +| --- | --- | +| **feed** | A log identified by `(feed key, name)`. | +| **feed key** | An Ed25519 key. A publisher's default feed uses the publisher key itself. | +| **entry** | A record of type `ipdb.entry`, sequenced and chained. | +| **key** | A string unique within a feed that later entries overwrite. | +| **head** | The latest entry of a feed. | +| **segment** | A batch of entries stored as one file for replication. | +| **replica** | A peer holding some feeds, verified, queryable. | +| **follow** | A replica's decision to keep a feed current. | + +## 3. Data model + +### 3.1 Feed keys + +The default feed is the publisher key with name `default`. Named feeds +derive from the publisher seed the same way files do: + +``` +feedSeed = HKDF-SHA256(ikm = publisher seed, salt = utf8(name), info = "openswarm:feed:v1") +feed key = Ed25519 keypair from feedSeed +``` + +A feed reference is written `ed25519:<hex>/<name>`; the default feed is +`ed25519:<hex>/default`. Names match `^[a-z0-9][a-z0-9-]{0,63}$`. + +### 3.2 Entry + +```json +{ + "openswarm": "0.1", + "type": "ipdb.entry", + "feed": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "name": "default", + "seq": 12, + "prev": "sha256:ba8cd1a97b865a05cdc5840c63b086dd463624a831874c1ba0d7c4ce6f806f7a", + "op": "put", + "key": "file:ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "kind": "ipfile.manifest", + "subject": "sha256:41d10f45e705e0526c9eeedf62b32dda3daaf552dd9b6a7b09e01776b05813bb", + "record": { + "openswarm": "0.1", + "type": "ipfile.manifest", + "file": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "rev": 1, + "createdAt": "2026-09-05T18:00:00.000Z", + "sigs": [] + }, + "createdAt": "2026-09-05T18:00:05.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +(The embedded manifest is abbreviated here; in a real entry it is the +complete record with its signatures.) + +| Field | Rule | +| --- | --- | +| `feed`, `name` | The feed. The entry MUST be signed by `feed`. | +| `seq` | 1 for the first entry, then `prev.seq + 1`. | +| `prev` | Record id of entry `seq - 1`, or `null` for `seq` 1. | +| `op` | `put` or `del`. | +| `key` | The logical key. Convention: `<noun>:<identifier>`, e.g. `file:ed25519:...`, `track:sha256:...`, `playlist:my-mix`. | +| `kind` | The embedded record's `type`. Absent for `del`. | +| `subject` | The embedded record's id. Absent for `del`. A replica MUST recompute it and refuse a mismatch. | +| `record` | The complete embedded record. Absent for `del`. Its own signatures MUST verify. | + +An entry is at most 256 KiB. A record larger than that is published as an +`ipfile` and the entry embeds an `ipfile.manifest` pointing at it, with +`kind` naming the payload type in `record.meta.kind`. + +### 3.3 State + +The state of a feed is the map `key -> latest put record` after applying +entries in `seq` order, with `del` removing the key. A replica exposes both +the log (for audit and replication) and the state (for queries). A record +that was overwritten stays in the log; that is what makes a manifest +revision history free. + +Across feeds there is no merge. A query over several feeds returns matches +from each; the `feed` of every result is returned with it. + +### 3.4 Head + +```json +{ + "openswarm": "0.1", + "type": "ipdb.head", + "feed": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "name": "default", + "seq": 12, + "head": "sha256:41d10f45e705e0526c9eeedf62b32dda3daaf552dd9b6a7b09e01776b05813bb", + "segments": [ + { "from": 1, "to": 10, "file": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94" } + ], + "createdAt": "2026-09-05T18:00:05.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +`segments` lists the `ipfile` swarms that hold sealed ranges of the log +(§5.1). Entries after the last segment are fetched by gossip (§5.2). + +## 4. Publishing + +`ip db put <key> <record.json>` appends an entry. `ip file add` appends one +automatically under `file:<file key>` in the default feed. `ip audio +publish`, `ip video publish` and `ip live create` do the same for their +records. A publisher never edits a feed by hand; every tool that creates a +record appends it. + +Rules for the writer: + +- `seq` MUST be strictly sequential. A gap is a fork. +- Two entries with the same `seq` from the same feed are a **fork**. A + replica that observes a fork MUST keep both, mark the feed forked from that + `seq`, and stop applying either branch to state until the feed publishes + an `ipdb.entry` with `op: "put"`, `key: "ipdb:resolve"`, and a record + naming the surviving branch head. Forks happen when a seed is restored on + two machines; they are detected, not prevented. +- A `del` of a key that was never put is valid and does nothing. + +## 5. Replication + +### 5.1 Segments as ipfile swarms + +Every 1000 entries, or daily, the writer seals entries `from..to` as one +file: newline-delimited canonical JSON, one entry per line. It publishes the +file as an `ipfile` with `pricing.perGib` and `keyUsd` at `0.000000` and +`keys.public` set to the content key, so the swarm is readable by anyone +while staying an ordinary `ipfile` for seeders, gateways and c0mpute pin +jobs. The head lists the segment. Readers verify each line's chain as they +apply it, so a segment served by a stranger is safe. + +### 5.2 Gossip on swarm connections + +Peers that advertise `ipdb` in the BEP 10 handshake exchange heads on any +swarm connection: + +| `t` | Payload | Meaning | +| --- | --- | --- | +| `heads` | `{ "f": [ { "k": <32 bytes>, "n": string, "s": int } ] }` | Feeds I follow and the seq I have. At most 64 per message. | +| `entries_req` | `{ "k": <32 bytes>, "n": string, "from": int, "to": int }` | Send me entries in the range. At most 100. | +| `entries` | `{ "r": [bytes] }` | Entries as JSON, in seq order. | +| `head` | `{ "r": bytes }` | An `ipdb.head` record, when the sender has a newer one. | + +A peer sends `heads` once after `hello` and again whenever a followed feed +advances. A peer that receives a `heads` with a higher `s` for a feed it +follows sends `entries_req`. Gossip fills the tail after the last segment +and keeps replicas close to real time without any peer being special. + +### 5.3 DHT announcement + +The head is a BEP 44 mutable item (core §7.3): + +``` +k = feed key +salt = "ipdb:" + name +seq = head seq +v = bencoded { "h": <head record id, 32 bytes>, "s": <seq> } +``` + +A client with only a feed reference does `get` on the DHT, obtains `h`, +then asks any peer or gateway for the head record by id, then segments and +tail. A gateway also serves `GET /db/feeds/<key>/<name>/head`. + +### 5.4 Following + +`ip db follow ed25519:<hex>/default` marks a feed followed: the replica +keeps its head current by DHT polling (every 10 minutes, or on `head` +gossip), fetches segments, and applies entries. A c0mpute node with role +`index` follows every feed it is paid to follow (`c0mpute.md` §3). + +## 6. Queries + +### 6.1 Shape + +```json +{ + "type": "ipaudio.track", + "feeds": ["ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e/default"], + "where": [ + ["record.artist", "==", "Ada"], + ["record.durationMs", ">=", 120000] + ], + "orderBy": [["record.createdAt", "desc"]], + "limit": 20, + "cursor": null +} +``` + +| Field | Rule | +| --- | --- | +| `type` | The embedded record type to match. REQUIRED. | +| `feeds` | Feed references to search. `null` means every followed feed. | +| `where` | Conjunction of `[path, op, value]`. Paths dot into the entry; `record.` reaches the embedded record. | +| `orderBy` | List of `[path, "asc"|"desc"]`. | +| `limit` | 1 to 200. | +| `cursor` | Opaque, from a previous result. | + +Operators: `==`, `!=`, `<`, `<=`, `>`, `>=`, `in`, `contains` (array +membership or substring), `prefix`. No joins, no aggregation in 0.1; a +rating average is computed by the caller from the entries it gets back. + +### 6.2 Result + +```json +{ + "results": [ + { + "feed": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e/default", + "key": "track:sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff", + "seq": 14, + "subject": "sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff", + "record": { "openswarm": "0.1", "type": "ipaudio.track", "sigs": [] } + } + ], + "cursor": "eyJzZXEiOjE0fQ", + "asOf": { "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e/default": 27 } +} +``` + +`asOf` reports the head seq of each feed the answer was computed from, so a +caller can tell a stale replica from an empty one. + +### 6.3 Over HTTP + +`POST /db/query` on a gateway with the query as the body. A gateway MAY +require a pass (`Authorization: Bearer`) and MAY sell one over x402 for +queries the way a hub sells swarm passes; the reference price is +`0.000100` USD per query. A public gateway with no charge is the common case +and is what bittorrented.com's `/dht` browse would become for `ipfile` +swarms it can resolve. + +## 7. Record kinds carried + +`ipdb` carries any record type. These are the ones the family defines and +the keys they use: + +| Kind | Key convention | Defined in | +| --- | --- | --- | +| `ipfile.manifest` | `file:<file key>` | ipfile | +| `ipaudio.release` | `release:<record id of first revision>` | ipaudio | +| `ipaudio.track` | `track:<record id of first revision>` | ipaudio | +| `ipvideo.title` | `title:<record id of first revision>` | ipvideo | +| `iplive.channel` | `channel:<channel key>` | iplive | +| `ipname.pin` | `name:<moshpit name>` | ipname | +| `ipdb.playlist` | `playlist:<slug>` | this document §7.1 | +| `ipdb.rating` | `rating:<subject record id>` | this document §7.2 | + +### 7.1 Playlist + +```json +{ + "openswarm": "0.1", + "type": "ipdb.playlist", + "slug": "late-night", + "title": "Late night", + "items": [ + { "kind": "ipaudio.track", "subject": "sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff", "feed": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e/default" } + ], + "createdAt": "2026-09-05T19:00:00.000Z", + "sigs": [] +} +``` + +A playlist item references a record by id and the feed it was found in, so +a reader can fetch it even if the playlist author's replica is gone. The +media-streamer `collections` table maps onto this one to one. + +### 7.2 Rating + +```json +{ + "openswarm": "0.1", + "type": "ipdb.rating", + "subject": "sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff", + "value": 4, + "createdAt": "2026-09-05T19:05:00.000Z", + "sigs": [] +} +``` + +`value` is 1 to 5. One rating per subject per feed, by the key convention. +An aggregate is `query type ipdb.rating where record.subject == ...` over +the feeds a caller trusts; a gateway MAY cache the count and mean. + +## 8. Local storage + +A replica stores entries in a local database. The reference layout is +SQLite with tables `feeds(key, name, seq, head, forked_at)`, +`entries(feed, name, seq, id, prev, op, key, kind, subject, json)`, +`state(feed, name, key, seq, kind, subject)`, and one FTS table over +`record.title`, `record.artist`, `record.name` for the `contains` +operator. This is the layout the `ip` CLI would use; a gateway on Postgres +does the same with jsonb. Nothing in the wire depends on it. + +## 9. Security notes + +- **Authenticity.** Every entry is signed by the feed key and chained. A + replica cannot insert, reorder or drop an entry without breaking the chain + to the announced head. +- **Freshness.** The DHT head carries `seq`; BEP 44 refuses a lower `seq`. + A replica that shows an old head is stale, not lying, and `asOf` says so. +- **Embedded records** verify on their own signatures. A feed cannot put + someone else's manifest under its own key and have it trusted as theirs; + the manifest's `publisher` signature is the authority, the feed entry is + the announcement. +- **Spam.** A gateway that follows any feed anyone asks for will be filled. + Following is a decision; `c0mpute` index jobs are paid per followed feed + for that reason. +- **Privacy.** A feed is public by construction. A private catalogue is a + segment published as a paid `ipfile` (no `keys.public`), and the entries + are then readable only by pass holders. The head still reveals the seq. + +## 10. Events + +| Type | Emitted by | +| --- | --- | +| `com.logicsrc.openswarm.ipdb.entry.appended.v1` | writer | +| `com.logicsrc.openswarm.ipdb.head.published.v1` | writer | +| `com.logicsrc.openswarm.ipdb.fork.detected.v1` | replica | + +## 11. Implementations + +| Piece | Exists | Where | What is new | +| --- | --- | --- | --- | +| Infohash catalogue with browse and search | Yes (central) | `media-streamer` `dht_torrents`, `/api/dht/browse`, `services/dht-search-api` | Learning what an infohash is from a signed feed instead of BEP 9 names | +| Collections, playlists, favourites | Yes (central) | `media-streamer` `collections`, `collection_items` | The `ipdb.playlist` record as the portable form | +| Signed append-only log | Partly | Moshpit `moshpit_tld_log` (server side, `seq` cursor) | The feed as a client-verifiable chain with DHT heads | +| Entry, head, segments, gossip, query shape | No | | All of it | + +## 12. Conformance + +A replica verifies chains and embedded signatures per §3, detects forks +per §4, replicates by segments and gossip per §5, and answers every +operator in §6.1. A writer never emits a gap. A gateway serves `POST +/db/query` and the head route in §5.3. + +## 13. Version history + +| Version | Date | Change | +| --- | --- | --- | +| 0.1 | 2026-09-05 | Initial draft. | diff --git a/docs/openswarm/ipfile.md b/docs/openswarm/ipfile.md new file mode 100644 index 0000000..f4c3ac8 --- /dev/null +++ b/docs/openswarm/ipfile.md @@ -0,0 +1,545 @@ +# ipfile: Paid, Encrypted File Swarms + +Version: **0.1** (draft) +Status: draft. Extends the OpenSwarm core ([spec.md](./spec.md)). Nothing +below is implemented. + +## 1. Scope + +`ipfile` is BitTorrent with three additions carried as extension messages: + +1. **Encryption.** The swarm carries ciphertext. The content key is sold + separately and delivered only to a peer that has paid. +2. **Payment.** A seeder serves pieces inside a bounded credit window and a + leecher signs a cumulative voucher for every verified piece batch. Whoever + seeds gets paid. +3. **Ownership.** Every file has a key pair. The manifest that names the + swarm, the price and the payout address is signed by it. + +It is not a new transport. The wire is BEP 3, the extension is BEP 10, the +hashes are BEP 52, discovery is BEP 5 and BEP 44, browsers are WebTorrent. A +client that knows none of this can still join the swarm and, if the manifest +allows, download ciphertext it cannot read. + +### Non-goals + +Plaintext swarms (use a torrent). Hiding the size of a file. Hiding that a +swarm exists (use `swarm.private` to keep it off the DHT, but a tracker still +knows). Revoking a delivered key without re-encrypting. Streaming media +semantics (those are `ipaudio`, `ipvideo`, `iplive`). + +## 2. Terminology + +Core terms apply. In addition: + +| Term | Meaning | +| --- | --- | +| **manifest** | The `ipfile.manifest` record: identity, roots, swarm, price, key delivery, policy. | +| **revision** | A manifest with a higher `rev` under the same file key. | +| **keeper** | A peer holding the content key with a `delegate: true` grant, allowed to grant it onward. | +| **credit window** | Bytes a seeder will serve beyond the last voucher before choking. | +| **voucher interval** | Bytes after which a leecher owes a new voucher. | +| **vanilla peer** | A peer that did not advertise `ipfile` in its extension handshake. | + +## 3. Data model + +### 3.1 The manifest + +```json +{ + "openswarm": "0.1", + "type": "ipfile.manifest", + "file": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "box": "x25519:4c4055604fe6bd8781cab6835093ad896f68f676b472d5bd6811e52f28cb18c3", + "publisher": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "fileId": "165aa20f2088f71a1880fe42c48d211b", + "keyDerivation": "derived", + "rev": 1, + "name": "interview-2026-09-05.flac", + "size": 734003200, + "contentType": "audio/flac", + "pieceLength": 1048576, + "plainRoot": "sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff", + "cipherRoot": "sha256:ba8cd1a97b865a05cdc5840c63b086dd463624a831874c1ba0d7c4ce6f806f7a", + "cipher": { + "alg": "aes-256-ctr", + "keyId": "sha256:41d10f45e705e0526c9eeedf62b32dda3daaf552dd9b6a7b09e01776b05813bb" + }, + "swarm": { + "infohashV1": "sha1:a3ce2180413415d7cf4268fb892b8ffd539e8459", + "infohashV2": "sha256:4b74eb43677e4d03af5fb0856333f9aa21d9a5a3bbf944b13aa7eef379c7a342", + "private": false, + "trackers": [ + "wss://tracker.openwebtorrent.com", + "wss://tracker.webtorrent.dev", + "udp://tracker.opentrackr.org:1337/announce" + ], + "webseeds": [ + "https://gw.c0mpute.com/swarm/4b74eb43677e4d03af5fb0856333f9aa21d9a5a3bbf944b13aa7eef379c7a342/data" + ] + }, + "visibility": "public", + "policy": { + "vanilla": "ciphertext", + "creditBytes": 4194304, + "voucherBytes": 1048576 + }, + "pricing": { + "currency": "USD", + "perGib": "0.010000", + "keyUsd": "0.500000", + "split": { "publisherBps": 7000, "seederBps": 2500, "hubBps": 500 }, + "keeperBps": 1000, + "hubs": ["https://coinpayportal.com/api/openswarm"], + "payTo": { "network": "eip155:8453", "address": "0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5" } + }, + "keys": { + "modes": ["peer", "url"], + "url": "https://keys.example.com/openswarm", + "keepers": ["ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94"], + "owner": "HjYr1barG9BmJK6DEBSm9Rl8iGeYTZFGwfyG8AJy5g3Wk8uP9nyG1IeDaHuOT/VgYoNMmGvlCnaWw4olY6xM/0GKdzr05DqWt49benOyvAs=" + }, + "replication": { "min": 3, "erasure": { "k": 10, "parity": 4 } }, + "meta": { "duration": 2941.2, "language": "en" }, + "createdAt": "2026-09-05T18:00:00.000Z", + "updatedAt": "2026-09-05T18:00:00.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + }, + { + "alg": "ed25519", + "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +| Field | Rule | +| --- | --- | +| `file` | The file public key. REQUIRED. The record's first signature MUST be by this key. | +| `box` | The file box public key (core §4.2). REQUIRED. | +| `publisher` | The publisher identity. REQUIRED. A second signature by this key is REQUIRED. | +| `fileId` | 16 bytes hex. REQUIRED. Stable across revisions. | +| `keyDerivation` | `derived` (from the publisher seed, core §4.2) or `standalone`. | +| `rev` | Integer, starts at 1, strictly increasing per file key. | +| `predecessor`, `successor` | File keys, for rotation (core §4.5). OPTIONAL. | +| `name` | Display name. Visible to anyone who obtains the manifest. MAY be empty. | +| `size` | Plaintext byte length. Ciphertext has the same length. | +| `contentType` | Media type of the plaintext. | +| `pieceLength` | Power of two, at least 16384. | +| `plainRoot`, `cipherRoot` | BEP 52 pieces roots (core §5.1). | +| `cipher.alg` | `aes-256-ctr` in this version. | +| `cipher.keyId` | `sha256:` of the content key. Identifies which key a grant carries without revealing it. | +| `swarm.infohashV1`, `swarm.infohashV2` | Of the hybrid torrent whose one file is the ciphertext (core §5.3). | +| `swarm.private` | BEP 27 flag. | +| `swarm.trackers`, `swarm.webseeds` | Announce URLs and BEP 19 URLs. | +| `visibility` | `public`: any peer gets the manifest on request. `pass`: only a peer presenting a valid pass in scope. | +| `policy.vanilla` | `ciphertext`: serve pieces to vanilla peers free. `deny`: choke them. | +| `policy.creditBytes` | Credit window. Default 4194304. | +| `policy.voucherBytes` | Voucher interval. Default 1048576. MUST be at most `creditBytes / 2`. | +| `pricing` | See §7. `keeperBps` is taken from `keyUsd`, the `split` is applied to per-byte vouchers. | +| `keys.modes` | Any of `peer` (a key holder in the swarm grants on request), `url` (an HTTPS key service). | +| `keys.keepers` | Identities delegated to grant. Each MUST hold an `ipfile.grant` with `delegate: true` signed by the file key. | +| `keys.owner` | The content key sealed to `box`. Lets the owner recover the key from seed plus manifest. | +| `keys.public` | OPTIONAL. The content key itself, hex. Set only when the publisher wants anyone to decrypt (a free release, an `ipdb` segment). With it present, `keyUsd` MUST be `0.000000` and no grant is ever needed. | +| `replication` | Publisher's request: at least `min` independent seeders. `erasure` is advice to storage nodes, never a wire format (§9). | +| `meta` | Free-form. Member protocols define what they put here: `ipaudio` sets `meta.track`, `ipvideo` sets `meta.title`, `ipdb` sets `meta.kind` on segment files. | + +`bps` values are basis points; the three in `split` MUST sum to 10000. + +### 3.2 The grant + +A grant carries the content key to one recipient: + +```json +{ + "openswarm": "0.1", + "type": "ipfile.grant", + "file": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "keyId": "sha256:41d10f45e705e0526c9eeedf62b32dda3daaf552dd9b6a7b09e01776b05813bb", + "grantor": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94", + "grantee": "x25519:de3c5a1791131231f797726a59b46b5b877628f0d10ae97fb5f119faa1ad1dc5", + "pass": "sha256:39655de63a29b02c90b236fa870fe6f8dfad1f52a8119fe4df85e88cd6ab02fd", + "delegate": false, + "expiresAt": null, + "sealed": "KrGWWxD4blHwoh7UmJedgv/CdxWz2J9cbRZpMY+09mm6FT7exyU7KjEAXmWRTLh2Xr8INCGKhv6dpjScKFGCkCrPZDyfAHE2s4E5/VXc2zQ=", + "createdAt": "2026-09-05T18:01:30.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +| Field | Rule | +| --- | --- | +| `grantor` | The file key, or a keeper listed in the manifest. A grant by anyone else is invalid. | +| `grantee` | The recipient's box public key. | +| `pass` | Record id of the pass that paid `keyUsd`. `null` only when `delegate` is true. | +| `delegate` | True makes the grantee a keeper. Only the file key MAY sign a delegating grant. | +| `expiresAt` | Keeper delegation SHOULD expire. A paid grant is perpetual: the peer holds the key. | +| `sealed` | `crypto_box_seal(contentKey, grantee)`, base64. 80 bytes for a 32-byte key. | + +A grantee decrypts `sealed` with its box private key and checks +`sha256(key) == keyId` before using it. + +## 4. Encryption + +### 4.1 Piece encryption + +The plaintext is split into pieces of `pieceLength`. Piece `i` is encrypted +independently under AES-256-CTR: + +``` +iv(i) = HKDF-SHA256(ikm = contentKey, salt = "", info = "openswarm:ipfile:iv:v1:" + decimal(i), L = 12) || 0x00000000 +cipher(i) = AES-256-CTR(contentKey, iv(i), plain(i)) +``` + +The 32-bit counter is big-endian and starts at zero, which is the WebCrypto +`AES-CTR` shape with `length: 32`. A piece is at most 2^32 blocks of 16 +bytes, so the counter never wraps below 64 GiB per piece. + +CTR is chosen over an AEAD because it is length-preserving and seekable: a +16 KiB block at offset `o` in piece `i` decrypts from counter `o / 16` +without touching the rest of the piece. Integrity does not come from the +cipher. It comes from two merkle trees: + +- The swarm verifies every block of ciphertext against `cipherRoot` through + the torrent's own v2 piece layers. A tampered block is rejected by any + BitTorrent v2 client before this specification is involved. +- The decrypting client verifies every plaintext piece against the plaintext + piece layer (§5.6), whose root is `plainRoot` in the signed manifest. + +A malleable cipher whose output is pinned by a signed hash tree is not +malleable in practice: any bit flip fails one of the two checks. + +### 4.2 The content key + +32 random bytes per file version. A new `cipherRoot` MUST use a new content +key; reusing a key across two plaintexts under the same IV derivation would +leak their XOR. The key never leaves a peer except inside a grant's sealed +box, over a key service response, or in `keys.owner`. + +## 5. Wire protocol + +### 5.1 Extension handshake and `hello` + +After the BitTorrent handshake, both peers send the BEP 10 extension +handshake. A peer that supports this specification includes `ipfile` in `m` +and `"openswarm": "0.1"`. Immediately after, it sends `hello`: + +``` +{ "t": "hello", + "v": 1, + "id": <32 bytes, Ed25519 public key>, + "box": <32 bytes, X25519 public key>, + "nonce": <16 bytes>, + "roles": ["seed"], + "hubs": ["https://coinpayportal.com/api/openswarm"], + "credit": 4194304, + "interval": 1048576, + "mtp": <optional 32 bytes, SHA-256 of the peer's MTP/1 SPKI>, + "sig": <64 bytes> } +``` + +`sig` is Ed25519 over: + +``` +"openswarm:hello:v1" || infohashV2 (32 bytes) || own peer_id (20 bytes) || remote peer_id (20 bytes) || nonce +``` + +Binding both `peer_id`s and the infohash ties the identity to this +connection in this swarm; a captured `hello` replays nowhere. `roles` is any +of `seed`, `leech`, `keeper`, `gateway`. `credit` and `interval` are the +window and the voucher interval this seeder will apply, which MUST be at +least the manifest's `policy` values. + +A peer MUST NOT send any other `ipfile` message before it has sent `hello` +and received a valid one. + +### 5.2 Messages + +All payloads are bencoded dictionaries. `r` carries a record as UTF-8 JSON. + +| `t` | Direction | Payload | Meaning | +| --- | --- | --- | --- | +| `hello` | both | §5.1 | Identity and terms. | +| `manifest_req` | any | `{ "rev": int or absent }` | Ask for the manifest, optionally a specific revision. | +| `manifest` | any | `{ "r": bytes }` | The manifest record. Sent unsolicited by a seeder after `hello` when `visibility` is `public`. | +| `layer_req` | leecher | `{}` | Ask for the plaintext piece layer. | +| `layer` | seeder | `{ "d": bytes }` | 32 bytes per piece, concatenated (§5.6). | +| `pass` | leecher | `{ "r": bytes }` | Present an `ippay.pass`. | +| `credit` | seeder | `{ "unpaid": int, "limit": int, "owed": string }` | Bytes served since the last accepted voucher, the window, and the amount owed. | +| `voucher` | leecher | `{ "r": bytes }` | An `ippay.voucher`. | +| `key_req` | leecher | `{ "keyId": bytes }` | Ask a key holder for a grant. | +| `grant` | key holder | `{ "r": bytes }` | An `ipfile.grant` sealed to the requester's `box`. | +| `deny` | any | `{ "what": string, "reason": string }` | Refuse `pass`, `key_req` or `manifest_req` with a registered reason. | +| `bye` | any | `{ "reason": string }` | About to disconnect. | + +Registered `deny` and `bye` reasons: `no-pass`, `pass-invalid`, +`pass-expired`, `pass-scope`, `pass-cap`, `unpaid`, `voucher-invalid`, +`voucher-stale`, `not-keeper`, `no-key`, `visibility`, `rate`, `done`. + +### 5.3 The paid session + +``` +leecher seeder + | BT handshake, BEP 10 handshake | + |---------------------------------------->| + |<----------------------------------------| + | hello | + |---------------------------------------->| + |<------------------------------- hello | + |<---------------------------- manifest | (public visibility) + | pass | + |---------------------------------------->| seeder verifies hub signature, + |<----------------------------- unchoke | scope, expiry, cap + | request / piece ... (vanilla) | + |<=======================================>| up to `credit` bytes unpaid + |<------------------------------ credit | unpaid >= interval: seeder asks + | voucher (cumulative) | + |---------------------------------------->| seeder verifies, unpaid resets + | request / piece ... | + |<=======================================>| + | key_req | + |---------------------------------------->| if seeder is a key holder + |<------------------------------- grant | + | bye done | + |---------------------------------------->| +``` + +Rules: + +1. A seeder MUST keep a leecher choked until a valid `pass` arrives, unless + `policy.vanilla` is `ciphertext`, in which case an unpaid peer is treated + as vanilla (§6) and gets no priority. +2. A seeder MUST NOT serve more than `credit` bytes beyond the bytes covered + by the last accepted voucher. When the window is full it chokes and sends + `credit`. It MAY disconnect with `bye unpaid` after 60 seconds choked. +3. A leecher MUST send a voucher when unpaid bytes reach `interval`, and + only for bytes whose blocks it has verified against the v2 piece layer. A + voucher is cumulative for this (pass, payee, swarm) triple (`ippay` §4). +4. A seeder MUST verify the voucher's signature, its monotonic `seq`, that + `bytes` is at least the previous voucher's `bytes`, and that the increase + does not exceed what it actually served. A voucher that claims more than + served is accepted for what was served and flagged; one that claims less + is `voucher-stale` and rejected. +5. A leecher MUST NOT sign a voucher for bytes it has not verified. It MAY + sign for fewer than it has received; the seeder then chokes sooner. That + is the only lever a leecher has against a slow seeder, and it costs + nothing but throughput. +6. Vouchers cover ciphertext bytes. Payment does not depend on the leecher + ever obtaining the key. + +### 5.4 Who may pay and who is paid + +The voucher's `payer` is the pass's `payer`. The voucher's `payee` is the +seeder's `hello.id`. A seeder MUST reject a voucher whose `payee` is not its +own identity. The hub pays whoever redeems a voucher signed to them, so an +identity is a payout account. A seeder that wants to be paid registers its +identity and payout address at the hub once (`ippay` §6). + +### 5.5 Key delivery + +**Peer mode.** Any peer whose `roles` include `keeper`, or that is the file +key itself, answers `key_req`. It MUST verify that the requester's pass is +valid, in scope for this file, and has `grant: true` (meaning `keyUsd` was +paid). It then signs and sends a grant sealed to the requester's `box`. A +keeper MUST report the grant to the hub (`ippay` §5.4) to earn `keeperBps` +and to let the hub mark the pass's grant as consumed; a pass buys one grant +per file. + +**URL mode.** `GET <keys.url>/grant?file=<file key hex>&box=<x25519 hex>` +with `Authorization: Bearer <pass token>` returns the grant record as JSON. +Without a pass it returns 402 with an x402 offer for `keyUsd`, so a client +that has no hub relationship can still buy a key directly (`ippay` §3.4). + +**Owner recovery.** The owner decrypts `keys.owner` with the file box private +key derived from the seed. + +### 5.6 The plaintext piece layer + +The plaintext v2 piece layer is the sequence of per-piece SHA-256 merkle +roots (32 bytes each) whose root is `plainRoot`. A seeder holds it because it +had the plaintext or received it with the swarm. A leecher fetches it with +`layer_req` (or from a gateway at `/swarm/<infohashV2>/layer`) and MUST +verify that it hashes to `plainRoot` before trusting a single entry. After +decrypting a piece the leecher checks it against the corresponding entry. +For a 1 GiB file with 1 MiB pieces the layer is 32 KiB. + +## 6. Coexistence with vanilla BitTorrent + +A vanilla client sees a hybrid torrent with one file named by a hex string. +It can download it if seeders allow. Specifically: + +- The extension handshake without `ipfile` marks the peer as vanilla. +- With `policy.vanilla: "ciphertext"`, a seeder treats it as any BitTorrent + peer: normal choking algorithm, no credit accounting, no payment. It gets + bytes it cannot decrypt. This is a free choice a publisher makes to widen + the swarm; vanilla peers that then seed are unpaid redundancy. +- With `policy.vanilla: "deny"`, a seeder keeps it choked and MAY disconnect. + It still answers `ut_metadata` so the peer can learn it has nothing to gain. +- A vanilla peer never receives an `ipfile` message, because BEP 10 forbids + sending an extension a peer did not advertise. +- The DHT, trackers and PEX carry infohashes only. A public tracker learns a + swarm exists and how many peers it has, exactly as today. +- WebTorrent peers are vanilla until the page loads the `ipfile` extension. + bittorrented.com's player would add it with `wire.use()`. + +Encryption of the wire itself (the RC4 "message stream encryption" some +clients offer) is neither required nor relied on. Confidentiality of content +comes from piece encryption; confidentiality of the link, where wanted, comes +from the MTP/1 tunnel (core §6.5). + +## 7. Pricing and settlement hook + +The manifest's `pricing` is the whole price list: + +| Field | Meaning | +| --- | --- | +| `perGib` | USD per GiB of ciphertext served. Vouchers are computed from it (`ippay` §4.2). | +| `keyUsd` | USD for one grant. `0.000000` makes the key free to any pass holder. | +| `split` | Where each voucher's value goes: publisher, the seeder who served, the hub. | +| `keeperBps` | Share of `keyUsd` paid to a keeper that grants. The rest of `keyUsd` goes to the publisher. | +| `hubs` | Hubs the publisher accepts passes from. A seeder MUST reject a pass from a hub not listed. | +| `payTo` | Where the publisher's share is paid. CAIP-2 network and an address. | +| `royalties` | OPTIONAL. A list of `{ "label", "network", "address", "bps" }` summing to 10000 that splits the publisher's share further. `ipaudio` and `ipvideo` records carry the same list; when both exist the manifest's wins, because the manifest is what the file key signed. | + +Passes are bought over x402 exactly as `@profullstack/x402-gateway` sells +crawl passes today: a 402 whose body is CoinPay's x402 v2 offer, a proof in +`X-PAYMENT`, verify and settle through CoinPay. `ippay` §3 has the exchange. +A publisher who wants no payment at all sets `perGib` and `keyUsd` to +`0.000000`, and seeders serve for free with vouchers that carry zero; the +accounting still runs so the publisher sees who served what. + +## 8. Publishing procedure + +`ip file add <path>` does, in order: + +1. Load the publisher seed. Generate `fileId`. Derive the file key and file + box (core §4.2), or generate a standalone seed with `--standalone-key`. +2. Generate the content key. Compute `plainRoot` and the plaintext piece + layer. Encrypt piece by piece; compute `cipherRoot`. +3. Build the hybrid torrent: one file named by `cipherRoot` hex, `piece + length` as chosen, `private` per flag, `announce-list` from + configuration, `url-list` for any gateway. Compute both infohashes. +4. Seal the content key to the file box (`keys.owner`). +5. Write the manifest with `rev: 1`. Sign with the file key, then the + publisher key. +6. Publish the BEP 44 pointer under the file key (core §7.2). +7. Append an `ipdb.entry` for the manifest to the publisher's default feed + (`ipdb` §4). +8. Start seeding. Announce to trackers. If any keeper is configured, send it + a delegating grant. + +A revision repeats steps 2 to 7 with `rev + 1` and the same `fileId`. A +metadata-only revision (price, trackers, keepers) skips step 2 and keeps +`cipherRoot`, `keyId` and the swarm. + +## 9. Storage and serving on a c0mpute node + +A c0mpute storage node stores whatever it likes internally: today that is +blake3-addressed chunks with Reed-Solomon 10 data + 4 parity shards spread +over hosts. On the wire it MUST present whole ciphertext pieces verified +against `cipherRoot`. Erasure coding is a storage policy. `replication. +erasure` in the manifest is the publisher saying what durability it is +paying for in a pin job; it never changes what a peer sends. + +A node that holds only shards reconstructs the piece before serving it. A +node advertises `c0mpute:role:storage` and bids on `ipfile.pin` jobs +(`c0mpute.md` §3). Pin income and voucher income are separate: the pin job +pays for holding the bytes for a period, vouchers pay for serving them. + +## 10. Retrieval procedure + +`ip file get <file key | infohashV2 | ip:// url>`: + +1. Resolve to a manifest: BEP 44 `get` under the file key, or `manifest_req` + from a peer, or a gateway. +2. Verify both signatures. Verify `sha256` of the canonical bytes matches + any record id the caller supplied. +3. Choose a hub from `pricing.hubs` the client has an account with. Buy or + reuse a pass in scope (`ippay` §3). Include `grant: true` if the key is + needed. +4. Join the swarm. For each peer: exchange `hello`, send `pass`, download + with sequential priority for streaming or rarest-first for bulk, sign + vouchers on schedule. +5. Fetch and verify the plaintext piece layer. Obtain a grant from a key + holder or the key URL. Decrypt pieces as they complete; verify each + against the layer. +6. On completion, verify the whole against `plainRoot`. Send `bye done`. +7. Keep seeding if configured. The client is now a seeder and earns. + +## 11. Security notes specific to ipfile + +- **Freeloading leecher.** Bounded by `creditBytes`. A seeder loses at most + one window per leecher identity per session and MAY refuse an identity it + has seen freeload (`ippay` §5.5 reputation). +- **Freeloading seeder.** Cannot happen: a voucher is only for bytes already + received and verified. A seeder that stops after being paid was paid for + what it sent. +- **Voucher inflation.** A seeder cannot forge a voucher; it lacks the + payer's key. A payer that over-states `bytes` pays more than it must; that + is its own loss. +- **Double spend across seeders.** A pass has a `capUsd`. A payer could sign + vouchers to many seeders totalling more than the cap. The hub honours + redemptions in order until the cap is reached and reports the rest as + `cap-exceeded`; each seeder's exposure is still one window. A payer that + does this is reported and its passes are refused (`ippay` §5.5). +- **Content key exposure.** A paying peer holds the key. Any peer can leak + it. This family prices access, it does not prevent copying. Rotation is the + remedy for a leaked key going forward, and only forward. +- **Metadata.** Size, piece length and both infohashes are public. `name` + and `meta` are visible to anyone who obtains the manifest; `visibility: + "pass"` restricts that to pass holders. +- **Manifest substitution.** The BEP 44 pointer is signed by the file key + and carries `seq`; a stale pointer is refused by the DHT itself. A + manifest from a peer is verified against the file key the client started + from. + +## 12. Events + +| Type | Emitted by | `data` | +| --- | --- | --- | +| `com.logicsrc.openswarm.ipfile.manifest.published.v1` | publisher | `record` | +| `com.logicsrc.openswarm.ipfile.manifest.revised.v1` | publisher | `record` | +| `com.logicsrc.openswarm.ipfile.grant.issued.v1` | key holder | `record` (with `sealed` removed) | +| `com.logicsrc.openswarm.ipfile.session.closed.v1` | seeder | `{ "swarm", "peer", "bytes", "vouchers", "reason" }` | + +## 13. Implementations + +| Piece | Exists | Where | What is new | +| --- | --- | --- | --- | +| Hybrid seeder announcing to wss trackers, browser WebTorrent player, range streaming | Yes | `media-streamer` (`src/lib/streaming/streaming.ts`, `src/hooks/use-webtorrent.ts`) | The `ipfile` extension in `wire.use()`, piece decryption in the player | +| DHT observation of infohashes | Yes | `media-streamer/services/dht-search-api` (bitmagnet), `dht-infohash-crawler` | Nothing; an `ipfile` swarm is an opaque infohash to a crawler | +| x402 pass sale and verification | Yes | `x402-gateway`, CoinPay `/api/x402/verify` and `/settle` | Pass scope and vouchers (`ippay`) | +| Content-addressed chunk store, erasure coding, gateway by hash | Yes | `c0mpute` (`c0mpute-store`, `c0mpute-gateway`) | Serving pieces by index verified by SHA-256, the pin workload | +| Sealed box key wrapping | Yes | `logicsrc credentials` team vaults, OpenCreds `team` profile | The grant record | +| Manifest, file key derivation, piece encryption, `hello`, credit window, voucher exchange | No | | All of it | + +## 14. Conformance + +An `ipfile` implementation: + +1. Produces manifests per §3.1 with both signatures and a correct BEP 44 + pointer. +2. Encrypts and decrypts per §4 and verifies against both roots. +3. Speaks every message in §5.2, refuses out-of-order messages per §5.1, and + enforces §5.3 rules 2 to 5. +4. Treats vanilla peers per §6 according to `policy.vanilla`. +5. Issues grants only against a valid pass or a delegating grant per §5.5. +6. Passes the published fixture swarm end to end: manifest, pass, three + vouchers, grant, decrypted output matching `plainRoot`. + +## 15. Version history + +| Version | Date | Change | +| --- | --- | --- | +| 0.1 | 2026-09-05 | Initial draft. | diff --git a/docs/openswarm/iplive.md b/docs/openswarm/iplive.md new file mode 100644 index 0000000..f3db2a3 --- /dev/null +++ b/docs/openswarm/iplive.md @@ -0,0 +1,378 @@ +# iplive: Live Streams over Paid Peers + +Version: **0.1** (draft) +Status: draft. Extends the OpenSwarm core ([spec.md](./spec.md)). Reuses +`ipfile`'s encryption, grants and credit window and `ippay`'s passes and +vouchers. Adds one BEP 10 extension, `iplive`. Nothing below is +implemented. + +## 1. Scope + +`iplive` moves a live stream from one origin to many viewers through peers +that are paid to relay it: + +1. A **channel** record: identity, renditions, latency mode, price, relay + policy. +2. A **head** record: signed every segment, naming the segments that exist + and the key epoch in force. +3. The **`iplive` extension**: announcing, requesting and fanning out + encrypted segments with backpressure. +4. **Roles**: origin, relay, viewer, and how a relay is paid. +5. **Recording**: how a finished stream becomes an `ipvideo` title. + +### Non-goals + +Ingest protocols (RTMP and SRT are the encoder's business; c0mpute's DIP +0019 covers them). Sub-second latency; the floor is one part duration. +Chat. Ads. + +## 2. Terminology + +| Term | Meaning | +| --- | --- | +| **channel** | A named live source with a key pair derived from the publisher seed. | +| **channel id** | 20 bytes: SHA-256 of `"openswarm:iplive:v1" || channel public key`, truncated. Used where the DHT and trackers want an infohash. | +| **origin** | The peer that encodes, encrypts, signs heads and holds the content key. | +| **relay** | A peer that forwards segments it has verified and is paid per byte. | +| **viewer** | A peer that plays. It MAY also relay. | +| **segment** | One CMAF fragment, 2000 ms by default, encrypted as one unit. | +| **part** | In low-latency mode, a sub-segment chunk (500 ms default) announced before the segment completes. | +| **key epoch** | The span of segments encrypted under one content key. | +| **live edge** | The newest segment the origin has announced. | + +## 3. Data model + +### 3.1 Channel + +The channel key derives like a feed: + +``` +channelSeed = HKDF-SHA256(ikm = publisher seed, salt = utf8("live:" + name), info = "openswarm:live:v1") +``` + +```json +{ + "openswarm": "0.1", + "type": "iplive.channel", + "channel": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "box": "x25519:4c4055604fe6bd8781cab6835093ad896f68f676b472d5bd6811e52f28cb18c3", + "publisher": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "name": "courtside", + "title": "Courtside, live", + "latency": "low", + "segmentMs": 2000, + "partMs": 500, + "epochSegments": 900, + "dvrSegments": 1800, + "renditions": [ + { "id": "720p", "codecs": "avc1.64001f,mp4a.40.2", "width": 1280, "height": 720, "frameRate": 30, "bitrateKbps": 2800 }, + { "id": "360p", "codecs": "avc1.64001e,mp4a.40.2", "width": 640, "height": 360, "frameRate": 30, "bitrateKbps": 800 } + ], + "swarm": { + "trackers": ["wss://tracker.openwebtorrent.com", "udp://tracker.opentrackr.org:1337/announce"], + "private": false + }, + "relays": { "allow": "any", "maxDownstream": 8, "minRelays": 2 }, + "policy": { "vanilla": "deny", "creditBytes": 8388608, "voucherBytes": 2097152 }, + "pricing": { + "currency": "USD", + "perGib": "0.020000", + "keyUsd": "1.000000", + "split": { "publisherBps": 6000, "seederBps": 3500, "hubBps": 500 }, + "keeperBps": 0, + "hubs": ["https://coinpayportal.com/api/openswarm"], + "payTo": { "network": "eip155:8453", "address": "0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5" } + }, + "keys": { "modes": ["peer"], "keepers": [] }, + "createdAt": "2026-09-05T20:00:00.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + }, + { + "alg": "ed25519", + "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +| Field | Rule | +| --- | --- | +| `channel`, `box` | Channel signing and box keys. Signed by both channel and publisher, as an `ipfile` manifest is. | +| `latency` | `normal`: segments announced when complete. `low`: parts announced as they are encoded. | +| `segmentMs`, `partMs` | 2000 and 500 by default. 4000 is allowed and matches c0mpute DIP 0019. | +| `epochSegments` | Segments per content key. 900 at 2 s is 30 minutes. | +| `dvrSegments` | Segments the origin and relays keep for late joiners and scrubbing. | +| `relays.allow` | `any`, or a list of identities allowed to pull from the origin. | +| `relays.maxDownstream` | Peers one relay will feed at once. | +| `relays.minRelays` | Relays the origin wants before it stops feeding viewers directly. | +| `policy`, `pricing`, `keys` | As `ipfile` §3.1 and §7, with the same fields and meanings. `seederBps` pays relays. | + +The record lives in `ipdb` under `channel:<channel key>`. + +### 3.2 Head + +Signed by the channel key every time a segment (or, in low-latency mode, +a part) is announced: + +```json +{ + "openswarm": "0.1", + "type": "iplive.head", + "channel": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "seq": 4187, + "prev": "sha256:ba8cd1a97b865a05cdc5840c63b086dd463624a831874c1ba0d7c4ce6f806f7a", + "epoch": 4, + "keyId": "sha256:41d10f45e705e0526c9eeedf62b32dda3daaf552dd9b6a7b09e01776b05813bb", + "live": true, + "segments": [ + { + "seq": 4187, + "t": 8374000, + "d": 2000, + "renditions": [ + { "id": "720p", "root": "sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff", "size": 702113, "parts": [175528, 175530, 175527, 175528] }, + { "id": "360p", "root": "sha256:4b74eb43677e4d03af5fb0856333f9aa21d9a5a3bbf944b13aa7eef379c7a342", "size": 200418, "parts": [50104, 50105, 50104, 50105] } + ] + } + ], + "init": [ + { "id": "720p", "root": "sha256:c9f17d4eaf2b28122b46d111cef6697d2c3f708a19d4628bce5c0304481b355b", "size": 1524 }, + { "id": "360p", "root": "sha256:00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be", "size": 1498 } + ], + "relays": ["ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94"], + "createdAt": "2026-09-05T22:19:34.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +| Field | Rule | +| --- | --- | +| `seq` | Segment number of the live edge. Starts at 1 when the stream starts. | +| `prev` | Id of the previous head, so a viewer can detect a skipped announcement. | +| `epoch`, `keyId` | Which content key encrypts segments from this seq on. A key change is announced one full segment before it applies. | +| `segments` | The newest segment, or the newest few. A head is small: relays fill history from `have`. | +| `segments[].renditions[].root` | BEP 52 pieces root of the segment ciphertext, with the segment as one piece. That is what a relay verifies before forwarding. | +| `segments[].renditions[].parts` | Byte lengths of each part, in order, for low-latency requests. | +| `init` | Roots and sizes of each rendition's init segment, which is not encrypted. | +| `relays` | Identities the origin currently feeds; a viewer prefers these. | +| `live` | `false` in the final head, which marks the end of the stream. | + +Heads are not stored in `ipdb`. The final head is, under +`channel:<key>:end:<seq>`, alongside the recording (§8). + +### 3.3 Segment encryption + +As `ipfile` §4.1, with the segment as a single piece and the IV derived with +`info = "openswarm:iplive:iv:v1:" + decimal(seq)`. The content key changes +each epoch; a grant carries `keyId` and a viewer requests a new grant when +the head announces a new `keyId`. A pass expiring mid-stream stops at the +next epoch, which is why epochs are short. + +## 4. Wire protocol + +### 4.1 Finding peers + +Peers announce the channel id to the DHT (`announce_peer`) and to the +channel's trackers exactly as they would an infohash. Vanilla clients that +see it find a swarm whose `ut_metadata` returns nothing; they leave. +Browsers find WebRTC peers through wss trackers as with any WebTorrent +swarm. + +### 4.2 Handshake + +The BitTorrent handshake uses the channel id as the infohash. The BEP 10 +handshake advertises `iplive`. Both sides send `hello` with the `ipfile` +§5.1 fields (identity, box, nonce, roles, hubs, credit, interval, sig) +where `roles` is any of `origin`, `relay`, `viewer`, and the signature +covers the channel id in place of `infohashV2`. + +### 4.3 Messages + +| `t` | Payload | Meaning | +| --- | --- | --- | +| `hello` | as `ipfile` | Identity and terms. | +| `channel` | `{ "r": bytes }` | The channel record. Sent by origin and relays after `hello`. | +| `head` | `{ "r": bytes }` | A head record. Forwarded to every downstream peer within 100 ms of receipt. | +| `have` | `{ "from": int, "to": int, "r": [string] }` | Segment range and rendition ids the sender holds. | +| `window` | `{ "buffer": int, "live": int, "down": int, "max": int }` | Segments buffered, live edge, downstream count, `maxDownstream`. Sent after `hello` and whenever it changes. | +| `want` | `{ "seq": int, "r": string, "part": int or absent }` | Request one segment, or one part. | +| `seg` | `{ "seq": int, "r": string, "part": int or absent, "o": int, "d": bytes }` | A 16 KiB chunk of the requested unit at offset `o`. | +| `init` | `{ "r": string, "d": bytes }` | An init segment. | +| `pass`, `credit`, `voucher`, `key_req`, `grant`, `deny`, `bye` | as `ipfile` | Payment and keys, unchanged. | + +`seg` chunks are 16 KiB so that a WebRTC data channel and an MTP/1 record +both carry one without fragmentation. + +### 4.4 Session + +``` +viewer relay + | hello | + |---------------------------------------->| + |<------------------------------- hello | + |<----------------------------- channel | + |<------------------------------ window | buffer=30 live=4187 down=3 max=8 + | pass | + |---------------------------------------->| + |<-------------------------------- head | seq 4187 + |<-------------------------------- have | 4158..4187 + | want 720p init | + |---------------------------------------->| + |<-------------------------------- init | + | want 4185 720p | + |---------------------------------------->| + |<--------------------------------- seg | x 43 chunks + | want 4186 720p ... | + | key_req | + |---------------------------------------->| relay is not a keeper: + |<-------------------------------- deny | not-keeper; ask origin or keys.url + | voucher | + |---------------------------------------->| +``` + +Rules: + +1. A viewer starts `segmentMs * 2` behind the live edge in `normal` mode + and one part behind in `low` mode. +2. A peer MUST NOT `want` a seq below `live - buffer` of the peer it asks. + If it has fallen further behind it jumps to `live - 1` and discards. + Stalls are resolved by dropping, never by holding a relay's buffer. +3. A relay forwards a segment only after verifying its ciphertext against + the `root` in a head signed by the channel key. A relay never needs the + content key. +4. A relay serving `down >= max` answers new `hello`s with `deny rate` and + the current `head`, whose `relays` list points elsewhere. +5. Payment is `ipfile` §5.3 rules 2 to 6, with a relay as payee. The + default window is larger (8 MiB) because a segment is bigger than a + piece and a choke at the live edge is a stall. +6. Keys come from the origin or a keeper listed in the channel, never from + a plain relay. A relay MAY be a keeper if the publisher delegates to it. + +### 4.5 Low latency + +In `low` mode the origin announces a head as soon as the first part of a +segment is encoded, with `parts` filled in as they complete (lengths for +finished parts, `0` for pending). A viewer requests parts by index and +appends them to MSE as they arrive. A part is encrypted as part of the +segment's CTR stream: part `k` begins at counter `offset(k) / 16`, so a +relay can forward parts without waiting for the segment and a viewer can +decrypt them in order. The segment `root` is only known when the segment +completes; a relay in `low` mode forwards parts on the strength of the +signed head naming the part lengths and verifies the root when the last +part lands, disconnecting any upstream whose completed segment fails. + +## 5. Roles and payment + +| Role | Holds key | Pays | Is paid | +| --- | --- | --- | --- | +| Origin | Yes | Nothing | Publisher share of every voucher, `keyUsd` per grant | +| Relay | No | Vouchers to its upstream for bytes it pulled | Vouchers from downstream for bytes it served | +| Viewer | Yes, after a grant | Vouchers to whoever served it | Vouchers if it also relays | + +A relay's margin is `seederBps` of what it serves minus what it pays +upstream. With `perGib = 0.020000`, a relay feeding eight viewers from one +upstream pull earns `8 * 0.35 * 0.02 = 0.056` and pays `0.02` per GiB +pulled, before the hub's share. That is the incentive to relay, and a +c0mpute node bids on `iplive.relay` jobs for the guaranteed floor +(`c0mpute.md` §3). + +The origin is a peer that pays nobody; `relays.allow` and `minRelays` are +how it limits its own upload. A publisher who wants more reach than its +relays provide buys pins on c0mpute. + +## 6. Publishing procedure + +`ip live create courtside --latency low` writes the channel record and +puts it in `ipdb`. `ip live start courtside --input srt://:9000` then: + +1. Derives the channel key. Generates epoch 1's content key. +2. Runs the encoder (ffmpeg) producing CMAF segments per rendition, and + for `low` mode, parts. +3. For each completed unit: encrypts, computes the root, signs a head, + announces to the DHT and trackers, sends `head` to every connected peer. +4. Answers `key_req` with grants for pass holders. +5. Rotates the content key every `epochSegments`, announcing the new + `keyId` one segment early. +6. On stop: signs a final head with `live: false`, and if `--record`, + assembles the recording (§8). + +## 7. Viewer procedure + +`ip live watch ed25519:<channel key>` or a browser page: + +1. Resolve the channel record from `ipdb` or a peer. +2. Buy or reuse a pass scoped to the channel key with `grant: true`. +3. Find peers; prefer identities in the latest head's `relays`; connect to + two. +4. Get the init segment, the head, a grant. Start at the live edge minus + the offset in §4.4 rule 1. +5. Pull each segment from the connected peer with the smaller `live - seq` + lag; on a `deny rate` or a stall over one segment duration, replace the + peer. +6. Send vouchers on schedule to each peer that served bytes. + +## 8. Recording + +With `--record`, the origin keeps every segment's plaintext, concatenates +each rendition into one CMAF file, and publishes an `ipvideo.title` whose +segment index is exactly the head history. Because segments were 2 or 4 +seconds and keyframe-aligned, the recording is a valid `ipvideo` ladder +with no re-encode. The title's `ipdb` entry references the channel in +`meta.channel` and the final head's id. + +## 9. Security and privacy + +- **Relays see ciphertext and heads.** They learn segment sizes and + timing, never content. +- **Head forgery** needs the channel key. A relay forwarding an unsigned or + wrongly signed head is dropped by every downstream peer. +- **Freeloading** is bounded per `ipfile` §11 with the larger window. +- **Denial by relay count**: an origin with `relays.allow: any` can be + surrounded by relays that pull and never serve. It pays them nothing (it + pays nobody) and they earn nothing; the cost is its upload. `maxDownstream` + on the origin is the cap. +- **Late key**: a viewer whose pass expires keeps the current epoch's key + and loses the next. Epoch length is the publisher's tolerance. + +## 10. Events + +| Type | Emitted by | +| --- | --- | +| `com.logicsrc.openswarm.iplive.channel.created.v1` | publisher | +| `com.logicsrc.openswarm.iplive.stream.started.v1` | origin | +| `com.logicsrc.openswarm.iplive.stream.ended.v1` | origin, with the final head id and recording title id | +| `com.logicsrc.openswarm.iplive.relay.joined.v1` | origin | + +## 11. Implementations + +| Piece | Exists | Where | What is new | +| --- | --- | --- | --- | +| RTMP/SRT ingest to HLS segments, segment swarming in the DHT, relay nodes rebuilding playlists, LL-HLS opt-in | Designed | `c0mpute` `dips/0019-live-stream-plugin.md` | Encrypted segments, signed heads, paid relays: the same design with `ippay` attached | +| Live TV playback in the browser (hls.js, mpegts.js) | Yes | `media-streamer` `hls-player-modal.tsx`, tipoffwatch and genrewatch players | MSE append of decrypted CMAF parts from peers instead of an HLS URL | +| Pay-per-game passes with live session caps | Yes (central) | `media-streamer` IPTV resale (`iptv_share_sessions`) | Concurrency is no longer the owner's provider line; it is the origin's `maxDownstream` | +| Channel and head records, `iplive` messages, relay economics, recording to `ipvideo` | No | | All of it | + +## 12. Conformance + +An origin signs a head per unit, rotates keys per epoch, and announces a +key change one segment early. A relay verifies roots before forwarding, +honours `window` rules, and never serves below `live - buffer`. A viewer +drops rather than stalls, vouchers every peer that served it, and requests +a grant on a `keyId` change. + +## 13. Version history + +| Version | Date | Change | +| --- | --- | --- | +| 0.1 | 2026-09-05 | Initial draft. | diff --git a/docs/openswarm/ipname.md b/docs/openswarm/ipname.md new file mode 100644 index 0000000..0f52d72 --- /dev/null +++ b/docs/openswarm/ipname.md @@ -0,0 +1,187 @@ +# ipname: Names for Publishers + +Version: **0.1** (draft) +Status: draft. A thin bridge between the OpenSwarm core +([spec.md](./spec.md)) and the Moshpit registry. Nothing below is +implemented. + +## 1. Scope + +`ipname` answers one question: given a name a person can type, what +publisher key and what catalogue does it mean? It defines: + +1. An `ipname.pin` record a publisher signs, served from the name's host. +2. A Moshpit registry pin kind, `openswarm`, that anchors that record. +3. A DNS TXT form for ordinary domains. +4. The `ip://` URL. + +It does not define a naming system. Moshpit names exist (`label.tld`, +registry at `pit.moshcode.sh`) and DNS exists. `ipname` maps both to keys. + +## 2. Terminology + +| Term | Meaning | +| --- | --- | +| **name** | A Moshpit name (`chovy.hacker`) or a DNS host (`example.com`). | +| **pin** | In the Moshpit registry, `base64(SHA-256(SubjectPublicKeyInfo))` of a key, with a `kind`. | +| **openswarm pin** | A registry pin of kind `openswarm` over the publisher's Ed25519 SPKI (RFC 8410). | +| **name record** | The `ipname.pin` record at `/.well-known/openswarm.json`. | + +## 3. The name record + +```json +{ + "openswarm": "0.1", + "type": "ipname.pin", + "name": "chovy.hacker", + "publisher": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "feeds": [ + "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e/default", + "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e/podcasts" + ], + "gateway": "https://gw.c0mpute.com", + "hubs": ["https://coinpayportal.com/api/openswarm"], + "mtp": "sK8m2eLwG4l3iF5oT9uYQ6c1Vb0nZxA7hE2dR8pJkLw=", + "createdAt": "2026-09-05T18:30:00.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +| Field | Rule | +| --- | --- | +| `name` | The name this record is served for. A record served under a different host MUST be refused. | +| `publisher` | The key that signs the record and every feed it lists. | +| `feeds` | Feed references (`ipdb` §3.1). The first is the default. | +| `gateway` | An HTTP gateway that serves this publisher's swarms and `POST /db/query`. MAY be null. | +| `hubs` | Hubs the publisher sells passes through. | +| `mtp` | The publisher's MTP/1 pin, if it runs a Moshpit transport endpoint. MAY be null. | + +The record is also put in `ipdb` under `name:<name>` in the default feed, +so a replica that has the feed can answer the reverse question: what names +does this key claim. + +## 4. Resolution + +### 4.1 Moshpit names + +A Moshpit name has exactly two labels of letters and digits. The registry +holds a `target` host and a list of pins per name. `ipname` uses both: + +1. `GET https://pit.moshcode.sh/api/moshpit/pins?name=chovy.hacker` returns + `{ "name", "pins": [...], "target" }`. Pins are objects or strings + depending on the registry version; the resolver looks for entries of + kind `openswarm`. +2. Fetch `https://<target>/.well-known/openswarm.json` (through + `moshpit-proxy` when the target is itself a Moshpit name, plain HTTPS + when it is a clearnet host). +3. Compute `base64(SHA-256(SPKI(record.publisher)))` and require it to equal + an `openswarm` pin. Verify the record signature. Require `record.name` + to equal the queried name. + +The registry pin is the anchor. The registry is not trusted for the key +itself; it is trusted to say which hash the name's owner registered, and +the owner's key is whatever hashes to that. This is the same argument +`moshpit-proxy` makes for TLS pins and `qrypt.chat` makes for `mtp` pins, +and the pin format is byte-identical so one registry field carries all +three kinds. + +Writing the pin is one registry call by the name's owner: + +``` +POST https://app.moshcode.sh/api/moshpit/tlds/hacker/pins +Authorization: Bearer <MOSHPIT_API_KEY> +{ "label": "chovy", "pin": "base64...", "kind": "openswarm", "note": "OpenSwarm publisher key" } +``` + +`kind: "openswarm"` does not exist in the registry today; adding it is a +one-line change to the accepted kinds and is listed as an open question. + +### 4.2 House policy: clearnet first + +Moshpit's `prefer` rule applies unchanged: a name that resolves on the +public DNS root is a clearnet host by default, and the registry is +consulted only in `fallback` or `moshpit` mode. `ipname` MUST use +`@moshcoder/moshpit-resolve`'s `decideResolution` (or an equivalent) before +touching the registry, so that `example.com` is never redirected to a pit +record because someone claimed `.com` as an ending. + +### 4.3 DNS hosts + +For a clearnet host, the anchor is a TXT record: + +``` +_openswarm.example.com. IN TXT "v=openswarm1; key=ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e" +``` + +The resolver fetches `https://example.com/.well-known/openswarm.json` and +requires `record.publisher` to equal the TXT key. Without the TXT record +the resolver MAY accept the well-known record on the strength of TLS alone +and MUST report the lower assurance to the caller (`"anchor": "tls"` +rather than `"anchor": "dns"` or `"anchor": "moshpit"`). + +### 4.4 Caching + +A resolved name is cached for the shorter of 300 seconds and the registry's +own TTL, matching `moshpit-proxy`'s pin cache. A negative result is cached +for 5 seconds. + +## 5. The ip URL + +``` +ip://<name or key>[/<feed>[/<key>]] +``` + +| Example | Meaning | +| --- | --- | +| `ip://chovy.hacker` | The publisher's default feed. | +| `ip://chovy.hacker/podcasts` | A named feed. | +| `ip://chovy.hacker/default/track:sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff` | One record by `ipdb` key. | +| `ip://ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e` | The same publisher by key; no name resolution. | +| `ip://ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d?file` | A file by its file key: BEP 44 lookup, no feed. | + +A gateway maps `ip://` to HTTPS: `https://<gateway>/ip/<name or key>/...` +with the same path. A `magnet:` link with `xs=urn:btpk:<file key>&s=ipfile` +is the BitTorrent-native equivalent of the last row and opens in any BEP 46 +client, which then needs the `ipfile` extension to do anything with it. + +## 6. Security notes + +- **Registry compromise** can change a pin. A resolver that has seen a name + before SHOULD keep the last verified publisher key and warn on change, the + way `moshpit-proxy` handles a pin rotation with `MOSHPIT_PROXY_TOFU`. +- **Host compromise** can serve a different record, which fails the pin. +- **Name squatting** is the registry's problem, and only the ending owner + can mint names under it. +- **Privacy**: resolving a name tells the registry which names a client + asks about, as DNS does. A client that already holds a key never resolves. + +## 7. Events + +None. Name changes surface as `ipdb.entry.appended` for `name:<name>`. + +## 8. Implementations + +| Piece | Exists | Where | What is new | +| --- | --- | --- | --- | +| Registry with per-name pins of kind `tls` and `mtp` | Yes | `pit.moshcode.sh`, `moshpit_name_pins` | The `openswarm` kind | +| Pin lookup, cache, TOFU handling | Yes | `moshpit-proxy` `lib/pins.ts` | Reuse as the resolver's registry client | +| Clearnet-first decision | Yes | `@moshcoder/moshpit-resolve` | Called before any registry lookup | +| Name record, TXT form, `ip://` URL | No | | All of it | + +## 9. Conformance + +A resolver implements §4.1 to §4.4, refuses a record whose `name` or pin +does not match, and reports the anchor kind. A publisher serves the record +at the well-known path over HTTPS and keeps it in `ipdb`. + +## 10. Version history + +| Version | Date | Change | +| --- | --- | --- | +| 0.1 | 2026-09-05 | Initial draft. | diff --git a/docs/openswarm/ippay.md b/docs/openswarm/ippay.md new file mode 100644 index 0000000..721b23b --- /dev/null +++ b/docs/openswarm/ippay.md @@ -0,0 +1,464 @@ +# ippay: Passes, Vouchers and Settlement + +Version: **0.1** (draft) +Status: draft. Extends the OpenSwarm core ([spec.md](./spec.md)). The x402 +half exists (`@profullstack/x402-gateway`, CoinPay); the pass and voucher +half does not. + +## 1. Scope + +`ippay` defines how money moves in an OpenSwarm swarm: + +1. A **pass**: a hub-signed record that lets one payer spend up to a cap on + named files, bought over x402 in USDC. +2. A **voucher**: a payer-signed, cumulative IOU to one payee for verified + bytes, exchanged on the swarm wire or over HTTP. +3. A **hub**: the settlement service that sells passes, redeems vouchers, + splits the value between publisher, payee and itself, and pays out. +4. **Reputation**: the minimum a hub records so a seeder can bound its risk. + +It reuses the x402 v2 offer and proof exactly as CoinPay speaks them and as +`x402-gateway` already sells crawl passes. It does not define a token, a +chain, or an on-chain payment channel. + +### Non-goals + +Trustless settlement. A hub is trusted to pay out what it accepted, the way +a payment processor is. Anyone may run a hub; a manifest lists which hubs a +publisher trusts. Per-request on-chain payments: a 16 KiB block is worth +about 0.00000015 USD at the reference price, and no chain settles that. + +## 2. Terminology + +| Term | Meaning | +| --- | --- | +| **payer** | The identity that bought a pass and signs vouchers. Usually a leecher or viewer. | +| **payee** | The identity a voucher is signed to. A seeder, relay, keeper or gateway. | +| **cap** | The most a pass can spend across all payees. | +| **standing** | The hub's record of a payer's or payee's behaviour. | + +## 3. Passes + +### 3.1 Record + +```json +{ + "openswarm": "0.1", + "type": "ippay.pass", + "hub": "ed25519:c9f17d4eaf2b28122b46d111cef6697d2c3f708a19d4628bce5c0304481b355b", + "payer": "ed25519:00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be", + "scope": { + "files": ["ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d"], + "publishers": [] + }, + "capUsd": "2.000000", + "grants": ["ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d"], + "paidUsd": "2.500000", + "payment": { + "network": "eip155:8453", + "nonce": "0x2f0a9c1b8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a" + }, + "issuedAt": "2026-09-05T18:00:40.000Z", + "expiresAt": "2026-10-05T18:00:40.000Z", + "createdAt": "2026-09-05T18:00:40.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:c9f17d4eaf2b28122b46d111cef6697d2c3f708a19d4628bce5c0304481b355b", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +| Field | Rule | +| --- | --- | +| `hub` | The hub's signing key. A seeder verifies the signature against the key in the hub record (§6.1), fetched once and cached. | +| `payer` | The identity that will sign vouchers. Bound at purchase time (§3.3). | +| `scope.files` | File keys this pass may spend on. `["*"]` means any file whose manifest lists this hub. | +| `scope.publishers` | Publisher keys; any file by one of them is in scope. Either list MAY be empty; at least one MUST be non-empty. | +| `capUsd` | Spending cap across all vouchers. | +| `grants` | Files for which `keyUsd` was paid. A pass buys one grant per listed file. | +| `paidUsd` | What was paid: `capUsd` plus each granted file's `keyUsd`. | +| `payment.nonce` | The EIP-3009 nonce of the proof that bought it. Lets the pass be traced to a settlement. | +| `expiresAt` | Hubs SHOULD issue 30 day passes. Unspent cap is not refunded in 0.1. | + +A pass is self-contained. Presented over HTTP it is +`Authorization: Bearer <base64url(canonical JSON of the record including sigs)>`. +A verifier needs the hub's public key and a clock; no round trip. + +### 3.2 Purchase over x402 + +A client asks the hub for a pass. The hub answers 402 with CoinPay's x402 v2 +offer. The client pays and retries. This is the same exchange +`@profullstack/x402-gateway` runs for `/crawl`. + +``` +GET /passes?payer=00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be + &file=0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d + &cap=2.000000&grant=1 +Host: coinpayportal.com +Accept: application/json +``` + +``` +HTTP/1.1 402 Payment Required +Content-Type: application/json; charset=utf-8 +Cache-Control: no-store +Vary: Accept, X-Payment + +{ + "x402Version": 2, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:8453", + "amount": "2500000", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": "0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5", + "resource": "https://coinpayportal.com/api/openswarm/passes?payer=00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be&file=0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d&cap=2.000000&grant=1", + "description": "OpenSwarm pass: 2.000000 USD cap plus 1 key grant", + "mimeType": "application/json", + "maxTimeoutSeconds": 300, + "extra": { "name": "USD Coin", "version": "2" } + } + ], + "pass": { "capUsd": "2.000000", "keyUsd": "0.500000", "expiresInDays": 30 } +} +``` + +`amount` is in the asset's smallest unit: 2.5 USDC is `2500000`. The client +signs an EIP-3009 `transferWithAuthorization` for that amount to `payTo` +(the `@profullstack/x402-client` `pay()` call, or CoinPay Wallet in a +browser) and retries with the proof: + +``` +GET /passes?payer=...&file=...&cap=2.000000&grant=1 +X-PAYMENT: <base64 JSON proof> +PAYMENT-SIGNATURE: <same, v2 header name> +``` + +The hub calls CoinPay `/api/x402/verify` with `expected: { amount, resource, +payTo, asset }` from the entry it offered, then `/api/x402/settle`. On +`settled: true` it returns `200` with the pass record as the body and +`X-OpenSwarm-Pass: <base64url record>` as a header. A replayed proof returns +the pass it already bought, with `replayed: true` in a wrapper, and never a +second pass. + +The `payer` key is in the resource URL on purpose. EIP-3009 cannot bind a +resource; a proof for one resource can be presented for another from the +same merchant at the same or lower price. The hub therefore records +`nonce -> resource` at verify time and refuses a nonce for any other +resource, which closes the gap for passes. + +### 3.3 Binding the payer + +The pass names an Ed25519 `payer`. The buyer proves control of it by signing +the request: header `X-OpenSwarm-Payer: <base64 Ed25519 signature over +"openswarm:passreq:v1" || resource URL>`. Without it the hub MUST refuse to +issue; otherwise anyone who observed a proof could bind the pass to their +own key. + +### 3.4 Direct key purchase + +A key service (`ipfile` §5.5, URL mode) MAY sell a grant without a hub: its +402 offers `keyUsd` alone. That is a publisher running the x402-gateway +pattern on its own key endpoint. It gets no per-byte income that way; it is +the choice for a publisher who pays for distribution with free webseeds. + +## 4. Vouchers + +### 4.1 Record + +```json +{ + "openswarm": "0.1", + "type": "ippay.voucher", + "pass": "sha256:39655de63a29b02c90b236fa870fe6f8dfad1f52a8119fe4df85e88cd6ab02fd", + "payer": "ed25519:00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be", + "payee": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94", + "file": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "swarm": "sha256:4b74eb43677e4d03af5fb0856333f9aa21d9a5a3bbf944b13aa7eef379c7a342", + "seq": 3, + "bytes": 268435456, + "usd": "0.002500", + "createdAt": "2026-09-05T18:04:02.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +| Field | Rule | +| --- | --- | +| `pass` | Record id of the pass. | +| `payer` | MUST equal the pass's `payer`. The signature MUST be by this key. | +| `payee` | The identity being paid. From the peer's `hello`. | +| `file`, `swarm` | The file key and `infohashV2` the bytes belong to. | +| `seq` | Starts at 1 per (pass, payee, swarm) and increases by one. | +| `bytes` | Cumulative verified ciphertext bytes received from this payee on this swarm under this pass. Never decreases. | +| `usd` | Cumulative value, computed per §4.2 from `bytes` and the manifest's `perGib`. | + +A voucher is cumulative, so only the latest one per (pass, payee, swarm) +matters. A payee stores the latest and discards the rest. Redemption is one +record per triple, however long the session was. + +### 4.2 Value + +``` +perGibMicro = perGib * 1_000_000 (exact: the string has 6 places) +usdMicro = floor(bytes * perGibMicro / 2^30) +usd = usdMicro formatted with 6 places +``` + +Integer arithmetic only. For `perGib = "0.010000"` and `bytes = 268435456` +(256 MiB): `268435456 * 10000 / 1073741824 = 2500`, so `"0.002500"`. A +payee MUST recompute `usd` and reject a voucher whose `usd` differs from the +computed value, so a payer cannot understate by editing the number. + +### 4.3 Exchange + +On the swarm wire a voucher travels in the `ipfile` `voucher` message +(`ipfile` §5.2) or the `iplive` `voucher` message. Over HTTP it travels as +`X-OpenSwarm-Voucher: <base64url record>` on the request that would push +unpaid bytes past the interval (§7). + +### 4.4 Validation by the payee + +1. Signature by `payer`; `payer` equals the pass's payer. +2. Pass signature by a hub in the manifest's `pricing.hubs`, pass not + expired, file in scope. +3. `payee` is this peer. `swarm` is this swarm. +4. `seq` is exactly previous `seq + 1` (or 1 for the first). +5. `bytes` is at least previous `bytes` and at most bytes actually served. +6. `usd` recomputes. + +A voucher failing 1 to 4 or 6 is rejected with `voucher-invalid`. One +failing 5 by claiming too much is accepted at the served amount and the +excess is reported to the hub as `overclaim`. One claiming less than +previous is `voucher-stale`. + +## 5. The hub + +### 5.1 Hub record + +Published at `GET <base>/.well-known/openswarm-hub.json`: + +```json +{ + "openswarm": "0.1", + "type": "ippay.hub", + "key": "ed25519:c9f17d4eaf2b28122b46d111cef6697d2c3f708a19d4628bce5c0304481b355b", + "name": "CoinPay", + "base": "https://coinpayportal.com/api/openswarm", + "networks": ["eip155:8453", "eip155:137", "eip155:1"], + "assets": { + "eip155:8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "eip155:137": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "eip155:1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + }, + "minHubBps": 300, + "payout": { "minUsd": "1.000000", "schedule": "daily" }, + "passDays": 30, + "createdAt": "2026-09-05T00:00:00.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:c9f17d4eaf2b28122b46d111cef6697d2c3f708a19d4628bce5c0304481b355b", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +The record is self-signed; trust in the hub key comes from the manifest that +lists the hub's `base`, from TLS to that base, and optionally from an MTP/1 +pin. `minHubBps` is the least `split.hubBps` a manifest may set for this hub +to sell passes for it. + +### 5.2 API + +All bodies are JSON. Records are verified on receipt. + +| Method and path | Auth | Purpose | +| --- | --- | --- | +| `GET /.well-known/openswarm-hub.json` | none | Hub record. | +| `GET /passes?payer&file|publisher&cap&grant` | x402 + `X-OpenSwarm-Payer` | Buy a pass (§3.2). | +| `GET /passes/<id>` | none | `{ "pass", "spentUsd", "grantsUsed": [...], "status": "active|expired|revoked" }`. | +| `POST /vouchers` | none | Body: one voucher or an array. Returns receipts (§5.3). | +| `POST /grants` | none | Body: `{ "grant": <record without sealed>, "manifest": <record> }`. Keeper reports a grant. | +| `POST /payees` | signed | Body: `{ "payee", "payTo": { "network", "address" }, "sig" }`, `sig` by the payee key over `"openswarm:payee:v1" || address`. | +| `GET /payees/<key>` | none | `{ "payTo", "balanceUsd", "paidOutUsd", "standing" }`. | +| `GET /payers/<key>/standing` | none | `{ "passes", "overclaims", "capExceeded", "freeloads", "since" }`. | +| `POST /reports` | signed | `{ "kind": "freeload", "payer", "swarm", "unpaidBytes", "reporter", "sig" }`. | +| `POST /webhooks` | publisher signed | Register a CloudEvents endpoint for a publisher key. | + +### 5.3 Redemption and receipts + +For each voucher the hub: + +1. Validates §4.4 steps 1, 2 and 6 (it cannot check 3 to 5; those were the + payee's job, and the payee signed nothing, so an inflated voucher only + hurts the payer). +2. Loads the latest accepted voucher for the triple. Accepts only the + increase in `usd` since it, so re-submitting an old voucher pays nothing. +3. Applies the cap: `spentUsd + increase <= capUsd`. If not, accepts the + remainder up to the cap with status `partial`, or `cap-exceeded` if + nothing remains. +4. Fetches the manifest for `file` (from `ipdb`, a gateway, or the DHT) and + applies `split`. +5. Credits the payee, the publisher's `payTo`, and itself. +6. Returns a receipt: + +```json +{ + "openswarm": "0.1", + "type": "ippay.receipt", + "voucher": "sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff", + "pass": "sha256:39655de63a29b02c90b236fa870fe6f8dfad1f52a8119fe4df85e88cd6ab02fd", + "payee": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94", + "status": "accepted", + "accepted": { "bytes": 268435456, "usd": "0.002500" }, + "split": { "publisherUsd": "0.001750", "payeeUsd": "0.000625", "hubUsd": "0.000125" }, + "createdAt": "2026-09-05T18:10:00.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:c9f17d4eaf2b28122b46d111cef6697d2c3f708a19d4628bce5c0304481b355b", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +Split arithmetic is in micro-USD, integer, with the remainder after +`floor` for each share going to the publisher. `2500 * 7000 / 10000 = 1750`, +`2500 * 2500 / 10000 = 625`, `2500 * 500 / 10000 = 125`. + +### 5.4 Key grants + +`POST /grants` lets a keeper collect `keeperBps` of `keyUsd` and marks the +grant on the pass as used. The hub verifies the grant signature is by the +file key or a keeper in the manifest, that the pass listed the file in +`grants`, and that no grant was recorded for that pass and file before. A +second grant for the same pass and file is refused; the peer already holds +the key. + +### 5.5 Standing + +A hub keeps, per payer: passes issued, overclaims, cap-exceeded redemptions, +freeload reports. Per payee: vouchers redeemed, overclaims accepted against +it. A seeder MAY consult `GET /payers/<key>/standing` before extending a +window larger than the manifest minimum, and MUST NOT refuse a peer for +standing alone when the manifest's `creditBytes` window is what it offers. +Standing is advisory; the credit window is the guarantee. + +### 5.6 Payout + +The hub pays each payee's balance to its registered `payTo` in USDC on the +payee's network when it reaches `payout.minUsd`, on `payout.schedule`. The +publisher's share goes to the manifest's `pricing.payTo`. The hub keeps its +share. A payout emits `com.logicsrc.openswarm.ippay.payout.sent.v1`. + +Where the hub is CoinPay: today's x402 rail pays a merchant's own wallet +directly and collects no platform fee. A hub is different: it is the +merchant for the pass sale, holds the balance, and pays out. This is a +custodial position and is flagged as an open question in the PR. + +## 6. Payees + +### 6.1 Registering + +A seeder, relay, keeper or gateway that wants to be paid registers once: + +```json +{ + "payee": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94", + "payTo": { "network": "eip155:8453", "address": "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf" }, + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" +} +``` + +Vouchers redeemed before registration accumulate and pay out once an +address exists. A c0mpute node registers its libp2p-adjacent OpenSwarm +identity this way at first start (`c0mpute.md` §2). + +### 6.2 Relationship to the file key + +A publisher's payout address is in its signed manifest and can only be +changed by a manifest revision, that is, by the file key. A payee's payout +address is registered at the hub by the payee key. The two never mix: a +publisher that also seeds its own file is paid twice, once as publisher and +once as payee, to two addresses it may set to the same value. + +## 7. HTTP presentation + +A gateway or key service applies the same rules over HTTP: + +| Header | Direction | Meaning | +| --- | --- | --- | +| `Authorization: Bearer <base64url pass>` | request | The pass. | +| `X-OpenSwarm-Voucher: <base64url voucher>` | request | A voucher covering bytes received so far. | +| `X-OpenSwarm-Credit: unpaid=<bytes>; limit=<bytes>; owed=<usd>` | response | Sent with every 206 so the client knows when to voucher. | +| `402` with `X-OpenSwarm-Credit` and no x402 body | response | Window exhausted; send a voucher and retry. | + +The gateway's `payee` is its identity key, published in the hub record it +registered with and returned in `X-OpenSwarm-Payee` on every response. + +## 8. Events + +| Type | Emitted by | +| --- | --- | +| `com.logicsrc.openswarm.ippay.pass.issued.v1` | hub | +| `com.logicsrc.openswarm.ippay.voucher.redeemed.v1` | hub | +| `com.logicsrc.openswarm.ippay.voucher.rejected.v1` | hub | +| `com.logicsrc.openswarm.ippay.grant.recorded.v1` | hub | +| `com.logicsrc.openswarm.ippay.payout.sent.v1` | hub | +| `com.logicsrc.openswarm.ippay.report.filed.v1` | hub | + +Publishers subscribe with `POST /webhooks`. Delivery is per core §8.2. + +## 9. Security notes + +- **Hub compromise.** A hub key signs passes. A stolen key mints free + passes until the manifest's `hubs` list is revised. Hubs SHOULD keep the + signing key in an HSM and publish a successor key in the hub record before + rotating. +- **Pass theft.** A pass is bearer for HTTP presentation but useless without + the payer's private key on the swarm, since vouchers must be signed by + `payer`. A gateway that accepts a pass without a voucher is serving one + window on trust, same as a seeder. +- **Voucher replay.** Cumulative and per-triple; a replay pays nothing. +- **Cap races.** Two seeders redeem at once past the cap: the hub serialises + per pass, so exactly one gets the remainder. Each seeder's exposure was one + window regardless. +- **Payee address change.** Signed by the payee key. A hub SHOULD hold + payouts for 24 hours after a change and notify the previous address's + webhook. + +## 10. Implementations + +| Piece | Exists | Where | What is new | +| --- | --- | --- | --- | +| x402 v2 offer, `X-PAYMENT` proof, verify and settle, HMAC passes | Yes | `x402-gateway` (`src/x402.js`, `src/pass.js`), CoinPay `/api/x402/*` | The pass record replaces the HMAC token; scope, cap, grants | +| Node payer with EIP-3009 signing | Yes | `x402-client` | Nothing; it can buy a pass unchanged | +| Browser payer | Yes | CoinPay Wallet, `@profullstack/coinpay` `x402-browser.js` | Nothing | +| Pay-per-pass grants with concurrency caps | Yes (central) | `media-streamer` IPTV and seedbox rails | The swarm version does not need the session table; the window is the cap | +| Vouchers, receipts, split, payees, standing, payout batching | No | | All of it | + +## 11. Conformance + +A payee implementation validates vouchers per §4.4 and redeems per §5.2. +A payer implementation computes `usd` per §4.2 and never signs for bytes it +has not verified. A hub implements every row of §5.2, the receipt of §5.3, +and the arithmetic of §4.2 and §5.3 exactly. + +## 12. Version history + +| Version | Date | Change | +| --- | --- | --- | +| 0.1 | 2026-09-05 | Initial draft. | diff --git a/docs/openswarm/ipvideo.md b/docs/openswarm/ipvideo.md new file mode 100644 index 0000000..2997f27 --- /dev/null +++ b/docs/openswarm/ipvideo.md @@ -0,0 +1,271 @@ +# ipvideo: Video on Demand on ipfile Swarms + +Version: **0.1** (draft) +Status: draft. Extends the OpenSwarm core ([spec.md](./spec.md)) and rides +on [ipfile](./ipfile.md), [ippay](./ippay.md) and [ipdb](./ipdb.md). +Nothing below is implemented. + +## 1. Scope + +`ipvideo` defines the records that make a set of `ipfile` swarms playable +as a film, an episode or a clip: + +1. A **title**: renditions (CMAF fragmented MP4, one `ipfile` each), a + segment index, subtitles, thumbnails, chapters, artwork. +2. **Playback rules**: segment-aligned adaptive bitrate over pieces, MSE in + the browser. +3. A **gateway bridge** that renders HLS for players that speak nothing + else. +4. **Transcode on c0mpute**: how the renditions get made. + +Bytes, keys, payment and discovery are `ipfile` and `ippay`. No new wire +messages. + +### Non-goals + +Live (that is `iplive`). Interactive video. Ad insertion (c0mpute's DAI +DIPs may reference a title; this document does not). DRM in the device +sense. + +## 2. Terminology + +| Term | Meaning | +| --- | --- | +| **rendition** | One encoding ladder step, stored as one `ipfile` in CMAF form. | +| **segment** | A fragmented MP4 `moof + mdat` pair, starting with a keyframe. | +| **init segment** | The `ftyp + moov` bytes a decoder needs before any segment. | +| **segment index** | The list of `{ t, d, o, l }` for every segment of a rendition. | +| **ladder** | The set of renditions of a title, aligned on segment boundaries. | + +## 3. Data model + +### 3.1 Title + +```json +{ + "openswarm": "0.1", + "type": "ipvideo.title", + "publisher": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "title": "The Interview", + "kind": "movie", + "series": null, + "season": null, + "episode": null, + "year": 2026, + "durationMs": 5400000, + "language": "en", + "artwork": { + "poster": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94", + "backdrop": null + }, + "renditions": [ + { + "id": "1080p", + "container": "cmaf", + "codecs": "avc1.640028,mp4a.40.2", + "width": 1920, + "height": 1080, + "frameRate": 24, + "bitrateKbps": 5000, + "file": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "size": 3375000000, + "init": { "o": 0, "l": 1524 }, + "segmentsFile": "ed25519:00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be" + }, + { + "id": "480p", + "container": "cmaf", + "codecs": "avc1.64001e,mp4a.40.2", + "width": 854, + "height": 480, + "frameRate": 24, + "bitrateKbps": 1400, + "file": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94", + "size": 945000000, + "init": { "o": 0, "l": 1498 }, + "segments": [ + { "t": 0, "d": 4000, "o": 1498, "l": 702113 }, + { "t": 4000, "d": 4000, "o": 703611, "l": 698402 } + ] + } + ], + "audio": [ + { "id": "en", "language": "en", "label": "English", "channels": 2, "default": true } + ], + "subtitles": [ + { "id": "en-cc", "language": "en", "kind": "captions", "format": "vtt", "file": "ed25519:c9f17d4eaf2b28122b46d111cef6697d2c3f708a19d4628bce5c0304481b355b" } + ], + "thumbnails": { + "file": "ed25519:4b74eb43677e4d03af5fb0856333f9aa21d9a5a3bbf944b13aa7eef379c7a342", + "vtt": "ed25519:ba8cd1a97b865a05cdc5840c63b086dd463624a831874c1ba0d7c4ce6f806f7a", + "intervalMs": 10000 + }, + "chapters": [ + { "t": 0, "title": "Opening" }, + { "t": 1830000, "title": "The question" } + ], + "royalties": [], + "createdAt": "2026-09-05T18:20:00.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +(The `480p` segment list is truncated to two entries; a real one has one +entry per segment.) + +| Field | Rule | +| --- | --- | +| `kind` | `movie`, `episode`, `clip`, `trailer`. `series`, `season`, `episode` are set for `episode`. | +| `renditions[].container` | `cmaf` in this version: fragmented MP4 per ISO 23000-19, one segment per `moof`. | +| `renditions[].codecs` | RFC 6381 string as MSE `isTypeSupported` wants it. | +| `renditions[].file` | The `ipfile` manifest's file key. `size` MUST match. | +| `renditions[].init` | Byte range of the init segment within the plaintext. | +| `renditions[].segments` | Inline index, or `segmentsFile`: file key of a free `ipfile` (`keys.public` set) whose plaintext is the JSON array. Inline when under 64 KiB, file otherwise. | +| `segments[].t`, `d` | Presentation start and duration in ms. `o`, `l`: byte offset and length in the plaintext. | +| `audio` | Audio tracks muxed in the renditions. Alternate-language audio is a rendition with `codecs` of audio only and `video: false`. | +| `subtitles[].format` | `vtt` in this version. Each is a free `ipfile` unless the publisher prices it. | +| `thumbnails` | A sprite sheet `ipfile` and a WebVTT file mapping times to sprite regions, as hls.js and video.js expect. | +| `royalties` | As `ipaudio` §5. Empty means the whole publisher share goes to `payTo`. | + +Every rendition MUST share the same `t` list. Every segment MUST start with +a keyframe. Segment duration SHOULD be 4000 ms, matching c0mpute's +live-stream DIP (`-hls_time 4`) so a recorded live stream and a VOD title +use one ladder. + +### 3.2 Where records live + +`ipdb` under `title:<id>` in the publisher's feed. Renditions, subtitles +and thumbnails are `ipfile` manifests in the same feed with +`meta.title = <title record id>`. + +## 4. Playback + +### 4.1 Segment to pieces + +To play segment `s` of rendition `r`: + +``` +first piece = floor(s.o / pieceLength) +last piece = floor((s.o + s.l - 1) / pieceLength) +``` + +Request those pieces; decrypt; verify against the plaintext piece layer; +slice `[s.o, s.o + s.l)`; append to the MSE `SourceBuffer` after the init +segment. With 1 MiB pieces and a 5 Mbps rendition a 4 s segment is about +2.5 MiB, so two to three pieces. + +### 4.2 Adaptive bitrate + +Switch at segment boundaries only. The next segment is fetched from the +rendition whose `bitrateKbps` is the highest below 0.8 times the measured +piece throughput over the last 10 seconds, with a two-segment hysteresis +before stepping up. Because `t` lists are shared, the MSE buffer stays +contiguous across a switch; a new init segment is appended before the +first segment of the new rendition, as in any CMAF player. + +### 4.3 Buffering and priority + +Hold 30 seconds ahead. Request the pieces for the next segment with +sequential priority (what media-streamer's `torrent.select` does for range +streaming today). Seeking to time `t`: find the segment with the largest +`t <= target`, drop outstanding requests, start there. A player MUST keep +sending vouchers for bytes received while seeking, including bytes it then +discards; discarded bytes were still served. + +### 4.4 Browser + +A browser peer is a WebTorrent client with the `ipfile` extension, a +WebCrypto AES-CTR decryptor, and MSE. This is bittorrented.com's player +with three additions and no server in the path. Where MSE is unavailable +(some iOS contexts), the gateway bridge in §5 applies. + +## 5. Gateway bridge + +A keeper gateway renders HLS from the title record: + +``` +GET /video/<title id>/master.m3u8 +GET /video/<title id>/<rendition id>/index.m3u8 +GET /video/<title id>/<rendition id>/data (plaintext, Range) +Authorization: Bearer <base64url pass> +``` + +The media playlist uses `#EXT-X-MAP` for the init range and +`#EXT-X-BYTERANGE` for every segment against the single `data` URL, so the +gateway serves plaintext by range from decrypted pieces and never has to +split files. Subtitles become `#EXT-X-MEDIA` entries pointing at +`/file/<file key>/data`. Credit and voucher headers are as `ippay` §7; a +player that cannot send vouchers (a bare `<video>` tag) is limited to the +pass's first credit window unless the gateway is configured to trust the +pass for the title's full size. + +HLS's own encryption (`EXT-X-KEY` with AES-128-CBC) is not used. The +gateway is a keeper; it decrypts. A publisher who does not want any +gateway holding a key does not delegate to one, and browser MSE is the +only path. + +## 6. Transcode on c0mpute + +`ip video publish <source> --ladder default` submits one c0mpute job per +rendition. The existing `ffmpeg.transcode` workload takes +`TranscodeSpec { codec, bitrate_bps, width, height, keyframe_interval, +hardware_pref, extra_ffmpeg_args }`; `ipvideo` adds `extra_ffmpeg_args` +that force CMAF output and a 4 s keyframe interval, and expects the result +to include the segment index. The proposed `ipvideo.transcode` workload +(`c0mpute.md` §3) is that: a wrapper whose `TranscodeResult` carries +`segments`, `init`, `codecs` and gapless-irrelevant fields, and whose +output is handed to `ip file add` on the worker so the rendition is seeded +from where it was made. + +Default ladder: + +| id | codecs | size | bitrate | +| --- | --- | --- | --- | +| `1080p` | `avc1.640028,mp4a.40.2` | 1920x1080 | 5000 kbps | +| `720p` | `avc1.64001f,mp4a.40.2` | 1280x720 | 2800 kbps | +| `480p` | `avc1.64001e,mp4a.40.2` | 854x480 | 1400 kbps | +| `360p` | `avc1.64001e,mp4a.40.2` | 640x360 | 800 kbps | +| `1080p-av1` | `av01.0.08M.08,opus` | 1920x1080 | 3000 kbps, optional | + +## 7. Security and privacy + +As `ipfile`. A segment index reveals segment sizes, which for a +constant-quality encode leak scene complexity over time; a publisher who +cares uses constant bitrate. Subtitle files are small free swarms and +reveal the dialogue to anyone; price them if that matters. + +## 8. Events + +| Type | Emitted by | +| --- | --- | +| `com.logicsrc.openswarm.ipvideo.title.published.v1` | publisher | +| `com.logicsrc.openswarm.ipvideo.transcode.completed.v1` | c0mpute worker | + +## 9. Implementations + +| Piece | Exists | Where | What is new | +| --- | --- | --- | --- | +| Browser WebTorrent player, MSE-compatible fMP4 output, HLS fallback, range seeking | Yes | `media-streamer` (`media-player-modal.tsx`, `transcoding.ts`, `/api/stream/hls`) | Piece decryption before MSE; the title record instead of `vod_titles` | +| Hardware ffmpeg transcode job with codec ladder | Yes | `c0mpute` (`c0mpute-transcode`, `TranscodeSpec`) | CMAF output flags and the segment index in the result | +| VOD catalogue with per-title price and pass window | Yes (central) | `media-streamer` `vod_titles`, `vod_providers` | The title record and `ipfile` pricing replace both tables | +| Thumbnail VTT, chapters, subtitle tracks | Partly | hls.js and video.js conventions | The record fields | +| Title record, segment index file, HLS bridge from records | No | | All of it | + +## 10. Conformance + +A player fetches by segment per §4.1, switches at boundaries per §4.2, +and vouchers for discarded bytes per §4.3. A publisher emits aligned +ladders with keyframe-aligned segments. A gateway implementing §5 renders +byte-range HLS and applies `ippay` §7. + +## 11. Version history + +| Version | Date | Change | +| --- | --- | --- | +| 0.1 | 2026-09-05 | Initial draft. | diff --git a/docs/openswarm/security.md b/docs/openswarm/security.md new file mode 100644 index 0000000..697c8cd --- /dev/null +++ b/docs/openswarm/security.md @@ -0,0 +1,133 @@ +# OpenSwarm Security Model + +Version: **0.1** (draft) + +This document collects the security claims the member documents make and +states plainly what the family does not protect. + +## 1. What is protected, by what + +| Asset | Protection | Where | +| --- | --- | --- | +| File contents | AES-256-CTR under a random content key; integrity from two SHA-256 merkle trees pinned by a signed manifest | ipfile §4 | +| Content key in transit | X25519 sealed box to the recipient's box key | ipfile §3.2 | +| Content key at rest for the owner | Sealed to the file box key; recoverable from seed plus manifest | core §4.3 | +| Manifest authenticity | Ed25519 by the file key and the publisher key, with a domain prefix | core §3.4 | +| Latest-manifest pointer | BEP 44 signature by the file key, `seq` monotonic | core §7.2 | +| Catalogue integrity | Per-entry signature plus hash chain; head on the DHT | ipdb §3 | +| Passes | Ed25519 by the hub; payer bound by a request signature | ippay §3 | +| Vouchers | Ed25519 by the payer; cumulative; `usd` recomputed by the payee | ippay §4 | +| Live segments | Same cipher; root in a signed head per unit; relays verify before forwarding | iplive §3 | +| Name to key | Registry pin (SHA-256 of SPKI) or DNS TXT, plus a signed record | ipname §4 | +| Peer identity on a connection | `hello` signature over infohash and both peer ids | ipfile §5.1 | +| Link confidentiality (optional) | MTP/1: X25519 + ML-KEM-768, ML-DSA-65, ChaCha20-Poly1305 | core §6.5 | +| Events | Standard Webhooks HMAC-SHA256 with timestamp tolerance | core §8 | + +## 2. Key material + +| Key | Type | Lives | Backs up as | +| --- | --- | --- | --- | +| Publisher seed | 32 bytes | Keystore or OpenCreds `key` item | The one thing to back up | +| File seed (derived) | HKDF from publisher seed and `fileId` | Never stored | Recomputed | +| File seed (standalone) | 32 random bytes | Keystore | Its own OpenCreds item | +| Content key | 32 random bytes | Keystore, `keys.owner`, keepers, grantees | `keys.owner` | +| Box keys | X25519 from the seeds | Derived | Recomputed | +| Hub signing key | Ed25519 | Hub HSM | Hub's problem | +| Payer EVM key | secp256k1 | `X402_PRIVATE_KEY`, wallet | Wallet | +| Node identity | libp2p Ed25519 | `~/.config/c0mpute/identity.key` | c0mpute's problem | + +## 3. Threats and answers + +**A public tracker or DHT node wants to know what a swarm is.** It gets an +infohash, a size, a piece length, a peer count, and a file named by a hex +string. The manifest is not on the DHT; the pointer to it is signed and +opaque. With `swarm.private` it gets nothing from the DHT at all. + +**A peer wants bytes without paying.** It gets one credit window per +identity per session, then a choke. With `policy.vanilla: "ciphertext"` it +can get all the ciphertext free, by design, and cannot read it. + +**A seeder wants to be paid for bytes it did not serve.** It cannot; the +voucher is signed by the payer for bytes the payer verified. Its only lever +is to stop serving after being paid for what it already sent. + +**A payer signs vouchers past its cap to many seeders.** Each seeder was +exposed for one window. The hub pays in redemption order until the cap +and refuses the rest; the payer's standing records it and future passes +are refused. + +**A payer or seeder replays a voucher.** It is cumulative and per triple; +replay pays zero. + +**A hub key is stolen.** Free passes until manifests drop the hub. The hub +record SHOULD carry a successor key and publishers SHOULD list two hubs. + +**A hub does not pay out.** It is a trusted processor; the answer is to +list a different hub. Receipts are signed, so non-payment is provable. + +**The publisher seed is stolen.** Every derived file key is compromised: +the thief can revise manifests, change payout addresses and delegate +keepers. Detection is a manifest revision the owner did not make, visible +in the `ipdb` log. Remedy is publisher rotation (core §4.5) from a machine +the thief does not control; `successor` links require the new key too, so +the thief cannot forge the rotation. + +**The publisher seed is lost.** Core §4.7. Nothing is unreadable; nothing +can be changed. + +**A grantee leaks the content key.** Anyone with the key and the ciphertext +reads the file. The family prices access and does not prevent copying. A +leaked key is answered by re-encryption, which protects only future +readers. This is the same position every streaming service is in once the +frame is on a screen, stated rather than hidden behind a DRM promise. + +**A relay forwards a bad segment.** Every downstream peer verifies the root +against a signed head and drops the relay. + +**A Sybil of relays surrounds an origin.** They pull, earn nothing (nobody +pays them), and cost the origin upload up to `maxDownstream`. Pins and +hired relays are how an origin buys reach it controls. + +**A registry hands out a wrong pin.** The record fails the pin check; +first-seen pinning in the resolver warns on change. + +**An EIP-3009 proof is presented for a different resource.** Known +limitation of x402 v2: the struct cannot bind a resource. The hub records +nonce to resource at verify time and refuses reuse (ippay §3.2). + +**A gateway is asked to serve plaintext to a bare `<video>` tag.** It serves +one credit window against the pass and then 402s; a gateway configured to +trust the pass for the whole title is taking the whole title as its +exposure, and the configuration says so. + +## 4. What is deliberately visible + +- File size, piece length, both infohashes, tracker list, peer count. +- Manifest `name` and `meta`, to anyone who obtains the manifest (public + visibility) or any pass holder (pass visibility). +- The existence and sequence length of a feed; the content of a public + feed. +- Segment sizes and timing of a live stream, to relays. +- Who paid whom how much, to the hub. +- The publisher's payout address, in the signed manifest. + +## 5. Defaults an implementation MUST ship + +| Setting | Default | Reason | +| --- | --- | --- | +| `policy.creditBytes` | 4 MiB (`ipfile`), 8 MiB (`iplive`) | Bounded exposure, no choke at the live edge | +| `policy.voucherBytes` | 1 MiB, 2 MiB | Four vouchers per window | +| `policy.vanilla` | `ciphertext` | Wider swarm at zero confidentiality cost | +| `visibility` | `public` | A catalogue that cannot be read is not a catalogue | +| Pass lifetime | 30 days | Long enough to stream a release, short enough to bound a stolen key | +| Key epoch (`iplive`) | 900 segments | 30 minutes at 2 s | +| Resolver cache | 300 s positive, 5 s negative | Matches moshpit-proxy | +| Keystore mode | 0600 | As every other Profullstack keystore | + +## 6. Cryptographic agility + +`cipher.alg`, `sigs[].alg`, `kdf` labels with `:v1` suffixes, and +extension names are all versioned data. A new cipher is a new `alg` value +and a new IV label; a new signature is a new `alg`; a breaking wire change +is a new extension name. Nothing is edited in place, for the reason +OpenCreds gives: peers that shipped the old value are still running. diff --git a/docs/openswarm/spec.md b/docs/openswarm/spec.md new file mode 100644 index 0000000..1a89cf7 --- /dev/null +++ b/docs/openswarm/spec.md @@ -0,0 +1,510 @@ +# The OpenSwarm Core Specification + +Version: **0.1** (draft) +Status: draft. Nothing below is implemented yet. Wire formats are expected to +change before 1.0, and this document says where. + +The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT and MAY are to be +interpreted as described in RFC 2119. + +## 1. Scope + +The core defines what every member protocol shares: + +1. **Records** (§3): the JSON shape, canonicalisation, identifiers and + signatures of every object in the family. +2. **Keys** (§4): publisher identities, derived per-file keys, box keys, and + the rules for reuse, rotation and loss. +3. **Hashing and chunking** (§5): what a content identifier is and why it is + a BitTorrent v2 merkle root. +4. **Transports** (§6): the BitTorrent wire, the extension protocol, browser + peers, webseeds, and the optional Moshpit tunnel. +5. **Discovery** (§7): DHT, trackers, Moshpit names. +6. **Events** (§8): the CloudEvents envelope and webhook signing. +7. **Versioning and conformance** (§9, §10). + +Member protocols (`ipfile`, `ippay`, `ipdb`, `ipaudio`, `ipvideo`, `iplive`, +`ipname`) add record types and extension messages. They MUST NOT redefine +anything in this document. + +## 2. Terminology + +| Term | Meaning | +| --- | --- | +| **record** | A signed JSON object with an `openswarm` version and a `type`. | +| **record id** | `sha256:` plus the hex SHA-256 of the record's canonical bytes without `sigs`. | +| **publisher** | The Ed25519 identity that owns files and feeds. | +| **file key** | A per-file Ed25519 pair derived from the publisher key. Its public half is the file's identity. | +| **box key** | An X25519 pair used to receive sealed content keys. Every identity has one. | +| **content key** | 32 random bytes that encrypt one file version's pieces. | +| **swarm** | One BitTorrent swarm carrying one ciphertext payload. | +| **piece** | A BEP 52 piece: a power-of-two length, at least 16 KiB. | +| **block** | 16 KiB, the unit of a BitTorrent `request` and of a v2 merkle leaf. | +| **peer** | Any participant in a swarm. A **seeder** has all pieces, a **leecher** wants some, a **relay** forwards live segments. | +| **gateway** | An HTTP server that serves ciphertext by range (a BEP 19 webseed) and answers catalogue queries. | +| **hub** | The settlement service that sells passes and redeems vouchers. See `ippay`. | +| **pass** | A hub-signed record that lets a peer spend up to a cap on named swarms. | +| **voucher** | A payer-signed cumulative IOU for bytes received from one payee. | +| **grant** | A record carrying a content key sealed to one recipient's box key. | +| **feed** | An `ipdb` log owned by one key. | + +## 3. Records + +### 3.1 Shape + +Every object in the family is a record: + +```json +{ + "openswarm": "0.1", + "type": "ipfile.manifest", + "createdAt": "2026-09-05T18:00:00.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +| Field | Type | Rule | +| --- | --- | --- | +| `openswarm` | string | Family version the record was written under. `"0.1"`. | +| `type` | string | `<protocol>.<noun>`, lower case. Registered types are listed by each member document. | +| `createdAt` | string | RFC 3339, UTC, millisecond precision. | +| `sigs` | array | One or more signatures (§3.4). MAY be empty only for record types a member document marks as unsigned. | + +Every other field is defined by the member document for that `type`. An +implementation reading an unknown field MUST preserve it on round trip. + +### 3.2 Canonical form + +The canonical bytes of a record are the RFC 8785 (JSON Canonicalization +Scheme) serialisation of the record with the `sigs` member removed. JCS +sorts keys, strips whitespace and fixes number formatting. Amounts and hashes +are strings in this family precisely so that JCS never has to touch a float. + +### 3.3 Record id + +``` +id = "sha256:" + lowercase_hex(SHA-256(canonical bytes)) +``` + +The id is never stored inside the record. A reader computes it. Two records +with the same canonical bytes are the same record, which is what makes `ipdb` +replication idempotent. + +### 3.4 Signatures + +A signature entry is `{ "alg", "key", "sig" }`. The signed message is: + +``` +"openswarm:sig:v1:" + type + "\n" + canonical bytes +``` + +The prefix is domain separation. A signature over an `ipfile.manifest` can +not be replayed as a signature over an `ippay.voucher` with the same body. + +| `alg` | `key` prefix | Signature | Status | +| --- | --- | --- | --- | +| `ed25519` | `ed25519:` | 64 bytes, base64 | REQUIRED | +| `mldsa65` | `mldsa65:` | 3309 bytes, base64 | OPTIONAL, for post-quantum ownership | + +A record MUST carry at least one `ed25519` signature by the key the member +document names as its signer. It MAY carry an `mldsa65` signature by a key +the same identity has bound (§4.6). A verifier that does not implement +`mldsa65` MUST ignore those entries and MUST NOT treat their presence as an +error. + +Key strings are `<alg>:<hex>`. Ed25519 and X25519 public keys are 32 bytes, +so 64 hex characters. ML-DSA-65 public keys are 1952 bytes. + +### 3.5 Amounts and times + +Money is a decimal string with exactly six fractional digits, in USD: +`"0.002500"`. Six digits is USDC's precision and is what CoinPay's x402 +dialect already carries. Integer arithmetic is specified where rounding +matters (`ippay` §4). Times are RFC 3339 UTC. Byte counts are JSON integers +and MUST stay below 2^53. + +## 4. Keys + +### 4.1 Publisher identity + +A publisher identity is a 32-byte seed. From it: + +``` +publisher signing key = Ed25519 keypair from seed +publisher box key = X25519 keypair from HKDF-SHA256(seed, salt = "", info = "openswarm:box:v1") +``` + +The identity string is `ed25519:<hex of the signing public key>`. The box +public key is published in the records that need it (§4.4). + +`ip init` creates one seed. It is reused for every file, feed and channel the +publisher adds. The seed SHOULD be stored as an OpenCreds `key` item with +`keyType: "openswarm-seed"`; it is the one thing in this family that cannot +be regenerated. + +### 4.2 Per-file keys are derived, and reusable by default + +Adding a file mints a key pair for that file. By default the pair is derived, +not random: + +``` +fileId = 16 random bytes, recorded in the manifest as hex +fileSeed = HKDF-SHA256(ikm = publisher seed, salt = fileId, info = "openswarm:file:v1") +file key = Ed25519 keypair from fileSeed +file box = X25519 keypair from HKDF-SHA256(fileSeed, salt = "", info = "openswarm:box:v1") +``` + +Consequences, all intended: + +- Holding the publisher seed and any manifest (which carries `fileId`) is + enough to recover that file's private key. There is no key database to back + up beyond the seed. +- Two files never share a key. Compromise of one file seed does not expose + another, because HKDF output is one-way from the seed. +- The public half is stable across revisions of the file. A re-encode keeps + the same `fileId`, so it keeps the same identity and the same BEP 46 + pointer (§7.2). + +`ip file add --standalone-key` mints a random `fileSeed` instead. The manifest +records `"keyDerivation": "standalone"` so tooling knows the seed must be +backed up on its own. Use it for a file whose ownership will be transferred: +handing over a standalone seed transfers exactly one file. + +### 4.3 What each half does + +| Half | Used for | +| --- | --- | +| File public key | The file's identity in every reference (`"file": "ed25519:..."`), the BEP 44 key under which the latest manifest is announced, the key peers verify manifests against. | +| File private key | Signs every manifest revision. Signs `ipfile.grant` records that delegate the content key to a keeper. Signs payout changes: the `pricing.payTo` a hub pays a publisher's share to is whatever the latest signed manifest says. | +| File box key | Receives the owner's own sealed copy of the content key (`keys.owner` in the manifest), so the content key is recoverable from the manifest plus the seed. | +| Content key | Encrypts the pieces. Random, 32 bytes, per file version. Never signs anything. | + +The file key is not a wallet. It authorises where money goes; the money goes +to an EVM address the manifest names. A seeder's payout address is likewise +its own, registered at the hub against the seeder's identity key (`ippay` +§6). Nothing in this family derives a wallet from a signing key. + +### 4.4 Box keys and sealing + +Any content key that travels does so inside a sealed box: + +``` +sealed = X25519 anonymous sealed box (libsodium crypto_box_seal) to the recipient's box public key +``` + +This is the construction the OpenCreds `team` profile already uses to wrap a +vault key to a member, and it is chosen for the same reason: a seeder can hand +a key to a peer it has never met, given only that peer's box public key from +the `hello` message. When the link is already an MTP/1 session (§6.5) the +box is still applied; the transport protects the link, the box protects the +key at rest in a `grant` record. + +### 4.5 Rotation + +**Publisher rotation.** Mint a new seed. For each file, publish a manifest +revision signed by the old file key (derived from the old seed) that names +the new `publisher`, and that carries a new `fileId` and a new file key +derived from the new seed in `successor`. Readers that already hold the old +file key follow `successor` once and re-pin. The old seed is kept only to +derive old file keys during the transition. + +**File rotation.** Same mechanism for one file: a final revision under the +old file key with `successor` set. A reader MUST NOT follow a `successor` +chain more than 8 deep, and MUST refuse a successor whose own latest manifest +does not name the predecessor in `predecessor`. Both links are required so a +compromised old key cannot redirect a file to an attacker's swarm; the +attacker would also need to sign as the new key. + +**Content key rotation.** Re-encrypt under a fresh content key and publish a +new revision with a new `cipherRoot` and new swarm. Old grants stop working +for the new swarm. Rotation is the only way to revoke a key that has been +delivered; a delivered key is held by the peer, and the spec does not pretend +otherwise. + +### 4.6 Post-quantum binding + +An identity MAY bind an ML-DSA-65 key by publishing an `openswarm.binding` +record signed by both keys: + +```json +{ + "openswarm": "0.1", + "type": "openswarm.binding", + "subject": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "pq": "mldsa65:0f3a51c0a3e6d1e2f4c99b3d3b6a2c5a9f1e8d7c6b5a4f3e2d1c0b9a8f7e6d5c", + "createdAt": "2026-09-05T18:00:00.000Z", + "sigs": [ + { + "alg": "ed25519", + "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e", + "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w==" + } + ] +} +``` + +(`pq` is truncated above for readability; a real value is 1952 bytes of +hex, and the record carries a second entry with `"alg": "mldsa65"` whose +3309-byte signature is omitted here for the same reason.) +This matches the Moshpit registry's `mtp` pin kind, which is an ML-DSA-65 +identity. A verifier that trusts the binding accepts an `mldsa65` signature +from `pq` as a signature by `subject`. + +### 4.7 Loss + +If the publisher seed is lost and no standalone seeds exist: + +- Every file's ciphertext stays servable. Seeders hold pieces, not keys. +- Every grant already delivered keeps working. +- No manifest can be revised: prices, payout address, trackers and + `successor` are frozen at the last signed revision. +- Payouts continue to the frozen `pricing.payTo`. +- No new keeper can be delegated. Existing keepers keep granting. + +That is the failure mode, stated so that it can be planned for. Store the +seed in a vault. + +## 5. Hashing and chunking + +### 5.1 The content identifier + +The identifier of a file's bytes is its **BEP 52 pieces root**: the SHA-256 +merkle root over 16 KiB leaves, padded per BEP 52. It is written +`sha256:<64 hex>`. + +This is chosen over a flat SHA-256 or an IPFS CID because a swarm peer can +verify a single 16 KiB block against it with a merkle proof, which is what +lets a leecher sign a voucher the moment a block arrives. A flat hash would +make every payment wait for the whole file. A CID would be a second tree that +no BitTorrent client checks. + +An `ipfile` manifest carries two roots: `plainRoot` over the plaintext and +`cipherRoot` over the ciphertext. The swarm verifies against `cipherRoot` +through the torrent's own hashes; the client verifies its decrypted output +against `plainRoot`. + +c0mpute's chunk store addresses objects by blake3 (`c0mpute://blake3:<hex>`). +That stays internal to the node. A c0mpute storage node maps piece index to +blake3 chunk however it likes; what it puts on the wire is verified by SHA-256. + +### 5.2 Piece length + +A piece length MUST be a power of two and at least 16 KiB (BEP 52). Members +recommend defaults: `ipfile` 1 MiB for files over 64 MiB and 256 KiB below; +`ipaudio` 256 KiB; `ipvideo` 1 MiB; `iplive` one segment per piece. + +### 5.3 Hybrid torrents + +A swarm's info dictionary MUST be a hybrid v1/v2 torrent per BEP 52: it +carries both `pieces` (SHA-1, v1) and `file tree` with `piece layers` +(SHA-256, v2). Two infohashes result: + +- `infohashV1`: SHA-1 of the info dictionary, 20 bytes. This is what + WebTorrent in a browser uses, and what today's trackers and the bitmagnet + crawl behind bittorrented.com/dht record. +- `infohashV2`: SHA-256 of the info dictionary, truncated to 20 bytes for + DHT and tracker use, full 32 bytes in records. + +The info dictionary MUST NOT contain plaintext file names. `name` is the +ciphertext root in hex. A crawler that fetches metadata over BEP 9 learns a +size and a hash. + +## 6. Transports + +### 6.1 The wire + +Peer connections are BitTorrent (BEP 3) over TCP, uTP, or WebRTC data +channels (the WebTorrent dialect). Every standard message keeps its meaning. +OpenSwarm never changes `choke`, `unchoke`, `have`, `request`, `piece` or the +bitfield. That is what makes a vanilla client a valid, if unpaid, participant. + +### 6.2 Extension messages + +All family messages ride on the extension protocol (BEP 10). The extension +handshake advertises: + +``` +{ "m": { "ipfile": 20, "ipdb": 21, "iplive": 22, "ut_metadata": 3, "ut_pex": 1 }, + "v": "ip/0.1", + "openswarm": "0.1" } +``` + +The numbers are local ids chosen by the sender, as BEP 10 requires. The +names are fixed: `ipfile`, `ipdb`, `iplive`. Message payloads are bencoded +dictionaries with a string key `t` naming the message. Records embedded in a +message are carried as their UTF-8 JSON bytes under key `r`, so the bencode +layer never has to understand JSON. + +A peer that did not advertise a name MUST NOT be sent that extension's +messages. A peer receiving a message for an extension it advertised but a `t` +it does not know MUST ignore it. + +### 6.3 Browser peers + +WebTorrent supports BEP 10 extensions via `wire.use()`, and the four wss +trackers bittorrented.com's player already announces to +(`wss://tracker.webtorrent.dev`, `wss://tracker.openwebtorrent.com` are the +two that handshake today) find WebRTC peers. A browser peer signs vouchers +with WebCrypto Ed25519 and seals with X25519 from the same API. Nothing in +this family requires a native binary in a browser. + +A browser can only reach WebRTC peers. A swarm that wants browser reach MUST +have at least one hybrid peer (a native seeder that also announces to a wss +tracker), which is what media-streamer's `StreamingService` already is. + +### 6.4 Webseeds and gateways + +A gateway is a BEP 19 webseed: it serves ciphertext by HTTP range under the +URL the manifest lists. A gateway MAY require a pass (`Authorization: Bearer` +with the pass's `token`) and MAY require vouchers in a request header +(`ippay` §7). A gateway that requires neither is a free webseed and is how a +publisher pays for its own distribution instead of charging for it. + +### 6.5 Moshpit tunnel + +Between two native peers that both hold Moshpit identities, the BitTorrent +connection MAY be tunnelled through an MTP/1 session +(`@profullstack/moshpit-transport`: X25519 + ML-KEM-768 key agreement, +ML-DSA-65 server identity, ChaCha20-Poly1305 records, TCP). The `hello` +message carries the peer's MTP pin (`base64(SHA-256(SPKI))`) so a peer can +reconnect over the tunnel after meeting on the plain wire. MTP/1 has no +client authentication; ownership on the tunnel is still proved by `hello` +signatures (`ipfile` §5.1), not by the transport. + +MTP/1 is TCP only and 64 KiB per record. It is not available to browsers and +it does not replace the swarm wire. It is the confidentiality option for +node-to-node links, and it is the transport the hub API SHOULD be reachable +over in addition to HTTPS. + +## 7. Discovery + +### 7.1 Peers + +A swarm is found the way any torrent is: DHT `get_peers` on `infohashV1` and +on the truncated `infohashV2` (BEP 5), tracker announces (BEP 15, HTTP, and +wss for browsers), PEX (BEP 11), LSD. A manifest lists the trackers it wants. +A manifest MAY set `swarm.private` to true (BEP 27), which disables DHT and +PEX and leaves only trackers and webseeds; that is the choice for a swarm +whose existence should not be public. + +### 7.2 Latest manifest by file key + +The latest manifest for a file is announced as a BEP 44 mutable item: + +``` +k = file public key (32 bytes) +salt = "ipfile" +seq = manifest rev +v = bencoded { "ih": <infohashV1, 20 bytes>, "ih2": <infohashV2, 32 bytes>, "m": <manifest record id, 32 bytes> } +``` + +`v` is 100 bytes, well inside the 1000 byte BEP 44 limit. The item is signed +by the file key, which is why the file key is Ed25519 and why it is the file's +identity. This is BEP 46 with an extra pointer: the magnet form is +`magnet:?xs=urn:btpk:<file key hex>&s=ipfile`. + +The manifest itself is fetched from any peer in the swarm over the `ipfile` +`manifest` message, from a gateway at `/swarm/<infohashV2>/manifest`, or from +an `ipdb` replica. + +### 7.3 Feeds and names + +An `ipdb` feed head is a BEP 44 mutable item under the feed key with salt +`ipdb:<feed name>`. A Moshpit name resolves to a publisher key and a gateway +through `ipname`. A client that starts from a name, a feed, a file key or an +infohash reaches the same manifest. + +## 8. Events + +### 8.1 Envelope + +Every event is a CloudEvents 1.0 envelope, as `@profullstack/autoblog` +already emits: + +```json +{ + "specversion": "1.0", + "id": "7f2b5a7e-2b0e-4c8f-9a4d-3d6c0b9c1e10", + "type": "com.logicsrc.openswarm.ipfile.voucher.redeemed.v1", + "source": "https://coinpayportal.com/api/openswarm", + "subject": "sha256:41d10f45e705e0526c9eeedf62b32dda3daaf552dd9b6a7b09e01776b05813bb", + "time": "2026-09-05T18:02:11.000Z", + "datacontenttype": "application/json", + "data": { + "record": { "openswarm": "0.1", "type": "ippay.voucher" } + } +} +``` + +`type` is `com.logicsrc.openswarm.<protocol>.<noun>.<verb>.v1`. `subject` is +the record id the event is about. `data.record` is the full record where the +event carries one. Each member document registers its types. + +### 8.2 Delivery + +Webhooks are Standard Webhooks as autoblog signs them: `POST` with +`content-type: application/cloudevents+json`, headers `webhook-id` (the +event id), `webhook-timestamp` (unix seconds) and `webhook-signature` +(`v1,<base64 HMAC-SHA256>` over `<id>.<timestamp>.<body>`), a five minute +tolerance window, retries at 0, 10 and 60 seconds on network error, 5xx, 408 +and 429. Receivers verify with a constant-time compare. + +## 9. Versioning + +- `openswarm` on a record is the family version. A 0.x reader MUST accept + any 0.x record and MUST preserve unknown fields. +- Each member document has its own version history. Adding a record type, + a message `t` or a field is additive and does not bump the family version. +- Wire changes that break an existing message get a new `t` name. A message + name is never redefined, for the same reason an OpenCreds label is never + edited: peers that shipped it are still running. +- Extension names in the BEP 10 handshake (`ipfile`, `ipdb`, `iplive`) are + permanent. A breaking revision would ship as `ipfile2`. + +## 10. Conformance + +A conforming implementation of the core: + +1. Reads and writes records per §3, computes ids and verifies `ed25519` + signatures with the domain prefix. +2. Derives file keys and box keys per §4 from a seed, and produces a manifest + that a second implementation given the same seed and `fileId` can sign + identically. +3. Computes BEP 52 pieces roots and builds hybrid torrents per §5. +4. Speaks BEP 10 and ignores unknown `t` values per §6.2. +5. Publishes and reads the BEP 44 pointer per §7.2. +6. Emits events per §8 where it emits events at all. + +Member conformance is defined per document; the checklist is collected in +[conformance.md](./conformance.md). + +## 11. Registered record types + +| Type | Signer | Document | +| --- | --- | --- | +| `openswarm.binding` | subject and pq | this document §4.6 | +| `ipfile.manifest` | file key (and publisher key) | ipfile | +| `ipfile.grant` | grantor | ipfile | +| `ippay.hub` | hub key | ippay | +| `ippay.pass` | hub key | ippay | +| `ippay.voucher` | payer key | ippay | +| `ippay.receipt` | hub key | ippay | +| `ipdb.entry` | feed key | ipdb | +| `ipdb.head` | feed key | ipdb | +| `ipdb.tombstone` | feed key | ipdb | +| `ipaudio.release`, `ipaudio.track` | publisher key | ipaudio | +| `ipvideo.title` | publisher key | ipvideo | +| `iplive.channel`, `iplive.head` | channel key | iplive | +| `ipname.pin` | publisher key | ipname | + +## 12. Version history + +| Version | Date | Change | +| --- | --- | --- | +| 0.1 | 2026-09-05 | Initial draft. Records, derived file keys, BEP 52 identifiers, BEP 10 extensions, BEP 44 pointers, CloudEvents. | diff --git a/packages/logicsrc-mcp/src/standards.test.ts b/packages/logicsrc-mcp/src/standards.test.ts index 50340e8..14ce0a8 100644 --- a/packages/logicsrc-mcp/src/standards.test.ts +++ b/packages/logicsrc-mcp/src/standards.test.ts @@ -203,7 +203,7 @@ describe("MCP: OpenPRD", () => { it("reports the next free id and the allowed lifecycle moves", async () => { const client = await connect(); // Asserted against the live prd/ directory, so this advances with every PRD added. - expect(toolText(await client.callTool({ name: "prd_next_id", arguments: {} }))).toBe("0005"); + expect(toolText(await client.callTool({ name: "prd_next_id", arguments: {} }))).toBe("0006"); const moves = await client.callTool({ name: "prd_next_statuses", arguments: { ref: "0001" } }); const payload = JSON.parse(toolText(moves)) as { status: string; allowedNext: string[] }; diff --git a/prd/0005-add-logicsrc-openswarm-spec.md b/prd/0005-add-logicsrc-openswarm-spec.md new file mode 100644 index 0000000..4ca0a49 --- /dev/null +++ b/prd/0005-add-logicsrc-openswarm-spec.md @@ -0,0 +1,184 @@ +--- +openprd: "0.2" +id: "0005" +title: "Add the LogicSRC OpenSwarm specification family" +status: Draft +authors: + - anthony@profullstack.com +created: 2026-09-05 +updated: 2026-09-05 +repo: profullstack/logicsrc +discussion: +implementation: +tags: + - openswarm + - ipfile + - ipdb + - ipaudio + - ipvideo + - iplive + - bittorrent + - x402 + - c0mpute +supersedes: +superseded-by: +--- + +## Problem + +BitTorrent distributes bytes better than any CDN and has never been a +product, for two reasons. Nobody is paid to seed, so swarms die when the +uploader loses interest. Nothing in it is private, so a publisher who wants +to charge for access cannot use it at all. + +Every Profullstack media property has answered that by putting a central +HTTP proxy in front of the bytes and bolting a pass system onto it: +bittorrented.com's seedbox, IPTV and radio rails, tipoffwatch and +genrewatch's shared playlists, p0dcasters' episodes. Each one re-implements +pay-per-pass grants, session caps and manifest sealing, and each one pays +for every byte it serves. The swarm the site is named after cannot carry +the payment, so it is not used for the thing that costs money. + +c0mpute.com has the other half: nodes that already store, transcode, +gateway and (by design) relay live segments, paid through a job auction. +They have no way to earn from serving media once a job is done, and no +protocol that tells them what to seed. + +What is missing is an open, versioned description of a paid, encrypted +swarm: how a file is encrypted so the tracker learns nothing, how a peer +that serves a verified piece is paid for it, how the key reaches a peer +that paid, and how a catalogue of such swarms is replicated without a +server. And the same primitives, reused, for audio, video and live. + +## Goals + +- A publisher adds a file once and it stays available as long as anyone is + paid to seed it, on c0mpute nodes or on strangers' machines. +- A seeder is paid per verified byte served, with exposure bounded to a + small credit window, and a leecher never pays for a byte it did not + verify. +- A public tracker, DHT node or crawler learns an infohash and a size, not + a title. +- One key model: a publisher seed from which every file, feed and channel + key derives and can be recovered; a fresh unrelated key only on request. +- Vanilla BitTorrent clients remain valid swarm members and browsers remain + first-class peers (WebTorrent, WebCrypto, MSE). +- Payment reuses x402 and CoinPay exactly as `@profullstack/x402-gateway` + sells crawl passes today; events reuse CloudEvents and Standard Webhooks + as `@profullstack/autoblog` emits them; post-quantum links reuse + `moshpit-transport`; names reuse the Moshpit registry. +- Every building block (chunking, manifests, keys, encryption, payment, + relay) is specified once and reused by `ipaudio`, `ipvideo`, `iplive` + and c0mpute; reuse is the default posture. +- c0mpute nodes have concrete workload types to bid on and a per-byte + income path that needs no auction. + +## Non-Goals + +- Not a new transport. The wire is BEP 3, the extension is BEP 10, hashing + is BEP 52, discovery is BEP 5, 44 and 46. +- Not a token or a chain. Passes are bought once in USDC; vouchers are + off-chain and redeemed at a hub; payouts are batched. +- Not trustless settlement. A hub is trusted as a payment processor is. +- Not DRM. A paying peer holds the key; the spec says so. +- Not a player, a recommender, or a moderation system. +- Not an implementation. This PRD adds specifications only. + +## Users + +- **A publisher** (label, studio, podcaster, vendor, company backing up) + who wants distribution paid for by the people who want the bytes. +- **A seeder or relay operator**, including every c0mpute node, who wants + to be paid for bandwidth and disk. +- **A listener or viewer** in a browser or an app, who wants to pay once + and stream without a server deciding whether they may. +- **An agent** with an x402 wallet that buys a dataset unattended. +- **An implementer** who wants to interoperate from the published + documents without reading Profullstack source. + +## Requirements + +- R1 [P0] Define the core: signed JSON records with JCS canonical form and + SHA-256 ids, Ed25519 signatures with domain prefixes, optional ML-DSA-65, + derived per-file, per-feed and per-channel keys from one seed, BEP 52 + identifiers, BEP 10 extension names, BEP 44 pointers, CloudEvents. +- R2 [P0] Define `ipfile`: the manifest, the grant, AES-256-CTR piece + encryption with two merkle roots, the `hello` and payment messages, the + credit window and voucher interval, key delivery by peer and by URL, + vanilla coexistence, publishing and retrieval procedures. +- R3 [P0] Define `ippay`: passes bought over x402 with CoinPay's v2 offer + and proof, payer binding, cumulative vouchers with exact integer value + arithmetic, the hub record and API, receipts and split arithmetic, payee + registration, standing, payout, HTTP presentation headers. +- R4 [P0] Define `ipdb`: signed hash-chained feeds, entries with put and + delete, heads on the DHT, replication by `ipfile` segments and by gossip, + fork detection, the query shape and result, playlists and ratings. +- R5 [P0] Define `ipaudio`: track and release records, renditions as + `ipfile`s, seek index, aligned anchors, gapless trim, royalty split, the + gateway bridge and RSS. +- R6 [P0] Define `ipvideo`: title record, CMAF renditions with segment + index, subtitles, thumbnails, chapters, MSE playback, byte-range HLS + bridge, transcode on c0mpute. +- R7 [P0] Define `iplive`: channel and head records, key epochs, the + `iplive` extension with `window` backpressure, roles and relay + economics, low-latency parts, recording to `ipvideo`. +- R8 [P1] Define `ipname`: the name record, the `openswarm` registry pin + kind, the DNS TXT form, clearnet-first, the `ip://` URL. +- R9 [P1] Define the c0mpute integration: node identity and payee + registration, seven workload types with `JobOffer` shapes, at least ten + use cases with CLI flows, the list of proposed changes to the node. +- R10 [P1] Propose the `ip` CLI as a contract: command tree, exit codes, + output fields, environment. +- R11 [P1] Publish conformance profiles and checklists, a security model, + and an FAQ. +- R12 [P1] Publish the family at `logicsrc.com/openswarm` with the same + site registration as OpenCreds (nav, docs registry, sitemap, landing). +- R13 [P2] Ship JSON Schemas under `@logicsrc/schemas` and a reference + implementation with the fixture suite described in conformance.md. + Deferred to a later PRD; this one is documents only. + +## UX Notes + +Adding a file is one command and prints everything a person needs to hand +out: the file key, both infohashes, a magnet link and an `ip://` URL. The +seed is created once and reused; the CLI never prints it without +`--reveal`. + +Fetching is one command that buys or reuses a pass, joins, pays, decrypts +and verifies, and reports what it paid and who granted the key. A pass +scoped to a publisher rather than a file is what a player wants, so a +listener buys once per label, not per track. + +Failure modes are named: `no-pass`, `pass-cap`, `unpaid`, `not-keeper`, +`voucher-stale`. A choke is explained by a `credit` message, not by +silence. + +## Success Metrics + +- A swarm seeded by one implementation, paid for by a leecher from a + second, with vouchers redeemed at a third party's hub, decrypts to + `plainRoot`. +- bittorrented.com's browser player streams an `ipaudio` track from peers + with no proxy in the path and the publisher's share arrives at the + address in the manifest. +- A c0mpute node with no jobs earns voucher income from a swarm it chose + to pin. +- Every spec document has zero em dashes and every JSON example is valid. + +## Risks & Open Questions + +- **Custodial hub.** CoinPay's x402 rail pays merchants directly and + collects no fee. A hub holds balances and pays out; that is a different + regulatory position and a product decision. +- **EIP-3009 cannot bind a resource.** Mitigated at the hub by recording + nonce to resource; inherent to x402 v2. +- **Key copying.** A grantee can leak the key. The family prices access + and does not prevent copying; re-encryption is the only remedy. +- **The Moshpit registry has no `openswarm` pin kind.** One-line change on + the registry side; until then `ipname` works over DNS TXT only. +- **Name.** The working name was IPMedia. OpenSwarm is proposed for the + reasons in the overview; the `ip*` protocol names stay either way. +- **Seed loss** freezes every derived file. Mitigated by storing the seed + in OpenCreds and by standalone keys for files that change hands. +- **Spec before code.** Every wire format here is unimplemented. The + fixture suite and the interop test are how this gets honest. diff --git a/prd/README.md b/prd/README.md index 057fda1..4ef71fd 100644 --- a/prd/README.md +++ b/prd/README.md @@ -15,3 +15,4 @@ Status lives in each file's front-matter and is the source of truth: | [0002](./0002-hourly-hire-us-rate.md) | Move Hire Us pricing from a weekly retainer to an hourly rate | Accepted | pricing, site, billing | | [0003](./0003-add-logicsrc-opencontext-spec.md) | Add the LogicSRC OpenContext specification | Draft | opencontext, context, agents, permissions, provenance, schemas | | [0004](./0004-add-logicsrc-opencreds-spec.md) | Add the LogicSRC OpenCreds specification | Draft | opencreds, credentials, vault, encryption, portability, schemas | +| [0005](./0005-add-logicsrc-openswarm-spec.md) | Add the LogicSRC OpenSwarm specification family | Draft | openswarm, ipfile, ipdb, ipaudio, ipvideo, iplive, bittorrent, x402, c0mpute |