Skip to main content
Status: Draft Type: Standards Track Category: Core Created: 2026-02-17 Revised: 2026-04-16 (renamed to Watchtower; content unchanged) Requires: CIP-2 (Off-Chain Compute), CIP-3 (Dual-Metered Gas), CIP-5 (Timers)
Normative conventions. The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119.

Summary

This CIP defines Watchtower, the canonical stream protocol for Cowboy. Watchtower is the product name for any implementation of this specification; the terms “Watchtower,” “Watchtower feed,” and “StreamActor” are used interchangeably throughout. A StreamActor is the Watchtower feed primitive. Publishers append ordered messages to a stream. Consumers use push, pull, or push-with-pull-fallback delivery. Filtering is deterministic and evaluated only on message headers. The protocol stores payloads inline with a bounded on-chain replay window. For paid streams, encryption and decryption are off-chain SDK operations backed by CBSS (CIP-24) threshold proxy re-encryption, while key-access billing is a native platform event settled in CBY via the Stream Key Manager system actor. X25519 account keys serve as the CBSS recipient identity; one access purchase covers every downstream consumer the account holder delegates to. The protocol collects a configurable fee on every key-access purchase. The cowboy watchtower CLI (see CLI spec) is the reference toolchain for deploying, publishing to, and subscribing to Watchtower feeds. The WatchtowerRegistry system actor, when deployed, is a named-feed index built on top of this protocol and is the canonical discovery mechanism for public Watchtower feeds.

Abstract

CIP-7 standardizes:
  • One StreamMessage format for news, pricing, alerts, and other stream kinds
  • Explicit message versioning for forward compatibility
  • Strictly increasing per-stream sequence numbers with no gaps
  • Subscription and delivery semantics for PUSH, PULL, and PUSH_WITH_PULL_FALLBACK
  • A deterministic JSON filter DSL over headers/tags
  • A bounded replay window (10,000 messages) with explicit CURSOR_TOO_OLD
  • Optional timer-driven ingestion through CIP-2 runners
  • Single active publisher key with deterministic key rotation cutover
  • Optional subscriber-paid access with CBSS-routed key custody, on-chain stream-access records, epoch keying, and native CBY billing

Motivation

CIP-7 optimizes for:
  • Fast deployment with one actor pattern
  • Real-time multicast delivery
  • Deterministic replay and filtering behavior
  • Practical support for news feeds and higher-volume pricing via micro-batching
  • Platform-managed encryption so actors never implement crypto
  • Native CBY value capture from paid stream economics

Design Goals

  • One protocol, one actor model
  • Deterministic behavior for sequence, signing, filters, and replay
  • Push and pull both first-class
  • Explicit resource bounds for storage and fan-out work
  • Optional ingestion without changing core stream semantics
  • CBSS-routed key custody for paid mode (no chain-wide master secret; no validator-decryptable content)
  • Native per-key-epoch billing with protocol fee support
  • Account-scoped X25519 keys (CBSS recipient identity) with sponsored purchases (payer != beneficiary)
  • Rolling access windows

Non-goals

  • Permanent storage guarantees
  • Exactly-once delivery
  • Ack/retry protocol in core spec
  • Payload-level query language
  • External payload URI hosting guarantees
  • Actor-defined custom cryptography for paid streams
  • Custom billing assets beyond CBY in v1
  • Off-protocol key marketplaces or resale
  • On-chain key escrow or multi-operator key distribution (future CIP)
  • Refundable prepaid balances (future CIP)

Protocol Constants

  • MAX_INLINE_PAYLOAD_BYTES = 16_384 (16 KiB)
  • DEFAULT_RING_BUFFER_CAPACITY = 10_000
  • MAX_GET_SINCE_LIMIT = 500
  • DEFAULT_MAX_SUBSCRIBERS = 10_000
  • DEFAULT_INGEST_INTERVAL_BLOCKS = 1
  • DEFAULT_KEY_EPOCH_BLOCKS = 600
  • BILLING_ASSET = CBY (paid mode v1 REQUIRED)
  • MAX_PROTOCOL_FEE_BPS = 5_000 (50% cap)
  • CONTENT_CIPHER = XCHACHA20_POLY1305 (paid mode v1 REQUIRED)
  • NONCE_BYTES = 24
  • TAG_BYTES = 16
  • MAX_EFFECTIVE_PLAINTEXT_BYTES = 16_344 (16,384 - 24 - 16)
  • DEFAULT_MIN_PURCHASE_EPOCHS = 1
  • MAX_ACCOUNT_KEYS_PER_ACCOUNT = 8
  • MAX_EPOCHS_PER_ACQUIRE = 256 (per-tx cap on the SealRequest fan-out of a single acquire_epoch_access call)
  • REQUEST_FRESHNESS_BLOCKS = 384 (CBSS-defined; mirrored here for SealRequest expiration)

Definitions

  • StreamActor: Actor implementing this CIP’s interface
  • StreamMessage: Canonical message envelope with ordered sequence
  • Cursor: Last consumed sequence used with get_since
  • Header filter: Deterministic filter over kind, tags, sequence, timestamp_unix_ms
  • Head sequence: Latest published sequence
  • Floor sequence: Oldest retained sequence in ring buffer
  • Key epoch: Time window (measured in blocks) during which a single content encryption key is active
  • Beneficiary account: Account that receives stream access (the CBSS recipient identity for delivered per-epoch content keys)
  • Payer account: Account charged in CBY for stream-access extension (may differ from beneficiary)
  • Account key: Account-scoped X25519 key registration used as the CBSS recipient for committee-delivered per-epoch content keys
  • Rolling access window: active_until_key_epoch upper bound per (stream_id, beneficiary_account)
  • Stream secret: 32-byte symmetric master key generated by the publisher off-chain. Used only as the HKDF input to derive per-epoch content keys; never wrapped to the committee, never shared, never on-chain. If lost, the publisher rotates by generating a new stream secret and re-registering all upcoming content keys (past epochs remain decryptable by anyone who already paid for them).
  • Content key: Per-epoch AEAD key derived as HKDF(stream_secret, "cip-7-content-key-v1", chain_id || stream_id || u64_be(key_epoch)). Used by XChaCha20-Poly1305 for payload_inline encryption/decryption. The publisher IBE-encrypts each content key to the committee MPK and registers it on-chain as WrappedContentKey.
  • Generation: Per-epoch monotonic counter at 0xD || 0x06 || keccak(stream_id) || u64_be(epoch). Starts at 1, increments on every re-wrap of that epoch’s content key. Bound into the IBE identity so a threshold signature obtained for one generation cannot decrypt a re-wrapped ciphertext under the same epoch.
  • SealRequest: On-chain CBSS request for the committee to threshold-sign a per-(stream, epoch, generation) identity, enabling the recipient to unwrap the WrappedContentKey for that epoch. Minted by SKM during acquire_epoch_accessone SealRequest per newly-covered epoch.

Stream Model

Each stream is represented by one actor with:
  • stream_id
  • head_sequence (starts at 0; first publish becomes 1)
  • floor_sequence (starts at 1; advances as pruning occurs)
  • single active publisher_key
  • key schedule history for signature verification across rotations
  • ring_buffer_capacity (default 10,000)
  • max_subscribers
  • subscription_policy
  • push delivery work limits
  • optional ingestion configuration
  • optional paid-mode configuration for CBSS-routed key custody and key-access billing

Platform Architecture (Paid Mode)

Paid stream confidentiality is delegated to CBSS (CIP-24 Secrets Manager at seed 0x04), which provides identity-based threshold encryption over BLS12-381. CIP-7 introduces only the Stream Key Manager (SKM) system actor — a thin coordination layer that handles billing, access tracking, and SealRequest emission to CBSS. Encryption and decryption themselves are off-chain operations performed by publisher and subscriber processes, using on-chain wrapped secrets delivered via the CBSS committee. This design has four consequences:
  1. Validators cannot decrypt paid content. Per-epoch wrapped content keys are IBE-encrypted to the CBSS committee’s master public key (MPK) and only recoverable via a t-of-n threshold of committee partial signatures.
  2. Per-epoch billing is enforced cryptographically. The publisher wraps one content key per (stream_id, key_epoch, generation) triple. Subscribers paying for epochs E+1..T receive committee threshold signatures only for those specific epochs and cannot derive keys for unpaid epochs. The publisher’s master stream_secret never leaves the publisher’s keystore.
  3. No VM-side stream_encrypt / stream_decrypt host functions. Encryption happens in publisher SDK; decryption happens in subscriber SDK. Both are bounded by audited library code (cbss-client, cbss-crypto).
  4. acquire_epoch_access is synchronous for billing, asynchronous for key material. Billing settles in the same block; per-epoch wrapped-key delivery follows the standard CBSS partial-aggregation flow (typically 4–5 blocks per epoch’s SealRequest; capped at REQUEST_FRESHNESS_BLOCKS = 384).

Stream Key Manager System Actor

A new system actor is added at deterministic seed 0x000000000000000D (see whitepaper §9 for the canonical system-actor allocation across all CIPs). Note: CIP-2’s Entitlement Registry (a separate system actor governing runner-job entitlements) is unrelated to this CIP’s stream-access concept (see StreamAccess). The two are deliberately disambiguated by name. The Stream Key Manager is the single authority for:
  • Per-epoch wrapped-content-key registration (publisher → CBSS committee)
  • Account X25519 key registration (subscriber recipient binding)
  • Stream access tracking and CBY billing
  • Per-epoch SealRequest emission to CBSS upon successful acquire_epoch_access
  • Protocol fee collection
The SKM does not hold any decryption material. The CBSS committee is the sole party (in aggregate) that can produce the BLS signatures needed to unwrap per-epoch content keys.

HostApi Extensions

Two new methods are added to the HostApi trait, exposed to actor Python code as VM host functions:
fn acquire_epoch_access(
    &mut self,
    params: &[u8],  // canonical-CBOR AcquireEpochAccessParams
) -> HostResult<Bytes>;  // canonical-CBOR KeyAccessReceipt

