Skip to main content
Status: Draft
Type: Standards Track
Category: Core
Created: 2026-10-25
Requires: CIP-40 auth v3 (transaction v3, signature slots §7.3, signed objects §7.6, tagged signing keys §7.7, wallet identity §12.1), CIP-3 (transaction charging, §2.5 signature metering), CIP-4 (state prefixes), CIP-43 (signed-transaction envelope accesses)
Related: CIP-37 (protocol ActorOwner), CIP-41 (multi-signer authorization)
Cowboy Cloud and Cowboy Chain. This CIP specifies the Cowboy protocol. Cowboy Cloud runs on this protocol today. Cowboy Chain, a public network on the same protocol, is under consideration for a future release. Where the two differ, this CIP says so.

1. Background

A Cowboy account address is the last 20 bytes of the keccak256 hash of its secp256k1 public key, EVM-style. Without an authorization policy, transaction verification recovers the public key from each signature slot and requires the derived address to equal the slot’s declared address. The key is the account. There is no way to replace it, and no way for an ordinary account to require more than one key. Losing the key therefore loses the account and everything it controls: balances, tokens, CBFS volumes, delegations, and every actor whose protocol ActorOwner (CIP-37 §7.1.1) is that account. ActorOwner cannot be transferred and its website sharing cannot be changed without a signature from the owning account. A leaked key is equally final: the owner can move assets out, but the address, and anything bound to it by other accounts or actors, stays compromised. Off-chain wallet backup (encrypted key backup and CBSS-based restore in the Dashboard) covers a lost device. It does not cover a key that is gone everywhere, and it cannot help when the key itself must be replaced because it leaked.

2. Goal

An account address is a stable identity that outlives any one key.
  • The account is controlled by an authorization policy: one key, or a threshold of a set of keys. Ordinary accounts get native multisig.
  • The current policy can replace itself (SetAuthPolicy).
  • An account can name a recovery account that can replace the policy after a delay, during which the current policy can cancel. A recovery account with a k-of-n policy is guardian recovery.
  • Accounts that never set a policy behave exactly as today and pay nothing extra beyond one state read per signer.
  • Everything sits on the CIP-40 transaction v3 model: the same signature slots, the same signing-digest construction, the same tagged key encoding.

2.1 CIP-40 dependency

This CIP builds on the CIP-40 auth v3 text. It uses: the SignatureSlot and slot-policy constants including SECP256K1_OBJECT_POLICY and TX_SLOT_POLICY (§7.6); object_signing_preimage framing (§7.6); the signed-object table and its row numbers (§7.6: rows 1 RasOwnerDelegation, 7 workload DelegationCert, 9 ReadTransferReceipt authorization and completion, 19 session vouchers, 22 PaymentIntent, 24 account Intent, 26 TokenPermit); SigningPublicKey, canonical_signing_preimage, storage_key_fragment() and KEY_VALIDATION_CYCLES (§7.7).

3. Non-goals

  • New signature algorithms. At genesis TX_SLOT_POLICY admits secp256k1 only, so policy keys are secp256k1. If the transaction policy later admits another algorithm (for example P-256 passkeys), a policy can include it with no change here.
  • Undoing account actions. A policy change alters which keys authenticate the account. State that earlier transactions created (grants, transfers, approvals recorded on chain) stays as it is; those were the account’s own actions.
  • User-actor signature checks. Actor code that compares an ecrecover result to an account address is not policy-aware, and user actors get no policy read. Actors authorize from the transaction sender and CIP-41 co-signers.
  • Address-authorized objects. Some wallet-signed objects still verify against the key the address derives from, and ignore the account policy: CBFS read-channel vouchers and requests (CIP-40 §7.6 rows 8 and 30, which keep address authorization), workload mount-policy owner signatures, purchase authority (row 23) and the CIP-34 auction grant originator (row 25). A policy change does not revoke a leaked original key for these objects, and an account on a k-of-n policy cannot sign them. Moving them to AccountSignature is separate work (§9).

