diff --git a/.gitignore b/.gitignore index 78e9b56a9..48f9f9bf5 100644 --- a/.gitignore +++ b/.gitignore @@ -57,7 +57,6 @@ zombienet /pallets/dispenser/contracts/lib /pallets/dispenser/contracts/cache /pallets/dispenser/contracts/out - -/scripts/dispenser-tests/solana-signet-program/.github +/scripts/dispenser-tests/solana-signet-program findings/ diff --git a/Cargo.lock b/Cargo.lock index 13136477e..8b6171526 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10246,7 +10246,7 @@ dependencies = [ [[package]] name = "pallet-dispenser" -version = "0.3.0" +version = "0.4.0" dependencies = [ "alloy-primitives 0.7.7", "alloy-sol-types 0.7.7", @@ -11849,7 +11849,7 @@ dependencies = [ [[package]] name = "pallet-signet" -version = "1.2.0" +version = "1.3.0" dependencies = [ "ethereum", "frame-benchmarking", diff --git a/pallets/dispenser/Cargo.toml b/pallets/dispenser/Cargo.toml index 48fe2c55d..8671ddfca 100644 --- a/pallets/dispenser/Cargo.toml +++ b/pallets/dispenser/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-dispenser" -version = "0.3.0" +version = "0.4.0" edition = "2021" [package.metadata.docs.rs] @@ -27,7 +27,7 @@ pallet-signet = { path = "../signet", default-features = false } pallet-currencies = { workspace = true, default-features = false } frame-benchmarking = { workspace = true, optional = true } log = { version = "0.4", default-features = false } -pallet-asset-registry = { workspace = true } +pallet-asset-registry = { workspace = true, default-features = false } borsh = { version = "1.5", default-features = false, features = ["derive", "hashbrown"] } [dev-dependencies] @@ -58,6 +58,7 @@ std = [ "serde_json/std", "pallet-currencies/std", "pallet-signet/std", + "pallet-asset-registry/std", "hex/std", ] runtime-benchmarks = [ diff --git a/pallets/dispenser/src/benchmarking.rs b/pallets/dispenser/src/benchmarking.rs index 799136f95..6c525977a 100644 --- a/pallets/dispenser/src/benchmarking.rs +++ b/pallets/dispenser/src/benchmarking.rs @@ -6,11 +6,6 @@ use frame_support::assert_ok; use frame_system::RawOrigin; use sp_runtime::traits::AccountIdConversion; -fn bench_chain_id() -> BoundedVec::MaxChainIdLength> { - let v: Vec = b"bench-chain".to_vec(); - BoundedVec::try_from(v).expect("bench-chain fits MaxChainIdLength") -} - #[benchmarks(where T: Config)] mod benches { use super::*; @@ -19,17 +14,36 @@ mod benches { use core::ops::{Add, Mul}; use frame_support::traits::Currency; + fn test_config_data() -> DispenserConfigData { + DispenserConfigData { + paused: false, + faucet_balance_wei: (u64::MAX - 1) as u128, + faucet_address: EvmAddress::from([1u8; 20]), + min_faucet_threshold: 1, + min_request: 100, + max_dispense: 1_000_000_000, + dispenser_fee: 10, + } + } + #[benchmark] - fn set_faucet_balance() { - DispenserConfig::::put(DispenserConfigData { paused: false }); + fn set_config() { #[extrinsic_call] - set_faucet_balance(RawOrigin::Root, 123u128); - assert_eq!(FaucetBalanceWei::::get(), 123u128); + set_config( + RawOrigin::Root, + EvmAddress::from([1u8; 20]), + 1u128, + 100u128, + 1_000_000_000u128, + 10u128, + 1_000_000_000_000u128, + ); + assert!(DispenserConfig::::get().is_some()); } #[benchmark] fn pause() { - DispenserConfig::::put(DispenserConfigData { paused: false }); + DispenserConfig::::put(test_config_data()); #[extrinsic_call] pause(RawOrigin::Root); @@ -39,7 +53,9 @@ mod benches { #[benchmark] fn unpause() { - DispenserConfig::::put(DispenserConfigData { paused: true }); + let mut cfg = test_config_data(); + cfg.paused = true; + DispenserConfig::::put(cfg); #[extrinsic_call] unpause(RawOrigin::Root); @@ -50,7 +66,6 @@ mod benches { #[benchmark] fn request_fund() { let signet_admin: T::AccountId = whitelisted_caller(); - let chain_id = super::bench_chain_id::(); let pallet_account: T::AccountId = Pallet::::account_id(); let signet_pallet_account: T::AccountId = @@ -59,24 +74,25 @@ mod benches { let fee_asset = T::FeeAsset::get(); let faucet_asset = T::FaucetAsset::get(); - ::Currency::set_balance(fee_asset, &signet_admin, 340266920938463463374607431768211455); - ::Currency::set_balance( - faucet_asset, - &signet_admin, - 340282366920938463463374607431768211455, - ); - ::Currency::set_balance(fee_asset, &pallet_account, 340266920938463463374607431768211455); - ::Currency::set_balance( - faucet_asset, - &pallet_account, - 340282366920938463463374607431768211455, - ); + // Register assets in the registry so mint_into works in the real runtime. + assert_ok!(T::BenchmarkHelper::register_asset(fee_asset, 1)); + assert_ok!(T::BenchmarkHelper::register_asset(faucet_asset, 1)); + + let large_balance: Balance = 340_266_920_938_463_463_374_607_431_768_211_455; + assert_ok!(T::BenchmarkHelper::mint(fee_asset, &signet_admin, large_balance)); + assert_ok!(T::BenchmarkHelper::mint(faucet_asset, &signet_admin, large_balance)); + assert_ok!(T::BenchmarkHelper::mint(fee_asset, &pallet_account, large_balance)); + assert_ok!(T::BenchmarkHelper::mint(faucet_asset, &pallet_account, large_balance)); let ed_native: BalanceOf = ::Currency::minimum_balance(); - assert_ok!(pallet_signet::Pallet::::initialize( + let chain_id: BoundedVec> = + BoundedVec::try_from(b"bench-chain".to_vec()).expect("bench-chain fits"); + + assert_ok!(pallet_signet::Pallet::::set_config( RawOrigin::Root.into(), - signet_admin, ed_native, + 128u32, + 100_000u32, chain_id, )); @@ -84,11 +100,8 @@ mod benches { let _ = ::Currency::deposit_creating(&pallet_account, requester_needed); let _ = ::Currency::deposit_creating(&signet_pallet_account, requester_needed); - let current_faucet_bal: u128 = (u64::MAX - 1) as u128; - assert_ok!(Pallet::::set_faucet_balance( - RawOrigin::Root.into(), - current_faucet_bal - )); + // Set dispenser config with a large faucet balance + DispenserConfig::::put(test_config_data()); let caller: T::AccountId = whitelisted_caller(); @@ -109,10 +122,10 @@ mod benches { amount: U256::from(amount), }; - let faucet_addr = T::FaucetAddress::get(); + let config = DispenserConfig::::get().expect("config must be set"); let rlp = pallet_signet::Pallet::::build_evm_tx( RawOrigin::Signed(caller.clone()).into(), - Some(faucet_addr), + Some(config.faucet_address), 0u128, call.abi_encode(), tx.nonce, @@ -124,12 +137,7 @@ mod benches { ) .expect("build_evm_tx ok in benchmark"); - let path_bytes: Vec = { - let enc = caller.encode(); - let mut s = String::from("0x"); - s.push_str(&hex::encode(enc)); - s.into_bytes() - }; + let path = SIGNING_PATH.to_vec(); // CAIP-2 chain ID format let caip2_id = alloc::format!("eip155:{}", tx.chain_id); @@ -139,11 +147,12 @@ mod benches { &rlp, &caip2_id, 0, - &path_bytes, + &path, b"ecdsa", b"ethereum", b"", - ); + ) + .expect("generate_request_id ok in benchmark"); #[extrinsic_call] request_fund(RawOrigin::Signed(caller), to, amount, req_id, tx); diff --git a/pallets/dispenser/src/lib.rs b/pallets/dispenser/src/lib.rs index 07be3e2e0..9b89c19e2 100644 --- a/pallets/dispenser/src/lib.rs +++ b/pallets/dispenser/src/lib.rs @@ -76,22 +76,14 @@ pub mod pallet { /// Pallet configuration trait. #[pallet::config] - pub trait Config: frame_system::Config + pallet_signet::Config { + pub trait Config: frame_system::Config>> + pallet_signet::Config { + /// Origin that is allowed to call administrative extrinsics + /// (set_config, pause, unpause). + type UpdateOrigin: EnsureOrigin; + /// Multi-asset fungible currency implementation used for fees and faucet tokens. type Currency: Mutate; - /// Minimum amount of faucet asset that can be requested in a single call. - #[pallet::constant] - type MinimumRequestAmount: Get; - - /// Maximum amount of faucet asset that can be requested in a single call. - #[pallet::constant] - type MaxDispenseAmount: Get; - - /// Flat fee charged in `FeeAsset` for each faucet request. - #[pallet::constant] - type DispenserFee: Get; - /// Asset ID used to charge the faucet request fee. /// (HDX - 0) #[pallet::constant] @@ -106,47 +98,44 @@ pub mod pallet { #[pallet::constant] type FeeDestination: Get; - /// EVM address of the external gas faucet contract. - #[pallet::constant] - type FaucetAddress: Get; - /// Pallet ID used to derive the pallet's sovereign account. #[pallet::constant] type PalletId: Get; - /// Minimum remaining ETH (in wei) that must be available in the faucet - /// after servicing a request. Requests are rejected if this threshold - /// would be breached. - #[pallet::constant] - type MinFaucetEthThreshold: Get; - /// Weight information provider for extrinsics of this pallet. type WeightInfo: crate::WeightInfo; + + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper: crate::BenchmarkHelper; } /*************************** STORAGE ***************************/ /// Global configuration for the dispenser. /// - /// Currently only tracks whether the pallet is paused. If `None`, defaults - /// to unpaused. + /// If `None`, the pallet has not been configured and cannot process requests. #[pallet::storage] #[pallet::getter(fn dispenser_config)] pub type DispenserConfig = StorageValue<_, DispenserConfigData, OptionQuery>; - /// Tracked ETH balance (in wei) currently available in the external faucet. - /// - /// This value is updated manually via governance and is used as a guardrail - /// to prevent issuing requests that would over-spend the faucet. - #[pallet::storage] - #[pallet::getter(fn current_faucet_balance_wei)] - pub type FaucetBalanceWei = StorageValue<_, Balance, ValueQuery>; - /// Dispenser configuration data. #[derive(Encode, Decode, TypeInfo, Clone, Debug, PartialEq, MaxEncodedLen)] pub struct DispenserConfigData { /// If `true`, all user-facing requests are blocked. pub paused: bool, + /// Tracked ETH balance (in wei) currently available in the external faucet. + pub faucet_balance_wei: Balance, + /// EVM address of the external gas faucet contract. + pub faucet_address: EvmAddress, + /// Minimum remaining ETH (in wei) that must be available in the faucet + /// after servicing a request. + pub min_faucet_threshold: Balance, + /// Minimum amount of faucet asset that can be requested in a single call. + pub min_request: Balance, + /// Maximum amount of faucet asset that can be requested in a single call. + pub max_dispense: Balance, + /// Flat fee charged in `FeeAsset` for each faucet request. + pub dispenser_fee: Balance, } /// Request IDs that have already been used. @@ -159,6 +148,15 @@ pub mod pallet { #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { + /// Dispenser configuration has been set or updated. + ConfigUpdated { + faucet_address: EvmAddress, + min_faucet_threshold: Balance, + min_request: Balance, + max_dispense: Balance, + dispenser_fee: Balance, + faucet_balance_wei: Balance, + }, /// Dispenser has been paused. No new requests will be accepted. Paused, /// Dispenser has been unpaused. New requests are allowed again. @@ -177,18 +175,13 @@ pub mod pallet { /// Requested amount of ETH (in wei). amount: Balance, }, - /// Tracked faucet ETH balance has been updated. - FaucetBalanceUpdated { - /// Previous tracked balance (in wei). - old_balance_wei: Balance, - /// New tracked balance (in wei). - new_balance_wei: Balance, - }, } /// Pallet errors. #[pallet::error] pub enum Error { + /// The pallet has not been configured yet. + NotConfigured, /// Request ID has already been used. DuplicateRequest, /// Failed to (de)serialize data. @@ -211,6 +204,8 @@ pub mod pallet { NotEnoughFeeFunds, /// Caller does not have enough balance of the faucet asset. NotEnoughFaucetFunds, + /// Configuration parameters are invalid (e.g., min_request > max_dispense). + InvalidConfig, } /// Dispatchable functions. @@ -218,17 +213,7 @@ pub mod pallet { impl Pallet { /// Request ETH from the external faucet for a given EVM address. /// - /// This call: - /// - Verifies amount bounds and EVM transaction parameters. - /// - Checks the tracked faucet ETH balance against `MinFaucetEthThreshold`. - /// - Charges the configured fee in `FeeAsset`. - /// - Transfers the requested faucet asset from the user to `FeeDestination`. - /// - Builds an EVM transaction calling `IGasFaucet::fund`. - /// - Submits a signing request to SigNet via `pallet_signet::sign_bidirectional`. - /// - /// The `request_id` must match the ID derived internally from the inputs, - /// otherwise the call will fail with `InvalidRequestId`. - /// Parameters: + /// Parameters: /// - `to`: Target EVM address to receive ETH. /// - `amount`: Amount of ETH (in wei) to request. /// - `request_id`: Client-supplied request ID; must match derived ID. @@ -245,20 +230,24 @@ pub mod pallet { let requester = ensure_signed(origin)?; let pallet_acc = Self::account_id(); - // Pallet must not be paused. - Self::ensure_not_paused()?; + // Pallet must be configured and not paused. + let config = DispenserConfig::::get().ok_or(Error::::NotConfigured)?; + ensure!(!config.paused, Error::::Paused); // Basic validation of parameters. ensure!(to != EvmAddress::zero(), Error::::InvalidAddress); - ensure!(amount >= T::MinimumRequestAmount::get(), Error::::AmountTooSmall); - ensure!(amount <= T::MaxDispenseAmount::get(), Error::::AmountTooLarge); + ensure!(amount >= config.min_request, Error::::AmountTooSmall); + ensure!(amount <= config.max_dispense, Error::::AmountTooLarge); // Check tracked faucet balance vs. threshold. - let observed = FaucetBalanceWei::::get(); - let needed = T::MinFaucetEthThreshold::get() + let needed = config + .min_faucet_threshold .checked_add(amount) .ok_or(Error::::InvalidOutput)?; - ensure!(observed >= needed, Error::::FaucetBalanceBelowThreshold); + ensure!( + config.faucet_balance_wei >= needed, + Error::::FaucetBalanceBelowThreshold + ); // EIP-1559 fee sanity checks. ensure!(tx.gas_limit > 0, Error::::InvalidOutput); @@ -276,7 +265,7 @@ pub mod pallet { // Build EVM transaction bytes using pallet_signet helper. let rlp = pallet_signet::Pallet::::build_evm_tx( frame_system::RawOrigin::Signed(requester.clone()).into(), - Some(T::FaucetAddress::get()), + Some(config.faucet_address), 0u128, call.abi_encode(), tx.nonce, @@ -287,16 +276,14 @@ pub mod pallet { tx.chain_id, )?; - // Construct signing path used by SigNet. - let mut path = Vec::with_capacity(2 + requester.encoded_size() * 2); - path.extend_from_slice(b"0x"); - path.extend_from_slice(hex::encode(requester.encode()).as_bytes()); + // Fixed signing path — all requests derive the same MPC key. + let path = SIGNING_PATH.to_vec(); // CAIP-2 chain ID (e.g., "eip155:1" for Ethereum mainnet) let caip2_id = alloc::format!("eip155:{}", tx.chain_id); // Derive canonical request ID and compare with user-supplied one. - let req_id = Self::generate_request_id(&pallet_acc, &rlp, &caip2_id, 0, &path, ECDSA, ETHEREUM, b""); + let req_id = Self::generate_request_id(&pallet_acc, &rlp, &caip2_id, 0, &path, ECDSA, ETHEREUM, b"")?; ensure!(req_id == request_id, Error::::InvalidRequestId); ensure!( @@ -305,7 +292,7 @@ pub mod pallet { ); // Check balances for fee and faucet asset. - let fee = T::DispenserFee::get(); + let fee = config.dispenser_fee; let fee_bal = ::Currency::balance(T::FeeAsset::get(), &requester); let faucet_bal = ::Currency::balance(T::FaucetAsset::get(), &requester); ensure!(fee_bal >= fee, Error::::NotEnoughFeeFunds); @@ -349,7 +336,11 @@ pub mod pallet { // Mark request ID as used and update tracked faucet balance. UsedRequestIds::::insert(request_id, ()); - FaucetBalanceWei::::mutate(|b| *b = b.saturating_sub(amount)); + DispenserConfig::::mutate(|c| { + if let Some(cfg) = c.as_mut() { + cfg.faucet_balance_wei = cfg.faucet_balance_wei.saturating_sub(amount); + } + }); Self::deposit_event(Event::FundRequested { request_id: req_id, @@ -361,6 +352,60 @@ pub mod pallet { Ok(()) } + /// Set or update the dispenser configuration. + /// + /// On first call, the pallet starts unpaused. On subsequent calls, + /// `paused` state is preserved. + /// + /// Parameters: + /// - `origin`: Must satisfy `UpdateOrigin`. + /// - `faucet_address`: EVM address of the external gas faucet contract. + /// - `min_faucet_threshold`: Minimum remaining ETH (wei) after a request. + /// - `min_request`: Minimum request amount. + /// - `max_dispense`: Maximum request amount. + /// - `dispenser_fee`: Flat fee in `FeeAsset` per request. + /// - `faucet_balance_wei`: Tracked faucet ETH balance (in wei). + #[pallet::call_index(1)] + #[pallet::weight(::WeightInfo::set_config())] + pub fn set_config( + origin: OriginFor, + faucet_address: EvmAddress, + min_faucet_threshold: Balance, + min_request: Balance, + max_dispense: Balance, + dispenser_fee: Balance, + faucet_balance_wei: Balance, + ) -> DispatchResult { + ::UpdateOrigin::ensure_origin(origin)?; + + ensure!(faucet_address != EvmAddress::zero(), Error::::InvalidAddress); + ensure!(max_dispense > 0, Error::::InvalidConfig); + ensure!(min_request <= max_dispense, Error::::InvalidConfig); + + let paused = DispenserConfig::::get().map(|c| c.paused).unwrap_or(false); + + DispenserConfig::::put(DispenserConfigData { + paused, + faucet_balance_wei, + faucet_address, + min_faucet_threshold, + min_request, + max_dispense, + dispenser_fee, + }); + + Self::deposit_event(Event::ConfigUpdated { + faucet_address, + min_faucet_threshold, + min_request, + max_dispense, + dispenser_fee, + faucet_balance_wei, + }); + + Ok(()) + } + /// Pause the dispenser so that no new funding requests can be made. /// /// Parameters: @@ -368,12 +413,12 @@ pub mod pallet { #[pallet::call_index(2)] #[pallet::weight(::WeightInfo::pause())] pub fn pause(origin: OriginFor) -> DispatchResult { - T::UpdateOrigin::ensure_origin(origin)?; - if DispenserConfig::::get().is_none() { - DispenserConfig::::put(DispenserConfigData { paused: true }); - } else { - DispenserConfig::::mutate_exists(|p| p.as_mut().unwrap().paused = true); - }; + ::UpdateOrigin::ensure_origin(origin)?; + DispenserConfig::::mutate(|maybe| -> DispatchResult { + let cfg = maybe.as_mut().ok_or(Error::::NotConfigured)?; + cfg.paused = true; + Ok(()) + })?; Self::deposit_event(Event::Paused); Ok(()) @@ -386,35 +431,14 @@ pub mod pallet { #[pallet::call_index(3)] #[pallet::weight(::WeightInfo::unpause())] pub fn unpause(origin: OriginFor) -> DispatchResult { - T::UpdateOrigin::ensure_origin(origin)?; - if DispenserConfig::::get().is_none() { - DispenserConfig::::put(DispenserConfigData { paused: false }); - } else { - DispenserConfig::::mutate_exists(|p| p.as_mut().unwrap().paused = false); - }; - Self::deposit_event(Event::Unpaused); - Ok(()) - } + ::UpdateOrigin::ensure_origin(origin)?; + DispenserConfig::::mutate(|maybe| -> DispatchResult { + let cfg = maybe.as_mut().ok_or(Error::::NotConfigured)?; + cfg.paused = false; + Ok(()) + })?; - /// Increase the tracked faucet ETH balance (in wei). - /// - /// This is an accounting helper used to keep `FaucetBalanceWei` - /// roughly in sync with the real faucet balance on the EVM chain. - /// - /// Parameters: - /// - `origin`: Must satisfy `UpdateOrigin`. - /// - `balance_wei`: Amount (in wei) to add to the currently stored balance. - #[pallet::call_index(4)] - #[pallet::weight(::WeightInfo::set_faucet_balance())] - pub fn set_faucet_balance(origin: OriginFor, balance_wei: Balance) -> DispatchResult { - T::UpdateOrigin::ensure_origin(origin)?; - let old = FaucetBalanceWei::::get(); - let new_balance = old + balance_wei; - FaucetBalanceWei::::put(new_balance); - Self::deposit_event(Event::FaucetBalanceUpdated { - old_balance_wei: old, - new_balance_wei: new_balance, - }); + Self::deposit_event(Event::Unpaused); Ok(()) } } @@ -423,15 +447,6 @@ pub mod pallet { impl Pallet { /// Derive a deterministic request ID from the given parameters. - /// - /// The ID is computed as: - /// - Encode `(sender_ss58, transaction_data, caip2_id, key_version, - /// path_str, algo_str, dest_str, params_str)` using Solidity's - /// `abi_encode_packed`. - /// - Apply `keccak256` to the result. - /// - /// This mirrors the off-chain logic used by SigNet clients and prevents - /// clients from supplying arbitrary request IDs. #[allow(clippy::too_many_arguments)] pub fn generate_request_id( sender: &T::AccountId, @@ -442,7 +457,7 @@ pub mod pallet { algo: &[u8], dest: &[u8], params: &[u8], - ) -> Bytes32 { + ) -> Result { use alloy_sol_types::SolValue; use sp_core::crypto::Ss58Codec; @@ -454,40 +469,29 @@ pub mod pallet { let account_id32 = sp_runtime::AccountId32::from(account_bytes); let sender_ss58 = account_id32.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(0)); + let path_str = core::str::from_utf8(path).map_err(|_| Error::::Serialization)?; + let algo_str = core::str::from_utf8(algo).map_err(|_| Error::::Serialization)?; + let dest_str = core::str::from_utf8(dest).map_err(|_| Error::::Serialization)?; + let params_str = core::str::from_utf8(params).map_err(|_| Error::::Serialization)?; + let encoded = ( sender_ss58.as_str(), transaction_data, caip2_id, key_version, - core::str::from_utf8(path).unwrap_or(""), - core::str::from_utf8(algo).unwrap_or(""), - core::str::from_utf8(dest).unwrap_or(""), - core::str::from_utf8(params).unwrap_or(""), + path_str, + algo_str, + dest_str, + params_str, ) .abi_encode_packed(); - sp_io::hashing::keccak_256(&encoded) + Ok(sp_io::hashing::keccak_256(&encoded)) } - } - impl Pallet { /// Returns the pallet's sovereign account ID. - /// - /// This account is derived from `PalletId` and is used as the logical - /// owner of outbound EVM transactions and SigNet requests. pub fn account_id() -> T::AccountId { ::PalletId::get().into_account_truncating() } - - /// Ensures that the dispenser is not paused. - /// - /// Returns `Ok(())` if the dispenser is active, otherwise `Error::Paused`. - #[inline] - fn ensure_not_paused() -> Result<(), Error> { - match DispenserConfig::::get() { - Some(DispenserConfigData { paused: true, .. }) => Err(Error::::Paused), - _ => Ok(()), - } - } } } diff --git a/pallets/dispenser/src/tests/mod.rs b/pallets/dispenser/src/tests/mod.rs index 28203b3f5..9a9cde652 100644 --- a/pallets/dispenser/src/tests/mod.rs +++ b/pallets/dispenser/src/tests/mod.rs @@ -25,6 +25,14 @@ pub type Amount = i128; pub const HDX: AssetId = 0; pub const MIN_WEI_BALANCE: u128 = 1_000_000_000_000_000_000_000; +pub const TEST_DISPENSER_FEE: u128 = 10; +pub const TEST_MAX_DISPENSE: u128 = 1_000_000_000; +pub const TEST_MIN_REQUEST: u128 = 100; +pub const TEST_MIN_FAUCET_THRESHOLD: u128 = 1; + +pub fn test_faucet_address() -> primitives::EvmAddress { + primitives::EvmAddress::from(hex!("3c44CdDdB6a900fa2b585dd299e03d12FA4293BC")) +} frame_support::construct_runtime!( pub enum Test { @@ -82,11 +90,10 @@ parameter_type_with_key! { parameter_types! { pub const SignetPalletId: PalletId = PalletId(*b"py/signt"); - pub const MaxChainIdLength: u32 = 128; pub const MaxReserves: u32 = 50; pub const ExistentialDeposit: u128 = 1; pub const HDXAssetId: AssetId = HDX; - pub const TreasuryPalletId: PalletId = PalletId(*b"py/treas"); + pub const TreasuryPalletId: PalletId = PalletId(*b"py/treas"); } impl pallet_balances::Config for Test { @@ -140,51 +147,29 @@ impl frame_system::offchain::SigningTypes for Test { type Signature = MultiSignature; } -parameter_types! { - pub const MaxDataLength: u32 = 1024; - pub const MaxSignatureDeposit: u128 = 100_000_000_000; -} - impl pallet_signet::Config for Test { type Currency = Balances; type PalletId = SignetPalletId; - type MaxChainIdLength = MaxChainIdLength; type WeightInfo = pallet_signet::weights::WeightInfo; - type MaxDataLength = MaxDataLength; type UpdateOrigin = frame_system::EnsureRoot; - type MaxSignatureDeposit = MaxSignatureDeposit; } parameter_types! { pub const DispenserPalletId: PalletId = PalletId(*b"py/erc20"); - pub const SigEthFaucetDispenserFee: u128 = 10; - pub const SigEthFaucetMaxDispense: u128 = 1_000_000_000; - pub const SigEthFaucetMinRequest: u128 = 100; pub const SigEthFaucetFeeAssetId: AssetId = 0; pub const SigEthFaucetFaucetAssetId: AssetId = 20; - pub const SigEthMinFaucetThreshold: u128 = 1; -} - -pub struct SigEthFaucetMpcRoot; -impl frame_support::traits::Get for SigEthFaucetMpcRoot { - fn get() -> primitives::EvmAddress { - // 0x3c44CdDdB6a900fa2b585dd299e03d12FA4293BC - primitives::EvmAddress::from(hex!("3c44CdDdB6a900fa2b585dd299e03d12FA4293BC")) - } } impl pallet_dispenser::Config for Test { + type UpdateOrigin = frame_system::EnsureRoot; type PalletId = DispenserPalletId; type Currency = FungibleCurrencies; - type MinimumRequestAmount = SigEthFaucetMinRequest; - type MaxDispenseAmount = SigEthFaucetMaxDispense; - type DispenserFee = SigEthFaucetDispenserFee; type FeeAsset = SigEthFaucetFeeAssetId; type FaucetAsset = SigEthFaucetFaucetAssetId; type FeeDestination = TreasuryAccount; - type FaucetAddress = SigEthFaucetMpcRoot; - type MinFaucetEthThreshold = SigEthMinFaucetThreshold; type WeightInfo = crate::weights::WeightInfo; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = (); } pub fn new_test_ext() -> sp_io::TestExternalities { @@ -210,17 +195,25 @@ pub fn new_test_ext() -> sp_io::TestExternalities { let _ = Currencies::deposit(faucet_asset, alice, initial_balance); let _ = Currencies::deposit(faucet_asset, bob, initial_balance); let _ = Currencies::deposit(faucet_asset, charlie, initial_balance); - let requester = acct(1); - assert_ok!(pallet_signet::Pallet::::initialize( + assert_ok!(pallet_signet::Pallet::::set_config( RuntimeOrigin::root(), - requester, 100_000_000, + 128, + 100_000, bounded_chain_id(b"test-chain".to_vec()), )); let pallet_account = Dispenser::account_id(); let _ = >::deposit_creating(&pallet_account, 10_000); - assert_ok!(Dispenser::set_faucet_balance(RuntimeOrigin::root(), MIN_WEI_BALANCE)); + assert_ok!(Dispenser::set_config( + RuntimeOrigin::root(), + test_faucet_address(), + TEST_MIN_FAUCET_THRESHOLD, + TEST_MIN_REQUEST, + TEST_MAX_DISPENSE, + TEST_DISPENSER_FEE, + MIN_WEI_BALANCE, + )); }); ext } diff --git a/pallets/dispenser/src/tests/test_cases.rs b/pallets/dispenser/src/tests/test_cases.rs index 355d8e1a6..b0a93134d 100644 --- a/pallets/dispenser/src/tests/test_cases.rs +++ b/pallets/dispenser/src/tests/test_cases.rs @@ -1,14 +1,16 @@ use crate::{self as pallet_dispenser}; use crate::{ tests::{ - new_test_ext, + new_test_ext, test_faucet_address, utils::{acct, compute_request_id, create_test_receiver_address, create_test_tx_params}, - Currencies, Dispenser, RuntimeEvent, RuntimeOrigin, System, Test, MIN_WEI_BALANCE, + Currencies, Dispenser, RuntimeEvent, RuntimeOrigin, System, Test, MIN_WEI_BALANCE, TEST_DISPENSER_FEE, + TEST_MAX_DISPENSE, TEST_MIN_FAUCET_THRESHOLD, TEST_MIN_REQUEST, }, - Error, Event, FaucetBalanceWei, + Error, Event, }; use frame_support::{assert_noop, assert_ok}; use orml_traits::MultiCurrency; +use sp_runtime::BuildStorage; #[test] fn test_request_rejected_when_paused() { @@ -34,6 +36,24 @@ fn test_request_rejected_when_paused() { }); } +#[test] +fn test_request_rejected_when_not_configured() { + let t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| { + frame_system::Pallet::::set_block_number(1); + let requester = acct(1); + let receiver = create_test_receiver_address(); + let amount = 1_000u128; + let tx = create_test_tx_params(); + + assert_noop!( + Dispenser::request_fund(RuntimeOrigin::signed(requester), receiver, amount, [0u8; 32], tx), + Error::::NotConfigured + ); + }); +} + #[test] fn test_invalid_request_id_reverts_balances() { new_test_ext().execute_with(|| { @@ -71,7 +91,8 @@ fn test_fee_and_asset_routing() { let tx = create_test_tx_params(); let req_id = compute_request_id(requester.clone(), receiver, amount, &tx); - let fee = ::DispenserFee::get(); + let config = Dispenser::dispenser_config().unwrap(); + let fee = config.dispenser_fee; let treasury = ::FeeDestination::get(); let pallet_account = Dispenser::account_id(); @@ -127,7 +148,7 @@ fn test_amount_too_small_and_too_large() { let receiver = create_test_receiver_address(); let tx = create_test_tx_params(); - let amt_small = ::MinimumRequestAmount::get() - 1; + let amt_small = TEST_MIN_REQUEST - 1; let rid_small = compute_request_id(requester.clone(), receiver, amt_small, &tx); assert_noop!( Dispenser::request_fund( @@ -140,7 +161,7 @@ fn test_amount_too_small_and_too_large() { Error::::AmountTooSmall ); - let amt_big = ::MaxDispenseAmount::get() + 1; + let amt_big = TEST_MAX_DISPENSE + 1; let rid_big = compute_request_id(requester.clone(), receiver, amt_big, &tx); assert_noop!( Dispenser::request_fund(RuntimeOrigin::signed(requester), receiver, amt_big, rid_big, tx), @@ -195,9 +216,10 @@ fn test_deposit_erc20_success() { ) })); + let config = Dispenser::dispenser_config().unwrap(); assert_eq!( Currencies::free_balance(fee_asset, &requester), - hdx_balance_before - ::DispenserFee::get() + hdx_balance_before - config.dispenser_fee ); assert_eq!( @@ -208,32 +230,68 @@ fn test_deposit_erc20_success() { } #[test] -fn governance_sets_faucet_balance_and_emits_event() { +fn test_set_config_works() { new_test_ext().execute_with(|| { - let old = Dispenser::current_faucet_balance_wei(); - assert_ok!(Dispenser::set_faucet_balance(RuntimeOrigin::root(), 42u128)); - assert_eq!(Dispenser::current_faucet_balance_wei(), MIN_WEI_BALANCE + 42u128); - - let ev = System::events().into_iter().any(|rec| { - matches!(rec.event, - RuntimeEvent::Dispenser(Event::FaucetBalanceUpdated { - old_balance_wei, new_balance_wei - }) if old_balance_wei == old && new_balance_wei == MIN_WEI_BALANCE + 42u128 - ) - }); - assert!(ev, "FaucetBalanceUpdated event not found"); + let new_address = primitives::EvmAddress::from([2u8; 20]); + assert_ok!(Dispenser::set_config( + RuntimeOrigin::root(), + new_address, + 500, + 200, + 2_000_000_000, + 25, + 999, + )); + + let config = Dispenser::dispenser_config().unwrap(); + assert_eq!(config.faucet_address, new_address); + assert_eq!(config.min_faucet_threshold, 500); + assert_eq!(config.min_request, 200); + assert_eq!(config.max_dispense, 2_000_000_000); + assert_eq!(config.dispenser_fee, 25); + assert_eq!(config.faucet_balance_wei, 999); + // paused state preserved from previous set_config (was false) + assert!(!config.paused); }); } #[test] -fn non_governance_cannot_set_faucet_balance() { +fn test_set_config_preserves_paused_state() { + new_test_ext().execute_with(|| { + assert_ok!(Dispenser::pause(RuntimeOrigin::root())); + assert!(Dispenser::dispenser_config().unwrap().paused); + + assert_ok!(Dispenser::set_config( + RuntimeOrigin::root(), + test_faucet_address(), + 1, + 100, + 1_000_000_000, + 10, + MIN_WEI_BALANCE, + )); + + // paused should still be true + assert!(Dispenser::dispenser_config().unwrap().paused); + }); +} + +#[test] +fn non_governance_cannot_set_config() { new_test_ext().execute_with(|| { let alice = acct(1); assert_noop!( - Dispenser::set_faucet_balance(RuntimeOrigin::signed(alice), 7u128), + Dispenser::set_config( + RuntimeOrigin::signed(alice), + test_faucet_address(), + 1, + 100, + 1_000_000_000, + 10, + MIN_WEI_BALANCE, + ), sp_runtime::DispatchError::BadOrigin ); - assert_eq!(Dispenser::current_faucet_balance_wei(), MIN_WEI_BALANCE); }); } @@ -242,9 +300,17 @@ fn request_rejected_when_balance_below_threshold() { new_test_ext().execute_with(|| { let requester = acct(1); let receiver = create_test_receiver_address(); - assert_ok!(Dispenser::set_faucet_balance(RuntimeOrigin::root(), 10u128)); - FaucetBalanceWei::::put(100u128); + // Set config with very low faucet balance + assert_ok!(Dispenser::set_config( + RuntimeOrigin::root(), + test_faucet_address(), + TEST_MIN_FAUCET_THRESHOLD, + TEST_MIN_REQUEST, + TEST_MAX_DISPENSE, + TEST_DISPENSER_FEE, + 100u128, // low balance + )); let amount = 100u128; let tx = create_test_tx_params(); @@ -267,8 +333,18 @@ fn request_rejected_when_balance_below_threshold() { fn request_allowed_at_or_above_threshold() { new_test_ext().execute_with(|| { let amount = 101u128; - let needed = ::MinFaucetEthThreshold::get() + amount; - assert_ok!(Dispenser::set_faucet_balance(RuntimeOrigin::root(), needed)); + let needed = TEST_MIN_FAUCET_THRESHOLD + amount; + + // Set config with enough balance + assert_ok!(Dispenser::set_config( + RuntimeOrigin::root(), + test_faucet_address(), + TEST_MIN_FAUCET_THRESHOLD, + TEST_MIN_REQUEST, + TEST_MAX_DISPENSE, + TEST_DISPENSER_FEE, + needed, + )); let requester = acct(1); let receiver = create_test_receiver_address(); @@ -289,26 +365,23 @@ fn request_allowed_at_or_above_threshold() { fn request_reduces_faucet_balance() { new_test_ext().execute_with(|| { let amount: u128 = 1_000u128; - let min_threshold = ::MinFaucetEthThreshold::get(); - let initial_balance = min_threshold + amount + 1_000u128; - - assert_ok!(Dispenser::set_faucet_balance(RuntimeOrigin::root(), initial_balance)); - assert_eq!( - Dispenser::current_faucet_balance_wei(), - MIN_WEI_BALANCE + initial_balance - ); + let initial_balance = TEST_MIN_FAUCET_THRESHOLD + amount + 1_000u128; + + assert_ok!(Dispenser::set_config( + RuntimeOrigin::root(), + test_faucet_address(), + TEST_MIN_FAUCET_THRESHOLD, + TEST_MIN_REQUEST, + TEST_MAX_DISPENSE, + TEST_DISPENSER_FEE, + initial_balance, + )); let requester = acct(1); let receiver = create_test_receiver_address(); let tx = create_test_tx_params(); let req_id = compute_request_id(requester.clone(), receiver, amount, &tx); - let fee_asset = ::FeeAsset::get(); - let faucet_asset = ::FaucetAsset::get(); - - let hdx_before = Currencies::free_balance(fee_asset, &requester); - let weth_before = Currencies::free_balance(faucet_asset, &requester); - assert_ok!(Dispenser::request_fund( RuntimeOrigin::signed(requester.clone()), receiver, @@ -317,14 +390,8 @@ fn request_reduces_faucet_balance() { tx )); - let expected_balance = initial_balance.saturating_sub(amount).saturating_add(MIN_WEI_BALANCE); - assert_eq!(Dispenser::current_faucet_balance_wei(), expected_balance); - - assert_eq!( - Currencies::free_balance(fee_asset, &requester), - hdx_before - ::DispenserFee::get() - ); - assert_eq!(Currencies::free_balance(faucet_asset, &requester), weth_before - amount); + let config = Dispenser::dispenser_config().unwrap(); + assert_eq!(config.faucet_balance_wei, initial_balance - amount); }); } @@ -373,11 +440,10 @@ fn request_fails_when_insufficient_faucet_balance() { let req_id = compute_request_id(requester.clone(), receiver, amount, &tx); let fee_asset = ::FeeAsset::get(); - - let fee = ::DispenserFee::get(); + let config = Dispenser::dispenser_config().unwrap(); + let fee = config.dispenser_fee; let _ = Currencies::deposit(fee_asset, &requester, 1_000_000_000_000_000_000_000); - assert_ok!(Currencies::deposit(1, &requester, fee)); assert_noop!( @@ -412,16 +478,123 @@ fn request_fails_with_duplicate_request_id() { } #[test] -fn request_fails_with_zero_gas_limit() { +fn pause_fails_when_not_configured() { + let t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| { + frame_system::Pallet::::set_block_number(1); + assert_noop!(Dispenser::pause(RuntimeOrigin::root()), Error::::NotConfigured); + }); +} + +#[test] +fn unpause_fails_when_not_configured() { + let t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| { + frame_system::Pallet::::set_block_number(1); + assert_noop!(Dispenser::unpause(RuntimeOrigin::root()), Error::::NotConfigured); + }); +} + +#[test] +fn set_config_fails_with_zero_address() { new_test_ext().execute_with(|| { - let amount = 10_000u128; - let min_threshold = ::MinFaucetEthThreshold::get(); - let initial_balance = min_threshold + amount + 1_000u128; + assert_noop!( + Dispenser::set_config( + RuntimeOrigin::root(), + primitives::EvmAddress::zero(), + 1, + 100, + 1_000_000_000, + 10, + MIN_WEI_BALANCE, + ), + Error::::InvalidAddress + ); + }); +} - assert_ok!(Dispenser::set_faucet_balance(RuntimeOrigin::root(), initial_balance)); +#[test] +fn set_config_fails_with_zero_max_dispense() { + new_test_ext().execute_with(|| { + assert_noop!( + Dispenser::set_config( + RuntimeOrigin::root(), + test_faucet_address(), + 1, + 0, + 0, + 10, + MIN_WEI_BALANCE, + ), + Error::::InvalidConfig + ); + }); +} +#[test] +fn set_config_fails_when_min_request_exceeds_max_dispense() { + new_test_ext().execute_with(|| { + assert_noop!( + Dispenser::set_config( + RuntimeOrigin::root(), + test_faucet_address(), + 1, + 1_000, + 500, + 10, + MIN_WEI_BALANCE, + ), + Error::::InvalidConfig + ); + }); +} + +#[test] +fn set_config_emits_event_with_faucet_balance() { + new_test_ext().execute_with(|| { + let new_address = primitives::EvmAddress::from([2u8; 20]); + let balance_wei = 999u128; + + assert_ok!(Dispenser::set_config( + RuntimeOrigin::root(), + new_address, + 500, + 200, + 2_000_000_000, + 25, + balance_wei, + )); + + let events = System::events(); + assert!(events.iter().any(|e| { + matches!( + &e.event, + RuntimeEvent::Dispenser(Event::ConfigUpdated { + faucet_address, + min_faucet_threshold, + min_request, + max_dispense, + dispenser_fee, + faucet_balance_wei, + }) if *faucet_address == new_address + && *min_faucet_threshold == 500 + && *min_request == 200 + && *max_dispense == 2_000_000_000 + && *dispenser_fee == 25 + && *faucet_balance_wei == balance_wei + ) + })); + }); +} + +#[test] +fn request_fails_with_zero_gas_limit() { + new_test_ext().execute_with(|| { let requester = acct(1); let receiver = create_test_receiver_address(); + let amount = 10_000u128; let mut tx = create_test_tx_params(); tx.gas_limit = 0; diff --git a/pallets/dispenser/src/tests/utils.rs b/pallets/dispenser/src/tests/utils.rs index 2fcc49c11..89aa2bb98 100644 --- a/pallets/dispenser/src/tests/utils.rs +++ b/pallets/dispenser/src/tests/utils.rs @@ -2,17 +2,13 @@ use alloy_primitives::{Address, U256}; use alloy_sol_types::SolCall; use alloy_sol_types::SolValue; use codec::Encode; -use sp_core::Get; use sp_io::hashing::keccak_256; use sp_runtime::{AccountId32, BoundedVec}; -use crate::tests::Dispenser; -use crate::{ - tests::{MaxChainIdLength, Test}, - EvmTransactionParams, -}; +use crate::tests::{Dispenser, Test}; +use crate::EvmTransactionParams; -pub fn bounded_chain_id(v: Vec) -> BoundedVec { +pub fn bounded_chain_id(v: Vec) -> BoundedVec> { BoundedVec::try_from(v).unwrap() } @@ -39,15 +35,16 @@ pub fn compute_request_id( ) -> [u8; 32] { use sp_core::crypto::Ss58Codec; + let config = Dispenser::dispenser_config().expect("dispenser must be configured"); + let call = crate::IGasFaucet::fundCall { to: Address::from_slice(to.as_bytes()), amount: U256::from(amount_wei), }; - let faucet_addr = ::FaucetAddress::get(); let rlp_encoded = pallet_signet::Pallet::::build_evm_tx( frame_system::RawOrigin::Signed(requester.clone()).into(), - Some(faucet_addr), + Some(config.faucet_address), 0u128, call.abi_encode(), tx_params.nonce, @@ -68,12 +65,6 @@ pub fn compute_request_id( let account_id32 = sp_runtime::AccountId32::from(account_bytes); let sender_ss58 = account_id32.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(0)); - let path = { - let req_scale = requester.encode(); - let mut s = String::from("0x"); - s.push_str(&hex::encode(req_scale)); - s - }; // CAIP-2 chain ID format let caip2_id = format!("eip155:{}", tx_params.chain_id); @@ -83,7 +74,7 @@ pub fn compute_request_id( rlp_encoded.as_slice(), caip2_id.as_str(), 0u32, - path.as_str(), + core::str::from_utf8(crate::SIGNING_PATH).unwrap(), "ecdsa", "ethereum", "", diff --git a/pallets/dispenser/src/types.rs b/pallets/dispenser/src/types.rs index f0b828f7d..ed8c4236a 100644 --- a/pallets/dispenser/src/types.rs +++ b/pallets/dispenser/src/types.rs @@ -9,9 +9,30 @@ pub type BalanceOf = pub const ECDSA: &[u8] = b"ecdsa"; pub const ETHEREUM: &[u8] = b"ethereum"; +/// Fixed signing derivation path — all dispenser requests use the same +/// MPC-derived key so that only one EVM wallet needs to be funded and +/// whitelisted on the faucet contract. +pub const SIGNING_PATH: &[u8] = b"dispenser"; + pub trait WeightInfo { fn request_fund() -> Weight; - fn set_faucet_balance() -> Weight; + fn set_config() -> Weight; fn pause() -> Weight; fn unpause() -> Weight; } + +#[cfg(feature = "runtime-benchmarks")] +pub trait BenchmarkHelper { + fn register_asset(asset_id: AssetId, min_balance: Balance) -> sp_runtime::DispatchResult; + fn mint(asset_id: AssetId, who: &AccountId, amount: Balance) -> sp_runtime::DispatchResult; +} + +#[cfg(feature = "runtime-benchmarks")] +impl BenchmarkHelper for () { + fn register_asset(_asset_id: AssetId, _min_balance: Balance) -> sp_runtime::DispatchResult { + Ok(()) + } + fn mint(_asset_id: AssetId, _who: &AccountId, _amount: Balance) -> sp_runtime::DispatchResult { + Ok(()) + } +} diff --git a/pallets/dispenser/src/weights.rs b/pallets/dispenser/src/weights.rs index 4ec3ac3a2..c24789a0d 100644 --- a/pallets/dispenser/src/weights.rs +++ b/pallets/dispenser/src/weights.rs @@ -7,25 +7,6 @@ //! HOSTNAME: `Yashs-MacBook-Pro.local`, CPU: `` //! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: 1024 -// Executed Command: -// target/release/hydradx -// benchmark -// pallet -// --chain -// dev -// --pallet -// pallet_dispenser -// --extrinsic -// * -// --steps -// 50 -// --repeat -// 20 -// --execution=wasm -// --wasm-execution=compiled -// --output -// pallets/dispenser/src/weights.rs - #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] #![allow(unused_imports)] @@ -37,81 +18,25 @@ use core::marker::PhantomData; /// Weight functions for `pallet_dispenser`. pub struct WeightInfo(PhantomData); impl crate::WeightInfo for WeightInfo { - /// Storage: `EthDispenser::FaucetBalanceWei` (r:1 w:1) - /// Proof: `EthDispenser::FaucetBalanceWei` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - fn set_faucet_balance() -> Weight { - // Proof Size summary in bytes: - // Measured: `232` - // Estimated: `1501` - // Minimum execution time: 8_000_000 picoseconds. + fn set_config() -> Weight { Weight::from_parts(9_000_000, 0) .saturating_add(Weight::from_parts(0, 1501)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } - /// Storage: `EthDispenser::DispenserConfig` (r:1 w:1) - /// Proof: `EthDispenser::DispenserConfig` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) fn pause() -> Weight { - // Proof Size summary in bytes: - // Measured: `232` - // Estimated: `1486` - // Minimum execution time: 9_000_000 picoseconds. Weight::from_parts(9_000_000, 0) .saturating_add(Weight::from_parts(0, 1486)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } - /// Storage: `EthDispenser::DispenserConfig` (r:1 w:1) - /// Proof: `EthDispenser::DispenserConfig` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) fn unpause() -> Weight { - // Proof Size summary in bytes: - // Measured: `232` - // Estimated: `1486` - // Minimum execution time: 9_000_000 picoseconds. Weight::from_parts(9_000_000, 0) .saturating_add(Weight::from_parts(0, 1486)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } - /// Storage: `EthDispenser::DispenserConfig` (r:1 w:0) - /// Proof: `EthDispenser::DispenserConfig` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) - /// Storage: `EthDispenser::FaucetBalanceWei` (r:1 w:1) - /// Proof: `EthDispenser::FaucetBalanceWei` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - /// Storage: `EthDispenser::UsedRequestIds` (r:1 w:1) - /// Proof: `EthDispenser::UsedRequestIds` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) - /// Storage: `AssetRegistry::Assets` (r:2 w:0) - /// Proof: `AssetRegistry::Assets` (`max_values`: None, `max_size`: Some(125), added: 2600, mode: `MaxEncodedLen`) - /// Storage: `Tokens::Accounts` (r:2 w:2) - /// Proof: `Tokens::Accounts` (`max_values`: None, `max_size`: Some(108), added: 2583, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:3 w:3) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `EVMAccounts::AccountExtension` (r:1 w:0) - /// Proof: `EVMAccounts::AccountExtension` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) - /// Storage: `HSM::FlashMinter` (r:1 w:0) - /// Proof: `HSM::FlashMinter` (`max_values`: Some(1), `max_size`: Some(20), added: 515, mode: `MaxEncodedLen`) - /// Storage: `Duster::AccountWhitelist` (r:1 w:0) - /// Proof: `Duster::AccountWhitelist` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) - /// Storage: `AssetRegistry::BannedAssets` (r:1 w:0) - /// Proof: `AssetRegistry::BannedAssets` (`max_values`: None, `max_size`: Some(20), added: 2495, mode: `MaxEncodedLen`) - /// Storage: `MultiTransactionPayment::AccountCurrencyMap` (r:2 w:0) - /// Proof: `MultiTransactionPayment::AccountCurrencyMap` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) - /// Storage: `Balances::Locks` (r:1 w:1) - /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) - /// Storage: `Balances::Freezes` (r:1 w:0) - /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) - /// Storage: `AssetRegistry::ExistentialDepositCounter` (r:1 w:1) - /// Proof: `AssetRegistry::ExistentialDepositCounter` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - /// Storage: `MultiTransactionPayment::AcceptedCurrencies` (r:1 w:0) - /// Proof: `MultiTransactionPayment::AcceptedCurrencies` (`max_values`: None, `max_size`: Some(28), added: 2503, mode: `MaxEncodedLen`) - /// Storage: `Signet::Admin` (r:1 w:0) - /// Proof: `Signet::Admin` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `Signet::SignatureDeposit` (r:1 w:0) - /// Proof: `Signet::SignatureDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) fn request_fund() -> Weight { - // Proof Size summary in bytes: - // Measured: `1792` - // Estimated: `8799` - // Minimum execution time: 397_000_000 picoseconds. Weight::from_parts(399_000_000, 0) .saturating_add(Weight::from_parts(0, 8799)) .saturating_add(T::DbWeight::get().reads(22)) diff --git a/pallets/signet/Cargo.toml b/pallets/signet/Cargo.toml index 9bca3f844..5f8b8e30b 100644 --- a/pallets/signet/Cargo.toml +++ b/pallets/signet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-signet" -version = "1.2.0" +version = "1.3.0" authors = ["Signet"] edition = "2021" license = "Apache-2.0" diff --git a/pallets/signet/src/benchmarks.rs b/pallets/signet/src/benchmarks.rs index ec74ea6e6..200eaf890 100644 --- a/pallets/signet/src/benchmarks.rs +++ b/pallets/signet/src/benchmarks.rs @@ -2,12 +2,21 @@ use super::*; use frame_benchmarking::v2::*; use frame_support::assert_ok; use frame_system::RawOrigin; -use sp_runtime::traits::{One, Saturating}; +use sp_runtime::traits::Saturating; use sp_std::vec; -fn bench_chain_id() -> BoundedVec::MaxChainIdLength> { - let v: Vec = b"bench-chain".to_vec(); - BoundedVec::try_from(v).expect("bench-chain fits MaxChainIdLength") +fn setup_config() { + let deposit: BalanceOf = T::Currency::minimum_balance().saturating_mul(10u32.into()); + let chain_id: BoundedVec> = + BoundedVec::try_from(b"bench-chain".to_vec()).expect("bench-chain fits"); + + assert_ok!(Pallet::::set_config( + RawOrigin::Root.into(), + deposit, + 128u32, + 100_000u32, + chain_id, + )); } #[benchmarks(where T: Config)] @@ -15,54 +24,20 @@ mod benches { use super::*; #[benchmark] - fn initialize() { - let admin: T::AccountId = whitelisted_caller(); - let max_dep: BalanceOf = T::MaxSignatureDeposit::get(); - let deposit: BalanceOf = max_dep.saturating_sub(One::one()); - let chain_id = super::bench_chain_id::(); + fn set_config() { + let deposit: BalanceOf = T::Currency::minimum_balance().saturating_mul(10u32.into()); + let chain_id: BoundedVec> = + BoundedVec::try_from(b"bench-chain".to_vec()).expect("bench-chain fits"); #[extrinsic_call] - initialize(RawOrigin::Root, admin.clone(), deposit, chain_id); + set_config(RawOrigin::Root, deposit, 128u32, 100_000u32, chain_id); - assert_eq!(Admin::::get(), Some(admin)); - assert_eq!(SignatureDeposit::::get(), deposit); - } - - #[benchmark] - fn update_deposit() { - let admin: T::AccountId = whitelisted_caller(); - let max_dep: BalanceOf = T::MaxSignatureDeposit::get(); - let initial_deposit: BalanceOf = max_dep.saturating_sub(One::one()); - let chain_id = super::bench_chain_id::(); - - assert_ok!(Pallet::::initialize( - RawOrigin::Root.into(), - admin.clone(), - initial_deposit, - chain_id, - )); - - let new_deposit: BalanceOf = initial_deposit; - - #[extrinsic_call] - update_deposit(RawOrigin::Signed(admin.clone()), new_deposit); - - assert_eq!(SignatureDeposit::::get(), new_deposit); + assert!(SignetConfig::::get().is_some()); } #[benchmark] fn withdraw_funds() { - let admin: T::AccountId = whitelisted_caller(); - let chain_id = super::bench_chain_id::(); - let max_dep: BalanceOf = T::MaxSignatureDeposit::get(); - let deposit: BalanceOf = max_dep.saturating_sub(One::one()); - - assert_ok!(Pallet::::initialize( - RawOrigin::Root.into(), - admin.clone(), - deposit, - chain_id, - )); + setup_config::(); let pallet_account = Pallet::::account_id(); let amount: BalanceOf = T::Currency::minimum_balance().saturating_mul(100u32.into()); @@ -72,27 +47,18 @@ mod benches { let withdraw_amount: BalanceOf = T::Currency::minimum_balance().saturating_mul(50u32.into()); #[extrinsic_call] - withdraw_funds(RawOrigin::Signed(admin.clone()), recipient.clone(), withdraw_amount); + withdraw_funds(RawOrigin::Root, recipient.clone(), withdraw_amount); assert!(T::Currency::free_balance(&recipient) >= withdraw_amount); } #[benchmark] fn sign() { - let admin: T::AccountId = whitelisted_caller(); - let max_dep: BalanceOf = T::MaxSignatureDeposit::get(); - let deposit: BalanceOf = max_dep.saturating_sub(One::one()); - let chain_id = super::bench_chain_id::(); - - assert_ok!(Pallet::::initialize( - RawOrigin::Root.into(), - admin, - deposit, - chain_id, - )); + setup_config::(); let requester: T::AccountId = whitelisted_caller(); - let fund: BalanceOf = deposit.saturating_mul(10u32.into()); + let config = SignetConfig::::get().unwrap(); + let fund: BalanceOf = config.signature_deposit.saturating_mul(10u32.into()); let _ = T::Currency::deposit_creating(&requester, fund); let payload: [u8; 32] = [1u8; 32]; @@ -123,20 +89,11 @@ mod benches { #[benchmark] fn sign_bidirectional() { - let admin: T::AccountId = whitelisted_caller(); - let max_dep: BalanceOf = T::MaxSignatureDeposit::get(); - let deposit: BalanceOf = max_dep.saturating_sub(One::one()); - let chain_id = super::bench_chain_id::(); - - assert_ok!(Pallet::::initialize( - RawOrigin::Root.into(), - admin, - deposit, - chain_id, - )); + setup_config::(); let requester: T::AccountId = whitelisted_caller(); - let fund: BalanceOf = deposit.saturating_mul(10u32.into()); + let config = SignetConfig::::get().unwrap(); + let fund: BalanceOf = config.signature_deposit.saturating_mul(10u32.into()); let _ = T::Currency::deposit_creating(&requester, fund); let tx_bytes = vec![5u8; MAX_TRANSACTION_LENGTH as usize]; @@ -265,5 +222,26 @@ mod benches { ); } + #[benchmark] + fn pause() { + setup_config::(); + + #[extrinsic_call] + pause(RawOrigin::Root); + + assert!(SignetConfig::::get().unwrap().paused); + } + + #[benchmark] + fn unpause() { + setup_config::(); + SignetConfig::::mutate(|c| c.as_mut().unwrap().paused = true); + + #[extrinsic_call] + unpause(RawOrigin::Root); + + assert!(!SignetConfig::::get().unwrap().paused); + } + impl_benchmark_test_suite!(Pallet, crate::tests::new_test_ext(), crate::tests::Test); } diff --git a/pallets/signet/src/lib.rs b/pallets/signet/src/lib.rs index 2baa94c6c..fd945de15 100644 --- a/pallets/signet/src/lib.rs +++ b/pallets/signet/src/lib.rs @@ -32,6 +32,9 @@ const MAX_ERROR_MESSAGE_LENGTH: u32 = 1024; /// Maximum batch sizes const MAX_BATCH_SIZE: u32 = 100; +/// Hard upper bound for chain ID length (used as BoundedVec bound) +pub const MAX_CHAIN_ID_LENGTH: u32 = 128; + const EIP1559_TX_TYPE: u8 = 0x02; #[cfg(feature = "runtime-benchmarks")] @@ -53,7 +56,9 @@ pub mod pallet { pub struct Pallet(_); #[pallet::config] - pub trait Config: frame_system::Config { + pub trait Config: frame_system::Config>> { + /// Origin that is allowed to call administrative extrinsics + /// (set_config, withdraw_funds, pause, unpause). type UpdateOrigin: EnsureOrigin; /// Currency for handling deposits and fees @@ -63,18 +68,7 @@ pub mod pallet { #[pallet::constant] type PalletId: Get; - /// Maximum length for chain ID - #[pallet::constant] - type MaxChainIdLength: Get; - type WeightInfo: WeightInfo; - - /// Maximum length of transaction data - #[pallet::constant] - type MaxDataLength: Get; - - #[pallet::constant] - type MaxSignatureDeposit: Get>; } // ======================================== @@ -110,24 +104,31 @@ pub mod pallet { pub error_message: BoundedVec>, } + /// Signet configuration data. + #[derive(Encode, Decode, TypeInfo, Clone, Debug, PartialEq, MaxEncodedLen)] + pub struct SignetConfigData { + /// If `true`, all user-facing requests are blocked. + pub paused: bool, + /// Amount required as deposit for signature requests. + pub signature_deposit: Balance, + /// Maximum length for chain ID. + pub max_chain_id_length: u32, + /// Maximum length for EVM transaction data. + pub max_evm_data_length: u32, + /// The CAIP-2 chain identifier. + pub chain_id: BoundedVec>, + } + // ======================================== // Storage // ======================================== - /// The admin account that controls this pallet - #[pallet::storage] - #[pallet::getter(fn admin)] - pub type Admin = StorageValue<_, T::AccountId>; - - /// The amount required as deposit for signature requests - #[pallet::storage] - #[pallet::getter(fn signature_deposit)] - pub type SignatureDeposit = StorageValue<_, BalanceOf, ValueQuery>; - - /// The CAIP-2 chain identifier + /// Global configuration for the signet pallet. + /// + /// If `None`, the pallet has not been configured yet and cannot be used. #[pallet::storage] - #[pallet::getter(fn chain_id)] - pub type ChainId = StorageValue<_, BoundedVec, ValueQuery>; + #[pallet::getter(fn signet_config)] + pub type SignetConfig = StorageValue<_, SignetConfigData>, OptionQuery>; // ======================================== // Events @@ -136,18 +137,18 @@ pub mod pallet { #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { - /// Pallet has been initialized with an admin - Initialized { - admin: T::AccountId, + /// Signet configuration has been updated. + ConfigUpdated { signature_deposit: BalanceOf, + max_chain_id_length: u32, + max_evm_data_length: u32, chain_id: Vec, }, - /// Signature deposit amount has been updated - DepositUpdated { - old_deposit: BalanceOf, - new_deposit: BalanceOf, - }, + /// Signet has been paused. No new requests will be accepted. + Paused, + /// Signet has been unpaused. New requests are allowed again. + Unpaused, /// Funds have been withdrawn from the pallet FundsWithdrawn { @@ -212,28 +213,22 @@ pub mod pallet { #[pallet::error] pub enum Error { - /// The pallet has already been initialized - AlreadyInitialized, - /// The pallet has not been initialized yet - NotInitialized, - /// Unauthorized - caller is not admin - Unauthorized, + /// The pallet has not been configured yet + NotConfigured, + /// Pallet is paused and cannot process this call. + Paused, /// Insufficient funds for withdrawal InsufficientFunds, /// Invalid transaction data (empty) InvalidTransaction, /// Arrays must have the same length InvalidInputLength, - /// The chain ID is too long - ChainIdTooLong, /// Transaction data exceeds maximum allowed length DataTooLong, /// Invalid address format - must be exactly 20 bytes InvalidAddress, /// Priority fee cannot exceed max fee per gas (EIP-1559 requirement) InvalidGasPrice, - /// Signature Deposit cannot exceed MaxSignatureDeposit - MaxDepositExceeded, } // ======================================== @@ -242,67 +237,57 @@ pub mod pallet { #[pallet::call] impl Pallet { - /// Initialize the pallet with admin, deposit, and chain ID + /// Set or update the signet configuration. + /// + /// Can be called multiple times to update the configuration. + /// + /// Parameters: + /// - `origin`: Must satisfy `UpdateOrigin`. + /// - `signature_deposit`: Deposit amount for signature requests. + /// - `max_chain_id_length`: Maximum chain ID length. + /// - `max_evm_data_length`: Maximum EVM transaction data length. + /// - `chain_id`: The CAIP-2 chain identifier. #[pallet::call_index(0)] - #[pallet::weight(::WeightInfo::initialize())] - pub fn initialize( + #[pallet::weight(::WeightInfo::set_config())] + pub fn set_config( origin: OriginFor, - admin: T::AccountId, signature_deposit: BalanceOf, - chain_id: BoundedVec, + max_chain_id_length: u32, + max_evm_data_length: u32, + chain_id: BoundedVec>, ) -> DispatchResult { T::UpdateOrigin::ensure_origin(origin)?; - ensure!(Admin::::get().is_none(), Error::::AlreadyInitialized); - ensure!( - signature_deposit <= T::MaxSignatureDeposit::get(), - Error::::MaxDepositExceeded - ); + let paused = SignetConfig::::get().map(|c| c.paused).unwrap_or(false); - Admin::::put(&admin); - SignatureDeposit::::put(signature_deposit); - ChainId::::put(chain_id.clone()); + SignetConfig::::put(SignetConfigData { + paused, + signature_deposit, + max_chain_id_length, + max_evm_data_length, + chain_id: chain_id.clone(), + }); - Self::deposit_event(Event::Initialized { - admin, + Self::deposit_event(Event::ConfigUpdated { signature_deposit, + max_chain_id_length, + max_evm_data_length, chain_id: chain_id.to_vec(), }); Ok(()) } - /// Update the signature deposit amount (admin only) + /// Withdraw funds from the pallet account. + /// + /// Parameters: + /// - `origin`: Must satisfy `UpdateOrigin`. + /// - `recipient`: Account to receive the withdrawn funds. + /// - `amount`: Amount to withdraw. #[pallet::call_index(1)] - #[pallet::weight(::WeightInfo::update_deposit())] - pub fn update_deposit(origin: OriginFor, new_deposit: BalanceOf) -> DispatchResult { - let who = ensure_signed(origin)?; - let admin = Admin::::get().ok_or(Error::::NotInitialized)?; - ensure!(who == admin, Error::::Unauthorized); - - ensure!( - new_deposit < T::MaxSignatureDeposit::get(), - Error::::MaxDepositExceeded - ); - - let old_deposit = SignatureDeposit::::get(); - SignatureDeposit::::put(new_deposit); - - Self::deposit_event(Event::DepositUpdated { - old_deposit, - new_deposit, - }); - - Ok(()) - } - - /// Withdraw funds from the pallet account (admin only) - #[pallet::call_index(2)] #[pallet::weight(::WeightInfo::withdraw_funds())] pub fn withdraw_funds(origin: OriginFor, recipient: T::AccountId, amount: BalanceOf) -> DispatchResult { - let who = ensure_signed(origin)?; - let admin = Admin::::get().ok_or(Error::::NotInitialized)?; - ensure!(who == admin, Error::::Unauthorized); + T::UpdateOrigin::ensure_origin(origin)?; let pallet_account = Self::account_id(); let pallet_balance = T::Currency::free_balance(&pallet_account); @@ -316,7 +301,7 @@ pub mod pallet { } /// Request a signature for a payload - #[pallet::call_index(3)] + #[pallet::call_index(2)] #[pallet::weight(::WeightInfo::sign())] pub fn sign( origin: OriginFor, @@ -329,20 +314,16 @@ pub mod pallet { ) -> DispatchResult { let requester = ensure_signed(origin)?; - // Ensure initialized - ensure!(Admin::::get().is_some(), Error::::NotInitialized); + let config = SignetConfig::::get().ok_or(Error::::NotConfigured)?; + ensure!(!config.paused, Error::::Paused); - // Get deposit amount - let deposit = SignatureDeposit::::get(); + let deposit = config.signature_deposit; + let chain_id = config.chain_id.to_vec(); // Transfer deposit from requester to pallet account let pallet_account = Self::account_id(); T::Currency::transfer(&requester, &pallet_account, deposit, ExistenceRequirement::AllowDeath)?; - // Get chain ID for event (convert BoundedVec to Vec) - let chain_id = ChainId::::get().to_vec(); - - // Emit event Self::deposit_event(Event::SignatureRequested { sender: requester, payload, @@ -359,7 +340,7 @@ pub mod pallet { } /// Request a signature for a serialized transaction - #[pallet::call_index(4)] + #[pallet::call_index(3)] #[pallet::weight(::WeightInfo::sign_bidirectional())] pub fn sign_bidirectional( origin: OriginFor, @@ -375,20 +356,18 @@ pub mod pallet { ) -> DispatchResult { let requester = ensure_signed(origin)?; - // Ensure initialized - ensure!(Admin::::get().is_some(), Error::::NotInitialized); + let config = SignetConfig::::get().ok_or(Error::::NotConfigured)?; + ensure!(!config.paused, Error::::Paused); // Validate transaction data ensure!(!serialized_transaction.is_empty(), Error::::InvalidTransaction); - // Get deposit amount - let deposit = SignatureDeposit::::get(); + let deposit = config.signature_deposit; // Transfer deposit from requester to pallet account let pallet_account = Self::account_id(); T::Currency::transfer(&requester, &pallet_account, deposit, ExistenceRequirement::AllowDeath)?; - // Emit event Self::deposit_event(Event::SignBidirectionalRequested { sender: requester, serialized_transaction: serialized_transaction.to_vec(), @@ -407,7 +386,7 @@ pub mod pallet { } /// Respond to signature requests (batch support) - #[pallet::call_index(5)] + #[pallet::call_index(4)] #[pallet::weight(::WeightInfo::respond())] pub fn respond( origin: OriginFor, @@ -416,10 +395,8 @@ pub mod pallet { ) -> DispatchResult { let responder = ensure_signed(origin)?; - // Validate input lengths ensure!(request_ids.len() == signatures.len(), Error::::InvalidInputLength); - // Emit events for each response for i in 0..request_ids.len() { Self::deposit_event(Event::SignatureResponded { request_id: request_ids[i], @@ -432,7 +409,7 @@ pub mod pallet { } /// Report signature generation errors (batch support) - #[pallet::call_index(6)] + #[pallet::call_index(5)] #[pallet::weight(::WeightInfo::respond_error())] pub fn respond_error( origin: OriginFor, @@ -440,7 +417,6 @@ pub mod pallet { ) -> DispatchResult { let responder = ensure_signed(origin)?; - // Emit error events for error in errors { Self::deposit_event(Event::SignatureError { request_id: error.request_id, @@ -453,7 +429,7 @@ pub mod pallet { } /// Provide a read response with signature - #[pallet::call_index(7)] + #[pallet::call_index(6)] #[pallet::weight(::WeightInfo::respond_bidirectional())] pub fn respond_bidirectional( origin: OriginFor, @@ -463,7 +439,6 @@ pub mod pallet { ) -> DispatchResult { let responder = ensure_signed(origin)?; - // Just emit event Self::deposit_event(Event::RespondBidirectionalEvent { request_id, responder, @@ -473,6 +448,42 @@ pub mod pallet { Ok(()) } + + /// Pause the signet so that no new signing requests can be made. + /// + /// Parameters: + /// - `origin`: Must satisfy `UpdateOrigin`. + #[pallet::call_index(7)] + #[pallet::weight(::WeightInfo::pause())] + pub fn pause(origin: OriginFor) -> DispatchResult { + T::UpdateOrigin::ensure_origin(origin)?; + SignetConfig::::mutate(|maybe_config| { + if let Some(config) = maybe_config { + config.paused = true; + } + }); + + Self::deposit_event(Event::Paused); + Ok(()) + } + + /// Unpause the signet so that signing requests are allowed again. + /// + /// Parameters: + /// - `origin`: Must satisfy `UpdateOrigin`. + #[pallet::call_index(8)] + #[pallet::weight(::WeightInfo::unpause())] + pub fn unpause(origin: OriginFor) -> DispatchResult { + T::UpdateOrigin::ensure_origin(origin)?; + SignetConfig::::mutate(|maybe_config| { + if let Some(config) = maybe_config { + config.paused = false; + } + }); + + Self::deposit_event(Event::Unpaused); + Ok(()) + } } // Helper functions @@ -483,20 +494,6 @@ pub mod pallet { } /// Build an EIP-1559 EVM transaction and return the RLP-encoded data - /// - /// # Parameters - /// - `origin`: The signed origin - /// - `to_address`: Optional recipient address (None for contract creation) - /// - `value`: ETH value in wei - /// - `data`: Transaction data/calldata - /// - `nonce`: Transaction nonce - /// - `gas_limit`: Maximum gas units for transaction - /// - `max_fee_per_gas`: Maximum total fee per gas (base + priority) - /// - `max_priority_fee_per_gas`: Maximum priority fee (tip) per gas - /// - `chain_id`: Target EVM chain ID - /// - /// # Returns - /// RLP-encoded transaction data with EIP-2718 type prefix (0x02 for EIP-1559) pub fn build_evm_tx( origin: OriginFor, to_address: Option, @@ -511,7 +508,11 @@ pub mod pallet { ) -> Result, DispatchError> { ensure_signed(origin)?; - ensure!(data.len() <= T::MaxDataLength::get() as usize, Error::::DataTooLong); + let config = SignetConfig::::get().ok_or(Error::::NotConfigured)?; + ensure!( + data.len() <= config.max_evm_data_length as usize, + Error::::DataTooLong + ); ensure!(max_priority_fee_per_gas <= max_fee_per_gas, Error::::InvalidGasPrice); let action = match to_address { diff --git a/pallets/signet/src/tests/mod.rs b/pallets/signet/src/tests/mod.rs index b42229aee..86962cb01 100644 --- a/pallets/signet/src/tests/mod.rs +++ b/pallets/signet/src/tests/mod.rs @@ -36,13 +36,9 @@ pub mod pallet_mock_caller { #[pallet::call_index(0)] #[pallet::weight(Weight::from_parts(10_000, 0))] pub fn call_signet(origin: OriginFor) -> DispatchResult { - // This pallet will call signet with ITS OWN account as the sender let _who = ensure_signed(origin)?; - - // Get this pallet's derived account (use fully-qualified syntax) let pallet_account: T::AccountId = ::PalletId::get().into_account_truncating(); - // Call signet from this pallet's account pallet_signet::Pallet::::sign( frame_system::RawOrigin::Signed(pallet_account).into(), [99u8; 32], @@ -73,7 +69,6 @@ frame_support::construct_runtime!( parameter_types! { pub const BlockHashCount: u64 = 250; pub const SS58Prefix: u8 = 42; - pub const MaxDataLength: u32 = 100_000; } impl system::Config for Test { @@ -132,18 +127,13 @@ impl pallet_balances::Config for Test { parameter_types! { pub const SignetPalletId: PalletId = PalletId(*b"py/signt"); - pub const MaxChainIdLength: u32 = 128; - pub const MaxSignatureDeposit: u32 = 10000000; } impl pallet_signet::Config for Test { type Currency = Balances; type PalletId = SignetPalletId; - type MaxChainIdLength = MaxChainIdLength; type WeightInfo = WeightInfo; - type MaxDataLength = MaxDataLength; type UpdateOrigin = frame_system::EnsureRoot; - type MaxSignatureDeposit = MaxSignatureDeposit; } parameter_types! { diff --git a/pallets/signet/src/tests/test_cases.rs b/pallets/signet/src/tests/test_cases.rs index 76ed8debf..9f1baccfb 100644 --- a/pallets/signet/src/tests/test_cases.rs +++ b/pallets/signet/src/tests/test_cases.rs @@ -1,7 +1,7 @@ use crate::{ tests::{ new_test_ext, - utils::{bounded_array, bounded_chain_id, bounded_err, bounded_sig, bounded_u8, create_test_signature}, + utils::{bounded_array, bounded_err, bounded_sig, bounded_u8, create_test_signature}, Balances, MockCaller, MockCallerPalletId, RuntimeEvent, RuntimeOrigin, Signet, System, Test, }, Error, ErrorResponse, Event, @@ -14,17 +14,14 @@ use sp_runtime::traits::AccountIdConversion; // Constants // ----------------------------------------------------------------------------- -const ADMIN: u64 = 1; -const NON_ADMIN: u64 = 2; +const REQUESTER: u64 = 1; +const OTHER_USER: u64 = 2; const POOR_USER: u64 = 3; const INITIAL_DEPOSIT: u128 = 100; -const UPDATED_DEPOSIT: u128 = 200; -const INSUFFICIENT_BALANCE_DEPOSIT: u128 = 100_000; const WITHDRAW_AMOUNT: u128 = 5_000; const PALLET_INITIAL_BALANCE: u128 = 10_000; -const WITHDRAW_TOO_MUCH_AMOUNT: u128 = 20_000; const CAIP2_SEPOLIA: &[u8] = b"eip155:11155111"; @@ -35,13 +32,14 @@ const HYDRADX_CHAIN_ID_BYTES: &[u8] = b"hydradx:polkadot:0"; // Helpers // ----------------------------------------------------------------------------- -/// Initialize Signet with the default "test-chain" chain id. -fn init_signet(admin: u64, deposit: u128) { - assert_ok!(Signet::initialize( +/// Configure Signet with default test values. +fn configure_signet(deposit: u128) { + assert_ok!(Signet::set_config( RuntimeOrigin::root(), - admin, deposit, - bounded_chain_id(TEST_CHAIN_ID_BYTES.to_vec()), + 128, + 100_000, + bounded_u8::<128>(TEST_CHAIN_ID_BYTES.to_vec()), )); } @@ -53,73 +51,84 @@ fn fund_signet_pallet(amount: u128) -> u64 { } // ----------------------------------------------------------------------------- -// Tests +// set_config tests // ----------------------------------------------------------------------------- #[test] -fn test_initialize_works() { +fn test_set_config_works() { new_test_ext().execute_with(|| { - let admin_account = ADMIN; let deposit = INITIAL_DEPOSIT; - let chain_id = bounded_chain_id(TEST_CHAIN_ID_BYTES.to_vec()); + let chain_id = bounded_u8::<128>(TEST_CHAIN_ID_BYTES.to_vec()); - assert_eq!(Signet::admin(), None); + assert_eq!(Signet::signet_config(), None); - assert_ok!(Signet::initialize( + assert_ok!(Signet::set_config( RuntimeOrigin::root(), - admin_account, deposit, - chain_id.clone() + 128, + 100_000, + chain_id.clone(), )); - assert_eq!(Signet::admin(), Some(admin_account)); - assert_eq!(Signet::signature_deposit(), deposit); - assert_eq!(Signet::chain_id().to_vec(), chain_id.to_vec()); + let config = Signet::signet_config().unwrap(); + assert_eq!(config.signature_deposit, deposit); + assert_eq!(config.max_chain_id_length, 128); + assert_eq!(config.max_evm_data_length, 100_000); + assert_eq!(config.chain_id.to_vec(), chain_id.to_vec()); + assert!(!config.paused); + }); +} - System::assert_last_event( - Event::Initialized { - admin: admin_account, - signature_deposit: deposit, - chain_id: chain_id.to_vec(), - } - .into(), - ); +#[test] +fn test_set_config_can_be_called_multiple_times() { + new_test_ext().execute_with(|| { + configure_signet(INITIAL_DEPOSIT); + let config1 = Signet::signet_config().unwrap(); + assert_eq!(config1.signature_deposit, INITIAL_DEPOSIT); + + // Update config + assert_ok!(Signet::set_config( + RuntimeOrigin::root(), + 200, + 128, + 100_000, + bounded_u8::<128>(TEST_CHAIN_ID_BYTES.to_vec()), + )); + let config2 = Signet::signet_config().unwrap(); + assert_eq!(config2.signature_deposit, 200); }); } #[test] -fn test_cannot_initialize_twice() { +fn test_set_config_preserves_paused_state() { new_test_ext().execute_with(|| { - init_signet(ADMIN, INITIAL_DEPOSIT); + configure_signet(INITIAL_DEPOSIT); - assert_noop!( - Signet::initialize( - RuntimeOrigin::root(), - NON_ADMIN, - INITIAL_DEPOSIT, - bounded_chain_id(TEST_CHAIN_ID_BYTES.to_vec()) - ), - Error::::AlreadyInitialized - ); + // Pause + assert_ok!(Signet::pause(RuntimeOrigin::root())); + assert!(Signet::signet_config().unwrap().paused); - assert_noop!( - Signet::initialize( - RuntimeOrigin::root(), - NON_ADMIN, - INITIAL_DEPOSIT, - bounded_chain_id(TEST_CHAIN_ID_BYTES.to_vec()) - ), - Error::::AlreadyInitialized - ); + // Update config - paused state should be preserved + assert_ok!(Signet::set_config( + RuntimeOrigin::root(), + 200, + 128, + 100_000, + bounded_u8::<128>(TEST_CHAIN_ID_BYTES.to_vec()), + )); + + let config = Signet::signet_config().unwrap(); + assert_eq!(config.signature_deposit, 200); + assert!(config.paused); }); } #[test] -fn test_cannot_use_before_initialization() { +fn test_cannot_use_before_config() { new_test_ext().execute_with(|| { assert_noop!( Signet::sign( - RuntimeOrigin::signed(ADMIN), + RuntimeOrigin::signed(REQUESTER), [0u8; 32], 1, bounded_u8::<256>(b"path".to_vec()), @@ -127,131 +136,57 @@ fn test_cannot_use_before_initialization() { bounded_u8::<64>(b"dest".to_vec()), bounded_u8::<1024>(b"params".to_vec()) ), - Error::::NotInitialized - ); - }); -} - -#[test] -fn test_any_signed_can_initialize_once() { - new_test_ext().execute_with(|| { - init_signet(ADMIN, INITIAL_DEPOSIT); - - assert_eq!(Signet::admin(), Some(ADMIN)); - assert_eq!(Signet::signature_deposit(), INITIAL_DEPOSIT); - - assert_noop!( - Signet::initialize( - RuntimeOrigin::root(), - 3, - INITIAL_DEPOSIT, - bounded_chain_id(TEST_CHAIN_ID_BYTES.to_vec()) - ), - Error::::AlreadyInitialized - ); - - assert_noop!( - Signet::initialize( - RuntimeOrigin::root(), - 3, - INITIAL_DEPOSIT, - bounded_chain_id(TEST_CHAIN_ID_BYTES.to_vec()) - ), - Error::::AlreadyInitialized + Error::::NotConfigured ); - - assert_eq!(Signet::admin(), Some(ADMIN)); - assert_eq!(Signet::signature_deposit(), INITIAL_DEPOSIT); }); } -#[test] -fn test_initialize_sets_deposit() { - new_test_ext().execute_with(|| { - let admin = ADMIN; - let initial_deposit = INITIAL_DEPOSIT; - - assert_ok!(Signet::initialize( - RuntimeOrigin::root(), - admin, - initial_deposit, - bounded_chain_id(TEST_CHAIN_ID_BYTES.to_vec()) - )); - - assert_eq!(Signet::signature_deposit(), initial_deposit); - - System::assert_last_event( - Event::Initialized { - admin, - signature_deposit: initial_deposit, - chain_id: bounded_chain_id(TEST_CHAIN_ID_BYTES.to_vec()).to_vec(), - } - .into(), - ); - }); -} +// ----------------------------------------------------------------------------- +// Pause / Unpause tests +// ----------------------------------------------------------------------------- #[test] -fn test_update_deposit_as_admin() { +fn test_pause_unpause_state() { new_test_ext().execute_with(|| { - let admin = ADMIN; - let initial_deposit = INITIAL_DEPOSIT; - let new_deposit = UPDATED_DEPOSIT; - - assert_ok!(Signet::initialize( - RuntimeOrigin::root(), - admin, - initial_deposit, - bounded_chain_id(TEST_CHAIN_ID_BYTES.to_vec()) - )); + configure_signet(INITIAL_DEPOSIT); - assert_ok!(Signet::update_deposit(RuntimeOrigin::signed(admin), new_deposit)); - assert_eq!(Signet::signature_deposit(), new_deposit); + assert_ok!(Signet::pause(RuntimeOrigin::root())); + assert!(Signet::signet_config().unwrap().paused); - System::assert_last_event( - Event::DepositUpdated { - old_deposit: initial_deposit, - new_deposit, - } - .into(), - ); + assert_ok!(Signet::unpause(RuntimeOrigin::root())); + assert!(!Signet::signet_config().unwrap().paused); }); } #[test] -fn test_non_admin_cannot_update_deposit() { +fn test_request_rejected_when_paused() { new_test_ext().execute_with(|| { - let admin = ADMIN; - let non_admin = NON_ADMIN; - - init_signet(admin, INITIAL_DEPOSIT); + configure_signet(INITIAL_DEPOSIT); + assert_ok!(Signet::pause(RuntimeOrigin::root())); assert_noop!( - Signet::update_deposit(RuntimeOrigin::signed(non_admin), 2_000), - Error::::Unauthorized + Signet::sign( + RuntimeOrigin::signed(REQUESTER), + [0u8; 32], + 1, + bounded_u8::<256>(b"path".to_vec()), + bounded_u8::<32>(b"algo".to_vec()), + bounded_u8::<64>(b"dest".to_vec()), + bounded_u8::<1024>(b"params".to_vec()) + ), + Error::::Paused ); - - assert_eq!(Signet::signature_deposit(), INITIAL_DEPOSIT); }); } -#[test] -fn test_cannot_update_deposit_before_initialization() { - new_test_ext().execute_with(|| { - assert_noop!( - Signet::update_deposit(RuntimeOrigin::signed(ADMIN), 1_000), - Error::::NotInitialized - ); - }); -} +// ----------------------------------------------------------------------------- +// Withdraw tests +// ----------------------------------------------------------------------------- #[test] -fn test_withdraw_funds_as_admin() { +fn test_withdraw_funds() { new_test_ext().execute_with(|| { - let admin = ADMIN; - let recipient = NON_ADMIN; - - init_signet(admin, INITIAL_DEPOSIT); + let recipient = OTHER_USER; let pallet_account = fund_signet_pallet(PALLET_INITIAL_BALANCE); @@ -259,7 +194,7 @@ fn test_withdraw_funds_as_admin() { assert_eq!(Balances::free_balance(pallet_account), PALLET_INITIAL_BALANCE); assert_ok!(Signet::withdraw_funds( - RuntimeOrigin::signed(admin), + RuntimeOrigin::root(), recipient, WITHDRAW_AMOUNT )); @@ -283,39 +218,23 @@ fn test_withdraw_funds_as_admin() { }); } -#[test] -fn test_non_admin_cannot_withdraw() { - new_test_ext().execute_with(|| { - let admin = ADMIN; - let non_admin = NON_ADMIN; - - init_signet(admin, INITIAL_DEPOSIT); - let pallet_account = fund_signet_pallet(PALLET_INITIAL_BALANCE); - assert_eq!(Balances::free_balance(pallet_account), PALLET_INITIAL_BALANCE); - - assert_noop!( - Signet::withdraw_funds(RuntimeOrigin::signed(non_admin), non_admin, WITHDRAW_AMOUNT), - Error::::Unauthorized - ); - }); -} - #[test] fn test_cannot_withdraw_more_than_balance() { new_test_ext().execute_with(|| { - let admin = ADMIN; - - init_signet(admin, INITIAL_DEPOSIT); let pallet_account = fund_signet_pallet(PALLET_INITIAL_BALANCE); assert_eq!(Balances::free_balance(pallet_account), PALLET_INITIAL_BALANCE); assert_noop!( - Signet::withdraw_funds(RuntimeOrigin::signed(admin), admin, WITHDRAW_TOO_MUCH_AMOUNT), + Signet::withdraw_funds(RuntimeOrigin::root(), REQUESTER, 20_000), Error::::InsufficientFunds ); }); } +// ----------------------------------------------------------------------------- +// Sign tests +// ----------------------------------------------------------------------------- + #[test] fn test_pallet_account_id_is_deterministic() { new_test_ext().execute_with(|| { @@ -323,24 +242,18 @@ fn test_pallet_account_id_is_deterministic() { let account2 = Signet::account_id(); assert_eq!(account1, account2); - assert_ne!(account1, ADMIN); - assert_ne!(account1, NON_ADMIN); + assert_ne!(account1, REQUESTER); + assert_ne!(account1, OTHER_USER); }); } #[test] fn test_sign_request_works() { new_test_ext().execute_with(|| { - let admin = ADMIN; - let requester = NON_ADMIN; + let requester = OTHER_USER; let deposit = INITIAL_DEPOSIT; - assert_ok!(Signet::initialize( - RuntimeOrigin::root(), - admin, - deposit, - bounded_chain_id(TEST_CHAIN_ID_BYTES.to_vec()) - )); + configure_signet(deposit); let balance_before = Balances::free_balance(requester); let payload = [42u8; 32]; @@ -370,7 +283,7 @@ fn test_sign_request_works() { payload, key_version, deposit, - chain_id: bounded_chain_id(TEST_CHAIN_ID_BYTES.to_vec()).to_vec(), + chain_id: TEST_CHAIN_ID_BYTES.to_vec(), path: path.to_vec(), algo: algo.to_vec(), dest: dest.to_vec(), @@ -384,16 +297,10 @@ fn test_sign_request_works() { #[test] fn test_sign_request_insufficient_balance() { new_test_ext().execute_with(|| { - let admin = ADMIN; let poor_user = POOR_USER; - let deposit = INSUFFICIENT_BALANCE_DEPOSIT; + let deposit = 100_000u128; - assert_ok!(Signet::initialize( - RuntimeOrigin::root(), - admin, - deposit, - bounded_chain_id(TEST_CHAIN_ID_BYTES.to_vec()) - )); + configure_signet(deposit); assert_noop!( Signet::sign( @@ -410,38 +317,14 @@ fn test_sign_request_insufficient_balance() { }); } -#[test] -fn test_sign_request_before_initialization() { - new_test_ext().execute_with(|| { - assert_noop!( - Signet::sign( - RuntimeOrigin::signed(ADMIN), - [0u8; 32], - 1, - bounded_u8::<256>(b"path".to_vec()), - bounded_u8::<32>(b"algo".to_vec()), - bounded_u8::<64>(b"dest".to_vec()), - bounded_u8::<1024>(b"params".to_vec()) - ), - Error::::NotInitialized - ); - }); -} - #[test] fn test_multiple_sign_requests() { new_test_ext().execute_with(|| { - let admin = ADMIN; - let requester1 = ADMIN; - let requester2 = NON_ADMIN; + let requester1 = REQUESTER; + let requester2 = OTHER_USER; let deposit = INITIAL_DEPOSIT; - assert_ok!(Signet::initialize( - RuntimeOrigin::root(), - admin, - deposit, - bounded_chain_id(TEST_CHAIN_ID_BYTES.to_vec()) - )); + configure_signet(deposit); let pallet_account = Signet::account_id(); @@ -474,16 +357,10 @@ fn test_multiple_sign_requests() { #[test] fn test_sign_bidirectional_works() { new_test_ext().execute_with(|| { - let admin = ADMIN; - let requester = NON_ADMIN; + let requester = OTHER_USER; let deposit = INITIAL_DEPOSIT; - assert_ok!(Signet::initialize( - RuntimeOrigin::root(), - admin, - deposit, - bounded_chain_id(TEST_CHAIN_ID_BYTES.to_vec()) - )); + configure_signet(deposit); let tx_data = b"mock_transaction_data".to_vec(); let caip2_id = CAIP2_SEPOLIA; @@ -515,10 +392,9 @@ fn test_sign_bidirectional_works() { #[test] fn test_sign_bidirectional_empty_transaction_fails() { new_test_ext().execute_with(|| { - let admin = ADMIN; - let requester = NON_ADMIN; + let requester = OTHER_USER; - init_signet(admin, INITIAL_DEPOSIT); + configure_signet(INITIAL_DEPOSIT); assert_noop!( Signet::sign_bidirectional( @@ -538,10 +414,14 @@ fn test_sign_bidirectional_empty_transaction_fails() { }); } +// ----------------------------------------------------------------------------- +// Respond tests +// ----------------------------------------------------------------------------- + #[test] fn test_respond_single() { new_test_ext().execute_with(|| { - let responder = ADMIN; + let responder = REQUESTER; let request_id = [99u8; 32]; let signature = create_test_signature(); @@ -565,7 +445,7 @@ fn test_respond_single() { #[test] fn test_respond_batch() { new_test_ext().execute_with(|| { - let responder = ADMIN; + let responder = REQUESTER; let request_ids = vec![[1u8; 32], [2u8; 32], [3u8; 32]]; let signatures = vec![ create_test_signature(), @@ -591,7 +471,7 @@ fn test_respond_batch() { #[test] fn test_respond_mismatched_arrays_fails() { new_test_ext().execute_with(|| { - let responder = ADMIN; + let responder = REQUESTER; assert_noop!( Signet::respond( @@ -611,7 +491,7 @@ fn test_respond_mismatched_arrays_fails() { #[test] fn test_respond_error_single() { new_test_ext().execute_with(|| { - let responder = ADMIN; + let responder = REQUESTER; let error_response = ErrorResponse { request_id: [99u8; 32], error_message: bounded_u8::<1024>(b"Signature generation failed".to_vec()), @@ -636,7 +516,7 @@ fn test_respond_error_single() { #[test] fn test_respond_error_batch() { new_test_ext().execute_with(|| { - let responder = ADMIN; + let responder = REQUESTER; let errors = vec![ ErrorResponse { request_id: [1u8; 32], @@ -665,7 +545,7 @@ fn test_respond_error_batch() { #[test] fn test_respond_bidirectional() { new_test_ext().execute_with(|| { - let responder = ADMIN; + let responder = REQUESTER; let request_id = [99u8; 32]; let output = b"read_output_data".to_vec(); let signature = create_test_signature(); @@ -692,15 +572,15 @@ fn test_respond_bidirectional() { #[test] fn test_sign_includes_chain_id() { new_test_ext().execute_with(|| { - let admin = ADMIN; - let requester = NON_ADMIN; - let chain_id = bounded_chain_id(HYDRADX_CHAIN_ID_BYTES.to_vec()); + let requester = OTHER_USER; + let chain_id_bytes = HYDRADX_CHAIN_ID_BYTES; - assert_ok!(Signet::initialize( + assert_ok!(Signet::set_config( RuntimeOrigin::root(), - admin, INITIAL_DEPOSIT, - chain_id.clone() + 128, + 100_000, + bounded_u8::<128>(chain_id_bytes.to_vec()), )); assert_ok!(Signet::sign( @@ -726,22 +606,21 @@ fn test_sign_includes_chain_id() { } }); - assert_eq!(sign_event, Some(chain_id.to_vec())); + assert_eq!(sign_event, Some(chain_id_bytes.to_vec())); }); } #[test] fn test_cross_pallet_execution() { new_test_ext().execute_with(|| { - // Initialize signet first - init_signet(ADMIN, INITIAL_DEPOSIT); + configure_signet(INITIAL_DEPOSIT); // Fund the MockCaller pallet's account let mock_pallet_account: u64 = MockCallerPalletId::get().into_account_truncating(); let _ = Balances::deposit_creating(&mock_pallet_account, PALLET_INITIAL_BALANCE); // User calls MockCaller, which then calls Signet - assert_ok!(MockCaller::call_signet(RuntimeOrigin::signed(NON_ADMIN))); + assert_ok!(MockCaller::call_signet(RuntimeOrigin::signed(OTHER_USER))); // Check the event - the sender should be the PALLET's account System::assert_last_event( @@ -750,7 +629,7 @@ fn test_cross_pallet_execution() { payload: [99u8; 32], key_version: 1, deposit: INITIAL_DEPOSIT, - chain_id: bounded_chain_id(TEST_CHAIN_ID_BYTES.to_vec()).to_vec(), + chain_id: TEST_CHAIN_ID_BYTES.to_vec(), path: b"from_pallet".to_vec(), algo: b"ecdsa".to_vec(), dest: b"".to_vec(), @@ -764,11 +643,5 @@ fn test_cross_pallet_execution() { Balances::free_balance(mock_pallet_account), PALLET_INITIAL_BALANCE - INITIAL_DEPOSIT ); - - println!("✅ Cross-pallet test passed!"); - println!(" User {NON_ADMIN} called MockCaller"); - println!(" MockCaller called Signet"); - println!(" Signet saw sender as: {mock_pallet_account:?} (the pallet account)",); - println!(" NOT as: {NON_ADMIN} (the original user)"); }); } diff --git a/pallets/signet/src/tests/utils.rs b/pallets/signet/src/tests/utils.rs index 2183d0f28..7e2a85568 100644 --- a/pallets/signet/src/tests/utils.rs +++ b/pallets/signet/src/tests/utils.rs @@ -1,4 +1,4 @@ -use crate::{tests::MaxChainIdLength, AffinePoint, ErrorResponse, Signature}; +use crate::{AffinePoint, ErrorResponse, Signature}; use sp_core::ConstU32; use sp_runtime::BoundedVec; @@ -18,10 +18,6 @@ pub fn bounded_err(v: Vec) -> BoundedVec) -> BoundedVec { - BoundedVec::try_from(v).unwrap() -} - pub fn create_test_signature() -> Signature { Signature { big_r: AffinePoint { diff --git a/pallets/signet/src/types.rs b/pallets/signet/src/types.rs index 836cde994..834d61851 100644 --- a/pallets/signet/src/types.rs +++ b/pallets/signet/src/types.rs @@ -1,12 +1,13 @@ use frame_support::weights::Weight; pub trait WeightInfo { - fn initialize() -> Weight; - fn update_deposit() -> Weight; + fn set_config() -> Weight; fn withdraw_funds() -> Weight; fn sign() -> Weight; fn sign_bidirectional() -> Weight; fn respond() -> Weight; fn respond_error() -> Weight; fn respond_bidirectional() -> Weight; + fn pause() -> Weight; + fn unpause() -> Weight; } diff --git a/pallets/signet/src/weights.rs b/pallets/signet/src/weights.rs index ed6ea63c7..5f4011784 100644 --- a/pallets/signet/src/weights.rs +++ b/pallets/signet/src/weights.rs @@ -37,106 +37,52 @@ use core::marker::PhantomData; /// Weight functions for `pallet_signet`. pub struct WeightInfo(PhantomData); impl crate::WeightInfo for WeightInfo { - /// Storage: `Signet::Admin` (r:1 w:1) - /// Proof: `Signet::Admin` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `Signet::SignatureDeposit` (r:0 w:1) - /// Proof: `Signet::SignatureDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - /// Storage: `Signet::ChainId` (r:0 w:1) - /// Proof: `Signet::ChainId` (`max_values`: Some(1), `max_size`: Some(130), added: 625, mode: `MaxEncodedLen`) - fn initialize() -> Weight { - // Proof Size summary in bytes: - // Measured: `4` - // Estimated: `1517` - // Minimum execution time: 8_000_000 picoseconds. + fn set_config() -> Weight { Weight::from_parts(9_000_000, 0) .saturating_add(Weight::from_parts(0, 1517)) .saturating_add(T::DbWeight::get().reads(1)) - .saturating_add(T::DbWeight::get().writes(3)) - } - /// Storage: `Signet::Admin` (r:1 w:0) - /// Proof: `Signet::Admin` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `Signet::SignatureDeposit` (r:1 w:1) - /// Proof: `Signet::SignatureDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - fn update_deposit() -> Weight { - // Proof Size summary in bytes: - // Measured: `128` - // Estimated: `1517` - // Minimum execution time: 10_000_000 picoseconds. - Weight::from_parts(11_000_000, 0) - .saturating_add(Weight::from_parts(0, 1517)) - .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) } - /// Storage: `Signet::Admin` (r:1 w:0) - /// Proof: `Signet::Admin` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:1 w:1) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn withdraw_funds() -> Weight { - // Proof Size summary in bytes: - // Measured: `299` - // Estimated: `3593` - // Minimum execution time: 52_000_000 picoseconds. Weight::from_parts(53_000_000, 0) .saturating_add(Weight::from_parts(0, 3593)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) } - /// Storage: `Signet::Admin` (r:1 w:0) - /// Proof: `Signet::Admin` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `Signet::SignatureDeposit` (r:1 w:0) - /// Proof: `Signet::SignatureDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:1 w:1) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `Signet::ChainId` (r:1 w:0) - /// Proof: `Signet::ChainId` (`max_values`: Some(1), `max_size`: Some(130), added: 625, mode: `MaxEncodedLen`) fn sign() -> Weight { - // Proof Size summary in bytes: - // Measured: `167` - // Estimated: `3593` - // Minimum execution time: 52_000_000 picoseconds. Weight::from_parts(53_000_000, 0) .saturating_add(Weight::from_parts(0, 3593)) .saturating_add(T::DbWeight::get().reads(4)) .saturating_add(T::DbWeight::get().writes(1)) } - /// Storage: `Signet::Admin` (r:1 w:0) - /// Proof: `Signet::Admin` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `Signet::SignatureDeposit` (r:1 w:0) - /// Proof: `Signet::SignatureDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:1 w:1) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn sign_bidirectional() -> Weight { - // Proof Size summary in bytes: - // Measured: `167` - // Estimated: `3593` - // Minimum execution time: 88_000_000 picoseconds. Weight::from_parts(89_000_000, 0) .saturating_add(Weight::from_parts(0, 3593)) .saturating_add(T::DbWeight::get().reads(3)) .saturating_add(T::DbWeight::get().writes(1)) } fn respond() -> Weight { - // Proof Size summary in bytes: - // Measured: `0` - // Estimated: `0` - // Minimum execution time: 216_000_000 picoseconds. Weight::from_parts(217_000_000, 0) .saturating_add(Weight::from_parts(0, 0)) } fn respond_error() -> Weight { - // Proof Size summary in bytes: - // Measured: `0` - // Estimated: `0` - // Minimum execution time: 291_000_000 picoseconds. Weight::from_parts(296_000_000, 0) .saturating_add(Weight::from_parts(0, 0)) } fn respond_bidirectional() -> Weight { - // Proof Size summary in bytes: - // Measured: `0` - // Estimated: `0` - // Minimum execution time: 36_000_000 picoseconds. Weight::from_parts(36_000_000, 0) .saturating_add(Weight::from_parts(0, 0)) } + fn pause() -> Weight { + Weight::from_parts(9_000_000, 0) + .saturating_add(Weight::from_parts(0, 1517)) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } + fn unpause() -> Weight { + Weight::from_parts(9_000_000, 0) + .saturating_add(Weight::from_parts(0, 1517)) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } } diff --git a/runtime/hydradx/src/assets.rs b/runtime/hydradx/src/assets.rs index e28cb16be..1697ed391 100644 --- a/runtime/hydradx/src/assets.rs +++ b/runtime/hydradx/src/assets.rs @@ -1833,30 +1833,19 @@ impl pallet_hsm::Config for Runtime { parameter_types! { pub const SignetPalletId: PalletId = PalletId(*b"py/signt"); - pub const MaxChainIdLength: u32 = 128; - - pub const MaxEvmDataLength: u32 = 100_000; - pub const MaxSignatureDeposit: Balance = 200_000_000_000_000; } impl pallet_signet::Config for Runtime { type Currency = Balances; type PalletId = SignetPalletId; - type MaxChainIdLength = MaxChainIdLength; type WeightInfo = weights::pallet_signet::HydraWeight; - type MaxDataLength = MaxEvmDataLength; - type UpdateOrigin = EnsureRoot; - type MaxSignatureDeposit = MaxSignatureDeposit; + type UpdateOrigin = EitherOf, TechCommitteeMajority>; } parameter_types! { pub const SigEthPalletId: PalletId = PalletId(*b"py/fucet"); - pub const SigEthFaucetDispenserFee: u128 = 5_000; - pub const SigEthFaucetMaxDispense: u128 = 1_000_000_000_000_000_000; - pub const SigEthFaucetMinRequest: u64 = 0; pub const SigEthFaucetFeeAssetId: AssetId = 0; pub const SigEthFaucetFaucetAssetId: AssetId = 20; - pub const SigEthMinFaucetThreshold: u128 = 50_000_000_000_000_000u128; } // Treasury as the fee receiver (reuses the Treasury pallet account) @@ -1867,26 +1856,52 @@ impl frame_support::traits::Get for SigEthFaucetTreasuryAccount { } } -pub struct SigEthFaucetContractAddr; -impl frame_support::traits::Get for SigEthFaucetContractAddr { - fn get() -> EvmAddress { - // 0x189d33ea9A9701fdb67C21df7420868193dcf578 - EvmAddress::from(hex_literal::hex!("189d33ea9A9701fdb67C21df7420868193dcf578")) - } -} - impl pallet_dispenser::Config for Runtime { + type UpdateOrigin = EitherOf, TechCommitteeMajority>; type Currency = FungibleCurrencies; - type MinimumRequestAmount = SigEthFaucetMinRequest; - type MaxDispenseAmount = SigEthFaucetMaxDispense; - type DispenserFee = SigEthFaucetDispenserFee; type FeeAsset = SigEthFaucetFeeAssetId; type FaucetAsset = SigEthFaucetFaucetAssetId; type FeeDestination = SigEthFaucetTreasuryAccount; - type FaucetAddress = SigEthFaucetContractAddr; type PalletId = SigEthPalletId; - type MinFaucetEthThreshold = SigEthMinFaucetThreshold; type WeightInfo = weights::pallet_dispenser::HydraWeight; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = DispenserBenchmarkHelper; +} + +#[cfg(feature = "runtime-benchmarks")] +pub struct DispenserBenchmarkHelper; + +#[cfg(feature = "runtime-benchmarks")] +impl pallet_dispenser::BenchmarkHelper for DispenserBenchmarkHelper { + fn register_asset(asset_id: AssetId, min_balance: Balance) -> DispatchResult { + if ::exists(asset_id) { + return Ok(()); + } + let name: BoundedVec = asset_id + .to_le_bytes() + .to_vec() + .try_into() + .map_err(|_| DispatchError::Other("BoundedConversionFailed"))?; + with_transaction(|| { + TransactionOutcome::Commit(AssetRegistry::register_sufficient_asset( + Some(asset_id), + Some(name), + AssetKind::Token, + min_balance, + None, + None, + None, + None, + )) + })?; + Ok(()) + } + + fn mint(asset_id: AssetId, who: &AccountId, amount: Balance) -> DispatchResult { + use frame_support::traits::fungibles::Mutate; + as Mutate>::mint_into(asset_id, who, amount)?; + Ok(()) + } } pub struct ConvertViaOmnipool(PhantomData); diff --git a/runtime/hydradx/src/weights/pallet_dispenser.rs b/runtime/hydradx/src/weights/pallet_dispenser.rs index 7c1c0f247..2901dcc42 100644 --- a/runtime/hydradx/src/weights/pallet_dispenser.rs +++ b/runtime/hydradx/src/weights/pallet_dispenser.rs @@ -18,14 +18,14 @@ //! Autogenerated weights for `pallet_dispenser` //! -//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 -//! DATE: 2026-03-17, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 48.0.0 +//! DATE: 2026-03-09, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `bench-bot`, CPU: `Intel(R) Core(TM) i7-7700K CPU @ 4.20GHz` +//! HOSTNAME: `Yashs-MacBook-Pro.local`, CPU: `` //! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` // Executed Command: -// ./bin/hydradx +// ./target/release/hydradx // benchmark // pallet // --wasm-execution=compiled @@ -60,43 +60,43 @@ pub struct WeightInfo(PhantomData); /// Weights for `pallet_dispenser` using the HydraDX node and recommended hardware. pub struct HydraWeight(PhantomData); impl pallet_dispenser::WeightInfo for HydraWeight { - /// Storage: `EthDispenser::FaucetBalanceWei` (r:1 w:1) - /// Proof: `EthDispenser::FaucetBalanceWei` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - fn set_faucet_balance() -> Weight { + /// Storage: `EthDispenser::DispenserConfig` (r:1 w:1) + /// Proof: `EthDispenser::DispenserConfig` (`max_values`: Some(1), `max_size`: Some(101), added: 596, mode: `MaxEncodedLen`) + fn set_config() -> Weight { // Proof Size summary in bytes: // Measured: `232` // Estimated: `1501` - // Minimum execution time: 12_852_000 picoseconds. - Weight::from_parts(13_125_000, 1501) + // Minimum execution time: 12_496_000 picoseconds. + Weight::from_parts(12_645_000, 1501) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } /// Storage: `EthDispenser::DispenserConfig` (r:1 w:1) - /// Proof: `EthDispenser::DispenserConfig` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) + /// Proof: `EthDispenser::DispenserConfig` (`max_values`: Some(1), `max_size`: Some(101), added: 596, mode: `MaxEncodedLen`) fn pause() -> Weight { // Proof Size summary in bytes: // Measured: `232` // Estimated: `1486` - // Minimum execution time: 13_380_000 picoseconds. - Weight::from_parts(13_745_000, 1486) + // Minimum execution time: 12_981_000 picoseconds. + Weight::from_parts(13_290_000, 1486) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } /// Storage: `EthDispenser::DispenserConfig` (r:1 w:1) - /// Proof: `EthDispenser::DispenserConfig` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) + /// Proof: `EthDispenser::DispenserConfig` (`max_values`: Some(1), `max_size`: Some(101), added: 596, mode: `MaxEncodedLen`) fn unpause() -> Weight { // Proof Size summary in bytes: // Measured: `232` // Estimated: `1486` - // Minimum execution time: 13_537_000 picoseconds. - Weight::from_parts(13_878_000, 1486) + // Minimum execution time: 12_933_000 picoseconds. + Weight::from_parts(13_220_000, 1486) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } - /// Storage: `EthDispenser::DispenserConfig` (r:1 w:0) - /// Proof: `EthDispenser::DispenserConfig` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) - /// Storage: `EthDispenser::FaucetBalanceWei` (r:1 w:1) - /// Proof: `EthDispenser::FaucetBalanceWei` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `EthDispenser::DispenserConfig` (r:1 w:1) + /// Proof: `EthDispenser::DispenserConfig` (`max_values`: Some(1), `max_size`: Some(101), added: 596, mode: `MaxEncodedLen`) + /// Storage: `Signet::SignetConfig` (r:1 w:0) + /// Proof: `Signet::SignetConfig` (`max_values`: Some(1), `max_size`: Some(155), added: 650, mode: `MaxEncodedLen`) /// Storage: `EthDispenser::UsedRequestIds` (r:1 w:1) /// Proof: `EthDispenser::UsedRequestIds` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) /// Storage: `AssetRegistry::Assets` (r:2 w:0) @@ -105,10 +105,6 @@ impl pallet_dispenser::WeightInfo for HydraWeight { /// Proof: `Tokens::Accounts` (`max_values`: None, `max_size`: Some(108), added: 2583, mode: `MaxEncodedLen`) /// Storage: `System::Account` (r:3 w:3) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `CircuitBreaker::GlobalAssetOverrides` (r:2 w:0) - /// Proof: `CircuitBreaker::GlobalAssetOverrides` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) - /// Storage: `CircuitBreaker::EgressAccounts` (r:2 w:0) - /// Proof: `CircuitBreaker::EgressAccounts` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) /// Storage: `EVMAccounts::AccountExtension` (r:1 w:0) /// Proof: `EVMAccounts::AccountExtension` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) /// Storage: `HSM::FlashMinter` (r:1 w:0) @@ -117,27 +113,17 @@ impl pallet_dispenser::WeightInfo for HydraWeight { /// Proof: `Duster::AccountWhitelist` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) /// Storage: `AssetRegistry::BannedAssets` (r:1 w:0) /// Proof: `AssetRegistry::BannedAssets` (`max_values`: None, `max_size`: Some(20), added: 2495, mode: `MaxEncodedLen`) - /// Storage: `MultiTransactionPayment::AccountCurrencyMap` (r:2 w:0) + /// Storage: `MultiTransactionPayment::AccountCurrencyMap` (r:1 w:0) /// Proof: `MultiTransactionPayment::AccountCurrencyMap` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) - /// Storage: `Balances::Locks` (r:1 w:1) - /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) - /// Storage: `Balances::Freezes` (r:1 w:0) - /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) - /// Storage: `AssetRegistry::ExistentialDepositCounter` (r:1 w:1) - /// Proof: `AssetRegistry::ExistentialDepositCounter` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) /// Storage: `MultiTransactionPayment::AcceptedCurrencies` (r:1 w:0) /// Proof: `MultiTransactionPayment::AcceptedCurrencies` (`max_values`: None, `max_size`: Some(28), added: 2503, mode: `MaxEncodedLen`) - /// Storage: `Signet::Admin` (r:1 w:0) - /// Proof: `Signet::Admin` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `Signet::SignatureDeposit` (r:1 w:0) - /// Proof: `Signet::SignatureDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) fn request_fund() -> Weight { // Proof Size summary in bytes: - // Measured: `1759` + // Measured: `1792` // Estimated: `8799` - // Minimum execution time: 537_078_000 picoseconds. - Weight::from_parts(541_027_000, 8799) - .saturating_add(T::DbWeight::get().reads(26_u64)) + // Minimum execution time: 470_146_000 picoseconds. + Weight::from_parts(471_759_000, 8799) + .saturating_add(T::DbWeight::get().reads(22_u64)) .saturating_add(T::DbWeight::get().writes(9_u64)) } } \ No newline at end of file diff --git a/runtime/hydradx/src/weights/pallet_signet.rs b/runtime/hydradx/src/weights/pallet_signet.rs index 13cb2601a..694556bf0 100644 --- a/runtime/hydradx/src/weights/pallet_signet.rs +++ b/runtime/hydradx/src/weights/pallet_signet.rs @@ -24,27 +24,6 @@ //! HOSTNAME: `bench-bot`, CPU: `Intel(R) Core(TM) i7-7700K CPU @ 4.20GHz` //! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` -// Executed Command: -// ./bin/hydradx -// benchmark -// pallet -// --wasm-execution=compiled -// --pallet -// pallet_signet -// --extrinsic -// * -// --heap-pages -// 4096 -// --steps -// 50 -// --repeat -// 20 -// --template -// scripts/pallet-weight-template.hbs -// --output -// runtime/hydradx/src/weights/pallet_signet.rs -// --quiet - #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] #![allow(unused_imports)] @@ -60,76 +39,39 @@ pub struct WeightInfo(PhantomData); /// Weights for `pallet_signet` using the HydraDX node and recommended hardware. pub struct HydraWeight(PhantomData); impl pallet_signet::WeightInfo for HydraWeight { - /// Storage: `Signet::Admin` (r:1 w:1) - /// Proof: `Signet::Admin` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `Signet::SignatureDeposit` (r:0 w:1) - /// Proof: `Signet::SignatureDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - /// Storage: `Signet::ChainId` (r:0 w:1) - /// Proof: `Signet::ChainId` (`max_values`: Some(1), `max_size`: Some(130), added: 625, mode: `MaxEncodedLen`) - fn initialize() -> Weight { + fn set_config() -> Weight { // Proof Size summary in bytes: // Measured: `4` // Estimated: `1517` - // Minimum execution time: 13_225_000 picoseconds. - Weight::from_parts(13_569_000, 1517) + // Minimum execution time: 12_621_000 picoseconds. + Weight::from_parts(13_044_000, 1517) .saturating_add(T::DbWeight::get().reads(1_u64)) - .saturating_add(T::DbWeight::get().writes(3_u64)) - } - /// Storage: `Signet::Admin` (r:1 w:0) - /// Proof: `Signet::Admin` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `Signet::SignatureDeposit` (r:1 w:1) - /// Proof: `Signet::SignatureDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - fn update_deposit() -> Weight { - // Proof Size summary in bytes: - // Measured: `128` - // Estimated: `1517` - // Minimum execution time: 15_218_000 picoseconds. - Weight::from_parts(15_577_000, 1517) - .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } - /// Storage: `Signet::Admin` (r:1 w:0) - /// Proof: `Signet::Admin` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:1 w:1) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn withdraw_funds() -> Weight { // Proof Size summary in bytes: // Measured: `299` // Estimated: `3593` - // Minimum execution time: 65_225_000 picoseconds. - Weight::from_parts(65_776_000, 3593) + // Minimum execution time: 62_011_000 picoseconds. + Weight::from_parts(62_534_000, 3593) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } - /// Storage: `Signet::Admin` (r:1 w:0) - /// Proof: `Signet::Admin` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `Signet::SignatureDeposit` (r:1 w:0) - /// Proof: `Signet::SignatureDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:1 w:1) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `Signet::ChainId` (r:1 w:0) - /// Proof: `Signet::ChainId` (`max_values`: Some(1), `max_size`: Some(130), added: 625, mode: `MaxEncodedLen`) fn sign() -> Weight { // Proof Size summary in bytes: // Measured: `167` // Estimated: `3593` - // Minimum execution time: 69_289_000 picoseconds. - Weight::from_parts(70_025_000, 3593) + // Minimum execution time: 65_063_000 picoseconds. + Weight::from_parts(65_820_000, 3593) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } - /// Storage: `Signet::Admin` (r:1 w:0) - /// Proof: `Signet::Admin` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `Signet::SignatureDeposit` (r:1 w:0) - /// Proof: `Signet::SignatureDeposit` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:1 w:1) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn sign_bidirectional() -> Weight { // Proof Size summary in bytes: // Measured: `167` // Estimated: `3593` - // Minimum execution time: 173_881_000 picoseconds. - Weight::from_parts(175_101_000, 3593) + // Minimum execution time: 162_949_000 picoseconds. + Weight::from_parts(164_844_000, 3593) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -137,21 +79,31 @@ impl pallet_signet::WeightInfo for HydraWeight { // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 225_686_000 picoseconds. - Weight::from_parts(229_243_000, 0) + // Minimum execution time: 215_458_000 picoseconds. + Weight::from_parts(216_808_000, 0) } fn respond_error() -> Weight { // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 337_374_000 picoseconds. - Weight::from_parts(340_383_000, 0) + // Minimum execution time: 321_457_000 picoseconds. + Weight::from_parts(323_288_000, 0) } fn respond_bidirectional() -> Weight { // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 102_088_000 picoseconds. - Weight::from_parts(102_520_000, 0) + // Minimum execution time: 95_250_000 picoseconds. + Weight::from_parts(95_830_000, 0) + } + fn pause() -> Weight { + Weight::from_parts(13_044_000, 1517) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + fn unpause() -> Weight { + Weight::from_parts(13_044_000, 1517) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) } -} \ No newline at end of file +} diff --git a/scripts/dispenser-tests/.env.example b/scripts/dispenser-tests/.env.example index cb96633b3..71b1f73b1 100644 --- a/scripts/dispenser-tests/.env.example +++ b/scripts/dispenser-tests/.env.example @@ -1,28 +1,28 @@ # ============================================= # Dispenser Test Configuration -# Copy to .env and fill in values for your target network. +# Copy to .env and set the two network selectors. +# All other values have sensible defaults in networks.ts. # ============================================= -# ---- Network selector ---- -# One of: local | sepolia | mainnet -NETWORK=local +# ---- Network selection (required) ---- +# Substrate: chopsticks | lark | mainnet +SUBSTRATE_NETWORK=chopsticks +# EVM: anvil | sepolia | mainnet +EVM_NETWORK=anvil -# ---- EVM ---- -EVM_RPC_URL=http://localhost:8545 -EVM_CHAIN_ID=31337 -ROOT_PUBLIC_KEY=0x04... # Uncompressed secp256k1 public key (starts with 0x04) -FAUCET_ADDRESS=0x... # GasFaucet contract address - -# ---- Substrate ---- -SUBSTRATE_WS_ENDPOINT=ws://127.0.0.1:8000 -SUBSTRATE_CHAIN_ID=polkadot:2034 -SS58_PREFIX=0 - -# ---- Test parameters ---- -TARGET_ADDRESS=0x7f67681ce8c292bbbef0ccfa1475d9742b6ab3ac -REQUEST_FUND_AMOUNT_WEI=1000000000000 # 0.000001 ETH - -# ---- Gas (optional, sensible defaults used if omitted) ---- +# ---- Overrides (optional — presets in networks.ts used if omitted) ---- +# SUBSTRATE_WS_ENDPOINT=ws://localhost:8000 +# SUBSTRATE_CHAIN_ID=polkadot:e6b50b06e72a81194e9c96c488175ecd +# SS58_PREFIX=63 +# EVM_RPC_URL=http://localhost:8545 +# EVM_CHAIN_ID=31337 +# ROOT_PUBLIC_KEY=0x04... # Uncompressed secp256k1 MPC root public key +# FAUCET_ADDRESS=0x... # GasFaucet contract address +# TARGET_ADDRESS=0x... # EVM address to receive funds in test +# REQUEST_FUND_AMOUNT_WEI=100000000000000 # Must be above WETH existential deposit (~5.4e12) # GAS_LIMIT=100000 # DEFAULT_MAX_FEE_PER_GAS=30000000000 # DEFAULT_MAX_PRIORITY_FEE_PER_GAS=2000000000 + +# ---- For tc-set-config.ts on real networks ---- +# SURI=//Alice # SR25519 SURI of a TC member (not needed for chopsticks) diff --git a/scripts/dispenser-tests/README.md b/scripts/dispenser-tests/README.md index 2b8d47b26..45ec3ad6e 100644 --- a/scripts/dispenser-tests/README.md +++ b/scripts/dispenser-tests/README.md @@ -1,81 +1,176 @@ -# Signet Substrate Client +# Dispenser E2E Tests -Test client for the Signet pallet on Substrate/Polkadot. Validates signature generation and verification for both simple payloads, EIP-1559 transactions, and ERC20 vault deposits. +End-to-end tests for the `pallet_signet` + `pallet_dispenser` flow: Substrate `requestFund` -> MPC signature -> EVM faucet `fund()` call. ## Prerequisites -- Node.js v16+ and npm/yarn -- Running Substrate node with Signet pallet deployed (port 8000) -- Access to the Signet signature server -- For Dispenser tests: Funded Ethereum Sepolia account with ETH and USDC +- **Substrate node** — one of: + - Chopsticks (local fork of HydraDX mainnet) + - Lark testnet + - HydraDX mainnet +- **EVM node** — one of: + - Anvil (local) + - Sepolia + - Ethereum mainnet +- **MPC response server** running and connected to the substrate node (chopsticks uses `mock-signature-host: true`) +- **GasFaucet contract** deployed on the EVM network +- Node.js + yarn -## Setup - -### 1. Start the Signature Server - -Clone and run the signature server that responds to Substrate signature requests. Add .env to the root of the repository: +## Quick Start (Chopsticks + Anvil) ```bash -# Get to the tests directory cd scripts/dispenser-tests -# Clone the server repository -git clone https://github.com/sig-net/solana-signet-program -cd solana-signet-program/clients/response-server - -# Install dependencies yarn install -# Configure environment variables -cat > .env << EOF -SUBSTRATE_WS_URL=ws://localhost:8000 -SUBSTRATE_SIGNER_SEED=//Bob +# 1. Start chopsticks (separate terminal) +npx @acala-network/chopsticks@latest \ + --config=../../launch-configs/chopsticks/hydradx.yml \ + --wasm-override ../../target/release/wbuild/hydradx-runtime/hydradx_runtime.compact.compressed.wasm \ + --db=:memory: --build-block-mode Instant -PRIVATE_KEY_TESTNET= +# 2. Start Anvil (separate terminal) +anvil -SEPOLIA_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com +# 3. Deploy faucet contract + fund derived address (see pallets/dispenser/contracts/) -# Dummy solana key -SOLANA_PRIVATE_KEY='[16,151,155,240,122,151,187,95,145,26,179,205,196,113,3,62,17,105,18,240,197,176,45,90,176,108,30,106,182,43,7,104,80,202,59,51,239,219,236,17,39,204,155,35,175,195,17,172,201,196,134,125,25,214,148,76,102,47,123,37,203,86,159,147]' -EOF +# 4. Set on-chain configs for Signet + Dispenser +npx ts-node tc-set-config.ts -# Start the server -yarn start +# 5. Run the test +yarn test dispenser.test.ts ``` -The server will: +## Configuration + +### `.env` — Network Selection + +Only two values are required. Everything else has defaults in `networks.ts`. + +```env +SUBSTRATE_NETWORK=chopsticks # chopsticks | lark | mainnet +EVM_NETWORK=anvil # anvil | sepolia | mainnet +``` + +Any preset value can be overridden via env vars (e.g. `EVM_RPC_URL`, `SUBSTRATE_WS_ENDPOINT`, `SUBSTRATE_CHAIN_ID`). See `.env.example` for the full list. + +### Network Presets (`networks.ts`) + +| Substrate | WS Endpoint | CAIP-2 Chain ID | SS58 | +|-----------|-------------|-----------------|------| +| `chopsticks` | `ws://localhost:8000` | `polkadot:e6b50b06e72a81194e9c96c488175ecd` | 63 | +| `lark` | `wss://1.lark.hydration.cloud` | `polkadot:e6b50b06e72a81194e9c96c488175ecd` | 63 | +| `mainnet` | `wss://rpc.hydradx.cloud` | `polkadot:afdc188f45c71dacbaa0b62e16a91f72` | 63 | + +| EVM | RPC URL | Chain ID | +|-----|---------|----------| +| `anvil` | `http://localhost:8545` | 31337 | +| `sepolia` | `https://ethereum-sepolia-rpc.publicnode.com` | 11155111 | +| `mainnet` | `https://eth.llamarpc.com` | 1 | + +### Important: `SUBSTRATE_CHAIN_ID` -- Connect to your Substrate node -- Automatically respond to signature requests -- Monitor Ethereum transactions and report results back to Substrate +This is the CAIP-2 chain identifier stored in the signet on-chain config. It determines **which MPC key is derived** for signing. The test's key derivation and the MPC server must use the same value. -### 2. Install Test Client Dependencies +- Must match what `tc-set-config.ts` wrote to `Signet.SignetConfig.chainId` +- Format: `polkadot:` (NOT `polkadot:`) +- If the derived ETH address doesn't match the MPC signature, this is the first thing to check + +## Setting On-Chain Configs (`tc-set-config.ts`) + +Both `pallet_signet` and `pallet_dispenser` require on-chain configuration before the test can run. `tc-set-config.ts` sets both in one step. + +### Chopsticks + +Writes directly to storage via `dev_setStorage` — no governance needed. ```bash -yarn install +npx ts-node tc-set-config.ts ``` -### 3. Ensure Substrate Node is Running +### Lark / Mainnet -The tests expect a Substrate node with the Signet pallet at `ws://localhost:8000`. If using Chopsticks: +Creates a Technical Committee (TC) proposal. Requires `SURI` of a TC member. ```bash -npx @acala-network/chopsticks@latest --config=hydradx \ - --wasm-override ./target/release/wbuild/hydradx-runtime/hydradx_runtime.compact.compressed.wasm \ - --db=:memory: +SUBSTRATE_NETWORK=lark SURI=//Alice npx ts-node tc-set-config.ts ``` -### 4. Fund Ethereum Account for Vault Tests +If the signer is the **only TC member**, the proposal executes immediately (threshold=1). Otherwise, other TC members must vote Aye. -The Dispenser test requires a funded account on Sepolia. The test derives an Ethereum address from your Substrate account and expects it to have: +### What it configures -- At least 0.001 ETH for gas -- At least 0.01 USDC (testnet) at address +**Signet** (`signet.setConfig`): -The derived address is deterministic based on your Substrate account. Run the test once to see the address, then fund it on Sepolia +| Field | Value | Description | +|-------|-------|-------------| +| `signatureDeposit` | 0.1 HDX | Deposit locked per signing request | +| `maxChainIdLength` | 128 | Max chain ID byte length | +| `maxEvmDataLength` | 100,000 | Max EVM tx data byte length | +| `chainId` | From network preset | CAIP-2 chain ID for MPC key derivation | -## Running Tests +**Dispenser** (`ethDispenser.setConfig`): + +| Field | Value | Description | +|-------|-------|-------------| +| `faucetAddress` | `0x189d33...` | GasFaucet contract on EVM | +| `minFaucetThreshold` | 0.05 ETH | Min remaining ETH after a request | +| `minRequest` | 0 | Min request amount | +| `maxDispense` | 1 ETH | Max request amount | +| `dispenserFee` | 1 HDX | Fee charged per request (must be >= HDX existential deposit) | +| `faucetBalanceWei` | 10 ETH | Tracked faucet balance | + +## MPC Response Server + +Clone and run the MPC response server that listens for `SignBidirectionalRequested` events and responds with signatures. ```bash -# Run all tests -yarn test dispenser.test.ts +# From scripts/dispenser-tests +git clone https://github.com/sig-net/solana-signet-program +cd solana-signet-program/clients/response-server + +# Configure .env +cat > .env << 'EOF' +SUBSTRATE_WS_URL=ws://localhost:8000 +SUBSTRATE_SIGNER_SEED=//Bob +PRIVATE_KEY_TESTNET= +SEPOLIA_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com +SOLANA_PRIVATE_KEY='[16,151,155,240,...,147]' +EOF + +yarn install && yarn start ``` + +On chopsticks with `mock-signature-host: true`, the mock MPC is built in — no separate server needed. + +## Test Flow + +1. **Setup** — fund pallet accounts (dispenser + signet), ensure Alice has WETH, set configs +2. **requestFund** — Alice submits `ethDispenser.requestFund` on substrate + - Charges `dispenserFee` (HDX) and locks WETH collateral to Treasury + - Emits `SignBidirectionalRequested` event for the MPC +3. **MPC signature** — MPC server signs the EVM transaction, emits `SignatureResponded` +4. **Signature verification** — test recovers the signer address and verifies it matches the derived MPC address +5. **EVM broadcast** — signed transaction is broadcast to the EVM network, calling `fund(to, amount)` on the faucet contract +6. **Read response** — MPC reads the EVM tx receipt and emits `RespondBidirectionalEvent` + +## Common Issues + +| Error | Cause | Fix | +|-------|-------|-----| +| `{"token":"BelowMinimum"}` | Transfer amount below existential deposit | Ensure `REQUEST_FUND_AMOUNT_WEI` > WETH ED (~5.4e12), `dispenserFee` >= HDX ED (1e12), and signet pallet account is funded with HDX | +| Signature verification failed | `SUBSTRATE_CHAIN_ID` mismatch | Ensure `.env` chain ID matches the on-chain signet config. Re-run `tc-set-config.ts` if needed | +| `NotConfigured` | Signet or dispenser config not set | Run `npx ts-node tc-set-config.ts` | +| `DuplicateRequest` | Same request ID used twice | Restart chopsticks or wait for nonce to advance | +| Timeout waiting for MPC signature | MPC not running or not connected | Check MPC server logs. On chopsticks, ensure `mock-signature-host: true` in the yml config | + +## File Overview + +| File | Description | +|------|-------------| +| `networks.ts` | Network presets (endpoints, chain IDs, defaults) | +| `env.ts` | Loads `.env`, merges with presets, exports `ENV` | +| `tc-set-config.ts` | Sets signet + dispenser on-chain configs (chopsticks or TC proposal) | +| `dispenser.test.ts` | Main e2e test | +| `signet-client.ts` | Signet pallet helpers (request ID, wait for signature, block scanning) | +| `utils.ts` | Shared helpers (submitWithRetry, executeAsRoot, fund accounts, tip escalation) | +| `key-derivation.ts` | MPC child key derivation (epsilon derivation from root public key) | diff --git a/scripts/dispenser-tests/dispenser.test.ts b/scripts/dispenser-tests/dispenser.test.ts index 8831dbfe0..a0f19e8d3 100644 --- a/scripts/dispenser-tests/dispenser.test.ts +++ b/scripts/dispenser-tests/dispenser.test.ts @@ -1,16 +1,18 @@ import { ApiPromise } from '@polkadot/api' import { waitReady } from '@polkadot/wasm-crypto' -import { u8aToHex } from '@polkadot/util' import { ethers } from 'ethers' import { SignetClient } from './signet-client' import { ENV } from './env' import { + DISPENSER_SIGNING_PATH, submitWithRetry, constructSignedTransaction, waitForReadResponse, createApi, createKeyringAndAccounts, ensureBobHasAssets, + ensureServerSignerFunded, + ensureFaucetMpcAddress, logAliceTokenBalances, fundPalletAccounts, deriveEthAddress, @@ -29,8 +31,8 @@ describe('ERC20 Vault Integration', () => { let evmProvider: ethers.JsonRpcProvider let derivedEthAddress: string let derivedPubKey: string - let aliceHexPath: string let palletSS58: string + let palletSS58Prefix0: string beforeAll(async () => { await waitReady() @@ -43,25 +45,23 @@ describe('ERC20 Vault Integration', () => { console.log( `feeAsset = ${feeAsset}`, `faucetAsset = ${faucetAsset}`, - `faucetAddress = ${api.consts.ethDispenser.faucetAddress.toString()}`, ) const { keyring, alice: aliceAcc, bob } = createKeyringAndAccounts() alice = aliceAcc - const aliceAccountId = keyring.decodeAddress(alice.address) - aliceHexPath = '0x' + u8aToHex(aliceAccountId).slice(2) - await logAliceTokenBalances(api, alice, faucetAsset, feeAsset) await ensureBobHasAssets(api, bob, faucetAsset) + await ensureServerSignerFunded(api, alice, bob) const palletFunding = await fundPalletAccounts(api, alice, faucetAsset) palletSS58 = palletFunding.palletSS58 + palletSS58Prefix0 = palletFunding.palletSS58Prefix0 signetClient = new SignetClient(api, alice) evmProvider = new ethers.JsonRpcProvider(ENV.EVM_RPC_URL) - await signetClient.ensureSignetInitializedViaReferendum( + await signetClient.ensureSignetConfigured( api, alice, ENV.SUBSTRATE_CHAIN_ID, @@ -72,6 +72,7 @@ describe('ERC20 Vault Integration', () => { derivedPubKey = derived.derivedPubKey await ensureDerivedEthHasGas(evmProvider, derivedEthAddress) + await ensureFaucetMpcAddress(evmProvider, derivedEthAddress) }, 600_000) afterAll(async () => { @@ -81,7 +82,7 @@ describe('ERC20 Vault Integration', () => { }) it('should complete full deposit and claim flow', async () => { - await initializeVaultIfNeeded(api) + await initializeVaultIfNeeded(api, alice) const feeData = await evmProvider.getFeeData() const currentNonce = await evmProvider.getTransactionCount( @@ -91,13 +92,18 @@ describe('ERC20 Vault Integration', () => { console.log(`Current nonce for ${derivedEthAddress}: ${currentNonce}`) + // Add a unique component to maxPriorityFeePerGas so each run produces + // a different unsigned tx → different request ID (avoids DuplicateRequest) + const basePriorityFee = Number( + feeData.maxPriorityFeePerGas || ENV.DEFAULT_MAX_PRIORITY_FEE_PER_GAS, + ) + const uniquePriorityFee = basePriorityFee + Math.floor(Math.random() * 1_000_000) + const txParams = { value: 0, gasLimit: Number(ENV.GAS_LIMIT), maxFeePerGas: Number(feeData.maxFeePerGas || ENV.DEFAULT_MAX_FEE_PER_GAS), - maxPriorityFeePerGas: Number( - feeData.maxPriorityFeePerGas || ENV.DEFAULT_MAX_PRIORITY_FEE_PER_GAS, - ), + maxPriorityFeePerGas: uniquePriorityFee, nonce: currentNonce, chainId: ENV.EVM_CHAIN_ID, } @@ -111,6 +117,11 @@ describe('ERC20 Vault Integration', () => { ENV.REQUEST_FUND_AMOUNT, ]) + // Read faucet address from on-chain config (no longer a compile-time constant) + const dispenserCfg = await (api.query as any).ethDispenser.dispenserConfig() + const cfg = dispenserCfg.toJSON() as any + const faucetAddress = cfg?.faucetAddress || ENV.FAUCET_ADDRESS + const tx = ethers.Transaction.from({ type: 2, chainId: txParams.chainId, @@ -118,18 +129,19 @@ describe('ERC20 Vault Integration', () => { maxPriorityFeePerGas: txParams.maxPriorityFeePerGas, maxFeePerGas: txParams.maxFeePerGas, gasLimit: txParams.gasLimit, - to: ENV.FAUCET_ADDRESS, + to: faucetAddress, value: 0, data, }) + // Fixed signing path — all users derive the same MPC key const requestId = signetClient.calculateSignRespondRequestId( - palletSS58, + palletSS58Prefix0, Array.from(ethers.getBytes(tx.unsignedSerialized)), { caip2_id: `eip155:${ENV.EVM_CHAIN_ID}`, keyVersion: 0, - path: aliceHexPath, + path: DISPENSER_SIGNING_PATH, algo: 'ecdsa', dest: 'ethereum', params: '', @@ -166,13 +178,7 @@ describe('ERC20 Vault Integration', () => { `Found ${signetEvents.length} SignBidirectionalRequested event(s)`, ) - if (signetEvents.length > 0) { - console.log( - 'SignBidirectionalRequested event emitted - MPC should pick it up', - ) - } else { - console.log('No SignBidirectionalRequested event found!') - } + expect(signetEvents.length).toBeGreaterThan(0) console.log('Waiting for MPC signature...') @@ -208,7 +214,8 @@ describe('ERC20 Vault Integration', () => { `Signature verification failed!\n` + ` Expected: ${derivedEthAddress}\n` + ` Recovered: ${recoveredAddress}\n` + - ` This means the MPC signed with the wrong key or recovery ID is incorrect.`, + ` On chopsticks with mock-signature-host the mock MPC returns a dummy signature.\n` + + ` Run against a real network (lark/mainnet) with an actual MPC for the full flow.`, ) } @@ -230,13 +237,15 @@ describe('ERC20 Vault Integration', () => { console.log(` Tx Hash: ${txResponse.hash}`) const receipt = await txResponse.wait() - console.log(`Transaction confirmed in block ${receipt?.blockNumber}\n`) + expect(receipt).not.toBeNull() + expect(receipt!.status).toBe(1) + console.log(`Transaction confirmed in block ${receipt!.blockNumber}\n`) console.log('Waiting for MPC to read transaction result...') const readResponse = await waitForReadResponse( api, ethers.hexlify(requestId), - 60_000, + 180_000, ) if (!readResponse) { @@ -251,5 +260,5 @@ describe('ERC20 Vault Integration', () => { ' Output (hex):', Buffer.from(readResponse.output).toString('hex'), ) - }, 180_000) + }, 1_200_000) }) diff --git a/scripts/dispenser-tests/env.ts b/scripts/dispenser-tests/env.ts index d08d2ca05..134e8310b 100644 --- a/scripts/dispenser-tests/env.ts +++ b/scripts/dispenser-tests/env.ts @@ -1,88 +1,108 @@ import { config } from 'dotenv' import { ethers } from 'ethers' import path from 'path' +import { + SUBSTRATE_PRESETS, + EVM_PRESETS, + DEFAULT_ROOT_PUBLIC_KEY, + DEFAULT_FAUCET_ADDRESS, + DEFAULT_TARGET_ADDRESS, + DEFAULT_REQUEST_FUND_AMOUNT_WEI, + type SubstrateNetwork, + type EvmNetwork, +} from './networks' config({ path: path.resolve(__dirname, '.env') }) -function required(key: string): string { - const value = process.env[key] - if (!value) { - throw new Error(`Missing required env variable: ${key}`) - } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function env(key: string): string | undefined { + return process.env[key] +} + +function envRequired(key: string): string { + const value = env(key) + if (!value) throw new Error(`Missing required env variable: ${key}`) return value } -function optionalInt(key: string, fallback: number): number { - const value = process.env[key] +function envInt(key: string, fallback: number): number { + const value = env(key) if (!value) return fallback const parsed = parseInt(value, 10) - if (isNaN(parsed)) { - throw new Error(`Env variable ${key} must be an integer, got: ${value}`) - } + if (isNaN(parsed)) throw new Error(`${key} must be an integer, got: ${value}`) return parsed } -function optionalBigInt(key: string, fallback: bigint): bigint { - const value = process.env[key] +function envBigInt(key: string, fallback: bigint): bigint { + const value = env(key) if (!value) return fallback try { return BigInt(value) } catch { - throw new Error(`Env variable ${key} must be a bigint, got: ${value}`) + throw new Error(`${key} must be a bigint, got: ${value}`) } } -function validateEthAddress(key: string, value: string): string { - if (!ethers.isAddress(value)) { - throw new Error(`Env variable ${key} is not a valid Ethereum address: ${value}`) - } - return value -} +// --------------------------------------------------------------------------- +// Load network presets +// --------------------------------------------------------------------------- -function validateUrl(key: string, value: string): string { - try { - new URL(value) - } catch { - throw new Error(`Env variable ${key} is not a valid URL: ${value}`) - } - return value -} +const validSubstrate = Object.keys(SUBSTRATE_PRESETS) +const validEvm = Object.keys(EVM_PRESETS) -function validateHexKey(key: string, value: string): string { - if (!/^0x[0-9a-fA-F]+$/.test(value)) { - throw new Error(`Env variable ${key} is not valid hex: ${value}`) - } - return value +const SUBSTRATE_NETWORK = envRequired('SUBSTRATE_NETWORK') as SubstrateNetwork +if (!validSubstrate.includes(SUBSTRATE_NETWORK)) { + throw new Error( + `SUBSTRATE_NETWORK must be one of: ${validSubstrate.join(', ')}. Got: ${SUBSTRATE_NETWORK}`, + ) } -// --- Load and validate --- - -const NETWORK = required('NETWORK') // 'local' | 'sepolia' | 'mainnet' -const validNetworks = ['local', 'sepolia', 'mainnet'] as const -if (!validNetworks.includes(NETWORK as any)) { +const EVM_NETWORK = envRequired('EVM_NETWORK') as EvmNetwork +if (!validEvm.includes(EVM_NETWORK)) { throw new Error( - `NETWORK must be one of: ${validNetworks.join(', ')}. Got: ${NETWORK}`, + `EVM_NETWORK must be one of: ${validEvm.join(', ')}. Got: ${EVM_NETWORK}`, ) } -const EVM_RPC_URL = validateUrl('EVM_RPC_URL', required('EVM_RPC_URL')) -const EVM_CHAIN_ID = optionalInt('EVM_CHAIN_ID', NETWORK === 'sepolia' ? 11155111 : NETWORK === 'mainnet' ? 1 : 31337) -const ROOT_PUBLIC_KEY = validateHexKey('ROOT_PUBLIC_KEY', required('ROOT_PUBLIC_KEY')) -const FAUCET_ADDRESS = validateEthAddress('FAUCET_ADDRESS', required('FAUCET_ADDRESS')) +const substrate = SUBSTRATE_PRESETS[SUBSTRATE_NETWORK] +const evm = EVM_PRESETS[EVM_NETWORK] + +// --------------------------------------------------------------------------- +// Resolve final values (env overrides take precedence over presets) +// --------------------------------------------------------------------------- + +const SUBSTRATE_WS_ENDPOINT = env('SUBSTRATE_WS_ENDPOINT') || substrate.wsEndpoint +const SUBSTRATE_CHAIN_ID = env('SUBSTRATE_CHAIN_ID') || substrate.chainId +const SS58_PREFIX = envInt('SS58_PREFIX', substrate.ss58Prefix) -const SUBSTRATE_WS_ENDPOINT = validateUrl('SUBSTRATE_WS_ENDPOINT', required('SUBSTRATE_WS_ENDPOINT')) -const SUBSTRATE_CHAIN_ID = required('SUBSTRATE_CHAIN_ID') // e.g. 'polkadot:2034' -const SS58_PREFIX = optionalInt('SS58_PREFIX', 0) +const EVM_RPC_URL = env('EVM_RPC_URL') || evm.rpcUrl +const EVM_CHAIN_ID = envInt('EVM_CHAIN_ID', evm.chainId) -const TARGET_ADDRESS = validateEthAddress('TARGET_ADDRESS', required('TARGET_ADDRESS')) -const REQUEST_FUND_AMOUNT = optionalBigInt('REQUEST_FUND_AMOUNT_WEI', 1_000_000_000_000n) // 0.000001 ETH +const ROOT_PUBLIC_KEY = env('ROOT_PUBLIC_KEY') || DEFAULT_ROOT_PUBLIC_KEY +const FAUCET_ADDRESS = env('FAUCET_ADDRESS') || DEFAULT_FAUCET_ADDRESS +const TARGET_ADDRESS = env('TARGET_ADDRESS') || DEFAULT_TARGET_ADDRESS +const REQUEST_FUND_AMOUNT = envBigInt('REQUEST_FUND_AMOUNT_WEI', DEFAULT_REQUEST_FUND_AMOUNT_WEI) -const GAS_LIMIT = optionalBigInt('GAS_LIMIT', 100_000n) -const DEFAULT_MAX_FEE_PER_GAS = optionalBigInt('DEFAULT_MAX_FEE_PER_GAS', 30_000_000_000n) -const DEFAULT_MAX_PRIORITY_FEE_PER_GAS = optionalBigInt('DEFAULT_MAX_PRIORITY_FEE_PER_GAS', 2_000_000_000n) +const GAS_LIMIT = envBigInt('GAS_LIMIT', 100_000n) +const DEFAULT_MAX_FEE_PER_GAS = envBigInt('DEFAULT_MAX_FEE_PER_GAS', 30_000_000_000n) +const DEFAULT_MAX_PRIORITY_FEE_PER_GAS = envBigInt('DEFAULT_MAX_PRIORITY_FEE_PER_GAS', 2_000_000_000n) + +// Validate critical values +if (!ethers.isAddress(FAUCET_ADDRESS)) throw new Error(`Invalid FAUCET_ADDRESS: ${FAUCET_ADDRESS}`) +if (!ethers.isAddress(TARGET_ADDRESS)) throw new Error(`Invalid TARGET_ADDRESS: ${TARGET_ADDRESS}`) +if (!/^0x[0-9a-fA-F]+$/.test(ROOT_PUBLIC_KEY)) throw new Error(`Invalid ROOT_PUBLIC_KEY`) export const ENV = { - NETWORK: NETWORK as 'local' | 'sepolia' | 'mainnet', + SUBSTRATE_NETWORK, + EVM_NETWORK, + + // Substrate + SUBSTRATE_WS_ENDPOINT, + SUBSTRATE_CHAIN_ID, + SS58_PREFIX, // EVM EVM_RPC_URL, @@ -90,11 +110,6 @@ export const ENV = { ROOT_PUBLIC_KEY, FAUCET_ADDRESS, - // Substrate - SUBSTRATE_WS_ENDPOINT, - SUBSTRATE_CHAIN_ID, - SS58_PREFIX, - // Test params TARGET_ADDRESS, REQUEST_FUND_AMOUNT, @@ -107,11 +122,11 @@ export const ENV = { // Print resolved config on load console.log(`\n--- Dispenser Test Config ---`) -console.log(` Network: ${ENV.NETWORK}`) -console.log(` EVM RPC: ${ENV.EVM_RPC_URL}`) +console.log(` Substrate: ${ENV.SUBSTRATE_NETWORK} (${ENV.SUBSTRATE_WS_ENDPOINT})`) +console.log(` EVM: ${ENV.EVM_NETWORK} (${ENV.EVM_RPC_URL})`) +console.log(` Chain ID (CAIP2): ${ENV.SUBSTRATE_CHAIN_ID}`) console.log(` EVM Chain ID: ${ENV.EVM_CHAIN_ID}`) console.log(` Faucet contract: ${ENV.FAUCET_ADDRESS}`) -console.log(` Substrate WS: ${ENV.SUBSTRATE_WS_ENDPOINT}`) -console.log(` Substrate Chain: ${ENV.SUBSTRATE_CHAIN_ID}`) console.log(` Target address: ${ENV.TARGET_ADDRESS}`) +console.log(` Request amount: ${ENV.REQUEST_FUND_AMOUNT} wei`) console.log(`----------------------------\n`) diff --git a/scripts/dispenser-tests/networks.ts b/scripts/dispenser-tests/networks.ts new file mode 100644 index 000000000..d59df4886 --- /dev/null +++ b/scripts/dispenser-tests/networks.ts @@ -0,0 +1,74 @@ +// --------------------------------------------------------------------------- +// Network presets — define SUBSTRATE_NETWORK + EVM_NETWORK in .env and +// everything else is derived automatically. Any value can be overridden +// via env vars. +// --------------------------------------------------------------------------- + +export type SubstrateNetwork = 'chopsticks' | 'lark' | 'mainnet' +export type EvmNetwork = 'anvil' | 'sepolia' | 'mainnet' + +export interface SubstratePreset { + wsEndpoint: string + /** CAIP-2 chain ID used for MPC key derivation (must match signet on-chain config) */ + chainId: string + ss58Prefix: number +} + +export interface EvmPreset { + rpcUrl: string + chainId: number +} + +// ---- Substrate presets ---- + +export const SUBSTRATE_PRESETS: Record = { + chopsticks: { + wsEndpoint: 'ws://localhost:8000', + // Chopsticks forks mainnet but tc-set-config.ts writes the lark chain ID + // by default. Override with SUBSTRATE_CHAIN_ID if needed. + chainId: 'polkadot:e6b50b06e72a81194e9c96c488175ecd', + ss58Prefix: 63, + }, + lark: { + wsEndpoint: 'wss://1.lark.hydration.cloud', + chainId: 'polkadot:e6b50b06e72a81194e9c96c488175ecd', + ss58Prefix: 63, + }, + mainnet: { + wsEndpoint: 'wss://rpc.hydradx.cloud', + chainId: 'polkadot:afdc188f45c71dacbaa0b62e16a91f72', + ss58Prefix: 63, + }, +} + +// ---- EVM presets ---- + +export const EVM_PRESETS: Record = { + anvil: { + rpcUrl: 'http://localhost:8545', + chainId: 31337, + }, + sepolia: { + rpcUrl: 'https://ethereum-sepolia-rpc.publicnode.com', + chainId: 11155111, + }, + mainnet: { + rpcUrl: 'https://eth.llamarpc.com', + chainId: 1, + }, +} + +// ---- Shared defaults ---- + +/** MPC root public key (uncompressed secp256k1, same across all networks) */ +export const DEFAULT_ROOT_PUBLIC_KEY = + '0x048318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5' + +/** GasFaucet contract address (current deployment) */ +export const DEFAULT_FAUCET_ADDRESS = '0x189d33ea9A9701fdb67C21df7420868193dcf578' + +/** Default test target address */ +export const DEFAULT_TARGET_ADDRESS = '0x7f67681ce8c292bbbef0ccfa1475d9742b6ab3ac' + +/** Default request amount — must be above WETH existential deposit (~5.4e12) */ +export const DEFAULT_REQUEST_FUND_AMOUNT_WEI = 100_000_000_000_000n // 0.0001 ETH diff --git a/scripts/dispenser-tests/package.json b/scripts/dispenser-tests/package.json index 4149e1dd3..c73fae2b8 100644 --- a/scripts/dispenser-tests/package.json +++ b/scripts/dispenser-tests/package.json @@ -14,7 +14,6 @@ "**/*.test.ts" ] }, - "type": "module", "dependencies": { "@polkadot/api": "latest", "@polkadot/keyring": "^12.6.2", diff --git a/scripts/dispenser-tests/signet-client.ts b/scripts/dispenser-tests/signet-client.ts index 747b545b7..60fe4b39e 100644 --- a/scripts/dispenser-tests/signet-client.ts +++ b/scripts/dispenser-tests/signet-client.ts @@ -2,13 +2,9 @@ import { ApiPromise } from '@polkadot/api' import { EventRecord } from '@polkadot/types/interfaces' import { Vec } from '@polkadot/types' import { u8aToHex } from '@polkadot/util' -import { ISubmittableResult } from '@polkadot/types/types' import { ethers } from 'ethers' import { keccak256, recoverAddress } from 'viem' -import { - executeAsRootViaReferendum, - executeAsRootViaScheduler, -} from './utils' +import { executeAsRoot } from './utils' export class SignetClient { constructor( @@ -16,21 +12,35 @@ export class SignetClient { private signer: any, ) {} - async ensureSignetInitializedViaReferendum( + /** + * Ensure Signet is configured. If the config is already set, skip. + * Uses setConfig (no more initialize). + */ + async ensureSignetConfigured( api: ApiPromise, signer: any, chainId: string, ) { + const cfgOpt = await (api.query as any).signet.signetConfig() + const cfg = cfgOpt.toJSON() + if (cfg) { + console.log('Signet already configured, skipping') + return + } + + console.log('Signet not configured, setting config via Root...') const chainIdBytes = Array.from(new TextEncoder().encode(chainId)) - const signetInitCall = api.tx.signet.initialize( - signer.address, - 1_000_000_000_000n, - chainIdBytes, + const setConfigCall = api.tx.signet.setConfig( + 100_000_000_000n, // signature_deposit + 128, // max_chain_id_length + 100_000, // max_evm_data_length + chainIdBytes, // chain_id ) - await executeAsRootViaScheduler( + await executeAsRoot( api, - signetInitCall, - 'Initialize signet via Root', + signer, + setConfigCall, + 'Set signet config via Root', ) } @@ -80,37 +90,98 @@ export class SignetClient { async waitForSignature(requestId: string, timeout: number): Promise { return new Promise((resolve) => { let unsubscribe: any + let resolved = false + const timer = setTimeout(() => { + resolved = true if (unsubscribe) unsubscribe() resolve(null) }, timeout) - this.api.query.system - .events((events: Vec) => { - events.forEach((record: EventRecord) => { - const { event } = record - if ( - event.section === 'signet' && - event.method === 'SignatureResponded' - ) { - const [reqId, responder, signature] = event.data as any - if (u8aToHex(reqId.toU8a()) === requestId) { - clearTimeout(timer) - if (unsubscribe) unsubscribe() - resolve({ - responder: responder.toString(), - signature: signature.toJSON(), - }) + const done = (result: any) => { + if (resolved) return + resolved = true + clearTimeout(timer) + if (unsubscribe) unsubscribe() + resolve(result) + } + + const matchEvents = (events: any[]): any => { + for (const record of events) { + const { event } = record + if ( + event.section === 'signet' && + event.method === 'SignatureResponded' + ) { + const [reqId, responder, signature] = event.data as any + if (u8aToHex(reqId.toU8a()) === requestId) { + return { + responder: responder.toString(), + signature: signature.toJSON(), } } - }) + } + } + return null + } + + // 1. Subscribe to new events (future blocks) + this.api.query.system + .events((events: Vec) => { + const result = matchEvents(Array.from(events)) + if (result) done(result) }) .then((unsub: any) => { unsubscribe = unsub }) + + // 2. Scan recent blocks to catch events emitted before subscription started + this.scanRecentBlocksForEvent( + requestId, + 'SignatureResponded', + 30, + ).then((result) => { + if (result) done(result) + }).catch(() => {}) }) } + private async scanRecentBlocksForEvent( + requestId: string, + method: string, + numBlocks: number, + ): Promise { + try { + const header = await this.api.rpc.chain.getHeader() + const currentBlock = header.number.toNumber() + const startBlock = Math.max(1, currentBlock - numBlocks) + + console.log(`Scanning blocks ${startBlock}..${currentBlock} for ${method}...`) + + for (let i = currentBlock; i >= startBlock; i--) { + const hash = await this.api.rpc.chain.getBlockHash(i) + const events = await this.api.query.system.events.at(hash) as any + + for (const record of events) { + const { event } = record + if (event.section === 'signet' && event.method === method) { + const [reqId, responder, signature] = event.data as any + if (u8aToHex(reqId.toU8a()) === requestId) { + console.log(`Found ${method} in block ${i}`) + return { + responder: responder.toString(), + signature: signature.toJSON(), + } + } + } + } + } + } catch (err) { + console.warn(`Failed to scan recent blocks for ${method}:`, err) + } + return null + } + calculateRequestId( sender: string, payload: Uint8Array, diff --git a/scripts/dispenser-tests/tc-set-config.ts b/scripts/dispenser-tests/tc-set-config.ts new file mode 100644 index 000000000..f3eceb25f --- /dev/null +++ b/scripts/dispenser-tests/tc-set-config.ts @@ -0,0 +1,248 @@ +/** + * Set Signet and Dispenser on-chain configs. + * + * Modes (auto-detected): + * - Chopsticks: writes storage directly via dev_setStorage + * - Real network (lark/mainnet): creates a TC proposal via technicalCommittee.propose() + * + * Usage: + * # Uses SUBSTRATE_NETWORK from .env (defaults to chopsticks) + * npx ts-node tc-set-config.ts + * + * # Override for lark — requires SURI of a TC member + * SUBSTRATE_NETWORK=lark SURI=//Alice npx ts-node tc-set-config.ts + */ + +import { ApiPromise, WsProvider } from '@polkadot/api' +import { Keyring } from '@polkadot/keyring' +import { config } from 'dotenv' +import path from 'path' +import { + SUBSTRATE_PRESETS, + DEFAULT_FAUCET_ADDRESS, + type SubstrateNetwork, +} from './networks' + +config({ path: path.resolve(__dirname, '.env') }) + +// ---- Resolve substrate network ---- + +const networkName = (process.env.SUBSTRATE_NETWORK || 'chopsticks') as SubstrateNetwork +const preset = SUBSTRATE_PRESETS[networkName] +if (!preset) { + console.error(`Unknown SUBSTRATE_NETWORK: ${networkName}`) + process.exit(1) +} +const wsEndpoint = process.env.SUBSTRATE_WS_ENDPOINT || preset.wsEndpoint +const chainId = process.env.SUBSTRATE_CHAIN_ID || preset.chainId + +// ---- Configuration values ---- + +const SIGNET_CONFIG = { + signatureDeposit: 100_000_000_000n, // 0.1 HDX + maxChainIdLength: 128, + maxEvmDataLength: 100_000, + chainId, +} + +const DISPENSER_CONFIG = { + faucetAddress: process.env.FAUCET_ADDRESS || DEFAULT_FAUCET_ADDRESS, + minFaucetThreshold: 50_000_000_000_000_000n, // 0.05 ETH + minRequest: 0n, + maxDispense: 1_000_000_000_000_000_000n, // 1 ETH + dispenserFee: 1_000_000_000_000n, // 1 HDX (12 decimals) + faucetBalanceWei: 10_000_000_000_000_000_000n, // 10 ETH +} + +// ---- Helpers ---- + +async function isChopsticks(api: ApiPromise): Promise { + try { + await (api.rpc as any)('dev_newBlock', { count: 0 }) + return true + } catch { + return false + } +} + +function buildCalls(api: ApiPromise) { + const chainIdBytes = Array.from( + new TextEncoder().encode(SIGNET_CONFIG.chainId), + ) + + const signetCall = api.tx.signet.setConfig( + SIGNET_CONFIG.signatureDeposit.toString(), + SIGNET_CONFIG.maxChainIdLength, + SIGNET_CONFIG.maxEvmDataLength, + chainIdBytes, + ) + + const dispenserCall = (api.tx as any).ethDispenser.setConfig( + DISPENSER_CONFIG.faucetAddress, + DISPENSER_CONFIG.minFaucetThreshold.toString(), + DISPENSER_CONFIG.minRequest.toString(), + DISPENSER_CONFIG.maxDispense.toString(), + DISPENSER_CONFIG.dispenserFee.toString(), + DISPENSER_CONFIG.faucetBalanceWei.toString(), + ) + + return { signetCall, dispenserCall } +} + +// ---- Chopsticks: write config storage directly ---- + +async function executeOnChopsticks(api: ApiPromise) { + const chainIdHex = + '0x' + Buffer.from(SIGNET_CONFIG.chainId).toString('hex') + + console.log('Writing Signet and Dispenser configs directly to storage...') + + await (api.rpc as any)('dev_setStorage', { + Signet: { + SignetConfig: { + paused: false, + signatureDeposit: SIGNET_CONFIG.signatureDeposit.toString(), + maxChainIdLength: SIGNET_CONFIG.maxChainIdLength, + maxEvmDataLength: SIGNET_CONFIG.maxEvmDataLength, + chainId: chainIdHex, + }, + }, + EthDispenser: { + DispenserConfig: { + paused: false, + faucetBalanceWei: DISPENSER_CONFIG.faucetBalanceWei.toString(), + faucetAddress: DISPENSER_CONFIG.faucetAddress, + minFaucetThreshold: DISPENSER_CONFIG.minFaucetThreshold.toString(), + minRequest: DISPENSER_CONFIG.minRequest.toString(), + maxDispense: DISPENSER_CONFIG.maxDispense.toString(), + dispenserFee: DISPENSER_CONFIG.dispenserFee.toString(), + }, + }, + }) + + await (api.rpc as any)('dev_newBlock', { count: 1 }) + console.log('Storage set in new block.') +} + +// ---- Real network: TC proposal ---- + +async function proposeViaTechCommittee(api: ApiPromise) { + const suri = process.env.SURI + if (!suri) { + console.error('Error: SURI env var required for real networks (e.g. SURI=//Alice or SURI="mnemonic words...")') + process.exit(1) + } + + const keyring = new Keyring({ type: 'sr25519' }) + const signer = keyring.addFromUri(suri) + console.log(`Signer (TC member): ${signer.address}`) + + const { signetCall, dispenserCall } = buildCalls(api) + + // Batch both setConfig calls + const batchCall = api.tx.utility.batchAll([signetCall, dispenserCall]) + + // Get TC member count for threshold (majority = floor(n/2) + 1) + const members = await (api.query as any).technicalCommittee.members() + const memberCount = (members.toJSON() as any[]).length + const threshold = Math.floor(memberCount / 2) + 1 + + console.log(`TC members: ${memberCount}, threshold: ${threshold}`) + + // Propose via TC + const lengthBound = batchCall.method.encodedLength + 100 + const proposeTx = (api.tx as any).technicalCommittee.propose( + threshold, + batchCall, + lengthBound, + ) + + console.log('Submitting TC proposal...') + await new Promise((resolve, reject) => { + proposeTx.signAndSend(signer, { nonce: -1 }, (result: any) => { + if (result.dispatchError) { + if (result.dispatchError.isModule) { + const decoded = api.registry.findMetaError(result.dispatchError.asModule) + reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`)) + } else { + reject(new Error(result.dispatchError.toString())) + } + } else if (result.status.isInBlock) { + console.log(`Proposal included in block: ${result.status.asInBlock.toHex()}`) + + // Extract proposal index from events + for (const { event } of result.events) { + if (event.section === 'technicalCommittee' && event.method === 'Proposed') { + const [, , proposalIndex] = event.data + console.log(`Proposal index: ${proposalIndex.toString()}`) + console.log(`Other TC members need to vote Aye on proposal #${proposalIndex}`) + } + } + resolve() + } + }).catch(reject) + }) +} + +// ---- Verify ---- + +async function verifyConfigs(api: ApiPromise, expectSet: boolean) { + console.log('\n--- Verifying configs ---') + + const signetCfg = await (api.query as any).signet.signetConfig() + const signet = signetCfg.toJSON() + console.log('Signet config:', signet) + + const dispenserCfg = await (api.query as any).ethDispenser.dispenserConfig() + const dispenser = dispenserCfg.toJSON() + console.log('Dispenser config:', dispenser) + + if (expectSet) { + if (!signet) { + console.error('ERROR: Signet config is null after storage write!') + process.exit(1) + } + if (!dispenser) { + console.error('ERROR: Dispenser config is null after storage write!') + process.exit(1) + } + if (dispenser.dispenserFee < 1_000_000_000_000) { + console.error(`ERROR: dispenserFee (${dispenser.dispenserFee}) is below HDX existential deposit (1e12)!`) + process.exit(1) + } + console.log('Configs verified.') + } else { + console.log('TC proposal submitted — configs will be set after the proposal passes.') + } +} + +// ---- Main ---- + +async function main() { + console.log(`Network: ${networkName}`) + console.log(`Connecting to ${wsEndpoint}...`) + console.log(`Signet chain ID: ${chainId}`) + + const provider = new WsProvider(wsEndpoint, undefined, undefined, 180_000) + const api = await ApiPromise.create({ provider }) + console.log(`Connected to chain: ${(await api.rpc.system.chain()).toString()}`) + + const chopsticks = await isChopsticks(api) + + if (chopsticks) { + console.log('Mode: Chopsticks (dev_setStorage)\n') + await executeOnChopsticks(api) + await verifyConfigs(api, true) + } else { + console.log('Mode: Real network (TC proposal)\n') + await proposeViaTechCommittee(api) + await verifyConfigs(api, false) + } + await api.disconnect() + console.log('\nDone.') +} + +main().catch((err) => { + console.error('Error:', err) + process.exit(1) +}) diff --git a/scripts/dispenser-tests/utils.ts b/scripts/dispenser-tests/utils.ts index b54fd1cc2..4f010c3b3 100644 --- a/scripts/dispenser-tests/utils.ts +++ b/scripts/dispenser-tests/utils.ts @@ -1,6 +1,7 @@ import { ApiPromise, WsProvider, Keyring } from '@polkadot/api' import { ISubmittableResult } from '@polkadot/types/types' import { encodeAddress } from '@polkadot/keyring' +import { u8aToHex } from '@polkadot/util' import { ethers } from 'ethers' import { KeyDerivation } from './key-derivation' import { blake2AsHex } from '@polkadot/util-crypto' @@ -11,11 +12,44 @@ import { ENV } from './env' export const MIN_BOB_NATIVE_BALANCE = 1 export const PALLET_MIN_NATIVE_BALANCE = 10_000_000_000_000n export const BOB_NATIVE_TOPUP = 100_000_000_000_000n +// Minimum HDX Bob needs to send substrate txs as the MPC server signer +export const SERVER_SIGNER_MIN_BALANCE = 50_000_000_000_000n // 50 HDX +export const SERVER_SIGNER_TOPUP = 200_000_000_000_000n // 200 HDX export const PALLET_FAUCET_FUND = ethers.parseEther('100') export const PALLET_ID_STR = 'py/fucet' export const MODL_PREFIX = 'modl' +// Fixed signing path used by the dispenser pallet for all users +export const DISPENSER_SIGNING_PATH = 'dispenser' + +// Cached result for dev chain detection +let _isDevChain: boolean | null = null + +/** + * Probe whether the connected node supports dev RPCs (chopsticks). + * Tries multiple detection methods. Result is cached for the lifetime of the process. + */ +export async function isDevChain(api: ApiPromise): Promise { + if (_isDevChain !== null) return _isDevChain + // Try dev_setBlockBuildMode which doesn't produce blocks — safest probe + for (const method of ['dev_setBlockBuildMode', 'dev_newBlock']) { + try { + if (method === 'dev_setBlockBuildMode') { + await (api.rpc as any)(method, 'Instant') + } else { + await (api.rpc as any)(method, { count: 1 }) + } + _isDevChain = true + console.log(`Dev chain detected via ${method}`) + return true + } catch {} + } + _isDevChain = false + console.log('Not a dev chain — will use governance for Root calls') + return false +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -29,6 +63,9 @@ export function getPalletAccountId(): Uint8Array { return data } +// Large tip so our transactions always replace stuck pool entries on live chains. +const DEFAULT_TIP = 100_000_000_000_000n // 100 HDX + export async function submitWithRetry( tx: any, signer: any, @@ -38,29 +75,120 @@ export async function submitWithRetry( timeoutMs: number = 600_000, ): Promise<{ events: any[] }> { let attempt = 0 + const dev = await isDevChain(api) while (attempt <= maxRetries) { + const tip = dev ? 0n : DEFAULT_TIP * BigInt(2 ** attempt) + try { - console.log(`${label} - Attempt ${attempt + 1}/${maxRetries + 1}`) + console.log( + `${label} - Attempt ${attempt + 1}/${maxRetries + 1}${tip > 0n ? ` (tip: ${tip})` : ''}`, + ) + + // Capture nonce before submission so we can poll for inclusion as a fallback + const preNonce = dev + ? 0 + : (await api.rpc.system.accountNextIndex(signer.address)).toNumber() const result = await new Promise<{ events: any[] }>((resolve, reject) => { let unsubscribe: any + let resolved = false + let pollTimer: any - const timer = setTimeout(() => { + const cleanup = () => { + resolved = true if (unsubscribe) unsubscribe() + if (pollTimer) clearInterval(pollTimer) + } + + const timer = setTimeout(() => { + cleanup() console.log(`${label} timed out after ${timeoutMs}ms`) reject(new Error('TIMEOUT')) }, timeoutMs) + const clearAll = () => { + clearTimeout(timer) + cleanup() + } + + // Polling fallback: if WS subscription misses InBlock, detect via nonce advance + const startPollFallback = () => { + if (pollTimer || dev) return + pollTimer = setInterval(async () => { + if (resolved) return + try { + const currentNonce = ( + await api.rpc.system.accountNextIndex(signer.address) + ).toNumber() + if (currentNonce > preNonce) { + console.log( + `${label} nonce advanced ${preNonce} → ${currentNonce} (poll fallback)`, + ) + clearAll() + + // Scan recent blocks to find the actual events for our tx + const blockEvents = await scanRecentBlocksForExtrinsicEvents( + api, + signer.address, + preNonce, + ) + + if (blockEvents) { + // Check for dispatch errors in the recovered events + const dispatchError = blockEvents.find( + (r: any) => + r.event.section === 'system' && + r.event.method === 'ExtrinsicFailed', + ) + if (dispatchError) { + const errorData = dispatchError.event.data[0] + if (errorData?.isModule) { + const decoded = api.registry.findMetaError(errorData.asModule) + reject( + new Error( + `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`, + ), + ) + } else { + reject(new Error(`Dispatch error: ${errorData?.toString()}`)) + } + return + } + console.log( + `${label} recovered ${blockEvents.length} events from block scan`, + ) + resolve({ events: blockEvents }) + } else { + console.log(`${label} could not recover events from block scan`) + resolve({ events: [] }) + } + } + } catch {} + }, 6_000) // check every ~1 block + } + + const signingOpts: any = dev + ? { nonce: -1, era: 0 } + : { nonce: -1, tip: tip.toString() } + tx.signAndSend( signer, - { nonce: -1, era: 0 }, + signingOpts, (result: ISubmittableResult) => { + if (resolved) return const { status, events, dispatchError } = result + // Log every status update so we can diagnose hangs + console.log(`${label} status: ${status.type}`) + + if (status.isReady || status.type === 'Ready') { + // Start polling fallback 15s after Ready (live chains only) + setTimeout(() => startPollFallback(), 15_000) + } + if (status.isInBlock) { - clearTimeout(timer) - if (unsubscribe) unsubscribe() + clearAll() console.log( `${label} included in block ${status.asInBlock.toHex()}`, @@ -86,13 +214,11 @@ export async function submitWithRetry( resolve({ events: Array.from(events) }) } else if (status.isInvalid) { - clearTimeout(timer) - if (unsubscribe) unsubscribe() + clearAll() console.log(`${label} marked as Invalid`) reject(new Error('INVALID_TX')) } else if (status.isDropped) { - clearTimeout(timer) - if (unsubscribe) unsubscribe() + clearAll() reject(new Error(`${label} dropped`)) } }, @@ -100,24 +226,28 @@ export async function submitWithRetry( .then((unsub: any) => { unsubscribe = unsub // Produce a block so the transaction gets included on the dev chain - ;(api.rpc as any)('dev_newBlock', { count: 1 }).catch(() => {}) + if (dev) { + ;(api.rpc as any)('dev_newBlock', { count: 1 }).catch(() => {}) + } }) .catch((error: any) => { - clearTimeout(timer) + clearAll() reject(error) }) }) return result } catch (error: any) { - const msg = error.message || '' + const msg = String(error?.message || error) const isRetryable = msg === 'INVALID_TX' || msg === 'TIMEOUT' || msg.includes('1010') || + msg.includes('1014') || + msg.includes('Priority is too low') || msg.includes('payment') if (isRetryable && attempt < maxRetries) { - console.log(`Retrying ${label}...`) + console.log(`Retrying ${label} (will bump tip)...`) attempt++ await new Promise((resolve) => setTimeout(resolve, 2_000)) continue @@ -129,6 +259,62 @@ export async function submitWithRetry( throw new Error(`${label} failed after ${maxRetries + 1} attempts`) } +/** + * When the nonce polling fallback fires, scan recent blocks to find events + * for the extrinsic sent by `sender` with the given `nonce`. + */ +async function scanRecentBlocksForExtrinsicEvents( + api: ApiPromise, + sender: string, + nonce: number, +): Promise { + try { + // Decode sender to raw account ID for reliable comparison + // (SS58 prefix may differ between keyring and chain) + const keyring = new Keyring() + const senderAccountId = u8aToHex(keyring.decodeAddress(sender)) + + const header = await api.rpc.chain.getHeader() + const currentBlock = header.number.toNumber() + const startBlock = Math.max(1, currentBlock - 10) // check last 10 blocks + + for (let i = currentBlock; i >= startBlock; i--) { + const hash = await api.rpc.chain.getBlockHash(i) + const signedBlock = await api.rpc.chain.getBlock(hash) + const allEvents = await api.query.system.events.at(hash) as any + + // Find our extrinsic by matching sender account ID and nonce + const extrinsics = signedBlock.block.extrinsics + for (let extIdx = 0; extIdx < extrinsics.length; extIdx++) { + const ext = extrinsics[extIdx] + if (!ext.isSigned) continue + + const extSignerHex = u8aToHex(keyring.decodeAddress(ext.signer.toString())) + const extNonce = ext.nonce.toNumber() + + if (extSignerHex === senderAccountId && extNonce === nonce) { + // Found our extrinsic — collect its events + const events = allEvents.filter( + (record: any) => + record.phase.isApplyExtrinsic && + record.phase.asApplyExtrinsic.toNumber() === extIdx, + ) + console.log( + `Found extrinsic in block ${i} (idx ${extIdx}) with ${events.length} events`, + ) + return Array.from(events) + } + } + } + console.log( + `Could not find extrinsic (sender=${sender}, nonce=${nonce}) in blocks ${startBlock}..${currentBlock}`, + ) + } catch (err: any) { + console.warn(`Failed to scan blocks for extrinsic events: ${err.message}`) + } + return null +} + export function ethAddressFromPubKey(pubKey: string): string { const hash = ethers.keccak256('0x' + pubKey.slice(4)) return '0x' + hash.slice(-40) @@ -159,45 +345,109 @@ export async function waitForReadResponse( ): Promise { return new Promise((resolve) => { let unsubscribe: any + let resolved = false + const timer = setTimeout(() => { + resolved = true if (unsubscribe) unsubscribe() resolve(null) }, timeout) + const done = (result: any) => { + if (resolved) return + resolved = true + clearTimeout(timer) + if (unsubscribe) unsubscribe() + resolve(result) + } + + const matchReadEvent = (event: any): any => { + if ( + event.section === 'signet' && + event.method === 'RespondBidirectionalEvent' + ) { + const [reqId, responder, output, signature] = event.data + if (ethers.hexlify(reqId.toU8a()) === requestId) { + return { + responder: responder.toString(), + output: Array.from(output.toU8a()), + signature: signature.toJSON(), + } + } + } + return null + } + + // 1. Subscribe to new events api.query.system .events((events: any) => { events.forEach((record: any) => { - const { event } = record - if ( - event.section === 'signet' && - event.method === 'RespondBidirectionalEvent' - ) { - const [reqId, responder, output, signature] = event.data - if (ethers.hexlify(reqId.toU8a()) === requestId) { - clearTimeout(timer) - if (unsubscribe) unsubscribe() - resolve({ - responder: responder.toString(), - output: Array.from(output.toU8a()), - signature: signature.toJSON(), - }) - } - } + const result = matchReadEvent(record.event) + if (result) done(result) }) }) .then((unsub: any) => { unsubscribe = unsub }) + + // 2. Scan recent blocks for already-emitted events + scanRecentBlocksForReadResponse(api, requestId, 30) + .then((result) => { + if (result) done(result) + }) + .catch(() => {}) }) } +async function scanRecentBlocksForReadResponse( + api: ApiPromise, + requestId: string, + numBlocks: number, +): Promise { + try { + const header = await api.rpc.chain.getHeader() + const currentBlock = header.number.toNumber() + const startBlock = Math.max(1, currentBlock - numBlocks) + + console.log(`Scanning blocks ${startBlock}..${currentBlock} for RespondBidirectionalEvent...`) + + for (let i = currentBlock; i >= startBlock; i--) { + const hash = await api.rpc.chain.getBlockHash(i) + const events = await api.query.system.events.at(hash) as any + + for (const record of events) { + const { event } = record + if ( + event.section === 'signet' && + event.method === 'RespondBidirectionalEvent' + ) { + const [reqId, responder, output, signature] = event.data + if (ethers.hexlify(reqId.toU8a()) === requestId) { + console.log(`Found RespondBidirectionalEvent in block ${i}`) + return { + responder: responder.toString(), + output: Array.from(output.toU8a()), + signature: signature.toJSON(), + } + } + } + } + } + } catch (err) { + console.warn('Failed to scan recent blocks for RespondBidirectionalEvent:', err) + } + return null +} + export async function getTokenFree( api: ApiPromise, who: string, assetId: number, ): Promise { const acc = await api.query.tokens.accounts(who, assetId) - return (acc as any).free as unknown as bigint + const free = (acc as any).free + // Handle both codec types (.toBigInt()) and raw bigints + return typeof free === 'bigint' ? free : BigInt(free.toString()) } export async function transferAsset( @@ -243,17 +493,19 @@ export async function ensureBobHasAssets( const bobFaucetBalance = await getTokenFree(api, bob.address, faucetAsset) if (bobBalance.free.toBigInt() < MIN_BOB_NATIVE_BALANCE) { - throw new Error( - `Bob has insufficient native balance: ${bobBalance.free.toBigInt()}. ` + - `Expected at least ${MIN_BOB_NATIVE_BALANCE}. Fund Bob via chopsticks config.`, + console.warn( + `[WARN] Bob has insufficient native balance: ${bobBalance.free.toBigInt()}. ` + + `Expected at least ${MIN_BOB_NATIVE_BALANCE}. Skipping Bob check (not required for test flow).`, ) + return } if (bobFaucetBalance < ethers.parseEther('1')) { - throw new Error( - `Bob has insufficient faucet asset (${faucetAsset}) balance: ${bobFaucetBalance}. ` + - `Fund Bob via chopsticks config.`, + console.warn( + `[WARN] Bob has insufficient faucet asset (${faucetAsset}) balance: ${bobFaucetBalance}. ` + + `Skipping Bob check (not required for test flow).`, ) + return } console.log( @@ -261,6 +513,32 @@ export async function ensureBobHasAssets( ) } +/** + * Ensure Bob (the MPC response server signer) has enough native HDX to pay + * substrate transaction fees. Alice transfers HDX to Bob if needed. + */ +export async function ensureServerSignerFunded( + api: ApiPromise, + alice: any, + bob: any, +) { + const { data: bobBalance } = (await api.query.system.account(bob.address)) as any + const bobFree = bobBalance.free.toBigInt() + console.log(`Server signer (Bob) native balance: ${bobFree}`) + + if (bobFree >= SERVER_SIGNER_MIN_BALANCE) { + console.log('Server signer has sufficient balance') + return + } + + console.log(`Funding server signer (Bob) with ${SERVER_SIGNER_TOPUP} HDX...`) + const fundTx = api.tx.balances.transferKeepAlive(bob.address, SERVER_SIGNER_TOPUP.toString()) + await submitWithRetry(fundTx, alice, api, 'Fund server signer (Bob)') + + const { data: afterBalance } = (await api.query.system.account(bob.address)) as any + console.log(`Server signer (Bob) balance after funding: ${afterBalance.free.toBigInt()}`) +} + export async function logAliceTokenBalances( api: ApiPromise, alice: any, @@ -283,23 +561,21 @@ export async function fundPalletAccounts( api: ApiPromise, alice: any, faucetAsset: number, -): Promise<{ palletSS58: string }> { +): Promise<{ palletSS58: string; palletSS58Prefix0: string }> { const palletAccountId = getPalletAccountId() const palletSS58 = encodeAddress(palletAccountId, ENV.SS58_PREFIX) + // The pallet always uses SS58 prefix 0 for request ID computation + const palletSS58Prefix0 = encodeAddress(palletAccountId, 0) console.log(`Pallet address: ${palletSS58}`) + console.log(`Pallet address (prefix 0, for request ID): ${palletSS58Prefix0}`) // Warm up: prefetch pallet storage so chopsticks caches it before tx submission await api.query.system.account(palletSS58) await api.query.tokens.accounts(palletSS58, faucetAsset) - await transferAsset( - api, - alice, - palletSS58, - faucetAsset, - PALLET_FAUCET_FUND, - `Fund pallet faucet asset ${faucetAsset}`, - ) + // The dispenser pallet collects WETH collateral from the REQUESTER (Alice), + // not from the pallet account. Ensure Alice has enough WETH. + await ensureAliceHasFaucetAsset(api, alice, faucetAsset) const { data: palletBalance } = (await api.query.system.account( palletSS58, @@ -313,9 +589,97 @@ export async function fundPalletAccounts( PALLET_MIN_NATIVE_BALANCE, ) await submitWithRetry(fundTx, alice, api, 'Fund pallet account') + } else { + console.log(`Pallet native balance sufficient (${palletBalance.free.toBigInt()}), skipping`) + } + + // Fund the signet pallet account so it can receive signature deposits. + // sign_bidirectional transfers signature_deposit from the dispenser pallet + // to the signet pallet; if the signet pallet has 0 HDX the transfer fails + // with BelowMinimum because the deposit may be below the native ED. + await fundSignetPalletAccount(api, alice) + + return { palletSS58, palletSS58Prefix0 } +} + +const SIGNET_PALLET_ID_STR = 'py/signt' + +async function fundSignetPalletAccount(api: ApiPromise, alice: any) { + const modl = new TextEncoder().encode(MODL_PREFIX) + const palletId = new TextEncoder().encode(SIGNET_PALLET_ID_STR) + const data = new Uint8Array(32) + data.set(modl, 0) + data.set(palletId, 4) + const signetSS58 = encodeAddress(data, ENV.SS58_PREFIX) + + const { data: bal } = (await api.query.system.account(signetSS58)) as any + const free = bal.free.toBigInt() + console.log(`Signet pallet (${signetSS58}) native balance: ${free}`) + + if (free >= PALLET_MIN_NATIVE_BALANCE) { + console.log('Signet pallet balance sufficient, skipping') + return + } + + console.log(`Funding signet pallet account...`) + const fundTx = api.tx.balances.transferKeepAlive( + signetSS58, + PALLET_MIN_NATIVE_BALANCE, + ) + await submitWithRetry(fundTx, alice, api, 'Fund signet pallet account') +} + +/** + * Ensure Alice has enough faucet asset (WETH) to pay collateral in requestFund. + * On dev chains, Alice already has tokens from the forked state. + * On live chains (lark), we mint via currencies.updateBalance through Root governance. + */ +async function ensureAliceHasFaucetAsset( + api: ApiPromise, + alice: any, + faucetAsset: number, +) { + const aliceBal = await getTokenFree(api, alice.address, faucetAsset) + // Need enough for at least a few requestFund calls + const needed = ENV.REQUEST_FUND_AMOUNT * 10n + console.log( + `Alice faucet asset (${faucetAsset}) balance: ${aliceBal}, needed: ${needed}`, + ) + + if (aliceBal >= needed) { + console.log('Alice has sufficient faucet asset balance') + return + } + + if (await isDevChain(api)) { + // On dev chains, Alice should already have balance from fork state + console.warn( + `[WARN] Alice has insufficient faucet asset (${faucetAsset}) on dev chain. ` + + `Check chopsticks fork config.`, + ) + return } - return { palletSS58 } + const mintAmount = needed - aliceBal + console.log( + `Minting ${mintAmount} of asset ${faucetAsset} to Alice via Root governance...`, + ) + + const mintCall = (api.tx as any).currencies.updateBalance( + alice.address, + faucetAsset, + mintAmount.toString(), + ) + + await executeAsRoot( + api, + alice, + mintCall, + `Mint faucet asset ${faucetAsset} to Alice`, + ) + + const afterBal = await getTokenFree(api, alice.address, faucetAsset) + console.log(`Alice faucet asset (${faucetAsset}) balance after mint: ${afterBal}`) } export function deriveEthAddress(): { @@ -350,67 +714,324 @@ export async function ensureDerivedEthHasGas( ` Estimated gas needed: ${ethers.formatEther(estimatedGas)} ETH\n`, ) - if (ethBalance < estimatedGas) { - throw new Error( - `Insufficient ETH at ${derivedEthAddress}\n` + - ` Need: ${ethers.formatEther(estimatedGas)} ETH\n` + - ` Have: ${ethers.formatEther(ethBalance)} ETH\n` + - ` Please fund this address with ETH for gas`, - ) + if (ethBalance >= estimatedGas) return + + if (ENV.EVM_NETWORK === 'anvil') { + const ANVIL_DEFAULT_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + const funder = new ethers.Wallet(ANVIL_DEFAULT_KEY, provider) + const fundAmount = estimatedGas * 10n + console.log(`Funding ${derivedEthAddress} with ${ethers.formatEther(fundAmount)} ETH from Anvil account...`) + const tx = await funder.sendTransaction({ to: derivedEthAddress, value: fundAmount }) + await tx.wait() + console.log(`Funded. Tx: ${tx.hash}`) + return + } + + throw new Error( + `Insufficient ETH at ${derivedEthAddress}\n` + + ` Need: ${ethers.formatEther(estimatedGas)} ETH\n` + + ` Have: ${ethers.formatEther(ethBalance)} ETH\n` + + ` Please fund this address with ETH for gas`, + ) +} + +/** + * Ensure the faucet contract's MPC address is set to the derived address. + * On local Anvil, the deployer (account 0) is the owner and can call setMPC. + */ +export async function ensureFaucetMpcAddress( + provider: ethers.JsonRpcProvider, + derivedEthAddress: string, +) { + const faucetContract = new ethers.Contract( + ENV.FAUCET_ADDRESS, + ['function mpc() view returns (address)', 'function setMPC(address)'], + provider, + ) + + const currentMpc = await faucetContract.mpc() + console.log(`Faucet MPC address: ${currentMpc}`) + + if (currentMpc.toLowerCase() === derivedEthAddress.toLowerCase()) { + console.log('Faucet MPC already set to derived address') + return } + + console.log(`Setting faucet MPC to derived address ${derivedEthAddress}...`) + // Use Anvil account 0 (deployer/owner) to call setMPC + const ownerKey = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + const ownerWallet = new ethers.Wallet(ownerKey, provider) + const contractWithSigner = faucetContract.connect(ownerWallet) + const tx = await (contractWithSigner as any).setMPC(derivedEthAddress) + await tx.wait() + console.log('Faucet MPC address updated') } -export async function initializeVaultIfNeeded(api: ApiPromise) { +/** + * Ensure the dispenser pallet is configured and not paused. + * Uses setConfig (new API — all params in one config struct, no separate faucetBalanceWei storage). + */ +export async function initializeVaultIfNeeded(api: ApiPromise, signer?: any) { const cfgOpt = await (api.query as any).ethDispenser.dispenserConfig() const cfg = cfgOpt.toJSON() as any console.log('Dispenser config JSON ->', cfg) - if (cfg?.paused === true) { + if (!cfg) { + console.log('Dispenser not configured; setting config via Root...') + const setConfigCall = (api.tx as any).ethDispenser.setConfig( + ENV.FAUCET_ADDRESS, // faucet_address + ethers.parseEther('0.05').toString(), // min_faucet_threshold (0.05 ETH) + '0', // min_request + ethers.parseEther('1').toString(), // max_dispense (1 ETH) + '1000000000000', // dispenser_fee (1 HDX, 12 decimals) + ethers.parseEther('10').toString(), // faucet_balance_wei (10 ETH) + ) + if (signer) { + await executeAsRoot(api, signer, setConfigCall, 'Set ethDispenser config via Root') + } else { + await executeAsRootViaScheduler(api, setConfigCall, 'Set ethDispenser config via Root') + } + return + } + + if (cfg.paused === true) { console.log('Dispenser is paused; unpausing via Root...') const unpauseCall = (api.tx as any).ethDispenser.unpause() - await executeAsRootViaScheduler( - api, - unpauseCall, - 'Unpause ethDispenser via Root', - ) + if (signer) { + await executeAsRoot(api, signer, unpauseCall, 'Unpause ethDispenser via Root') + } else { + await executeAsRootViaScheduler(api, unpauseCall, 'Unpause ethDispenser via Root') + } + } else { + console.log('Dispenser is not paused, skipping unpause') } - const current = ( - await (api.query as any).ethDispenser.faucetBalanceWei() - ).toBigInt() - const threshold = ( - (api.consts as any).ethDispenser.minFaucetEthThreshold as any - ).toBigInt() + // Check if faucet balance is sufficient + const currentBalance = BigInt(cfg.faucetBalanceWei || '0') + const threshold = BigInt(cfg.minFaucetThreshold || '0') - console.log('Current faucetBalanceWei =', current.toString()) - console.log('MinFaucetEthThreshold =', threshold.toString()) + console.log('Current faucetBalanceWei =', currentBalance.toString()) + console.log('minFaucetThreshold =', threshold.toString()) const targetMin = threshold + ENV.REQUEST_FUND_AMOUNT + ethers.parseEther('1') - if (current >= targetMin) { - console.log('FaucetBalanceWei already sufficient, skipping top-up') + if (currentBalance >= targetMin) { + console.log('FaucetBalanceWei already sufficient, skipping reconfigure') return } - const addWei = targetMin - current - console.log('Topping up faucet balance via Root, add =', addWei.toString()) - - const setBalCall = (api.tx as any).ethDispenser.setFaucetBalance( - addWei.toString(), - ) - await executeAsRootViaScheduler( - api, - setBalCall, - 'Top up ethDispenser faucet balance via Root', + console.log('Reconfiguring dispenser with higher faucet balance via Root...') + const setConfigCall = (api.tx as any).ethDispenser.setConfig( + cfg.faucetAddress, + cfg.minFaucetThreshold.toString(), + cfg.minRequest.toString(), + cfg.maxDispense.toString(), + cfg.dispenserFee.toString(), + targetMin.toString(), ) + if (signer) { + await executeAsRoot(api, signer, setConfigCall, 'Update ethDispenser faucet balance via Root') + } else { + await executeAsRootViaScheduler(api, setConfigCall, 'Update ethDispenser faucet balance via Root') + } - const after = await (api.query as any).ethDispenser.faucetBalanceWei() - console.log('faucetBalanceWei after =', after.toString()) + const afterOpt = await (api.query as any).ethDispenser.dispenserConfig() + const afterCfg = afterOpt.toJSON() as any + console.log('faucetBalanceWei after =', afterCfg?.faucetBalanceWei) } // --------------------------------------------------------------------------- // Root execution helpers // --------------------------------------------------------------------------- +/** + * Execute a call with elevated origin, auto-detecting the best strategy: + * 1. Chopsticks → dev_setStorage scheduler (instant) + * 2. Signer is TC member → technicalCommittee.propose (fast) + * 3. Fallback → referendum (slow, but always works) + */ +export async function executeAsRoot( + api: ApiPromise, + signer: any, + call: SubmittableExtrinsic<'promise'>, + label: string, +) { + if (await isDevChain(api)) { + await executeAsRootViaScheduler(api, call, label) + return + } + + // Try TC path first — much faster than governance referendum + const isTcMember = await isSignerTcMember(api, signer) + if (isTcMember) { + await executeViaTechCommittee(api, signer, call, label) + return + } + + console.log(`${label}: signer is not a TC member, falling back to referendum`) + await executeAsRootViaReferendum(api, signer, call, label) +} + +/** + * Remove votes on completed/non-ongoing referendums to free up voting slots. + * MaxVotes=25 on Hydration — Alice can't vote on new referendums if she's hit the limit. + */ +async function cleanupOldVotes( + api: ApiPromise, + signer: any, + trackId: number, + label: string, +) { + try { + const votingInfo = await (api.query as any).convictionVoting.votingFor( + signer.address, + trackId, + ) + const voting = votingInfo.toJSON() as any + + if (!voting?.casting?.votes) return + + const votes = voting.casting.votes as [number, any][] + console.log(`${label}: found ${votes.length} existing votes on track ${trackId}`) + + if (votes.length < 10) { + console.log(`${label}: vote count under limit, no cleanup needed`) + return + } + + // Find votes for non-ongoing referendums + const toRemove: number[] = [] + for (const [refIndex] of votes) { + const info = await api.query.referenda.referendumInfoFor(refIndex) + const human = info.toHuman() as any + if (!human?.Ongoing) { + toRemove.push(refIndex) + } + } + + if (toRemove.length === 0) { + console.log(`${label}: all votes are for ongoing referendums, nothing to clean up`) + return + } + + console.log(`${label}: removing ${toRemove.length} old votes: [${toRemove.join(', ')}]`) + + // Batch remove votes + const removeCalls = toRemove.map((refIndex) => + api.tx.convictionVoting.removeVote(trackId, refIndex), + ) + + const batchTx = api.tx.utility.batchAll(removeCalls) + await submitWithRetry(batchTx, signer, api, `${label} - cleanup old votes`) + + console.log(`${label}: cleaned up ${toRemove.length} old votes`) + } catch (err: any) { + console.warn(`${label}: failed to cleanup old votes: ${err.message || err}`) + } +} + +async function isSignerTcMember(api: ApiPromise, signer: any): Promise { + try { + const members = await (api.query as any).technicalCommittee.members() + const memberList = members.toJSON() as string[] + const keyring = new Keyring() + const signerAccountId = u8aToHex(keyring.decodeAddress(signer.address)) + return memberList.some( + (m) => u8aToHex(keyring.decodeAddress(m)) === signerAccountId, + ) + } catch { + return false + } +} + +/** + * Execute a call via Tech Committee proposal. + * If signer is the only TC member, threshold=1 → executes immediately. + * Otherwise creates a proposal that other TC members must vote on. + */ +async function executeViaTechCommittee( + api: ApiPromise, + signer: any, + call: SubmittableExtrinsic<'promise'>, + label: string, +) { + const members = await (api.query as any).technicalCommittee.members() + const memberList = members.toJSON() as string[] + const memberCount = memberList.length + // Majority threshold: floor(n/2) + 1 + const threshold = Math.floor(memberCount / 2) + 1 + + console.log(`${label}: executing via TC (members: ${memberCount}, threshold: ${threshold})`) + + const lengthBound = call.method.encodedLength + 100 + const proposeTx = (api.tx as any).technicalCommittee.propose( + threshold, + call, + lengthBound, + ) + + const result = await submitWithRetry(proposeTx, signer, api, `${label} - TC propose`) + + // If threshold=1, the call executed inline. Otherwise log the proposal index. + if (threshold > 1) { + for (const { event } of result.events) { + if (event.section === 'technicalCommittee' && event.method === 'Proposed') { + const proposalIndex = event.data[1]?.toString() ?? event.data[2]?.toString() + console.log(`${label}: TC proposal #${proposalIndex} created. Other TC members must vote Aye.`) + } + } + } + + // Check if the call was executed (Executed event = threshold was 1 or auto-closed) + const executed = result.events.some( + (r: any) => + r.event.section === 'technicalCommittee' && + (r.event.method === 'Executed' || r.event.method === 'Closed'), + ) + + if (executed) { + console.log(`${label}: TC proposal executed immediately`) + } else if (threshold > 1) { + console.log(`${label}: TC proposal needs ${threshold - 1} more Aye votes from other members`) + // Poll for execution (other TC members may vote via separate process) + await pollForTcExecution(api, result, label) + } +} + +/** + * Poll for TC proposal execution after other members vote. + */ +async function pollForTcExecution( + api: ApiPromise, + proposeResult: { events: any[] }, + label: string, + timeoutMs = 600_000, +) { + // Extract proposal hash from Proposed event + let proposalHash: string | null = null + for (const { event } of proposeResult.events) { + if (event.section === 'technicalCommittee' && event.method === 'Proposed') { + proposalHash = event.data[2]?.toHex?.() ?? event.data[2]?.toString() + } + } + + if (!proposalHash) { + console.log(`${label}: could not find proposal hash, skipping poll`) + return + } + + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const proposal = await (api.query as any).technicalCommittee.proposalOf(proposalHash) + if (proposal.isNone) { + console.log(`${label}: TC proposal executed (no longer in storage)`) + return + } + console.log(`${label}: waiting for TC proposal to be voted on...`) + await new Promise((r) => setTimeout(r, 6_000)) + } + console.warn(`${label}: TC proposal poll timed out after ${timeoutMs}ms`) +} + export async function executeAsRootViaReferendum( api: ApiPromise, signer: any, @@ -424,46 +1045,82 @@ export async function executeAsRootViaReferendum( const encodedCall = call.method.toHex() const encodedHash = blake2AsHex(encodedCall) + // Note preimage — skip if already noted console.log(`${label}: noting preimage...`) - const notePreimageTx = api.tx.preimage.notePreimage(encodedCall) - await submitWithRetry( - notePreimageTx, - signer, - api, - `${label} - notePreimage`, - maxRetries, - timeoutMs, - ) + try { + const notePreimageTx = api.tx.preimage.notePreimage(encodedCall) + await submitWithRetry( + notePreimageTx, + signer, + api, + `${label} - notePreimage`, + maxRetries, + timeoutMs, + ) + } catch (err: any) { + if (String(err).includes('AlreadyNoted')) { + console.log(`${label}: preimage already noted, skipping`) + } else { + throw err + } + } - console.log(`${label}: submitting referendum with Root origin...`) - const proposalOrigin = { system: 'Root' } - const proposalCall = { - Lookup: { - hash: encodedHash, - len: encodedCall.length / 2 - 1, - }, + // Check if there's already an ongoing referendum for the same proposal + let referendumIndex = -1 + const refCount = parseInt((await api.query.referenda.referendumCount()).toString()) + for (let i = refCount - 1; i >= Math.max(0, refCount - 50); i--) { + const info = await api.query.referenda.referendumInfoFor(i) + const human = info.toHuman() as any + if (human?.Ongoing?.proposal?.Lookup?.hash_ === encodedHash) { + referendumIndex = i + console.log(`${label}: found existing ongoing referendum ${i} for this proposal, reusing`) + break + } } - const enactmentMoment = { After: 1 } - const submitTx = api.tx.referenda.submit( - proposalOrigin, - proposalCall, - enactmentMoment, - ) + if (referendumIndex < 0) { + console.log(`${label}: submitting referendum with Root origin...`) + const proposalOrigin = { system: 'Root' } + const proposalCall = { + Lookup: { + hash: encodedHash, + len: encodedCall.length / 2 - 1, + }, + } + const enactmentMoment = { After: 1 } - await submitWithRetry( - submitTx, - signer, - api, - `${label} - submitReferendum`, - maxRetries, - timeoutMs, - ) + const submitTx = api.tx.referenda.submit( + proposalOrigin, + proposalCall, + enactmentMoment, + ) + + await submitWithRetry( + submitTx, + signer, + api, + `${label} - submitReferendum`, + maxRetries, + timeoutMs, + ) + + referendumIndex = + parseInt((await api.query.referenda.referendumCount()).toString()) - 1 + } - const referendumIndex = - parseInt((await api.query.referenda.referendumCount()).toString()) - 1 console.log(`${label}: referendumIndex = ${referendumIndex}`) + // If referendum is already completed, skip all remaining steps + const earlyInfo = await api.query.referenda.referendumInfoFor(referendumIndex) + const earlyHuman = earlyInfo.toHuman() as any + if (earlyHuman?.Approved || earlyHuman?.Confirmed || earlyHuman?.Executed) { + console.log(`${label}: referendum ${referendumIndex} already completed (${Object.keys(earlyHuman)[0]}), skipping`) + return referendumIndex + } + if (earlyHuman?.Rejected || earlyHuman?.Cancelled || earlyHuman?.TimedOut || earlyHuman?.Killed) { + console.warn(`${label}: referendum ${referendumIndex} is in terminal state: ${Object.keys(earlyHuman)[0]}. Will create a new one on next run.`) + } + const faucetAsset = (api.consts.ethDispenser.faucetAsset as any).toNumber() let { data } = (await api.query.system.account(signer.address)) as any @@ -482,47 +1139,119 @@ export async function executeAsRootViaReferendum( const tracks: any = api.consts.referenda.tracks console.log('Tracks:', tracks.toHuman()) - console.log(`${label}: placing decision deposit...`) - const decisionDepositTx = - api.tx.referenda.placeDecisionDeposit(referendumIndex) - await submitWithRetry( - decisionDepositTx, - signer, - api, - `${label} - decisionDeposit`, - maxRetries, - timeoutMs, - ) + // Check if decision deposit is already placed + const refInfoBefore = await api.query.referenda.referendumInfoFor(referendumIndex) + const refHumanBefore = refInfoBefore.toHuman() as any + const hasDecisionDeposit = !!refHumanBefore?.Ongoing?.decisionDeposit - console.log(`${label}: voting AYE on referendum...`) - data = ((await api.query.system.account(signer.address)) as any).data - const free = data.free.toBigInt() + if (hasDecisionDeposit) { + console.log(`${label}: decision deposit already placed, skipping`) + } else { + console.log(`${label}: placing decision deposit...`) + try { + const decisionDepositTx = + api.tx.referenda.placeDecisionDeposit(referendumIndex) + await submitWithRetry( + decisionDepositTx, + signer, + api, + `${label} - decisionDeposit`, + maxRetries, + timeoutMs, + ) + } catch (err: any) { + if (String(err).includes('HasDeposit')) { + console.log(`${label}: decision deposit already placed, skipping`) + } else { + throw err + } + } + } - const voteAmount = (free * 5n) / 10n + // Clean up old votes to stay under MaxVotes (25) limit + await cleanupOldVotes(api, signer, 0, label) - console.log( - `${label}: free balance = ${free.toString()}, voteAmount = ${voteAmount.toString()}`, - ) + // Check if already voted on this referendum + const refInfoForVote = await api.query.referenda.referendumInfoFor(referendumIndex) + const refHumanForVote = refInfoForVote.toHuman() as any + const currentTally = refHumanForVote?.Ongoing?.tally + const alreadyVoted = currentTally && currentTally.ayes !== '0' - const voteTx = api.tx.convictionVoting.vote(referendumIndex, { - Standard: { - balance: voteAmount, - vote: { aye: true, conviction: 'Locked1x' }, - }, - }) + if (alreadyVoted) { + console.log(`${label}: already voted (ayes=${currentTally.ayes}), skipping vote`) + } else { + console.log(`${label}: voting AYE on referendum...`) + data = ((await api.query.system.account(signer.address)) as any).data + const free = data.free.toBigInt() - await submitWithRetry( - voteTx, - signer, - api, - `${label} - vote`, - maxRetries, - timeoutMs, - ) + const voteAmount = (free * 5n) / 10n + + console.log( + `${label}: free balance = ${free.toString()}, voteAmount = ${voteAmount.toString()}`, + ) + + const voteTx = api.tx.convictionVoting.vote(referendumIndex, { + Standard: { + balance: voteAmount, + vote: { aye: true, conviction: 'Locked1x' }, + }, + }) + + try { + await submitWithRetry( + voteTx, + signer, + api, + `${label} - vote`, + maxRetries, + timeoutMs, + ) + } catch (err: any) { + // MaxVotesReached or other vote errors — log and continue + console.warn(`${label}: vote failed: ${err.message || err}`) + } + + // Verify vote was counted + const postVoteInfo = await api.query.referenda.referendumInfoFor(referendumIndex) + const postVoteHuman = postVoteInfo.toHuman() as any + const tally = postVoteHuman?.Ongoing?.tally + if (tally && tally.ayes === '0') { + console.warn(`[WARN] Vote may not have been counted (tally ayes=0). Check MaxVotes limit.`) + } else { + console.log(`${label}: vote confirmed, tally:`, JSON.stringify(tally)) + } + } console.log(`${label}: waiting for referendum to progress...`) - await (api.rpc as any)('dev_newBlock', { count: 10 }) + const dev = await isDevChain(api) + if (dev) { + await (api.rpc as any)('dev_newBlock', { count: 10 }) + } else { + // On live chains, poll until the referendum is no longer ongoing + const pollInterval = 6_000 // ~1 block time + const pollTimeout = 600_000 // 10 minutes max + const start = Date.now() + while (Date.now() - start < pollTimeout) { + const info = await api.query.referenda.referendumInfoFor(referendumIndex) + const human = info.toHuman() as any + console.log( + `${label}: referendum ${referendumIndex} status:`, + JSON.stringify(human), + ) + if (human?.Approved || human?.Confirmed || human?.Executed) { + break + } + // If it's still Ongoing, wait for next block + if (human?.Ongoing) { + await new Promise((r) => setTimeout(r, pollInterval)) + continue + } + // Rejected or other terminal state + break + } + } + const info = await api.query.referenda.referendumInfoFor(referendumIndex) console.log('Referendum info:', info.toHuman())