diff --git a/Cargo.lock b/Cargo.lock index a8732adf0..c6ee13301 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1395,6 +1395,12 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.8.1" @@ -1481,6 +1487,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bb454f0228b18c7f4c3b0ebbee346ed9c52e7443b0999cd543ff3571205701d" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "downcast-rs" version = "1.2.0" @@ -2141,6 +2153,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -4715,11 +4736,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ab571328afa78ae322493cacca3efac6a0f2e0a67305b4df31fd439ef129ac0" dependencies = [ "cfg-if", - "downcast", + "downcast 0.10.0", "fragile", "lazy_static", - "mockall_derive", - "predicates", + "mockall_derive 0.10.2", + "predicates 1.0.8", + "predicates-tree", +] + +[[package]] +name = "mockall" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2be9a9090bc1cac2930688fa9478092a64c6a92ddc6ae0692d46b37d9cab709" +dependencies = [ + "cfg-if", + "downcast 0.11.0", + "fragile", + "lazy_static", + "mockall_derive 0.11.2", + "predicates 2.1.1", "predicates-tree", ] @@ -4735,6 +4771,18 @@ dependencies = [ "syn 1.0.95", ] +[[package]] +name = "mockall_derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86d702a0530a0141cf4ed147cf5ec7be6f2c187d4e37fcbefc39cf34116bfe8f" +dependencies = [ + "cfg-if", + "proc-macro2 1.0.39", + "quote 1.0.18", + "syn 1.0.95", +] + [[package]] name = "more-asserts" version = "0.2.2" @@ -5339,7 +5387,7 @@ dependencies = [ "frame-support", "frame-system", "hex-literal", - "mockall", + "mockall 0.10.2", "parity-scale-codec", "scale-info", "serde", @@ -5357,7 +5405,7 @@ dependencies = [ "frame-benchmarking", "frame-support", "frame-system", - "mockall", + "mockall 0.10.2", "parity-scale-codec", "scale-info", "serde", @@ -5647,6 +5695,26 @@ dependencies = [ "sp-timestamp", ] +[[package]] +name = "pallet-token-claims" +version = "0.1.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "mockall 0.11.2", + "once_cell", + "pallet-balances", + "pallet-pot", + "parity-scale-codec", + "primitives-ethereum", + "scale-info", + "serde", + "serde_json", + "sp-core", + "sp-runtime", +] + [[package]] name = "pallet-transaction-payment" version = "4.0.0-dev" @@ -6047,7 +6115,7 @@ dependencies = [ "frame-support", "frame-system", "getrandom 0.2.6", - "mockall", + "mockall 0.10.2", "num_enum", "pallet-bioauth", "pallet-evm", @@ -6070,7 +6138,7 @@ dependencies = [ "frame-support", "frame-system", "hex-literal", - "mockall", + "mockall 0.10.2", "pallet-evm-accounts-mapping", "parity-scale-codec", "primitive-types", @@ -6121,7 +6189,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f49cfaf7fdaa3bfacc6fa3e7054e65148878354a5cfddcf661df4c851f8021df" dependencies = [ "difference", - "float-cmp", + "float-cmp 0.8.0", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5aab5be6e4732b473071984b3164dbbfb7a3674d30ea5ff44410b6bcd960c3c" +dependencies = [ + "difflib", + "float-cmp 0.9.0", + "itertools 0.10.3", "normalize-line-endings", "predicates-core", "regex", @@ -6167,6 +6249,19 @@ dependencies = [ "sp-std", ] +[[package]] +name = "primitives-ethereum" +version = "0.1.0" +dependencies = [ + "frame-support", + "frame-system", + "parity-scale-codec", + "rustc-hex", + "scale-info", + "serde", + "serde_json", +] + [[package]] name = "primitives-frontier" version = "0.1.0" @@ -6850,7 +6945,7 @@ dependencies = [ "async-trait", "facetec-api-client", "hex", - "mockall", + "mockall 0.10.2", "parity-scale-codec", "primitives-auth-ticket", "primitives-liveness-data", diff --git a/crates/pallet-token-claims/Cargo.toml b/crates/pallet-token-claims/Cargo.toml new file mode 100644 index 000000000..a41cfb8f1 --- /dev/null +++ b/crates/pallet-token-claims/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "pallet-token-claims" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +primitives-ethereum = { version = "0.1", path = "../primitives-ethereum", default-features = false } + +codec = { package = "parity-scale-codec", version = "3.0.0", default-features = false, features = ["derive"] } +frame-benchmarking = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "master", optional = true } +frame-support = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "master" } +frame-system = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "master" } +scale-info = { version = "2.1.1", default-features = false, features = ["derive"] } +serde = { version = "1", optional = true } + +[dev-dependencies] +pallet-pot = { version = "0.1", path = "../pallet-pot" } + +mockall = "0.11" +once_cell = "1" +pallet-balances = { git = "https://github.com/humanode-network/substrate", branch = "master" } +serde_json = "1" +sp-core = { git = "https://github.com/humanode-network/substrate", branch = "master" } +sp-runtime = { git = "https://github.com/humanode-network/substrate", branch = "master" } + +[features] +default = ["std"] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", +] +std = [ + "codec/std", + "frame-benchmarking/std", + "frame-support/std", + "frame-system/std", + "primitives-ethereum/std", + "serde", +] diff --git a/crates/pallet-token-claims/src/benchmarking.rs b/crates/pallet-token-claims/src/benchmarking.rs new file mode 100644 index 000000000..32c1371bc --- /dev/null +++ b/crates/pallet-token-claims/src/benchmarking.rs @@ -0,0 +1,109 @@ +//! The benchmarks for the pallet. + +use frame_benchmarking::benchmarks; +use frame_system::RawOrigin; +use primitives_ethereum::{EcdsaSignature, EthereumAddress}; + +use crate::*; + +/// The benchmark interface into the environment. +pub trait Interface: super::Config { + /// Obtain an Account ID. + /// + /// This is an account to claim the funds to. + fn account_id_to_claim_to() -> ::AccountId; + + /// Obtain an ethereum address. + /// + /// This is an ethereum account that is supposed to have a valid calim associated with it + /// in the runtime genesis. + fn ethereum_address() -> EthereumAddress; + + /// Obtain an ECDSA signature that would fit the provided Account ID and the Ethereum address + /// under the associated runtime. + fn create_ecdsa_signature( + account_id: &::AccountId, + ethereum_address: &EthereumAddress, + ) -> EcdsaSignature; +} + +benchmarks! { + where_clause { + where + T: Interface + } + + claim { + let account_id = ::account_id_to_claim_to(); + let ethereum_address = ::ethereum_address(); + let ethereum_signature = ::create_ecdsa_signature(&account_id, ðereum_address); + + // We assume the genesis has the corresponding claim; crash the bench if it doesn't. + let claim_info = Claims::::get(ethereum_address).unwrap(); + + let account_balance_before = >::total_balance(&account_id); + let currency_total_issuance_before = >::total_issuance(); + + #[cfg(test)] + let test_data = { + use crate::mock; + + let mock_runtime_guard = mock::runtime_lock(); + + let recover_signer_ctx = mock::MockEthereumSignatureVerifier::recover_signer_context(); + recover_signer_ctx.expect().times(1..).return_const(Some(ethereum_address)); + + let lock_under_vesting_ctx = mock::MockVestingInterface::lock_under_vesting_context(); + lock_under_vesting_ctx.expect().times(1..).return_const(Ok(())); + + (mock_runtime_guard, recover_signer_ctx, lock_under_vesting_ctx) + }; + + let origin = RawOrigin::Signed(account_id.clone()); + + }: _(origin, ethereum_address, ethereum_signature) + verify { + assert_eq!(Claims::::get(ethereum_address), None); + + let account_balance_after = >::total_balance(&account_id); + assert_eq!(account_balance_after - account_balance_before, claim_info.balance); + assert_eq!( + currency_total_issuance_before, + >::total_issuance(), + ); + + #[cfg(test)] + { + let (mock_runtime_guard, recover_signer_ctx, lock_under_vesting_ctx) = test_data; + + recover_signer_ctx.checkpoint(); + lock_under_vesting_ctx.checkpoint(); + + drop(mock_runtime_guard); + } + } + + impl_benchmark_test_suite!( + Pallet, + crate::mock::new_test_ext(), + crate::mock::Test, + ); +} + +#[cfg(test)] +impl Interface for crate::mock::Test { + fn account_id_to_claim_to() -> ::AccountId { + 42 + } + + fn ethereum_address() -> EthereumAddress { + mock::eth(mock::EthAddr::WithVesting) + } + + fn create_ecdsa_signature( + _account_id: &::AccountId, + _ethereum_address: &EthereumAddress, + ) -> EcdsaSignature { + EcdsaSignature::default() + } +} diff --git a/crates/pallet-token-claims/src/lib.rs b/crates/pallet-token-claims/src/lib.rs new file mode 100644 index 000000000..a594a2460 --- /dev/null +++ b/crates/pallet-token-claims/src/lib.rs @@ -0,0 +1,248 @@ +//! Token claims. + +#![cfg_attr(not(feature = "std"), no_std)] + +use frame_support::traits::{Currency, StorageVersion}; + +pub use self::pallet::*; + +pub mod traits; +pub mod types; +pub mod weights; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +/// The current storage version. +const STORAGE_VERSION: StorageVersion = StorageVersion::new(0); + +/// The currency from a given config. +type CurrencyOf = ::Currency; +/// The balance from a given config. +type BalanceOf = as Currency<::AccountId>>::Balance; +/// The claim info from a given config. +type ClaimInfoOf = types::ClaimInfo, ::VestingSchedule>; + +// We have to temporarily allow some clippy lints. Later on we'll send patches to substrate to +// fix them at their end. +#[allow(clippy::missing_docs_in_private_items)] +#[frame_support::pallet] +pub mod pallet { + #[cfg(feature = "std")] + use frame_support::sp_runtime::traits::{CheckedAdd, Zero}; + use frame_support::{ + pallet_prelude::{ValueQuery, *}, + sp_runtime::traits::Saturating, + storage::with_storage_layer, + traits::{ExistenceRequirement, WithdrawReasons}, + }; + use frame_system::pallet_prelude::*; + use primitives_ethereum::{EcdsaSignature, EthereumAddress}; + + use super::*; + use crate::{ + traits::{PreconstructedMessageVerifier, VestingInterface}, + types::{ClaimInfo, EthereumSignatureMessageParams}, + weights::WeightInfo, + }; + + #[pallet::pallet] + #[pallet::generate_store(pub(super) trait Store)] + #[pallet::storage_version(STORAGE_VERSION)] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + /// Overarching event type. + type Event: From> + IsType<::Event>; + + /// Currency to claim. + type Currency: Currency<::AccountId>; + + /// The ID for the pot account to use. + #[pallet::constant] + type PotAccountId: Get<::AccountId>; + + /// Vesting schedule configuration type. + type VestingSchedule: Member + Parameter + MaxEncodedLen + MaybeSerializeDeserialize; + + /// Interface into the vesting implementation. + type VestingInterface: VestingInterface< + AccountId = Self::AccountId, + Balance = BalanceOf, + Schedule = Self::VestingSchedule, + >; + + /// The ethereum signature verifier for the claim requests. + type EthereumSignatureVerifier: PreconstructedMessageVerifier< + MessageParams = EthereumSignatureMessageParams, + >; + + /// The weight informtation provider type. + type WeightInfo: WeightInfo; + } + + /// The claims that are available in the system. + #[pallet::storage] + #[pallet::getter(fn claims)] + pub type Claims = StorageMap<_, Twox64Concat, EthereumAddress, ClaimInfoOf, OptionQuery>; + + /// The total amount of claimable balance in the pot. + #[pallet::storage] + #[pallet::getter(fn total_claimable)] + pub type TotalClaimable = StorageValue<_, BalanceOf, ValueQuery>; + + #[pallet::genesis_config] + pub struct GenesisConfig { + /// The claims to initialize at genesis. + pub claims: Vec<(EthereumAddress, ClaimInfoOf)>, + /// The total claimable balance. + /// + /// If provided, must be equal to the sum of all claims balances. + /// This is useful for double-checking the expected sum during the genesis construction. + pub total_claimable: Option>, + } + + #[cfg(feature = "std")] + impl Default for GenesisConfig { + fn default() -> Self { + GenesisConfig { + claims: Default::default(), + total_claimable: None, + } + } + } + + #[pallet::genesis_build] + impl GenesisBuild for GenesisConfig { + fn build(&self) { + let mut total_claimable_balance: BalanceOf = Zero::zero(); + + for (eth_address, info) in self.claims.iter() { + Claims::::insert(eth_address, info.clone()); + total_claimable_balance = + total_claimable_balance.checked_add(&info.balance).unwrap(); + } + + // Ensure that our pot account has exactly the right balance. + let expected_pot_balance = >::minimum_balance() + total_claimable_balance; + let pot_account_id = T::PotAccountId::get(); + let actual_pot_balance = >::free_balance(&pot_account_id); + if actual_pot_balance != expected_pot_balance { + panic!( + "invalid balance in the token claims pot account: got {:?}, expected {:?}", + actual_pot_balance, expected_pot_balance + ); + } + + // Check that the total claimable balance we computed matched the one declared in the + // genesis configuration. + if let Some(expected_total_claimable_balance) = self.total_claimable { + if expected_total_claimable_balance != total_claimable_balance { + panic!( + "computed total claimable balance ({:?}) is different from the one specified at the genesis config ({:?})", + total_claimable_balance, expected_total_claimable_balance + ); + } + } + + // Initialize the total claimable balance. + >::update_total_claimable_balance(); + } + } + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// Tokens were claimed. + TokensClaimed { + /// Who claimed the tokens. + who: T::AccountId, + /// The ethereum address used for token claiming. + ethereum_address: EthereumAddress, + /// The balance that was claimed. + balance: BalanceOf, + /// The vesting schedule. + vesting: Option, + }, + } + + #[pallet::error] + pub enum Error { + /// The signature was invalid. + InvalidSignature, + /// No claim was found. + NoClaim, + } + + #[pallet::call] + impl Pallet { + /// Claim the tokens. + #[pallet::weight(T::WeightInfo::claim())] + pub fn claim( + origin: OriginFor, + ethereum_address: EthereumAddress, + ethereum_signature: EcdsaSignature, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + let message_params = EthereumSignatureMessageParams { + account_id: who.clone(), + ethereum_address, + }; + + if !::EthereumSignatureVerifier::verify( + message_params, + ðereum_address, + ethereum_signature, + ) { + return Err(Error::::InvalidSignature.into()); + } + + Self::process_claim(who, ethereum_address) + } + } + + impl Pallet { + fn process_claim(who: T::AccountId, ethereum_address: EthereumAddress) -> DispatchResult { + with_storage_layer(move || { + let ClaimInfo { balance, vesting } = + >::take(ethereum_address).ok_or(>::NoClaim)?; + + let funds = >::withdraw( + &T::PotAccountId::get(), + balance, + WithdrawReasons::TRANSFER, + ExistenceRequirement::KeepAlive, + )?; + >::resolve_creating(&who, funds); + + if let Some(ref vesting) = vesting { + T::VestingInterface::lock_under_vesting(&who, balance, vesting.clone())?; + } + + Self::update_total_claimable_balance(); + + Self::deposit_event(Event::TokensClaimed { + who, + ethereum_address, + balance, + vesting, + }); + + Ok(()) + }) + } + + fn update_total_claimable_balance() { + >::set( + >::free_balance(&T::PotAccountId::get()) + .saturating_sub(>::minimum_balance()), + ); + } + } +} diff --git a/crates/pallet-token-claims/src/mock.rs b/crates/pallet-token-claims/src/mock.rs new file mode 100644 index 000000000..e2156c6c3 --- /dev/null +++ b/crates/pallet-token-claims/src/mock.rs @@ -0,0 +1,167 @@ +//! The mock for the pallet. + +use frame_support::{ + parameter_types, sp_io, + traits::{ConstU32, ConstU64}, + PalletId, +}; +use primitives_ethereum::{EcdsaSignature, EthereumAddress}; +use sp_core::H256; +use sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, + BuildStorage, +}; + +use crate::{self as pallet_token_claims}; + +mod utils; +pub use self::utils::*; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; + +frame_support::construct_runtime!( + pub enum Test where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system::{Pallet, Call, Config, Storage, Event}, + Balances: pallet_balances::{Pallet, Call, Storage, Config, Event}, + Pot: pallet_pot::{Pallet, Config, Event}, + TokenClaims: pallet_token_claims::{Pallet, Call, Storage, Config, Event}, + } +); + +impl frame_system::Config for Test { + type BaseCallFilter = frame_support::traits::Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type Origin = Origin; + type Call = Call; + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup; + type Header = Header; + type Event = Event; + type BlockHashCount = ConstU64<250>; + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = ConstU32<16>; +} + +impl pallet_balances::Config for Test { + type Balance = u64; + type Event = Event; + type DustRemoval = (); + type ExistentialDeposit = ConstU64<1>; + type AccountStore = System; + type MaxLocks = (); + type MaxReserves = (); + type ReserveIdentifier = [u8; 8]; + type WeightInfo = (); +} + +parameter_types! { + pub const PotPalletId: PalletId = PalletId(*b"tokenclm"); +} + +impl pallet_pot::Config for Test { + type Event = Event; + type Currency = Balances; + type PalletId = PotPalletId; +} + +parameter_types! { + pub PotAccountId: u64 = Pot::account_id(); +} + +impl pallet_token_claims::Config for Test { + type Event = Event; + type Currency = Balances; + type PotAccountId = PotAccountId; + type VestingSchedule = MockVestingSchedule; + type VestingInterface = MockVestingInterface; + type EthereumSignatureVerifier = MockEthereumSignatureVerifier; + type WeightInfo = (); +} + +pub enum EthAddr { + NoVesting, + WithVesting, + Unknown, + Other(u8), +} + +impl From for u8 { + fn from(eth_addr: EthAddr) -> Self { + match eth_addr { + EthAddr::NoVesting => 1, + EthAddr::WithVesting => 2, + EthAddr::Unknown => 3, + EthAddr::Other(val) => val, + } + } +} + +/// Utility function for creating dummy ethereum accounts. +pub fn eth(val: EthAddr) -> EthereumAddress { + let mut addr = [0; 20]; + addr[19] = val.into(); + EthereumAddress(addr) +} + +/// Utility function for creating dummy ecdsa signatures. +pub fn sig(num: u8) -> EcdsaSignature { + let mut signature = [0; 65]; + signature[64] = num; + EcdsaSignature(signature) +} + +pub fn new_test_ext() -> sp_io::TestExternalities { + let genesis_config = GenesisConfig { + system: Default::default(), + balances: BalancesConfig { + balances: vec![( + Pot::account_id(), + 30 /* tokens sum */ + + 1, /* existential deposit */ + )], + }, + pot: Default::default(), + token_claims: TokenClaimsConfig { + claims: [ + (eth(EthAddr::NoVesting), 10, None), + (eth(EthAddr::WithVesting), 20, Some(MockVestingSchedule)), + ] + .into_iter() + .map(|(eth_address, balance, vesting)| { + ( + eth_address, + pallet_token_claims::types::ClaimInfo { balance, vesting }, + ) + }) + .collect(), + total_claimable: Some(30), + }, + }; + new_test_ext_with(genesis_config) +} + +// This function basically just builds a genesis storage key/value store according to +// our desired mockup. +pub fn new_test_ext_with(genesis_config: GenesisConfig) -> sp_io::TestExternalities { + let storage = genesis_config.build_storage().unwrap(); + storage.into() +} diff --git a/crates/pallet-token-claims/src/mock/utils.rs b/crates/pallet-token-claims/src/mock/utils.rs new file mode 100644 index 000000000..a317f01ec --- /dev/null +++ b/crates/pallet-token-claims/src/mock/utils.rs @@ -0,0 +1,76 @@ +//! Mock utils. + +use codec::{Decode, Encode, MaxEncodedLen}; +use frame_support::{Deserialize, Serialize}; +use mockall::mock; +use primitives_ethereum::EcdsaSignature; +use scale_info::TypeInfo; + +use super::*; +use crate::{traits, types::EthereumSignatureMessageParams}; + +type AccountId = ::AccountId; + +#[derive( + Debug, Clone, Decode, Encode, MaxEncodedLen, TypeInfo, PartialEq, Eq, Serialize, Deserialize, +)] +pub struct MockVestingSchedule; + +mock! { + #[derive(Debug)] + pub VestingInterface {} + impl traits::VestingInterface for VestingInterface { + type AccountId = AccountId; + type Balance = crate::BalanceOf; + type Schedule = MockVestingSchedule; + + fn lock_under_vesting( + account: &::AccountId, + balance_to_lock: ::Balance, + schedule: ::Schedule, + ) -> frame_support::dispatch::DispatchResult; + } +} + +mock! { + #[derive(Debug)] + pub EthereumSignatureVerifier {} + + impl traits::PreconstructedMessageVerifier for EthereumSignatureVerifier { + type MessageParams = EthereumSignatureMessageParams; + + fn recover_signer( + message_params: ::MessageParams, + signature: EcdsaSignature, + ) -> Option; + } +} + +pub fn runtime_lock() -> std::sync::MutexGuard<'static, ()> { + static MOCK_RUNTIME_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + // Ignore the poisoning for the tests that panic. + // We only care about concurrency here, not about the poisoning. + match MOCK_RUNTIME_MUTEX.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +pub trait TestExternalitiesExt { + fn execute_with_ext(&mut self, execute: E) -> R + where + E: for<'e> FnOnce(&'e ()) -> R; +} + +impl TestExternalitiesExt for frame_support::sp_io::TestExternalities { + fn execute_with_ext(&mut self, execute: E) -> R + where + E: for<'e> FnOnce(&'e ()) -> R, + { + let guard = runtime_lock(); + let result = self.execute_with(|| execute(&guard)); + drop(guard); + result + } +} diff --git a/crates/pallet-token-claims/src/tests.rs b/crates/pallet-token-claims/src/tests.rs new file mode 100644 index 000000000..c71ae3137 --- /dev/null +++ b/crates/pallet-token-claims/src/tests.rs @@ -0,0 +1,558 @@ +//! The tests for the pallet. + +use frame_support::{assert_noop, assert_ok}; +use mockall::predicate; +use primitives_ethereum::EthereumAddress; +use sp_runtime::DispatchError; + +use crate::{ + mock::{ + eth, new_test_ext, new_test_ext_with, sig, Balances, EthAddr, + MockEthereumSignatureVerifier, MockVestingInterface, MockVestingSchedule, Origin, Test, + TestExternalitiesExt, TokenClaims, + }, + types::{ClaimInfo, EthereumSignatureMessageParams}, + *, +}; + +fn pot_account_balance() -> BalanceOf { + >::free_balance(&::PotAccountId::get()) +} + +fn total_claimable_balance() -> BalanceOf { + >::get() +} + +fn currency_total_issuance() -> BalanceOf { + >::total_issuance() +} + +#[test] +fn basic_setup_works() { + new_test_ext().execute_with_ext(|_| { + // Check the claims. + assert_eq!(>::get(&EthereumAddress::default()), None); + assert_eq!( + >::get(ð(EthAddr::NoVesting)), + Some(ClaimInfo { + balance: 10, + vesting: None + }) + ); + assert_eq!( + >::get(ð(EthAddr::WithVesting)), + Some(ClaimInfo { + balance: 20, + vesting: Some(MockVestingSchedule) + }) + ); + + // Check the pot balance. + assert_eq!( + pot_account_balance(), + 30 + >::minimum_balance() + ); + + // Check the total claimable balance value. + assert_eq!(total_claimable_balance(), 30); + }); +} + +/// This test verifies that claiming works in the happy path (when there is no vesting). +#[test] +fn claiming_works_no_vesting() { + new_test_ext().execute_with_ext(|_| { + // Check test preconditions. + assert!(>::contains_key(ð(EthAddr::NoVesting))); + assert_eq!(Balances::free_balance(42), 0); + let pot_account_balance_before = pot_account_balance(); + let total_claimable_balance_before = total_claimable_balance(); + let currency_total_issuance_before = currency_total_issuance(); + + // Set mock expectations. + let recover_signer_ctx = MockEthereumSignatureVerifier::recover_signer_context(); + recover_signer_ctx + .expect() + .once() + .with( + predicate::eq(EthereumSignatureMessageParams { + account_id: 42, + ethereum_address: eth(EthAddr::NoVesting), + }), + predicate::eq(sig(1)), + ) + .return_const(Some(eth(EthAddr::NoVesting))); + let lock_under_vesting_ctx = MockVestingInterface::lock_under_vesting_context(); + lock_under_vesting_ctx.expect().never(); + + // Invoke the function under test. + assert_ok!(TokenClaims::claim( + Origin::signed(42), + eth(EthAddr::NoVesting), + sig(1), + )); + + // Assert state changes. + assert!(!>::contains_key(ð(EthAddr::NoVesting))); + assert_eq!(Balances::free_balance(42), 10); + assert_eq!(pot_account_balance_before - pot_account_balance(), 10); + assert_eq!( + total_claimable_balance_before - total_claimable_balance(), + 10 + ); + assert_eq!( + currency_total_issuance_before - currency_total_issuance(), + 0 + ); + + // Assert mock invocations. + recover_signer_ctx.checkpoint(); + lock_under_vesting_ctx.checkpoint(); + }); +} + +/// This test verifies that claiming works in the happy path with vesting. +#[test] +fn claiming_works_with_vesting() { + new_test_ext().execute_with_ext(|_| { + // Check test preconditions. + assert!(>::contains_key(ð(EthAddr::WithVesting))); + assert_eq!(Balances::free_balance(42), 0); + let pot_account_balance_before = pot_account_balance(); + let total_claimable_balance_before = total_claimable_balance(); + let currency_total_issuance_before = currency_total_issuance(); + + // Set mock expectations. + let recover_signer_ctx = MockEthereumSignatureVerifier::recover_signer_context(); + let lock_under_vesting_ctx = MockVestingInterface::lock_under_vesting_context(); + recover_signer_ctx + .expect() + .once() + .with( + predicate::eq(EthereumSignatureMessageParams { + account_id: 42, + ethereum_address: eth(EthAddr::WithVesting), + }), + predicate::eq(sig(1)), + ) + .return_const(Some(eth(EthAddr::WithVesting))); + lock_under_vesting_ctx + .expect() + .once() + .with(predicate::eq(42), predicate::eq(20), predicate::always()) + .return_const(Ok(())); + + // Invoke the function under test. + assert_ok!(TokenClaims::claim( + Origin::signed(42), + eth(EthAddr::WithVesting), + sig(1) + )); + + // Assert state changes. + assert!(!>::contains_key(ð(EthAddr::WithVesting))); + assert_eq!(Balances::free_balance(42), 20); + assert_eq!(pot_account_balance_before - pot_account_balance(), 20); + assert_eq!( + total_claimable_balance_before - total_claimable_balance(), + 20 + ); + assert_eq!( + currency_total_issuance_before - currency_total_issuance(), + 0 + ); + + // Assert mock invocations. + recover_signer_ctx.checkpoint(); + lock_under_vesting_ctx.checkpoint(); + }); +} + +/// This test verifies that claiming does not go through when the ethereum address recovery from +/// the ethereum signature fails. +#[test] +fn claim_eth_signature_recovery_failure() { + new_test_ext().execute_with_ext(|_| { + // Check test preconditions. + assert!(>::contains_key(ð(EthAddr::NoVesting))); + assert_eq!(Balances::free_balance(42), 0); + let pot_account_balance_before = pot_account_balance(); + let total_claimable_balance_before = total_claimable_balance(); + let currency_total_issuance_before = currency_total_issuance(); + + // Set mock expectations. + let recover_signer_ctx = MockEthereumSignatureVerifier::recover_signer_context(); + let lock_under_vesting_ctx = MockVestingInterface::lock_under_vesting_context(); + recover_signer_ctx + .expect() + .once() + .with( + predicate::eq(EthereumSignatureMessageParams { + account_id: 42, + ethereum_address: eth(EthAddr::NoVesting), + }), + predicate::eq(sig(1)), + ) + .return_const(None); + lock_under_vesting_ctx.expect().never(); + + // Invoke the function under test. + assert_noop!( + TokenClaims::claim(Origin::signed(42), eth(EthAddr::NoVesting), sig(1)), + >::InvalidSignature + ); + + // Assert state changes. + assert!(>::contains_key(ð(EthAddr::NoVesting))); + assert_eq!(Balances::free_balance(42), 0); + assert_eq!(pot_account_balance_before - pot_account_balance(), 0); + assert_eq!( + total_claimable_balance_before - total_claimable_balance(), + 0 + ); + assert_eq!( + currency_total_issuance_before - currency_total_issuance(), + 0 + ); + + // Assert mock invocations. + recover_signer_ctx.checkpoint(); + lock_under_vesting_ctx.checkpoint(); + }); +} + +/// This test verifies that claiming does not go through when the ethereum address recovery from +/// the ethereum signature recoves an address that does not match the expected one. +#[test] +fn claim_eth_signature_recovery_invalid() { + new_test_ext().execute_with_ext(|_| { + // Check test preconditions. + assert!(>::contains_key(ð(EthAddr::NoVesting))); + assert!(!>::contains_key(ð(EthAddr::Unknown))); + assert_eq!(Balances::free_balance(42), 0); + let pot_account_balance_before = pot_account_balance(); + let total_claimable_balance_before = total_claimable_balance(); + let currency_total_issuance_before = currency_total_issuance(); + + // Set mock expectations. + let recover_signer_ctx = MockEthereumSignatureVerifier::recover_signer_context(); + let lock_under_vesting_ctx = MockVestingInterface::lock_under_vesting_context(); + recover_signer_ctx + .expect() + .once() + .with( + predicate::eq(EthereumSignatureMessageParams { + account_id: 42, + ethereum_address: eth(EthAddr::NoVesting), + }), + predicate::eq(sig(1)), + ) + .return_const(Some(eth(EthAddr::Unknown))); + lock_under_vesting_ctx.expect().never(); + + // Invoke the function under test. + assert_noop!( + TokenClaims::claim(Origin::signed(42), eth(EthAddr::NoVesting), sig(1)), + >::InvalidSignature + ); + + // Assert state changes. + assert!(>::contains_key(ð(EthAddr::NoVesting))); + assert!(!>::contains_key(ð(EthAddr::Unknown))); + assert_eq!(Balances::free_balance(42), 0); + assert_eq!(pot_account_balance_before - pot_account_balance(), 0); + assert_eq!( + total_claimable_balance_before - total_claimable_balance(), + 0 + ); + assert_eq!( + currency_total_issuance_before - currency_total_issuance(), + 0 + ); + + // Assert mock invocations. + recover_signer_ctx.checkpoint(); + lock_under_vesting_ctx.checkpoint(); + }); +} + +/// This test verifies that claiming does end up in a consistent state if the vesting interface call +/// returns an error. +#[test] +fn claim_lock_under_vesting_failure() { + new_test_ext().execute_with_ext(|_| { + // Check test preconditions. + assert!(>::contains_key(ð(EthAddr::WithVesting))); + assert_eq!(Balances::free_balance(42), 0); + let pot_account_balance_before = pot_account_balance(); + let total_claimable_balance_before = total_claimable_balance(); + let currency_total_issuance_before = currency_total_issuance(); + + // Set mock expectations. + let recover_signer_ctx = MockEthereumSignatureVerifier::recover_signer_context(); + let lock_under_vesting_ctx = MockVestingInterface::lock_under_vesting_context(); + recover_signer_ctx + .expect() + .once() + .with( + predicate::eq(EthereumSignatureMessageParams { + account_id: 42, + ethereum_address: eth(EthAddr::WithVesting), + }), + predicate::eq(sig(1)), + ) + .return_const(Some(eth(EthAddr::WithVesting))); + lock_under_vesting_ctx + .expect() + .once() + .with(predicate::eq(42), predicate::eq(20), predicate::always()) + .return_const(Err(DispatchError::Other("vesting interface failed"))); + + // Invoke the function under test. + assert_noop!( + TokenClaims::claim(Origin::signed(42), eth(EthAddr::WithVesting), sig(1)), + DispatchError::Other("vesting interface failed"), + ); + + // Assert state changes. + assert!(>::contains_key(ð(EthAddr::WithVesting))); + assert_eq!(Balances::free_balance(42), 0); + assert_eq!(pot_account_balance_before - pot_account_balance(), 0); + assert_eq!( + total_claimable_balance_before - total_claimable_balance(), + 0 + ); + assert_eq!( + currency_total_issuance_before - currency_total_issuance(), + 0 + ); + + // Assert mock invocations. + recover_signer_ctx.checkpoint(); + lock_under_vesting_ctx.checkpoint(); + }); +} + +/// This test verifies that when there is no claim, the claim call fails. +#[test] +fn claim_non_existing() { + new_test_ext().execute_with_ext(|_| { + // Check test preconditions. + assert!(!>::contains_key(ð(EthAddr::Unknown))); + assert_eq!(Balances::free_balance(42), 0); + let pot_account_balance_before = pot_account_balance(); + let total_claimable_balance_before = total_claimable_balance(); + let currency_total_issuance_before = currency_total_issuance(); + + // Set mock expectations. + let recover_signer_ctx = MockEthereumSignatureVerifier::recover_signer_context(); + let lock_under_vesting_ctx = MockVestingInterface::lock_under_vesting_context(); + recover_signer_ctx + .expect() + .once() + .with( + predicate::eq(EthereumSignatureMessageParams { + account_id: 42, + ethereum_address: eth(EthAddr::Unknown), + }), + predicate::eq(sig(1)), + ) + .return_const(Some(eth(EthAddr::Unknown))); + lock_under_vesting_ctx.expect().never(); + + // Invoke the function under test. + assert_noop!( + TokenClaims::claim(Origin::signed(42), eth(EthAddr::Unknown), sig(1)), + >::NoClaim, + ); + + // Assert state changes. + assert!(!>::contains_key(ð(EthAddr::Unknown))); + assert_eq!(Balances::free_balance(42), 0); + assert_eq!(pot_account_balance_before - pot_account_balance(), 0); + assert_eq!( + total_claimable_balance_before - total_claimable_balance(), + 0 + ); + assert_eq!( + currency_total_issuance_before - currency_total_issuance(), + 0 + ); + + // Assert mock invocations. + recover_signer_ctx.checkpoint(); + lock_under_vesting_ctx.checkpoint(); + }); +} + +/// This test verifies that empty claims in genesis are handled correctly. +#[test] +fn genesis_empty() { + new_test_ext_with(mock::GenesisConfig { + balances: mock::BalancesConfig { + balances: vec![( + mock::Pot::account_id(), + 1, /* existential deposit only */ + )], + }, + ..Default::default() + }) + .execute_with_ext(|_| { + // Check the pot balance. + assert_eq!(pot_account_balance(), >::minimum_balance()); + }); +} + +/// This test verifies that the genesis builder correctly ensures the pot balance. +#[test] +#[should_panic = "invalid balance in the token claims pot account: got 124, expected 457"] +fn genesis_ensure_pot_balance_is_checked() { + new_test_ext_with(mock::GenesisConfig { + balances: mock::BalancesConfig { + balances: vec![( + mock::Pot::account_id(), + 1 /* existential deposit */ + + 123, /* total claimable amount that doesn't match the sum of claims */ + )], + }, + token_claims: mock::TokenClaimsConfig { + claims: vec![( + EthereumAddress([0; 20]), + ClaimInfo { + balance: 456, + vesting: None, + }, + )], + total_claimable: Some(456), + }, + ..Default::default() + }); +} + +/// This test verifies that the genesis builder asserted the equality of the configured and computed +/// total claimable balances. +#[test] +#[should_panic = "computed total claimable balance (123) is different from the one specified at the genesis config (456)"] +fn genesis_ensure_total_claimable_balance_is_asserted() { + new_test_ext_with(mock::GenesisConfig { + balances: mock::BalancesConfig { + balances: vec![( + mock::Pot::account_id(), + 1 /* existential deposit */ + + 123, /* total claimable amount */ + )], + }, + token_claims: mock::TokenClaimsConfig { + claims: vec![( + EthereumAddress([0; 20]), + ClaimInfo { + balance: 123, /* the only contribution to the total claimable balance */ + vesting: None, + }, + )], + total_claimable: Some(456), /* the configured total claimable balance that doesn't matched the computed value */ + }, + ..Default::default() + }); +} + +/// This test verifies that the genesis builder works when no assertion of the total claimable +/// balance is set. +#[test] +fn genesis_no_total_claimable_balance_assertion_works() { + new_test_ext_with(mock::GenesisConfig { + balances: mock::BalancesConfig { + balances: vec![( + mock::Pot::account_id(), + 1 /* existential deposit */ + + 123, /* total claimable amount */ + )], + }, + token_claims: mock::TokenClaimsConfig { + claims: vec![( + EthereumAddress([0; 20]), + ClaimInfo { + balance: 123, + vesting: None, + }, + )], + total_claimable: None, /* don't assert */ + }, + ..Default::default() + }); +} + +/// This test verifies that we can consume all of the claims seqentially and get to the empty +/// claimable balance in the pot but without killing the pot account. +#[test] +fn claiming_sequential() { + new_test_ext().execute_with_ext(|_| { + // Check test preconditions. + assert_eq!(Balances::free_balance(42), 0); + let pot_account_balance_before = pot_account_balance(); + let currency_total_issuance_before = currency_total_issuance(); + + // Prepare the keys to iterate over all the claims. + let claims: Vec<_> = >::iter().collect(); + + // Iterate over all the claims conuming them. + for (claim_eth_address, claim_info) in &claims { + // Set mock expectations. + let recover_signer_ctx = MockEthereumSignatureVerifier::recover_signer_context(); + recover_signer_ctx + .expect() + .once() + .with( + predicate::eq(EthereumSignatureMessageParams { + account_id: 42, + ethereum_address: *claim_eth_address, + }), + predicate::eq(sig(1)), + ) + .return_const(Some(*claim_eth_address)); + let lock_under_vesting_ctx = MockVestingInterface::lock_under_vesting_context(); + + match claim_info.vesting { + Some(ref vesting) => lock_under_vesting_ctx + .expect() + .once() + .with( + predicate::eq(42), + predicate::eq(claim_info.balance), + predicate::eq(vesting.clone()), + ) + .return_const(Ok(())), + None => lock_under_vesting_ctx.expect().never(), + }; + + assert_ok!(TokenClaims::claim( + Origin::signed(42), + *claim_eth_address, + sig(1), + )); + + // Assert state changes for this local iteration. + assert!(!>::contains_key(claim_eth_address)); + assert_eq!( + currency_total_issuance_before - currency_total_issuance(), + 0 + ); + + // Assert mock invocations. + recover_signer_ctx.checkpoint(); + lock_under_vesting_ctx.checkpoint(); + } + + // Assert overall state changes. + assert_eq!( + Balances::free_balance(42), + pot_account_balance_before - >::minimum_balance() + ); + assert_eq!(pot_account_balance(), >::minimum_balance()); + assert_eq!(total_claimable_balance(), 0); + assert_eq!( + currency_total_issuance_before - currency_total_issuance(), + 0 + ); + }); +} diff --git a/crates/pallet-token-claims/src/traits.rs b/crates/pallet-token-claims/src/traits.rs new file mode 100644 index 000000000..cc6e6a96c --- /dev/null +++ b/crates/pallet-token-claims/src/traits.rs @@ -0,0 +1,58 @@ +//! Traits we use and expose. + +use frame_support::dispatch::DispatchResult; +use primitives_ethereum::{EcdsaSignature, EthereumAddress}; + +/// The verifier for the Ethereum signature. +/// +/// The idea is we don't pass in the message we use for the verification, but instead we pass in +/// the message parameters. +/// +/// This abstraction is built with EIP-712 in mind, but can also be implemented with any generic +/// ECDSA signature. +pub trait PreconstructedMessageVerifier { + /// The type describing the parameters used to construct a message. + type MessageParams; + + /// Generate a message and verify the provided `signature` against the said message. + /// Extract the [`EthereumAddress`] from the signature and return it. + /// + /// The caller should check that the extracted address matches what is expected, as successfull + /// recovery does not necessarily guarantee the correctness of the signature - that can only + /// be achieved with checking the recovered address against the expected one. + fn recover_signer( + message_params: Self::MessageParams, + signature: EcdsaSignature, + ) -> Option; + + /// Calls [`Self::recover_signer`] and then checks that the `signer` matches the recovered address. + fn verify( + message_params: Self::MessageParams, + signer: &EthereumAddress, + signature: EcdsaSignature, + ) -> bool { + let recovered = match Self::recover_signer(message_params, signature) { + Some(recovered) => recovered, + None => return false, + }; + &recovered == signer + } +} + +/// The interface to the vesting implementation. +pub trait VestingInterface { + /// The Account ID to apply vesting to. + type AccountId; + /// The type of balance to lock under the vesting. + type Balance; + /// The vesting schedule configuration. + type Schedule; + + /// Lock the specified amount of balance (`balance_to_lock`) on the given account (`account`) + /// with the provided vesting schedule configuration (`schedule`). + fn lock_under_vesting( + account: &Self::AccountId, + balance_to_lock: Self::Balance, + schedule: Self::Schedule, + ) -> DispatchResult; +} diff --git a/crates/pallet-token-claims/src/types.rs b/crates/pallet-token-claims/src/types.rs new file mode 100644 index 000000000..21da771ee --- /dev/null +++ b/crates/pallet-token-claims/src/types.rs @@ -0,0 +1,29 @@ +//! Custom types we use. + +use codec::{Decode, Encode, MaxEncodedLen}; +use frame_support::RuntimeDebug; +#[cfg(feature = "std")] +use frame_support::{Deserialize, Serialize}; +use primitives_ethereum::EthereumAddress; +use scale_info::TypeInfo; + +/// The claim information. +#[derive( + Clone, Copy, PartialEq, Eq, Encode, Decode, Default, RuntimeDebug, TypeInfo, MaxEncodedLen, +)] +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +pub struct ClaimInfo { + /// The amount to claim. + pub balance: Balance, + /// The vesting configuration for the given claim. + pub vesting: Option, +} + +/// The collection of parameters used for constructing a message that had to be signed. +#[derive(PartialEq, Eq, RuntimeDebug)] +pub struct EthereumSignatureMessageParams { + /// The account ID of whoever is requesting the claim. + pub account_id: AccountId, + /// The ethereum address the claim is authorized for. + pub ethereum_address: EthereumAddress, +} diff --git a/crates/pallet-token-claims/src/weights.rs b/crates/pallet-token-claims/src/weights.rs new file mode 100644 index 000000000..761c8366a --- /dev/null +++ b/crates/pallet-token-claims/src/weights.rs @@ -0,0 +1,15 @@ +//! The weights. + +use frame_support::dispatch::Weight; + +/// The weight information trait, to be implemented in from the benches. +pub trait WeightInfo { + /// Weight for `claim` call. + fn claim() -> Weight; +} + +impl WeightInfo for () { + fn claim() -> Weight { + 0 + } +} diff --git a/crates/primitives-ethereum/Cargo.toml b/crates/primitives-ethereum/Cargo.toml new file mode 100644 index 000000000..b4c71d3cb --- /dev/null +++ b/crates/primitives-ethereum/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "primitives-ethereum" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +codec = { package = "parity-scale-codec", version = "3.0.0", default-features = false, features = ["derive"] } +frame-support = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "master" } +frame-system = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "master" } +rustc-hex = { version = "2.1.0", optional = true } +scale-info = { version = "2.1.1", default-features = false, features = ["derive"] } +serde = { version = "1", optional = true } + +[dev-dependencies] +serde_json = "1" + +[features] +default = ["std"] +std = [ + "codec/std", + "frame-support/std", + "frame-system/std", + "rustc-hex", + "serde", +] diff --git a/crates/primitives-ethereum/src/ecdsa_signature.rs b/crates/primitives-ethereum/src/ecdsa_signature.rs new file mode 100644 index 000000000..27f8a3693 --- /dev/null +++ b/crates/primitives-ethereum/src/ecdsa_signature.rs @@ -0,0 +1,15 @@ +//! ECDSA Signature. + +use codec::{Decode, Encode, MaxEncodedLen}; +use frame_support::RuntimeDebug; +use scale_info::TypeInfo; + +/// A ECDSA signature, used by Ethereum. +#[derive(Clone, Copy, PartialEq, Eq, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] +pub struct EcdsaSignature(pub [u8; 65]); + +impl Default for EcdsaSignature { + fn default() -> Self { + Self([0; 65]) + } +} diff --git a/crates/primitives-ethereum/src/ethereum_address.rs b/crates/primitives-ethereum/src/ethereum_address.rs new file mode 100644 index 000000000..47412f70d --- /dev/null +++ b/crates/primitives-ethereum/src/ethereum_address.rs @@ -0,0 +1,93 @@ +//! Ethereum address. + +use codec::{Decode, Encode, MaxEncodedLen}; +#[cfg(feature = "std")] +use frame_support::serde::{self, Deserialize, Deserializer, Serialize, Serializer}; +use frame_support::RuntimeDebug; +use scale_info::TypeInfo; + +/// An Ethereum address (i.e. 20 bytes, used to represent an Ethereum account). +/// +/// This gets serialized to the 0x-prefixed hex representation. +#[derive( + Clone, Copy, PartialEq, Eq, Encode, Decode, Default, RuntimeDebug, TypeInfo, MaxEncodedLen, +)] +pub struct EthereumAddress(pub [u8; 20]); + +#[cfg(feature = "std")] +impl Serialize for EthereumAddress { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let hex: String = "0x" + .chars() + .chain(rustc_hex::ToHexIter::new(self.0.iter())) + .collect(); + serializer.serialize_str(&hex) + } +} + +#[cfg(feature = "std")] +impl<'de> Deserialize<'de> for EthereumAddress { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let base_string = String::deserialize(deserializer)?; + let offset = if base_string.starts_with("0x") { 2 } else { 0 }; + let s = &base_string[offset..]; + if s.len() != 40 { + return Err(serde::de::Error::custom( + "bad length of Ethereum address (should be 42 including '0x')", + )); + } + let mut iter = rustc_hex::FromHexIter::new(s); + + let mut to_fill = [0u8; 20]; + for slot in to_fill.iter_mut() { + // We check the length above, so this must work. + let result = iter.next().unwrap(); + + let ch = result.map_err(|err| match err { + rustc_hex::FromHexError::InvalidHexCharacter(ch, idx) => { + serde::de::Error::custom(&format_args!( + "invalid character '{}' at position {}, expected 0-9 or a-z or A-Z", + ch, idx + )) + } + // We check the length above, so this will never happen. + rustc_hex::FromHexError::InvalidHexLength => unreachable!(), + })?; + *slot = ch; + } + Ok(EthereumAddress(to_fill)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialize_ok() { + assert_eq!( + &serde_json::to_string(&EthereumAddress([ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 + ])) + .unwrap(), + "\"0x000102030405060708090a0b0c0d0e0f10111213\"", + ); + } + + #[test] + fn deserialize_ok() { + assert_eq!( + serde_json::from_str::( + "\"0x000102030405060708090a0b0c0d0e0f10111213\"" + ) + .unwrap(), + EthereumAddress([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]) + ); + } +} diff --git a/crates/primitives-ethereum/src/lib.rs b/crates/primitives-ethereum/src/lib.rs new file mode 100644 index 000000000..532729c82 --- /dev/null +++ b/crates/primitives-ethereum/src/lib.rs @@ -0,0 +1,9 @@ +//! Common ethereum related primitives. + +#![cfg_attr(not(feature = "std"), no_std)] + +mod ecdsa_signature; +mod ethereum_address; + +pub use ecdsa_signature::*; +pub use ethereum_address::*;