fn register_account_key(
    &mut self,
    params: &[u8],  // canonical-CBOR RegisterAccountKeyParams
) -> HostResult<Bytes>;  // canonical-CBOR AccountKeyRegistration
Encryption and decryption are not VM host functions; they are SDK-side operations (see SDK and Off-Chain Components). All other SKM operations (register_content_keys, revoke_account_key) are invoked via standard actor message calls to 0x0D. All CBOR-encoded params and results use the canonical CBOR rules already established by cowboy_sdk.codec (RFC 8949 canonical form: sorted map keys, shortest integer encoding, no indefinite-length, float64-only).

Storage Layout

The Stream Key Manager uses storage sub-prefix 0xD under its system actor address:
0xD || 0x01 || keccak(stream_id)                                                          -> PaidStreamConfig
0xD || 0x02 || keccak(account)                                                            -> [AccountKeyRegistration...]
0xD || 0x03 || keccak(stream_id) || u64_be(key_epoch)                                     -> WrappedContentKey (IBE-encrypted to committee MPK)
0xD || 0x04 || keccak(stream_id) || keccak(account)                                       -> StreamAccess
0xD || 0x05 || keccak(stream_id) || keccak(account) || u64_be(account_key_id) || u64_be(epoch)  -> SealRequestStatus
0xD || 0x06 || keccak(stream_id) || u64_be(key_epoch)                                     -> u64 generation (incremented on re-wrap of that epoch)
All state is committed through the standard MPT path and included in state_root. No raw content_key is stored anywhere on-chain. Each WrappedContentKey is the IBE ciphertext of content_key_e = HKDF(stream_secret, "cip-7-content-key-v1", chain_id || stream_id || u64_be(key_epoch)), where stream_secret lives only in the publisher’s off-chain keystore. No actor_nonce is stored: encryption nonces are generated client-side by the publisher (random 24-byte XChaCha20 nonces, freshness ensured by publisher-side RNG and bound by AEAD AAD). SealRequestStatus is keyed per (stream, beneficiary, account_key_id, epoch) so that a beneficiary account holding multiple active X25519 keys can have an independent in-flight SealRequest per key for the same epoch without overwriting one another. This is load-bearing: previously, switching account_key_id mid-flight would silently drop the prior key’s pending delivery (late partials would be ignored). With this layout, each key’s request lifecycle is isolated.

Content-key Identity (Normative)

CBSS IBE signs a G1 hash-to-curve point. CIP-7 defines the bytes layer, the domain separation tag (DST), the hash-to-curve mapping, and the AEAD AAD used by WrappedDek — each in exact form so cross-language and cross-implementation derivations are byte-identical. Domain separation tag (DST). Used for IBE hash-to-curve. Follows the existing CBSS naming convention (cf. CIP9_VOLUME_DEK_IBE_DOMAIN):
CIP7_CONTENT_KEY_IBE_DOMAIN = b"cbss/ibe/cip7-content-key/v1"
The chain MUST use this exact byte string. The hash-to-curve operation itself uses the BLS12-381 G1 implementation in blstrs::G1Projective::hash_to_curve (SSWU map, random oracle). Bytes form. The pre-hash identity payload:
content_key_identity_bytes(chain_id, stream_id, key_epoch, generation) =
    b"cip-7-content-key-v1"           // 20-byte literal domain prefix
    || u64_be(chain_id)                 // 8 bytes
    || u32_be(stream_id.len())          // 4 bytes (stream_id is variable length)
    || stream_id                        // stream_id.len() bytes
    || u64_be(key_epoch)                // 8 bytes
    || u64_be(generation)               // 8 bytes
The length prefix on stream_id prevents collision between e.g. ("abc", epoch=42) and ("abc\x00\x00\x00\x00\x00\x00\x00\x2a", epoch=0). Hash-to-curve. The IBE identity point is computed via the BLS12-381 G1 hash-to-curve operation specified in draft-irtf-cfrg-bls-signature §4.2:
identity_point = hash_to_curve_g1(
    msg = content_key_identity_bytes(...),
    dst = CIP7_CONTENT_KEY_IBE_DOMAIN,
)
Concretely the implementation calls cbss_crypto::hash_to_curve_g1(msg, &CIP7_CONTENT_KEY_IBE_DOMAIN), reusing the same H2C primitive CIP-9 uses (different DST, so no cross-feature signature reuse). The identity_point is the input to both ibe_encrypt(committee_mpk, identity_point, content_key, aad) and the committee’s partial-sign operation. WrappedDek AAD. The AAD baked into the AES-GCM layer inside WrappedDek is a fixed-width byte concatenation (mirrors the CBSS aad_for_secret_version convention — no CBOR, deterministic, no parser ambiguity):
base_wrapped_dek_aad(chain_id, stream_id, key_epoch, generation, committee_epoch, committee_selector) =
    b"cip-7-wd-v1"                  // 11-byte literal version prefix
    || u64_be(chain_id)              // 8 bytes
    || u32_be(stream_id.len())       // 4 bytes
    || stream_id                     // stream_id.len() bytes
    || u64_be(key_epoch)             // 8 bytes
    || u64_be(generation)            // 8 bytes
    || u64_be(committee_epoch)       // 8 bytes
    || selector_byte                 // 1 byte: 0x00 = default committee, 0x01 = override
    || override_hash_or_zero         // 32 bytes: committee override hash, or all-zero for default
This is the base AAD. The AEAD AAD actually baked into the v2 WrappedDek extends it with the Boneh-Franklin encryption ephemeral, exactly as in CIP-24:
aad = base_wrapped_dek_aad(...) || compress(U)      // U = r · G2_gen, 96-byte compressed G2
The publisher MUST pass the base AAD to ibe_encrypt, which samples a fresh r != 0 per wrap, publishes U in the envelope’s ephemeral_u field, and binds compress(U) into the AAD. SKM verifies at register_content_keys time that ephemeral_u is a canonical non-identity G2 point and that the submitted WrappedDek.aad equals base || ephemeral_u by full equality — exact length included (mismatch → INVALID_WRAPPED_DEK_AAD). Notation: U is the G2 group element; ephemeral_u = compress(U) is its stored 96-byte canonical compressed encoding, and formulas over the stored field use ephemeral_u directly. Subscriber unwrap recomputes and verifies the full AAD before decrypt. Cross-language Python and Rust implementations both emit byte-identical AAD without any CBOR codec involved. Why the layers stack.
  1. The DST + hash-to-curve isolate CIP-7 from CIP-9 even if their pre-hash bytes happen to collide.
  2. The bytes-form length-prefixing prevents collisions between different stream_id values.
  3. generation in bytes form means re-wrapping bumps the H2C point — old σs stop working for new wrapped material.
  4. The WrappedDek.aad includes committee_epoch, so an attacker can’t swap a WrappedDek from a prior committee into a new SealRequest path: the AEAD tag verification fails.
  5. The per-wrap ephemeral U bound into the AAD is what makes the wrap key secret at all: K = HKDF(serialize(e(I, MPK)^r)), so K is not recomputable from the public (I, MPK) (CIP-24 confidentiality fix), and post-publish tampering with ephemeral_u is a hard AEAD failure rather than a silent mis-derivation.
generation itself is the integer at 0xD || 0x06 || keccak(stream_id) || u64_be(key_epoch). It starts at 1 on first register_content_keys for that epoch and increments on every re-wrap.

SDK and Off-Chain Components

The reference Python SDK (cowboy_sdk.stream) exposes off-chain helpers backed by cbss-client and a vetted AEAD library (PyNaCl / libsodium for XChaCha20-Poly1305): Publisher:
  • Stream.derive_content_key(stream_secret, chain_id, stream_id, key_epoch) -> bytes(32) — pure HKDF-SHA256.
  • Stream.encrypt(content_key, plaintext, aad) -> ciphertext_envelope — XChaCha20-Poly1305 with a fresh CSPRNG nonce; returns nonce(24) || ciphertext || tag(16).
  • Stream.wrap_content_key(release_key, identity, content_key) -> WrappedDek — wraps a derived content key to the committee MPK for a specific (stream, epoch, generation) identity.
  • Stream.pre_wrap_epochs(stream_secret, chain_id, stream_id, from_epoch, to_epoch) -> [(epoch, wrapped_dek)] — derives + wraps a range of upcoming epochs in one call, ready to submit via register_content_keys.
Subscriber:
  • Stream.unwrap_content_key(σ, wrapped_dek, identity_aad) -> content_key — verifies aggregated threshold signature, decrypts WrappedDek to recover the per-epoch content key.
  • Stream.decrypt(content_key, ciphertext_envelope, aad) -> plaintext — XChaCha20-Poly1305 decrypt with mandatory AAD check.
  • Stream.acquire_access(stream_id, beneficiary, payer, target_epoch, account_key_id) -> KeyAccessReceipt — VM host fn wrapper.
  • Stream.register_account_key(scheme, public_key) -> AccountKeyRegistration — VM host fn wrapper.
These helpers are wrappers over cbss-client::wrap_cip7_content_key, combine_verified_partials, and unwrap_cip7_content_key. Implementers MUST NOT roll their own AEAD or HKDF. The Python AEAD library MUST support XChaCha20-Poly1305 natively (PyNaCl crypto_aead_xchacha20poly1305_ietf_encrypt). Publisher operational pattern (non-normative): a publisher SHOULD run a wrap-ahead daemon (cron, systemd timer, or in-actor CIP-5 timer) that maintains a buffer of pre-wrapped epoch keys covering at least the next K epochs (e.g., K=100). On stream deploy, the publisher SHOULD pre-wrap an initial batch (e.g., 1,000 epochs ≈ 7 days at 10-min epochs) before exposing the stream to subscribers. SKM does not enforce a minimum pre-wrap depth in v1; subscribers calling acquire_epoch_access for epochs the publisher has not yet wrapped will receive EPOCHS_NOT_WRAPPED(missing_epochs).

