@@ -2542,3 +2542,279 @@ pub fn init_omnipol() {
25422542 TREASURY_ACCOUNT_INIT_BALANCE ,
25432543 ) ) ;
25442544}
2545+
2546+ // Tests validating that the CALLPERMIT precompile and dispatch_permit share
2547+ // a single permit domain by design. dispatch_permit is a self-relay mechanism:
2548+ // the user signs one permit and submits it as an unsigned extrinsic to pay fees
2549+ // in a non-native currency. The shared EIP-712 digest and nonce space are intentional.
2550+
2551+ #[ test]
2552+ fn permit_is_accepted_by_both_callpermit_and_dispatch_permit_by_design ( ) {
2553+ // The CALLPERMIT precompile and dispatch_permit share the same EIP-712 domain
2554+ // and nonce space. A permit signed once can be submitted via either interface.
2555+ // This is by design — dispatch_permit is a self-relay path, not a separate trust domain.
2556+ TestNet :: reset ( ) ;
2557+
2558+ let user_evm_address = alith_evm_address ( ) ;
2559+ let user_secret_key = alith_secret_key ( ) ;
2560+ let user_acc = MockAccount :: new ( alith_truncated_account ( ) ) ;
2561+
2562+ Hydra :: execute_with ( || {
2563+ init_omnipool_with_oracle_for_block_10 ( ) ;
2564+ pallet_transaction_payment:: pallet:: NextFeeMultiplier :: < hydradx_runtime:: Runtime > :: put (
2565+ hydradx_runtime:: MinimumMultiplier :: get ( ) ,
2566+ ) ;
2567+
2568+ assert_ok ! ( Tokens :: set_balance(
2569+ RawOrigin :: Root . into( ) ,
2570+ user_acc. address( ) ,
2571+ WETH ,
2572+ to_ether( 1 ) ,
2573+ 0 ,
2574+ ) ) ;
2575+ assert_ok ! ( hydradx_runtime:: Currencies :: update_balance(
2576+ hydradx_runtime:: RuntimeOrigin :: root( ) ,
2577+ user_acc. address( ) ,
2578+ HDX ,
2579+ ( 10 * UNITS ) as i128 ,
2580+ ) ) ;
2581+
2582+ let initial_user_weth = user_acc. balance ( WETH ) ;
2583+
2584+ let omni_sell =
2585+ hydradx_runtime:: RuntimeCall :: Omnipool ( pallet_omnipool:: Call :: < hydradx_runtime:: Runtime > :: sell {
2586+ asset_in : HDX ,
2587+ asset_out : DAI ,
2588+ amount : 10_000_000 ,
2589+ min_buy_amount : 0 ,
2590+ } ) ;
2591+
2592+ let gas_limit = 1_000_000u64 ;
2593+ let deadline = U256 :: from ( 1_000_000_000_000u128 ) ;
2594+
2595+ // Generate permit using the shared CALLPERMIT domain
2596+ let permit =
2597+ pallet_evm_precompile_call_permit:: CallPermitPrecompile :: < hydradx_runtime:: Runtime > :: generate_permit (
2598+ CALLPERMIT ,
2599+ user_evm_address,
2600+ DISPATCH_ADDR ,
2601+ U256 :: from ( 0 ) ,
2602+ omni_sell. encode ( ) ,
2603+ gas_limit,
2604+ U256 :: zero ( ) ,
2605+ deadline,
2606+ ) ;
2607+ let secret_key = SecretKey :: parse ( & user_secret_key) . unwrap ( ) ;
2608+ let message = Message :: parse ( & permit) ;
2609+ let ( rs, v) = sign ( & message, & secret_key) ;
2610+
2611+ // Submit via dispatch_permit (self-relay path)
2612+ assert_ok ! ( MultiTransactionPayment :: dispatch_permit(
2613+ hydradx_runtime:: RuntimeOrigin :: none( ) ,
2614+ user_evm_address,
2615+ DISPATCH_ADDR ,
2616+ U256 :: from( 0 ) ,
2617+ omni_sell. encode( ) ,
2618+ gas_limit,
2619+ deadline,
2620+ v. serialize( ) ,
2621+ H256 :: from( rs. r. b32( ) ) ,
2622+ H256 :: from( rs. s. b32( ) ) ,
2623+ ) ) ;
2624+
2625+ // Signer pays the EVM fee via dispatch_permit (expected for self-relay)
2626+ let fee_paid = initial_user_weth - user_acc. balance ( WETH ) ;
2627+ assert ! (
2628+ fee_paid > 0 ,
2629+ "signer should pay fee when self-relaying via dispatch_permit"
2630+ ) ;
2631+
2632+ // Permit nonce consumed — prevents reuse via either interface
2633+ let permit_nonce =
2634+ <hydradx_runtime:: Runtime as pallet_transaction_multi_payment:: Config >:: EvmPermit :: permit_nonce (
2635+ user_evm_address,
2636+ ) ;
2637+ assert_eq ! ( permit_nonce, U256 :: one( ) ) ;
2638+ } )
2639+ }
2640+
2641+ #[ test]
2642+ fn shared_nonce_prevents_permit_reuse_across_submission_paths ( ) {
2643+ // The shared nonce space ensures a permit can only be used once, regardless
2644+ // of which interface it was submitted through. This is the intended replay protection.
2645+ TestNet :: reset ( ) ;
2646+
2647+ let user_evm_address = alith_evm_address ( ) ;
2648+ let user_secret_key = alith_secret_key ( ) ;
2649+ let user_acc = MockAccount :: new ( alith_truncated_account ( ) ) ;
2650+
2651+ Hydra :: execute_with ( || {
2652+ init_omnipool_with_oracle_for_block_10 ( ) ;
2653+ pallet_transaction_payment:: pallet:: NextFeeMultiplier :: < hydradx_runtime:: Runtime > :: put (
2654+ hydradx_runtime:: MinimumMultiplier :: get ( ) ,
2655+ ) ;
2656+
2657+ assert_ok ! ( Tokens :: set_balance(
2658+ RawOrigin :: Root . into( ) ,
2659+ user_acc. address( ) ,
2660+ WETH ,
2661+ to_ether( 1 ) ,
2662+ 0 ,
2663+ ) ) ;
2664+ assert_ok ! ( hydradx_runtime:: Currencies :: update_balance(
2665+ hydradx_runtime:: RuntimeOrigin :: root( ) ,
2666+ user_acc. address( ) ,
2667+ HDX ,
2668+ ( 10 * UNITS ) as i128 ,
2669+ ) ) ;
2670+
2671+ let omni_sell =
2672+ hydradx_runtime:: RuntimeCall :: Omnipool ( pallet_omnipool:: Call :: < hydradx_runtime:: Runtime > :: sell {
2673+ asset_in : HDX ,
2674+ asset_out : DAI ,
2675+ amount : 10_000_000 ,
2676+ min_buy_amount : 0 ,
2677+ } ) ;
2678+
2679+ let gas_limit = 1_000_000u64 ;
2680+ let deadline = U256 :: from ( 1_000_000_000_000u128 ) ;
2681+
2682+ let permit =
2683+ pallet_evm_precompile_call_permit:: CallPermitPrecompile :: < hydradx_runtime:: Runtime > :: generate_permit (
2684+ CALLPERMIT ,
2685+ user_evm_address,
2686+ DISPATCH_ADDR ,
2687+ U256 :: from ( 0 ) ,
2688+ omni_sell. encode ( ) ,
2689+ gas_limit,
2690+ U256 :: zero ( ) ,
2691+ deadline,
2692+ ) ;
2693+ let secret_key = SecretKey :: parse ( & user_secret_key) . unwrap ( ) ;
2694+ let message = Message :: parse ( & permit) ;
2695+ let ( rs, v) = sign ( & message, & secret_key) ;
2696+
2697+ // First use succeeds
2698+ assert_ok ! ( MultiTransactionPayment :: dispatch_permit(
2699+ hydradx_runtime:: RuntimeOrigin :: none( ) ,
2700+ user_evm_address,
2701+ DISPATCH_ADDR ,
2702+ U256 :: from( 0 ) ,
2703+ omni_sell. encode( ) ,
2704+ gas_limit,
2705+ deadline,
2706+ v. serialize( ) ,
2707+ H256 :: from( rs. r. b32( ) ) ,
2708+ H256 :: from( rs. s. b32( ) ) ,
2709+ ) ) ;
2710+
2711+ assert_eq ! (
2712+ <hydradx_runtime:: Runtime as pallet_transaction_multi_payment:: Config >:: EvmPermit :: permit_nonce(
2713+ user_evm_address,
2714+ ) ,
2715+ U256 :: one( )
2716+ ) ;
2717+
2718+ // Second use of the same permit is rejected — nonce already consumed
2719+ let call = pallet_transaction_multi_payment:: Call :: dispatch_permit {
2720+ from : user_evm_address,
2721+ to : DISPATCH_ADDR ,
2722+ value : U256 :: from ( 0 ) ,
2723+ data : omni_sell. encode ( ) ,
2724+ gas_limit,
2725+ deadline,
2726+ v : v. serialize ( ) ,
2727+ r : H256 :: from ( rs. r . b32 ( ) ) ,
2728+ s : H256 :: from ( rs. s . b32 ( ) ) ,
2729+ } ;
2730+ assert ! (
2731+ MultiTransactionPayment :: validate_unsigned( TransactionSource :: External , & call) . is_err( ) ,
2732+ "same permit cannot be used twice — shared nonce prevents replay"
2733+ ) ;
2734+ } )
2735+ }
2736+
2737+ #[ test]
2738+ fn dispatch_permit_fee_currency_override_works_with_any_to_address ( ) {
2739+ // dispatch_permit decodes fee currency from `data` regardless of the `to` address.
2740+ // This is safe because `data` is part of the signed permit — the signer explicitly
2741+ // committed to this data. An external party cannot alter it post-signature.
2742+ TestNet :: reset ( ) ;
2743+
2744+ let user_evm_address = alith_evm_address ( ) ;
2745+ let user_secret_key = alith_secret_key ( ) ;
2746+ let user_acc = MockAccount :: new ( alith_truncated_account ( ) ) ;
2747+
2748+ Hydra :: execute_with ( || {
2749+ init_omnipool_with_oracle_for_block_10 ( ) ;
2750+ pallet_transaction_payment:: pallet:: NextFeeMultiplier :: < hydradx_runtime:: Runtime > :: put (
2751+ hydradx_runtime:: MinimumMultiplier :: get ( ) ,
2752+ ) ;
2753+
2754+ assert_ok ! ( hydradx_runtime:: Currencies :: update_balance(
2755+ hydradx_runtime:: RuntimeOrigin :: root( ) ,
2756+ user_acc. address( ) ,
2757+ DAI ,
2758+ 100_000_000_000_000_000_000i128 ,
2759+ ) ) ;
2760+ assert_ok ! ( Tokens :: set_balance(
2761+ RawOrigin :: Root . into( ) ,
2762+ user_acc. address( ) ,
2763+ WETH ,
2764+ to_ether( 1 ) ,
2765+ 0 ,
2766+ ) ) ;
2767+
2768+ let initial_dai = user_acc. balance ( DAI ) ;
2769+ let initial_weth = user_acc. balance ( WETH ) ;
2770+
2771+ // The signer explicitly signs a permit with set_currency(DAI) as data.
2772+ // The `to` address does not need to be DISPATCH_ADDR for fee currency
2773+ // detection to work — this is by design since data is signer-committed.
2774+ let set_currency_call = hydradx_runtime:: RuntimeCall :: MultiTransactionPayment (
2775+ pallet_transaction_multi_payment:: Call :: set_currency { currency : DAI } ,
2776+ ) ;
2777+ let data = set_currency_call. encode ( ) ;
2778+
2779+ let arbitrary_to: sp_core:: H160 = sp_core:: H160 :: from_low_u64_be ( 0xdeadbeef ) ;
2780+
2781+ let gas_limit = 1_000_000u64 ;
2782+ let deadline = U256 :: from ( 1_000_000_000_000u128 ) ;
2783+
2784+ let permit =
2785+ pallet_evm_precompile_call_permit:: CallPermitPrecompile :: < hydradx_runtime:: Runtime > :: generate_permit (
2786+ CALLPERMIT ,
2787+ user_evm_address,
2788+ arbitrary_to,
2789+ U256 :: from ( 0 ) ,
2790+ data. clone ( ) ,
2791+ gas_limit,
2792+ U256 :: zero ( ) ,
2793+ deadline,
2794+ ) ;
2795+ let secret_key = SecretKey :: parse ( & user_secret_key) . unwrap ( ) ;
2796+ let message = Message :: parse ( & permit) ;
2797+ let ( rs, v) = sign ( & message, & secret_key) ;
2798+
2799+ assert_ok ! ( MultiTransactionPayment :: dispatch_permit(
2800+ hydradx_runtime:: RuntimeOrigin :: none( ) ,
2801+ user_evm_address,
2802+ arbitrary_to,
2803+ U256 :: from( 0 ) ,
2804+ data,
2805+ gas_limit,
2806+ deadline,
2807+ v. serialize( ) ,
2808+ H256 :: from( rs. r. b32( ) ) ,
2809+ H256 :: from( rs. s. b32( ) ) ,
2810+ ) ) ;
2811+
2812+ let dai_spent = initial_dai - user_acc. balance ( DAI ) ;
2813+ let weth_spent = initial_weth - user_acc. balance ( WETH ) ;
2814+
2815+ // Fee currency override applied from data regardless of `to` address.
2816+ // This is safe: the signer chose this data and signed over it.
2817+ assert ! ( dai_spent > 0 , "DAI should be used as fee currency per signer's data" ) ;
2818+ assert_eq ! ( weth_spent, 0 , "WETH should not be touched when DAI is overridden" ) ;
2819+ } )
2820+ }
0 commit comments