add support for partial claims of governance shares

Signed-off-by: Uncle Stinky <uncle.stinky@ghostchain.io>
This commit is contained in:
Uncle Stinky 2026-09-23 19:44:08 +03:00
parent fde1f68dc0
commit d3b926cd47
Signed by: st1nky
GPG Key ID: 016064BD97603B40
5 changed files with 304 additions and 40 deletions

View File

@ -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",

View File

@ -99,7 +99,8 @@ mod benchmarks {
let message_string_bytes = Pallet::<T>::ethereum_signable_message(
&current_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::<T>::get().claimed_shares);

View File

@ -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<u32>;
#[pallet::constant]
type MaxPartialClaims: Get<ClaimNonce>;
type WeightInfo: WeightInfo;
}
@ -229,9 +238,10 @@ pub mod pallet {
#[pallet::error]
pub enum Error<T> {
PreimageAlreadyClaimed,
PartialClaimsExhausted,
BoundedToOtherNetwork,
AuthorityNotGovernor,
ExceedsAllowedShares,
NetworkHasNoGenesis,
InvalidMerkleProof,
SharesOverflowed,
@ -269,7 +279,16 @@ pub mod pallet {
pub type ClaimedHashes<T: Config> = StorageMap<
_,
Blake2_256, EvmHash,
bool,
ClaimNonce,
ValueQuery,
>;
#[pallet::storage]
#[pallet::getter(fn claimed_shares)]
pub type ClaimedShares<T: Config> = StorageMap<
_,
Twox64Concat, EvmHash,
BalanceOf<T>,
ValueQuery,
>;
@ -317,6 +336,7 @@ pub mod pallet {
claim_package: ClaimPackage<BalanceOf<T>>,
evm_address: EvmAddress,
evm_signature: EvmSignature,
requested_shares: BalanceOf<T>,
) -> 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::<T>::contains_key(&preimage_hash),
Error::<T>::PreimageAlreadyClaimed
);
let max_claim_nonces = T::MaxPartialClaims::get();
let current_nonce = ClaimedHashes::<T>::get(&preimage_hash);
Self::verify_signature(&who, &network_id, &preimage_hash, &evm_address, &evm_signature)?;
ensure!(current_nonce < max_claim_nonces, Error::<T>::PartialClaimsExhausted);
let already_claimed = ClaimedShares::<T>::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::<T>::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::<T>::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::<T>::SharesOverflowed);
let increased = state.try_increase_claimed(claim_package.shares);
let increased = state.try_increase_claimed(claimed_shares);
ensure!(increased.is_some(), Error::<T>::SharesOverflowed);
Ok(())
})?;
GlobalShares::<T>::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::<T>::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::<T>::insert(&who, member_share);
ClaimedHashes::<T>::insert(&preimage_hash, true);
ClaimedHashes::<T>::insert(&preimage_hash, current_nonce.saturating_add(1));
ClaimedShares::<T>::insert(
&preimage_hash,
already_claimed.saturating_add(claimed_shares),
);
Self::deposit_event(Event::<T>::SharesClaimed {
shares: claim_package.shares,
shares: claimed_shares,
network_id,
who,
});
@ -390,45 +423,73 @@ impl<T: Config> Pallet<T> {
r
}
fn to_ascii_base10<N: PrimInt + UniqueSaturatedInto<u128>>(num: N) -> Vec<u8> {
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<T>,
preimage_hash: &EvmHash,
nonce: ClaimNonce,
) -> Vec<u8> {
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<T: Config> Pallet<T> {
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)

View File

@ -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 = ();
}

View File

@ -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(
&current_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::<Test>::get(&current_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 = <<Test as crate::Config>::MinimumDonation as Get<u128>>::get();
let minimum_donation: BalanceOf<Test> = 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, &current_user)];
let merkle_tree = generate_tree::<SubstrateKeccakHasher, u8, _, _, _>(
max_index,
raw_values_complex,
|item: (u8, &NewTestUser)| -> Result<(usize, Vec<u8>), ()> {
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::<Test>::insert(&network_id, network_state);
GlobalShares::<Test>::put(global_state);
let setup_governor = |account: &<Test as frame_system::Config>::AccountId| {
MemberShares::<Test>::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::<SubstrateKeccakHasher, u8>(&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(&current_user.evm_address, network_id);
let preimage_hash = SubstrateKeccakHasher::hash(&preimage);
let generate_test_signature = |target_account: &<Test as frame_system::Config>::AccountId| -> EvmSignature {
let active_nonce = ClaimedHashes::<Test>::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, &current_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::<Test>::get();
assert_eq!(global_share.claimed_shares, 400);
assert_eq!(global_share.total_shares, total_shares);
let network_share = NetworkShares::<Test>::get(&network_id);
assert_eq!(network_share.claimed_shares(), 400);
assert_eq!(network_share.total_shares(), total_shares);
assert_eq!(MemberShares::<Test>::get(&substrate_account_a).activated_shares, 400);
assert_eq!(ClaimedHashes::<Test>::get(&preimage_hash), 1);
assert_eq!(ClaimedShares::<Test>::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::<Test>::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::<Test>::get();
assert_eq!(global_share.claimed_shares, 500);
assert_eq!(global_share.total_shares, total_shares);
let network_share = NetworkShares::<Test>::get(&network_id);
assert_eq!(network_share.claimed_shares(), 500);
assert_eq!(network_share.total_shares(), total_shares);
assert_eq!(MemberShares::<Test>::get(&substrate_account_b).activated_shares, 100);
assert_eq!(ClaimedHashes::<Test>::get(&preimage_hash), 2);
assert_eq!(ClaimedShares::<Test>::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::<Test>::get();
assert_eq!(global_share.claimed_shares, 1_000);
assert_eq!(global_share.total_shares, total_shares);
let network_share = NetworkShares::<Test>::get(&network_id);
assert_eq!(network_share.claimed_shares(), 1_000);
assert_eq!(network_share.total_shares(), total_shares);
assert_eq!(MemberShares::<Test>::get(&substrate_account_c).activated_shares, 500);
assert_eq!(ClaimedHashes::<Test>::get(&preimage_hash), 3);
assert_eq!(ClaimedShares::<Test>::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::<Test>::PartialClaimsExhausted
);
});
}
#[test]
fn verify_proof_from_python_preclaims_for_sepolia() {
new_test_ext().execute_with(|| {