Data Types

1. StreamConfig

Fields:
  • stream_id (bytes32/string)
  • owner (address)
  • publisher_key (bytes): active ed25519 public key
  • current_signing_key_id (uint64): key identifier for publisher_key
  • ring_buffer_capacity (uint32): default 10,000
  • max_subscribers (uint32): default 10,000
  • subscription_policy (enum): PUBLIC | PRIVATE_ALLOWLIST
  • max_push_deliveries_per_block (uint32)
  • max_push_cycles_per_block (uint64)
  • access_mode (enum): OPEN | SUBSCRIBER_PAID
  • paid_stream_config (optional PaidStreamConfig)
  • ingestion (optional IngestionConfig)
Rules:
  • ring_buffer_capacity MUST be > 0
  • current_signing_key_id MUST be >= 1; the constructor (init, below) establishes the initial value
  • max_subscribers MUST be > 0
  • Push limits MUST be finite and non-zero
  • If access_mode == SUBSCRIBER_PAID, paid_stream_config MUST be set
  • If access_mode == OPEN, paid_stream_config MUST be absent

2. PaidStreamConfig (optional)

Fields:
  • fee_per_key_epoch_cby (uint64): publisher amount per newly covered key epoch, in CBY wei
  • protocol_fee_bps (uint16): basis points applied on top of publisher amount
  • publisher_treasury (address): where publisher revenue is credited
  • key_epoch_blocks (uint32): default 600
  • content_cipher (enum/string): XCHACHA20_POLY1305 (v1 REQUIRED)
  • min_purchase_epochs (uint32): default 1
Rules:
  • fee_per_key_epoch_cby MUST be > 0
  • protocol_fee_bps MUST be in [0, MAX_PROTOCOL_FEE_BPS]
  • key_epoch_blocks MUST be > 0
  • content_cipher MUST be XCHACHA20_POLY1305 in this CIP version
  • min_purchase_epochs MUST be >= 1; purchases covering fewer than min_purchase_epochs newly-charged epochs MUST fail with MIN_PURCHASE_NOT_MET
  • publisher_treasury MUST be a valid account address
Protocol fees are credited to the platform-wide system Treasury at 0x0000000000000008 (CIP-2 §5.4). Publishers do not configure a per-stream protocol treasury; the Stream Key Manager credits protocol_fee_cby to the system Treasury at billing time.

2a. WrappedContentKey (paid mode)

The publisher-registered, CBSS-encrypted per-epoch content key. One per (stream_id, key_epoch) pair, registered by the publisher in advance of subscriber demand. Fields:
  • stream_id (bytes32/string)
  • key_epoch (uint64)
  • generation (uint64): starts at 1; increments on each re-wrap of this epoch
  • committee_epoch (uint64): CBSS committee epoch at the time of wrap
  • wrap_block_height (uint64): block at which the wrap was registered
  • wrapped_dek (WrappedDek): IBE ciphertext envelope per node/types/src/cbss.rs — fields version: u8 (leading format tag, value 2, rejected on unknown values), ciphertext: bytes, nonce: bytes(12), ephemeral_u: bytes(96), aad: bytes (serialized format version 2; the earlier three-field envelope is not valid)
  • committee_override_hash (optional bytes32): per-stream committee override, defaulting to the chain-wide committee
Rules:
  • One row per (stream_id, key_epoch). The publisher MUST register a row before any subscriber can acquire access to that epoch (else EPOCHS_NOT_WRAPPED).
  • Re-registering an existing (stream_id, key_epoch) increments generation, generates a fresh IBE identity (which incorporates generation), and produces a new wrapped_dek. Any in-flight SealRequests for the prior generation are invalidated by the identity change; on-chain partials gathered against the old identity cannot decrypt the new wrapped key.
  • wrapped_dek is opaque to the SKM; only the CBSS committee + holders of a valid threshold signature for the matching identity can decrypt.
  • The publisher SHOULD batch-register many epochs via a single register_content_keys call (see Actor Interface).

3. StreamMessage

Fields:
  • version (uint8): MUST be 1 for this CIP revision
  • stream_id (bytes32/string)
  • sequence (uint64)
  • timestamp_unix_ms (uint64)
  • kind (string): examples news, price_batch, alert
  • content_type (string): payload media type, default application/json
  • tags (map<string, string | float64 | bool>)
  • payload_format (enum): PLAINTEXT | CIPHERTEXT
  • payload_inline (bytes): MUST be ≤ 16 KiB
  • payload_hash (bytes32): SHA-256(payload_inline)
  • key_epoch (optional uint64): REQUIRED when payload_format == CIPHERTEXT
  • signing_key_id (uint64): publisher-key identifier active at this sequence
  • publisher_sig (bytes): ed25519 signature over canonical signing bytes
Rules:
  • version MUST be 1
  • payload_inline is REQUIRED
  • payload_inline size MUST be ≤ MAX_INLINE_PAYLOAD_BYTES
  • sequence MUST be strictly increasing and contiguous (prev + 1)
  • payload_hash MUST match payload_inline
  • If payload_format == CIPHERTEXT, subscribers require committee-delivered stream-secret access to decrypt
  • In SUBSCRIBER_PAID mode, stream MUST publish CIPHERTEXT messages
  • In SUBSCRIBER_PAID mode, ciphertext payloads MUST be produced off-chain using cowboy_sdk.stream.Stream.encrypt(...) (or equivalent audited library)
  • In CIPHERTEXT mode, payload_inline MUST be encoded as: nonce(24 bytes) || ciphertext_with_tag
  • In CIPHERTEXT mode, the 16 KiB limit applies to the full encrypted envelope (nonce + ciphertext + tag)
  • In CIPHERTEXT mode, aad for the AEAD MUST be the canonical-CBOR encoding of {stream_id, sequence, key_epoch, kind, content_type} (the same fields included in the signing payload). Subscribers MUST recompute and verify this AAD before accepting plaintext.
  • For XCHACHA20_POLY1305, effective maximum plaintext size is 16_384 - 24 - 16 = 16_344 bytes
  • The 24-byte nonce MUST be drawn from a cryptographically secure random source by the publisher SDK; nonce reuse with the same content key is forbidden

4. Subscription

Fields:
  • subscriber (address)
  • mode (enum): PUSH, PULL, PUSH_WITH_PULL_FALLBACK
  • filter (JSON Filter DSL)
  • created_at_sequence (uint64)
  • account_key_id (optional uint64): account key used as the CBSS re-encryption recipient. REQUIRED for SUBSCRIBER_PAID mode at acquire_epoch_access time.
  • status (enum): ACTIVE, CANCELLED
Rules:
  • subscribe MUST validate filter schema before activation
  • New subscription MUST fail with SUBSCRIBER_CAP_REACHED when full
  • In PRIVATE_ALLOWLIST, non-allowlisted addresses MUST fail with SUBSCRIPTION_NOT_ALLOWED
  • Subscription controls delivery (push/pull routing). In SUBSCRIBER_PAID mode, decryption access is controlled separately by StreamAccess records on the Stream Key Manager.
  • account_key_id SHOULD be set at subscribe time for SUBSCRIBER_PAID streams; it specifies the X25519 recipient for CBSS-delivered per-epoch content keys. If unset at subscribe time, the subscriber MUST provide it at acquire_epoch_access. Subscription itself never triggers billing or CBSS activity.
  • In OPEN mode, no payment or access record is required for subscription.
  • If start_cursor is provided and is strictly less than floor_sequence - 1 at subscribe time, the call MUST fail with CURSOR_TOO_OLD. Subscribers MAY retry with start_cursor omitted to attach at head_sequence.
  • status transitions: a subscription is created ACTIVE. unsubscribe sets it to CANCELLED. CANCELLED is terminal; reactivation requires a new subscribe call.

5. StreamAccess (paid mode)

Tracks key-access rights per (stream_id, beneficiary_account). Distinct from CIP-2 runner entitlements (see 0x07 EntitlementRegistry). Fields:
  • stream_id (bytes32/string)
  • beneficiary_account (address)
  • active_until_key_epoch (uint64)
Semantics:
  • Access is rolling and account-scoped
  • Account has access for all key epochs <= active_until_key_epoch
  • Purchasing access to epoch T when current access is E charges for epochs E+1..T inclusive
  • Repeated calls for already-covered epochs are idempotent and free
  • Past covered epochs remain accessible indefinitely within the window
  • Access is per (stream_id, beneficiary_account) pair

6. AccountKeyRegistration (paid mode)

Fields:
  • account (address)
  • account_key_id (uint64): auto-incrementing per account
  • scheme (string): "X25519" (v1 REQUIRED)
  • public_key (bytes): X25519 public key — the recipient identity for CBSS-delivered per-epoch content keys
  • status (enum): ACTIVE | REVOKED
  • registered_at (uint64): block height
Rules:
  • Keys are owned by the account, not by any actor
  • An account MAY register up to MAX_ACCOUNT_KEYS_PER_ACCOUNT keys
  • account_key_id is assigned sequentially starting at 1
  • Revoked keys MUST NOT be used as the recipient for new SealRequests
  • In-flight SealRequests against a revoked key continue per CBSS rules (committee delivers; subscriber discards); future acquire_epoch_access calls referencing a revoked key MUST fail with KEY_REVOKED
  • Key registration MUST be signed by the account holder
  • The corresponding X25519 private key MUST be held off-chain (wallet keystore, runner sandbox, or operator process). It is never stored on-chain in any form.

7. KeyAccessReceipt (paid mode)

