diff --git a/Cargo.lock b/Cargo.lock index e0b4f5dae..bd0bd2be6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15646,7 +15646,7 @@ checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" [[package]] name = "runtime-integration-tests" -version = "1.76.1" +version = "1.77.0" dependencies = [ "cumulus-pallet-aura-ext", "cumulus-pallet-parachain-system", @@ -15720,7 +15720,6 @@ dependencies = [ "pallet-omnipool", "pallet-omnipool-liquidity-mining", "pallet-otc", - "pallet-proxy", "pallet-referenda", "pallet-referrals", "pallet-relaychain-info", diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml index 157bfa77a..8e6605752 100644 --- a/integration-tests/Cargo.toml +++ b/integration-tests/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "runtime-integration-tests" -version = "1.76.1" +version = "1.77.0" description = "Integration tests" authors = ["GalacticCouncil"] edition = "2021" @@ -57,7 +57,6 @@ pallet-scheduler = { workspace = true } pallet-referenda = { workspace = true } pallet-conviction-voting = { workspace = true } pallet-dispatcher = { workspace = true } -pallet-proxy = { workspace = true } pallet-hsm = { workspace = true } # collator support diff --git a/integration-tests/src/evm_permit.rs b/integration-tests/src/evm_permit.rs index 532144983..53874f7f1 100644 --- a/integration-tests/src/evm_permit.rs +++ b/integration-tests/src/evm_permit.rs @@ -2542,3 +2542,279 @@ pub fn init_omnipol() { TREASURY_ACCOUNT_INIT_BALANCE, )); } + +// Tests validating that the CALLPERMIT precompile and dispatch_permit share +// a single permit domain by design. dispatch_permit is a self-relay mechanism: +// the user signs one permit and submits it as an unsigned extrinsic to pay fees +// in a non-native currency. The shared EIP-712 digest and nonce space are intentional. + +#[test] +fn permit_is_accepted_by_both_callpermit_and_dispatch_permit_by_design() { + // The CALLPERMIT precompile and dispatch_permit share the same EIP-712 domain + // and nonce space. A permit signed once can be submitted via either interface. + // This is by design — dispatch_permit is a self-relay path, not a separate trust domain. + TestNet::reset(); + + let user_evm_address = alith_evm_address(); + let user_secret_key = alith_secret_key(); + let user_acc = MockAccount::new(alith_truncated_account()); + + Hydra::execute_with(|| { + init_omnipool_with_oracle_for_block_10(); + pallet_transaction_payment::pallet::NextFeeMultiplier::::put( + hydradx_runtime::MinimumMultiplier::get(), + ); + + assert_ok!(Tokens::set_balance( + RawOrigin::Root.into(), + user_acc.address(), + WETH, + to_ether(1), + 0, + )); + assert_ok!(hydradx_runtime::Currencies::update_balance( + hydradx_runtime::RuntimeOrigin::root(), + user_acc.address(), + HDX, + (10 * UNITS) as i128, + )); + + let initial_user_weth = user_acc.balance(WETH); + + let omni_sell = + hydradx_runtime::RuntimeCall::Omnipool(pallet_omnipool::Call::::sell { + asset_in: HDX, + asset_out: DAI, + amount: 10_000_000, + min_buy_amount: 0, + }); + + let gas_limit = 1_000_000u64; + let deadline = U256::from(1_000_000_000_000u128); + + // Generate permit using the shared CALLPERMIT domain + let permit = + pallet_evm_precompile_call_permit::CallPermitPrecompile::::generate_permit( + CALLPERMIT, + user_evm_address, + DISPATCH_ADDR, + U256::from(0), + omni_sell.encode(), + gas_limit, + U256::zero(), + deadline, + ); + let secret_key = SecretKey::parse(&user_secret_key).unwrap(); + let message = Message::parse(&permit); + let (rs, v) = sign(&message, &secret_key); + + // Submit via dispatch_permit (self-relay path) + assert_ok!(MultiTransactionPayment::dispatch_permit( + hydradx_runtime::RuntimeOrigin::none(), + user_evm_address, + DISPATCH_ADDR, + U256::from(0), + omni_sell.encode(), + gas_limit, + deadline, + v.serialize(), + H256::from(rs.r.b32()), + H256::from(rs.s.b32()), + )); + + // Signer pays the EVM fee via dispatch_permit (expected for self-relay) + let fee_paid = initial_user_weth - user_acc.balance(WETH); + assert!( + fee_paid > 0, + "signer should pay fee when self-relaying via dispatch_permit" + ); + + // Permit nonce consumed — prevents reuse via either interface + let permit_nonce = + ::EvmPermit::permit_nonce( + user_evm_address, + ); + assert_eq!(permit_nonce, U256::one()); + }) +} + +#[test] +fn shared_nonce_prevents_permit_reuse_across_submission_paths() { + // The shared nonce space ensures a permit can only be used once, regardless + // of which interface it was submitted through. This is the intended replay protection. + TestNet::reset(); + + let user_evm_address = alith_evm_address(); + let user_secret_key = alith_secret_key(); + let user_acc = MockAccount::new(alith_truncated_account()); + + Hydra::execute_with(|| { + init_omnipool_with_oracle_for_block_10(); + pallet_transaction_payment::pallet::NextFeeMultiplier::::put( + hydradx_runtime::MinimumMultiplier::get(), + ); + + assert_ok!(Tokens::set_balance( + RawOrigin::Root.into(), + user_acc.address(), + WETH, + to_ether(1), + 0, + )); + assert_ok!(hydradx_runtime::Currencies::update_balance( + hydradx_runtime::RuntimeOrigin::root(), + user_acc.address(), + HDX, + (10 * UNITS) as i128, + )); + + let omni_sell = + hydradx_runtime::RuntimeCall::Omnipool(pallet_omnipool::Call::::sell { + asset_in: HDX, + asset_out: DAI, + amount: 10_000_000, + min_buy_amount: 0, + }); + + let gas_limit = 1_000_000u64; + let deadline = U256::from(1_000_000_000_000u128); + + let permit = + pallet_evm_precompile_call_permit::CallPermitPrecompile::::generate_permit( + CALLPERMIT, + user_evm_address, + DISPATCH_ADDR, + U256::from(0), + omni_sell.encode(), + gas_limit, + U256::zero(), + deadline, + ); + let secret_key = SecretKey::parse(&user_secret_key).unwrap(); + let message = Message::parse(&permit); + let (rs, v) = sign(&message, &secret_key); + + // First use succeeds + assert_ok!(MultiTransactionPayment::dispatch_permit( + hydradx_runtime::RuntimeOrigin::none(), + user_evm_address, + DISPATCH_ADDR, + U256::from(0), + omni_sell.encode(), + gas_limit, + deadline, + v.serialize(), + H256::from(rs.r.b32()), + H256::from(rs.s.b32()), + )); + + assert_eq!( + ::EvmPermit::permit_nonce( + user_evm_address, + ), + U256::one() + ); + + // Second use of the same permit is rejected — nonce already consumed + let call = pallet_transaction_multi_payment::Call::dispatch_permit { + from: user_evm_address, + to: DISPATCH_ADDR, + value: U256::from(0), + data: omni_sell.encode(), + gas_limit, + deadline, + v: v.serialize(), + r: H256::from(rs.r.b32()), + s: H256::from(rs.s.b32()), + }; + assert!( + MultiTransactionPayment::validate_unsigned(TransactionSource::External, &call).is_err(), + "same permit cannot be used twice — shared nonce prevents replay" + ); + }) +} + +#[test] +fn dispatch_permit_fee_currency_override_works_with_any_to_address() { + // dispatch_permit decodes fee currency from `data` regardless of the `to` address. + // This is safe because `data` is part of the signed permit — the signer explicitly + // committed to this data. An external party cannot alter it post-signature. + TestNet::reset(); + + let user_evm_address = alith_evm_address(); + let user_secret_key = alith_secret_key(); + let user_acc = MockAccount::new(alith_truncated_account()); + + Hydra::execute_with(|| { + init_omnipool_with_oracle_for_block_10(); + pallet_transaction_payment::pallet::NextFeeMultiplier::::put( + hydradx_runtime::MinimumMultiplier::get(), + ); + + assert_ok!(hydradx_runtime::Currencies::update_balance( + hydradx_runtime::RuntimeOrigin::root(), + user_acc.address(), + DAI, + 100_000_000_000_000_000_000i128, + )); + assert_ok!(Tokens::set_balance( + RawOrigin::Root.into(), + user_acc.address(), + WETH, + to_ether(1), + 0, + )); + + let initial_dai = user_acc.balance(DAI); + let initial_weth = user_acc.balance(WETH); + + // The signer explicitly signs a permit with set_currency(DAI) as data. + // The `to` address does not need to be DISPATCH_ADDR for fee currency + // detection to work — this is by design since data is signer-committed. + let set_currency_call = hydradx_runtime::RuntimeCall::MultiTransactionPayment( + pallet_transaction_multi_payment::Call::set_currency { currency: DAI }, + ); + let data = set_currency_call.encode(); + + let arbitrary_to: sp_core::H160 = sp_core::H160::from_low_u64_be(0xdeadbeef); + + let gas_limit = 1_000_000u64; + let deadline = U256::from(1_000_000_000_000u128); + + let permit = + pallet_evm_precompile_call_permit::CallPermitPrecompile::::generate_permit( + CALLPERMIT, + user_evm_address, + arbitrary_to, + U256::from(0), + data.clone(), + gas_limit, + U256::zero(), + deadline, + ); + let secret_key = SecretKey::parse(&user_secret_key).unwrap(); + let message = Message::parse(&permit); + let (rs, v) = sign(&message, &secret_key); + + assert_ok!(MultiTransactionPayment::dispatch_permit( + hydradx_runtime::RuntimeOrigin::none(), + user_evm_address, + arbitrary_to, + U256::from(0), + data, + gas_limit, + deadline, + v.serialize(), + H256::from(rs.r.b32()), + H256::from(rs.s.b32()), + )); + + let dai_spent = initial_dai - user_acc.balance(DAI); + let weth_spent = initial_weth - user_acc.balance(WETH); + + // Fee currency override applied from data regardless of `to` address. + // This is safe: the signer chose this data and signed over it. + assert!(dai_spent > 0, "DAI should be used as fee currency per signer's data"); + assert_eq!(weth_spent, 0, "WETH should not be touched when DAI is overridden"); + }) +}