diff --git a/.changelog/native-multisig.md b/.changelog/native-multisig.md new file mode 100644 index 0000000000..2ae75822e2 --- /dev/null +++ b/.changelog/native-multisig.md @@ -0,0 +1,11 @@ +--- +tempo-alloy: minor +tempo-contracts: minor +tempo-precompiles: minor +tempo-primitives: minor +tempo-evm: minor +tempo-revm: minor +tempo-node: minor +--- + +Activates native multisig accounts at T11, including a new multisig precompile, signature-carried `InitMultisig` bootstrap configs, and `MultisigSignature` validation across the EVM, transaction pool, and RPC layers. Native 1-of-1 secp256k1 multisigs pay an 8,400 gas authorization surcharge over equivalent primitive secp256k1 transactions. diff --git a/tips/tip-1061.md b/tips/tip-1061.md new file mode 100644 index 0000000000..0b4f41a2a3 --- /dev/null +++ b/tips/tip-1061.md @@ -0,0 +1,1068 @@ +--- +id: TIP-1061 +title: Configurable Accounts +description: Adds configurable accounts with weighted owners, stable addresses, nested owner signatures, and account keychain support. +authors: Jake Moxey (@jxom), Tanishk Goyal (@legion2002) +status: Draft +related: TIP-0001, TIP-1011, TIP-1020, TIP-1049, TIP-1053 +protocolVersion: T11 +--- + +# TIP-1061: Configurable Accounts + +## Abstract + +Configurable accounts can change who controls them and their configuration without changing their identity. They support multisig control, recovery, key rotation, and permissioned access (via access keys) while remaining the same Tempo account to applications and counterparties. + +This TIP implements configurable accounts as native multisig accounts. Protocol identifiers, signatures, and ABI names use the term `multisig`. + +## Motivation + +A team should be able to change its members or control policy without replacing the account, and a lost or compromised key should not require moving assets to a new address. + +Today, these needs typically require a contract wallet or external account abstraction system. Configurable accounts make them part of Tempo's account keychain model, giving wallets and applications one consistent way to serve individuals, teams, treasuries, validators, and institutions. + +## Assumptions + +- T11 activates consensus acceptance and validation of multisig signatures, the native multisig precompile, and the signed key authorization changes together. +- The existing Tempo transaction, key authorization, and sponsorship digests bind the fields documented by their respective TIPs. Multisig authorization inherits those digest guarantees and adds the account and configuration version bindings defined below. +- Only protocol bootstrap and the native multisig precompile can modify multisig configuration storage. +- Account keychain validation continues to enforce access-key expiry, scope, spending limits, and admin permissions. Multisig authorization does not weaken those restrictions. + +## Threat Model + +- Transaction submitters are untrusted and may supply malformed, oversized, deeply nested, cyclic, duplicate, stale, or insufficient owner data. Decoding and validation limits MUST bound work before rejecting these transactions. +- Owners may be unavailable, compromised, or malicious. Only the current configuration's threshold is trusted; no individual owner receives authority beyond its configured weight. +- Nested multisig owners are untrusted authorization principals. Nesting depth, ordering, membership, cycle, and per-node threshold checks prevent them from bypassing the parent configuration. +- Access keys and fee payers are untrusted delegates, not owners. Access keys remain subject to account keychain restrictions and cannot update the owner configuration; fee sponsorship grants no account authority. +- Attackers may choose salts, owner sets, and primitive keys while searching for address collisions. Reserved namespace outputs are rejected. Finding an EOA key and multisig input with the same 160-bit address, with generic work of approximately 2^80, is outside this TIP's security scope. +- RPC simulators and transaction pools may use stale or incomplete configuration data. Their results are advisory; consensus validation MUST use the configuration and version at the transaction's block position, and pools MUST revalidate affected transactions after configuration changes. + +--- + +# Specification + +## Types and Limits + +```rust +/// Tempo signature type byte for multisig account signatures. +pub const SIGNATURE_TYPE_MULTISIG: u8 = 0x05; + +/// Domain prefix for multisig account address derivation. +pub const MULTISIG_ACCOUNT_DOMAIN: &[u8] = b"tempo:multisig:account"; + +/// Domain prefix for multisig account owner signatures. +pub const MULTISIG_SIGNATURE_DOMAIN: &[u8] = b"tempo:multisig:signature"; + +/// Maximum owner count that leaves room for fresh nonce creation and transaction overhead. +pub const MAX_MULTISIG_OWNERS: usize = 48; + +/// Maximum threshold for one account configuration. +pub const MAX_MULTISIG_THRESHOLD: u8 = u8::MAX; + +/// Maximum number of owner signatures in one multisig account signature. +pub const MAX_MULTISIG_SIGNATURES: usize = 8; + +/// Maximum number of multisig account signatures in one nested authorization path. +pub const MAX_MULTISIG_NESTING_DEPTH: usize = 2; + +/// Maximum encoded byte length of one primitive owner signature. +pub const MAX_MULTISIG_OWNER_SIGNATURE_BYTES: usize = + 1 + MAX_WEBAUTHN_SIGNATURE_LENGTH; + +/// Weighted owner in an account configuration. +pub struct MultisigOwner { + /// Address recovered from a primitive owner signature or named by a nested multisig account signature. + pub owner: Address, + + /// Nonzero weight contributed by this owner. + pub weight: u8, +} + +/// Initial owner configuration used to derive and bootstrap an account. +pub struct InitMultisig { + /// Caller-chosen value that permits distinct accounts with otherwise identical configurations. + pub salt: B256, + + /// Minimum total owner weight required for authorization. + pub threshold: u8, + + /// Strictly ascending weighted owners. + pub owners: Vec, +} + +/// Signature payload for a multisig account. +pub enum MultisigSignature { + /// Carries the initial configuration that derives the account. + Bootstrap { + /// Initial owner configuration. + init: InitMultisig, + + /// Ordered primitive or nested multisig account owner signatures. + signatures: Vec, + }, + + /// Carries the account directly. + Initialized { + /// Account authorized by the owner signatures. + account: Address, + + /// Ordered primitive or nested multisig account owner signatures. + signatures: Vec, + }, +} +``` + +An account may store up to 48 owners so a worst-case bootstrap, including complete validation of eight depth-2 registered-owner configurations and eight maximum-size WebAuthn signatures in each, fits within the transaction gas cap while leaving room for fresh nonce creation and transaction overhead. Each multisig account signature may contain at most 8 owner signatures, and primitive owner signatures have a byte limit. Nested multisig account owner signatures are limited to one level: the outer account is depth 1 and the nested owner account is depth 2. These limits bound transaction size and recursive signature-verification work. + +Rules: + +- Configurations MUST contain 1 to 48 unique, nonzero, address-sorted owners. Weights MUST be nonzero and total at most 255. +- A configuration MUST NOT include the multisig account itself as an owner. +- Threshold MUST be nonzero and reachable by at most `MAX_MULTISIG_SIGNATURES` owners. Equivalently, it MUST NOT exceed the sum of the eight highest owner weights. +- Each owner signature MUST be a `TempoSignature::Primitive` or `TempoSignature::Multisig`. +- Primitive owner signatures MUST NOT exceed `MAX_MULTISIG_OWNER_SIGNATURE_BYTES`; nested owner signatures are bounded recursively by the signature-count and nesting-depth limits. + +## Signature Encoding + +Signature type `0x05` has two forms: + +- **Bootstrap.** Used to establish a multisig account's initial configuration during its first transaction. +- **Initialized.** Used after bootstrap, when authorization can use the account's stored configuration. + +Rules: + +- Exactly 65 signature bytes MUST decode as secp256k1 regardless of the first byte. Type `0x05` MUST be considered only at other lengths. +- Transactions carrying type `0x05` MUST be rejected before T11. Generic wire-format decoders MAY parse the type without hardfork context, but consensus validation MUST NOT accept it before T11. +- `signatures` MUST contain 1 to `MAX_MULTISIG_SIGNATURES` owner signatures, each using the existing `TempoSignature` byte encoding. Decoders MUST reject an empty list before intrinsic gas is computed. +- Decoders MUST reject malformed encodings, trailing fields, limit violations, and excessive nesting. + +### Bootstrap + +The bootstrap form establishes a multisig account during its first transaction. It can authorize the transaction directly or authorize an access key that signs that transaction. + +```text +0x05 || rlp([init, signatures]) + +init = rlp([salt, threshold, owners]) +owner = rlp([owner, weight]) +``` + +Rules: + +- A bootstrap signature MUST use `0x05 || rlp([init, signatures])` and derive its account from `init`. +- A top-level bootstrap MUST carry this encoding in either the outer signature or its key authorization signature, but not both. + +### Initialized + +The initialized form is used once the account configuration exists. It identifies the account so validation can authorize owner signatures against the current stored configuration. + +```text +0x05 || rlp([account, signatures]) +``` + +Rules: + +- An initialized signature MUST use `0x05 || rlp([account, signatures])`. +- Nested owner signatures MUST use the initialized encoding. +- Key authorization signatures MUST use initialized encoding unless the key authorization supplies `init` for a top-level bootstrap. + +## Account Identity + +The initial owner configuration and salt establish an account identity that remains stable across later owner changes. The salt lets identical owner configurations derive distinct accounts: + +```text +multisig_address = address(keccak256( + "tempo:multisig:account" || + salt || + uint8(threshold) || + uint8(owners.len()) || + owners[0].owner || uint8(owners[0].weight) || + ... +)[12:32]) +``` + +Rules: + +- Derivation MUST use the formula exactly: ASCII domain, one-byte integers, raw concatenation, and no chain ID, RLP, or ABI encoding. +- The derived address MUST be nonzero and outside virtual, active-precompile, TIP-20, and ZonePortal namespaces. +- Configuration updates MUST replace the current threshold and owners without changing the account address. + +## Authorization + +Owners approve: + +```text +multisig_digest = keccak256( + "tempo:multisig:signature" || + inner_digest || + account || + uint64(config_version) +) +``` + +The account binding prevents an owner signature from being reused for another multisig account or as an ordinary primitive signature. The configuration version prevents an owner signature from becoming valid again after later owner updates. + +Rules: + +- The digest MUST encode `config_version` as an eight-byte, big-endian integer. Bootstrap authorization MUST use version `0`; initialized authorization MUST use the current stored version. +- `inner_digest` MUST be `tx.signature_hash()` when direct or the parent's multisig account digest when nested. +- Owner signatures MUST be owner-ordered, belong to the applicable configuration, and reach threshold on the final item. +- Validation MUST reject missing quorum, duplicate or unsorted owners, cycles, and any owner signature submitted after quorum is reached. +- Stateless recovery MUST validate the shape and return the claimed or derived account, without proving membership or quorum. +- Stateful validation MUST complete before the account is treated as an authorized sender. + +## Transaction Execution + +Bootstrap transactions establish account state before executing their call batch. Later transactions use the stored owner configuration and behave as ordinary Tempo transactions from that account. + +### Bootstrap + +A bootstrap transaction is the account's first transaction. It derives the account from its initial owner configuration and establishes that configuration before its calls execute, whether the quorum or a newly authorized access key signs the transaction. + +The example assumes `alice_address < bob_address`, each owner has weight 1, and the threshold is 2. + +```text +// Define the initial owner configuration. +// Ref: Types and Limits +init = multisig_init( + salt = salt, + threshold = 2, + owners = [ + [alice_address, 1], + [bob_address, 1] + ] +) + +// Ref: Account Identity formula +account = multisig_address + +// Build the account's first transaction. +tx = transaction( + from = account, + calls = [...] +) + +// Sign the account-bound transaction digest. +// Ref: Authorization formula +digest = multisig_digest( + tx.signature_hash(), + account, + 0 +) + +alice_signature = sign(alice_key, digest) +bob_signature = sign(bob_key, digest) + +// Attach the bootstrap signature containing the initial configuration. +tx.signature = + 0x05 || rlp([ + init, + [alice_signature, bob_signature] + ]) +``` + +After validation, the account is available to the transaction's call batch. Initialization and nonce consumption are transaction-level effects rather than EVM call effects. + +Rules: + +- Bootstrap MUST target the account derived from `init`, without an existing account header. +- `init` MUST be carried by either the outer signature or `key_authorization.signature`. +- When `key_authorization.signature` carries `init`, the outer V2 account keychain signature MUST use the key authorized by that same key authorization. +- The target MUST have a zero protocol nonce. +- Prior 2D or expiring-nonce activity MUST NOT affect bootstrap eligibility. +- The target MAY already have a balance or storage. +- The target MUST have empty code and no EIP-7702 delegation. +- When bootstrap validation succeeds, the protocol MUST store the header and owner rows, emit `MultisigInitialized(account)`, and consume the nonce. These effects MUST survive later call reverts. +- Failed bootstrap validation MUST write no account state and consume no nonce. +- `updateConfig` MUST reject same-transaction bootstrap accounts. + +### Initialized + +An initialized transaction uses the account configuration at its block position to authorize the full call batch. Configuration updates are visible to later calls in the batch but do not change that transaction's authorization. + +```text +// Build a transaction from the initialized account. +tx = transaction( + from = account, + calls = [...] +) + +// Sign the account-bound transaction digest. +// Ref: Authorization formula +digest = multisig_digest( + tx.signature_hash(), + account, + config.version +) + +alice_signature = sign(alice_key, digest) +bob_signature = sign(bob_key, digest) + +// Attach the initialized signature containing the account address. +tx.signature = + 0x05 || rlp([ + account, + [alice_signature, bob_signature] + ]) +``` + +Rules: + +- Direct transactions MUST use initialized encoding and the block-position owner configuration. +- `tx.from`, `tx.origin`, and top-level `msg.sender` MUST equal the account; one authorization MUST cover the call batch. +- Configuration updates MUST be immediately visible to later calls in the batch. If the batch succeeds, its final update MUST be stored and affect authorization only for later transactions. +- Multisig accounts MUST have no EVM code or EIP-7702 delegation and MUST NOT be authorization-list authorities. +- Authorization lists MUST reject bare multisig account signatures. +- Primitive authorization-list signatures authenticate their authority directly and MUST NOT require a multisig-registry read. Address collisions with derived multisig accounts are outside this TIP's security scope. +- Keychain authorization-list signatures authenticate an access key for a claimed root account. The root MUST be checked against the multisig registry and rejected if registered. +- Subblock transactions MUST NOT use multisig outer or key authorization signatures. +- Sponsorship MUST use existing sponsored signing hashes. +- Pools MAY index by stateless recovery but MUST revalidate affected authorizations after initialization or owner updates, including transactions that name a newly initialized account in a multisig-restricted role. + +## Storage + +The multisig account precompile stores a compact header, ordered owner rows for enumeration, and direct owner-weight rows for authorization. `pad32` encodes an address or unsigned integer as a 32-byte, big-endian, left-zero-padded value. `mapping_slot` returns the Keccak-256 result as a big-endian `uint256`. + +```text +mapping_slot(key, base) = keccak256(pad32(key) || pad32(base)) + +header_slot(account) = mapping_slot(account, 0) +owner_root(account) = mapping_slot(account, 1) +owner_slot(account, index) = mapping_slot(uint32(index), owner_root(account)) +weight_root(account) = mapping_slot(account, 2) +weight_slot(account, owner) = mapping_slot(owner, weight_root(account)) + +storage[header_slot(account)] = + uint256(threshold) | + uint256(owner_count) << 8 | + uint256(config_version) << 16 + +storage[owner_slot(account, index)] = + uint256(uint160(owner)) | + uint256(weight) << 160 + +storage[weight_slot(account, owner)] = uint256(weight) +``` + +Rules: + +- The header, ordered owners, and direct weights MUST use mapping base slots `0`, `1`, and `2`, respectively, with the key order above. +- Ordered owner rows MUST use zero-based `uint32` indices satisfying `0 <= index < owner_count`. +- Unused bits in every stored word MUST be zero. A zero header word MUST mean uninitialized; a nonzero header with a zero threshold, zero owner count, zero version, nonzero unused bits, or out-of-range value MUST be rejected as `InvalidConfig`. +- Bootstrap authorization MUST use version `0`, but bootstrap MUST store configuration version `1`. Every successful `updateConfig` MUST increment the stored version by one and MUST revert with `InvalidConfig` on `uint64` overflow. +- Ordered owner rows and direct weight rows MUST encode the same configuration. +- Replacing a configuration MUST clear stale ordered rows and direct owner-weight rows. + +## Native Multisig Precompile + +A dedicated precompile exposes account derivation, detection, configuration reads, and owner updates: + +```solidity +interface INativeMultisig { + struct MultisigOwner { + address owner; + uint8 weight; + } + + struct MultisigConfig { + uint64 version; + uint8 threshold; + MultisigOwner[] owners; + } + + /// Derives an account address from an initial configuration. + /// Reverts when the configuration or derived address is invalid. + function deriveAccount( + bytes32 salt, + uint8 threshold, + MultisigOwner[] calldata owners + ) external pure returns (address account); + + /// Returns whether account has a complete multisig account header. + /// Reverts with InvalidConfig for a partial header. + function isMultisigAccount(address account) external view returns (bool); + + /// Returns account's current configuration. + /// Reverts with NotMultisigAccount or InvalidConfig. + function getConfig( + address account + ) external view returns (MultisigConfig memory); + + /// Replaces msg.sender's current configuration. + /// Requires direct current-quorum authorization and a top-level batch call. + function updateConfig( + uint8 threshold, + MultisigOwner[] calldata owners + ) external; + + event MultisigInitialized(address indexed account); + event MultisigConfigUpdated( + address indexed account, + uint8 threshold, + MultisigOwner[] owners + ); + + error NotMultisigAccount(); + error InvalidAccount(); + error InvalidConfig(); + error InvalidThreshold(); + error InvalidOwner(); + error InvalidWeight(); + error TooManyOwners(); + error DuplicateOwner(); + error InvalidOwnerOrder(); + error AccountAlreadyInitialized(); + error UnauthorizedCaller(); + error SameTransactionUpdateNotAllowed(); +} +``` + +Configuration updates are authorized by the account's current owner quorum. + +Rules: + +- The precompile MUST be deployed at `0xAACC000000000000000000000000000000000000` from T11. +- `deriveAccount` MUST use the account identity formula above and enforce the same configuration and address constraints as bootstrap, without reading account state. +- `isMultisigAccount` MUST revert with `InvalidConfig` for partial headers. +- `getConfig` MUST return the current version, threshold, and owners or revert with `NotMultisigAccount` or `InvalidConfig`. +- `updateConfig` MUST be a direct top-level call with `msg.sender == tx.origin` and a zero keychain transaction key. +- It MUST require a complete header and valid configuration, and reject same-transaction bootstrap accounts. +- `updateConfig` MUST NOT accept a separate authorization signature. +- Configuration validation MUST return the first applicable error in this order: + - `InvalidOwner` for an empty owner list. + - `TooManyOwners` above `MAX_MULTISIG_OWNERS`. + - `InvalidThreshold` when the threshold is zero. + - For each owner: `InvalidOwner` for the zero address or the multisig account itself, `InvalidWeight` for zero weight, `DuplicateOwner` when equal to the previous owner, or `InvalidOwnerOrder` when below it. + - `InvalidWeight` when the weight sum overflows or exceeds `u8::MAX`. + - `InvalidThreshold` when the threshold exceeds the weight reachable from the eight highest-weight owners. +- Address derivation and bootstrap MUST return `InvalidAccount` when the derived address is zero or reserved. Bootstrap MUST return `AccountAlreadyInitialized` when the account already has a complete header. +- `getConfig` and `updateConfig` MUST return `NotMultisigAccount` for an absent header. `isMultisigAccount`, `getConfig`, and `updateConfig` MUST return `InvalidConfig` for a partial header. +- `updateConfig` MUST return `UnauthorizedCaller` for an indirect call, `msg.sender != tx.origin`, or a nonzero keychain transaction key, and `SameTransactionUpdateNotAllowed` for an account bootstrapped in the same transaction. + +## Account Keychain + +### Access Keys on Multisig Accounts + +A multisig account can delegate ordinary or admin authority through the existing account keychain. The owner quorum can register an access key directly or through transaction-level key authorization, including while bootstrapping the account. + +The owner quorum authorizes a key authorization with this digest: + +```text +multisig_digest(key_authorization.signature_hash(), account, config_version) +``` + +#### Signed Key Authorization Encoding + +At T11, `SignedKeyAuthorization.signature` expands from `PrimitiveSignature` to `TempoSignature`. Its position and RLP representation remain unchanged, so existing primitive key authorizations remain byte-identical. + +```text +signed_key_authorization = rlp([ + key_authorization, + bytes(tempo_signature) +]) +``` + +The second field is one RLP byte string containing the existing byte encoding of a `TempoSignature`. + +Rules: + +- Before T11, the field MUST decode as `PrimitiveSignature`; multisig and keychain signatures MUST be rejected. +- At and after T11, the field MUST decode as `TempoSignature::Primitive` or `TempoSignature::Multisig`; `TempoSignature::Keychain` MUST be rejected. +- Multisig signatures MUST follow the bootstrap and initialized context rules above. +- Decoders MUST reject a list-valued signature field, malformed signature bytes, and trailing fields. + +An active access key for a multisig account uses the existing V2 account keychain envelope: + +```text +0x04 || user_address || access_key_signature +``` + +The access key signs the existing V2 account keychain digest, and the transaction executes as `user_address` under the key's stored restrictions. + +Rules: + +- Quorums MAY add ordinary or admin access keys directly or via `key_authorization`, including at bootstrap; existing key rules MUST apply. +- A registered multisig account MUST NOT be newly registered as an access key for another account. A pre-existing access-key row whose key ID later bootstraps as a multisig account cannot be exercised with a multisig signature and MAY remain stored. +- When a multisig account's owners authorize a key for that account, `key_authorization.account` MUST equal the multisig account address. +- The multisig signature MUST carry the same address in initialized encoding or derive it from `init` in bootstrap encoding, and MUST use the digest above. +- A bootstrap key authorization MAY carry `init`; validation MUST establish the account and register the key atomically. +- When the outer signature carries `init`, an accompanying key authorization signature MUST use initialized encoding and validate against that initial configuration. +- Authorize-and-use MAY occur in one transaction, including bootstrap, when the outer signature uses the new key. +- Access key transactions MUST use the V2 envelope, execute as the account under stored restrictions, and skip the parent quorum. +- Stateless TIP-1020 `recover` and `verify` MUST reject bare multisig account signatures. +- Access keys MUST NOT call `updateConfig` for their parent. +- Only a direct multisig account signature with a zero keychain transaction key MAY replace the parent's owner set. + +## Gas + +Multisig account authorization is charged as regular intrinsic gas. Costs scale with submitted owner signatures and nested account nodes: + +```text +node(signature) = + 2,100 + + 2,100 * signature.signatures.len() + + sum(full_primitive_cost(owner) or 2,600 + node(nested_owner)) + +registered_config_validation(config) = + 4,200 * config.owners.len() + +full_primitive_cost(signature) = + secp256k1: 3,000 + P256: 8,000 + WebAuthn: 8,000 + calldata_gas(webauthn_data) + +direct_multisig_surcharge = + node(outer_signature) + + sum(registered_config_validation(config) for each registered node) + - 3,000 + +key_authorization_multisig_surcharge = + node(key_authorization.signature) + + sum(registered_config_validation(config) for each registered node) + +registry_gating_surcharge = + gas consumed by required multisig-registry reads for: + - each newly authorized access-key ID + - each keychain authorization-list authority + - a keychain caller with code or delegation + +bootstrap_storage(config) = + active_sstore_set(warm_header) + + 2 * config.owners.len() * active_sstore_set(cold_slot) + + 2 * warm_storage_read_cost + + 375 + 2 * 375 +``` + +Each registered configuration is validated by reading every ordered owner row and direct weight row before its threshold and version are used. The validation term charges those two reads per configured owner. Bootstrap nodes carry their configuration inline and do not add this term. + +Each nested node adds one cold account access for checking that the nested multisig owner has no bytecode or EIP-7702 delegation. Implementations only need the account's code hash and MUST NOT load its bytecode for this check. + +The direct subtraction accounts for the secp256k1 transaction signature already included in ordinary intrinsic gas. A registered 1-of-1 secp256k1 multisig account therefore adds 8,400 gas; a registered 2-of-2 adds 17,700 gas. + +When a signed key authorization uses a multisig signature, its existing signature-verification term uses `key_authorization_multisig_surcharge`. It is charged independently of the outer signature and receives no 3,000 gas subtraction. + +Bootstrap also creates one packed header, one ordered row per owner, and one direct weight row per owner. The header is warm after the registration check; the newly created owner rows and direct weight rows are charged as cold. Bootstrap additionally charges the repeated warm header read, the warm transient bootstrap guard write, and the exact LOG2/no-data cost of `MultisigInitialized`. + +Registry gating checks a newly authorized access-key ID because that address does not sign, and a keychain authorization-list authority because the signature authenticates its access key rather than the claimed root. It also checks a keychain caller with code or delegation to enforce the multisig no-code invariant. Explicit fee payers and primitive authorization-list authorities are recovered directly from primitive signatures and do not require registry reads under this TIP's address-collision assumption. Successful checks use the active precompile storage schedule, including warm pricing when a prior check read the same header. + +Rules: + +- Intrinsic gas MUST use the formulas above. +- Non-subblock transactions MUST validate fee affordability before verifying multisig owner approvals. +- State-dependent intrinsic gas MUST be computed from registry headers and checked against `gas_limit` before complete configuration loading or owner-signature verification. +- Every registered multisig account node MUST add `registered_config_validation` for its complete stored configuration, including nested nodes and RPC simulation. +- A multisig key authorization MUST add `key_authorization_multisig_surcharge` independently of any outer multisig surcharge. +- The 3,000 gas subtraction MUST apply once at the outer account node, never to nested nodes or key authorization sidecars. +- Direct multisig authorization MUST NOT add the account keychain's 900 gas processing buffer. +- Bootstrap MUST charge one warm header SSTORE and `2 * owners.len()` cold SSTOREs, including the active TIP-1060 creditable portion, plus its warm header read, transient guard write, and `MultisigInitialized` event. +- State-dependent role restrictions MUST add `registry_gating_surcharge` to intrinsic gas. +- Precompile reads and updates MUST use the active precompile storage schedule. + +## Tooling + +Wallets, SDKs, RPCs, and indexers need to expose multisig accounts across address derivation, signing, transaction decoding, and configuration history. + +Rules: + +- Tooling MUST support the T11 signature encodings, signed key authorization shape, current configuration version, and precompile ABI and events; address derivation SHOULD use `deriveAccount`. + +## Observability + +Bootstrap transaction signatures and owner-update events expose enough data to reconstruct an account's configuration history. + +Rules: + +- Reconstructing the initial configuration MUST decode `init` from the transaction's outer or key authorization bootstrap signature. +- A successful owner update MUST emit `MultisigConfigUpdated(account, threshold, owners)`. +- The initial stored configuration version is `1`; each successful owner-update event increments the reconstructed version by one. Bootstrap approvals use version `0` only. +- Account keychain paths MUST retain their existing events. + +## Examples + +These examples illustrate the canonical signing flows and main authorization edge cases. They add no normative rules. + +### Bootstrap + +A bootstrap transaction creates the multisig account and stores its initial owner configuration. Its signature carries `init` because no configuration exists in state yet. + +```text +// Define and derive the account with alice_address < bob_address. +// Ref: Types and Limits +init = multisig_init( + salt = salt, + threshold = 2, + owners = [ + [alice_address, 1], + [bob_address, 1] + ] +) + +// Ref: Account Identity formula +account = multisig_address + +// Build the account's first transaction. +tx = transaction( + from = account, + calls = [...] +) + +// Authorize the transaction with the initial owners. +// Ref: Authorization formula +digest = multisig_digest( + tx.signature_hash(), + account, + 0 +) + +tx.signature = + 0x05 || rlp([ + init, + [ + sign(alice_key, digest), + sign(bob_key, digest) + ] + ]) + +execute(tx) +``` + +### Initialized + +An initialized transaction uses the configuration already stored for the account. Its signature carries the account address instead of `init` so validation can load the current owners and threshold. + +```text +// Build a later transaction from the initialized account. +tx = transaction( + from = account, + calls = [...] +) + +// Authorize the transaction with the current owners. +// Ref: Authorization formula +digest = multisig_digest( + tx.signature_hash(), + account, + config.version +) + +tx.signature = + 0x05 || rlp([ + account, + [ + sign(alice_key, digest), + sign(bob_key, digest) + ] + ]) + +execute(tx) +``` + +### Nested Ownership + +Each multisig account wraps the digest approved by its parent. This example uses an initialized multisig account as an owner of another initialized multisig account. + +```text +// Build a transaction from the parent multisig account. +tx = transaction( + from = parent_account, + calls = [...] +) + +// Bind the transaction to the parent multisig account. +// Ref: Authorization formula +parent_digest = multisig_digest( + tx.signature_hash(), + parent_account, + parent_config.version +) + +// Bind the parent digest to the child multisig account. +child_digest = multisig_digest( + parent_digest, + child_account, + child_config.version +) + +alice_signature = sign(alice_key, child_digest) +bob_signature = sign(bob_key, child_digest) + +// Use the child's multisig account signature as a parent owner signature. +child_owner_signature = + 0x05 || rlp([ + child_account, + [alice_signature, bob_signature] + ]) + +// Attach the parent's multisig account signature. +tx.signature = + 0x05 || rlp([ + parent_account, + [child_owner_signature] + ]) + +execute(tx) +``` + +### Fee Sponsorship + +Fee sponsorship supports either signing order because the owners and fee payer sign separate digests. + +Owners sign first: + +```text +// Mark the transaction as sponsored before the owners sign. +tx = transaction( + from = account, + calls = [...], + fee_payer_signature = placeholder +) + +// Sign the sender digest, which omits the fee token. +// Ref: Authorization formula +digest = multisig_digest( + tx.signature_hash(), + account, + config.version +) + +alice_signature = sign(alice_key, digest) +bob_signature = sign(bob_key, digest) + +tx.signature = + 0x05 || rlp([ + account, + [alice_signature, bob_signature] + ]) + +// Select the fee token and authorize payment for this account. +tx.fee_token = fee_token +fee_payer_digest = tx.fee_payer_signature_hash(account) +tx.fee_payer_signature = sign_secp256k1(fee_payer_key, fee_payer_digest) +``` + +The fee payer signs first: + +```text +// Build the transaction with the fee token selected. +tx = transaction( + from = account, + calls = [...], + fee_token = fee_token +) + +// Authorize payment for the known multisig account. +fee_payer_digest = tx.fee_payer_signature_hash(account) +tx.fee_payer_signature = sign_secp256k1(fee_payer_key, fee_payer_digest) + +// Sign the sender digest after sponsorship is attached. +// Ref: Authorization formula +digest = multisig_digest( + tx.signature_hash(), + account, + config.version +) + +alice_signature = sign(alice_key, digest) +bob_signature = sign(bob_key, digest) + +tx.signature = + 0x05 || rlp([ + account, + [alice_signature, bob_signature] + ]) +``` + +### Weighted Quorum + +Owner weights determine which ordered owner signatures satisfy the threshold and when an extra signature is invalid. + +Assume `alice_address < bob_address < carol_address`: + +```text +threshold = 3 +owners = [ + [alice_address, 2], + [bob_address, 1], + [carol_address, 1] +] + +// Ref: Authorization formula +digest = multisig_digest(tx.signature_hash(), account, config.version) + +alice_signature = sign(alice_key, digest) +bob_signature = sign(bob_key, digest) +carol_signature = sign(carol_key, digest) + +// Valid: the final owner signature reaches the threshold. +signatures = [alice_signature, bob_signature] +signatures = [alice_signature, carol_signature] + +// Invalid: the total weight is below the threshold. +signatures = [bob_signature, carol_signature] + +// Invalid: Bob reaches the threshold before Carol's extra owner signature. +signatures = [alice_signature, bob_signature, carol_signature] +``` + +### Bootstrap and Immediate Access Key Use + +This transaction initializes the multisig account and registers an access key at the same time. The owner quorum signs the key authorization, and the new access key signs the transaction. + +```text +// Define and derive the new multisig account. +// Ref: Types and Limits +init = multisig_init( + salt = salt, + threshold = 2, + owners = [ + [alice_address, 1], + [bob_address, 1] + ] +) + +// Ref: Account Identity formula +account = multisig_address + +// Authorize a primitive access key for the new account. +// Ref: Access Keys on Multisig Accounts +key_authorization = KeyAuthorization( + chain_id = chain_id, + account = account, + key_type = Secp256k1, + key_id = access_key_address +) + +// Ref: Authorization formula +key_authorization_digest = multisig_digest( + key_authorization.signature_hash(), + account, + 0 +) + +key_authorization_signature = + 0x05 || rlp([ + init, + [ + sign(alice_key, key_authorization_digest), + sign(bob_key, key_authorization_digest) + ] + ]) + +// Include the signed key authorization in the bootstrap transaction. +tx = transaction( + from = account, + calls = [...], + // Ref: Signed Key Authorization Encoding + key_authorization = signed_key_authorization( + key_authorization, + key_authorization_signature + ) +) + +// Authorize this transaction with the access key being registered. +// Ref: Account Keychain formula +access_key_digest = keccak256( + 0x04 || + tx.signature_hash() || + account +) + +tx.signature = + 0x04 || account || sign(access_key, access_key_digest) + +execute(tx) +``` + +Validation establishes the account from `init`, registers the access key, and executes the same transaction as `account` under the key's restrictions. + +### Bootstrap and Subsequent Access Key Use + +The owner quorum can initialize the account and register an access key without using that key for the bootstrap transaction. The access key can then authorize a subsequent transaction. + +```text +// Define and derive the new multisig account. +// Ref: Types and Limits +init = multisig_init( + salt = salt, + threshold = 2, + owners = [ + [alice_address, 1], + [bob_address, 1] + ] +) + +// Ref: Account Identity formula +account = multisig_address + +// Authorize a primitive access key for the new account. +// Ref: Access Keys on Multisig Accounts +key_authorization = KeyAuthorization( + chain_id = chain_id, + account = account, + key_type = Secp256k1, + key_id = access_key_address +) + +// Ref: Authorization formula +key_authorization_digest = multisig_digest( + key_authorization.signature_hash(), + account, + 0 +) + +// The outer bootstrap supplies init, so this signature uses initialized encoding. +key_authorization_signature = + 0x05 || rlp([ + account, + [ + sign(alice_key, key_authorization_digest), + sign(bob_key, key_authorization_digest) + ] + ]) + +bootstrap_tx = transaction( + from = account, + calls = [...], + // Ref: Signed Key Authorization Encoding + key_authorization = signed_key_authorization( + key_authorization, + key_authorization_signature + ) +) + +// The owner quorum authorizes the bootstrap transaction. +// Ref: Authorization formula +bootstrap_digest = multisig_digest( + bootstrap_tx.signature_hash(), + account, + 0 +) + +bootstrap_tx.signature = + 0x05 || rlp([ + init, + [ + sign(alice_key, bootstrap_digest), + sign(bob_key, bootstrap_digest) + ] + ]) + +execute(bootstrap_tx) + +// The registered access key authorizes a later transaction. +access_key_tx = transaction( + from = account, + calls = [...] +) + +// Ref: Account Keychain formula +access_key_digest = keccak256( + 0x04 || + access_key_tx.signature_hash() || + account +) + +access_key_tx.signature = + 0x04 || account || sign(access_key, access_key_digest) + +execute(access_key_tx) +``` + +The bootstrap transaction initializes the account and registers the access key. The later transaction executes as `account` under the key's stored restrictions without another owner quorum. + +### Configuration Rotation + +The current quorum authorizes an owner update, which preserves the account address and applies to later transactions. + +Assume Alice and Bob are both required. They replace themselves with Carol: + +```text +// Build a top-level owner update. +rotate_tx = transaction( + from = account, + calls = [ + // Ref: Native Multisig Precompile + updateConfig( + threshold = 1, + owners = [[carol_address, 1]] + ) + ] +) + +// The current owners authorize the update. +// Ref: Authorization formula +current_version = getConfig(account).version +rotate_digest = multisig_digest( + rotate_tx.signature_hash(), + account, + current_version +) + +rotate_tx.signature = + 0x05 || rlp([ + account, + [ + sign(alice_key, rotate_digest), + sign(bob_key, rotate_digest) + ] + ]) + +execute(rotate_tx) + +// The next transaction uses Carol and keeps the same account address. +next_tx = transaction( + from = account, + calls = [...] +) + +// Ref: Authorization formula +next_config = getConfig(account) +next_digest = multisig_digest( + next_tx.signature_hash(), + account, + next_config.version +) + +next_tx.signature = + 0x05 || rlp([ + account, + [sign(carol_key, next_digest)] + ]) +``` + +## Backwards Compatibility + +This change is additive for transactions and accounts that do not use registered multisig accounts. Applications can move assets or state from an existing EOA through their current flows. + +Registration also affects transactions that name a multisig account as a newly authorized access key or as a keychain-signed authorization-list authority. Those roles gain state-dependent intrinsic gas and are rejected where this TIP forbids multisig accounts. Primitive-signed fee payers and authorization-list authorities remain stateless under the address-collision assumption. Generic `SignedKeyAuthorization` decoders also accept the new multisig signature form, while consensus rejects it before T11 and primitive encodings remain unchanged. + +Rules: + +- This TIP MUST NOT add or reorder transaction fields. +- Transactions that do not use multisig accounts MUST remain byte-identical. +- An address with a nonzero protocol nonce, EVM code, or EIP-7702 delegation MUST NOT bootstrap. + +## Invariants + +Rules: + +- **Stable identity.** Bootstrap MUST satisfy `tx.from == multisig_address`. `updateConfig` MUST NOT change the address. +- **Bootstrap exclusivity.** When a multisig account signature authorizes a caller without a header, the transaction MUST carry exactly one bootstrap signature as its outer or key authorization signature. Transactions that do not use multisig account authorization do not require bootstrap. After a header exists for that account, bootstrap signatures MUST be rejected. +- **Bootstrap eligibility.** Bootstrap MUST require no header, a zero protocol nonce, empty code, and no delegation. Balance and storage MUST NOT block it. +- **Configuration validity.** Configurations MUST satisfy all owner, weight, total-weight, and threshold limits above, and MUST NOT include their own account as an owner. +- **Marker consistency.** An initialized header MUST have complete matching ordered owner and direct weight rows. +- **No account code.** A multisig account MUST NOT have EVM bytecode or EIP-7702 delegation code. +- **Owner signatures.** Owner signatures MUST be primitive over the current digest or nested with the parent digest as `inner_digest`, without `init`. Keychain signatures MUST NOT be accepted as owner signatures. +- **Owner set membership.** Recovered or nested owner addresses MUST be sorted and belong to the applicable owner configuration. +- **Threshold enforcement.** Current owner weights MUST reach threshold on the final owner signature, not before. +- **Current-state authorization.** Initialized authorization MUST use the owner configuration and version at its block position. +- **Multisig account sender.** Direct quorum transactions MUST use `TempoSignature::Multisig` as the outer signature. +- **Signature contexts.** Multisig signatures MUST be outer, nested owner, or key authorization signatures. Bootstrap MUST be outer or authorize the outer access key. +- **Key authorization.** Transactions MAY carry `key_authorization`, including at bootstrap. Its signature MUST use bootstrap encoding only when it supplies `init`; otherwise it MUST use initialized encoding. +- **Account keychain access.** Multisig accounts MAY own access keys. A registered multisig account MUST NOT be newly registered as an access key for another account, and multisig signatures MUST NOT authorize keychain access. Access keys MUST NOT replace parent owner sets. +- **Fee payer.** When `fee_payer_signature` is present, it MUST be secp256k1. Its recovered address is treated as a primitive identity and MUST NOT require a multisig-registry read. +- **Config update frame.** `updateConfig` MUST run in a protocol-created top-level frame for one `tx.calls` entry. +- **Config update identity.** `updateConfig` MUST require a complete multisig account header for `msg.sender`. +- **Config update bootstrap exclusion.** `updateConfig` MUST reject accounts initialized earlier in the same transaction. +- **No config update signature.** `updateConfig` MUST NOT accept an additional signature parameter.