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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions integration-tests/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "runtime-integration-tests"
version = "1.76.1"
version = "1.77.0"
description = "Integration tests"
authors = ["GalacticCouncil"]
edition = "2021"
Expand Down Expand Up @@ -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 }
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pallet-proxy was removed from this crate’s dependencies, but integration-tests/src/evm.rs still references pallet_proxy::Call::proxy (e.g. around lines ~5318 and ~5448). This will fail to compile unless the dependency is restored (or the tests are refactored to avoid direct pallet_proxy references).

Suggested change
pallet-hsm = { workspace = true }
pallet-hsm = { workspace = true }
pallet-proxy = { workspace = true }

Copilot uses AI. Check for mistakes.

# collator support
Expand Down
276 changes: 276 additions & 0 deletions integration-tests/src/evm_permit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +2552 to +2555
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name/comment claims a permit is accepted by both CALLPERMIT and dispatch_permit, but the test only exercises dispatch_permit (it only generates the digest via the CALLPERMIT helper). Consider either (a) renaming the test to reflect what’s actually being verified (CALLPERMIT-domain permit validates on dispatch_permit), or (b) extending it to actually submit via the CALLPERMIT precompile as well (likely with a fresh nonce/permit for the second path).

Suggested change
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.
fn callpermit_domain_permit_is_accepted_by_dispatch_permit_by_design() {
// The CALLPERMIT precompile and dispatch_permit share the same EIP-712 domain
// and nonce space. This test verifies that a permit signed for that shared
// domain is accepted by dispatch_permit, which is a self-relay path rather
// than a separate trust domain.

Copilot uses AI. Check for mistakes.
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::<hydradx_runtime::Runtime>::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::<hydradx_runtime::Runtime>::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::<hydradx_runtime::Runtime>::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 =
<hydradx_runtime::Runtime as pallet_transaction_multi_payment::Config>::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.
Comment on lines +2642 to +2644
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test description says it prevents permit reuse “across submission paths”, but it only checks replay rejection within the dispatch_permit unsigned validation path (no CALLPERMIT submission is attempted). Either rename to match the current coverage, or add an assertion that the same signed permit is also rejected when routed through the CALLPERMIT precompile (to truly validate the shared nonce space across both interfaces).

Suggested change
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.
fn shared_nonce_prevents_dispatch_permit_replay() {
// The shared nonce space ensures the same signed permit cannot be replayed
// through the dispatch_permit submission path. This validates replay protection here.

Copilot uses AI. Check for mistakes.
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::<hydradx_runtime::Runtime>::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::<hydradx_runtime::Runtime>::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::<hydradx_runtime::Runtime>::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!(
<hydradx_runtime::Runtime as pallet_transaction_multi_payment::Config>::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::<hydradx_runtime::Runtime>::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::<hydradx_runtime::Runtime>::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");
})
}
Loading