From fc48d99de499a26d184b328dfaf0679ea4deb8ec Mon Sep 17 00:00:00 2001 From: Emmazdev Date: Mon, 28 Sep 2026 18:14:56 +0100 Subject: [PATCH 1/2] feat: key merge voting and holder position migration Add weighted holder voting, threshold validation, atomic balance migration and source-key deprecation to the key merge module. --- creator-keys/src/acl_limits_merge_sunset.rs | 774 +++++++++++++++++++- 1 file changed, 764 insertions(+), 10 deletions(-) diff --git a/creator-keys/src/acl_limits_merge_sunset.rs b/creator-keys/src/acl_limits_merge_sunset.rs index d609c8e5..b7f51fd4 100644 --- a/creator-keys/src/acl_limits_merge_sunset.rs +++ b/creator-keys/src/acl_limits_merge_sunset.rs @@ -1,5 +1,5 @@ use crate::ContractError; -use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol}; +use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol, Vec}; #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] @@ -14,11 +14,32 @@ pub struct AclEntry { pub struct KeyMergeProposal { pub source_key: Address, pub target_key: Address, - pub conversion_ratio_bps: u32, // 10000 = 1:1 + pub conversion_ratio_bps: u32, // 10000 = 1:1 + pub approval_threshold_bps: u32, // share of cast weight that must approve + pub votes_for: u32, + pub votes_against: u32, + pub total_voted_weight: u32, pub is_approved_by_admin: bool, pub is_executed: bool, } +/// A single holder's recorded vote on a merge proposal. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct MergeVoteRecord { + pub approve: bool, + pub weight: u32, +} + +/// One migrated source-key position, kept as the on-chain migration log. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct MergeMigrationEntry { + pub holder: Address, + pub liquid_migrated: u32, + pub staked_migrated: u32, +} + #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] pub struct KeySunsetStatus { @@ -32,12 +53,19 @@ pub struct KeySunsetStatus { pub enum EmwulrdDataKey { Acl(Address), MaxBuyPerTx(Address), - MergeProposal(Address), // source_key + MergeProposal(Address), // source_key -> KeyMergeProposal + /// source_key -> Vec
, the holders whose positions are migrated. + MergeHolders(Address), + /// (source_key, voter) -> MergeVoteRecord + MergeVote(Address, Address), + /// source_key -> Vec + MergeMigrationLog(Address), SunsetStatus(Address), } pub const ACL_UPDATED_EVENT: Symbol = symbol_short!("acl_upd"); pub const BUY_LIMIT_CONFIGURED_EVENT: Symbol = symbol_short!("buy_lim"); +pub const MERGE_VOTE_EVENT: Symbol = symbol_short!("merge_vt"); pub const MERGE_EXECUTED_EVENT: Symbol = symbol_short!("merge_ex"); pub const KEY_SUNSET_FLAGGED_EVENT: Symbol = symbol_short!("sunset"); pub const KEY_DEPRECATED_EVENT: Symbol = symbol_short!("deprec"); @@ -152,35 +180,194 @@ pub fn check_tx_buy_limit( } /// #949: Propose key consolidation/merge into a target key. +/// +/// The creator supplies the complete set of `source_holders` that currently +/// hold a position on `source_key`; this list is the enumeration the async +/// execution path migrates from. Duplicate addresses are rejected so the +/// migration can never credit the same position twice. pub fn propose_key_merge( env: &Env, creator: &Address, source_key: &Address, target_key: &Address, conversion_ratio_bps: u32, + approval_threshold_bps: u32, + source_holders: Vec
, ) -> Result<(), ContractError> { creator.require_auth(); - if source_key == target_key || conversion_ratio_bps == 0 { + if source_key == target_key + || conversion_ratio_bps == 0 + || conversion_ratio_bps > 10_000 + || approval_threshold_bps == 0 + || approval_threshold_bps > 10_000 + { return Err(ContractError::InvalidFeeConfig); } + if source_holders.is_empty() { + return Err(ContractError::NoKeyHolders); + } + + // Reject duplicate holders: a repeated address would be migrated twice. + let len = source_holders.len(); + let mut i = 0; + while i < len { + let mut j = i + 1; + while j < len { + if source_holders.get(i) == source_holders.get(j) { + return Err(ContractError::AlreadyRegistered); + } + j += 1; + } + i += 1; + } + + let proposal_key = EmwulrdDataKey::MergeProposal(source_key.clone()); + if let Some(existing) = env + .storage() + .instance() + .get::<_, KeyMergeProposal>(&proposal_key) + { + if existing.is_executed { + return Err(ContractError::AlreadyRegistered); + } + } let proposal = KeyMergeProposal { source_key: source_key.clone(), target_key: target_key.clone(), conversion_ratio_bps, + approval_threshold_bps, + votes_for: 0, + votes_against: 0, + total_voted_weight: 0, is_approved_by_admin: false, is_executed: false, }; + env.storage().instance().set(&proposal_key, &proposal); env.storage().instance().set( - &EmwulrdDataKey::MergeProposal(source_key.clone()), - &proposal, + &EmwulrdDataKey::MergeHolders(source_key.clone()), + &source_holders, + ); + + Ok(()) +} + +/// Read the merge proposal for a source key, if one exists. +pub fn get_key_merge_proposal(env: &Env, source_key: &Address) -> Option { + env.storage() + .instance() + .get(&EmwulrdDataKey::MergeProposal(source_key.clone())) +} + +/// Read the migration log captured when a merge executed. +pub fn get_merge_migration_log(env: &Env, source_key: &Address) -> Vec { + env.storage() + .instance() + .get(&EmwulrdDataKey::MergeMigrationLog(source_key.clone())) + .unwrap_or(Vec::new(env)) +} + +/// #962: Cast a merge vote as a holder of the source key. +/// +/// Vote weight is the voter's liquid (non-staked) source-key balance, mirroring +/// the token-weighted poll and governance implementations. Casting a new vote +/// replaces the previous one, removing the old weight before adding the new so +/// a holder can never be counted twice. +pub fn vote_on_merge( + env: &Env, + source_key_holder: &Address, + source_key: &Address, + approve: bool, +) -> Result<(), ContractError> { + source_key_holder.require_auth(); + + let proposal_key = EmwulrdDataKey::MergeProposal(source_key.clone()); + let mut proposal: KeyMergeProposal = env + .storage() + .instance() + .get(&proposal_key) + .ok_or(ContractError::ProposalNotFound)?; + + if proposal.is_executed { + return Err(ContractError::ActionNotPending); + } + + let balance_key = crate::constants::storage::holder_balance_key(source_key, source_key_holder); + let weight: u32 = env.storage().persistent().get(&balance_key).unwrap_or(0); + if weight == 0 { + return Err(ContractError::InsufficientBalance); + } + + let vote_key = EmwulrdDataKey::MergeVote(source_key.clone(), source_key_holder.clone()); + let previous: Option = env.storage().instance().get(&vote_key); + if let Some(prev) = previous { + if prev.approve { + proposal.votes_for = proposal + .votes_for + .checked_sub(prev.weight) + .ok_or(ContractError::Overflow)?; + } else { + proposal.votes_against = proposal + .votes_against + .checked_sub(prev.weight) + .ok_or(ContractError::Overflow)?; + } + proposal.total_voted_weight = proposal + .total_voted_weight + .checked_sub(prev.weight) + .ok_or(ContractError::Overflow)?; + } + + if approve { + proposal.votes_for = proposal + .votes_for + .checked_add(weight) + .ok_or(ContractError::Overflow)?; + } else { + proposal.votes_against = proposal + .votes_against + .checked_add(weight) + .ok_or(ContractError::Overflow)?; + } + proposal.total_voted_weight = proposal + .total_voted_weight + .checked_add(weight) + .ok_or(ContractError::Overflow)?; + + env.storage().instance().set(&proposal_key, &proposal); + env.storage() + .instance() + .set(&vote_key, &MergeVoteRecord { approve, weight }); + + env.events().publish( + ( + MERGE_VOTE_EVENT, + source_key.clone(), + source_key_holder.clone(), + ), + (approve, weight), ); Ok(()) } -/// Execute approved key merge and migrate holder balance ratio. +/// Convert a source balance at the proposal ratio, rounding down. +fn convert_at_ratio(amount: u32, ratio_bps: u32) -> Result { + let scaled = (amount as u64) + .checked_mul(ratio_bps as u64) + .ok_or(ContractError::Overflow)?; + u32::try_from(scaled / 10_000).map_err(|_| ContractError::Overflow) +} + +/// #962: Execute an approved key merge and migrate holder balance ratio. +/// +/// The merge only runs once the weighted approval share reaches the proposal's +/// `approval_threshold_bps`. Every registered source holder's liquid and staked +/// positions are then moved to the target key at `conversion_ratio_bps`, the +/// source aggregate stake total is transferred, and the source key is +/// deprecated in the same call so no window exists where it is neither live +/// nor deprecated. pub fn execute_key_merge( env: &Env, creator: &Address, @@ -196,20 +383,171 @@ pub fn execute_key_merge( .storage() .instance() .get(&proposal_key) - .ok_or(ContractError::Unauthorized)?; + .ok_or(ContractError::ProposalNotFound)?; if proposal.is_executed || proposal.target_key != *target_key { return Err(ContractError::AlreadyRegistered); } + // Validate the vote threshold before touching any holder position. A merge + // with no cast weight can never satisfy a positive threshold. + let approval_share_bps = if proposal.total_voted_weight == 0 { + 0u64 + } else { + (proposal.votes_for as u64) * 10_000 / (proposal.total_voted_weight as u64) + }; + if approval_share_bps < proposal.approval_threshold_bps as u64 { + return Err(ContractError::Unauthorized); + } + + let holders: Vec
= env + .storage() + .instance() + .get(&EmwulrdDataKey::MergeHolders(source_key.clone())) + .ok_or(ContractError::NoKeyHolders)?; + if holders.is_empty() { + return Err(ContractError::NoKeyHolders); + } + + let ratio = proposal.conversion_ratio_bps; + let mut total_liquid_migrated: u32 = 0; + let mut total_staked_migrated: u32 = 0; + let mut log: Vec = Vec::new(env); + + for holder in holders.iter() { + let source_liquid_key = crate::constants::storage::holder_balance_key(source_key, &holder); + let source_staked_key = crate::constants::storage::staked_balance(source_key, &holder); + + let liquid: u32 = env + .storage() + .persistent() + .get(&source_liquid_key) + .unwrap_or(0); + let staked: u32 = env + .storage() + .persistent() + .get(&source_staked_key) + .unwrap_or(0); + + if liquid == 0 && staked == 0 { + continue; + } + + let liquid_migrated = convert_at_ratio(liquid, ratio)?; + let staked_migrated = convert_at_ratio(staked, ratio)?; + + // Drain the source position even when rounding leaves zero: the merge is + // a move, and dust must not keep the deprecated key alive. + if liquid > 0 { + if liquid_migrated > 0 { + let target_balance_key = + crate::constants::storage::holder_balance_key(target_key, &holder); + let existing: u32 = env + .storage() + .persistent() + .get(&target_balance_key) + .unwrap_or(0); + let updated = existing + .checked_add(liquid_migrated) + .ok_or(ContractError::Overflow)?; + env.storage() + .persistent() + .set(&target_balance_key, &updated); + } + env.storage().persistent().remove(&source_liquid_key); + } + + if staked > 0 { + if staked_migrated > 0 { + let target_staked_key = + crate::constants::storage::staked_balance(target_key, &holder); + let existing: u32 = env + .storage() + .persistent() + .get(&target_staked_key) + .unwrap_or(0); + let updated = existing + .checked_add(staked_migrated) + .ok_or(ContractError::Overflow)?; + env.storage().persistent().set(&target_staked_key, &updated); + } + env.storage().persistent().remove(&source_staked_key); + total_staked_migrated = total_staked_migrated + .checked_add(staked_migrated) + .ok_or(ContractError::Overflow)?; + } + + total_liquid_migrated = total_liquid_migrated + .checked_add(liquid_migrated) + .ok_or(ContractError::Overflow)?; + + log.push_back(MergeMigrationEntry { + holder, + liquid_migrated, + staked_migrated, + }); + } + + // Move the aggregate staked total so the source key reports no stake after + // deprecation and the target key's total reflects the migrated positions. + if total_staked_migrated > 0 { + let source_total_key = crate::constants::storage::total_staked(source_key); + let target_total_key = crate::constants::storage::total_staked(target_key); + let source_total = crate::read_total_staked(env, source_key); + let target_total = crate::read_total_staked(env, target_key); + + let remaining = source_total.saturating_sub(total_staked_migrated); + if remaining == 0 { + env.storage().persistent().remove(&source_total_key); + } else { + env.storage() + .persistent() + .set(&source_total_key, &remaining); + } + + let new_target_total = target_total + .checked_add(total_staked_migrated) + .ok_or(ContractError::Overflow)?; + env.storage() + .persistent() + .set(&target_total_key, &new_target_total); + } + + // Atomically deprecate the source key: the protocol-wide marker rejects new + // buys, and the module's sunset status records the deprecation. + env.storage().persistent().set( + &crate::constants::storage::deprecated_key(source_key), + &0i128, + ); + env.storage().instance().set( + &EmwulrdDataKey::SunsetStatus(source_key.clone()), + &KeySunsetStatus { + last_trade_ledger: env.ledger().sequence(), + is_sunset_pending: false, + is_deprecated: true, + }, + ); + + env.storage() + .instance() + .set(&EmwulrdDataKey::MergeMigrationLog(source_key.clone()), &log); + proposal.is_approved_by_admin = true; proposal.is_executed = true; - env.storage().instance().set(&proposal_key, &proposal); env.events().publish( (MERGE_EXECUTED_EVENT, source_key.clone(), target_key.clone()), - proposal.conversion_ratio_bps, + ( + proposal.conversion_ratio_bps, + log.len(), + total_liquid_migrated, + total_staked_migrated, + ), + ); + env.events().publish( + (KEY_DEPRECATED_EVENT, source_key.clone()), + env.ledger().sequence(), ); Ok(proposal.conversion_ratio_bps) @@ -290,3 +628,419 @@ pub fn finalize_key_deprecation( Ok(()) } + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::testutils::Address as _; + + fn setup_contract(env: &Env) -> Address { + env.register(crate::CreatorKeysContract, ()) + } + + fn set_position( + env: &Env, + contract_id: &Address, + creator: &Address, + holder: &Address, + liquid: u32, + staked: u32, + ) { + env.as_contract(contract_id, || { + if liquid > 0 { + env.storage().persistent().set( + &crate::constants::storage::holder_balance_key(creator, holder), + &liquid, + ); + } + if staked > 0 { + env.storage().persistent().set( + &crate::constants::storage::staked_balance(creator, holder), + &staked, + ); + // Mirror the staking entrypoint: keep the aggregate in step. + let total_key = crate::constants::storage::total_staked(creator); + let current: u32 = env.storage().persistent().get(&total_key).unwrap_or(0); + env.storage() + .persistent() + .set(&total_key, &(current + staked)); + } + }); + } + + fn read_liquid(env: &Env, contract_id: &Address, creator: &Address, holder: &Address) -> u32 { + env.as_contract(contract_id, || { + env.storage() + .persistent() + .get(&crate::constants::storage::holder_balance_key( + creator, holder, + )) + .unwrap_or(0) + }) + } + + fn read_staked(env: &Env, contract_id: &Address, creator: &Address, holder: &Address) -> u32 { + env.as_contract(contract_id, || { + env.storage() + .persistent() + .get(&crate::constants::storage::staked_balance(creator, holder)) + .unwrap_or(0) + }) + } + + // Each entrypoint is invoked in its own contract frame: in production the + // proposal, the votes and the execution are separate transactions, and the + // mock auth recorder rejects a repeated `require_auth` inside one frame. + fn propose( + env: &Env, + contract_id: &Address, + creator: &Address, + source_key: &Address, + target_key: &Address, + ratio: u32, + threshold: u32, + holders: Vec
, + ) -> Result<(), ContractError> { + env.as_contract(contract_id, || { + propose_key_merge( + env, creator, source_key, target_key, ratio, threshold, holders, + ) + }) + } + + fn vote( + env: &Env, + contract_id: &Address, + holder: &Address, + source_key: &Address, + approve: bool, + ) -> Result<(), ContractError> { + env.as_contract(contract_id, || { + vote_on_merge(env, holder, source_key, approve) + }) + } + + fn execute( + env: &Env, + contract_id: &Address, + creator: &Address, + admin: &Address, + source_key: &Address, + target_key: &Address, + ) -> Result { + env.as_contract(contract_id, || { + execute_key_merge(env, creator, admin, source_key, target_key) + }) + } + + fn read_proposal(env: &Env, contract_id: &Address, source_key: &Address) -> KeyMergeProposal { + env.as_contract(contract_id, || { + get_key_merge_proposal(env, source_key).unwrap() + }) + } + + #[test] + fn merge_votes_use_liquid_source_balance_as_weight() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = setup_contract(&env); + + let creator = Address::generate(&env); + let source_key = Address::generate(&env); + let target_key = Address::generate(&env); + let holder_a = Address::generate(&env); + let holder_b = Address::generate(&env); + + set_position(&env, &contract_id, &source_key, &holder_a, 600, 0); + set_position(&env, &contract_id, &source_key, &holder_b, 400, 0); + + let mut holders = Vec::new(&env); + holders.push_back(holder_a.clone()); + holders.push_back(holder_b.clone()); + + assert!(propose( + &env, + &contract_id, + &creator, + &source_key, + &target_key, + 10_000, + 5_000, + holders, + ) + .is_ok()); + + assert!(vote(&env, &contract_id, &holder_a, &source_key, true).is_ok()); + assert!(vote(&env, &contract_id, &holder_b, &source_key, false).is_ok()); + + let proposal = read_proposal(&env, &contract_id, &source_key); + assert_eq!(proposal.votes_for, 600); + assert_eq!(proposal.votes_against, 400); + assert_eq!(proposal.total_voted_weight, 1_000); + + // A wallet with no liquid source-key balance cannot vote. + let non_holder = Address::generate(&env); + let rejected = vote(&env, &contract_id, &non_holder, &source_key, true); + assert_eq!(rejected, Err(ContractError::InsufficientBalance)); + } + + #[test] + fn changing_vote_replaces_weight_without_double_counting() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = setup_contract(&env); + + let creator = Address::generate(&env); + let source_key = Address::generate(&env); + let target_key = Address::generate(&env); + let holder_a = Address::generate(&env); + let holder_b = Address::generate(&env); + + set_position(&env, &contract_id, &source_key, &holder_a, 600, 0); + set_position(&env, &contract_id, &source_key, &holder_b, 400, 0); + + let mut holders = Vec::new(&env); + holders.push_back(holder_a.clone()); + holders.push_back(holder_b.clone()); + assert!(propose( + &env, + &contract_id, + &creator, + &source_key, + &target_key, + 10_000, + 5_000, + holders, + ) + .is_ok()); + + assert!(vote(&env, &contract_id, &holder_a, &source_key, true).is_ok()); + assert!(vote(&env, &contract_id, &holder_b, &source_key, false).is_ok()); + // holder_b switches to approve: its 400 moves from against to for. + assert!(vote(&env, &contract_id, &holder_b, &source_key, true).is_ok()); + + let proposal = read_proposal(&env, &contract_id, &source_key); + assert_eq!(proposal.votes_for, 1_000); + assert_eq!(proposal.votes_against, 0); + assert_eq!(proposal.total_voted_weight, 1_000); + } + + #[test] + fn execute_rejects_below_approval_threshold() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = setup_contract(&env); + + let creator = Address::generate(&env); + let admin = Address::generate(&env); + let source_key = Address::generate(&env); + let target_key = Address::generate(&env); + let holder_a = Address::generate(&env); + let holder_b = Address::generate(&env); + + set_position(&env, &contract_id, &source_key, &holder_a, 900, 0); + set_position(&env, &contract_id, &source_key, &holder_b, 600, 0); + + let mut holders = Vec::new(&env); + holders.push_back(holder_a.clone()); + holders.push_back(holder_b.clone()); + // 70% approval required; only 900 of 1500 cast weight approves. + assert!(propose( + &env, + &contract_id, + &creator, + &source_key, + &target_key, + 10_000, + 7_000, + holders, + ) + .is_ok()); + + assert!(vote(&env, &contract_id, &holder_a, &source_key, true).is_ok()); + assert!(vote(&env, &contract_id, &holder_b, &source_key, false).is_ok()); + + let result = execute( + &env, + &contract_id, + &creator, + &admin, + &source_key, + &target_key, + ); + assert_eq!(result, Err(ContractError::Unauthorized)); + + // No position moved and the source key is not deprecated. + assert_eq!(read_liquid(&env, &contract_id, &source_key, &holder_a), 900); + assert_eq!(read_liquid(&env, &contract_id, &source_key, &holder_b), 600); + let deprecated = env.as_contract(&contract_id, || { + env.storage() + .persistent() + .has(&crate::constants::storage::deprecated_key(&source_key)) + }); + assert!(!deprecated); + + // A proposal with zero cast weight is also rejected. + let no_votes = execute( + &env, + &contract_id, + &creator, + &admin, + &source_key, + &target_key, + ); + assert_eq!(no_votes, Err(ContractError::Unauthorized)); + } + + #[test] + fn execute_migrates_all_positions_and_deprecates_source() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = setup_contract(&env); + + let creator = Address::generate(&env); + let admin = Address::generate(&env); + let source_key = Address::generate(&env); + let target_key = Address::generate(&env); + let holder_a = Address::generate(&env); + let holder_b = Address::generate(&env); + + // holder_a: 600 liquid + 200 staked on the source key. + // holder_b: 400 liquid on the source key. + set_position(&env, &contract_id, &source_key, &holder_a, 600, 200); + set_position(&env, &contract_id, &source_key, &holder_b, 400, 0); + // holder_a already has a target position that must receive the migrated keys. + set_position(&env, &contract_id, &target_key, &holder_a, 50, 10); + + let ratio = { + let mut holders = Vec::new(&env); + holders.push_back(holder_a.clone()); + holders.push_back(holder_b.clone()); + assert!(propose( + &env, + &contract_id, + &creator, + &source_key, + &target_key, + 5_000, + 5_000, + holders, + ) + .is_ok()); + + assert!(vote(&env, &contract_id, &holder_a, &source_key, true).is_ok()); + assert!(vote(&env, &contract_id, &holder_b, &source_key, true).is_ok()); + + execute( + &env, + &contract_id, + &creator, + &admin, + &source_key, + &target_key, + ) + .unwrap() + }; + assert_eq!(ratio, 5_000); + + // 600 * 0.5 = 300 liquid and 200 * 0.5 = 100 staked added to holder_a. + assert_eq!(read_liquid(&env, &contract_id, &target_key, &holder_a), 350); + assert_eq!(read_staked(&env, &contract_id, &target_key, &holder_a), 110); + // 400 * 0.5 = 200 liquid for holder_b. + assert_eq!(read_liquid(&env, &contract_id, &target_key, &holder_b), 200); + + // Source positions are fully drained. + assert_eq!(read_liquid(&env, &contract_id, &source_key, &holder_a), 0); + assert_eq!(read_staked(&env, &contract_id, &source_key, &holder_a), 0); + assert_eq!(read_liquid(&env, &contract_id, &source_key, &holder_b), 0); + + let (log_len, staked_total, is_deprecated, is_executed) = + env.as_contract(&contract_id, || { + let log = get_merge_migration_log(&env, &source_key); + let staked_total = crate::read_total_staked(&env, &target_key); + let status: KeySunsetStatus = env + .storage() + .instance() + .get(&EmwulrdDataKey::SunsetStatus(source_key.clone())) + .unwrap(); + let proposal = get_key_merge_proposal(&env, &source_key).unwrap(); + ( + log.len(), + staked_total, + status.is_deprecated, + proposal.is_executed, + ) + }); + assert_eq!(log_len, 2); + assert_eq!(staked_total, 110); + assert!(is_deprecated); + assert!(is_executed); + + // Protocol-wide deprecation marker is set with a zero buyback price. + let marker = env.as_contract(&contract_id, || { + env.storage() + .persistent() + .get::<_, i128>(&crate::constants::storage::deprecated_key(&source_key)) + }); + assert_eq!(marker, Some(0)); + + // The merge cannot be executed twice. + let second = execute( + &env, + &contract_id, + &creator, + &admin, + &source_key, + &target_key, + ); + assert_eq!(second, Err(ContractError::AlreadyRegistered)); + } + + #[test] + fn propose_rejects_duplicate_holders_and_bad_parameters() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = setup_contract(&env); + + let creator = Address::generate(&env); + let source_key = Address::generate(&env); + let target_key = Address::generate(&env); + let holder = Address::generate(&env); + + let duplicate = env.as_contract(&contract_id, || { + let mut holders = Vec::new(&env); + holders.push_back(holder.clone()); + holders.push_back(holder.clone()); + propose_key_merge( + &env, + &creator, + &source_key, + &target_key, + 10_000, + 5_000, + holders, + ) + }); + assert_eq!(duplicate, Err(ContractError::AlreadyRegistered)); + + let empty = env.as_contract(&contract_id, || { + propose_key_merge( + &env, + &creator, + &source_key, + &target_key, + 10_000, + 5_000, + Vec::new(&env), + ) + }); + assert_eq!(empty, Err(ContractError::NoKeyHolders)); + + let bad_ratio = env.as_contract(&contract_id, || { + let mut holders = Vec::new(&env); + holders.push_back(holder.clone()); + propose_key_merge(&env, &creator, &source_key, &target_key, 0, 5_000, holders) + }); + assert_eq!(bad_ratio, Err(ContractError::InvalidFeeConfig)); + } +} From 81f1da4e12928aecc504da1d0b620f1597ec34c9 Mon Sep 17 00:00:00 2001 From: Emmazdev Date: Thu, 1 Oct 2026 15:39:10 +0100 Subject: [PATCH 2/2] fix(test): keep the key-merge proposal helper within clippy's argument budget `cargo clippy --workspace --all-targets -- -D warnings` fails with `this function has too many arguments (8/7)` at the test helper `propose`. The helper mirrors the entrypoint's seven parameters and adds `contract_id`, because each entrypoint runs in its own contract frame. Rather than allow the lint, group the proposal under test - the two keys, the quorum and the holder list - into a `MergeProposalSpec`, which brings the helper to four arguments and leaves the entrypoint signature untouched. Verified with `cargo clippy --workspace --all-targets -- -D warnings` and `cargo test -p creator-keys` on the branch tree. --- creator-keys/src/acl_limits_merge_sunset.rs | 75 ++++++++++++++------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/creator-keys/src/acl_limits_merge_sunset.rs b/creator-keys/src/acl_limits_merge_sunset.rs index b7f51fd4..725d9f06 100644 --- a/creator-keys/src/acl_limits_merge_sunset.rs +++ b/creator-keys/src/acl_limits_merge_sunset.rs @@ -688,6 +688,19 @@ mod test { }) } + /// The proposal under test: which keys are being merged, the quorum the + /// contract will enforce, and the holders whose votes the test casts. + /// + /// Grouping these keeps `propose` within clippy's argument budget while + /// still mirroring the entrypoint's own seven parameters. + struct MergeProposalSpec<'a> { + source_key: &'a Address, + target_key: &'a Address, + ratio: u32, + threshold: u32, + holders: Vec
, + } + // Each entrypoint is invoked in its own contract frame: in production the // proposal, the votes and the execution are separate transactions, and the // mock auth recorder rejects a repeated `require_auth` inside one frame. @@ -695,15 +708,17 @@ mod test { env: &Env, contract_id: &Address, creator: &Address, - source_key: &Address, - target_key: &Address, - ratio: u32, - threshold: u32, - holders: Vec
, + spec: MergeProposalSpec<'_>, ) -> Result<(), ContractError> { env.as_contract(contract_id, || { propose_key_merge( - env, creator, source_key, target_key, ratio, threshold, holders, + env, + creator, + spec.source_key, + spec.target_key, + spec.ratio, + spec.threshold, + spec.holders, ) }) } @@ -762,11 +777,13 @@ mod test { &env, &contract_id, &creator, - &source_key, - &target_key, - 10_000, - 5_000, - holders, + MergeProposalSpec { + source_key: &source_key, + target_key: &target_key, + ratio: 10_000, + threshold: 5_000, + holders, + }, ) .is_ok()); @@ -806,11 +823,13 @@ mod test { &env, &contract_id, &creator, - &source_key, - &target_key, - 10_000, - 5_000, - holders, + MergeProposalSpec { + source_key: &source_key, + target_key: &target_key, + ratio: 10_000, + threshold: 5_000, + holders, + }, ) .is_ok()); @@ -849,11 +868,13 @@ mod test { &env, &contract_id, &creator, - &source_key, - &target_key, - 10_000, - 7_000, - holders, + MergeProposalSpec { + source_key: &source_key, + target_key: &target_key, + ratio: 10_000, + threshold: 7_000, + holders, + }, ) .is_ok()); @@ -920,11 +941,13 @@ mod test { &env, &contract_id, &creator, - &source_key, - &target_key, - 5_000, - 5_000, - holders, + MergeProposalSpec { + source_key: &source_key, + target_key: &target_key, + ratio: 5_000, + threshold: 5_000, + holders, + }, ) .is_ok());