Status: Draft
Type: Standards Track
Category: Core
Created: 2026-08-08
Requires: CIP-3 (fee model — §2.5 multi-signer admission and metering), CIP-6 (SDK — §11.3 deterministic collections), CIP-40 (transaction verification — §7.3 duplicate-primary rule and
signature_mode)
Relates-to: CIP-12 (Governance — Council SignerSet precedent), CIP-28 (agent banking), CIP-37 (Sign in with Cowboy)1. Abstract
Cowboy transactions already carry multiple secp256k1 signatures.Transaction.additional_signers is bound into the mode-selected authorization digest, canonicalized against reordering and duplication, and ecrecover-verified for every signer at block admission. The one thing missing is visibility: the verified signer set never reaches actor code, so its only consumers are two authorization checks inside the execution engine.
This proposal exposes that set. HostContext gains a co_signers field, the SDK gains get_co_signers(), and the client tooling gains a co-signing flow that assembles a multi-signer transaction from independently produced signatures. With those three pieces, an actor holding its own roster and threshold can authorize an operation from a quorum in one atomic transaction, with no in-handler cryptography and no new signature scheme.
It builds on two companion changes to the existing multi-signer path, specified where those rules live: CIP-40 §7.3 rejects a transaction whose primary is repeated in additional_signers (what makes a naive quorum count safe), and CIP-3 §2.5 prices and bounds co-signature verification (intrinsic non-sponsorable charge, block budget, ingress caps).
2. Background
The transaction layer is already multi-signer end to end.Transaction.additional_signers: Vec<(Address, EthSignature)> holds the co-signers (cowboy-protocol/crates/cowboy-protocol-codec/src/transaction.rs:106). The signing preimage is the full canonical encoding with every signature zeroed and every signer address retained (transaction.rs:142), so each signature commits to the whole transaction including the roster — a signer cannot be added, removed, or reordered after the fact without invalidating every signature. verify() requires the additional set to be strictly ascending by address, bounded by MAX_ADDITIONAL_SIGNERS (16), and ecrecovers each entry against the shared hash (transaction.rs:405). Transaction::sign_multi produces the canonical form (transaction.rs:304). Block admission verifies every transaction, deferred ones included, precisely so downstream authorization can trust the field (node/chain/src/application.rs:2656).
Two consumers exist, both inside the execution engine. council_authorized counts distinct roster members across {tx.from} ∪ additional_signers against a SignerSet loaded from governance actor 0x09 (node/execution/src/execution/council.rs:19), performing membership and threshold only. CreateAccount reads the same set through the existing Transaction::signers() helper (transaction.rs:202) and requires both tx.from and the account being created to appear in it (node/execution/src/execution/system_instruction.rs:67) — a dual-signature rule rather than a threshold. Neither re-verifies signatures; both rely on admission having done so.
Actor code cannot reach any of this. HostContext carries sender and nothing about co-signers (node/pvm/crates/pvm-host/src/lib.rs:8), and the PVM exposes keccak256 and ed25519_verify but deliberately no secp256k1 recovery (pvm-host/src/lib.rs:532) — an actor therefore cannot verify Cowboy account signatures for itself, by design. There is also no CLI co-signing flow; multi-signer transactions are assembled by test harnesses and external tooling.
The client-side primitives, by contrast, are already shipped. The wasm codec exposes signing_preimage, signing_hash, sign, and encode_submission (cowboy-protocol/crates/cowboy-protocol-wasm/src/lib.rs:71), and documents exactly the flow this CIP standardizes: each signer signs the shared digest independently, assemble additional_signers client-side, submit the envelope.
3. Goals
- An actor can require a quorum of accounts for a privileged operation, enforced in one atomic transaction.
- Authorization semantics are explicit: what a co-signature does and does not authorize, in which execution frames it is visible, and how it expires.
- No new cryptography, no new signature scheme, no change to transaction identity.
- Any client that can produce a secp256k1 signature over a 32-byte hash — CLI, wasm SDK, or an EVM hardware wallet under CIP-40 — can be a co-signer.
3.1 Non-Goals
- Actors as co-signers. Actors hold no keys and cannot produce a signature; an actor address can never appear in
additional_signers. Actor quorums are expressed by approval accumulation across calls, where the callee observes the calling actor throughsender, and are specified separately. - Threshold or aggregate signatures. The set is k independent ECDSA signatures, not one aggregated signature. Cost and transaction size scale linearly.
- Protocol-level multisig accounts. Authorization policy lives in the actor, not in a system-level account type. The protocol supplies an authenticated signer set; it does not interpret it.
- Changing
tx.digest(). Transaction identity is signature-independent and stays that way.
4. Specification
4.1 Signer Set Semantics
A co-signature is authorization only. It carries no economic or sequencing role:tx.nonceis checked againsttx.from’s account nonce alone (node/execution/src/execution/transaction.rs:143), and the nonce increment writes only that account (transaction.rs:886). Co-signers consume no nonce.- A co-signer need not hold a balance, and need not exist as a funded account at all. An address that has never transacted is a valid co-signer. The co-signer role never causes a debit —
F_sigis paid bytx.fromalone — though a co-signing account may separately pay in an ordinary sponsorship role (as the target actor’sUseOwnerBalanceowner, say), which is that role’s debit, not the co-signature’s. - The primary signer controls submission: it chooses whether, when, and at which nonce to assemble. Once assembled, the transaction is a bearer artifact — it carries every signature it needs, so any party holding the bytes can broadcast it. Exclusive control ends at assembly, not at inclusion.
UseOwnerBalance, then the sender (transaction.rs:660) — so tx.from is the payer of last resort rather than the sole payer, and a co-signed transaction targeting a sponsoring actor is funded by that actor like any other. The one exception is the co-signer intrinsic charge, which is non-sponsorable; see §4.7.
Because the authorization digest — in either signature mode — binds chain_id, nonce, and the full instruction, a collected signature set authorizes exactly one transaction, on one network, at one nonce of one account. Once tx.from’s nonce advances past it, the set is permanently dead. Collected approvals therefore expire on their own, without an expiry field.
4.2 Host Context
HostContext gains two fields:
co_signers, not signers, because Transaction::signers() already exists and returns the primary plus the additional set (transaction.rs:202). Two names differing only in whether the sender is a member is a defect generator; the host field is named for what it holds.
The distinction between None and Some([]) is load-bearing and MUST be preserved end to end. Some([]) means a signed transaction entered this frame and carried one signer. None means this frame’s authority did not come from a transaction signature at all. Collapsing both to an empty list makes a timer fire indistinguishable from a single-signer transaction at exactly the point where an actor is deciding whether it was authorized.
code_hash is a separate, unconditional field — it describes the executing actor, not the transaction, so it is present in every frame including nested and non-signed ones, and it always reflects the code actually executing: simulation derives it from the executed code itself, overriding any injected value (§7). It exists because §5.2’s policy commitment is otherwise unverifiable from actor code: the execution context already carries actor.code_hash internally (it is read on every cross-actor call at node/execution/src/pvm_host.rs:2158), but no host field or SDK accessor exposes it.
code_hash is a frame-entry snapshot, immutable for the life of the handler frame. This matters because upgrade_self mutates the live context mid-handler — it stages the new bytes and overwrites ctx.actor.code_hash while the old code continues executing (pvm_host.rs:4451). If the field tracked the live value, old code could call upgrade_self and then observe the new hash, passing a §5.2 commitment check under code that no longer matches it. Normatively: the value is captured at frame entry; upgrade_self changes what subsequent invocations observe, never the current frame; a self-call into the new code observes the new hash in its own (nested) frame; and nested frames restore the outer snapshot on both success and error return.
Normative rules:
- Excludes the sender. The set contains
additional_signersonly.senderis already available and istx.fromwhen the set isSome. The full authorizing set is{sender} ∪ co_signers. - Canonical order preserved. Strictly ascending under byte-lexicographic comparison of the 20-byte big-endian address — the same total order
Address: Ordprovides and that the wire canonicalization is verified against. Actors may rely on the ordering and on the absence of duplicates. - Derived from the verified transaction. Populated from the
Transactionthat passedverify()at admission. Nothing in the execution path may reconstruct it from handler input, instruction payload, or any other caller-supplied source. - Populated by provenance, not by depth alone. The set is
Somein exactly one case: the frame was entered by an actor-invoking instruction of a directly signed, non-deferred transaction —ExecuteActor, or the atomicinitinvocation of a deploy.initqualifies because the signed instruction contains the code, the handler, and the payload; the deployer authorized exactly that invocation. Every other frame isNone. “Outermost frame” is not the predicate — several execution paths create a fresh, outermost PVM context without a signed instruction behind them, and each of them MUST beNone: deferred transactions, timer fires, mailbox deliveries, asynchronous event fires, token transfer hooks, runner result callbacks, gateway read paths, and default simulation contexts — while a synchronous event fire is a nested entry (it runs through the same switch/restore machinery ascall_actor) and isNoneby the nested-frame rule, not by fresh construction (run_simulationaccepts a caller-injectedHostContext,node/pvm/crates/pvm-runtime/src/simulate.rs:501; the default isNone, and injected values are test fixtures carrying no authorization claim). A gateway submit is not on that list: the gateway builds and signs an ordinary non-deferredExecuteActortransaction with its own key (gateway/crates/gateway-actor/src/lib.rs:383), so the actor observesSome([])with the gateway assender— signed provenance, no co-signers. - Cleared on every nested entry. A nested handler invocation sets
Noneand restores the caller’s value on return. This covers the cross-actor path throughswitch_to_callee/restore_call_snapshot(node/execution/src/pvm_host.rs:2183) and the self-call path, which bypasses both and dispatches straight toexecute_current_handler(pvm_host.rs:3154). Asender-based test does not work here: a self-call leavessenderuntouched, sosender == tx.fromstill holds in the nested frame. See §5.1.
verify() returns true for a deferred transaction only if additional_signers is empty, which is the barrier that lets Council authorization trust the field at all (node/chain/src/application.rs:2661).
The Python bridge is additive: host_context_to_dict gains a co_signers key, holding a list of 20-byte values or None, and a code_hash key holding 32 bytes (node/pvm/crates/pvm-runtime/src/module.rs:1023). Actors reading context through the named accessors observe no change. Context is exposed as a dictionary, so an actor that enumerates or serializes its keys does see the new ones; that is the full extent of the compatibility surface.
4.3 SDK Surface
cowboy_sdk.runtime and re-exported through pvm_sys, mirroring get_sender() (node/pvm/Lib/cowboy_sdk/runtime.py:140, pvm_sys.py:22). get_co_signers reads context().get("co_signers") — .get, not indexing, so a missing key (an older runtime) yields None rather than raising. It never returns [] for an absent key: an older runtime is a frame whose provenance is unknown, which is exactly the None case.
A roster check is then ordinary actor code. It fails closed on a degenerate threshold — threshold < 1 would authorize everyone, threshold > len(roster) is unsatisfiable and should read as a configuration error, not run silently. The None branch is a distinct rejection rather than a quorum of one, and the count needs no de-duplication of its own — canonical form (§4.5, CIP-40 §7.3) guarantees the set is ascending, duplicate-free, and disjoint from the sender:
ordered_set, not a built-in set — CIP-6 §11.3 prohibits set() in actor code for determinism, and this CIP requires CIP-6.
4.4 Client Co-Signing Flow
Co-signing is a three-phase flow over the transaction’s mode-selected authorization digest: in signature mode0 that is signing_hash(), in mode 1 the EIP-712 digest of the typed representation (§4.8). The roster and the mode must both be fixed before anyone signs — the roster because it is inside the preimage, the mode because it selects what is signed.
- Build. The initiator constructs the complete transaction — instruction,
chain_id,from,from’s next nonce, gas parameters,signature_mode, andadditional_signerspopulated with every co-signer address in ascending order andEthSignature::ZEROin each slot. This object is the co-signing request. - Sign. Each signer independently computes the mode-selected digest over that object and signs it. The digest is identical for every signer including the primary. No signer needs the others’ signatures, and signatures may be produced in any order, on any device, at any time.
- Assemble and submit. The initiator fills each signature into its slot, places the primary in
signature, and submits. Any single field altered between phases 1 and 3 invalidates every signature.
cowboy transaction subcommand (node/cli/src/commands.rs:107):
The artifact
assemble emits is the bare transaction encoding, not a submission envelope: submit decodes Transaction::read to EOF and constructs Submission::Transactions itself (node/cli/src/commands.rs:1096). The wasm encode_submission path emits the envelope for direct POST instead, and is a separate artifact tested separately — the two must not be conflated in tooling or in tests.
cosign MUST render the decoded instruction before signing, not just the digest. The digest is opaque in either mode; a co-signer that cannot see the operation is signing blind. Clients holding a decoder — the CLI, or the wasm codec in a browser — MUST show the decoded transaction; those that cannot MUST say so rather than present the digest alone.
In native signature mode the wasm path needs no new digest primitive — signing_hash for phase 2, sign for the signature, encode_submission for phase 3 (cowboy-protocol-wasm/src/lib.rs:71) — though its existing exports gain the shared roster validation of CIP-40 §7.3. Under CIP-40’s eip712 mode phase 2 is a different digest entirely — see §4.8. Landed and pending transactions already expose the roster over the indexer JSON (node/indexer/src/json.rs:72), so a UI can show which slots are filled without new endpoints.
4.5 Canonicalization
Canonical form — strictly ascending, duplicate-free, bounded, disjoint fromfrom, and the matching producer contract — is normative in CIP-40 §7.3. This CIP relies on those guarantees: they are what make the §4.3 helper’s naive membership count correct, and what keeps one authorization mapped to exactly one transaction identity.
4.6 No Migration
These rules need no activation height, version gate, or migration path: pre-launch networks are reset routinely, so there is no history to carry across the change.Transaction::verify() has no height parameter and gains none. The rules in this CIP are one coherent unit — a build enforcing the context semantics without the SDK, CLI, and canonicalization pieces is simply incomplete — but that is release hygiene, not activation machinery.
CIP-40 also revises Transaction::verify() (it adds signature_mode to the encoding and re-baselines the golden corpus); whichever lands second rebases onto the other.
4.7 Metering
Co-signature verification is priced and bounded by CIP-3 §2.5: an intrinsic per-co-signer charge that is non-sponsorable (split from the sponsorship cascade at settlement — co-signers are chosen by whoever assembles the envelope, and the sponsor never consented to them), a pre-recovery structural minimum oncycles_limit, a per-block co-signer budget checked before any recovery, and ingress caps with a cross-connection peer key and a global work budget. Those mechanisms are normative there; this CIP adds no metering rules of its own.
4.8 Signature Modes
Under CIP-40 a transaction carries asignature_mode discriminant covering the transaction as a whole: the primary and every additional signer use the same mode. A multi-signer roster is therefore mode-homogeneous — every member signs natively, or every member signs EIP-712 typed data.
This binds the flow in §4.4. build-unsigned freezes signature_mode along with the roster, because the mode determines what each signer signs: in mode 0 that is signing_hash(), and in mode 1 it is the EIP-712 digest of the typed representation, which is not the same value. cosign branches on the mode recorded in the phase-1 object and produces the corresponding digest and signature; a signature produced under one mode is not valid under the other, in any slot.
The consequence is a roster constraint, and rosters should be assembled with it in mind: a hardware wallet signing over WalletConnect and a CLI signing natively cannot co-sign the same transaction. Making mode per-signer would require the mode selector for each slot to sit inside the signing preimage, so that no signature is verifiable under a mode its signer did not choose; that is a CIP-40 change, not one this proposal makes.
5. Security Considerations
5.1 Cross-Frame Confused Deputy
The reasonco_signers is frame-scoped is the confused-deputy case. If a callee could observe the transaction’s signer set, then any actor called — at any depth — by a quorum-authorized transaction would appear to have been authorized by that quorum directly. Actor A, holding a legitimate 3-of-5 roster, calls actor B for an unrelated reason; B checks the same roster and concludes it was authorized. The quorum signed an instruction against A, not a downstream call into B.
Clearing co_signers on cross-actor entry makes the guarantee precise: a co-signature authorizes exactly the instruction it signed, in the frame that instruction entered. An actor that wants to propagate quorum authorization must pass it explicitly as call data, which correctly reduces the question to “does B trust A” — a decision B makes with full knowledge, rather than one the runtime makes on B’s behalf.
The same reasoning applies within an actor, and this is the case a sender-based rule silently misses. call_actor dispatches a self-call — target equal to caller — straight to execute_current_handler without entering switch_to_callee at all (node/execution/src/pvm_host.rs:3154). sender is left untouched, so in that nested frame sender still equals tx.from and a sender comparison would rule the quorum visible. An actor whose public handler self-calls a privileged one would carry the quorum into it. The co-signers authorized an instruction naming one handler, not every handler that one can reach.
Hence the provenance rule in §4.2.4 and the clearing rule in §4.2.5. The implementation obligation covers both nested entries: clear and restore around the cross-actor path (switch_to_callee / restore_call_snapshot, pvm_host.rs:2183) and around the self-call dispatch (pvm_host.rs:3154).
5.2 Roster Changes Between Signing and Inclusion
The transaction binds no actor state, so a signature collected under one authorization policy is still presented after that policy changes. Roster membership is the obvious case — a removed signer’s signature keeps counting — but it is not the dangerous one. Lowering a threshold, widening a role, or raising a spend tier makes an already collected set newly sufficient for an operation nobody authorized at that strength. An actor whose authorization policy is mutable MUST therefore bind a policy commitment into the signed instruction, covering every mutable input to the decision — roster, threshold, roles, limits — not roster membership alone. The actor rejects on mismatch, so any policy change invalidates every in-flight authorization rather than reinterpreting it. An actor whose policy is immutable after construction has no such obligation. The commitment MUST cover the actor’s code hash, not only its policy state.upgrade_self (node/pvm/crates/pvm-host/src/lib.rs:518) lets an actor replace its own code, so the same actor, handler, payload, and policy version can execute under changed authorization semantics. A hash commitment binds this structurally; a monotonic version counter binds it only if every upgrade remembers to bump, which is the discipline that fails first.
This is why §4.2 adds code_hash to the host context and §4.3 exposes it as get_code_hash(). Without an oracle the obligation is unverifiable from inside the actor — the context exposes actor_addr but nothing about the code running at it — and an unverifiable requirement is not a requirement. With it, the actor compares the committed hash against get_code_hash() and rejects on mismatch, with no reliance on an upgrade path remembering anything.
The protocol cannot enforce this by exposing co_signers. It is an obligation on the actor, and a reference multisig actor MUST carry an acceptance test in which an in-flight transaction is rejected after a policy change that would otherwise have made it succeed.
5.3 Blind Co-Signing
A co-signer’s exposure is bounded by the instruction they signed, but only if they can see it. The authorization digest is opaque in either mode; presenting it alone tells a signer nothing. §4.4 makes decode-before-sign a requirement of the signing surface rather than a UI nicety, and CIP-40’s typed-data mode extends the same property to hardware wallets.5.4 Submission Withholding
The primary signer can collect a quorum and never submit, or submit at a chosen moment. This is inherent: someone must pay and sequence. It is bounded by §4.1’s nonce binding — the set dies when the primary’s nonce advances — and by the actor’s own policy-commitment check (§5.2). Actors whose operations are timing-sensitive should bind a deadline height into the signed payload and reject late execution.6. Rationale
Why expose the set rather than build multisig into the protocol. A protocol-level multisig account type would fix one policy — k-of-n over a static roster — into consensus. Actors already own policy, and the policies people actually want (spend limits, per-operation thresholds, time locks, role-scoped signers, agent budgets under CIP-28) are actor logic. The protocol’s job is to say truthfully which keys authorized this transaction; interpretation belongs above it. Why not in-actor signature verification. Exposing secp256k1 recovery to the PVM would let an actor verify collected signatures itself, at the cost of every actor author reimplementing replay protection, domain separation, and canonicalization. Consensus already does all three correctly for transactions. CIP-37 draws the same line for gateway authentication. Why the Council does not migrate.council_authorized operates on the transaction inside the execution engine, where the field is already available, and its roster is system state rather than actor state. It is unaffected. The CIP-40 §7.3 duplicate-primary rule applies to both paths; the Council never depended on it (its HashSet accumulation de-duplicates), but actor code counting {sender} ∪ co_signers does.
7. Acceptance Criteria
Every criterion below is a runnable boundary — a concrete input, an observable output — not a review assertion. Client flow (the duplicate-primary and metering criteria live with their rules, in CIP-40 §7.3 and CIP-3 §2.5)- The
authorizedhelper fails closed on degenerate thresholds: 0, negative, and greater than the roster size each returnFalseregardless of the signer set. - A table test mutates each field of the canonical signing preimage except signature bytes in turn, between
build-unsignedandassemble, run once per signature mode; every row fails assembly’s local verification, and in mode 1 the CIP-40 §7.3 forbidden fields (access_list, theorigin_*fields) reject structurally. The row set is the preimage schema itself:chain_id,nonce, every instruction field, each of the six gas parameters,from,access_list,signature_mode, the signer roster,origin_tx_hash/origin_remaining_cycles/origin_remaining_cells, andmetadata. (Signature slots are exactly the fields assembly is supposed to fill.) - A transaction assembled by
build-unsigned→cosign(×k, independently, offline) →assembleverifies locally and executes on a devnet. assembleoutput decodes as a bareTransactionbyte-equal toencode_transactionof the same object, and feedstransaction submitunchanged;encode_submissionoutput decodes asSubmission::Transactions— one test per artifact, proving the two are not interchangeable.cosignfails closed: when the phase-1 object cannot be decoded and rendered, no signature is emitted.- A signature produced under
signature_mode0 fails verification when placed in a mode-1 transaction and vice versa — tested in the primary slot and in an additional slot.
Some cases
- An actor called directly by a k-signer
ExecuteActorobservesSomeof exactly the k−1 additional signers, ascending, excluding the sender. - A single-signer transaction yields
Some([]), distinguishable at the SDK boundary from everyNonecase below. - A deploy’s atomic
initinvocation observesSomeof the deploying transaction’s co-signers. - A gateway submit — the gateway-signed
ExecuteActor— yieldsSome([])with the gateway account assender.
None cases and restoration
Every causally seedable derived-path test in this group originates from a transaction carrying a non-empty co-signer set (k ≥ 2), so that an implementation which never populates the field cannot pass by accident — the tests must observe Some at the origin and None on the derived path, not None everywhere. Gateway reads and default simulation have no originating transaction and are tested independently.
- Cross-call: the callee observes
Noneand the caller’sSome([...])is restored — on the success path and on the error path (callee raises, caller resumes). - Self-call: the nested frame observes
Noneand the outerSome([...])is restored, success and error paths both. A handler reachable only by self-call cannot see the quorum. TokenTransferFromwherefrom ≠ tx.from: both transfer hooks observesender = fromandco_signers = None.- Synchronous event fire: the subscriber observes
None; the emitting frame’sSome([...])is restored after the fire, on the success path and when the subscriber raises. - Asynchronous event fire: the subscriber observes
Noneeven though the deferred transaction carries anorigin_tx_hashnaming the co-signed origin. - Deferred transactions, timer fires, mailbox deliveries, runner result callbacks, and gateway reads each yield
None— one test per entry path, each seeded from a co-signed origin where the path admits one. - Default simulation yields
None:run_simulationwith no injected context observesco_signers = None(node/pvm/crates/pvm-runtime/src/simulate.rs:501accepts a caller-suppliedHostContext, so this is a property of the default context — injectedco_signersvalues are untrusted test fixtures and carry no authorization claim). - Simulation
code_hashis the executed code’s, on every simulation API:run_simulation, andSimulator::new(code, options)/simulate::Network(which copyoptions.contextunchanged today,node/pvm/crates/pvm-runtime/src/simulate.rs:612) each override default or injectedcode_hashwithkeccak256of the code actually executed — an injected context cannot makeget_code_hash()lie about the running code. Runnable tests for both APIs. - Bridge mapping is exact:
None→ PythonNone;Some([])→[];Some([a, b])→[a, b]in that order; a missing key (older runtime) →None. get_code_hash()returns the executing actor’s code hash in every frame — signed, nested, and non-signed.get_code_hash()is a frame-entry snapshot: within the handler that callsupgrade_self, it continues to return the old hash after the call; a self-call made after the upgrade observes the new hash in its nested frame; and the first subsequent invocation observes the new hash.- Nested frames restore the outer frame’s
code_hashsnapshot on return, on both the success and the error path.
- Council authorization (
council_authorized) andCreateAccountdual-signature behavior are unchanged — continuity tests for both. - A zero-co-signer transaction is byte-identical on the wire to one produced without this CIP, and unchanged in behavior — this CIP adds no field and changes no encoding.
- One release carries every CIP-41 rule together with its CIP-40 §7.3 and CIP-3 §2.5 prerequisites; none is separately togglable.
- The reference multisig actor —
cowboy/examplesfixture accompanying this CIP, usingget_co_signers()and a policy commitment covering roster, threshold, limits, andget_code_hash()— rejects an in-flight authorization after a policy change: a threshold reduction that would otherwise have made the collected set sufficient, and anupgrade_selfthat changes authorization semantics with policy state unchanged.
8. Implementation Notes
The canonicalization changes (duplicate-primary rejection in
verify(), the producer contract across the Rust/Python/CLI/wasm signing surfaces) are implemented under CIP-40 §7.3, and the metering changes (structural minimum, precharge, settlement split, block budget, ingress caps) under CIP-3 §2.5; their implementation maps live there. This CIP’s protocol dependency is a cowboy-protocol release consumed by node — the codec bump lands first, with the node pins and Cargo.lock. No field is added to Transaction, so the wire format is unchanged.
Canonical sources and mirrors. Several touched surfaces exist in more than one place, and the edit lands in the canonical copy with propagation to each mirror in the same release:
HostContextandhost_context_to_dict: canonical innode/pvm; the standalonepvm/repo mirrors the PVM crates, andbroncosyncs its shared crates fromnode/pvm(per its README). The synced crates pick the change up automatically — but sync is not the whole story: both repos also contain localHostContextliterals outside the synced crates (bronco/crates/cli/src/host.rs,bronco/crates/wasm-sim/src/host.rs,pvm/examples/*constructingHostContextdirectly). The release includes a sweep of everyHostContextconstruction site across consumers — enforced as a build gate, since a missed literal is a compile error once the fields are non-optional in the struct — hand-updating non-synced consumers toco_signers = Noneand an honest executed-code hash.cowboy_sdk(runtime.py,pvm_sys.py): canonical innode/pvm/Lib/cowboy_sdk; the PyPI package republishes a duplicated snapshot undernode/pvm/Lib/pypi_build, refreshed bypypi_build/sync_sdk.pyand guarded bycowboy_sdk/tests/test_pypi_build_sync.py— the release runs the sync script before rebuilding, since rebuilding alone ships the stale snapshot.- The published
python-sdkpackage is a separate codebase, not a synced mirror — but it is touched twice: its signer is a CIP-40 §7.3 producer, and itscowboy/runtime.pypromises a local-dev interface identical tocowboy_sdk.runtime, so it implements both accessors with mock state (listed above).
cowboy-protocol (codec + wasm) first, then node (pins + Cargo.lock), then the mirrors by their normal sync.
