Status: Draft
Type: Standards Track
Category: Core
Created: 2026-05-04
Requires: CIP-3 (Fee Model), CIP-6 (SDK), Atomic-init rollback (COW-870)
1. Abstract
This proposal introduces account-scoped actor libraries: a per-account namespace of Python modules that the account’s actors mayimport from. A library is published once (cowboy lib publish) and stored on chain under the publisher’s account. When an actor is deployed, the runtime resolves the actor’s import statements against the deployer’s library namespace, silently pins each resolved name to the library’s content hash, and records the pin set in the actor’s metadata. At handler execution the PVM allowlist is extended with the pinned modules; lookup goes by hash, not by name, so the actor always sees the exact bytes that existed at deploy time even if the account later replaces the library.
Libraries are strictly account-scoped. An actor deployed by account A cannot import a library published by account B. This is intentional: actors are immutable and consequential, and the cost of accidentally pulling in unverified third-party code is severe. If a library should be reusable across accounts, the consumer publishes their own copy (or pins the upstream hash via the same publish flow against their own account).
2. Motivation
Today the PVM allowlist (pvm-runtime/src/guard.rs::_is_allowed) admits only the stdlib whitelist (determinism.rs::default_whitelist) plus cowboy_sdk. Any other import is rejected as NonDeterministicError: module not allowed: <name>. Multi-actor systems that share rules end up with one of two unsatisfying patterns:
- Inline-and-duplicate. Copy the shared code into every actor file. The Texas Hold’em example notes this directly: “hand evaluation inlined — PVM cannot import external modules”. Drift, duplication, no single source of truth.
- Build-time bundling. A relayer or Makefile concatenates the shared module into each actor before deploy. Works (some examples do this), but the deployed actor’s code stops resembling its source, hash-pinning is per-deployer rather than per-library, and audit/review tooling has to reverse the bundle.
engine.py, publish it once, import it from N actors.
2.1 Why account-scoped (not global)
A global library registry — anyone publishes, anyone imports, content-addressed — looks tempting. We reject it for two reasons:- Trust. An actor that imports
crypto_helpersfrom a global namespace is one TOCTOU window away from importing whatever a stranger published under that name first. Hash-pinning at the deploy site mitigates this for the deploy itself, but the human writing the actor source is now responsible for verifying every dependency hash, which is a security review burden with very poor ergonomics. Account scoping makes “did I write or audit this code?” trivially answerable. - Operational sovereignty. With account scoping, each account controls its own dependency surface. With a global registry, a third party publishing buggy or malicious code under a popular name affects every consumer simultaneously.
cowboy lib publish calls.
2.2 Why immutability of actors makes this simple
Cowboy actors are immutable: noupgrade_self outside the sys.upgrade entitlement (very rare and gated), no in-place code rewrite. Combined with the COW-870 atomic-init rollback fix, this means an actor that ever runs handlers with library L@hash_x will always run with L@hash_x. There is no migration story for “the lib changed under me” because the actor’s pin set is part of its immutable metadata.
If actor code were mutable, the design would have to deal with version negotiation, dual-pin-set transitions, lib-side compat guarantees, and a much larger spec surface.
3. Specification
3.1 Library data structure
StatePrefix::Code table (CIP-9 Model A.4) — the same content-addressed, idempotent, garbage-free storage used for actor code. A library entry stores only the metadata + hash; the bytes are shared with any other actor or library that happens to have the same hash.
3.2 Storage layout
Two new state prefixes:
The
ActorLibPin table is the actor’s deploy-time-frozen view of its dependencies. Lookup at handler execution is (self.address, import_name) → code_hash → code_bytes. The library publisher’s Library table can change freely (re-publish replaces); the actor’s ActorLibPin table is write-once at deploy, read-only thereafter.
3.3 Publishing
A new instruction:- Validate
namematches the Python identifier regex^[a-zA-Z_][a-zA-Z0-9_]{0,63}$. - Run
validate_actor_code(code)(CIP-3 §2.2.4) — same determinism gate as actor code; bansos.system,pickle,import timeat module top, large integer literals, etc. - Compute
code_hash = keccak256(code). set_code(code_hash, code)— idempotent; shared blob storage.- Write
Library { publisher: sender, name, code_hash, code_size, published_at: block_height }to(sender, name). Replaces any prior entry under the same(sender, name).
lib_publish_base_cycles + per_byte_cycles * len(code) — modeled on actor deploy. Cells: len(code) * cells_per_byte_lib if the code blob is new (cache miss), zero if the blob already existed (the set_code path is idempotent and free for repeats).
CLI:
LibraryPublished event is emitted: { publisher, name, code_hash, code_size }.
3.4 Resolution and pinning at actor deploy
WhenActorInstruction::DeployActor runs:
- Existing flow validates the actor code, reserves the address, etc.
- New step: scan the actor’s source AST for top-level
import Xandfrom X import ...statements whereXis not in the SDK / stdlib whitelist. Call this set the candidate import set. - For each name
Xin the candidate import set:- Look up
Libraryat(sender, X). - If absent →
Err(ExecutionError::UnresolvedImport { name: X }). Deploy fails atomically (atomic-init rollback already covers the actor record per COW-870; the same rollback path applies here before any storage is committed). - If present → record
(actor_address, X) → library.code_hashin the actor’s pin set.
- Look up
- Persist the pin set as a sequence of writes to
ActorLibPinin the same atomic deploy. - The pin set’s total size is added to the actor’s deploy gas cost (
cells_per_pin = 64cells for the hash + key overhead — matches §3.6 andgas.rs::LIB_PIN_OVERHEAD_CELLS).
validate_actor_code). Imports gated by runtime conditions (if condition: import lazy_thing) are not supported — the spec mandates the full set be discoverable from the AST. This is a deliberate restriction: dynamic imports defeat the audit story and complicate gas accounting.
Conditional imports inside SDK helpers (from cowboy_sdk import ...) are exempt from this restriction since they resolve to the trusted SDK.
3.5 Resolution at handler execution
When an actor handler runs, the PVM’s import guard (pvm-runtime/src/guard.rs) is extended:
- Before handler entry, the runtime reads the actor’s pin set from
ActorLibPinand pre-resolves eachcode_hash → code_bytesfrom the sharedCodetable. - For each pinned
(name, code_hash, code_bytes), the runtime executescode_bytesin a fresh module object, installs it assys.modules[name], and addsnameto the import allowlist for this PVM context. - The handler runs. Any
import nameresolves to the pre-loaded module. - After the handler returns (success or failure), the per-context allowlist additions are discarded; the global allowlist stays untouched.
cowboy_sdk is already injected.
3.6 Gas
Three new metered points:
The per-handler load cost is real — the lib’s bytecode has to be re-executed to materialize the module each handler invocation, since the PVM does not retain Python state across calls. Caching the compiled bytecode form across calls (a follow-up optimization) would amortize this; out of scope for this CIP.
Normative limits:
MAX_LIBS_PER_ACTOR = 8 (pin-set count cap, enforced at DeployActor), MAX_LIBRARY_CODE_BYTES = 131_072 (per-library code cap, enforced at PublishLibrary), and MAX_TOTAL_LIB_BYTES = 131_072 (sum of all pinned libraries’ code_size per actor, enforced at DeployActor). Exceeding any yields LibCapExceeded { kind: Count | Bytes }. Higher caps invite footguns around actor instantiation cost; these are plenty for the patterns this CIP is designed for.
3.7 Replacing or removing a library
cowboy lib publish --name X --code newer.py overwrites the publisher’s (publisher, X) entry with a new code_hash. Existing actors that pinned the prior hash are unaffected — their ActorLibPin entries continue to resolve the old code_hash from StatePrefix::Code (the old blob is reachable as long as any actor pins it; eligible for the same long-term GC story as any other code blob, currently never).
A separate RemoveLibrary instruction removes the metadata entry only — the underlying code blob persists for any actor still pinning it. Removal is purely a “stop offering this name to new deploys” operation. It emits a LibraryRemoved event { publisher, name } (wire layout publisher(20) ‖ name_len(1) ‖ name), mirroring the LibraryPublished event of §3.3 (wire layout publisher(20) ‖ name_len(1) ‖ name ‖ code_hash(32) ‖ code_size(8 LE)).
3.7bis Error codes
The runtime error variants for CIP-26 (matchingnode/execution/src/error.rs):
3.8 Manifest interaction
Actors that import account libraries do not need to declare anything in theirActorManifest. The pin set is computed from source and recorded by the runtime. This keeps the manifest focused on entitlements (capabilities the actor wants from the host) rather than duplicating dependency information that the source already encodes.
Future revisions may add a manifest field listing expected pins for human review — purely informational, not enforced — but the canonical pin set always comes from the AST scan.
Tooling guidance (COW-1133). Even without a manifest field, the pin set is fully discoverable on-chain: it is the set of StatePrefix::ActorLibPin entries keyed (actor_address, name) → code_hash (§3.4). Tooling SHOULD surface it from there rather than from the manifest:
- Explorer: for any deployed actor, scan its
ActorLibPinrows and displayname → code_hash(optionally resolving eachcode_hashto the publisher/library it came from) so users can see exactly which library versions the actor is frozen against. - CLI: a
cowboy actor pins <address>command lists the same(name, code_hash)pairs (implementation tracked separately as a CLI follow-up; this section specifies what it surfaces). Because the pin set is deploy-time-frozen and read-only, the displayed dependencies are stable for the life of the actor.
3.9 Determinism guarantees
A handler invocation against the same(actor, block_state, payload) always sees the same library code, because:
- The actor’s
ActorLibPintable is immutable post-deploy. - The pinned
code_hashresolves to a content-addressed blob inStatePrefix::Codethat is written once and never mutated. - Library code passes the same
validate_actor_codechecks as actor code, so it cannot introduce non-deterministic behavior the host would otherwise reject.
Code blob is somehow missing at handler time — currently impossible since set_code writes are durable and never deleted. If a future GC mechanism is introduced, the GC must check the union of ActorLibPin.code_hash (across all live actors) before reaping, the same way it would have to for actor code.
4. Worked example
Account0xAlice publishes a shared engine for her three game actors:
engine:
engine v2:
World actor continues to resolve engine → 0xabcd1234 (the v1 hash is in its pin set). Any new deploy from Alice resolves to v2.
Bob, working from account 0xBob, tries to deploy an actor that imports engine:
engine library under his own account; Alice’s is invisible to him. He must cowboy lib publish --name engine --code <vetted_copy.py> against his own account first.
5. Reference implementation
Code paths that change:node/storage/src/state_key.rs— addStatePrefix::LibraryandStatePrefix::ActorLibPin.node/storage/src/traits.rs— addget_library,set_library,delete_library,get_actor_lib_pins,set_actor_lib_pin.node/storage/src/accounts.rs— concrete impls of the above.node/cowboy-types/src/instruction.rs—Instruction::Library(LibraryInstruction::{PublishLibrary, RemoveLibrary}).node/execution/src/execution/library_instruction.rs— new file; instruction handler.node/execution/src/execution/actor_instruction.rs::DeployActor— add the AST scan + pin resolution step before atomic init.node/pvm/crates/pvm-runtime/src/guard.rs— extend_is_allowedand the_pvm_importhook to consult the per-context pin set.node/pvm/crates/pvm-runtime/src/lib.rs— pre-load pinned libraries intosys.modulesbefore handler entry.node/cli/src/commands.rs—cowboy lib publish | remove | listsubcommands.
- Publish + import + handler call round-trip.
- Cross-account import is rejected.
- Pin survives library re-publish.
- Pin survives library removal (existing actors keep working; new deploys can’t pick up the removed name).
- Atomic deploy rolls back on
UnresolvedImport(extends the COW-870 regression test). - Determinism: re-running the same handler at the same height with the same input returns identical state across nodes after a library re-publish.
6. Backwards compatibility
Pure addition. Existing actors don’t haveActorLibPin rows; the import guard treats their pin set as empty, so behavior is unchanged. Existing deploys continue to be atomic with no extra cost.
7. Security considerations
7.1 Trust model
The trust boundary is the deployer’s account. An actor implicitly trusts every library its deployer has published, because the deployer chose to deploy the actor knowing those libraries exist and what they contain. Cross-account imports are forbidden precisely so this boundary is unambiguous. A compromised deployer key can publish malicious library updates, but those updates only affect future deploys from that account — already-deployed actors’ pins are immutable. This bounds blast radius: an attacker who compromises Alice’s key cannot retroactively poison her existing actors, only the next ones she (or the attacker) deploys.7.2 Determinism attacks
The library code path goes through the samevalidate_actor_code checks as actor code, so it cannot reach os.system, time.time(), hardware FPU, or other non-deterministic primitives. Library authors can no more break consensus than actor authors can.
7.3 Storage exhaustion
Library blobs live in the sameStatePrefix::Code table as actor code, which today never garbage-collects. The marginal cost of accepting a 1 MiB library blob is the same as accepting a 1 MiB actor blob. Gas pricing in §3.6 is set to make publishing realistic-sized libraries cheap and oversize libraries painful.
MAX_LIBS_PER_ACTOR = 8 caps the per-handler load cost to a known maximum and prevents a deploy-time DoS against the import resolver.
7.4 Pin-set tampering
ActorLibPin is written exclusively by the chain runtime during DeployActor, and exclusively read during handler dispatch. There is no SDK API to mutate pins, and the storage prefix is excluded from runtime.set_state / self.storage[] (mirroring the existing __OWNER__ / __INITIALIZED__ reserved-key protections in CIP-6).
8. Out of scope
- Cross-account imports. Forbidden by design (§2.1). Not a future addition.
- Bytecode caching across handler invocations. Performance optimization; doesn’t change semantics. Worth doing eventually since the per-call lib re-execution is real cost.
- Lazy / conditional imports. Disallowed by §3.4 because they break the AST-driven pin discovery. Could be revisited if a compelling use case emerges.
- Library transitive dependencies. Transitive imports are not resolved by the §3.4 mechanism: the deploy-time AST scan and pin set are computed from the actor’s source only, and only the actor’s own
ActorLibPinmodules are installed intosys.modulesat handler entry (§3.5). A library that doesimport other_libtherefore only resolves ifother_libis also a direct import of the actor (so it is independently pinned under the actor); a library’s private dependency that the actor does not itself import is unresolved at runtime. Library authors must flatten their dependency closure into the actor’s direct import set (or vendor it into the library source). Automatic transitive resolution is spec’d separately if pain emerges. - Versioning / semver. Not built in. Account holders can publish multiple distinct names (
engine_v1,engine_v2) if they want explicit version pinning visible in actor source. The hash-pinning at deploy makes “what version did this actor pin?” trivially answerable fromActorLibPin.
9. Open questions
- Should
cowboy lib publishaccept a directory and bundle multiple files into a single library, or strictly single-file? Single-file is simpler and matches how almost all examples are structured today; multi-file can be added if anyone hits the friction. - Should a library be allowed to import
cowboy_sdk? Most utility libraries won’t need it (pure rules), but some will (lib that emits events). Default: yes, allowcowboy_sdkimports from libraries on the same terms as actors. The handler-execution import map already covers it. MAX_LIBS_PER_ACTOR = 8is a guess. Empire’s example would use 1; Texas Hold’em would use 1. If real usage demands more, raising the cap is a numeric change with no spec impact.

