From 3e975f4acb82c33cbd8ee6427950c652d2b0e8d1 Mon Sep 17 00:00:00 2001 From: Yash Sharma Date: Thu, 18 Jun 2026 18:08:33 +0530 Subject: [PATCH] feat(signet): feeless allowlisted respond Gate respond/respond_error/respond_bidirectional behind a signer allowlist and mark them Pays::No. --- Cargo.lock | 4 +- pallets/signet/Cargo.toml | 2 +- pallets/signet/src/benchmarks.rs | 25 ++ pallets/signet/src/lib.rs | 65 ++++- pallets/signet/src/tests/mod.rs | 1 + pallets/signet/src/tests/signer_allowlist.rs | 270 ++++++++++++++++++ pallets/signet/src/tests/test_cases.rs | 11 + pallets/signet/src/types.rs | 2 + pallets/signet/src/weights.rs | 22 +- runtime/hydradx/Cargo.toml | 2 +- runtime/hydradx/src/lib.rs | 2 +- runtime/hydradx/src/weights/pallet_signet.rs | 31 +- scripts/signet-feeless-test/.gitignore | 3 + scripts/signet-feeless-test/README.md | 25 ++ scripts/signet-feeless-test/package.json | 28 ++ scripts/signet-feeless-test/run.sh | 42 +++ .../signet-feeless.test.ts | 79 +++++ scripts/signet-feeless-test/tsconfig.json | 13 + scripts/signet-feeless-test/utils.ts | 68 +++++ 19 files changed, 666 insertions(+), 29 deletions(-) create mode 100644 pallets/signet/src/tests/signer_allowlist.rs create mode 100644 scripts/signet-feeless-test/.gitignore create mode 100644 scripts/signet-feeless-test/README.md create mode 100644 scripts/signet-feeless-test/package.json create mode 100755 scripts/signet-feeless-test/run.sh create mode 100644 scripts/signet-feeless-test/signet-feeless.test.ts create mode 100644 scripts/signet-feeless-test/tsconfig.json create mode 100644 scripts/signet-feeless-test/utils.ts diff --git a/Cargo.lock b/Cargo.lock index 534867c07..33f6708e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "hydradx-runtime" -version = "428.0.0" +version = "429.0.0" dependencies = [ "cumulus-pallet-aura-ext", "cumulus-pallet-parachain-system", @@ -10599,7 +10599,7 @@ dependencies = [ [[package]] name = "pallet-signet" -version = "1.3.0" +version = "1.4.0" dependencies = [ "ethereum", "frame-benchmarking", diff --git a/pallets/signet/Cargo.toml b/pallets/signet/Cargo.toml index 5f8b8e30b..b208e87b6 100644 --- a/pallets/signet/Cargo.toml +++ b/pallets/signet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-signet" -version = "1.3.0" +version = "1.4.0" authors = ["Signet"] edition = "2021" license = "Apache-2.0" diff --git a/pallets/signet/src/benchmarks.rs b/pallets/signet/src/benchmarks.rs index 200eaf890..8ed68c9ad 100644 --- a/pallets/signet/src/benchmarks.rs +++ b/pallets/signet/src/benchmarks.rs @@ -141,6 +141,7 @@ mod benches { #[benchmark] fn respond() { let responder: T::AccountId = whitelisted_caller(); + Signers::::insert(&responder, ()); let mut ids: Vec<[u8; 32]> = Vec::with_capacity(MAX_BATCH_SIZE as usize); let mut sigs: Vec = Vec::with_capacity(MAX_BATCH_SIZE as usize); @@ -171,6 +172,7 @@ mod benches { #[benchmark] fn respond_error() { let responder: T::AccountId = whitelisted_caller(); + Signers::::insert(&responder, ()); let mut errs: Vec = Vec::with_capacity(MAX_BATCH_SIZE as usize); @@ -198,6 +200,7 @@ mod benches { #[benchmark] fn respond_bidirectional() { let responder: T::AccountId = whitelisted_caller(); + Signers::::insert(&responder, ()); let request_id: [u8; 32] = [7u8; 32]; let output_vec = vec![8u8; MAX_SERIALIZED_OUTPUT_LENGTH as usize]; @@ -222,6 +225,28 @@ mod benches { ); } + #[benchmark] + fn add_signer() { + let who: T::AccountId = whitelisted_caller(); + + #[extrinsic_call] + add_signer(RawOrigin::Root, who.clone()); + + assert!(Signers::::contains_key(&who)); + } + + #[benchmark] + fn remove_signer() { + let who: T::AccountId = whitelisted_caller(); + Signers::::insert(&who, ()); + SignerCount::::put(1); + + #[extrinsic_call] + remove_signer(RawOrigin::Root, who.clone()); + + assert!(!Signers::::contains_key(&who)); + } + #[benchmark] fn pause() { setup_config::(); diff --git a/pallets/signet/src/lib.rs b/pallets/signet/src/lib.rs index fd945de15..2ec4a76e9 100644 --- a/pallets/signet/src/lib.rs +++ b/pallets/signet/src/lib.rs @@ -2,6 +2,7 @@ use ethereum::{AccessListItem, EIP1559TransactionMessage, TransactionAction}; use frame_support::{ + dispatch::Pays, pallet_prelude::*, traits::{Currency, ExistenceRequirement}, PalletId, @@ -32,6 +33,9 @@ const MAX_ERROR_MESSAGE_LENGTH: u32 = 1024; /// Maximum batch sizes const MAX_BATCH_SIZE: u32 = 100; +/// Maximum number of authorized responder (MPC signer) accounts. +const MAX_SIGNERS: u32 = 64; + /// Hard upper bound for chain ID length (used as BoundedVec bound) pub const MAX_CHAIN_ID_LENGTH: u32 = 128; @@ -130,6 +134,14 @@ pub mod pallet { #[pallet::getter(fn signet_config)] pub type SignetConfig = StorageValue<_, SignetConfigData>, OptionQuery>; + /// Accounts authorized to submit signature responses. + #[pallet::storage] + pub type Signers = StorageMap<_, Blake2_128Concat, T::AccountId, (), OptionQuery>; + + /// Number of authorized signer accounts currently registered. + #[pallet::storage] + pub type SignerCount = StorageValue<_, u32, ValueQuery>; + // ======================================== // Events // ======================================== @@ -205,6 +217,11 @@ pub mod pallet { serialized_output: Vec, signature: Signature, }, + + /// A new responder account has been authorized. + SignerAdded { who: T::AccountId }, + /// A responder account has been deauthorized. + SignerRemoved { who: T::AccountId }, } // ======================================== @@ -229,6 +246,14 @@ pub mod pallet { InvalidAddress, /// Priority fee cannot exceed max fee per gas (EIP-1559 requirement) InvalidGasPrice, + /// Responder is not in the authorized signer set. + NotAuthorizedSigner, + /// The account is already an authorized signer. + SignerAlreadyExists, + /// The account is not an authorized signer. + SignerNotFound, + /// The maximum number of authorized signers has been reached. + TooManySigners, } // ======================================== @@ -387,13 +412,14 @@ pub mod pallet { /// Respond to signature requests (batch support) #[pallet::call_index(4)] - #[pallet::weight(::WeightInfo::respond())] + #[pallet::weight((::WeightInfo::respond(), Pays::No))] pub fn respond( origin: OriginFor, request_ids: BoundedVec<[u8; 32], ConstU32>, signatures: BoundedVec>, ) -> DispatchResult { let responder = ensure_signed(origin)?; + ensure!(Signers::::contains_key(&responder), Error::::NotAuthorizedSigner); ensure!(request_ids.len() == signatures.len(), Error::::InvalidInputLength); @@ -410,12 +436,13 @@ pub mod pallet { /// Report signature generation errors (batch support) #[pallet::call_index(5)] - #[pallet::weight(::WeightInfo::respond_error())] + #[pallet::weight((::WeightInfo::respond_error(), Pays::No))] pub fn respond_error( origin: OriginFor, errors: BoundedVec>, ) -> DispatchResult { let responder = ensure_signed(origin)?; + ensure!(Signers::::contains_key(&responder), Error::::NotAuthorizedSigner); for error in errors { Self::deposit_event(Event::SignatureError { @@ -430,7 +457,7 @@ pub mod pallet { /// Provide a read response with signature #[pallet::call_index(6)] - #[pallet::weight(::WeightInfo::respond_bidirectional())] + #[pallet::weight((::WeightInfo::respond_bidirectional(), Pays::No))] pub fn respond_bidirectional( origin: OriginFor, request_id: [u8; 32], @@ -438,6 +465,7 @@ pub mod pallet { signature: Signature, ) -> DispatchResult { let responder = ensure_signed(origin)?; + ensure!(Signers::::contains_key(&responder), Error::::NotAuthorizedSigner); Self::deposit_event(Event::RespondBidirectionalEvent { request_id, @@ -484,6 +512,37 @@ pub mod pallet { Self::deposit_event(Event::Unpaused); Ok(()) } + + /// Authorize an account to submit signature responses. + #[pallet::call_index(9)] + #[pallet::weight(::WeightInfo::add_signer())] + pub fn add_signer(origin: OriginFor, who: T::AccountId) -> DispatchResult { + T::UpdateOrigin::ensure_origin(origin)?; + + ensure!(!Signers::::contains_key(&who), Error::::SignerAlreadyExists); + ensure!(SignerCount::::get() < MAX_SIGNERS, Error::::TooManySigners); + + Signers::::insert(&who, ()); + SignerCount::::mutate(|n| *n = n.saturating_add(1)); + + Self::deposit_event(Event::SignerAdded { who }); + Ok(()) + } + + /// Remove an account from the signer allowlist. + #[pallet::call_index(10)] + #[pallet::weight(::WeightInfo::remove_signer())] + pub fn remove_signer(origin: OriginFor, who: T::AccountId) -> DispatchResult { + T::UpdateOrigin::ensure_origin(origin)?; + + ensure!(Signers::::contains_key(&who), Error::::SignerNotFound); + + Signers::::remove(&who); + SignerCount::::mutate(|n| *n = n.saturating_sub(1)); + + Self::deposit_event(Event::SignerRemoved { who }); + Ok(()) + } } // Helper functions diff --git a/pallets/signet/src/tests/mod.rs b/pallets/signet/src/tests/mod.rs index 86962cb01..d0de414f4 100644 --- a/pallets/signet/src/tests/mod.rs +++ b/pallets/signet/src/tests/mod.rs @@ -1,3 +1,4 @@ +mod signer_allowlist; mod test_cases; pub mod utils; diff --git a/pallets/signet/src/tests/signer_allowlist.rs b/pallets/signet/src/tests/signer_allowlist.rs new file mode 100644 index 000000000..96909505d --- /dev/null +++ b/pallets/signet/src/tests/signer_allowlist.rs @@ -0,0 +1,270 @@ +use crate::{ + tests::{ + new_test_ext, + utils::{bounded_array, bounded_err, bounded_sig, bounded_u8, create_test_signature}, + RuntimeCall, RuntimeOrigin, Signet, System, Test, + }, + Error, ErrorResponse, Event, SignerCount, Signers, MAX_SIGNERS, +}; +use frame_support::dispatch::{GetDispatchInfo, Pays}; +use frame_support::{assert_noop, assert_ok}; +use sp_runtime::DispatchError; + +const SIGNER: u64 = 1; +const OUTSIDER: u64 = 2; + +// ----------------------------------------------------------------------------- +// add_signer / remove_signer +// ----------------------------------------------------------------------------- + +#[test] +fn add_signer_should_authorize_account_when_called_by_update_origin() { + new_test_ext().execute_with(|| { + assert_ok!(Signet::add_signer(RuntimeOrigin::root(), SIGNER)); + + assert!(Signers::::contains_key(SIGNER)); + assert_eq!(SignerCount::::get(), 1); + System::assert_last_event(Event::SignerAdded { who: SIGNER }.into()); + }); +} + +#[test] +fn add_signer_should_fail_when_origin_is_not_update_origin() { + new_test_ext().execute_with(|| { + assert_noop!( + Signet::add_signer(RuntimeOrigin::signed(OUTSIDER), SIGNER), + DispatchError::BadOrigin + ); + assert!(!Signers::::contains_key(SIGNER)); + }); +} + +#[test] +fn add_signer_should_fail_when_account_already_authorized() { + new_test_ext().execute_with(|| { + assert_ok!(Signet::add_signer(RuntimeOrigin::root(), SIGNER)); + + assert_noop!( + Signet::add_signer(RuntimeOrigin::root(), SIGNER), + Error::::SignerAlreadyExists + ); + assert_eq!(SignerCount::::get(), 1); + }); +} + +#[test] +fn add_signer_should_fail_when_max_signers_reached() { + new_test_ext().execute_with(|| { + for i in 0..MAX_SIGNERS as u64 { + assert_ok!(Signet::add_signer(RuntimeOrigin::root(), 1000 + i)); + } + assert_eq!(SignerCount::::get(), MAX_SIGNERS); + + assert_noop!( + Signet::add_signer(RuntimeOrigin::root(), 9999), + Error::::TooManySigners + ); + assert!(!Signers::::contains_key(9999u64)); + }); +} + +#[test] +fn remove_signer_should_deauthorize_account_when_authorized() { + new_test_ext().execute_with(|| { + assert_ok!(Signet::add_signer(RuntimeOrigin::root(), SIGNER)); + + assert_ok!(Signet::remove_signer(RuntimeOrigin::root(), SIGNER)); + + assert!(!Signers::::contains_key(SIGNER)); + assert_eq!(SignerCount::::get(), 0); + System::assert_last_event(Event::SignerRemoved { who: SIGNER }.into()); + }); +} + +#[test] +fn remove_signer_should_fail_when_account_not_authorized() { + new_test_ext().execute_with(|| { + assert_noop!( + Signet::remove_signer(RuntimeOrigin::root(), SIGNER), + Error::::SignerNotFound + ); + }); +} + +#[test] +fn remove_signer_should_fail_when_origin_is_not_update_origin() { + new_test_ext().execute_with(|| { + assert_ok!(Signet::add_signer(RuntimeOrigin::root(), SIGNER)); + + assert_noop!( + Signet::remove_signer(RuntimeOrigin::signed(OUTSIDER), SIGNER), + DispatchError::BadOrigin + ); + assert!(Signers::::contains_key(SIGNER)); + }); +} + +#[test] +fn signer_count_should_track_additions_and_removals() { + new_test_ext().execute_with(|| { + assert_eq!(SignerCount::::get(), 0); + + assert_ok!(Signet::add_signer(RuntimeOrigin::root(), SIGNER)); + assert_ok!(Signet::add_signer(RuntimeOrigin::root(), OUTSIDER)); + assert_eq!(SignerCount::::get(), 2); + + assert_ok!(Signet::remove_signer(RuntimeOrigin::root(), SIGNER)); + assert_eq!(SignerCount::::get(), 1); + }); +} + +// ----------------------------------------------------------------------------- +// respond authorization +// ----------------------------------------------------------------------------- + +#[test] +fn respond_should_succeed_when_caller_is_authorized_signer() { + new_test_ext().execute_with(|| { + assert_ok!(Signet::add_signer(RuntimeOrigin::root(), SIGNER)); + let request_id = [7u8; 32]; + let signature = create_test_signature(); + + assert_ok!(Signet::respond( + RuntimeOrigin::signed(SIGNER), + bounded_array::<100>(vec![request_id]), + bounded_sig::<100>(vec![signature.clone()]) + )); + + System::assert_last_event( + Event::SignatureResponded { + request_id, + responder: SIGNER, + signature, + } + .into(), + ); + }); +} + +#[test] +fn respond_should_fail_when_caller_is_not_authorized() { + new_test_ext().execute_with(|| { + assert_noop!( + Signet::respond( + RuntimeOrigin::signed(OUTSIDER), + bounded_array::<100>(vec![[1u8; 32]]), + bounded_sig::<100>(vec![create_test_signature()]) + ), + Error::::NotAuthorizedSigner + ); + }); +} + +#[test] +fn respond_error_should_fail_when_caller_is_not_authorized() { + new_test_ext().execute_with(|| { + let error_response = ErrorResponse { + request_id: [1u8; 32], + error_message: bounded_u8::<1024>(b"boom".to_vec()), + }; + + assert_noop!( + Signet::respond_error( + RuntimeOrigin::signed(OUTSIDER), + bounded_err::<100>(vec![error_response]) + ), + Error::::NotAuthorizedSigner + ); + }); +} + +#[test] +fn respond_bidirectional_should_fail_when_caller_is_not_authorized() { + new_test_ext().execute_with(|| { + assert_noop!( + Signet::respond_bidirectional( + RuntimeOrigin::signed(OUTSIDER), + [1u8; 32], + bounded_u8::<65536>(b"out".to_vec()), + create_test_signature() + ), + Error::::NotAuthorizedSigner + ); + }); +} + +#[test] +fn respond_should_fail_when_signer_is_removed() { + new_test_ext().execute_with(|| { + assert_ok!(Signet::add_signer(RuntimeOrigin::root(), SIGNER)); + + assert_ok!(Signet::respond( + RuntimeOrigin::signed(SIGNER), + bounded_array::<100>(vec![[1u8; 32]]), + bounded_sig::<100>(vec![create_test_signature()]) + )); + + assert_ok!(Signet::remove_signer(RuntimeOrigin::root(), SIGNER)); + + assert_noop!( + Signet::respond( + RuntimeOrigin::signed(SIGNER), + bounded_array::<100>(vec![[2u8; 32]]), + bounded_sig::<100>(vec![create_test_signature()]) + ), + Error::::NotAuthorizedSigner + ); + }); +} + +// ----------------------------------------------------------------------------- +// feeless (Pays::No) annotation +// ----------------------------------------------------------------------------- + +#[test] +fn respond_should_be_feeless_when_called() { + new_test_ext().execute_with(|| { + let call = RuntimeCall::Signet(crate::Call::respond { + request_ids: bounded_array::<100>(vec![[1u8; 32]]), + signatures: bounded_sig::<100>(vec![create_test_signature()]), + }); + assert_eq!(call.get_dispatch_info().pays_fee, Pays::No); + }); +} + +#[test] +fn respond_error_should_be_feeless_when_called() { + new_test_ext().execute_with(|| { + let call = RuntimeCall::Signet(crate::Call::respond_error { + errors: bounded_err::<100>(vec![ErrorResponse { + request_id: [1u8; 32], + error_message: bounded_u8::<1024>(b"boom".to_vec()), + }]), + }); + assert_eq!(call.get_dispatch_info().pays_fee, Pays::No); + }); +} + +#[test] +fn respond_bidirectional_should_be_feeless_when_called() { + new_test_ext().execute_with(|| { + let call = RuntimeCall::Signet(crate::Call::respond_bidirectional { + request_id: [1u8; 32], + serialized_output: bounded_u8::<65536>(b"out".to_vec()), + signature: create_test_signature(), + }); + assert_eq!(call.get_dispatch_info().pays_fee, Pays::No); + }); +} + +// ----------------------------------------------------------------------------- +// admin calls remain fee-paying +// ----------------------------------------------------------------------------- + +#[test] +fn add_signer_should_pay_fee_when_called() { + new_test_ext().execute_with(|| { + let call = RuntimeCall::Signet(crate::Call::add_signer { who: SIGNER }); + assert_eq!(call.get_dispatch_info().pays_fee, Pays::Yes); + }); +} diff --git a/pallets/signet/src/tests/test_cases.rs b/pallets/signet/src/tests/test_cases.rs index 9f1baccfb..eed327940 100644 --- a/pallets/signet/src/tests/test_cases.rs +++ b/pallets/signet/src/tests/test_cases.rs @@ -50,6 +50,11 @@ fn fund_signet_pallet(amount: u128) -> u64 { pallet_account } +/// Authorize an account as a responder (signer). +fn authorize_signer(who: u64) { + assert_ok!(Signet::add_signer(RuntimeOrigin::root(), who)); +} + // ----------------------------------------------------------------------------- // set_config tests // ----------------------------------------------------------------------------- @@ -422,6 +427,7 @@ fn test_sign_bidirectional_empty_transaction_fails() { fn test_respond_single() { new_test_ext().execute_with(|| { let responder = REQUESTER; + authorize_signer(responder); let request_id = [99u8; 32]; let signature = create_test_signature(); @@ -446,6 +452,7 @@ fn test_respond_single() { fn test_respond_batch() { new_test_ext().execute_with(|| { let responder = REQUESTER; + authorize_signer(responder); let request_ids = vec![[1u8; 32], [2u8; 32], [3u8; 32]]; let signatures = vec![ create_test_signature(), @@ -472,6 +479,7 @@ fn test_respond_batch() { fn test_respond_mismatched_arrays_fails() { new_test_ext().execute_with(|| { let responder = REQUESTER; + authorize_signer(responder); assert_noop!( Signet::respond( @@ -492,6 +500,7 @@ fn test_respond_mismatched_arrays_fails() { fn test_respond_error_single() { new_test_ext().execute_with(|| { let responder = REQUESTER; + authorize_signer(responder); let error_response = ErrorResponse { request_id: [99u8; 32], error_message: bounded_u8::<1024>(b"Signature generation failed".to_vec()), @@ -517,6 +526,7 @@ fn test_respond_error_single() { fn test_respond_error_batch() { new_test_ext().execute_with(|| { let responder = REQUESTER; + authorize_signer(responder); let errors = vec![ ErrorResponse { request_id: [1u8; 32], @@ -546,6 +556,7 @@ fn test_respond_error_batch() { fn test_respond_bidirectional() { new_test_ext().execute_with(|| { let responder = REQUESTER; + authorize_signer(responder); let request_id = [99u8; 32]; let output = b"read_output_data".to_vec(); let signature = create_test_signature(); diff --git a/pallets/signet/src/types.rs b/pallets/signet/src/types.rs index 834d61851..a11fb33d9 100644 --- a/pallets/signet/src/types.rs +++ b/pallets/signet/src/types.rs @@ -8,6 +8,8 @@ pub trait WeightInfo { fn respond() -> Weight; fn respond_error() -> Weight; fn respond_bidirectional() -> Weight; + fn add_signer() -> Weight; + fn remove_signer() -> Weight; fn pause() -> Weight; fn unpause() -> Weight; } diff --git a/pallets/signet/src/weights.rs b/pallets/signet/src/weights.rs index 5f4011784..81a1f7dc8 100644 --- a/pallets/signet/src/weights.rs +++ b/pallets/signet/src/weights.rs @@ -62,16 +62,26 @@ impl crate::WeightInfo for WeightInfo { .saturating_add(T::DbWeight::get().writes(1)) } fn respond() -> Weight { - Weight::from_parts(217_000_000, 0) - .saturating_add(Weight::from_parts(0, 0)) + Weight::from_parts(217_000_000, 1517) + .saturating_add(T::DbWeight::get().reads(1)) } fn respond_error() -> Weight { - Weight::from_parts(296_000_000, 0) - .saturating_add(Weight::from_parts(0, 0)) + Weight::from_parts(296_000_000, 1517) + .saturating_add(T::DbWeight::get().reads(1)) } fn respond_bidirectional() -> Weight { - Weight::from_parts(36_000_000, 0) - .saturating_add(Weight::from_parts(0, 0)) + Weight::from_parts(36_000_000, 1517) + .saturating_add(T::DbWeight::get().reads(1)) + } + fn add_signer() -> Weight { + Weight::from_parts(15_000_000, 1517) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(2)) + } + fn remove_signer() -> Weight { + Weight::from_parts(15_000_000, 1517) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(2)) } fn pause() -> Weight { Weight::from_parts(9_000_000, 0) diff --git a/runtime/hydradx/Cargo.toml b/runtime/hydradx/Cargo.toml index dc597f6fc..24cad0a21 100644 --- a/runtime/hydradx/Cargo.toml +++ b/runtime/hydradx/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hydradx-runtime" -version = "428.0.0" +version = "429.0.0" authors = ["GalacticCouncil"] edition = "2021" license = "Apache 2.0" diff --git a/runtime/hydradx/src/lib.rs b/runtime/hydradx/src/lib.rs index ab85011d5..b32860526 100644 --- a/runtime/hydradx/src/lib.rs +++ b/runtime/hydradx/src/lib.rs @@ -129,7 +129,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: Cow::Borrowed("hydradx"), impl_name: Cow::Borrowed("hydradx"), authoring_version: 1, - spec_version: 428, + spec_version: 429, impl_version: 0, apis: RUNTIME_API_VERSIONS, transaction_version: 1, diff --git a/runtime/hydradx/src/weights/pallet_signet.rs b/runtime/hydradx/src/weights/pallet_signet.rs index 694556bf0..f9fbb14a5 100644 --- a/runtime/hydradx/src/weights/pallet_signet.rs +++ b/runtime/hydradx/src/weights/pallet_signet.rs @@ -76,25 +76,26 @@ impl pallet_signet::WeightInfo for HydraWeight { .saturating_add(T::DbWeight::get().writes(1_u64)) } fn respond() -> Weight { - // Proof Size summary in bytes: - // Measured: `0` - // Estimated: `0` - // Minimum execution time: 215_458_000 picoseconds. - Weight::from_parts(216_808_000, 0) + Weight::from_parts(216_808_000, 1517) + .saturating_add(T::DbWeight::get().reads(1_u64)) } fn respond_error() -> Weight { - // Proof Size summary in bytes: - // Measured: `0` - // Estimated: `0` - // Minimum execution time: 321_457_000 picoseconds. - Weight::from_parts(323_288_000, 0) + Weight::from_parts(323_288_000, 1517) + .saturating_add(T::DbWeight::get().reads(1_u64)) } fn respond_bidirectional() -> Weight { - // Proof Size summary in bytes: - // Measured: `0` - // Estimated: `0` - // Minimum execution time: 95_250_000 picoseconds. - Weight::from_parts(95_830_000, 0) + Weight::from_parts(95_830_000, 1517) + .saturating_add(T::DbWeight::get().reads(1_u64)) + } + fn add_signer() -> Weight { + Weight::from_parts(15_000_000, 1517) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + fn remove_signer() -> Weight { + Weight::from_parts(15_000_000, 1517) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) } fn pause() -> Weight { Weight::from_parts(13_044_000, 1517) diff --git a/scripts/signet-feeless-test/.gitignore b/scripts/signet-feeless-test/.gitignore new file mode 100644 index 000000000..ea0b6b754 --- /dev/null +++ b/scripts/signet-feeless-test/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +yarn.lock +package-lock.json diff --git a/scripts/signet-feeless-test/README.md b/scripts/signet-feeless-test/README.md new file mode 100644 index 000000000..d96dc88d8 --- /dev/null +++ b/scripts/signet-feeless-test/README.md @@ -0,0 +1,25 @@ +# signet-feeless-test + +Chopsticks e2e for the allowlist-gated, feeless `signet.respond` flow: an +authorized signer holding only the existential deposit (1 HDX) can call +`respond` without paying a fee, and a non-allowlisted account is rejected. + +Mirrors the pallet unit tests in `pallets/signet/src/tests/signer_allowlist.rs`. + +## Run + +Build the runtime wasm and install deps: + +```sh +cargo build --release -p hydradx-runtime +cd scripts/signet-feeless-test && yarn install +``` + +Then run (launches a Chopsticks fork of hydradx, runs the tests, tears down): + +```sh +./run.sh +``` + +`add_signer` is dispatched as Root via the scheduler on the fork; on a live +chain it goes through governance (`UpdateOrigin = EnsureRoot | TechCommittee`). diff --git a/scripts/signet-feeless-test/package.json b/scripts/signet-feeless-test/package.json new file mode 100644 index 000000000..d4c5f049b --- /dev/null +++ b/scripts/signet-feeless-test/package.json @@ -0,0 +1,28 @@ +{ + "name": "signet-feeless-test", + "version": "1.0.0", + "private": true, + "description": "Chopsticks e2e test: allowlist-gated, feeless signet.respond", + "scripts": { + "test": "jest --runInBand --forceExit" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "testMatch": ["**/*.test.ts"], + "testTimeout": 600000 + }, + "dependencies": { + "@polkadot/api": "latest", + "@polkadot/keyring": "latest", + "@polkadot/util": "latest", + "@polkadot/util-crypto": "latest" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^20.11.5", + "jest": "^30.1.3", + "ts-jest": "^29.4.4", + "typescript": "^5.3.3" + } +} diff --git a/scripts/signet-feeless-test/run.sh b/scripts/signet-feeless-test/run.sh new file mode 100755 index 000000000..6f1ff8d4f --- /dev/null +++ b/scripts/signet-feeless-test/run.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Launch a Chopsticks fork with the local runtime wasm, run the e2e suite, tear down. +# Prereqs: build the wasm + `yarn install` (see README). +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +WASM="$ROOT/target/release/wbuild/hydradx-runtime/hydradx_runtime.compact.compressed.wasm" +CHOPLOG="$(mktemp -t chopsticks-signet.XXXXXX.log)" +PORT=8000 + +if [ ! -f "$WASM" ]; then + echo "missing runtime wasm: $WASM" + echo "build it first: (cd $ROOT && cargo build --release -p hydradx-runtime)" + exit 1 +fi + +echo "[run] freeing port $PORT if held..." +lsof -ti "tcp:$PORT" 2>/dev/null | xargs -r kill -9 2>/dev/null || true + +echo "[run] launching chopsticks (fork hydradx mainnet, wasm-override, db-less)..." +( cd "$ROOT" && npx -y @acala-network/chopsticks@latest \ + --endpoint=wss://rpc.hydradx.cloud \ + --wasm-override "$WASM" \ + --mock-signature-host \ + --build-block-mode Manual \ + --port "$PORT" ) > "$CHOPLOG" 2>&1 & +CHOPID=$! +trap 'kill $CHOPID 2>/dev/null; lsof -ti "tcp:'"$PORT"'" 2>/dev/null | xargs -r kill -9 2>/dev/null; wait $CHOPID 2>/dev/null' EXIT + +echo "[run] waiting for chopsticks to listen (log: $CHOPLOG)..." +READY=0 +for i in $(seq 1 90); do + if grep -qi "listening" "$CHOPLOG" 2>/dev/null; then READY=1; break; fi + if ! kill -0 "$CHOPID" 2>/dev/null; then echo "[run] chopsticks exited early:"; tail -30 "$CHOPLOG"; exit 1; fi + sleep 2 +done +[ "$READY" = "1" ] || { echo "[run] chopsticks not ready; log:"; tail -30 "$CHOPLOG"; exit 1; } +echo "[run] chopsticks listening after ~$((i*2))s" + +echo "[run] running jest..." +cd "$HERE" +WS_URL="ws://localhost:$PORT" ./node_modules/.bin/jest --runInBand --forceExit --verbose diff --git a/scripts/signet-feeless-test/signet-feeless.test.ts b/scripts/signet-feeless-test/signet-feeless.test.ts new file mode 100644 index 000000000..fb68de6cf --- /dev/null +++ b/scripts/signet-feeless-test/signet-feeless.test.ts @@ -0,0 +1,79 @@ +import { ApiPromise, WsProvider } from '@polkadot/api'; +import { Keyring } from '@polkadot/keyring'; +import type { KeyringPair } from '@polkadot/keyring/types'; +import { + createApi, + ED, + executeAsRootViaScheduler, + findEvent, + freeBalance, + moduleErrorName, + setNativeBalance, + submitAndMine, +} from './utils'; + +// Arbitrary request id + dummy signature (not verified on-chain). +const REQUEST_ID = '0x' + '11'.repeat(32); +const SIGNATURE = { + bigR: { x: '0x' + '01'.repeat(32), y: '0x' + '02'.repeat(32) }, + s: '0x' + '03'.repeat(32), + recoveryId: 0, +}; + +describe('signet feeless respond (allowlist-gated)', () => { + let api: ApiPromise; + let provider: WsProvider; + let signer: KeyringPair; + let outsider: KeyringPair; + + beforeAll(async () => { + ({ api, provider } = await createApi()); + const keyring = new Keyring({ type: 'sr25519' }); + signer = keyring.addFromUri('//signetSigner'); + outsider = keyring.addFromUri('//signetOutsider'); + + await executeAsRootViaScheduler(api, provider, api.tx.signet.addSigner(signer.address)); + + // Fund both with exactly the existential deposit — no gas buffer. + await setNativeBalance(provider, signer.address, ED); + await setNativeBalance(provider, outsider.address, ED); + }, 600000); + + afterAll(async () => { + await api?.disconnect(); + }); + + it('add_signer should have authorized the signer', async () => { + const entry = await api.query.signet.signers(signer.address); + expect((entry as any).isSome).toBe(true); + }); + + it('respond should be annotated Pays::No (zero partial fee)', async () => { + const info = await api.tx.signet.respond([REQUEST_ID], [SIGNATURE]).paymentInfo(signer.address); + expect((info as any).partialFee.toBigInt()).toBe(0n); + }); + + it('respond should succeed for an ED-only signer and not charge a fee', async () => { + const before = await freeBalance(api, signer.address); + + const events = await submitAndMine(api, provider, api.tx.signet.respond([REQUEST_ID], [SIGNATURE]), signer); + + expect(findEvent(events, 'signet', 'SignatureResponded')).toBeDefined(); + expect(findEvent(events, 'system', 'ExtrinsicFailed')).toBeUndefined(); + + const after = await freeBalance(api, signer.address); + expect(after).toBe(before); + expect(after).toBe(ED); + }, 300000); + + it('respond should be rejected for a non-allowlisted account', async () => { + const events = await submitAndMine(api, provider, api.tx.signet.respond([REQUEST_ID], [SIGNATURE]), outsider); + + const failed = findEvent(events, 'system', 'ExtrinsicFailed'); + expect(failed).toBeDefined(); + + const err = moduleErrorName(api, failed); + expect(err.section).toBe('signet'); + expect(err.name).toBe('NotAuthorizedSigner'); + }, 300000); +}); diff --git a/scripts/signet-feeless-test/tsconfig.json b/scripts/signet-feeless-test/tsconfig.json new file mode 100644 index 000000000..72f4783bf --- /dev/null +++ b/scripts/signet-feeless-test/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["jest", "node"] + }, + "include": ["**/*.ts"] +} diff --git a/scripts/signet-feeless-test/utils.ts b/scripts/signet-feeless-test/utils.ts new file mode 100644 index 000000000..ad54da8de --- /dev/null +++ b/scripts/signet-feeless-test/utils.ts @@ -0,0 +1,68 @@ +import { ApiPromise, WsProvider } from '@polkadot/api'; + +export const ENDPOINT = process.env.WS_URL || 'ws://localhost:8000'; + +// HDX existential deposit (1 HDX, 12 decimals). +export const ED = 1_000_000_000_000n; + +export async function createApi(): Promise<{ api: ApiPromise; provider: WsProvider }> { + // Big request timeout: the first dev_newBlock on a fresh fork can exceed 60s. + const provider = new WsProvider(ENDPOINT, 2_500, {}, 600_000); + const api = await ApiPromise.create({ provider, throwOnConnect: false }); + await api.isReady; + return { api, provider }; +} + +export async function newBlock(provider: WsProvider, count = 1): Promise { + await provider.send('dev_newBlock', [{ count }]); +} + +// Dispatch `call` as Root via the scheduler (chopsticks dev_setStorage). +export async function executeAsRootViaScheduler(api: ApiPromise, provider: WsProvider, call: any): Promise { + const header = await api.rpc.chain.getHeader(); + const at = header.number.toNumber() + 1; + const callHex = call.method.toHex(); + + await provider.send('dev_setStorage', [ + { + Scheduler: { + Agenda: [[[at], [{ call: { Inline: callHex }, origin: { system: 'Root' } }]]], + }, + }, + ]); + await newBlock(provider); +} + +// Make the account exist (providers = 1) with exactly `free` balance. +export async function setNativeBalance(provider: WsProvider, address: string, free: bigint): Promise { + await provider.send('dev_setStorage', [ + { + System: { + Account: [[[address], { providers: 1, data: { free: free.toString() } }]], + }, + }, + ]); +} + +export async function freeBalance(api: ApiPromise, address: string): Promise { + const acc = await api.query.system.account(address); + return (acc as any).data.free.toBigInt(); +} + +export async function submitAndMine(api: ApiPromise, provider: WsProvider, tx: any, signer: any): Promise { + await tx.signAndSend(signer); + await newBlock(provider); + const blockHash = await api.rpc.chain.getBlockHash(); + const events = await api.query.system.events.at(blockHash); + return (events as any).toArray(); +} + +export function findEvent(events: any[], section: string, method: string): any | undefined { + return events.find((r) => r.event.section === section && r.event.method === method); +} + +export function moduleErrorName(api: ApiPromise, failedEvent: any): { section: string; name: string } { + const dispatchError = failedEvent.event.data[0]; + const meta = api.registry.findMetaError(dispatchError.asModule); + return { section: meta.section, name: meta.name }; +}