Pay-to-open content, previews, Discord-model public channels · qalpub/README.md
qalpub — Paid-Unlock Content & Public Channels
Service: python3 qalpub/qalpub.py → 127.0.0.1:8832 · Suite: bash test_qalpub.sh (42 assertions, green) + bash test_workspaces.sh (54 assertions, green — v2) · Date built: 2026-09-09 (v2 same day)
What: the public content layer of the Qals communication stack — deliberately on its own port, separate from the private relay (:8830, separation doctrine).
A. Paid-unlock content ("send NFTs/images/PDFs/data that only open once a chosen QALS amount is paid — previews allowed")
seller qalpub :8832 buyer
│ encrypt file (AES-GCM) │ │
├─ content blob ─────────► store │
├─ key wrapped for ESCROW► listing {price, preview?, title} │
│ │◄──── intent ───────────────────┤
│ │──── payment request ──────────►│ (loopd transfer → escrow agent)
│ │◄──── redeem + receipt id ──────┤
│ │ verify receipt vs loopd │
│ escrow→seller payout ◄───┤ release key WRAPPED TO BUYER ──►│ decrypt → sha256 must match
│ │ anchor unlock record on-chain │
- No payment → no key, ever (receipt-verified redemption; forged receipts 402, replays across listings 402, reuse 409)
- Previews: image or text-blur preview blobs shown before purchase (seller's choice)
- Trust model (honest): content confidentiality is cryptographic (escrow-wrapped keys), but v1's escrow trustee is the qalpub operator — on-chain upgrade path noted in code; unlocks are anchored so misbehavior is provable.
B. Public channels (Discord-model)
#channelswith topics; posts are public plaintext + ed25519 author signatures (that's the point of public);sig ✓/⚠verification on read- Daily Merkle digest of every channel's posts anchored on Qalnet (background thread + manual force) — tamper-evident public history without putting every post on-chain
- Discord lineage: channels now; servers/roles/threads are the next layer (see wallet Chat v3 note)
CLI
| Command | Who | What |
|---|---|---|
qalpay.py send-locked <to> <file> --price-cents N [--preview F] [--title T] [--public] |
seller | seal + list (+ direct manifest message to buyer) |
qalpay.py listings [--mine] / buy-unlock <id> |
buyer | browse / pay→redeem→decrypt→verify |
qalpay.py serve --as <handle> |
seller | payout-watching daemon (optional) |
qalchannels.py list / create <name> --topic / post <ch> <text\|--file F> / read <ch> |
everyone | public boards |
MCP tool specs (JSON block in source) are wired for agents: send_locked, list_listings, buy_unlock, channels_list, channels_post.
APIs
/health /escrow/pubkey · blobs POST /blobs GET /blobs/{id} · listings POST/GET /listings[/{id}] · POST /listings/{id}/intent POST /listings/{id}/redeem · channels POST/GET /channels POST/GET /channels/{id}/posts · GET /channels/{id}/digest
C. Workspaces & the Slack-class layer (v2, 2026-09-09)
On top of the v1 channels: workspaces, threads, reactions, mentions, pins, presence, search, and digest v2. Proof suite: bash qalpub/test_workspaces.sh (54 assertions, green).
- Workspaces
{workspace_id, name, topic, owner, channels[], members[]}—POST /workspaces(create; owner auto-member),POST /workspaces/{id}/join|leave(owner can't leave),GET /workspaces[/{id}](detail joinschannels_meta).POST /workspaces/{id}/channelscreates a channel inside the ws and links it. Membership is handle-based TOFU — exactly as strong (and as honest) as the v1 identity model. - Threads — posts take an optional
parent_post_id("<chid>:<seq>", same channel, must exist). The parent is signed into the canonical (v1 canonical is byte-identical when no parent, so old sigs/digests verify unchanged).GET /channels/{id}/thread/{seq}→{parent, replies[]}; every served post carriesreply_count. - Reactions —
POST /reactions {post_id, emoji, by}unique per (post, emoji, by) → dup = 409;DELETE /reactionsretracts (404 if absent);GET /reactions?post_id=→{emoji: {count, by[]}}; reactions also ride inline on every post read. - Mentions — server-side parse of
@handlein post bodies into a derivedmentions[]field (syntactic, unsigned — honest).GET /mentions?handle=&workspace=|channel=across a workspace, newest first, withsig_okflags. - Pins —
POST /channels/{id}/pins {post_id, by}(dup = 409),DELETE /channels/{id}/pins {post_id},GET /channels/{id}/pins→ pinned posts withpinned_by. - Presence —
POST /presence {handle, status(online/away/offline), ts}with 90 s TTL;GET /presence[?workspace=&now=](thenowknob lets auditors evaluate expiry at any instant).typing_tofield = DM typing beacons with a 5 s TTL. Honest note: presence is relay-observed liveness, NOT cryptographically attested. - Search —
GET /search?q=&workspace=|channel=— case-insensitive plaintext search over public post bodies + attachment names, results carrysig_ok. - Digest v2 — the daily Merkle digest now folds activity leaves (threads with reply counts, reaction counts+by, pins) into the tree:
type: qalpub-channel-digest-v2,merkle_root(v2),counts {threads, replies, reactions, pins}, plusmerkle_root_posts_v1so a v1 verifier recomputing leaves from served posts lands byte-exactly on the posts-only root (proven in the suite). Both v1 and v2 records verify on Qalnet via qalpipe.
Trust deltas (v2, honest): posts/thread-placement stay ed25519-signed + tamper-evident; reactions/pins/presence/workspace-membership are server-trusted, handle-claimed (paper edition) — the same trust level as v1 channel creation. Signed social actions are a phase-3 upgrade path.
CLI: qalchannels.py post <ch> <text> --reply-to <seq> --as HANDLE signs the parent into the reply; read shows ↳#parent and reply counts.
D. Pay-to-post — spam & fake-actor economics (v3 fee layer, 2026-09-10)
Communities can set a posting fee in QALS per channel (post_fee_cents, default 0 = free). The point is economic, not moral: a sybil's cost scales with fee × attempts — every fake account must actually move QALS to the channel treasury per post, so spam farms burn their own float instead of everyone's attention, and honest users who post a handful of times are barely charged (or not at all, see trust). The refundable window (refundable_until_ms) completes the loop: mods deleting a paid post inside the window auto-refund the fee from treasury → author, so self-moderation is free for honest users and unrecoverable for spam (a deleted spam post refunds the author's fee only if the mod judges it a refund-worthy deletion — the mod decides, the money moves on-ledger).
- Fee config — set at channel create (
post_fee_cents,trusted_after,refundable_until_ms) or later by the workspace owner / channel creator:POST /channels/{id}/fee {fee_cents, trusted_after?, refundable_until_ms?, by}(non-owner → 403). Displayed inGET /channels/{id}andGET /channels/{id}/fee[?author=](the?author=form returnseffective_fee_cents+trusted— this is what a wallet composer renders as the fee chip). - Paid posting flow —
POST /channels/{id}/postson a fee>0 channel requirespayment_receipt_id: a real loopd transfer from the author's agent to the channel treasury (auto-provisioned per channel: handleqalpub-<channel_id>, escrow ensure/claim pattern, secret held 0600 by qalpub). qalpub verifies the receipt against loopd/receipts: kind=transfer, recipient = this treasury, signer = the author's agent, cents ≥ fee, memo contains the channel id — then the post lands withpayment_receipt_id+paid_centsembedded (visible proof-of-payment in every GET). Missing receipt → 402 with the payment chip (fee_cents,to_agent_id,memo_hint); underpayment → 402; receipt replay → 409 (single-use registry, same doctrine as unlocks). - Trust ladder (
trusted_after) — channels can set N: after N clean posts in that channel, an author's effective fee drops to 0 (per-author, per-channel counters; mod-deleted posts decrement — spam can't farm trust by getting deleted). Config matrix: free (fee 0, default — unchanged behavior), paid (fee>0, everyone pays), paid+trusted (fee>0 until N clean posts, then free). - Treasury —
GET /channels/{id}/treasury→{collected_cents, refunded_cents, payouts_cents, balance_cents, balanced, entries[]}with the invariant collected = refunded + payouts + balance (proven in the suite). Workspace owner pays out revenue (moderation wage) viaPOST /channels/{id}/treasury/payout {to_handle, cents, by}(owner-only, overdraft → 400). - Digest v3 — the daily Merkle tree additionally folds one
fee|<post_id>|<receipt_id>|<cents>leaf per paid post plus one treasury-delta leaf (collected/refunded/payouts in the window), so the anchored history commits to the money flow as well as the words. Fee-free channels produce byte-identical trees to the previous format.
Honest trust note (same tier as v1/v2 socials): the fee check is receipt-verified against loopd (cryptographic), but moderation (who deletes, who gets refunds, who owns the channel) is handle-claimed/paper-trust. A mod-deleted post's fee leaf stays in the anchored tree for the day it was posted; the refund and the deletion show up in the treasury ledger and the next digest window.
Wallet UI integration: read the fee chip from GET /channels/{id}/fee?author=<me> (post_fee_cents, effective_fee_cents, trusted, treasury.agent_id, memo_hint); on 402 the error body itself carries the same chip, so a composer can go straight from refusal → payment sheet → retry with payment_receipt_id. CLI: qalchannels.py post <ch> "text" --receipt <loopd_receipt_id> --as me.
Proof suite: bash qalpub/test_paytopost.sh (58 assertions, green).
E. Private invite-gated channels (phase c, 2026-09-12)
visibility: private channels — the server stores ciphertext only. Posts are sealed client-side with a fresh 32-byte CHANNEL KEY (AES-256-GCM, aad = qalpub/channel/v1|<channel_id>|<post_ts>) into a qenc1:<nonce>:<ct> envelope; the envelope is what gets ed25519-signed (sig covers ciphertext — still tamper-evident after decryption). The key exists at rest ONLY as ECIES wraps (qalpub-ecies-v1, HKDF domain qalpub/chwrap/v1): a keeper wrap (0600 channel_keys.json, operator-trustee tier) + one wrap per member to their relay-keyserver x25519 key. Create --private / invite (creator-only, 403 otherwise) / join (returns YOUR wrap or 403) / remove (creator-only, optional rotate:true → fresh key + re-wrap remaining + era bump) / meta-only views for non-members (no topic, no members, no post bodies; sub-resources 403). Era stamping on posts lets clients pick the right key; rotation rekeys the channel so revoked members cannot read new content.
Honest limitations (v1, by design):
- No forward secrecy after revocation — a removed member who kept ciphertext copies + their old key can still read era-N posts; rotation only protects era N+1. Rekey-every-post (ratchet) is the upgrade path.
- Operator-trustee keeper — the qalpub operator holds the keeper x25519 key and can recover the current channel key in memory (same tier as the listings escrow). Raw keys are never persisted server-side.
- TOFU keyserver invites — invite wraps use the invitee's keyserver x25519 key; a hostile keyserver could pre-substitute before any pin (same tier as qalchat invites).
- Membership is handle-claimed (paper-trust, like v1 channel creation); the 403s leak nothing beyond member_count.
Proof suite: bash qalpub/test_private_channels.sh (isolated instance :8873, QALPUB_SKIP_ESCROW=1, sandbox identities in QALCHAT_DIR=/tmp/pvtest_idents; never touches the live :8832/:8830 services) — 10/10 proofs green, incl. a cryptographic stale-era-key-fails-era-2 proof (pv_era_proof.py).