4. Definitions

  • Authorization policy. AuthPolicy { threshold, keys } (§5.1): the account authorizes a signature when threshold distinct keys from keys sign.
  • AddressDerived. The policy of an account with no AccountAuth row: one signature by the key whose derived address equals the account address. The key itself is not stored or known until it signs.
  • AccountSignature. An account’s signature on a transaction or signed object: a list of key-indexed signature slots (§5.2).
  • AccountAuth. The per-account consensus row defined in §5.1.
  • Recovery account. An account, named by address, whose signed transactions may start and complete recovery of another account. It is an ordinary account with its own policy, rotation and recovery.
  • Auth revision. AccountAuth.revision, a counter incremented by every change to the row. It guards instructions against stale approvals. An absent row has revision zero.
  • Policy revision. AccountAuth.policy_revision, a monotonic counter incremented only when the policy changes (SetAuthPolicy, CompleteRecovery). Every account signature names it. An absent row has policy revision zero.

5. Specification

5.1 State

A new state prefix AccountAuth (0x26, CIP-4 §4.2) uses the key prefix ‖ address[20] ‖ zeros in the same fixed-length layout as WalletMark. The value is a new canonical record:
A policy is valid iff 1 <= threshold <= keys.len() <= MAX_POLICY_KEYS, every key uses an algorithm in TX_SLOT_POLICY (secp256k1 at genesis), and keys is strictly ascending by storage_key_fragment() (CIP-40 §7.7), which also makes the keys distinct. A single-key account is the policy { threshold: 1, keys: [k] }; there is no separate single-key form. Only the instructions in §5.3 write this row. Actor code, deferred transactions and genesis configuration cannot write it. A row, once created, is never deleted. A malformed row is a storage error and never reads as absent. A row is created by the first SetAuthPolicy or SetRecovery on an account. SetRecovery on an AddressDerived account, including SetRecovery(None), stores { threshold: 1, keys: [k] } with auth revision 1 and policy revision zero, where k is the key recovered from the transaction’s own primary signature, so a row always holds an explicit policy. This is the same authorization made explicit, so the policy revision does not change. The row’s wire form starts with the version byte 1; any other version is a decode error.

5.2 Policy verification

The address rule for creating an account does not change: a new account’s address is derived from its initial key. From then on the address is frozen. It never changes when the policy changes.
AddressDerived carries no key: the public key of an account that has never set a policy may never have been revealed. Its policy revision is zero, and a verifier recovers the key from the supplied signature and checks that it derives to A. An account’s signature is an AccountSignature:
policy_revision is part of the signed message. For transactions, each signer entry’s policy_revision and key_index list are bound into both digests (§5.10). For native account-signed objects (item 2 below), every slot signs object_signing_preimage(domain, identity, body ‖ account[20] ‖ u64be(policy_revision)), the row’s ordinary body followed by the signing account and the policy revision. Row 19 is EIP-712 and takes no native framing; it binds the same two fields inside its typed struct (§5.2.1). On the wire, policy_revision is a varint followed by the entry list; an empty list is a decode error. It is structurally valid iff entries has between 1 and MAX_POLICY_KEYS items, key_index is strictly ascending, every slot passes its surface’s slot policy, and, when policy_revision is zero, there is exactly one entry at index 0. Revision zero can only ever be satisfied by the key the account address derives from: either AddressDerived, or the stored 1-of-1 that SetRecovery makes explicit from that same key. A verifier can therefore check a revision-zero signature without reading state. It satisfies account_policy(A) iff:
  1. policy_revision equals the account’s current policy revision;
  2. for AddressDerived: exactly one entry, at index 0, whose recovered key derives to A;
  3. for Stored: exactly threshold entries, every key_index < keys.len(), and the key recovered from each slot equals keys[key_index].
