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 VM-level host functions and key issuance is a native platform billing event settled in CBY. Keys are scoped to accounts so multiple authorized actors can consume the same entitlement. The protocol collects a configurable fee on every key-epoch 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 VM-level encryption, on-chain entitlements, 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
- VM-level encryption/decryption primitives for paid mode
- Native per-key-epoch billing with protocol fee support
- Account-scoped keys with sponsored purchases (
payer != beneficiary) - Rolling entitlement 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_ACTOR_AUTHORIZATIONS_PER_STREAM = 64
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 key-epoch entitlement and decryption access
- Payer account: Account charged in
CBYfor entitlement extension (may differ from beneficiary) - Account key: Account-scoped X25519 key registration used for wrapped epoch-key delivery
- Rolling entitlement window:
active_until_key_epochupper bound per(stream_id, beneficiary_account)
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 VM-managed encryption and key-access billing
Platform Architecture (Paid Mode)
Paid stream encryption and billing are handled by a Stream Key Manager system actor and VM-level host functions, not by actor code. This eliminates per-actor crypto overhead and makes every decrypted epoch a nativeCBY billing event.
Stream Key Manager System Actor
A new system actor is added at deterministic seed0x0000000000000006:
| Seed | System Actor |
|---|---|
0x01 | Runner Registry |
0x02 | Job Dispatcher |
0x03 | Result Verifier |
0x04 | Secrets Manager |
0x05 | TEE Verifier |
0x06 | Stream Key Manager |
- Epoch content-key derivation and storage
- Account key registration
- Actor authorization for account-scoped decryption
- Entitlement tracking and
CBYbilling - Protocol fee collection
HostApi Extensions
Four new methods are added to theHostApi trait, exposed to actor Python code as VM host functions:
stream_encrypt(...) and gets ciphertext back in the same execution frame with no message-passing overhead.
Storage Layout
The Stream Key Manager uses storage prefix0x6 under its system actor address:
state_root.
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|PLATFORM_MANAGED(alias:SUBSCRIBER_PAID)paid_stream_config(optionalPaidStreamConfig)ingestion(optionalIngestionConfig)
ring_buffer_capacityMUST be > 0current_signing_key_idMUST be >= 1max_subscribersMUST be > 0- Push limits MUST be finite and non-zero
SUBSCRIBER_PAIDis an accepted alias forPLATFORM_MANAGEDfor migration compatibility; implementations MUST treat them as identical- If
access_mode == PLATFORM_MANAGED,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 creditedprotocol_treasury(address): where protocol fee is creditedkey_epoch_blocks(uint32): default600content_cipher(enum/string):XCHACHA20_POLY1305(v1 REQUIRED)key_scope(enum):ACCOUNT(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 versionkey_scopeMUST beACCOUNTin this CIP version (future CIPs may introduceACTORor other scoping)min_purchase_epochsMUST be >= 1; purchases covering fewer thanmin_purchase_epochsnewly-charged epochs MUST fail withMIN_PURCHASE_NOT_METpublisher_treasuryMUST be a valid account addressprotocol_treasuryis set at genesis or via governance; publishers MAY NOT override it
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 the corresponding epoch key to decrypt - In
PLATFORM_MANAGEDmode, stream MUST publishCIPHERTEXTmessages - In
PLATFORM_MANAGEDmode, ciphertext payloads MUST be produced by thestream_encryptVM host function - 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) - For
XCHACHA20_POLY1305, effective maximum plaintext size is16_384 - 24 - 16 = 16_344bytes
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): preferred account key for wrapped epoch-key delivery; hint onlystatus(enum):ACTIVE,PAUSED,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
PLATFORM_MANAGEDmode, decryption access is controlled separately by entitlements on the Stream Key Manager. account_key_idis an optional hint that identifies the subscriber’s preferred account key for key wrapping. It does NOT grant entitlement and does NOT trigger billing. It is informational for SDK and indexer convenience.- In
OPENmode, no payment or entitlement is required for subscription.
5. Entitlement (paid mode)
Fields:stream_id(bytes32/string)beneficiary_account(address)active_until_key_epoch(uint64)
- Entitlement is rolling and account-scoped
- Account is entitled for all key epochs
<= active_until_key_epoch - Purchasing access to epoch
Twhen current entitlement isEcharges for epochsE+1..Tinclusive - Repeated calls for already-entitled epochs are idempotent and free
- Past epochs remain accessible indefinitely within the window
- Entitlement 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 for epoch key wrappingstatus(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 for future key wrapping or decryption
- Key registration MUST be signed by the account holder
7. ActorAuthorization (paid mode)
Fields:account(address)actor(address)stream_id(bytes32)scope(enum):STREAM_DECRYPTstatus(enum):ACTIVE|REVOKEDgranted_at(uint64): block height
- An actor MUST be explicitly authorized by an account to decrypt on that account’s behalf for a specific stream
- Authorization is per
(account, actor, stream_id)triple - An account MAY authorize up to
MAX_ACTOR_AUTHORIZATIONS_PER_STREAMactors per stream - Revocation is immediate: revoked actors MUST NOT decrypt from the next block onward
- The account owner’s own actors (where
actor.creator == account) are auto-authorized unless explicitly revoked
8. KeyAccessReceipt (paid mode)
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 if idempotent)publisher_amount_cby(uint64)protocol_fee_cby(uint64)total_amount_cby(uint64)
9. 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. - In
PLATFORM_MANAGEDmode, nonce generation is handled by the VM and incorporatesactor_nonce(per-actor monotonic counter) to prevent reuse. Nonce reuse with the same content key is forbidden.
Actor Interface
Required Methods
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
PLATFORM_MANAGEDmode, 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 actor composes plaintext payload, metadata, tags, and kind.
- Actor computes
key_epoch = floor(block_height / key_epoch_blocks). - Actor calls
stream_encrypt(stream_id, key_epoch, aad, plaintext)— a VM host function. - VM returns ciphertext envelope (
nonce || ciphertext || tag). - Actor sets
payload_format = CIPHERTEXT,payload_inline = ciphertext_envelope. - Actor signs and calls
publish(...)as normal.
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.
PLATFORM_MANAGED 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 is an optional hint for key wrapping preference and does not trigger any payment.
unsubscribe()
Behavior:
- Set status to
CANCELLED - Emit
SubscriberUpdated
renew_subscription(target_key_epoch, beneficiary_account?, payer_account?, account_key_id?)
Behavior:
- Convenience wrapper over native
acquire_epoch_accessfor SDK ergonomics. - If
access_mode != PLATFORM_MANAGED, fail withNOT_PLATFORM_MANAGED_STREAM. - If
beneficiary_accountomitted, defaults to caller account. - If
payer_accountomitted, defaults to caller account. - If caller has no active subscription, fail with
PAYMENT_REQUIRED(subscribe first, then acquire access). - If
account_key_idprovided, updates the subscription’saccount_key_idhint. - MUST delegate to
acquire_epoch_accesson the Stream Key Manager and return the resultingKeyAccessReceipt.
- This method is OPTIONAL. Clients MAY call
acquire_epoch_accessdirectly via the platform host function. - Billing occurs on key-access acquisition, not on subscription creation. This method exists solely for backward compatibility and SDK convenience.
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 entitlement status.- Entitlement gates decryption key access, 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.
announce_key_epoch(key_epoch)
Behavior:
- Owner-only
- Announces key epoch progression metadata on-chain
key_epochMUST be non-decreasing relative to previous announcement- Emit
KeyEpochRotated(stream_id, key_epoch, effective_block_height, announced_by)
- This method does not publish key material on-chain.
- It anchors key-epoch progression for indexers, subscribers, and auditing.
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
These methods are exposed as VM host functions and backed by the Stream Key Manager system actor. They are available to any actor during execution.stream_encrypt(stream_id, key_epoch, aad, plaintext) -> ciphertext
Caller: Publisher actor (or any actor with publish rights on the stream).
Behavior:
- Validate
access_mode == PLATFORM_MANAGEDfor this stream. - Derive the epoch content key:
content_key = KDF(master_seed, stream_id, key_epoch). - Generate a deterministic nonce:
nonce = HKDF-Expand(content_key, stream_id || key_epoch || actor_nonce, 24). - Encrypt:
ciphertext = XChaCha20-Poly1305-Encrypt(content_key, nonce, aad, plaintext). - Return
nonce(24) || ciphertext || tag(16).
- Cycles:
500 + (plaintext.len() * 2) - Cells: output envelope size
- Actors MUST NOT implement their own encryption for
PLATFORM_MANAGEDstreams. plaintext.len()MUST be ≤MAX_EFFECTIVE_PLAINTEXT_BYTES(16,344 bytes).aadSHOULD includestream_id,sequence,key_epoch,kind,content_typefor binding.- Nonce reuse with the same content key is prevented by incorporating
actor_nonce(auto-incremented).
stream_decrypt(stream_id, beneficiary_account, key_epoch, ciphertext, aad) -> plaintext
Caller: Any actor authorized by beneficiary_account for this stream.
Behavior:
- Resolve calling actor’s address from execution context.
- Verify
ActorAuthorizationfor(beneficiary_account, caller_actor, stream_id)isACTIVE. - Verify
Entitlementfor(stream_id, beneficiary_account)includeskey_epoch(i.e.,key_epoch ≤ active_until_key_epoch). - Derive epoch content key (same KDF as encrypt).
- Extract nonce from first 24 bytes of
ciphertext. - Decrypt:
plaintext = XChaCha20-Poly1305-Decrypt(content_key, nonce, aad, ciphertext_body). - Return
plaintext.
- Cycles:
500 + (ciphertext.len() * 2) - Cells: plaintext output size
ACTOR_NOT_AUTHORIZED_FOR_ACCOUNT— actor lacks authorizationENTITLEMENT_REQUIRED— epoch not within entitlement windowDECRYPTION_FAILED— ciphertext tampered or wrong keyNOT_PLATFORM_MANAGED_STREAM— stream isOPEN
acquire_epoch_access(stream_id, beneficiary_account, payer_account, target_key_epoch) -> 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.
Behavior:
- Validate
access_mode == PLATFORM_MANAGEDfor this stream. - Resolve current entitlement:
E = active_until_key_epochfor(stream_id, beneficiary_account). If no entitlement exists,E = current_key_epoch - 1(no free retroactive access). - If
target_key_epoch ≤ E: return idempotent receipt withepochs_charged = 0,total_amount_cby = 0. EmitEpochAccessIdempotent. - Compute
epochs_charged = target_key_epoch - E. - If
epochs_charged < min_purchase_epochs, fail withMIN_PURCHASE_NOT_MET. - Compute fees:
- Transfer
totalCBY frompayer_accountbalance. Fail withINSUFFICIENT_CBY_BALANCEif insufficient. - Credit
publisher_amounttopublisher_treasury. - Credit
protocol_feetoprotocol_treasury. - Set
active_until_key_epoch = target_key_epochfor(stream_id, beneficiary_account). - Emit
EpochAccessPurchased. - Return
KeyAccessReceipt.
- Cycles:
5,000(fixed) - Cells:
500
payer_accountMAY differ frombeneficiary_account(sponsorship).- Entitlement accrues to
beneficiary_account, never topayer_account. The payer has no implicit access to decryption keys. - Repeated calls for already-entitled epochs MUST be idempotent and non-charging.
target_key_epochMUST be >=current_key_epoch. Purchasing purely historical epochs without current coverage is not allowed in v1.- This method is the canonical and only billing point for paid stream decryption.
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
authorize_actor(account, actor, stream_id) -> ActorAuthorization
Caller: Account holder.
Behavior:
- Verify transaction is signed by
account. - Validate actor address exists.
- Check authorization count for
(account, stream_id)is belowMAX_ACTOR_AUTHORIZATIONS_PER_STREAM. - Store
ActorAuthorizationwithstatus = ACTIVE. - Emit
ActorAuthorizationGranted.
- Cycles:
1,000 - Cells:
100
revoke_actor(account, actor, stream_id)
Caller: Account holder.
Behavior:
- Verify transaction is signed by
account. - Set
ActorAuthorization.status = REVOKED. - Emit
ActorAuthorizationRevoked.
- Cycles:
500 - Cells:
50
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 entitlement.
- Decryption remains gated by account entitlement and actor authorization on the Stream Key Manager.
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 entitlement status.- Entitlement gates key issuance/decryption, not ciphertext retrieval.
Payments and Key Management (Normative)
This section defines how subscriber-paid monetization works with VM-level encryption and nativeCBY billing.
On-chain entitlements
- In
PLATFORM_MANAGEDmode, entitlement 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) - Entitlement window is rolling: access check for epoch
ksucceeds iffk ≤ active_until_key_epoch
Payload confidentiality
- Stream publishes inline
CIPHERTEXTpayloads inPLATFORM_MANAGEDmode - Ciphertext remains globally readable, but plaintext requires key access and actor authorization
- Encryption/decryption MUST use VM-native host functions
Epoch content keys
- Platform manages one content key per stream per key epoch
StreamMessage.key_epochbinds each message to a key epoch- Actor code MUST NOT implement custom content-key cryptography in
PLATFORM_MANAGEDmode
Key distribution flow
- Caller invokes
acquire_epoch_accesswithstream_id,beneficiary_account,payer_account, andtarget_key_epoch - Platform resolves current entitlement end
E - If
target_key_epoch ≤ E, no charge (idempotent) - Else compute
epochs_charged = target_key_epoch - E; reject if belowmin_purchase_epochs - Bill for newly covered epochs
E+1..target_key_epoch:publisher_amount = epochs_charged * fee_per_key_epoch_cbyprotocol_fee = floor(publisher_amount * protocol_fee_bps / 10_000)total = publisher_amount + protocol_fee
- Transfers
totalinCBYfrompayer_account - Credits publisher and protocol treasuries
- Sets
active_until_key_epoch = target_key_epoch - Emits
EpochAccessPurchased
Account-scoped keys and actor reuse
- Keys are registered per account (not per actor or per subscription)
- Authorized actors MAY decrypt on behalf of that account for specific streams
- Actor authorization and account key revocation are immediate
- This model means one subscription payment covers all of an account’s actors consuming the same stream
Sponsored purchases
payer_accountMAY be any funded account, including a third partyCBYis debited frompayer_account; entitlement 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
protocol_treasuryaddress is set at genesis or via governance; publishers MAY NOT override it- 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
PLATFORM_MANAGEDmode, actor callsstream_encrypt(...)before publish - 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
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
ActorAuthorizationGranted
accountactorstream_idscopeblock_height
ActorAuthorizationRevoked
accountactorstream_idblock_height
KeyEpochRotated
stream_idkey_epocheffective_block_heightannounced_by(address)
IngestFailed
stream_idtimestamp_unix_msreason
Error Codes
Actor method errors:INVALID_SIGNATUREPAYLOAD_TOO_LARGEINVALID_FILTERCURSOR_TOO_OLDLIMIT_EXCEEDEDUNAUTHORIZEDSUBSCRIBER_CAP_REACHEDSUBSCRIPTION_NOT_ALLOWED
NOT_PLATFORM_MANAGED_STREAMPAYMENT_REQUIRED— no entitlement exists and caller has not initiated a purchaseACTOR_NOT_AUTHORIZED_FOR_ACCOUNTENTITLEMENT_REQUIREDINSUFFICIENT_CBY_BALANCEINVALID_TARGET_KEY_EPOCHMIN_PURCHASE_NOT_METDECRYPTION_FAILEDINVALID_ACCOUNT_KEYACCOUNT_KEY_LIMIT_REACHEDAUTHORIZATION_LIMIT_REACHEDKEY_REVOKED
publish:INVALID_SIGNATURE,PAYLOAD_TOO_LARGE,UNAUTHORIZEDsubscribe:INVALID_FILTER,SUBSCRIBER_CAP_REACHED,SUBSCRIPTION_NOT_ALLOWEDrenew_subscription:PAYMENT_REQUIRED,INSUFFICIENT_CBY_BALANCE,INVALID_TARGET_KEY_EPOCH,MIN_PURCHASE_NOT_MET,NOT_PLATFORM_MANAGED_STREAMget_since:LIMIT_EXCEEDED,CURSOR_TOO_OLDrotate_publisher_key:UNAUTHORIZEDset_subscription_policy:UNAUTHORIZEDallowlist_add/allowlist_remove:UNAUTHORIZEDannounce_key_epoch:UNAUTHORIZEDstream_encrypt:NOT_PLATFORM_MANAGED_STREAM,PAYLOAD_TOO_LARGEstream_decrypt:NOT_PLATFORM_MANAGED_STREAM,ACTOR_NOT_AUTHORIZED_FOR_ACCOUNT,ENTITLEMENT_REQUIRED,DECRYPTION_FAILED,KEY_REVOKEDacquire_epoch_access:NOT_PLATFORM_MANAGED_STREAM,INSUFFICIENT_CBY_BALANCE,INVALID_TARGET_KEY_EPOCH,MIN_PURCHASE_NOT_METregister_account_key:INVALID_ACCOUNT_KEY,ACCOUNT_KEY_LIMIT_REACHED,UNAUTHORIZEDauthorize_actor:AUTHORIZATION_LIMIT_REACHED,UNAUTHORIZEDrevoke_actor:UNAUTHORIZEDrevoke_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 decryption MUST enforce both entitlement and actor authorization checks
- Account key compromise grants access to all entitled epochs for that account across all streams; accounts SHOULD rotate keys periodically
- Revocation of actor authorization MUST immediately block future decrypt calls for that actor
- Account key revocation blocks all future wrapping and decryption tied to that key ID
- Epoch-key compromise exposes all messages encrypted under that epoch key; operators SHOULD use short key epochs to reduce blast radius
- VM-native crypto MUST use constant-time implementations audited for side-channel resistance
- Nonce uniqueness is guaranteed by incorporating
actor_nonceinto nonce derivation - Sponsored purchases do not leak key material to the payer
- Protocol treasury address is governance-controlled; compromise of a publisher treasury does not affect protocol fee collection
- Entitlement state is on-chain and verifiable; no off-chain key service trust assumptions
Backwards Compatibility
This CIP can be adopted independently. Migration from prior stream implementations:- Map existing feed messages into
StreamMessage - Reuse subscriber logic through
subscribe+get_since - For actor-managed paid streams: switch
access_modetoPLATFORM_MANAGED(or useSUBSCRIBER_PAIDalias for migration compatibility), remove actor encryption code, register account keys, useacquire_epoch_accessfor billing - Push delivery no longer gates on entitlement — ciphertext is delivered to all subscribers regardless of payment status. Decryption is the access-control boundary, not transport.
Reference Implementation Notes (Non-normative)
- SDK
StreamActorshould expose the full actor method set - SDK should include convenience wrappers for
stream_encrypt,stream_decrypt, andacquire_epoch_access - Pruning should be O(1) amortized
- Push scheduler should use deterministic round-robin over active subscribers
- Entitlement lookup should be O(1) by
(stream_id, beneficiary_account) - VM host function implementations should use libsodium or equivalent audited AEAD library
Rationale
Why VM-level, not actor-level encryption?
Encryption at the actor level requires every paid stream to independently implement crypto in Python, paying interpreted-execution gas costs for operations that should be native. A 16 KiB encrypt costs ~180,000 cycles in Python vs ~33,000 cycles as a VM host function. Beyond gas savings, VM-level crypto eliminates an entire class of actor bugs (nonce reuse, wrong cipher mode, key leakage in actor storage).Why VM host functions, not system actor messages?
System actor messages require deferred transaction overhead (send message, wait for callback in next block). VM host functions are synchronous — the actor callsstream_encrypt(...) and gets ciphertext back in the same execution frame. This is critical for the publish path.
Why account-scoped, not actor-scoped keys?
An account is the natural billing entity. Actors are programs; accounts are economic agents. If account A deploys five consumer actors that all read the same price feed, they should share one entitlement and one key registration. Actor-scoped keys force five separate payments for the same economic relationship.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 entitlement?
Subscription controls delivery routing (push/pull). Entitlement controls decryption access (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.Open Questions
- Should
protocol_fee_bpsbe globally fixed or per-stream configurable within bounds? - Should a future extension define bulk epoch purchase discounts?
- Should a future extension define on-chain key escrow for operator failover?
- Should
protocol_treasurydistribution (burn vs. stake vs. fund) be specified in this CIP or deferred to a governance CIP? - 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?

