From 1a398ec4c79be77ee9610f78e13df9665a7664f9 Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:06:20 +0200 Subject: [PATCH 1/7] feat(primitives): add native multisig wire types --- crates/contracts/src/precompiles/mod.rs | 3 + .../src/precompiles/native_multisig.rs | 49 + .../tests/it/tempo_transaction/helpers.rs | 4 +- crates/primitives/src/transaction/mod.rs | 8 + crates/primitives/src/transaction/multisig.rs | 1835 +++++++++++++++++ .../src/transaction/tempo_transaction.rs | 6 +- .../src/transaction/tt_signature.rs | 443 +++- crates/revm/src/error.rs | 5 + crates/revm/src/handler.rs | 4 + crates/revm/src/handler/tests.rs | 40 +- crates/revm/src/signature_gas.rs | 2 + 11 files changed, 2382 insertions(+), 17 deletions(-) create mode 100644 crates/contracts/src/precompiles/native_multisig.rs create mode 100644 crates/primitives/src/transaction/multisig.rs diff --git a/crates/contracts/src/precompiles/mod.rs b/crates/contracts/src/precompiles/mod.rs index 16ab161969..34dbaf354d 100644 --- a/crates/contracts/src/precompiles/mod.rs +++ b/crates/contracts/src/precompiles/mod.rs @@ -2,6 +2,7 @@ pub mod account_keychain; pub mod address_registry; pub mod common_errors; pub mod current_committee; +pub mod native_multisig; pub mod nonce; pub mod receive_policy_guard; pub mod signature_verifier; @@ -20,6 +21,7 @@ pub use account_keychain::*; pub use address_registry::*; pub use common_errors::*; pub use current_committee::*; +pub use native_multisig::*; pub use nonce::*; pub use receive_policy_guard::*; pub use signature_verifier::*; @@ -57,6 +59,7 @@ pub const SIGNATURE_VERIFIER_ADDRESS: Address = address!("0x5165300000000000000000000000000000000000"); pub const RECEIVE_POLICY_GUARD_ADDRESS: Address = address!("0xB10C000000000000000000000000000000000000"); +pub const NATIVE_MULTISIG_ADDRESS: Address = address!("0xAACC000000000000000000000000000000000000"); pub const STORAGE_CREDITS_ADDRESS: Address = address!("0x1060000000000000000000000000000000000000"); pub const CURRENT_COMMITTEE_ADDRESS: Address = address!("0xC077E00000000000000000000000000000000000"); diff --git a/crates/contracts/src/precompiles/native_multisig.rs b/crates/contracts/src/precompiles/native_multisig.rs new file mode 100644 index 0000000000..3cc0ce89e6 --- /dev/null +++ b/crates/contracts/src/precompiles/native_multisig.rs @@ -0,0 +1,49 @@ +pub use INativeMultisig::{ + INativeMultisigErrors as NativeMultisigError, INativeMultisigEvents as NativeMultisigEvent, +}; + +crate::sol! { + /// Native multisig account precompile. + #[derive(Debug, PartialEq, Eq)] + #[sol(abi)] + interface INativeMultisig { + struct MultisigOwner { + address owner; + uint8 weight; + } + + struct MultisigConfig { + uint64 version; + uint8 threshold; + MultisigOwner[] owners; + } + + event MultisigInitialized(address indexed account); + event MultisigConfigUpdated( + address indexed account, + uint8 threshold, + MultisigOwner[] owners + ); + + function deriveAccount(bytes32 salt, uint8 threshold, MultisigOwner[] calldata owners) + external + pure + returns (address account); + function isMultisigAccount(address account) external view returns (bool); + function getConfig(address account) external view returns (MultisigConfig memory); + function updateConfig(uint8 threshold, MultisigOwner[] calldata owners) external; + + error NotMultisigAccount(); + error InvalidAccount(); + error InvalidConfig(); + error InvalidThreshold(); + error InvalidOwner(); + error InvalidWeight(); + error TooManyOwners(); + error DuplicateOwner(); + error InvalidOwnerOrder(); + error AccountAlreadyInitialized(); + error UnauthorizedCaller(); + error SameTransactionUpdateNotAllowed(); + } +} diff --git a/crates/node/tests/it/tempo_transaction/helpers.rs b/crates/node/tests/it/tempo_transaction/helpers.rs index ec40621e9b..6a9cc03c4f 100644 --- a/crates/node/tests/it/tempo_transaction/helpers.rs +++ b/crates/node/tests/it/tempo_transaction/helpers.rs @@ -496,7 +496,9 @@ fn create_key_authorization_inner( witness: Option, ) -> eyre::Result { // Infer key_type from the access key signature - let key_type = access_key_signature.signature_type(); + let key_type = access_key_signature + .signature_type() + .ok_or_else(|| eyre::eyre!("native multisig signatures cannot be used as account keys"))?; let mut key_auth = KeyAuthorization::unrestricted(chain_id, key_type, access_key_addr); key_auth.expiry = expiry; diff --git a/crates/primitives/src/transaction/mod.rs b/crates/primitives/src/transaction/mod.rs index 5dd7ad86a8..720eb03f22 100644 --- a/crates/primitives/src/transaction/mod.rs +++ b/crates/primitives/src/transaction/mod.rs @@ -1,5 +1,6 @@ pub mod envelope; pub mod key_authorization; +pub mod multisig; pub mod tempo_transaction; pub mod tt_authorization; pub mod tt_signature; @@ -19,6 +20,13 @@ pub use key_authorization::{ CallScope, KeyAuthorization, KeyAuthorizationChainIdError, SelectorRule, SignedKeyAuthorization, TokenLimit, }; +pub use multisig::{ + InitMultisig, MAX_MULTISIG_NESTING_DEPTH, MAX_MULTISIG_OWNER_SIGNATURE_BYTES, + MAX_MULTISIG_OWNERS, MAX_MULTISIG_SIGNATURES, MAX_MULTISIG_THRESHOLD, + MULTISIG_SIGNATURE_DOMAIN, MultisigAddress, MultisigConfigError, MultisigOwner, + MultisigQuorumError, MultisigSignature, MultisigWeightAccumulator, SIGNATURE_TYPE_MULTISIG, + multisig_digest, multisig_signature_count_for_threshold, +}; pub use tempo_transaction::{ Call, FEE_PAYER_SIGNATURE_MARKER, InvalidValidAfter, InvalidValidBefore, MAX_WEBAUTHN_SIGNATURE_LENGTH, P256_SIGNATURE_LENGTH, SECP256K1_SIGNATURE_LENGTH, diff --git a/crates/primitives/src/transaction/multisig.rs b/crates/primitives/src/transaction/multisig.rs new file mode 100644 index 0000000000..5d0fdd0828 --- /dev/null +++ b/crates/primitives/src/transaction/multisig.rs @@ -0,0 +1,1835 @@ +use super::{tempo_transaction::MAX_WEBAUTHN_SIGNATURE_LENGTH, tt_signature::TempoSignature}; +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; +use alloy_primitives::{Address, B256, Bytes, keccak256}; +use core::{ + hash::{Hash, Hasher}, + mem::size_of, +}; +use tempo_contracts::precompiles::INativeMultisig; + +#[cfg(feature = "serde")] +use serde::{ + Deserialize, Deserializer, + de::{DeserializeSeed, Error as _, SeqAccess, Visitor}, +}; + +#[cfg(not(feature = "std"))] +use once_cell::race::OnceBox as OnceLock; +#[cfg(feature = "std")] +use std::sync::OnceLock; + +/// Tempo signature type byte for native multisig signatures. +pub const SIGNATURE_TYPE_MULTISIG: u8 = 0x05; + +/// Domain prefix for native multisig owner approvals. +pub const MULTISIG_SIGNATURE_DOMAIN: &[u8] = b"tempo:multisig:signature"; + +/// Maximum number of owners allowed in a native multisig config. +pub const MAX_MULTISIG_OWNERS: usize = 48; + +/// Maximum threshold accepted by a native multisig config. +pub const MAX_MULTISIG_THRESHOLD: u8 = u8::MAX; + +/// Maximum number of owner approvals allowed in one native multisig signature. +pub const MAX_MULTISIG_SIGNATURES: usize = 8; + +/// Maximum number of native multisig signatures in one nested authorization path, including the +/// top-level transaction signature. +pub const MAX_MULTISIG_NESTING_DEPTH: usize = 2; + +/// Maximum encoded byte length for one primitive owner approval. +pub const MAX_MULTISIG_OWNER_SIGNATURE_BYTES: usize = 1 + MAX_WEBAUTHN_SIGNATURE_LENGTH; + +const MULTISIG_ACCOUNT_DOMAIN: &[u8] = b"tempo:multisig:account"; + +/// Native multisig config validation error. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MultisigConfigError { + /// The owner list is empty. + EmptyOwners, + /// The owner list exceeds [`MAX_MULTISIG_OWNERS`]. + TooManyOwners, + /// The threshold is zero. + ZeroThreshold, + /// The threshold exceeds [`MAX_MULTISIG_THRESHOLD`]. + ThresholdTooHigh, + /// An owner address is zero. + ZeroOwner, + /// An owner weight is zero. + ZeroWeight, + /// The owner list contains a duplicate owner. + DuplicateOwner, + /// The owner list is not strictly ascending. + OwnersNotAscending, + /// Owner weight accumulation overflowed. + WeightOverflow, + /// Total owner weight exceeds `u8::MAX`. + TotalWeightExceedsMax, + /// The threshold exceeds the weight reachable within the approval limit. + ThresholdExceedsWeight, + /// The derived multisig account address is zero. + DerivedAccountZero, +} + +impl MultisigConfigError { + /// Returns the stable validation message for this error. + pub const fn as_str(self) -> &'static str { + match self { + Self::EmptyOwners => "multisig owners cannot be empty", + Self::TooManyOwners => "too many multisig owners", + Self::ZeroThreshold => "multisig threshold cannot be zero", + Self::ThresholdTooHigh => "multisig threshold exceeds max threshold", + Self::ZeroOwner => "multisig owner cannot be zero", + Self::ZeroWeight => "multisig owner weight cannot be zero", + Self::DuplicateOwner => "multisig owners cannot contain duplicates", + Self::OwnersNotAscending => "multisig owners must be strictly ascending", + Self::WeightOverflow => "multisig owner weight overflow", + Self::TotalWeightExceedsMax => "multisig total owner weight exceeds u8::MAX", + Self::ThresholdExceedsWeight => { + "multisig threshold cannot be reached within the owner approval limit" + } + Self::DerivedAccountZero => "multisig account cannot be zero", + } + } +} + +impl core::fmt::Display for MultisigConfigError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Native multisig quorum validation error. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MultisigQuorumError { + /// The signature list is empty. + EmptySignatures, + /// The signature list exceeds [`MAX_MULTISIG_SIGNATURES`]. + TooManySignatures, + /// The signature list has entries after quorum is reached. + ExcessSignatures, + /// A recovered signer is not a configured owner. + SignerNotOwner, + /// Recovered signers are not strictly ascending. + SignersNotAscending, + /// Recovered signer weight accumulation overflowed. + WeightOverflow, + /// Recovered signer weight does not meet the threshold. + WeightBelowThreshold, +} + +impl MultisigQuorumError { + /// Returns the stable validation message for this error. + pub const fn as_str(self) -> &'static str { + match self { + Self::EmptySignatures => "multisig signatures cannot be empty", + Self::TooManySignatures => "too many multisig signatures", + Self::ExcessSignatures => "excess multisig owner signatures", + Self::SignerNotOwner => "multisig signer is not an owner", + Self::SignersNotAscending => "multisig recovered owners must be strictly ascending", + Self::WeightOverflow => "multisig recovered owner weight overflow", + Self::WeightBelowThreshold => "multisig signature weight below threshold", + } + } +} + +impl core::fmt::Display for MultisigQuorumError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl From for &'static str { + fn from(err: MultisigQuorumError) -> Self { + err.as_str() + } +} + +impl From for String { + fn from(err: MultisigQuorumError) -> Self { + err.as_str().to_string() + } +} + +/// Accumulates recovered native multisig owner weights while enforcing owner ordering. +pub struct MultisigWeightAccumulator { + threshold: u8, + prev_owner: Option
, + recovered_weight: u16, + signer_count: usize, +} + +impl MultisigWeightAccumulator { + /// Creates a new accumulator for a validated native multisig threshold. + pub const fn new(threshold: u8) -> Self { + Self { + threshold, + prev_owner: None, + recovered_weight: 0, + signer_count: 0, + } + } + + /// Records one recovered owner address and its configured weight. + pub fn record_owner(&mut self, owner: Address, weight: u8) -> Result { + self.signer_count = self + .signer_count + .checked_add(1) + .ok_or(MultisigQuorumError::TooManySignatures)?; + if self.signer_count > MAX_MULTISIG_SIGNATURES { + return Err(MultisigQuorumError::TooManySignatures); + } + + if self.prev_owner.is_some_and(|prev| prev >= owner) { + return Err(MultisigQuorumError::SignersNotAscending); + } + self.prev_owner = Some(owner); + + self.recovered_weight = self + .recovered_weight + .checked_add(u16::from(weight)) + .ok_or(MultisigQuorumError::WeightOverflow)?; + Ok(weight) + } + + /// Returns whether the accumulated weight satisfies the configured threshold. + pub fn has_quorum(&self) -> bool { + self.signer_count > 0 && self.recovered_weight >= u16::from(self.threshold) + } + + /// Returns the accumulated weight after enforcing the configured threshold. + pub fn finish(self) -> Result { + if self.signer_count == 0 { + return Err(MultisigQuorumError::EmptySignatures); + } + if self.recovered_weight < u16::from(self.threshold) { + return Err(MultisigQuorumError::WeightBelowThreshold); + } + + u8::try_from(self.recovered_weight).map_err(|_| MultisigQuorumError::WeightOverflow) + } +} + +/// Native multisig owner entry. +#[derive(Clone, Debug, PartialEq, Eq, Hash, alloy_rlp::RlpEncodable, alloy_rlp::RlpDecodable)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +#[cfg_attr(any(test, feature = "arbitrary"), derive(arbitrary::Arbitrary))] +#[cfg_attr(test, reth_codecs::add_arbitrary_tests(rlp))] +pub struct MultisigOwner { + /// Owner address recovered from a primitive signature. + pub owner: Address, + /// Nonzero owner weight. + pub weight: u8, +} + +impl From for MultisigOwner { + fn from(value: INativeMultisig::MultisigOwner) -> Self { + Self { + owner: value.owner, + weight: value.weight, + } + } +} + +impl From for INativeMultisig::MultisigOwner { + fn from(value: MultisigOwner) -> Self { + Self { + owner: value.owner, + weight: value.weight, + } + } +} + +/// Initial native multisig config carried by the first transaction. +#[derive(Clone, Debug, PartialEq, Eq, Hash, alloy_rlp::RlpEncodable)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +#[cfg_attr(any(test, feature = "arbitrary"), derive(arbitrary::Arbitrary))] +#[cfg_attr(test, reth_codecs::add_arbitrary_tests(rlp))] +pub struct InitMultisig { + /// Caller-chosen salt mixed into the derived account address. + pub salt: B256, + /// Minimum total owner weight required to authorize a transaction. + pub threshold: u8, + /// Sorted weighted owner list. + pub owners: Vec, +} + +#[cfg(feature = "serde")] +struct BoundedMultisigOwners(Vec); + +#[cfg(feature = "serde")] +struct RejectExtraElement(&'static str); + +#[cfg(feature = "serde")] +impl<'de> DeserializeSeed<'de> for RejectExtraElement { + type Value = (); + + fn deserialize(self, _deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Err(D::Error::custom(self.0)) + } +} + +#[cfg(feature = "serde")] +impl<'de> Deserialize<'de> for BoundedMultisigOwners { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct OwnersVisitor; + + impl<'de> Visitor<'de> for OwnersVisitor { + type Value = BoundedMultisigOwners; + + fn expecting(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(formatter, "at most {MAX_MULTISIG_OWNERS} multisig owners") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + if seq + .size_hint() + .is_some_and(|size| size > MAX_MULTISIG_OWNERS) + { + return Err(A::Error::custom("too many multisig owners")); + } + + let mut owners = Vec::with_capacity( + seq.size_hint().unwrap_or_default().min(MAX_MULTISIG_OWNERS), + ); + while owners.len() < MAX_MULTISIG_OWNERS { + let Some(owner) = seq.next_element()? else { + return Ok(BoundedMultisigOwners(owners)); + }; + owners.push(owner); + } + + match seq.next_element_seed(RejectExtraElement("too many multisig owners"))? { + None => Ok(BoundedMultisigOwners(owners)), + Some(()) => unreachable!("reject seed never returns a value"), + } + } + } + + deserializer.deserialize_seq(OwnersVisitor) + } +} + +#[cfg(feature = "serde")] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct InitMultisigDeserializeWire { + salt: B256, + threshold: u8, + owners: BoundedMultisigOwners, +} + +#[cfg(feature = "serde")] +impl<'de> Deserialize<'de> for InitMultisig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = InitMultisigDeserializeWire::deserialize(deserializer)?; + Ok(Self { + salt: wire.salt, + threshold: wire.threshold, + owners: wire.owners.0, + }) + } +} + +impl alloy_rlp::Decodable for InitMultisig { + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + let header = alloy_rlp::Header::decode(buf)?; + if !header.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + if buf.len() < header.payload_length { + return Err(alloy_rlp::Error::InputTooShort); + } + + let body = *buf; + let (mut fields, rest) = body.split_at(header.payload_length); + let salt = ::decode(&mut fields)?; + let threshold = ::decode(&mut fields)?; + + let owners_header = alloy_rlp::Header::decode(&mut fields)?; + if !owners_header.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + if fields.len() < owners_header.payload_length { + return Err(alloy_rlp::Error::InputTooShort); + } + let (mut owner_fields, trailing_fields) = fields.split_at(owners_header.payload_length); + let mut owners = Vec::new(); + while !owner_fields.is_empty() { + if owners.len() == MAX_MULTISIG_OWNERS { + return Err(alloy_rlp::Error::Custom("too many multisig owners")); + } + owners.push(::decode( + &mut owner_fields, + )?); + } + if !trailing_fields.is_empty() { + return Err(alloy_rlp::Error::Custom( + "unexpected trailing multisig init fields", + )); + } + + *buf = rest; + Ok(Self { + salt, + threshold, + owners, + }) + } +} + +impl InitMultisig { + /// Validates this native multisig config and returns its total owner weight. + pub fn validate(&self) -> Result { + if self.owners.is_empty() { + return Err(MultisigConfigError::EmptyOwners); + } + if self.owners.len() > MAX_MULTISIG_OWNERS { + return Err(MultisigConfigError::TooManyOwners); + } + if self.threshold == 0 { + return Err(MultisigConfigError::ZeroThreshold); + } + let mut total_weight = 0u16; + let mut approval_weights = [0u8; MAX_MULTISIG_SIGNATURES]; + let mut prev_owner = None; + for owner in &self.owners { + if owner.owner.is_zero() { + return Err(MultisigConfigError::ZeroOwner); + } + if owner.weight == 0 { + return Err(MultisigConfigError::ZeroWeight); + } + if let Some(prev) = prev_owner { + if prev == owner.owner { + return Err(MultisigConfigError::DuplicateOwner); + } + if prev > owner.owner { + return Err(MultisigConfigError::OwnersNotAscending); + } + } + + prev_owner = Some(owner.owner); + total_weight = total_weight + .checked_add(u16::from(owner.weight)) + .ok_or(MultisigConfigError::WeightOverflow)?; + if owner.weight > approval_weights[0] { + approval_weights[0] = owner.weight; + approval_weights.sort_unstable(); + } + } + + if total_weight > u16::from(u8::MAX) { + return Err(MultisigConfigError::TotalWeightExceedsMax); + } + let reachable_weight = approval_weights.into_iter().map(u16::from).sum::(); + if u16::from(self.threshold) > reachable_weight { + return Err(MultisigConfigError::ThresholdExceedsWeight); + } + + Ok(total_weight as u8) + } + + /// Derives the native multisig account address for this initial config. + pub fn account(&self) -> Result { + self.validate()?; + + let owner_count = + u8::try_from(self.owners.len()).expect("validated multisig owner count fits in u8"); + let mut input = Vec::with_capacity( + MULTISIG_ACCOUNT_DOMAIN.len() + 32 + 2 + self.owners.len() * (20 + 1), + ); + input.extend_from_slice(MULTISIG_ACCOUNT_DOMAIN); + input.extend_from_slice(self.salt.as_slice()); + input.push(self.threshold); + input.push(owner_count); + for owner in &self.owners { + input.extend_from_slice(owner.owner.as_slice()); + input.push(owner.weight); + } + + let account = Address::from_slice(&keccak256(input)[12..]); + if account.is_zero() { + return Err(MultisigConfigError::DerivedAccountZero); + } + Ok(account) + } + + /// Returns the configured weight for an owner, if present. + pub fn owner_weight(&self, owner: Address) -> Option { + self.owners + .binary_search_by_key(&owner, |entry| entry.owner) + .ok() + .map(|idx| self.owners[idx].weight) + } + /// Returns a heuristic for the in-memory size of the config. + pub fn size(&self) -> usize { + size_of::() + self.owners.capacity() * size_of::() + } +} + +/// Static account source for a native multisig signature. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum MultisigAddress { + /// Existing native multisig account. + Initialized(Address), + /// Initial config for bootstrapping a native multisig account. + Init(InitMultisig), +} + +impl MultisigAddress { + fn from_parts(account: Address, init: Option) -> Result { + if let Some(init) = init { + let init_account = init.account().map_err(MultisigConfigError::as_str)?; + if init_account != account { + return Err("multisig init does not derive account"); + } + Ok(Self::Init(init)) + } else { + Ok(Self::Initialized(account)) + } + } + + /// Returns the native multisig account address. + pub fn account(&self) -> Address { + match self { + Self::Initialized(account) => *account, + Self::Init(init) => init + .account() + .expect("multisig init was validated during construction"), + } + } + + /// Returns the bootstrap config, if this address source is an init config. + pub const fn init(&self) -> Option<&InitMultisig> { + match self { + Self::Initialized(_) => None, + Self::Init(init) => Some(init), + } + } + + fn size(&self) -> usize { + match self { + Self::Initialized(_) => 0, + Self::Init(init) => init.size(), + } + } +} + +/// Native multisig transaction signature. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(into = "MultisigSignatureSerde"))] +#[cfg_attr(test, reth_codecs::add_arbitrary_tests(rlp))] +pub struct MultisigSignature { + /// Native multisig account source. + address: MultisigAddress, + /// Owner approvals over the multisig digest. + /// + /// Each approval is either a primitive signature or a nested native multisig signature. + signatures: Vec, + /// Cached multisig digest for the transaction hash and config version this signature approved. + cached_digest: OnceLock<(B256, Address, u64, B256)>, +} + +#[cfg(feature = "serde")] +impl From for MultisigSignatureSerde { + fn from(value: MultisigSignature) -> Self { + match value.address { + MultisigAddress::Initialized(account) => { + Self::Initialized(InitializedMultisigSignatureWire { + account, + signatures: value.signatures, + }) + } + MultisigAddress::Init(init) => Self::Init(InitMultisigSignatureWire { + init, + signatures: value.signatures, + }), + } + } +} + +#[cfg(feature = "serde")] +impl TryFrom for MultisigSignature { + type Error = &'static str; + + fn try_from(value: MultisigSignatureSerde) -> Result { + match value { + MultisigSignatureSerde::Initialized(wire) => Self::from_decoded_address( + MultisigAddress::Initialized(wire.account), + wire.signatures, + ), + MultisigSignatureSerde::Init(wire) => { + Self::from_decoded_address(MultisigAddress::Init(wire.init), wire.signatures) + } + } + } +} + +impl MultisigSignature { + pub fn new(account: Address, signatures: Vec, init: Option) -> Self { + Self::try_new(account, signatures, init).expect("valid multisig owner signatures") + } + + pub fn try_new( + account: Address, + signatures: Vec, + init: Option, + ) -> Result { + let signatures = signatures + .into_iter() + .map(decode_multisig_owner_signature) + .collect::, _>>()?; + Self::from_decoded(account, signatures, init) + } + + pub fn from_decoded( + account: Address, + signatures: Vec, + init: Option, + ) -> Result { + let address = MultisigAddress::from_parts(account, init)?; + Self::from_decoded_address(address, signatures) + } + + fn from_decoded_address( + address: MultisigAddress, + signatures: Vec, + ) -> Result { + // Guarantee the init config is valid at construction (decode/serde) time so that every + // constructed `MultisigSignature` upholds the invariant `MultisigAddress::account()` relies + // on. Without this, an invalid init config reaches the infallible `account()` and panics. + if let MultisigAddress::Init(init) = &address { + init.account().map_err(MultisigConfigError::as_str)?; + } + let signature = Self { + address, + signatures, + cached_digest: OnceLock::new(), + }; + signature.validate_shape()?; + Ok(signature) + } + + /// Returns the native multisig account address. + pub fn account(&self) -> Address { + self.address.account() + } + + /// Returns encoded owner approvals. + pub fn signatures(&self) -> &[TempoSignature] { + &self.signatures + } + + /// Returns the number of encoded owner signatures. + pub fn signature_count(&self) -> usize { + self.signatures.len() + } + + /// Returns the optional bootstrap config. + pub fn init(&self) -> Option<&InitMultisig> { + self.address.init() + } + + /// Performs stateless sender-recovery checks and returns the attempted multisig account. + pub fn recover_account(&self) -> Result { + self.validate_shape()?; + Ok(self.account()) + } + + /// Validates only the stateless signature payload shape. + pub fn validate_shape(&self) -> Result<(), &'static str> { + if self.account().is_zero() { + return Err("multisig account cannot be zero"); + } + if self.signatures.is_empty() { + return Err("multisig signatures cannot be empty"); + } + if self.signatures.len() > MAX_MULTISIG_SIGNATURES { + return Err("too many multisig signatures"); + } + if self + .signatures + .iter() + .filter_map(TempoSignature::as_primitive) + .any(|sig| sig.encoded_length() > MAX_MULTISIG_OWNER_SIGNATURE_BYTES) + { + return Err("multisig owner signature too large"); + } + Ok(()) + } + + /// Performs only the registered-account stateless payload checks. + /// + /// Registered accounts are already bound to native multisig storage, so the derived-account + /// check can be skipped on the steady-state path. + pub fn validate_registered_shape(&self) -> Result<(), &'static str> { + self.validate_shape()?; + if self.init().is_some() { + return Err("multisig_init is only allowed when bootstrapping an account"); + } + Ok(()) + } + + /// Returns the multisig owner-approval digest for this signature and caches it on first use. + pub fn digest(&self, inner_digest: B256, config_version: u64) -> B256 { + let account = self.account(); + if let Some((cached_inner, cached_account, cached_version, cached_digest)) = + self.cached_digest.get() + && *cached_inner == inner_digest + && *cached_account == account + && *cached_version == config_version + { + return *cached_digest; + } + + let digest = multisig_digest(inner_digest, account, config_version); + if self.cached_digest.get().is_none() { + #[allow(clippy::useless_conversion)] + let _ = self + .cached_digest + .set((inner_digest, account, config_version, digest).into()); + } + if let Some((cached_inner, cached_account, cached_version, cached_digest)) = + self.cached_digest.get() + && *cached_inner == inner_digest + && *cached_account == account + && *cached_version == config_version + { + return *cached_digest; + } + + digest + } + + /// Returns a heuristic for the in-memory size of the signature. + pub fn size(&self) -> usize { + size_of::() + + self.address.size() + + self.signatures.capacity() * size_of::() + + self + .signatures + .iter() + .map(TempoSignature::size) + .sum::() + } +} + +impl PartialEq for MultisigSignature { + fn eq(&self, other: &Self) -> bool { + self.address == other.address && self.signatures == other.signatures + } +} + +impl Eq for MultisigSignature {} + +impl Hash for MultisigSignature { + fn hash(&self, state: &mut H) { + self.address.hash(state); + self.signatures.hash(state); + } +} + +#[derive(alloy_rlp::RlpEncodable, alloy_rlp::RlpDecodable)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + serde(rename_all = "camelCase", deny_unknown_fields) +)] +struct InitializedMultisigSignatureWire { + account: Address, + signatures: Vec, +} + +#[derive(alloy_rlp::RlpEncodable, alloy_rlp::RlpDecodable)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + serde(rename_all = "camelCase", deny_unknown_fields) +)] +struct InitMultisigSignatureWire { + init: InitMultisig, + signatures: Vec, +} + +#[cfg(feature = "serde")] +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +enum MultisigSignatureSerde { + Initialized(InitializedMultisigSignatureWire), + Init(InitMultisigSignatureWire), +} + +#[cfg(feature = "serde")] +impl<'de> Deserialize<'de> for MultisigSignature { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + if deserializer.is_human_readable() { + match TempoSignature::deserialize(deserializer)? { + TempoSignature::Multisig(signature) => Ok(signature), + TempoSignature::Primitive(_) | TempoSignature::Keychain(_) => { + Err(D::Error::custom("expected a multisig signature")) + } + } + } else { + let wire = MultisigSignatureSerde::deserialize(deserializer)?; + Self::try_from(wire).map_err(D::Error::custom) + } + } +} + +impl MultisigSignature { + /// Decodes a native multisig signature while bounding recursive nesting. + /// + /// `depth` is the nesting level of this signature node; the top-level transaction signature is + /// depth `1` and each nested owner approval is one level deeper. Owner approvals are decoded at + /// `depth + 1`, and a node deeper than [`MAX_MULTISIG_NESTING_DEPTH`] is rejected. Enforcing the + /// bound during decoding (not only during authorization) prevents untrusted, deeply nested + /// input from exhausting the stack before any gas, fee, or hardfork check runs. + pub(crate) fn decode_with_depth(buf: &mut &[u8], depth: usize) -> alloy_rlp::Result { + if depth > MAX_MULTISIG_NESTING_DEPTH { + return Err(alloy_rlp::Error::Custom( + "native multisig nesting depth exceeded", + )); + } + + let outer = alloy_rlp::Header::decode(buf)?; + if !outer.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + if buf.len() < outer.payload_length { + return Err(alloy_rlp::Error::InputTooShort); + } + + let body = *buf; + let (mut fields, rest) = body.split_at(outer.payload_length); + + // The first field distinguishes the wire shape: a bootstrap init config is an RLP list, + // an initialized account is a 20-byte string. + let mut peek = fields; + let first = alloy_rlp::Header::decode(&mut peek)?; + let address = if first.list { + MultisigAddress::Init(::decode(&mut fields)?) + } else { + MultisigAddress::Initialized(
::decode(&mut fields)?) + }; + + // Decode owner approvals one nesting level deeper so nested multisig approvals are bounded. + let sig_header = alloy_rlp::Header::decode(&mut fields)?; + if !sig_header.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + if fields.len() < sig_header.payload_length { + return Err(alloy_rlp::Error::InputTooShort); + } + let (mut sig_fields, sig_rest) = fields.split_at(sig_header.payload_length); + let mut signatures = Vec::new(); + while !sig_fields.is_empty() { + if signatures.len() == MAX_MULTISIG_SIGNATURES { + return Err(alloy_rlp::Error::Custom("too many multisig signatures")); + } + signatures.push(TempoSignature::decode_with_depth( + &mut sig_fields, + depth + 1, + )?); + } + if !sig_rest.is_empty() { + return Err(alloy_rlp::Error::Custom( + "unexpected trailing native multisig signature fields", + )); + } + + *buf = rest; + Self::from_decoded_address(address, signatures).map_err(alloy_rlp::Error::Custom) + } +} + +impl alloy_rlp::Decodable for MultisigSignature { + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + Self::decode_with_depth(buf, 1) + } +} + +impl alloy_rlp::Encodable for MultisigSignature { + fn encode(&self, out: &mut dyn alloy_rlp::BufMut) { + match &self.address { + MultisigAddress::Initialized(account) => InitializedMultisigSignatureWire { + account: *account, + signatures: self.signatures.clone(), + } + .encode(out), + MultisigAddress::Init(init) => InitMultisigSignatureWire { + init: init.clone(), + signatures: self.signatures.clone(), + } + .encode(out), + } + } + + fn length(&self) -> usize { + match &self.address { + MultisigAddress::Initialized(account) => InitializedMultisigSignatureWire { + account: *account, + signatures: self.signatures.clone(), + } + .length(), + MultisigAddress::Init(init) => InitMultisigSignatureWire { + init: init.clone(), + signatures: self.signatures.clone(), + } + .length(), + } + } +} + +/// Computes the digest that native multisig owners approve. +pub fn multisig_digest(inner_digest: B256, account: Address, config_version: u64) -> B256 { + let mut input = [0u8; MULTISIG_SIGNATURE_DOMAIN.len() + 32 + 20 + 8]; + let mut offset = 0; + input[offset..offset + MULTISIG_SIGNATURE_DOMAIN.len()] + .copy_from_slice(MULTISIG_SIGNATURE_DOMAIN); + offset += MULTISIG_SIGNATURE_DOMAIN.len(); + input[offset..offset + 32].copy_from_slice(inner_digest.as_slice()); + offset += 32; + input[offset..offset + 20].copy_from_slice(account.as_slice()); + offset += 20; + input[offset..].copy_from_slice(&config_version.to_be_bytes()); + keccak256(input) +} + +/// Returns the number of leading signatures needed for their weights to meet `threshold`. +pub fn multisig_signature_count_for_threshold( + weights: impl IntoIterator, + threshold: u8, +) -> Result { + let mut signed_weight = 0u16; + let mut count = 0usize; + + for weight in weights { + count = count + .checked_add(1) + .ok_or(MultisigQuorumError::TooManySignatures)?; + if count > MAX_MULTISIG_SIGNATURES { + return Err(MultisigQuorumError::TooManySignatures); + } + signed_weight = signed_weight + .checked_add(u16::from(weight)) + .ok_or(MultisigQuorumError::WeightOverflow)?; + if signed_weight >= u16::from(threshold) { + return Ok(count); + } + } + + if count == 0 { + return Err(MultisigQuorumError::EmptySignatures); + } + Err(MultisigQuorumError::WeightBelowThreshold) +} + +fn decode_multisig_owner_signature(signature: Bytes) -> Result { + if signature.is_empty() { + return Err("multisig owner signature cannot be empty"); + } + if signature.len() > MAX_MULTISIG_OWNER_SIGNATURE_BYTES + && signature[0] != SIGNATURE_TYPE_MULTISIG + { + return Err("multisig owner signature too large"); + } + TempoSignature::from_bytes(&signature).map_err(|_| "invalid multisig owner signature") +} + +#[cfg(any(test, feature = "arbitrary"))] +impl<'a> arbitrary::Arbitrary<'a> for MultisigSignature { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + let len = u.int_in_range(1..=MAX_MULTISIG_SIGNATURES)?; + let mut signatures = Vec::new(); + for _ in 0..len { + signatures.push(TempoSignature::Primitive(u.arbitrary()?)); + } + + let init = if bool::arbitrary(u)? { + let mut owner = Address::arbitrary(u)?; + if owner.is_zero() { + owner = Address::repeat_byte(1); + } + Some(InitMultisig { + salt: u.arbitrary()?, + threshold: 1, + owners: vec![MultisigOwner { owner, weight: 1 }], + }) + } else { + None + }; + let account = if let Some(init) = &init { + init.account() + .map_err(|_| arbitrary::Error::IncorrectFormat)? + } else { + let mut account = Address::arbitrary(u)?; + if account.is_zero() { + account = Address::repeat_byte(1); + } + account + }; + + Self::from_decoded(account, signatures, init).map_err(|_| arbitrary::Error::IncorrectFormat) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transaction::{ + PrimitiveSignature, TempoSignature, derive_p256_address, + tt_authorization::tests::{generate_secp256k1_keypair, sign_hash}, + tt_signature::{P256SignatureWithPreHash, WebAuthnSignature, normalize_p256_s}, + }; + use alloy_rlp::{Decodable, Encodable}; + use p256::{ + ecdsa::{SigningKey as P256SigningKey, signature::hazmat::PrehashSigner}, + elliptic_curve::rand_core::OsRng, + }; + use proptest::prelude::*; + use sha2::{Digest, Sha256}; + + fn sorted_secp_config(owners: &[(Address, u8)], threshold: u8) -> InitMultisig { + let mut owners = owners + .iter() + .map(|(owner, weight)| MultisigOwner { + owner: *owner, + weight: *weight, + }) + .collect::>(); + owners.sort_by_key(|owner| owner.owner); + InitMultisig { + salt: B256::ZERO, + threshold, + owners, + } + } + + fn indexed_owner(index: u16) -> Address { + let mut bytes = [0u8; 20]; + bytes[18..].copy_from_slice(&index.to_be_bytes()); + Address::from(bytes) + } + + fn valid_owner_signature_bytes() -> Bytes { + PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()).to_bytes() + } + + fn generate_p256_keypair() -> (P256SigningKey, B256, B256, Address) { + let signing_key = P256SigningKey::random(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + let encoded_point = verifying_key.to_encoded_point(false); + let pub_key_x = B256::from_slice(encoded_point.x().unwrap().as_ref()); + let pub_key_y = B256::from_slice(encoded_point.y().unwrap().as_ref()); + let owner = derive_p256_address(&pub_key_x, &pub_key_y); + (signing_key, pub_key_x, pub_key_y, owner) + } + + fn sign_p256_owner_approval_with_prehash( + signing_key: &P256SigningKey, + digest: B256, + pub_key_x: B256, + pub_key_y: B256, + ) -> Bytes { + let prehashed = B256::from_slice(Sha256::digest(digest).as_ref()); + let signature: p256::ecdsa::Signature = + signing_key.sign_prehash(prehashed.as_slice()).unwrap(); + let sig_bytes = signature.to_bytes(); + PrimitiveSignature::P256(P256SignatureWithPreHash { + r: B256::from_slice(&sig_bytes[..32]), + s: normalize_p256_s(&sig_bytes[32..64]).expect("p256 crate produces valid s"), + pub_key_x, + pub_key_y, + pre_hash: true, + }) + .to_bytes() + } + + fn encoded_multisig_without_init_slot(account: Address, signatures: Vec>) -> Vec { + let signatures = signatures.into_iter().map(Bytes::from).collect::>(); + let payload_length = account.length() + signatures.length(); + let mut encoded = Vec::new(); + alloy_rlp::Header { + list: true, + payload_length, + } + .encode(&mut encoded); + account.encode(&mut encoded); + signatures.encode(&mut encoded); + encoded + } + + fn encoded_multisig_with_init_config(init: &InitMultisig, signatures: Vec>) -> Vec { + let signatures = signatures.into_iter().map(Bytes::from).collect::>(); + let payload_length = init.length() + signatures.length(); + let mut encoded = Vec::new(); + alloy_rlp::Header { + list: true, + payload_length, + } + .encode(&mut encoded); + init.encode(&mut encoded); + signatures.encode(&mut encoded); + encoded + } + + fn encoded_multisig_with_empty_init_placeholder( + account: Address, + signatures: Vec>, + ) -> Vec { + let signatures = signatures.into_iter().map(Bytes::from).collect::>(); + let payload_length = account.length() + signatures.length() + 1; + let mut encoded = Vec::new(); + alloy_rlp::Header { + list: true, + payload_length, + } + .encode(&mut encoded); + account.encode(&mut encoded); + signatures.encode(&mut encoded); + encoded.push(alloy_rlp::EMPTY_STRING_CODE); + encoded + } + + /// Builds `levels` of nested initialized native multisig signatures, where the innermost owner + /// approval is a primitive signature and each outer level has a single nested multisig owner. + fn nested_multisig_encoding(levels: usize) -> Vec { + let account = Address::repeat_byte(0x11); + let mut current = encoded_multisig_without_init_slot( + account, + vec![valid_owner_signature_bytes().to_vec()], + ); + for _ in 1..levels { + let mut owner_approval = vec![SIGNATURE_TYPE_MULTISIG]; + owner_approval.extend_from_slice(¤t); + current = encoded_multisig_without_init_slot(account, vec![owner_approval]); + } + current + } + + fn encoded_legacy_multisig_with_trailing_init( + account: Address, + signatures: Vec>, + init: &InitMultisig, + ) -> Vec { + let signatures = signatures.into_iter().map(Bytes::from).collect::>(); + let payload_length = account.length() + signatures.length() + init.length(); + let mut encoded = Vec::new(); + alloy_rlp::Header { + list: true, + payload_length, + } + .encode(&mut encoded); + account.encode(&mut encoded); + signatures.encode(&mut encoded); + init.encode(&mut encoded); + encoded + } + + #[test] + fn account_derivation_is_stable_and_validates_owner_order() { + let owner_a = Address::from([0x11; 20]); + let owner_b = Address::from([0x22; 20]); + let config = sorted_secp_config(&[(owner_b, 2), (owner_a, 1)], 2); + + config.validate().expect("config is valid"); + assert_eq!(config.account().unwrap(), config.account().unwrap()); + + let unsorted = InitMultisig { + salt: B256::ZERO, + threshold: 1, + owners: vec![ + MultisigOwner { + owner: owner_b, + weight: 1, + }, + MultisigOwner { + owner: owner_a, + weight: 1, + }, + ], + }; + assert!(unsorted.validate().is_err()); + } + + #[test] + fn account_derivation_includes_salt() { + let owner = Address::from([0x11; 20]); + let zero_salt = sorted_secp_config(&[(owner, 1)], 1); + let mut nonzero_salt = zero_salt.clone(); + nonzero_salt.salt = B256::repeat_byte(0x42); + + assert_ne!( + zero_salt.account().unwrap(), + nonzero_salt.account().unwrap() + ); + zero_salt.validate().expect("zero salt is valid"); + } + + #[test] + fn multisig_domains_match_spec_vectors() { + let config = sorted_secp_config(&[(Address::repeat_byte(0x11), 1)], 1); + let account = config.account().unwrap(); + + assert_eq!( + account, + alloy_primitives::address!("8820d1497eeaf4f68e00b2cfc00a2f3b1dbb00da") + ); + assert_eq!( + multisig_digest(B256::repeat_byte(0x42), account, 0), + alloy_primitives::b256!( + "bf944a7a752b2cfab0418d5fb4591c5a7ff62976488edce11794d7f35fb34f41" + ) + ); + } + + #[test] + fn config_accepts_max_owners() { + let owners = (1..=MAX_MULTISIG_OWNERS as u16) + .map(|index| (indexed_owner(index), 1)) + .collect::>(); + let config = sorted_secp_config(&owners, MAX_MULTISIG_SIGNATURES as u8); + + assert_eq!(config.validate(), Ok(MAX_MULTISIG_OWNERS as u8)); + assert!(config.account().is_ok()); + } + + #[test] + fn config_rejects_more_than_max_owners() { + let owners = (1..=MAX_MULTISIG_OWNERS as u16 + 1) + .map(|index| (indexed_owner(index), 1)) + .collect::>(); + let config = sorted_secp_config(&owners, 1); + + assert_eq!(config.validate(), Err(MultisigConfigError::TooManyOwners)); + } + + #[test] + fn config_total_weight_is_capped_at_u8_max() { + let owner_a = Address::from([0x11; 20]); + let owner_b = Address::from([0x22; 20]); + let config = sorted_secp_config(&[(owner_a, 128), (owner_b, 128)], 1); + + assert_eq!( + config.validate(), + Err(MultisigConfigError::TotalWeightExceedsMax) + ); + } + + #[test] + fn config_accepts_threshold_above_signature_cap() { + let owner = Address::from([0x11; 20]); + let threshold = MAX_MULTISIG_THRESHOLD; + let config = sorted_secp_config(&[(owner, threshold)], threshold); + + assert_eq!(config.validate(), Ok(threshold)); + assert_eq!( + multisig_signature_count_for_threshold([threshold], threshold), + Ok(1) + ); + } + + #[test] + fn config_rejects_threshold_requiring_too_many_approvals() { + let owners = (1..=MAX_MULTISIG_SIGNATURES as u16 + 1) + .map(|index| (indexed_owner(index), 1)) + .collect::>(); + let config = sorted_secp_config(&owners, owners.len() as u8); + + assert_eq!( + config.validate(), + Err(MultisigConfigError::ThresholdExceedsWeight) + ); + } + + #[test] + fn shared_quorum_helpers_verify_order_and_threshold() { + let owner_a = indexed_owner(1); + let owner_b = indexed_owner(2); + let owner_c = indexed_owner(3); + let config = sorted_secp_config(&[(owner_a, 1), (owner_b, 3), (owner_c, 2)], 4); + + // Reproduce the weight-accounting the native multisig verifier performs: look up each + // recovered owner's configured weight and feed it to the shared accumulator in order. + let ordered_weights = |owners: &[Address]| -> Result { + let mut accumulator = MultisigWeightAccumulator::new(config.threshold); + for &owner in owners { + let weight = config + .owner_weight(owner) + .ok_or(MultisigQuorumError::SignerNotOwner)?; + accumulator.record_owner(owner, weight)?; + } + accumulator.finish() + }; + + assert_eq!(ordered_weights(&[owner_a, owner_b]), Ok(4)); + assert_eq!( + ordered_weights(&[owner_b]), + Err(MultisigQuorumError::WeightBelowThreshold) + ); + assert_eq!( + ordered_weights(&[owner_b, owner_a]), + Err(MultisigQuorumError::SignersNotAscending) + ); + assert_eq!( + ordered_weights(&[indexed_owner(4)]), + Err(MultisigQuorumError::SignerNotOwner) + ); + + assert_eq!( + multisig_signature_count_for_threshold( + config.owners.iter().map(|owner| owner.weight), + 4 + ), + Ok(2) + ); + assert_eq!( + multisig_signature_count_for_threshold([1, 2], 4), + Err(MultisigQuorumError::WeightBelowThreshold) + ); + assert_eq!( + multisig_signature_count_for_threshold([], 1), + Err(MultisigQuorumError::EmptySignatures) + ); + } + + #[test] + fn owner_signature_cannot_replay_across_accounts_with_same_owners() { + let (signer, owner) = generate_secp256k1_keypair(); + let mut config_a = sorted_secp_config(&[(owner, 1)], 1); + config_a.salt = B256::repeat_byte(0x11); + let mut config_b = sorted_secp_config(&[(owner, 1)], 1); + config_b.salt = B256::repeat_byte(0x22); + + let account_a = config_a.account().unwrap(); + let account_b = config_b.account().unwrap(); + assert_ne!(account_a, account_b); + + let inner_digest = B256::repeat_byte(0x42); + let digest_a = multisig_digest(inner_digest, account_a, 0); + let digest_b = multisig_digest(inner_digest, account_b, 0); + assert_ne!(digest_a, digest_b, "digest is domain-separated by account"); + + // An owner approval recovers the owner only against the account it was signed for; replaying + // it against another account's digest recovers a different address that is not an owner. + let signature = sign_hash(&signer, &digest_a); + assert_eq!(signature.recover_signer(&digest_a).unwrap(), owner); + assert_ne!(signature.recover_signer(&digest_b).unwrap(), owner); + } + + #[test] + fn owner_signature_cannot_replay_across_config_versions() { + let (signer, owner) = generate_secp256k1_keypair(); + let config = sorted_secp_config(&[(owner, 1)], 1); + let account = config.account().unwrap(); + let inner_digest = B256::repeat_byte(0x42); + let initial_digest = multisig_digest(inner_digest, account, 0); + let rotated_digest = multisig_digest(inner_digest, account, 1); + + assert_ne!(initial_digest, rotated_digest); + let signature = sign_hash(&signer, &initial_digest); + assert_eq!(signature.recover_signer(&initial_digest).unwrap(), owner); + assert_ne!(signature.recover_signer(&rotated_digest).unwrap(), owner); + } + + #[test] + fn verifies_weighted_owner_signatures_in_sorted_order() { + let (signer_a, owner_a) = generate_secp256k1_keypair(); + let (signer_b, owner_b) = generate_secp256k1_keypair(); + let config = sorted_secp_config(&[(owner_a, 1), (owner_b, 1)], 2); + let account = config.account().unwrap(); + let digest = multisig_digest(B256::repeat_byte(0x42), account, 0); + + let mut signed = [ + (owner_a, sign_hash(&signer_a, &digest)), + (owner_b, sign_hash(&signer_b, &digest)), + ]; + signed.sort_by_key(|(owner, _)| *owner); + + // Feed the recovered owners through the shared accumulator, as the verifier does. + let quorum_weight = |approvals: &[&TempoSignature]| -> Result { + let mut accumulator = MultisigWeightAccumulator::new(config.threshold); + for approval in approvals { + let owner = approval.recover_signer(&digest).unwrap(); + let weight = config + .owner_weight(owner) + .ok_or(MultisigQuorumError::SignerNotOwner)?; + accumulator.record_owner(owner, weight)?; + } + accumulator.finish() + }; + + let both = [&signed[0].1, &signed[1].1]; + assert_eq!(quorum_weight(&both), Ok(2)); + + // A single owner falls short of the threshold of 2. + assert!(quorum_weight(&[&signed[0].1]).is_err()); + } + + #[test] + fn noncanonical_p256_owner_prehash_flag_canonicalizes() { + // A P256 owner approval carrying a noncanonical pre_hash flag byte decodes to the same + // signature and re-encodes with the canonical flag, so it cannot malleate the transaction + // hash even though the raw wire byte differs. This structural canonicalization replaces the + // (STF-breaking) strict-flag rejection that was previously attempted at decode time. + let (signer, pub_key_x, pub_key_y, owner) = generate_p256_keypair(); + let config = sorted_secp_config(&[(owner, 1)], 1); + let account = config.account().unwrap(); + let digest = multisig_digest(B256::repeat_byte(0x42), account, 0); + + let canonical_signature = + sign_p256_owner_approval_with_prehash(&signer, digest, pub_key_x, pub_key_y); + assert_eq!( + canonical_signature[canonical_signature.len() - 1], + 1, + "test setup should use canonical pre_hash=true encoding" + ); + + let mut noncanonical_signature = canonical_signature.to_vec(); + let flag_index = noncanonical_signature.len() - 1; + noncanonical_signature[flag_index] = 2; + + let decoded = TempoSignature::from_bytes(&noncanonical_signature) + .expect("noncanonical pre_hash flag decodes leniently"); + assert_eq!( + decoded.to_bytes(), + canonical_signature, + "noncanonical owner approval re-encodes to the canonical signature bytes" + ); + } + + #[test] + fn multisig_signature_without_init_omits_trailing_slot() { + let account = Address::repeat_byte(0x11); + let signatures = vec![valid_owner_signature_bytes()]; + let signature = MultisigSignature::new(account, signatures.clone(), None); + + let mut encoded = Vec::new(); + signature.encode(&mut encoded); + assert_eq!( + encoded, + encoded_multisig_without_init_slot( + account, + signatures + .iter() + .map(|signature| signature.to_vec()) + .collect(), + ) + ); + + let mut input = encoded.as_slice(); + let decoded = MultisigSignature::decode(&mut input).unwrap(); + assert!(input.is_empty()); + assert_eq!(decoded, signature); + } + + #[test] + fn multisig_signature_rejects_empty_init_placeholder() { + let encoded = encoded_multisig_with_empty_init_placeholder( + Address::repeat_byte(0x11), + vec![vec![0x03, 0x04]], + ); + + let mut input = encoded.as_slice(); + assert!(MultisigSignature::decode(&mut input).is_err()); + } + + #[test] + fn multisig_signature_rejects_legacy_trailing_init() { + let owner = Address::from([0x11; 20]); + let config = sorted_secp_config(&[(owner, 1)], 1); + let account = config.account().unwrap(); + let encoded = encoded_legacy_multisig_with_trailing_init( + account, + vec![valid_owner_signature_bytes().to_vec()], + &config, + ); + + let mut input = encoded.as_slice(); + assert!(MultisigSignature::decode(&mut input).is_err()); + } + + #[test] + fn multisig_signature_rejects_init_account_mismatch() { + let owner = Address::from([0x11; 20]); + let config = sorted_secp_config(&[(owner, 1)], 1); + let wrong_account = Address::repeat_byte(0x99); + + let signature = MultisigSignature::try_new( + wrong_account, + vec![valid_owner_signature_bytes()], + Some(config), + ); + + assert_eq!(signature, Err("multisig init does not derive account")); + } + + #[test] + fn tempo_signature_decode_bounds_multisig_nesting() { + // Nesting up to MAX_MULTISIG_NESTING_DEPTH decodes structurally. + let mut ok = vec![SIGNATURE_TYPE_MULTISIG]; + ok.extend(nested_multisig_encoding(MAX_MULTISIG_NESTING_DEPTH)); + assert!( + TempoSignature::from_bytes(&ok).is_ok(), + "nesting within the depth bound must decode" + ); + + // One level deeper exceeds the bound and is rejected at decode time. + let mut too_deep = vec![SIGNATURE_TYPE_MULTISIG]; + too_deep.extend(nested_multisig_encoding(MAX_MULTISIG_NESTING_DEPTH + 1)); + assert!( + TempoSignature::from_bytes(&too_deep).is_err(), + "nesting past the depth bound must be rejected" + ); + + // A pathologically deep payload is rejected quickly instead of recursing into a stack + // overflow during decoding. + let mut pathological = vec![SIGNATURE_TYPE_MULTISIG]; + pathological.extend(nested_multisig_encoding(4096)); + assert!(TempoSignature::from_bytes(&pathological).is_err()); + } + + #[test] + fn multisig_signature_decode_rejects_invalid_init_config() { + // A bootstrap-shaped signature whose init config is structurally valid RLP but + // semantically invalid (empty owners / zero threshold) must be rejected at decode time + // instead of reaching the infallible `MultisigAddress::account()` and panicking. + let invalid_init = InitMultisig { + salt: B256::ZERO, + threshold: 0, + owners: Vec::new(), + }; + let encoded = encoded_multisig_with_init_config( + &invalid_init, + vec![valid_owner_signature_bytes().to_vec()], + ); + + let mut input = encoded.as_slice(); + assert!( + MultisigSignature::decode(&mut input).is_err(), + "decode must reject a semantically invalid init config without panicking" + ); + + // The same payload reaches the decoder through the 0x05-prefixed signature form. + let mut tempo_encoded = vec![SIGNATURE_TYPE_MULTISIG]; + tempo_encoded.extend(encoded); + assert!(TempoSignature::from_bytes(&tempo_encoded).is_err()); + } + + #[test] + fn init_multisig_decode_bounds_owner_count() { + let config = InitMultisig { + salt: B256::ZERO, + threshold: MAX_MULTISIG_THRESHOLD, + owners: (1..=MAX_MULTISIG_OWNERS as u16 + 1) + .map(|index| MultisigOwner { + owner: Address::from_word(B256::from(alloy_primitives::U256::from(index))), + weight: 1, + }) + .collect(), + }; + let mut encoded = Vec::new(); + config.encode(&mut encoded); + + let mut input = encoded.as_slice(); + assert!(matches!( + InitMultisig::decode(&mut input), + Err(alloy_rlp::Error::Custom("too many multisig owners")) + )); + } + + #[test] + fn multisig_signature_decode_bounds_approval_count() { + let encoded = encoded_multisig_without_init_slot( + Address::repeat_byte(0x11), + vec![valid_owner_signature_bytes().to_vec(); MAX_MULTISIG_SIGNATURES + 1], + ); + + let mut input = encoded.as_slice(); + assert!(matches!( + MultisigSignature::decode(&mut input), + Err(alloy_rlp::Error::Custom("too many multisig signatures")) + )); + } + + #[test] + fn multisig_signature_shape_rejects_oversized_owner_signature() { + let signature = MultisigSignature::try_new( + Address::repeat_byte(0x11), + vec![Bytes::from(vec![ + 0xaa; + MAX_MULTISIG_OWNER_SIGNATURE_BYTES + 1 + ])], + None, + ); + + assert_eq!(signature, Err("multisig owner signature too large")); + } + + #[test] + fn multisig_signature_shape_allows_nested_signature_above_primitive_byte_cap() { + let primitive = PrimitiveSignature::WebAuthn(WebAuthnSignature { + r: B256::ZERO, + s: B256::ZERO, + pub_key_x: B256::ZERO, + pub_key_y: B256::ZERO, + webauthn_data: Bytes::from(vec![0; MAX_WEBAUTHN_SIGNATURE_LENGTH - 128]), + }); + let nested = TempoSignature::Multisig(MultisigSignature::new( + Address::repeat_byte(0x22), + vec![primitive.to_bytes(), primitive.to_bytes()], + None, + )); + assert!(nested.encoded_length() > MAX_MULTISIG_OWNER_SIGNATURE_BYTES); + + let signature = + MultisigSignature::try_new(Address::repeat_byte(0x11), vec![nested.to_bytes()], None); + + assert!(signature.is_ok()); + } + + #[test] + fn multisig_signature_decode_rejects_oversized_owner_signature() { + let encoded = encoded_multisig_without_init_slot( + Address::repeat_byte(0x11), + vec![vec![0xaa; MAX_MULTISIG_OWNER_SIGNATURE_BYTES + 1]], + ); + let mut input = encoded.as_slice(); + + assert!( + MultisigSignature::decode(&mut input).is_err(), + "RLP decode should reject oversized owner approval bytes" + ); + } + + #[test] + fn tempo_signature_decode_rejects_oversized_multisig_owner_signature() { + let mut encoded = vec![SIGNATURE_TYPE_MULTISIG]; + encoded.extend(encoded_multisig_without_init_slot( + Address::repeat_byte(0x11), + vec![vec![0xaa; MAX_MULTISIG_OWNER_SIGNATURE_BYTES + 1]], + )); + + assert!( + TempoSignature::from_bytes(&encoded).is_err(), + "TempoSignature decode should reject multisig payloads with oversized owner approvals" + ); + } + + #[test] + fn multisig_signature_roundtrips_through_tempo_signature_bytes() { + let (signer, owner) = generate_secp256k1_keypair(); + let config = sorted_secp_config(&[(owner, 1)], 1); + let account = config.account().unwrap(); + let signature_hash = B256::ZERO; + let digest = multisig_digest(signature_hash, account, 0); + let signature = + MultisigSignature::new(account, vec![sign_hash(&signer, &digest).to_bytes()], None); + let tempo_signature = TempoSignature::Multisig(signature.clone()); + + let encoded = tempo_signature.to_bytes(); + assert_eq!(encoded[0], SIGNATURE_TYPE_MULTISIG); + let decoded = TempoSignature::from_bytes(&encoded).unwrap(); + assert_eq!(decoded.as_multisig(), Some(&signature)); + assert_eq!( + decoded.recover_signer(&signature_hash).unwrap(), + signature.account() + ); + } + + #[test] + fn multisig_signature_roundtrips_init_config() { + let (signer, owner) = generate_secp256k1_keypair(); + let mut config = sorted_secp_config(&[(owner, 1)], 1); + config.salt = B256::repeat_byte(0x33); + let account = config.account().unwrap(); + let signature_hash = B256::ZERO; + let digest = multisig_digest(signature_hash, account, 0); + let signatures = vec![sign_hash(&signer, &digest).to_bytes()]; + let signature = MultisigSignature::new(account, signatures.clone(), Some(config.clone())); + let tempo_signature = TempoSignature::Multisig(signature.clone()); + + let encoded = tempo_signature.to_bytes(); + assert_eq!( + &encoded[1..], + encoded_multisig_with_init_config( + &config, + signatures + .iter() + .map(|signature| signature.to_vec()) + .collect(), + ) + ); + let decoded = TempoSignature::from_bytes(&encoded).unwrap(); + assert_eq!(decoded.as_multisig(), Some(&signature)); + assert_eq!( + decoded.recover_signer(&signature_hash).unwrap(), + signature.account() + ); + } + + #[cfg(feature = "serde")] + #[test] + fn multisig_signature_serde_uses_static_wire_shapes() { + let (signer, owner) = generate_secp256k1_keypair(); + let config = sorted_secp_config(&[(owner, 1)], 1); + let account = config.account().unwrap(); + let digest = multisig_digest(B256::ZERO, account, 0); + let owner_signature = sign_hash(&signer, &digest); + let signatures = vec![owner_signature.to_bytes()]; + + let initialized = MultisigSignature::new(account, signatures.clone(), None); + let initialized_json = serde_json::to_value(&initialized).unwrap(); + assert!(initialized_json.get("account").is_some()); + assert!(initialized_json.get("init").is_none()); + let decoded: MultisigSignature = serde_json::from_value(initialized_json).unwrap(); + assert_eq!(decoded, initialized); + + let bootstrap = MultisigSignature::new(account, signatures, Some(config.clone())); + let bootstrap_json = serde_json::to_value(&bootstrap).unwrap(); + assert!(bootstrap_json.get("init").is_some()); + assert!(bootstrap_json.get("account").is_none()); + let decoded: MultisigSignature = serde_json::from_value(bootstrap_json).unwrap(); + assert_eq!(decoded, bootstrap); + + let legacy_combined_shape = serde_json::json!({ + "account": account, + "signatures": vec![owner_signature], + "init": config, + }); + assert!(serde_json::from_value::(legacy_combined_shape).is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn multisig_signature_json_bounds_nesting_during_deserialization() { + fn nested_json(depth: usize, account_json: &str, leaf_json: &str) -> String { + let prefix = format!(r#"{{"account":{account_json},"signatures":["#); + let mut json = String::with_capacity(depth * (prefix.len() + 2) + leaf_json.len()); + for _ in 0..depth { + json.push_str(&prefix); + } + json.push_str(leaf_json); + for _ in 0..depth { + json.push_str("]}"); + } + json + } + + let account_json = serde_json::to_string(&Address::repeat_byte(0x11)).unwrap(); + let primitive_json = serde_json::to_string(&TempoSignature::Primitive( + PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()), + )) + .unwrap(); + + let allowed = nested_json(MAX_MULTISIG_NESTING_DEPTH, &account_json, &primitive_json); + assert!(serde_json::from_str::(&allowed).is_ok()); + + let pathological = nested_json(4_096, &account_json, &primitive_json); + let error = serde_json::from_str::(&pathological) + .unwrap_err() + .to_string(); + assert!(error.contains("native multisig nesting depth exceeded")); + } + + #[cfg(feature = "serde")] + #[test] + fn multisig_signature_json_rejects_excess_approvals_before_decoding_them() { + let account_json = serde_json::to_string(&Address::repeat_byte(0x11)).unwrap(); + let primitive_json = serde_json::to_string(&TempoSignature::Primitive( + PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()), + )) + .unwrap(); + let mut approvals = vec![primitive_json; MAX_MULTISIG_SIGNATURES]; + approvals.push(r#""not-a-signature""#.to_string()); + let json = format!( + r#"{{"account":{account_json},"signatures":[{}]}}"#, + approvals.join(",") + ); + + let error = serde_json::from_str::(&json) + .unwrap_err() + .to_string(); + assert!(error.contains("too many multisig signatures")); + } + + #[cfg(feature = "serde")] + #[test] + fn init_multisig_json_rejects_excess_owners_before_decoding_them() { + let mut owners = (1..=MAX_MULTISIG_OWNERS as u16) + .map(|index| { + serde_json::to_value(MultisigOwner { + owner: indexed_owner(index), + weight: 1, + }) + .unwrap() + }) + .collect::>(); + owners.push(serde_json::json!("not-an-owner")); + let json = serde_json::json!({ + "salt": B256::ZERO, + "threshold": MAX_MULTISIG_THRESHOLD, + "owners": owners, + }) + .to_string(); + + let error = serde_json::from_str::(&json) + .unwrap_err() + .to_string(); + assert!(error.contains("too many multisig owners")); + } + + proptest! { + #[test] + fn proptest_multisig_signature_decode_encode_canonicalizes_accepted_raw_bytes( + raw in prop_oneof![ + proptest::collection::vec(any::(), 0..256), + ( + any::
(), + proptest::collection::vec(proptest::collection::vec(any::(), 0..128), 0..=MAX_MULTISIG_SIGNATURES), + ).prop_map(|(account, signatures)| { + encoded_multisig_without_init_slot(account, signatures) + }), + ], + ) { + let mut input = raw.as_slice(); + if let Ok(decoded) = MultisigSignature::decode(&mut input) { + prop_assert!(input.is_empty()); + + let mut reencoded = Vec::new(); + decoded.encode(&mut reencoded); + + let mut canonical_input = reencoded.as_slice(); + let canonical_decoded = MultisigSignature::decode(&mut canonical_input).unwrap(); + prop_assert!(canonical_input.is_empty()); + prop_assert_eq!(&canonical_decoded, &decoded); + + let mut canonical_reencoded = Vec::new(); + canonical_decoded.encode(&mut canonical_reencoded); + prop_assert_eq!(canonical_reencoded, reencoded); + } + } + } +} diff --git a/crates/primitives/src/transaction/tempo_transaction.rs b/crates/primitives/src/transaction/tempo_transaction.rs index 12ded3f4f2..63e84d2a0c 100644 --- a/crates/primitives/src/transaction/tempo_transaction.rs +++ b/crates/primitives/src/transaction/tempo_transaction.rs @@ -1191,19 +1191,19 @@ mod tests { // Secp256k1 (detected by 65-byte length, no type identifier) let sig1_bytes = vec![0u8; SECP256K1_SIGNATURE_LENGTH]; let sig1 = TempoSignature::from_bytes(&sig1_bytes).unwrap(); - assert_eq!(sig1.signature_type(), SignatureType::Secp256k1); + assert_eq!(sig1.signature_type(), Some(SignatureType::Secp256k1)); // P256 let mut sig2_bytes = vec![SIGNATURE_TYPE_P256]; sig2_bytes.extend_from_slice(&[0u8; P256_SIGNATURE_LENGTH]); let sig2 = TempoSignature::from_bytes(&sig2_bytes).unwrap(); - assert_eq!(sig2.signature_type(), SignatureType::P256); + assert_eq!(sig2.signature_type(), Some(SignatureType::P256)); // WebAuthn let mut sig3_bytes = vec![SIGNATURE_TYPE_WEBAUTHN]; sig3_bytes.extend_from_slice(&[0u8; 200]); let sig3 = TempoSignature::from_bytes(&sig3_bytes).unwrap(); - assert_eq!(sig3.signature_type(), SignatureType::WebAuthn); + assert_eq!(sig3.signature_type(), Some(SignatureType::WebAuthn)); } #[test] diff --git a/crates/primitives/src/transaction/tt_signature.rs b/crates/primitives/src/transaction/tt_signature.rs index e5323d6a5b..17dbc3a144 100644 --- a/crates/primitives/src/transaction/tt_signature.rs +++ b/crates/primitives/src/transaction/tt_signature.rs @@ -1,11 +1,26 @@ -use super::tempo_transaction::{ - MAX_WEBAUTHN_SIGNATURE_LENGTH, P256_SIGNATURE_LENGTH, SECP256K1_SIGNATURE_LENGTH, SignatureType, +#[cfg(feature = "serde")] +use super::multisig::{InitMultisig, MAX_MULTISIG_NESTING_DEPTH, MAX_MULTISIG_SIGNATURES}; +use super::{ + multisig::{MultisigSignature, SIGNATURE_TYPE_MULTISIG}, + tempo_transaction::{ + MAX_WEBAUTHN_SIGNATURE_LENGTH, P256_SIGNATURE_LENGTH, SECP256K1_SIGNATURE_LENGTH, + SignatureType, + }, }; +#[cfg(feature = "serde")] +use alloc::string::String; use alloc::vec::Vec; use alloy_primitives::{Address, B256, Bytes, Signature, U256, keccak256, uint}; +use alloy_rlp::Encodable; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use sha2::{Digest, Sha256}; +#[cfg(feature = "serde")] +use serde::{ + Deserialize, Deserializer, + de::{DeserializeSeed, Error as _, MapAccess, SeqAccess, Visitor}, +}; + // Always mark `p256` as used to avoid `unused_crate_dependencies` warnings in `std` builds. use p256 as _; @@ -108,6 +123,11 @@ fn split_p256_signature_fields( let (pub_key_y, pre_hash) = sig_data .split_first_chunk::<32>() .expect("P256 signature length checked"); + // Any nonzero flag byte decodes as pre_hash=true. This lenient decoding matches the behavior + // of the deployed SignatureVerifier precompile and payment-lane classifier, which decode + // verbatim on-chain calldata; rejecting noncanonical flag bytes here would be STF-breaking. + // Noncanonical bytes cannot malleate transaction hashes because signatures are re-encoded + // canonically (`to_bytes` emits `0x01` for true) before hashing. (r, s, pub_key_x, pub_key_y, pre_hash[0] != 0) } @@ -557,7 +577,7 @@ impl<'a> arbitrary::Arbitrary<'a> for KeychainSignature { /// /// Note: Uses custom Compact implementation that delegates to `to_bytes()` / `from_bytes()`. #[derive(Clone, Debug, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] #[cfg_attr(feature = "serde", serde(untagged, rename_all = "camelCase"))] #[cfg_attr(any(test, feature = "arbitrary"), derive(arbitrary::Arbitrary))] #[cfg_attr(test, reth_codecs::add_arbitrary_tests(compact, rlp))] @@ -570,6 +590,245 @@ pub enum TempoSignature { /// IMP: The inner signature MUST NOT be another Keychain (validated at runtime) /// Note: Recursion is prevented by KeychainSignature's custom Arbitrary impl Keychain(KeychainSignature), + + /// Native multisig signature. + Multisig(MultisigSignature), +} + +#[cfg(feature = "serde")] +#[derive(Deserialize)] +#[serde(untagged)] +enum NonMultisigSignatureSerde { + Primitive(PrimitiveSignature), + Keychain(KeychainSignature), +} + +#[cfg(feature = "serde")] +impl From for TempoSignature { + fn from(signature: NonMultisigSignatureSerde) -> Self { + match signature { + NonMultisigSignatureSerde::Primitive(signature) => Self::Primitive(signature), + NonMultisigSignatureSerde::Keychain(signature) => Self::Keychain(signature), + } + } +} + +#[cfg(feature = "serde")] +#[derive(Deserialize)] +#[serde(untagged)] +enum TempoSignatureDeserializeWire { + Primitive(PrimitiveSignature), + Keychain(KeychainSignature), + Multisig(MultisigSignature), +} + +#[cfg(feature = "serde")] +struct TempoSignatureSeed { + depth: usize, +} + +#[cfg(feature = "serde")] +impl<'de> DeserializeSeed<'de> for TempoSignatureSeed { + type Value = TempoSignature; + + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + if deserializer.is_human_readable() { + deserializer.deserialize_map(TempoSignatureVisitor { depth: self.depth }) + } else { + Ok( + match TempoSignatureDeserializeWire::deserialize(deserializer)? { + TempoSignatureDeserializeWire::Primitive(signature) => { + TempoSignature::Primitive(signature) + } + TempoSignatureDeserializeWire::Keychain(signature) => { + TempoSignature::Keychain(signature) + } + TempoSignatureDeserializeWire::Multisig(signature) => { + TempoSignature::Multisig(signature) + } + }, + ) + } + } +} + +#[cfg(feature = "serde")] +struct RejectExtraSignature; + +#[cfg(feature = "serde")] +impl<'de> DeserializeSeed<'de> for RejectExtraSignature { + type Value = (); + + fn deserialize(self, _deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Err(D::Error::custom("too many multisig signatures")) + } +} + +#[cfg(feature = "serde")] +struct MultisigSignaturesSeed { + depth: usize, +} + +#[cfg(feature = "serde")] +impl<'de> DeserializeSeed<'de> for MultisigSignaturesSeed { + type Value = Vec; + + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct SignaturesVisitor { + depth: usize, + } + + impl<'de> Visitor<'de> for SignaturesVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + formatter, + "at most {MAX_MULTISIG_SIGNATURES} multisig owner signatures" + ) + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + if seq + .size_hint() + .is_some_and(|size| size > MAX_MULTISIG_SIGNATURES) + { + return Err(A::Error::custom("too many multisig signatures")); + } + + let mut signatures = Vec::with_capacity( + seq.size_hint() + .unwrap_or_default() + .min(MAX_MULTISIG_SIGNATURES), + ); + while signatures.len() < MAX_MULTISIG_SIGNATURES { + let Some(signature) = + seq.next_element_seed(TempoSignatureSeed { depth: self.depth })? + else { + return Ok(signatures); + }; + signatures.push(signature); + } + + match seq.next_element_seed(RejectExtraSignature)? { + None => Ok(signatures), + Some(()) => unreachable!("reject seed never returns a value"), + } + } + } + + deserializer.deserialize_seq(SignaturesVisitor { depth: self.depth }) + } +} + +#[cfg(feature = "serde")] +struct TempoSignatureVisitor { + depth: usize, +} + +#[cfg(feature = "serde")] +impl<'de> Visitor<'de> for TempoSignatureVisitor { + type Value = TempoSignature; + + fn expecting(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter.write_str("a Tempo signature object") + } + + fn visit_map(self, mut map: M) -> Result + where + M: MapAccess<'de>, + { + let mut non_multisig_fields = serde_json::Map::new(); + let mut account = None; + let mut init = None; + let mut signatures = None; + + while let Some(field) = map.next_key::()? { + match field.as_str() { + "account" => { + if self.depth > MAX_MULTISIG_NESTING_DEPTH { + return Err(M::Error::custom("native multisig nesting depth exceeded")); + } + if account.is_some() { + return Err(M::Error::duplicate_field("account")); + } + account = Some(map.next_value()?); + } + "init" => { + if self.depth > MAX_MULTISIG_NESTING_DEPTH { + return Err(M::Error::custom("native multisig nesting depth exceeded")); + } + if init.is_some() { + return Err(M::Error::duplicate_field("init")); + } + init = Some(map.next_value::()?); + } + "signatures" => { + if self.depth > MAX_MULTISIG_NESTING_DEPTH { + return Err(M::Error::custom("native multisig nesting depth exceeded")); + } + if signatures.is_some() { + return Err(M::Error::duplicate_field("signatures")); + } + signatures = Some(map.next_value_seed(MultisigSignaturesSeed { + depth: self.depth + 1, + })?); + } + _ => { + let value = map.next_value()?; + if non_multisig_fields.insert(field, value).is_some() { + return Err(M::Error::custom("duplicate Tempo signature field")); + } + } + } + } + + let has_multisig_fields = account.is_some() || init.is_some() || signatures.is_some(); + if !has_multisig_fields { + return serde_json::from_value::(serde_json::Value::Object( + non_multisig_fields, + )) + .map(TempoSignature::from) + .map_err(M::Error::custom); + } + if !non_multisig_fields.is_empty() { + return Err(M::Error::custom("mixed Tempo signature fields")); + } + + let signatures = signatures.ok_or_else(|| M::Error::missing_field("signatures"))?; + match (account, init) { + (Some(account), None) => MultisigSignature::from_decoded(account, signatures, None), + (None, Some(init)) => { + let account = init.account().map_err(M::Error::custom)?; + MultisigSignature::from_decoded(account, signatures, Some(init)) + } + _ => Err("multisig signature requires exactly one of account or init"), + } + .map(TempoSignature::Multisig) + .map_err(M::Error::custom) + } +} + +#[cfg(feature = "serde")] +impl<'de> Deserialize<'de> for TempoSignature { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + TempoSignatureSeed { depth: 1 }.deserialize(deserializer) + } } impl TempoSignature { @@ -579,14 +838,36 @@ impl TempoSignature { /// - If length is 65 bytes: treat as secp256k1 signature (no type identifier) /// - Otherwise: first byte is the signature type identifier pub fn from_bytes(data: &[u8]) -> Result { + Self::from_bytes_with_depth(data, 1) + } + + /// Parses a signature while tracking native multisig nesting `depth`. + /// + /// The top-level signature is depth `1`; each nested owner approval is parsed one level deeper. + /// [`MultisigSignature::decode_with_depth`] rejects nodes past + /// [`crate::transaction::MAX_MULTISIG_NESTING_DEPTH`], + /// so deeply nested untrusted input cannot recurse the parser into a stack overflow. + fn from_bytes_with_depth(data: &[u8], depth: usize) -> Result { if data.is_empty() { return Err("Signature data is empty"); } - // Check if this is a Keychain signature (type identifier 0x03 or 0x04) - // We need to handle this specially before delegating to PrimitiveSignature + if data.len() == SECP256K1_SIGNATURE_LENGTH { + return PrimitiveSignature::from_bytes(data).map(Self::Primitive); + } + + if data.len() > 1 && data[0] == SIGNATURE_TYPE_MULTISIG { + let mut sig_data = &data[1..]; + match MultisigSignature::decode_with_depth(&mut sig_data, depth) { + Ok(signature) if sig_data.is_empty() => return Ok(Self::Multisig(signature)), + _ => return Err("Invalid Multisig signature RLP"), + } + } + + // Check if this is a Keychain signature before delegating to + // PrimitiveSignature. The exact 65-byte secp256k1 path remains untyped for + // backwards compatibility. if data.len() > 1 - && data.len() != SECP256K1_SIGNATURE_LENGTH && (data[0] == SIGNATURE_TYPE_KEYCHAIN || data[0] == SIGNATURE_TYPE_KEYCHAIN_V2) { let version = if data[0] == SIGNATURE_TYPE_KEYCHAIN { @@ -621,6 +902,15 @@ impl TempoSignature { Ok(Self::Primitive(primitive)) } + /// Decodes one RLP-encoded owner approval at the given native multisig nesting `depth`. + /// + /// Owner approvals are length-prefixed byte strings; this preserves the depth so nested + /// multisig approvals stay bounded by [`MultisigSignature::decode_with_depth`]. + pub(crate) fn decode_with_depth(buf: &mut &[u8], depth: usize) -> alloy_rlp::Result { + let bytes: Bytes = alloy_rlp::Decodable::decode(buf)?; + Self::from_bytes_with_depth(&bytes, depth).map_err(alloy_rlp::Error::Custom) + } + /// Encode signature to bytes /// /// For backward compatibility: @@ -642,6 +932,12 @@ impl TempoSignature { bytes.extend_from_slice(&inner_bytes); Bytes::from(bytes) } + Self::Multisig(multisig_sig) => { + let mut bytes = Vec::with_capacity(1 + multisig_sig.length()); + bytes.push(SIGNATURE_TYPE_MULTISIG); + multisig_sig.encode(&mut bytes); + Bytes::from(bytes) + } } } @@ -654,14 +950,16 @@ impl TempoSignature { match self { Self::Primitive(primitive_sig) => primitive_sig.encoded_length(), Self::Keychain(keychain_sig) => 1 + 20 + keychain_sig.signature.encoded_length(), + Self::Multisig(multisig_sig) => 1 + multisig_sig.length(), } } - /// Get signature type - pub fn signature_type(&self) -> SignatureType { + /// Get the primitive signature type, if the outer signature has one. + pub fn signature_type(&self) -> Option { match self { - Self::Primitive(primitive_sig) => primitive_sig.signature_type(), - Self::Keychain(keychain_sig) => keychain_sig.signature.signature_type(), + Self::Primitive(primitive_sig) => Some(primitive_sig.signature_type()), + Self::Keychain(keychain_sig) => Some(keychain_sig.signature.signature_type()), + Self::Multisig(_) => None, } } @@ -670,6 +968,7 @@ impl TempoSignature { match self { Self::Primitive(primitive_sig) => primitive_sig.size(), Self::Keychain(keychain_sig) => 1 + 20 + keychain_sig.signature.size(), + Self::Multisig(multisig_sig) => 1 + multisig_sig.size(), } } @@ -687,6 +986,12 @@ impl TempoSignature { /// that the signature is valid for the keychain. They also need to check the access key is authorized /// in the keychain precompile. /// We cannot check this here, as we don't have access to the keychain precompile. + /// + /// - Multisig: returns the derived/claimed native multisig account after stateless shape + /// checks only. It does NOT verify owner approvals or that the owner-weight threshold is met. + /// This is the same footgun as Keychain: callers must not treat a returned account as an + /// authorized transaction. Owner-threshold verification is stateful and happens in the native + /// multisig verifier (`NativeMultisig::verify_authorization`), which needs the stored config. pub fn recover_signer( &self, sig_hash: &B256, @@ -700,6 +1005,9 @@ impl TempoSignature { // Return the user_address - the root account this transaction is for Ok(keychain_sig.user_address) } + Self::Multisig(multisig_sig) => multisig_sig + .recover_account() + .map_err(|_| alloy_consensus::crypto::RecoveryError::new()), } } @@ -708,6 +1016,11 @@ impl TempoSignature { matches!(self, Self::Keychain(_)) } + /// Check if this is a native multisig signature. + pub fn is_multisig(&self) -> bool { + matches!(self, Self::Multisig(_)) + } + /// Check if this is a legacy V1 Keychain signature (deprecated at T1C). pub fn is_legacy_keychain(&self) -> bool { matches!(self, Self::Keychain(k) if k.is_legacy()) @@ -745,6 +1058,22 @@ impl TempoSignature { _ => None, } } + + /// Get the primitive signature if this is a primitive signature. + pub fn as_primitive(&self) -> Option<&PrimitiveSignature> { + match self { + Self::Primitive(signature) => Some(signature), + _ => None, + } + } + + /// Get the native multisig signature if this is a multisig signature. + pub fn as_multisig(&self) -> Option<&MultisigSignature> { + match self { + Self::Multisig(multisig_sig) => Some(multisig_sig), + _ => None, + } + } } impl Default for TempoSignature { @@ -781,6 +1110,12 @@ impl From for TempoSignature { } } +impl From for TempoSignature { + fn from(signature: PrimitiveSignature) -> Self { + Self::Primitive(signature) + } +} + // ============================================================================ // Helper Functions for Signature Verification // ============================================================================ @@ -1030,6 +1365,10 @@ mod tests { (signing_key, pub_key_x, pub_key_y) } + fn valid_multisig_owner_signature_bytes() -> Bytes { + PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()).to_bytes() + } + /// Sign a message hash with P256, normalize s, return (r, s) fn sign_p256_normalized(signing_key: &P256SigningKey, message_hash: &B256) -> (B256, B256) { let signature: p256::ecdsa::Signature = @@ -1415,6 +1754,28 @@ mod tests { } } + #[test] + fn test_tempo_signature_65_byte_multisig_shape_decodes_as_secp256k1() { + let account = Address::repeat_byte(0x11); + let signatures = vec![Bytes::from(vec![0x33; 39])]; + let payload_length = account.length() + signatures.length(); + + let mut sig_bytes = vec![SIGNATURE_TYPE_MULTISIG]; + alloy_rlp::Header { + list: true, + payload_length, + } + .encode(&mut sig_bytes); + account.encode(&mut sig_bytes); + signatures.encode(&mut sig_bytes); + + assert_eq!(sig_bytes.len(), SECP256K1_SIGNATURE_LENGTH); + assert!(matches!( + TempoSignature::from_bytes(&sig_bytes).unwrap(), + TempoSignature::Primitive(PrimitiveSignature::Secp256k1(_)) + )); + } + #[test] fn test_tempo_signature_from_bytes_p256() { use super::{P256_SIGNATURE_LENGTH, SIGNATURE_TYPE_P256}; @@ -1431,6 +1792,27 @@ mod tests { } } + #[test] + fn test_p256_from_bytes_canonicalizes_prehash_flag() { + // Decoding is lenient (any nonzero flag byte means pre_hash=true), matching the deployed + // network, and re-encoding is canonical, so noncanonical flag bytes cannot malleate hashes. + let mut sig_bytes = vec![SIGNATURE_TYPE_P256]; + sig_bytes.extend_from_slice(&[0u8; P256_SIGNATURE_LENGTH]); + + sig_bytes[1 + P256_SIGNATURE_LENGTH - 1] = 0; + let decoded = TempoSignature::from_bytes(&sig_bytes).expect("flag 0 decodes"); + assert_eq!(decoded.to_bytes(), Bytes::from(sig_bytes.clone())); + + sig_bytes[1 + P256_SIGNATURE_LENGTH - 1] = 2; + let decoded = TempoSignature::from_bytes(&sig_bytes).expect("noncanonical flag decodes"); + let reencoded = decoded.to_bytes(); + assert_eq!( + reencoded[reencoded.len() - 1], + 1, + "noncanonical pre_hash flag re-encodes to the canonical 0x01" + ); + } + #[test] fn test_tempo_signature_from_bytes_webauthn() { use super::SIGNATURE_TYPE_WEBAUTHN; @@ -1508,7 +1890,9 @@ mod tests { // Test P256 let mut sig2_bytes = vec![SIGNATURE_TYPE_P256]; - sig2_bytes.extend_from_slice(&[2u8; P256_SIGNATURE_LENGTH]); + let mut p256_payload = [2u8; P256_SIGNATURE_LENGTH]; + p256_payload[128] = 1; + sig2_bytes.extend_from_slice(&p256_payload); let sig2 = TempoSignature::from_bytes(&sig2_bytes).unwrap(); let encoded2 = sig2.to_bytes(); assert_eq!(encoded2.len(), 1 + P256_SIGNATURE_LENGTH); @@ -1877,6 +2261,43 @@ mod tests { ); } + #[test] + fn test_signature_type_is_none_for_multisig() { + let signature = TempoSignature::Multisig(MultisigSignature::new( + Address::repeat_byte(0x11), + vec![valid_multisig_owner_signature_bytes()], + None, + )); + + assert_eq!(signature.signature_type(), None); + } + + #[test] + fn test_recover_signer_multisig_only_recovers_account() { + use crate::transaction::{InitMultisig, MultisigOwner}; + + let config = InitMultisig { + salt: B256::repeat_byte(0x42), + threshold: 1, + owners: vec![MultisigOwner { + owner: Address::repeat_byte(0x11), + weight: 1, + }], + }; + let account = config.account().unwrap(); + let inner_hash = B256::repeat_byte(0x24); + let signature = TempoSignature::Multisig(MultisigSignature::new( + account, + vec![valid_multisig_owner_signature_bytes()], + Some(config), + )); + + // recover_signer returns the claimed account after stateless shape checks only; it does + // not verify owner approvals. Stateful owner-threshold verification is exercised by the + // native multisig precompile tests. + assert_eq!(signature.recover_signer(&inner_hash).unwrap(), account); + } + #[test] fn test_signing_hash_properties() { let hash_a = B256::from([0x11; 32]); diff --git a/crates/revm/src/error.rs b/crates/revm/src/error.rs index 938da8cefd..2ffded2a74 100644 --- a/crates/revm/src/error.rs +++ b/crates/revm/src/error.rs @@ -236,6 +236,10 @@ pub enum TempoInvalidTransaction { #[error("keychain operations are not supported in subblock transactions")] KeychainOpInSubblockTransaction, + /// Native multisig transactions are not active. + #[error("native multisig transactions are not active")] + NativeMultisigNotActive, + /// Fee payment error. #[error(transparent)] CollectFeePreTx(#[from] FeePaymentError), @@ -325,6 +329,7 @@ impl TempoInvalidTransaction { | Self::AccessKeyExpiryInPast { .. } | Self::KeychainPrecompileError { .. } | Self::KeychainValidationFailed { .. } + | Self::NativeMultisigNotActive | Self::CollectFeePreTx(_) | Self::NonceManagerError(_) | Self::V2KeychainBeforeActivation => false, diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 1b27e1bc58..f924409982 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1800,6 +1800,10 @@ where ) .map_err(TempoInvalidTransaction::from)?; + if aa_env.signature.is_multisig() { + return Err(TempoInvalidTransaction::NativeMultisigNotActive.into()); + } + // Access-key CREATE is a cheap structural rejection that does not depend on any // per-call scope walk or state mutation. Rejecting it here keeps validation work // constant and avoids entering CREATE execution paths that require special protocol- diff --git a/crates/revm/src/handler/tests.rs b/crates/revm/src/handler/tests.rs index 01f3b4d771..c03ceb6241 100644 --- a/crates/revm/src/handler/tests.rs +++ b/crates/revm/src/handler/tests.rs @@ -24,8 +24,8 @@ use tempo_precompiles::{ tip_fee_manager::TipFeeManager, }; use tempo_primitives::transaction::{ - Call, PrimitiveSignature, RecoveredTempoAuthorization, TempoSignature, - TempoSignedAuthorization, + Call, InitMultisig, MultisigOwner, MultisigSignature, PrimitiveSignature, + RecoveredTempoAuthorization, TempoSignature, TempoSignedAuthorization, tt_signature::{P256SignatureWithPreHash, WebAuthnSignature}, }; @@ -4477,3 +4477,39 @@ fn test_state_gas_failed_batch_preserves_upfront_create_intrinsic_gas() { assert_eq!(result.gas().state_gas_spent(), 0); assert_eq!(result.gas().reservoir(), 0); } + +#[test] +fn native_multisig_execution_remains_inactive() { + let config = InitMultisig { + salt: B256::ZERO, + threshold: 1, + owners: vec![MultisigOwner { + owner: Address::repeat_byte(0x11), + weight: 1, + }], + }; + let account = config.account().unwrap(); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Multisig(MultisigSignature::new( + account, + vec![Bytes::from_static(&[0xaa; 65])], + Some(config), + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = account; + }); + + assert!(matches!( + test.validate_env(), + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigNotActive + )) + )); +} diff --git a/crates/revm/src/signature_gas.rs b/crates/revm/src/signature_gas.rs index 6a79642216..aa7b9484ba 100644 --- a/crates/revm/src/signature_gas.rs +++ b/crates/revm/src/signature_gas.rs @@ -41,5 +41,7 @@ pub(crate) fn tempo_signature_verification_gas(signature: &TempoSignature) -> u6 TempoSignature::Keychain(keychain_sig) => { primitive_signature_verification_gas(&keychain_sig.signature) + KEYCHAIN_VALIDATION_GAS } + // Native multisig transactions are rejected before intrinsic gas is calculated. + TempoSignature::Multisig(_) => 0, } } From c60bc625c2b4b685a4ce3be8dfd20c4860ca9898 Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:39:55 +0200 Subject: [PATCH 2/7] ci: pin multisig-compatible Foundry revision --- .github/workflows/specs.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml index 0f5b8a8ad3..7046902b49 100644 --- a/.github/workflows/specs.yml +++ b/.github/workflows/specs.yml @@ -112,7 +112,8 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: foundry-rs/foundry - ref: master + # Temporary: foundry-rs/foundry#16213 pins tempoxyz/mpp-rs#391 at 5d2fdf7. + ref: 607d560005bec7972df15e4f231b5fe33b11e7b6 path: foundry persist-credentials: false @@ -260,7 +261,8 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: foundry-rs/foundry - ref: master + # Temporary: foundry-rs/foundry#16213 pins tempoxyz/mpp-rs#391 at 5d2fdf7. + ref: 607d560005bec7972df15e4f231b5fe33b11e7b6 path: foundry persist-credentials: false From 977114a74805bc6b51cb26a4e9809695bb4ab054 Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:21:59 +0200 Subject: [PATCH 3/7] fix(primitives): reject multisig EIP-7702 authorities --- .../src/transaction/tt_authorization.rs | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/primitives/src/transaction/tt_authorization.rs b/crates/primitives/src/transaction/tt_authorization.rs index f37b59eb6f..109662b23c 100644 --- a/crates/primitives/src/transaction/tt_authorization.rs +++ b/crates/primitives/src/transaction/tt_authorization.rs @@ -80,7 +80,13 @@ impl TempoSignedAuthorization { /// # Note /// /// Implementers should check that the authority has no code. + /// Native multisig signatures are rejected because their claimed account cannot be + /// authenticated without stateful owner and threshold validation. pub fn recover_authority(&self) -> Result { + if self.signature.is_multisig() { + return Err(alloy_consensus::crypto::RecoveryError::new()); + } + let sig_hash = self.signature_hash(); self.signature.recover_signer(&sig_hash) } @@ -309,7 +315,7 @@ impl AuthorizationTr for RecoveredTempoAuthorization { #[cfg(test)] pub mod tests { use super::*; - use crate::TempoSignature; + use crate::{TempoSignature, transaction::MultisigSignature}; use alloy_primitives::{U256, address}; use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; @@ -449,4 +455,28 @@ pub mod tests { assert!(bad_lazy.authority().is_some()); assert_ne!(bad_lazy.authority().unwrap(), expected_address); } + + #[test] + fn test_multisig_cannot_recover_eip7702_authority() { + let claimed_account = Address::random(); + let auth = Authorization { + chain_id: U256::ONE, + address: Address::random(), + nonce: 1, + }; + let signature = TempoSignature::Multisig(MultisigSignature::new( + claimed_account, + vec![TempoSignature::default().to_bytes()], + None, + )); + let signed = TempoSignedAuthorization::new_unchecked(auth, signature); + + assert!(signed.recover_authority().is_err()); + assert!(signed.clone().into_recovered().authority().is_none()); + assert!( + RecoveredTempoAuthorization::new(signed) + .authority() + .is_none() + ); + } } From 76ef153c5c736ab0ca7d6c96d50e34a615603936 Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:32:18 +0200 Subject: [PATCH 4/7] refactor(primitives): simplify multisig invariants --- crates/primitives/src/transaction/multisig.rs | 72 +++++++++---------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/crates/primitives/src/transaction/multisig.rs b/crates/primitives/src/transaction/multisig.rs index 5d0fdd0828..eff7ea289a 100644 --- a/crates/primitives/src/transaction/multisig.rs +++ b/crates/primitives/src/transaction/multisig.rs @@ -4,6 +4,7 @@ use alloc::{ vec::Vec, }; use alloy_primitives::{Address, B256, Bytes, keccak256}; +use alloy_rlp::Encodable as _; use core::{ hash::{Hash, Hasher}, mem::size_of, @@ -54,8 +55,6 @@ pub enum MultisigConfigError { TooManyOwners, /// The threshold is zero. ZeroThreshold, - /// The threshold exceeds [`MAX_MULTISIG_THRESHOLD`]. - ThresholdTooHigh, /// An owner address is zero. ZeroOwner, /// An owner weight is zero. @@ -64,8 +63,6 @@ pub enum MultisigConfigError { DuplicateOwner, /// The owner list is not strictly ascending. OwnersNotAscending, - /// Owner weight accumulation overflowed. - WeightOverflow, /// Total owner weight exceeds `u8::MAX`. TotalWeightExceedsMax, /// The threshold exceeds the weight reachable within the approval limit. @@ -81,12 +78,10 @@ impl MultisigConfigError { Self::EmptyOwners => "multisig owners cannot be empty", Self::TooManyOwners => "too many multisig owners", Self::ZeroThreshold => "multisig threshold cannot be zero", - Self::ThresholdTooHigh => "multisig threshold exceeds max threshold", Self::ZeroOwner => "multisig owner cannot be zero", Self::ZeroWeight => "multisig owner weight cannot be zero", Self::DuplicateOwner => "multisig owners cannot contain duplicates", Self::OwnersNotAscending => "multisig owners must be strictly ascending", - Self::WeightOverflow => "multisig owner weight overflow", Self::TotalWeightExceedsMax => "multisig total owner weight exceeds u8::MAX", Self::ThresholdExceedsWeight => { "multisig threshold cannot be reached within the owner approval limit" @@ -174,7 +169,7 @@ impl MultisigWeightAccumulator { } /// Records one recovered owner address and its configured weight. - pub fn record_owner(&mut self, owner: Address, weight: u8) -> Result { + pub fn record_owner(&mut self, owner: Address, weight: u8) -> Result<(), MultisigQuorumError> { self.signer_count = self .signer_count .checked_add(1) @@ -188,11 +183,8 @@ impl MultisigWeightAccumulator { } self.prev_owner = Some(owner); - self.recovered_weight = self - .recovered_weight - .checked_add(u16::from(weight)) - .ok_or(MultisigQuorumError::WeightOverflow)?; - Ok(weight) + self.recovered_weight += u16::from(weight); + Ok(()) } /// Returns whether the accumulated weight satisfies the configured threshold. @@ -427,9 +419,7 @@ impl InitMultisig { } prev_owner = Some(owner.owner); - total_weight = total_weight - .checked_add(u16::from(owner.weight)) - .ok_or(MultisigConfigError::WeightOverflow)?; + total_weight += u16::from(owner.weight); if owner.weight > approval_weights[0] { approval_weights[0] = owner.weight; approval_weights.sort_unstable(); @@ -607,7 +597,7 @@ impl MultisigSignature { init: Option, ) -> Result { let address = MultisigAddress::from_parts(account, init)?; - Self::from_decoded_address(address, signatures) + Self::from_validated_address(address, signatures) } fn from_decoded_address( @@ -620,6 +610,13 @@ impl MultisigSignature { if let MultisigAddress::Init(init) = &address { init.account().map_err(MultisigConfigError::as_str)?; } + Self::from_validated_address(address, signatures) + } + + fn from_validated_address( + address: MultisigAddress, + signatures: Vec, + ) -> Result { let signature = Self { address, signatures, @@ -731,6 +728,13 @@ impl MultisigSignature { .map(TempoSignature::size) .sum::() } + + fn rlp_payload_length(&self) -> usize { + (match &self.address { + MultisigAddress::Initialized(account) => account.length(), + MultisigAddress::Init(init) => init.length(), + }) + self.signatures.length() + } } impl PartialEq for MultisigSignature { @@ -872,33 +876,27 @@ impl alloy_rlp::Decodable for MultisigSignature { impl alloy_rlp::Encodable for MultisigSignature { fn encode(&self, out: &mut dyn alloy_rlp::BufMut) { + let payload_length = self.rlp_payload_length(); + alloy_rlp::Header { + list: true, + payload_length, + } + .encode(out); match &self.address { - MultisigAddress::Initialized(account) => InitializedMultisigSignatureWire { - account: *account, - signatures: self.signatures.clone(), - } - .encode(out), - MultisigAddress::Init(init) => InitMultisigSignatureWire { - init: init.clone(), - signatures: self.signatures.clone(), - } - .encode(out), + MultisigAddress::Initialized(account) => account.encode(out), + MultisigAddress::Init(init) => init.encode(out), } + self.signatures.encode(out); } fn length(&self) -> usize { - match &self.address { - MultisigAddress::Initialized(account) => InitializedMultisigSignatureWire { - account: *account, - signatures: self.signatures.clone(), - } - .length(), - MultisigAddress::Init(init) => InitMultisigSignatureWire { - init: init.clone(), - signatures: self.signatures.clone(), - } - .length(), + let payload_length = self.rlp_payload_length(); + alloy_rlp::Header { + list: true, + payload_length, } + .length() + + payload_length } } From 921b193f8ce54e7aec12ed89e7a5113333af6fe4 Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:39:47 +0200 Subject: [PATCH 5/7] fix(primitives): enforce multisig shape invariants --- crates/primitives/src/transaction/multisig.rs | 94 +++++++++++++++++-- crates/revm/src/handler.rs | 7 +- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/crates/primitives/src/transaction/multisig.rs b/crates/primitives/src/transaction/multisig.rs index eff7ea289a..680292c5f2 100644 --- a/crates/primitives/src/transaction/multisig.rs +++ b/crates/primitives/src/transaction/multisig.rs @@ -20,7 +20,36 @@ use serde::{ #[cfg(not(feature = "std"))] use once_cell::race::OnceBox as OnceLock; #[cfg(feature = "std")] -use std::sync::OnceLock; +use std::{cell::Cell, sync::OnceLock}; + +#[cfg(all(feature = "serde", feature = "std"))] +std::thread_local! { + static BINARY_MULTISIG_DESERIALIZE_DEPTH: Cell = const { Cell::new(0) }; +} + +#[cfg(all(feature = "serde", feature = "std"))] +struct BinaryMultisigDepthGuard; + +#[cfg(all(feature = "serde", feature = "std"))] +impl BinaryMultisigDepthGuard { + fn enter() -> Result { + BINARY_MULTISIG_DESERIALIZE_DEPTH.with(|depth| { + let next = depth.get() + 1; + if next > MAX_MULTISIG_NESTING_DEPTH { + return Err(E::custom("native multisig nesting depth exceeded")); + } + depth.set(next); + Ok(Self) + }) + } +} + +#[cfg(all(feature = "serde", feature = "std"))] +impl Drop for BinaryMultisigDepthGuard { + fn drop(&mut self) { + BINARY_MULTISIG_DESERIALIZE_DEPTH.with(|depth| depth.set(depth.get() - 1)); + } +} /// Tempo signature type byte for native multisig signatures. pub const SIGNATURE_TYPE_MULTISIG: u8 = 0x05; @@ -69,6 +98,8 @@ pub enum MultisigConfigError { ThresholdExceedsWeight, /// The derived multisig account address is zero. DerivedAccountZero, + /// The multisig account is included in its own owner set. + AccountIsOwner, } impl MultisigConfigError { @@ -87,6 +118,7 @@ impl MultisigConfigError { "multisig threshold cannot be reached within the owner approval limit" } Self::DerivedAccountZero => "multisig account cannot be zero", + Self::AccountIsOwner => "multisig account cannot own itself", } } } @@ -437,6 +469,15 @@ impl InitMultisig { Ok(total_weight as u8) } + /// Validates this config for an existing multisig account. + pub fn validate_for_account(&self, account: Address) -> Result { + let total_weight = self.validate()?; + if self.owner_weight(account).is_some() { + return Err(MultisigConfigError::AccountIsOwner); + } + Ok(total_weight) + } + /// Derives the native multisig account address for this initial config. pub fn account(&self) -> Result { self.validate()?; @@ -459,6 +500,9 @@ impl InitMultisig { if account.is_zero() { return Err(MultisigConfigError::DerivedAccountZero); } + if self.owner_weight(account).is_some() { + return Err(MultisigConfigError::AccountIsOwner); + } Ok(account) } @@ -663,13 +707,18 @@ impl MultisigSignature { if self.signatures.len() > MAX_MULTISIG_SIGNATURES { return Err("too many multisig signatures"); } - if self - .signatures - .iter() - .filter_map(TempoSignature::as_primitive) - .any(|sig| sig.encoded_length() > MAX_MULTISIG_OWNER_SIGNATURE_BYTES) - { - return Err("multisig owner signature too large"); + for signature in &self.signatures { + match signature { + TempoSignature::Primitive(signature) + if signature.encoded_length() > MAX_MULTISIG_OWNER_SIGNATURE_BYTES => + { + return Err("multisig owner signature too large"); + } + TempoSignature::Keychain(_) => { + return Err("keychain signatures cannot authorize native multisig owners"); + } + TempoSignature::Primitive(_) | TempoSignature::Multisig(_) => {} + } } Ok(()) } @@ -796,6 +845,8 @@ impl<'de> Deserialize<'de> for MultisigSignature { } } } else { + #[cfg(feature = "std")] + let _depth_guard = BinaryMultisigDepthGuard::enter::()?; let wire = MultisigSignatureSerde::deserialize(deserializer)?; Self::try_from(wire).map_err(D::Error::custom) } @@ -997,7 +1048,7 @@ impl<'a> arbitrary::Arbitrary<'a> for MultisigSignature { mod tests { use super::*; use crate::transaction::{ - PrimitiveSignature, TempoSignature, derive_p256_address, + KeychainSignature, PrimitiveSignature, TempoSignature, derive_p256_address, tt_authorization::tests::{generate_secp256k1_keypair, sign_hash}, tt_signature::{P256SignatureWithPreHash, WebAuthnSignature, normalize_p256_s}, }; @@ -1262,6 +1313,31 @@ mod tests { ); } + #[test] + fn config_rejects_own_account_as_owner() { + let account = indexed_owner(1); + let config = sorted_secp_config(&[(account, 1)], 1); + + assert_eq!( + config.validate_for_account(account), + Err(MultisigConfigError::AccountIsOwner) + ); + } + + #[test] + fn multisig_shape_rejects_keychain_owner_approval() { + let account = indexed_owner(1); + let approval = TempoSignature::Keychain(KeychainSignature::new( + indexed_owner(2), + PrimitiveSignature::default(), + )); + + assert_eq!( + MultisigSignature::from_decoded(account, vec![approval], None), + Err("keychain signatures cannot authorize native multisig owners") + ); + } + #[test] fn shared_quorum_helpers_verify_order_and_threshold() { let owner_a = indexed_owner(1); diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index f924409982..bf3a1be45f 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1800,7 +1800,12 @@ where ) .map_err(TempoInvalidTransaction::from)?; - if aa_env.signature.is_multisig() { + if aa_env.signature.is_multisig() + || aa_env + .tempo_authorization_list + .iter() + .any(|authorization| authorization.signature().is_multisig()) + { return Err(TempoInvalidTransaction::NativeMultisigNotActive.into()); } From d718839b7e849f44d9a59feca4276af79a019adc Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:57:59 +0200 Subject: [PATCH 6/7] fix(primitives): validate nested multisig construction --- crates/primitives/src/transaction/multisig.rs | 88 ++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/crates/primitives/src/transaction/multisig.rs b/crates/primitives/src/transaction/multisig.rs index 680292c5f2..962252fa3c 100644 --- a/crates/primitives/src/transaction/multisig.rs +++ b/crates/primitives/src/transaction/multisig.rs @@ -698,6 +698,13 @@ impl MultisigSignature { /// Validates only the stateless signature payload shape. pub fn validate_shape(&self) -> Result<(), &'static str> { + self.validate_shape_at_depth(1) + } + + fn validate_shape_at_depth(&self, depth: usize) -> Result<(), &'static str> { + if depth > MAX_MULTISIG_NESTING_DEPTH { + return Err("native multisig nesting depth exceeded"); + } if self.account().is_zero() { return Err("multisig account cannot be zero"); } @@ -717,7 +724,13 @@ impl MultisigSignature { TempoSignature::Keychain(_) => { return Err("keychain signatures cannot authorize native multisig owners"); } - TempoSignature::Primitive(_) | TempoSignature::Multisig(_) => {} + TempoSignature::Multisig(nested) => { + if nested.init().is_some() { + return Err("nested multisig owner signatures cannot bootstrap accounts"); + } + nested.validate_shape_at_depth(depth + 1)?; + } + TempoSignature::Primitive(_) => {} } } Ok(()) @@ -1086,6 +1099,16 @@ mod tests { PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()).to_bytes() } + fn bootstrap_multisig_signature() -> MultisigSignature { + let config = sorted_secp_config(&[(indexed_owner(2), 1)], 1); + MultisigSignature::from_decoded( + config.account().unwrap(), + vec![TempoSignature::Primitive(PrimitiveSignature::default())], + Some(config), + ) + .unwrap() + } + fn generate_p256_keypair() -> (P256SigningKey, B256, B256, Address) { let signing_key = P256SigningKey::random(&mut OsRng); let verifying_key = signing_key.verifying_key(); @@ -1338,6 +1361,43 @@ mod tests { ); } + #[test] + fn multisig_shape_rejects_nested_bootstrap_approval() { + assert_eq!( + MultisigSignature::from_decoded( + indexed_owner(1), + vec![TempoSignature::Multisig(bootstrap_multisig_signature())], + None, + ), + Err("nested multisig owner signatures cannot bootstrap accounts") + ); + } + + #[test] + fn multisig_shape_rejects_programmatic_excess_nesting() { + let leaf = MultisigSignature::from_decoded( + indexed_owner(3), + vec![TempoSignature::Primitive(PrimitiveSignature::default())], + None, + ) + .unwrap(); + let middle = MultisigSignature::from_decoded( + indexed_owner(2), + vec![TempoSignature::Multisig(leaf)], + None, + ) + .unwrap(); + + assert_eq!( + MultisigSignature::from_decoded( + indexed_owner(1), + vec![TempoSignature::Multisig(middle)], + None, + ), + Err("native multisig nesting depth exceeded") + ); + } + #[test] fn shared_quorum_helpers_verify_order_and_threshold() { let owner_a = indexed_owner(1); @@ -1612,6 +1672,21 @@ mod tests { assert!(TempoSignature::from_bytes(&tempo_encoded).is_err()); } + #[test] + fn multisig_signature_decode_rejects_nested_bootstrap_approval() { + let mut encoded = vec![SIGNATURE_TYPE_MULTISIG]; + encoded.extend(encoded_multisig_without_init_slot( + indexed_owner(1), + vec![ + TempoSignature::Multisig(bootstrap_multisig_signature()) + .to_bytes() + .to_vec(), + ], + )); + + assert!(TempoSignature::from_bytes(&encoded).is_err()); + } + #[test] fn init_multisig_decode_bounds_owner_count() { let config = InitMultisig { @@ -1828,6 +1903,17 @@ mod tests { assert!(error.contains("native multisig nesting depth exceeded")); } + #[cfg(feature = "serde")] + #[test] + fn multisig_signature_json_rejects_nested_bootstrap_approval() { + let json = serde_json::json!({ + "account": indexed_owner(1), + "signatures": [TempoSignature::Multisig(bootstrap_multisig_signature())], + }); + + assert!(serde_json::from_value::(json).is_err()); + } + #[cfg(feature = "serde")] #[test] fn multisig_signature_json_rejects_excess_approvals_before_decoding_them() { From ba158270d90984a7d593139a5bcc136793a48ac9 Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:19:41 +0200 Subject: [PATCH 7/7] refactor(primitives): type multisig signature errors --- crates/primitives/src/transaction/mod.rs | 4 +- crates/primitives/src/transaction/multisig.rs | 148 ++++++++++++++---- .../src/transaction/tt_signature.rs | 15 +- 3 files changed, 129 insertions(+), 38 deletions(-) diff --git a/crates/primitives/src/transaction/mod.rs b/crates/primitives/src/transaction/mod.rs index 720eb03f22..7f2056a979 100644 --- a/crates/primitives/src/transaction/mod.rs +++ b/crates/primitives/src/transaction/mod.rs @@ -24,8 +24,8 @@ pub use multisig::{ InitMultisig, MAX_MULTISIG_NESTING_DEPTH, MAX_MULTISIG_OWNER_SIGNATURE_BYTES, MAX_MULTISIG_OWNERS, MAX_MULTISIG_SIGNATURES, MAX_MULTISIG_THRESHOLD, MULTISIG_SIGNATURE_DOMAIN, MultisigAddress, MultisigConfigError, MultisigOwner, - MultisigQuorumError, MultisigSignature, MultisigWeightAccumulator, SIGNATURE_TYPE_MULTISIG, - multisig_digest, multisig_signature_count_for_threshold, + MultisigQuorumError, MultisigSignature, MultisigSignatureError, MultisigWeightAccumulator, + SIGNATURE_TYPE_MULTISIG, multisig_digest, multisig_signature_count_for_threshold, }; pub use tempo_transaction::{ Call, FEE_PAYER_SIGNATURE_MARKER, InvalidValidAfter, InvalidValidBefore, diff --git a/crates/primitives/src/transaction/multisig.rs b/crates/primitives/src/transaction/multisig.rs index 962252fa3c..b8e24dbdc4 100644 --- a/crates/primitives/src/transaction/multisig.rs +++ b/crates/primitives/src/transaction/multisig.rs @@ -129,6 +129,82 @@ impl core::fmt::Display for MultisigConfigError { } } +/// Native multisig signature construction and shape validation error. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MultisigSignatureError { + /// The bootstrap config is invalid. + InvalidConfig(MultisigConfigError), + /// The claimed account does not match the bootstrap config. + InitAccountMismatch, + /// The signature exceeds [`MAX_MULTISIG_NESTING_DEPTH`]. + NestingDepthExceeded, + /// The claimed multisig account is zero. + ZeroAccount, + /// The owner approval list is empty. + EmptySignatures, + /// The owner approval list exceeds [`MAX_MULTISIG_SIGNATURES`]. + TooManySignatures, + /// An encoded owner approval is empty. + EmptyOwnerSignature, + /// An encoded primitive owner approval exceeds [`MAX_MULTISIG_OWNER_SIGNATURE_BYTES`]. + OwnerSignatureTooLarge, + /// An encoded owner approval is not a valid [`TempoSignature`]. + InvalidOwnerSignature, + /// A keychain signature was supplied as an owner approval. + KeychainOwnerSignature, + /// A nested multisig owner approval contains a bootstrap config. + NestedBootstrap, + /// A registered-account signature contains a bootstrap config. + UnexpectedInit, +} + +impl MultisigSignatureError { + /// Returns the stable validation message for this error. + pub const fn as_str(self) -> &'static str { + match self { + Self::InvalidConfig(error) => error.as_str(), + Self::InitAccountMismatch => "multisig init does not derive account", + Self::NestingDepthExceeded => "native multisig nesting depth exceeded", + Self::ZeroAccount => "multisig account cannot be zero", + Self::EmptySignatures => "multisig signatures cannot be empty", + Self::TooManySignatures => "too many multisig signatures", + Self::EmptyOwnerSignature => "multisig owner signature cannot be empty", + Self::OwnerSignatureTooLarge => "multisig owner signature too large", + Self::InvalidOwnerSignature => "invalid multisig owner signature", + Self::KeychainOwnerSignature => { + "keychain signatures cannot authorize native multisig owners" + } + Self::NestedBootstrap => "nested multisig owner signatures cannot bootstrap accounts", + Self::UnexpectedInit => "multisig_init is only allowed when bootstrapping an account", + } + } +} + +impl core::fmt::Display for MultisigSignatureError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl From for MultisigSignatureError { + fn from(error: MultisigConfigError) -> Self { + Self::InvalidConfig(error) + } +} + +impl From for &'static str { + fn from(error: MultisigSignatureError) -> Self { + error.as_str() + } +} + +impl From for String { + fn from(error: MultisigSignatureError) -> Self { + error.as_str().to_string() + } +} + /// Native multisig quorum validation error. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum MultisigQuorumError { @@ -529,11 +605,14 @@ pub enum MultisigAddress { } impl MultisigAddress { - fn from_parts(account: Address, init: Option) -> Result { + fn from_parts( + account: Address, + init: Option, + ) -> Result { if let Some(init) = init { - let init_account = init.account().map_err(MultisigConfigError::as_str)?; + let init_account = init.account()?; if init_account != account { - return Err("multisig init does not derive account"); + return Err(MultisigSignatureError::InitAccountMismatch); } Ok(Self::Init(init)) } else { @@ -603,7 +682,7 @@ impl From for MultisigSignatureSerde { #[cfg(feature = "serde")] impl TryFrom for MultisigSignature { - type Error = &'static str; + type Error = MultisigSignatureError; fn try_from(value: MultisigSignatureSerde) -> Result { match value { @@ -627,7 +706,7 @@ impl MultisigSignature { account: Address, signatures: Vec, init: Option, - ) -> Result { + ) -> Result { let signatures = signatures .into_iter() .map(decode_multisig_owner_signature) @@ -639,7 +718,7 @@ impl MultisigSignature { account: Address, signatures: Vec, init: Option, - ) -> Result { + ) -> Result { let address = MultisigAddress::from_parts(account, init)?; Self::from_validated_address(address, signatures) } @@ -647,12 +726,12 @@ impl MultisigSignature { fn from_decoded_address( address: MultisigAddress, signatures: Vec, - ) -> Result { + ) -> Result { // Guarantee the init config is valid at construction (decode/serde) time so that every // constructed `MultisigSignature` upholds the invariant `MultisigAddress::account()` relies // on. Without this, an invalid init config reaches the infallible `account()` and panics. if let MultisigAddress::Init(init) = &address { - init.account().map_err(MultisigConfigError::as_str)?; + init.account()?; } Self::from_validated_address(address, signatures) } @@ -660,7 +739,7 @@ impl MultisigSignature { fn from_validated_address( address: MultisigAddress, signatures: Vec, - ) -> Result { + ) -> Result { let signature = Self { address, signatures, @@ -691,42 +770,42 @@ impl MultisigSignature { } /// Performs stateless sender-recovery checks and returns the attempted multisig account. - pub fn recover_account(&self) -> Result { + pub fn recover_account(&self) -> Result { self.validate_shape()?; Ok(self.account()) } /// Validates only the stateless signature payload shape. - pub fn validate_shape(&self) -> Result<(), &'static str> { + pub fn validate_shape(&self) -> Result<(), MultisigSignatureError> { self.validate_shape_at_depth(1) } - fn validate_shape_at_depth(&self, depth: usize) -> Result<(), &'static str> { + fn validate_shape_at_depth(&self, depth: usize) -> Result<(), MultisigSignatureError> { if depth > MAX_MULTISIG_NESTING_DEPTH { - return Err("native multisig nesting depth exceeded"); + return Err(MultisigSignatureError::NestingDepthExceeded); } if self.account().is_zero() { - return Err("multisig account cannot be zero"); + return Err(MultisigSignatureError::ZeroAccount); } if self.signatures.is_empty() { - return Err("multisig signatures cannot be empty"); + return Err(MultisigSignatureError::EmptySignatures); } if self.signatures.len() > MAX_MULTISIG_SIGNATURES { - return Err("too many multisig signatures"); + return Err(MultisigSignatureError::TooManySignatures); } for signature in &self.signatures { match signature { TempoSignature::Primitive(signature) if signature.encoded_length() > MAX_MULTISIG_OWNER_SIGNATURE_BYTES => { - return Err("multisig owner signature too large"); + return Err(MultisigSignatureError::OwnerSignatureTooLarge); } TempoSignature::Keychain(_) => { - return Err("keychain signatures cannot authorize native multisig owners"); + return Err(MultisigSignatureError::KeychainOwnerSignature); } TempoSignature::Multisig(nested) => { if nested.init().is_some() { - return Err("nested multisig owner signatures cannot bootstrap accounts"); + return Err(MultisigSignatureError::NestedBootstrap); } nested.validate_shape_at_depth(depth + 1)?; } @@ -740,10 +819,10 @@ impl MultisigSignature { /// /// Registered accounts are already bound to native multisig storage, so the derived-account /// check can be skipped on the steady-state path. - pub fn validate_registered_shape(&self) -> Result<(), &'static str> { + pub fn validate_registered_shape(&self) -> Result<(), MultisigSignatureError> { self.validate_shape()?; if self.init().is_some() { - return Err("multisig_init is only allowed when bootstrapping an account"); + return Err(MultisigSignatureError::UnexpectedInit); } Ok(()) } @@ -928,7 +1007,8 @@ impl MultisigSignature { } *buf = rest; - Self::from_decoded_address(address, signatures).map_err(alloy_rlp::Error::Custom) + Self::from_decoded_address(address, signatures) + .map_err(|error| alloy_rlp::Error::Custom(error.as_str())) } } @@ -1008,16 +1088,19 @@ pub fn multisig_signature_count_for_threshold( Err(MultisigQuorumError::WeightBelowThreshold) } -fn decode_multisig_owner_signature(signature: Bytes) -> Result { +fn decode_multisig_owner_signature( + signature: Bytes, +) -> Result { if signature.is_empty() { - return Err("multisig owner signature cannot be empty"); + return Err(MultisigSignatureError::EmptyOwnerSignature); } if signature.len() > MAX_MULTISIG_OWNER_SIGNATURE_BYTES && signature[0] != SIGNATURE_TYPE_MULTISIG { - return Err("multisig owner signature too large"); + return Err(MultisigSignatureError::OwnerSignatureTooLarge); } - TempoSignature::from_bytes(&signature).map_err(|_| "invalid multisig owner signature") + TempoSignature::from_bytes(&signature) + .map_err(|_| MultisigSignatureError::InvalidOwnerSignature) } #[cfg(any(test, feature = "arbitrary"))] @@ -1357,7 +1440,7 @@ mod tests { assert_eq!( MultisigSignature::from_decoded(account, vec![approval], None), - Err("keychain signatures cannot authorize native multisig owners") + Err(MultisigSignatureError::KeychainOwnerSignature) ); } @@ -1369,7 +1452,7 @@ mod tests { vec![TempoSignature::Multisig(bootstrap_multisig_signature())], None, ), - Err("nested multisig owner signatures cannot bootstrap accounts") + Err(MultisigSignatureError::NestedBootstrap) ); } @@ -1394,7 +1477,7 @@ mod tests { vec![TempoSignature::Multisig(middle)], None, ), - Err("native multisig nesting depth exceeded") + Err(MultisigSignatureError::NestingDepthExceeded) ); } @@ -1617,7 +1700,7 @@ mod tests { Some(config), ); - assert_eq!(signature, Err("multisig init does not derive account")); + assert_eq!(signature, Err(MultisigSignatureError::InitAccountMismatch)); } #[test] @@ -1734,7 +1817,10 @@ mod tests { None, ); - assert_eq!(signature, Err("multisig owner signature too large")); + assert_eq!( + signature, + Err(MultisigSignatureError::OwnerSignatureTooLarge) + ); } #[test] diff --git a/crates/primitives/src/transaction/tt_signature.rs b/crates/primitives/src/transaction/tt_signature.rs index 17dbc3a144..0e2e19722d 100644 --- a/crates/primitives/src/transaction/tt_signature.rs +++ b/crates/primitives/src/transaction/tt_signature.rs @@ -808,16 +808,21 @@ impl<'de> Visitor<'de> for TempoSignatureVisitor { } let signatures = signatures.ok_or_else(|| M::Error::missing_field("signatures"))?; - match (account, init) { + let signature = match (account, init) { (Some(account), None) => MultisigSignature::from_decoded(account, signatures, None), (None, Some(init)) => { let account = init.account().map_err(M::Error::custom)?; MultisigSignature::from_decoded(account, signatures, Some(init)) } - _ => Err("multisig signature requires exactly one of account or init"), - } - .map(TempoSignature::Multisig) - .map_err(M::Error::custom) + _ => { + return Err(M::Error::custom( + "multisig signature requires exactly one of account or init", + )); + } + }; + signature + .map(TempoSignature::Multisig) + .map_err(M::Error::custom) } }