Strictly ascending indices make the signatures distinct and canonically ordered; two entries cannot count the same key. Binding the policy revision makes a signature valid only under the policy it was made for. Any policy change revokes everything signed earlier, including signatures by keys that remain in the new policy, and returning to an earlier key set (A→B→A) does not revive old signatures because the revision only increases. This applies to:
  1. Transactions. The primary signer and every additional signer (CIP-41) carry an AccountSignature. The sponsor field is reserved at genesis and keeps a single SignatureSlot; when CIP-40 enables sponsorship, the sponsor signature becomes an AccountSignature under the same rules. A transaction carries at most MAX_TX_SIGNATURE_SLOTS slots in total, checked in the structural check before any recovery. Transaction signature verification is stateless: it checks structure, recovers every slot and returns each signer’s address, policy revision and (key_index, recovered key) list, keys in the §7.7 33-byte compressed form. It also rejects a revision-zero signer whose single key does not derive the declared address. Satisfaction against account_policy is part of the stateful admission checks that CIP-40 §7.3 and §12.1 already run against the current execution view (wallet/actor role, nonce, balance), and fails with AccountPolicyUnsatisfied. A signer that is AddressDerived at revision zero needs no further work. For any other signer, a transaction that was pre-verified at block proposal is recovered again during this check, so pre-verification never skips the policy check. RPC ingress runs the same check against committed state before accepting a transaction.
  2. Account-signed objects. CIP-40 §7.6 marks each row whose signer is a wallet account (among them row 1 RasOwnerDelegation, row 7 workload DelegationCert, row 9 ReadTransferReceipt authorization and completion, row 19 session vouchers, row 22 PaymentIntent, row 24 account Intent, row 26 TokenPermit). Each marked row carries an AccountSignature in place of its single slot, and its body MUST name the signing account, so the verifier knows whose policy to resolve and an object cannot be replayed against a second account that shares keys. Each slot signs the same digest. Rows signed by non-accounts (CBSS proxies, runner anchors, the credit authority) are unaffected.
  3. Where objects are verified. Node verifiers read account_policy from the current execution view. The Session actor and Trading Post verify row 19 in native execution code and read the policy from the same store; user actors get no policy read. Off-chain verifiers (CBFS relays, runners, gateways) resolve the policy through the finalized-state proof in §5.7, verified against a trusted checkpoint, and fail closed when they cannot.

5.2.1 EIP-712 session rows

Row 19 (session vouchers, per-call receipts and route session reports) is the only EIP-712 row in the CIP-40 §7.6 inventory. Its account-signed objects are exempt from the native framing above. Its domain is unchanged: EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt) with name Cowboy MPP Session, version 1, the real chain ID, salt = chain_instance_id, and verifyingContract set to the Session actor for vouchers or the Trading Post for per-call receipts. That keeps the chain, genesis-instance and surface separation exactly as CIP-40 defines it. Row 19 has a single typed struct. All three objects are a CIP-8 SessionVoucher hashed under the one Voucher type; they differ only in verifyingContract and in who signs: For a session voucher, account is session.signer and the voucher is checked against that account’s policy. The payer only funds, closes and receives the refund; its policy plays no part in voucher verification unless it is also the signer. A route session report is signed by the route-metering key bound into the session, so CIP-49 does not change it; that key’s lifecycle belongs to the session binding. For the two account-signed objects the Voucher type appends address account and uint64 policy_revision after its existing members:
Session vouchers and per-call receipts use this one type string; the verifyingContract keeps them apart, as today. Because route session reports share the struct, they use the same extended type with account set to the zero address and policy_revision zero; their verifier requires account = 0, policy_revision = 0 and a single entry at index 0, then checks the metering key as before. There is no other row-19 type string to extend. account is the signing account and MUST equal the account the verifier attributes the object to; policy_revision MUST equal the AccountSignature’s. Every slot is a SECP256K1_EIP712_OBJECT_POLICY slot over the same final digest keccak256(0x19 ‖ 0x01 ‖ domainSeparator ‖ hashStruct(struct)), verified unchanged as an EIP-712 digest. A voucher signed under an earlier policy revision therefore fails even when its keys remain in the current policy. The declared address in every signer entry, and Signer.signer in the EIP-712 projection, remain the account address. The digests bind every signer’s key_index list and slot descriptors and exclude signature values, as CIP-40 already does for descriptors, so entries cannot be added, removed or re-indexed after signing. A key may appear in the policies of several accounts, including the account at its own derived address. Each is a separate account with its own nonce, balance and row.

5.3 Instructions