Synchronous billing receipt returned by acquire_epoch_access. Does not carry decryption material — the per-epoch wrapped content keys arrive asynchronously via the standard CBSS partial-delivery flow, one delivery per requested epoch. Fields:
  • stream_id (bytes32)
  • beneficiary_account (address)
  • payer_account (address)
  • from_key_epoch (uint64): first newly-charged epoch (E+1)
  • to_key_epoch (uint64): last charged epoch (T)
  • epochs_charged (uint64): T - E (may be 0 on billing-idempotent calls)
  • publisher_amount_cby (uint64)
  • protocol_fee_cby (uint64)
  • total_amount_cby (uint64)
  • seal_requests_minted (Vec<{epoch: uint64, seal_request_id: bytes32, generation: uint64}>): one entry per SealRequest minted by SKM for this call. Empty when all requested epochs already have valid in-flight or delivered SealRequests for (beneficiary, account_key_id). Subscribers track these IDs to correlate inbound committee deliveries.

8. IngestionConfig (optional)

Fields:
  • enabled (bool)
  • interval_blocks (uint32): default 1
  • task_definition (object): CIP-2 task request
  • result_schema (object)
  • transform_method (optional string)
  • num_runners (uint8)
  • proof_type (enum from CIP-2)
Rules:
  • When enabled, actor MUST schedule recurring timer callbacks
  • Actor MUST submit CIP-2 tasks using configured num_runners and proof_type
  • Ingestion failures MUST NOT increment sequence

Canonical Hashing and Signing (Normative)

This CIP fixes signature and encoding rules now.
  • signature_scheme: ed25519
  • hash_alg: SHA-256
  • canonical_encoding: Deterministic CBOR (RFC 8949 canonical form)
Signing payload object keys and values:
  • stream_id
  • version
  • sequence
  • timestamp_unix_ms
  • kind
  • content_type
  • tags
  • payload_format
  • payload_hash
  • key_epoch (or null when payload_format == PLAINTEXT)
  • signing_key_id
Procedure:
  1. Compute payload_hash = SHA-256(payload_inline)
  2. Build the signing payload object with exactly the keys above
  3. Encode using deterministic CBOR
  4. Sign bytes with ed25519 private key
  5. Store signature in publisher_sig
Verification:
  • Consumer MUST recompute payload_hash
  • Consumer MUST rebuild deterministic-CBOR signing payload
  • Consumer MUST verify publisher_sig against key schedule entry effective at that sequence
  • Consumers MAY resolve key schedule via get_key_at_sequence or cached get_key_history output
Tag canonicalization for signing payload:
  • tags keys MUST be CBOR text strings.
  • String tag values MUST be CBOR text strings.
  • Boolean tag values MUST be CBOR true/false.
  • Numeric tag values MUST be finite IEEE-754 binary64 and encoded as CBOR float64 (major type 7, additional info 27).
  • Producers MUST NOT encode numeric tags as CBOR integers in signing payload bytes.
Ciphertext nonce format:
  • For XCHACHA20_POLY1305, nonce MUST be exactly 24 bytes.
  • Nonce MUST be carried inline as the first 24 bytes of payload_inline.
  • Nonce generation is performed off-chain by the publisher SDK using a cryptographically secure RNG. The 192-bit nonce space is large enough that random sampling is statistically nonce-collision-free for any practical publish volume.
  • Nonce reuse with the same content_key is forbidden. Publishers MUST NOT cache or reuse nonces across messages.
AEAD AAD construction: For SUBSCRIBER_PAID mode, the AEAD AAD is mandatory and MUST be the canonical-CBOR encoding (using cowboy_sdk.codec.encode) of:
{
  "stream_id":     <bytes>,
  "sequence":      <u64>,
  "key_epoch":     <u64>,
  "kind":          <text>,
  "content_type":  <text>,
}
Subscribers MUST recompute this AAD from the StreamMessage envelope before decryption; an AAD mismatch raises DECRYPTION_FAILED client-side. This binds ciphertext to its envelope and prevents reorder/replay attacks within a stream.

Actor Interface

Required Methods

init(stream_id, initial_publisher_key, access_mode, paid_stream_config?, ring_buffer_capacity?, max_subscribers?, subscription_policy?, ingestion?)

Constructor. Establishes initial state for a fresh StreamActor. Behavior:
  1. Set owner = sender (deploying account).
  2. Validate initial_publisher_key is a 32-byte ed25519 public key.
  3. Set head_sequence = 0, floor_sequence = 1.
  4. Set current_signing_key_id = 1, append key-schedule entry {signing_key_id: 1, publisher_key: initial_publisher_key, effective_sequence: 1}.
  5. Apply access_mode rules from StreamConfig; if SUBSCRIBER_PAID, paid_stream_config MUST be present and pass PaidStreamConfig validation.
  6. Apply defaults for unprovided fields per Protocol Constants.
  7. Emit StreamInitialized.
Rules:
  • init MUST be called exactly once per StreamActor; subsequent calls MUST fail with ALREADY_INITIALIZED.
  • init MUST NOT publish any message; first published sequence is 1 via subsequent publish calls.

publish(kind, content_type?, tags, payload_format, payload_inline, key_epoch?, publisher_sig)

Behavior:
  1. Validate payload size ≤ 16 KiB
  2. If content_type omitted, set content_type = application/json
  3. Compute payload_hash = SHA-256(payload_inline)
  4. Validate payload_format and key_epoch consistency
  5. If SUBSCRIBER_PAID mode, require payload_format == CIPHERTEXT
  6. Validate signature against active publisher key using signing payload that includes computed payload_hash
  7. Set next_sequence = head_sequence + 1
  8. Persist message at next_sequence with version=1, content_type, and signing_key_id
  9. Update head_sequence = next_sequence
  10. Prune ring buffer deterministically (see pruning section)
  11. Emit StreamMessagePublished
  12. Enqueue message for push delivery
Paid mode publish flow (publisher SDK, off-chain):
  1. Publisher composes plaintext payload, metadata, tags, and kind.
  2. Publisher computes key_epoch = floor(block_height / key_epoch_blocks).
  3. Publisher derives content_key = HKDF(stream_secret, "cip-7-content-key-v1", chain_id || stream_id || u64_be(key_epoch)) locally, holding stream_secret in its off-chain keystore.
  4. Publisher generates a fresh random 24-byte nonce.
  5. Publisher builds canonical-CBOR AAD per Canonical Hashing and Signing.
  6. Publisher encrypts: ciphertext_envelope = nonce || XChaCha20-Poly1305-Encrypt(content_key, nonce, aad, plaintext) || tag.
  7. Publisher signs the StreamMessage and submits via publish(...).
The reference helpers are Stream.derive_content_key(...) + Stream.encrypt(content_key, plaintext, aad). The publisher’s stream_secret is only the HKDF input; it is never IBE-wrapped, never registered on-chain, never delivered to subscribers. Subscribers receive per-epoch content_key material via CBSS delivery and cannot derive other epochs.

subscribe(mode, filter, start_cursor?, account_key_id?)

Behavior:
  • Validate mode and filter schema
  • Enforce subscription policy and subscriber cap
  • Create or update subscription
  • If start_cursor omitted, set to current head_sequence
  • Emit SubscriberUpdated
Re-subscribe / update semantics:
  • subscribe is an upsert keyed by subscriber.
  • On update, subscriber MAY change mode, filter, and account_key_id.
  • created_at_sequence MUST remain unchanged on update.
  • start_cursor applies only on create; on update it is ignored.
Note: In SUBSCRIBER_PAID mode, subscription controls delivery routing only. Billing occurs on key-access acquisition (via acquire_epoch_access), not on subscription creation or renewal. The account_key_id parameter records which X25519 key the subscriber wants CBSS to use as the recipient identity for delivered per-epoch content keys — it must reference an ACTIVE AccountKeyRegistration owned by the subscriber, but does not trigger payment.

unsubscribe()

Behavior:
  • Set status to CANCELLED
  • Emit SubscriberUpdated

get_since(cursor, limit)

Inputs:
  • cursor: last consumed sequence
  • limit: 1..500
Behavior:
  • Reject invalid limit with LIMIT_EXCEEDED
  • If cursor < floor_sequence - 1, return CURSOR_TOO_OLD
  • Return messages where sequence > cursor, ascending
  • Return at most limit
Paid mode pull rule:
  • get_since returns ciphertext messages regardless of access status.
  • Stream access gates decryption, not ciphertext retrieval.

get_head()

Returns:
  • head_sequence
  • floor_sequence
  • stream metadata required for consumers

rotate_publisher_key(new_key)

Behavior:
  • Owner-only
  • Allocate new_signing_key_id = current_signing_key_id + 1
  • Set effective_sequence = head_sequence + 1
  • New key is active from effective_sequence onward
  • Append key schedule entry {signing_key_id, pubkey, effective_sequence}
  • Emit PublisherKeyRotated(old_key, new_key, old_signing_key_id, new_signing_key_id, effective_sequence)

get_key_at_sequence(sequence)

Returns key schedule resolution for verification:
  • signing_key_id
  • publisher_key
  • effective_sequence

get_key_history(from_signing_key_id?, limit?)

Returns ordered key schedule entries for batch verification use.

set_subscription_policy(policy)

Behavior:
  • Owner-only
  • Set PUBLIC or PRIVATE_ALLOWLIST

allowlist_add(address) / allowlist_remove(address)

Behavior:
  • Owner-only
  • Manage allowlist used when policy is PRIVATE_ALLOWLIST

Platform Key Management Methods

Two methods are exposed as VM host functions backed by the Stream Key Manager. The remaining SKM operations (register_content_keys, revoke_account_key) are invoked via standard actor message calls to 0x0D.

acquire_epoch_access(stream_id, beneficiary_account, payer_account, target_key_epoch, account_key_id) -> KeyAccessReceipt

