> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cowboy.inc/llms.txt
> Use this file to discover all available pages before exploring further.

# CIP-49: Account Authorization Policy and Recovery

> Decouples an account's address from its keys. The account stores an authorization policy on chain (one key or k-of-n keys), replaces it with SetAuthPolicy, and can register a recovery account that replaces the policy after a cancellable delay.

> [!NOTE]
>
> **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)

> [!NOTE]
>
> **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:

```
AccountAuth {
  // wire prefix: version byte 1 (not a struct field)
  revision:        u64,                  // every change to the row
  policy_revision: u64,                  // policy changes only
  policy:          AuthPolicy,
  recovery: Option<RecoveryConfig>,
  pending:  Option<PendingRecovery>,
}

AuthPolicy {
  threshold: u8,                         // 1 <= threshold <= keys.len()
  keys:      Vec<SigningPublicKey>,      // 1..=MAX_POLICY_KEYS, CIP-40 §7.7 wire form
}

RecoveryConfig {
  account:    Address,                   // recovery account
  delay_secs: u64,
}

PendingRecovery {
  new_policy:       AuthPolicy,
  executable_at_ms: u64,                 // initiating block timestamp + delay_secs * 1000
}
```

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.

```
AccountPolicyView = AddressDerived                              // no row
                  | Stored { policy_revision: u64, policy: AuthPolicy }

account_policy(A) = Stored { AccountAuth[A].policy_revision, AccountAuth[A].policy }  if the row exists
                  = AddressDerived                                                     otherwise
```

`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`:

```
AccountSignature {
  policy_revision: u64,
  entries: Vec<(key_index: u8, slot: SignatureSlot)>,
}
```