All five are system instructions carried by an ordinary caller-signed transaction v3. They MUST be the transaction’s top-level instruction; actors cannot invoke them. Deferred transactions cannot carry them. Every one is authorized by the sender’s AccountSignature satisfying the sender’s current policy, like any transaction. Each except CancelRecovery takes expected_revision and rejects with AuthRevisionMismatch unless it equals the current auth revision of the affected account; each successful instruction increments the revision by one. Opcodes 231 and 232 are CIP-37’s SetWebAccess and TransferActorOwnership. Key proofs. key_proofs has exactly one entry per key of the proposed policy, in key order. Entry i is a CIP-40 §7.6 signed object under SECP256K1_OBJECT_POLICY, domain cowboy/account/key-proof/v1, body account[20] ‖ u64be(expected_revision) ‖ canonical_signing_preimage(keys[i]), and MUST recover to keys[i]. The proofs show every key exists and is held, so a typo or a foreign public key cannot brick the account. The trusted chain identity in the object frame, the account and the revision prevent replay onto another chain, account or revision. The handler validates the policy shape first, then charges KEY_VALIDATION_CYCLES[algorithm] and validates each key before verifying its proof (CIP-40 §7.7). SetAuthPolicy. Replaces policy and increments policy_revision. If a recovery is pending, clears it and emits RecoveryCanceled { reason: PolicyChanged } before AuthPolicyChanged { cause: Set }. SetRecovery. Some(config) requires config.account != account, config.account present in the transaction’s additional_signers with an AccountSignature satisfying its own policy (CIP-41), and MIN_RECOVERY_DELAY_SECS <= delay_secs <= MAX_RECOVERY_DELAY_SECS. The co-signature proves the recovery account exists and is held, and CIP-40 §12.1 admission already rejects actor and system addresses as signers and marks the recovery account as a wallet. None removes the recovery account. Either form clears a pending recovery (emitting RecoveryCanceled { reason: Reconfigured }) and takes effect immediately. Emits RecoveryConfigured. InitiateRecovery. Checks, in order: expected_revision, a configured recovery account equal to from (NotRecoveryAccount; an account with no row has none), no pending recovery (RecoveryPending), then the policy and key proofs. Writes pending with the proposed policy and executable_at_ms = the executing block’s timestamp + delay_secs × 1000, using the delay configured at initiation. Emits RecoveryInitiated. CancelRecovery. Requires a pending recovery (NoPendingRecovery). Clears it. The current policy may cancel at any time until CompleteRecovery executes, including after executable_at_ms. Emits RecoveryCanceled { reason: Owner }. CompleteRecovery. Requires from to equal the configured recovery account, a pending recovery, and the executing block timestamp >= executable_at_ms (RecoveryNotReady). Sets policy = pending.new_policy, increments policy_revision, clears pending, keeps recovery unchanged. Emits AuthPolicyChanged { cause: Recovery }. Errors and their structured codes: A failed instruction follows the normal CIP-3 failure path: nonce and fees are consumed and the row is unchanged.

5.4 Guardian recovery

Guardian recovery is a recovery account whose own policy is k-of-n over the guardians’ keys. InitiateRecovery and CompleteRecovery from that account need k guardian signatures in its AccountSignature, exactly as any transaction from a k-of-n account does; guardians assemble them with the CIP-41 §4.4 co-signing flow. Nothing in this CIP is guardian-specific. Changing the guardian set is a SetAuthPolicy on the recovery account, authorized by its current quorum.

5.5 Nonces and replay

The five instructions use the sender’s ordinary account nonce and CIP-40’s chain-identity-bound transaction digest. No separate nonce exists. Initiation and completion consume the recovery account’s nonce and are paid by it. The recovered account’s nonce and balance are untouched. The account nonce continues across policy changes and recovery. Every account signature names the policy revision it was made under (§5.2), so a transaction signed before a policy change can never be admitted afterwards: if it was included its nonce is consumed, and if it was withheld its policy revision is stale. This holds even when the new policy keeps some of the old keys. Recovery therefore does not need to advance the nonce; the policy revision does that job. expected_revision makes every auth change except cancellation compare-and-set, so a wallet approval prepared against one state cannot apply to another; an InitiateRecovery prepared before a SetRecovery cannot apply after it. CancelRecovery carries no revision: at most one recovery is pending, and canceling whichever one is pending is always what the owner wants.

5.6 In-flight transactions and objects

