diff --git a/contracts/Scarb.lock b/contracts/Scarb.lock index 0c0ba352..900ef28e 100644 --- a/contracts/Scarb.lock +++ b/contracts/Scarb.lock @@ -59,6 +59,7 @@ version = "0.1.0" dependencies = [ "alexandria_bytes", "alexandria_encoding", + "alexandria_math", "openzeppelin", "snforge_std", ] diff --git a/contracts/Scarb.toml b/contracts/Scarb.toml index a84f65f8..97e8ee5d 100644 --- a/contracts/Scarb.toml +++ b/contracts/Scarb.toml @@ -19,6 +19,9 @@ alexandria_bytes = { git = "https://github.com/keep-starknet-strange/alexandria. alexandria_encoding = { git = "https://github.com/keep-starknet-strange/alexandria.git", rev = "bcdca70afdf59c9976148e95cebad5cf63d75a7f" } snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry.git", tag = "v0.27.0" } +[dev-dependencies] +alexandria_math = { git = "https://github.com/keep-starknet-strange/alexandria.git", rev = "bcdca70afdf59c9976148e95cebad5cf63d75a7f" } + [lib] [[target.starknet-contract]] diff --git a/contracts/src/lib.cairo b/contracts/src/lib.cairo index eef049d8..71ffd233 100644 --- a/contracts/src/lib.cairo +++ b/contracts/src/lib.cairo @@ -8,6 +8,7 @@ mod emergency; mod multisig; mod token; mod access_control; +mod mcms; #[cfg(test)] mod tests; diff --git a/contracts/src/libraries/mocks/mock_multisig_target.cairo b/contracts/src/libraries/mocks/mock_multisig_target.cairo index 686fa976..2c0fd05e 100644 --- a/contracts/src/libraries/mocks/mock_multisig_target.cairo +++ b/contracts/src/libraries/mocks/mock_multisig_target.cairo @@ -1,16 +1,40 @@ +use array::ArrayTrait; + +#[starknet::interface] +trait IMockMultisigTarget { + fn increment(ref self: TContractState, val1: felt252, val2: felt252) -> Array; + fn set_value(ref self: TContractState, value: felt252); + fn flip_toggle(ref self: TContractState); + fn read(self: @TContractState) -> (felt252, bool); +} + #[starknet::contract] mod MockMultisigTarget { use array::ArrayTrait; + use super::IMockMultisigTarget; #[storage] - struct Storage {} + struct Storage { + value: felt252, + toggle: bool + } - #[abi(per_item)] - #[generate_trait] - impl HelperImpl of HelperTrait { - #[external(v0)] + #[abi(embed_v0)] + impl MockMultisigTargetImpl of super::IMockMultisigTarget { fn increment(ref self: ContractState, val1: felt252, val2: felt252) -> Array { array![val1 + 1, val2 + 1] } + + fn set_value(ref self: ContractState, value: felt252) { + self.value.write(value); + } + + fn flip_toggle(ref self: ContractState) { + self.toggle.write(!self.toggle.read()); + } + + fn read(self: @ContractState) -> (felt252, bool) { + (self.value.read(), self.toggle.read()) + } } } diff --git a/contracts/src/mcms.cairo b/contracts/src/mcms.cairo new file mode 100644 index 00000000..c7c5b4b8 --- /dev/null +++ b/contracts/src/mcms.cairo @@ -0,0 +1,655 @@ +use starknet::ContractAddress; +use starknet::{ + eth_signature::public_key_point_to_eth_address, EthAddress, + secp256_trait::{ + Secp256Trait, Secp256PointTrait, recover_public_key, is_signature_entry_valid, Signature + }, + secp256k1::Secp256k1Point, SyscallResult, SyscallResultTrait +}; +use alexandria_bytes::{Bytes, BytesTrait}; +use alexandria_encoding::sol_abi::sol_bytes::SolBytesTrait; +use alexandria_encoding::sol_abi::encode::SolAbiEncodeTrait; + +#[starknet::interface] +trait IManyChainMultiSig { + fn set_root( + ref self: TContractState, + root: u256, + valid_until: u32, + metadata: RootMetadata, + metadata_proof: Span, + // note: v is a boolean and not uint8 + signatures: Array + ); + fn execute(ref self: TContractState, op: Op, proof: Span); + fn set_config( + ref self: TContractState, + signer_addresses: Span, + signer_groups: Span, + group_quorums: Span, + group_parents: Span, + clear_root: bool + ); + fn get_config(self: @TContractState) -> Config; + fn get_op_count(self: @TContractState) -> u64; + fn get_root(self: @TContractState) -> (u256, u32); + fn get_root_metadata(self: @TContractState) -> RootMetadata; +} + +#[derive(Copy, Drop, Serde, starknet::Store, PartialEq, Debug)] +struct Signer { + address: EthAddress, + index: u8, + group: u8 +} + +#[derive(Copy, Drop, Serde, starknet::Store, PartialEq)] +struct RootMetadata { + chain_id: u256, + multisig: ContractAddress, + pre_op_count: u64, + post_op_count: u64, + override_previous_root: bool +} + +#[derive(Copy, Drop, Serde)] +struct Op { + chain_id: u256, + multisig: ContractAddress, + nonce: u64, + to: ContractAddress, + selector: felt252, + data: Span +} + +// does not implement Storage trait because structs cannot support arrays or maps +#[derive(Copy, Drop, Serde, PartialEq)] +struct Config { + signers: Span, + group_quorums: Span, + group_parents: Span +} + +#[derive(Copy, Drop, Serde, starknet::Store, PartialEq)] +struct ExpiringRootAndOpCount { + root: u256, + valid_until: u32, + op_count: u64 +} + +// based of https://github.com/starkware-libs/cairo/blob/1b747da1ec7e43a6fd0c0a4cbce302616408bc72/corelib/src/starknet/eth_signature.cairo#L25 +pub fn recover_eth_ecdsa(msg_hash: u256, signature: Signature) -> Result { + if !is_signature_entry_valid::(signature.r) { + return Result::Err('Signature out of range'); + } + if !is_signature_entry_valid::(signature.s) { + return Result::Err('Signature out of range'); + } + + let public_key_point = recover_public_key::(:msg_hash, :signature).unwrap(); + // calculated eth address + return Result::Ok(public_key_point_to_eth_address(:public_key_point)); +} + +pub fn to_u256(address: EthAddress) -> u256 { + let temp: felt252 = address.into(); + temp.into() +} + +pub fn verify_merkle_proof(proof: Span, root: u256, leaf: u256) -> bool { + let mut computed_hash = leaf; + + let mut i = 0; + + while i < proof.len() { + computed_hash = hash_pair(computed_hash, *proof.at(i)); + i += 1; + }; + + computed_hash == root +} + +fn hash_pair(a: u256, b: u256) -> u256 { + let (lower, higher) = if a < b { + (a, b) + } else { + (b, a) + }; + BytesTrait::new_empty().encode(lower).encode(higher).keccak() +} + +fn hash_op(op: Op) -> u256 { + let mut encoded_leaf: Bytes = BytesTrait::new_empty() + .encode(MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_OP) + .encode(op.chain_id) + .encode(op.multisig) + .encode(op.nonce) + .encode(op.to) + .encode(op.selector); + // encode the data field by looping through + let mut i = 0; + while i < op.data.len() { + encoded_leaf = encoded_leaf.encode(*op.data.at(i)); + i += 1; + }; + encoded_leaf.keccak() +} + +// keccak256("MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_OP") +const MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_OP: u256 = + 0x08d275622006c4ca82d03f498e90163cafd53c663a48470c3b52ac8bfbd9f52c; +// keccak256("MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_METADATA") +const MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_METADATA: u256 = + 0xe6b82be989101b4eb519770114b997b97b3c8707515286748a871717f0e4ea1c; + +fn hash_metadata(metadata: RootMetadata, valid_until: u32) -> u256 { + let encoded_metadata: Bytes = BytesTrait::new_empty() + .encode(MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_METADATA) + .encode(valid_until) + .encode(metadata.chain_id) + .encode(metadata.multisig) + .encode(metadata.pre_op_count) + .encode(metadata.post_op_count) + .encode(metadata.override_previous_root); + + encoded_metadata.keccak() +} + +fn eip_191_message_hash(msg: u256) -> u256 { + let mut eip_191_msg: Bytes = BytesTrait::new_empty(); + + // '\x19Ethereum Signed Message:\n32' in byte array + let prefix = array![ + 0x19, + 0x45, + 0x74, + 0x68, + 0x65, + 0x72, + 0x65, + 0x75, + 0x6d, + 0x20, + 0x53, + 0x69, + 0x67, + 0x6e, + 0x65, + 0x64, + 0x20, + 0x4d, + 0x65, + 0x73, + 0x73, + 0x61, + 0x67, + 0x65, + 0x3a, + 0x0a, + 0x33, + 0x32 + ]; + + let mut i = 0; + while i < prefix.len() { + eip_191_msg.append_u8(*prefix.at(i)); + i += 1; + }; + eip_191_msg.append_u256(msg); + + eip_191_msg.keccak() +} + +#[starknet::contract] +mod ManyChainMultiSig { + use core::array::ArrayTrait; + use core::starknet::SyscallResultTrait; + use core::array::SpanTrait; + use core::dict::Felt252Dict; + use core::traits::PanicDestruct; + use super::{ + ExpiringRootAndOpCount, Config, Signer, RootMetadata, Op, Signature, recover_eth_ecdsa, + to_u256, verify_merkle_proof, hash_op, hash_metadata, eip_191_message_hash, + MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_OP, MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_METADATA + }; + use starknet::{ + EthAddress, EthAddressZeroable, EthAddressIntoFelt252, ContractAddress, + call_contract_syscall + }; + + use openzeppelin::access::ownable::OwnableComponent; + + use alexandria_bytes::{Bytes, BytesTrait}; + use alexandria_encoding::sol_abi::sol_bytes::SolBytesTrait; + use alexandria_encoding::sol_abi::encode::SolAbiEncodeTrait; + + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); + #[abi(embed_v0)] + impl OwnableImpl = OwnableComponent::OwnableTwoStepImpl; + impl OwnableInternalImpl = OwnableComponent::InternalImpl; + + const NUM_GROUPS: u8 = 32; + const MAX_NUM_SIGNERS: u8 = 200; + + #[storage] + struct Storage { + #[substorage(v0)] + ownable: OwnableComponent::Storage, + // s_signers is used to easily validate the existence of the signer by its address. + s_signers: LegacyMap, + // begin s_config (defined in storage bc Config struct cannot support maps) + _s_config_signers_len: u8, + _s_config_signers: LegacyMap, + // no _s_config_group_len because there are always 32 groups + _s_config_group_quorums: LegacyMap, + _s_config_group_parents: LegacyMap, + // end s_config + s_seen_signed_hashes: LegacyMap, + s_expiring_root_and_op_count: ExpiringRootAndOpCount, + s_root_metadata: RootMetadata + } + + #[derive(Drop, starknet::Event)] + struct NewRoot { + #[key] + root: u256, + valid_until: u32, + metadata: RootMetadata, + } + + #[derive(Drop, starknet::Event)] + struct OpExecuted { + #[key] + nonce: u64, + to: ContractAddress, + selector: felt252, + data: Span, + // no value because value is sent through ERC20 tokens, even the native STRK token + } + + #[derive(Drop, starknet::Event)] + struct ConfigSet { + config: Config, + is_root_cleared: bool, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + OwnableEvent: OwnableComponent::Event, + NewRoot: NewRoot, + OpExecuted: OpExecuted, + ConfigSet: ConfigSet, + } + + #[constructor] + fn constructor(ref self: ContractState) { + let caller = starknet::info::get_caller_address(); + self.ownable.initializer(caller); + } + + + #[abi(embed_v0)] + impl ManyChainMultiSigImpl of super::IManyChainMultiSig { + fn set_root( + ref self: ContractState, + root: u256, + valid_until: u32, + metadata: RootMetadata, + metadata_proof: Span, + // note: v is a boolean and not uint8 + mut signatures: Array + ) { + let encoded_root: Bytes = BytesTrait::new_empty().encode(root).encode(valid_until); + + let msg_hash = eip_191_message_hash(encoded_root.keccak()); + + assert(!self.s_seen_signed_hashes.read(msg_hash), 'signed hash already seen'); + + let mut prev_address = EthAddressZeroable::zero(); + let mut group_vote_counts: Felt252Dict = Default::default(); + while let Option::Some(signature) = signatures + .pop_front() { + let signer_address = match recover_eth_ecdsa(msg_hash, signature) { + Result::Ok(signer_address) => signer_address, + Result::Err(e) => panic_with_felt252(e), + }; + + assert( + to_u256(prev_address) < to_u256(signer_address.clone()), + 'signer address must increase' + ); + prev_address = signer_address; + + let signer = self.get_signer_by_address(signer_address); + assert(signer.address == signer_address, 'invalid signer'); + + let mut group = signer.group; + loop { + let counts = group_vote_counts.get(group.into()); + group_vote_counts.insert(group.into(), counts + 1); + if counts + 1 != self._s_config_group_quorums.read(group) { + break; + } + if group == 0 { + // reached root + break; + } + group = self._s_config_group_parents.read(group) + }; + }; + + let root_group_quorum = self._s_config_group_quorums.read(0); + assert(root_group_quorum > 0, 'root group missing quorum'); + assert(group_vote_counts.get(0) >= root_group_quorum, 'insufficient signers'); + assert( + valid_until.into() >= starknet::info::get_block_timestamp(), + 'valid until has passed' + ); + + // verify metadataProof + let hashed_metadata_leaf = hash_metadata(metadata, valid_until); + assert( + verify_merkle_proof(metadata_proof, root, hashed_metadata_leaf), + 'proof verification failed' + ); + + // maybe move to beginning of function + assert( + starknet::info::get_tx_info().unbox().chain_id.into() == metadata.chain_id, + 'wrong chain id' + ); + assert( + starknet::info::get_contract_address() == metadata.multisig, + 'wrong multisig address' + ); + + let op_count = self.s_expiring_root_and_op_count.read().op_count; + let current_root_metadata = self.s_root_metadata.read(); + + // new root can be set only if the current op_count is the expected post op count (unless an override is requested) + assert( + op_count == current_root_metadata.post_op_count + || current_root_metadata.override_previous_root, + 'pending operations remain' + ); + assert(op_count == metadata.pre_op_count, 'wrong pre-operation count'); + assert(metadata.pre_op_count <= metadata.post_op_count, 'wrong post-operation count'); + + self.s_seen_signed_hashes.write(msg_hash, true); + self + .s_expiring_root_and_op_count + .write( + ExpiringRootAndOpCount { + root: root, valid_until: valid_until, op_count: metadata.pre_op_count + } + ); + self.s_root_metadata.write(metadata); + self + .emit( + Event::NewRoot( + NewRoot { root: root, valid_until: valid_until, metadata: metadata, } + ) + ); + } + + fn execute(ref self: ContractState, op: Op, proof: Span) { + let current_expiring_root_and_op_count = self.s_expiring_root_and_op_count.read(); + + assert( + self + .s_root_metadata + .read() + .post_op_count > current_expiring_root_and_op_count + .op_count, + 'post-operation count reached' + ); + + assert( + starknet::info::get_tx_info().unbox().chain_id.into() == op.chain_id, + 'wrong chain id' + ); + + assert(starknet::info::get_contract_address() == op.multisig, 'wrong multisig address'); + + assert( + current_expiring_root_and_op_count + .valid_until + .into() >= starknet::info::get_block_timestamp(), + 'root has expired' + ); + + assert(op.nonce == current_expiring_root_and_op_count.op_count, 'wrong nonce'); + + // verify op exists in merkle tree + let hashed_op_leaf = hash_op(op); + + assert( + verify_merkle_proof(proof, current_expiring_root_and_op_count.root, hashed_op_leaf), + 'proof verification failed' + ); + + let mut new_expiring_root_and_op_count = current_expiring_root_and_op_count; + new_expiring_root_and_op_count.op_count += 1; + + self.s_expiring_root_and_op_count.write(new_expiring_root_and_op_count); + self._execute(op.to, op.selector, op.data); + + self + .emit( + Event::OpExecuted( + OpExecuted { + nonce: op.nonce, to: op.to, selector: op.selector, data: op.data + } + ) + ); + } + + fn set_config( + ref self: ContractState, + signer_addresses: Span, + signer_groups: Span, + group_quorums: Span, + group_parents: Span, + clear_root: bool + ) { + self.ownable.assert_only_owner(); + + assert( + signer_addresses.len() != 0 && signer_addresses.len() <= MAX_NUM_SIGNERS.into(), + 'out of bound signers len' + ); + + assert(signer_addresses.len() == signer_groups.len(), 'signer groups len mismatch'); + + assert( + group_quorums.len() == NUM_GROUPS.into() + && group_quorums.len() == group_parents.len(), + 'wrong group quorums/parents len' + ); + + let mut group_children_counts: Felt252Dict = Default::default(); + let mut i = 0; + while i < signer_groups + .len() { + let group = *signer_groups.at(i); + assert(group < NUM_GROUPS, 'out of bounds group'); + // increment count for each group + group_children_counts + .insert(group.into(), group_children_counts.get(group.into()) + 1); + i += 1; + }; + + let mut j = 0; + while j < NUM_GROUPS { + // iterate backwards: i is the group # + let i = NUM_GROUPS - 1 - j; + let group_parent = (*group_parents.at(i.into())); + + let group_malformed = (i != 0 && group_parent >= i) + || (i == 0 && group_parent != 0); + assert(!group_malformed, 'group tree malformed'); + + let disabled = *group_quorums.at(i.into()) == 0; + + if disabled { + assert(group_children_counts.get(i.into()) == 0, 'signer in disabled group'); + } else { + assert( + group_children_counts.get(i.into()) >= *group_quorums.at(i.into()), + 'quorum impossible' + ); + + group_children_counts + .insert( + group_parent.into(), group_children_counts.get(group_parent.into()) + 1 + ); + // the above line clobbers group_children_counts[0] in last iteration, don't use it after the loop ends + } + j += 1; + }; + + // remove any old signer addresses + let mut i: u8 = 0; + let signers_len = self._s_config_signers_len.read(); + + while i < signers_len { + let mut old_signer = self._s_config_signers.read(i); + let empty_signer = Signer { + address: EthAddressZeroable::zero(), index: 0, group: 0 + }; + // reset s_signers + self.s_signers.write(old_signer.address, empty_signer); + // reset _s_config_signers + self._s_config_signers.write(i.into(), empty_signer); + i += 1; + }; + // reset _s_config_signers_len + self._s_config_signers_len.write(0); + + let mut i: u8 = 0; + while i < NUM_GROUPS { + self._s_config_group_quorums.write(i, *group_quorums.at(i.into())); + self._s_config_group_parents.write(i, *group_parents.at(i.into())); + i += 1; + }; + + // create signers array (for event logs) + let mut signers = ArrayTrait::::new(); + let mut prev_signer_address = EthAddressZeroable::zero(); + let mut i: u8 = 0; + while i + .into() < signer_addresses + .len() { + let signer_address = *signer_addresses.at(i.into()); + assert( + to_u256(prev_signer_address) < to_u256(signer_address), + 'signer addresses not sorted' + ); + + let signer = Signer { + address: signer_address, index: i, group: *signer_groups.at(i.into()) + }; + + self.s_signers.write(signer_address, signer); + self._s_config_signers.write(i.into(), signer); + + signers.append(signer); + + prev_signer_address = signer_address; + i += 1; + }; + + // length will always be less than MAX_NUM_SIGNERS so try_into will never panic + self._s_config_signers_len.write(signer_addresses.len().try_into().unwrap()); + + if clear_root { + let op_count = self.s_expiring_root_and_op_count.read().op_count; + self + .s_expiring_root_and_op_count + .write(ExpiringRootAndOpCount { root: 0, valid_until: 0, op_count: op_count }); + self + .s_root_metadata + .write( + RootMetadata { + chain_id: starknet::info::get_tx_info().unbox().chain_id.into(), + multisig: starknet::info::get_contract_address(), + pre_op_count: op_count, + post_op_count: op_count, + override_previous_root: true + } + ); + } + + self + .emit( + Event::ConfigSet( + ConfigSet { + config: Config { + signers: signers.span(), + group_quorums: group_quorums, + group_parents: group_parents, + }, + is_root_cleared: clear_root + } + ) + ); + } + + fn get_config(self: @ContractState) -> Config { + let mut signers = ArrayTrait::::new(); + + let mut i = 0; + let signers_len = self._s_config_signers_len.read(); + while i < signers_len { + let signer = self._s_config_signers.read(i); + signers.append(signer); + i += 1 + }; + + let mut group_quorums = ArrayTrait::::new(); + let mut group_parents = ArrayTrait::::new(); + + let mut i = 0; + while i < NUM_GROUPS { + group_quorums.append(self._s_config_group_quorums.read(i)); + group_parents.append(self._s_config_group_parents.read(i)); + i += 1; + }; + + Config { + signers: signers.span(), + group_quorums: group_quorums.span(), + group_parents: group_parents.span() + } + } + + fn get_op_count(self: @ContractState) -> u64 { + self.s_expiring_root_and_op_count.read().op_count + } + + fn get_root(self: @ContractState) -> (u256, u32) { + let current = self.s_expiring_root_and_op_count.read(); + (current.root, current.valid_until) + } + + fn get_root_metadata(self: @ContractState) -> RootMetadata { + self.s_root_metadata.read() + } + } + + #[generate_trait] + impl InternalFunctions of InternalFunctionsTrait { + fn _execute( + ref self: ContractState, target: ContractAddress, selector: felt252, data: Span + ) { + let _response = call_contract_syscall(target, selector, data).unwrap_syscall(); + } + + fn get_signer_by_address(ref self: ContractState, signer_address: EthAddress) -> Signer { + self.s_signers.read(signer_address) + } + } +} + diff --git a/contracts/src/tests.cairo b/contracts/src/tests.cairo index 4d810449..3ee396c6 100644 --- a/contracts/src/tests.cairo +++ b/contracts/src/tests.cairo @@ -10,3 +10,4 @@ mod test_upgradeable; mod test_access_controller; mod test_mock_aggregator; mod test_sequencer_uptime_feed; +mod test_mcms; diff --git a/contracts/src/tests/test_aggregator.cairo b/contracts/src/tests/test_aggregator.cairo index 77719d6f..eecca9a8 100644 --- a/contracts/src/tests/test_aggregator.cairo +++ b/contracts/src/tests/test_aggregator.cairo @@ -145,11 +145,6 @@ fn test_access_control() { let (aggregatorAddr, _) = declare("Aggregator").unwrap().deploy(@calldata).unwrap(); - // let (aggregatorAddr, _) = deploy_syscall( - // Aggregator::TEST_CLASS_HASH.try_into().unwrap(), 0, calldata.span(), false - // ) - // .unwrap(); - should_implement_access_control(aggregatorAddr, account); } diff --git a/contracts/src/tests/test_mcms.cairo b/contracts/src/tests/test_mcms.cairo new file mode 100644 index 00000000..3c4ce69c --- /dev/null +++ b/contracts/src/tests/test_mcms.cairo @@ -0,0 +1,5 @@ +mod test_set_config; +mod test_set_root; +mod test_execute; +mod utils; + diff --git a/contracts/src/tests/test_mcms/test_execute.cairo b/contracts/src/tests/test_mcms/test_execute.cairo new file mode 100644 index 00000000..e4c58886 --- /dev/null +++ b/contracts/src/tests/test_mcms/test_execute.cairo @@ -0,0 +1,342 @@ +use starknet::{contract_address_const, EthAddress}; +use chainlink::libraries::mocks::mock_multisig_target::{ + IMockMultisigTarget, IMockMultisigTargetDispatcherTrait, IMockMultisigTargetDispatcher +}; +use chainlink::mcms::{ + ExpiringRootAndOpCount, RootMetadata, Config, Signer, ManyChainMultiSig, Op, + IManyChainMultiSigDispatcher, IManyChainMultiSigDispatcherTrait, + IManyChainMultiSigSafeDispatcher, IManyChainMultiSigSafeDispatcherTrait, IManyChainMultiSig, +}; +use snforge_std::{ + declare, ContractClassTrait, start_cheat_chain_id_global, spy_events, + EventSpyAssertionsTrait, // Add for assertions on the EventSpy + cheatcodes::{events::{EventSpy}}, start_cheat_block_timestamp_global, +}; +use chainlink::tests::test_mcms::utils::{setup_mcms_deploy_set_config_and_set_root}; + + +#[test] +fn test_success() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + mcms.set_root(root, valid_until, metadata, metadata_proof, signatures); + + let op1 = *ops.at(0); + let op1_proof = *ops_proof.at(0); + + let target_address = op1.to; + let target = IMockMultisigTargetDispatcher { contract_address: target_address }; + + let (value, toggle) = target.read(); + assert(value == 0, 'should be 0'); + assert(toggle == false, 'should be false'); + + mcms.execute(op1, op1_proof); + + spy + .assert_emitted( + @array![ + ( + mcms_address, + ManyChainMultiSig::Event::OpExecuted( + ManyChainMultiSig::OpExecuted { + nonce: op1.nonce, to: op1.to, selector: op1.selector, data: op1.data + } + ) + ) + ] + ); + + assert(mcms.get_op_count() == 1, 'op count should be 1'); + + let (new_value, _) = target.read(); + assert(new_value == 1234123, 'value should be updated'); + + let op2 = *ops.at(1); + let op2_proof = *ops_proof.at(1); + + mcms.execute(op2, op2_proof); + + spy + .assert_emitted( + @array![ + ( + mcms_address, + ManyChainMultiSig::Event::OpExecuted( + ManyChainMultiSig::OpExecuted { + nonce: op2.nonce, to: op2.to, selector: op2.selector, data: op2.data + } + ) + ) + ] + ); + + assert(mcms.get_op_count() == 2, 'op count should be 2'); + + let (_, new_toggle) = target.read(); + assert(new_toggle == true, 'toggled should be true'); +} + +#[test] +#[feature("safe_dispatcher")] +fn test_no_more_ops_to_execute() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + mcms.set_root(root, valid_until, metadata, metadata_proof, signatures); + + let op1 = *ops.at(0); + let op1_proof = *ops_proof.at(0); + + let op2 = *ops.at(1); + let op2_proof = *ops_proof.at(1); + + mcms.execute(op1, op1_proof); + mcms.execute(op2, op2_proof); + + let result = safe_mcms.execute(op1, op1_proof); + match result { + Result::Ok(_) => panic!("expect 'post-operation count reached'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'post-operation count reached', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_wrong_chain_id() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + mcms.set_root(root, valid_until, metadata, metadata_proof, signatures); + + let op1 = *ops.at(0); + let op1_proof = *ops_proof.at(0); + + start_cheat_chain_id_global(1231); + let result = safe_mcms.execute(op1, op1_proof); + + match result { + Result::Ok(_) => panic!("expect 'wrong chain id'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'wrong chain id', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_wrong_multisig_address() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + mcms.set_root(root, valid_until, metadata, metadata_proof, signatures); + + let mut op1 = *ops.at(0); + op1.multisig = contract_address_const::<119922>(); + let op1_proof = *ops_proof.at(0); + + let result = safe_mcms.execute(op1, op1_proof); + + match result { + Result::Ok(_) => panic!("expect 'wrong multisig address'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'wrong multisig address', *panic_data.at(0)); + } + } +} + + +#[test] +#[feature("safe_dispatcher")] +fn test_root_expired() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + mcms.set_root(root, valid_until, metadata, metadata_proof, signatures); + + let op1 = *ops.at(0); + let op1_proof = *ops_proof.at(0); + + start_cheat_block_timestamp_global(valid_until.into() + 1); + let result = safe_mcms.execute(op1, op1_proof); + + match result { + Result::Ok(_) => panic!("expect 'root has expired'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'root has expired', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_wrong_nonce() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + mcms.set_root(root, valid_until, metadata, metadata_proof, signatures); + + let mut op1 = *ops.at(0); + op1.nonce = 100; + let op1_proof = *ops_proof.at(0); + + let result = safe_mcms.execute(op1, op1_proof); + + match result { + Result::Ok(_) => panic!("expect 'wrong nonce'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'wrong nonce', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_proof_verification_failed() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + mcms.set_root(root, valid_until, metadata, metadata_proof, signatures); + + let op1 = *ops.at(0); + let bad_proof = array![0x12312312312321]; + + let result = safe_mcms.execute(op1, bad_proof.span()); + + match result { + Result::Ok(_) => panic!("expect 'proof verification failed'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'proof verification failed', *panic_data.at(0)); + } + } +} + diff --git a/contracts/src/tests/test_mcms/test_set_config.cairo b/contracts/src/tests/test_mcms/test_set_config.cairo new file mode 100644 index 00000000..9d68415d --- /dev/null +++ b/contracts/src/tests/test_mcms/test_set_config.cairo @@ -0,0 +1,605 @@ +use core::array::{SpanTrait, ArrayTrait}; +use starknet::{ + ContractAddress, EthAddress, Felt252TryIntoEthAddress, EthAddressIntoFelt252, + EthAddressZeroable, contract_address_const +}; +use chainlink::mcms::{ + ExpiringRootAndOpCount, RootMetadata, Config, Signer, ManyChainMultiSig, + ManyChainMultiSig::{ + InternalFunctionsTrait, contract_state_for_testing, s_signersContractMemberStateTrait, + s_expiring_root_and_op_countContractMemberStateTrait, + s_root_metadataContractMemberStateTrait + }, + IManyChainMultiSigDispatcher, IManyChainMultiSigDispatcherTrait, + IManyChainMultiSigSafeDispatcher, IManyChainMultiSigSafeDispatcherTrait, IManyChainMultiSig, + ManyChainMultiSig::{MAX_NUM_SIGNERS}, +}; +use snforge_std::{ + declare, ContractClassTrait, start_cheat_caller_address_global, start_cheat_caller_address, + stop_cheat_caller_address, stop_cheat_caller_address_global, spy_events, + EventSpyAssertionsTrait, // Add for assertions on the EventSpy + test_address, // the contract being tested, + start_cheat_chain_id, + cheatcodes::{events::{EventSpy}} +}; +use chainlink::tests::test_mcms::utils::{ + setup_mcms_deploy, setup_mcms_deploy_and_set_config_2_of_2, ZERO_ARRAY, fill_array +}; + +#[test] +#[feature("safe_dispatcher")] +fn test_not_owner() { + let (_, _, mcms_safe) = setup_mcms_deploy(); + + let signer_addresses = array![]; + let signer_groups = array![]; + let group_quorums = array![]; + let group_parents = array![]; + let clear_root = false; + + // so that caller is not owner + start_cheat_caller_address_global(contract_address_const::<123123>()); + + let result = mcms_safe + .set_config( + signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + match result { + Result::Ok(_) => panic!("expect 'Caller is not the owner'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'Caller is not the owner', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_set_config_out_of_bound_signers() { + // 1. test if len(signer_address) = 0 => revert + let (_, _, mcms_safe) = setup_mcms_deploy(); + + let signer_addresses = array![]; + let signer_groups = array![]; + let group_quorums = array![]; + let group_parents = array![]; + let clear_root = false; + + let result = mcms_safe + .set_config( + signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + match result { + Result::Ok(_) => panic!("expect 'out of bound signers len'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'out of bound signers len', *panic_data.at(0)); + } + } + + // 2. test if lena(signer_address) > MAX_NUM_SIGNERS => revert + + // todo: use fixed-size array in cairo >= 2.7.0 + // let signer_addresses = [EthAddressZeroable::zero(); 201]; + + let mut signer_addresses = ArrayTrait::new(); + let mut i = 0; + while i < 201_usize { + signer_addresses.append(EthAddressZeroable::zero()); + i += 1; + }; + + let result = mcms_safe + .set_config( + signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + match result { + Result::Ok(_) => panic!("expect 'out of bound signers len'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'out of bound signers len', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_set_config_signer_groups_len_mismatch() { + // 3. test if signer addresses and signer groups not same size + let (_, _, mcms_safe) = setup_mcms_deploy(); + + let signer_addresses = array![EthAddressZeroable::zero()]; + let signer_groups = array![]; + let group_quorums = array![]; + let group_parents = array![]; + let clear_root = false; + + let result = mcms_safe + .set_config( + signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + match result { + Result::Ok(_) => panic!("expect 'signer groups len mismatch'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'signer groups len mismatch', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_set_config_group_quorums_parents_mismatch() { + // 4. test if group_quorum and group_parents not length 32 + let (_, _, mcms_safe) = setup_mcms_deploy(); + + let signer_addresses = array![EthAddressZeroable::zero()]; + let signer_groups = array![0]; + let group_quorums = array![0]; + let group_parents = array![0]; + let clear_root = false; + + let result = mcms_safe + .set_config( + signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + match result { + Result::Ok(_) => panic!("expect 'wrong group quorums/parents len'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'wrong group quorums/parents len', *panic_data.at(0)); + } + } + + // 5. test if group_quorum and group_parents not equal in length + + let mut group_quorums = ZERO_ARRAY(); + + let result = mcms_safe + .set_config( + signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + match result { + Result::Ok(_) => panic!("expect 'wrong group quorums/parents len'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'wrong group quorums/parents len', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_set_config_signers_group_out_of_bounds() { + // 6. test if one of signer_group #'s is out of bounds NUM_GROUPS + let (_, _, mcms_safe) = setup_mcms_deploy(); + + let signer_addresses = array![EthAddressZeroable::zero()]; + let signer_groups = array![33]; + + let mut group_quorums = ArrayTrait::new(); + let mut i = 0; + while i < 32_usize { + group_quorums.append(0); + i += 1; + }; + + let mut group_parents = ArrayTrait::new(); + let mut i = 0; + while i < 32_usize { + group_parents.append(0); + i += 1; + }; + + let clear_root = false; + + let result = mcms_safe + .set_config( + signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + match result { + Result::Ok(_) => panic!("expect 'out of bounds group'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'out of bounds group', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_set_config_group_tree_malformed() { + // 7. test if group_parents[i] is greater than or equal to i (when not 0) there is revert + let (_, _, mcms_safe) = setup_mcms_deploy(); + + let signer_addresses = array![EthAddressZeroable::zero()]; + let signer_groups = array![0]; + + let mut group_quorums = ZERO_ARRAY(); + let mut group_parents = fill_array(array![(31, 31)]); + + let clear_root = false; + + let result = mcms_safe + .set_config( + signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + match result { + Result::Ok(_) => panic!("expect 'group tree malformed'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'group tree malformed', *panic_data.at(0)); + } + } + + let mut group_parents = array![ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ]; + + let result = mcms_safe + .set_config( + signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + match result { + Result::Ok(_) => panic!("expect 'group tree malformed'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'group tree malformed', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_set_config_signer_in_disabled_group() { + // 9. test if there is a signer in a group where group_quorum[i] == 0 => revert + let (_, _, mcms_safe) = setup_mcms_deploy(); + + let mut signer_addresses = array![EthAddressZeroable::zero()]; + let signer_groups = array![0]; + let mut group_quorums = ZERO_ARRAY(); + let mut group_parents = ZERO_ARRAY(); + let clear_root = false; + + let result = mcms_safe + .set_config( + signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + match result { + Result::Ok(_) => panic!("expect 'signer in disabled group'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'signer in disabled group', *panic_data.at(0)); + } + } +} + +// 10. test if there are not enough signers to meet a quorum => revert +#[test] +#[feature("safe_dispatcher")] +fn test_set_config_quorum_impossible() { + let (_, _, mcms_safe) = setup_mcms_deploy(); + + let mut signer_addresses = array![EthAddressZeroable::zero()]; + let signer_groups = array![0]; + let mut group_quorums = fill_array(array![(0, 2)]); + let mut group_parents = ZERO_ARRAY(); + let clear_root = false; + + let result = mcms_safe + .set_config( + signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + match result { + Result::Ok(_) => panic!("expect 'quorum impossible'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'quorum impossible', *panic_data.at(0)); + } + } +} + +// 11. test if signer addresses are not in ascending order +#[test] +#[feature("safe_dispatcher")] +fn test_set_config_signer_addresses_not_sorted() { + let (_, _, mcms_safe) = setup_mcms_deploy(); + + let mut signer_addresses: Array = array![ + // 0x1 address + u256 { high: 0, low: 1 }.into(), EthAddressZeroable::zero() + ]; + let signer_groups = array![0, 0]; + let mut group_quorums = fill_array(array![(0, 2)]); + let mut group_parents = ZERO_ARRAY(); + let clear_root = false; + + let result = mcms_safe + .set_config( + signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + match result { + Result::Ok(_) => panic!("expect 'signer addresses not sorted'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'signer addresses not sorted', *panic_data.at(0)); + } + } +} + +// test success, root not cleared, event emitted +// 12. successful => test without clearing root. test the state of storage variables and that event was emitted +// +// ┌──────┐ +// ┌─►│2-of-2│ +// │ └──────┘ +// │ ▲ +// │ │ +// ┌──┴───┐ ┌──┴───┐ +// signer 1 signer 2 +// └──────┘ └──────┘ +#[test] +fn test_set_config_success_dont_clear_root() { + let signer_address_1: EthAddress = (0x141).try_into().unwrap(); + let signer_address_2: EthAddress = (0x2412).try_into().unwrap(); + let ( + mut spy, + mcms_address, + mcms, + _, + _, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root + ) = + setup_mcms_deploy_and_set_config_2_of_2( + signer_address_1, signer_address_2 + ); + + let expected_signer_1 = Signer { address: signer_address_1, index: 0, group: 0 }; + let expected_signer_2 = Signer { address: signer_address_2, index: 1, group: 0 }; + + let expected_config = Config { + signers: array![expected_signer_1, expected_signer_2].span(), + group_quorums: group_quorums.span(), + group_parents: group_parents.span(), + }; + + spy + .assert_emitted( + @array![ + ( + mcms_address, + ManyChainMultiSig::Event::ConfigSet( + ManyChainMultiSig::ConfigSet { + config: expected_config, is_root_cleared: false + } + ) + ) + ] + ); + let config = mcms.get_config(); + assert(config == expected_config, 'config should be same'); + + // mock the contract state + let test_address = test_address(); + start_cheat_caller_address(test_address, contract_address_const::<777>()); + + // test internal function state + let mut state = contract_state_for_testing(); + ManyChainMultiSig::constructor(ref state); + state + .set_config( + signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + let signer_1 = state.get_signer_by_address(signer_address_1); + let signer_2 = state.get_signer_by_address(signer_address_2); + + assert(signer_1 == expected_signer_1, 'signer 1 not equal'); + assert(signer_2 == expected_signer_2, 'signer 2 not equal'); + + // test second set_config + let new_signer_address_1: EthAddress = u256 { high: 0, low: 3 }.into(); + let new_signer_address_2: EthAddress = u256 { high: 0, low: 4 }.into(); + let new_signer_addresses = array![new_signer_address_1, new_signer_address_2]; + + mcms + .set_config( + new_signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + let new_config = mcms.get_config(); + + let new_expected_signer_1 = Signer { address: new_signer_address_1, index: 0, group: 0 }; + let new_expected_signer_2 = Signer { address: new_signer_address_2, index: 1, group: 0 }; + + let new_expected_config = Config { + signers: array![new_expected_signer_1, new_expected_signer_2].span(), + group_quorums: group_quorums.span(), + group_parents: group_parents.span(), + }; + + assert(new_config == new_expected_config, 'new config should be same'); + + state + .set_config( + new_signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + let new_signer_1 = state.get_signer_by_address(new_signer_address_1); + let new_signer_2 = state.get_signer_by_address(new_signer_address_2); + + assert(new_signer_1 == new_expected_signer_1, 'new signer 1 not equal'); + assert(new_signer_2 == new_expected_signer_2, 'new signer 2 not equal'); + + // test old signers were reset + let old_signer_1 = state.get_signer_by_address(signer_address_1); + let old_signer_2 = state.get_signer_by_address(signer_address_2); + assert(old_signer_1.address == EthAddressZeroable::zero(), 'old signer 1 should be reset'); + assert(old_signer_2.address == EthAddressZeroable::zero(), 'old signer 1 should be reset'); +} + + +// test that the config was reset +#[test] +fn test_set_config_success_and_clear_root() { + // mock the contract state + let test_address = test_address(); + let mock_chain_id = 990; + start_cheat_caller_address(test_address, contract_address_const::<777>()); + start_cheat_chain_id(test_address, mock_chain_id); + + let mut state = contract_state_for_testing(); + ManyChainMultiSig::constructor(ref state); + + // initialize s_expiring_root_and_op_count & s_root_metadata + state + .s_expiring_root_and_op_count + .write( + ExpiringRootAndOpCount { + root: u256 { high: 777, low: 777 }, valid_until: 102934894, op_count: 134 + } + ); + + state + .s_root_metadata + .write( + RootMetadata { + chain_id: 123123, + multisig: contract_address_const::<111>(), + pre_op_count: 20, + post_op_count: 155, + override_previous_root: false + } + ); + + let signer_address_1: EthAddress = u256 { high: 0, low: 1 }.into(); + let signer_address_2: EthAddress = u256 { high: 0, low: 2 }.into(); + let signer_addresses: Array = array![signer_address_1, signer_address_2]; + let signer_groups = array![0, 0]; + let group_quorums = fill_array(array![(0, 2)]); + let group_parents = ZERO_ARRAY(); + let clear_root = true; + + state + .set_config( + signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + let expected_s_expiring_root_and_op_count = ExpiringRootAndOpCount { + root: u256 { high: 0, low: 0 }, valid_until: 0, op_count: 134 + }; + let s_expiring_root_and_op_count = state.s_expiring_root_and_op_count.read(); + assert!( + s_expiring_root_and_op_count == expected_s_expiring_root_and_op_count, + "s_expiring_root_and_op_count not equal" + ); + + let expected_s_root_metadata = RootMetadata { + chain_id: mock_chain_id.into(), + multisig: test_address, + pre_op_count: 134, + post_op_count: 134, + override_previous_root: true + }; + let s_root_metadata = state.s_root_metadata.read(); + assert(expected_s_root_metadata == s_root_metadata, 's_root_metadata not equal'); +} diff --git a/contracts/src/tests/test_mcms/test_set_root.cairo b/contracts/src/tests/test_mcms/test_set_root.cairo new file mode 100644 index 00000000..d8d893a9 --- /dev/null +++ b/contracts/src/tests/test_mcms/test_set_root.cairo @@ -0,0 +1,680 @@ +use alexandria_data_structures::array_ext::ArrayTraitExt; +use alexandria_bytes::{Bytes, BytesTrait}; +use alexandria_encoding::sol_abi::sol_bytes::SolBytesTrait; +use alexandria_encoding::sol_abi::encode::SolAbiEncodeTrait; +use core::array::{SpanTrait, ArrayTrait}; +use starknet::{ + eth_signature::is_eth_signature_valid, ContractAddress, EthAddress, EthAddressIntoFelt252, + EthAddressZeroable, contract_address_const, eth_signature::public_key_point_to_eth_address, + secp256_trait::{ + Secp256Trait, Secp256PointTrait, recover_public_key, is_signature_entry_valid, Signature, + signature_from_vrs + }, + secp256k1::Secp256k1Point, SyscallResult, SyscallResultTrait +}; +use chainlink::mcms::{ + recover_eth_ecdsa, hash_pair, hash_op, hash_metadata, ExpiringRootAndOpCount, RootMetadata, + Config, Signer, eip_191_message_hash, ManyChainMultiSig, Op, + ManyChainMultiSig::{ + NewRoot, InternalFunctionsTrait, contract_state_for_testing, + s_signersContractMemberStateTrait, s_expiring_root_and_op_countContractMemberStateTrait, + s_root_metadataContractMemberStateTrait + }, + IManyChainMultiSigDispatcher, IManyChainMultiSigDispatcherTrait, + IManyChainMultiSigSafeDispatcher, IManyChainMultiSigSafeDispatcherTrait, IManyChainMultiSig, + ManyChainMultiSig::{MAX_NUM_SIGNERS}, +}; +use chainlink::tests::test_mcms::utils::{ + insecure_sign, setup_signers, SignerMetadata, setup_mcms_deploy_and_set_config_2_of_2, + setup_mcms_deploy_set_config_and_set_root, set_root_args, merkle_root +}; + +use snforge_std::{ + declare, ContractClassTrait, start_cheat_caller_address_global, start_cheat_caller_address, + stop_cheat_caller_address, stop_cheat_caller_address_global, start_cheat_chain_id_global, + spy_events, EventSpyAssertionsTrait, // Add for assertions on the EventSpy + test_address, // the contract being tested, + start_cheat_chain_id, + cheatcodes::{events::{EventSpy}}, start_cheat_block_timestamp_global, + start_cheat_block_timestamp, start_cheat_account_contract_address_global, + start_cheat_account_contract_address +}; + +// sets up root but with wrong multisig address in metadata +fn setup_mcms_deploy_set_config_and_set_root_WRONG_MULTISIG() -> ( + EventSpy, + ContractAddress, + IManyChainMultiSigDispatcher, + IManyChainMultiSigSafeDispatcher, + Config, + Array, + Array, + Array, + Array, + bool, // clear root + u256, + u32, + RootMetadata, + Span, + Array, + Array, + Span>, +) { + let (signer_address_1, private_key_1, signer_address_2, private_key_2, signer_metadata) = + setup_signers(); + + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root + ) = + setup_mcms_deploy_and_set_config_2_of_2( + signer_address_1, signer_address_2 + ); + + let calldata = ArrayTrait::new(); + let mock_target_contract = declare("MockMultisigTarget").unwrap(); + let (target_address, _) = mock_target_contract.deploy(@calldata).unwrap(); + + // mock chain id & timestamp + let mock_chain_id = 732; + start_cheat_chain_id_global(mock_chain_id); + + start_cheat_block_timestamp_global(3); + + // first operation + let selector1 = selector!("set_value"); + let calldata1: Array = array![1234123]; + let op1 = Op { + chain_id: mock_chain_id.into(), + multisig: mcms_address, + nonce: 0, + to: target_address, + selector: selector1, + data: calldata1.span() + }; + + // second operation + // todo update + let selector2 = selector!("flip_toggle"); + let calldata2 = array![]; + let op2 = Op { + chain_id: mock_chain_id.into(), + multisig: mcms_address, + nonce: 1, + to: target_address, + selector: selector2, + data: calldata2.span() + }; + + let metadata = RootMetadata { + chain_id: mock_chain_id.into(), + multisig: contract_address_const::<123123>(), // choose wrong multisig address + pre_op_count: 0, + post_op_count: 2, + override_previous_root: false, + }; + let valid_until = 9; + + let op1_hash = hash_op(op1); + let op2_hash = hash_op(op2); + + let metadata_hash = hash_metadata(metadata, valid_until); + + // create merkle tree + let (root, metadata_proof, ops_proof) = merkle_root(array![op1_hash, op2_hash, metadata_hash]); + + let encoded_root = BytesTrait::new_empty().encode(root).encode(valid_until); + let message_hash = eip_191_message_hash(encoded_root.keccak()); + + let (r_1, s_1, y_parity_1) = insecure_sign(message_hash, private_key_1); + let (r_2, s_2, y_parity_2) = insecure_sign(message_hash, private_key_2); + + let signature1 = Signature { r: r_1, s: s_1, y_parity: y_parity_1 }; + let signature2 = Signature { r: r_2, s: s_2, y_parity: y_parity_2 }; + + let addr1 = recover_eth_ecdsa(message_hash, signature1).unwrap(); + let addr2 = recover_eth_ecdsa(message_hash, signature2).unwrap(); + + assert(addr1 == signer_address_1, 'signer 1 not equal'); + assert(addr2 == signer_address_2, 'signer 2 not equal'); + + let signatures = array![signature1, signature2]; + + let ops = array![op1.clone(), op2.clone()]; + + ( + spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) +} + +#[test] +fn test_set_root_success() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + mcms.set_root(root, valid_until, metadata, metadata_proof, signatures); + + let (actual_root, actual_valid_until) = mcms.get_root(); + + assert(actual_root == root, 'root returned not equal'); + assert(actual_valid_until == valid_until, 'valid until not equal'); + + let actual_root_metadata = mcms.get_root_metadata(); + assert(actual_root_metadata == metadata, 'root metadata not equal'); + + spy + .assert_emitted( + @array![ + ( + mcms_address, + ManyChainMultiSig::Event::NewRoot( + ManyChainMultiSig::NewRoot { + root: root, valid_until: valid_until, metadata: metadata + } + ) + ) + ] + ); +} +#[test] +#[feature("safe_dispatcher")] +fn test_set_root_hash_seen() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + mcms.set_root(root, valid_until, metadata, metadata_proof, signatures.clone()); + + let result = safe_mcms.set_root(root, valid_until, metadata, metadata_proof, signatures); + + match result { + Result::Ok(_) => panic!("expect 'signed hash already seen'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'signed hash already seen', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_set_root_signatures_wrong_order() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + let unsorted_signatures = array![*signatures.at(1), *signatures.at(0)]; + + let result = safe_mcms + .set_root(root, valid_until, metadata, metadata_proof, unsorted_signatures); + + match result { + Result::Ok(_) => panic!("expect 'signer address must increase'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'signer address must increase', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_set_root_signatures_invalid_signer() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + let invalid_signatures = array![ + signature_from_vrs( + v: 27, + r: u256 { + high: 0x9e8df5d64fb9d2ae155b435ac37519fd, low: 0x6d1ffddf225cde953f6c97f8b3a7531d, + }, + s: u256 { + high: 0x21f13cc6eb1d14f6ebdc497411c57589, low: 0xea109b402fcde2cfe8f3d1b6d2bb8948 + }, + ), + signature_from_vrs( + v: 27, + r: u256 { + high: 0x7a5d64ca9b1814e15eb8df73b3c79ac2, low: 0x9b9080ac6546e07b1118b16e5651e19d, + }, + s: u256 { + high: 0x62794369d5bb5f5a02d2eb6805951990, low: 0xdfcd8563639dcc6668e235e1bea93303 + }, + ) + ]; + + let result = safe_mcms + .set_root(root, valid_until, metadata, metadata_proof, invalid_signatures); + + match result { + Result::Ok(_) => panic!("expect 'invalid signer'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'invalid signer', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_insufficient_signers() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + let missing_1_signature = array![*signatures.at(0)]; + + let result = safe_mcms + .set_root(root, valid_until, metadata, metadata_proof, missing_1_signature); + + match result { + Result::Ok(_) => panic!("expect 'insufficient signers'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'insufficient signers', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_valid_until_expired() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + // cheat block timestamp + start_cheat_block_timestamp_global(valid_until.into() + 1); + + let result = safe_mcms.set_root(root, valid_until, metadata, metadata_proof, signatures); + + match result { + Result::Ok(_) => panic!("expect 'valid until has passed'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'valid until has passed', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_invalid_metadata_proof() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + let invalid_metadata_proof = array![*metadata_proof.at(0), *metadata_proof.at(0)]; + + let result = safe_mcms + .set_root(root, valid_until, metadata, invalid_metadata_proof.span(), signatures); + + match result { + Result::Ok(_) => panic!("expect 'proof verification failed'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'proof verification failed', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_invalid_chain_id() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + start_cheat_chain_id_global(123123); + + let result = safe_mcms.set_root(root, valid_until, metadata, metadata_proof, signatures); + + match result { + Result::Ok(_) => panic!("expect 'wrong chain id'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'wrong chain id', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_invalid_multisig_address() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root_WRONG_MULTISIG(); + + let result = safe_mcms.set_root(root, valid_until, metadata, metadata_proof, signatures); + + match result { + Result::Ok(_) => panic!("expect 'wrong multisig address'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'wrong multisig address', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_pending_ops_remain() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + // first time passes + mcms.set_root(root, valid_until, metadata, metadata_proof, signatures.clone()); + + // sign a different set of operations with same signers + let (signer_address_1, private_key_1, signer_address_2, private_key_2, signer_metadata) = + setup_signers(); + let (root, valid_until, metadata, metadata_proof, signatures, ops, ops_proof) = set_root_args( + mcms_address, contract_address_const::<123123>(), signer_metadata, 0, 2 + ); + + // second time fails + let result = safe_mcms.set_root(root, valid_until, metadata, metadata_proof, signatures); + + match result { + Result::Ok(_) => panic!("expect 'pending operations remain'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'pending operations remain', *panic_data.at(0)); + } + } +} + + +#[test] +#[feature("safe_dispatcher")] +fn test_wrong_pre_op_count() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + _ + ) = + setup_mcms_deploy_set_config_and_set_root(); + + // sign a different set of operations with same signers + let (signer_address_1, private_key_1, signer_address_2, private_key_2, signer_metadata) = + setup_signers(); + let wrong_pre_op_count = 1; + let (root, valid_until, metadata, metadata_proof, signatures, _, _) = set_root_args( + mcms_address, + contract_address_const::<123123>(), + signer_metadata, + wrong_pre_op_count, + wrong_pre_op_count + 2 + ); + + // first time passes + let result = safe_mcms + .set_root(root, valid_until, metadata, metadata_proof, signatures.clone()); + + match result { + Result::Ok(_) => panic!("expect 'wrong pre-operation count'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'wrong pre-operation count', *panic_data.at(0)); + } + } +} + +#[test] +#[feature("safe_dispatcher")] +fn test_wrong_post_ops_count() { + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) = + setup_mcms_deploy_set_config_and_set_root(); + + mcms.set_root(root, valid_until, metadata, metadata_proof, signatures); + + // sign a different set of operations with same signers + + let (signer_address_1, private_key_1, signer_address_2, private_key_2, signer_metadata) = + setup_signers(); + + let op1 = *ops.at(0); + let op1_proof = *ops_proof.at(0); + + let op2 = *ops.at(1); + let op2_proof = *ops_proof.at(1); + + mcms.execute(op1, op1_proof); + mcms.execute(op2, op2_proof); + + let (root, valid_until, metadata, metadata_proof, signatures, ops, ops_proof) = set_root_args( + mcms_address, + contract_address_const::<123123>(), + signer_metadata, + 2, // correct pre-op count + 1 // wrong post-op count + ); + + let result = safe_mcms.set_root(root, valid_until, metadata, metadata_proof, signatures); + match result { + Result::Ok(_) => panic!("expect 'wrong post-operation count'"), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'wrong post-operation count', *panic_data.at(0)); + } + } +} diff --git a/contracts/src/tests/test_mcms/utils.cairo b/contracts/src/tests/test_mcms/utils.cairo new file mode 100644 index 00000000..bbb1195a --- /dev/null +++ b/contracts/src/tests/test_mcms/utils.cairo @@ -0,0 +1,434 @@ +use core::integer::{u512, u256_wide_mul}; +use alexandria_bytes::{Bytes, BytesTrait}; +use alexandria_encoding::sol_abi::sol_bytes::SolBytesTrait; +use alexandria_encoding::sol_abi::encode::SolAbiEncodeTrait; +use alexandria_math::u512_arithmetics; +use core::math::{u256_mul_mod_n, u256_div_mod_n}; +use core::zeroable::{IsZeroResult, NonZero, zero_based}; +use alexandria_math::u512_arithmetics::{u512_add, u512_sub, U512Intou256X2,}; + +use starknet::{ + ContractAddress, EthAddress, EthAddressIntoFelt252, EthAddressZeroable, contract_address_const, + eth_signature::public_key_point_to_eth_address, + secp256_trait::{ + Secp256Trait, Secp256PointTrait, recover_public_key, is_signature_entry_valid, Signature, + signature_from_vrs + }, + secp256k1::{Secp256k1Point, Secp256k1Impl}, SyscallResult, SyscallResultTrait +}; +use chainlink::mcms::{ + recover_eth_ecdsa, hash_pair, hash_op, hash_metadata, ExpiringRootAndOpCount, RootMetadata, + Config, Signer, eip_191_message_hash, ManyChainMultiSig, Op, + ManyChainMultiSig::{ + NewRoot, InternalFunctionsTrait, contract_state_for_testing, + s_signersContractMemberStateTrait, s_expiring_root_and_op_countContractMemberStateTrait, + s_root_metadataContractMemberStateTrait + }, + IManyChainMultiSigDispatcher, IManyChainMultiSigDispatcherTrait, + IManyChainMultiSigSafeDispatcher, IManyChainMultiSigSafeDispatcherTrait, IManyChainMultiSig, + ManyChainMultiSig::{MAX_NUM_SIGNERS}, +}; +use snforge_std::{ + declare, ContractClassTrait, start_cheat_caller_address_global, start_cheat_caller_address, + stop_cheat_caller_address, stop_cheat_caller_address_global, spy_events, + EventSpyAssertionsTrait, // Add for assertions on the EventSpy + test_address, // the contract being tested, + start_cheat_chain_id, start_cheat_chain_id_global, + start_cheat_block_timestamp_global, cheatcodes::{events::{EventSpy}} +}; + +// +// setup helpers +// + +// returns a length 32 array +// give (index, value) tuples to fill array with +// +// ex: fill_array(array!(0, 1)) will fill the 0th index with value 1 +// +// assumes that values array is sorted in ascending order of the index +fn fill_array(mut values: Array<(u32, u8)>) -> Array { + let mut result: Array = ArrayTrait::new(); + + let mut maybe_next = values.pop_front(); + + let mut i = 0; + while i < 32_u32 { + match maybe_next { + Option::Some(next) => { + let (next_index, next_value) = next; + + if i == next_index { + result.append(next_value); + maybe_next = values.pop_front(); + } else { + result.append(0); + } + }, + Option::None(_) => { result.append(0); }, + } + + i += 1; + }; + + result +} + +fn ZERO_ARRAY() -> Array { + // todo: replace with [0_u8; 32] in cairo 2.7.0+ + fill_array(array![]) +} + +#[derive(Copy, Drop, Serde)] +struct SignerMetadata { + address: EthAddress, + private_key: u256 +} + +fn setup_signers() -> (EthAddress, u256, EthAddress, u256, Array) { + let signer_address_1: EthAddress = (0x13Cf92228941e27eBce80634Eba36F992eCB148A) + .try_into() + .unwrap(); + let private_key_1: u256 = 0xf366414c9042ec470a8d92e43418cbf62caabc2bbc67e82bd530958e7fcaa688; + + let signer_address_2: EthAddress = (0xDa09C953823E1F60916E85faD44bF99A7DACa267) + .try_into() + .unwrap(); + let private_key_2: u256 = 0xed10b7a09dd0418ab35b752caffb70ee50bbe1fe25a2ebe8bba8363201d48527; + + let signer_metadata = array![ + SignerMetadata { address: signer_address_1, private_key: private_key_1 }, + SignerMetadata { address: signer_address_2, private_key: private_key_2 } + ]; + (signer_address_1, private_key_1, signer_address_2, private_key_2, signer_metadata) +} + +impl U512PartialOrd of PartialOrd { + #[inline(always)] + fn le(lhs: u512, rhs: u512) -> bool { + !(rhs < lhs) + } + #[inline(always)] + fn ge(lhs: u512, rhs: u512) -> bool { + !(lhs < rhs) + } + fn lt(lhs: u512, rhs: u512) -> bool { + if lhs.limb3 < rhs.limb3 { + true + } else if lhs.limb3 == rhs.limb3 { + if lhs.limb2 < rhs.limb2 { + true + } else if lhs.limb2 == rhs.limb2 { + if lhs.limb1 < rhs.limb1 { + true + } else { + false + } + } else { + false + } + } else { + false + } + } + #[inline(always)] + fn gt(lhs: u512, rhs: u512) -> bool { + rhs < lhs + } +} + +// *** THIS IS CRYPTOGRAPHICALLY INSECURE *** +// the usage of a constant random target means that anyone can reverse engineer the private keys +// therefore this method is only meant to be used for tests +// arg z: message hash, arg e: private key +fn insecure_sign(z: u256, e: u256) -> (u256, u256, bool) { + let z_u512: u512 = u256_wide_mul(z, (0x1).into()); + + // order of the finite group + let N = Secp256k1Impl::get_curve_size().try_into().unwrap(); + let n_u512: u512 = u256_wide_mul(N, (0x1).into()); + + // "random" number k would be generated by a pseudo-random number generator + // in secure applications it's important that k is random, or else the private key can + // be derived from r and s + let k = 777; + + // random target + let R = Secp256k1Impl::get_generator_point().mul(k).unwrap(); + let (r_x, r_y) = R.get_coordinates().unwrap(); + + // calculate s = ( z + r*e ) / k (finite element operations) + // where product = r*e and sum = z + r*re + let product = u256_mul_mod_n(r_x, e, N.try_into().unwrap()); + let product_u512: u512 = u256_wide_mul(product, (0x1).into()); + + // sum = z + product (finite element operations) + // avoid u256 overflow by casting to u512 + let mut sum_u512 = u512_add(z_u512, product_u512); + while sum_u512 >= n_u512 { + sum_u512 = u512_sub(sum_u512, n_u512); + }; + let sum: u256 = sum_u512.try_into().unwrap(); + + let s = u256_div_mod_n(sum, k, N.try_into().unwrap()).unwrap(); + + let v = 27 + (r_y % 2); + + let y_parity = v % 2 == 0; + + (r_x, s, y_parity) +} + +// simplified logic will only work when len(ops) = 2 +// metadata nodes is the last leaf so that len(leafs) = 3 +fn merkle_root(leafs: Array) -> (u256, Span, Span>) { + let mut level: Array = ArrayTrait::new(); + + let metadata = *leafs.at(leafs.len() - 1); + let mut i = 0; + + // we assume metadata is last leaf so we exclude for now + while i < leafs.len() - 1 { + level.append(*leafs.at(i)); + i += 1; + }; + + let mut level = level.span(); // [leaf1, leaf2] + + let proof1 = array![*level.at(1), metadata]; + let proof2 = array![*level.at(0), metadata]; + + // level length is always even (except when it's 1) + while level + .len() > 1 { + let mut i = 0; + let mut new_level: Array = ArrayTrait::new(); + while i < level + .len() { + new_level.append(hash_pair(*(level.at(i)), *level.at(i + 1))); + i += 2 + }; + level = new_level.span(); + }; + + let mut metadata_proof = *level.at(0); + + // based on merkletree.js lib we use, the odd leaf out is not hashed until the very end + let root = hash_pair(*level.at(0), metadata); + + (root, array![metadata_proof].span(), array![proof1.span(), proof2.span()].span()) +} + +fn set_root_args( + mcms_address: ContractAddress, + target_address: ContractAddress, + mut signers_metadata: Array, + pre_op_count: u64, + post_op_count: u64 +) -> (u256, u32, RootMetadata, Span, Array, Array, Span>) { + let mock_chain_id = 732; + + // first operation + let selector1 = selector!("set_value"); + let calldata1: Array = array![1234123]; + let op1 = Op { + chain_id: mock_chain_id.into(), + multisig: mcms_address, + nonce: 0, + to: target_address, + selector: selector1, + data: calldata1.span() + }; + + // second operation + let selector2 = selector!("flip_toggle"); + let calldata2 = array![]; + let op2 = Op { + chain_id: mock_chain_id.into(), + multisig: mcms_address, + nonce: 1, + to: target_address, + selector: selector2, + data: calldata2.span() + }; + + let metadata = RootMetadata { + chain_id: mock_chain_id.into(), + multisig: mcms_address, + pre_op_count: pre_op_count, + post_op_count: post_op_count, + override_previous_root: false, + }; + let valid_until = 9; + + let op1_hash = hash_op(op1); + let op2_hash = hash_op(op2); + + let metadata_hash = hash_metadata(metadata, valid_until); + + // create merkle tree + let (root, metadata_proof, ops_proof) = merkle_root(array![op1_hash, op2_hash, metadata_hash]); + + let encoded_root = BytesTrait::new_empty().encode(root).encode(valid_until); + let message_hash = eip_191_message_hash(encoded_root.keccak()); + + let mut signatures: Array = ArrayTrait::new(); + + while let Option::Some(signer_metadata) = signers_metadata + .pop_front() { + let (r, s, y_parity) = insecure_sign(message_hash, signer_metadata.private_key); + let signature = Signature { r: r, s: s, y_parity: y_parity }; + let address = recover_eth_ecdsa(message_hash, signature).unwrap(); + + // sanity check + assert(address == signer_metadata.address, 'signer not equal'); + + signatures.append(signature); + }; + + let ops = array![op1.clone(), op2.clone()]; + + (root, valid_until, metadata, metadata_proof, signatures, ops, ops_proof) +} + +// +// setup functions +// + +fn setup_mcms_deploy() -> ( + ContractAddress, IManyChainMultiSigDispatcher, IManyChainMultiSigSafeDispatcher +) { + let calldata = array![]; + + let (mcms_address, _) = declare("ManyChainMultiSig").unwrap().deploy(@calldata).unwrap(); + + ( + mcms_address, + IManyChainMultiSigDispatcher { contract_address: mcms_address }, + IManyChainMultiSigSafeDispatcher { contract_address: mcms_address } + ) +} + +fn setup_mcms_deploy_and_set_config_2_of_2( + signer_address_1: EthAddress, signer_address_2: EthAddress +) -> ( + EventSpy, + ContractAddress, + IManyChainMultiSigDispatcher, + IManyChainMultiSigSafeDispatcher, + Config, + Array, + Array, + Array, + Array, + bool +) { + let (mcms_address, mcms, safe_mcms) = setup_mcms_deploy(); + + let signer_addresses: Array = array![signer_address_1, signer_address_2]; + let signer_groups = array![0, 0]; + let group_quorums = fill_array(array![(0, 2)]); + let group_parents = ZERO_ARRAY(); + + let clear_root = false; + + let mut spy = spy_events(); + + mcms + .set_config( + signer_addresses.span(), + signer_groups.span(), + group_quorums.span(), + group_parents.span(), + clear_root + ); + + let config = mcms.get_config(); + + ( + spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root + ) +} + +// sets up root +fn setup_mcms_deploy_set_config_and_set_root() -> ( + EventSpy, + ContractAddress, + IManyChainMultiSigDispatcher, + IManyChainMultiSigSafeDispatcher, + Config, + Array, + Array, + Array, + Array, + bool, // clear root + u256, + u32, + RootMetadata, + Span, + Array, + Array, + Span> +) { + let (signer_address_1, private_key_1, signer_address_2, private_key_2, signer_metadata) = + setup_signers(); + + let ( + mut spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root + ) = + setup_mcms_deploy_and_set_config_2_of_2( + signer_address_1, signer_address_2 + ); + + let calldata = ArrayTrait::new(); + let mock_target_contract = declare("MockMultisigTarget").unwrap(); + let (target_address, _) = mock_target_contract.deploy(@calldata).unwrap(); + + let (root, valid_until, metadata, metadata_proof, signatures, ops, ops_proof) = set_root_args( + mcms_address, target_address, signer_metadata, 0, 2 + ); + + // mock chain id & timestamp + start_cheat_chain_id_global(metadata.chain_id.try_into().unwrap()); + + let mock_timestamp = 3; + start_cheat_block_timestamp_global(mock_timestamp); + + ( + spy, + mcms_address, + mcms, + safe_mcms, + config, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root, + root, + valid_until, + metadata, + metadata_proof, + signatures, + ops, + ops_proof + ) +} diff --git a/contracts/src/utils.cairo b/contracts/src/utils.cairo index 13db189c..24891764 100644 --- a/contracts/src/utils.cairo +++ b/contracts/src/utils.cairo @@ -7,4 +7,3 @@ fn split_felt(felt: felt252) -> (u128, u128) { U128sFromFelt252Result::Wide((high, low)) => (high, low), } } -