`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:

| Object | `verifyingContract` | Signer | CIP-49 treatment |
| - | - | - | - |
| Session voucher | Session actor | `session.signer` (account; the payer by default, or the signer `SessionOpenAssigned` names) | account-signed |
| Per-call receipt | Trading Post | hirer (account) | account-signed |
| Route session report | Session actor | the session binding's `metering_public_key` | not account-signed; unchanged |

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:

```
Voucher(bytes32 session_id,uint128 cumulative_amount,uint64 nonce,uint64 expires_at,bytes32 usage_digest,address account,uint64 policy_revision)
```

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.

| Opcode | Name | Sender (`from`) | Arguments |
| - | - | - | - |
| 233 | `SetAuthPolicy` | the account | `expected_revision: u64`, `policy: AuthPolicy`, `key_proofs: Vec<SignatureSlot>` |
| 234 | `SetRecovery` | the account | `expected_revision: u64`, `recovery: Option<RecoveryConfig>` |
| 235 | `InitiateRecovery` | the recovery account | `account: Address`, `expected_revision: u64`, `policy: AuthPolicy`, `key_proofs: Vec<SignatureSlot>` |
| 236 | `CancelRecovery` | the account | none |
| 237 | `CompleteRecovery` | the recovery account | `account: Address`, `expected_revision: u64` |

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:

| Error | Code | Raised when |
| - | - | - |
| `AccountPolicyUnsatisfied` | 1016 | a signer's `AccountSignature` does not satisfy its current policy (admission rejection, §5.6) |
| `AuthRevisionMismatch` | 1017 | `expected_revision` is not the current auth revision |
| `InvalidAuthPolicy` | 1018 | the policy shape is invalid, or a key fails point validation |
| `InvalidKeyProof` | 1019 | the number of key proofs differs from the number of keys, or a proof does not recover to its key |
| `UnsupportedKeyAlgorithm` | 1020 | a key's algorithm is outside `TX_SLOT_POLICY` |
| `InvalidRecoveryAccount` | 1021 | the recovery account is the account itself or did not co-sign |
| `DelayOutOfRange` | 1022 | `delay_secs` is outside the bounds, or `executable_at_ms` would overflow |
| `NotRecoveryAccount` | 1023 | the sender is not the configured recovery account |
| `RecoveryPending` | 1025 | a recovery is already pending |
| `NoPendingRecovery` | 1026 | no recovery is pending |
| `RecoveryNotReady` | 1027 | the block timestamp is before `executable_at_ms` |

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 `DelegationCert`s. 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.

| Event | Fields |
| - | - |
| `AuthPolicyChanged` | `account`, `revision`, `policy`, `cause: Set \| Recovery` |
| `RecoveryConfigured` | `account`, `revision`, `recovery_account: Option<Address>`, `delay_secs` |
| `RecoveryInitiated` | `account`, `revision`, `recovery_account`, `new_policy`, `executable_at_ms` |
| `RecoveryCanceled` | `account`, `revision`, `reason: Owner \| PolicyChanged \| Reconfigured` |

`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 `AccountSignature`s (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

| Constant | Value | Unit | Adjustable |
| - | - | - | - |
| `MAX_POLICY_KEYS` | 16 | keys | no |
| `MAX_TX_SIGNATURE_SLOTS` | 32 | slots per transaction | no |
| `MIN_RECOVERY_DELAY_SECS` | 86 400 (1 day) | seconds | no |
| `MAX_RECOVERY_DELAY_SECS` | 2 592 000 (30 days) | seconds | no |

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 `SigningPublicKey`s 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`](vectors/cip49-account-auth-v1.json) is produced by [`vectors/gen_cip49_vectors.py`](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.

| Value | Hex |
| - | - |
| `keccak256(EIP712Domain type)` | `0xd87cd6ef79d4e2b95e15ce8abf732db51ec771f1ca2edccf22a46c729ac56472` |
| extended `Voucher` typehash | `0x0529bafcd16531eb10a19c913199bc8ab0210a15c51b0073b3d4f8590c7df840` |
| domain separator, Session actor (`0x…0c`) | `0x19d92930dac2373e8934fd3130facfa8251fe5158eb2119dd36993b960411156` |
| domain separator, Trading Post (`0x…1e`) | `0x2d327936e470899c65cdbed093602ddb93c4d15b24b316c5c004867f309530d9` |
| key proof, key B, auth revision 0 (hash) | `0x0555f5aa1149df6f16fa9e0037ebf215d2f6b5dece3f29626b643799d8b44c42` |
| `TokenPermit` by K0 at policy revision 0 (hash) | `0xdbc17660bd3cd6e6ab9c82f576781d41929c24c633c2280eed1e0260734af7a8` |
| `TokenPermit` by A at policy revision 1 / 2 (hash) | `0xd9c0768ee55a7a4a028bacf523efc7aa3b0208f1d22f5fe21bcbdea14938cb9f` / `0xeda05e0009d0f5a0c456d09f37579e31f5165f2a19a91eb303293fabb66e40bd` |

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):

| Case | Digest | Expected |
| - | - | - |
| `address-derived` | `0x9e954790c7bfd29e61f118d318db796a11b81a33b80f8a6bc88dff3e1804c03b` | K0 at rev 0; accepts while `AddressDerived`, rejects from rev 1 |
| `retained-key-old-revision` / `aba-first-period` | `0x13213f4e671842a30e56ad94444dc05a0cf4e6779e7345dced38543e75f3128d` | A at rev 1; accepts under 1-of-\[A,B] (retained\_key) or 1-of-\[A] (aba); rejects at retained\_key rev 2 and at aba rev 3 |
| `retained-key-new-revision` | `0xc20ab487364f0417f8d8f30c961137ef2c92a7370b0e53908d4407ac04f55867` | A at rev 2; accepts under 1-of-\[A,C] |
| `aba-second-period` | `0x58482c8559549e5790d18c187801b048bf3d6678355b2c160054484d8025f129` | A at rev 3; accepts under 1-of-\[A] |
| `two-of-three` | `0x2243a0be30d76509ed21eb5e320174a58f8f2988eddd86e8dc31359f1876582e` | C and A (indices 1, 2) at rev 1; accepts under 2-of-3; one entry rejects |
| `designated-signer` | `0x1f9b1d35b6a4eedebd4d1e6ba3d81470b55d7946ca052108bb019eb66f75005d` | `account = session.signer` ≠ payer, both `AddressDerived` at rev 0; accepts |
| `payer-instead-of-designated-signer` | `0xf6c1bdaa767b40372b505d4711597b37bfbbff6eacb1969b8b42eaef67071af7` | signed by the payer with `account = payer`; rejects (not `session.signer`) |
| `per-call-receipt` | `0xa4ea3e8f3b38af35827d6106a9e651a94f7e63afe52f6bc696d379c9db4d03b0` | hirer A at retained\_key rev 2 under 1-of-\[A,C], Trading Post domain; accepts; never verifies under the Session domain |
| `route-session-report` | `0x3419707f2d494a2d3d5708eb7e847a96b74aa25722842560bf448bae4b4494de` | `account = 0`, `policy_revision = 0`, metering key B; accepts |

### 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)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.