A transaction whose signatures no longer satisfy the account’s policy fails the stateful check in §5.2. It is an admission rejection, handled exactly like a signature that recovers to the wrong address: no nonce, fee or wallet marker is consumed. The same holds for a transaction signed under a new policy before the change that installs it has executed. Ordering inside a block follows the execution view: a policy change at position i invalidates that account’s transactions signed under the old policy at positions after i. Block producers MUST evaluate the check against the speculative state of the block being built. RPC ingress checks against committed state. Mempools MUST re-check pending transactions of an account (as primary or co-signer) when its AccountAuth row changes, and evict those that no longer satisfy it. Signed objects (§5.2 item 2) are checked against account_policy every time they are verified. A policy change, by SetAuthPolicy or by recovery, advances the policy revision and so revokes every outstanding object signed under an earlier revision, including objects signed by keys that remain in the new policy and standing objects re-verified on each use such as RAS owner delegations and CBFS DelegationCerts. The owner re-signs the ones still wanted under the new policy; wallets SHOULD re-issue the account’s live delegations as part of a routine change.

5.7 Events and reads

Events are emitted under the affected account’s address so a watcher filters by its own account. RecoveryConfigured carries delay_secs = 0 when the recovery account is removed. RecoveryInitiated and the AuthPolicyChanged from completion are emitted under the recovered account, even though the recovery account sends them. RPC exposes two reads:
  • GET /accounts/{address}/auth returns the committed height and the row in one flat object: { "height": h, "kind": "AddressDerived" } (no row; revision and policy revision zero, no key) or { "height": h, "kind": "Stored", "revision", "policy_revision", "policy", "recovery", "pending" }. Policy keys are 33-byte compressed hex. This read is unproven and suits wallets and display.
  • POST /proof/finalized-state accepts account_auth: [address, ...] (sorted, unique) alongside actor-storage claims in a version 2 bundle request. The response proves each row present or absent at a finalized block. Off-chain verifiers that authorize anything from an account signature use this read, verify it against a trusted checkpoint, and fail closed when they cannot.
Wallets and the Dashboard poll the first read, or watch the events above, for every account they manage. Whenever pending is set they MUST warn the user with the time remaining and offer a one-step cancel; they SHOULD also alert on any AuthPolicyChanged the user did not initiate. Account history beyond the current row is the explorer’s concern. Wallets display the canonical approval facts for each instruction before signing: account, current and expected revision, the proposed policy (threshold, each key and its derived address), recovery account, delay and, for SetAuthPolicy and SetRecovery, whether a pending recovery will be canceled. The client-signing allowlist and inspect_unsigned_transaction cover all five instructions. A wallet that derives an account’s address from its key MUST track the account address separately once that account has changed its policy.

5.8 Fees

Each instruction is charged as an ordinary transaction under CIP-3, with no surcharge and no deposit:
  • intrinsic and calldata charges as for any system instruction;
  • the CIP-3 §2.5 per-slot verification charge for every slot of every AccountSignature, including a k-of-n sender’s k slots and the recovery account’s co-signature on SetRecovery;
  • for SetAuthPolicy and InitiateRecovery, KEY_VALIDATION_CYCLES[algorithm] and one object-signature verification per proposed key;
  • the system instruction’s base cycles and Cells, and a flat storage_write_cells charge per AccountAuth write, independent of the row’s size;
  • one AccountAuth read and one write in the instruction’s access accounting.
Policy resolution adds one AccountAuth read per signer account (primary and each co-signer) to the CIP-43 signed-transaction envelope, alongside the existing Actor read and WalletMark write per signer. An absent row is a cold read like any other.

5.9 Interaction with ActorOwner

ActorOwner.owner (CIP-37 §7.1.1) is an account address. Because policy changes and recovery keep the address, they restore control of every actor that account owns: TransferActorOwnership and SetWebAccess verify the new policy through §5.2 with no change to the ActorOwner record, its revision or website access. An actor owned by a k-of-n account needs k signatures for those operations. Recovery is not an ownership transfer and does not reset sharing. An owner recovering from a leaked key should review sharing and re-lock sites the attacker may have published.

5.10 Auth v3 structures

