From d3b926cd47b77db8794d065225217ddd98e12555 Mon Sep 17 00:00:00 2001 From: Uncle Stinky Date: Wed, 23 Sep 2026 19:44:08 +0300 Subject: [PATCH] add support for partial claims of governance shares Signed-off-by: Uncle Stinky --- pallets/governance/Cargo.toml | 4 +- pallets/governance/src/benchmarking.rs | 4 +- pallets/governance/src/lib.rs | 130 +++++++++++----- pallets/governance/src/mock.rs | 3 +- pallets/governance/src/tests.rs | 203 ++++++++++++++++++++++++- 5 files changed, 304 insertions(+), 40 deletions(-) diff --git a/pallets/governance/Cargo.toml b/pallets/governance/Cargo.toml index 9df2720..cfd25c6 100644 --- a/pallets/governance/Cargo.toml +++ b/pallets/governance/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ghost-governance" -version = "0.3.7" +version = "0.3.8" description = "Full-chain and cross-chain governance pallet with early adopter share claims" license.workspace = true authors.workspace = true @@ -13,6 +13,7 @@ codec = { workspace = true, features = ["derive"] } scale-info = { workspace = true, features = ["derive"] } serde = { workspace = true } serde_derive = { workspace = true } +num-traits = { workspace = true } rustc-hex = { workspace = true } libsecp256k1 = { workspace = true, default-features = false } @@ -42,6 +43,7 @@ std = [ "serde/std", "codec/std", "scale-info/std", + "num-traits/std", "libsecp256k1/std", "frame-support/std", "frame-system/std", diff --git a/pallets/governance/src/benchmarking.rs b/pallets/governance/src/benchmarking.rs index a6b7bb8..b464883 100644 --- a/pallets/governance/src/benchmarking.rs +++ b/pallets/governance/src/benchmarking.rs @@ -99,7 +99,8 @@ mod benchmarks { let message_string_bytes = Pallet::::ethereum_signable_message( ¤t_account, &network_id, - &preimage_hash + &preimage_hash, + 0u8, ); let message_hash = SubstrateKeccakHasher::hash(&message_string_bytes); @@ -124,6 +125,7 @@ mod benchmarks { claim_package, dummy_evm_address, evm_signature, + dummy_shares, ); assert_eq!(dummy_shares, GlobalShares::::get().claimed_shares); diff --git a/pallets/governance/src/lib.rs b/pallets/governance/src/lib.rs index 18fd7c1..f8101f1 100644 --- a/pallets/governance/src/lib.rs +++ b/pallets/governance/src/lib.rs @@ -9,7 +9,11 @@ use frame_system::pallet_prelude::*; use sp_core::U256; use sp_std::vec::Vec; use sp_io::crypto::secp256k1_ecdsa_recover; -use sp_runtime::traits::{AtLeast32BitUnsigned, CheckedAdd, UniqueSaturatedInto}; +use sp_runtime::traits::{ + AtLeast32BitUnsigned, CheckedAdd, Saturating, UniqueSaturatedInto, +}; + +use num_traits::PrimInt; use ghost_helpers::{ SubstrateKeccakHasher, merkle_tree::verify_tree_proof, @@ -32,9 +36,10 @@ mod benchmarking; mod mock; mod tests; -const MAX_POSSIBLE_PROOF_DEPTH: u32 = 256; +const MAX_POSSIBLE_PROOF_DEPTH: u32 = 18; type TokenId = u128; +type ClaimNonce = u8; type EvmHash = sp_core::H256; type EvmAddress = sp_core::H160; type EvmSignature = [u8; 65]; @@ -134,7 +139,8 @@ where } pub fn lock_balance(&mut self, extra_balance: Balance) { - self.locked_balance.saturating_add(extra_balance); + self.locked_balance = self.locked_balance + .saturating_add(extra_balance); } } @@ -214,6 +220,9 @@ pub mod pallet { #[pallet::constant] type MaxProofDepth: Get; + #[pallet::constant] + type MaxPartialClaims: Get; + type WeightInfo: WeightInfo; } @@ -229,9 +238,10 @@ pub mod pallet { #[pallet::error] pub enum Error { - PreimageAlreadyClaimed, + PartialClaimsExhausted, BoundedToOtherNetwork, AuthorityNotGovernor, + ExceedsAllowedShares, NetworkHasNoGenesis, InvalidMerkleProof, SharesOverflowed, @@ -269,7 +279,16 @@ pub mod pallet { pub type ClaimedHashes = StorageMap< _, Blake2_256, EvmHash, - bool, + ClaimNonce, + ValueQuery, + >; + + #[pallet::storage] + #[pallet::getter(fn claimed_shares)] + pub type ClaimedShares = StorageMap< + _, + Twox64Concat, EvmHash, + BalanceOf, ValueQuery, >; @@ -317,6 +336,7 @@ pub mod pallet { claim_package: ClaimPackage>, evm_address: EvmAddress, evm_signature: EvmSignature, + requested_shares: BalanceOf, ) -> DispatchResult { let who = ensure_signed(origin)?; @@ -338,12 +358,21 @@ pub mod pallet { let preimage = claim_package.get_preimage(&evm_address, network_id); let preimage_hash = SubstrateKeccakHasher::hash(&preimage); - ensure!( - !ClaimedHashes::::contains_key(&preimage_hash), - Error::::PreimageAlreadyClaimed - ); + let max_claim_nonces = T::MaxPartialClaims::get(); + let current_nonce = ClaimedHashes::::get(&preimage_hash); - Self::verify_signature(&who, &network_id, &preimage_hash, &evm_address, &evm_signature)?; + ensure!(current_nonce < max_claim_nonces, Error::::PartialClaimsExhausted); + + let already_claimed = ClaimedShares::::get(&preimage_hash); + let claimed_shares = if current_nonce == max_claim_nonces.saturating_sub(1) { + claim_package.shares.saturating_sub(already_claimed) + } else { + let cumulative_shares = already_claimed.saturating_add(requested_shares); + ensure!(cumulative_shares <= claim_package.shares, Error::::ExceedsAllowedShares); + requested_shares + }; + + Self::verify_signature(&who, &network_id, &preimage_hash, &evm_address, &evm_signature, current_nonce)?; Self::verify_merkle_proof(&network_id, &preimage, &claim_package)?; NetworkShares::::try_mutate(&network_id, |state| -> DispatchResult { @@ -351,25 +380,29 @@ pub mod pallet { let proof_size_match = package_proof_size == state.proofs_size; ensure!(proof_size_match, Error::::SharesOverflowed); - let increased = state.try_increase_claimed(claim_package.shares); + let increased = state.try_increase_claimed(claimed_shares); ensure!(increased.is_some(), Error::::SharesOverflowed); Ok(()) })?; GlobalShares::::try_mutate(|state| -> DispatchResult { - let increased = state.try_increase_claimed(claim_package.shares); + let increased = state.try_increase_claimed(claimed_shares); ensure!(increased.is_some(), Error::::SharesOverflowed); Ok(()) })?; - member_share.increase_activated_shares(claim_package.shares); + member_share.increase_activated_shares(claimed_shares); member_share.initiate_on_network(network_id); MemberShares::::insert(&who, member_share); - ClaimedHashes::::insert(&preimage_hash, true); + ClaimedHashes::::insert(&preimage_hash, current_nonce.saturating_add(1)); + ClaimedShares::::insert( + &preimage_hash, + already_claimed.saturating_add(claimed_shares), + ); Self::deposit_event(Event::::SharesClaimed { - shares: claim_package.shares, + shares: claimed_shares, network_id, who, }); @@ -390,45 +423,73 @@ impl Pallet { r } + fn to_ascii_base10>(num: N) -> Vec { + let zero = N::zero(); + if num == zero { return vec![b'0']; } + + let ten = N::from(10).unwrap_or_else(|| N::zero()); + let num_u128 = num.unique_saturated_into(); + let capacity = (num_u128.ilog10() + 1) as usize; + + let mut result = Vec::with_capacity(capacity); + let mut n = num; + + while n > zero { + result.push(b'0' + (n % ten).to_u8().unwrap_or(0)); + n = n / ten; + } + result.reverse(); + + result + } + fn ethereum_signable_message( receiver: &T::AccountId, network_id: &NetworkIdOf, preimage_hash: &EvmHash, + nonce: ClaimNonce, ) -> Vec { let prefix = b"GMV Claim:"; let network_id_u64: u64 = (*network_id).unique_saturated_into(); let receiver_ascii_hex = Self::to_ascii_hex(&receiver.encode()); let preimage_ascii_hex = Self::to_ascii_hex(preimage_hash.as_ref()); - let network_ascii_hex = Self::to_ascii_hex(&network_id_u64.to_be_bytes()); + let network_ascii = Self::to_ascii_base10(network_id_u64); + let nonce_ascii = Self::to_ascii_base10(nonce); - let what_length = receiver_ascii_hex.len() - .saturating_add(preimage_ascii_hex.len()) - .saturating_add(network_ascii_hex.len()) - .saturating_add(10 + 11 + 12 + 11); // all 4 prefixes + let account_label = b"\naccount:0x"; + let preimage_label = b"\npreimage:0x"; + let network_label = b"\nnetwork:"; + let nonce_label = b"\nnonce:"; + + let what_length = prefix.len() + .saturating_add(account_label.len()).saturating_add(receiver_ascii_hex.len()) + .saturating_add(preimage_label.len()).saturating_add(preimage_ascii_hex.len()) + .saturating_add(network_label.len()).saturating_add(network_ascii.len()) + .saturating_add(nonce_label.len()).saturating_add(nonce_ascii.len()); let mut what = Vec::with_capacity(what_length); what.extend_from_slice(prefix); - what.extend_from_slice(b"\naccount:0x"); + what.extend_from_slice(account_label); what.extend_from_slice(&receiver_ascii_hex); - what.extend_from_slice(b"\npreimage:0x"); + what.extend_from_slice(preimage_label); what.extend_from_slice(&preimage_ascii_hex); - what.extend_from_slice(b"\nnetwork:0x"); - what.extend_from_slice(&network_ascii_hex); + what.extend_from_slice(network_label); + what.extend_from_slice(&network_ascii); - let mut l = what.len(); - let mut rev = Vec::new(); - while l > 0 { - rev.push(b'0' + (l % 10) as u8); - l /= 10; - } + what.extend_from_slice(nonce_label); + what.extend_from_slice(&nonce_ascii); - let mut v = Vec::with_capacity(26 + rev.len() + what.len()); - v.extend_from_slice(b"\x19Ethereum Signed Message:\n"); - v.extend(rev.into_iter().rev()); + let length_ascii = Self::to_ascii_base10(what.len()); + let message_prefix = b"\x19Ethereum Signed Message:\n"; + + let message_capacity = message_prefix.len() + length_ascii.len() + what.len(); + let mut v = Vec::with_capacity(message_capacity); + v.extend_from_slice(message_prefix); + v.extend_from_slice(&length_ascii); v.extend(what); v @@ -440,8 +501,9 @@ impl Pallet { preimage_hash: &EvmHash, evm_address: &EvmAddress, evm_signature: &EvmSignature, + nonce: ClaimNonce, ) -> DispatchResult { - let message = Self::ethereum_signable_message(who, network_id, preimage_hash); + let message = Self::ethereum_signable_message(who, network_id, preimage_hash, nonce); let message_bytes = SubstrateKeccakHasher::hash(&message).to_fixed_bytes(); let recovered_pubkey = secp256k1_ecdsa_recover(evm_signature, &message_bytes) diff --git a/pallets/governance/src/mock.rs b/pallets/governance/src/mock.rs index eb63c76..28d1fd3 100644 --- a/pallets/governance/src/mock.rs +++ b/pallets/governance/src/mock.rs @@ -5,7 +5,7 @@ use super::*; pub use crate as ghost_governance; use frame_support::{ derive_impl, parameter_types, - traits::{ConstU32, ConstU128}, + traits::{ConstU8, ConstU32, ConstU128}, }; use frame_system::EnsureRoot; use sp_runtime::BuildStorage; @@ -53,6 +53,7 @@ impl Config for Test { type MinimumDonation = ConstU128<69>; type MaxProofDepth = ConstU32<5>; + type MaxPartialClaims = ConstU8<3>; type WeightInfo = (); } diff --git a/pallets/governance/src/tests.rs b/pallets/governance/src/tests.rs index 69d219f..bf367ec 100644 --- a/pallets/governance/src/tests.rs +++ b/pallets/governance/src/tests.rs @@ -6,7 +6,7 @@ use hex_literal::hex; use sp_runtime::AccountId32; use frame_support::traits::Get; -use frame_support::assert_ok; +use frame_support::{assert_ok, assert_err}; use libsecp256k1::{Message, SecretKey, PublicKey}; use ghost_helpers::merkle_tree::{generate_tree, generate_proof}; @@ -131,7 +131,8 @@ fn claim_happy_path() { let message_string_bytes = Governance::ethereum_signable_message( ¤t_user.substrate_account, &network_id, - &preimage_hash + &preimage_hash, + 0u8, ); let message_hash = SubstrateKeccakHasher::hash(&message_string_bytes); @@ -154,7 +155,8 @@ fn claim_happy_path() { network_id, claim_package, current_user.evm_address.clone(), - evm_signature + evm_signature, + current_user.allowed_shares, )); let updated_share = MemberShares::::get(¤t_user.substrate_account); @@ -172,6 +174,201 @@ fn claim_happy_path() { }); } +#[test] +fn partial_claims_happy_path() { + new_test_ext().execute_with(|| { + let network_id = 69u64; + + let minimum_donation_u128 = <::MinimumDonation as Get>::get(); + let minimum_donation: BalanceOf = minimum_donation_u128.unique_saturated_into(); + + let secret_key = SecretKey::parse(&[7u8; 32]).unwrap(); + let public_key = PublicKey::from_secret_key(&secret_key); + let pubkey_raw = &public_key.serialize()[1..65]; + let pubkey_hash = SubstrateKeccakHasher::hash(pubkey_raw); + + let mut evm_addr_bytes = [0u8; 20]; + evm_addr_bytes.copy_from_slice(&pubkey_hash.as_ref()[12..32]); + + let substrate_account_a = AccountId32::new([11u8; 32]); + let substrate_account_b = AccountId32::new([22u8; 32]); + let substrate_account_c = AccountId32::new([33u8; 32]); + + let current_user = NewTestUser { + substrate_account: substrate_account_a.clone(), + evm_secret: secret_key, + evm_address: EvmAddress::from(evm_addr_bytes), + allowed_shares: 1000u128, + token_id: 7, + }; + + let max_index = 0u8; + let raw_values_complex = vec![(0u8, ¤t_user)]; + + let merkle_tree = generate_tree::( + max_index, + raw_values_complex, + + |item: (u8, &NewTestUser)| -> Result<(usize, Vec), ()> { + let (index, u) = item; + let tmp_package = ClaimPackage { + shares: u.allowed_shares, + merkle_proof: Default::default(), + token_id: u.token_id, + index: index as u32 + }; + Ok((index as usize, tmp_package.get_preimage(&u.evm_address, network_id).to_vec())) + } + ).unwrap(); + + let total_shares = 1_000_000; + let merkle_root = *merkle_tree.last().unwrap(); + let network_state = NetworkShare::new(total_shares, merkle_root, 0); + let global_state = ShareState::new(total_shares); + + NetworkShares::::insert(&network_id, network_state); + GlobalShares::::put(global_state); + + let setup_governor = |account: &::AccountId| { + MemberShares::::insert(account, MemberShare { + locked_balance: minimum_donation, + initiated_network: None, + activated_shares: 0, + }); + }; + setup_governor(&substrate_account_a); + setup_governor(&substrate_account_b); + setup_governor(&substrate_account_c); + + let proof = generate_proof::(&merkle_tree, max_index, 0u8); + let claim_package = ClaimPackage { + shares: current_user.allowed_shares, + merkle_proof: BoundedVec::try_from(proof).unwrap(), + token_id: current_user.token_id, + index: 0, + }; + + let preimage = claim_package.get_preimage(¤t_user.evm_address, network_id); + let preimage_hash = SubstrateKeccakHasher::hash(&preimage); + + let generate_test_signature = |target_account: &::AccountId| -> EvmSignature { + let active_nonce = ClaimedHashes::::get(&preimage_hash); + let message_string_bytes = Governance::ethereum_signable_message( + &target_account, + &network_id, + &preimage_hash, + active_nonce, + ); + + let message_hash = SubstrateKeccakHasher::hash(&message_string_bytes); + let mut message_bytes = [0u8; 32]; + message_bytes.copy_from_slice(message_hash.as_ref()); + + let msg_to_sign = Message::parse(&message_bytes); + let (sig, recovery_id) = libsecp256k1::sign(&msg_to_sign, ¤t_user.evm_secret); + + let mut signature_raw = [0u8; 65]; + signature_raw[0..32].copy_from_slice(&sig.r.b32()); + signature_raw[32..64].copy_from_slice(&sig.s.b32()); + signature_raw[64] = recovery_id.serialize(); + + EvmSignature::from(signature_raw) + }; + + let sig_0 = generate_test_signature(&substrate_account_a); + assert_ok!(Governance::claim( + RuntimeOrigin::signed(substrate_account_a.clone()), + network_id, + claim_package.clone(), + current_user.evm_address.clone(), + sig_0, + 400, + )); + + let global_share = GlobalShares::::get(); + assert_eq!(global_share.claimed_shares, 400); + assert_eq!(global_share.total_shares, total_shares); + + let network_share = NetworkShares::::get(&network_id); + assert_eq!(network_share.claimed_shares(), 400); + assert_eq!(network_share.total_shares(), total_shares); + + assert_eq!(MemberShares::::get(&substrate_account_a).activated_shares, 400); + assert_eq!(ClaimedHashes::::get(&preimage_hash), 1); + assert_eq!(ClaimedShares::::get(&preimage_hash), 400); + + let sig_1_fail = generate_test_signature(&substrate_account_b); + assert_err!( + Governance::claim( + RuntimeOrigin::signed(substrate_account_b.clone()), + network_id, + claim_package.clone(), + current_user.evm_address.clone(), + sig_1_fail, + 700, + ), + Error::::ExceedsAllowedShares + ); + + let sig_1_success = generate_test_signature(&substrate_account_b); + assert_ok!(Governance::claim( + RuntimeOrigin::signed(substrate_account_b.clone()), + network_id, + claim_package.clone(), + current_user.evm_address.clone(), + sig_1_success, + 100, + )); + + let global_share = GlobalShares::::get(); + assert_eq!(global_share.claimed_shares, 500); + assert_eq!(global_share.total_shares, total_shares); + + let network_share = NetworkShares::::get(&network_id); + assert_eq!(network_share.claimed_shares(), 500); + assert_eq!(network_share.total_shares(), total_shares); + + assert_eq!(MemberShares::::get(&substrate_account_b).activated_shares, 100); + assert_eq!(ClaimedHashes::::get(&preimage_hash), 2); + assert_eq!(ClaimedShares::::get(&preimage_hash), 500); + + let sig_2_final = generate_test_signature(&substrate_account_c); + assert_ok!(Governance::claim( + RuntimeOrigin::signed(substrate_account_c.clone()), + network_id, + claim_package.clone(), + current_user.evm_address.clone(), + sig_2_final, + 300, + )); + + let global_share = GlobalShares::::get(); + assert_eq!(global_share.claimed_shares, 1_000); + assert_eq!(global_share.total_shares, total_shares); + + let network_share = NetworkShares::::get(&network_id); + assert_eq!(network_share.claimed_shares(), 1_000); + assert_eq!(network_share.total_shares(), total_shares); + + assert_eq!(MemberShares::::get(&substrate_account_c).activated_shares, 500); + assert_eq!(ClaimedHashes::::get(&preimage_hash), 3); + assert_eq!(ClaimedShares::::get(&preimage_hash), 1_000); + + let sig_3_exhausted = generate_test_signature(&substrate_account_a); + assert_err!( + Governance::claim( + RuntimeOrigin::signed(substrate_account_a), + network_id, + claim_package, + current_user.evm_address.clone(), + sig_3_exhausted, + 100, + ), + Error::::PartialClaimsExhausted + ); + }); +} + #[test] fn verify_proof_from_python_preclaims_for_sepolia() { new_test_ext().execute_with(|| {