Caller: Any actor (on behalf of payer_account). The transaction MUST be signed by payer_account or the payer must have delegated spending authority. Synchronous behavior (settles in the calling block):
  1. Validate access_mode == SUBSCRIBER_PAID for this stream.
  2. Resolve AccountKeyRegistration(beneficiary_account, account_key_id); require status == ACTIVE. Fail with KEY_REVOKED (revoked) or INVALID_ACCOUNT_KEY (missing).
  3. Resolve current access: E = active_until_key_epoch for (stream_id, beneficiary_account). If no access record exists, E = current_key_epoch - 1 (no free retroactive access).
  4. target_key_epoch MUST be >= current_key_epoch; else fail with INVALID_TARGET_KEY_EPOCH.
  5. target_key_epoch - current_key_epoch + 1 MUST be ≤ MAX_EPOCHS_PER_ACQUIRE (256); else fail with ACQUIRE_RANGE_TOO_LARGE { requested: u64, max: u64 }. Subscribers wanting more epochs must call acquire_epoch_access multiple times.
  6. Compute the full mint set first. Collect epochs_needing_delivery: Vec<u64> = every epoch in current_key_epoch .. target_key_epoch for which delivery is needed under the current (beneficiary, account_key_id) pair. An epoch needs delivery iff it falls into ONE of:
    • newly billed (current_key_epoch ≤ e ≤ target_key_epoch AND e > E), OR
    • already billed (e ≤ E) but no SealRequestStatus exists at 0xD || 0x05 || keccak(stream_id) || keccak(beneficiary_account) || u64_be(account_key_id) || u64_be(e), OR
    • already billed AND status EXPIRED (i.e., request_block + REQUEST_FRESHNESS_BLOCKS < current_block), OR
    • already billed AND the stored generation < the current WrappedContentKey.generation for that epoch.
  7. Pre-billing validation (covers every epoch that will mint or re-mint): for every epoch e in epochs_needing_delivery, verify a WrappedContentKey exists at 0xD || 0x03 || keccak(stream_id) || u64_be(e) AND its committee_epoch matches the current CBSS committee epoch (or registered override). Collect missing epochs; if non-empty, fail with EPOCHS_NOT_WRAPPED { missing_epochs: Vec<u64> }. If any present row has a stale committee epoch, fail with COMMITTEE_EPOCH_MISMATCH { epoch: u64, found: u64, current: u64 }. No CBY moves and no SealRequest mints until this check passes for all epochs that will mint (newly-billed AND already-billed-but-needing-re-mint).
  8. Billing path:
    • Compute epochs_charged = max(0, target_key_epoch - E).
    • If 0 < epochs_charged < min_purchase_epochs, fail with MIN_PURCHASE_NOT_MET.
    • If epochs_charged == 0, set publisher_amount = protocol_fee = total = 0 (billing-idempotent path). Do NOT enforce min-purchase.
    • Else compute:
      publisher_amount = epochs_charged * fee_per_key_epoch_cby
      protocol_fee     = floor(publisher_amount * protocol_fee_bps / 10_000)
      total            = publisher_amount + protocol_fee
      
    • If total > 0: transfer total CBY from payer_account (fail with INSUFFICIENT_CBY_BALANCE if insufficient); credit publisher_amount to publisher_treasury and protocol_fee to system Treasury (0x08).
    • Set active_until_key_epoch = max(E, target_key_epoch).
  9. Delivery path (independent of billing): for every epoch e in epochs_needing_delivery:
    • Mint a fresh CBSS SealRequest with identity_bytes = content_key_identity_bytes(chain_id, stream_id, e, generation) and recipient = AccountKeyRegistration.public_key. (See Content-key Identity for full hash-to-curve derivation.)
    • Persist as SealRequestStatus at 0xD || 0x05 || keccak(stream_id) || keccak(beneficiary_account) || u64_be(account_key_id) || u64_be(e), replacing any prior row. The row’s account_key_id is implicit in the storage key, so different account_key_id values for the same (beneficiary, epoch) never overwrite each other.
    • Append { epoch: e, seal_request_id, generation } to seal_requests_minted in the receipt.
  10. Emit EpochAccessPurchased (if epochs_charged > 0) or EpochAccessIdempotent (if epochs_charged == 0). Emit one SealRequestEmitted per entry in seal_requests_minted.
  11. Return KeyAccessReceipt.
This separation means a subscriber whose prior SealRequest expired (committee was down) can re-call acquire_epoch_access with the same target_key_epoch: step 8 sees epochs_charged = 0 and skips billing; step 9 sees the expired row and mints a fresh SealRequest. The receipt’s seal_requests_minted lists the new request ID(s); epochs_charged is 0. Crucially, step 7’s pre-validation covers BOTH newly-billed and already-billed-but-re-minting epochs. After a committee rotation, a re-mint for a paid epoch whose WrappedContentKey.committee_epoch is stale will fail with COMMITTEE_EPOCH_MISMATCH before any SealRequest is created — forcing the publisher to register_content_keys (re-wrap under the new committee, bumping generation) before subscribers can re-deliver. Asynchronous behavior (committee-driven, per epoch, follows CBSS partial-delivery flow):
  • Committee proxies sign partials on each SealRequest’s identity, deliver via the standard CBSS flow (HPKE to subscriber recipient + on-chain accountability receipts).
  • Subscriber aggregates t-of-n partials off-chain per epoch and decrypts the corresponding WrappedContentKey to obtain content_key_e.
  • content_key_e decrypts only that epoch’s ciphertexts; the subscriber cannot derive other epochs’ keys.
  • SKM updates SealRequestStatus to DELIVERED on the corresponding SealDelivered event, or to EXPIRED after REQUEST_FRESHNESS_BLOCKS. SKM emits SealRequestExpired on transition to expired.
Gas cost (synchronous portion):
  • Cycles: 5,000 fixed + 1,000 * epochs_with_new_seal_request (one SealRequest mint per requested epoch that needs one) + CBSS SealRequest mint costs (charged separately per CBSS gas table)
  • Cells: 500 + 200 * epochs_with_new_seal_request
Rules:
  • payer_account MAY differ from beneficiary_account (sponsorship). Stream access accrues to beneficiary_account; the payer never receives delivered key material.
  • Billing idempotency (charged amount) and delivery idempotency (SealRequest minting) are independent. A repeat call for already-covered epochs charges 0 CBY but MAY still mint fresh SealRequests if prior delivery has expired or has a stale generation. This prevents the “stuck subscriber after committee failure” trap.
  • A subscriber buying epochs E+1..T receives epochs_charged = T - E and up to T - max(E, current_key_epoch) SealRequests (one per epoch needing fresh material).
  • This method is the canonical and only billing point for paid streams.

register_account_key(account, scheme, public_key) -> AccountKeyRegistration

Caller: Account holder. Behavior:
  1. Verify transaction is signed by account.
  2. Validate scheme is "X25519" (v1).
  3. Validate public_key length (32 bytes for X25519).
  4. Check account has fewer than MAX_ACCOUNT_KEYS_PER_ACCOUNT active keys.
  5. Assign account_key_id = next_id for this account (auto-increment).
  6. Store AccountKeyRegistration.
  7. Emit AccountKeyRegistered.
  8. Return registration.
Gas cost:
  • Cycles: 2,000
  • Cells: 200

register_content_keys(stream_id, entries, committee_epoch, committee_override_hash?)

Caller: Stream owner. entries: Vec<{ key_epoch: u64, wrapped_dek: WrappedDek }> — one entry per epoch to register or re-wrap. Batch size is bounded only by tx gas; publishers typically batch 100–10,000 entries per call (e.g., one week of 10-minute epochs = 1,008 entries). Behavior:
  1. Verify caller is the stream’s owner.
  2. Validate stream_id corresponds to an existing SUBSCRIBER_PAID stream.
  3. Validate committee_epoch matches the current CBSS committee epoch (or the optional committee_override_hash committee’s epoch). Else fail with COMMITTEE_EPOCH_MISMATCH.
  4. For each entry:
    • Validate wrapped_dek shape ({version(== 2), ciphertext, nonce(12), ephemeral_u(96), aad}); reject any other version tag value, reject empty ciphertext, and reject ephemeral_u unless it is the canonical compressed encoding of a non-identity G2 point.
    • Read current generation at 0xD || 0x06 || keccak(stream_id) || u64_be(key_epoch) (default 0 if missing); set new_generation = current + 1.
    • Compute the expected AEAD AAD expected_aad = base_wrapped_dek_aad(chain_id, stream_id, key_epoch, new_generation, committee_epoch, committee_selector) || entry.wrapped_dek.ephemeral_u per Content-key Identity. Verify entry.wrapped_dek.aad == expected_aad by full equality — exact length included; mismatch → INVALID_WRAPPED_DEK_AAD { epoch }. This binds the wrapped ciphertext to the exact (stream, epoch, generation, committee) tuple and the per-wrap encryption ephemeral, preventing cross-epoch / cross-stream wrap-swap and post-publish ephemeral_u tampering.
    • Persist WrappedContentKey at 0xD || 0x03 || keccak(stream_id) || u64_be(key_epoch). Replaces any prior row for the same epoch.
    • Write new_generation at 0xD || 0x06 || keccak(stream_id) || u64_be(key_epoch).
    • Emit WrappedContentKeyRegistered { stream_id, key_epoch, generation: new_generation, committee_epoch }.
Rules:
  • The publisher derives content_key_e = HKDF(stream_secret, "cip-7-content-key-v1", chain_id || stream_id || u64_be(key_epoch)) off-chain and IBE-encrypts content_key_e to the committee using cbss-client::wrap_cip7_content_key (a new helper analogous to wrap_cip9_volume_dek).
  • On committee rotation, the publisher SHOULD re-register all upcoming-or-active epoch keys bound to the new committee. Subscribers with delivered partials for the prior committee may still decrypt their already-delivered material; but future acquire_epoch_access for those epochs will require the publisher to have re-wrapped under the new committee.
  • Re-registering an existing epoch (publisher mistake, key compromise, voluntary rotation) bumps generation, which invalidates threshold signatures gathered against the prior identity. Subscribers who already aggregated a threshold signature for an old generation still hold the prior content_key_e they decrypted — re-wrap does not erase past material, only changes what new SealRequests can decrypt.
  • v1 does NOT enforce a minimum pre-wrap inventory. Publishers SHOULD maintain a buffer per SDK and Off-Chain Components.