This CIP uses these auth v3 structures:
  1. The primary signature and each additional_signers entry are AccountSignatures (the reserved sponsor field follows when sponsorship is enabled), with each signer’s policy_revision and key-index list bound into both digests. In EIP-712 mode Signer becomes Signer(address signer,uint64 policy_revision,SignerSlot[] slots) with SignerSlot(uint8 key_index,uint8 algorithm,uint8 mode,bool has_public_key,bytes public_key).
  2. Transaction signature verification returns each signer’s address, policy revision and (key_index, recovered key) list in compressed form. The declared-address comparison is replaced by the stateful policy check of §5.2, run at RPC ingress and in execution. The structural check bounds the slot count by MAX_TX_SIGNATURE_SLOTS before recovery.
  3. The §7.6 table marks account-signed rows (rows 1, 7, 9, 19, 22, 24 and 26 listed in §2.1); each marked row carries an AccountSignature; native rows sign body ‖ account ‖ u64be(policy_revision) under their existing domain, row 19’s typed structs gain address account,uint64 policy_revision (§5.2.1), and each verifier takes a resolved account_policy.
  4. A §7.6 table row for cowboy/account/key-proof/v1 under SECP256K1_OBJECT_POLICY.
  5. SigningPublicKey (§7.7) as the stored key type, with KEY_VALIDATION_CYCLES charged before validation.
  6. One AccountAuth read per signer account in the envelope, and the new state prefix and opcodes 233–237 in the codec.
Wallet markers (§12.1) are unchanged. The marker stays on the account address; a policy change neither moves nor adds one. CIP-41’s co_signers still lists accounts; an account appears in it when its AccountSignature satisfies its policy, so actor quorum logic counts accounts as before.

5.11 Parameters

The protocol requires an explicit delay_secs. Wallets pre-fill 7 days (604 800 seconds).

6. Rationale

Threshold policies. Storing a threshold over a key set costs almost nothing over a single key, and it gives every ordinary account native multisig without deploying an actor. A single key is the 1-of-1 case. Guardian recovery then needs no mechanism of its own: it is a recovery account with a k-of-n policy. Exactly threshold signatures, indexed. Key indices make the distinctness check a stateless ascending-order check and let verification compare each recovered key to one stored key. Requiring exactly threshold entries keeps metering predictable and leaves one valid shape per signer set. Tagged stored keys. Storing tagged SigningPublicKeys reuses CIP-40 §7.7 and lets a future non-recoverable algorithm (Ed25519, P-256) join a policy without a second format. No row for the common case. An AddressDerived account keeps the address-derived rule, so the default path costs one absent read and no stored keys. Recovery account instead of a bare recovery key. Making the recovery party an account gives it a nonce, a balance to pay with, wallet support, its own policy and its own recovery, with no new replay machinery. A bare key would need its own nonce space and a fee payer. Explicit completion. Completing by transaction writes the new policy and emits an event at a known height. Resolving the policy lazily from a pending timestamp would make every signature check time-dependent and leave no record of when control changed. Cancel until completion. The owner can cancel after the delay has elapsed, up to the moment completion executes. A completion window that locks the owner out would favor the recovery party with no benefit to an owner who still holds the account. Revoke on policy change. Every account signature names the policy revision it was made under and is valid only at that revision. A policy change therefore revokes everything signed under the old policy, held-back transactions included, even when the new policy keeps some old keys. Checking keys alone would let a retained or restored key keep old authorizations alive. Key proofs. A policy change is the one operation that can strand an account by mistake. A possession proof per key removes the typo and wrong-key cases, and threshold <= keys.len() with every key proven means the new policy is satisfiable. Fixed delay bounds. One day leaves an absent owner a real chance to notice; thirty days keeps recovery useful. Governance adjustment buys nothing at this stage.

7. Security Considerations

