From 70aa5c2e860865d3ba4052f6d27a9e6be2fa94fa Mon Sep 17 00:00:00 2001 From: Yash Sharma Date: Mon, 2 Mar 2026 16:34:41 +0530 Subject: [PATCH 1/5] Changed path, Added robust testing - changed the path used for deriving a signing address to be same for each requester - refactored tests for multiple account testing --- pallets/dispenser/src/lib.rs | 5 +- pallets/dispenser/src/types.rs | 1 + scripts/dispenser-tests/dispenser.test.ts | 34 +++----- scripts/dispenser-tests/env.ts | 4 + scripts/dispenser-tests/utils.ts | 102 +++++++++++++++------- 5 files changed, 92 insertions(+), 54 deletions(-) diff --git a/pallets/dispenser/src/lib.rs b/pallets/dispenser/src/lib.rs index 242f24b20..457ee0378 100644 --- a/pallets/dispenser/src/lib.rs +++ b/pallets/dispenser/src/lib.rs @@ -289,10 +289,7 @@ 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()); + 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); diff --git a/pallets/dispenser/src/types.rs b/pallets/dispenser/src/types.rs index f0b828f7d..64b5bc6e8 100644 --- a/pallets/dispenser/src/types.rs +++ b/pallets/dispenser/src/types.rs @@ -8,6 +8,7 @@ pub type BalanceOf = pub const ECDSA: &[u8] = b"ecdsa"; pub const ETHEREUM: &[u8] = b"ethereum"; +pub const SIGNING_PATH: &[u8] = b"dispenser"; pub trait WeightInfo { fn request_fund() -> Weight; diff --git a/scripts/dispenser-tests/dispenser.test.ts b/scripts/dispenser-tests/dispenser.test.ts index 8831dbfe0..d2844d0c1 100644 --- a/scripts/dispenser-tests/dispenser.test.ts +++ b/scripts/dispenser-tests/dispenser.test.ts @@ -1,6 +1,5 @@ -import { ApiPromise } from '@polkadot/api' +import { ApiPromise, Keyring } 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' @@ -10,8 +9,8 @@ import { waitForReadResponse, createApi, createKeyringAndAccounts, - ensureBobHasAssets, - logAliceTokenBalances, + ensureAccountHasAssets, + logTokenBalances, fundPalletAccounts, deriveEthAddress, ensureDerivedEthHasGas, @@ -24,12 +23,10 @@ import { describe('ERC20 Vault Integration', () => { let api: ApiPromise - let alice: any + let requester: any let signetClient: SignetClient let evmProvider: ethers.JsonRpcProvider let derivedEthAddress: string - let derivedPubKey: string - let aliceHexPath: string let palletSS58: string beforeAll(async () => { @@ -46,30 +43,27 @@ describe('ERC20 Vault Integration', () => { `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) + const { requester: acc } = createKeyringAndAccounts(ENV.TEST_ACCOUNT_URI) + requester = acc + const alice = new Keyring({ type: 'sr25519' }).addFromUri('//Alice') const palletFunding = await fundPalletAccounts(api, alice, faucetAsset) + + await ensureAccountHasAssets(api, requester, faucetAsset, feeAsset, ENV.TEST_ACCOUNT_URI) + await logTokenBalances(api, requester, faucetAsset, feeAsset) palletSS58 = palletFunding.palletSS58 - signetClient = new SignetClient(api, alice) + signetClient = new SignetClient(api, requester) evmProvider = new ethers.JsonRpcProvider(ENV.EVM_RPC_URL) await signetClient.ensureSignetInitializedViaReferendum( api, - alice, + requester, ENV.SUBSTRATE_CHAIN_ID, ) const derived = deriveEthAddress() derivedEthAddress = derived.derivedEthAddress - derivedPubKey = derived.derivedPubKey await ensureDerivedEthHasGas(evmProvider, derivedEthAddress) }, 600_000) @@ -129,7 +123,7 @@ describe('ERC20 Vault Integration', () => { { caip2_id: `eip155:${ENV.EVM_CHAIN_ID}`, keyVersion: 0, - path: aliceHexPath, + path: 'dispenser', algo: 'ecdsa', dest: 'ethereum', params: '', @@ -151,7 +145,7 @@ describe('ERC20 Vault Integration', () => { console.log('Submitting requestFund transaction...') const depositResult = await submitWithRetry( depositTx, - alice, + requester, api, 'Request Fund', ) diff --git a/scripts/dispenser-tests/env.ts b/scripts/dispenser-tests/env.ts index d08d2ca05..cd41e5747 100644 --- a/scripts/dispenser-tests/env.ts +++ b/scripts/dispenser-tests/env.ts @@ -74,6 +74,8 @@ const SUBSTRATE_WS_ENDPOINT = validateUrl('SUBSTRATE_WS_ENDPOINT', required('SUB const SUBSTRATE_CHAIN_ID = required('SUBSTRATE_CHAIN_ID') // e.g. 'polkadot:2034' const SS58_PREFIX = optionalInt('SS58_PREFIX', 0) +const TEST_ACCOUNT_URI = process.env.TEST_ACCOUNT_URI || '//Alice' + 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 @@ -96,6 +98,7 @@ export const ENV = { SS58_PREFIX, // Test params + TEST_ACCOUNT_URI, TARGET_ADDRESS, REQUEST_FUND_AMOUNT, @@ -113,5 +116,6 @@ 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(` Test account: ${ENV.TEST_ACCOUNT_URI}`) console.log(` Target address: ${ENV.TARGET_ADDRESS}`) console.log(`----------------------------\n`) diff --git a/scripts/dispenser-tests/utils.ts b/scripts/dispenser-tests/utils.ts index b54fd1cc2..e7a4ad94e 100644 --- a/scripts/dispenser-tests/utils.ts +++ b/scripts/dispenser-tests/utils.ts @@ -8,9 +8,9 @@ import { SubmittableExtrinsic } from '@polkadot/api/types' import { ENV } from './env' // --- Substrate funding thresholds (not network-specific) --- -export const MIN_BOB_NATIVE_BALANCE = 1 +export const MIN_NATIVE_BALANCE = 1 export const PALLET_MIN_NATIVE_BALANCE = 10_000_000_000_000n -export const BOB_NATIVE_TOPUP = 100_000_000_000_000n +export const NATIVE_TOPUP = 100_000_000_000_000n export const PALLET_FAUCET_FUND = ethers.parseEther('100') export const PALLET_ID_STR = 'py/fucet' @@ -222,56 +222,98 @@ export async function createApi(): Promise { }) } -export function createKeyringAndAccounts() { +export function createKeyringAndAccounts(requesterUri: string = '//Alice') { const keyring = new Keyring({ type: 'sr25519' }) - const alice = keyring.addFromUri('//Alice') - const bob = keyring.addFromUri('//Bob') - return { keyring, alice, bob } + const requester = keyring.addFromUri(requesterUri) + return { keyring, requester } } -export async function ensureBobHasAssets( +export async function ensureAccountHasAssets( api: ApiPromise, - bob: any, + account: any, faucetAsset: number, + feeAsset: number, + requesterUri: string, ) { - console.log(`Bob address: ${bob.address}`) + console.log(`Account address: ${account.address}`) - const { data: bobBalance } = (await api.query.system.account( - bob.address, + const { data: balance } = (await api.query.system.account( + account.address, )) as any - const bobFaucetBalance = await getTokenFree(api, bob.address, faucetAsset) + const faucetBalance = await getTokenFree(api, account.address, faucetAsset) + const feeBalance = await getTokenFree(api, account.address, feeAsset) - 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.`, - ) - } + const needsNative = balance.free.toBigInt() < MIN_NATIVE_BALANCE + const needsFaucet = faucetBalance < ethers.parseEther('1') + const needsFee = feeBalance < ethers.parseEther('1') + + if ((needsNative || needsFaucet || needsFee) && requesterUri !== '//Alice') { + console.log(`Funding ${requesterUri} from //Alice...`) + const keyring = new Keyring({ type: 'sr25519' }) + const alice = keyring.addFromUri('//Alice') + + if (needsNative) { + const tx = api.tx.balances.transferKeepAlive(account.address, NATIVE_TOPUP) + await submitWithRetry(tx, alice, api, `Fund ${requesterUri} native`) + } + + if (needsFaucet) { + await transferAsset( + api, + alice, + account.address, + faucetAsset, + ethers.parseEther('100'), + `Fund ${requesterUri} faucet asset ${faucetAsset}`, + ) + } - if (bobFaucetBalance < ethers.parseEther('1')) { + if (needsFee) { + if (feeAsset === 0) { + const tx = api.tx.balances.transferKeepAlive(account.address, NATIVE_TOPUP) + await submitWithRetry(tx, alice, api, `Fund ${requesterUri} fee asset ${feeAsset}`) + } else { + await transferAsset( + api, + alice, + account.address, + feeAsset, + ethers.parseEther('100'), + `Fund ${requesterUri} fee asset ${feeAsset}`, + ) + } + } + } else if (needsNative || needsFaucet || needsFee) { throw new Error( - `Bob has insufficient faucet asset (${faucetAsset}) balance: ${bobFaucetBalance}. ` + - `Fund Bob via chopsticks config.`, + `Alice has insufficient balance. native=${balance.free.toBigInt()}, ` + + `faucetAsset(${faucetAsset})=${faucetBalance}, feeAsset(${feeAsset})=${feeBalance}. ` + + `Fund via chopsticks config.`, ) } + const { data: updatedBalance } = (await api.query.system.account( + account.address, + )) as any + const updatedFaucet = await getTokenFree(api, account.address, faucetAsset) + const updatedFee = await getTokenFree(api, account.address, feeAsset) + console.log( - `Bob balances: native=${bobBalance.free.toBigInt()}, faucetAsset(${faucetAsset})=${bobFaucetBalance}`, + `Account balances: native=${updatedBalance.free.toBigInt()}, faucetAsset(${faucetAsset})=${updatedFaucet}, feeAsset(${feeAsset})=${updatedFee}`, ) } -export async function logAliceTokenBalances( +export async function logTokenBalances( api: ApiPromise, - alice: any, + account: any, faucetAsset: number, feeAsset: number, ) { - const faucetBal = await getTokenFree(api, alice.address, faucetAsset) - const feeBal = await getTokenFree(api, alice.address, feeAsset) + const faucetBal = await getTokenFree(api, account.address, faucetAsset) + const feeBal = await getTokenFree(api, account.address, feeAsset) console.log( - 'Alice balances:', + `${account.address} balances:`, 'faucetBalance =', faucetBal.toString(), 'feeBalance =', @@ -281,7 +323,7 @@ export async function logAliceTokenBalances( export async function fundPalletAccounts( api: ApiPromise, - alice: any, + signer: any, faucetAsset: number, ): Promise<{ palletSS58: string }> { const palletAccountId = getPalletAccountId() @@ -294,7 +336,7 @@ export async function fundPalletAccounts( await transferAsset( api, - alice, + signer, palletSS58, faucetAsset, PALLET_FAUCET_FUND, @@ -312,7 +354,7 @@ export async function fundPalletAccounts( palletSS58, PALLET_MIN_NATIVE_BALANCE, ) - await submitWithRetry(fundTx, alice, api, 'Fund pallet account') + await submitWithRetry(fundTx, signer, api, 'Fund pallet account') } return { palletSS58 } From b3635bfd3ba824685fa833dd91ec8870021f8e12 Mon Sep 17 00:00:00 2001 From: Yash Sharma Date: Mon, 2 Mar 2026 19:09:23 +0530 Subject: [PATCH 2/5] Runtime constants change, config packaging under one struct --- pallets/dispenser/src/benchmarking.rs | 47 +++-- pallets/dispenser/src/lib.rs | 161 +++++++++--------- pallets/dispenser/src/tests/mod.rs | 30 ++-- pallets/dispenser/src/tests/test_cases.rs | 32 ++-- pallets/dispenser/src/tests/utils.rs | 12 +- pallets/dispenser/src/types.rs | 24 +++ pallets/dispenser/src/weights.rs | 6 + runtime/hydradx/src/assets.rs | 17 -- .../hydradx/src/weights/pallet_dispenser.rs | 5 + scripts/dispenser-tests/dispenser.test.ts | 3 +- scripts/dispenser-tests/env.ts | 16 ++ scripts/dispenser-tests/utils.ts | 45 ++++- 12 files changed, 242 insertions(+), 156 deletions(-) diff --git a/pallets/dispenser/src/benchmarking.rs b/pallets/dispenser/src/benchmarking.rs index 799136f95..4f4f1927a 100644 --- a/pallets/dispenser/src/benchmarking.rs +++ b/pallets/dispenser/src/benchmarking.rs @@ -18,18 +18,36 @@ mod benches { use alloy_sol_types::SolCall; use core::ops::{Add, Mul}; use frame_support::traits::Currency; + use primitives::EvmAddress; #[benchmark] fn set_faucet_balance() { - DispenserConfig::::put(DispenserConfigData { paused: false }); + DispenserConfig::::put(DispenserConfigData::default()); #[extrinsic_call] set_faucet_balance(RawOrigin::Root, 123u128); - assert_eq!(FaucetBalanceWei::::get(), 123u128); + assert_eq!(DispenserConfig::::get().unwrap().faucet_balance_wei, 123u128); + } + + #[benchmark] + fn set_faucet_params() { + let addr = EvmAddress::from([1u8; 20]); + let threshold = 50_000_000_000_000_000u128; + let min_request = 100u128; + let max_dispense = 1_000_000_000u128; + let fee = 10u128; + #[extrinsic_call] + set_faucet_params(RawOrigin::Root, addr, threshold, min_request, max_dispense, fee); + let config = DispenserConfig::::get().unwrap(); + assert_eq!(config.faucet_address, addr); + assert_eq!(config.min_faucet_threshold, threshold); + assert_eq!(config.min_request, min_request); + assert_eq!(config.max_dispense, max_dispense); + assert_eq!(config.dispenser_fee, fee); } #[benchmark] fn pause() { - DispenserConfig::::put(DispenserConfigData { paused: false }); + DispenserConfig::::put(DispenserConfigData::default()); #[extrinsic_call] pause(RawOrigin::Root); @@ -39,7 +57,7 @@ mod benches { #[benchmark] fn unpause() { - DispenserConfig::::put(DispenserConfigData { paused: true }); + DispenserConfig::::put(DispenserConfigData { paused: true, ..Default::default() }); #[extrinsic_call] unpause(RawOrigin::Root); @@ -84,6 +102,17 @@ mod benches { let _ = ::Currency::deposit_creating(&pallet_account, requester_needed); let _ = ::Currency::deposit_creating(&signet_pallet_account, requester_needed); + let faucet_addr = EvmAddress::from([1u8; 20]); + let min_threshold = 1u128; + assert_ok!(Pallet::::set_faucet_params( + RawOrigin::Root.into(), + faucet_addr, + min_threshold, + 0u128, + u128::MAX, + 10u128, + )); + let current_faucet_bal: u128 = (u64::MAX - 1) as u128; assert_ok!(Pallet::::set_faucet_balance( RawOrigin::Root.into(), @@ -109,7 +138,6 @@ mod benches { amount: U256::from(amount), }; - let faucet_addr = T::FaucetAddress::get(); let rlp = pallet_signet::Pallet::::build_evm_tx( RawOrigin::Signed(caller.clone()).into(), Some(faucet_addr), @@ -124,13 +152,6 @@ 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() - }; - // CAIP-2 chain ID format let caip2_id = alloc::format!("eip155:{}", tx.chain_id); @@ -139,7 +160,7 @@ mod benches { &rlp, &caip2_id, 0, - &path_bytes, + SIGNING_PATH, b"ecdsa", b"ethereum", b"", diff --git a/pallets/dispenser/src/lib.rs b/pallets/dispenser/src/lib.rs index 457ee0378..0d0ff3ee9 100644 --- a/pallets/dispenser/src/lib.rs +++ b/pallets/dispenser/src/lib.rs @@ -16,7 +16,7 @@ use alloc::{string::String, vec}; use alloy_primitives::U256; use alloy_sol_types::{sol, SolCall}; -use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use codec::{Decode, DecodeWithMemTracking, Encode}; use frame_support::pallet_prelude::*; use frame_support::traits::fungibles::Inspect; use frame_support::traits::{fungibles::Mutate, tokens::Preservation}; @@ -82,18 +82,6 @@ pub mod pallet { /// 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] @@ -108,49 +96,26 @@ 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; } /*************************** STORAGE ***************************/ - /// Global configuration for the dispenser. + /// Unified dispenser state: pause flag, tracked faucet balance, and all + /// governance-controlled parameters in a single storage entry. /// - /// Currently only tracks whether the pallet is paused. If `None`, defaults - /// to unpaused. + /// Must be initialised via `set_faucet_params` before any funding requests + /// can be made. `pause`/`unpause`/`set_faucet_balance` will create the + /// entry with defaults if it does not yet exist. #[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, - } - /// Request IDs that have already been used. /// /// This prevents accidental or malicious re-submission of the same request. @@ -179,6 +144,19 @@ pub mod pallet { /// Requested amount of ETH (in wei). amount: Balance, }, + /// Faucet parameters have been updated via governance. + FaucetParamsUpdated { + /// EVM address of the faucet contract. + faucet_address: EvmAddress, + /// Minimum ETH (in wei) that must remain in the faucet. + min_faucet_threshold: Balance, + /// Minimum request amount. + min_request: Balance, + /// Maximum dispense amount. + max_dispense: Balance, + /// Flat fee per request. + dispenser_fee: Balance, + }, /// Tracked faucet ETH balance has been updated. FaucetBalanceUpdated { /// Previous tracked balance (in wei). @@ -213,6 +191,8 @@ pub mod pallet { NotEnoughFeeFunds, /// Caller does not have enough balance of the faucet asset. NotEnoughFaucetFunds, + /// Faucet address or minimum threshold has not been set via governance. + NotInitialized, } /// Dispatchable functions. @@ -247,17 +227,19 @@ pub mod pallet { let requester = ensure_signed(origin)?; let pallet_acc = Self::account_id(); - // Pallet must not be paused. - Self::ensure_not_paused()?; + // Load full config (includes paused flag, balance, and params). + let config = DispenserConfig::::get().ok_or(Error::::NotInitialized)?; + 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 observed = config.faucet_balance_wei; + let min_threshold = config.min_faucet_threshold; + let needed = min_threshold .checked_add(amount) .ok_or(Error::::InvalidOutput)?; ensure!(observed >= needed, Error::::FaucetBalanceBelowThreshold); @@ -276,9 +258,10 @@ pub mod pallet { }; // Build EVM transaction bytes using pallet_signet helper. + let faucet_addr = config.faucet_address; let rlp = pallet_signet::Pallet::::build_evm_tx( frame_system::RawOrigin::Signed(requester.clone()).into(), - Some(T::FaucetAddress::get()), + Some(faucet_addr), 0u128, call.abi_encode(), tx.nonce, @@ -289,7 +272,7 @@ pub mod pallet { tx.chain_id, )?; - let path = SIGNING_PATH.to_vec(); + 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); @@ -304,7 +287,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); @@ -348,7 +331,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(|s| { + if let Some(ref mut state) = s { + state.faucet_balance_wei = state.faucet_balance_wei.saturating_sub(amount); + } + }); Self::deposit_event(Event::FundRequested { request_id: req_id, @@ -368,12 +355,9 @@ pub mod pallet { #[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); - }; - + let mut state = DispenserConfig::::get().unwrap_or_default(); + state.paused = true; + DispenserConfig::::put(state); Self::deposit_event(Event::Paused); Ok(()) } @@ -386,11 +370,9 @@ pub mod pallet { #[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); - }; + let mut state = DispenserConfig::::get().unwrap_or_default(); + state.paused = false; + DispenserConfig::::put(state); Self::deposit_event(Event::Unpaused); Ok(()) } @@ -407,15 +389,53 @@ pub mod pallet { #[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); + let mut state = DispenserConfig::::get().unwrap_or_default(); + let old = state.faucet_balance_wei; + state.faucet_balance_wei = old + balance_wei; + let new_balance = state.faucet_balance_wei; + DispenserConfig::::put(state); Self::deposit_event(Event::FaucetBalanceUpdated { old_balance_wei: old, new_balance_wei: new_balance, }); Ok(()) } + /// Set or update all governance-controlled dispenser parameters. + /// + /// Parameters: + /// - `origin`: Must satisfy `UpdateOrigin`. + /// - `faucet_address`: EVM address of the external faucet contract. + /// - `min_faucet_threshold`: Minimum ETH (in wei) that must remain after a request. + /// - `min_request`: Minimum amount of faucet asset per request. + /// - `max_dispense`: Maximum amount of faucet asset per request. + /// - `dispenser_fee`: Flat fee charged in `FeeAsset` per request. + #[pallet::call_index(5)] + #[pallet::weight(::WeightInfo::set_faucet_params())] + pub fn set_faucet_params( + origin: OriginFor, + faucet_address: EvmAddress, + min_faucet_threshold: Balance, + min_request: Balance, + max_dispense: Balance, + dispenser_fee: Balance, + ) -> DispatchResult { + T::UpdateOrigin::ensure_origin(origin)?; + let mut state = DispenserConfig::::get().unwrap_or_default(); + state.faucet_address = faucet_address; + state.min_faucet_threshold = min_faucet_threshold; + state.min_request = min_request; + state.max_dispense = max_dispense; + state.dispenser_fee = dispenser_fee; + DispenserConfig::::put(state); + Self::deposit_event(Event::FaucetParamsUpdated { + faucet_address, + min_faucet_threshold, + min_request, + max_dispense, + dispenser_fee, + }); + Ok(()) + } } // ========================= Helper Functions ========================= @@ -477,16 +497,5 @@ pub mod pallet { 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 1750244f1..3239d1645 100644 --- a/pallets/dispenser/src/tests/mod.rs +++ b/pallets/dispenser/src/tests/mod.rs @@ -160,34 +160,26 @@ impl pallet_signet::Config for Test { 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")) - } +pub fn test_faucet_address() -> primitives::EvmAddress { + primitives::EvmAddress::from(hex!("3c44CdDdB6a900fa2b585dd299e03d12FA4293BC")) } +pub const TEST_MIN_FAUCET_THRESHOLD: u128 = 1; +pub const TEST_DISPENSER_FEE: u128 = 10; +pub const TEST_MAX_DISPENSE: u128 = 1_000_000_000; +pub const TEST_MIN_REQUEST: u128 = 100; + impl pallet_dispenser::Config for Test { type RuntimeEvent = RuntimeEvent; 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; } @@ -224,6 +216,14 @@ pub fn new_test_ext() -> sp_io::TestExternalities { let pallet_account = Dispenser::account_id(); let _ = >::deposit_creating(&pallet_account, 10_000); + assert_ok!(Dispenser::set_faucet_params( + RuntimeOrigin::root(), + test_faucet_address(), + TEST_MIN_FAUCET_THRESHOLD, + TEST_MIN_REQUEST, + TEST_MAX_DISPENSE, + TEST_DISPENSER_FEE, + )); assert_ok!(Dispenser::set_faucet_balance(RuntimeOrigin::root(), MIN_WEI_BALANCE)); }); ext diff --git a/pallets/dispenser/src/tests/test_cases.rs b/pallets/dispenser/src/tests/test_cases.rs index 355d8e1a6..786221b38 100644 --- a/pallets/dispenser/src/tests/test_cases.rs +++ b/pallets/dispenser/src/tests/test_cases.rs @@ -5,7 +5,7 @@ use crate::{ utils::{acct, compute_request_id, create_test_receiver_address, create_test_tx_params}, Currencies, Dispenser, RuntimeEvent, RuntimeOrigin, System, Test, MIN_WEI_BALANCE, }, - Error, Event, FaucetBalanceWei, + Error, Event, }; use frame_support::{assert_noop, assert_ok}; use orml_traits::MultiCurrency; @@ -71,7 +71,7 @@ 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 fee = Dispenser::dispenser_config().unwrap().dispenser_fee; let treasury = ::FeeDestination::get(); let pallet_account = Dispenser::account_id(); @@ -127,7 +127,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 = Dispenser::dispenser_config().unwrap().min_request - 1; let rid_small = compute_request_id(requester.clone(), receiver, amt_small, &tx); assert_noop!( Dispenser::request_fund( @@ -140,7 +140,7 @@ fn test_amount_too_small_and_too_large() { Error::::AmountTooSmall ); - let amt_big = ::MaxDispenseAmount::get() + 1; + let amt_big = Dispenser::dispenser_config().unwrap().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), @@ -197,7 +197,7 @@ fn test_deposit_erc20_success() { assert_eq!( Currencies::free_balance(fee_asset, &requester), - hdx_balance_before - ::DispenserFee::get() + hdx_balance_before - Dispenser::dispenser_config().unwrap().dispenser_fee ); assert_eq!( @@ -210,9 +210,9 @@ fn test_deposit_erc20_success() { #[test] fn governance_sets_faucet_balance_and_emits_event() { new_test_ext().execute_with(|| { - let old = Dispenser::current_faucet_balance_wei(); + let old = Dispenser::dispenser_config().unwrap().faucet_balance_wei; assert_ok!(Dispenser::set_faucet_balance(RuntimeOrigin::root(), 42u128)); - assert_eq!(Dispenser::current_faucet_balance_wei(), MIN_WEI_BALANCE + 42u128); + assert_eq!(Dispenser::dispenser_config().unwrap().faucet_balance_wei, MIN_WEI_BALANCE + 42u128); let ev = System::events().into_iter().any(|rec| { matches!(rec.event, @@ -233,7 +233,7 @@ fn non_governance_cannot_set_faucet_balance() { Dispenser::set_faucet_balance(RuntimeOrigin::signed(alice), 7u128), sp_runtime::DispatchError::BadOrigin ); - assert_eq!(Dispenser::current_faucet_balance_wei(), MIN_WEI_BALANCE); + assert_eq!(Dispenser::dispenser_config().unwrap().faucet_balance_wei, MIN_WEI_BALANCE); }); } @@ -244,7 +244,7 @@ fn request_rejected_when_balance_below_threshold() { let receiver = create_test_receiver_address(); assert_ok!(Dispenser::set_faucet_balance(RuntimeOrigin::root(), 10u128)); - FaucetBalanceWei::::put(100u128); + crate::DispenserConfig::::mutate(|s| s.as_mut().unwrap().faucet_balance_wei = 100u128); let amount = 100u128; let tx = create_test_tx_params(); @@ -267,7 +267,7 @@ 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; + let needed = Dispenser::dispenser_config().unwrap().min_faucet_threshold + amount; assert_ok!(Dispenser::set_faucet_balance(RuntimeOrigin::root(), needed)); let requester = acct(1); @@ -289,12 +289,12 @@ 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 min_threshold = Dispenser::dispenser_config().unwrap().min_faucet_threshold; 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(), + Dispenser::dispenser_config().unwrap().faucet_balance_wei, MIN_WEI_BALANCE + initial_balance ); @@ -318,11 +318,11 @@ fn request_reduces_faucet_balance() { )); let expected_balance = initial_balance.saturating_sub(amount).saturating_add(MIN_WEI_BALANCE); - assert_eq!(Dispenser::current_faucet_balance_wei(), expected_balance); + assert_eq!(Dispenser::dispenser_config().unwrap().faucet_balance_wei, expected_balance); assert_eq!( Currencies::free_balance(fee_asset, &requester), - hdx_before - ::DispenserFee::get() + hdx_before - Dispenser::dispenser_config().unwrap().dispenser_fee ); assert_eq!(Currencies::free_balance(faucet_asset, &requester), weth_before - amount); }); @@ -374,7 +374,7 @@ fn request_fails_when_insufficient_faucet_balance() { let fee_asset = ::FeeAsset::get(); - let fee = ::DispenserFee::get(); + let fee = Dispenser::dispenser_config().unwrap().dispenser_fee; let _ = Currencies::deposit(fee_asset, &requester, 1_000_000_000_000_000_000_000); @@ -415,7 +415,7 @@ fn request_fails_with_duplicate_request_id() { fn request_fails_with_zero_gas_limit() { new_test_ext().execute_with(|| { let amount = 10_000u128; - let min_threshold = ::MinFaucetEthThreshold::get(); + let min_threshold = Dispenser::dispenser_config().unwrap().min_faucet_threshold; let initial_balance = min_threshold + amount + 1_000u128; assert_ok!(Dispenser::set_faucet_balance(RuntimeOrigin::root(), initial_balance)); diff --git a/pallets/dispenser/src/tests/utils.rs b/pallets/dispenser/src/tests/utils.rs index 2fcc49c11..f2caa6d95 100644 --- a/pallets/dispenser/src/tests/utils.rs +++ b/pallets/dispenser/src/tests/utils.rs @@ -2,7 +2,6 @@ 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}; @@ -44,7 +43,7 @@ pub fn compute_request_id( amount: U256::from(amount_wei), }; - let faucet_addr = ::FaucetAddress::get(); + let faucet_addr = crate::DispenserConfig::::get().expect("dispenser config must be set").faucet_address; let rlp_encoded = pallet_signet::Pallet::::build_evm_tx( frame_system::RawOrigin::Signed(requester.clone()).into(), Some(faucet_addr), @@ -68,13 +67,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 +75,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 64b5bc6e8..8806c8f69 100644 --- a/pallets/dispenser/src/types.rs +++ b/pallets/dispenser/src/types.rs @@ -1,4 +1,7 @@ +use codec::{Decode, Encode, MaxEncodedLen}; use frame_support::{traits::Currency, weights::Weight}; +use primitives::EvmAddress; +use scale_info::TypeInfo; pub type Balance = u128; pub type AssetId = u32; @@ -10,9 +13,30 @@ pub const ECDSA: &[u8] = b"ecdsa"; pub const ETHEREUM: &[u8] = b"ethereum"; pub const SIGNING_PATH: &[u8] = b"dispenser"; +/// Complete dispenser state: operational flags, tracked faucet balance, +/// and all governance-controlled parameters — stored as a single entry. +#[derive(Encode, Decode, TypeInfo, Clone, Debug, Default, 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 stay in the faucet after a request. + pub min_faucet_threshold: Balance, + /// Minimum amount of faucet asset per request. + pub min_request: Balance, + /// Maximum amount of faucet asset per request. + pub max_dispense: Balance, + /// Flat fee charged in `FeeAsset` per request. + pub dispenser_fee: Balance, +} + pub trait WeightInfo { fn request_fund() -> Weight; fn set_faucet_balance() -> Weight; fn pause() -> Weight; fn unpause() -> Weight; + fn set_faucet_params() -> Weight; } diff --git a/pallets/dispenser/src/weights.rs b/pallets/dispenser/src/weights.rs index 4ec3ac3a2..88d39d469 100644 --- a/pallets/dispenser/src/weights.rs +++ b/pallets/dispenser/src/weights.rs @@ -73,6 +73,12 @@ impl crate::WeightInfo for WeightInfo { .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } + fn set_faucet_params() -> 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:0) /// Proof: `EthDispenser::DispenserConfig` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) /// Storage: `EthDispenser::FaucetBalanceWei` (r:1 w:1) diff --git a/runtime/hydradx/src/assets.rs b/runtime/hydradx/src/assets.rs index fbb945079..dcb8fed76 100644 --- a/runtime/hydradx/src/assets.rs +++ b/runtime/hydradx/src/assets.rs @@ -1892,12 +1892,8 @@ impl pallet_signet::Config for Runtime { 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) @@ -1908,26 +1904,13 @@ 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 RuntimeEvent = RuntimeEvent; 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; } diff --git a/runtime/hydradx/src/weights/pallet_dispenser.rs b/runtime/hydradx/src/weights/pallet_dispenser.rs index bf97ebcbb..060671715 100644 --- a/runtime/hydradx/src/weights/pallet_dispenser.rs +++ b/runtime/hydradx/src/weights/pallet_dispenser.rs @@ -93,6 +93,11 @@ impl pallet_dispenser::WeightInfo for HydraWeight { .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } + fn set_faucet_params() -> Weight { + 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:0) /// Proof: `EthDispenser::DispenserConfig` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) /// Storage: `EthDispenser::FaucetBalanceWei` (r:1 w:1) diff --git a/scripts/dispenser-tests/dispenser.test.ts b/scripts/dispenser-tests/dispenser.test.ts index d2844d0c1..b82bd4d6f 100644 --- a/scripts/dispenser-tests/dispenser.test.ts +++ b/scripts/dispenser-tests/dispenser.test.ts @@ -37,10 +37,11 @@ describe('ERC20 Vault Integration', () => { const feeAsset = (api.consts.ethDispenser.feeAsset as any).toNumber() const faucetAsset = (api.consts.ethDispenser.faucetAsset as any).toNumber() + const dispenserCfg = await (api.query as any).ethDispenser.dispenserConfig() console.log( `feeAsset = ${feeAsset}`, `faucetAsset = ${faucetAsset}`, - `faucetAddress = ${api.consts.ethDispenser.faucetAddress.toString()}`, + `dispenserConfig = ${JSON.stringify(dispenserCfg.toJSON())}`, ) const { requester: acc } = createKeyringAndAccounts(ENV.TEST_ACCOUNT_URI) diff --git a/scripts/dispenser-tests/env.ts b/scripts/dispenser-tests/env.ts index cd41e5747..67644f285 100644 --- a/scripts/dispenser-tests/env.ts +++ b/scripts/dispenser-tests/env.ts @@ -79,6 +79,12 @@ const TEST_ACCOUNT_URI = process.env.TEST_ACCOUNT_URI || '//Alice' 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 +// Dispenser governance params +const DISPENSER_FEE = optionalBigInt('DISPENSER_FEE', 5_000n) +const MIN_REQUEST_AMOUNT = optionalBigInt('MIN_REQUEST_AMOUNT', 0n) +const MAX_DISPENSE_AMOUNT = optionalBigInt('MAX_DISPENSE_AMOUNT', 1_000_000_000_000_000_000n) +const MIN_FAUCET_ETH_THRESHOLD = optionalBigInt('MIN_FAUCET_ETH_THRESHOLD', 50_000_000_000_000_000n) + 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) @@ -102,6 +108,12 @@ export const ENV = { TARGET_ADDRESS, REQUEST_FUND_AMOUNT, + // Dispenser governance params + DISPENSER_FEE, + MIN_REQUEST_AMOUNT, + MAX_DISPENSE_AMOUNT, + MIN_FAUCET_ETH_THRESHOLD, + // Gas GAS_LIMIT, DEFAULT_MAX_FEE_PER_GAS, @@ -118,4 +130,8 @@ console.log(` Substrate WS: ${ENV.SUBSTRATE_WS_ENDPOINT}`) console.log(` Substrate Chain: ${ENV.SUBSTRATE_CHAIN_ID}`) console.log(` Test account: ${ENV.TEST_ACCOUNT_URI}`) console.log(` Target address: ${ENV.TARGET_ADDRESS}`) +console.log(` Dispenser fee: ${ENV.DISPENSER_FEE}`) +console.log(` Min request: ${ENV.MIN_REQUEST_AMOUNT}`) +console.log(` Max dispense: ${ENV.MAX_DISPENSE_AMOUNT}`) +console.log(` Min ETH threshold:${ENV.MIN_FAUCET_ETH_THRESHOLD}`) console.log(`----------------------------\n`) diff --git a/scripts/dispenser-tests/utils.ts b/scripts/dispenser-tests/utils.ts index e7a4ad94e..1cecd1362 100644 --- a/scripts/dispenser-tests/utils.ts +++ b/scripts/dispenser-tests/utils.ts @@ -417,12 +417,12 @@ export async function initializeVaultIfNeeded(api: ApiPromise) { ) } - const current = ( - await (api.query as any).ethDispenser.faucetBalanceWei() - ).toBigInt() - const threshold = ( - (api.consts as any).ethDispenser.minFaucetEthThreshold as any - ).toBigInt() + // Initialize faucet params if not yet set (or update them) + await initializeFaucetParams(api) + + const cfgAfterParams = (await (api.query as any).ethDispenser.dispenserConfig()).toJSON() + const current = BigInt(cfgAfterParams?.faucetBalanceWei ?? 0) + const threshold = ENV.MIN_FAUCET_ETH_THRESHOLD console.log('Current faucetBalanceWei =', current.toString()) console.log('MinFaucetEthThreshold =', threshold.toString()) @@ -445,8 +445,37 @@ export async function initializeVaultIfNeeded(api: ApiPromise) { 'Top up ethDispenser faucet balance via Root', ) - const after = await (api.query as any).ethDispenser.faucetBalanceWei() - console.log('faucetBalanceWei after =', after.toString()) + const cfgAfterTopup = (await (api.query as any).ethDispenser.dispenserConfig()).toJSON() + console.log('faucetBalanceWei after =', cfgAfterTopup?.faucetBalanceWei ?? 0) +} + +export async function initializeFaucetParams(api: ApiPromise) { + const existing = await (api.query as any).ethDispenser.dispenserConfig() + console.log('Current dispenserConfig =', existing.toJSON()) + + const setParamsCall = (api.tx as any).ethDispenser.setFaucetParams( + ENV.FAUCET_ADDRESS, + ENV.MIN_FAUCET_ETH_THRESHOLD.toString(), + ENV.MIN_REQUEST_AMOUNT.toString(), + ENV.MAX_DISPENSE_AMOUNT.toString(), + ENV.DISPENSER_FEE.toString(), + ) + + console.log('Setting faucet params via Root...') + console.log(` faucetAddress: ${ENV.FAUCET_ADDRESS}`) + console.log(` minFaucetThreshold: ${ENV.MIN_FAUCET_ETH_THRESHOLD}`) + console.log(` minRequestAmount: ${ENV.MIN_REQUEST_AMOUNT}`) + console.log(` maxDispenseAmount: ${ENV.MAX_DISPENSE_AMOUNT}`) + console.log(` dispenserFee: ${ENV.DISPENSER_FEE}`) + + await executeAsRootViaScheduler( + api, + setParamsCall, + 'Set ethDispenser faucet params via Root', + ) + + const afterCfg = await (api.query as any).ethDispenser.dispenserConfig() + console.log('Dispenser config after set:', afterCfg.toJSON()) } // --------------------------------------------------------------------------- From ab9acff01a96c0fcf49c9380328e1363b9025b1e Mon Sep 17 00:00:00 2001 From: Yash Sharma Date: Mon, 2 Mar 2026 19:32:42 +0530 Subject: [PATCH 3/5] gas voucher issue test case --- scripts/dispenser-tests/dispenser.test.ts | 191 ++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/scripts/dispenser-tests/dispenser.test.ts b/scripts/dispenser-tests/dispenser.test.ts index b82bd4d6f..212912be2 100644 --- a/scripts/dispenser-tests/dispenser.test.ts +++ b/scripts/dispenser-tests/dispenser.test.ts @@ -247,4 +247,195 @@ describe('ERC20 Vault Integration', () => { Buffer.from(readResponse.output).toString('hex'), ) }, 180_000) + + // This test uses anvil_setBalance to drain the faucet, so it can only run on Anvil. + const isAnvil = async (provider: ethers.JsonRpcProvider) => { + try { + await provider.send('anvil_nodeInfo', []) + return true + } catch { + return false + } + } + + it('should issue IOU voucher tokens when faucet has insufficient ETH', async () => { + if (!(await isAnvil(evmProvider))) { + console.log('Skipping voucher test: EVM provider is not Anvil') + return + } + + await initializeVaultIfNeeded(api) + + // --- Read GasVoucher address from GasFaucet contract --- + const faucetContract = new ethers.Contract( + ENV.FAUCET_ADDRESS, + [ + 'function voucher() view returns (address)', + 'function minEthThreshold() view returns (uint256)', + ], + evmProvider, + ) + const voucherAddress = await faucetContract.voucher() + console.log(`GasVoucher address: ${voucherAddress}`) + + const voucherContract = new ethers.Contract( + voucherAddress, + ['function balanceOf(address) view returns (uint256)'], + evmProvider, + ) + + // --- Force GasFaucet to have 0 ETH so fund() issues vouchers --- + const originalFaucetBalance = await evmProvider.getBalance( + ENV.FAUCET_ADDRESS, + ) + console.log( + `GasFaucet original ETH balance: ${ethers.formatEther(originalFaucetBalance)}`, + ) + + await evmProvider.send('anvil_setBalance', [ENV.FAUCET_ADDRESS, '0x0']) + const drainedBalance = await evmProvider.getBalance(ENV.FAUCET_ADDRESS) + console.log( + `GasFaucet ETH balance after drain: ${ethers.formatEther(drainedBalance)}`, + ) + + // --- Read voucher balance before the request --- + const voucherBalanceBefore = await voucherContract.balanceOf( + ENV.TARGET_ADDRESS, + ) + console.log(`Voucher balance before: ${voucherBalanceBefore}`) + + // --- Build the EVM transaction --- + const feeData = await evmProvider.getFeeData() + const currentNonce = await evmProvider.getTransactionCount( + derivedEthAddress, + 'pending', + ) + + console.log(`Current nonce for ${derivedEthAddress}: ${currentNonce}`) + + 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, + ), + nonce: currentNonce, + chainId: ENV.EVM_CHAIN_ID, + } + + const iface = new ethers.Interface([ + 'function fund(address to, uint256 amount) external', + ]) + + const data = iface.encodeFunctionData('fund', [ + ENV.TARGET_ADDRESS, + ENV.REQUEST_FUND_AMOUNT, + ]) + + const tx = ethers.Transaction.from({ + type: 2, + chainId: txParams.chainId, + nonce: txParams.nonce, + maxPriorityFeePerGas: txParams.maxPriorityFeePerGas, + maxFeePerGas: txParams.maxFeePerGas, + gasLimit: txParams.gasLimit, + to: ENV.FAUCET_ADDRESS, + value: 0, + data, + }) + + const requestId = signetClient.calculateSignRespondRequestId( + palletSS58, + Array.from(ethers.getBytes(tx.unsignedSerialized)), + { + caip2_id: `eip155:${ENV.EVM_CHAIN_ID}`, + keyVersion: 0, + path: 'dispenser', + algo: 'ecdsa', + dest: 'ethereum', + params: '', + }, + ) + + console.log(`Request ID: ${ethers.hexlify(requestId)}\n`) + + const requestIdBytes = + typeof requestId === 'string' ? ethers.getBytes(requestId) : requestId + + const depositTx = api.tx.ethDispenser.requestFund( + Array.from(ethers.getBytes(ENV.TARGET_ADDRESS)), + ENV.REQUEST_FUND_AMOUNT.toString(), + requestIdBytes, + txParams, + ) + + // --- Submit requestFund on Substrate --- + console.log('Submitting requestFund transaction (voucher path)...') + await submitWithRetry(depositTx, requester, api, 'Request Fund (voucher)') + + // --- Wait for MPC signature --- + console.log('Waiting for MPC signature...') + const signature = await signetClient.waitForSignature( + ethers.hexlify(requestId), + 1_200_000, + ) + + if (!signature) { + throw new Error('Timeout waiting for MPC signature') + } + + console.log(`Received signature from: ${signature.responder}`) + + // --- Broadcast the signed EVM transaction --- + const signedTx = constructSignedTransaction( + tx.unsignedSerialized, + signature.signature, + ) + + console.log('Broadcasting transaction...') + const txResponse = await evmProvider.broadcastTransaction(signedTx) + console.log(` Tx Hash: ${txResponse.hash}`) + + const receipt = await txResponse.wait() + console.log(`Transaction confirmed in block ${receipt?.blockNumber}\n`) + + // --- Verify VoucherIssued event was emitted (not Funded) --- + const voucherIssuedTopic = ethers.id('VoucherIssued(address,uint256)') + const fundedTopic = ethers.id('Funded(address,uint256)') + + const voucherEvents = + receipt?.logs.filter( + (log) => log.topics[0] === voucherIssuedTopic, + ) || [] + + const fundedEvents = + receipt?.logs.filter((log) => log.topics[0] === fundedTopic) || [] + + console.log(`VoucherIssued events: ${voucherEvents.length}`) + console.log(`Funded events: ${fundedEvents.length}`) + + expect(voucherEvents.length).toBeGreaterThan(0) + expect(fundedEvents.length).toBe(0) + + // --- Verify voucher token balance increased by the requested amount --- + const voucherBalanceAfter = await voucherContract.balanceOf( + ENV.TARGET_ADDRESS, + ) + console.log(`Voucher balance after: ${voucherBalanceAfter}`) + + const increase = voucherBalanceAfter - voucherBalanceBefore + console.log(`Voucher balance increase: ${increase}`) + + expect(increase).toBe(ENV.REQUEST_FUND_AMOUNT) + + // --- Restore GasFaucet ETH balance --- + if (originalFaucetBalance > 0n) { + await evmProvider.send('anvil_setBalance', [ + ENV.FAUCET_ADDRESS, + ethers.toQuantity(originalFaucetBalance), + ]) + console.log('Restored GasFaucet ETH balance') + } + }, 180_000) }) From 2844a27569f279b0facface5ca26e97418a2bcd4 Mon Sep 17 00:00:00 2001 From: Yash Sharma Date: Mon, 2 Mar 2026 20:08:24 +0530 Subject: [PATCH 4/5] fix for github actions --- Cargo.lock | 4 ++-- pallets/dispenser/Cargo.toml | 2 +- pallets/dispenser/src/benchmarking.rs | 5 ++++- pallets/dispenser/src/lib.rs | 4 +--- pallets/dispenser/src/tests/test_cases.rs | 15 ++++++++++++--- pallets/dispenser/src/tests/utils.rs | 4 +++- runtime/hydradx/Cargo.toml | 2 +- runtime/hydradx/src/lib.rs | 2 +- scripts/dispenser-tests/solana-signet-program | 1 + scripts/dispenser-tests/utils.ts | 10 +++++----- 10 files changed, 31 insertions(+), 18 deletions(-) create mode 160000 scripts/dispenser-tests/solana-signet-program diff --git a/Cargo.lock b/Cargo.lock index 6f7f31389..01d24d18e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6161,7 +6161,7 @@ dependencies = [ [[package]] name = "hydradx-runtime" -version = "396.0.0" +version = "397.0.0" dependencies = [ "alloy-primitives 0.7.7", "alloy-sol-types 0.7.7", @@ -9987,7 +9987,7 @@ dependencies = [ [[package]] name = "pallet-dispenser" -version = "0.2.0" +version = "0.3.0" dependencies = [ "alloy-primitives 0.7.7", "alloy-sol-types 0.7.7", diff --git a/pallets/dispenser/Cargo.toml b/pallets/dispenser/Cargo.toml index 7e38ce21a..27a78f8c7 100644 --- a/pallets/dispenser/Cargo.toml +++ b/pallets/dispenser/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-dispenser" -version = "0.2.0" +version = "0.3.0" edition = "2021" [package.metadata.docs.rs] diff --git a/pallets/dispenser/src/benchmarking.rs b/pallets/dispenser/src/benchmarking.rs index 4f4f1927a..0cd987a06 100644 --- a/pallets/dispenser/src/benchmarking.rs +++ b/pallets/dispenser/src/benchmarking.rs @@ -57,7 +57,10 @@ mod benches { #[benchmark] fn unpause() { - DispenserConfig::::put(DispenserConfigData { paused: true, ..Default::default() }); + DispenserConfig::::put(DispenserConfigData { + paused: true, + ..Default::default() + }); #[extrinsic_call] unpause(RawOrigin::Root); diff --git a/pallets/dispenser/src/lib.rs b/pallets/dispenser/src/lib.rs index 0d0ff3ee9..d0b1481a8 100644 --- a/pallets/dispenser/src/lib.rs +++ b/pallets/dispenser/src/lib.rs @@ -239,9 +239,7 @@ pub mod pallet { // Check tracked faucet balance vs. threshold. let observed = config.faucet_balance_wei; let min_threshold = config.min_faucet_threshold; - let needed = min_threshold - .checked_add(amount) - .ok_or(Error::::InvalidOutput)?; + let needed = min_threshold.checked_add(amount).ok_or(Error::::InvalidOutput)?; ensure!(observed >= needed, Error::::FaucetBalanceBelowThreshold); // EIP-1559 fee sanity checks. diff --git a/pallets/dispenser/src/tests/test_cases.rs b/pallets/dispenser/src/tests/test_cases.rs index 786221b38..e86e5c8b3 100644 --- a/pallets/dispenser/src/tests/test_cases.rs +++ b/pallets/dispenser/src/tests/test_cases.rs @@ -212,7 +212,10 @@ fn governance_sets_faucet_balance_and_emits_event() { new_test_ext().execute_with(|| { let old = Dispenser::dispenser_config().unwrap().faucet_balance_wei; assert_ok!(Dispenser::set_faucet_balance(RuntimeOrigin::root(), 42u128)); - assert_eq!(Dispenser::dispenser_config().unwrap().faucet_balance_wei, MIN_WEI_BALANCE + 42u128); + assert_eq!( + Dispenser::dispenser_config().unwrap().faucet_balance_wei, + MIN_WEI_BALANCE + 42u128 + ); let ev = System::events().into_iter().any(|rec| { matches!(rec.event, @@ -233,7 +236,10 @@ fn non_governance_cannot_set_faucet_balance() { Dispenser::set_faucet_balance(RuntimeOrigin::signed(alice), 7u128), sp_runtime::DispatchError::BadOrigin ); - assert_eq!(Dispenser::dispenser_config().unwrap().faucet_balance_wei, MIN_WEI_BALANCE); + assert_eq!( + Dispenser::dispenser_config().unwrap().faucet_balance_wei, + MIN_WEI_BALANCE + ); }); } @@ -318,7 +324,10 @@ fn request_reduces_faucet_balance() { )); let expected_balance = initial_balance.saturating_sub(amount).saturating_add(MIN_WEI_BALANCE); - assert_eq!(Dispenser::dispenser_config().unwrap().faucet_balance_wei, expected_balance); + assert_eq!( + Dispenser::dispenser_config().unwrap().faucet_balance_wei, + expected_balance + ); assert_eq!( Currencies::free_balance(fee_asset, &requester), diff --git a/pallets/dispenser/src/tests/utils.rs b/pallets/dispenser/src/tests/utils.rs index f2caa6d95..e4a758764 100644 --- a/pallets/dispenser/src/tests/utils.rs +++ b/pallets/dispenser/src/tests/utils.rs @@ -43,7 +43,9 @@ pub fn compute_request_id( amount: U256::from(amount_wei), }; - let faucet_addr = crate::DispenserConfig::::get().expect("dispenser config must be set").faucet_address; + let faucet_addr = crate::DispenserConfig::::get() + .expect("dispenser config must be set") + .faucet_address; let rlp_encoded = pallet_signet::Pallet::::build_evm_tx( frame_system::RawOrigin::Signed(requester.clone()).into(), Some(faucet_addr), diff --git a/runtime/hydradx/Cargo.toml b/runtime/hydradx/Cargo.toml index d698d4524..32d7ba08e 100644 --- a/runtime/hydradx/Cargo.toml +++ b/runtime/hydradx/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hydradx-runtime" -version = "396.0.0" +version = "397.0.0" authors = ["GalacticCouncil"] edition = "2021" license = "Apache 2.0" diff --git a/runtime/hydradx/src/lib.rs b/runtime/hydradx/src/lib.rs index ef40e16b4..bfe55aca0 100644 --- a/runtime/hydradx/src/lib.rs +++ b/runtime/hydradx/src/lib.rs @@ -129,7 +129,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: Cow::Borrowed("hydradx"), impl_name: Cow::Borrowed("hydradx"), authoring_version: 1, - spec_version: 396, + spec_version: 397, impl_version: 0, apis: RUNTIME_API_VERSIONS, transaction_version: 1, diff --git a/scripts/dispenser-tests/solana-signet-program b/scripts/dispenser-tests/solana-signet-program new file mode 160000 index 000000000..abc3c1423 --- /dev/null +++ b/scripts/dispenser-tests/solana-signet-program @@ -0,0 +1 @@ +Subproject commit abc3c14231862effc6368b531b7696f315e31cb9 diff --git a/scripts/dispenser-tests/utils.ts b/scripts/dispenser-tests/utils.ts index 1cecd1362..d3a034a7d 100644 --- a/scripts/dispenser-tests/utils.ts +++ b/scripts/dispenser-tests/utils.ts @@ -249,13 +249,13 @@ export async function ensureAccountHasAssets( const needsFee = feeBalance < ethers.parseEther('1') if ((needsNative || needsFaucet || needsFee) && requesterUri !== '//Alice') { - console.log(`Funding ${requesterUri} from //Alice...`) + console.log(`Funding account from //Alice...`) const keyring = new Keyring({ type: 'sr25519' }) const alice = keyring.addFromUri('//Alice') if (needsNative) { const tx = api.tx.balances.transferKeepAlive(account.address, NATIVE_TOPUP) - await submitWithRetry(tx, alice, api, `Fund ${requesterUri} native`) + await submitWithRetry(tx, alice, api, 'Fund requester native') } if (needsFaucet) { @@ -265,14 +265,14 @@ export async function ensureAccountHasAssets( account.address, faucetAsset, ethers.parseEther('100'), - `Fund ${requesterUri} faucet asset ${faucetAsset}`, + `Fund requester faucet asset ${faucetAsset}`, ) } if (needsFee) { if (feeAsset === 0) { const tx = api.tx.balances.transferKeepAlive(account.address, NATIVE_TOPUP) - await submitWithRetry(tx, alice, api, `Fund ${requesterUri} fee asset ${feeAsset}`) + await submitWithRetry(tx, alice, api, `Fund requester fee asset ${feeAsset}`) } else { await transferAsset( api, @@ -280,7 +280,7 @@ export async function ensureAccountHasAssets( account.address, feeAsset, ethers.parseEther('100'), - `Fund ${requesterUri} fee asset ${feeAsset}`, + `Fund requester fee asset ${feeAsset}`, ) } } From 83515053fb42d3f0a5a15a473d8898ec0652a0c2 Mon Sep 17 00:00:00 2001 From: Yash Sharma Date: Tue, 3 Mar 2026 00:34:56 +0530 Subject: [PATCH 5/5] Remove orphaned submodule gitlink --- scripts/dispenser-tests/solana-signet-program | 1 - 1 file changed, 1 deletion(-) delete mode 160000 scripts/dispenser-tests/solana-signet-program diff --git a/scripts/dispenser-tests/solana-signet-program b/scripts/dispenser-tests/solana-signet-program deleted file mode 160000 index abc3c1423..000000000 --- a/scripts/dispenser-tests/solana-signet-program +++ /dev/null @@ -1 +0,0 @@ -Subproject commit abc3c14231862effc6368b531b7696f315e31cb9