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)
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. AStreamActor 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
StreamMessageformat 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, andPUSH_WITH_PULL_FALLBACK - A deterministic JSON filter DSL over headers/tags
- A bounded replay window (
10,000messages) with explicitCURSOR_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
CBYbilling
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
CBYvalue 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
CBYin 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_000MAX_GET_SINCE_LIMIT = 500DEFAULT_MAX_SUBSCRIBERS = 10_000DEFAULT_INGEST_INTERVAL_BLOCKS = 1DEFAULT_KEY_EPOCH_BLOCKS = 600BILLING_ASSET = CBY(paid mode v1 REQUIRED)MAX_PROTOCOL_FEE_BPS = 5_000(50% cap)CONTENT_CIPHER = XCHACHA20_POLY1305(paid mode v1 REQUIRED)NONCE_BYTES = 24TAG_BYTES = 16MAX_EFFECTIVE_PLAINTEXT_BYTES = 16_344(16,384 - 24 - 16)DEFAULT_MIN_PURCHASE_EPOCHS = 1MAX_ACCOUNT_KEYS_PER_ACCOUNT = 8MAX_EPOCHS_PER_ACQUIRE = 256(per-tx cap on the SealRequest fan-out of a singleacquire_epoch_accesscall)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
CBYfor 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_epochupper 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 byXChaCha20-Poly1305forpayload_inlineencryption/decryption. The publisher IBE-encrypts each content key to the committee MPK and registers it on-chain asWrappedContentKey. - Generation: Per-epoch monotonic counter at
0xD || 0x06 || keccak(stream_id) || u64_be(epoch). Starts at1, 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
WrappedContentKeyfor that epoch. Minted by SKM duringacquire_epoch_access— one SealRequest per newly-covered epoch.
Stream Model
Each stream is represented by one actor with:stream_idhead_sequence(starts at0; first publish becomes1)floor_sequence(starts at1; advances as pruning occurs)- single active
publisher_key - key schedule history for signature verification across rotations
ring_buffer_capacity(default10,000)max_subscriberssubscription_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 seed0x04), 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:
- 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.
- Per-epoch billing is enforced cryptographically. The publisher wraps one content key per
(stream_id, key_epoch, generation)triple. Subscribers paying for epochsE+1..Treceive committee threshold signatures only for those specific epochs and cannot derive keys for unpaid epochs. The publisher’s masterstream_secretnever leaves the publisher’s keystore. - No VM-side
stream_encrypt/stream_decrypthost functions. Encryption happens in publisher SDK; decryption happens in subscriber SDK. Both are bounded by audited library code (cbss-client,cbss-crypto). acquire_epoch_accessis 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 atREQUEST_FRESHNESS_BLOCKS = 384).
Stream Key Manager System Actor
A new system actor is added at deterministic seed0x000000000000000D (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
X25519key registration (subscriber recipient binding) - Stream access tracking and
CBYbilling - Per-epoch SealRequest emission to CBSS upon successful
acquire_epoch_access - Protocol fee collection
HostApi Extensions
Two new methods are added to theHostApi trait, exposed to actor Python code as VM host functions:
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-prefix0xD under its system actor address:
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 byWrappedDek — 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):
blstrs::G1Projective::hash_to_curve (SSWU map, random oracle).
Bytes form. The pre-hash identity payload:
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:
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):
WrappedDek extends it with the Boneh-Franklin encryption ephemeral, exactly as in CIP-24:
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.
- The DST + hash-to-curve isolate CIP-7 from CIP-9 even if their pre-hash bytes happen to collide.
- The bytes-form length-prefixing prevents collisions between different
stream_idvalues. generationin bytes form means re-wrapping bumps the H2C point — old σs stop working for new wrapped material.- The
WrappedDek.aadincludescommittee_epoch, so an attacker can’t swap aWrappedDekfrom a prior committee into a new SealRequest path: the AEAD tag verification fails. - The per-wrap ephemeral
Ubound into the AAD is what makes the wrap key secret at all:K = HKDF(serialize(e(I, MPK)^r)), soKis not recomputable from the public(I, MPK)(CIP-24 confidentiality fix), and post-publish tampering withephemeral_uis 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; returnsnonce(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 viaregister_content_keys.
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.
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 keycurrent_signing_key_id(uint64): key identifier forpublisher_keyring_buffer_capacity(uint32): default10,000max_subscribers(uint32): default10,000subscription_policy(enum):PUBLIC|PRIVATE_ALLOWLISTmax_push_deliveries_per_block(uint32)max_push_cycles_per_block(uint64)access_mode(enum):OPEN|SUBSCRIBER_PAIDpaid_stream_config(optionalPaidStreamConfig)ingestion(optionalIngestionConfig)
ring_buffer_capacityMUST be > 0current_signing_key_idMUST be >= 1; the constructor (init, below) establishes the initial valuemax_subscribersMUST be > 0- Push limits MUST be finite and non-zero
- If
access_mode == SUBSCRIBER_PAID,paid_stream_configMUST be set - If
access_mode == OPEN,paid_stream_configMUST be absent
2. PaidStreamConfig (optional)
Fields:fee_per_key_epoch_cby(uint64): publisher amount per newly covered key epoch, in CBY weiprotocol_fee_bps(uint16): basis points applied on top of publisher amountpublisher_treasury(address): where publisher revenue is creditedkey_epoch_blocks(uint32): default600content_cipher(enum/string):XCHACHA20_POLY1305(v1 REQUIRED)min_purchase_epochs(uint32): default1
fee_per_key_epoch_cbyMUST be > 0protocol_fee_bpsMUST be in[0, MAX_PROTOCOL_FEE_BPS]key_epoch_blocksMUST be > 0content_cipherMUST beXCHACHA20_POLY1305in this CIP versionmin_purchase_epochsMUST be >= 1; purchases covering fewer thanmin_purchase_epochsnewly-charged epochs MUST fail withMIN_PURCHASE_NOT_METpublisher_treasuryMUST be a valid account address
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 epochcommittee_epoch(uint64): CBSS committee epoch at the time of wrapwrap_block_height(uint64): block at which the wrap was registeredwrapped_dek(WrappedDek): IBE ciphertext envelope pernode/types/src/cbss.rs— fieldsversion: 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
- One row per
(stream_id, key_epoch). The publisher MUST register a row before any subscriber can acquire access to that epoch (elseEPOCHS_NOT_WRAPPED). - Re-registering an existing
(stream_id, key_epoch)incrementsgeneration, generates a fresh IBE identity (which incorporatesgeneration), and produces a newwrapped_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_dekis 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_keyscall (see Actor Interface).
3. StreamMessage
Fields:version(uint8): MUST be1for this CIP revisionstream_id(bytes32/string)sequence(uint64)timestamp_unix_ms(uint64)kind(string): examplesnews,price_batch,alertcontent_type(string): payload media type, defaultapplication/jsontags(map<string, string | float64 | bool>)payload_format(enum):PLAINTEXT|CIPHERTEXTpayload_inline(bytes): MUST be ≤ 16 KiBpayload_hash(bytes32):SHA-256(payload_inline)key_epoch(optional uint64): REQUIRED whenpayload_format == CIPHERTEXTsigning_key_id(uint64): publisher-key identifier active at this sequencepublisher_sig(bytes): ed25519 signature over canonical signing bytes
versionMUST be1payload_inlineis REQUIREDpayload_inlinesize MUST be ≤MAX_INLINE_PAYLOAD_BYTESsequenceMUST be strictly increasing and contiguous (prev + 1)payload_hashMUST matchpayload_inline- If
payload_format == CIPHERTEXT, subscribers require committee-delivered stream-secret access to decrypt - In
SUBSCRIBER_PAIDmode, stream MUST publishCIPHERTEXTmessages - In
SUBSCRIBER_PAIDmode, ciphertext payloads MUST be produced off-chain usingcowboy_sdk.stream.Stream.encrypt(...)(or equivalent audited library) - In
CIPHERTEXTmode,payload_inlineMUST be encoded as:nonce(24 bytes) || ciphertext_with_tag - In
CIPHERTEXTmode, the 16 KiB limit applies to the full encrypted envelope (nonce + ciphertext + tag) - In
CIPHERTEXTmode,aadfor 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 is16_384 - 24 - 16 = 16_344bytes - 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_FALLBACKfilter(JSON Filter DSL)created_at_sequence(uint64)account_key_id(optional uint64): account key used as the CBSS re-encryption recipient. REQUIRED forSUBSCRIBER_PAIDmode atacquire_epoch_accesstime.status(enum):ACTIVE,CANCELLED
subscribeMUST validate filter schema before activation- New subscription MUST fail with
SUBSCRIBER_CAP_REACHEDwhen full - In
PRIVATE_ALLOWLIST, non-allowlisted addresses MUST fail withSUBSCRIPTION_NOT_ALLOWED - Subscription controls delivery (push/pull routing). In
SUBSCRIBER_PAIDmode, decryption access is controlled separately byStreamAccessrecords on the Stream Key Manager. account_key_idSHOULD be set at subscribe time forSUBSCRIBER_PAIDstreams; it specifies the X25519 recipient for CBSS-delivered per-epoch content keys. If unset at subscribe time, the subscriber MUST provide it atacquire_epoch_access. Subscription itself never triggers billing or CBSS activity.- In
OPENmode, no payment or access record is required for subscription. - If
start_cursoris provided and is strictly less thanfloor_sequence - 1at subscribe time, the call MUST fail withCURSOR_TOO_OLD. Subscribers MAY retry withstart_cursoromitted to attach athead_sequence. statustransitions: a subscription is createdACTIVE.unsubscribesets it toCANCELLED.CANCELLEDis terminal; reactivation requires a newsubscribecall.
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)
- Access is rolling and account-scoped
- Account has access for all key epochs
<= active_until_key_epoch - Purchasing access to epoch
Twhen current access isEcharges for epochsE+1..Tinclusive - 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 accountscheme(string):"X25519"(v1 REQUIRED)public_key(bytes): X25519 public key — the recipient identity for CBSS-delivered per-epoch content keysstatus(enum):ACTIVE|REVOKEDregistered_at(uint64): block height
- Keys are owned by the account, not by any actor
- An account MAY register up to
MAX_ACCOUNT_KEYS_PER_ACCOUNTkeys account_key_idis assigned sequentially starting at1- 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_accesscalls referencing a revoked key MUST fail withKEY_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 byacquire_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): default1task_definition(object): CIP-2 task requestresult_schema(object)transform_method(optional string)num_runners(uint8)proof_type(enum from CIP-2)
- When enabled, actor MUST schedule recurring timer callbacks
- Actor MUST submit CIP-2 tasks using configured
num_runnersandproof_type - Ingestion failures MUST NOT increment sequence
Canonical Hashing and Signing (Normative)
This CIP fixes signature and encoding rules now.signature_scheme: ed25519hash_alg: SHA-256canonical_encoding: Deterministic CBOR (RFC 8949 canonical form)
stream_idversionsequencetimestamp_unix_mskindcontent_typetagspayload_formatpayload_hashkey_epoch(ornullwhenpayload_format == PLAINTEXT)signing_key_id
- Compute
payload_hash = SHA-256(payload_inline) - Build the signing payload object with exactly the keys above
- Encode using deterministic CBOR
- Sign bytes with ed25519 private key
- Store signature in
publisher_sig
- Consumer MUST recompute
payload_hash - Consumer MUST rebuild deterministic-CBOR signing payload
- Consumer MUST verify
publisher_sigagainst key schedule entry effective at that sequence - Consumers MAY resolve key schedule via
get_key_at_sequenceor cachedget_key_historyoutput
tagskeys 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.
- 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_keyis forbidden. Publishers MUST NOT cache or reuse nonces across messages.
SUBSCRIBER_PAID mode, the AEAD AAD is mandatory and MUST be the canonical-CBOR encoding (using cowboy_sdk.codec.encode) of:
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:
- Set
owner = sender(deploying account). - Validate
initial_publisher_keyis a 32-byte ed25519 public key. - Set
head_sequence = 0,floor_sequence = 1. - Set
current_signing_key_id = 1, append key-schedule entry{signing_key_id: 1, publisher_key: initial_publisher_key, effective_sequence: 1}. - Apply
access_moderules fromStreamConfig; ifSUBSCRIBER_PAID,paid_stream_configMUST be present and passPaidStreamConfigvalidation. - Apply defaults for unprovided fields per Protocol Constants.
- Emit
StreamInitialized.
initMUST be called exactly once per StreamActor; subsequent calls MUST fail withALREADY_INITIALIZED.initMUST NOT publish any message; first published sequence is1via subsequentpublishcalls.
publish(kind, content_type?, tags, payload_format, payload_inline, key_epoch?, publisher_sig)
Behavior:
- Validate payload size ≤ 16 KiB
- If
content_typeomitted, setcontent_type = application/json - Compute
payload_hash = SHA-256(payload_inline) - Validate
payload_formatandkey_epochconsistency - If
SUBSCRIBER_PAIDmode, requirepayload_format == CIPHERTEXT - Validate signature against active publisher key using signing payload that includes computed
payload_hash - Set
next_sequence = head_sequence + 1 - Persist message at
next_sequencewithversion=1,content_type, andsigning_key_id - Update
head_sequence = next_sequence - Prune ring buffer deterministically (see pruning section)
- Emit
StreamMessagePublished - Enqueue message for push delivery
- Publisher composes plaintext payload, metadata, tags, and kind.
- Publisher computes
key_epoch = floor(block_height / key_epoch_blocks). - Publisher derives
content_key = HKDF(stream_secret, "cip-7-content-key-v1", chain_id || stream_id || u64_be(key_epoch))locally, holdingstream_secretin its off-chain keystore. - Publisher generates a fresh random 24-byte nonce.
- Publisher builds canonical-CBOR AAD per Canonical Hashing and Signing.
- Publisher encrypts:
ciphertext_envelope = nonce || XChaCha20-Poly1305-Encrypt(content_key, nonce, aad, plaintext) || tag. - Publisher signs the StreamMessage and submits via
publish(...).
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
modeand filter schema - Enforce subscription policy and subscriber cap
- Create or update subscription
- If
start_cursoromitted, set to currenthead_sequence - Emit
SubscriberUpdated
subscribeis an upsert keyed bysubscriber.- On update, subscriber MAY change
mode,filter, andaccount_key_id. created_at_sequenceMUST remain unchanged on update.start_cursorapplies only on create; on update it is ignored.
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 sequencelimit:1..500
- Reject invalid limit with
LIMIT_EXCEEDED - If
cursor < floor_sequence - 1, returnCURSOR_TOO_OLD - Return messages where
sequence > cursor, ascending - Return at most
limit
get_sincereturns ciphertext messages regardless of access status.- Stream access gates decryption, not ciphertext retrieval.
get_head()
Returns:
head_sequencefloor_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_sequenceonward - 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_idpublisher_keyeffective_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
PUBLICorPRIVATE_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):
- Validate
access_mode == SUBSCRIBER_PAIDfor this stream. - Resolve
AccountKeyRegistration(beneficiary_account, account_key_id); requirestatus == ACTIVE. Fail withKEY_REVOKED(revoked) orINVALID_ACCOUNT_KEY(missing). - Resolve current access:
E = active_until_key_epochfor(stream_id, beneficiary_account). If no access record exists,E = current_key_epoch - 1(no free retroactive access). target_key_epochMUST be >=current_key_epoch; else fail withINVALID_TARGET_KEY_EPOCH.target_key_epoch - current_key_epoch + 1MUST be ≤MAX_EPOCHS_PER_ACQUIRE(256); else fail withACQUIRE_RANGE_TOO_LARGE { requested: u64, max: u64 }. Subscribers wanting more epochs must callacquire_epoch_accessmultiple times.- Compute the full mint set first. Collect
epochs_needing_delivery: Vec<u64>= every epoch incurrent_key_epoch .. target_key_epochfor 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_epochANDe > E), OR - already billed (
e ≤ E) but noSealRequestStatusexists at0xD || 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 currentWrappedContentKey.generationfor that epoch.
- newly billed (
- Pre-billing validation (covers every epoch that will mint or re-mint): for every epoch
einepochs_needing_delivery, verify aWrappedContentKeyexists at0xD || 0x03 || keccak(stream_id) || u64_be(e)AND itscommittee_epochmatches the current CBSS committee epoch (or registered override). Collect missing epochs; if non-empty, fail withEPOCHS_NOT_WRAPPED { missing_epochs: Vec<u64> }. If any present row has a stale committee epoch, fail withCOMMITTEE_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). - Billing path:
- Compute
epochs_charged = max(0, target_key_epoch - E). - If
0 < epochs_charged < min_purchase_epochs, fail withMIN_PURCHASE_NOT_MET. - If
epochs_charged == 0, setpublisher_amount = protocol_fee = total = 0(billing-idempotent path). Do NOT enforce min-purchase. - Else compute:
- If
total > 0: transfertotalCBY frompayer_account(fail withINSUFFICIENT_CBY_BALANCEif insufficient); creditpublisher_amounttopublisher_treasuryandprotocol_feeto system Treasury (0x08). - Set
active_until_key_epoch = max(E, target_key_epoch).
- Compute
- Delivery path (independent of billing): for every epoch
einepochs_needing_delivery:- Mint a fresh CBSS
SealRequestwithidentity_bytes = content_key_identity_bytes(chain_id, stream_id, e, generation)andrecipient = AccountKeyRegistration.public_key. (See Content-key Identity for full hash-to-curve derivation.) - Persist as
SealRequestStatusat0xD || 0x05 || keccak(stream_id) || keccak(beneficiary_account) || u64_be(account_key_id) || u64_be(e), replacing any prior row. The row’saccount_key_idis implicit in the storage key, so differentaccount_key_idvalues for the same(beneficiary, epoch)never overwrite each other. - Append
{ epoch: e, seal_request_id, generation }toseal_requests_mintedin the receipt.
- Mint a fresh CBSS
- Emit
EpochAccessPurchased(ifepochs_charged > 0) orEpochAccessIdempotent(ifepochs_charged == 0). Emit oneSealRequestEmittedper entry inseal_requests_minted. - Return
KeyAccessReceipt.
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
WrappedContentKeyto obtaincontent_key_e. content_key_edecrypts only that epoch’s ciphertexts; the subscriber cannot derive other epochs’ keys.- SKM updates
SealRequestStatustoDELIVEREDon the correspondingSealDeliveredevent, or toEXPIREDafterREQUEST_FRESHNESS_BLOCKS. SKM emitsSealRequestExpiredon transition to expired.
- Cycles:
5,000fixed +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
payer_accountMAY differ frombeneficiary_account(sponsorship). Stream access accrues tobeneficiary_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
0CBY 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 - Eand up toT - 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:
- Verify transaction is signed by
account. - Validate
schemeis"X25519"(v1). - Validate
public_keylength (32 bytes for X25519). - Check account has fewer than
MAX_ACCOUNT_KEYS_PER_ACCOUNTactive keys. - Assign
account_key_id = next_idfor this account (auto-increment). - Store
AccountKeyRegistration. - Emit
AccountKeyRegistered. - Return registration.
- 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:
- Verify caller is the stream’s
owner. - Validate
stream_idcorresponds to an existingSUBSCRIBER_PAIDstream. - Validate
committee_epochmatches the current CBSS committee epoch (or the optionalcommittee_override_hashcommittee’s epoch). Else fail withCOMMITTEE_EPOCH_MISMATCH. - For each entry:
- Validate
wrapped_dekshape ({version(== 2), ciphertext, nonce(12), ephemeral_u(96), aad}); reject any otherversiontag value, reject emptyciphertext, and rejectephemeral_uunless 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)(default0if missing); setnew_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_uper Content-key Identity. Verifyentry.wrapped_dek.aad == expected_aadby 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-publishephemeral_utampering. - Persist
WrappedContentKeyat0xD || 0x03 || keccak(stream_id) || u64_be(key_epoch). Replaces any prior row for the same epoch. - Write
new_generationat0xD || 0x06 || keccak(stream_id) || u64_be(key_epoch). - Emit
WrappedContentKeyRegistered { stream_id, key_epoch, generation: new_generation, committee_epoch }.
- Validate
- 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-encryptscontent_key_eto the committee usingcbss-client::wrap_cip7_content_key(a new helper analogous towrap_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_accessfor 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 priorcontent_key_ethey 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.
- Cycles:
2,000fixed +1,000 * entries.len() - Cells: size of serialized
WrappedContentKeyrows ×entries.len()
revoke_account_key(account, account_key_id)
Caller: Account holder.
Behavior:
- Verify transaction is signed by
account. - Set
AccountKeyRegistration.status = REVOKED. - Emit
AccountKeyRevoked.
- 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)
- Push delivery is a transport concern and MAY deliver ciphertext regardless of key access.
- Decryption is entirely off-chain; the on-chain
StreamAccessrecord (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_blockmax_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
get_sincereturns 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 nativeCBY billing.
On-chain stream access
- In
SUBSCRIBER_PAIDmode, stream access is tracked per(stream_id, beneficiary_account)viaactive_until_key_epochon 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
ksucceeds iffk ≤ active_until_key_epoch.
Payload confidentiality
- Stream publishes inline
CIPHERTEXTpayloads inSUBSCRIBER_PAIDmode. - 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 atacquire_epoch_accesstime). - 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_secretnever leaves the publisher’s off-chain keystore. - For each upcoming
key_epoch, publisher derivescontent_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_eto the committee under identitycontent_key_identity(chain_id, stream_id, key_epoch, generation)and submits viaregister_content_keys(batched). One on-chain row per epoch. StreamMessage.key_epochselects whichcontent_key_eto use when decryptingpayload_inline.- Forward + backward isolation. Even a subscriber who decrypts
content_key_ecannot derivecontent_key_{e'}for any other epoche', because the underlyingstream_secretis the publisher’s secret and never delivered. Each epoch’s confidentiality is independent. - Re-wrap semantics. Re-registering an epoch bumps its
generationcounter; 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 cleartextcontent_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 ofacquire_epoch_access. The detailed method-section is authoritative; implementers SHOULD work from it directly.
- Publisher pre-wraps content keys for upcoming epochs via
register_content_keys(out-of-band, ahead of subscriber demand). - Caller invokes
acquire_epoch_access(stream_id, beneficiary, payer, target_epoch, account_key_id). - SKM resolves subscriber’s
AccountKeyRegistration(failKEY_REVOKED/INVALID_ACCOUNT_KEYif revoked or missing). - SKM bounds the request:
target_epoch - current_key_epoch + 1MUST be ≤MAX_EPOCHS_PER_ACQUIRE, elseACQUIRE_RANGE_TOO_LARGE. - SKM computes
epochs_needing_delivery— the union of (a) newly-billed epochsE+1..target_epochand (b) already-billed epochs whoseSealRequestStatusat0xD || 0x05 || keccak(stream_id) || keccak(beneficiary) || u64_be(account_key_id) || u64_be(epoch)is missing, EXPIRED, or bound to a stalegeneration. - SKM pre-validates the entire
epochs_needing_deliveryset: every epoch MUST have aWrappedContentKeyrow whosecommittee_epochmatches the current CBSS committee. Failures yieldEPOCHS_NOT_WRAPPED { missing_epochs }orCOMMITTEE_EPOCH_MISMATCH { epoch, found, current }. No CBY moves and no SealRequests are minted until this passes for every epoch that would mint. - Billing path:
epochs_charged = max(0, target_epoch - E). If> 0and belowmin_purchase_epochs, fail. Else compute fees, transfer CBY, credit treasuries, setactive_until_key_epoch = max(E, target_epoch). - Delivery path: for each epoch
einepochs_needing_delivery, mint a CBSS SealRequest withidentity_point = content_key_identity_point(...)and recipientAccountKeyRegistration.public_key. PersistSealRequestStatusat the per-(beneficiary, account_key_id, epoch) key — distinctaccount_key_idvalues for the same beneficiary/epoch never overwrite. EmitEpochAccessPurchased(orEpochAccessIdempotent) plus oneSealRequestEmittedper mint. - 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
WrappedContentKeyto recovercontent_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 = 384blocks. Expired requests yield no delivery; SKM emitsSealRequestExpired; subscriber MAY re-callacquire_epoch_accessto re-mint. Billing idempotency ensures the re-call charges0CBY; delivery idempotency ensures fresh SealRequests are minted for the still-needed epochs. - Subscriber tracks delivery via
SealDeliveredevents emitted by CBSS, correlated by theseal_request_idvalues inKeyAccessReceipt.seal_requests_minted. - Publisher and subscriber MUST tolerate the latency window: subscribers polling
get_sinceimmediately afteracquire_epoch_accesswill 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_accesscalls. 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_accesspurchase covers all consumers the account holder delegates to, since they all share the same X25519 private key off-chain.
Sponsored purchases
payer_accountMAY be any funded account, including a third partyCBYis debited frompayer_account; stream access accrues tobeneficiary_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
CBYdemand proportional to paid stream usage across the ecosystem
CBY value flow
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_sequencefloor_sequencering_buffer_capacity
- Append message at
head_sequence + 1 - Set
head_sequence = head_sequence + 1 - If
(head_sequence - floor_sequence + 1) > ring_buffer_capacity:- delete message at
floor_sequence - set
floor_sequence = floor_sequence + 1
- delete message at
- When
head_sequence == 0, stream has no retained messages. - In that state, pruning condition MUST NOT be evaluated.
get_sinceMUST return an empty result set for valid limits.
cursoris stale iffcursor < floor_sequence - 1
JSON Filter DSL
Filters are evaluated only on:kindtags.<key>sequencetimestamp_unix_ms
eq,ne,in,nin,gte,lte,exists
{"all": [ ... ]}(AND){"any": [ ... ]}(OR){"not": { ... }}
- Maximum depth:
4 - Maximum predicates:
16 - Unknown fields/operators MUST fail subscription validation
Ingestion Flow (Optional)
Wheningestion.enabled == true:
- Timer fires every
interval_blocks(default1) - Actor submits CIP-2 task using configured:
task_definitionresult_schemanum_runnersproof_type
- Runner callback returns result
- Actor transforms result into publishable payloads
- For
SUBSCRIBER_PAIDmode, publisher SDK encrypts each transformed payload off-chain usingStream.encrypt(stream_secret, key_epoch, plaintext, aad)before the actor invokespublish(...)with the ciphertext envelope - Actor calls
publish(...)for each transformed payload - Actor reschedules next ingestion timer
- 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_batchmessage per block - Include multiple ticks in one inline payload
- Keep payload ≤ 16 KiB
- Use tags such as
symbol,venue,window_ms,countfor 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) inpayload_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_idversionsequencekindcontent_typepayload_formatpayload_hashkey_epoch(nullable)signing_key_idtimestamp_unix_ms
SubscriberUpdated
stream_idsubscribermodestatus
PublisherKeyRotated
stream_idold_keynew_keyold_signing_key_idnew_signing_key_ideffective_sequence
EpochAccessPurchased
stream_idbeneficiary_accountpayer_accountfrom_key_epochto_key_epochepochs_chargedpublisher_amount_cbyprotocol_fee_cbytotal_amount_cby
EpochAccessIdempotent
stream_idbeneficiary_accounttarget_key_epochactive_until_key_epoch
AccountKeyRegistered
accountaccount_key_idschemeblock_height
AccountKeyRevoked
accountaccount_key_idblock_height
WrappedContentKeyRegistered
stream_idkey_epochgenerationcommittee_epochwrap_block_heightcommittee_override_hash(nullable)
SealRequestEmitted
stream_idbeneficiary_accountseal_request_idaccount_key_idkey_epochgeneration
SealRequestExpired
stream_idbeneficiary_accountseal_request_idkey_epochexpired_at_block
StreamInitialized
stream_idowneraccess_modeinitial_publisher_keyinitial_signing_key_id
IngestFailed
stream_idtimestamp_unix_msreason
Error Codes
Actor method errors:ALREADY_INITIALIZEDINVALID_SIGNATUREPAYLOAD_TOO_LARGEINVALID_FILTERCURSOR_TOO_OLDLIMIT_EXCEEDEDUNAUTHORIZEDSUBSCRIBER_CAP_REACHEDSUBSCRIPTION_NOT_ALLOWED
NOT_SUBSCRIBER_PAID_STREAMINSUFFICIENT_CBY_BALANCEINVALID_TARGET_KEY_EPOCHMIN_PURCHASE_NOT_METINVALID_ACCOUNT_KEYACCOUNT_KEY_LIMIT_REACHEDKEY_REVOKEDEPOCHS_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 }
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_INITIALIZEDpublish:INVALID_SIGNATURE,PAYLOAD_TOO_LARGE,UNAUTHORIZEDsubscribe:INVALID_FILTER,SUBSCRIBER_CAP_REACHED,SUBSCRIPTION_NOT_ALLOWED,CURSOR_TOO_OLD,INVALID_ACCOUNT_KEY(whenaccount_key_idreferences a missing or revoked registration in paid mode)get_since:LIMIT_EXCEEDED,CURSOR_TOO_OLDrotate_publisher_key:UNAUTHORIZEDset_subscription_policy:UNAUTHORIZEDallowlist_add/allowlist_remove:UNAUTHORIZEDacquire_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_MISMATCHregister_account_key:INVALID_ACCOUNT_KEY,ACCOUNT_KEY_LIMIT_REACHED,UNAUTHORIZEDregister_content_keys:UNAUTHORIZED,NOT_SUBSCRIBER_PAID_STREAM,COMMITTEE_EPOCH_MISMATCH,INVALID_WRAPPED_DEK_AADrevoke_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_ematerial for which they have a delivered SealRequest. They CANNOT derive other epochs’ content keys, because the publisher’sstream_secretnever 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_accesscalls; in-flight SealRequests already on-chain continue per CBSS rules. Re-wrapping the relevant epochs (viaregister_content_keys) to bumpgenerationinvalidates any pending or already-aggregated threshold signatures for the prior identity. - Publisher
stream_secretcompromise exposes all past and future epoch keys. Publishers SHOULD treatstream_secretas long-lived high-value material and hold it in HSM-grade keystore; recovery requires re-keying the stream (newstream_secret, re-wrap all upcoming epochs with bumped generations). - Compromise of one epoch’s
content_key_edoes 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. StreamAccessstate 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 priorstream_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
StreamAccessrecord 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
OPENstreams are usable;SUBSCRIBER_PAIDregister_content_keyscalls 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_WRAPPEDonacquire_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
StreamActorshould expose the full actor method set - SDK should include
cowboy_sdk.stream.Streamhelpers forderive_content_key,encrypt,decrypt,wrap_content_key,pre_wrap_epochs,unwrap_content_key, andacquire_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
StreamAccesslookup should be O(1) by(stream_id, beneficiary_account)- Publisher and subscriber SDK helpers should reuse
cbss-client(wrap_cip9_volume_dekanalog,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_sequencelocally to resume after restart; onCURSOR_TOO_OLDthey MAY reattach athead_sequenceand accept the gap - Subscribers SHOULD watch CBSS
SealDeliveredevents filtered byseal_request_idreturned inKeyAccessReceipt; aggregation of partials should follow the existing CIP-9 runner pattern incbss/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 masterstream_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 oneStreamAccess 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_bpsbe 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_blocksto 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
StreamConfigUpdatedevent emitted whenaccess_modeorpaid_stream_configis changed (in particular,access_modemutability is not yet specified)? - Should CBSS committee rotation auto-trigger a re-wrap notification for affected
WrappedContentKeyrows, 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 = 10upcoming 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_accessatMAX_EPOCHS_PER_ACQUIRE = 256epochs 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
SealRequestExpiredevents to help subscriber SDKs decide when to re-acquire, or rely on subscribers’ own deadline tracking againstREQUEST_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-watchtowercurrently predates this CIP and covers onlypublish/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.jsonbut no spec file exists. Out of scope for this CIP; flag for separate authoring.