Recovery-key theft. A thief who can satisfy the recovery account’s policy can initiate. The owner has the full delay to cancel, and wallets warn loudly while recovery is pending. The owner should then replace the recovery account (SetRecovery also cancels the pending attempt). Residual risk, accepted: an owner who does not look at any wallet for longer than the delay loses the account. A k-of-n recovery account raises the bar to k compromised guardians. Recovery keys should not share a backup with the account’s own keys. Account-key theft. The owner changes the policy; the first change to execute wins. An attacker who satisfies the current policy and changes it first also controls CancelRecovery and SetRecovery, so recovery cannot defeat an active attacker who holds a quorum of current keys. This follows from the current policy’s power to cancel. Recovery is for lost keys; a policy change is for leaked ones. A k-of-n account tolerates up to k−1 leaked keys and up to n−k lost keys without recovery. Griefing by repeated initiation. Only the configured recovery account can initiate, at most one recovery is pending, and each attempt costs the initiator a transaction. A recovery account that initiates repeatedly is hostile or compromised; the owner removes it with one SetRecovery. An initiation landing first bumps the revision and makes a concurrent SetRecovery fail; one retry converges, because no new initiation is possible while that one is pending, and CancelRecovery needs no revision. Signature-count abuse. Slot counts are bounded per account and per transaction and checked before any recovery; every slot is metered under CIP-3 §2.5. Duplicate or reordered indices fail structure checks. Extra entries beyond the threshold fail the policy check. Stale approvals. Every instruction except cancellation is compare-and-set on the auth revision (§5.5). Bricking. Key proofs and the threshold bound guarantee the new policy is satisfiable when installed. Losing keys afterwards is the case recovery covers. Old-policy artifacts. Signatures and account-signed objects (§5.2 item 2) from a replaced policy stop verifying, including standing delegations and signatures by keys kept in the new policy (§5.6). On-chain state created by the account’s earlier transactions stays in force (§3). After a leak, wallets SHOULD walk the owner through reviewing grants and approvals the attacker may have made. Determinism. The delay uses the executing block timestamp, which validators already agree on. Policy resolution reads the current speculative view, so a change earlier in the block is observed by every later check. Off-chain verifiers. RAS, CBFS relays and gateways that accept wallet-signed objects must resolve the policy from finalized state. A verifier that still compares recovered addresses would keep accepting a replaced key; the conformance suite covers each such verifier.

8. Test Vectors

8.1 Computed vectors

vectors/cip49-account-auth-v1.json is produced by vectors/gen_cip49_vectors.py with eth-account 0.13.4 (eth-abi, eth-keys). The script recovers every signature before writing it and cross-checks every hand-built EIP-712 digest against eth-account’s encode_typed_data; its output is deterministic. Keys are keccak256("cip49-vector-key-" ‖ label); the chain identity is illustrative (chain_id 26909, chain_instance_id = keccak256("cip49-vector-chain-instance")). Signatures are CIP-40 slots: r ‖ s ‖ v, v ∈ {0,1}, low-S. Every case starts from a reachable state. The account’s address derives from key K0, so at policy revision 0 it is AddressDerived, and each SetAuthPolicy increments the policy revision:
  • retained_key: rev 0 AddressDerived (K0) → rev 1 1-of-[A,B] → rev 2 1-of-[A,C].
  • aba: rev 0 AddressDerived (K0) → rev 1 1-of-[A] → rev 2 1-of-[B] → rev 3 1-of-[A].
  • multisig: rev 0 AddressDerived (K0) → rev 1 2-of-[A,B,C] (canonical order B, C, A).
The K0 permit accepts at rev 0 and rejects from rev 1. A’s rev-1 permit accepts under 1-of-[A,B] and rejects at rev 2; A’s rev-2 permit accepts at rev 2. Voucher cases (final EIP-712 digests; signatures, key indices, voucher fields and hashStruct are in the JSON):

8.2 Additional cases