Gas cost:
  • Cycles: 2,000 fixed + 1,000 * entries.len()
  • Cells: size of serialized WrappedContentKey rows × entries.len()

revoke_account_key(account, account_key_id)

Caller: Account holder. Behavior:
  1. Verify transaction is signed by account.
  2. Set AccountKeyRegistration.status = REVOKED.
  3. Emit AccountKeyRevoked.
Gas cost:
  • Cycles: 500
  • Cells: 50

Push Delivery Semantics

  • Push is best-effort multicast
  • Delivery is at-least-once
  • No protocol-level ack or retry
  • Push message includes full StreamMessage, including full inline payload
  • Consumers MUST deduplicate by (stream_id, sequence)
Paid mode delivery rule:
  • Push delivery is a transport concern and MAY deliver ciphertext regardless of key access.
  • Decryption is entirely off-chain; the on-chain StreamAccess record (plus per-epoch CBSS committee delivery) gates whether a subscriber can recover the per-epoch content key needed to decrypt that epoch’s payloads.

Push Work Bounds and Economics

  • Stream actor execution pays push fan-out costs
  • Owner is responsible for funding actor operations
  • Actor MUST enforce:
    • max_push_deliveries_per_block
    • max_push_cycles_per_block
  • Undelivered subscribers remain pending for subsequent blocks
  • Push delivery MUST never exceed configured per-block bounds

PUSH_WITH_PULL_FALLBACK Semantics

PUSH_WITH_PULL_FALLBACK means:
  • Actor behavior is same as PUSH (best-effort, no retries)
  • Subscriber behavior additionally includes periodic pull catch-up using get_since
  • Fallback is subscriber-driven, not actor-driven
  • No automatic mode switching is performed by actor

Pull Delivery Semantics

  • Pull consumers track local cursor
  • Consumers call get_since(cursor, limit)
  • Stale cursors are explicit via CURSOR_TOO_OLD
Paid mode pull rule (restated):
  • get_since returns ciphertext messages regardless of access status.
  • Stream access gates key issuance/decryption, not ciphertext retrieval.

Payments and Key Management (Normative)

This section defines how subscriber-paid monetization works with CBSS threshold-PRE custody and native CBY billing.

On-chain stream access

  • In SUBSCRIBER_PAID mode, stream access is tracked per (stream_id, beneficiary_account) via active_until_key_epoch on the Stream Key Manager.
  • Key epoch is computed as floor(block_height / paid_stream_config.key_epoch_blocks).
  • Access window is rolling: check for epoch k succeeds iff k ≤ active_until_key_epoch.

Payload confidentiality

  • Stream publishes inline CIPHERTEXT payloads in SUBSCRIBER_PAID mode.
  • Ciphertext is globally readable on-chain. Plaintext recovery requires the per-epoch content_key_e, which is IBE-encrypted to the CBSS committee MPK and recoverable only via a threshold of committee partial signatures (gated by the per-epoch SealRequest minted at acquire_epoch_access time).
  • Validators cannot decrypt content unilaterally; they would need to collude as ≥ t-of-n CBSS committee members.
  • A subscriber’s purchased epochs are cryptographically isolated. The committee threshold-signs only the specific (stream_id, epoch, generation) identities for which a SealRequest was minted. Without a fresh SealRequest, no σ exists; without σ, the wrapped content key for that epoch cannot be decrypted; without that key, the payload’s AEAD ciphertext is unintelligible.

