Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion crates/pallet-token-claims/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
//! Token claims.
//!
//! # Security
//!
//! This pallet requires adding [`CheckTokenClaim`] to the tuple of signed extension checkers
//! at runtime to be utilized safely, otherwise it exposes a flooding vulnerability.
//! There is no way to ensure this would be automatically picked up by the runtime, so double-check
//! it at integration!

#![cfg_attr(not(feature = "std"), no_std)]

use frame_support::traits::{Currency, StorageVersion};

pub use self::pallet::*;
pub use self::signed_ext::*;

mod signed_ext;
pub mod traits;
pub mod types;
pub mod weights;
Expand Down Expand Up @@ -189,7 +198,7 @@ pub mod pallet {
#[pallet::call]
impl<T: Config> Pallet<T> {
/// Claim the tokens.
#[pallet::weight(T::WeightInfo::claim())]
#[pallet::weight((T::WeightInfo::claim(), Pays::No))]
pub fn claim(
origin: OriginFor<T>,
ethereum_address: EthereumAddress,
Expand Down
120 changes: 120 additions & 0 deletions crates/pallet-token-claims/src/signed_ext.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//! Signed extension implentation for token claims.

use core::marker::PhantomData;

use frame_support::{
dispatch::Dispatchable,
pallet_prelude::*,
sp_runtime,
traits::IsSubType,
unsigned::{TransactionValidity, TransactionValidityError},
weights::DispatchInfo,
};
use sp_runtime::traits::{DispatchInfoOf, SignedExtension};

use super::*;
use crate::{traits::verify_ethereum_signature, types::EthereumSignatureMessageParams};

impl<T: Config> Pallet<T> {
/// Validate that the `claim` is correct and should be allowed for inclusion.
///
/// Implement the flood protection logic.
fn validate_claim_call(who: &T::AccountId, call: &Call<T>) -> TransactionValidity {
// Check if the call matches.
let (ethereum_address, ethereum_signature) = match call {
// Allow `claim` call.
Call::claim {
ethereum_address,
ethereum_signature,
} => (ethereum_address, ethereum_signature),
// Deny all unknown calls.
_ => return Err(TransactionValidityError::Invalid(InvalidTransaction::Call)),
};

// Check the signature.
let message_params = EthereumSignatureMessageParams {
account_id: who.clone(),
ethereum_address: *ethereum_address,
};
if !verify_ethereum_signature::<<T as Config>::EthereumSignatureVerifier>(
ethereum_signature,
&message_params,
ethereum_address,
) {
return Err(TransactionValidityError::Invalid(
InvalidTransaction::BadProof,
));
}

// Check the presence of a claim.
if !<Claims<T>>::contains_key(ethereum_address) {
return Err(TransactionValidityError::Invalid(InvalidTransaction::Call));
}

// All good, letting through.
Ok(ValidTransaction::default())
}
}

/// Check the `claim` call for validity.
///
/// The call is free, so this check is required to ensure it will be properly verified to
/// prevent chain flooding.
#[derive(Clone, Eq, PartialEq, codec::Encode, codec::Decode, scale_info::TypeInfo)]
#[scale_info(skip_type_params(T))]
pub struct CheckTokenClaim<T: Config + Send + Sync>(PhantomData<T>);

impl<T: Config + Send + Sync> SignedExtension for CheckTokenClaim<T>
where
T::Call: Dispatchable<Info = DispatchInfo>,
<T as frame_system::Config>::Call: IsSubType<Call<T>>,
{
const IDENTIFIER: &'static str = "CheckTokenClaim";
type AccountId = T::AccountId;
type Call = T::Call;
type AdditionalSigned = ();
type Pre = ();

fn additional_signed(
&self,
) -> Result<Self::AdditionalSigned, frame_support::unsigned::TransactionValidityError> {
Ok(())
}

fn pre_dispatch(
self,
who: &Self::AccountId,
call: &Self::Call,
info: &DispatchInfoOf<Self::Call>,
len: usize,
) -> Result<Self::Pre, frame_support::unsigned::TransactionValidityError> {
self.validate(who, call, info, len).map(|_| ())
}

fn validate(
&self,
who: &Self::AccountId,
call: &Self::Call,
_info: &DispatchInfoOf<Self::Call>,
_len: usize,
) -> TransactionValidity {
match call.is_sub_type() {
Some(call) => Pallet::<T>::validate_claim_call(who, call),
_ => Ok(Default::default()),
}
}
}

impl<T: Config + Send + Sync> core::fmt::Debug for CheckTokenClaim<T> {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
write!(f, "CheckTokenClaim")
}
}

impl<T: Config + Send + Sync> CheckTokenClaim<T> {
/// Create a new [`CheckTokenClaim`] instance.
#[allow(clippy::new_without_default)] // following the pattern
pub fn new() -> Self {
Self(PhantomData)
}
}
164 changes: 162 additions & 2 deletions crates/pallet-token-claims/src/tests.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
//! The tests for the pallet.

use frame_support::{assert_noop, assert_ok};
use frame_support::{
assert_noop, assert_ok, assert_storage_noop,
pallet_prelude::{InvalidTransaction, ValidTransaction},
unsigned::TransactionValidityError,
weights::{DispatchClass, DispatchInfo, Pays},
};
use mockall::predicate;
use primitives_ethereum::EthereumAddress;
use sp_runtime::DispatchError;
use sp_runtime::{traits::SignedExtension, DispatchError};

use crate::{
mock::{
Expand Down Expand Up @@ -702,3 +707,158 @@ mod optional_vesting_interface {
})
}
}

/// This test verifies that signed extension's `validate` works in the happy path.
#[test]
fn signed_ext_validate_works() {
new_test_ext().execute_with_ext(|_| {
// Check test preconditions.
assert!(<Claims<Test>>::contains_key(&eth(EthAddr::Existing)));
assert_eq!(Balances::free_balance(42), 0);

// Set mock expectations.
let recover_signer_ctx = MockEthereumSignatureVerifier::recover_signer_context();
recover_signer_ctx
.expect()
.once()
.with(
predicate::eq(sig(1)),
predicate::eq(EthereumSignatureMessageParams {
account_id: 42,
ethereum_address: eth(EthAddr::Existing),
}),
)
.return_const(Some(eth(EthAddr::Existing)));
let lock_under_vesting_ctx = MockVestingInterface::lock_under_vesting_context();
lock_under_vesting_ctx.expect().never();

// Invoke the function under test.
let normal = DispatchInfo {
weight: 100,
class: DispatchClass::Normal,
pays_fee: Pays::No,
};
let len = 0;
let ext = <CheckTokenClaim<Test>>::new();
assert_storage_noop!(assert_ok!(
ext.validate(
&42,
&mock::Call::TokenClaims(Call::claim {
ethereum_address: eth(EthAddr::Existing),
ethereum_signature: sig(1),
}),
&normal,
len
),
ValidTransaction::default()
));

// Assert mock invocations.
recover_signer_ctx.checkpoint();
lock_under_vesting_ctx.checkpoint();
});
}

/// This test verifies that signed extension's `validate` properly fails when the eth signature is
/// invalid.
#[test]
fn signed_ext_validate_fails_invalid_eth_signatue() {
new_test_ext().execute_with_ext(|_| {
// Check test preconditions.
assert!(<Claims<Test>>::contains_key(&eth(EthAddr::Existing)));
assert_eq!(Balances::free_balance(42), 0);

// Set mock expectations.
let recover_signer_ctx = MockEthereumSignatureVerifier::recover_signer_context();
recover_signer_ctx
.expect()
.once()
.with(
predicate::eq(sig(1)),
predicate::eq(EthereumSignatureMessageParams {
account_id: 42,
ethereum_address: eth(EthAddr::Existing),
}),
)
.return_const(None);
let lock_under_vesting_ctx = MockVestingInterface::lock_under_vesting_context();
lock_under_vesting_ctx.expect().never();

// Invoke the function under test.
let normal = DispatchInfo {
weight: 100,
class: DispatchClass::Normal,
pays_fee: Pays::No,
};
let len = 0;
let ext = <CheckTokenClaim<Test>>::new();
assert_noop!(
ext.validate(
&42,
&mock::Call::TokenClaims(Call::claim {
ethereum_address: eth(EthAddr::Existing),
ethereum_signature: sig(1),
}),
&normal,
len
),
TransactionValidityError::Invalid(InvalidTransaction::BadProof)
);

// Assert mock invocations.
recover_signer_ctx.checkpoint();
lock_under_vesting_ctx.checkpoint();
});
}

/// This test verifies that signed extension's `validate` properly fails when the claim is
/// not present in the state for the requested eth address.
#[test]
fn signed_ext_validate_fails_when_claim_is_absent() {
new_test_ext().execute_with_ext(|_| {
// Check test preconditions.
assert!(!<Claims<Test>>::contains_key(&eth(EthAddr::Unknown)));
assert_eq!(Balances::free_balance(42), 0);

// Set mock expectations.
let recover_signer_ctx = MockEthereumSignatureVerifier::recover_signer_context();
recover_signer_ctx
.expect()
.once()
.with(
predicate::eq(sig(1)),
predicate::eq(EthereumSignatureMessageParams {
account_id: 42,
ethereum_address: eth(EthAddr::Unknown),
}),
)
.return_const(Some(eth(EthAddr::Unknown)));
let lock_under_vesting_ctx = MockVestingInterface::lock_under_vesting_context();
lock_under_vesting_ctx.expect().never();

// Invoke the function under test.
let normal = DispatchInfo {
weight: 100,
class: DispatchClass::Normal,
pays_fee: Pays::No,
};
let len = 0;
let ext = <CheckTokenClaim<Test>>::new();
assert_noop!(
ext.validate(
&42,
&mock::Call::TokenClaims(Call::claim {
ethereum_address: eth(EthAddr::Unknown),
ethereum_signature: sig(1),
}),
&normal,
len
),
TransactionValidityError::Invalid(InvalidTransaction::Call)
);

// Assert mock invocations.
recover_signer_ctx.checkpoint();
lock_under_vesting_ctx.checkpoint();
});
}