The cases below are not in the §8.1 file yet. Wire goldens are computed with the protocol codec; behavioral cases run against a node. Where a case below is already computed in §8.1, it reuses those digests.
  • Encoding goldens. AuthPolicy 1-of-1, 2-of-3 and 16-of-16; AccountAuth with no recovery, with recovery, and with a pending recovery; AccountSignature; each of the five instructions in commonware wire bytes and JSON; the EIP-712 Signer/SignerSlot typed data and digest for a 2-of-3 signer.
  • Policy validity. Threshold 0, threshold above keys.len(), 17 keys, unsorted keys, duplicate keys, unsupported algorithm.
  • Key proofs. Preimage and hash for a fixed account, revision, key and chain identity; a valid proof set; rejections for a missing proof, proofs out of order, wrong account, wrong revision, wrong chain instance and a proof recovering to a different key.
  • Policy revision. 1-of-[A,B] changed to 1-of-[A,C]: a transaction and a TokenPermit signed by A at the old revision reject, and the same signed at the new revision accept. A→B→A: signatures from the first A period reject. A transaction withheld before CompleteRecovery, with a fresh nonce, rejects after it. A SetRecovery does not change the policy revision and does not revoke signatures.
  • Session vouchers. (Computed in §8.1.) The revised Voucher type string, typehash and final digest for a fixed domain. A voucher signed by session.signer where the signer differs from the payer accepts, and the payer’s signature on the same session rejects. 1-of-[A,B] changed to 1-of-[A,C]: a voucher signed by retained key A at the old revision rejects at settlement, and A’s voucher at the new revision accepts. A→B→A: a voucher from the first A period rejects. A voucher whose account differs from the attributed account rejects. The same voucher digest under the Trading Post verifyingContract does not verify as a Session voucher.
  • Per-call receipts. The extended Voucher digest under the Trading Post verifyingContract for a fixed hire; the hirer’s 2-of-3 receipt with two slots accepts and with one rejects; a receipt signed by retained key A at the old policy revision rejects after 1-of-[A,B] changes to 1-of-[A,C].
  • Route session reports. A report with account = 0 and policy_revision = 0 signed by the bound metering key accepts, and the payer’s policy change has no effect on it.
  • Satisfaction. For an AddressDerived account and for 1-of-1 and 2-of-3 policies, in both signature modes: exactly threshold valid entries accept; fewer or more entries reject; an out-of-range index, a repeated or descending index, and a slot recovering to a key other than keys[key_index] reject; slots signed by the address-derived key of an account that changed policy reject; a transaction over MAX_TX_SIGNATURE_SLOTS rejects before recovery.
  • Same-block ordering. SetAuthPolicy then a transaction signed under the old policy in one block: the second is an admission rejection with no nonce, fee or marker effect.
  • Recovery lifecycle. Configure, initiate, complete at exactly executable_at_ms (accept) and 1 ms before (reject); cancel before and after executable_at_ms; initiation while pending; initiation from a non-recovery account; SetAuthPolicy and SetRecovery each canceling a pending recovery; the recovery account changing its own policy mid-delay and then completing.
  • Guardians. A 2-of-3 recovery account initiates and completes with two guardian signatures and is rejected with one.
  • Bounds and roles. Delays at MIN - 1, MIN, MAX, MAX + 1; recovery account equal to self or missing from additional_signers.
  • Revision. Every revision-carrying instruction with a stale expected_revision; CancelRecovery racing a fresh initiation.
  • Signed objects. A DelegationCert and a TokenPermit signed before a policy change reject after it; the same objects re-signed under the new policy accept; a 2-of-3 TokenPermit with two slots accepts and with one rejects; an object naming account A does not verify for account B that shares a key.
  • Mempool. A transaction declaring an AddressDerived account but signed by another key is refused at ingress.
  • RPC. An account with no row returns AddressDerived with no key; after SetRecovery it returns Stored with the key recovered from that transaction.
  • ActorOwner. After recovery, the new policy signs TransferActorOwnership and SetWebAccess for an actor owned by the account; the old key cannot.

9. Status

Implemented on devnet, including off-chain resolution through the finalized-state proof for CBFS owner authorization and runner storage grants. Not built yet:
  • Mempool re-check and eviction when an AccountAuth row changes (§5.6).
  • AccountSignature for the sponsor, which waits on CIP-40 sponsorship.
  • Proof-based policy resolution in every off-chain verifier. Gateway payments and CBFS and runner paid reads use the unproven GET read; the runner’s off-chain voucher check accepts only AddressDerived signers and fails closed for any other policy. The CIP-37 gateway wallet login, which resolves through the finalized-state proof, is not on devnet yet.
  • AccountSignature for the address-authorized objects listed in §3.
  • Reuse of already-recovered keys when SetRecovery makes an AddressDerived row explicit, and in the admission check for pre-verified transactions; both currently recover the signatures again without a metering charge, bounded by MAX_TX_SIGNATURE_SLOTS.
  • CIP-49 instruction builders in the wasm and SDK packages.
  • The wallet and Dashboard pending-recovery warning, cancel flow, delegation re-issue and key-backup refresh.

10. References

  • CIP-40 §7.3, §7.6, §7.7, §12.1, auth v3 (transaction verification, signed objects, tagged keys, wallet identity)
  • CIP-37 §7.1.1 (protocol ActorOwner)
  • CIP-41 (multi-signer authorization, co-signing flow)
  • CIP-3 §2.5 (signature metering)
  • CIP-43 (signed-transaction envelope accesses)
  • CIP-4 §4.2 (state prefixes)