Per-epoch wrapped content key lifecycle

  • Publisher generates stream_secret (32 bytes random) off-chain at stream deploy time. stream_secret never leaves the publisher’s off-chain keystore.
  • For each upcoming key_epoch, publisher derives content_key_e = HKDF(stream_secret, "cip-7-content-key-v1", chain_id || stream_id || u64_be(key_epoch)) off-chain.
  • Publisher IBE-encrypts content_key_e to the committee under identity content_key_identity(chain_id, stream_id, key_epoch, generation) and submits via register_content_keys (batched). One on-chain row per epoch.
  • StreamMessage.key_epoch selects which content_key_e to use when decrypting payload_inline.
  • Forward + backward isolation. Even a subscriber who decrypts content_key_e cannot derive content_key_{e'} for any other epoch e', because the underlying stream_secret is the publisher’s secret and never delivered. Each epoch’s confidentiality is independent.
  • Re-wrap semantics. Re-registering an epoch bumps its generation counter; the IBE identity changes; threshold signatures gathered against the prior identity stop working for the new wrapped key. Subscribers who already aggregated and unwrapped under the old generation retain their cleartext content_key_e — re-wrap does not retroactively revoke past unwraps, only invalidates pending/future SealRequests for that epoch.

Key distribution flow

This is a normative summary of acquire_epoch_access. The detailed method-section is authoritative; implementers SHOULD work from it directly.
  1. Publisher pre-wraps content keys for upcoming epochs via register_content_keys (out-of-band, ahead of subscriber demand).
  2. Caller invokes acquire_epoch_access(stream_id, beneficiary, payer, target_epoch, account_key_id).
  3. SKM resolves subscriber’s AccountKeyRegistration (fail KEY_REVOKED / INVALID_ACCOUNT_KEY if revoked or missing).
  4. SKM bounds the request: target_epoch - current_key_epoch + 1 MUST be ≤ MAX_EPOCHS_PER_ACQUIRE, else ACQUIRE_RANGE_TOO_LARGE.
  5. SKM computes epochs_needing_delivery — the union of (a) newly-billed epochs E+1..target_epoch and (b) already-billed epochs whose SealRequestStatus at 0xD || 0x05 || keccak(stream_id) || keccak(beneficiary) || u64_be(account_key_id) || u64_be(epoch) is missing, EXPIRED, or bound to a stale generation.
  6. SKM pre-validates the entire epochs_needing_delivery set: every epoch MUST have a WrappedContentKey row whose committee_epoch matches the current CBSS committee. Failures yield EPOCHS_NOT_WRAPPED { missing_epochs } or COMMITTEE_EPOCH_MISMATCH { epoch, found, current }. No CBY moves and no SealRequests are minted until this passes for every epoch that would mint.
  7. Billing path: epochs_charged = max(0, target_epoch - E). If > 0 and below min_purchase_epochs, fail. Else compute fees, transfer CBY, credit treasuries, set active_until_key_epoch = max(E, target_epoch).
  8. Delivery path: for each epoch e in epochs_needing_delivery, mint a CBSS SealRequest with identity_point = content_key_identity_point(...) and recipient AccountKeyRegistration.public_key. Persist SealRequestStatus at the per-(beneficiary, account_key_id, epoch) key — distinct account_key_id values for the same beneficiary/epoch never overwrite. Emit EpochAccessPurchased (or EpochAccessIdempotent) plus one SealRequestEmitted per mint.
  9. Asynchronous: CBSS committee proxies sign partials per SealRequest and deliver via the standard CBSS flow. Subscriber aggregates t-of-n partials per epoch, decrypts each WrappedContentKey to recover content_key_e, and decrypts the corresponding ciphertexts locally.

Async delivery semantics

  • Typical end-to-end delivery latency: 4–5 blocks under healthy committee conditions, per SealRequest. A subscriber buying N new epochs has N parallel SealRequests in flight.
  • Maximum SealRequest lifetime: REQUEST_FRESHNESS_BLOCKS = 384 blocks. Expired requests yield no delivery; SKM emits SealRequestExpired; subscriber MAY re-call acquire_epoch_access to re-mint. Billing idempotency ensures the re-call charges 0 CBY; delivery idempotency ensures fresh SealRequests are minted for the still-needed epochs.
  • Subscriber tracks delivery via SealDelivered events emitted by CBSS, correlated by the seal_request_id values in KeyAccessReceipt.seal_requests_minted.
  • Publisher and subscriber MUST tolerate the latency window: subscribers polling get_since immediately after acquire_epoch_access will receive ciphertexts they cannot yet decrypt; decryption succeeds once committee delivery completes for the matching epoch.

Account-scoped keys

  • X25519 keys are registered per account (not per actor or per subscription).
  • The account holder is responsible for delegating the corresponding X25519 private key to whatever process consumes the stream (a CLI, an off-chain worker, a CIP-2 runner, etc.). The chain does not enforce which process holds the key.
  • Account-key revocation is immediate for future acquire_epoch_access calls. In-flight SealRequests (already on-chain, awaiting committee delivery) continue per CBSS rules; the subscriber MAY discard delivered material bound to a revoked key.
  • One paid acquire_epoch_access purchase covers all consumers the account holder delegates to, since they all share the same X25519 private key off-chain.
  • payer_account MAY be any funded account, including a third party
  • CBY is debited from payer_account; stream access accrues to beneficiary_account
  • The payer has no implicit access to decryption keys
  • Use cases: employer sponsors employee access, DAO treasury funds member access, trial/promotional grants

Protocol fee

  • Configurable per-stream at creation within [0, MAX_PROTOCOL_FEE_BPS]
  • Additive: total cost = publisher price + protocol fee on top
  • Credited to the platform-wide system Treasury at 0x08 (CIP-2 §5.4). Publishers do not configure a per-stream protocol treasury.
  • Protocol fee creates direct CBY demand proportional to paid stream usage across the ecosystem

CBY value flow

Payer Account
    |
    |── publisher_amount ──> Publisher Treasury (stream owner revenue)
    |
    └── protocol_fee ──────> System Treasury 0x08 (CIP-2 §5.4, shared sink)
                                |
                                |── Burned (deflationary pressure)
                                |── Staking rewards
                                └── Development fund
                                    (distribution is governance-defined)

Scalability guidance

  • Use epoch keys (for example, 10-minute epochs at 1-second blocks = 600 blocks) rather than per-message keys
  • Rolling windows and idempotent purchase semantics avoid duplicate charging
  • Model supports large subscriber sets (100, 1,000, 10,000) with stable delivery and billing overhead

Ring Buffer Pruning (Deterministic)

State:
  • head_sequence
  • floor_sequence
  • ring_buffer_capacity
On every successful publish:
  1. Append message at head_sequence + 1
  2. Set head_sequence = head_sequence + 1
  3. If (head_sequence - floor_sequence + 1) > ring_buffer_capacity:
    • delete message at floor_sequence
    • set floor_sequence = floor_sequence + 1
Empty-stream rule:
  • When head_sequence == 0, stream has no retained messages.
  • In that state, pruning condition MUST NOT be evaluated.
  • get_since MUST return an empty result set for valid limits.
Stale cursor rule:
  • cursor is stale iff cursor < floor_sequence - 1

JSON Filter DSL

Filters are evaluated only on:
  • kind
  • tags.<key>
  • sequence
  • timestamp_unix_ms
Operators:
  • eq, ne, in, nin, gte, lte, exists
Logical forms:
  • {"all": [ ... ]} (AND)
  • {"any": [ ... ]} (OR)
  • {"not": { ... }}
Example:
{
  "all": [
    {"field": "kind", "op": "eq", "value": "price_batch"},
    {"field": "tags.symbol", "op": "in", "value": ["BTC", "ETH"]},
    {"field": "tags.venue", "op": "eq", "value": "coinbase"},
    {"field": "tags.confidence", "op": "gte", "value": 0.9}
  ]
}
Determinism constraints:
  • Maximum depth: 4
  • Maximum predicates: 16
  • Unknown fields/operators MUST fail subscription validation

Ingestion Flow (Optional)

When ingestion.enabled == true:
  1. Timer fires every interval_blocks (default 1)
  2. Actor submits CIP-2 task using configured:
    • task_definition
    • result_schema
    • num_runners
    • proof_type
  3. Runner callback returns result
  4. Actor transforms result into publishable payloads
  5. For SUBSCRIBER_PAID mode, publisher SDK encrypts each transformed payload off-chain using Stream.encrypt(stream_secret, key_epoch, plaintext, aad) before the actor invokes publish(...) with the ciphertext envelope
  6. Actor calls publish(...) for each transformed payload
  7. Actor reschedules next ingestion timer
Failure behavior:
  • Increment ingest_fail_count
  • Emit IngestFailed(reason, timestamp_unix_ms)
  • Do not increment sequence on failed attempts

High-Volume Pricing Guidance (Non-normative)

For 1-second block cadence, pricing streams SHOULD micro-batch:
  • Publish one price_batch message per block
  • Include multiple ticks in one inline payload
  • Keep payload ≤ 16 KiB
  • Use tags such as symbol, venue, window_ms, count for filtering

Large-Payload Path (Non-normative, Future)

The 16 KiB inline cap is intentional for v1. For long-form payloads (news articles, encoded media, model outputs >16 KiB), a future extension may permit publishing a CBFS handle (see CIP-9) in payload_inline and dereferencing client-side. The signing payload would still cover only the inline bytes (i.e., the CBFS handle), keeping the canonical hashing path unchanged. v1 does not specify this and clients SHOULD NOT rely on out-of-band fetching.

Events

StreamMessagePublished

  • stream_id
  • version
  • sequence
  • kind
  • content_type
  • payload_format
  • payload_hash
  • key_epoch (nullable)
  • signing_key_id
  • timestamp_unix_ms

SubscriberUpdated

  • stream_id
  • subscriber
  • mode
  • status

PublisherKeyRotated

  • stream_id
  • old_key
  • new_key
  • old_signing_key_id
  • new_signing_key_id
  • effective_sequence

EpochAccessPurchased

  • stream_id
  • beneficiary_account
  • payer_account
  • from_key_epoch
  • to_key_epoch
  • epochs_charged
  • publisher_amount_cby
  • protocol_fee_cby
  • total_amount_cby

EpochAccessIdempotent

  • stream_id
  • beneficiary_account
  • target_key_epoch
  • active_until_key_epoch

AccountKeyRegistered

  • account
  • account_key_id
  • scheme
  • block_height

AccountKeyRevoked

  • account
  • account_key_id
  • block_height

WrappedContentKeyRegistered

  • stream_id
  • key_epoch
  • generation
  • committee_epoch
  • wrap_block_height
  • committee_override_hash (nullable)

SealRequestEmitted

  • stream_id
  • beneficiary_account
  • seal_request_id
  • account_key_id
  • key_epoch
  • generation

SealRequestExpired

  • stream_id
  • beneficiary_account
  • seal_request_id
  • key_epoch
  • expired_at_block

StreamInitialized

  • stream_id
  • owner
  • access_mode
  • initial_publisher_key
  • initial_signing_key_id

IngestFailed

  • stream_id
  • timestamp_unix_ms
  • reason

Error Codes

Actor method errors:
  • ALREADY_INITIALIZED
  • INVALID_SIGNATURE
  • PAYLOAD_TOO_LARGE
  • INVALID_FILTER
  • CURSOR_TOO_OLD
  • LIMIT_EXCEEDED
  • UNAUTHORIZED
  • SUBSCRIBER_CAP_REACHED
  • SUBSCRIPTION_NOT_ALLOWED
Platform key management errors:
  • NOT_SUBSCRIBER_PAID_STREAM
  • INSUFFICIENT_CBY_BALANCE
  • INVALID_TARGET_KEY_EPOCH
  • MIN_PURCHASE_NOT_MET
  • INVALID_ACCOUNT_KEY
  • ACCOUNT_KEY_LIMIT_REACHED
  • KEY_REVOKED
  • EPOCHS_NOT_WRAPPED { missing_epochs: Vec<u64> }
  • COMMITTEE_EPOCH_MISMATCH { epoch: u64, found: u64, current: u64 }
  • ACQUIRE_RANGE_TOO_LARGE { requested: u64, max: u64 }
  • INVALID_WRAPPED_DEK_AAD { epoch: u64 }
Note: DECRYPTION_FAILED and STREAM_ACCESS_REQUIRED are subscriber-side off-chain errors raised by the SDK; they are not VM error codes. Error mapping:
  • init: ALREADY_INITIALIZED
  • publish: INVALID_SIGNATURE, PAYLOAD_TOO_LARGE, UNAUTHORIZED
  • subscribe: INVALID_FILTER, SUBSCRIBER_CAP_REACHED, SUBSCRIPTION_NOT_ALLOWED, CURSOR_TOO_OLD, INVALID_ACCOUNT_KEY (when account_key_id references a missing or revoked registration in paid mode)
  • get_since: LIMIT_EXCEEDED, CURSOR_TOO_OLD
  • rotate_publisher_key: UNAUTHORIZED
  • set_subscription_policy: UNAUTHORIZED
  • allowlist_add / allowlist_remove: UNAUTHORIZED
  • acquire_epoch_access: NOT_SUBSCRIBER_PAID_STREAM, INSUFFICIENT_CBY_BALANCE, INVALID_TARGET_KEY_EPOCH, ACQUIRE_RANGE_TOO_LARGE, MIN_PURCHASE_NOT_MET, INVALID_ACCOUNT_KEY, KEY_REVOKED, EPOCHS_NOT_WRAPPED, COMMITTEE_EPOCH_MISMATCH
  • register_account_key: INVALID_ACCOUNT_KEY, ACCOUNT_KEY_LIMIT_REACHED, UNAUTHORIZED
  • register_content_keys: UNAUTHORIZED, NOT_SUBSCRIBER_PAID_STREAM, COMMITTEE_EPOCH_MISMATCH, INVALID_WRAPPED_DEK_AAD
  • revoke_account_key: UNAUTHORIZED, KEY_REVOKED

Security Considerations

  • Consumers MUST verify signature and payload hash before trusting data
  • Push may duplicate deliveries; consumers MUST implement idempotent handling
  • Filter validation limits reduce DoS risk from pathological expressions
  • Subscriber caps and push work limits reduce fan-out abuse risk
  • Deterministic key-rotation cutover avoids signer ambiguity at sequence boundaries
  • Paid mode confidentiality MUST rely on CBSS threshold-PRE: per-epoch wrapped content keys are decryptable only by ≥ t-of-n committee members in aggregate. Validators alone cannot decrypt content.
  • Subscribers receive only the per-epoch content_key_e material for which they have a delivered SealRequest. They CANNOT derive other epochs’ content keys, because the publisher’s stream_secret never leaves the publisher’s keystore.
  • Account-key (X25519 private key) compromise grants the attacker access to every paid epoch that account has already received delivered material for; accounts SHOULD rotate X25519 keys periodically and re-acquire access under the new key.
  • Account-key revocation is immediate for future acquire_epoch_access calls; in-flight SealRequests already on-chain continue per CBSS rules. Re-wrapping the relevant epochs (via register_content_keys) to bump generation invalidates any pending or already-aggregated threshold signatures for the prior identity.
  • Publisher stream_secret compromise exposes all past and future epoch keys. Publishers SHOULD treat stream_secret as long-lived high-value material and hold it in HSM-grade keystore; recovery requires re-keying the stream (new stream_secret, re-wrap all upcoming epochs with bumped generations).
  • Compromise of one epoch’s content_key_e does NOT expose other epochs (HKDF domain separation; publisher’s master secret never leaves keystore).
  • AEAD libraries MUST use constant-time implementations (RustCrypto chacha20poly1305, libsodium, or equivalent audited).
  • Nonce uniqueness is achieved by the publisher SDK using a CSPRNG; the 192-bit nonce space makes collision probability negligible for practical publish volumes.
  • Sponsored purchases do not leak key material to the payer (per-epoch content keys are delivered only to the beneficiary’s X25519 recipient).
  • Protocol treasury is the chain-wide system Treasury (0x08); its address is governance-controlled and compromise of any publisher treasury does not affect protocol fee collection.
  • StreamAccess state is on-chain and verifiable; the CBSS committee enforces the off-chain key delivery contract.
  • The CBSS committee is a trust assumption: a corrupt threshold of committee members could collude to decrypt every paid stream. Operators SHOULD monitor committee health and rotate via the standard CBSS RotateCommittee flow.

Backwards Compatibility

This CIP is greenfield. Cowboy has no installed Watchtower base; the prior stream_actor.py and watchtower_registry.py reference actors at node/cli/actors/ are pre-CIP-7 sketches and are replaced, not migrated. For implementers building consumer applications:
  • Ciphertext is delivered to all subscribers regardless of payment status. Decryption — not transport — is the access-control boundary.
  • One StreamAccess record per (stream_id, beneficiary_account) covers all downstream consumers the account holder delegates to off-chain (the account-scoped X25519 private key gates delivery).
  • Paid mode REQUIRES the CBSS committee to be live. If CBSS is not yet bootstrapped on a chain (e.g., devnets pre-DKG), only OPEN streams are usable; SUBSCRIBER_PAID register_content_keys calls will fail until the committee is established.
  • Publishers MUST pre-wrap upcoming content keys (typically a daemon maintaining the next ~100–1,000 epochs) or subscribers will see EPOCHS_NOT_WRAPPED on acquire_epoch_access.
  • Subscribers MUST be prepared for asynchronous decryption: ciphertext arrives via push or pull immediately, but decryption succeeds only after CBSS committee delivery completes per epoch (~4–5 blocks per SealRequest, capped at REQUEST_FRESHNESS_BLOCKS = 384 blocks).
  • An expired SealRequest does NOT force a re-payment: subscribers re-call acquire_epoch_access, and the billing/delivery idempotency split means CBY is charged only for genuinely new epochs while fresh SealRequests get minted for any still-needed deliveries.

Reference Implementation Notes (Non-normative)

  • SDK StreamActor should expose the full actor method set
  • SDK should include cowboy_sdk.stream.Stream helpers for derive_content_key, encrypt, decrypt, wrap_content_key, pre_wrap_epochs, unwrap_content_key, and acquire_epoch_access (the latter wraps the VM host function for convenience)
  • Pruning should be O(1) amortized
  • Push scheduler should use deterministic round-robin over active subscribers
  • StreamAccess lookup should be O(1) by (stream_id, beneficiary_account)
  • Publisher and subscriber SDK helpers should reuse cbss-client (wrap_cip9_volume_dek analog, combine_verified_partials, ibe_decrypt) for all CBSS interactions; reimplementing IBE primitives is forbidden
  • Canonical CBOR for params, results, and AAD MUST reuse cowboy_sdk.codec (Python) and the equivalent Rust helper at the host boundary; cross-language test vectors are required
  • For sustained pull-only consumers, the SDK should advance cursors in chunks of up to MAX_GET_SINCE_LIMIT (500) per call; a fully-backed subscriber catching up from a 10,000-message buffer requires ≤ 20 calls
  • Subscribers SHOULD persist last_received_sequence locally to resume after restart; on CURSOR_TOO_OLD they MAY reattach at head_sequence and accept the gap
  • Subscribers SHOULD watch CBSS SealDelivered events filtered by seal_request_id returned in KeyAccessReceipt; aggregation of partials should follow the existing CIP-9 runner pattern in cbss/crates/cbssd/src/cip9_volume_seal.rs

Rationale

Why CBSS-routed key custody?

Validators execute the VM deterministically and have visibility into all on-chain state. A purely “VM-managed” key model — where the platform holds a master seed used to derive content keys inside the VM — necessarily means any validator can decrypt every paid stream. CBSS threshold-PRE breaks this: per-epoch content keys are IBE-encrypted to a t-of-n committee MPK and recoverable only by aggregating committee partial signatures. No single validator (or even a sub-threshold subset) can decrypt content without colluding as a CBSS committee threshold.

Why per-epoch wrapped content keys (and not per-stream secrets)?

The naïve design — wrap one master stream_secret per stream, deliver to paying subscribers, let them derive per-epoch content keys via HKDF — is broken: any subscriber who pays for a single epoch and receives stream_secret can derive every past and future epoch’s content_key. The active_until_key_epoch access window becomes unenforceable once the secret leaves the publisher. Per-epoch wrapped content keys fix this cryptographically: each epoch’s content key is IBE-wrapped under a distinct identity, so committee threshold signatures gathered for (stream_id, epoch_5) only decrypt that epoch’s wrapped key — they cannot be used to decrypt (stream_id, epoch_6). Subscribers receive committee signatures exactly for the epochs they paid for and nothing else. The publisher’s stream_secret stays in the publisher’s keystore and is never delivered to anyone. The cost is storage growth (~110–150 bytes per epoch per stream — about 8 MB/year at 10-min epochs for a continuously-active stream) and an operational requirement that publishers pre-wrap upcoming epochs. Both are acceptable; both alternatives (per-stream secret with broken billing; per-acquire publisher-in-the-loop wrapping with broken scalability) are worse.

Why include generation in the IBE identity?

If content_key_identity were stable across re-wraps of the same (stream_id, epoch), a threshold signature obtained for an old wrapped key would also decrypt any future re-wrap of that epoch — defeating the purpose of re-wrapping after a key compromise or committee rotation. The generation counter in identity ensures that incrementing it produces a fresh identity, so old signatures stop working on new wrapped material.

Why no stream_encrypt / stream_decrypt VM host functions?

In a CBSS-routed model, decryption requires the subscriber’s X25519 private key — material that cannot live in the VM (deterministic execution would expose it to all validators). The natural place for both encryption (publisher knows the per-stream secret) and decryption (subscriber receives the committee-delivered wrapped secret) is off-chain in vetted SDK code. The trade-off — losing in-VM gas-metered crypto — is acceptable because the publisher already operates off-chain to assemble each message, and subscribers already need an off-chain consumer process to drive any non-trivial application.

Why account-scoped, not actor-scoped keys?

An account is the natural billing entity. Actors are programs; accounts are economic agents. If account A delegates consumption to five different worker processes that all read the same price feed, they all use the account’s X25519 private key off-chain and share one StreamAccess purchase. Actor-scoped keys would force separate purchases for what is one economic relationship.

Why async key delivery?

CBSS is a t-of-n threshold scheme; committee proxies sign partials independently and aggregation happens off-chain. There is no synchronous “give me the key now” path that doesn’t either (a) move trust onto a single party or (b) replace the threshold scheme with a TEE. The 4–5 block latency is the price of true threshold custody and matches the existing CIP-9 volume DEK delivery flow.

Why rolling window, not static ranges?

Static ranges (from_epoch..to_epoch) create complexity around gaps, overlaps, and partial refunds. A rolling upper bound is simpler: one integer per entitlement, monotonically increasing, idempotent extension. It supports buy-as-you-go, sponsored top-ups, and continuous consumption without subscription lifecycle management.

Why protocol fee on top (additive), not embedded?

An additive fee (total = publisher_price + protocol_cut) is transparent. Publishers set their price; the protocol adds its fee. There is no ambiguity about who gets what. An embedded fee (protocol takes X% of publisher’s stated price) creates incentive misalignment where publishers inflate prices to offset the cut.

Why separate subscription from stream access?

Subscription controls delivery routing (push/pull). StreamAccess controls decryption (key epochs). Separating them means a subscriber can receive ciphertext via push without paying (they just can’t decrypt), and an account can pre-purchase epoch access before any actor subscribes. This is cleaner than coupling payment to subscription lifecycle, and is why this CIP intentionally does not define a renew_subscription convenience method: billing happens at acquire_epoch_access and nowhere else.

Open Questions

  • Should protocol_fee_bps be globally fixed or per-stream configurable within bounds?
  • Should a future extension define bulk epoch purchase discounts?
  • Distribution of the system Treasury (0x08) — burn vs. stake vs. fund — is set by governance (see CIP-2 §5.4); should CIP-7 protocol fees be earmarked or treated as fungible with runner-fee revenue?
  • Should there be a maximum key_epoch_blocks to prevent publishers from setting excessively long epochs that reduce billing granularity?
  • Should a future extension add optional delivery receipts without retries?
  • Should a future extension define refundable prepaid balances or proration?
  • Should there be a StreamConfigUpdated event emitted when access_mode or paid_stream_config is changed (in particular, access_mode mutability is not yet specified)?
  • Should CBSS committee rotation auto-trigger a re-wrap notification for affected WrappedContentKey rows, or is publisher-driven re-registration sufficient? Auto-notification would surface stale-committee state proactively but adds SKM/CBSS coupling.
  • Should the spec define a minimum pre-wrap depth that publishers MUST maintain (e.g., K = 10 upcoming epochs) so subscribers have predictable acquire success? v1 leaves this to publisher operational discipline; a future CIP could add an on-chain SLA.
  • v1 caps a single acquire_epoch_access at MAX_EPOCHS_PER_ACQUIRE = 256 epochs to bound block-time cost of SealRequest fan-out. Should a future extension introduce a deferred-batch acquire that spans tx boundaries for larger purchases (e.g., a year’s worth of epochs at once)?
  • Should the spec define a v1.1 actor-attested decrypt path (e.g., authorized actors hold derived runner-X25519 keys via TEE attestation), restoring on-chain ActorAuthorization and finer-grained enforcement?
  • Should SKM emit explicit SealRequestExpired events to help subscriber SDKs decide when to re-acquire, or rely on subscribers’ own deadline tracking against REQUEST_FRESHNESS_BLOCKS?
  • For sponsored purchases, should the payer optionally be notified of delivery completion (separate event vs. relying on the beneficiary)?
  • The CLI spec at /cli-specs/cowboy-watchtower currently predates this CIP and covers only publish/subscribe/list. It needs to be expanded to cover key rotation, account-key registration, register_content_keys, acquire_epoch_access, off-chain decrypt commands, and pull/get_since. Tracked separately.
  • CIP-17 (stream-bridge) is referenced from docs/docs.json but no spec file exists. Out of scope for this CIP; flag for separate authoring.