Status: Draft
Type: Standards Track
Category: Core
Created: 2026-05-04
Requires: CIP-3 (Fee Model), CIP-6 (SDK), CIP-27 (fork pin initialization; error-code allocation
PinSetMismatch = 134; CodeMeta marker), 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. Atomic initialization rollback ensures 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 actor creation — DeployActor, or CIP-27 fork initialization — and 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_INSTRUCTION_BASE_CYCLES (200) + LIB_PUBLISH_PER_BYTE_CYCLES (100) × len(code) Cycles; Cells: LIB_PUBLISH_PER_BYTE_CELLS (200) × len(code) if the code blob is new (cache miss), zero code-blob Cells if the blob already existed (idempotent set_code) — the catalog+metadata row charge below applies on every publish regardless, and the two are different charges for different writes (blob vs catalog/CodeMeta rows; the 128-Cell row charge covers both the Library row and the CodeMeta marker write). These are the canonical values, mirrored verbatim from node/execution/src/gas.rs; every other figure previously printed in this CIP or CIP-3 is superseded — one table, one owner (§3.6).
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).
DeployActor is no longer the only writer of a pin set: a CIP-27 fork() atomically initializes the child’s pin table as a verbatim copy of the parent’s (same code, same imports — the pins are part of the code binding and MUST clone with it), staged and rolled back with the rest of the fork, and charged per row (CIP-3’s fork schedule). This is initialization at birth, exactly like the deploy-time write; post-birth immutability is unchanged — no path mutates an existing actor’s pins, ever.
Upgrades must respect the pin set. Because pins are immutable while upgrade_self/UpgradeActor replace the code, an unconstrained upgrade could leave an actor executing code whose imports the pin set no longer describes. Normative rule: every code upgrade validates that the new code’s statically-extracted candidate import set is name-identical to the actor’s existing pin names — any addition, removal, or rename rejects the upgrade with a pinned error on both carriers — the check runs wherever upgrades run: the upgrade_self host path returns HostError::PinSetMismatch = 134 (CIP-27’s code table; actor code can branch on it), and the EOA/system UpgradeActor path returns a new ExecutionError::PinSetMismatch variant mapped 1:1 (system instructions cannot carry HostError). Both rows, concretely, in this CIP’s error table:
Acceptance vectors: host-path add/drop/rename each → 134; system-path add/drop/rename each →
ExecutionError::PinSetMismatch; identical imports pass on both paths. Re-pinning is not offered: an actor that needs different imports is a different actor. Fork/upgrade vectors: fork-then-upgrade with identical imports (accepted), upgrade adding an import (rejected), upgrade dropping one (rejected).
Pin gas, one rule: each pin row is charged LIB_PIN_OVERHEAD_CYCLES = 300 Cycles + LIB_PIN_OVERHEAD_CELLS = 64 Cells wherever pins are written — deploy and fork alike (CIP-3’s fork schedule cites these constants; pin rows are not priced as generic state_set). Ancillary rates, same source of truth: LIB_DEPLOY_AST_PARSE_PER_BYTE_CYCLES = 10, LIB_HANDLER_LOAD_PER_BYTE_CYCLES = 1.
The candidate import set is computed statically from the source AST (already parsed during 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
Library gas charges:
Canonical gas rules. CIP-3 and CIP-27 reference the table above. Cold/cached classification affects gas exhaustion and receipts and is therefore consensus-critical.
- Deterministic classification: warm/cold pricing MUST depend only on committed state and in-block access order. The physical compiled-code cache is an optimization with no pricing role; validators with different process histories MUST charge identically.
- Block-local shadow cache: reset it empty at each block, with capacity 32 and LRU ordering over library and actor-code accesses. A library hash absent from this set prices cold. Admission or promotion occurs only after the charge and compile succeed; a 33rd entry evicts the least recently used entry, which prices cold on its next load.
- Synchronous charging: every compile-relevant access, including a physical cache hit, MUST call
before_access(kind: ActorCode | Library, code_hash, len) → Result<Cold | Cached, OutOfGas>. The engine classifies and applies the fallible charge before compile work.OutOfGasaborts that access. The runtime then callsafter_access(compile_succeeded: bool); only success admits or promotes the entry. - Actor-code occupancy: actor-code accesses count toward shadow capacity but incur no library cold/cached charge. Actor-entry gas already pays for their compilation; charging again would double-bill them.
- Access order: each handler accesses libraries first in canonical sorted pin-name order, followed by the actor-code compile. All validators MUST derive the same shadow-cache order.
- Failure semantics: a failed charge or compile MUST leave a cold entry cold. A successfully charged and compiled entry stays warm even if the enclosing handler or transaction later faults; shadow membership is block-scoped metering state, not rollbackable application state.
- Publication accounting: every publish pays
LIB_CATALOG_ROW_CELLS = 128for the catalog/metadata row. Code-blob Cells are charged only after the cache lookup establishes that the blob is new. Reusing code bytes does not make a new catalog entry free. - Code metadata:
PublishLibraryMUST stage the CIP-27CodeMetamarker for each new Code row, derived from the bytes by the same rule as other code producers. Publish-first and deploy-first execution MUST produce the same marker.
MAX_LIBS_PER_ACTOR = 8 (pin-set count cap, enforced at DeployActor and inherited unchanged through CIP-27 fork initialization), 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).
Implementation requirements:
gas.rsdefines the gas constants, includingLIB_CATALOG_ROW_CELLS.pvm-runtime/lib.rscalls the synchronousbefore_access/after_accessAPI from §3.6 on every compile-relevant access, whether the physical cache hits or misses.- The engine’s block-local shadow tracker owns warm/cold classification, the fallible gas charge, admission, LRU ordering, and capacity.
actor_instruction.rsdoes not classify or charge these accesses separately. library_instruction.rscharges for the catalog row and stagesCodeMetafor new code.pvm_host.rsandsystem_instruction.rscarryPinSetMismatchand its structured receipt codes on both upgrade paths. Protocol dependency pins must include the corresponding shared types.
before_access calls and pricing on physical cache hits and misses; transaction failure followed by reload; restart parity; actor-code/library interleaving; matching CodeMeta for publish-first and deploy-first execution; and publish charging: a cache hit avoids duplicate blob-storage Cells, while both new-blob and cache-hit publishes pay catalog-row Cells.
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 at actor creation — DeployActor or CIP-27 fork initialization (§3.4) — 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.

