diff --git a/modules/pallets/intents-coprocessor/src/benchmarking.rs b/modules/pallets/intents-coprocessor/src/benchmarking.rs index 4c2ed8aac..3171cd378 100644 --- a/modules/pallets/intents-coprocessor/src/benchmarking.rs +++ b/modules/pallets/intents-coprocessor/src/benchmarking.rs @@ -186,5 +186,44 @@ mod benchmarks { Ok(()) } + #[benchmark] + fn set_phantom_order_config() -> Result<(), BenchmarkError> { + let origin = + T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; + let config = types::PhantomOrderConfiguration { + chain: StateMachine::Evm(8453), + token_pairs: vec![types::PhantomTokenPair { + token_a: H160::repeat_byte(0x01), + token_b: H160::repeat_byte(0x02), + standard_amount: 1_000_000_000_000_000_000u128, + min_output: 990_000_000_000_000_000u128, + }] + .try_into() + .expect("one pair fits in BoundedVec<_, 10>"), + interval_blocks: 10, + }; + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, config); + + assert!(PhantomOrderConfig::::get().is_some()); + Ok(()) + } + + #[benchmark] + fn set_phantom_bid_window() -> Result<(), BenchmarkError> { + let origin = + T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; + let window: u32 = 100; + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, window); + + // Verify the window was stored + assert_eq!(PhantomBidWindow::::get(), window); + + Ok(()) + } + impl_benchmark_test_suite!(Pallet, crate::tests::new_test_ext(), crate::tests::Test); } diff --git a/modules/pallets/intents-coprocessor/src/lib.rs b/modules/pallets/intents-coprocessor/src/lib.rs index 1c8fd4932..607d6ebed 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -39,10 +39,16 @@ use polkadot_sdk::*; use primitive_types::{H160, H256}; use sp_core::Get; use sp_io::offchain_index; -use sp_runtime::traits::{ConstU32, Zero}; +use sp_runtime::{ + traits::{ConstU32, Zero}, + SaturatedConversion, +}; pub use weights::WeightInfo; -use types::{Bid, GatewayInfo, IntentGatewayParams, RequestKind, TokenDecimalsUpdate, TokenInfo}; +use types::{ + Bid, GatewayInfo, IntentGatewayParams, PhantomOrderConfiguration, PhantomOrderInfo, + PhantomTokenPair, RequestKind, TokenDecimalsUpdate, TokenInfo, +}; // Re-export pallet items so that they can be accessed from the crate namespace. pub use pallet::*; @@ -58,12 +64,21 @@ pub fn offchain_bid_key_raw(commitment: &H256, filler_encoded: &[u8]) -> Vec key } +/// Generate the offchain storage key for the ABI-encoded phantom order, keyed by commitment. +pub fn offchain_phantom_key(commitment: &H256) -> Vec { + let mut key = b"intents::phantom::order::".to_vec(); + key.extend_from_slice(commitment.as_bytes()); + key +} + #[frame_support::pallet] pub mod pallet { use super::*; use crate::alloc::string::ToString; use frame_support::pallet_prelude::*; + use frame_support::traits::UnixTime; use frame_system::pallet_prelude::*; + use polkadot_sdk::sp_runtime::traits::Saturating; #[pallet::pallet] #[pallet::without_storage_info] @@ -83,6 +98,11 @@ pub mod pallet { #[pallet::constant] type StorageDepositFee: Get>; + /// How many blocks after phantom order creation bids are accepted. Fallback when + /// the PhantomBidWindow storage value is zero. + #[pallet::constant] + type PhantomOrderBidWindowBlocks: Get; + /// Origin that can perform governance actions type GovernanceOrigin: EnsureOrigin; @@ -119,6 +139,23 @@ pub mod pallet { pub type Gateways = StorageMap<_, Blake2_128Concat, StateMachine, GatewayInfo, OptionQuery>; + /// The single active phantom order. Only one is recognised at a time; the hook + /// replaces it on each generation cycle. + #[pallet::storage] + pub type CurrentPhantomOrder = + StorageValue<_, (H256, PhantomOrderInfo>), OptionQuery>; + + /// Governance-updatable bid acceptance window for phantom orders (in blocks). + /// Falls back to PhantomOrderBidWindowBlocks when zero. + #[pallet::storage] + pub type PhantomBidWindow = StorageValue<_, u32, ValueQuery>; + + /// Governance-settable phantom order configuration. When present, the + /// on_initialize hook generates a new phantom commitment every interval_blocks. + #[pallet::storage] + pub type PhantomOrderConfig = + StorageValue<_, PhantomOrderConfiguration, OptionQuery>; + #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { @@ -147,6 +184,20 @@ pub mod pallet { }, /// Storage deposit fee was updated StorageDepositFeeUpdated { fee: BalanceOf }, + /// The runtime generated a new phantom order commitment + PhantomOrderRegistered { + commitment: H256, + chain: Vec, + created_at: BlockNumberFor, + token_a: H160, + token_b: H160, + standard_amount: u128, + min_output: u128, + }, + /// The phantom order bid window was updated + PhantomBidWindowUpdated { window: u32 }, + /// The phantom order configuration was updated by governance + PhantomOrderConfigSet { chain: StateMachine, pair_count: u32, interval_blocks: u32 }, } #[pallet::error] @@ -163,6 +214,10 @@ pub mod pallet { InvalidUserOp, /// Failed to dispatch cross-chain request DispatchFailed, + /// A bid was submitted for a phantom order after the acceptance window closed + PhantomOrderBidWindowClosed, + /// A filler already has a bid for this phantom order + DuplicatePhantomBid, } #[pallet::call] @@ -191,6 +246,22 @@ pub mod pallet { // Validate user_op is not empty ensure!(!user_op.is_empty(), Error::::InvalidUserOp); + // Phantom orders have stricter rules: one bid per filler, no updates, and only + // within the configured acceptance window after the order was registered. + if let Some((phantom_commitment, info)) = CurrentPhantomOrder::::get() { + if commitment == phantom_commitment { + let window: BlockNumberFor = Self::phantom_bid_window().into(); + ensure!( + frame_system::Pallet::::block_number() <= info.created_at_block + window, + Error::::PhantomOrderBidWindowClosed + ); + ensure!( + !Bids::::contains_key(&commitment, &filler), + Error::::DuplicatePhantomBid + ); + } + } + // If a bid already exists, unreserve the old deposit first if let Some(old_deposit) = Bids::::get(&commitment, &filler) { ::Currency::unreserve(&filler, old_deposit); @@ -280,10 +351,8 @@ pub mod pallet { } // Prepare cross-chain request to notify existing gateway - let new_deployment = types::NewDeployment { - chain: state_machine.to_string().into_bytes(), - gateway, - }; + let new_deployment = + types::NewDeployment { chain: state_machine.to_string().into_bytes(), gateway }; let request = RequestKind::AddDeployment(new_deployment); let body = request.encode_body(); @@ -443,6 +512,97 @@ pub mod pallet { Ok(()) } + + /// Set the phantom order configuration. The on_initialize hook reads this every + /// block and generates a new phantom commitment when the interval elapses. + /// Also clears the current active phantom order so the hook fires immediately + /// on the next block. + #[pallet::call_index(7)] + #[pallet::weight(T::WeightInfo::set_phantom_order_config())] + pub fn set_phantom_order_config( + origin: OriginFor, + config: PhantomOrderConfiguration, + ) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + + let pair_count = config.token_pairs.len() as u32; + let interval_blocks = config.interval_blocks; + let chain = config.chain.clone(); + + PhantomOrderConfig::::put(&config); + CurrentPhantomOrder::::kill(); + + Self::deposit_event(Event::PhantomOrderConfigSet { + chain, + pair_count, + interval_blocks, + }); + + Ok(()) + } + + /// Update the phantom order bid acceptance window. + #[pallet::call_index(8)] + #[pallet::weight(T::WeightInfo::set_phantom_bid_window())] + pub fn set_phantom_bid_window(origin: OriginFor, window: u32) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + + PhantomBidWindow::::put(window); + + Self::deposit_event(Event::PhantomBidWindowUpdated { window }); + + Ok(()) + } + } + + #[pallet::hooks] + impl Hooks> for Pallet + where + T::AccountId: From<[u8; 32]>, + { + fn on_initialize(n: BlockNumberFor) -> Weight { + let Some(config) = PhantomOrderConfig::::get() else { + return Weight::zero(); + }; + + let should_generate = match CurrentPhantomOrder::::get() { + None => true, + Some((_, info)) => { + let interval: BlockNumberFor = config.interval_blocks.into(); + !interval.is_zero() && n >= info.created_at_block.saturating_add(interval) + }, + }; + + if !should_generate { + return T::DbWeight::get().reads(2); + } + + // Use pallet-ismp's time provider so the deadline is a real chain timestamp + // (non-zero). Simulation uses block 0 (timestamp=0) so the check still passes. + let deadline_secs = ::TimestampProvider::now().as_secs(); + + let chain_bytes = config.chain.to_string().into_bytes(); + for pair in config.token_pairs.iter() { + let (commitment, order_bytes) = + Self::compute_phantom_commitment(n, &chain_bytes, pair, deadline_secs); + let info = PhantomOrderInfo { created_at_block: n, chain: chain_bytes.clone() }; + CurrentPhantomOrder::::put((commitment, info)); + offchain_index::set(&offchain_phantom_key(&commitment), &order_bytes); + Self::deposit_event(Event::PhantomOrderRegistered { + commitment, + chain: chain_bytes.clone(), + created_at: n, + token_a: pair.token_a, + token_b: pair.token_b, + standard_amount: pair.standard_amount, + min_output: pair.min_output, + }); + } + + T::DbWeight::get() + .reads(2) + .saturating_add(T::DbWeight::get().writes(config.token_pairs.len() as u64 + 1)) + } } impl Pallet @@ -461,11 +621,36 @@ pub mod pallet { } } + pub fn phantom_bid_window() -> u32 { + let window = PhantomBidWindow::::get(); + if window == 0 { + T::PhantomOrderBidWindowBlocks::get() + } else { + window + } + } + /// Generate offchain storage key for a bid pub fn offchain_bid_key(commitment: &H256, filler: &T::AccountId) -> Vec { offchain_bid_key_raw(commitment, &filler.encode()) } + fn compute_phantom_commitment( + block: BlockNumberFor, + chain: &[u8], + pair: &PhantomTokenPair, + deadline_secs: u64, + ) -> (H256, Vec) { + types::phantom_order_commitment( + block.saturated_into::(), + chain, + &pair.token_a, + &pair.token_b, + pair.standard_amount, + deadline_secs, + ) + } + /// Dispatch a cross-chain message to a gateway contract fn dispatch(state_machine: StateMachine, to: H160, body: Vec) -> DispatchResult { // Create dispatcher instance diff --git a/modules/pallets/intents-coprocessor/src/types.rs b/modules/pallets/intents-coprocessor/src/types.rs index ed24f3a66..b102c7500 100644 --- a/modules/pallets/intents-coprocessor/src/types.rs +++ b/modules/pallets/intents-coprocessor/src/types.rs @@ -18,9 +18,11 @@ use alloc::{vec, vec::Vec}; use alloy_sol_types::SolValue; use codec::{Decode, DecodeWithMemTracking, Encode}; - +use ismp::host::StateMachine; +use polkadot_sdk::frame_support::{traits::ConstU32, BoundedVec}; use primitive_types::{H160, H256, U256}; use scale_info::TypeInfo; +use sp_io; /// Represents a token and amount pair for cross-chain transfers #[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] @@ -135,6 +137,32 @@ pub struct GatewayInfo { pub params: IntentGatewayParams, } +/// Tracks the single active phantom order recognised by the pallet. +#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] +pub struct PhantomOrderInfo { + pub created_at_block: BlockNumber, + /// Raw state machine identifier bytes (e.g. b"EVM-8453"). + pub chain: Vec, +} + +/// A single token pair the phantom generator probes for price and liquidity. +#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] +pub struct PhantomTokenPair { + pub token_a: H160, + pub token_b: H160, + pub standard_amount: u128, + pub min_output: u128, +} + +/// Governance-settable configuration for autonomous phantom order generation. +/// Stored in `PhantomOrderConfig`; the pallet hook reads it every block. +#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] +pub struct PhantomOrderConfiguration { + pub chain: StateMachine, + pub token_pairs: BoundedVec>, + pub interval_blocks: u32, +} + /// A bid placed by a filler for an order #[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] pub struct Bid { @@ -186,7 +214,7 @@ pub enum RequestKind { } // Solidity type definitions for cross-chain encoding -mod sol_types { +pub(crate) mod sol_types { use alloy_sol_types::sol; sol! { @@ -224,7 +252,30 @@ mod sol_types { uint256 amount; } - /// Solidity representation of SweepDust + struct DispatchInfo { + TokenInfo[] assets; + bytes call; + } + + struct PaymentInfo { + bytes32 beneficiary; + TokenInfo[] assets; + bytes call; + } + + struct Order { + bytes32 user; + bytes source; + bytes destination; + uint256 deadline; + uint256 nonce; + uint256 fees; + address session; + DispatchInfo predispatch; + TokenInfo[] inputs; + PaymentInfo output; + } + struct SweepDust { address beneficiary; TokenInfo[] outputs; @@ -244,6 +295,60 @@ mod sol_types { } } +/// Fixed session address for all phantom orders, derived from private key 0x01. +/// The indexer uses the matching private key when signing simulated bids. +pub const PHANTOM_SESSION_ADDRESS: alloy_primitives::Address = + alloy_primitives::address!("7E5F4552091A69125d5DfCb7b8C2659029395Bdf"); + +/// Builds the IntentGatewayV2 `Order` for a phantom order and returns both the +/// ABI-encoded bytes and its `keccak256` commitment. +/// +/// `deadline_secs` is the Unix timestamp (in seconds) beyond which the order is +/// considered expired by the gateway. Callers should set this to a non-zero +/// value; zero prevents the order from simulating correctly. +pub fn phantom_order_commitment( + block: u64, + chain: &[u8], + token_a: &H160, + token_b: &H160, + standard_amount: u128, + deadline_secs: u64, +) -> (H256, Vec) { + use alloy_primitives::{Address, Bytes, FixedBytes, U256 as AlloyU256}; + + let mut token_a_bytes = [0u8; 32]; + token_a_bytes[12..].copy_from_slice(token_a.as_bytes()); + let mut token_b_bytes = [0u8; 32]; + token_b_bytes[12..].copy_from_slice(token_b.as_bytes()); + + let order = sol_types::Order { + user: FixedBytes::from([0u8; 32]), + source: Bytes::copy_from_slice(chain), + destination: Bytes::copy_from_slice(chain), + deadline: AlloyU256::from(deadline_secs), + nonce: AlloyU256::from(block), + fees: AlloyU256::ZERO, + session: PHANTOM_SESSION_ADDRESS, + predispatch: sol_types::DispatchInfo { assets: vec![], call: Bytes::new() }, + inputs: vec![sol_types::TokenInfo { + token: FixedBytes::from(token_a_bytes), + amount: AlloyU256::from(standard_amount), + }], + output: sol_types::PaymentInfo { + beneficiary: FixedBytes::from([0u8; 32]), + assets: vec![sol_types::TokenInfo { + token: FixedBytes::from(token_b_bytes), + amount: AlloyU256::ZERO, + }], + call: Bytes::new(), + }, + }; + + let encoded = order.abi_encode(); + let commitment = sp_io::hashing::keccak_256(&encoded).into(); + (commitment, encoded) +} + impl From for sol_types::Params { fn from(params: IntentGatewayParams) -> Self { use alloy_primitives::{Address, U256 as AlloyU256}; diff --git a/modules/pallets/intents-coprocessor/src/weights.rs b/modules/pallets/intents-coprocessor/src/weights.rs index 8f6a70140..94412cf62 100644 --- a/modules/pallets/intents-coprocessor/src/weights.rs +++ b/modules/pallets/intents-coprocessor/src/weights.rs @@ -41,6 +41,8 @@ pub trait WeightInfo { fn update_params() -> Weight; fn sweep_dust() -> Weight; fn update_token_decimals() -> Weight; + fn set_phantom_order_config() -> Weight; + fn set_phantom_bid_window() -> Weight; } /// Weights for pallet_intents using the Substrate node and recommended hardware. @@ -105,6 +107,22 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) } + + /// Storage: PhantomOrderConfig (r:0 w:1), CurrentPhantomOrder (r:0 w:1) + /// Proof Skipped: PhantomOrderConfig (max_values: Some(1), max_size: None, mode: Measured) + fn set_phantom_order_config() -> Weight { + Weight::from_parts(20_000_000, 0) + .saturating_add(Weight::from_parts(0, 1_024)) + .saturating_add(T::DbWeight::get().writes(2)) + } + + /// Storage: PhantomBidWindow (r:0 w:1) + /// Proof Skipped: PhantomBidWindow (max_values: Some(1), max_size: Some(4), mode: Measured) + fn set_phantom_bid_window() -> Weight { + // Measured on reference hardware; re-run benchmarks after schema changes. + Weight::from_parts(10_000_000, 0) + .saturating_add(T::DbWeight::get().writes(1)) + } } // For backwards compatibility and tests @@ -127,4 +145,10 @@ impl WeightInfo for () { fn update_token_decimals() -> Weight { Weight::from_parts(75_000_000, 0) } + fn set_phantom_order_config() -> Weight { + Weight::from_parts(20_000_000, 0) + } + fn set_phantom_bid_window() -> Weight { + Weight::from_parts(10_000_000, 0) + } } diff --git a/parachain/runtimes/gargantua/src/ismp.rs b/parachain/runtimes/gargantua/src/ismp.rs index f78cc5b94..666be1ab0 100644 --- a/parachain/runtimes/gargantua/src/ismp.rs +++ b/parachain/runtimes/gargantua/src/ismp.rs @@ -16,7 +16,7 @@ use crate::{ alloc::{boxed::Box, string::ToString}, weights, AccountId, Assets, Balance, Balances, Fishermen, Ismp, IsmpParachain, Mmr, - ParachainInfo, Runtime, RuntimeEvent, Timestamp, TreasuryPalletId, EXISTENTIAL_DEPOSIT, + ParachainInfo, Runtime, RuntimeEvent, Timestamp, TreasuryPalletId, EXISTENTIAL_DEPOSIT, HOURS, }; use anyhow::anyhow; use evm_state_machine::SubstrateEvmStateMachine; @@ -105,12 +105,14 @@ impl pallet_state_coprocessor::Config for Runtime { parameter_types! { pub const IntentStorageDepositFee: Balance = 100 * EXISTENTIAL_DEPOSIT; + pub const IntentPhantomOrderBidWindow: u32 = HOURS as u32; } impl pallet_intents_coprocessor::Config for Runtime { type Dispatcher = Ismp; type Currency = Balances; type StorageDepositFee = IntentStorageDepositFee; + type PhantomOrderBidWindowBlocks = IntentPhantomOrderBidWindow; type GovernanceOrigin = EnsureRoot; type WeightInfo = weights::pallet_intents_coprocessor::WeightInfo; } diff --git a/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs b/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs index 7cbdf69d9..b9c1c2368 100644 --- a/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs +++ b/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs @@ -16,20 +16,22 @@ //! Autogenerated weights for `pallet_intents_coprocessor` //! -//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 52.0.0 -//! DATE: 2026-01-06, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 +//! DATE: 2026-06-22, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `polytope-labs`, CPU: `AMD Ryzen Threadripper PRO 5995WX 64-Cores` //! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: 1024 // Executed Command: -// frame-omni-bencher +// /home/dare/.cargo/bin/frame-omni-bencher // v1 // benchmark // pallet // --wasm-execution=compiled // --pallet=pallet_intents_coprocessor // --extrinsic=* +// --steps=50 +// --repeat=20 // --unsafe-overwrite-results // --genesis-builder-preset=development // --template=./scripts/template.hbs @@ -49,39 +51,44 @@ use core::marker::PhantomData; /// Weight functions for `pallet_intents_coprocessor`. pub struct WeightInfo(PhantomData); impl pallet_intents_coprocessor::WeightInfo for WeightInfo { + /// Storage: `IntentsCoprocessor::CurrentPhantomOrder` (r:1 w:0) + /// Proof: `IntentsCoprocessor::CurrentPhantomOrder` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `IntentsCoprocessor::Bids` (r:1 w:1) /// Proof: `IntentsCoprocessor::Bids` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::StorageDepositFee` (r:1 w:0) + /// Proof: `IntentsCoprocessor::StorageDepositFee` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn place_bid() -> Weight { // Proof Size summary in bytes: // Measured: `109` // Estimated: `3574` - // Minimum execution time: 32_581_000 picoseconds. - Weight::from_parts(33_173_000, 0) + // Minimum execution time: 40_326_000 picoseconds. + Weight::from_parts(40_978_000, 0) .saturating_add(Weight::from_parts(0, 3574)) - .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().reads(3)) .saturating_add(T::DbWeight::get().writes(1)) } /// Storage: `IntentsCoprocessor::Bids` (r:1 w:1) /// Proof: `IntentsCoprocessor::Bids` (`max_values`: None, `max_size`: None, mode: `Measured`) fn retract_bid() -> Weight { // Proof Size summary in bytes: - // Measured: `384` - // Estimated: `3849` - // Minimum execution time: 33_714_000 picoseconds. - Weight::from_parts(34_115_000, 0) - .saturating_add(Weight::from_parts(0, 3849)) + // Measured: `247` + // Estimated: `3712` + // Minimum execution time: 33_574_000 picoseconds. + Weight::from_parts(34_316_000, 0) + .saturating_add(Weight::from_parts(0, 3712)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } - /// Storage: `IntentsCoprocessor::Gateways` (r:0 w:1) + /// Storage: `IntentsCoprocessor::Gateways` (r:2 w:1) /// Proof: `IntentsCoprocessor::Gateways` (`max_values`: None, `max_size`: None, mode: `Measured`) fn add_deployment() -> Weight { // Proof Size summary in bytes: - // Measured: `0` - // Estimated: `0` - // Minimum execution time: 12_394_000 picoseconds. - Weight::from_parts(12_764_000, 0) - .saturating_add(Weight::from_parts(0, 0)) + // Measured: `113` + // Estimated: `6053` + // Minimum execution time: 19_767_000 picoseconds. + Weight::from_parts(20_148_000, 0) + .saturating_add(Weight::from_parts(0, 6053)) + .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) } /// Storage: `IntentsCoprocessor::Gateways` (r:1 w:1) @@ -96,15 +103,15 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// Proof: `Mmr::NumberOfLeaves` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `Mmr::IntermediateLeaves` (r:1 w:1) /// Proof: `Mmr::IntermediateLeaves` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473ab9b34a1c0b9250e527df4384714` (r:1 w:1) - /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473ab9b34a1c0b9250e527df4384714` (r:1 w:1) + /// Storage: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e747381304b08734ac57355df9e6a4981` (r:1 w:1) + /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e747381304b08734ac57355df9e6a4981` (r:1 w:1) fn update_params() -> Weight { // Proof Size summary in bytes: - // Measured: `700` - // Estimated: `4165` - // Minimum execution time: 72_277_000 picoseconds. - Weight::from_parts(73_349_000, 0) - .saturating_add(Weight::from_parts(0, 4165)) + // Measured: `733` + // Estimated: `4198` + // Minimum execution time: 68_730_000 picoseconds. + Weight::from_parts(70_183_000, 0) + .saturating_add(Weight::from_parts(0, 4198)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(5)) } @@ -120,15 +127,15 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// Proof: `Mmr::NumberOfLeaves` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `Mmr::IntermediateLeaves` (r:1 w:1) /// Proof: `Mmr::IntermediateLeaves` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473c2db305104f20bb2d90764605673` (r:1 w:1) - /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473c2db305104f20bb2d90764605673` (r:1 w:1) + /// Storage: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473327e6d3fd4ea2a33f45afabe27cd` (r:1 w:1) + /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473327e6d3fd4ea2a33f45afabe27cd` (r:1 w:1) fn sweep_dust() -> Weight { // Proof Size summary in bytes: - // Measured: `700` - // Estimated: `4165` - // Minimum execution time: 65_073_000 picoseconds. - Weight::from_parts(66_085_000, 0) - .saturating_add(Weight::from_parts(0, 4165)) + // Measured: `733` + // Estimated: `4198` + // Minimum execution time: 69_622_000 picoseconds. + Weight::from_parts(70_413_000, 0) + .saturating_add(Weight::from_parts(0, 4198)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) } @@ -144,16 +151,40 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// Proof: `Mmr::NumberOfLeaves` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `Mmr::IntermediateLeaves` (r:1 w:1) /// Proof: `Mmr::IntermediateLeaves` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473dca7a0a9e3ad5485a922b1f11f43` (r:1 w:1) - /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473dca7a0a9e3ad5485a922b1f11f43` (r:1 w:1) + /// Storage: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e747385e5b6d284bf082f891a28c67756` (r:1 w:1) + /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e747385e5b6d284bf082f891a28c67756` (r:1 w:1) fn update_token_decimals() -> Weight { // Proof Size summary in bytes: - // Measured: `700` - // Estimated: `4165` - // Minimum execution time: 68_621_000 picoseconds. - Weight::from_parts(69_752_000, 0) - .saturating_add(Weight::from_parts(0, 4165)) + // Measured: `733` + // Estimated: `4198` + // Minimum execution time: 64_543_000 picoseconds. + Weight::from_parts(74_421_000, 0) + .saturating_add(Weight::from_parts(0, 4198)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) } + /// Storage: `IntentsCoprocessor::PhantomOrderConfig` (r:0 w:1) + /// Proof: `IntentsCoprocessor::PhantomOrderConfig` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::CurrentPhantomOrder` (r:0 w:1) + /// Proof: `IntentsCoprocessor::CurrentPhantomOrder` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_phantom_order_config() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 10_049_000 picoseconds. + Weight::from_parts(10_340_000, 0) + .saturating_add(Weight::from_parts(0, 0)) + .saturating_add(T::DbWeight::get().writes(2)) + } + /// Storage: `IntentsCoprocessor::PhantomBidWindow` (r:0 w:1) + /// Proof: `IntentsCoprocessor::PhantomBidWindow` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_phantom_bid_window() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 8_226_000 picoseconds. + Weight::from_parts(8_446_000, 0) + .saturating_add(Weight::from_parts(0, 0)) + .saturating_add(T::DbWeight::get().writes(1)) + } } diff --git a/parachain/runtimes/nexus/src/ismp.rs b/parachain/runtimes/nexus/src/ismp.rs index 7e302ca1b..a853dbb6b 100644 --- a/parachain/runtimes/nexus/src/ismp.rs +++ b/parachain/runtimes/nexus/src/ismp.rs @@ -17,7 +17,7 @@ use crate::{ alloc::{boxed::Box, string::ToString}, weights, AccountId, Balance, Balances, Fishermen, Ismp, IsmpParachain, Mmr, ParachainInfo, ReputationAsset, Runtime, RuntimeEvent, Timestamp, TreasuryAccount, TreasuryPalletId, - EXISTENTIAL_DEPOSIT, + EXISTENTIAL_DEPOSIT, HOURS, }; use anyhow::anyhow; use evm_state_machine::SubstrateEvmStateMachine; @@ -297,12 +297,14 @@ impl pallet_bandwidth::Config for Runtime { parameter_types! { pub const IntentsStorageDepositFee: Balance = EXISTENTIAL_DEPOSIT * 10; + pub const IntentsPhantomOrderBidWindow: u32 = HOURS as u32; } impl pallet_intents_coprocessor::Config for Runtime { type Dispatcher = Ismp; type Currency = Balances; type StorageDepositFee = IntentsStorageDepositFee; + type PhantomOrderBidWindowBlocks = IntentsPhantomOrderBidWindow; type GovernanceOrigin = EnsureRoot; type WeightInfo = weights::pallet_intents_coprocessor::WeightInfo; } diff --git a/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs b/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs index 9fb77b5ce..5595026a7 100644 --- a/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs +++ b/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs @@ -1,13 +1,11 @@ -// This file is part of Substrate. - -// Copyright (C) Parity Technologies (UK) Ltd. +// Copyright (C) Polytope Labs Ltd. // SPDX-License-Identifier: Apache-2.0 // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// http://www.apache.org/licenses/LICENSE-2.0 +// http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, @@ -19,140 +17,175 @@ //! Autogenerated weights for `pallet_intents_coprocessor` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 -//! DATE: 2026-02-04, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-06-19, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `polytope-labs`, CPU: `AMD Ryzen Threadripper PRO 5995WX 64-Cores` -//! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` +//! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: 1024 // Executed Command: // frame-omni-bencher // v1 // benchmark // pallet -// --runtime -// target/release/wbuild/nexus-runtime/nexus_runtime.compact.compressed.wasm -// --pallet -// pallet-intents-coprocessor -// --extrinsic -// -// --template -// frame-weight-template.hbs +// --wasm-execution=compiled +// --pallet=pallet_intents_coprocessor +// --extrinsic=* +// --steps=50 +// --repeat=20 +// --unsafe-overwrite-results +// --genesis-builder-preset=development +// --template=./scripts/template.hbs +// --genesis-builder=runtime +// --runtime=./target/release/wbuild/nexus-runtime/nexus_runtime.compact.wasm // --output -// pallet-intents-coprocessor.rs +// parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] #![allow(unused_imports)] #![allow(missing_docs)] -#![allow(dead_code)] use polkadot_sdk::*; -use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use frame_support::{traits::Get, weights::Weight}; use core::marker::PhantomData; -/// Weights for `pallet_intents_coprocessor` using the Substrate node and recommended hardware. +/// Weight functions for `pallet_intents_coprocessor`. pub struct WeightInfo(PhantomData); impl pallet_intents_coprocessor::WeightInfo for WeightInfo { - /// Storage: `IntentsCoprocessor::Bids` (r:1 w:1) - /// Proof: `IntentsCoprocessor::Bids` (`max_values`: None, `max_size`: None, mode: `Measured`) - fn place_bid() -> Weight { - // Proof Size summary in bytes: - // Measured: `142` - // Estimated: `3607` - // Minimum execution time: 38_934_000 picoseconds. - Weight::from_parts(39_705_000, 3607) - .saturating_add(T::DbWeight::get().reads(1_u64)) - .saturating_add(T::DbWeight::get().writes(1_u64)) - } - /// Storage: `IntentsCoprocessor::Bids` (r:1 w:1) - /// Proof: `IntentsCoprocessor::Bids` (`max_values`: None, `max_size`: None, mode: `Measured`) - fn retract_bid() -> Weight { - // Proof Size summary in bytes: - // Measured: `280` - // Estimated: `3745` - // Minimum execution time: 37_962_000 picoseconds. - Weight::from_parts(38_733_000, 3745) - .saturating_add(T::DbWeight::get().reads(1_u64)) - .saturating_add(T::DbWeight::get().writes(1_u64)) - } - /// Storage: `IntentsCoprocessor::Gateways` (r:2 w:1) - /// Proof: `IntentsCoprocessor::Gateways` (`max_values`: None, `max_size`: None, mode: `Measured`) - fn add_deployment() -> Weight { - // Proof Size summary in bytes: - // Measured: `146` - // Estimated: `6086` - // Minimum execution time: 22_212_000 picoseconds. - Weight::from_parts(22_733_000, 6086) - .saturating_add(T::DbWeight::get().reads(2_u64)) - .saturating_add(T::DbWeight::get().writes(1_u64)) - } - /// Storage: `IntentsCoprocessor::Gateways` (r:1 w:1) - /// Proof: `IntentsCoprocessor::Gateways` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `ParachainInfo::ParachainId` (r:1 w:0) - /// Proof: `ParachainInfo::ParachainId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) - /// Storage: `Ismp::Nonce` (r:1 w:1) - /// Proof: `Ismp::Nonce` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Mmr::CounterForIntermediateLeaves` (r:1 w:1) - /// Proof: `Mmr::CounterForIntermediateLeaves` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) - /// Storage: `Mmr::NumberOfLeaves` (r:1 w:0) - /// Proof: `Mmr::NumberOfLeaves` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Mmr::IntermediateLeaves` (r:1 w:1) - /// Proof: `Mmr::IntermediateLeaves` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473181a67a237d3dcdb40543bc340e2` (r:1 w:1) - /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473181a67a237d3dcdb40543bc340e2` (r:1 w:1) - fn update_params() -> Weight { - // Proof Size summary in bytes: - // Measured: `866` - // Estimated: `4331` - // Minimum execution time: 76_615_000 picoseconds. - Weight::from_parts(77_276_000, 4331) - .saturating_add(T::DbWeight::get().reads(7_u64)) - .saturating_add(T::DbWeight::get().writes(5_u64)) - } - /// Storage: `IntentsCoprocessor::Gateways` (r:1 w:0) - /// Proof: `IntentsCoprocessor::Gateways` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `ParachainInfo::ParachainId` (r:1 w:0) - /// Proof: `ParachainInfo::ParachainId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) - /// Storage: `Ismp::Nonce` (r:1 w:1) - /// Proof: `Ismp::Nonce` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Mmr::CounterForIntermediateLeaves` (r:1 w:1) - /// Proof: `Mmr::CounterForIntermediateLeaves` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) - /// Storage: `Mmr::NumberOfLeaves` (r:1 w:0) - /// Proof: `Mmr::NumberOfLeaves` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Mmr::IntermediateLeaves` (r:1 w:1) - /// Proof: `Mmr::IntermediateLeaves` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473c5c0c95be005c8b7c35d93fb57f3` (r:1 w:1) - /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473c5c0c95be005c8b7c35d93fb57f3` (r:1 w:1) - fn sweep_dust() -> Weight { - // Proof Size summary in bytes: - // Measured: `866` - // Estimated: `4331` - // Minimum execution time: 66_826_000 picoseconds. - Weight::from_parts(68_479_000, 4331) - .saturating_add(T::DbWeight::get().reads(7_u64)) - .saturating_add(T::DbWeight::get().writes(4_u64)) - } - /// Storage: `IntentsCoprocessor::Gateways` (r:1 w:0) - /// Proof: `IntentsCoprocessor::Gateways` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `ParachainInfo::ParachainId` (r:1 w:0) - /// Proof: `ParachainInfo::ParachainId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) - /// Storage: `Ismp::Nonce` (r:1 w:1) - /// Proof: `Ismp::Nonce` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Mmr::CounterForIntermediateLeaves` (r:1 w:1) - /// Proof: `Mmr::CounterForIntermediateLeaves` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) - /// Storage: `Mmr::NumberOfLeaves` (r:1 w:0) - /// Proof: `Mmr::NumberOfLeaves` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Mmr::IntermediateLeaves` (r:1 w:1) - /// Proof: `Mmr::IntermediateLeaves` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473ea75191a82691c4c9d91addf8d05` (r:1 w:1) - /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473ea75191a82691c4c9d91addf8d05` (r:1 w:1) - fn update_token_decimals() -> Weight { - // Proof Size summary in bytes: - // Measured: `866` - // Estimated: `4331` - // Minimum execution time: 71_384_000 picoseconds. - Weight::from_parts(72_557_000, 4331) - .saturating_add(T::DbWeight::get().reads(7_u64)) - .saturating_add(T::DbWeight::get().writes(4_u64)) - } -} \ No newline at end of file + /// Storage: `IntentsCoprocessor::CurrentPhantomOrder` (r:1 w:0) + /// Proof: `IntentsCoprocessor::CurrentPhantomOrder` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::Bids` (r:1 w:1) + /// Proof: `IntentsCoprocessor::Bids` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::StorageDepositFee` (r:1 w:0) + /// Proof: `IntentsCoprocessor::StorageDepositFee` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn place_bid() -> Weight { + // Proof Size summary in bytes: + // Measured: `142` + // Estimated: `3607` + // Minimum execution time: 40_407_000 picoseconds. + Weight::from_parts(41_498_000, 0) + .saturating_add(Weight::from_parts(0, 3607)) + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::Bids` (r:1 w:1) + /// Proof: `IntentsCoprocessor::Bids` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn retract_bid() -> Weight { + // Proof Size summary in bytes: + // Measured: `280` + // Estimated: `3745` + // Minimum execution time: 38_272_000 picoseconds. + Weight::from_parts(39_074_000, 0) + .saturating_add(Weight::from_parts(0, 3745)) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::Gateways` (r:2 w:1) + /// Proof: `IntentsCoprocessor::Gateways` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn add_deployment() -> Weight { + // Proof Size summary in bytes: + // Measured: `146` + // Estimated: `6086` + // Minimum execution time: 22_232_000 picoseconds. + Weight::from_parts(22_723_000, 0) + .saturating_add(Weight::from_parts(0, 6086)) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::Gateways` (r:1 w:1) + /// Proof: `IntentsCoprocessor::Gateways` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `ParachainInfo::ParachainId` (r:1 w:0) + /// Proof: `ParachainInfo::ParachainId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Ismp::Nonce` (r:1 w:1) + /// Proof: `Ismp::Nonce` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Mmr::CounterForIntermediateLeaves` (r:1 w:1) + /// Proof: `Mmr::CounterForIntermediateLeaves` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Mmr::NumberOfLeaves` (r:1 w:0) + /// Proof: `Mmr::NumberOfLeaves` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Mmr::IntermediateLeaves` (r:1 w:1) + /// Proof: `Mmr::IntermediateLeaves` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473cfc4a36f0dfb70382708d5bc152d` (r:1 w:1) + /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473cfc4a36f0dfb70382708d5bc152d` (r:1 w:1) + fn update_params() -> Weight { + // Proof Size summary in bytes: + // Measured: `866` + // Estimated: `4331` + // Minimum execution time: 75_133_000 picoseconds. + Weight::from_parts(76_144_000, 0) + .saturating_add(Weight::from_parts(0, 4331)) + .saturating_add(T::DbWeight::get().reads(7)) + .saturating_add(T::DbWeight::get().writes(5)) + } + /// Storage: `IntentsCoprocessor::Gateways` (r:1 w:0) + /// Proof: `IntentsCoprocessor::Gateways` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `ParachainInfo::ParachainId` (r:1 w:0) + /// Proof: `ParachainInfo::ParachainId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Ismp::Nonce` (r:1 w:1) + /// Proof: `Ismp::Nonce` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Mmr::CounterForIntermediateLeaves` (r:1 w:1) + /// Proof: `Mmr::CounterForIntermediateLeaves` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Mmr::NumberOfLeaves` (r:1 w:0) + /// Proof: `Mmr::NumberOfLeaves` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Mmr::IntermediateLeaves` (r:1 w:1) + /// Proof: `Mmr::IntermediateLeaves` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473e6e8c09efb35a07dc00774806bcd` (r:1 w:1) + /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473e6e8c09efb35a07dc00774806bcd` (r:1 w:1) + fn sweep_dust() -> Weight { + // Proof Size summary in bytes: + // Measured: `866` + // Estimated: `4331` + // Minimum execution time: 66_035_000 picoseconds. + Weight::from_parts(67_417_000, 0) + .saturating_add(Weight::from_parts(0, 4331)) + .saturating_add(T::DbWeight::get().reads(7)) + .saturating_add(T::DbWeight::get().writes(4)) + } + /// Storage: `IntentsCoprocessor::Gateways` (r:1 w:0) + /// Proof: `IntentsCoprocessor::Gateways` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `ParachainInfo::ParachainId` (r:1 w:0) + /// Proof: `ParachainInfo::ParachainId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Ismp::Nonce` (r:1 w:1) + /// Proof: `Ismp::Nonce` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Mmr::CounterForIntermediateLeaves` (r:1 w:1) + /// Proof: `Mmr::CounterForIntermediateLeaves` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Mmr::NumberOfLeaves` (r:1 w:0) + /// Proof: `Mmr::NumberOfLeaves` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Mmr::IntermediateLeaves` (r:1 w:1) + /// Proof: `Mmr::IntermediateLeaves` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e74731c023622c98128a1e9a16c60652e` (r:1 w:1) + /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e74731c023622c98128a1e9a16c60652e` (r:1 w:1) + fn update_token_decimals() -> Weight { + // Proof Size summary in bytes: + // Measured: `866` + // Estimated: `4331` + // Minimum execution time: 70_303_000 picoseconds. + Weight::from_parts(71_005_000, 0) + .saturating_add(Weight::from_parts(0, 4331)) + .saturating_add(T::DbWeight::get().reads(7)) + .saturating_add(T::DbWeight::get().writes(4)) + } + /// Storage: `IntentsCoprocessor::PhantomOrderConfig` (r:0 w:1) + /// Proof: `IntentsCoprocessor::PhantomOrderConfig` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::CurrentPhantomOrder` (r:0 w:1) + /// Proof: `IntentsCoprocessor::CurrentPhantomOrder` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_phantom_order_config() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 10_049_000 picoseconds. + Weight::from_parts(10_340_000, 0) + .saturating_add(Weight::from_parts(0, 0)) + .saturating_add(T::DbWeight::get().writes(2)) + } + /// Storage: `IntentsCoprocessor::PhantomBidWindow` (r:0 w:1) + /// Proof: `IntentsCoprocessor::PhantomBidWindow` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_phantom_bid_window() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 8_226_000 picoseconds. + Weight::from_parts(8_446_000, 0) + .saturating_add(Weight::from_parts(0, 0)) + .saturating_add(T::DbWeight::get().writes(1)) + } +} diff --git a/parachain/simtests/src/lib.rs b/parachain/simtests/src/lib.rs index 355a6d158..d6725876f 100644 --- a/parachain/simtests/src/lib.rs +++ b/parachain/simtests/src/lib.rs @@ -6,4 +6,5 @@ mod pallet_beefy_consensus_proofs; mod pallet_fishermen; mod pallet_ismp; mod pallet_mmr; +mod phantom_orders; mod pool_aware_nonce; diff --git a/parachain/simtests/src/phantom_orders.rs b/parachain/simtests/src/phantom_orders.rs new file mode 100644 index 000000000..0d275840d --- /dev/null +++ b/parachain/simtests/src/phantom_orders.rs @@ -0,0 +1,516 @@ +//! Simnode integration tests for phantom order lifecycle in +//! `pallet-intents-coprocessor`. +//! +//! Tests exercise the full phantom order flow driven by `on_initialize`: +//! +//! - `test_set_phantom_order_config_stores_and_emits_event` — governance config triggers the hook +//! which populates `CurrentPhantomOrder` and emits the event. +//! - `test_phantom_order_replaces_previous_on_interval` — with an interval of 1 block, consecutive +//! empty blocks produce distinct commitments. +//! - `test_multiple_fillers_can_bid_on_phantom_order` — Alice and Bob each place one bid; both are +//! discoverable via `intents_getBidsForOrder`. +//! - `test_duplicate_phantom_bid_rejected` — a filler that already holds a bid for the current +//! phantom order is rejected with `DuplicatePhantomBid`. +//! - `test_bid_rejected_after_phantom_window_closes` — a bid arriving after the governance-set +//! window has passed is rejected with `PhantomOrderBidWindowClosed`. +//! - `test_set_phantom_bid_window_via_governance` — sudo can update `PhantomBidWindow`; the new +//! value is persisted to storage. +//! - `test_phantom_order_full_flow_config_bid_rpc` — end-to-end: configure, three fillers bid, all +//! three appear in the RPC response with the correct commitment. +//! +//! Run with a simnode on the default port: +//! +//! ```text +//! cargo test -p simtests phantom_orders -- --ignored --test-threads=1 +//! ``` + +#![cfg(test)] + +use std::env; + +use codec::Decode; +use pallet_intents_rpc::RpcBidInfo; +use polkadot_sdk::*; +use primitive_types::H256; +use sc_consensus_manual_seal::CreatedBlock; +use sp_core::{crypto::Ss58Codec, Bytes}; +use sp_keyring::sr25519::Keyring; +use subxt::{ + dynamic::Value, + ext::{scale_value::Composite, subxt_rpcs::rpc_params}, + tx::SubmittableTransaction, +}; +use subxt_utils::Hyperbridge; + +const SIMNODE_PORT_DEFAULT: &str = "9990"; + +// --------------------------------------------------------------------------- +// Storage key helpers +// --------------------------------------------------------------------------- + +fn current_phantom_order_key() -> Vec { + [ + sp_core::twox_128(b"IntentsCoprocessor").to_vec(), + sp_core::twox_128(b"CurrentPhantomOrder").to_vec(), + ] + .concat() +} + +fn phantom_bid_window_key() -> Vec { + [ + sp_core::twox_128(b"IntentsCoprocessor").to_vec(), + sp_core::twox_128(b"PhantomBidWindow").to_vec(), + ] + .concat() +} + +// --------------------------------------------------------------------------- +// Common helpers +// --------------------------------------------------------------------------- + +/// Seal an empty block and finalize it. Returns the new block hash. +async fn create_and_finalize_block( + rpc: &subxt::backend::rpc::RpcClient, +) -> Result { + let block: CreatedBlock = + rpc.request("engine_createBlock", rpc_params![true, false]).await?; + let finalized: bool = rpc.request("engine_finalizeBlock", rpc_params![block.hash]).await?; + assert!(finalized, "block must finalize"); + Ok(block.hash) +} + +/// Author a pre-encoded call signed by `signer` via `simnode_authorExtrinsic`, +/// seal a block, and wait for finalized success. Returns the block hash. +async fn author_and_seal( + client: &subxt::OnlineClient, + rpc: &subxt::backend::rpc::RpcClient, + call_data: Vec, + signer: Keyring, +) -> Result { + let extrinsic: Bytes = rpc + .request( + "simnode_authorExtrinsic", + rpc_params![Bytes::from(call_data), signer.to_account_id().to_ss58check()], + ) + .await?; + let submittable = SubmittableTransaction::from_bytes(client.clone(), extrinsic.0); + let progress = submittable.submit_and_watch().await?; + let block: CreatedBlock = + rpc.request("engine_createBlock", rpc_params![true, false]).await?; + let finalized: bool = rpc.request("engine_finalizeBlock", rpc_params![block.hash]).await?; + assert!(finalized, "block must finalize"); + progress.wait_for_finalized_success().await?; + Ok(block.hash) +} + +/// Wrap `inner` in `Sudo::sudo`, author as Alice, seal, and await success. +async fn sudo_and_seal( + client: &subxt::OnlineClient, + rpc: &subxt::backend::rpc::RpcClient, + inner: subxt::dynamic::Value, +) -> Result { + let sudo_call = subxt::dynamic::tx("Sudo", "sudo", vec![inner]); + let call_data = client.tx().call_data(&sudo_call)?; + author_and_seal(client, rpc, call_data, Keyring::Alice).await +} + +/// Build a `PhantomOrderConfiguration` dynamic value for EVM chain `chain_id` +/// with one standard token pair and the given block interval. +fn phantom_config_value(chain_id: u64, interval_blocks: u32) -> Value { + let pair = Value::named_composite(vec![ + ("token_a", Value::from_bytes([1u8; 20])), + ("token_b", Value::from_bytes([2u8; 20])), + ("standard_amount", Value::u128(1_000_000_000_000_000_000u128)), + ("min_output", Value::u128(900_000_000_000_000_000u128)), + ]); + Value::named_composite(vec![ + ("chain", Value::variant("Evm", Composite::unnamed(vec![Value::u128(chain_id.into())]))), + ("token_pairs", Value::unnamed_composite(vec![pair])), + ("interval_blocks", Value::u128(interval_blocks as u128)), + ]) +} + +/// Call `set_phantom_order_config` via sudo and seal a block. Returns the block hash. +async fn set_phantom_order_config( + client: &subxt::OnlineClient, + rpc: &subxt::backend::rpc::RpcClient, + chain_id: u64, + interval_blocks: u32, +) -> Result { + let call = subxt::dynamic::tx( + "IntentsCoprocessor", + "set_phantom_order_config", + vec![phantom_config_value(chain_id, interval_blocks)], + ); + sudo_and_seal(client, rpc, call.into_value()).await +} + +/// Read the active phantom commitment from `CurrentPhantomOrder` storage at +/// the given block hash. Returns `None` when the storage slot is empty. +async fn read_active_commitment( + client: &subxt::OnlineClient, + block_hash: H256, +) -> Option { + let raw = client + .storage() + .at(block_hash) + .fetch_raw(current_phantom_order_key()) + .await + .ok()??; + if raw.len() < 32 { + return None; + } + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(&raw[..32]); + Some(H256::from(bytes)) +} + +/// Build a signed `place_bid` extrinsic for `commitment` / `user_op` but do +/// NOT submit or seal it. Returns the raw extrinsic bytes so the caller can +/// batch multiple bids into a single block. +async fn author_place_bid( + client: &subxt::OnlineClient, + rpc: &subxt::backend::rpc::RpcClient, + commitment: H256, + user_op: &[u8], + signer: Keyring, +) -> Result { + let call = subxt::dynamic::tx( + "IntentsCoprocessor", + "place_bid", + vec![ + subxt::dynamic::Value::from_bytes(commitment.as_bytes()), + subxt::dynamic::Value::from_bytes(user_op), + ], + ); + let call_data = client.tx().call_data(&call)?; + let extrinsic: Bytes = rpc + .request( + "simnode_authorExtrinsic", + rpc_params![Bytes::from(call_data), signer.to_account_id().to_ss58check()], + ) + .await?; + Ok(extrinsic) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// Governance config followed by one empty block must populate `CurrentPhantomOrder` +/// and emit a `PhantomOrderRegistered` event from `on_initialize`. +#[tokio::test] +#[ignore] +async fn test_set_phantom_order_config_stores_and_emits_event() -> Result<(), anyhow::Error> { + let port = env::var("PORT").unwrap_or(SIMNODE_PORT_DEFAULT.into()); + let url = format!("ws://127.0.0.1:{port}"); + let (client, rpc) = subxt_utils::client::ws_client::(&url, u32::MAX).await?; + + set_phantom_order_config(&client, &rpc, 8453, 10).await?; + + // on_initialize fires in the next block. + let block_hash = create_and_finalize_block(&rpc).await?; + + let raw = client + .storage() + .at(block_hash) + .fetch_raw(current_phantom_order_key()) + .await? + .expect("CurrentPhantomOrder must be set after config block"); + + // Layout: [H256 (32)] [u32 LE (4)] [SCALE compact len (1)] [chain bytes] + let chain_len = (raw[36] >> 2) as usize; + let stored_chain = &raw[37..37 + chain_len]; + assert_eq!(stored_chain, b"EVM-8453", "chain must match the governance-set value"); + + let events = client.events().at(block_hash).await?; + let emitted = events.iter().any(|ev| { + ev.ok() + .map(|e| { + e.pallet_name() == "IntentsCoprocessor" && + e.variant_name() == "PhantomOrderRegistered" + }) + .unwrap_or(false) + }); + assert!(emitted, "PhantomOrderRegistered event must be emitted by on_initialize"); + + Ok(()) +} + +/// With `interval_blocks = 1`, each consecutive empty block must produce a +/// distinct commitment because the block number is part of the commitment preimage. +#[tokio::test] +#[ignore] +async fn test_phantom_order_replaces_previous_on_interval() -> Result<(), anyhow::Error> { + let port = env::var("PORT").unwrap_or(SIMNODE_PORT_DEFAULT.into()); + let url = format!("ws://127.0.0.1:{port}"); + let (client, rpc) = subxt_utils::client::ws_client::(&url, u32::MAX).await?; + + // interval_blocks=1 means the hook re-fires every block. + set_phantom_order_config(&client, &rpc, 8453, 1).await?; + + let block1 = create_and_finalize_block(&rpc).await?; + let c1 = read_active_commitment(&client, block1) + .await + .expect("first commitment must exist"); + + let block2 = create_and_finalize_block(&rpc).await?; + let c2 = read_active_commitment(&client, block2) + .await + .expect("second commitment must exist"); + + assert_ne!(c1, c2, "consecutive blocks must produce different commitments"); + + Ok(()) +} + +/// Multiple distinct fillers must each be able to place one bid on the active +/// phantom order within the bid window, and all bids must be visible via the +/// `intents_getBidsForOrder` RPC. +#[tokio::test] +#[ignore] +async fn test_multiple_fillers_can_bid_on_phantom_order() -> Result<(), anyhow::Error> { + let port = env::var("PORT").unwrap_or(SIMNODE_PORT_DEFAULT.into()); + let url = format!("ws://127.0.0.1:{port}"); + let (client, rpc) = subxt_utils::client::ws_client::(&url, u32::MAX).await?; + + set_phantom_order_config(&client, &rpc, 8453, 10).await?; + + let block_hash = create_and_finalize_block(&rpc).await?; + let commitment = read_active_commitment(&client, block_hash) + .await + .expect("commitment must exist"); + + let alice_ext = + author_place_bid(&client, &rpc, commitment, &[0xAA, 0xBB], Keyring::Alice).await?; + let alice_progress = SubmittableTransaction::from_bytes(client.clone(), alice_ext.0) + .submit_and_watch() + .await?; + + let bob_ext = author_place_bid(&client, &rpc, commitment, &[0xDD, 0xEE], Keyring::Bob).await?; + let bob_progress = SubmittableTransaction::from_bytes(client.clone(), bob_ext.0) + .submit_and_watch() + .await?; + + create_and_finalize_block(&rpc).await?; + alice_progress.wait_for_finalized_success().await?; + bob_progress.wait_for_finalized_success().await?; + + let bids: Vec = + rpc.request("intents_getBidsForOrder", rpc_params![commitment]).await?; + assert_eq!(bids.len(), 2, "both phantom bids must appear in the RPC response"); + assert!( + bids.iter().all(|b| b.commitment == commitment), + "all returned bids must reference the active commitment", + ); + + Ok(()) +} + +/// A filler that already holds a bid for the active phantom order must be +/// rejected with `DuplicatePhantomBid` on a second attempt. +#[tokio::test] +#[ignore] +async fn test_duplicate_phantom_bid_rejected() -> Result<(), anyhow::Error> { + let port = env::var("PORT").unwrap_or(SIMNODE_PORT_DEFAULT.into()); + let url = format!("ws://127.0.0.1:{port}"); + let (client, rpc) = subxt_utils::client::ws_client::(&url, u32::MAX).await?; + + set_phantom_order_config(&client, &rpc, 8453, 10).await?; + + let block_hash = create_and_finalize_block(&rpc).await?; + let commitment = read_active_commitment(&client, block_hash) + .await + .expect("commitment must exist"); + + // First bid from Alice — must succeed. + let first_call = subxt::dynamic::tx( + "IntentsCoprocessor", + "place_bid", + vec![ + subxt::dynamic::Value::from_bytes(commitment.as_bytes()), + subxt::dynamic::Value::from_bytes(&[0xAA, 0xBB, 0xCC]), + ], + ); + author_and_seal(&client, &rpc, client.tx().call_data(&first_call)?, Keyring::Alice).await?; + + // Second bid from Alice on the same commitment — must be rejected. + let dup_call_data = client.tx().call_data(&subxt::dynamic::tx( + "IntentsCoprocessor", + "place_bid", + vec![ + subxt::dynamic::Value::from_bytes(commitment.as_bytes()), + subxt::dynamic::Value::from_bytes(&[0x11, 0x22, 0x33]), + ], + ))?; + let dup_ext: Bytes = rpc + .request( + "simnode_authorExtrinsic", + rpc_params![Bytes::from(dup_call_data), Keyring::Alice.to_account_id().to_ss58check()], + ) + .await?; + let dup_progress = SubmittableTransaction::from_bytes(client.clone(), dup_ext.0) + .submit_and_watch() + .await?; + create_and_finalize_block(&rpc).await?; + let result = dup_progress.wait_for_finalized_success().await; + assert!( + result.is_err(), + "second bid from same filler must be rejected with DuplicatePhantomBid", + ); + + Ok(()) +} + +/// A bid placed after the governance-set bid window has expired must be +/// rejected with `PhantomOrderBidWindowClosed`. +/// +/// Flow: +/// Block N — set bid window to 1 via sudo +/// Block N+1 — set phantom order config via sudo (on_initialize has no config yet) +/// Block N+2 — empty block; on_initialize fires, phantom created at N+2 +/// Block N+3 — empty block; window: N+3 <= N+2+1 — still open +/// Block N+4 — bid executes; window: N+4 > N+2+1 — closed → rejected +#[tokio::test] +#[ignore] +async fn test_bid_rejected_after_phantom_window_closes() -> Result<(), anyhow::Error> { + let port = env::var("PORT").unwrap_or(SIMNODE_PORT_DEFAULT.into()); + let url = format!("ws://127.0.0.1:{port}"); + let (client, rpc) = subxt_utils::client::ws_client::(&url, u32::MAX).await?; + + // Set the bid window to 1 block via governance (block N). + let window_call = subxt::dynamic::tx( + "IntentsCoprocessor", + "set_phantom_bid_window", + vec![subxt::dynamic::Value::u128(1)], + ); + sudo_and_seal(&client, &rpc, window_call.into_value()).await?; + + // Set phantom order config (block N+1); on_initialize at N+1 has no config yet. + set_phantom_order_config(&client, &rpc, 8453, 10).await?; + + // Block N+2: on_initialize fires, phantom created at block N+2. + let block_hash = create_and_finalize_block(&rpc).await?; + let commitment = read_active_commitment(&client, block_hash) + .await + .expect("commitment must exist at N+2"); + + // Advance to block N+3 — window still open (N+3 <= N+2+1). + create_and_finalize_block(&rpc).await?; + + // Author a bid but do NOT seal yet. + let bid_call_data = client.tx().call_data(&subxt::dynamic::tx( + "IntentsCoprocessor", + "place_bid", + vec![ + subxt::dynamic::Value::from_bytes(commitment.as_bytes()), + subxt::dynamic::Value::from_bytes(&[0xAA, 0xBB]), + ], + ))?; + let bid_ext: Bytes = rpc + .request( + "simnode_authorExtrinsic", + rpc_params![Bytes::from(bid_call_data), Keyring::Bob.to_account_id().to_ss58check()], + ) + .await?; + let bid_progress = SubmittableTransaction::from_bytes(client.clone(), bid_ext.0) + .submit_and_watch() + .await?; + + // Seal block N+4 — bid executes here, window is now closed (N+4 > N+3). + create_and_finalize_block(&rpc).await?; + let result = bid_progress.wait_for_finalized_success().await; + assert!( + result.is_err(), + "bid placed after window closure must be rejected with PhantomOrderBidWindowClosed", + ); + + // Reset the bid window so subsequent tests are unaffected. + let reset_call = subxt::dynamic::tx( + "IntentsCoprocessor", + "set_phantom_bid_window", + vec![subxt::dynamic::Value::u128(100)], + ); + sudo_and_seal(&client, &rpc, reset_call.into_value()).await?; + + Ok(()) +} + +/// Governance (sudo) can update `PhantomBidWindow`; the new value must be +/// persisted to storage. +#[tokio::test] +#[ignore] +async fn test_set_phantom_bid_window_via_governance() -> Result<(), anyhow::Error> { + let port = env::var("PORT").unwrap_or(SIMNODE_PORT_DEFAULT.into()); + let url = format!("ws://127.0.0.1:{port}"); + let (client, rpc) = subxt_utils::client::ws_client::(&url, u32::MAX).await?; + + let new_window: u32 = 42; + + let window_call = subxt::dynamic::tx( + "IntentsCoprocessor", + "set_phantom_bid_window", + vec![subxt::dynamic::Value::u128(new_window as u128)], + ); + let block_hash = sudo_and_seal(&client, &rpc, window_call.into_value()).await?; + + let raw = client + .storage() + .at(block_hash) + .fetch_raw(phantom_bid_window_key()) + .await? + .expect("PhantomBidWindow must be set after governance call"); + + let stored = u32::decode(&mut &raw[..]).expect("PhantomBidWindow must decode as u32"); + assert_eq!(stored, new_window, "PhantomBidWindow must equal the governance-set value"); + + Ok(()) +} + +/// End-to-end phantom order flow: configure, have three fillers bid in a single +/// block, and verify all three bids are returned by `intents_getBidsForOrder`. +#[tokio::test] +#[ignore] +async fn test_phantom_order_full_flow_config_bid_rpc() -> Result<(), anyhow::Error> { + let port = env::var("PORT").unwrap_or(SIMNODE_PORT_DEFAULT.into()); + let url = format!("ws://127.0.0.1:{port}"); + let (client, rpc) = subxt_utils::client::ws_client::(&url, u32::MAX).await?; + + set_phantom_order_config(&client, &rpc, 8453, 10).await?; + + let block_hash = create_and_finalize_block(&rpc).await?; + let commitment = read_active_commitment(&client, block_hash) + .await + .expect("commitment must exist"); + + let fillers: &[(&[u8], Keyring)] = + &[(&[0xAA], Keyring::Alice), (&[0xBB], Keyring::Bob), (&[0xCC], Keyring::Charlie)]; + let mut progresses = Vec::new(); + for (op, who) in fillers { + let ext = author_place_bid(&client, &rpc, commitment, op, *who).await?; + let p = SubmittableTransaction::from_bytes(client.clone(), ext.0) + .submit_and_watch() + .await?; + progresses.push(p); + } + + create_and_finalize_block(&rpc).await?; + for p in progresses { + p.wait_for_finalized_success().await?; + } + + let bids: Vec = + rpc.request("intents_getBidsForOrder", rpc_params![commitment]).await?; + assert_eq!(bids.len(), 3, "all three phantom bids must be discoverable via RPC"); + assert!( + bids.iter().all(|b| b.commitment == commitment), + "every returned bid must carry the active commitment", + ); + + let user_ops: Vec<&Vec> = bids.iter().map(|b| &b.user_op).collect(); + assert!(user_ops.contains(&&vec![0xAA]), "Alice's user_op must be present"); + assert!(user_ops.contains(&&vec![0xBB]), "Bob's user_op must be present"); + assert!(user_ops.contains(&&vec![0xCC]), "Charlie's user_op must be present"); + + Ok(()) +} diff --git a/sdk/packages/indexer/docker/docker-compose.simnode.yml b/sdk/packages/indexer/docker/docker-compose.simnode.yml new file mode 100644 index 000000000..158ffc867 --- /dev/null +++ b/sdk/packages/indexer/docker/docker-compose.simnode.yml @@ -0,0 +1,41 @@ +services: + subquery-node-simnode: + image: subquerynetwork/subql-node-substrate:v5.9.1 + restart: unless-stopped + network_mode: host + volumes: + - ../src/configs:/app + - ../dist:/app/dist + command: + - -f=/app/hyperbridge-simnode.yaml + - --db-schema=simnode_test + - --workers=1 + - --batch-size=5 + - --unsafe + - --log-level=debug + - --block-confirmations=0 + - --store-cache-async=false + - --store-cache-threshold=1 + - --skipTransactions + environment: + DB_USER: postgres + DB_PASS: postgres + DB_DATABASE: indexer + DB_HOST: 127.0.0.1 + DB_PORT: 5433 + + graphql-engine-simnode: + image: polytopelabs/omnihedron:latest + restart: unless-stopped + network_mode: host + environment: + DB_USER: postgres + DB_PASS: postgres + DB_DATABASE: indexer + DB_HOST: 127.0.0.1 + DB_PORT: 5433 + command: + - --name=simnode_test + - --playground + - --query-timeout=0 + - --port=3101 diff --git a/sdk/packages/indexer/package.json b/sdk/packages/indexer/package.json index 2e26ba2de..ad5a28f7d 100644 --- a/sdk/packages/indexer/package.json +++ b/sdk/packages/indexer/package.json @@ -48,6 +48,7 @@ "build:release": "tsx scripts/generate-chain-yamls.ts --skip-rpc && npm run codegen:l2-chains && npm run codegen:subql && ./node_modules/.bin/subql build" }, "dependencies": { + "@hyperbridge/sdk": "workspace:*", "@ethersproject/abi": "^5.7.0", "@ethersproject/providers": "^5.7.2", "@ethersproject/shims": "^5.7.0", diff --git a/sdk/packages/indexer/src/configs/schema.graphql b/sdk/packages/indexer/src/configs/schema.graphql index e8867d63b..eff610430 100644 --- a/sdk/packages/indexer/src/configs/schema.graphql +++ b/sdk/packages/indexer/src/configs/schema.graphql @@ -2192,3 +2192,116 @@ type BandwidthTier @entity { lastUpdatedAt: BigInt! } +""" +A phantom order autonomously generated by the intents-coprocessor pallet on Hyperbridge. +Phantom orders are synthetic benchmark orders that the pallet creates at the start of each +interval (governed by PhantomBidWindow) to solicit bids from fillers and measure real +market liquidity and pricing for a given token pair on a destination chain. +The commitment is keccak256(abi.encode(Order)) and serves as the unique identifier. +""" +type PhantomOrder @entity { + """ + The phantom order commitment — keccak256(abi.encode(Order)). Primary key and lookup handle + used when querying bids via intents_getBidsForOrder. + """ + id: ID! + + """ + Destination chain state machine ID (e.g. EVM-8453) where the phantom order seeks liquidity. + """ + chain: String! @index + + """ + EVM address of the input token (tokenA) offered by the phantom order, as a 0x-prefixed hex string. + """ + tokenA: String! @index + + """ + EVM address of the output token (tokenB) requested by the phantom order, as a 0x-prefixed hex string. + """ + tokenB: String! @index + + """ + Standard input amount in the smallest unit of tokenA used as the benchmark quantity for price discovery. + """ + standardAmount: BigInt! + + """ + Minimum acceptable output amount in the smallest unit of tokenB. Bids below this threshold are rejected by the pallet. + """ + minOutput: BigInt! + + """ + Hyperbridge block number at which this phantom order was registered (emitted via PhantomOrderRegistered). + All phantom orders sharing the same createdAtBlock belong to the same generation interval. + """ + createdAtBlock: BigInt! @index + + """ + Timestamp of the Hyperbridge block at which this phantom order was registered. + """ + blockTimestamp: Date +} + +""" +A periodic price and liquidity snapshot for a phantom order, written every 10 Hyperbridge blocks. +Each snapshot collects all live bids via intents_getBidsForOrder, simulates each filler's user +operation against the real IntentGatewayV2 code (with the phantom commitment injected via state +override and block timestamp set to zero), and records the output amount distribution and total +solver liquidity across all bids that pass simulation. +Snapshots are only written when at least one valid bid exists and an EVM RPC endpoint is +configured for the phantom order's destination chain. +""" +type PhantomOrderPriceSnapshot @entity { + """ + Composite identifier: {commitment}-{blockNumber}. + """ + id: ID! + + """ + The phantom order commitment this snapshot belongs to. References PhantomOrder.id. + """ + commitment: String! @index + + """ + Hyperbridge block number at which this snapshot was taken. + """ + blockNumber: BigInt! @index + + """ + Median output amount across all valid bids, in the smallest unit of the output token (tokenB). + Null if no valid bids were found. + """ + medianPrice: BigInt + + """ + Lowest output amount across all valid bids — the least the user would receive (most favorable for the solver). + Null if no valid bids were found. + """ + lowestPrice: BigInt + + """ + Highest output amount across all valid bids — the most the user would receive (most favorable for the user). + Null if no valid bids were found. + """ + highestPrice: BigInt + + """ + Number of valid bids that contributed to this snapshot (passed simulation). + """ + bidCount: Int! + + """ + Total liquidity available from the winning solver across raw ERC-20 balance and ERC-4626 + vault positions (e.g. Aave stataTokens, yield vaults) for the output token. + """ + lpBalance: BigInt + + """ + Timestamp of the Hyperbridge block at which this snapshot was taken. + """ + snapshotTime: Date! +} + + + diff --git a/sdk/packages/indexer/src/handlers/events/substrateChains/handlePhantomOrderPrices.handler.ts b/sdk/packages/indexer/src/handlers/events/substrateChains/handlePhantomOrderPrices.handler.ts new file mode 100644 index 000000000..6c812e434 --- /dev/null +++ b/sdk/packages/indexer/src/handlers/events/substrateChains/handlePhantomOrderPrices.handler.ts @@ -0,0 +1,280 @@ +import { SubstrateEvent } from "@subql/types" +import { decodeFunctionData, toHex } from "viem" +import { + decodeUserOpScale, + decodeERC7821ExecuteBatch, + IntentGatewayABI, + requestCommitmentKey, + type HexString, +} from "@hyperbridge/sdk" + +import { wrap } from "@/utils/event.utils" +import { getBlockTimestamp, replaceWebsocketWithHttp } from "@/utils/rpc.helpers" +import { getHostStateMachine } from "@/utils/substrate.helpers" +import { timestampToDate } from "@/utils/date.helpers" +import { fetchWithRetry } from "@/utils/fetch-retry.helpers" +import { bytes32ToBytes20 } from "@/utils/transfer.helpers" +import { ENV_CONFIG } from "@/constants" +import { INTENT_GATEWAY_V2_ADDRESSES } from "@/intent-gateway-v2-addresses" +import { YIELD_VAULT_ADDRESSES } from "@/yield-vault-addresses" +import { PhantomOrder, PhantomOrderPriceSnapshot } from "@/configs/src/types" + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface RpcBidInfo { + commitment: string + filler: string + user_op: string +} + +interface FillData { + outputs: { token: string; amount: bigint }[] +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function extractFillData(callData: HexString, gatewayAddress: string): FillData | null { + const calls = decodeERC7821ExecuteBatch(callData) + if (!calls) return null + + const normalized = gatewayAddress.toLowerCase() + for (const call of calls) { + if (call.target.toLowerCase() !== normalized) continue + try { + const decoded = decodeFunctionData({ abi: IntentGatewayABI, data: call.data }) + if (decoded.functionName !== "fillOrder" || !decoded.args || decoded.args.length < 2) continue + const options = decoded.args[1] as { outputs: { token: string; amount: bigint }[] } + if (!options.outputs?.length) continue + return { outputs: options.outputs.map((o) => ({ token: o.token as string, amount: o.amount })) } + } catch { + continue + } + } + return null +} + +async function fetchBidsForOrder(nodeUrl: string, commitment: string): Promise { + const response = await fetchWithRetry(nodeUrl, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify({ id: 1, jsonrpc: "2.0", method: "intents_getBidsForOrder", params: [commitment] }), + }) + const data = await response.json() + return Array.isArray(data.result) ? (data.result as RpcBidInfo[]) : [] +} + +// Block number is overridden to 0 so the deadline check (deadline < block.number) +// always passes. The intent gateway's requestCommitments slot is overridden so the +// gateway treats the phantom commitment as a registered order. +async function simulateBid( + evmRpcUrl: string, + solver: string, + callData: HexString, + gatewayAddress: string, + commitment: string, +): Promise { + try { + const { slot1, slot2 } = requestCommitmentKey(commitment as HexString) + const response = await fetchWithRetry(evmRpcUrl, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "eth_call", + params: [ + { from: solver, to: solver, data: callData }, + "latest", + { + [gatewayAddress]: { + stateDiff: { + [slot2]: toHex(1n, { size: 32 }), + [slot1]: toHex(1n, { size: 32 }), + }, + }, + }, + { number: toHex(0n) }, + ], + }), + }) + const result = await response.json() + return !result.error + } catch { + return false + } +} + +async function getTokenBalance(evmRpcUrl: string, token: string, holder: string): Promise { + try { + const paddedHolder = holder.replace("0x", "").padStart(64, "0") + const data = `0x70a08231${paddedHolder}` as HexString + const response = await fetchWithRetry(evmRpcUrl, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "eth_call", + params: [{ to: token, data }, "latest"], + }), + }) + const result = await response.json() + if (result.error || !result.result || result.result === "0x") return 0n + return BigInt(result.result) + } catch { + return 0n + } +} + +// Calls ERC-4626 maxWithdraw(owner) to get the solver's redeemable balance from a vault. +async function getVaultBalance(evmRpcUrl: string, vault: string, owner: string): Promise { + try { + const paddedOwner = owner.replace("0x", "").padStart(64, "0") + // maxWithdraw(address owner) → bytes4 selector ce96cb77 + const data = `0xce96cb77${paddedOwner}` as HexString + const response = await fetchWithRetry(evmRpcUrl, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "eth_call", + params: [{ to: vault, data }, "latest"], + }), + }) + const result = await response.json() + if (result.error || !result.result || result.result === "0x") return 0n + return BigInt(result.result) + } catch { + return 0n + } +} + +// Returns raw ERC-20 balance plus total redeemable from all configured yield vaults +// for the given token on the given chain. +async function getTotalSolverBalance( + evmRpcUrl: string, + chain: string, + token: string, + solver: string, +): Promise { + const raw = await getTokenBalance(evmRpcUrl, token, solver) + const vaultMap = YIELD_VAULT_ADDRESSES[chain] ?? {} + const vaults = vaultMap[token.toLowerCase()] ?? [] + const vaultBalances = await Promise.all(vaults.map((v) => getVaultBalance(evmRpcUrl, v, solver))) + return vaultBalances.reduce((acc, b) => acc + b, raw) +} + +// Reads the commitment of the currently active phantom order from storage. +// The first 32 bytes of the SCALE-encoded value are always the H256 commitment. +async function getActiveCommitment(blockHash: string): Promise { + try { + const storageKey = api.query.intentsCoprocessor.currentPhantomOrder.key() + const rawResult = await api.rpc.state.getStorage(storageKey, blockHash) + const hex: string = rawResult.toHex() + if (!hex || hex === "0x") return null + const bare = hex.replace("0x", "") + if (bare.length < 64) return null + return "0x" + bare.slice(0, 64) + } catch { + return null + } +} + +function medianOf(values: bigint[]): bigint { + const sorted = [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) + return sorted[Math.floor(sorted.length / 2)] +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +export const handlePhantomOrderPrices = wrap(async (event: SubstrateEvent): Promise => { + const blockNumber = event.block.block.header.number.toBigInt() + if (blockNumber % 10n !== 0n) return + const blockHash = event.block.block.header.hash.toString() + + // Use the storage value to identify the current interval — only its commitment is needed. + const activeCommitment = await getActiveCommitment(blockHash) + if (!activeCommitment) return + + // The anchor order tells us createdAtBlock, which all pairs in the same interval share. + const anchor = await PhantomOrder.get(activeCommitment) + if (!anchor) return + + // Fetch every pair registered in the same interval. + const phantomOrders = await PhantomOrder.getByCreatedAtBlock(anchor.createdAtBlock, { limit: 100 }) + if (!phantomOrders.length) return + + const host = getHostStateMachine(chainId) + const nodeUrl = replaceWebsocketWithHttp(ENV_CONFIG[host] ?? "") + if (!nodeUrl) { + logger.warn({ host }, "No RPC URL configured for Hyperbridge node") + return + } + + const blockTimestamp = await getBlockTimestamp(blockHash, host) + + for (const phantom of phantomOrders) { + const snapshotId = `${phantom.id}-${blockNumber}` + if (await PhantomOrderPriceSnapshot.get(snapshotId)) continue + + let bids: RpcBidInfo[] + try { + bids = await fetchBidsForOrder(nodeUrl, phantom.id) + } catch (err) { + logger.warn({ err, commitment: phantom.id }, "intents_getBidsForOrder failed") + continue + } + + if (bids.length === 0) continue + + const evmUrl = replaceWebsocketWithHttp(ENV_CONFIG[phantom.chain] ?? "") + const gatewayAddress = INTENT_GATEWAY_V2_ADDRESSES[phantom.chain as keyof typeof INTENT_GATEWAY_V2_ADDRESSES] + if (!evmUrl || !gatewayAddress) continue + + const prices: bigint[] = [] + let bestLpBalance = 0n + + for (const bid of bids) { + if (!bid.user_op) continue + try { + const decoded = decodeUserOpScale(bid.user_op as HexString) + const callData = decoded.callData + const solver = decoded.sender + + const fillData = extractFillData(callData, gatewayAddress) + if (!fillData?.outputs.length) continue + + const simOk = await simulateBid(evmUrl, solver, callData, gatewayAddress, phantom.id) + if (!simOk) continue + + const output = fillData.outputs[0] + const tokenAddress = bytes32ToBytes20(output.token) + const totalBalance = await getTotalSolverBalance(evmUrl, phantom.chain, tokenAddress, solver) + + prices.push(output.amount) + if (totalBalance > bestLpBalance) bestLpBalance = totalBalance + } catch (err) { + logger.warn({ err, filler: bid.filler }, "Failed to process bid for price snapshot") + } + } + + if (prices.length === 0) continue + + const sorted = [...prices].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) + + await PhantomOrderPriceSnapshot.create({ + id: snapshotId, + commitment: phantom.id, + blockNumber, + lowestPrice: sorted[0], + highestPrice: sorted[sorted.length - 1], + medianPrice: medianOf(prices), + bidCount: prices.length, + lpBalance: bestLpBalance > 0n ? bestLpBalance : undefined, + snapshotTime: timestampToDate(blockTimestamp), + }).save() + + logger.info({ commitment: phantom.id, blockNumber, bidCount: prices.length }, "PhantomOrderPriceSnapshot saved") + } +}) diff --git a/sdk/packages/indexer/src/handlers/events/substrateChains/handlePhantomOrderRegistered.handler.ts b/sdk/packages/indexer/src/handlers/events/substrateChains/handlePhantomOrderRegistered.handler.ts new file mode 100644 index 000000000..6cbbfc148 --- /dev/null +++ b/sdk/packages/indexer/src/handlers/events/substrateChains/handlePhantomOrderRegistered.handler.ts @@ -0,0 +1,39 @@ +import { SubstrateEvent } from "@subql/types" +import { hexToU8a } from "@polkadot/util" + +import { wrap } from "@/utils/event.utils" +import { getBlockTimestamp } from "@/utils/rpc.helpers" +import { timestampToDate } from "@/utils/date.helpers" +import { PhantomOrder } from "@/configs/src/types" + +export const handlePhantomOrderRegistered = wrap(async (event: SubstrateEvent): Promise => { + const [commitmentData, chainData, createdAtData, tokenAData, tokenBData, standardAmountData, minOutputData] = + event.event.data + + const commitment = commitmentData.toString() + + if (await PhantomOrder.get(commitment)) return + + const chain = Buffer.from(hexToU8a(chainData.toHex())).toString("utf8") + const createdAtBlock = BigInt(createdAtData.toString()) + const tokenA = tokenAData.toHex() + const tokenB = tokenBData.toHex() + const standardAmount = BigInt(standardAmountData.toString()) + const minOutput = BigInt(minOutputData.toString()) + + const blockHash = event.block.block.header.hash.toString() + const blockTimestamp = await getBlockTimestamp(blockHash, chainId) + + await PhantomOrder.create({ + id: commitment, + chain, + tokenA, + tokenB, + standardAmount, + minOutput, + createdAtBlock, + blockTimestamp: timestampToDate(blockTimestamp), + }).save() + + logger.info({ commitment, chain, tokenA, tokenB }, "PhantomOrder indexed") +}) diff --git a/sdk/packages/indexer/src/mappings/mappingHandlers.ts b/sdk/packages/indexer/src/mappings/mappingHandlers.ts index 86c1c61a7..346d54457 100644 --- a/sdk/packages/indexer/src/mappings/mappingHandlers.ts +++ b/sdk/packages/indexer/src/mappings/mappingHandlers.ts @@ -21,6 +21,8 @@ export { handleDustSweptEventV3 } from "@/handlers/events/intentGatewayV3/dustSw // Substrate Chains Handlers export { handleIsmpStateMachineUpdatedEvent } from "@/handlers/events/substrateChains/handleIsmpStateMachineUpdatedEvent.handler" +export { handlePhantomOrderRegistered } from "@/handlers/events/substrateChains/handlePhantomOrderRegistered.handler" +export { handlePhantomOrderPrices } from "@/handlers/events/substrateChains/handlePhantomOrderPrices.handler" export { handleSubstratePostRequestTimeoutHandledEvent } from "@/handlers/events/substrateChains/handlePostRequestTimeoutHandledEvent.handler" export { handleSubstrateRequestEvent } from "@/handlers/events/substrateChains/handleRequestEvent.handler" export { handleSubstrateResponseEvent } from "@/handlers/events/substrateChains/handleResponseEvent.handler" diff --git a/sdk/packages/indexer/src/substrate-chaintypes/hyperbridge.ts b/sdk/packages/indexer/src/substrate-chaintypes/hyperbridge.ts index 7cec120f8..672634e61 100644 --- a/sdk/packages/indexer/src/substrate-chaintypes/hyperbridge.ts +++ b/sdk/packages/indexer/src/substrate-chaintypes/hyperbridge.ts @@ -1,11 +1,20 @@ +// PrioritizeVeto encodes to 0 bytes (PhantomData); CheckMetadataHash adds a 1-byte mode field. +// Without these entries polkadot.js miscounts extension bytes and reads a garbage call index. +const signedExtensions = { + PrioritizeVeto: { extrinsic: {}, payload: {} }, + CheckMetadataHash: { extrinsic: { mode: "u8" }, payload: {} }, +} + export default { typesBundle: { spec: { gargantua: { hasher: "keccakAsU8a", + signedExtensions, }, nexus: { hasher: "keccakAsU8a", + signedExtensions, }, }, }, diff --git a/sdk/packages/indexer/src/yield-vault-addresses.ts b/sdk/packages/indexer/src/yield-vault-addresses.ts new file mode 100644 index 000000000..e42119581 --- /dev/null +++ b/sdk/packages/indexer/src/yield-vault-addresses.ts @@ -0,0 +1,13 @@ +// ERC-4626 vault addresses per chain, keyed by underlying token address (lowercase). +// Add vault addresses here as new yield integrations are deployed. +// Values are arrays because multiple vaults may wrap the same underlying token. +export const YIELD_VAULT_ADDRESSES: Record> = { + "EVM-1": {}, + "EVM-10": {}, + "EVM-56": {}, + "EVM-100": {}, + "EVM-130": {}, + "EVM-137": {}, + "EVM-8453": {}, + "EVM-42161": {}, +} diff --git a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts index c97ff8fbd..62ca54502 100644 --- a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts +++ b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts @@ -82,6 +82,16 @@ interface RpcBidInfo { user_op: HexString } +export interface PhantomOrderEvent { + commitment: HexString + chain: string + createdAt: number + tokenA: HexString + tokenB: HexString + standardAmount: bigint + minOutput: bigint +} + /** * Service for interacting with Hyperbridge's pallet-intents coprocessor. * Handles bid submission and retrieval for the IntentGatewayV2 protocol. @@ -154,8 +164,8 @@ export class IntentsCoprocessor { } /** - * Creates a Substrate keypair from the configured private key - * Supports both hex seed (without 0x prefix) and mnemonic phrases + * Creates a Substrate keypair from the configured private key. + * Supports hex seed (with or without 0x), mnemonic phrases, and URI derivation paths (//Alice). */ public getKeyPair(): KeyringPair { if (!this.substratePrivateKey) { @@ -164,6 +174,9 @@ export class IntentsCoprocessor { const keyring = new Keyring({ type: "sr25519" }) + if (this.substratePrivateKey.startsWith("//")) { + return keyring.addFromUri(this.substratePrivateKey) + } if (this.substratePrivateKey.includes(" ")) { return keyring.addFromMnemonic(this.substratePrivateKey) } @@ -243,20 +256,43 @@ export class IntentsCoprocessor { .signAndSend(keyPair, { tip }, (result) => { if (resolved) return - if (result.status.isInBlock || result.status.isFinalized) { + if (result.dispatchError && (result.status.isInBlock || result.status.isFinalized)) { resolved = true clearTimeout(timeoutId) + let errorMsg: string + if (result.dispatchError.isModule) { + const decoded = this.api.registry.findMetaError(result.dispatchError.asModule) + errorMsg = `Dispatch error: ${decoded.section}::${decoded.name}` + } else { + errorMsg = `Dispatch error: ${result.dispatchError.toString()}` + } resolve({ - success: true, - blockHash: result.status.asInBlock.toHex() as HexString, - extrinsicHash: extrinsic.hash.toHex() as HexString, + success: false, + error: errorMsg, }) - } else if (result.dispatchError) { + } else if ( + result.status.isDropped || + result.status.isInvalid || + result.status.isUsurped || + result.status.isFinalityTimeout + ) { + // Pool-level terminal statuses — don't retry, let caller decide resolved = true clearTimeout(timeoutId) resolve({ success: false, - error: `Dispatch error: ${result.dispatchError.toString()}`, + error: `Transaction ${result.status.type.toLowerCase()}`, + }) + } else if (result.status.isInBlock || result.status.isFinalized) { + resolved = true + clearTimeout(timeoutId) + resolve({ + success: true, + blockHash: (result.status.isInBlock + ? result.status.asInBlock + : result.status.asFinalized + ).toHex() as HexString, + extrinsicHash: extrinsic.hash.toHex() as HexString, }) } }) @@ -421,4 +457,29 @@ export class IntentsCoprocessor { private buildOffchainBidKey(commitment: HexString, filler: string): Uint8Array { return u8aConcat(OFFCHAIN_BID_PREFIX, hexToU8a(commitment), decodeAddress(filler)) } + + /** + * Subscribes to PhantomOrderRegistered events from the intents coprocessor pallet. + * Calls the callback for each new phantom order as blocks arrive. + * Returns an unsubscribe function to stop the subscription. + */ + async subscribePhantomOrders(callback: (event: PhantomOrderEvent) => void): Promise<() => void> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const unsub = await (this.api.query.system.events as any)((records: any[]) => { + for (const { event } of records) { + if (event.section !== "intentsCoprocessor" || event.method !== "PhantomOrderRegistered") continue + const [commitment, chain, createdAt, tokenA, tokenB, standardAmount, minOutput] = event.data + callback({ + commitment: commitment.toHex() as HexString, + chain: new TextDecoder().decode(hexToU8a(chain.toHex())), + createdAt: createdAt.toNumber(), + tokenA: tokenA.toHex() as HexString, + tokenB: tokenB.toHex() as HexString, + standardAmount: BigInt(standardAmount.toString()), + minOutput: BigInt(minOutput.toString()), + }) + } + }) + return unsub as () => void + } } diff --git a/sdk/packages/sdk/src/protocols/intents/index.ts b/sdk/packages/sdk/src/protocols/intents/index.ts index c79d34e04..bd19c43c9 100644 --- a/sdk/packages/sdk/src/protocols/intents/index.ts +++ b/sdk/packages/sdk/src/protocols/intents/index.ts @@ -11,7 +11,7 @@ export type { UniswapV4IntentQuoteOptions, UniswapV4PoolKey, } from "./quote" -export { encodeERC7821ExecuteBatch, transformOrderForContract, fetchSourceProof, orderCommitment } from "./utils" +export { encodeERC7821ExecuteBatch, decodeERC7821ExecuteBatch, transformOrderForContract, fetchSourceProof, orderCommitment } from "./utils" export { CryptoUtils, SELECT_SOLVER_TYPEHASH, PACKED_USEROP_TYPEHASH, DOMAIN_TYPEHASH } from "./CryptoUtils" export { DEFAULT_GRAFFITI, diff --git a/sdk/packages/sdk/src/protocols/intents/utils.ts b/sdk/packages/sdk/src/protocols/intents/utils.ts index 8dc10347a..779789c1f 100644 --- a/sdk/packages/sdk/src/protocols/intents/utils.ts +++ b/sdk/packages/sdk/src/protocols/intents/utils.ts @@ -1,4 +1,4 @@ -import { encodeFunctionData, encodeAbiParameters, formatUnits, parseUnits, keccak256 } from "viem" +import { encodeFunctionData, encodeAbiParameters, decodeFunctionData, decodeAbiParameters, formatUnits, parseUnits, keccak256 } from "viem" import IntentGatewayV2 from "@/abis/IntentGatewayV2" import Decimal from "decimal.js" import type { Order } from "@/types" @@ -73,6 +73,34 @@ export function encodeERC7821ExecuteBatch(calls: ERC7821Call[]): HexString { }) as HexString } +/** + * Decodes ERC-7821 `execute` calldata back into its constituent calls. + * + * Returns `null` if the calldata does not match the expected `execute` + * function signature or cannot be decoded. + * + * @param callData - Hex-encoded calldata produced by {@link encodeERC7821ExecuteBatch}. + * @returns Array of decoded {@link ERC7821Call} objects, or `null` on failure. + */ +export function decodeERC7821ExecuteBatch(callData: HexString): ERC7821Call[] | null { + try { + const decoded = decodeFunctionData({ abi: ERC7821ABI.ABI, data: callData }) + if (decoded.functionName !== "execute" || !decoded.args || decoded.args.length < 2) return null + const executionData = decoded.args[1] as HexString + const [calls] = decodeAbiParameters( + [{ type: "tuple[]", components: ERC7821ABI.ABI[1].components }], + executionData, + ) as [ERC7821Call[]] + return calls.map((call) => ({ + target: call.target as HexString, + value: call.value, + data: call.data as HexString, + })) + } catch { + return null + } +} + /** * Fetches a Merkle/state proof for the given ISMP request commitment on the * source chain. diff --git a/sdk/packages/simplex/filler-config-example.toml b/sdk/packages/simplex/filler-config-example.toml index 3345da0a5..634167cc1 100644 --- a/sdk/packages/simplex/filler-config-example.toml +++ b/sdk/packages/simplex/filler-config-example.toml @@ -241,6 +241,22 @@ points = [ # +# Phantom order generator (optional) +# Places expired same-chain swaps to collect price and liquidity bids from fillers. +# Requires hyperbridgeWsUrl and substratePrivateKey to be set in [simplex]. +# +# [phantom_generator] +# enabled = true +# chain = "EVM-8453" # Chain where phantom orders are placed +# interval_hours = 1 # How often to run a round (default: 1 hour) +# +# [[phantom_generator.token_pairs]] +# token_a = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" # USDC +# token_b = "0xdac17f958d2ee523a2206206994597c13d831ec7" # USDT +# standard_amount = "1000000000" # 1000 USDC (6 decimals) +# min_output = "990000000" # 990 USDT minimum + + # Chain configuration - RPC and bundler URLs are required # Chain IDs are resolved automatically from the RPC endpoints. # All other chain data (addresses, assets, etc.) comes from the SDK automatically. diff --git a/sdk/packages/simplex/package.json b/sdk/packages/simplex/package.json index 3b1f15a70..534045a9e 100644 --- a/sdk/packages/simplex/package.json +++ b/sdk/packages/simplex/package.json @@ -29,6 +29,8 @@ "test:fx-testnet": "pnpm run codegen && vitest --watch=false --maxConcurrency=1 --testTimeout=1000000 src/tests/strategies/fx.testnet.test.ts", "test:mpcvault": "pnpm run codegen && vitest --watch=false --maxConcurrency=1 --testTimeout=120000 src/tests/wallet/mpcvault.integration.test.ts", "test:uniswapV4Price": "pnpm run codegen && vitest --watch=false --maxConcurrency=1 --testTimeout=1000000 src/tests/funding/uniswapV4Price.test.ts", + "test:prices": "pnpm run codegen && vitest --watch=false --maxConcurrency=1 --testTimeout=120000 src/tests/price-submission.simnode.test.ts", + "test:phantom-e2e": "pnpm run codegen && vitest --watch=false --maxConcurrency=1 --testTimeout=120000 src/tests/phantom-e2e.simnode.test.ts", "test:watch": "pnpm run codegen && vitest", "lint": "pnpm run codegen && biome lint src/bin src/config src/core src/services src/strategies src/tests src/index.ts", "lint:fix": "pnpm run codegen && biome lint --write src/bin src/config src/core src/services src/strategies src/tests src/index.ts", diff --git a/sdk/packages/simplex/src/core/filler.ts b/sdk/packages/simplex/src/core/filler.ts index 4955ca70f..89f730d4b 100644 --- a/sdk/packages/simplex/src/core/filler.ts +++ b/sdk/packages/simplex/src/core/filler.ts @@ -8,6 +8,8 @@ import { retryPromise, type HexString, IntentsCoprocessor, + type PhantomOrderEvent, + orderCommitment, bytes32ToBytes20, type TokenInfo, } from "@hyperbridge/sdk" @@ -41,6 +43,7 @@ export class IntentFiller { private pendingRetractions = new Set() private rebalancingInterval?: NodeJS.Timeout private retractionSweepInterval?: NodeJS.Timeout + private phantomUnsubscribe: (() => void) | null = null private hyperbridge: Promise | undefined = undefined private config: FillerConfig private configService: FillerConfigService @@ -187,6 +190,10 @@ export class IntentFiller { if (this.bidStorage && this.hyperbridge) { this.startRetractionSweep() } + + if (this.hyperbridge) { + this.startPhantomBidding() + } } /** @@ -273,6 +280,11 @@ export class IntentFiller { public async stop(): Promise { this.monitor.stopListening() + if (this.phantomUnsubscribe) { + this.phantomUnsubscribe() + this.phantomUnsubscribe = null + } + // Stop rebalancing interval if (this.rebalancingInterval) { clearInterval(this.rebalancingInterval) @@ -729,4 +741,86 @@ export class IntentFiller { } }) } + + private startPhantomBidding(): void { + if (!this.hyperbridge) return + this.hyperbridge + .then(async (coprocessor) => { + this.phantomUnsubscribe = await coprocessor.subscribePhantomOrders((event) => { + this.globalQueue.add(() => this.handlePhantomOrder(event, coprocessor)) + }) + this.logger.info("Phantom order subscription active") + }) + .catch((err) => { + this.logger.error({ err }, "Failed to start phantom order subscription") + }) + } + + private async handlePhantomOrder(event: PhantomOrderEvent, coprocessor: IntentsCoprocessor): Promise { + const entryPointAddress = this.configService.getEntryPointAddress(`EVM-${getChainId(event.chain) ?? event.chain}`) + if (!entryPointAddress) { + this.logger.debug({ chain: event.chain }, "No entry point configured for phantom order chain, skipping") + return + } + + // Reconstruct the Order with the same field values the pallet used when computing the commitment. + const ZERO_BYTES32 = `0x${"00".repeat(32)}` as HexString + const ZERO_ADDRESS = `0x${"00".repeat(20)}` as HexString + const phantomOrder: Order = { + user: ZERO_BYTES32, + source: event.chain, + destination: event.chain, + deadline: 0n, + nonce: BigInt(event.createdAt), + fees: 0n, + session: ZERO_ADDRESS, + predispatch: { assets: [], call: "0x" }, + inputs: [{ token: event.tokenA, amount: event.standardAmount }], + output: { + beneficiary: ZERO_BYTES32, + assets: [{ token: event.tokenB, amount: event.minOutput }], + call: "0x", + }, + } + phantomOrder.id = orderCommitment(phantomOrder) + + const strategy = this.strategies.find((s) => typeof s.quotePhantomFill === "function") + if (!strategy?.quotePhantomFill) { + this.logger.debug("No strategy supports quotePhantomFill, skipping phantom order") + return + } + + let fillerOutputs: TokenInfo[] | null = null + try { + fillerOutputs = await strategy.quotePhantomFill(phantomOrder) + } catch (err) { + this.logger.warn({ err, commitment: phantomOrder.id, chain: event.chain }, "quotePhantomFill failed") + return + } + + if (!fillerOutputs || fillerOutputs.length === 0) { + this.logger.debug({ chain: event.chain }, "Strategy declined phantom order") + return + } + + const solverAccountAddress = this.signer.account.address as HexString + + try { + const { commitment, userOp } = await this.contractService.preparePhantomBidUserOp( + phantomOrder, + entryPointAddress, + solverAccountAddress, + fillerOutputs, + ) + + const result = await coprocessor.submitBid(commitment, userOp) + if (result.success) { + this.logger.info({ commitment, chain: event.chain }, "Phantom bid submitted") + } else { + this.logger.warn({ commitment, chain: event.chain, error: result.error }, "Phantom bid rejected") + } + } catch (err) { + this.logger.error({ err, chain: event.chain }, "Failed to prepare or submit phantom bid") + } + } } diff --git a/sdk/packages/simplex/src/services/ContractInteractionService.ts b/sdk/packages/simplex/src/services/ContractInteractionService.ts index 90dd209fc..4878a5da0 100644 --- a/sdk/packages/simplex/src/services/ContractInteractionService.ts +++ b/sdk/packages/simplex/src/services/ContractInteractionService.ts @@ -633,6 +633,58 @@ export class ContractInteractionService { return { commitment, userOp: encodedUserOp } } + /** + * Builds a PackedUserOperation for a phantom (expired same-chain) order bid. + * Uses zero relayer fees and default gas values — no estimation needed since + * the order will never execute; the indexer only reads the proposed fill amounts. + */ + async preparePhantomBidUserOp( + order: Order, + entryPointAddress: HexString, + solverAccountAddress: HexString, + fillerOutputs: TokenInfo[], + ): Promise<{ commitment: HexString; userOp: HexString }> { + const sdkHelper = await this.getIntentGateway(order.source, order.destination) + const client = this.clientManager.getPublicClient(order.destination) + + const fillOptions: FillOptions = { relayerFee: 0n, nativeDispatchFee: 0n, outputs: fillerOutputs } + const callData = await this.buildApprovalAndFillCalldata(order, fillerOutputs, fillOptions, 0n) + + const commitment = orderCommitment(order) + + let nonce = 0n + try { + nonce = await client.readContract({ + address: entryPointAddress, + abi: ENTRYPOINT_ABI, + functionName: "getNonce", + args: [solverAccountAddress, BigInt(commitment) & ((1n << 192n) - 1n)], + }) as bigint + } catch { + // Nonce defaults to 0 for phantom bids — the bid is never executed on-chain + } + + const gasPrice = await client.getGasPrice().catch(() => 1_000_000_000n) + + const userOp = await sdkHelper.prepareSubmitBid({ + order, + fillOptions, + solverAccount: solverAccountAddress, + solverSigner: this.signer, + nonce, + entryPointAddress, + callGasLimit: 500_000n, + verificationGasLimit: 150_000n, + preVerificationGas: 50_000n, + maxFeePerGas: gasPrice, + maxPriorityFeePerGas: gasPrice / 10n, + callData, + paymasterAndData: "0x" as HexString, + }) + + return { commitment, userOp: encodeUserOpScale(userOp) } + } + /** * Builds ERC-7821 batch calldata that prepends any required ERC20 approvals * before the fillOrder call, all within a single UserOp payload. diff --git a/sdk/packages/simplex/src/services/FillerConfigService.ts b/sdk/packages/simplex/src/services/FillerConfigService.ts index cbdd67f85..8a5b33ddf 100644 --- a/sdk/packages/simplex/src/services/FillerConfigService.ts +++ b/sdk/packages/simplex/src/services/FillerConfigService.ts @@ -496,4 +496,5 @@ export class FillerConfigService { getMaxConsecutiveClamps(): number { return this.fillerConfig?.overfillProtection?.maxConsecutiveClamps ?? 3 } + } diff --git a/sdk/packages/simplex/src/strategies/base.ts b/sdk/packages/simplex/src/strategies/base.ts index 0bd9056b3..cb05e455c 100644 --- a/sdk/packages/simplex/src/strategies/base.ts +++ b/sdk/packages/simplex/src/strategies/base.ts @@ -1,4 +1,4 @@ -import { Order, ExecutionResult, IntentsCoprocessor } from "@hyperbridge/sdk" +import { Order, ExecutionResult, IntentsCoprocessor, TokenInfo } from "@hyperbridge/sdk" import { Decimal } from "decimal.js" /** Supported token types for same-token execution */ @@ -26,4 +26,11 @@ export interface FillerStrategy { confirmationPolicy?: { getConfirmationBlocks: (chainId: number, amountUsd: number) => number } + + /** + * Quote fill outputs for a phantom (expired same-chain) order. + * Returns the token amounts the strategy would provide without gas estimation. + * Returns null when the strategy cannot handle this token pair. + */ + quotePhantomFill?(order: Order): Promise } diff --git a/sdk/packages/simplex/src/strategies/fx.ts b/sdk/packages/simplex/src/strategies/fx.ts index e2f48429b..73a9bfcdb 100644 --- a/sdk/packages/simplex/src/strategies/fx.ts +++ b/sdk/packages/simplex/src/strategies/fx.ts @@ -929,6 +929,82 @@ export class FXFiller implements FillerStrategy { ) } + /** + * Returns the filler's proposed output amounts for a phantom order without + * checking on-chain balance or estimating gas. Phantom orders are probes that + * never execute; we only need the price signal. + * + * Returns `null` when the pair is not supported or the USD value cannot be + * computed (e.g. venue price unavailable and no fallback). + */ + async quotePhantomFill(order: Order): Promise { + if (!(await this.canFill(order))) return null + + const pairs = this.classifyAllPairs(order) + if (!pairs) return null + + const usdResult = await this.getOrderUsdValue(order) + if (!usdResult || usdResult.inputUsd.lte(0)) return null + + const cappedOrderUsd = Decimal.min(usdResult.inputUsd, this.maxOrderUsd) + if (cappedOrderUsd.lte(0)) return null + + const chain = order.source + const venuePrice = this.fundingVenues.length > 0 ? await this.getVenuePrice(chain) : null + const policyBidPrice = this.bidPricePolicy.getPrice(cappedOrderUsd) + const policyAskPrice = this.askPricePolicy.getPrice(cappedOrderUsd) + + const outputs: TokenInfo[] = [] + let remainingUsd = cappedOrderUsd + + for (let i = 0; i < order.inputs.length; i++) { + const input = order.inputs[i] + const output = order.output.assets[i] + const pair = pairs[i] + + const inputDecimals = await this.contractService.getTokenDecimals( + bytes32ToBytes20(input.token) as HexString, + chain, + ) + const outputDecimals = await this.contractService.getTokenDecimals( + bytes32ToBytes20(output.token) as HexString, + chain, + ) + + const stableDecimals = pair.inputIsStable ? inputDecimals : outputDecimals + const exoticDecimals = pair.inputIsStable ? outputDecimals : inputDecimals + const bidPrice = venuePrice?.bid ?? policyBidPrice + const askPrice = venuePrice?.ask ?? policyAskPrice + + const legResult = this.computeLegPolicyOutput( + input.amount, + pair.inputIsStable, + stableDecimals, + exoticDecimals, + remainingUsd, + pair.inputIsStable ? askPrice : bidPrice, + ) + + if (!legResult) continue + + remainingUsd = remainingUsd.minus(legResult.usdUsed) + + const overfillCeiling = (output.amount * (10000n + this.maxOverfillBps)) / 10000n + const amount = legResult.policyMaxOutput > overfillCeiling ? overfillCeiling : legResult.policyMaxOutput + outputs.push({ token: output.token, amount }) + + if (remainingUsd.lte(0)) break + } + + if (outputs.length === 0) return null + + if (order.id) { + this.contractService.cacheService.setFillerOutputs(order.id, outputs) + } + + return outputs + } + /** * Returns the USD value of the order's full input basket. * Stablecoin inputs are priced at face value; exotic inputs are converted diff --git a/sdk/packages/simplex/src/strategies/stable.ts b/sdk/packages/simplex/src/strategies/stable.ts index 04844d8a8..44b156271 100644 --- a/sdk/packages/simplex/src/strategies/stable.ts +++ b/sdk/packages/simplex/src/strategies/stable.ts @@ -321,6 +321,38 @@ export class StableFiller implements FillerStrategy { return { isValid: true, profitFromSlippage: totalProfitNormalized } } + /** + * Quotes fill outputs for a phantom order using the bps policy, without gas estimation. + * Returns null when this strategy does not support the order's token pair. + */ + async quotePhantomFill(order: Order): Promise { + if (!(await this.canFill(order))) return null + + const basisPoints = 10000n + const inputUsdValue = await this.contractService.getInputUsdValue(order) + const fillerBps = this.bpsPolicy.getBps(inputUsdValue) + const outputs: TokenInfo[] = [] + + for (let i = 0; i < order.inputs.length; i++) { + const input = order.inputs[i] + const output = order.output.assets[i] + + const [inputDecimals, outputDecimals] = await Promise.all([ + this.contractService.getTokenDecimals(input.token, order.source), + this.contractService.getTokenDecimals(output.token, order.destination), + ]) + + const convertedInput = adjustDecimals(input.amount, inputDecimals, outputDecimals) + const bpsOutput = (convertedInput * (basisPoints - fillerBps)) / basisPoints + const overfillCeiling = (output.amount * (10000n + this.maxOverfillBps)) / 10000n + + outputs.push({ token: output.token, amount: bpsOutput > overfillCeiling ? overfillCeiling : bpsOutput }) + } + + this.contractService.cacheService.setFillerOutputs(order.id!, outputs) + return outputs + } + /** * Sources each output token from the solver's wallet, topping up shortfalls * via funding venues (e.g. an ERC-4626 vault `withdraw`). When a venue can only diff --git a/sdk/packages/simplex/src/tests/phantom-e2e.simnode.test.ts b/sdk/packages/simplex/src/tests/phantom-e2e.simnode.test.ts new file mode 100644 index 000000000..e40b2d14c --- /dev/null +++ b/sdk/packages/simplex/src/tests/phantom-e2e.simnode.test.ts @@ -0,0 +1,315 @@ +/** + * End-to-end integration test for the phantom order bid lifecycle. + * + * Tests the full governance → on_initialize → bid flow: governance sets the + * phantom order config, the runtime hook generates a commitment each interval, + * fillers place bids via the SDK, and the bids are discoverable via + * intents_getBidsForOrder RPC. + * + * Requires a running hyperbridge simnode (WITHOUT --instant): + * cargo build -p hyperbridge + * ./target/debug/hyperbridge simnode --chain gargantua-1000 --rpc-port 9990 --tmp + * + * Run with: + * SIMNODE_URL=ws://127.0.0.1:9990 pnpm test:phantom-e2e + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest" +import { ApiPromise, WsProvider, Keyring } from "@polkadot/api" +import { keccakAsU8a } from "@polkadot/util-crypto" +import { IntentsCoprocessor, encodeUserOpScale } from "@hyperbridge/sdk" +import type { HexString, PackedUserOperation } from "@hyperbridge/sdk" + +const SIMNODE_URL = process.env.SIMNODE_URL || "ws://127.0.0.1:9990" + +function makeUserOp(callData: string = "0x"): HexString { + const userOp: PackedUserOperation = { + sender: "0x0000000000000000000000000000000000000001" as HexString, + nonce: 0n, + initCode: "0x" as HexString, + callData: callData as HexString, + accountGasLimits: "0x00000000000000000000000000007530000000000000000000000000000f4240" as HexString, + preVerificationGas: 21000n, + gasFees: "0x00000000000000000000000000000001000000000000000000000000000f4240" as HexString, + paymasterAndData: "0x" as HexString, + signature: "0x" as HexString, + } + return encodeUserOpScale(userOp) +} + +async function rpc(api: ApiPromise, method: string, params: any[] = []): Promise { + return (api as any)._rpcCore.provider.send(method, params) +} + +async function createBlock(api: ApiPromise): Promise { + const block = await rpc(api, "engine_createBlock", [true, false]) + await rpc(api, "engine_finalizeBlock", [block.hash]) +} + +async function submitAndSeal( + api: ApiPromise, + extrinsic: any, + signer: any, +): Promise<{ success: boolean; error?: string }> { + await extrinsic.signAsync(signer) + const txHash = extrinsic.hash.toHex() + await api.rpc.author.submitExtrinsic(extrinsic) + await createBlock(api) + const header = await api.rpc.chain.getHeader() + const apiAt = await api.at(header.hash) + const block = await api.rpc.chain.getBlock(header.hash) + const extrinsicIndex = block.block.extrinsics.findIndex((ext: any) => ext.hash.toHex() === txHash) + const events: any[] = (await apiAt.query.system.events()) as any + for (const { phase, event } of events) { + if ( + phase.isApplyExtrinsic && + phase.asApplyExtrinsic.toNumber() === extrinsicIndex && + event.section === "system" && + event.method === "ExtrinsicFailed" + ) { + return { success: false, error: `Dispatch error: ${event.data[0].toString()}` } + } + } + return { success: true } +} + +async function sudoAndSeal(api: ApiPromise, call: any): Promise { + const keyring = new Keyring({ type: "sr25519" }) + const alice = keyring.addFromUri("//Alice") + const sudoCall = api.tx.sudo.sudo(call) + const result = await submitAndSeal(api, sudoCall, alice) + if (!result.success) throw new Error(result.error || "sudo call failed") +} + +/** + * Submits a `set_phantom_order_config` governance call and seals a block. + * Uses a single token pair of zero-address tokens as a probe. + */ +async function setPhantomOrderConfig(api: ApiPromise, chainId: number, intervalBlocks: number): Promise { + const config = { + chain: { Evm: chainId }, + token_pairs: [ + { + token_a: "0x0101010101010101010101010101010101010101", + token_b: "0x0202020202020202020202020202020202020202", + standard_amount: 1_000_000_000_000_000_000n, + min_output: 900_000_000_000_000_000n, + }, + ], + interval_blocks: intervalBlocks, + } + await sudoAndSeal(api, api.tx.intentsCoprocessor.setPhantomOrderConfig(config)) +} + +/** + * Reads the active phantom commitment from `CurrentPhantomOrder` storage at + * the latest block. Returns null when the storage slot is empty. + * + * Storage layout (SCALE): `H256 (32 bytes) | u32 LE (4 bytes) | compact Vec` + */ +async function getActivePhantomCommitment(api: ApiPromise): Promise { + const storageKey = api.query.intentsCoprocessor.currentPhantomOrder.key() + const raw: any = await api.rpc.state.getStorage(storageKey) + if (!raw) return null + const hex: string = raw.toHex() + if (!hex || hex === "0x" || hex.length < 66) return null + return `0x${hex.slice(2, 66)}` as HexString +} + +describe("Phantom Order E2E (simnode)", () => { + let api: ApiPromise + let coprocessor: IntentsCoprocessor + let bobFiller: IntentsCoprocessor + let charlieFiller: IntentsCoprocessor + let daveFiller: IntentsCoprocessor + + beforeAll(async () => { + api = await ApiPromise.create({ + provider: new WsProvider(SIMNODE_URL), + typesBundle: { + spec: { + gargantua: { hasher: keccakAsU8a }, + }, + }, + }) + + coprocessor = IntentsCoprocessor.fromApi(api, "//Alice") + bobFiller = IntentsCoprocessor.fromApi(api, "//Bob") + charlieFiller = IntentsCoprocessor.fromApi(api, "//Charlie") + daveFiller = IntentsCoprocessor.fromApi(api, "//Dave") + + // Fund Charlie and Dave — gargantua-1000 genesis only includes Alice and Bob + const keyring = new Keyring({ type: "sr25519" }) + const alice = keyring.addFromUri("//Alice") + const charlieAddress = keyring.addFromUri("//Charlie").address + const daveAddress = keyring.addFromUri("//Dave").address + await submitAndSeal(api, api.tx.balances.transferKeepAlive(charlieAddress, 10_000_000_000_000_000_000n), alice) + await submitAndSeal(api, api.tx.balances.transferKeepAlive(daveAddress, 10_000_000_000_000_000_000n), alice) + + // Reset bid window to a generous default so tests start from a clean state. + await sudoAndSeal(api, api.tx.intentsCoprocessor.setPhantomBidWindow(100)) + }, 60_000) + + afterAll(async () => { + await api.disconnect() + }) + + it("setPhantomOrderConfig() triggers on_initialize which stores a commitment", async () => { + await setPhantomOrderConfig(api, 8453, 10) + + // on_initialize fires in the next block. + await createBlock(api) + + const storageKey = api.query.intentsCoprocessor.currentPhantomOrder.key() + const raw: any = await api.rpc.state.getStorage(storageKey) + expect(raw).not.toBeNull() + + const hex: string = raw.toHex() + expect(hex.length).toBeGreaterThanOrEqual(66) + + // bytes[36] = SCALE compact length of the chain bytes + const bytes = Buffer.from(hex.slice(2), "hex") + const chainLen = bytes[36] >> 2 + const storedChain = bytes.slice(37, 37 + chainLen).toString("utf8") + expect(storedChain).toBe("EVM-8453") + }, 30_000) + + it("submitBid() places a filler bid visible via getBidsForOrder()", async () => { + await setPhantomOrderConfig(api, 8453, 10) + await createBlock(api) + + const commitment = await getActivePhantomCommitment(api) + expect(commitment).not.toBeNull() + + const bidPromise = bobFiller.submitBid(commitment!, makeUserOp("0x0001")) + await new Promise((r) => setTimeout(r, 300)) + await createBlock(api) + const result = await bidPromise + + console.log("submitBid result:", result) + expect(result.success).toBe(true) + + const bids = await coprocessor.getBidsForOrder(commitment!) + console.log("bids:", bids.length, bids.map((b) => b.filler)) + expect(bids.length).toBe(1) + }, 60_000) + + it("multiple fillers can bid on the same phantom order", async () => { + await setPhantomOrderConfig(api, 8453, 10) + await createBlock(api) + + const commitment = await getActivePhantomCommitment(api) + expect(commitment).not.toBeNull() + + const bobPromise = bobFiller.submitBid(commitment!, makeUserOp("0x00bb")) + const charliePromise = charlieFiller.submitBid(commitment!, makeUserOp("0x00cc")) + await new Promise((r) => setTimeout(r, 300)) + await createBlock(api) + + const [bobResult, charlieResult] = await Promise.all([bobPromise, charliePromise]) + console.log("Bob bid result:", bobResult) + console.log("Charlie bid result:", charlieResult) + + expect(bobResult.success).toBe(true) + expect(charlieResult.success).toBe(true) + + const bids = await coprocessor.getBidsForOrder(commitment!) + console.log("bids count:", bids.length) + expect(bids.length).toBe(2) + }, 60_000) + + it("duplicate bid from same filler is rejected with DuplicatePhantomBid", async () => { + await setPhantomOrderConfig(api, 8453, 10) + await createBlock(api) + + const commitment = await getActivePhantomCommitment(api) + expect(commitment).not.toBeNull() + + // First bid from Bob — should succeed. + const firstPromise = bobFiller.submitBid(commitment!, makeUserOp("0x0001")) + await new Promise((r) => setTimeout(r, 300)) + await createBlock(api) + const firstResult = await firstPromise + expect(firstResult.success).toBe(true) + + // Second bid from the same account — should fail. + const dupPromise = bobFiller.submitBid(commitment!, makeUserOp("0x0002")) + await new Promise((r) => setTimeout(r, 300)) + await createBlock(api) + const dupResult = await dupPromise + + console.log("Duplicate bid result:", dupResult) + expect(dupResult.success).toBe(false) + expect(dupResult.error).toMatch(/DuplicatePhantomBid/i) + }, 60_000) + + it("bid is rejected after the bid window closes", async () => { + // Block N: set bid window to 1. + await sudoAndSeal(api, api.tx.intentsCoprocessor.setPhantomBidWindow(1)) + + // Block N+1: set config (on_initialize at N+1 has no config yet). + await setPhantomOrderConfig(api, 8453, 10) + + // Block N+2: on_initialize fires, phantom created at N+2. + await createBlock(api) + const commitment = await getActivePhantomCommitment(api) + expect(commitment).not.toBeNull() + + // Block N+3: advance empty block — window still open (N+3 <= N+2+1). + await createBlock(api) + + // Submit bid; it will be included in block N+4 (window closed: N+4 > N+3). + const bidPromise = bobFiller.submitBid(commitment!, makeUserOp("0x00ff")) + await new Promise((r) => setTimeout(r, 300)) + await createBlock(api) + const result = await bidPromise + + console.log("Post-window bid result:", result) + expect(result.success).toBe(false) + expect(result.error).toMatch(/PhantomOrderBidWindowClosed/i) + + // Reset window so subsequent tests are unaffected. + await sudoAndSeal(api, api.tx.intentsCoprocessor.setPhantomBidWindow(100)) + }, 60_000) + + it("on_initialize replaces the commitment on each interval", async () => { + // interval_blocks=1 means the hook re-fires every block. + await setPhantomOrderConfig(api, 8453, 1) + + await createBlock(api) + const c1 = await getActivePhantomCommitment(api) + expect(c1).not.toBeNull() + + await createBlock(api) + const c2 = await getActivePhantomCommitment(api) + expect(c2).not.toBeNull() + + expect(c1).not.toBe(c2) + }, 60_000) + + it("full flow: three fillers bid, all discoverable via getBidsForOrder", async () => { + await setPhantomOrderConfig(api, 8453, 10) + await createBlock(api) + + const commitment = await getActivePhantomCommitment(api) + expect(commitment).not.toBeNull() + + const bobPromise = bobFiller.submitBid(commitment!, makeUserOp("0x00bb")) + const charliePromise = charlieFiller.submitBid(commitment!, makeUserOp("0x00cc")) + const davePromise = daveFiller.submitBid(commitment!, makeUserOp("0x00dd")) + await new Promise((r) => setTimeout(r, 300)) + await createBlock(api) + + const [bobResult, charlieResult, daveResult] = await Promise.all([bobPromise, charliePromise, davePromise]) + console.log("Bob:", bobResult) + console.log("Charlie:", charlieResult) + console.log("Dave:", daveResult) + + expect(bobResult.success).toBe(true) + expect(charlieResult.success).toBe(true) + expect(daveResult.success).toBe(true) + + const bids = await coprocessor.getBidsForOrder(commitment!) + console.log("All bids count:", bids.length, bids.map((b) => b.filler)) + expect(bids.length).toBe(3) + }, 60_000) +})