From b0e038304715c94cf05bb1ee7bcfa01e006d7cea Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Wed, 11 Mar 2026 15:07:12 +0100 Subject: [PATCH 01/28] allow fillers to sumbmit pair price using membership and non membership proofs --- Cargo.lock | 1 + .../pallets/intents-coprocessor/Cargo.toml | 1 + .../pallets/intents-coprocessor/src/lib.rs | 283 ++++++++++++++- .../pallets/intents-coprocessor/src/tests.rs | 327 +++++++++++++++++- .../pallets/intents-coprocessor/src/types.rs | 51 +++ 5 files changed, 656 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7dcc802eb..2c00e13de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15069,6 +15069,7 @@ dependencies = [ "alloy-sol-macro 1.5.7", "alloy-sol-types 1.5.7", "anyhow", + "hex-literal 0.4.1", "ismp", "ismp-testsuite", "log", diff --git a/modules/pallets/intents-coprocessor/Cargo.toml b/modules/pallets/intents-coprocessor/Cargo.toml index 4d46096cf..0e406f929 100644 --- a/modules/pallets/intents-coprocessor/Cargo.toml +++ b/modules/pallets/intents-coprocessor/Cargo.toml @@ -28,6 +28,7 @@ anyhow = { workspace = true } alloy-primitives = { workspace = true } alloy-sol-macro = { workspace = true } alloy-sol-types = { workspace = true } +hex-literal = { workspace = true } ismp = { workspace = true } pallet-ismp = { workspace = true } diff --git a/modules/pallets/intents-coprocessor/src/lib.rs b/modules/pallets/intents-coprocessor/src/lib.rs index 962d73e22..6b5516e0f 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -33,16 +33,20 @@ use frame_support::{ }; use ismp::{ dispatcher::{DispatchPost, DispatchRequest, FeeMetadata, IsmpDispatcher}, - host::StateMachine, + host::{IsmpHost, StateMachine}, + messaging::Proof, }; use polkadot_sdk::*; -use primitive_types::{H160, H256}; +use primitive_types::{H160, H256, U256}; use sp_core::Get; use sp_io::offchain_index; use sp_runtime::traits::{ConstU32, Zero}; pub use weights::WeightInfo; -use types::{Bid, GatewayInfo, IntentGatewayParams, RequestKind, TokenDecimalsUpdate, TokenInfo}; +use types::{ + Bid, GatewayInfo, IntentGatewayParams, PriceAccumulator, RequestKind, TokenDecimalsUpdate, + TokenInfo, TokenPair, +}; // Re-export pallet items so that they can be accessed from the crate namespace. pub use pallet::*; @@ -119,6 +123,73 @@ pub mod pallet { pub type Gateways = StorageMap<_, Blake2_128Concat, StateMachine, GatewayInfo, OptionQuery>; + /// Recognized token pairs for price tracking + #[pallet::storage] + pub type RecognizedPairs = + StorageMap<_, Blake2_128Concat, H256, TokenPair, OptionQuery>; + + /// Current average price for each recognized token pair + #[pallet::storage] + pub type AveragePrice = StorageMap<_, Blake2_128Concat, H256, U256, ValueQuery>; + + /// Running price accumulator for each token pair in the current window + #[pallet::storage] + pub type PriceAccumulators = + StorageMap<_, Blake2_128Concat, H256, PriceAccumulator, ValueQuery>; + + /// Commitments that have already been used for price submissions + #[pallet::storage] + pub type UsedCommitments = StorageMap<_, Blake2_128Concat, H256, bool, ValueQuery>; + + /// Start timestamp (in seconds) of the current price window + #[pallet::storage] + pub type PriceWindowStart = StorageValue<_, u64, ValueQuery>; + + /// Price window duration in milliseconds + #[pallet::storage] + pub type PriceWindowDurationValue = StorageValue<_, u64, ValueQuery>; + + /// Proof freshness threshold in seconds + #[pallet::storage] + pub type ProofFreshnessThresholdValue = StorageValue<_, u64, ValueQuery>; + + #[pallet::hooks] + impl Hooks> for Pallet + where + T::AccountId: From<[u8; 32]>, + { + fn on_initialize(_n: BlockNumberFor) -> Weight { + let now = T::Dispatcher::default().timestamp().as_secs(); + let window_duration_secs = PriceWindowDurationValue::::get().saturating_div(1000); + + // Nothing to do if duration is not configured + if window_duration_secs == 0 { + return T::DbWeight::get().reads(2); + } + + let window_start = PriceWindowStart::::get(); + + if window_start == 0 || now.saturating_sub(window_start) >= window_duration_secs { + // New day, clear accumulators so today's average is computed fresh. + // AveragePrice is kept so yesterday's price remains readable until + // overwritten by the first submission of the new day. + // UsedCommitments are also cleared since the freshness threshold + // prevents old proofs from being replayed. + let acc_result = PriceAccumulators::::clear(u32::MAX, None); + let used_result = UsedCommitments::::clear(u32::MAX, None); + PriceWindowStart::::put(now); + + let cleared = + acc_result.unique.saturating_add(used_result.unique).saturating_add(1); + T::DbWeight::get() + .reads(3) + .saturating_add(T::DbWeight::get().writes(cleared.into())) + } else { + T::DbWeight::get().reads(3) + } + } + } + #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { @@ -147,6 +218,16 @@ pub mod pallet { }, /// Storage deposit fee was updated StorageDepositFeeUpdated { fee: BalanceOf }, + /// A recognized token pair was added + RecognizedPairAdded { pair_id: H256, pair: TokenPair }, + /// A recognized token pair was removed + RecognizedPairRemoved { pair_id: H256 }, + /// A price was submitted and the average updated + PriceSubmitted { filler: T::AccountId, pair_id: H256, price: U256, new_average: U256 }, + /// Price window duration was updated + PriceWindowDurationUpdated { duration_ms: u64 }, + /// Proof freshness threshold was updated + ProofFreshnessThresholdUpdated { threshold_secs: u64 }, } #[pallet::error] @@ -163,6 +244,22 @@ pub mod pallet { InvalidUserOp, /// Failed to dispatch cross-chain request DispatchFailed, + /// Token pair not recognized + PairNotRecognized, + /// Non-membership proof verification failed + NonMembershipProofFailed, + /// Membership proof verification failed + MembershipProofFailed, + /// The time gap between the two proofs exceeds the freshness threshold + ProofNotFresh, + /// State proof verification failed + ProofVerificationFailed, + /// Token pair already exists + PairAlreadyExists, + /// This commitment has already been used for a price submission + CommitmentAlreadyUsed, + /// The proof does not target the expected gateway contract + ProofContractMismatch, } #[pallet::call] @@ -443,6 +540,186 @@ pub mod pallet { Ok(()) } + + /// Submit a price for a recognized token pair, backed by proof of a filled order + /// + /// # Parameters + /// - `state_machine`: The state machine where the order was filled + /// - `commitment`: The filled order commitment hash + /// - `pair_id`: The token pair identifier + /// - `price`: The price of the base token in terms of the quote token + /// - `membership_proof`: Proof that the order was filled at some height + /// - `non_membership_proof`: Proof that the order was not filled at an earlier height + #[pallet::call_index(7)] + #[pallet::weight(T::DbWeight::get().reads(4).saturating_add(T::DbWeight::get().writes(3)))] + pub fn submit_pair_price( + origin: OriginFor, + state_machine: StateMachine, + commitment: H256, + pair_id: H256, + price: U256, + membership_proof: Proof, + non_membership_proof: Proof, + ) -> DispatchResult { + let filler = ensure_signed(origin)?; + + ensure!(RecognizedPairs::::contains_key(&pair_id), Error::::PairNotRecognized); + + // Prevent the same commitment from being used twice + ensure!(!UsedCommitments::::get(&commitment), Error::::CommitmentAlreadyUsed); + + let gateway_info = + Gateways::::get(state_machine).ok_or(Error::::GatewayNotFound)?; + + // 52-byte key: gateway address (20) + storage slot (32) + // This binds the proof to the specific gateway contract + let storage_key = types::filled_storage_key(&gateway_info.gateway, &commitment); + + // Get the ISMP host for proof verification + let host = T::Dispatcher::default(); + + // Get state commitments for both proof heights + let commitment_h1 = host + .state_machine_commitment(non_membership_proof.height) + .map_err(|_| Error::::ProofVerificationFailed)?; + let commitment_h2 = host + .state_machine_commitment(membership_proof.height) + .map_err(|_| Error::::ProofVerificationFailed)?; + + // Validate state machine clients + let state_machine_client_h1 = + ismp::handlers::validate_state_machine(&host, non_membership_proof.height) + .map_err(|_| Error::::ProofVerificationFailed)?; + let state_machine_client_h2 = + ismp::handlers::validate_state_machine(&host, membership_proof.height) + .map_err(|_| Error::::ProofVerificationFailed)?; + + // Verify non-membership proof: order was not filled at H1 + let non_membership_result = state_machine_client_h1 + .verify_state_proof( + &host, + vec![storage_key.clone()], + commitment_h1, + &non_membership_proof, + ) + .map_err(|_| Error::::NonMembershipProofFailed)?; + + let value_at_h1 = non_membership_result + .get(&storage_key) + .ok_or(Error::::NonMembershipProofFailed)?; + ensure!(value_at_h1.is_none(), Error::::NonMembershipProofFailed); + + // Verify membership proof: order was filled at H2 + let membership_result = state_machine_client_h2 + .verify_state_proof( + &host, + vec![storage_key.clone()], + commitment_h2, + &membership_proof, + ) + .map_err(|_| Error::::MembershipProofFailed)?; + + let filler_bytes = membership_result + .get(&storage_key) + .ok_or(Error::::MembershipProofFailed)? + .as_ref() + .ok_or(Error::::MembershipProofFailed)?; + + // Extract the filler address from the proof value + // EVM addresses are 20 bytes, left-padded to 32 bytes in storage + let filler_address = H160::from_slice( + filler_bytes.get(12..32).ok_or(Error::::MembershipProofFailed)?, + ); + ensure!(filler_address != H160::zero(), Error::::MembershipProofFailed); + + // Check proof freshness + let threshold = ProofFreshnessThresholdValue::::get(); + // The two proofs must bracket a narrow window around the fill + let proof_gap = commitment_h2.timestamp.saturating_sub(commitment_h1.timestamp); + ensure!(proof_gap <= threshold, Error::::ProofNotFresh); + // The fill must be recent relative to now (prevents replay) + let now = host.timestamp().as_secs(); + let age = now.saturating_sub(commitment_h2.timestamp); + ensure!(age <= threshold, Error::::ProofNotFresh); + + // Mark commitment as used + UsedCommitments::::insert(&commitment, true); + + // Update accumulator and compute new average (window reset happens in on_initialize) + let new_average = PriceAccumulators::::mutate(&pair_id, |acc| { + acc.sum = acc.sum.saturating_add(price); + acc.count = acc.count.saturating_add(1); + // count is always >= 1 after the increment above, so division is safe + acc.sum / U256::from(acc.count) + }); + AveragePrice::::insert(&pair_id, new_average); + + Self::deposit_event(Event::PriceSubmitted { filler, pair_id, price, new_average }); + + Ok(()) + } + + /// Add a recognized token pair for price tracking + #[pallet::call_index(8)] + #[pallet::weight(T::DbWeight::get().reads(1).saturating_add(T::DbWeight::get().writes(1)))] + pub fn add_recognized_pair(origin: OriginFor, pair: TokenPair) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + + let pair_id = pair.pair_id(); + ensure!(!RecognizedPairs::::contains_key(&pair_id), Error::::PairAlreadyExists); + + RecognizedPairs::::insert(&pair_id, &pair); + + Self::deposit_event(Event::RecognizedPairAdded { pair_id, pair }); + + Ok(()) + } + + /// Remove a recognized token pair + #[pallet::call_index(9)] + #[pallet::weight(T::DbWeight::get().reads(1).saturating_add(T::DbWeight::get().writes(3)))] + pub fn remove_recognized_pair(origin: OriginFor, pair_id: H256) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + + ensure!(RecognizedPairs::::contains_key(&pair_id), Error::::PairNotRecognized); + + RecognizedPairs::::remove(&pair_id); + AveragePrice::::remove(&pair_id); + PriceAccumulators::::remove(&pair_id); + + Self::deposit_event(Event::RecognizedPairRemoved { pair_id }); + + Ok(()) + } + + /// Set the price window duration + #[pallet::call_index(10)] + #[pallet::weight(T::DbWeight::get().writes(1))] + pub fn set_price_window_duration(origin: OriginFor, duration_ms: u64) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + + PriceWindowDurationValue::::put(duration_ms); + + Self::deposit_event(Event::PriceWindowDurationUpdated { duration_ms }); + + Ok(()) + } + + /// Set the proof freshness threshold + #[pallet::call_index(11)] + #[pallet::weight(T::DbWeight::get().writes(1))] + pub fn set_proof_freshness_threshold( + origin: OriginFor, + threshold_secs: u64, + ) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + + ProofFreshnessThresholdValue::::put(threshold_secs); + + Self::deposit_event(Event::ProofFreshnessThresholdUpdated { threshold_secs }); + + Ok(()) + } } impl Pallet diff --git a/modules/pallets/intents-coprocessor/src/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index 08a3f757a..f599476f7 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -18,14 +18,23 @@ #![cfg(test)] use crate::{self as pallet_intents, *}; -use alloc::vec; +use alloc::{boxed::Box, collections::BTreeMap, vec}; use frame_support::{ assert_noop, assert_ok, parameter_types, - traits::{ConstU32, Everything}, + traits::{ConstU32, Everything, Hooks}, BoundedVec, }; use frame_system::EnsureRoot; -use ismp::host::StateMachine; +use ismp::{ + consensus::{ + ConsensusClient, ConsensusClientId, ConsensusStateId, StateCommitment, StateMachineClient, + StateMachineHeight, StateMachineId, VerifiedCommitments, + }, + error::Error as IsmpError, + host::{IsmpHost, StateMachine}, + messaging::Proof, + router::RequestResponse, +}; use ismp_testsuite::mocks::MockRouter; use polkadot_sdk::*; @@ -36,6 +45,100 @@ use sp_runtime::{ AccountId32, BuildStorage, }; +/// Mock consensus client ID +const MOCK_CONSENSUS_CLIENT_ID: ConsensusClientId = [1u8; 4]; +/// Mock consensus state ID +const MOCK_CONSENSUS_STATE_ID: ConsensusStateId = *b"ETH0"; + +/// Height used for the non-membership proof (order not yet filled) +const H1_HEIGHT: u64 = 100; +/// Height used for the membership proof (order was filled) +const H2_HEIGHT: u64 = 200; + +/// A mock consensus client for testing `submit_pair_price`. +/// +/// Returns `MockPriceStateMachineClient` which encodes test behavior: +/// - At H1 (non-membership): returns `{key: None}` for storage queries +/// - At H2 (membership): returns `{key: Some(filler_address)}` from proof bytes +#[derive(Default)] +pub struct MockPriceConsensusClient; + +impl ConsensusClient for MockPriceConsensusClient { + fn verify_consensus( + &self, + _host: &dyn IsmpHost, + _consensus_state_id: ConsensusStateId, + _trusted_consensus_state: Vec, + _proof: Vec, + ) -> Result<(Vec, VerifiedCommitments), IsmpError> { + Ok(Default::default()) + } + + fn verify_fraud_proof( + &self, + _host: &dyn IsmpHost, + _trusted_consensus_state: Vec, + _proof_1: Vec, + _proof_2: Vec, + ) -> Result<(), IsmpError> { + Ok(()) + } + + fn consensus_client_id(&self) -> ConsensusClientId { + MOCK_CONSENSUS_CLIENT_ID + } + + fn state_machine(&self, _id: StateMachine) -> Result, IsmpError> { + Ok(Box::new(MockPriceStateMachineClient)) + } +} + +/// Mock state machine client that returns different results based on proof height. +/// +/// - Height == H1_HEIGHT: returns `{key: None}` (non-membership) +/// - Height == H2_HEIGHT: returns `{key: Some(proof_bytes)}` (membership, proof bytes = filler +/// address) +pub struct MockPriceStateMachineClient; + +impl StateMachineClient for MockPriceStateMachineClient { + fn verify_membership( + &self, + _host: &dyn IsmpHost, + _item: RequestResponse, + _root: StateCommitment, + _proof: &Proof, + ) -> Result<(), IsmpError> { + Ok(()) + } + + fn receipts_state_trie_key(&self, _request: RequestResponse) -> Vec> { + Default::default() + } + + fn verify_state_proof( + &self, + _host: &dyn IsmpHost, + keys: Vec>, + _root: StateCommitment, + proof: &Proof, + ) -> Result, Option>>, IsmpError> { + let mut result = BTreeMap::new(); + let is_non_membership = proof.height.height == H1_HEIGHT; + + for key in keys { + if is_non_membership { + // Non-membership: value not present + result.insert(key, None); + } else { + // Membership: value is the proof bytes (filler address padded to 32 bytes) + result.insert(key, Some(proof.proof.clone())); + } + } + + Ok(result) + } +} + type Block = frame_system::mocking::MockBlock; type Balance = u64; type AccountId = AccountId32; @@ -128,7 +231,7 @@ impl pallet_ismp::Config for Test { type Router = MockRouter; type Balance = Balance; type Currency = Balances; - type ConsensusClients = (); + type ConsensusClients = (MockPriceConsensusClient,); type OffchainDB = (); type FeeHandler = (); } @@ -163,6 +266,10 @@ pub fn new_test_ext() -> sp_io::TestExternalities { let mut ext: sp_io::TestExternalities = t.into(); ext.execute_with(|| { pallet_intents::StorageDepositFee::::put(200u64); + // 24 hours in milliseconds + pallet_intents::PriceWindowDurationValue::::put(86_400_000u64); + // 1 hour in seconds + pallet_intents::ProofFreshnessThresholdValue::::put(3600u64); }); ext } @@ -533,3 +640,215 @@ fn multiple_fillers_can_bid_on_same_order() { assert!(Bids::::contains_key(&commitment, &filler2)); }); } + +#[test] +fn remove_recognized_pair_works() { + new_test_ext().execute_with(|| { + let pair = + types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + let pair_id = pair.pair_id(); + + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + + PriceAccumulators::::insert( + &pair_id, + types::PriceAccumulator { sum: U256::from(1000), count: 5 }, + ); + AveragePrice::::insert(&pair_id, U256::from(200)); + + assert_ok!(Intents::remove_recognized_pair(RuntimeOrigin::root(), pair_id)); + + // Verify clean up + assert!(RecognizedPairs::::get(&pair_id).is_none()); + assert_eq!(AveragePrice::::get(&pair_id), U256::zero()); + assert_eq!(PriceAccumulators::::get(&pair_id).count, 0); + }); +} + +#[test] +fn submit_pair_price() { + new_test_ext().execute_with(|| { + let filler = AccountId32::new([1; 32]); + let state_machine = StateMachine::Evm(1); + let commitment = H256::repeat_byte(0xaa); + let price = U256::from(2000); + + // Add a recognized token pair + let pair = + types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + let pair_id = pair.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + + // Add a gateway deployment + let gateway = H160::from_low_u64_be(42); + let params = types::IntentGatewayParams { + host: H160::default(), + dispatcher: H160::default(), + solver_selection: true, + surplus_share_bps: U256::from(5000), + protocol_fee_bps: U256::from(100), + price_oracle: H160::default(), + }; + assert_ok!(Intents::add_deployment(RuntimeOrigin::root(), state_machine, gateway, params,)); + + // Set up ISMP consensus state + let sm_id = + StateMachineId { state_id: state_machine, consensus_state_id: MOCK_CONSENSUS_STATE_ID }; + let h1 = StateMachineHeight { id: sm_id, height: H1_HEIGHT }; + let h2 = StateMachineHeight { id: sm_id, height: H2_HEIGHT }; + + // Map consensus_state_id -> consensus_client_id + pallet_ismp::ConsensusStateClient::::insert( + MOCK_CONSENSUS_STATE_ID, + MOCK_CONSENSUS_CLIENT_ID, + ); + + // Store consensus state + pallet_ismp::ConsensusStates::::insert(MOCK_CONSENSUS_CLIENT_ID, vec![0u8]); + + // Store state commitments at both heights + // H1 timestamp=1000, H2 timestamp=1050 + pallet_ismp::child_trie::StateCommitments::::insert( + h1, + StateCommitment { + timestamp: 1000, + overlay_root: None, + state_root: H256::repeat_byte(0x11).into(), + }, + ); + pallet_ismp::child_trie::StateCommitments::::insert( + h2, + StateCommitment { + timestamp: 1050, + overlay_root: None, + state_root: H256::repeat_byte(0x22).into(), + }, + ); + + // Store challenge period + pallet_ismp::ChallengePeriod::::insert(sm_id, 0u64); + + // Store state machine update times + pallet_ismp::StateMachineUpdateTime::::insert(h1, 1000u64); + pallet_ismp::StateMachineUpdateTime::::insert(h2, 1050u64); + + // Set the pallet timestamp so host.timestamp() + pallet_timestamp::Now::::put(2_000_000u64); // 2000 seconds in ms + + // Build proofs + // Non-membership proof: all zeros + let non_membership_proof = Proof { height: h1, proof: vec![0u8; 32] }; + + // Membership proof: proof bytes contain the filler address padded to 32 bytes + // The code reads filler_bytes[12..32] as an H160 address + let mut filler_bytes = vec![0u8; 32]; + // Put a non-zero address in bytes 12..32 + filler_bytes[12..32].copy_from_slice(&H160::from_low_u64_be(0xdeadbeef).0); + let membership_proof = Proof { height: h2, proof: filler_bytes }; + + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(filler.clone()), + state_machine, + commitment, + pair_id, + price, + membership_proof, + non_membership_proof, + )); + + // Verify the price accumulator was updated + let acc = PriceAccumulators::::get(&pair_id); + assert_eq!(acc.sum, price); + assert_eq!(acc.count, 1); + + // Verify the average price was stored + let avg = AveragePrice::::get(&pair_id); + assert_eq!(avg, price); // With 1 submission, average = price + + // Submit a second price and verify the average updates + let price2 = U256::from(4000); + let commitment2 = H256::repeat_byte(0xbb); + + let non_membership_proof_2 = Proof { height: h1, proof: vec![0u8; 32] }; + let mut filler_bytes_2 = vec![0u8; 32]; + filler_bytes_2[12..32].copy_from_slice(&H160::from_low_u64_be(0xcafebabe).0); + let membership_proof_2 = Proof { height: h2, proof: filler_bytes_2 }; + + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(filler.clone()), + state_machine, + commitment2, + pair_id, + price2, + membership_proof_2, + non_membership_proof_2, + )); + + let acc2 = PriceAccumulators::::get(&pair_id); + assert_eq!(acc2.sum, price.saturating_add(price2)); + assert_eq!(acc2.count, 2); + + let avg2 = AveragePrice::::get(&pair_id); + assert_eq!(avg2, U256::from(3000)); + + // 9. Reusing the same commitment should fail + let non_membership_proof_dup = Proof { height: h1, proof: vec![0u8; 32] }; + let mut filler_bytes_dup = vec![0u8; 32]; + filler_bytes_dup[12..32].copy_from_slice(&H160::from_low_u64_be(0xdeadbeef).0); + let membership_proof_dup = Proof { height: h2, proof: filler_bytes_dup }; + + assert_noop!( + Intents::submit_pair_price( + RuntimeOrigin::signed(filler.clone()), + state_machine, + commitment, // same commitment as step 5 + pair_id, + U256::from(9999), + membership_proof_dup, + non_membership_proof_dup, + ), + Error::::CommitmentAlreadyUsed + ); + }); +} + +#[test] +fn on_initialize_resets_accumulators_on_new_day() { + new_test_ext().execute_with(|| { + let pair = + types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + let pair_id = pair.pair_id(); + + PriceAccumulators::::insert( + &pair_id, + types::PriceAccumulator { sum: U256::from(5000), count: 3 }, + ); + AveragePrice::::insert(&pair_id, U256::from(1666)); + + // Window started at second 1000, duration is 86_400_000 ms = 86_400 s + PriceWindowStart::::put(1000u64); + + pallet_timestamp::Now::::put(50_000_000u64); // 50_000 seconds in ms + Intents::on_initialize(1u64); + + // Window has NOT expired, accumulators and average should be untouched + let acc = PriceAccumulators::::get(&pair_id); + assert_eq!(acc.count, 3); + assert_eq!(acc.sum, U256::from(5000)); + assert_eq!(AveragePrice::::get(&pair_id), U256::from(1666)); + + // Advance past the window (1000 + 86_400 = 87_400 seconds) + pallet_timestamp::Now::::put(90_000_000u64); // 90_000 seconds in ms + Intents::on_initialize(2u64); + + // Window expired, accumulators should be cleared but average price + // from yesterday is preserved (readable until overwritten by new submissions) + let acc = PriceAccumulators::::get(&pair_id); + assert_eq!(acc.count, 0); + assert_eq!(acc.sum, U256::zero()); + assert_eq!(AveragePrice::::get(&pair_id), U256::from(1666)); + + // Window start should be updated + assert_eq!(PriceWindowStart::::get(), 90_000); + }); +} diff --git a/modules/pallets/intents-coprocessor/src/types.rs b/modules/pallets/intents-coprocessor/src/types.rs index 8c773acff..7876a0c27 100644 --- a/modules/pallets/intents-coprocessor/src/types.rs +++ b/modules/pallets/intents-coprocessor/src/types.rs @@ -144,6 +144,57 @@ pub struct Bid { pub user_op: Vec, } +/// A recognized token pair for price tracking +#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] +pub struct TokenPair { + /// The base token address + pub base: H160, + /// The quote token address + pub quote: H160, +} + +impl TokenPair { + /// Compute a unique identifier for this token pair + pub fn pair_id(&self) -> H256 { + let mut data = alloc::vec::Vec::with_capacity(40); + data.extend_from_slice(&self.base.0); + data.extend_from_slice(&self.quote.0); + sp_io::hashing::keccak_256(&data).into() + } +} + +/// Running price accumulator for a token pair within the current time window +#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq, Default)] +pub struct PriceAccumulator { + /// Sum of all submitted prices in the current window + pub sum: U256, + /// Number of submissions in the current window + pub count: u32, +} + +/// The storage slot index for the `_filled` mapping in IntentGateway.sol +pub const FILLED_SLOT: [u8; 32] = + hex_literal::hex!("0000000000000000000000000000000000000000000000000000000000000005"); + +/// Compute the EVM state proof key for `_filled[commitment]` on the given gateway contract. +/// +/// Returns a 52-byte key: 20-byte contract address + 32-byte storage slot. +/// The EVM state machine client uses the first 20 bytes to locate the contract +/// and hashes the last 32 bytes to derive the storage trie key. +pub fn filled_storage_key(gateway: &H160, commitment: &H256) -> Vec { + // Compute the raw storage slot: keccak256(commitment ++ FILLED_SLOT) + let mut slot_preimage = Vec::with_capacity(64); + slot_preimage.extend_from_slice(commitment.as_bytes()); + slot_preimage.extend_from_slice(&FILLED_SLOT); + let slot = sp_io::hashing::keccak_256(&slot_preimage); + + // 52-byte key: gateway address (20) + slot (32) + let mut key = Vec::with_capacity(52); + key.extend_from_slice(&gateway.0); + key.extend_from_slice(&slot); + key +} + impl IntentGatewayParams { /// Apply an update to the current parameters, returning a new instance pub fn update(&self, update: ParamsUpdate) -> Self { From fad61b626a9a435ac15cedc72896a039dcfdc930 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Wed, 11 Mar 2026 15:32:23 +0100 Subject: [PATCH 02/28] vec --- modules/pallets/intents-coprocessor/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/pallets/intents-coprocessor/src/lib.rs b/modules/pallets/intents-coprocessor/src/lib.rs index 6b5516e0f..1cf73dc4f 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -65,6 +65,7 @@ pub fn offchain_bid_key_raw(commitment: &H256, filler_encoded: &[u8]) -> Vec #[frame_support::pallet] pub mod pallet { use super::*; + use alloc::vec; use crate::alloc::string::ToString; use frame_support::pallet_prelude::*; use frame_system::pallet_prelude::*; From 004dd9c7f0f0a5dd45da3363e6121c71cd8b85d4 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Thu, 12 Mar 2026 14:28:22 +0100 Subject: [PATCH 03/28] dual-path price submission system for verified prices and unverified prices --- Cargo.lock | 2 + .../pallets/intents-coprocessor/Cargo.toml | 2 + .../intents-coprocessor/rpc/Cargo.toml | 1 + .../intents-coprocessor/rpc/src/lib.rs | 70 +++ .../pallets/intents-coprocessor/src/lib.rs | 448 +++++++++++++----- .../pallets/intents-coprocessor/src/tests.rs | 390 ++++++++++++--- .../pallets/intents-coprocessor/src/types.rs | 46 +- 7 files changed, 751 insertions(+), 208 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 03b334ed3..cfd985ce9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15069,6 +15069,7 @@ dependencies = [ "alloy-sol-macro 1.5.7", "alloy-sol-types 1.5.7", "anyhow", + "crypto-utils", "hex-literal 0.4.1", "ismp", "ismp-testsuite", @@ -15092,6 +15093,7 @@ dependencies = [ "pallet-intents-coprocessor", "parity-scale-codec", "polkadot-sdk", + "primitive-types 0.13.1", "serde", "tokio", ] diff --git a/modules/pallets/intents-coprocessor/Cargo.toml b/modules/pallets/intents-coprocessor/Cargo.toml index 0e406f929..da1c7a3f7 100644 --- a/modules/pallets/intents-coprocessor/Cargo.toml +++ b/modules/pallets/intents-coprocessor/Cargo.toml @@ -30,6 +30,7 @@ alloy-sol-macro = { workspace = true } alloy-sol-types = { workspace = true } hex-literal = { workspace = true } +crypto-utils = { workspace = true } ismp = { workspace = true } pallet-ismp = { workspace = true } @@ -50,6 +51,7 @@ std = [ "scale-info/std", "anyhow/std", "alloy-primitives/std", + "crypto-utils/std", "sp-io/std", "pallet-ismp/std", ] diff --git a/modules/pallets/intents-coprocessor/rpc/Cargo.toml b/modules/pallets/intents-coprocessor/rpc/Cargo.toml index 80ab88052..bfaa59a30 100644 --- a/modules/pallets/intents-coprocessor/rpc/Cargo.toml +++ b/modules/pallets/intents-coprocessor/rpc/Cargo.toml @@ -13,6 +13,7 @@ log = { workspace = true, default-features = true } tokio = { workspace = true, features = ["sync", "time"] } futures = { workspace = true } hex = { workspace = true, default-features = true } +primitive-types = { workspace = true, default-features = true } pallet-intents-coprocessor = { workspace = true, default-features = true } [dependencies.polkadot-sdk] diff --git a/modules/pallets/intents-coprocessor/rpc/src/lib.rs b/modules/pallets/intents-coprocessor/rpc/src/lib.rs index 1154a4d4f..e65cf2415 100644 --- a/modules/pallets/intents-coprocessor/rpc/src/lib.rs +++ b/modules/pallets/intents-coprocessor/rpc/src/lib.rs @@ -53,6 +53,27 @@ pub struct RpcBidInfo { pub user_op: Vec, } +/// A single price entry returned by the RPC +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +pub struct RpcPriceEntry { + /// The submitter's encoded account + #[serde(with = "hex_bytes")] + pub submitter: Vec, + /// The submitted price as a hex-encoded U256 + pub price: String, + /// Timestamp of submission (seconds) + pub timestamp: u64, +} + +/// Response for the `intents_getPairPrices` RPC method +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +pub struct RpcPairPrices { + /// High confidence prices (from verified fillers with proofs) + pub verified: Vec, + /// Low confidence prices (from unverified submitters) + pub unverified: Vec, +} + impl Ord for RpcBidInfo { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.filler.cmp(&other.filler) @@ -158,6 +179,17 @@ fn runtime_error_into_rpc_error(e: impl std::fmt::Display) -> ErrorObjectOwned { ErrorObject::owned(9877, format!("{e}"), None::) } +/// Construct the full storage key for a `StorageMap` entry with `Blake2_128Concat` hasher. +fn storage_map_key(pallet: &[u8], storage: &[u8], map_key: &H256) -> Vec { + let mut key = Vec::new(); + key.extend_from_slice(&sp_core::hashing::twox_128(pallet)); + key.extend_from_slice(&sp_core::hashing::twox_128(storage)); + let map_key_bytes = map_key.as_bytes(); + key.extend_from_slice(&sp_core::hashing::blake2_128(map_key_bytes)); + key.extend_from_slice(map_key_bytes); + key +} + /// Construct the storage key prefix for iterating all fillers in the on-chain /// `Bids` double-map for a given order commitment. fn bids_storage_prefix(commitment: &H256) -> Vec { @@ -176,6 +208,10 @@ pub trait IntentsApi { #[method(name = "intents_getBidsForOrder")] fn get_bids_for_order(&self, commitment: H256) -> RpcResult>; + /// Get all prices for a token pair, separated by confidence level + #[method(name = "intents_getPairPrices")] + fn get_pair_prices(&self, pair_id: H256) -> RpcResult; + #[subscription(name = "intents_subscribeBids" => "intents_bidNotification", unsubscribe = "intents_unsubscribeBids", item = RpcBidInfo)] async fn subscribe_bids(&self, commitment: Option) -> SubscriptionResult; } @@ -259,6 +295,40 @@ where Ok(bids.into_iter().collect()) } + fn get_pair_prices(&self, pair_id: H256) -> RpcResult { + let best_hash = self.client.info().best_hash; + + let decode_entries = |storage_name: &[u8]| -> Vec { + let key = storage_map_key(b"IntentsCoprocessor", storage_name, &pair_id); + let storage_key = sp_core::storage::StorageKey(key); + + let data = match self.client.storage(best_hash, &storage_key) { + Ok(Some(data)) => data.0, + _ => return Vec::new(), + }; + + // Decode Vec> + // PriceEntry SCALE-encodes as (AccountId32(32 bytes), U256(32 bytes), u64(8 bytes)) + type Entry = (sp_runtime::AccountId32, primitive_types::U256, u64); + match Vec::::decode(&mut &data[..]) { + Ok(entries) => entries + .into_iter() + .map(|(submitter, price, timestamp)| RpcPriceEntry { + submitter: submitter.encode(), + price: format!("0x{price:x}"), + timestamp, + }) + .collect(), + Err(_) => Vec::new(), + } + }; + + Ok(RpcPairPrices { + verified: decode_entries(b"VerifiedPrices"), + unverified: decode_entries(b"UnverifiedPrices"), + }) + } + async fn subscribe_bids( &self, pending: PendingSubscriptionSink, diff --git a/modules/pallets/intents-coprocessor/src/lib.rs b/modules/pallets/intents-coprocessor/src/lib.rs index 1cf73dc4f..9fd54f155 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -44,8 +44,8 @@ use sp_runtime::traits::{ConstU32, Zero}; pub use weights::WeightInfo; use types::{ - Bid, GatewayInfo, IntentGatewayParams, PriceAccumulator, RequestKind, TokenDecimalsUpdate, - TokenInfo, TokenPair, + Bid, GatewayInfo, IntentGatewayParams, PriceEntry, RequestKind, TokenDecimalsUpdate, TokenInfo, + TokenPair, }; // Re-export pallet items so that they can be accessed from the crate namespace. @@ -91,6 +91,9 @@ pub mod pallet { /// Origin that can perform governance actions type GovernanceOrigin: EnsureOrigin; + /// The treasury account that receives unverified submission fees + type TreasuryAccount: Get; + /// Weight information for extrinsics in this pallet type WeightInfo: WeightInfo; } @@ -129,15 +132,6 @@ pub mod pallet { pub type RecognizedPairs = StorageMap<_, Blake2_128Concat, H256, TokenPair, OptionQuery>; - /// Current average price for each recognized token pair - #[pallet::storage] - pub type AveragePrice = StorageMap<_, Blake2_128Concat, H256, U256, ValueQuery>; - - /// Running price accumulator for each token pair in the current window - #[pallet::storage] - pub type PriceAccumulators = - StorageMap<_, Blake2_128Concat, H256, PriceAccumulator, ValueQuery>; - /// Commitments that have already been used for price submissions #[pallet::storage] pub type UsedCommitments = StorageMap<_, Blake2_128Concat, H256, bool, ValueQuery>; @@ -154,6 +148,36 @@ pub mod pallet { #[pallet::storage] pub type ProofFreshnessThresholdValue = StorageValue<_, u64, ValueQuery>; + /// Verified (high confidence) price entries per pair, from fillers with proofs + #[pallet::storage] + pub type VerifiedPrices = + StorageMap<_, Blake2_128Concat, H256, Vec>, ValueQuery>; + + /// Unverified (low confidence) price entries per pair, from anyone without proofs + #[pallet::storage] + pub type UnverifiedPrices = + StorageMap<_, Blake2_128Concat, H256, Vec>, ValueQuery>; + + /// Nonces for EVM addresses to prevent signature replay. + /// Keyed by the 20-byte EVM address. + #[pallet::storage] + pub type EvmNonces = + StorageMap<_, Blake2_128Concat, H160, u64, ValueQuery>; + + /// Maximum number of unverified price submissions per pair + #[pallet::storage] + pub type MaxUnverifiedSubmissions = StorageValue<_, u32, ValueQuery>; + + /// Fee charged for unverified price submissions (in native/bridge tokens) + #[pallet::storage] + pub type UnverifiedSubmissionFee = StorageValue<_, BalanceOf, ValueQuery>; + + /// Whether prices have been cleared in the current window. + /// Reset to false by `on_initialize` when a new window starts. + /// Set to true on the first price submission in the new window. + #[pallet::storage] + pub type PricesClearedThisWindow = StorageValue<_, bool, ValueQuery>; + #[pallet::hooks] impl Hooks> for Pallet where @@ -171,17 +195,16 @@ pub mod pallet { let window_start = PriceWindowStart::::get(); if window_start == 0 || now.saturating_sub(window_start) >= window_duration_secs { - // New day, clear accumulators so today's average is computed fresh. - // AveragePrice is kept so yesterday's price remains readable until - // overwritten by the first submission of the new day. - // UsedCommitments are also cleared since the freshness threshold - // prevents old proofs from being replayed. - let acc_result = PriceAccumulators::::clear(u32::MAX, None); + // New window: clear UsedCommitments (safe because freshness threshold + // prevents old proofs from being replayed) and update the window start. + // Price entries (VerifiedPrices, UnverifiedPrices) are NOT cleared here — + // they persist so yesterday's prices remain readable. They are cleared + // lazily on the first submission per pair in the new window. let used_result = UsedCommitments::::clear(u32::MAX, None); PriceWindowStart::::put(now); + PricesClearedThisWindow::::put(false); - let cleared = - acc_result.unique.saturating_add(used_result.unique).saturating_add(1); + let cleared = used_result.unique.saturating_add(1); T::DbWeight::get() .reads(3) .saturating_add(T::DbWeight::get().writes(cleared.into())) @@ -223,12 +246,18 @@ pub mod pallet { RecognizedPairAdded { pair_id: H256, pair: TokenPair }, /// A recognized token pair was removed RecognizedPairRemoved { pair_id: H256 }, - /// A price was submitted and the average updated - PriceSubmitted { filler: T::AccountId, pair_id: H256, price: U256, new_average: U256 }, + /// A verified price was submitted (high confidence) + PriceSubmitted { filler: T::AccountId, pair_id: H256, price: U256 }, /// Price window duration was updated PriceWindowDurationUpdated { duration_ms: u64 }, /// Proof freshness threshold was updated ProofFreshnessThresholdUpdated { threshold_secs: u64 }, + /// An unverified price was submitted (low confidence) + UnverifiedPriceSubmitted { submitter: T::AccountId, pair_id: H256, price: U256 }, + /// Max unverified submissions per pair was updated + MaxUnverifiedSubmissionsUpdated { max: u32 }, + /// Unverified submission fee was updated + UnverifiedSubmissionFeeUpdated { fee: BalanceOf }, } #[pallet::error] @@ -259,8 +288,12 @@ pub mod pallet { PairAlreadyExists, /// This commitment has already been used for a price submission CommitmentAlreadyUsed, - /// The proof does not target the expected gateway contract - ProofContractMismatch, + /// EVM signature verification failed + InvalidSignature, + /// The recovered EVM address does not match the filler from the proof + SignerMismatch, + /// Unverified submissions are not configured (max or fee is zero) + UnverifiedSubmissionsNotConfigured, } #[pallet::call] @@ -542,120 +575,38 @@ pub mod pallet { Ok(()) } - /// Submit a price for a recognized token pair, backed by proof of a filled order + /// Submit a price for a recognized token pair. /// - /// # Parameters - /// - `state_machine`: The state machine where the order was filled - /// - `commitment`: The filled order commitment hash - /// - `pair_id`: The token pair identifier - /// - `price`: The price of the base token in terms of the quote token - /// - `membership_proof`: Proof that the order was filled at some height - /// - `non_membership_proof`: Proof that the order was not filled at an earlier height + /// This extrinsic supports two submission modes. When `verification` is provided, + /// the submission is treated as high confidence: the filler supplies ISMP state + /// proofs and an EVM signature to prove they filled the order and own the + /// submitting account (bound by an on-chain nonce). When `verification` is + /// `None`, anyone may submit a low confidence price by paying bridge tokens. + /// Unverified entries are capped per pair with FIFO replacement. + /// + /// The `pair_id` identifies the token pair, and `price` is the price of the base + /// token in terms of the quote token. #[pallet::call_index(7)] - #[pallet::weight(T::DbWeight::get().reads(4).saturating_add(T::DbWeight::get().writes(3)))] + #[pallet::weight({ + T::DbWeight::get().reads(12).saturating_add(T::DbWeight::get().writes(4)) + })] pub fn submit_pair_price( origin: OriginFor, - state_machine: StateMachine, - commitment: H256, pair_id: H256, price: U256, - membership_proof: Proof, - non_membership_proof: Proof, + verification: Option, ) -> DispatchResult { - let filler = ensure_signed(origin)?; + let submitter = ensure_signed(origin)?; ensure!(RecognizedPairs::::contains_key(&pair_id), Error::::PairNotRecognized); - // Prevent the same commitment from being used twice - ensure!(!UsedCommitments::::get(&commitment), Error::::CommitmentAlreadyUsed); - - let gateway_info = - Gateways::::get(state_machine).ok_or(Error::::GatewayNotFound)?; - - // 52-byte key: gateway address (20) + storage slot (32) - // This binds the proof to the specific gateway contract - let storage_key = types::filled_storage_key(&gateway_info.gateway, &commitment); - - // Get the ISMP host for proof verification - let host = T::Dispatcher::default(); - - // Get state commitments for both proof heights - let commitment_h1 = host - .state_machine_commitment(non_membership_proof.height) - .map_err(|_| Error::::ProofVerificationFailed)?; - let commitment_h2 = host - .state_machine_commitment(membership_proof.height) - .map_err(|_| Error::::ProofVerificationFailed)?; - - // Validate state machine clients - let state_machine_client_h1 = - ismp::handlers::validate_state_machine(&host, non_membership_proof.height) - .map_err(|_| Error::::ProofVerificationFailed)?; - let state_machine_client_h2 = - ismp::handlers::validate_state_machine(&host, membership_proof.height) - .map_err(|_| Error::::ProofVerificationFailed)?; - - // Verify non-membership proof: order was not filled at H1 - let non_membership_result = state_machine_client_h1 - .verify_state_proof( - &host, - vec![storage_key.clone()], - commitment_h1, - &non_membership_proof, - ) - .map_err(|_| Error::::NonMembershipProofFailed)?; - - let value_at_h1 = non_membership_result - .get(&storage_key) - .ok_or(Error::::NonMembershipProofFailed)?; - ensure!(value_at_h1.is_none(), Error::::NonMembershipProofFailed); - - // Verify membership proof: order was filled at H2 - let membership_result = state_machine_client_h2 - .verify_state_proof( - &host, - vec![storage_key.clone()], - commitment_h2, - &membership_proof, - ) - .map_err(|_| Error::::MembershipProofFailed)?; - - let filler_bytes = membership_result - .get(&storage_key) - .ok_or(Error::::MembershipProofFailed)? - .as_ref() - .ok_or(Error::::MembershipProofFailed)?; - - // Extract the filler address from the proof value - // EVM addresses are 20 bytes, left-padded to 32 bytes in storage - let filler_address = H160::from_slice( - filler_bytes.get(12..32).ok_or(Error::::MembershipProofFailed)?, - ); - ensure!(filler_address != H160::zero(), Error::::MembershipProofFailed); - - // Check proof freshness - let threshold = ProofFreshnessThresholdValue::::get(); - // The two proofs must bracket a narrow window around the fill - let proof_gap = commitment_h2.timestamp.saturating_sub(commitment_h1.timestamp); - ensure!(proof_gap <= threshold, Error::::ProofNotFresh); - // The fill must be recent relative to now (prevents replay) - let now = host.timestamp().as_secs(); - let age = now.saturating_sub(commitment_h2.timestamp); - ensure!(age <= threshold, Error::::ProofNotFresh); - - // Mark commitment as used - UsedCommitments::::insert(&commitment, true); - - // Update accumulator and compute new average (window reset happens in on_initialize) - let new_average = PriceAccumulators::::mutate(&pair_id, |acc| { - acc.sum = acc.sum.saturating_add(price); - acc.count = acc.count.saturating_add(1); - // count is always >= 1 after the increment above, so division is safe - acc.sum / U256::from(acc.count) - }); - AveragePrice::::insert(&pair_id, new_average); + let now = T::Dispatcher::default().timestamp().as_secs(); - Self::deposit_event(Event::PriceSubmitted { filler, pair_id, price, new_average }); + if let Some(v) = verification { + Self::submit_verified_price(submitter, pair_id, price, v, now)?; + } else { + Self::submit_unverified_price(submitter, pair_id, price, now)?; + } Ok(()) } @@ -685,8 +636,8 @@ pub mod pallet { ensure!(RecognizedPairs::::contains_key(&pair_id), Error::::PairNotRecognized); RecognizedPairs::::remove(&pair_id); - AveragePrice::::remove(&pair_id); - PriceAccumulators::::remove(&pair_id); + VerifiedPrices::::remove(&pair_id); + UnverifiedPrices::::remove(&pair_id); Self::deposit_event(Event::RecognizedPairRemoved { pair_id }); @@ -721,6 +672,38 @@ pub mod pallet { Ok(()) } + + /// Set the maximum number of unverified price submissions per pair + #[pallet::call_index(12)] + #[pallet::weight(T::DbWeight::get().writes(1))] + pub fn set_max_unverified_submissions( + origin: OriginFor, + max: u32, + ) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + + MaxUnverifiedSubmissions::::put(max); + + Self::deposit_event(Event::MaxUnverifiedSubmissionsUpdated { max }); + + Ok(()) + } + + /// Set the fee charged for unverified price submissions + #[pallet::call_index(13)] + #[pallet::weight(T::DbWeight::get().writes(1))] + pub fn set_unverified_submission_fee( + origin: OriginFor, + fee: BalanceOf, + ) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + + UnverifiedSubmissionFee::::put(fee); + + Self::deposit_event(Event::UnverifiedSubmissionFeeUpdated { fee }); + + Ok(()) + } } impl Pallet @@ -744,6 +727,215 @@ pub mod pallet { offchain_bid_key_raw(commitment, &filler.encode()) } + /// Process a verified (high confidence) price submission with ISMP proofs and + /// EVM signature verification. + fn submit_verified_price( + submitter: T::AccountId, + pair_id: H256, + price: U256, + v: types::PriceVerificationData, + now: u64, + ) -> DispatchResult { + ensure!( + !UsedCommitments::::get(&v.commitment), + Error::::CommitmentAlreadyUsed + ); + + let gateway_info = + Gateways::::get(v.state_machine).ok_or(Error::::GatewayNotFound)?; + + let filler_address = + Self::verify_fill_proofs(&v, &gateway_info, now)?; + + Self::verify_evm_signature(&v, &pair_id, &price, filler_address)?; + + UsedCommitments::::insert(&v.commitment, true); + + Self::maybe_clear_stale_prices(); + + VerifiedPrices::::mutate(&pair_id, |entries| { + entries.push(PriceEntry { + submitter: submitter.clone(), + price, + timestamp: now, + }); + }); + + Self::deposit_event(Event::PriceSubmitted { filler: submitter, pair_id, price }); + + Ok(()) + } + + /// Process an unverified (low confidence) price submission. Charges a fee + /// and stores with FIFO replacement if the cap is reached. + fn submit_unverified_price( + submitter: T::AccountId, + pair_id: H256, + price: U256, + now: u64, + ) -> DispatchResult { + let max = MaxUnverifiedSubmissions::::get(); + let fee = UnverifiedSubmissionFee::::get(); + ensure!( + max > 0 && !fee.is_zero(), + Error::::UnverifiedSubmissionsNotConfigured + ); + + ::Currency::transfer( + &submitter, + &T::TreasuryAccount::get(), + fee, + frame_support::traits::ExistenceRequirement::KeepAlive, + ) + .map_err(|_| Error::::InsufficientBalance)?; + + Self::maybe_clear_stale_prices(); + + UnverifiedPrices::::mutate(&pair_id, |entries| { + if entries.len() >= max as usize { + entries.remove(0); + } + entries.push(PriceEntry { + submitter: submitter.clone(), + price, + timestamp: now, + }); + }); + + Self::deposit_event(Event::UnverifiedPriceSubmitted { submitter, pair_id, price }); + + Ok(()) + } + + /// Verify ISMP state proofs for a fill order and return the filler's EVM address. + /// Confirms non-membership at H1, membership at H2, and that both proofs fall + /// within the freshness threshold. + fn verify_fill_proofs( + v: &types::PriceVerificationData, + gateway_info: &GatewayInfo, + now: u64, + ) -> Result { + let host = T::Dispatcher::default(); + + // 52-byte key: gateway address (20) + storage slot (32) + let storage_key = + types::filled_storage_key(&gateway_info.gateway, &v.commitment); + + // Get state commitments for both proof heights + let commitment_h1 = host + .state_machine_commitment(v.non_membership_proof.height) + .map_err(|_| Error::::ProofVerificationFailed)?; + let commitment_h2 = host + .state_machine_commitment(v.membership_proof.height) + .map_err(|_| Error::::ProofVerificationFailed)?; + + // Validate state machine clients + let state_machine_client_h1 = + ismp::handlers::validate_state_machine(&host, v.non_membership_proof.height) + .map_err(|_| Error::::ProofVerificationFailed)?; + let state_machine_client_h2 = + ismp::handlers::validate_state_machine(&host, v.membership_proof.height) + .map_err(|_| Error::::ProofVerificationFailed)?; + + // Verify non-membership proof: order was not filled at H1 + let non_membership_result = state_machine_client_h1 + .verify_state_proof( + &host, + vec![storage_key.clone()], + commitment_h1, + &v.non_membership_proof, + ) + .map_err(|_| Error::::NonMembershipProofFailed)?; + + let value_at_h1 = non_membership_result + .get(&storage_key) + .ok_or(Error::::NonMembershipProofFailed)?; + ensure!(value_at_h1.is_none(), Error::::NonMembershipProofFailed); + + // Verify membership proof: order was filled at H2 + let membership_result = state_machine_client_h2 + .verify_state_proof( + &host, + vec![storage_key.clone()], + commitment_h2, + &v.membership_proof, + ) + .map_err(|_| Error::::MembershipProofFailed)?; + + let filler_bytes = membership_result + .get(&storage_key) + .ok_or(Error::::MembershipProofFailed)? + .as_ref() + .ok_or(Error::::MembershipProofFailed)?; + + // Extract the filler address from the proof value + // EVM addresses are 20 bytes, left-padded to 32 bytes in storage + let filler_address = H160::from_slice( + filler_bytes.get(12..32).ok_or(Error::::MembershipProofFailed)?, + ); + ensure!(filler_address != H160::zero(), Error::::MembershipProofFailed); + + // Check proof freshness + let threshold = ProofFreshnessThresholdValue::::get(); + let proof_gap = + commitment_h2.timestamp.saturating_sub(commitment_h1.timestamp); + ensure!(proof_gap <= threshold, Error::::ProofNotFresh); + let age = now.saturating_sub(commitment_h2.timestamp); + ensure!(age <= threshold, Error::::ProofNotFresh); + + Ok(filler_address) + } + + /// Verify the EVM signature proving the filler owns the submitting account. + /// Recovers the signer from the signature over `(nonce, pair_id, price)`, + /// checks that it matches the filler from the proof, and increments the nonce. + fn verify_evm_signature( + v: &types::PriceVerificationData, + pair_id: &H256, + price: &U256, + filler_address: H160, + ) -> DispatchResult { + let evm_address_bytes = match &v.evm_signature { + crypto_utils::verification::Signature::Evm { address, .. } => + address.clone(), + _ => return Err(Error::::InvalidSignature.into()), + }; + + ensure!(evm_address_bytes.len() == 20, Error::::InvalidSignature); + let evm_address = H160::from_slice(&evm_address_bytes); + + let nonce = EvmNonces::::get(evm_address); + let msg = types::price_signature_message(nonce, pair_id, price); + + let recovered = v + .evm_signature + .verify(&msg, None) + .map_err(|_| Error::::InvalidSignature)?; + ensure!(recovered == evm_address_bytes, Error::::SignerMismatch); + + ensure!( + recovered.len() == 20 && H160::from_slice(&recovered) == filler_address, + Error::::SignerMismatch + ); + + EvmNonces::::insert(evm_address, nonce.saturating_add(1)); + + Ok(()) + } + + /// Clear all prices if this is the first submission in a new window. + /// + /// Prices from the previous window persist until the first new submission + /// in the new window, at which point all verified and unverified entries + /// across all pairs are cleared. + fn maybe_clear_stale_prices() { + if !PricesClearedThisWindow::::get() { + let _ = VerifiedPrices::::clear(u32::MAX, None); + let _ = UnverifiedPrices::::clear(u32::MAX, None); + PricesClearedThisWindow::::put(true); + } + } + /// 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/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index f599476f7..2b67fa133 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -19,8 +19,12 @@ use crate::{self as pallet_intents, *}; use alloc::{boxed::Box, collections::BTreeMap, vec}; +use codec::Decode; +use crypto_utils::verification::Signature; use frame_support::{ - assert_noop, assert_ok, parameter_types, + assert_noop, assert_ok, + crypto::ecdsa::ECDSAExt, + parameter_types, traits::{ConstU32, Everything, Hooks}, BoundedVec, }; @@ -39,7 +43,7 @@ use ismp_testsuite::mocks::MockRouter; use polkadot_sdk::*; use primitive_types::{H160, H256, U256}; -use sp_core::H256 as SpH256; +use sp_core::{Pair, H256 as SpH256}; use sp_runtime::{ traits::{BlakeTwo256, IdentityLookup}, AccountId32, BuildStorage, @@ -238,6 +242,7 @@ impl pallet_ismp::Config for Test { parameter_types! { pub const StorageDepositFee: Balance = 100; + pub TreasuryAccount: AccountId = AccountId32::new([10; 32]); } impl pallet_intents::Config for Test { @@ -245,6 +250,7 @@ impl pallet_intents::Config for Test { type Currency = Balances; type StorageDepositFee = StorageDepositFee; type GovernanceOrigin = EnsureRoot; + type TreasuryAccount = TreasuryAccount; type WeightInfo = (); } @@ -257,6 +263,7 @@ pub fn new_test_ext() -> sp_io::TestExternalities { (AccountId32::new([1; 32]), 10000), (AccountId32::new([2; 32]), 10000), (AccountId32::new([3; 32]), 10000), + (AccountId32::new([10; 32]), 1), // treasury (needs existential deposit) ], ..Default::default() } @@ -270,6 +277,10 @@ pub fn new_test_ext() -> sp_io::TestExternalities { pallet_intents::PriceWindowDurationValue::::put(86_400_000u64); // 1 hour in seconds pallet_intents::ProofFreshnessThresholdValue::::put(3600u64); + // Max 5 unverified submissions per pair + pallet_intents::MaxUnverifiedSubmissions::::put(5u32); + // Fee of 50 for unverified submissions + pallet_intents::UnverifiedSubmissionFee::::put(50u64); }); ext } @@ -650,36 +661,47 @@ fn remove_recognized_pair_works() { assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); - PriceAccumulators::::insert( + VerifiedPrices::::insert( &pair_id, - types::PriceAccumulator { sum: U256::from(1000), count: 5 }, + vec![types::PriceEntry { + submitter: AccountId32::new([1; 32]), + price: U256::from(1000), + timestamp: 1000, + }], + ); + UnverifiedPrices::::insert( + &pair_id, + vec![types::PriceEntry { + submitter: AccountId32::new([2; 32]), + price: U256::from(500), + timestamp: 1000, + }], ); - AveragePrice::::insert(&pair_id, U256::from(200)); assert_ok!(Intents::remove_recognized_pair(RuntimeOrigin::root(), pair_id)); // Verify clean up assert!(RecognizedPairs::::get(&pair_id).is_none()); - assert_eq!(AveragePrice::::get(&pair_id), U256::zero()); - assert_eq!(PriceAccumulators::::get(&pair_id).count, 0); + assert!(VerifiedPrices::::get(&pair_id).is_empty()); + assert!(UnverifiedPrices::::get(&pair_id).is_empty()); }); } #[test] -fn submit_pair_price() { +fn submit_pair_price_verified() { new_test_ext().execute_with(|| { let filler = AccountId32::new([1; 32]); let state_machine = StateMachine::Evm(1); let commitment = H256::repeat_byte(0xaa); let price = U256::from(2000); - // Add a recognized token pair + // Add a recognized token pair let pair = types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); - // Add a gateway deployment + // Add a gateway deployment let gateway = H160::from_low_u64_be(42); let params = types::IntentGatewayParams { host: H160::default(), @@ -697,17 +719,12 @@ fn submit_pair_price() { let h1 = StateMachineHeight { id: sm_id, height: H1_HEIGHT }; let h2 = StateMachineHeight { id: sm_id, height: H2_HEIGHT }; - // Map consensus_state_id -> consensus_client_id pallet_ismp::ConsensusStateClient::::insert( MOCK_CONSENSUS_STATE_ID, MOCK_CONSENSUS_CLIENT_ID, ); - - // Store consensus state pallet_ismp::ConsensusStates::::insert(MOCK_CONSENSUS_CLIENT_ID, vec![0u8]); - // Store state commitments at both heights - // H1 timestamp=1000, H2 timestamp=1050 pallet_ismp::child_trie::StateCommitments::::insert( h1, StateCommitment { @@ -725,87 +742,112 @@ fn submit_pair_price() { }, ); - // Store challenge period pallet_ismp::ChallengePeriod::::insert(sm_id, 0u64); - - // Store state machine update times pallet_ismp::StateMachineUpdateTime::::insert(h1, 1000u64); pallet_ismp::StateMachineUpdateTime::::insert(h2, 1050u64); - - // Set the pallet timestamp so host.timestamp() pallet_timestamp::Now::::put(2_000_000u64); // 2000 seconds in ms + // Create an EVM keypair for signing + let evm_pair = + sp_core::ecdsa::Pair::from_seed_slice(H256::repeat_byte(0x42).as_bytes()).unwrap(); + let evm_address = evm_pair.public().to_eth_address().unwrap().to_vec(); + + // The filler address in the proof must match the EVM signer + let filler_h160 = H160::from_slice(&evm_address); + // Build proofs - // Non-membership proof: all zeros let non_membership_proof = Proof { height: h1, proof: vec![0u8; 32] }; - - // Membership proof: proof bytes contain the filler address padded to 32 bytes - // The code reads filler_bytes[12..32] as an H160 address let mut filler_bytes = vec![0u8; 32]; - // Put a non-zero address in bytes 12..32 - filler_bytes[12..32].copy_from_slice(&H160::from_low_u64_be(0xdeadbeef).0); + filler_bytes[12..32].copy_from_slice(&filler_h160.0); let membership_proof = Proof { height: h2, proof: filler_bytes }; - assert_ok!(Intents::submit_pair_price( - RuntimeOrigin::signed(filler.clone()), + // Sign the price message: keccak256(encode(nonce=0, pair_id, price)) + let nonce = 0u64; + let msg = types::price_signature_message(nonce, &pair_id, &price); + let signature = evm_pair.sign_prehashed(&msg).0.to_vec(); + + let verification = types::PriceVerificationData { state_machine, commitment, - pair_id, - price, membership_proof, non_membership_proof, + evm_signature: Signature::Evm { address: evm_address.clone(), signature }, + }; + + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(filler.clone()), + pair_id, + price, + Some(verification), )); - // Verify the price accumulator was updated - let acc = PriceAccumulators::::get(&pair_id); - assert_eq!(acc.sum, price); - assert_eq!(acc.count, 1); + // Verify the verified price entry was stored + let verified = VerifiedPrices::::get(&pair_id); + assert_eq!(verified.len(), 1); + assert_eq!(verified[0].price, price); - // Verify the average price was stored - let avg = AveragePrice::::get(&pair_id); - assert_eq!(avg, price); // With 1 submission, average = price + // Verify the EVM nonce was incremented + assert_eq!(EvmNonces::::get(filler_h160), 1); - // Submit a second price and verify the average updates + // Submit a second price with nonce=1 let price2 = U256::from(4000); let commitment2 = H256::repeat_byte(0xbb); let non_membership_proof_2 = Proof { height: h1, proof: vec![0u8; 32] }; let mut filler_bytes_2 = vec![0u8; 32]; - filler_bytes_2[12..32].copy_from_slice(&H160::from_low_u64_be(0xcafebabe).0); + filler_bytes_2[12..32].copy_from_slice(&filler_h160.0); let membership_proof_2 = Proof { height: h2, proof: filler_bytes_2 }; + let msg2 = types::price_signature_message(1u64, &pair_id, &price2); + let signature2 = evm_pair.sign_prehashed(&msg2).0.to_vec(); + + let verification2 = types::PriceVerificationData { + state_machine, + commitment: commitment2, + membership_proof: membership_proof_2, + non_membership_proof: non_membership_proof_2, + evm_signature: Signature::Evm { address: evm_address.clone(), signature: signature2 }, + }; + assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(filler.clone()), - state_machine, - commitment2, pair_id, price2, - membership_proof_2, - non_membership_proof_2, + Some(verification2), )); - let acc2 = PriceAccumulators::::get(&pair_id); - assert_eq!(acc2.sum, price.saturating_add(price2)); - assert_eq!(acc2.count, 2); + // Verify two entries now stored + let verified2 = VerifiedPrices::::get(&pair_id); + assert_eq!(verified2.len(), 2); + assert_eq!(verified2[0].price, price); + assert_eq!(verified2[1].price, price2); - let avg2 = AveragePrice::::get(&pair_id); - assert_eq!(avg2, U256::from(3000)); - - // 9. Reusing the same commitment should fail + // Reusing the same commitment should fail let non_membership_proof_dup = Proof { height: h1, proof: vec![0u8; 32] }; let mut filler_bytes_dup = vec![0u8; 32]; - filler_bytes_dup[12..32].copy_from_slice(&H160::from_low_u64_be(0xdeadbeef).0); + filler_bytes_dup[12..32].copy_from_slice(&filler_h160.0); let membership_proof_dup = Proof { height: h2, proof: filler_bytes_dup }; + let msg_dup = types::price_signature_message(2u64, &pair_id, &U256::from(9999)); + let signature_dup = evm_pair.sign_prehashed(&msg_dup).0.to_vec(); + + let verification_dup = types::PriceVerificationData { + state_machine, + commitment, // same commitment + membership_proof: membership_proof_dup, + non_membership_proof: non_membership_proof_dup, + evm_signature: Signature::Evm { + address: evm_address.clone(), + signature: signature_dup, + }, + }; + assert_noop!( Intents::submit_pair_price( RuntimeOrigin::signed(filler.clone()), - state_machine, - commitment, // same commitment as step 5 pair_id, U256::from(9999), - membership_proof_dup, - non_membership_proof_dup, + Some(verification_dup), ), Error::::CommitmentAlreadyUsed ); @@ -813,42 +855,246 @@ fn submit_pair_price() { } #[test] -fn on_initialize_resets_accumulators_on_new_day() { +fn submit_pair_price_unverified() { new_test_ext().execute_with(|| { + let submitter = AccountId32::new([1; 32]); + let price = U256::from(1500); + + // Add a recognized token pair let pair = types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; let pair_id = pair.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + + // Set timestamp + pallet_timestamp::Now::::put(2_000_000u64); + + let balance_before = Balances::free_balance(&submitter); + + // Submit unverified price (no verification data) + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(submitter.clone()), + pair_id, + price, + None, + )); + + // Verify fee was charged + let fee = UnverifiedSubmissionFee::::get(); + assert_eq!(Balances::free_balance(&submitter), balance_before - fee); - PriceAccumulators::::insert( + // Verify unverified price entry was stored + let unverified = UnverifiedPrices::::get(&pair_id); + assert_eq!(unverified.len(), 1); + assert_eq!(unverified[0].price, price); + + // Verified prices should be empty + assert!(VerifiedPrices::::get(&pair_id).is_empty()); + }); +} + +#[test] +fn unverified_prices_fifo_replacement() { + new_test_ext().execute_with(|| { + let submitter = AccountId32::new([1; 32]); + + let pair = + types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + let pair_id = pair.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + + pallet_timestamp::Now::::put(2_000_000u64); + + // Set max to 3 for easier testing + MaxUnverifiedSubmissions::::put(3u32); + + // Submit 3 unverified prices (fills the cap) + for i in 1..=3u64 { + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(submitter.clone()), + pair_id, + U256::from(i * 1000), + None, + )); + } + + let entries = UnverifiedPrices::::get(&pair_id); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].price, U256::from(1000)); // oldest + + // Submit 4th — should pop the oldest (1000) and add new + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(submitter.clone()), + pair_id, + U256::from(4000), + None, + )); + + let entries = UnverifiedPrices::::get(&pair_id); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].price, U256::from(2000)); // 1000 was popped + assert_eq!(entries[2].price, U256::from(4000)); // new entry at end + }); +} + +#[test] +fn prices_persist_across_window_and_clear_on_first_submission() { + new_test_ext().execute_with(|| { + let submitter = AccountId32::new([1; 32]); + let pair = + types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + let pair_id = pair.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + + // Simulate day 1: store some prices with timestamps in the current window + VerifiedPrices::::insert( &pair_id, - types::PriceAccumulator { sum: U256::from(5000), count: 3 }, + vec![types::PriceEntry { + submitter: AccountId32::new([1; 32]), + price: U256::from(1666), + timestamp: 1000, + }], + ); + UnverifiedPrices::::insert( + &pair_id, + vec![types::PriceEntry { + submitter: AccountId32::new([2; 32]), + price: U256::from(1500), + timestamp: 1000, + }], ); - AveragePrice::::insert(&pair_id, U256::from(1666)); // Window started at second 1000, duration is 86_400_000 ms = 86_400 s PriceWindowStart::::put(1000u64); + // Before window expires: on_initialize does nothing to prices pallet_timestamp::Now::::put(50_000_000u64); // 50_000 seconds in ms Intents::on_initialize(1u64); - // Window has NOT expired, accumulators and average should be untouched - let acc = PriceAccumulators::::get(&pair_id); - assert_eq!(acc.count, 3); - assert_eq!(acc.sum, U256::from(5000)); - assert_eq!(AveragePrice::::get(&pair_id), U256::from(1666)); + // Prices should be untouched + assert_eq!(VerifiedPrices::::get(&pair_id).len(), 1); + assert_eq!(UnverifiedPrices::::get(&pair_id).len(), 1); // Advance past the window (1000 + 86_400 = 87_400 seconds) pallet_timestamp::Now::::put(90_000_000u64); // 90_000 seconds in ms Intents::on_initialize(2u64); - // Window expired, accumulators should be cleared but average price - // from yesterday is preserved (readable until overwritten by new submissions) - let acc = PriceAccumulators::::get(&pair_id); - assert_eq!(acc.count, 0); - assert_eq!(acc.sum, U256::zero()); - assert_eq!(AveragePrice::::get(&pair_id), U256::from(1666)); - - // Window start should be updated + // on_initialize only clears UsedCommitments and updates PriceWindowStart. + // Prices still persist! (yesterday's data readable until first new submission) + assert_eq!(VerifiedPrices::::get(&pair_id).len(), 1); + assert_eq!(UnverifiedPrices::::get(&pair_id).len(), 1); assert_eq!(PriceWindowStart::::get(), 90_000); + + // Now submit an unverified price, this is the first submission in the new window. + // It should clear stale entries for this pair before adding the new one. + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(submitter.clone()), + pair_id, + U256::from(2000), + None, + )); + + // Old entries are gone, only the new unverified entry remains + assert!(VerifiedPrices::::get(&pair_id).is_empty()); + let unverified = UnverifiedPrices::::get(&pair_id); + assert_eq!(unverified.len(), 1); + assert_eq!(unverified[0].price, U256::from(2000)); + }); +} + +#[test] +fn price_entry_encoding_matches_rpc_tuple_decoding() { + // The RPC decodes PriceEntry as Vec<(AccountId32, U256, u64)>. + // Verify that PriceEntry's SCALE encoding is identical to the tuple encoding. + use codec::Encode; + + let submitter = AccountId32::new([1; 32]); + let price = U256::from(42_000); + let timestamp = 1_700_000_000u64; + + let entry = + types::PriceEntry { submitter: submitter.clone(), price, timestamp }; + + let entry_bytes = entry.encode(); + let tuple_bytes = (submitter.clone(), price, timestamp).encode(); + assert_eq!(entry_bytes, tuple_bytes, "PriceEntry SCALE encoding must match tuple encoding"); + + // Also verify round-trip: encode as PriceEntry, decode as tuple + type RpcTuple = (AccountId32, U256, u64); + let entries = vec![entry]; + let encoded = entries.encode(); + let decoded: Vec = Decode::decode(&mut &encoded[..]).unwrap(); + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0].0, submitter); + assert_eq!(decoded[0].1, price); + assert_eq!(decoded[0].2, timestamp); +} + +#[test] +fn price_entry_storage_roundtrip_via_raw_key() { + // End-to-end test: write prices via pallet storage, read raw bytes, decode as the RPC would. + new_test_ext().execute_with(|| { + let pair = + types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + let pair_id = pair.pair_id(); + + let entry1 = types::PriceEntry { + submitter: AccountId32::new([1; 32]), + price: U256::from(2000), + timestamp: 1000, + }; + let entry2 = types::PriceEntry { + submitter: AccountId32::new([2; 32]), + price: U256::from(3000), + timestamp: 2000, + }; + + VerifiedPrices::::insert(&pair_id, vec![entry1.clone(), entry2.clone()]); + UnverifiedPrices::::insert( + &pair_id, + vec![types::PriceEntry { + submitter: AccountId32::new([3; 32]), + price: U256::from(1500), + timestamp: 500, + }], + ); + + // Build the storage key the same way the RPC does. + let pallet_prefix = b"Intents"; + + let mut key = Vec::new(); + key.extend_from_slice(&sp_io::hashing::twox_128(pallet_prefix)); + key.extend_from_slice(&sp_io::hashing::twox_128(b"VerifiedPrices")); + let pair_id_bytes = pair_id.as_bytes(); + key.extend_from_slice(&sp_io::hashing::blake2_128(pair_id_bytes)); + key.extend_from_slice(pair_id_bytes); + + // Read raw storage + let raw = sp_io::storage::get(&key).expect("VerifiedPrices storage should exist"); + + // Decode as the RPC would + type RpcTuple = (AccountId32, U256, u64); + let decoded: Vec = Decode::decode(&mut &raw[..]).unwrap(); + assert_eq!(decoded.len(), 2); + assert_eq!(decoded[0].0, AccountId32::new([1; 32])); + assert_eq!(decoded[0].1, U256::from(2000)); + assert_eq!(decoded[0].2, 1000u64); + assert_eq!(decoded[1].0, AccountId32::new([2; 32])); + assert_eq!(decoded[1].1, U256::from(3000)); + assert_eq!(decoded[1].2, 2000u64); + + // Do the same for UnverifiedPrices + let mut ukey = Vec::new(); + ukey.extend_from_slice(&sp_io::hashing::twox_128(pallet_prefix)); + ukey.extend_from_slice(&sp_io::hashing::twox_128(b"UnverifiedPrices")); + ukey.extend_from_slice(&sp_io::hashing::blake2_128(pair_id_bytes)); + ukey.extend_from_slice(pair_id_bytes); + + let uraw = sp_io::storage::get(&ukey).expect("UnverifiedPrices storage should exist"); + let udecoded: Vec = Decode::decode(&mut &uraw[..]).unwrap(); + assert_eq!(udecoded.len(), 1); + assert_eq!(udecoded[0].0, AccountId32::new([3; 32])); + assert_eq!(udecoded[0].1, U256::from(1500)); + assert_eq!(udecoded[0].2, 500u64); }); } diff --git a/modules/pallets/intents-coprocessor/src/types.rs b/modules/pallets/intents-coprocessor/src/types.rs index 7876a0c27..40e6c7463 100644 --- a/modules/pallets/intents-coprocessor/src/types.rs +++ b/modules/pallets/intents-coprocessor/src/types.rs @@ -18,7 +18,8 @@ use alloc::{vec, vec::Vec}; use alloy_sol_types::SolValue; use codec::{Decode, DecodeWithMemTracking, Encode}; - +use crypto_utils::verification::Signature; +use ismp::{host::StateMachine, messaging::Proof}; use primitive_types::{H160, H256, U256}; use scale_info::TypeInfo; @@ -163,13 +164,42 @@ impl TokenPair { } } -/// Running price accumulator for a token pair within the current time window -#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq, Default)] -pub struct PriceAccumulator { - /// Sum of all submitted prices in the current window - pub sum: U256, - /// Number of submissions in the current window - pub count: u32, +/// An individual price submission stored on-chain +#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] +pub struct PriceEntry { + /// The submitter's substrate account + pub submitter: AccountId, + /// The submitted price + pub price: U256, + /// Timestamp of submission (seconds) + pub timestamp: u64, +} + +/// Verification data for proven price submissions (high confidence) +/// +/// When provided, the submission is treated as a verified filler price. +/// The EVM signature proves the substrate account owner also controls the +/// EVM account that filled the order. +#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] +pub struct PriceVerificationData { + /// The state machine where the order was filled + pub state_machine: StateMachine, + /// The filled order commitment hash + pub commitment: H256, + /// Proof that the order was filled at some height + pub membership_proof: Proof, + /// Proof that the order was not filled at an earlier height + pub non_membership_proof: Proof, + /// EVM signature proving ownership of the filler's EVM account. + /// The signer must sign `keccak256(encode(nonce, pair_id, price))`. + pub evm_signature: Signature, +} + +/// Compute the message hash that the filler must sign with their EVM key. +/// +/// Message = keccak256(SCALE_encode(nonce, pair_id, price)) +pub fn price_signature_message(nonce: u64, pair_id: &H256, price: &U256) -> [u8; 32] { + sp_io::hashing::keccak_256(&(nonce, pair_id, price).encode()) } /// The storage slot index for the `_filled` mapping in IntentGateway.sol From cb2c312862136cc2c9ddfb738eea6f359ec80fab Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Thu, 12 Mar 2026 14:48:33 +0100 Subject: [PATCH 04/28] TreasuryAccount in config --- parachain/runtimes/gargantua/src/ismp.rs | 4 +++- parachain/runtimes/nexus/src/ismp.rs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/parachain/runtimes/gargantua/src/ismp.rs b/parachain/runtimes/gargantua/src/ismp.rs index aebc07f77..9eac57535 100644 --- a/parachain/runtimes/gargantua/src/ismp.rs +++ b/parachain/runtimes/gargantua/src/ismp.rs @@ -16,7 +16,8 @@ use crate::{ alloc::{boxed::Box, string::ToString}, weights, AccountId, Assets, Balance, Balances, Ismp, IsmpParachain, Mmr, ParachainInfo, - Runtime, RuntimeEvent, Timestamp, TokenGatewayInspector, TreasuryPalletId, XcmGateway, + Runtime, RuntimeEvent, Timestamp, TokenGatewayInspector, TreasuryAccount, TreasuryPalletId, + XcmGateway, EXISTENTIAL_DEPOSIT, }; use anyhow::anyhow; @@ -91,6 +92,7 @@ impl pallet_intents_coprocessor::Config for Runtime { type Currency = Balances; type StorageDepositFee = IntentStorageDepositFee; type GovernanceOrigin = EnsureRoot; + type TreasuryAccount = TreasuryAccount; type WeightInfo = weights::pallet_intents_coprocessor::WeightInfo; } diff --git a/parachain/runtimes/nexus/src/ismp.rs b/parachain/runtimes/nexus/src/ismp.rs index 51a570271..2eee4cd94 100644 --- a/parachain/runtimes/nexus/src/ismp.rs +++ b/parachain/runtimes/nexus/src/ismp.rs @@ -18,7 +18,7 @@ use crate::{ governance::WhitelistedCaller, weights, AccountId, Assets, Balance, Balances, Ismp, IsmpParachain, Mmr, ParachainInfo, ReputationAsset, Runtime, RuntimeEvent, TechnicalCollectiveInstance, Timestamp, TokenGateway, - TokenGatewayInspector, TreasuryPalletId, XcmGateway, EXISTENTIAL_DEPOSIT, + TokenGatewayInspector, TreasuryAccount, TreasuryPalletId, XcmGateway, EXISTENTIAL_DEPOSIT, MIN_TECH_COLLECTIVE_APPROVAL, }; use anyhow::anyhow; @@ -433,6 +433,7 @@ impl pallet_intents_coprocessor::Config for Runtime { MIN_TECH_COLLECTIVE_APPROVAL, >, >; + type TreasuryAccount = TreasuryAccount; type WeightInfo = weights::pallet_intents_coprocessor::WeightInfo; } impl IsmpModule for ProxyModule { From 9d13e85ac28c449240e39e6b6b9103beedf1ccc2 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Thu, 12 Mar 2026 14:49:26 +0100 Subject: [PATCH 05/28] fmt --- .../pallets/intents-coprocessor/src/lib.rs | 50 +++++-------------- .../pallets/intents-coprocessor/src/tests.rs | 3 +- 2 files changed, 14 insertions(+), 39 deletions(-) diff --git a/modules/pallets/intents-coprocessor/src/lib.rs b/modules/pallets/intents-coprocessor/src/lib.rs index 9fd54f155..0de7d7582 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -65,8 +65,8 @@ pub fn offchain_bid_key_raw(commitment: &H256, filler_encoded: &[u8]) -> Vec #[frame_support::pallet] pub mod pallet { use super::*; - use alloc::vec; use crate::alloc::string::ToString; + use alloc::vec; use frame_support::pallet_prelude::*; use frame_system::pallet_prelude::*; @@ -161,8 +161,7 @@ pub mod pallet { /// Nonces for EVM addresses to prevent signature replay. /// Keyed by the 20-byte EVM address. #[pallet::storage] - pub type EvmNonces = - StorageMap<_, Blake2_128Concat, H160, u64, ValueQuery>; + pub type EvmNonces = StorageMap<_, Blake2_128Concat, H160, u64, ValueQuery>; /// Maximum number of unverified price submissions per pair #[pallet::storage] @@ -676,10 +675,7 @@ pub mod pallet { /// Set the maximum number of unverified price submissions per pair #[pallet::call_index(12)] #[pallet::weight(T::DbWeight::get().writes(1))] - pub fn set_max_unverified_submissions( - origin: OriginFor, - max: u32, - ) -> DispatchResult { + pub fn set_max_unverified_submissions(origin: OriginFor, max: u32) -> DispatchResult { T::GovernanceOrigin::ensure_origin(origin)?; MaxUnverifiedSubmissions::::put(max); @@ -736,16 +732,12 @@ pub mod pallet { v: types::PriceVerificationData, now: u64, ) -> DispatchResult { - ensure!( - !UsedCommitments::::get(&v.commitment), - Error::::CommitmentAlreadyUsed - ); + ensure!(!UsedCommitments::::get(&v.commitment), Error::::CommitmentAlreadyUsed); let gateway_info = Gateways::::get(v.state_machine).ok_or(Error::::GatewayNotFound)?; - let filler_address = - Self::verify_fill_proofs(&v, &gateway_info, now)?; + let filler_address = Self::verify_fill_proofs(&v, &gateway_info, now)?; Self::verify_evm_signature(&v, &pair_id, &price, filler_address)?; @@ -754,11 +746,7 @@ pub mod pallet { Self::maybe_clear_stale_prices(); VerifiedPrices::::mutate(&pair_id, |entries| { - entries.push(PriceEntry { - submitter: submitter.clone(), - price, - timestamp: now, - }); + entries.push(PriceEntry { submitter: submitter.clone(), price, timestamp: now }); }); Self::deposit_event(Event::PriceSubmitted { filler: submitter, pair_id, price }); @@ -776,10 +764,7 @@ pub mod pallet { ) -> DispatchResult { let max = MaxUnverifiedSubmissions::::get(); let fee = UnverifiedSubmissionFee::::get(); - ensure!( - max > 0 && !fee.is_zero(), - Error::::UnverifiedSubmissionsNotConfigured - ); + ensure!(max > 0 && !fee.is_zero(), Error::::UnverifiedSubmissionsNotConfigured); ::Currency::transfer( &submitter, @@ -795,11 +780,7 @@ pub mod pallet { if entries.len() >= max as usize { entries.remove(0); } - entries.push(PriceEntry { - submitter: submitter.clone(), - price, - timestamp: now, - }); + entries.push(PriceEntry { submitter: submitter.clone(), price, timestamp: now }); }); Self::deposit_event(Event::UnverifiedPriceSubmitted { submitter, pair_id, price }); @@ -818,8 +799,7 @@ pub mod pallet { let host = T::Dispatcher::default(); // 52-byte key: gateway address (20) + storage slot (32) - let storage_key = - types::filled_storage_key(&gateway_info.gateway, &v.commitment); + let storage_key = types::filled_storage_key(&gateway_info.gateway, &v.commitment); // Get state commitments for both proof heights let commitment_h1 = host @@ -877,8 +857,7 @@ pub mod pallet { // Check proof freshness let threshold = ProofFreshnessThresholdValue::::get(); - let proof_gap = - commitment_h2.timestamp.saturating_sub(commitment_h1.timestamp); + let proof_gap = commitment_h2.timestamp.saturating_sub(commitment_h1.timestamp); ensure!(proof_gap <= threshold, Error::::ProofNotFresh); let age = now.saturating_sub(commitment_h2.timestamp); ensure!(age <= threshold, Error::::ProofNotFresh); @@ -896,8 +875,7 @@ pub mod pallet { filler_address: H160, ) -> DispatchResult { let evm_address_bytes = match &v.evm_signature { - crypto_utils::verification::Signature::Evm { address, .. } => - address.clone(), + crypto_utils::verification::Signature::Evm { address, .. } => address.clone(), _ => return Err(Error::::InvalidSignature.into()), }; @@ -907,10 +885,8 @@ pub mod pallet { let nonce = EvmNonces::::get(evm_address); let msg = types::price_signature_message(nonce, pair_id, price); - let recovered = v - .evm_signature - .verify(&msg, None) - .map_err(|_| Error::::InvalidSignature)?; + let recovered = + v.evm_signature.verify(&msg, None).map_err(|_| Error::::InvalidSignature)?; ensure!(recovered == evm_address_bytes, Error::::SignerMismatch); ensure!( diff --git a/modules/pallets/intents-coprocessor/src/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index 2b67fa133..4c4cbe849 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -1012,8 +1012,7 @@ fn price_entry_encoding_matches_rpc_tuple_decoding() { let price = U256::from(42_000); let timestamp = 1_700_000_000u64; - let entry = - types::PriceEntry { submitter: submitter.clone(), price, timestamp }; + let entry = types::PriceEntry { submitter: submitter.clone(), price, timestamp }; let entry_bytes = entry.encode(); let tuple_bytes = (submitter.clone(), price, timestamp).encode(); From 6e47e72283dc276de9ec613a0c223c16c0266240 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Fri, 13 Mar 2026 12:22:33 +0100 Subject: [PATCH 06/28] introduce price range input, include docs --- .../developers/intent-gateway/meta.json | 2 +- .../intent-gateway/price-submission.mdx | 70 ++++++++ .../intents-coprocessor/rpc/src/lib.rs | 30 +++- .../pallets/intents-coprocessor/src/lib.rs | 84 +++++++--- .../pallets/intents-coprocessor/src/tests.rs | 154 +++++++++++++----- .../pallets/intents-coprocessor/src/types.rs | 28 +++- parachain/runtimes/gargantua/src/ismp.rs | 1 + parachain/runtimes/nexus/src/ismp.rs | 1 + 8 files changed, 290 insertions(+), 80 deletions(-) create mode 100644 docs/content/developers/intent-gateway/price-submission.mdx diff --git a/docs/content/developers/intent-gateway/meta.json b/docs/content/developers/intent-gateway/meta.json index 542eedee1..560637c60 100644 --- a/docs/content/developers/intent-gateway/meta.json +++ b/docs/content/developers/intent-gateway/meta.json @@ -1,5 +1,5 @@ { "title": "Intent Gateway", - "pages": ["overview", "placing-orders", "cancelling-orders", "simplex"], + "pages": ["overview", "placing-orders", "cancelling-orders", "simplex", "price-submission"], "defaultOpen": false } diff --git a/docs/content/developers/intent-gateway/price-submission.mdx b/docs/content/developers/intent-gateway/price-submission.mdx new file mode 100644 index 000000000..a0d789b1f --- /dev/null +++ b/docs/content/developers/intent-gateway/price-submission.mdx @@ -0,0 +1,70 @@ +--- +title: Price Submission Protocol +description: Dual-path price submission system for the intents coprocessor pallet +--- + +# Price Submission Protocol + +## Overview + +The intents system needs on-chain price data for token pairs to function correctly. The challenge is sourcing reliable prices without depending on external oracles, while still ensuring data availability when verified sources are temporarily scarce. + +## Protocol Overview + +The `submit_pair_price` extrinsic on the intents coprocessor pallet accepts price submissions for governance-approved token pairs. It supports two confidence levels through a single entry point, distinguished by an optional `verification` parameter. All prices are assumed to have 18 decimal places. + +### Verified Prices (High Confidence) + +Fillers who have actually filled orders on the IntentGateway contract submit prices along with cryptographic proof of their fill. This path exists because fillers are the most trustworthy source of price data. They have real skin in the game and their prices reflect actual market activity. + +A verified submission requires three things: + +1. **Two ISMP state proofs** against the IntentGateway contract's `_filled` mapping. A non-membership proof at height H1 shows the order was not yet filled, and a membership proof at height H2 shows it was filled. Together these bracket when the fill occurred and extract the filler's EVM address from storage. + +2. **Proof freshness.** Both the gap between H1 and H2, and the age of H2 relative to the current time, must fall within a governance-configured threshold. This prevents stale or manufactured proofs from being accepted. + +3. **An EVM signature** over `keccak256(SCALE_encode(nonce, pair_id, price))`. This proves the substrate account submitting the price actually controls the EVM account that performed the fill. The nonce is tracked on-chain per EVM address to prevent signature replay. This pattern follows the existing approach in `pallet-ismp-relayer`. + +The 52-byte proof key binds verification to a specific gateway contract. The first 20 bytes are the gateway address (used by the EVM state machine client to locate the contract in the world state trie) and the last 32 bytes are the hashed storage slot for `_filled[commitment]`. + +Verified prices are stored in `VerifiedPrices` with no cap. The goal is to accumulate as many real data points as possible within each price window. + +### Unverified Prices (Low Confidence) + +Anyone can submit a price without proofs by paying a fee in bridge tokens. This path exists to maintain price data availability even when verified submissions are sparse. The fee, which is transferred to the treasury, discourages spam while keeping the door open for market participants who have price information but haven't filled orders themselves. + +Unverified prices are stored separately in `UnverifiedPrices` and capped per pair at `MaxUnverifiedSubmissions`. When the cap is reached, the oldest entry is replaced using a first-in, first-out policy. Both the fee and the cap must be configured via governance for unverified submissions to be accepted. + +### Why Two Confidence Levels + +Keeping verified and unverified prices separate lets consumers make informed decisions. A DEX might only trust verified prices for settlement, while a UI might display both to give users a fuller picture. Mixing them into a single pool would dilute the signal from proven fills. + +## Price Window and Data Lifecycle + +Prices are organized into daily windows, which are governance-configurable via `PriceWindowDurationValue`. + +The `on_initialize` hook runs every block and checks whether the current window has expired. When it has, it clears `UsedCommitments` (which is safe because the freshness threshold independently rejects stale proofs) and resets a `PricesClearedThisWindow` flag to false. + +Prices from the previous window are not cleared immediately. They persist so that consumers can still read yesterday's data. On the first new submission in the new window (verified or unverified), all price entries across all pairs are cleared before the new entry is stored. This lazy clearing approach avoids the cost of iterating all pairs in `on_initialize` while ensuring stale data is replaced as soon as fresh data arrives. + +### Why Lazy Clearing + +Clearing all price maps in `on_initialize` would cost weight proportional to the number of pairs with data, which is unbounded and paid by the block producer. Instead, the cost is deferred to the first submitter of the new window, who is already paying for a storage write. The global boolean flag (`PricesClearedThisWindow`) makes this a single check per submission after the first. + +## Recognized Pairs + +Only governance-approved token pairs can receive price submissions. This prevents spam for arbitrary token combinations and keeps storage growth under control. Governance manages pairs through `add_recognized_pair` and `remove_recognized_pair` extrinsics. Removing a pair also cleans up its associated price data. + +## RPC + +The `intents_getPairPrices(pair_id)` RPC endpoint returns verified and unverified prices separately, allowing consumers to apply their own weighting or filtering based on confidence level. + +## Governance Parameters + +All key parameters are stored on-chain and updatable via governance extrinsics: + +- `PriceWindowDurationValue` is the length of the price window in milliseconds. +- `ProofFreshnessThresholdValue` is the maximum allowed age and gap for state proofs in seconds. +- `MaxUnverifiedSubmissions` is the cap on unverified entries per pair. +- `UnverifiedSubmissionFee` is the fee charged for unverified submissions. +- Recognized token pairs determine which pairs accept submissions. diff --git a/modules/pallets/intents-coprocessor/rpc/src/lib.rs b/modules/pallets/intents-coprocessor/rpc/src/lib.rs index e65cf2415..66fbd5a18 100644 --- a/modules/pallets/intents-coprocessor/rpc/src/lib.rs +++ b/modules/pallets/intents-coprocessor/rpc/src/lib.rs @@ -56,10 +56,14 @@ pub struct RpcBidInfo { /// A single price entry returned by the RPC #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] pub struct RpcPriceEntry { - /// The submitter's encoded account + /// The filler's EVM address (zero address for unverified submissions) #[serde(with = "hex_bytes")] - pub submitter: Vec, - /// The submitted price as a hex-encoded U256 + pub filler: Vec, + /// Lower bound of the base token amount range (inclusive), with 18 decimal places + pub range_start: String, + /// Upper bound of the base token amount range (inclusive), with 18 decimal places + pub range_end: String, + /// The price of the base token in the quote token, with 18 decimal places pub price: String, /// Timestamp of submission (seconds) pub timestamp: u64, @@ -307,15 +311,23 @@ where _ => return Vec::new(), }; - // Decode Vec> - // PriceEntry SCALE-encodes as (AccountId32(32 bytes), U256(32 bytes), u64(8 bytes)) - type Entry = (sp_runtime::AccountId32, primitive_types::U256, u64); + // Decode Vec + // PriceEntry SCALE-encodes as (H160, U256, U256, U256, u64) + type Entry = ( + primitive_types::H160, + primitive_types::U256, + primitive_types::U256, + primitive_types::U256, + u64, + ); match Vec::::decode(&mut &data[..]) { Ok(entries) => entries .into_iter() - .map(|(submitter, price, timestamp)| RpcPriceEntry { - submitter: submitter.encode(), - price: format!("0x{price:x}"), + .map(|(filler, range_start, range_end, price, timestamp)| RpcPriceEntry { + filler: filler.as_bytes().to_vec(), + range_start: range_start.to_string(), + range_end: range_end.to_string(), + price: price.to_string(), timestamp, }) .collect(), diff --git a/modules/pallets/intents-coprocessor/src/lib.rs b/modules/pallets/intents-coprocessor/src/lib.rs index 0de7d7582..af4500045 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -44,8 +44,8 @@ use sp_runtime::traits::{ConstU32, Zero}; pub use weights::WeightInfo; use types::{ - Bid, GatewayInfo, IntentGatewayParams, PriceEntry, RequestKind, TokenDecimalsUpdate, TokenInfo, - TokenPair, + Bid, GatewayInfo, IntentGatewayParams, PriceEntry, PriceInput, RequestKind, + TokenDecimalsUpdate, TokenInfo, TokenPair, }; // Re-export pallet items so that they can be accessed from the crate namespace. @@ -94,6 +94,10 @@ pub mod pallet { /// The treasury account that receives unverified submission fees type TreasuryAccount: Get; + /// Maximum number of price entries per submission + #[pallet::constant] + type MaxPriceEntries: Get; + /// Weight information for extrinsics in this pallet type WeightInfo: WeightInfo; } @@ -151,12 +155,12 @@ pub mod pallet { /// Verified (high confidence) price entries per pair, from fillers with proofs #[pallet::storage] pub type VerifiedPrices = - StorageMap<_, Blake2_128Concat, H256, Vec>, ValueQuery>; + StorageMap<_, Blake2_128Concat, H256, Vec, ValueQuery>; /// Unverified (low confidence) price entries per pair, from anyone without proofs #[pallet::storage] pub type UnverifiedPrices = - StorageMap<_, Blake2_128Concat, H256, Vec>, ValueQuery>; + StorageMap<_, Blake2_128Concat, H256, Vec, ValueQuery>; /// Nonces for EVM addresses to prevent signature replay. /// Keyed by the 20-byte EVM address. @@ -245,14 +249,14 @@ pub mod pallet { RecognizedPairAdded { pair_id: H256, pair: TokenPair }, /// A recognized token pair was removed RecognizedPairRemoved { pair_id: H256 }, - /// A verified price was submitted (high confidence) - PriceSubmitted { filler: T::AccountId, pair_id: H256, price: U256 }, + /// Verified prices were submitted (high confidence) + PriceSubmitted { filler: T::AccountId, pair_id: H256 }, /// Price window duration was updated PriceWindowDurationUpdated { duration_ms: u64 }, /// Proof freshness threshold was updated ProofFreshnessThresholdUpdated { threshold_secs: u64 }, - /// An unverified price was submitted (low confidence) - UnverifiedPriceSubmitted { submitter: T::AccountId, pair_id: H256, price: U256 }, + /// Unverified prices were submitted (low confidence) + UnverifiedPriceSubmitted { submitter: T::AccountId, pair_id: H256 }, /// Max unverified submissions per pair was updated MaxUnverifiedSubmissionsUpdated { max: u32 }, /// Unverified submission fee was updated @@ -293,6 +297,10 @@ pub mod pallet { SignerMismatch, /// Unverified submissions are not configured (max or fee is zero) UnverifiedSubmissionsNotConfigured, + /// The price range is invalid (range_start > range_end) + InvalidPriceRange, + /// No price entries were provided + EmptyPriceEntries, } #[pallet::call] @@ -574,7 +582,7 @@ pub mod pallet { Ok(()) } - /// Submit a price for a recognized token pair. + /// Submit prices for a recognized token pair across one or more amount ranges. /// /// This extrinsic supports two submission modes. When `verification` is provided, /// the submission is treated as high confidence: the filler supplies ISMP state @@ -583,8 +591,8 @@ pub mod pallet { /// `None`, anyone may submit a low confidence price by paying bridge tokens. /// Unverified entries are capped per pair with FIFO replacement. /// - /// The `pair_id` identifies the token pair, and `price` is the price of the base - /// token in terms of the quote token. + /// Each entry in `entries` specifies a base token amount range and the + /// corresponding price of the base token in terms of the quote token. #[pallet::call_index(7)] #[pallet::weight({ T::DbWeight::get().reads(12).saturating_add(T::DbWeight::get().writes(4)) @@ -592,19 +600,24 @@ pub mod pallet { pub fn submit_pair_price( origin: OriginFor, pair_id: H256, - price: U256, + entries: BoundedVec, verification: Option, ) -> DispatchResult { let submitter = ensure_signed(origin)?; + ensure!(!entries.is_empty(), Error::::EmptyPriceEntries); + ensure!( + entries.iter().all(|e| e.range_start <= e.range_end), + Error::::InvalidPriceRange + ); ensure!(RecognizedPairs::::contains_key(&pair_id), Error::::PairNotRecognized); let now = T::Dispatcher::default().timestamp().as_secs(); if let Some(v) = verification { - Self::submit_verified_price(submitter, pair_id, price, v, now)?; + Self::submit_verified_price(submitter, pair_id, entries, v, now)?; } else { - Self::submit_unverified_price(submitter, pair_id, price, now)?; + Self::submit_unverified_price(submitter, pair_id, entries, now)?; } Ok(()) @@ -728,7 +741,7 @@ pub mod pallet { fn submit_verified_price( submitter: T::AccountId, pair_id: H256, - price: U256, + entries: BoundedVec, v: types::PriceVerificationData, now: u64, ) -> DispatchResult { @@ -739,17 +752,23 @@ pub mod pallet { let filler_address = Self::verify_fill_proofs(&v, &gateway_info, now)?; - Self::verify_evm_signature(&v, &pair_id, &price, filler_address)?; + Self::verify_evm_signature(&v, &pair_id, &entries[0].price, filler_address)?; UsedCommitments::::insert(&v.commitment, true); Self::maybe_clear_stale_prices(); - VerifiedPrices::::mutate(&pair_id, |entries| { - entries.push(PriceEntry { submitter: submitter.clone(), price, timestamp: now }); + VerifiedPrices::::mutate(&pair_id, |stored| { + stored.extend(entries.iter().map(|input| PriceEntry { + filler: filler_address, + range_start: input.range_start, + range_end: input.range_end, + price: input.price, + timestamp: now, + })); }); - Self::deposit_event(Event::PriceSubmitted { filler: submitter, pair_id, price }); + Self::deposit_event(Event::PriceSubmitted { filler: submitter, pair_id }); Ok(()) } @@ -759,7 +778,7 @@ pub mod pallet { fn submit_unverified_price( submitter: T::AccountId, pair_id: H256, - price: U256, + entries: BoundedVec, now: u64, ) -> DispatchResult { let max = MaxUnverifiedSubmissions::::get(); @@ -776,14 +795,25 @@ pub mod pallet { Self::maybe_clear_stale_prices(); - UnverifiedPrices::::mutate(&pair_id, |entries| { - if entries.len() >= max as usize { - entries.remove(0); + UnverifiedPrices::::mutate(&pair_id, |stored| { + let new_entries = entries.iter().map(|input| PriceEntry { + filler: H160::zero(), + range_start: input.range_start, + range_end: input.range_end, + price: input.price, + timestamp: now, + }); + + let total = stored.len() + entries.len(); + if total > max as usize { + let drain_count = total - max as usize; + stored.drain(..drain_count.min(stored.len())); } - entries.push(PriceEntry { submitter: submitter.clone(), price, timestamp: now }); + + stored.extend(new_entries); }); - Self::deposit_event(Event::UnverifiedPriceSubmitted { submitter, pair_id, price }); + Self::deposit_event(Event::UnverifiedPriceSubmitted { submitter, pair_id }); Ok(()) } @@ -861,6 +891,10 @@ pub mod pallet { ensure!(proof_gap <= threshold, Error::::ProofNotFresh); let age = now.saturating_sub(commitment_h2.timestamp); ensure!(age <= threshold, Error::::ProofNotFresh); + ensure!( + commitment_h2.timestamp.saturating_sub(now) <= threshold, + Error::::ProofNotFresh + ); Ok(filler_address) } diff --git a/modules/pallets/intents-coprocessor/src/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index 4c4cbe849..ce58c10eb 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -251,6 +251,7 @@ impl pallet_intents::Config for Test { type StorageDepositFee = StorageDepositFee; type GovernanceOrigin = EnsureRoot; type TreasuryAccount = TreasuryAccount; + type MaxPriceEntries = ConstU32<100>; type WeightInfo = (); } @@ -664,7 +665,9 @@ fn remove_recognized_pair_works() { VerifiedPrices::::insert( &pair_id, vec![types::PriceEntry { - submitter: AccountId32::new([1; 32]), + filler: H160::from_low_u64_be(1), + range_start: U256::zero(), + range_end: U256::from(999), price: U256::from(1000), timestamp: 1000, }], @@ -672,7 +675,9 @@ fn remove_recognized_pair_works() { UnverifiedPrices::::insert( &pair_id, vec![types::PriceEntry { - submitter: AccountId32::new([2; 32]), + filler: H160::zero(), + range_start: U256::zero(), + range_end: U256::from(999), price: U256::from(500), timestamp: 1000, }], @@ -774,22 +779,32 @@ fn submit_pair_price_verified() { evm_signature: Signature::Evm { address: evm_address.clone(), signature }, }; + let entries = BoundedVec::try_from(vec![types::PriceInput { + range_start: U256::zero(), + range_end: U256::from(999), + price, + }]) + .unwrap(); + assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(filler.clone()), pair_id, - price, + entries, Some(verification), )); // Verify the verified price entry was stored let verified = VerifiedPrices::::get(&pair_id); assert_eq!(verified.len(), 1); + assert_eq!(verified[0].filler, filler_h160); + assert_eq!(verified[0].range_start, U256::zero()); + assert_eq!(verified[0].range_end, U256::from(999)); assert_eq!(verified[0].price, price); // Verify the EVM nonce was incremented assert_eq!(EvmNonces::::get(filler_h160), 1); - // Submit a second price with nonce=1 + // Submit a second price with nonce=1 for a different range let price2 = U256::from(4000); let commitment2 = H256::repeat_byte(0xbb); @@ -809,10 +824,17 @@ fn submit_pair_price_verified() { evm_signature: Signature::Evm { address: evm_address.clone(), signature: signature2 }, }; + let entries2 = BoundedVec::try_from(vec![types::PriceInput { + range_start: U256::from(1000), + range_end: U256::from(5000), + price: price2, + }]) + .unwrap(); + assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(filler.clone()), pair_id, - price2, + entries2, Some(verification2), )); @@ -842,11 +864,18 @@ fn submit_pair_price_verified() { }, }; + let entries_dup = BoundedVec::try_from(vec![types::PriceInput { + range_start: U256::zero(), + range_end: U256::from(999), + price: U256::from(9999), + }]) + .unwrap(); + assert_noop!( Intents::submit_pair_price( RuntimeOrigin::signed(filler.clone()), pair_id, - U256::from(9999), + entries_dup, Some(verification_dup), ), Error::::CommitmentAlreadyUsed @@ -872,10 +901,17 @@ fn submit_pair_price_unverified() { let balance_before = Balances::free_balance(&submitter); // Submit unverified price (no verification data) + let entries = BoundedVec::try_from(vec![PriceInput { + range_start: U256::zero(), + range_end: U256::from(999), + price, + }]) + .unwrap(); + assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, - price, + entries, None, )); @@ -910,30 +946,42 @@ fn unverified_prices_fifo_replacement() { // Submit 3 unverified prices (fills the cap) for i in 1..=3u64 { + let input = BoundedVec::try_from(vec![PriceInput { + range_start: U256::zero(), + range_end: U256::from(999), + price: U256::from(i * 1000), + }]) + .unwrap(); assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, - U256::from(i * 1000), + input, None, )); } - let entries = UnverifiedPrices::::get(&pair_id); - assert_eq!(entries.len(), 3); - assert_eq!(entries[0].price, U256::from(1000)); // oldest + let stored = UnverifiedPrices::::get(&pair_id); + assert_eq!(stored.len(), 3); + assert_eq!(stored[0].price, U256::from(1000)); // oldest // Submit 4th — should pop the oldest (1000) and add new + let input4 = BoundedVec::try_from(vec![PriceInput { + range_start: U256::zero(), + range_end: U256::from(999), + price: U256::from(4000), + }]) + .unwrap(); assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, - U256::from(4000), + input4, None, )); - let entries = UnverifiedPrices::::get(&pair_id); - assert_eq!(entries.len(), 3); - assert_eq!(entries[0].price, U256::from(2000)); // 1000 was popped - assert_eq!(entries[2].price, U256::from(4000)); // new entry at end + let stored = UnverifiedPrices::::get(&pair_id); + assert_eq!(stored.len(), 3); + assert_eq!(stored[0].price, U256::from(2000)); // 1000 was popped + assert_eq!(stored[2].price, U256::from(4000)); // new entry at end }); } @@ -950,7 +998,9 @@ fn prices_persist_across_window_and_clear_on_first_submission() { VerifiedPrices::::insert( &pair_id, vec![types::PriceEntry { - submitter: AccountId32::new([1; 32]), + filler: H160::from_low_u64_be(1), + range_start: U256::zero(), + range_end: U256::from(999), price: U256::from(1666), timestamp: 1000, }], @@ -958,7 +1008,9 @@ fn prices_persist_across_window_and_clear_on_first_submission() { UnverifiedPrices::::insert( &pair_id, vec![types::PriceEntry { - submitter: AccountId32::new([2; 32]), + filler: H160::zero(), + range_start: U256::zero(), + range_end: U256::from(999), price: U256::from(1500), timestamp: 1000, }], @@ -987,10 +1039,16 @@ fn prices_persist_across_window_and_clear_on_first_submission() { // Now submit an unverified price, this is the first submission in the new window. // It should clear stale entries for this pair before adding the new one. + let new_entries = BoundedVec::try_from(vec![PriceInput { + range_start: U256::zero(), + range_end: U256::from(999), + price: U256::from(2000), + }]) + .unwrap(); assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, - U256::from(2000), + new_entries, None, )); @@ -1004,29 +1062,33 @@ fn prices_persist_across_window_and_clear_on_first_submission() { #[test] fn price_entry_encoding_matches_rpc_tuple_decoding() { - // The RPC decodes PriceEntry as Vec<(AccountId32, U256, u64)>. + // The RPC decodes PriceEntry as Vec<(H160, U256, U256, U256, u64)>. // Verify that PriceEntry's SCALE encoding is identical to the tuple encoding. use codec::Encode; - let submitter = AccountId32::new([1; 32]); + let filler = H160::from_low_u64_be(42); + let range_start = U256::zero(); + let range_end = U256::from(999); let price = U256::from(42_000); let timestamp = 1_700_000_000u64; - let entry = types::PriceEntry { submitter: submitter.clone(), price, timestamp }; + let entry = PriceEntry { filler, range_start, range_end, price, timestamp }; let entry_bytes = entry.encode(); - let tuple_bytes = (submitter.clone(), price, timestamp).encode(); + let tuple_bytes = (filler, range_start, range_end, price, timestamp).encode(); assert_eq!(entry_bytes, tuple_bytes, "PriceEntry SCALE encoding must match tuple encoding"); // Also verify round-trip: encode as PriceEntry, decode as tuple - type RpcTuple = (AccountId32, U256, u64); + type RpcTuple = (H160, U256, U256, U256, u64); let entries = vec![entry]; let encoded = entries.encode(); let decoded: Vec = Decode::decode(&mut &encoded[..]).unwrap(); assert_eq!(decoded.len(), 1); - assert_eq!(decoded[0].0, submitter); - assert_eq!(decoded[0].1, price); - assert_eq!(decoded[0].2, timestamp); + assert_eq!(decoded[0].0, filler); + assert_eq!(decoded[0].1, range_start); + assert_eq!(decoded[0].2, range_end); + assert_eq!(decoded[0].3, price); + assert_eq!(decoded[0].4, timestamp); } #[test] @@ -1038,12 +1100,16 @@ fn price_entry_storage_roundtrip_via_raw_key() { let pair_id = pair.pair_id(); let entry1 = types::PriceEntry { - submitter: AccountId32::new([1; 32]), + filler: H160::from_low_u64_be(1), + range_start: U256::zero(), + range_end: U256::from(999), price: U256::from(2000), timestamp: 1000, }; let entry2 = types::PriceEntry { - submitter: AccountId32::new([2; 32]), + filler: H160::from_low_u64_be(2), + range_start: U256::from(1000), + range_end: U256::from(5000), price: U256::from(3000), timestamp: 2000, }; @@ -1052,7 +1118,9 @@ fn price_entry_storage_roundtrip_via_raw_key() { UnverifiedPrices::::insert( &pair_id, vec![types::PriceEntry { - submitter: AccountId32::new([3; 32]), + filler: H160::zero(), + range_start: U256::zero(), + range_end: U256::from(999), price: U256::from(1500), timestamp: 500, }], @@ -1072,15 +1140,19 @@ fn price_entry_storage_roundtrip_via_raw_key() { let raw = sp_io::storage::get(&key).expect("VerifiedPrices storage should exist"); // Decode as the RPC would - type RpcTuple = (AccountId32, U256, u64); + type RpcTuple = (H160, U256, U256, U256, u64); let decoded: Vec = Decode::decode(&mut &raw[..]).unwrap(); assert_eq!(decoded.len(), 2); - assert_eq!(decoded[0].0, AccountId32::new([1; 32])); - assert_eq!(decoded[0].1, U256::from(2000)); - assert_eq!(decoded[0].2, 1000u64); - assert_eq!(decoded[1].0, AccountId32::new([2; 32])); - assert_eq!(decoded[1].1, U256::from(3000)); - assert_eq!(decoded[1].2, 2000u64); + assert_eq!(decoded[0].0, H160::from_low_u64_be(1)); + assert_eq!(decoded[0].1, U256::zero()); + assert_eq!(decoded[0].2, U256::from(999)); + assert_eq!(decoded[0].3, U256::from(2000)); + assert_eq!(decoded[0].4, 1000u64); + assert_eq!(decoded[1].0, H160::from_low_u64_be(2)); + assert_eq!(decoded[1].1, U256::from(1000)); + assert_eq!(decoded[1].2, U256::from(5000)); + assert_eq!(decoded[1].3, U256::from(3000)); + assert_eq!(decoded[1].4, 2000u64); // Do the same for UnverifiedPrices let mut ukey = Vec::new(); @@ -1092,8 +1164,10 @@ fn price_entry_storage_roundtrip_via_raw_key() { let uraw = sp_io::storage::get(&ukey).expect("UnverifiedPrices storage should exist"); let udecoded: Vec = Decode::decode(&mut &uraw[..]).unwrap(); assert_eq!(udecoded.len(), 1); - assert_eq!(udecoded[0].0, AccountId32::new([3; 32])); - assert_eq!(udecoded[0].1, U256::from(1500)); - assert_eq!(udecoded[0].2, 500u64); + assert_eq!(udecoded[0].0, H160::zero()); + assert_eq!(udecoded[0].1, U256::zero()); + assert_eq!(udecoded[0].2, U256::from(999)); + assert_eq!(udecoded[0].3, U256::from(1500)); + assert_eq!(udecoded[0].4, 500u64); }); } diff --git a/modules/pallets/intents-coprocessor/src/types.rs b/modules/pallets/intents-coprocessor/src/types.rs index 40e6c7463..6bfa383df 100644 --- a/modules/pallets/intents-coprocessor/src/types.rs +++ b/modules/pallets/intents-coprocessor/src/types.rs @@ -164,12 +164,30 @@ impl TokenPair { } } -/// An individual price submission stored on-chain +/// Caller-provided price data for a specific range of base token amounts. +/// The pallet fills in the filler address and timestamp when storing. #[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] -pub struct PriceEntry { - /// The submitter's substrate account - pub submitter: AccountId, - /// The submitted price +pub struct PriceInput { + /// Lower bound of the base token amount range (inclusive), with 18 decimal places + pub range_start: U256, + /// Upper bound of the base token amount range (inclusive), with 18 decimal places + pub range_end: U256, + /// The price of the base token in the quote token, with 18 decimal places + pub price: U256, +} + +/// An individual price submission stored on-chain. The price applies to a specific +/// range of base token amounts, allowing fillers to quote different rates for +/// different order sizes (e.g. USDC/CNGN: 0-999 -> 1414, 1000-5000 -> 1420). +#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] +pub struct PriceEntry { + /// The filler's EVM address. Set to `H160::zero()` for unverified submissions. + pub filler: H160, + /// Lower bound of the base token amount range (inclusive), with 18 decimal places + pub range_start: U256, + /// Upper bound of the base token amount range (inclusive), with 18 decimal places + pub range_end: U256, + /// The price of the base token in the quote token, with 18 decimal places pub price: U256, /// Timestamp of submission (seconds) pub timestamp: u64, diff --git a/parachain/runtimes/gargantua/src/ismp.rs b/parachain/runtimes/gargantua/src/ismp.rs index 9eac57535..a2dab8e33 100644 --- a/parachain/runtimes/gargantua/src/ismp.rs +++ b/parachain/runtimes/gargantua/src/ismp.rs @@ -93,6 +93,7 @@ impl pallet_intents_coprocessor::Config for Runtime { type StorageDepositFee = IntentStorageDepositFee; type GovernanceOrigin = EnsureRoot; type TreasuryAccount = TreasuryAccount; + type MaxPriceEntries = ConstU32<10>; type WeightInfo = weights::pallet_intents_coprocessor::WeightInfo; } diff --git a/parachain/runtimes/nexus/src/ismp.rs b/parachain/runtimes/nexus/src/ismp.rs index 2eee4cd94..14f8fbd8c 100644 --- a/parachain/runtimes/nexus/src/ismp.rs +++ b/parachain/runtimes/nexus/src/ismp.rs @@ -434,6 +434,7 @@ impl pallet_intents_coprocessor::Config for Runtime { >, >; type TreasuryAccount = TreasuryAccount; + type MaxPriceEntries = ConstU32<10>; type WeightInfo = weights::pallet_intents_coprocessor::WeightInfo; } impl IsmpModule for ProxyModule { From 6b7dcc14e1bcacc7bc5c616eb3ba46767a1ee5fa Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Fri, 13 Mar 2026 22:24:45 +0100 Subject: [PATCH 07/28] cross-chain filler verification by inspecting RedeemEscrow messages in the ProxyModule to passively build a VerifiedFillers map, enabling a third price submission path that requires only an EVM signature instead of full ISMP state proofs. --- .../intent-gateway/price-submission.mdx | 59 +++- .../pallets/intents-coprocessor/src/lib.rs | 126 +++++++- .../pallets/intents-coprocessor/src/tests.rs | 304 +++++++++++++++++- .../pallets/intents-coprocessor/src/types.rs | 64 ++++ parachain/runtimes/gargantua/src/ismp.rs | 7 +- parachain/runtimes/nexus/src/ismp.rs | 9 +- 6 files changed, 531 insertions(+), 38 deletions(-) diff --git a/docs/content/developers/intent-gateway/price-submission.mdx b/docs/content/developers/intent-gateway/price-submission.mdx index a0d789b1f..70762261d 100644 --- a/docs/content/developers/intent-gateway/price-submission.mdx +++ b/docs/content/developers/intent-gateway/price-submission.mdx @@ -1,6 +1,6 @@ --- title: Price Submission Protocol -description: Dual-path price submission system for the intents coprocessor pallet +description: Three-path price submission system for the intents coprocessor pallet --- # Price Submission Protocol @@ -11,31 +11,63 @@ The intents system needs on-chain price data for token pairs to function correct ## Protocol Overview -The `submit_pair_price` extrinsic on the intents coprocessor pallet accepts price submissions for governance-approved token pairs. It supports two confidence levels through a single entry point, distinguished by an optional `verification` parameter. All prices are assumed to have 18 decimal places. +The `submit_pair_price` extrinsic on the intents coprocessor pallet accepts price submissions for governance-approved token pairs. It supports three submission modes through a single entry point via the `mode` parameter. All prices and amount ranges are assumed to have 18 decimal places. -### Verified Prices (High Confidence) +Submissions are batched: each call accepts a `BoundedVec` where each entry specifies a base token amount range and the corresponding price. This allows fillers to quote different rates for different order sizes in a single transaction (for example, USDC/CNGN: 0 to 999 at 1414, 1000 to 5000 at 1420). The maximum number of entries per submission is a compile-time constant configurable per runtime via the `MaxPriceEntries` associated type. + +### Price Entries + +Each `PriceInput` contains three fields: + +- `range_start`: the lower bound of the base token amount range (inclusive), with 18 decimal places. +- `range_end`: the upper bound of the base token amount range (inclusive), with 18 decimal places. +- `price`: the price of the base token in terms of the quote token, with 18 decimal places. + +The pallet validates that `range_start <= range_end` for every entry and rejects submissions where this invariant is violated. It also rejects empty submissions. + +When stored on-chain, each entry becomes a `PriceEntry` which adds the filler's EVM address (set to zero for unverified submissions) and the submission timestamp. + +### Submission Modes + +The `mode` parameter is an enum with three variants: + +- `StateProof`: full verification with ISMP state proofs and an EVM signature. +- `CrossChain`: lightweight verification for fillers already recognized through cross-chain message inspection, requiring only an EVM signature. +- `Unverified`: no verification; anyone can submit by paying a fee. + +Both `StateProof` and `CrossChain` submissions are stored as verified (high confidence) prices. `Unverified` submissions are stored separately as low confidence prices. + +### State Proof Verification (High Confidence) Fillers who have actually filled orders on the IntentGateway contract submit prices along with cryptographic proof of their fill. This path exists because fillers are the most trustworthy source of price data. They have real skin in the game and their prices reflect actual market activity. -A verified submission requires three things: +A state proof submission requires three things: 1. **Two ISMP state proofs** against the IntentGateway contract's `_filled` mapping. A non-membership proof at height H1 shows the order was not yet filled, and a membership proof at height H2 shows it was filled. Together these bracket when the fill occurred and extract the filler's EVM address from storage. -2. **Proof freshness.** Both the gap between H1 and H2, and the age of H2 relative to the current time, must fall within a governance-configured threshold. This prevents stale or manufactured proofs from being accepted. +2. **Proof freshness.** The gap between H1 and H2, the age of H2 relative to the current time, and the difference between H2's timestamp and the current on-chain time must all fall within a governance-configured threshold. This check ensures we are getting verified price data from fillers that are actually active and have filled orders very recently, allowing us to offer high confidence rates to users. -3. **An EVM signature** over `keccak256(SCALE_encode(nonce, pair_id, price))`. This proves the substrate account submitting the price actually controls the EVM account that performed the fill. The nonce is tracked on-chain per EVM address to prevent signature replay. This pattern follows the existing approach in `pallet-ismp-relayer`. +3. **An EVM signature** over `keccak256(SCALE_encode(nonce, pair_id, price))`, where `price` is taken from the first entry in the batch. This proves the substrate account submitting the prices actually controls the EVM account that performed the fill. The nonce is tracked on-chain per EVM address to prevent signature replay. This pattern follows the existing approach in `pallet-ismp-relayer`. The 52-byte proof key binds verification to a specific gateway contract. The first 20 bytes are the gateway address (used by the EVM state machine client to locate the contract in the world state trie) and the last 32 bytes are the hashed storage slot for `_filled[commitment]`. -Verified prices are stored in `VerifiedPrices` with no cap. The goal is to accumulate as many real data points as possible within each price window. +### Cross-Chain Verification (High Confidence) + +When a filler completes a cross-chain order, the destination chain's IntentGateway dispatches a `RedeemEscrow` message back to the source chain via Hyperbridge. Because Hyperbridge routes all cross-chain messages through its `ProxyModule`, the intents coprocessor pallet can passively inspect these messages as they flow through. + +The `inspect_request` method is called in the `ProxyModule::on_accept` hook for every relayed request. It checks whether the sender is a known IntentGateway (by comparing the `from` address against the `Gateways` storage map), and if so, decodes the request body. When the first byte is `0x00` (the `RedeemEscrow` discriminator), the body is ABI-decoded as a `WithdrawalRequest` and the filler's EVM address is extracted from the `beneficiary` field (a `bytes32` containing the 20-byte address right-aligned). + +The extracted filler address is stored in a `VerifiedFillers` map along with the current timestamp. This serves as a lightweight, passive proof that the filler is genuinely active. When the filler later wants to submit prices, they use the `CrossChain` submission mode, which only requires an EVM signature (no state proofs). The pallet checks that the filler exists in `VerifiedFillers` and that their verification timestamp is within the freshness threshold. + +This path is particularly useful for cross-chain fills where the filler's activity is already visible to Hyperbridge without requiring any additional proof submission. ### Unverified Prices (Low Confidence) -Anyone can submit a price without proofs by paying a fee in bridge tokens. This path exists to maintain price data availability even when verified submissions are sparse. The fee, which is transferred to the treasury, discourages spam while keeping the door open for market participants who have price information but haven't filled orders themselves. +Anyone can submit prices without proofs by paying a fee in bridge tokens. This path exists to maintain price data availability even when verified submissions are sparse. The fee, which is transferred to the treasury, discourages spam while keeping the door open for market participants who have price information but have not filled orders themselves. -Unverified prices are stored separately in `UnverifiedPrices` and capped per pair at `MaxUnverifiedSubmissions`. When the cap is reached, the oldest entry is replaced using a first-in, first-out policy. Both the fee and the cap must be configured via governance for unverified submissions to be accepted. +Unverified prices are stored separately in `UnverifiedPrices` and capped per pair at `MaxUnverifiedSubmissions`. When a batch submission would exceed the cap, the oldest entries are drained in bulk to make room, following a first-in, first-out policy. Both the fee and the cap must be configured via governance for unverified submissions to be accepted. The filler field is set to `H160::zero()` for unverified entries since no EVM address verification is performed. -### Why Two Confidence Levels +### Why Separate Confidence Levels Keeping verified and unverified prices separate lets consumers make informed decisions. A DEX might only trust verified prices for settlement, while a UI might display both to give users a fuller picture. Mixing them into a single pool would dilute the signal from proven fills. @@ -45,7 +77,7 @@ Prices are organized into daily windows, which are governance-configurable via ` The `on_initialize` hook runs every block and checks whether the current window has expired. When it has, it clears `UsedCommitments` (which is safe because the freshness threshold independently rejects stale proofs) and resets a `PricesClearedThisWindow` flag to false. -Prices from the previous window are not cleared immediately. They persist so that consumers can still read yesterday's data. On the first new submission in the new window (verified or unverified), all price entries across all pairs are cleared before the new entry is stored. This lazy clearing approach avoids the cost of iterating all pairs in `on_initialize` while ensuring stale data is replaced as soon as fresh data arrives. +Prices from the previous window are not cleared immediately. They persist so that consumers can still read the previous window's data. On the first new submission in the new window (verified or unverified), all price entries across all pairs are cleared before the new entries are stored. This lazy clearing approach avoids the cost of iterating all pairs in `on_initialize` while ensuring stale data is replaced as soon as fresh data arrives. ### Why Lazy Clearing @@ -57,14 +89,15 @@ Only governance-approved token pairs can receive price submissions. This prevent ## RPC -The `intents_getPairPrices(pair_id)` RPC endpoint returns verified and unverified prices separately, allowing consumers to apply their own weighting or filtering based on confidence level. +The `intents_getPairPrices(pair_id)` RPC endpoint returns verified and unverified prices separately, allowing consumers to apply their own weighting or filtering based on confidence level. Each returned entry includes the filler's EVM address, the amount range (`range_start`, `range_end`), the price, and the submission timestamp. ## Governance Parameters All key parameters are stored on-chain and updatable via governance extrinsics: - `PriceWindowDurationValue` is the length of the price window in milliseconds. -- `ProofFreshnessThresholdValue` is the maximum allowed age and gap for state proofs in seconds. +- `ProofFreshnessThresholdValue` is the maximum allowed age and gap for state proofs and cross-chain filler verification, in seconds. - `MaxUnverifiedSubmissions` is the cap on unverified entries per pair. - `UnverifiedSubmissionFee` is the fee charged for unverified submissions. +- `MaxPriceEntries` is the compile-time maximum number of price entries per submission, configurable per runtime. - Recognized token pairs determine which pairs accept submissions. diff --git a/modules/pallets/intents-coprocessor/src/lib.rs b/modules/pallets/intents-coprocessor/src/lib.rs index af4500045..2be4cc84a 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -175,6 +175,12 @@ pub mod pallet { #[pallet::storage] pub type UnverifiedSubmissionFee = StorageValue<_, BalanceOf, ValueQuery>; + /// Fillers verified through cross-chain message inspection. + /// Maps filler EVM address to the timestamp (seconds) when they were last seen + /// filling an order via a RedeemEscrow message routed through Hyperbridge. + #[pallet::storage] + pub type VerifiedFillers = StorageMap<_, Blake2_128Concat, H160, u64, OptionQuery>; + /// Whether prices have been cleared in the current window. /// Reset to false by `on_initialize` when a new window starts. /// Set to true on the first price submission in the new window. @@ -301,6 +307,10 @@ pub mod pallet { InvalidPriceRange, /// No price entries were provided EmptyPriceEntries, + /// The filler is not in the VerifiedFillers map + FillerNotVerified, + /// The filler's cross-chain verification has expired + FillerVerificationExpired, } #[pallet::call] @@ -584,12 +594,16 @@ pub mod pallet { /// Submit prices for a recognized token pair across one or more amount ranges. /// - /// This extrinsic supports two submission modes. When `verification` is provided, - /// the submission is treated as high confidence: the filler supplies ISMP state - /// proofs and an EVM signature to prove they filled the order and own the - /// submitting account (bound by an on-chain nonce). When `verification` is - /// `None`, anyone may submit a low confidence price by paying bridge tokens. - /// Unverified entries are capped per pair with FIFO replacement. + /// This extrinsic supports three submission modes via the `mode` parameter: + /// + /// - `StateProof`: High confidence. The filler supplies ISMP state proofs and an EVM + /// signature to prove they filled the order and own the submitting account (bound by an + /// on-chain nonce). + /// - `CrossChain`: High confidence. The filler was passively verified by the IntentGateway + /// inspector when their RedeemEscrow message flowed through Hyperbridge. Only an EVM + /// signature is required (no state proofs). + /// - `Unverified`: Low confidence. Anyone may submit by paying bridge tokens. Unverified + /// entries are capped per pair with FIFO replacement. /// /// Each entry in `entries` specifies a base token amount range and the /// corresponding price of the base token in terms of the quote token. @@ -601,7 +615,7 @@ pub mod pallet { origin: OriginFor, pair_id: H256, entries: BoundedVec, - verification: Option, + mode: types::PriceSubmissionMode, ) -> DispatchResult { let submitter = ensure_signed(origin)?; @@ -614,10 +628,13 @@ pub mod pallet { let now = T::Dispatcher::default().timestamp().as_secs(); - if let Some(v) = verification { - Self::submit_verified_price(submitter, pair_id, entries, v, now)?; - } else { - Self::submit_unverified_price(submitter, pair_id, entries, now)?; + match mode { + types::PriceSubmissionMode::StateProof(v) => + Self::submit_verified_price(submitter, pair_id, entries, v, now)?, + types::PriceSubmissionMode::CrossChain(v) => + Self::submit_cross_chain_verified_price(submitter, pair_id, entries, v, now)?, + types::PriceSubmissionMode::Unverified => + Self::submit_unverified_price(submitter, pair_id, entries, now)?, } Ok(()) @@ -946,6 +963,93 @@ pub mod pallet { } } + /// Inspect a cross-chain request flowing through Hyperbridge. + /// + /// If the request is a RedeemEscrow from a known IntentGateway, extract the + /// filler's EVM address and store it in `VerifiedFillers` with the current + /// timestamp. This allows the filler to later submit prices without full + /// ISMP state proofs. + pub fn inspect_request(post: &ismp::router::PostRequest) -> Result<(), ismp::Error> { + // The `from` field must be at least 20 bytes (an EVM address) + if post.from.len() < 20 { + return Ok(()); + } + + let sender = H160::from_slice(&post.from[..20]); + let _gateway_info = match Gateways::::get(post.source) { + Some(info) if info.gateway == sender => info, + _ => return Ok(()), + }; + + if let Some(filler) = types::extract_filler_from_redeem(&post.body) { + let now = T::Dispatcher::default().timestamp().as_secs(); + VerifiedFillers::::insert(filler, now); + + log::info!( + target: "pallet-intents", + "Cross-chain verified filler {:?} from {:?}", + filler, + post.source, + ); + } + + Ok(()) + } + + /// Process a cross-chain verified price submission. + /// + /// The filler must be in the `VerifiedFillers` map with a timestamp within + /// the freshness threshold. An EVM signature is still required to prove + /// ownership of the filler address. + fn submit_cross_chain_verified_price( + submitter: T::AccountId, + pair_id: H256, + entries: BoundedVec, + v: types::CrossChainVerificationData, + now: u64, + ) -> DispatchResult { + let evm_address_bytes = match &v.evm_signature { + crypto_utils::verification::Signature::Evm { address, .. } => address.clone(), + _ => return Err(Error::::InvalidSignature.into()), + }; + + ensure!(evm_address_bytes.len() == 20, Error::::InvalidSignature); + let filler_address = H160::from_slice(&evm_address_bytes); + + let verified_at = + VerifiedFillers::::get(filler_address).ok_or(Error::::FillerNotVerified)?; + + // Check freshness of the verification + let threshold = ProofFreshnessThresholdValue::::get(); + let age = now.saturating_sub(verified_at); + ensure!(age <= threshold, Error::::FillerVerificationExpired); + + let nonce = EvmNonces::::get(filler_address); + let msg = types::price_signature_message(nonce, &pair_id, &entries[0].price); + + let recovered = + v.evm_signature.verify(&msg, None).map_err(|_| Error::::InvalidSignature)?; + ensure!(recovered == evm_address_bytes, Error::::SignerMismatch); + + EvmNonces::::insert(filler_address, nonce.saturating_add(1)); + + Self::maybe_clear_stale_prices(); + + VerifiedPrices::::mutate(&pair_id, |stored| { + stored.extend(entries.iter().map(|input| PriceEntry { + filler: filler_address, + range_start: input.range_start, + range_end: input.range_end, + price: input.price, + timestamp: now, + })); + }); + + Self::deposit_event(Event::PriceSubmitted { filler: submitter, pair_id }); + + Ok(()) + } + /// 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/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index ce58c10eb..e143360d8 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -790,7 +790,7 @@ fn submit_pair_price_verified() { RuntimeOrigin::signed(filler.clone()), pair_id, entries, - Some(verification), + types::PriceSubmissionMode::StateProof(verification), )); // Verify the verified price entry was stored @@ -835,7 +835,7 @@ fn submit_pair_price_verified() { RuntimeOrigin::signed(filler.clone()), pair_id, entries2, - Some(verification2), + types::PriceSubmissionMode::StateProof(verification2), )); // Verify two entries now stored @@ -876,7 +876,7 @@ fn submit_pair_price_verified() { RuntimeOrigin::signed(filler.clone()), pair_id, entries_dup, - Some(verification_dup), + types::PriceSubmissionMode::StateProof(verification_dup), ), Error::::CommitmentAlreadyUsed ); @@ -912,7 +912,7 @@ fn submit_pair_price_unverified() { RuntimeOrigin::signed(submitter.clone()), pair_id, entries, - None, + types::PriceSubmissionMode::Unverified, )); // Verify fee was charged @@ -956,7 +956,7 @@ fn unverified_prices_fifo_replacement() { RuntimeOrigin::signed(submitter.clone()), pair_id, input, - None, + types::PriceSubmissionMode::Unverified, )); } @@ -975,7 +975,7 @@ fn unverified_prices_fifo_replacement() { RuntimeOrigin::signed(submitter.clone()), pair_id, input4, - None, + types::PriceSubmissionMode::Unverified, )); let stored = UnverifiedPrices::::get(&pair_id); @@ -1049,7 +1049,7 @@ fn prices_persist_across_window_and_clear_on_first_submission() { RuntimeOrigin::signed(submitter.clone()), pair_id, new_entries, - None, + types::PriceSubmissionMode::Unverified, )); // Old entries are gone, only the new unverified entry remains @@ -1171,3 +1171,293 @@ fn price_entry_storage_roundtrip_via_raw_key() { assert_eq!(udecoded[0].4, 500u64); }); } + +#[test] +fn extract_filler_from_redeem_decodes_correctly() { + use alloy_sol_types::SolValue; + + let filler_address = H160::from_low_u64_be(0xdeadbeef); + + // Build a RedeemEscrow body: [0x00] + abi.encode(WithdrawalRequest) + let commitment = alloy_primitives::FixedBytes::<32>::from([0xaa; 32]); + // beneficiary = bytes32(uint256(uint160(filler))) + let mut beneficiary_bytes = [0u8; 32]; + beneficiary_bytes[12..32].copy_from_slice(&filler_address.0); + let beneficiary = alloy_primitives::FixedBytes::<32>::from(beneficiary_bytes); + + let withdrawal = ( + commitment, + beneficiary, + Vec::<(alloy_primitives::FixedBytes<32>, alloy_primitives::U256)>::new(), + ); + let mut body = vec![0x00u8]; // RedeemEscrow discriminator + body.extend_from_slice(&withdrawal.abi_encode()); + + let extracted = types::extract_filler_from_redeem(&body); + assert_eq!(extracted, Some(filler_address)); +} + +#[test] +fn extract_filler_from_redeem_rejects_non_redeem() { + use alloy_sol_types::SolValue; + + let commitment = alloy_primitives::FixedBytes::<32>::from([0xaa; 32]); + let beneficiary = alloy_primitives::FixedBytes::<32>::from([0xbb; 32]); + let withdrawal = ( + commitment, + beneficiary, + Vec::<(alloy_primitives::FixedBytes<32>, alloy_primitives::U256)>::new(), + ); + + // Use discriminator 0x01 (NewDeployment), not RedeemEscrow + let mut body = vec![0x01u8]; + body.extend_from_slice(&withdrawal.abi_encode()); + + assert_eq!(types::extract_filler_from_redeem(&body), None); +} + +#[test] +fn inspect_request_adds_verified_filler() { + new_test_ext().execute_with(|| { + use alloy_sol_types::SolValue; + + let state_machine = StateMachine::Evm(1); + let gateway = H160::from_low_u64_be(42); + let params = types::IntentGatewayParams { + host: H160::default(), + dispatcher: H160::default(), + solver_selection: true, + surplus_share_bps: U256::from(5000), + protocol_fee_bps: U256::from(100), + price_oracle: H160::default(), + }; + assert_ok!(Intents::add_deployment(RuntimeOrigin::root(), state_machine, gateway, params)); + + let filler_address = H160::from_low_u64_be(0xdeadbeef); + let mut beneficiary_bytes = [0u8; 32]; + beneficiary_bytes[12..32].copy_from_slice(&filler_address.0); + + let commitment = alloy_primitives::FixedBytes::<32>::from([0xaa; 32]); + let beneficiary = alloy_primitives::FixedBytes::<32>::from(beneficiary_bytes); + let withdrawal = ( + commitment, + beneficiary, + Vec::<(alloy_primitives::FixedBytes<32>, alloy_primitives::U256)>::new(), + ); + + let mut body = vec![0x00u8]; + body.extend_from_slice(&withdrawal.abi_encode()); + + // Set timestamp + pallet_timestamp::Now::::put(2_000_000u64); // 2000 seconds + + let post = ismp::router::PostRequest { + source: state_machine, + dest: StateMachine::Evm(2), + nonce: 0, + from: gateway.0.to_vec(), + to: vec![0u8; 20], + timeout_timestamp: 0, + body, + }; + + assert_ok!(Intents::inspect_request(&post)); + + // Verify filler was added + let verified_at = VerifiedFillers::::get(filler_address); + assert!(verified_at.is_some()); + assert_eq!(verified_at.unwrap(), 2000); // 2_000_000ms / 1000 + }); +} + +#[test] +fn inspect_request_ignores_unknown_gateway() { + new_test_ext().execute_with(|| { + use alloy_sol_types::SolValue; + + let filler_address = H160::from_low_u64_be(0xdeadbeef); + let mut beneficiary_bytes = [0u8; 32]; + beneficiary_bytes[12..32].copy_from_slice(&filler_address.0); + + let commitment = alloy_primitives::FixedBytes::<32>::from([0xaa; 32]); + let beneficiary = alloy_primitives::FixedBytes::<32>::from(beneficiary_bytes); + let withdrawal = ( + commitment, + beneficiary, + Vec::<(alloy_primitives::FixedBytes<32>, alloy_primitives::U256)>::new(), + ); + + let mut body = vec![0x00u8]; + body.extend_from_slice(&withdrawal.abi_encode()); + + pallet_timestamp::Now::::put(2_000_000u64); + + // No gateway registered — sender is unknown + let unknown_gateway = H160::from_low_u64_be(999); + let post = ismp::router::PostRequest { + source: StateMachine::Evm(1), + dest: StateMachine::Evm(2), + nonce: 0, + from: unknown_gateway.0.to_vec(), + to: vec![0u8; 20], + timeout_timestamp: 0, + body, + }; + + assert_ok!(Intents::inspect_request(&post)); + + // Filler should NOT be added + assert!(VerifiedFillers::::get(filler_address).is_none()); + }); +} + +#[test] +fn submit_cross_chain_verified_price() { + new_test_ext().execute_with(|| { + let submitter = AccountId32::new([1; 32]); + let price = U256::from(2000); + + // Add a recognized token pair + let pair = + types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + let pair_id = pair.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + + // Create an EVM keypair + let evm_pair = + sp_core::ecdsa::Pair::from_seed_slice(H256::repeat_byte(0x42).as_bytes()).unwrap(); + let evm_address = evm_pair.public().to_eth_address().unwrap().to_vec(); + let filler_h160 = H160::from_slice(&evm_address); + + // Simulate the filler being verified via cross-chain inspection + // (timestamp 2000 seconds) + VerifiedFillers::::insert(filler_h160, 2000u64); + + // Set current time to 2500 seconds (within 3600s threshold) + pallet_timestamp::Now::::put(2_500_000u64); + + // Sign the price message + let nonce = 0u64; + let msg = types::price_signature_message(nonce, &pair_id, &price); + let signature = evm_pair.sign_prehashed(&msg).0.to_vec(); + + let cross_chain_data = types::CrossChainVerificationData { + evm_signature: Signature::Evm { address: evm_address.clone(), signature }, + }; + + let entries = BoundedVec::try_from(vec![types::PriceInput { + range_start: U256::zero(), + range_end: U256::from(999), + price, + }]) + .unwrap(); + + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(submitter.clone()), + pair_id, + entries, + types::PriceSubmissionMode::CrossChain(cross_chain_data), + )); + + // Verify stored as verified price + let verified = VerifiedPrices::::get(&pair_id); + assert_eq!(verified.len(), 1); + assert_eq!(verified[0].filler, filler_h160); + assert_eq!(verified[0].price, price); + + // Verify nonce incremented + assert_eq!(EvmNonces::::get(filler_h160), 1); + }); +} + +#[test] +fn cross_chain_verified_fails_when_filler_not_verified() { + new_test_ext().execute_with(|| { + let submitter = AccountId32::new([1; 32]); + let price = U256::from(2000); + + let pair = + types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + let pair_id = pair.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + + let evm_pair = + sp_core::ecdsa::Pair::from_seed_slice(H256::repeat_byte(0x42).as_bytes()).unwrap(); + let evm_address = evm_pair.public().to_eth_address().unwrap().to_vec(); + + pallet_timestamp::Now::::put(2_500_000u64); + + let msg = types::price_signature_message(0u64, &pair_id, &price); + let signature = evm_pair.sign_prehashed(&msg).0.to_vec(); + + let cross_chain_data = types::CrossChainVerificationData { + evm_signature: Signature::Evm { address: evm_address, signature }, + }; + + let entries = BoundedVec::try_from(vec![types::PriceInput { + range_start: U256::zero(), + range_end: U256::from(999), + price, + }]) + .unwrap(); + + // Filler not in VerifiedFillers — should fail + assert_noop!( + Intents::submit_pair_price( + RuntimeOrigin::signed(submitter), + pair_id, + entries, + types::PriceSubmissionMode::CrossChain(cross_chain_data), + ), + Error::::FillerNotVerified + ); + }); +} + +#[test] +fn cross_chain_verified_fails_when_expired() { + new_test_ext().execute_with(|| { + let submitter = AccountId32::new([1; 32]); + let price = U256::from(2000); + + let pair = + types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + let pair_id = pair.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + + let evm_pair = + sp_core::ecdsa::Pair::from_seed_slice(H256::repeat_byte(0x42).as_bytes()).unwrap(); + let evm_address = evm_pair.public().to_eth_address().unwrap().to_vec(); + let filler_h160 = H160::from_slice(&evm_address); + + // Verified at timestamp 1000 + VerifiedFillers::::insert(filler_h160, 1000u64); + + // Current time is 5000 seconds — threshold is 3600, so age = 4000 > 3600 + pallet_timestamp::Now::::put(5_000_000u64); + + let msg = types::price_signature_message(0u64, &pair_id, &price); + let signature = evm_pair.sign_prehashed(&msg).0.to_vec(); + + let cross_chain_data = types::CrossChainVerificationData { + evm_signature: Signature::Evm { address: evm_address, signature }, + }; + + let entries = BoundedVec::try_from(vec![types::PriceInput { + range_start: U256::zero(), + range_end: U256::from(999), + price, + }]) + .unwrap(); + + assert_noop!( + Intents::submit_pair_price( + RuntimeOrigin::signed(submitter), + pair_id, + entries, + types::PriceSubmissionMode::CrossChain(cross_chain_data), + ), + Error::::FillerVerificationExpired + ); + }); +} diff --git a/modules/pallets/intents-coprocessor/src/types.rs b/modules/pallets/intents-coprocessor/src/types.rs index 6bfa383df..86e86683b 100644 --- a/modules/pallets/intents-coprocessor/src/types.rs +++ b/modules/pallets/intents-coprocessor/src/types.rs @@ -213,6 +213,32 @@ pub struct PriceVerificationData { pub evm_signature: Signature, } +/// Determines how a price submission is verified. +#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] +pub enum PriceSubmissionMode { + /// Full verification with ISMP state proofs and EVM signature. + StateProof(PriceVerificationData), + /// Cross-chain verification: the filler was passively verified by the + /// IntentGateway inspector when their RedeemEscrow message flowed through + /// Hyperbridge. Only an EVM signature is required. + CrossChain(CrossChainVerificationData), + /// No verification. Anyone can submit by paying a fee. + Unverified, +} + +/// Verification data for cross-chain verified price submissions. +/// +/// Fillers who have been passively verified through the IntentGateway inspector +/// (their RedeemEscrow message was seen flowing through Hyperbridge) only need +/// to provide an EVM signature to prove they own the verified address. +/// No ISMP state proofs are required. +#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] +pub struct CrossChainVerificationData { + /// EVM signature proving ownership of the filler's EVM account. + /// The signer must sign `keccak256(encode(nonce, pair_id, price))`. + pub evm_signature: Signature, +} + /// Compute the message hash that the filler must sign with their EVM key. /// /// Message = keccak256(SCALE_encode(nonce, pair_id, price)) @@ -340,9 +366,47 @@ mod sol_types { bytes sourceChain; TokenDecimal[] tokens; } + + /// Solidity representation of WithdrawalRequest (used by RedeemEscrow/RefundEscrow) + struct WithdrawalRequest { + bytes32 commitment; + bytes32 beneficiary; + TokenInfo[] tokens; + } } } +/// Extract the filler's EVM address from a RedeemEscrow intent gateway request body. +/// +/// The body format is: `[1-byte discriminator] + abi.encode(WithdrawalRequest)`. +/// Returns `Some(filler_address)` if the discriminator is `RedeemEscrow` (0x00) +/// and the body decodes successfully. The beneficiary field is a bytes32 containing +/// the filler's 20-byte EVM address left-padded to 32 bytes. +pub fn extract_filler_from_redeem(body: &[u8]) -> Option { + use alloy_sol_types::SolType; + + if body.is_empty() { + return None; + } + + if body[0] != IntentGatewayRequestKind::RedeemEscrow as u8 { + return None; + } + + let withdrawal = ::abi_decode(&body[1..]).ok()?; + + // The beneficiary is bytes32(uint256(uint160(msg.sender))) — the 20-byte address + // is stored in the low 20 bytes (right-aligned, big-endian). + let beneficiary_bytes: [u8; 32] = withdrawal.beneficiary.into(); + let filler = H160::from_slice(&beneficiary_bytes[12..32]); + + if filler == H160::zero() { + return None; + } + + Some(filler) +} + impl From for sol_types::Params { fn from(params: IntentGatewayParams) -> Self { use alloy_primitives::{Address, U256 as AlloyU256}; diff --git a/parachain/runtimes/gargantua/src/ismp.rs b/parachain/runtimes/gargantua/src/ismp.rs index a2dab8e33..0d612964f 100644 --- a/parachain/runtimes/gargantua/src/ismp.rs +++ b/parachain/runtimes/gargantua/src/ismp.rs @@ -15,9 +15,9 @@ use crate::{ alloc::{boxed::Box, string::ToString}, - weights, AccountId, Assets, Balance, Balances, Ismp, IsmpParachain, Mmr, ParachainInfo, - Runtime, RuntimeEvent, Timestamp, TokenGatewayInspector, TreasuryAccount, TreasuryPalletId, - XcmGateway, + weights, AccountId, Assets, Balance, Balances, IntentsCoprocessor, Ismp, IsmpParachain, Mmr, + ParachainInfo, Runtime, RuntimeEvent, Timestamp, TokenGatewayInspector, TreasuryAccount, + TreasuryPalletId, XcmGateway, EXISTENTIAL_DEPOSIT, }; use anyhow::anyhow; @@ -289,6 +289,7 @@ impl IsmpModule for ProxyModule { fn on_accept(&self, request: PostRequest) -> Result { if request.dest != HostStateMachine::get() { TokenGatewayInspector::inspect_request(&request)?; + IntentsCoprocessor::inspect_request(&request)?; Ismp::dispatch_request( Request::Post(request), diff --git a/parachain/runtimes/nexus/src/ismp.rs b/parachain/runtimes/nexus/src/ismp.rs index 14f8fbd8c..580db3686 100644 --- a/parachain/runtimes/nexus/src/ismp.rs +++ b/parachain/runtimes/nexus/src/ismp.rs @@ -16,10 +16,10 @@ use crate::{ alloc::{boxed::Box, string::ToString}, governance::WhitelistedCaller, - weights, AccountId, Assets, Balance, Balances, Ismp, IsmpParachain, Mmr, ParachainInfo, - ReputationAsset, Runtime, RuntimeEvent, TechnicalCollectiveInstance, Timestamp, TokenGateway, - TokenGatewayInspector, TreasuryAccount, TreasuryPalletId, XcmGateway, EXISTENTIAL_DEPOSIT, - MIN_TECH_COLLECTIVE_APPROVAL, + weights, AccountId, Assets, Balance, Balances, IntentsCoprocessor, Ismp, IsmpParachain, Mmr, + ParachainInfo, ReputationAsset, Runtime, RuntimeEvent, TechnicalCollectiveInstance, Timestamp, + TokenGateway, TokenGatewayInspector, TreasuryAccount, TreasuryPalletId, XcmGateway, + EXISTENTIAL_DEPOSIT, MIN_TECH_COLLECTIVE_APPROVAL, }; use anyhow::anyhow; use evm_state_machine::SubstrateEvmStateMachine; @@ -441,6 +441,7 @@ impl IsmpModule for ProxyModule { fn on_accept(&self, request: PostRequest) -> Result { if request.dest != HostStateMachine::get() { TokenGatewayInspector::inspect_request(&request)?; + IntentsCoprocessor::inspect_request(&request)?; Ismp::dispatch_request( Request::Post(request), From 00d16dd82e8ba9226e7030e86cd5b66f3cc817e3 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Mon, 16 Mar 2026 13:05:45 +0100 Subject: [PATCH 08/28] fully reserve deposit price submission --- .../intent-gateway/price-submission.mdx | 67 +- .../intents-coprocessor/rpc/src/lib.rs | 74 +- .../pallets/intents-coprocessor/src/lib.rs | 514 +++------- .../pallets/intents-coprocessor/src/tests.rs | 885 +++++------------- .../pallets/intents-coprocessor/src/types.rs | 123 +-- parachain/runtimes/gargantua/src/ismp.rs | 8 +- parachain/runtimes/nexus/src/ismp.rs | 6 +- 7 files changed, 391 insertions(+), 1286 deletions(-) diff --git a/docs/content/developers/intent-gateway/price-submission.mdx b/docs/content/developers/intent-gateway/price-submission.mdx index 70762261d..a09fb74e6 100644 --- a/docs/content/developers/intent-gateway/price-submission.mdx +++ b/docs/content/developers/intent-gateway/price-submission.mdx @@ -1,19 +1,19 @@ --- title: Price Submission Protocol -description: Three-path price submission system for the intents coprocessor pallet +description: Deposit-based price submission system for the intents coprocessor pallet --- # Price Submission Protocol ## Overview -The intents system needs on-chain price data for token pairs to function correctly. The challenge is sourcing reliable prices without depending on external oracles, while still ensuring data availability when verified sources are temporarily scarce. +The intents system needs on-chain price data for token pairs to function correctly. The price submission protocol uses a simple deposit-based model: anyone can submit prices for governance-approved token pairs by reserving a deposit on their first submission. Subsequent updates are free, and the deposit can be withdrawn after a configurable lock duration. -## Protocol Overview +## How It Works -The `submit_pair_price` extrinsic on the intents coprocessor pallet accepts price submissions for governance-approved token pairs. It supports three submission modes through a single entry point via the `mode` parameter. All prices and amount ranges are assumed to have 18 decimal places. +The `submit_pair_price` extrinsic on the intents coprocessor pallet accepts price submissions for governance-approved token pairs. All prices and amount ranges are assumed to have 18 decimal places. -Submissions are batched: each call accepts a `BoundedVec` where each entry specifies a base token amount range and the corresponding price. This allows fillers to quote different rates for different order sizes in a single transaction (for example, USDC/CNGN: 0 to 999 at 1414, 1000 to 5000 at 1420). The maximum number of entries per submission is a compile-time constant configurable per runtime via the `MaxPriceEntries` associated type. +Submissions are batched: each call accepts a `BoundedVec` where each entry specifies a base token amount range and the corresponding price. This allows submitters to quote different rates for different order sizes in a single transaction (for example, USDC/CNGN: 0 to 999 at 1414, 1000 to 5000 at 1420). The maximum number of entries per submission is a compile-time constant configurable per runtime via the `MaxPriceEntries` associated type. ### Price Entries @@ -25,59 +25,25 @@ Each `PriceInput` contains three fields: The pallet validates that `range_start <= range_end` for every entry and rejects submissions where this invariant is violated. It also rejects empty submissions. -When stored on-chain, each entry becomes a `PriceEntry` which adds the filler's EVM address (set to zero for unverified submissions) and the submission timestamp. +When stored on-chain, each entry becomes a `PriceEntry` which adds the submission timestamp. -### Submission Modes +### Deposit Model -The `mode` parameter is an enum with three variants: +On the first price submission per (account, token pair), a deposit is reserved from the submitter's balance via `ReservableCurrency::reserve()`. The deposit amount is set by governance through the `set_price_deposit_amount` extrinsic. -- `StateProof`: full verification with ISMP state proofs and an EVM signature. -- `CrossChain`: lightweight verification for fillers already recognized through cross-chain message inspection, requiring only an EVM signature. -- `Unverified`: no verification; anyone can submit by paying a fee. +After the initial deposit, the submitter can update prices for the same token pair as many times as they want without paying anything further. -Both `StateProof` and `CrossChain` submissions are stored as verified (high confidence) prices. `Unverified` submissions are stored separately as low confidence prices. +The deposit can be withdrawn after a configurable lock duration has elapsed by calling the `withdraw_price_deposit` extrinsic. The lock duration is set by governance through the `set_price_deposit_lock_duration` extrinsic. Until the lock duration has passed, the deposit remains reserved and cannot be withdrawn. -### State Proof Verification (High Confidence) - -Fillers who have actually filled orders on the IntentGateway contract submit prices along with cryptographic proof of their fill. This path exists because fillers are the most trustworthy source of price data. They have real skin in the game and their prices reflect actual market activity. - -A state proof submission requires three things: - -1. **Two ISMP state proofs** against the IntentGateway contract's `_filled` mapping. A non-membership proof at height H1 shows the order was not yet filled, and a membership proof at height H2 shows it was filled. Together these bracket when the fill occurred and extract the filler's EVM address from storage. - -2. **Proof freshness.** The gap between H1 and H2, the age of H2 relative to the current time, and the difference between H2's timestamp and the current on-chain time must all fall within a governance-configured threshold. This check ensures we are getting verified price data from fillers that are actually active and have filled orders very recently, allowing us to offer high confidence rates to users. - -3. **An EVM signature** over `keccak256(SCALE_encode(nonce, pair_id, price))`, where `price` is taken from the first entry in the batch. This proves the substrate account submitting the prices actually controls the EVM account that performed the fill. The nonce is tracked on-chain per EVM address to prevent signature replay. This pattern follows the existing approach in `pallet-ismp-relayer`. - -The 52-byte proof key binds verification to a specific gateway contract. The first 20 bytes are the gateway address (used by the EVM state machine client to locate the contract in the world state trie) and the last 32 bytes are the hashed storage slot for `_filled[commitment]`. - -### Cross-Chain Verification (High Confidence) - -When a filler completes a cross-chain order, the destination chain's IntentGateway dispatches a `RedeemEscrow` message back to the source chain via Hyperbridge. Because Hyperbridge routes all cross-chain messages through its `ProxyModule`, the intents coprocessor pallet can passively inspect these messages as they flow through. - -The `inspect_request` method is called in the `ProxyModule::on_accept` hook for every relayed request. It checks whether the sender is a known IntentGateway (by comparing the `from` address against the `Gateways` storage map), and if so, decodes the request body. When the first byte is `0x00` (the `RedeemEscrow` discriminator), the body is ABI-decoded as a `WithdrawalRequest` and the filler's EVM address is extracted from the `beneficiary` field (a `bytes32` containing the 20-byte address right-aligned). - -The extracted filler address is stored in a `VerifiedFillers` map along with the current timestamp. This serves as a lightweight, passive proof that the filler is genuinely active. When the filler later wants to submit prices, they use the `CrossChain` submission mode, which only requires an EVM signature (no state proofs). The pallet checks that the filler exists in `VerifiedFillers` and that their verification timestamp is within the freshness threshold. - -This path is particularly useful for cross-chain fills where the filler's activity is already visible to Hyperbridge without requiring any additional proof submission. - -### Unverified Prices (Low Confidence) - -Anyone can submit prices without proofs by paying a fee in bridge tokens. This path exists to maintain price data availability even when verified submissions are sparse. The fee, which is transferred to the treasury, discourages spam while keeping the door open for market participants who have price information but have not filled orders themselves. - -Unverified prices are stored separately in `UnverifiedPrices` and capped per pair at `MaxUnverifiedSubmissions`. When a batch submission would exceed the cap, the oldest entries are drained in bulk to make room, following a first-in, first-out policy. Both the fee and the cap must be configured via governance for unverified submissions to be accepted. The filler field is set to `H160::zero()` for unverified entries since no EVM address verification is performed. - -### Why Separate Confidence Levels - -Keeping verified and unverified prices separate lets consumers make informed decisions. A DEX might only trust verified prices for settlement, while a UI might display both to give users a fuller picture. Mixing them into a single pool would dilute the signal from proven fills. +This model discourages spam (each new pair requires locking funds) while keeping ongoing price updates free. ## Price Window and Data Lifecycle Prices are organized into daily windows, which are governance-configurable via `PriceWindowDurationValue`. -The `on_initialize` hook runs every block and checks whether the current window has expired. When it has, it clears `UsedCommitments` (which is safe because the freshness threshold independently rejects stale proofs) and resets a `PricesClearedThisWindow` flag to false. +The `on_initialize` hook runs every block and checks whether the current window has expired. When it has, it resets a `PricesClearedThisWindow` flag to false. -Prices from the previous window are not cleared immediately. They persist so that consumers can still read the previous window's data. On the first new submission in the new window (verified or unverified), all price entries across all pairs are cleared before the new entries are stored. This lazy clearing approach avoids the cost of iterating all pairs in `on_initialize` while ensuring stale data is replaced as soon as fresh data arrives. +Prices from the previous window are not cleared immediately. They persist so that consumers can still read the previous window's data. On the first new submission in the new window, all price entries across all pairs are cleared before the new entries are stored. This lazy clearing approach avoids the cost of iterating all pairs in `on_initialize` while ensuring stale data is replaced as soon as fresh data arrives. ### Why Lazy Clearing @@ -89,15 +55,14 @@ Only governance-approved token pairs can receive price submissions. This prevent ## RPC -The `intents_getPairPrices(pair_id)` RPC endpoint returns verified and unverified prices separately, allowing consumers to apply their own weighting or filtering based on confidence level. Each returned entry includes the filler's EVM address, the amount range (`range_start`, `range_end`), the price, and the submission timestamp. +The `intents_getPairPrices(pair_id)` RPC endpoint returns all prices for a given token pair. Each returned entry includes the amount range (`range_start`, `range_end`), the price, and the submission timestamp. ## Governance Parameters All key parameters are stored on-chain and updatable via governance extrinsics: - `PriceWindowDurationValue` is the length of the price window in milliseconds. -- `ProofFreshnessThresholdValue` is the maximum allowed age and gap for state proofs and cross-chain filler verification, in seconds. -- `MaxUnverifiedSubmissions` is the cap on unverified entries per pair. -- `UnverifiedSubmissionFee` is the fee charged for unverified submissions. +- `PriceDepositAmount` is the amount reserved from submitters on their first price submission per pair. +- `PriceDepositLockDuration` is how long (in seconds) the deposit is locked before it can be withdrawn. - `MaxPriceEntries` is the compile-time maximum number of price entries per submission, configurable per runtime. - Recognized token pairs determine which pairs accept submissions. diff --git a/modules/pallets/intents-coprocessor/rpc/src/lib.rs b/modules/pallets/intents-coprocessor/rpc/src/lib.rs index 66fbd5a18..7dc99230c 100644 --- a/modules/pallets/intents-coprocessor/rpc/src/lib.rs +++ b/modules/pallets/intents-coprocessor/rpc/src/lib.rs @@ -56,9 +56,6 @@ pub struct RpcBidInfo { /// A single price entry returned by the RPC #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] pub struct RpcPriceEntry { - /// The filler's EVM address (zero address for unverified submissions) - #[serde(with = "hex_bytes")] - pub filler: Vec, /// Lower bound of the base token amount range (inclusive), with 18 decimal places pub range_start: String, /// Upper bound of the base token amount range (inclusive), with 18 decimal places @@ -69,15 +66,6 @@ pub struct RpcPriceEntry { pub timestamp: u64, } -/// Response for the `intents_getPairPrices` RPC method -#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] -pub struct RpcPairPrices { - /// High confidence prices (from verified fillers with proofs) - pub verified: Vec, - /// Low confidence prices (from unverified submitters) - pub unverified: Vec, -} - impl Ord for RpcBidInfo { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.filler.cmp(&other.filler) @@ -212,9 +200,9 @@ pub trait IntentsApi { #[method(name = "intents_getBidsForOrder")] fn get_bids_for_order(&self, commitment: H256) -> RpcResult>; - /// Get all prices for a token pair, separated by confidence level + /// Get all prices for a token pair #[method(name = "intents_getPairPrices")] - fn get_pair_prices(&self, pair_id: H256) -> RpcResult; + fn get_pair_prices(&self, pair_id: H256) -> RpcResult>; #[subscription(name = "intents_subscribeBids" => "intents_bidNotification", unsubscribe = "intents_unsubscribeBids", item = RpcBidInfo)] async fn subscribe_bids(&self, commitment: Option) -> SubscriptionResult; @@ -299,46 +287,32 @@ where Ok(bids.into_iter().collect()) } - fn get_pair_prices(&self, pair_id: H256) -> RpcResult { + fn get_pair_prices(&self, pair_id: H256) -> RpcResult> { let best_hash = self.client.info().best_hash; - let decode_entries = |storage_name: &[u8]| -> Vec { - let key = storage_map_key(b"IntentsCoprocessor", storage_name, &pair_id); - let storage_key = sp_core::storage::StorageKey(key); - - let data = match self.client.storage(best_hash, &storage_key) { - Ok(Some(data)) => data.0, - _ => return Vec::new(), - }; - - // Decode Vec - // PriceEntry SCALE-encodes as (H160, U256, U256, U256, u64) - type Entry = ( - primitive_types::H160, - primitive_types::U256, - primitive_types::U256, - primitive_types::U256, - u64, - ); - match Vec::::decode(&mut &data[..]) { - Ok(entries) => entries - .into_iter() - .map(|(filler, range_start, range_end, price, timestamp)| RpcPriceEntry { - filler: filler.as_bytes().to_vec(), - range_start: range_start.to_string(), - range_end: range_end.to_string(), - price: price.to_string(), - timestamp, - }) - .collect(), - Err(_) => Vec::new(), - } + let key = storage_map_key(b"IntentsCoprocessor", b"Prices", &pair_id); + let storage_key = sp_core::storage::StorageKey(key); + + let data = match self.client.storage(best_hash, &storage_key) { + Ok(Some(data)) => data.0, + _ => return Ok(Vec::new()), }; - Ok(RpcPairPrices { - verified: decode_entries(b"VerifiedPrices"), - unverified: decode_entries(b"UnverifiedPrices"), - }) + // Decode Vec + // PriceEntry SCALE-encodes as (U256, U256, U256, u64) + type Entry = (primitive_types::U256, primitive_types::U256, primitive_types::U256, u64); + match Vec::::decode(&mut &data[..]) { + Ok(entries) => Ok(entries + .into_iter() + .map(|(range_start, range_end, price, timestamp)| RpcPriceEntry { + range_start: range_start.to_string(), + range_end: range_end.to_string(), + price: price.to_string(), + timestamp, + }) + .collect()), + Err(_) => Ok(Vec::new()), + } } async fn subscribe_bids( diff --git a/modules/pallets/intents-coprocessor/src/lib.rs b/modules/pallets/intents-coprocessor/src/lib.rs index 2be4cc84a..39d41ea41 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -34,10 +34,9 @@ use frame_support::{ use ismp::{ dispatcher::{DispatchPost, DispatchRequest, FeeMetadata, IsmpDispatcher}, host::{IsmpHost, StateMachine}, - messaging::Proof, }; use polkadot_sdk::*; -use primitive_types::{H160, H256, U256}; +use primitive_types::{H160, H256}; use sp_core::Get; use sp_io::offchain_index; use sp_runtime::traits::{ConstU32, Zero}; @@ -91,9 +90,6 @@ pub mod pallet { /// Origin that can perform governance actions type GovernanceOrigin: EnsureOrigin; - /// The treasury account that receives unverified submission fees - type TreasuryAccount: Get; - /// Maximum number of price entries per submission #[pallet::constant] type MaxPriceEntries: Get; @@ -136,10 +132,6 @@ pub mod pallet { pub type RecognizedPairs = StorageMap<_, Blake2_128Concat, H256, TokenPair, OptionQuery>; - /// Commitments that have already been used for price submissions - #[pallet::storage] - pub type UsedCommitments = StorageMap<_, Blake2_128Concat, H256, bool, ValueQuery>; - /// Start timestamp (in seconds) of the current price window #[pallet::storage] pub type PriceWindowStart = StorageValue<_, u64, ValueQuery>; @@ -148,38 +140,31 @@ pub mod pallet { #[pallet::storage] pub type PriceWindowDurationValue = StorageValue<_, u64, ValueQuery>; - /// Proof freshness threshold in seconds - #[pallet::storage] - pub type ProofFreshnessThresholdValue = StorageValue<_, u64, ValueQuery>; - - /// Verified (high confidence) price entries per pair, from fillers with proofs + /// Price entries per pair #[pallet::storage] - pub type VerifiedPrices = - StorageMap<_, Blake2_128Concat, H256, Vec, ValueQuery>; + pub type Prices = StorageMap<_, Blake2_128Concat, H256, Vec, ValueQuery>; - /// Unverified (low confidence) price entries per pair, from anyone without proofs + /// Deposits reserved by price submitters. Maps (account, pair_id) to + /// (deposit_amount, deposit_timestamp_secs). The deposit is locked for + /// `PriceDepositLockDuration` seconds, after which it can be withdrawn. #[pallet::storage] - pub type UnverifiedPrices = - StorageMap<_, Blake2_128Concat, H256, Vec, ValueQuery>; - - /// Nonces for EVM addresses to prevent signature replay. - /// Keyed by the 20-byte EVM address. - #[pallet::storage] - pub type EvmNonces = StorageMap<_, Blake2_128Concat, H160, u64, ValueQuery>; - - /// Maximum number of unverified price submissions per pair - #[pallet::storage] - pub type MaxUnverifiedSubmissions = StorageValue<_, u32, ValueQuery>; + pub type PriceDeposits = StorageDoubleMap< + _, + Blake2_128Concat, + T::AccountId, + Blake2_128Concat, + H256, // pair_id + (BalanceOf, u64), // (deposit_amount, deposit_timestamp) + OptionQuery, + >; - /// Fee charged for unverified price submissions (in native/bridge tokens) + /// The amount reserved from submitters on their first price submission per pair #[pallet::storage] - pub type UnverifiedSubmissionFee = StorageValue<_, BalanceOf, ValueQuery>; + pub type PriceDepositAmount = StorageValue<_, BalanceOf, ValueQuery>; - /// Fillers verified through cross-chain message inspection. - /// Maps filler EVM address to the timestamp (seconds) when they were last seen - /// filling an order via a RedeemEscrow message routed through Hyperbridge. + /// How long (in seconds) the price deposit is locked before it can be withdrawn #[pallet::storage] - pub type VerifiedFillers = StorageMap<_, Blake2_128Concat, H160, u64, OptionQuery>; + pub type PriceDepositLockDuration = StorageValue<_, u64, ValueQuery>; /// Whether prices have been cleared in the current window. /// Reset to false by `on_initialize` when a new window starts. @@ -204,19 +189,10 @@ pub mod pallet { let window_start = PriceWindowStart::::get(); if window_start == 0 || now.saturating_sub(window_start) >= window_duration_secs { - // New window: clear UsedCommitments (safe because freshness threshold - // prevents old proofs from being replayed) and update the window start. - // Price entries (VerifiedPrices, UnverifiedPrices) are NOT cleared here — - // they persist so yesterday's prices remain readable. They are cleared - // lazily on the first submission per pair in the new window. - let used_result = UsedCommitments::::clear(u32::MAX, None); PriceWindowStart::::put(now); PricesClearedThisWindow::::put(false); - let cleared = used_result.unique.saturating_add(1); - T::DbWeight::get() - .reads(3) - .saturating_add(T::DbWeight::get().writes(cleared.into())) + T::DbWeight::get().reads(3).saturating_add(T::DbWeight::get().writes(2)) } else { T::DbWeight::get().reads(3) } @@ -255,18 +231,18 @@ pub mod pallet { RecognizedPairAdded { pair_id: H256, pair: TokenPair }, /// A recognized token pair was removed RecognizedPairRemoved { pair_id: H256 }, - /// Verified prices were submitted (high confidence) - PriceSubmitted { filler: T::AccountId, pair_id: H256 }, + /// Prices were submitted for a token pair + PriceSubmitted { submitter: T::AccountId, pair_id: H256 }, /// Price window duration was updated PriceWindowDurationUpdated { duration_ms: u64 }, - /// Proof freshness threshold was updated - ProofFreshnessThresholdUpdated { threshold_secs: u64 }, - /// Unverified prices were submitted (low confidence) - UnverifiedPriceSubmitted { submitter: T::AccountId, pair_id: H256 }, - /// Max unverified submissions per pair was updated - MaxUnverifiedSubmissionsUpdated { max: u32 }, - /// Unverified submission fee was updated - UnverifiedSubmissionFeeUpdated { fee: BalanceOf }, + /// Price deposit amount was updated + PriceDepositAmountUpdated { amount: BalanceOf }, + /// Price deposit lock duration was updated + PriceDepositLockDurationUpdated { duration_secs: u64 }, + /// Price deposit was reserved on first submission + PriceDepositReserved { submitter: T::AccountId, pair_id: H256, amount: BalanceOf }, + /// Price deposit was withdrawn + PriceDepositWithdrawn { submitter: T::AccountId, pair_id: H256, amount: BalanceOf }, } #[pallet::error] @@ -285,32 +261,18 @@ pub mod pallet { DispatchFailed, /// Token pair not recognized PairNotRecognized, - /// Non-membership proof verification failed - NonMembershipProofFailed, - /// Membership proof verification failed - MembershipProofFailed, - /// The time gap between the two proofs exceeds the freshness threshold - ProofNotFresh, - /// State proof verification failed - ProofVerificationFailed, /// Token pair already exists PairAlreadyExists, - /// This commitment has already been used for a price submission - CommitmentAlreadyUsed, - /// EVM signature verification failed - InvalidSignature, - /// The recovered EVM address does not match the filler from the proof - SignerMismatch, - /// Unverified submissions are not configured (max or fee is zero) - UnverifiedSubmissionsNotConfigured, /// The price range is invalid (range_start > range_end) InvalidPriceRange, /// No price entries were provided EmptyPriceEntries, - /// The filler is not in the VerifiedFillers map - FillerNotVerified, - /// The filler's cross-chain verification has expired - FillerVerificationExpired, + /// Price deposits are not configured (amount is zero) + PriceDepositsNotConfigured, + /// No deposit found for this account and pair + DepositNotFound, + /// The deposit is still within the lock duration + DepositStillLocked, } #[pallet::call] @@ -594,16 +556,10 @@ pub mod pallet { /// Submit prices for a recognized token pair across one or more amount ranges. /// - /// This extrinsic supports three submission modes via the `mode` parameter: - /// - /// - `StateProof`: High confidence. The filler supplies ISMP state proofs and an EVM - /// signature to prove they filled the order and own the submitting account (bound by an - /// on-chain nonce). - /// - `CrossChain`: High confidence. The filler was passively verified by the IntentGateway - /// inspector when their RedeemEscrow message flowed through Hyperbridge. Only an EVM - /// signature is required (no state proofs). - /// - `Unverified`: Low confidence. Anyone may submit by paying bridge tokens. Unverified - /// entries are capped per pair with FIFO replacement. + /// On the first submission per (account, pair), a deposit is reserved from the + /// submitter's balance. Subsequent submissions for the same pair are free. + /// The deposit can be withdrawn after the configured lock duration via + /// `withdraw_price_deposit`. /// /// Each entry in `entries` specifies a base token amount range and the /// corresponding price of the base token in terms of the quote token. @@ -615,7 +571,6 @@ pub mod pallet { origin: OriginFor, pair_id: H256, entries: BoundedVec, - mode: types::PriceSubmissionMode, ) -> DispatchResult { let submitter = ensure_signed(origin)?; @@ -626,17 +581,38 @@ pub mod pallet { ); ensure!(RecognizedPairs::::contains_key(&pair_id), Error::::PairNotRecognized); + let deposit_amount = PriceDepositAmount::::get(); + ensure!(!deposit_amount.is_zero(), Error::::PriceDepositsNotConfigured); + let now = T::Dispatcher::default().timestamp().as_secs(); - match mode { - types::PriceSubmissionMode::StateProof(v) => - Self::submit_verified_price(submitter, pair_id, entries, v, now)?, - types::PriceSubmissionMode::CrossChain(v) => - Self::submit_cross_chain_verified_price(submitter, pair_id, entries, v, now)?, - types::PriceSubmissionMode::Unverified => - Self::submit_unverified_price(submitter, pair_id, entries, now)?, + // Reserve deposit on first submission per (account, pair) + if !PriceDeposits::::contains_key(&submitter, &pair_id) { + ::Currency::reserve(&submitter, deposit_amount) + .map_err(|_| Error::::InsufficientBalance)?; + + PriceDeposits::::insert(&submitter, &pair_id, (deposit_amount, now)); + + Self::deposit_event(Event::PriceDepositReserved { + submitter: submitter.clone(), + pair_id, + amount: deposit_amount, + }); } + Self::maybe_clear_stale_prices(); + + Prices::::mutate(&pair_id, |stored| { + stored.extend(entries.iter().map(|input| PriceEntry { + range_start: input.range_start, + range_end: input.range_end, + price: input.price, + timestamp: now, + })); + }); + + Self::deposit_event(Event::PriceSubmitted { submitter, pair_id }); + Ok(()) } @@ -658,15 +634,14 @@ pub mod pallet { /// Remove a recognized token pair #[pallet::call_index(9)] - #[pallet::weight(T::DbWeight::get().reads(1).saturating_add(T::DbWeight::get().writes(3)))] + #[pallet::weight(T::DbWeight::get().reads(1).saturating_add(T::DbWeight::get().writes(2)))] pub fn remove_recognized_pair(origin: OriginFor, pair_id: H256) -> DispatchResult { T::GovernanceOrigin::ensure_origin(origin)?; ensure!(RecognizedPairs::::contains_key(&pair_id), Error::::PairNotRecognized); RecognizedPairs::::remove(&pair_id); - VerifiedPrices::::remove(&pair_id); - UnverifiedPrices::::remove(&pair_id); + Prices::::remove(&pair_id); Self::deposit_event(Event::RecognizedPairRemoved { pair_id }); @@ -686,47 +661,70 @@ pub mod pallet { Ok(()) } - /// Set the proof freshness threshold + /// Set the deposit amount required for price submissions #[pallet::call_index(11)] #[pallet::weight(T::DbWeight::get().writes(1))] - pub fn set_proof_freshness_threshold( + pub fn set_price_deposit_amount( origin: OriginFor, - threshold_secs: u64, + amount: BalanceOf, ) -> DispatchResult { T::GovernanceOrigin::ensure_origin(origin)?; - ProofFreshnessThresholdValue::::put(threshold_secs); + PriceDepositAmount::::put(amount); - Self::deposit_event(Event::ProofFreshnessThresholdUpdated { threshold_secs }); + Self::deposit_event(Event::PriceDepositAmountUpdated { amount }); Ok(()) } - /// Set the maximum number of unverified price submissions per pair + /// Set the lock duration for price deposits #[pallet::call_index(12)] #[pallet::weight(T::DbWeight::get().writes(1))] - pub fn set_max_unverified_submissions(origin: OriginFor, max: u32) -> DispatchResult { + pub fn set_price_deposit_lock_duration( + origin: OriginFor, + duration_secs: u64, + ) -> DispatchResult { T::GovernanceOrigin::ensure_origin(origin)?; - MaxUnverifiedSubmissions::::put(max); + PriceDepositLockDuration::::put(duration_secs); - Self::deposit_event(Event::MaxUnverifiedSubmissionsUpdated { max }); + Self::deposit_event(Event::PriceDepositLockDurationUpdated { duration_secs }); Ok(()) } - /// Set the fee charged for unverified price submissions + /// Withdraw a price deposit after the lock duration has elapsed + /// + /// # Parameters + /// - `pair_id`: The token pair the deposit was made for + /// + /// # Errors + /// - `DepositNotFound`: No deposit exists for this account and pair + /// - `DepositStillLocked`: The lock duration has not yet elapsed #[pallet::call_index(13)] - #[pallet::weight(T::DbWeight::get().writes(1))] - pub fn set_unverified_submission_fee( - origin: OriginFor, - fee: BalanceOf, - ) -> DispatchResult { - T::GovernanceOrigin::ensure_origin(origin)?; + #[pallet::weight(T::DbWeight::get().reads(3).saturating_add(T::DbWeight::get().writes(1)))] + pub fn withdraw_price_deposit(origin: OriginFor, pair_id: H256) -> DispatchResult { + let who = ensure_signed(origin)?; + + let (deposit_amount, deposit_timestamp) = + PriceDeposits::::get(&who, &pair_id).ok_or(Error::::DepositNotFound)?; + + let now = T::Dispatcher::default().timestamp().as_secs(); + let lock_duration = PriceDepositLockDuration::::get(); + + ensure!( + now.saturating_sub(deposit_timestamp) >= lock_duration, + Error::::DepositStillLocked + ); - UnverifiedSubmissionFee::::put(fee); + ::Currency::unreserve(&who, deposit_amount); + PriceDeposits::::remove(&who, &pair_id); - Self::deposit_event(Event::UnverifiedSubmissionFeeUpdated { fee }); + Self::deposit_event(Event::PriceDepositWithdrawn { + submitter: who, + pair_id, + amount: deposit_amount, + }); Ok(()) } @@ -753,303 +751,17 @@ pub mod pallet { offchain_bid_key_raw(commitment, &filler.encode()) } - /// Process a verified (high confidence) price submission with ISMP proofs and - /// EVM signature verification. - fn submit_verified_price( - submitter: T::AccountId, - pair_id: H256, - entries: BoundedVec, - v: types::PriceVerificationData, - now: u64, - ) -> DispatchResult { - ensure!(!UsedCommitments::::get(&v.commitment), Error::::CommitmentAlreadyUsed); - - let gateway_info = - Gateways::::get(v.state_machine).ok_or(Error::::GatewayNotFound)?; - - let filler_address = Self::verify_fill_proofs(&v, &gateway_info, now)?; - - Self::verify_evm_signature(&v, &pair_id, &entries[0].price, filler_address)?; - - UsedCommitments::::insert(&v.commitment, true); - - Self::maybe_clear_stale_prices(); - - VerifiedPrices::::mutate(&pair_id, |stored| { - stored.extend(entries.iter().map(|input| PriceEntry { - filler: filler_address, - range_start: input.range_start, - range_end: input.range_end, - price: input.price, - timestamp: now, - })); - }); - - Self::deposit_event(Event::PriceSubmitted { filler: submitter, pair_id }); - - Ok(()) - } - - /// Process an unverified (low confidence) price submission. Charges a fee - /// and stores with FIFO replacement if the cap is reached. - fn submit_unverified_price( - submitter: T::AccountId, - pair_id: H256, - entries: BoundedVec, - now: u64, - ) -> DispatchResult { - let max = MaxUnverifiedSubmissions::::get(); - let fee = UnverifiedSubmissionFee::::get(); - ensure!(max > 0 && !fee.is_zero(), Error::::UnverifiedSubmissionsNotConfigured); - - ::Currency::transfer( - &submitter, - &T::TreasuryAccount::get(), - fee, - frame_support::traits::ExistenceRequirement::KeepAlive, - ) - .map_err(|_| Error::::InsufficientBalance)?; - - Self::maybe_clear_stale_prices(); - - UnverifiedPrices::::mutate(&pair_id, |stored| { - let new_entries = entries.iter().map(|input| PriceEntry { - filler: H160::zero(), - range_start: input.range_start, - range_end: input.range_end, - price: input.price, - timestamp: now, - }); - - let total = stored.len() + entries.len(); - if total > max as usize { - let drain_count = total - max as usize; - stored.drain(..drain_count.min(stored.len())); - } - - stored.extend(new_entries); - }); - - Self::deposit_event(Event::UnverifiedPriceSubmitted { submitter, pair_id }); - - Ok(()) - } - - /// Verify ISMP state proofs for a fill order and return the filler's EVM address. - /// Confirms non-membership at H1, membership at H2, and that both proofs fall - /// within the freshness threshold. - fn verify_fill_proofs( - v: &types::PriceVerificationData, - gateway_info: &GatewayInfo, - now: u64, - ) -> Result { - let host = T::Dispatcher::default(); - - // 52-byte key: gateway address (20) + storage slot (32) - let storage_key = types::filled_storage_key(&gateway_info.gateway, &v.commitment); - - // Get state commitments for both proof heights - let commitment_h1 = host - .state_machine_commitment(v.non_membership_proof.height) - .map_err(|_| Error::::ProofVerificationFailed)?; - let commitment_h2 = host - .state_machine_commitment(v.membership_proof.height) - .map_err(|_| Error::::ProofVerificationFailed)?; - - // Validate state machine clients - let state_machine_client_h1 = - ismp::handlers::validate_state_machine(&host, v.non_membership_proof.height) - .map_err(|_| Error::::ProofVerificationFailed)?; - let state_machine_client_h2 = - ismp::handlers::validate_state_machine(&host, v.membership_proof.height) - .map_err(|_| Error::::ProofVerificationFailed)?; - - // Verify non-membership proof: order was not filled at H1 - let non_membership_result = state_machine_client_h1 - .verify_state_proof( - &host, - vec![storage_key.clone()], - commitment_h1, - &v.non_membership_proof, - ) - .map_err(|_| Error::::NonMembershipProofFailed)?; - - let value_at_h1 = non_membership_result - .get(&storage_key) - .ok_or(Error::::NonMembershipProofFailed)?; - ensure!(value_at_h1.is_none(), Error::::NonMembershipProofFailed); - - // Verify membership proof: order was filled at H2 - let membership_result = state_machine_client_h2 - .verify_state_proof( - &host, - vec![storage_key.clone()], - commitment_h2, - &v.membership_proof, - ) - .map_err(|_| Error::::MembershipProofFailed)?; - - let filler_bytes = membership_result - .get(&storage_key) - .ok_or(Error::::MembershipProofFailed)? - .as_ref() - .ok_or(Error::::MembershipProofFailed)?; - - // Extract the filler address from the proof value - // EVM addresses are 20 bytes, left-padded to 32 bytes in storage - let filler_address = H160::from_slice( - filler_bytes.get(12..32).ok_or(Error::::MembershipProofFailed)?, - ); - ensure!(filler_address != H160::zero(), Error::::MembershipProofFailed); - - // Check proof freshness - let threshold = ProofFreshnessThresholdValue::::get(); - let proof_gap = commitment_h2.timestamp.saturating_sub(commitment_h1.timestamp); - ensure!(proof_gap <= threshold, Error::::ProofNotFresh); - let age = now.saturating_sub(commitment_h2.timestamp); - ensure!(age <= threshold, Error::::ProofNotFresh); - ensure!( - commitment_h2.timestamp.saturating_sub(now) <= threshold, - Error::::ProofNotFresh - ); - - Ok(filler_address) - } - - /// Verify the EVM signature proving the filler owns the submitting account. - /// Recovers the signer from the signature over `(nonce, pair_id, price)`, - /// checks that it matches the filler from the proof, and increments the nonce. - fn verify_evm_signature( - v: &types::PriceVerificationData, - pair_id: &H256, - price: &U256, - filler_address: H160, - ) -> DispatchResult { - let evm_address_bytes = match &v.evm_signature { - crypto_utils::verification::Signature::Evm { address, .. } => address.clone(), - _ => return Err(Error::::InvalidSignature.into()), - }; - - ensure!(evm_address_bytes.len() == 20, Error::::InvalidSignature); - let evm_address = H160::from_slice(&evm_address_bytes); - - let nonce = EvmNonces::::get(evm_address); - let msg = types::price_signature_message(nonce, pair_id, price); - - let recovered = - v.evm_signature.verify(&msg, None).map_err(|_| Error::::InvalidSignature)?; - ensure!(recovered == evm_address_bytes, Error::::SignerMismatch); - - ensure!( - recovered.len() == 20 && H160::from_slice(&recovered) == filler_address, - Error::::SignerMismatch - ); - - EvmNonces::::insert(evm_address, nonce.saturating_add(1)); - - Ok(()) - } - /// Clear all prices if this is the first submission in a new window. /// /// Prices from the previous window persist until the first new submission - /// in the new window, at which point all verified and unverified entries - /// across all pairs are cleared. + /// in the new window, at which point all entries across all pairs are cleared. fn maybe_clear_stale_prices() { if !PricesClearedThisWindow::::get() { - let _ = VerifiedPrices::::clear(u32::MAX, None); - let _ = UnverifiedPrices::::clear(u32::MAX, None); + let _ = Prices::::clear(u32::MAX, None); PricesClearedThisWindow::::put(true); } } - /// Inspect a cross-chain request flowing through Hyperbridge. - /// - /// If the request is a RedeemEscrow from a known IntentGateway, extract the - /// filler's EVM address and store it in `VerifiedFillers` with the current - /// timestamp. This allows the filler to later submit prices without full - /// ISMP state proofs. - pub fn inspect_request(post: &ismp::router::PostRequest) -> Result<(), ismp::Error> { - // The `from` field must be at least 20 bytes (an EVM address) - if post.from.len() < 20 { - return Ok(()); - } - - let sender = H160::from_slice(&post.from[..20]); - let _gateway_info = match Gateways::::get(post.source) { - Some(info) if info.gateway == sender => info, - _ => return Ok(()), - }; - - if let Some(filler) = types::extract_filler_from_redeem(&post.body) { - let now = T::Dispatcher::default().timestamp().as_secs(); - VerifiedFillers::::insert(filler, now); - - log::info!( - target: "pallet-intents", - "Cross-chain verified filler {:?} from {:?}", - filler, - post.source, - ); - } - - Ok(()) - } - - /// Process a cross-chain verified price submission. - /// - /// The filler must be in the `VerifiedFillers` map with a timestamp within - /// the freshness threshold. An EVM signature is still required to prove - /// ownership of the filler address. - fn submit_cross_chain_verified_price( - submitter: T::AccountId, - pair_id: H256, - entries: BoundedVec, - v: types::CrossChainVerificationData, - now: u64, - ) -> DispatchResult { - let evm_address_bytes = match &v.evm_signature { - crypto_utils::verification::Signature::Evm { address, .. } => address.clone(), - _ => return Err(Error::::InvalidSignature.into()), - }; - - ensure!(evm_address_bytes.len() == 20, Error::::InvalidSignature); - let filler_address = H160::from_slice(&evm_address_bytes); - - let verified_at = - VerifiedFillers::::get(filler_address).ok_or(Error::::FillerNotVerified)?; - - // Check freshness of the verification - let threshold = ProofFreshnessThresholdValue::::get(); - let age = now.saturating_sub(verified_at); - ensure!(age <= threshold, Error::::FillerVerificationExpired); - - let nonce = EvmNonces::::get(filler_address); - let msg = types::price_signature_message(nonce, &pair_id, &entries[0].price); - - let recovered = - v.evm_signature.verify(&msg, None).map_err(|_| Error::::InvalidSignature)?; - ensure!(recovered == evm_address_bytes, Error::::SignerMismatch); - - EvmNonces::::insert(filler_address, nonce.saturating_add(1)); - - Self::maybe_clear_stale_prices(); - - VerifiedPrices::::mutate(&pair_id, |stored| { - stored.extend(entries.iter().map(|input| PriceEntry { - filler: filler_address, - range_start: input.range_start, - range_end: input.range_end, - price: input.price, - timestamp: now, - })); - }); - - Self::deposit_event(Event::PriceSubmitted { filler: submitter, pair_id }); - - Ok(()) - } - /// 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/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index e143360d8..a59fc6863 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -18,131 +18,25 @@ #![cfg(test)] use crate::{self as pallet_intents, *}; -use alloc::{boxed::Box, collections::BTreeMap, vec}; +use alloc::vec; use codec::Decode; -use crypto_utils::verification::Signature; use frame_support::{ - assert_noop, assert_ok, - crypto::ecdsa::ECDSAExt, - parameter_types, + assert_noop, assert_ok, parameter_types, traits::{ConstU32, Everything, Hooks}, BoundedVec, }; use frame_system::EnsureRoot; -use ismp::{ - consensus::{ - ConsensusClient, ConsensusClientId, ConsensusStateId, StateCommitment, StateMachineClient, - StateMachineHeight, StateMachineId, VerifiedCommitments, - }, - error::Error as IsmpError, - host::{IsmpHost, StateMachine}, - messaging::Proof, - router::RequestResponse, -}; +use ismp::host::StateMachine; use ismp_testsuite::mocks::MockRouter; use polkadot_sdk::*; use primitive_types::{H160, H256, U256}; -use sp_core::{Pair, H256 as SpH256}; +use sp_core::H256 as SpH256; use sp_runtime::{ traits::{BlakeTwo256, IdentityLookup}, AccountId32, BuildStorage, }; -/// Mock consensus client ID -const MOCK_CONSENSUS_CLIENT_ID: ConsensusClientId = [1u8; 4]; -/// Mock consensus state ID -const MOCK_CONSENSUS_STATE_ID: ConsensusStateId = *b"ETH0"; - -/// Height used for the non-membership proof (order not yet filled) -const H1_HEIGHT: u64 = 100; -/// Height used for the membership proof (order was filled) -const H2_HEIGHT: u64 = 200; - -/// A mock consensus client for testing `submit_pair_price`. -/// -/// Returns `MockPriceStateMachineClient` which encodes test behavior: -/// - At H1 (non-membership): returns `{key: None}` for storage queries -/// - At H2 (membership): returns `{key: Some(filler_address)}` from proof bytes -#[derive(Default)] -pub struct MockPriceConsensusClient; - -impl ConsensusClient for MockPriceConsensusClient { - fn verify_consensus( - &self, - _host: &dyn IsmpHost, - _consensus_state_id: ConsensusStateId, - _trusted_consensus_state: Vec, - _proof: Vec, - ) -> Result<(Vec, VerifiedCommitments), IsmpError> { - Ok(Default::default()) - } - - fn verify_fraud_proof( - &self, - _host: &dyn IsmpHost, - _trusted_consensus_state: Vec, - _proof_1: Vec, - _proof_2: Vec, - ) -> Result<(), IsmpError> { - Ok(()) - } - - fn consensus_client_id(&self) -> ConsensusClientId { - MOCK_CONSENSUS_CLIENT_ID - } - - fn state_machine(&self, _id: StateMachine) -> Result, IsmpError> { - Ok(Box::new(MockPriceStateMachineClient)) - } -} - -/// Mock state machine client that returns different results based on proof height. -/// -/// - Height == H1_HEIGHT: returns `{key: None}` (non-membership) -/// - Height == H2_HEIGHT: returns `{key: Some(proof_bytes)}` (membership, proof bytes = filler -/// address) -pub struct MockPriceStateMachineClient; - -impl StateMachineClient for MockPriceStateMachineClient { - fn verify_membership( - &self, - _host: &dyn IsmpHost, - _item: RequestResponse, - _root: StateCommitment, - _proof: &Proof, - ) -> Result<(), IsmpError> { - Ok(()) - } - - fn receipts_state_trie_key(&self, _request: RequestResponse) -> Vec> { - Default::default() - } - - fn verify_state_proof( - &self, - _host: &dyn IsmpHost, - keys: Vec>, - _root: StateCommitment, - proof: &Proof, - ) -> Result, Option>>, IsmpError> { - let mut result = BTreeMap::new(); - let is_non_membership = proof.height.height == H1_HEIGHT; - - for key in keys { - if is_non_membership { - // Non-membership: value not present - result.insert(key, None); - } else { - // Membership: value is the proof bytes (filler address padded to 32 bytes) - result.insert(key, Some(proof.proof.clone())); - } - } - - Ok(result) - } -} - type Block = frame_system::mocking::MockBlock; type Balance = u64; type AccountId = AccountId32; @@ -235,14 +129,13 @@ impl pallet_ismp::Config for Test { type Router = MockRouter; type Balance = Balance; type Currency = Balances; - type ConsensusClients = (MockPriceConsensusClient,); + type ConsensusClients = (); type OffchainDB = (); type FeeHandler = (); } parameter_types! { pub const StorageDepositFee: Balance = 100; - pub TreasuryAccount: AccountId = AccountId32::new([10; 32]); } impl pallet_intents::Config for Test { @@ -250,7 +143,6 @@ impl pallet_intents::Config for Test { type Currency = Balances; type StorageDepositFee = StorageDepositFee; type GovernanceOrigin = EnsureRoot; - type TreasuryAccount = TreasuryAccount; type MaxPriceEntries = ConstU32<100>; type WeightInfo = (); } @@ -264,7 +156,6 @@ pub fn new_test_ext() -> sp_io::TestExternalities { (AccountId32::new([1; 32]), 10000), (AccountId32::new([2; 32]), 10000), (AccountId32::new([3; 32]), 10000), - (AccountId32::new([10; 32]), 1), // treasury (needs existential deposit) ], ..Default::default() } @@ -276,12 +167,10 @@ pub fn new_test_ext() -> sp_io::TestExternalities { pallet_intents::StorageDepositFee::::put(200u64); // 24 hours in milliseconds pallet_intents::PriceWindowDurationValue::::put(86_400_000u64); - // 1 hour in seconds - pallet_intents::ProofFreshnessThresholdValue::::put(3600u64); - // Max 5 unverified submissions per pair - pallet_intents::MaxUnverifiedSubmissions::::put(5u32); - // Fee of 50 for unverified submissions - pallet_intents::UnverifiedSubmissionFee::::put(50u64); + // Price deposit: 500 tokens + pallet_intents::PriceDepositAmount::::put(500u64); + // Lock duration: 1 hour + pallet_intents::PriceDepositLockDuration::::put(3600u64); }); ext } @@ -662,249 +551,164 @@ fn remove_recognized_pair_works() { assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); - VerifiedPrices::::insert( + Prices::::insert( &pair_id, vec![types::PriceEntry { - filler: H160::from_low_u64_be(1), range_start: U256::zero(), range_end: U256::from(999), price: U256::from(1000), timestamp: 1000, }], ); - UnverifiedPrices::::insert( - &pair_id, - vec![types::PriceEntry { - filler: H160::zero(), - range_start: U256::zero(), - range_end: U256::from(999), - price: U256::from(500), - timestamp: 1000, - }], - ); assert_ok!(Intents::remove_recognized_pair(RuntimeOrigin::root(), pair_id)); - // Verify clean up assert!(RecognizedPairs::::get(&pair_id).is_none()); - assert!(VerifiedPrices::::get(&pair_id).is_empty()); - assert!(UnverifiedPrices::::get(&pair_id).is_empty()); + assert!(Prices::::get(&pair_id).is_empty()); }); } #[test] -fn submit_pair_price_verified() { +fn submit_pair_price_reserves_deposit_on_first_submission() { new_test_ext().execute_with(|| { - let filler = AccountId32::new([1; 32]); - let state_machine = StateMachine::Evm(1); - let commitment = H256::repeat_byte(0xaa); - let price = U256::from(2000); + let submitter = AccountId32::new([1; 32]); - // Add a recognized token pair let pair = types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); - // Add a gateway deployment - let gateway = H160::from_low_u64_be(42); - let params = types::IntentGatewayParams { - host: H160::default(), - dispatcher: H160::default(), - solver_selection: true, - surplus_share_bps: U256::from(5000), - protocol_fee_bps: U256::from(100), - price_oracle: H160::default(), - }; - assert_ok!(Intents::add_deployment(RuntimeOrigin::root(), state_machine, gateway, params,)); + pallet_timestamp::Now::::put(2_000_000u64); - // Set up ISMP consensus state - let sm_id = - StateMachineId { state_id: state_machine, consensus_state_id: MOCK_CONSENSUS_STATE_ID }; - let h1 = StateMachineHeight { id: sm_id, height: H1_HEIGHT }; - let h2 = StateMachineHeight { id: sm_id, height: H2_HEIGHT }; + let balance_before = Balances::free_balance(&submitter); + let deposit_amount = PriceDepositAmount::::get(); - pallet_ismp::ConsensusStateClient::::insert( - MOCK_CONSENSUS_STATE_ID, - MOCK_CONSENSUS_CLIENT_ID, - ); - pallet_ismp::ConsensusStates::::insert(MOCK_CONSENSUS_CLIENT_ID, vec![0u8]); + let entries = BoundedVec::try_from(vec![PriceInput { + range_start: U256::zero(), + range_end: U256::from(999), + price: U256::from(2000), + }]) + .unwrap(); - pallet_ismp::child_trie::StateCommitments::::insert( - h1, - StateCommitment { - timestamp: 1000, - overlay_root: None, - state_root: H256::repeat_byte(0x11).into(), - }, - ); - pallet_ismp::child_trie::StateCommitments::::insert( - h2, - StateCommitment { - timestamp: 1050, - overlay_root: None, - state_root: H256::repeat_byte(0x22).into(), - }, - ); + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(submitter.clone()), + pair_id, + entries, + )); - pallet_ismp::ChallengePeriod::::insert(sm_id, 0u64); - pallet_ismp::StateMachineUpdateTime::::insert(h1, 1000u64); - pallet_ismp::StateMachineUpdateTime::::insert(h2, 1050u64); - pallet_timestamp::Now::::put(2_000_000u64); // 2000 seconds in ms + // Deposit was reserved + assert_eq!(Balances::free_balance(&submitter), balance_before - deposit_amount); + assert_eq!(Balances::reserved_balance(&submitter), deposit_amount); - // Create an EVM keypair for signing - let evm_pair = - sp_core::ecdsa::Pair::from_seed_slice(H256::repeat_byte(0x42).as_bytes()).unwrap(); - let evm_address = evm_pair.public().to_eth_address().unwrap().to_vec(); + // Deposit record stored + let (stored_amount, stored_timestamp) = + PriceDeposits::::get(&submitter, &pair_id).unwrap(); + assert_eq!(stored_amount, deposit_amount); + assert_eq!(stored_timestamp, 2000); - // The filler address in the proof must match the EVM signer - let filler_h160 = H160::from_slice(&evm_address); + // Price entry stored + let prices = Prices::::get(&pair_id); + assert_eq!(prices.len(), 1); + assert_eq!(prices[0].price, U256::from(2000)); + }); +} - // Build proofs - let non_membership_proof = Proof { height: h1, proof: vec![0u8; 32] }; - let mut filler_bytes = vec![0u8; 32]; - filler_bytes[12..32].copy_from_slice(&filler_h160.0); - let membership_proof = Proof { height: h2, proof: filler_bytes }; +#[test] +fn submit_pair_price_second_submission_is_free() { + new_test_ext().execute_with(|| { + let submitter = AccountId32::new([1; 32]); - // Sign the price message: keccak256(encode(nonce=0, pair_id, price)) - let nonce = 0u64; - let msg = types::price_signature_message(nonce, &pair_id, &price); - let signature = evm_pair.sign_prehashed(&msg).0.to_vec(); + let pair = + types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + let pair_id = pair.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); - let verification = types::PriceVerificationData { - state_machine, - commitment, - membership_proof, - non_membership_proof, - evm_signature: Signature::Evm { address: evm_address.clone(), signature }, - }; + pallet_timestamp::Now::::put(2_000_000u64); - let entries = BoundedVec::try_from(vec![types::PriceInput { + let entries1 = BoundedVec::try_from(vec![PriceInput { range_start: U256::zero(), range_end: U256::from(999), - price, + price: U256::from(2000), }]) .unwrap(); assert_ok!(Intents::submit_pair_price( - RuntimeOrigin::signed(filler.clone()), + RuntimeOrigin::signed(submitter.clone()), pair_id, - entries, - types::PriceSubmissionMode::StateProof(verification), + entries1, )); - // Verify the verified price entry was stored - let verified = VerifiedPrices::::get(&pair_id); - assert_eq!(verified.len(), 1); - assert_eq!(verified[0].filler, filler_h160); - assert_eq!(verified[0].range_start, U256::zero()); - assert_eq!(verified[0].range_end, U256::from(999)); - assert_eq!(verified[0].price, price); - - // Verify the EVM nonce was incremented - assert_eq!(EvmNonces::::get(filler_h160), 1); - - // Submit a second price with nonce=1 for a different range - let price2 = U256::from(4000); - let commitment2 = H256::repeat_byte(0xbb); + let deposit_amount = PriceDepositAmount::::get(); + let balance_after_first = Balances::free_balance(&submitter); - let non_membership_proof_2 = Proof { height: h1, proof: vec![0u8; 32] }; - let mut filler_bytes_2 = vec![0u8; 32]; - filler_bytes_2[12..32].copy_from_slice(&filler_h160.0); - let membership_proof_2 = Proof { height: h2, proof: filler_bytes_2 }; - - let msg2 = types::price_signature_message(1u64, &pair_id, &price2); - let signature2 = evm_pair.sign_prehashed(&msg2).0.to_vec(); - - let verification2 = types::PriceVerificationData { - state_machine, - commitment: commitment2, - membership_proof: membership_proof_2, - non_membership_proof: non_membership_proof_2, - evm_signature: Signature::Evm { address: evm_address.clone(), signature: signature2 }, - }; - - let entries2 = BoundedVec::try_from(vec![types::PriceInput { + // Second submission — no additional deposit + let entries2 = BoundedVec::try_from(vec![PriceInput { range_start: U256::from(1000), range_end: U256::from(5000), - price: price2, + price: U256::from(3000), }]) .unwrap(); assert_ok!(Intents::submit_pair_price( - RuntimeOrigin::signed(filler.clone()), + RuntimeOrigin::signed(submitter.clone()), pair_id, entries2, - types::PriceSubmissionMode::StateProof(verification2), )); - // Verify two entries now stored - let verified2 = VerifiedPrices::::get(&pair_id); - assert_eq!(verified2.len(), 2); - assert_eq!(verified2[0].price, price); - assert_eq!(verified2[1].price, price2); + // Balance unchanged (no extra deposit) + assert_eq!(Balances::free_balance(&submitter), balance_after_first); + // Still only one deposit reserved + assert_eq!(Balances::reserved_balance(&submitter), deposit_amount); - // Reusing the same commitment should fail - let non_membership_proof_dup = Proof { height: h1, proof: vec![0u8; 32] }; - let mut filler_bytes_dup = vec![0u8; 32]; - filler_bytes_dup[12..32].copy_from_slice(&filler_h160.0); - let membership_proof_dup = Proof { height: h2, proof: filler_bytes_dup }; + // Two entries now stored + let prices = Prices::::get(&pair_id); + assert_eq!(prices.len(), 2); + }); +} - let msg_dup = types::price_signature_message(2u64, &pair_id, &U256::from(9999)); - let signature_dup = evm_pair.sign_prehashed(&msg_dup).0.to_vec(); +#[test] +fn submit_pair_price_fails_with_insufficient_balance() { + new_test_ext().execute_with(|| { + let submitter = AccountId32::new([4; 32]); // no balance - let verification_dup = types::PriceVerificationData { - state_machine, - commitment, // same commitment - membership_proof: membership_proof_dup, - non_membership_proof: non_membership_proof_dup, - evm_signature: Signature::Evm { - address: evm_address.clone(), - signature: signature_dup, - }, - }; + let pair = + types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + let pair_id = pair.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + + pallet_timestamp::Now::::put(2_000_000u64); - let entries_dup = BoundedVec::try_from(vec![types::PriceInput { + let entries = BoundedVec::try_from(vec![PriceInput { range_start: U256::zero(), range_end: U256::from(999), - price: U256::from(9999), + price: U256::from(2000), }]) .unwrap(); assert_noop!( - Intents::submit_pair_price( - RuntimeOrigin::signed(filler.clone()), - pair_id, - entries_dup, - types::PriceSubmissionMode::StateProof(verification_dup), - ), - Error::::CommitmentAlreadyUsed + Intents::submit_pair_price(RuntimeOrigin::signed(submitter), pair_id, entries,), + Error::::InsufficientBalance ); }); } #[test] -fn submit_pair_price_unverified() { +fn withdraw_price_deposit_works_after_lock_duration() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let price = U256::from(1500); - // Add a recognized token pair let pair = types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); - // Set timestamp + // Submit at timestamp 2000s pallet_timestamp::Now::::put(2_000_000u64); - let balance_before = Balances::free_balance(&submitter); - - // Submit unverified price (no verification data) let entries = BoundedVec::try_from(vec![PriceInput { range_start: U256::zero(), range_end: U256::from(999), - price, + price: U256::from(2000), }]) .unwrap(); @@ -912,25 +716,30 @@ fn submit_pair_price_unverified() { RuntimeOrigin::signed(submitter.clone()), pair_id, entries, - types::PriceSubmissionMode::Unverified, )); - // Verify fee was charged - let fee = UnverifiedSubmissionFee::::get(); - assert_eq!(Balances::free_balance(&submitter), balance_before - fee); + let deposit_amount = PriceDepositAmount::::get(); + let balance_after_submit = Balances::free_balance(&submitter); + + // Advance past lock duration (2000 + 3600 = 5600 seconds) + pallet_timestamp::Now::::put(6_000_000u64); // 6000 seconds + + assert_ok!(Intents::withdraw_price_deposit( + RuntimeOrigin::signed(submitter.clone()), + pair_id, + )); - // Verify unverified price entry was stored - let unverified = UnverifiedPrices::::get(&pair_id); - assert_eq!(unverified.len(), 1); - assert_eq!(unverified[0].price, price); + // Deposit unreserved + assert_eq!(Balances::free_balance(&submitter), balance_after_submit + deposit_amount); + assert_eq!(Balances::reserved_balance(&submitter), 0); - // Verified prices should be empty - assert!(VerifiedPrices::::get(&pair_id).is_empty()); + // Deposit record removed + assert!(PriceDeposits::::get(&submitter, &pair_id).is_none()); }); } #[test] -fn unverified_prices_fifo_replacement() { +fn withdraw_price_deposit_fails_when_still_locked() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); @@ -939,49 +748,58 @@ fn unverified_prices_fifo_replacement() { let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + // Submit at timestamp 2000s pallet_timestamp::Now::::put(2_000_000u64); - // Set max to 3 for easier testing - MaxUnverifiedSubmissions::::put(3u32); - - // Submit 3 unverified prices (fills the cap) - for i in 1..=3u64 { - let input = BoundedVec::try_from(vec![PriceInput { - range_start: U256::zero(), - range_end: U256::from(999), - price: U256::from(i * 1000), - }]) - .unwrap(); - assert_ok!(Intents::submit_pair_price( - RuntimeOrigin::signed(submitter.clone()), - pair_id, - input, - types::PriceSubmissionMode::Unverified, - )); - } - - let stored = UnverifiedPrices::::get(&pair_id); - assert_eq!(stored.len(), 3); - assert_eq!(stored[0].price, U256::from(1000)); // oldest - - // Submit 4th — should pop the oldest (1000) and add new - let input4 = BoundedVec::try_from(vec![PriceInput { + let entries = BoundedVec::try_from(vec![PriceInput { range_start: U256::zero(), range_end: U256::from(999), - price: U256::from(4000), + price: U256::from(2000), }]) .unwrap(); + assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, - input4, - types::PriceSubmissionMode::Unverified, + entries, )); - let stored = UnverifiedPrices::::get(&pair_id); - assert_eq!(stored.len(), 3); - assert_eq!(stored[0].price, U256::from(2000)); // 1000 was popped - assert_eq!(stored[2].price, U256::from(4000)); // new entry at end + // Still within lock duration (2000 + 1000 < 2000 + 3600) + pallet_timestamp::Now::::put(3_000_000u64); // 3000 seconds + + assert_noop!( + Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter), pair_id,), + Error::::DepositStillLocked + ); + }); +} + +#[test] +fn withdraw_price_deposit_fails_when_no_deposit() { + new_test_ext().execute_with(|| { + let submitter = AccountId32::new([1; 32]); + let pair_id = H256::random(); + + assert_noop!( + Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter), pair_id,), + Error::::DepositNotFound + ); + }); +} + +#[test] +fn set_price_deposit_amount_works() { + new_test_ext().execute_with(|| { + assert_ok!(Intents::set_price_deposit_amount(RuntimeOrigin::root(), 1000u64)); + assert_eq!(PriceDepositAmount::::get(), 1000u64); + }); +} + +#[test] +fn set_price_deposit_lock_duration_works() { + new_test_ext().execute_with(|| { + assert_ok!(Intents::set_price_deposit_lock_duration(RuntimeOrigin::root(), 7200u64)); + assert_eq!(PriceDepositLockDuration::::get(), 7200u64); }); } @@ -994,51 +812,36 @@ fn prices_persist_across_window_and_clear_on_first_submission() { let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); - // Simulate day 1: store some prices with timestamps in the current window - VerifiedPrices::::insert( + // Simulate day 1: store some prices + Prices::::insert( &pair_id, vec![types::PriceEntry { - filler: H160::from_low_u64_be(1), range_start: U256::zero(), range_end: U256::from(999), price: U256::from(1666), timestamp: 1000, }], ); - UnverifiedPrices::::insert( - &pair_id, - vec![types::PriceEntry { - filler: H160::zero(), - range_start: U256::zero(), - range_end: U256::from(999), - price: U256::from(1500), - timestamp: 1000, - }], - ); - // Window started at second 1000, duration is 86_400_000 ms = 86_400 s + // Window started at second 1000 PriceWindowStart::::put(1000u64); // Before window expires: on_initialize does nothing to prices pallet_timestamp::Now::::put(50_000_000u64); // 50_000 seconds in ms Intents::on_initialize(1u64); - // Prices should be untouched - assert_eq!(VerifiedPrices::::get(&pair_id).len(), 1); - assert_eq!(UnverifiedPrices::::get(&pair_id).len(), 1); + assert_eq!(Prices::::get(&pair_id).len(), 1); // Advance past the window (1000 + 86_400 = 87_400 seconds) pallet_timestamp::Now::::put(90_000_000u64); // 90_000 seconds in ms Intents::on_initialize(2u64); - // on_initialize only clears UsedCommitments and updates PriceWindowStart. - // Prices still persist! (yesterday's data readable until first new submission) - assert_eq!(VerifiedPrices::::get(&pair_id).len(), 1); - assert_eq!(UnverifiedPrices::::get(&pair_id).len(), 1); + // Prices still persist (readable until first new submission) + assert_eq!(Prices::::get(&pair_id).len(), 1); assert_eq!(PriceWindowStart::::get(), 90_000); - // Now submit an unverified price, this is the first submission in the new window. - // It should clear stale entries for this pair before adding the new one. + // Submit a new price — this is the first submission in the new window. + // It should clear stale entries before adding the new one. let new_entries = BoundedVec::try_from(vec![PriceInput { range_start: U256::zero(), range_end: U256::from(999), @@ -1049,415 +852,187 @@ fn prices_persist_across_window_and_clear_on_first_submission() { RuntimeOrigin::signed(submitter.clone()), pair_id, new_entries, - types::PriceSubmissionMode::Unverified, )); - // Old entries are gone, only the new unverified entry remains - assert!(VerifiedPrices::::get(&pair_id).is_empty()); - let unverified = UnverifiedPrices::::get(&pair_id); - assert_eq!(unverified.len(), 1); - assert_eq!(unverified[0].price, U256::from(2000)); + // Old entries gone, only new entry remains + let prices = Prices::::get(&pair_id); + assert_eq!(prices.len(), 1); + assert_eq!(prices[0].price, U256::from(2000)); }); } #[test] fn price_entry_encoding_matches_rpc_tuple_decoding() { - // The RPC decodes PriceEntry as Vec<(H160, U256, U256, U256, u64)>. + // The RPC decodes PriceEntry as Vec<(U256, U256, U256, u64)>. // Verify that PriceEntry's SCALE encoding is identical to the tuple encoding. use codec::Encode; - let filler = H160::from_low_u64_be(42); let range_start = U256::zero(); let range_end = U256::from(999); let price = U256::from(42_000); let timestamp = 1_700_000_000u64; - let entry = PriceEntry { filler, range_start, range_end, price, timestamp }; + let entry = PriceEntry { range_start, range_end, price, timestamp }; let entry_bytes = entry.encode(); - let tuple_bytes = (filler, range_start, range_end, price, timestamp).encode(); + let tuple_bytes = (range_start, range_end, price, timestamp).encode(); assert_eq!(entry_bytes, tuple_bytes, "PriceEntry SCALE encoding must match tuple encoding"); // Also verify round-trip: encode as PriceEntry, decode as tuple - type RpcTuple = (H160, U256, U256, U256, u64); + type RpcTuple = (U256, U256, U256, u64); let entries = vec![entry]; let encoded = entries.encode(); let decoded: Vec = Decode::decode(&mut &encoded[..]).unwrap(); assert_eq!(decoded.len(), 1); - assert_eq!(decoded[0].0, filler); - assert_eq!(decoded[0].1, range_start); - assert_eq!(decoded[0].2, range_end); - assert_eq!(decoded[0].3, price); - assert_eq!(decoded[0].4, timestamp); + assert_eq!(decoded[0].0, range_start); + assert_eq!(decoded[0].1, range_end); + assert_eq!(decoded[0].2, price); + assert_eq!(decoded[0].3, timestamp); } #[test] fn price_entry_storage_roundtrip_via_raw_key() { - // End-to-end test: write prices via pallet storage, read raw bytes, decode as the RPC would. new_test_ext().execute_with(|| { let pair = types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; let pair_id = pair.pair_id(); let entry1 = types::PriceEntry { - filler: H160::from_low_u64_be(1), range_start: U256::zero(), range_end: U256::from(999), price: U256::from(2000), timestamp: 1000, }; let entry2 = types::PriceEntry { - filler: H160::from_low_u64_be(2), range_start: U256::from(1000), range_end: U256::from(5000), price: U256::from(3000), timestamp: 2000, }; - VerifiedPrices::::insert(&pair_id, vec![entry1.clone(), entry2.clone()]); - UnverifiedPrices::::insert( - &pair_id, - vec![types::PriceEntry { - filler: H160::zero(), - range_start: U256::zero(), - range_end: U256::from(999), - price: U256::from(1500), - timestamp: 500, - }], - ); + Prices::::insert(&pair_id, vec![entry1.clone(), entry2.clone()]); // Build the storage key the same way the RPC does. let pallet_prefix = b"Intents"; let mut key = Vec::new(); key.extend_from_slice(&sp_io::hashing::twox_128(pallet_prefix)); - key.extend_from_slice(&sp_io::hashing::twox_128(b"VerifiedPrices")); + key.extend_from_slice(&sp_io::hashing::twox_128(b"Prices")); let pair_id_bytes = pair_id.as_bytes(); key.extend_from_slice(&sp_io::hashing::blake2_128(pair_id_bytes)); key.extend_from_slice(pair_id_bytes); - // Read raw storage - let raw = sp_io::storage::get(&key).expect("VerifiedPrices storage should exist"); + let raw = sp_io::storage::get(&key).expect("Prices storage should exist"); - // Decode as the RPC would - type RpcTuple = (H160, U256, U256, U256, u64); + type RpcTuple = (U256, U256, U256, u64); let decoded: Vec = Decode::decode(&mut &raw[..]).unwrap(); assert_eq!(decoded.len(), 2); - assert_eq!(decoded[0].0, H160::from_low_u64_be(1)); - assert_eq!(decoded[0].1, U256::zero()); - assert_eq!(decoded[0].2, U256::from(999)); - assert_eq!(decoded[0].3, U256::from(2000)); - assert_eq!(decoded[0].4, 1000u64); - assert_eq!(decoded[1].0, H160::from_low_u64_be(2)); - assert_eq!(decoded[1].1, U256::from(1000)); - assert_eq!(decoded[1].2, U256::from(5000)); - assert_eq!(decoded[1].3, U256::from(3000)); - assert_eq!(decoded[1].4, 2000u64); - - // Do the same for UnverifiedPrices - let mut ukey = Vec::new(); - ukey.extend_from_slice(&sp_io::hashing::twox_128(pallet_prefix)); - ukey.extend_from_slice(&sp_io::hashing::twox_128(b"UnverifiedPrices")); - ukey.extend_from_slice(&sp_io::hashing::blake2_128(pair_id_bytes)); - ukey.extend_from_slice(pair_id_bytes); - - let uraw = sp_io::storage::get(&ukey).expect("UnverifiedPrices storage should exist"); - let udecoded: Vec = Decode::decode(&mut &uraw[..]).unwrap(); - assert_eq!(udecoded.len(), 1); - assert_eq!(udecoded[0].0, H160::zero()); - assert_eq!(udecoded[0].1, U256::zero()); - assert_eq!(udecoded[0].2, U256::from(999)); - assert_eq!(udecoded[0].3, U256::from(1500)); - assert_eq!(udecoded[0].4, 500u64); + assert_eq!(decoded[0].0, U256::zero()); + assert_eq!(decoded[0].1, U256::from(999)); + assert_eq!(decoded[0].2, U256::from(2000)); + assert_eq!(decoded[0].3, 1000u64); + assert_eq!(decoded[1].0, U256::from(1000)); + assert_eq!(decoded[1].1, U256::from(5000)); + assert_eq!(decoded[1].2, U256::from(3000)); + assert_eq!(decoded[1].3, 2000u64); }); } #[test] -fn extract_filler_from_redeem_decodes_correctly() { - use alloy_sol_types::SolValue; - - let filler_address = H160::from_low_u64_be(0xdeadbeef); - - // Build a RedeemEscrow body: [0x00] + abi.encode(WithdrawalRequest) - let commitment = alloy_primitives::FixedBytes::<32>::from([0xaa; 32]); - // beneficiary = bytes32(uint256(uint160(filler))) - let mut beneficiary_bytes = [0u8; 32]; - beneficiary_bytes[12..32].copy_from_slice(&filler_address.0); - let beneficiary = alloy_primitives::FixedBytes::<32>::from(beneficiary_bytes); - - let withdrawal = ( - commitment, - beneficiary, - Vec::<(alloy_primitives::FixedBytes<32>, alloy_primitives::U256)>::new(), - ); - let mut body = vec![0x00u8]; // RedeemEscrow discriminator - body.extend_from_slice(&withdrawal.abi_encode()); - - let extracted = types::extract_filler_from_redeem(&body); - assert_eq!(extracted, Some(filler_address)); -} - -#[test] -fn extract_filler_from_redeem_rejects_non_redeem() { - use alloy_sol_types::SolValue; - - let commitment = alloy_primitives::FixedBytes::<32>::from([0xaa; 32]); - let beneficiary = alloy_primitives::FixedBytes::<32>::from([0xbb; 32]); - let withdrawal = ( - commitment, - beneficiary, - Vec::<(alloy_primitives::FixedBytes<32>, alloy_primitives::U256)>::new(), - ); - - // Use discriminator 0x01 (NewDeployment), not RedeemEscrow - let mut body = vec![0x01u8]; - body.extend_from_slice(&withdrawal.abi_encode()); - - assert_eq!(types::extract_filler_from_redeem(&body), None); -} - -#[test] -fn inspect_request_adds_verified_filler() { +fn multiple_submitters_independent_deposits() { new_test_ext().execute_with(|| { - use alloy_sol_types::SolValue; + let submitter1 = AccountId32::new([1; 32]); + let submitter2 = AccountId32::new([2; 32]); - let state_machine = StateMachine::Evm(1); - let gateway = H160::from_low_u64_be(42); - let params = types::IntentGatewayParams { - host: H160::default(), - dispatcher: H160::default(), - solver_selection: true, - surplus_share_bps: U256::from(5000), - protocol_fee_bps: U256::from(100), - price_oracle: H160::default(), - }; - assert_ok!(Intents::add_deployment(RuntimeOrigin::root(), state_machine, gateway, params)); - - let filler_address = H160::from_low_u64_be(0xdeadbeef); - let mut beneficiary_bytes = [0u8; 32]; - beneficiary_bytes[12..32].copy_from_slice(&filler_address.0); - - let commitment = alloy_primitives::FixedBytes::<32>::from([0xaa; 32]); - let beneficiary = alloy_primitives::FixedBytes::<32>::from(beneficiary_bytes); - let withdrawal = ( - commitment, - beneficiary, - Vec::<(alloy_primitives::FixedBytes<32>, alloy_primitives::U256)>::new(), - ); - - let mut body = vec![0x00u8]; - body.extend_from_slice(&withdrawal.abi_encode()); - - // Set timestamp - pallet_timestamp::Now::::put(2_000_000u64); // 2000 seconds - - let post = ismp::router::PostRequest { - source: state_machine, - dest: StateMachine::Evm(2), - nonce: 0, - from: gateway.0.to_vec(), - to: vec![0u8; 20], - timeout_timestamp: 0, - body, - }; - - assert_ok!(Intents::inspect_request(&post)); - - // Verify filler was added - let verified_at = VerifiedFillers::::get(filler_address); - assert!(verified_at.is_some()); - assert_eq!(verified_at.unwrap(), 2000); // 2_000_000ms / 1000 - }); -} - -#[test] -fn inspect_request_ignores_unknown_gateway() { - new_test_ext().execute_with(|| { - use alloy_sol_types::SolValue; - - let filler_address = H160::from_low_u64_be(0xdeadbeef); - let mut beneficiary_bytes = [0u8; 32]; - beneficiary_bytes[12..32].copy_from_slice(&filler_address.0); - - let commitment = alloy_primitives::FixedBytes::<32>::from([0xaa; 32]); - let beneficiary = alloy_primitives::FixedBytes::<32>::from(beneficiary_bytes); - let withdrawal = ( - commitment, - beneficiary, - Vec::<(alloy_primitives::FixedBytes<32>, alloy_primitives::U256)>::new(), - ); - - let mut body = vec![0x00u8]; - body.extend_from_slice(&withdrawal.abi_encode()); - - pallet_timestamp::Now::::put(2_000_000u64); - - // No gateway registered — sender is unknown - let unknown_gateway = H160::from_low_u64_be(999); - let post = ismp::router::PostRequest { - source: StateMachine::Evm(1), - dest: StateMachine::Evm(2), - nonce: 0, - from: unknown_gateway.0.to_vec(), - to: vec![0u8; 20], - timeout_timestamp: 0, - body, - }; - - assert_ok!(Intents::inspect_request(&post)); - - // Filler should NOT be added - assert!(VerifiedFillers::::get(filler_address).is_none()); - }); -} - -#[test] -fn submit_cross_chain_verified_price() { - new_test_ext().execute_with(|| { - let submitter = AccountId32::new([1; 32]); - let price = U256::from(2000); - - // Add a recognized token pair let pair = types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); - // Create an EVM keypair - let evm_pair = - sp_core::ecdsa::Pair::from_seed_slice(H256::repeat_byte(0x42).as_bytes()).unwrap(); - let evm_address = evm_pair.public().to_eth_address().unwrap().to_vec(); - let filler_h160 = H160::from_slice(&evm_address); - - // Simulate the filler being verified via cross-chain inspection - // (timestamp 2000 seconds) - VerifiedFillers::::insert(filler_h160, 2000u64); - - // Set current time to 2500 seconds (within 3600s threshold) - pallet_timestamp::Now::::put(2_500_000u64); - - // Sign the price message - let nonce = 0u64; - let msg = types::price_signature_message(nonce, &pair_id, &price); - let signature = evm_pair.sign_prehashed(&msg).0.to_vec(); + pallet_timestamp::Now::::put(2_000_000u64); - let cross_chain_data = types::CrossChainVerificationData { - evm_signature: Signature::Evm { address: evm_address.clone(), signature }, - }; + let deposit_amount = PriceDepositAmount::::get(); - let entries = BoundedVec::try_from(vec![types::PriceInput { + let entries = BoundedVec::try_from(vec![PriceInput { range_start: U256::zero(), range_end: U256::from(999), - price, + price: U256::from(2000), }]) .unwrap(); + // Both submitters submit prices assert_ok!(Intents::submit_pair_price( - RuntimeOrigin::signed(submitter.clone()), + RuntimeOrigin::signed(submitter1.clone()), + pair_id, + entries.clone(), + )); + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(submitter2.clone()), pair_id, entries, - types::PriceSubmissionMode::CrossChain(cross_chain_data), )); - // Verify stored as verified price - let verified = VerifiedPrices::::get(&pair_id); - assert_eq!(verified.len(), 1); - assert_eq!(verified[0].filler, filler_h160); - assert_eq!(verified[0].price, price); + // Each has their own deposit + assert_eq!(Balances::reserved_balance(&submitter1), deposit_amount); + assert_eq!(Balances::reserved_balance(&submitter2), deposit_amount); - // Verify nonce incremented - assert_eq!(EvmNonces::::get(filler_h160), 1); + // Two entries in prices + assert_eq!(Prices::::get(&pair_id).len(), 2); }); } #[test] -fn cross_chain_verified_fails_when_filler_not_verified() { +fn separate_deposits_per_pair() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let price = U256::from(2000); - let pair = + let pair1 = types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; - let pair_id = pair.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + let pair2 = + types::TokenPair { base: H160::from_low_u64_be(3), quote: H160::from_low_u64_be(4) }; + let pair_id1 = pair1.pair_id(); + let pair_id2 = pair2.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair1)); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair2)); - let evm_pair = - sp_core::ecdsa::Pair::from_seed_slice(H256::repeat_byte(0x42).as_bytes()).unwrap(); - let evm_address = evm_pair.public().to_eth_address().unwrap().to_vec(); - - pallet_timestamp::Now::::put(2_500_000u64); - - let msg = types::price_signature_message(0u64, &pair_id, &price); - let signature = evm_pair.sign_prehashed(&msg).0.to_vec(); + pallet_timestamp::Now::::put(2_000_000u64); - let cross_chain_data = types::CrossChainVerificationData { - evm_signature: Signature::Evm { address: evm_address, signature }, - }; + let deposit_amount = PriceDepositAmount::::get(); - let entries = BoundedVec::try_from(vec![types::PriceInput { + let entries = BoundedVec::try_from(vec![PriceInput { range_start: U256::zero(), range_end: U256::from(999), - price, + price: U256::from(2000), }]) .unwrap(); - // Filler not in VerifiedFillers — should fail - assert_noop!( - Intents::submit_pair_price( - RuntimeOrigin::signed(submitter), - pair_id, - entries, - types::PriceSubmissionMode::CrossChain(cross_chain_data), - ), - Error::::FillerNotVerified - ); - }); -} - -#[test] -fn cross_chain_verified_fails_when_expired() { - new_test_ext().execute_with(|| { - let submitter = AccountId32::new([1; 32]); - let price = U256::from(2000); - - let pair = - types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; - let pair_id = pair.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); - - let evm_pair = - sp_core::ecdsa::Pair::from_seed_slice(H256::repeat_byte(0x42).as_bytes()).unwrap(); - let evm_address = evm_pair.public().to_eth_address().unwrap().to_vec(); - let filler_h160 = H160::from_slice(&evm_address); - - // Verified at timestamp 1000 - VerifiedFillers::::insert(filler_h160, 1000u64); - - // Current time is 5000 seconds — threshold is 3600, so age = 4000 > 3600 - pallet_timestamp::Now::::put(5_000_000u64); + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(submitter.clone()), + pair_id1, + entries.clone(), + )); + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(submitter.clone()), + pair_id2, + entries, + )); - let msg = types::price_signature_message(0u64, &pair_id, &price); - let signature = evm_pair.sign_prehashed(&msg).0.to_vec(); + // Two deposits reserved (one per pair) + assert_eq!(Balances::reserved_balance(&submitter), deposit_amount * 2); - let cross_chain_data = types::CrossChainVerificationData { - evm_signature: Signature::Evm { address: evm_address, signature }, - }; + // Can withdraw each independently + pallet_timestamp::Now::::put(6_000_000u64); - let entries = BoundedVec::try_from(vec![types::PriceInput { - range_start: U256::zero(), - range_end: U256::from(999), - price, - }]) - .unwrap(); + assert_ok!(Intents::withdraw_price_deposit( + RuntimeOrigin::signed(submitter.clone()), + pair_id1, + )); + assert_eq!(Balances::reserved_balance(&submitter), deposit_amount); - assert_noop!( - Intents::submit_pair_price( - RuntimeOrigin::signed(submitter), - pair_id, - entries, - types::PriceSubmissionMode::CrossChain(cross_chain_data), - ), - Error::::FillerVerificationExpired - ); + assert_ok!(Intents::withdraw_price_deposit( + RuntimeOrigin::signed(submitter.clone()), + pair_id2, + )); + assert_eq!(Balances::reserved_balance(&submitter), 0); }); } diff --git a/modules/pallets/intents-coprocessor/src/types.rs b/modules/pallets/intents-coprocessor/src/types.rs index 86e86683b..936fa3992 100644 --- a/modules/pallets/intents-coprocessor/src/types.rs +++ b/modules/pallets/intents-coprocessor/src/types.rs @@ -18,8 +18,6 @@ use alloc::{vec, vec::Vec}; use alloy_sol_types::SolValue; use codec::{Decode, DecodeWithMemTracking, Encode}; -use crypto_utils::verification::Signature; -use ismp::{host::StateMachine, messaging::Proof}; use primitive_types::{H160, H256, U256}; use scale_info::TypeInfo; @@ -165,7 +163,7 @@ impl TokenPair { } /// Caller-provided price data for a specific range of base token amounts. -/// The pallet fills in the filler address and timestamp when storing. +/// The pallet adds a timestamp when storing. #[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] pub struct PriceInput { /// Lower bound of the base token amount range (inclusive), with 18 decimal places @@ -177,12 +175,10 @@ pub struct PriceInput { } /// An individual price submission stored on-chain. The price applies to a specific -/// range of base token amounts, allowing fillers to quote different rates for -/// different order sizes (e.g. USDC/CNGN: 0-999 -> 1414, 1000-5000 -> 1420). +/// range of base token amounts, allowing submitters to quote different rates for +/// different order sizes (e.g. USDC/CNGN: 0-999 at 1414, 1000-5000 at 1420). #[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] pub struct PriceEntry { - /// The filler's EVM address. Set to `H160::zero()` for unverified submissions. - pub filler: H160, /// Lower bound of the base token amount range (inclusive), with 18 decimal places pub range_start: U256, /// Upper bound of the base token amount range (inclusive), with 18 decimal places @@ -193,82 +189,6 @@ pub struct PriceEntry { pub timestamp: u64, } -/// Verification data for proven price submissions (high confidence) -/// -/// When provided, the submission is treated as a verified filler price. -/// The EVM signature proves the substrate account owner also controls the -/// EVM account that filled the order. -#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] -pub struct PriceVerificationData { - /// The state machine where the order was filled - pub state_machine: StateMachine, - /// The filled order commitment hash - pub commitment: H256, - /// Proof that the order was filled at some height - pub membership_proof: Proof, - /// Proof that the order was not filled at an earlier height - pub non_membership_proof: Proof, - /// EVM signature proving ownership of the filler's EVM account. - /// The signer must sign `keccak256(encode(nonce, pair_id, price))`. - pub evm_signature: Signature, -} - -/// Determines how a price submission is verified. -#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] -pub enum PriceSubmissionMode { - /// Full verification with ISMP state proofs and EVM signature. - StateProof(PriceVerificationData), - /// Cross-chain verification: the filler was passively verified by the - /// IntentGateway inspector when their RedeemEscrow message flowed through - /// Hyperbridge. Only an EVM signature is required. - CrossChain(CrossChainVerificationData), - /// No verification. Anyone can submit by paying a fee. - Unverified, -} - -/// Verification data for cross-chain verified price submissions. -/// -/// Fillers who have been passively verified through the IntentGateway inspector -/// (their RedeemEscrow message was seen flowing through Hyperbridge) only need -/// to provide an EVM signature to prove they own the verified address. -/// No ISMP state proofs are required. -#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] -pub struct CrossChainVerificationData { - /// EVM signature proving ownership of the filler's EVM account. - /// The signer must sign `keccak256(encode(nonce, pair_id, price))`. - pub evm_signature: Signature, -} - -/// Compute the message hash that the filler must sign with their EVM key. -/// -/// Message = keccak256(SCALE_encode(nonce, pair_id, price)) -pub fn price_signature_message(nonce: u64, pair_id: &H256, price: &U256) -> [u8; 32] { - sp_io::hashing::keccak_256(&(nonce, pair_id, price).encode()) -} - -/// The storage slot index for the `_filled` mapping in IntentGateway.sol -pub const FILLED_SLOT: [u8; 32] = - hex_literal::hex!("0000000000000000000000000000000000000000000000000000000000000005"); - -/// Compute the EVM state proof key for `_filled[commitment]` on the given gateway contract. -/// -/// Returns a 52-byte key: 20-byte contract address + 32-byte storage slot. -/// The EVM state machine client uses the first 20 bytes to locate the contract -/// and hashes the last 32 bytes to derive the storage trie key. -pub fn filled_storage_key(gateway: &H160, commitment: &H256) -> Vec { - // Compute the raw storage slot: keccak256(commitment ++ FILLED_SLOT) - let mut slot_preimage = Vec::with_capacity(64); - slot_preimage.extend_from_slice(commitment.as_bytes()); - slot_preimage.extend_from_slice(&FILLED_SLOT); - let slot = sp_io::hashing::keccak_256(&slot_preimage); - - // 52-byte key: gateway address (20) + slot (32) - let mut key = Vec::with_capacity(52); - key.extend_from_slice(&gateway.0); - key.extend_from_slice(&slot); - key -} - impl IntentGatewayParams { /// Apply an update to the current parameters, returning a new instance pub fn update(&self, update: ParamsUpdate) -> Self { @@ -367,44 +287,7 @@ mod sol_types { TokenDecimal[] tokens; } - /// Solidity representation of WithdrawalRequest (used by RedeemEscrow/RefundEscrow) - struct WithdrawalRequest { - bytes32 commitment; - bytes32 beneficiary; - TokenInfo[] tokens; - } - } -} - -/// Extract the filler's EVM address from a RedeemEscrow intent gateway request body. -/// -/// The body format is: `[1-byte discriminator] + abi.encode(WithdrawalRequest)`. -/// Returns `Some(filler_address)` if the discriminator is `RedeemEscrow` (0x00) -/// and the body decodes successfully. The beneficiary field is a bytes32 containing -/// the filler's 20-byte EVM address left-padded to 32 bytes. -pub fn extract_filler_from_redeem(body: &[u8]) -> Option { - use alloy_sol_types::SolType; - - if body.is_empty() { - return None; - } - - if body[0] != IntentGatewayRequestKind::RedeemEscrow as u8 { - return None; - } - - let withdrawal = ::abi_decode(&body[1..]).ok()?; - - // The beneficiary is bytes32(uint256(uint160(msg.sender))) — the 20-byte address - // is stored in the low 20 bytes (right-aligned, big-endian). - let beneficiary_bytes: [u8; 32] = withdrawal.beneficiary.into(); - let filler = H160::from_slice(&beneficiary_bytes[12..32]); - - if filler == H160::zero() { - return None; } - - Some(filler) } impl From for sol_types::Params { diff --git a/parachain/runtimes/gargantua/src/ismp.rs b/parachain/runtimes/gargantua/src/ismp.rs index 0d612964f..6d262e979 100644 --- a/parachain/runtimes/gargantua/src/ismp.rs +++ b/parachain/runtimes/gargantua/src/ismp.rs @@ -15,9 +15,9 @@ use crate::{ alloc::{boxed::Box, string::ToString}, - weights, AccountId, Assets, Balance, Balances, IntentsCoprocessor, Ismp, IsmpParachain, Mmr, - ParachainInfo, Runtime, RuntimeEvent, Timestamp, TokenGatewayInspector, TreasuryAccount, - TreasuryPalletId, XcmGateway, + weights, AccountId, Assets, Balance, Balances, Ismp, IsmpParachain, Mmr, + ParachainInfo, Runtime, RuntimeEvent, Timestamp, TokenGatewayInspector, TreasuryPalletId, + XcmGateway, EXISTENTIAL_DEPOSIT, }; use anyhow::anyhow; @@ -92,7 +92,6 @@ impl pallet_intents_coprocessor::Config for Runtime { type Currency = Balances; type StorageDepositFee = IntentStorageDepositFee; type GovernanceOrigin = EnsureRoot; - type TreasuryAccount = TreasuryAccount; type MaxPriceEntries = ConstU32<10>; type WeightInfo = weights::pallet_intents_coprocessor::WeightInfo; } @@ -289,7 +288,6 @@ impl IsmpModule for ProxyModule { fn on_accept(&self, request: PostRequest) -> Result { if request.dest != HostStateMachine::get() { TokenGatewayInspector::inspect_request(&request)?; - IntentsCoprocessor::inspect_request(&request)?; Ismp::dispatch_request( Request::Post(request), diff --git a/parachain/runtimes/nexus/src/ismp.rs b/parachain/runtimes/nexus/src/ismp.rs index 580db3686..3bf831abb 100644 --- a/parachain/runtimes/nexus/src/ismp.rs +++ b/parachain/runtimes/nexus/src/ismp.rs @@ -16,9 +16,9 @@ use crate::{ alloc::{boxed::Box, string::ToString}, governance::WhitelistedCaller, - weights, AccountId, Assets, Balance, Balances, IntentsCoprocessor, Ismp, IsmpParachain, Mmr, + weights, AccountId, Assets, Balance, Balances, Ismp, IsmpParachain, Mmr, ParachainInfo, ReputationAsset, Runtime, RuntimeEvent, TechnicalCollectiveInstance, Timestamp, - TokenGateway, TokenGatewayInspector, TreasuryAccount, TreasuryPalletId, XcmGateway, + TokenGateway, TokenGatewayInspector, TreasuryPalletId, XcmGateway, EXISTENTIAL_DEPOSIT, MIN_TECH_COLLECTIVE_APPROVAL, }; use anyhow::anyhow; @@ -433,7 +433,6 @@ impl pallet_intents_coprocessor::Config for Runtime { MIN_TECH_COLLECTIVE_APPROVAL, >, >; - type TreasuryAccount = TreasuryAccount; type MaxPriceEntries = ConstU32<10>; type WeightInfo = weights::pallet_intents_coprocessor::WeightInfo; } @@ -441,7 +440,6 @@ impl IsmpModule for ProxyModule { fn on_accept(&self, request: PostRequest) -> Result { if request.dest != HostStateMachine::get() { TokenGatewayInspector::inspect_request(&request)?; - IntentsCoprocessor::inspect_request(&request)?; Ismp::dispatch_request( Request::Post(request), From 4fd12d4d31de8c69c7af228a0410801849f3f027 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Mon, 16 Mar 2026 17:21:09 +0100 Subject: [PATCH 09/28] simplex integration for price update --- .../intent-gateway/price-submission.mdx | 79 +++++++++++++++++++ .../sdk/src/chains/intentsCoprocessor.ts | 52 +++++++++++- sdk/packages/sdk/src/types/index.ts | 10 +++ .../simplex/filler-config-example.toml | 23 ++++++ sdk/packages/simplex/src/bin/simplex.ts | 43 ++++++++++ sdk/packages/simplex/src/core/filler.ts | 19 +++++ sdk/packages/simplex/src/index.ts | 3 +- sdk/packages/simplex/src/services/index.ts | 1 + 8 files changed, 228 insertions(+), 2 deletions(-) diff --git a/docs/content/developers/intent-gateway/price-submission.mdx b/docs/content/developers/intent-gateway/price-submission.mdx index a09fb74e6..08fd093c6 100644 --- a/docs/content/developers/intent-gateway/price-submission.mdx +++ b/docs/content/developers/intent-gateway/price-submission.mdx @@ -57,6 +57,85 @@ Only governance-approved token pairs can receive price submissions. This prevent The `intents_getPairPrices(pair_id)` RPC endpoint returns all prices for a given token pair. Each returned entry includes the amount range (`range_start`, `range_end`), the price, and the submission timestamp. +## Simplex Filler Integration + +The simplex filler supports periodic price submission out of the box via a `[priceUpdates]` section in the filler configuration file. This requires `substratePrivateKey` and `hyperbridgeWsUrl` to be configured. + +### Configuration + +Add the following to your `filler-config.toml`: + +```toml +[priceUpdates] +intervalSeconds = 300 # How often to submit prices (default: 300 = 5 minutes) + +[[priceUpdates.pairs]] +pairId = "0x..." # The token pair ID (keccak256 of base_address ++ quote_address) +label = "USDC/CNGN" +decimals = 18 # Decimal places for amounts/prices (default: 18) + +[[priceUpdates.pairs.entries]] +rangeStart = "0" # Min base amount in human-readable form +rangeEnd = "999" # Max base amount in human-readable form +price = "1414" # Price in human-readable form + +[[priceUpdates.pairs.entries]] +rangeStart = "1000" +rangeEnd = "5000" +price = "1420" +``` + +Amounts are specified in human-readable form (e.g. `"1000"` for 1000 tokens) and automatically converted to 18-decimal format when submitted to the chain. The optional `decimals` field (default: 18) controls this conversion. + +Multiple pairs can be configured by repeating `[[priceUpdates.pairs]]` blocks. Each pair can have multiple entries for tiered pricing at different order sizes. + +The first submission for each pair will reserve a deposit from the substrate account. Subsequent updates to the same pair are free. The deposit can be reclaimed after the governance-configured lock duration by calling `withdrawPriceDeposit`. + +### SDK Usage + +The `IntentsCoprocessor` class in `@hyperbridge/sdk` exposes two methods for direct use. Note that the SDK accepts raw 18-decimal bigint values — the human-readable conversion is a simplex filler convenience. + +```typescript +import { IntentsCoprocessor } from "@hyperbridge/sdk" +import { parseUnits } from "viem" + +const coprocessor = await IntentsCoprocessor.connect(wsUrl, substratePrivateKey) + +// Submit prices for a token pair (values in 18-decimal format) +await coprocessor.submitPairPrice("0x...", [ + { rangeStart: parseUnits("0", 18), rangeEnd: parseUnits("999", 18), price: parseUnits("1414", 18) }, + { rangeStart: parseUnits("1000", 18), rangeEnd: parseUnits("5000", 18), price: parseUnits("1420", 18) }, +]) + +// Withdraw deposit after lock period +await coprocessor.withdrawPriceDeposit("0x...") +``` + +The `PriceUpdateService` from `@hyperbridge/simplex` can also be used standalone for periodic submissions without running the full filler: + +```typescript +import { PriceUpdateService } from "@hyperbridge/simplex" +import { IntentsCoprocessor } from "@hyperbridge/sdk" +import { parseUnits } from "viem" + +const coprocessor = IntentsCoprocessor.connect(wsUrl, substratePrivateKey) + +const service = new PriceUpdateService(coprocessor, { + intervalSeconds: 300, + pairs: [ + { + pairId: "0x...", + label: "USDC/CNGN", + entries: [ + { rangeStart: parseUnits("0", 18), rangeEnd: parseUnits("999", 18), price: parseUnits("1414", 18) }, + ], + }, + ], +}) + +service.start() +``` + ## Governance Parameters All key parameters are stored on-chain and updatable via governance extrinsics: diff --git a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts index ae4ca7ff0..57d01dce5 100644 --- a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts +++ b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts @@ -5,7 +5,7 @@ import { hexToU8a, u8aToHex, u8aConcat } from "@polkadot/util" import { decodeAddress, keccakAsU8a } from "@polkadot/util-crypto" import { numberToBytes, bytesToBigInt } from "viem" import { Bytes, Struct, u8, Vector } from "scale-ts" -import type { BidSubmissionResult, HexString, PackedUserOperation, BidStorageEntry, FillerBid } from "@/types" +import type { BidSubmissionResult, HexString, PackedUserOperation, BidStorageEntry, FillerBid, PriceInput } from "@/types" import type { SubstrateChain } from "./substrate" /** Offchain storage key prefix for bids */ @@ -331,6 +331,56 @@ export class IntentsCoprocessor { } } + /** + * Submits price entries for a recognized token pair on Hyperbridge. + * + * The first submission per pair requires a deposit (reserved from the caller's balance). + * Subsequent updates to the same pair are free. + * + * @param pairId - The token pair identifier (H256 / bytes32) + * @param entries - Array of price entries with range and price data + * @returns BidSubmissionResult with success status and block/extrinsic hash + */ + async submitPairPrice(pairId: HexString, entries: PriceInput[]): Promise { + try { + // Encode entries as a Vec of (U256, U256, U256) tuples for the pallet + const encodedEntries = entries.map((e) => ({ + range_start: e.rangeStart.toString(), + range_end: e.rangeEnd.toString(), + price: e.price.toString(), + })) + + const extrinsic = this.api.tx.intentsCoprocessor.submitPairPrice(pairId, encodedEntries) + return await this.signAndSendExtrinsic(extrinsic) + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + } + } + } + + /** + * Withdraws a previously reserved price deposit for a token pair. + * + * Funds can only be withdrawn after the configured lock duration has elapsed + * since the first price submission for that pair. + * + * @param pairId - The token pair identifier (H256 / bytes32) + * @returns BidSubmissionResult with success status and block/extrinsic hash + */ + async withdrawPriceDeposit(pairId: HexString): Promise { + try { + const extrinsic = this.api.tx.intentsCoprocessor.withdrawPriceDeposit(pairId) + return await this.signAndSendExtrinsic(extrinsic) + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + } + } + } + /** * Fetches all bid storage entries for a given order commitment. * Returns the on-chain data only (filler addresses and deposits). diff --git a/sdk/packages/sdk/src/types/index.ts b/sdk/packages/sdk/src/types/index.ts index ab159b0d8..225167dda 100644 --- a/sdk/packages/sdk/src/types/index.ts +++ b/sdk/packages/sdk/src/types/index.ts @@ -1424,6 +1424,16 @@ export type IntentOrderStatusUpdate = | { status: "PARTIAL_FILL_EXHAUSTED"; commitment: HexString; totalFilledAmount?: bigint; remainingAmount?: bigint; error: string } | { status: "FAILED"; commitment?: HexString; totalFilledAmount?: bigint; remainingAmount?: bigint; error: string } +/** + * Price input for submitting pair prices to the intents coprocessor. + * All values are raw 18-decimal bigints as expected by the pallet. + */ +export interface PriceInput { + rangeStart: bigint + rangeEnd: bigint + price: bigint +} + /** Result of selecting a bid and submitting to the bundler */ export interface SelectBidResult { userOp: PackedUserOperation diff --git a/sdk/packages/simplex/filler-config-example.toml b/sdk/packages/simplex/filler-config-example.toml index 242c2de4c..e85800218 100644 --- a/sdk/packages/simplex/filler-config-example.toml +++ b/sdk/packages/simplex/filler-config-example.toml @@ -73,6 +73,29 @@ triggerPercentage = 0.5 "137" = "10000" # Polygon: $10k USDT base "130" = "10000" # Unichain: $10k USDT base +# Price update configuration +# Periodically submits token pair prices to Hyperbridge's intents coprocessor. +# Requires substratePrivateKey and hyperbridgeWsUrl to be configured above. +# The first submission per pair reserves a deposit; subsequent updates are free. +# Amounts are specified in human-readable form and converted to 18-decimal format automatically. +# [priceUpdates] +# intervalSeconds = 300 # How often to submit prices (default: 300 = 5 minutes) +# +# [[priceUpdates.pairs]] +# pairId = "0x..." # The token pair ID (keccak256 of base_address ++ quote_address) +# label = "USDC/CNGN" +# decimals = 18 # Decimal places for amounts/prices (default: 18) +# +# [[priceUpdates.pairs.entries]] +# rangeStart = "0" # Min base amount in human-readable form +# rangeEnd = "999" # Max base amount in human-readable form +# price = "1414" # Price in human-readable form +# +# [[priceUpdates.pairs.entries]] +# rangeStart = "1000" +# rangeEnd = "5000" +# price = "1420" + # Strategy configuration # You can configure multiple strategies # All strategies use the simplex.privateKey from above diff --git a/sdk/packages/simplex/src/bin/simplex.ts b/sdk/packages/simplex/src/bin/simplex.ts index 501075de3..e2c1c93a1 100644 --- a/sdk/packages/simplex/src/bin/simplex.ts +++ b/sdk/packages/simplex/src/bin/simplex.ts @@ -9,6 +9,7 @@ import { BasicFiller } from "@/strategies/basic" import { FXFiller } from "@/strategies/fx" import { ConfirmationPolicy, FillerBpsPolicy, FillerPricePolicy } from "@/config/interpolated-curve" import { ChainConfig, FillerConfig, HexString } from "@hyperbridge/sdk" +import { parseUnits } from "viem" import { FillerConfigService, UserProvidedChainConfig, @@ -21,6 +22,7 @@ import { RebalancingService } from "@/services/RebalancingService" import { getLogger, configureLogger } from "@/services/Logger" import { CacheService } from "@/services/CacheService" import { BidStorageService } from "@/services/BidStorageService" +import type { PriceUpdateConfig } from "@/services/PriceUpdateService" import type { BinanceCexConfig } from "@/services/rebalancers/index" import { Decimal } from "decimal.js" @@ -235,6 +237,21 @@ interface BinanceConfig { withdrawTimeoutMs?: number } +interface PriceUpdatesConfig { + intervalSeconds?: number + pairs: Array<{ + pairId: HexString + label?: string + /** Decimal places for human-readable amounts/prices. Default: 18 */ + decimals?: number + entries: Array<{ + rangeStart: string + rangeEnd: string + price: string + }> + }> +} + interface FillerTomlConfig { simplex: { privateKey: string @@ -251,6 +268,7 @@ interface FillerTomlConfig { chains: (UserProvidedChainConfig & { bundlerUrl?: string })[] rebalancing?: RebalancingConfig binance?: BinanceConfig + priceUpdates?: PriceUpdatesConfig } const program = new Command() @@ -431,6 +449,30 @@ program logger.info("Rebalancing service initialized") } + // Parse price update configuration + let priceUpdateConfig: PriceUpdateConfig | undefined + if (config.priceUpdates?.pairs && config.priceUpdates.pairs.length > 0) { + priceUpdateConfig = { + intervalSeconds: config.priceUpdates.intervalSeconds, + pairs: config.priceUpdates.pairs.map((p) => { + const decimals = p.decimals ?? 18 + return { + pairId: p.pairId, + label: p.label, + entries: p.entries.map((e) => ({ + rangeStart: parseUnits(e.rangeStart, decimals), + rangeEnd: parseUnits(e.rangeEnd, decimals), + price: parseUnits(e.price, decimals), + })), + } + }), + } + logger.info( + { pairCount: priceUpdateConfig.pairs.length, intervalSeconds: priceUpdateConfig.intervalSeconds ?? 300 }, + "Price update configuration loaded", + ) + } + // Initialize and start the intent filler logger.info("Starting intent filler...") const intentFiller = new IntentFiller( @@ -443,6 +485,7 @@ program privateKey, rebalancingService, bidStorageService, + priceUpdateConfig, ) // Initialize (sets up EIP-7702 delegation if solver selection is configured) diff --git a/sdk/packages/simplex/src/core/filler.ts b/sdk/packages/simplex/src/core/filler.ts index 83fd2ad80..69e9cd1ea 100644 --- a/sdk/packages/simplex/src/core/filler.ts +++ b/sdk/packages/simplex/src/core/filler.ts @@ -16,8 +16,10 @@ import { ChainClientManager, ContractInteractionService, DelegationService, + PriceUpdateService, RebalancingService, } from "@/services" +import type { PriceUpdateConfig } from "@/services/PriceUpdateService" import { FillerConfigService } from "@/services/FillerConfigService" import { getLogger } from "@/services/Logger" import { Decimal } from "decimal.js" @@ -31,6 +33,7 @@ export class IntentFiller { private contractService: ContractInteractionService private delegationService?: DelegationService private rebalancingService?: RebalancingService + private priceUpdateService?: PriceUpdateService private bidStorage?: BidStorageService private retractionQueue: pQueue private pendingRetractions = new Set() @@ -52,6 +55,7 @@ export class IntentFiller { privateKey: HexString, rebalancingService?: RebalancingService, bidStorage?: BidStorageService, + priceUpdateConfig?: PriceUpdateConfig, ) { this.configService = configService this.privateKey = privateKey @@ -83,6 +87,11 @@ export class IntentFiller { this.hyperbridge = IntentsCoprocessor.connect(hyperbridgeWsUrl, substrateKey) } + // Set up price update service if configured and hyperbridge is available + if (priceUpdateConfig && priceUpdateConfig.pairs.length > 0 && this.hyperbridge) { + this.priceUpdateService = new PriceUpdateService(this.hyperbridge, priceUpdateConfig) + } + // Set up event handlers this.monitor.on("newOrder", ({ order }) => { this.handleNewOrder(order) @@ -136,6 +145,11 @@ export class IntentFiller { if (this.bidStorage && this.hyperbridge) { this.startRetractionSweep() } + + // Start periodic price updates if configured + if (this.priceUpdateService) { + this.priceUpdateService.start() + } } /** @@ -235,6 +249,11 @@ export class IntentFiller { this.logger.info("Periodic retraction sweep stopped") } + // Stop price update service + if (this.priceUpdateService) { + this.priceUpdateService.stop() + } + // Wait for all queues to complete const promises: Promise[] = [] this.chainQueues.forEach((queue) => { diff --git a/sdk/packages/simplex/src/index.ts b/sdk/packages/simplex/src/index.ts index 2ad5b0752..2821383d0 100644 --- a/sdk/packages/simplex/src/index.ts +++ b/sdk/packages/simplex/src/index.ts @@ -13,4 +13,5 @@ export { InterpolatedCurve, ConfirmationPolicy, FillerBpsPolicy } from "@/config export type { CurvePoint, CurveConfig } from "@/config/interpolated-curve" // Service exports -export { ChainClientManager, ContractInteractionService } from "@/services" +export { ChainClientManager, ContractInteractionService, PriceUpdateService } from "@/services" +export type { PriceUpdateConfig, PriceUpdatePairEntry } from "@/services/PriceUpdateService" diff --git a/sdk/packages/simplex/src/services/index.ts b/sdk/packages/simplex/src/services/index.ts index aeb0f29a2..c3208e997 100644 --- a/sdk/packages/simplex/src/services/index.ts +++ b/sdk/packages/simplex/src/services/index.ts @@ -5,4 +5,5 @@ export * from "./ContractInteractionService" export * from "./DelegationService" export * from "./FillerConfigService" export * from "./Logger" +export * from "./PriceUpdateService" export * from "./RebalancingService" From fb824aa44840980c791a59389e056798c0864ee7 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Mon, 16 Mar 2026 23:59:52 +0100 Subject: [PATCH 10/28] address concerns --- .../intent-gateway/price-submission.mdx | 16 ++- .../intents-coprocessor/rpc/src/lib.rs | 33 ++++-- .../pallets/intents-coprocessor/src/lib.rs | 110 ++++++++++++------ .../pallets/intents-coprocessor/src/tests.rs | 69 ++++++++--- 4 files changed, 165 insertions(+), 63 deletions(-) diff --git a/docs/content/developers/intent-gateway/price-submission.mdx b/docs/content/developers/intent-gateway/price-submission.mdx index 08fd093c6..15a0da5b6 100644 --- a/docs/content/developers/intent-gateway/price-submission.mdx +++ b/docs/content/developers/intent-gateway/price-submission.mdx @@ -33,7 +33,12 @@ On the first price submission per (account, token pair), a deposit is reserved f After the initial deposit, the submitter can update prices for the same token pair as many times as they want without paying anything further. -The deposit can be withdrawn after a configurable lock duration has elapsed by calling the `withdraw_price_deposit` extrinsic. The lock duration is set by governance through the `set_price_deposit_lock_duration` extrinsic. Until the lock duration has passed, the deposit remains reserved and cannot be withdrawn. +Withdrawing a deposit uses a two-phase process via the `withdraw_price_deposit` extrinsic: + +1. **Phase 1 — Initiate**: The first call records the unlock block (`current_block + PriceDepositLockDuration`). No tokens are moved. +2. **Phase 2 — Complete**: After the unlock block has been reached, a second call unreserves the tokens and removes the deposit record. + +The lock duration (in blocks) is set by governance through `set_price_deposit_lock_duration`. This two-phase model prevents instant withdrawals while keeping the deposit locked for a predictable number of blocks. This model discourages spam (each new pair requires locking funds) while keeping ongoing price updates free. @@ -89,7 +94,7 @@ Amounts are specified in human-readable form (e.g. `"1000"` for 1000 tokens) and Multiple pairs can be configured by repeating `[[priceUpdates.pairs]]` blocks. Each pair can have multiple entries for tiered pricing at different order sizes. -The first submission for each pair will reserve a deposit from the substrate account. Subsequent updates to the same pair are free. The deposit can be reclaimed after the governance-configured lock duration by calling `withdrawPriceDeposit`. +The first submission for each pair will reserve a deposit from the substrate account. Subsequent updates to the same pair are free. The deposit can be reclaimed via a two-phase withdrawal: call `withdrawPriceDeposit` once to initiate (records the unlock block), then call it again after the governance-configured lock duration (in blocks) has elapsed to unreserve the tokens. ### SDK Usage @@ -107,7 +112,10 @@ await coprocessor.submitPairPrice("0x...", [ { rangeStart: parseUnits("1000", 18), rangeEnd: parseUnits("5000", 18), price: parseUnits("1420", 18) }, ]) -// Withdraw deposit after lock period +// Withdraw deposit (two-phase): +// Phase 1 — initiate withdrawal (records unlock block) +await coprocessor.withdrawPriceDeposit("0x...") +// Phase 2 — complete withdrawal after unlock block is reached await coprocessor.withdrawPriceDeposit("0x...") ``` @@ -142,6 +150,6 @@ All key parameters are stored on-chain and updatable via governance extrinsics: - `PriceWindowDurationValue` is the length of the price window in milliseconds. - `PriceDepositAmount` is the amount reserved from submitters on their first price submission per pair. -- `PriceDepositLockDuration` is how long (in seconds) the deposit is locked before it can be withdrawn. +- `PriceDepositLockDuration` is how many blocks the deposit is locked before it can be withdrawn (used in the two-phase withdrawal process). - `MaxPriceEntries` is the compile-time maximum number of price entries per submission, configurable per runtime. - Recognized token pairs determine which pairs accept submissions. diff --git a/modules/pallets/intents-coprocessor/rpc/src/lib.rs b/modules/pallets/intents-coprocessor/rpc/src/lib.rs index 7dc99230c..58c519834 100644 --- a/modules/pallets/intents-coprocessor/rpc/src/lib.rs +++ b/modules/pallets/intents-coprocessor/rpc/src/lib.rs @@ -53,14 +53,15 @@ pub struct RpcBidInfo { pub user_op: Vec, } -/// A single price entry returned by the RPC +/// A single price entry returned by the RPC. +/// Amounts and prices are human-readable (divided by 10^18 from on-chain storage). #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] pub struct RpcPriceEntry { - /// Lower bound of the base token amount range (inclusive), with 18 decimal places + /// Lower bound of the base token amount range (inclusive) pub range_start: String, - /// Upper bound of the base token amount range (inclusive), with 18 decimal places + /// Upper bound of the base token amount range (inclusive) pub range_end: String, - /// The price of the base token in the quote token, with 18 decimal places + /// The price of the base token in the quote token pub price: String, /// Timestamp of submission (seconds) pub timestamp: u64, @@ -167,6 +168,24 @@ impl BidCache { } } +/// Format a U256 value with the given number of decimal places into a human-readable +/// decimal string, preserving fractional digits and trimming trailing zeros. +/// e.g. format_u256_decimals(U256::from(1_414_500_000_000_000_000_000u128), 18) => "1414.5" +fn format_u256_decimals(value: primitive_types::U256, decimals: u32) -> String { + let divisor = primitive_types::U256::from(10u64).pow(primitive_types::U256::from(decimals)); + let integer_part = value / divisor; + let remainder = value % divisor; + + if remainder.is_zero() { + return integer_part.to_string(); + } + + // Pad remainder to full `decimals` width, then trim trailing zeros + let frac = format!("{:0>width$}", remainder, width = decimals as usize); + let frac = frac.trim_end_matches('0'); + format!("{integer_part}.{frac}") +} + fn runtime_error_into_rpc_error(e: impl std::fmt::Display) -> ErrorObjectOwned { ErrorObject::owned(9877, format!("{e}"), None::) } @@ -305,9 +324,9 @@ where Ok(entries) => Ok(entries .into_iter() .map(|(range_start, range_end, price, timestamp)| RpcPriceEntry { - range_start: range_start.to_string(), - range_end: range_end.to_string(), - price: price.to_string(), + range_start: format_u256_decimals(range_start, 18), + range_end: format_u256_decimals(range_end, 18), + price: format_u256_decimals(price, 18), timestamp, }) .collect()), diff --git a/modules/pallets/intents-coprocessor/src/lib.rs b/modules/pallets/intents-coprocessor/src/lib.rs index 39d41ea41..a651e300e 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -39,7 +39,10 @@ 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}, + Saturating, +}; pub use weights::WeightInfo; use types::{ @@ -145,16 +148,18 @@ pub mod pallet { pub type Prices = StorageMap<_, Blake2_128Concat, H256, Vec, ValueQuery>; /// Deposits reserved by price submitters. Maps (account, pair_id) to - /// (deposit_amount, deposit_timestamp_secs). The deposit is locked for - /// `PriceDepositLockDuration` seconds, after which it can be withdrawn. + /// (deposit_amount, unlock_block). When `unlock_block` is `None`, the withdrawal + /// has not been initiated. The first call to `withdraw_price_deposit` sets + /// `unlock_block` to `current_block + PriceDepositLockDuration`. The second + /// call (after that block) unreserves the tokens. #[pallet::storage] pub type PriceDeposits = StorageDoubleMap< _, Blake2_128Concat, T::AccountId, Blake2_128Concat, - H256, // pair_id - (BalanceOf, u64), // (deposit_amount, deposit_timestamp) + H256, // pair_id + (BalanceOf, Option>), // (deposit_amount, unlock_block) OptionQuery, >; @@ -162,9 +167,11 @@ pub mod pallet { #[pallet::storage] pub type PriceDepositAmount = StorageValue<_, BalanceOf, ValueQuery>; - /// How long (in seconds) the price deposit is locked before it can be withdrawn + /// How many blocks the price deposit is locked before it can be withdrawn. + /// When a filler initiates a withdrawal, the unlock block is set to + /// `current_block + PriceDepositLockDuration`. #[pallet::storage] - pub type PriceDepositLockDuration = StorageValue<_, u64, ValueQuery>; + pub type PriceDepositLockDuration = StorageValue<_, BlockNumberFor, ValueQuery>; /// Whether prices have been cleared in the current window. /// Reset to false by `on_initialize` when a new window starts. @@ -237,11 +244,17 @@ pub mod pallet { PriceWindowDurationUpdated { duration_ms: u64 }, /// Price deposit amount was updated PriceDepositAmountUpdated { amount: BalanceOf }, - /// Price deposit lock duration was updated - PriceDepositLockDurationUpdated { duration_secs: u64 }, + /// Price deposit lock duration was updated (in blocks) + PriceDepositLockDurationUpdated { duration_blocks: BlockNumberFor }, /// Price deposit was reserved on first submission PriceDepositReserved { submitter: T::AccountId, pair_id: H256, amount: BalanceOf }, - /// Price deposit was withdrawn + /// Price deposit withdrawal was initiated (unlock block noted) + PriceDepositWithdrawalInitiated { + submitter: T::AccountId, + pair_id: H256, + unlock_block: BlockNumberFor, + }, + /// Price deposit was withdrawn (tokens unreserved) PriceDepositWithdrawn { submitter: T::AccountId, pair_id: H256, amount: BalanceOf }, } @@ -271,8 +284,10 @@ pub mod pallet { PriceDepositsNotConfigured, /// No deposit found for this account and pair DepositNotFound, - /// The deposit is still within the lock duration + /// The deposit is still within the lock duration (unlock block not yet reached) DepositStillLocked, + /// Withdrawal has already been initiated + WithdrawalAlreadyInitiated, } #[pallet::call] @@ -591,7 +606,11 @@ pub mod pallet { ::Currency::reserve(&submitter, deposit_amount) .map_err(|_| Error::::InsufficientBalance)?; - PriceDeposits::::insert(&submitter, &pair_id, (deposit_amount, now)); + PriceDeposits::::insert( + &submitter, + &pair_id, + (deposit_amount, None::>), + ); Self::deposit_event(Event::PriceDepositReserved { submitter: submitter.clone(), @@ -677,54 +696,75 @@ pub mod pallet { Ok(()) } - /// Set the lock duration for price deposits + /// Set the lock duration (in blocks) for price deposits #[pallet::call_index(12)] #[pallet::weight(T::DbWeight::get().writes(1))] pub fn set_price_deposit_lock_duration( origin: OriginFor, - duration_secs: u64, + duration_blocks: BlockNumberFor, ) -> DispatchResult { T::GovernanceOrigin::ensure_origin(origin)?; - PriceDepositLockDuration::::put(duration_secs); + PriceDepositLockDuration::::put(duration_blocks); - Self::deposit_event(Event::PriceDepositLockDurationUpdated { duration_secs }); + Self::deposit_event(Event::PriceDepositLockDurationUpdated { duration_blocks }); Ok(()) } - /// Withdraw a price deposit after the lock duration has elapsed + /// Withdraw a price deposit using a two-phase process. + /// + /// **First call**: Initiates the withdrawal by recording the unlock block + /// (current block + `PriceDepositLockDuration`). No tokens are moved. + /// + /// **Second call** (after the unlock block has been reached): Unreserves + /// the deposited tokens and removes the deposit record. /// /// # Parameters /// - `pair_id`: The token pair the deposit was made for /// /// # Errors /// - `DepositNotFound`: No deposit exists for this account and pair - /// - `DepositStillLocked`: The lock duration has not yet elapsed + /// - `WithdrawalAlreadyInitiated`: First call was already made (waiting for unlock) + /// - `DepositStillLocked`: The unlock block has not yet been reached #[pallet::call_index(13)] #[pallet::weight(T::DbWeight::get().reads(3).saturating_add(T::DbWeight::get().writes(1)))] pub fn withdraw_price_deposit(origin: OriginFor, pair_id: H256) -> DispatchResult { let who = ensure_signed(origin)?; - let (deposit_amount, deposit_timestamp) = + let (deposit_amount, unlock_block) = PriceDeposits::::get(&who, &pair_id).ok_or(Error::::DepositNotFound)?; - let now = T::Dispatcher::default().timestamp().as_secs(); - let lock_duration = PriceDepositLockDuration::::get(); - - ensure!( - now.saturating_sub(deposit_timestamp) >= lock_duration, - Error::::DepositStillLocked - ); - - ::Currency::unreserve(&who, deposit_amount); - PriceDeposits::::remove(&who, &pair_id); - - Self::deposit_event(Event::PriceDepositWithdrawn { - submitter: who, - pair_id, - amount: deposit_amount, - }); + match unlock_block { + None => { + // Phase 1: Initiate withdrawal — note the unlock block + let current_block = >::block_number(); + let lock_duration = PriceDepositLockDuration::::get(); + let unlock_at = current_block.saturating_add(lock_duration); + + PriceDeposits::::insert(&who, &pair_id, (deposit_amount, Some(unlock_at))); + + Self::deposit_event(Event::PriceDepositWithdrawalInitiated { + submitter: who, + pair_id, + unlock_block: unlock_at, + }); + }, + Some(unlock_at) => { + // Phase 2: Complete withdrawal — unreserve if unlock block reached + let current_block = >::block_number(); + ensure!(current_block >= unlock_at, Error::::DepositStillLocked); + + ::Currency::unreserve(&who, deposit_amount); + PriceDeposits::::remove(&who, &pair_id); + + Self::deposit_event(Event::PriceDepositWithdrawn { + submitter: who, + pair_id, + amount: deposit_amount, + }); + }, + } Ok(()) } diff --git a/modules/pallets/intents-coprocessor/src/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index a59fc6863..0a5dbb4c0 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -169,8 +169,8 @@ pub fn new_test_ext() -> sp_io::TestExternalities { pallet_intents::PriceWindowDurationValue::::put(86_400_000u64); // Price deposit: 500 tokens pallet_intents::PriceDepositAmount::::put(500u64); - // Lock duration: 1 hour - pallet_intents::PriceDepositLockDuration::::put(3600u64); + // Lock duration: 10 blocks + pallet_intents::PriceDepositLockDuration::::put(10u64); }); ext } @@ -600,11 +600,11 @@ fn submit_pair_price_reserves_deposit_on_first_submission() { assert_eq!(Balances::free_balance(&submitter), balance_before - deposit_amount); assert_eq!(Balances::reserved_balance(&submitter), deposit_amount); - // Deposit record stored - let (stored_amount, stored_timestamp) = + // Deposit record stored (no unlock block yet) + let (stored_amount, unlock_block) = PriceDeposits::::get(&submitter, &pair_id).unwrap(); assert_eq!(stored_amount, deposit_amount); - assert_eq!(stored_timestamp, 2000); + assert_eq!(unlock_block, None); // Price entry stored let prices = Prices::::get(&pair_id); @@ -693,7 +693,7 @@ fn submit_pair_price_fails_with_insufficient_balance() { } #[test] -fn withdraw_price_deposit_works_after_lock_duration() { +fn withdraw_price_deposit_two_phase() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); @@ -702,7 +702,6 @@ fn withdraw_price_deposit_works_after_lock_duration() { let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); - // Submit at timestamp 2000s pallet_timestamp::Now::::put(2_000_000u64); let entries = BoundedVec::try_from(vec![PriceInput { @@ -721,9 +720,30 @@ fn withdraw_price_deposit_works_after_lock_duration() { let deposit_amount = PriceDepositAmount::::get(); let balance_after_submit = Balances::free_balance(&submitter); - // Advance past lock duration (2000 + 3600 = 5600 seconds) - pallet_timestamp::Now::::put(6_000_000u64); // 6000 seconds + // Phase 1: Initiate withdrawal at block 1 + System::set_block_number(1); + assert_ok!(Intents::withdraw_price_deposit( + RuntimeOrigin::signed(submitter.clone()), + pair_id, + )); + + // Deposit is NOT yet unreserved + assert_eq!(Balances::free_balance(&submitter), balance_after_submit); + assert_eq!(Balances::reserved_balance(&submitter), deposit_amount); + // Unlock block is set (1 + 10 = 11) + let (_, unlock_block) = PriceDeposits::::get(&submitter, &pair_id).unwrap(); + assert_eq!(unlock_block, Some(11u64)); + + // Phase 2 too early: still locked at block 5 + System::set_block_number(5); + assert_noop!( + Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter.clone()), pair_id,), + Error::::DepositStillLocked + ); + + // Phase 2: Complete withdrawal at block 11 + System::set_block_number(11); assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id, @@ -739,7 +759,7 @@ fn withdraw_price_deposit_works_after_lock_duration() { } #[test] -fn withdraw_price_deposit_fails_when_still_locked() { +fn withdraw_price_deposit_phase2_fails_when_still_locked() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); @@ -748,7 +768,6 @@ fn withdraw_price_deposit_fails_when_still_locked() { let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); - // Submit at timestamp 2000s pallet_timestamp::Now::::put(2_000_000u64); let entries = BoundedVec::try_from(vec![PriceInput { @@ -764,9 +783,15 @@ fn withdraw_price_deposit_fails_when_still_locked() { entries, )); - // Still within lock duration (2000 + 1000 < 2000 + 3600) - pallet_timestamp::Now::::put(3_000_000u64); // 3000 seconds + // Phase 1: Initiate withdrawal at block 1 + System::set_block_number(1); + assert_ok!(Intents::withdraw_price_deposit( + RuntimeOrigin::signed(submitter.clone()), + pair_id, + )); + // Phase 2: Try to complete at block 5 (unlock is at 11) + System::set_block_number(5); assert_noop!( Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter), pair_id,), Error::::DepositStillLocked @@ -798,8 +823,8 @@ fn set_price_deposit_amount_works() { #[test] fn set_price_deposit_lock_duration_works() { new_test_ext().execute_with(|| { - assert_ok!(Intents::set_price_deposit_lock_duration(RuntimeOrigin::root(), 7200u64)); - assert_eq!(PriceDepositLockDuration::::get(), 7200u64); + assert_ok!(Intents::set_price_deposit_lock_duration(RuntimeOrigin::root(), 100u64)); + assert_eq!(PriceDepositLockDuration::::get(), 100u64); }); } @@ -1020,9 +1045,19 @@ fn separate_deposits_per_pair() { // Two deposits reserved (one per pair) assert_eq!(Balances::reserved_balance(&submitter), deposit_amount * 2); - // Can withdraw each independently - pallet_timestamp::Now::::put(6_000_000u64); + // Phase 1: Initiate both withdrawals at block 1 + System::set_block_number(1); + assert_ok!(Intents::withdraw_price_deposit( + RuntimeOrigin::signed(submitter.clone()), + pair_id1, + )); + assert_ok!(Intents::withdraw_price_deposit( + RuntimeOrigin::signed(submitter.clone()), + pair_id2, + )); + // Phase 2: Complete both after lock duration (block 11) + System::set_block_number(11); assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id1, From 789d109ad0095e88bfbd47726aacc78f56df13e9 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Tue, 17 Mar 2026 13:04:13 +0100 Subject: [PATCH 11/28] tested out implementation --- parachain/simtests/src/lib.rs | 1 + parachain/simtests/src/price_submission.rs | 254 ++++++++++++++++++ .../sdk/src/chains/intentsCoprocessor.ts | 22 +- .../src/services/PriceUpdateService.ts | 93 +++++++ 4 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 parachain/simtests/src/price_submission.rs create mode 100644 sdk/packages/simplex/src/services/PriceUpdateService.ts diff --git a/parachain/simtests/src/lib.rs b/parachain/simtests/src/lib.rs index 332ceb49b..2dff4a3b0 100644 --- a/parachain/simtests/src/lib.rs +++ b/parachain/simtests/src/lib.rs @@ -3,4 +3,5 @@ mod intents_rpc; mod migration_test; mod pallet_ismp; mod pallet_mmr; +mod price_submission; mod token_allocation; diff --git a/parachain/simtests/src/price_submission.rs b/parachain/simtests/src/price_submission.rs new file mode 100644 index 000000000..96001897b --- /dev/null +++ b/parachain/simtests/src/price_submission.rs @@ -0,0 +1,254 @@ +#![cfg(test)] + +use std::env; + +use codec::Encode; +use pallet_intents_coprocessor::types::{PriceInput, TokenPair}; +use pallet_intents_rpc::RpcPriceEntry; +use polkadot_sdk::*; +use primitive_types::{H160, H256, U256}; +use sc_consensus_manual_seal::CreatedBlock; +use sp_core::{crypto::Ss58Codec, hashing::keccak_256, Bytes}; +use sp_keyring::sr25519::Keyring; +use subxt::ext::subxt_rpcs::rpc_params; +use subxt_utils::Hyperbridge; + +/// Compute the same pair_id as `TokenPair::pair_id()` — keccak256(base ++ quote). +fn compute_pair_id(base: &H160, quote: &H160) -> H256 { + let mut data = Vec::with_capacity(40); + data.extend_from_slice(&base.0); + data.extend_from_slice("e.0); + keccak_256(&data).into() +} + +/// Helper: submit raw SCALE-encoded call bytes from a given keyring account, +/// create and finalize a block, then wait for success. +async fn submit_raw_and_finalize( + client: &subxt::OnlineClient, + rpc_client: &subxt::backend::rpc::RpcClient, + call_data: Vec, + who: Keyring, +) -> Result<(), anyhow::Error> { + let extrinsic: Bytes = rpc_client + .request( + "simnode_authorExtrinsic", + rpc_params![Bytes::from(call_data), who.to_account_id().to_ss58check()], + ) + .await?; + let submittable = subxt::tx::SubmittableTransaction::from_bytes(client.clone(), extrinsic.0); + let progress = submittable.submit_and_watch().await?; + let block = rpc_client + .request::>("engine_createBlock", rpc_params![true, false]) + .await?; + let finalized = rpc_client + .request::("engine_finalizeBlock", rpc_params![block.hash]) + .await?; + assert!(finalized); + progress.wait_for_finalized_success().await?; + Ok(()) +} + +/// Helper: wrap raw SCALE-encoded call bytes in `Sudo::sudo` and submit from Alice. +/// Constructs the sudo call by manually prepending the Sudo pallet index + call_index +/// and wrapping the inner call. +async fn sudo_raw_and_finalize( + client: &subxt::OnlineClient, + rpc_client: &subxt::backend::rpc::RpcClient, + inner_call_data: Vec, +) -> Result<(), anyhow::Error> { + let mut sudo_call_data = vec![25u8, 0u8]; + sudo_call_data.extend_from_slice(&inner_call_data); + submit_raw_and_finalize(client, rpc_client, sudo_call_data, Keyring::Alice).await +} + +/// Helper: create and finalize `n` empty blocks to advance the chain. +async fn advance_blocks( + rpc_client: &subxt::backend::rpc::RpcClient, + n: u32, +) -> Result<(), anyhow::Error> { + for _ in 0..n { + let block = rpc_client + .request::>("engine_createBlock", rpc_params![true, false]) + .await?; + rpc_client + .request::("engine_finalizeBlock", rpc_params![block.hash]) + .await?; + } + Ok(()) +} + +/// Manually encode a call to `IntentsCoprocessor::submit_pair_price`. +/// Pallet index 65, call index 7. +fn encode_submit_pair_price(pair_id: H256, entries: Vec) -> Vec { + let mut data = vec![65u8, 7u8]; // pallet_index, call_index + data.extend_from_slice(&pair_id.encode()); + data.extend_from_slice(&entries.encode()); + data +} + +/// Manually encode a call to `IntentsCoprocessor::add_recognized_pair`. +/// Pallet index 65, call index 8. +fn encode_add_recognized_pair(pair: &TokenPair) -> Vec { + let mut data = vec![65u8, 8u8]; + data.extend_from_slice(&pair.encode()); + data +} + +/// Manually encode a call to `IntentsCoprocessor::set_price_deposit_amount`. +/// Pallet index 65, call index 11. +fn encode_set_price_deposit_amount(amount: u128) -> Vec { + let mut data = vec![65u8, 11u8]; + data.extend_from_slice(&amount.encode()); + data +} + +/// Manually encode a call to `IntentsCoprocessor::set_price_deposit_lock_duration`. +/// Pallet index 65, call index 12. Duration is BlockNumberFor = u32 on gargantua. +fn encode_set_price_deposit_lock_duration(duration_blocks: u32) -> Vec { + let mut data = vec![65u8, 12u8]; + data.extend_from_slice(&duration_blocks.encode()); + data +} + +/// Manually encode a call to `IntentsCoprocessor::withdraw_price_deposit`. +/// Pallet index 65, call index 13. +fn encode_withdraw_price_deposit(pair_id: H256) -> Vec { + let mut data = vec![65u8, 13u8]; + data.extend_from_slice(&pair_id.encode()); + data +} + +/// Integration test for the deposit-based price submission system. +/// +/// Exercises the full lifecycle: +/// 1. Governance setup (add recognized pair, set deposit amount and lock duration) +/// 2. Price submission (verifies deposit is reserved) +/// 3. RPC query (verifies human-readable prices with decimals preserved) +/// 4. Two-phase withdrawal (initiate → fail before unlock → complete after unlock) +#[tokio::test] +#[ignore] +async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { + let port = env::var("PORT").unwrap_or("9990".into()); + let url = &format!("ws://127.0.0.1:{}", port); + let (client, rpc_client) = subxt_utils::client::ws_client::(url, u32::MAX).await?; + + let base = H160::from_low_u64_be(0xAAAA); + let quote = H160::from_low_u64_be(0xBBBB); + let pair = TokenPair { base, quote }; + let pair_id = compute_pair_id(&base, "e); + + // 1 unit = 10^18 in raw representation + let one_unit = U256::from(10u64).pow(U256::from(18)); + + // Deposit amount: 100 units + let deposit_amount: u128 = 100_000_000_000_000; + + // Lock duration: 5 blocks + let lock_duration: u32 = 5; + + // Price entries: 0-999 at 1414.5, 1000-5000 at 1420 + let price_entries = vec![ + PriceInput { + range_start: U256::zero(), + range_end: U256::from(999) * one_unit, + price: U256::from(14145) * one_unit / U256::from(10), // 1414.5 * 10^18 + }, + PriceInput { + range_start: U256::from(1000) * one_unit, + range_end: U256::from(5000) * one_unit, + price: U256::from(1420) * one_unit, + }, + ]; + + // Add recognized pair + sudo_raw_and_finalize(&client, &rpc_client, encode_add_recognized_pair(&pair)).await?; + println!("Recognized pair added: {pair_id:?}"); + + // Set deposit amount + sudo_raw_and_finalize(&client, &rpc_client, encode_set_price_deposit_amount(deposit_amount)) + .await?; + println!("Deposit amount set: {deposit_amount}"); + + // Set lock duration (5 blocks) + sudo_raw_and_finalize( + &client, + &rpc_client, + encode_set_price_deposit_lock_duration(lock_duration), + ) + .await?; + println!("Lock duration set: {lock_duration} blocks"); + + // Submit prices + let submit_call_data = encode_submit_pair_price(pair_id, price_entries); + submit_raw_and_finalize(&client, &rpc_client, submit_call_data, Keyring::Alice).await?; + println!("Prices submitted for pair {pair_id:?}"); + + // Query prices via RPC + let prices: Vec = + rpc_client.request("intents_getPairPrices", rpc_params![pair_id]).await?; + + assert_eq!(prices.len(), 2, "expected 2 price entries"); + + // Verify first entry: 0-999 at 1414.5 + assert_eq!(prices[0].range_start, "0", "range1 start should be 0"); + assert_eq!(prices[0].range_end, "999", "range1 end should be 999"); + assert_eq!(prices[0].price, "1414.5", "price1 should be 1414.5 (decimals preserved)"); + + // Verify second entry: 1000-5000 at 1420 + assert_eq!(prices[1].range_start, "1000", "range2 start should be 1000"); + assert_eq!(prices[1].range_end, "5000", "range2 end should be 5000"); + assert_eq!(prices[1].price, "1420", "price2 should be 1420"); + + println!("RPC returns human-readable prices with decimals preserved"); + println!(" entry[0]: {}-{} @ {}", prices[0].range_start, prices[0].range_end, prices[0].price); + println!(" entry[1]: {}-{} @ {}", prices[1].range_start, prices[1].range_end, prices[1].price); + + // Initiate withdrawal + submit_raw_and_finalize( + &client, + &rpc_client, + encode_withdraw_price_deposit(pair_id), + Keyring::Alice, + ) + .await?; + println!("Withdrawal initiated (unlock block recorded)"); + + // Attempting phase 2 immediately should fail (lock not expired) + let early_result = submit_raw_and_finalize( + &client, + &rpc_client, + encode_withdraw_price_deposit(pair_id), + Keyring::Alice, + ) + .await; + assert!(early_result.is_err(), "withdrawal should fail before lock expires"); + println!("correctly rejected (deposit still locked)"); + + // Advance blocks past the lock duration + advance_blocks(&rpc_client, lock_duration + 1).await?; + println!("Advanced {} blocks past lock duration", lock_duration + 1); + + // Complete withdrawal + submit_raw_and_finalize( + &client, + &rpc_client, + encode_withdraw_price_deposit(pair_id), + Keyring::Alice, + ) + .await?; + println!("Deposit successfully withdrawn"); + + // Verify deposit is gone, another withdrawal should fail with DepositNotFound + let gone_result = submit_raw_and_finalize( + &client, + &rpc_client, + encode_withdraw_price_deposit(pair_id), + Keyring::Alice, + ) + .await; + assert!(gone_result.is_err(), "deposit should no longer exist"); + println!("Deposit confirmed removed (subsequent withdrawal fails)"); + + println!("Price submission lifecycle test passed!"); + Ok(()) +} diff --git a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts index 57d01dce5..ef3a7b39a 100644 --- a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts +++ b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts @@ -261,11 +261,23 @@ export class IntentsCoprocessor { if (result.status.isInBlock || result.status.isFinalized) { resolved = true clearTimeout(timeoutId) - resolve({ - success: true, - blockHash: result.status.asInBlock.toHex() as HexString, - extrinsicHash: extrinsic.hash.toHex() as HexString, - }) + const blockHash = result.status.isInBlock + ? result.status.asInBlock.toHex() + : result.status.asFinalized.toHex() + + // Check for dispatch errors within the finalized/inBlock status + if (result.dispatchError) { + resolve({ + success: false, + error: `Dispatch error: ${result.dispatchError.toString()}`, + }) + } else { + resolve({ + success: true, + blockHash: blockHash as HexString, + extrinsicHash: extrinsic.hash.toHex() as HexString, + }) + } } else if (result.dispatchError) { resolved = true clearTimeout(timeoutId) diff --git a/sdk/packages/simplex/src/services/PriceUpdateService.ts b/sdk/packages/simplex/src/services/PriceUpdateService.ts new file mode 100644 index 000000000..293a72a52 --- /dev/null +++ b/sdk/packages/simplex/src/services/PriceUpdateService.ts @@ -0,0 +1,93 @@ +import { IntentsCoprocessor, type HexString, type PriceInput } from "@hyperbridge/sdk" +import { getLogger } from "./Logger" + +export interface PriceUpdateConfig { + /** How often to submit price updates, in seconds. Default: 300 (5 min) */ + intervalSeconds?: number + /** Token pairs and their price entries to submit */ + pairs: PriceUpdatePairEntry[] +} + +export interface PriceUpdatePairEntry { + /** The pair ID (H256 / bytes32) */ + pairId: HexString + /** Human-readable label for logging */ + label?: string + /** Price entries to submit */ + entries: PriceInput[] +} + +/** + * Periodically submits price updates to the intents coprocessor on Hyperbridge. + */ +export class PriceUpdateService { + private interval?: NodeJS.Timeout + private logger = getLogger("price-updates") + + constructor( + private hyperbridge: Promise, + private config: PriceUpdateConfig, + ) {} + + /** + * Start the periodic price update loop. + */ + start(): void { + const intervalMs = (this.config.intervalSeconds ?? 300) * 1000 + + // Run an initial submission after a short delay + setTimeout(() => { + this.submitAll().catch((err) => { + this.logger.error({ err }, "Error in initial price submission") + }) + }, 5_000) + + this.interval = setInterval(() => { + this.submitAll().catch((err) => { + this.logger.error({ err }, "Error in periodic price submission") + }) + }, intervalMs) + + this.logger.info( + { intervalSeconds: this.config.intervalSeconds ?? 300, pairCount: this.config.pairs.length }, + "Price update service started", + ) + } + + /** + * Stop the periodic price update loop. + */ + stop(): void { + if (this.interval) { + clearInterval(this.interval) + this.interval = undefined + this.logger.info("Price update service stopped") + } + } + + /** + * Submit prices for all configured pairs. + */ + async submitAll(): Promise { + const coprocessor = await this.hyperbridge + + for (const pair of this.config.pairs) { + try { + const result = await coprocessor.submitPairPrice(pair.pairId, pair.entries) + if (result.success) { + this.logger.info( + { pairId: pair.pairId, label: pair.label, blockHash: result.blockHash }, + "Price submitted successfully", + ) + } else { + this.logger.error( + { pairId: pair.pairId, label: pair.label, error: result.error }, + "Failed to submit price", + ) + } + } catch (err) { + this.logger.error({ pairId: pair.pairId, label: pair.label, err }, "Error submitting price") + } + } + } +} From dae9ad70ff99207958a5e766ad94d48a3a393252 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Tue, 17 Mar 2026 13:35:57 +0100 Subject: [PATCH 12/28] update to doc --- .../intent-gateway/price-submission.mdx | 91 ++++++++----------- 1 file changed, 40 insertions(+), 51 deletions(-) diff --git a/docs/content/developers/intent-gateway/price-submission.mdx b/docs/content/developers/intent-gateway/price-submission.mdx index 15a0da5b6..9a286a1f5 100644 --- a/docs/content/developers/intent-gateway/price-submission.mdx +++ b/docs/content/developers/intent-gateway/price-submission.mdx @@ -7,77 +7,64 @@ description: Deposit-based price submission system for the intents coprocessor p ## Overview -The intents system needs on-chain price data for token pairs to function correctly. The price submission protocol uses a simple deposit-based model: anyone can submit prices for governance-approved token pairs by reserving a deposit on their first submission. Subsequent updates are free, and the deposit can be withdrawn after a configurable lock duration. +The intents system needs on-chain price data for token pairs to function correctly. Rather than relying on external oracles, the protocol allows anyone to submit prices for governance-approved token pairs by putting up a deposit on their first submission. After that initial deposit, updating prices for the same pair is free. The deposit can be reclaimed later through a two-phase withdrawal process, which gives the system time to detect and respond to malicious data before the submitter disappears with their funds. -## How It Works +## Submitting Prices -The `submit_pair_price` extrinsic on the intents coprocessor pallet accepts price submissions for governance-approved token pairs. All prices and amount ranges are assumed to have 18 decimal places. +The `submit_pair_price` extrinsic on the intents coprocessor pallet is the entry point for all price submissions. It accepts a pair ID and a bounded list of price entries. All prices and amount ranges are encoded as U256 values scaled by 10^18, giving 18 decimal places of precision. -Submissions are batched: each call accepts a `BoundedVec` where each entry specifies a base token amount range and the corresponding price. This allows submitters to quote different rates for different order sizes in a single transaction (for example, USDC/CNGN: 0 to 999 at 1414, 1000 to 5000 at 1420). The maximum number of entries per submission is a compile-time constant configurable per runtime via the `MaxPriceEntries` associated type. +Submissions are batched. Each call accepts up to `MaxPriceEntries` entries (a compile-time constant configurable per runtime), where each entry specifies a base token amount range and the corresponding price of the base token in terms of the quote token. This makes it possible to quote different rates for different order sizes in a single transaction. For example, a submitter might quote USDC/CNGN at 1414 for orders between 0 and 999, and 1420 for orders between 1000 and 5000. -### Price Entries +Each price entry contains three fields. The `range_start` field is the lower bound of the base token amount range (inclusive). The `range_end` field is the upper bound (also inclusive). The `price` field is the price of the base token in terms of the quote token. The pallet validates that `range_start` is less than or equal to `range_end` for every entry and rejects empty submissions. When stored on-chain, each entry becomes a `PriceEntry` that also includes the submission timestamp. -Each `PriceInput` contains three fields: +## Deposit Model -- `range_start`: the lower bound of the base token amount range (inclusive), with 18 decimal places. -- `range_end`: the upper bound of the base token amount range (inclusive), with 18 decimal places. -- `price`: the price of the base token in terms of the quote token, with 18 decimal places. +On the first price submission for a given account and token pair, a deposit is reserved from the submitter's balance. The deposit amount is set by governance through the `set_price_deposit_amount` extrinsic. After the initial deposit, the submitter can update prices for the same token pair as many times as they want without paying anything further. -The pallet validates that `range_start <= range_end` for every entry and rejects submissions where this invariant is violated. It also rejects empty submissions. +This design discourages spam because each new pair requires locking funds, while keeping ongoing price updates free for active participants. -When stored on-chain, each entry becomes a `PriceEntry` which adds the submission timestamp. +## Two-Phase Withdrawal -### Deposit Model +Withdrawing a deposit uses a two-phase process through the `withdraw_price_deposit` extrinsic. The first call initiates the withdrawal by recording the unlock block, which is the current block number plus the governance-configured lock duration. No tokens are moved at this point, and the pallet emits a `PriceDepositWithdrawalInitiated` event. -On the first price submission per (account, token pair), a deposit is reserved from the submitter's balance via `ReservableCurrency::reserve()`. The deposit amount is set by governance through the `set_price_deposit_amount` extrinsic. +Once the unlock block has been reached, a second call to the same extrinsic completes the withdrawal. The pallet unreserves the tokens, removes the deposit record, and emits a `PriceDepositWithdrawn` event. If the second call is made before the unlock block, it fails with a `DepositStillLocked` error. -After the initial deposit, the submitter can update prices for the same token pair as many times as they want without paying anything further. +The lock duration is measured in blocks and set by governance through `set_price_deposit_lock_duration`. This two-phase model ensures there is always a window during which bad actors can be identified and penalized before they can withdraw their deposit. -Withdrawing a deposit uses a two-phase process via the `withdraw_price_deposit` extrinsic: +## Price Windows and Data Lifecycle -1. **Phase 1 — Initiate**: The first call records the unlock block (`current_block + PriceDepositLockDuration`). No tokens are moved. -2. **Phase 2 — Complete**: After the unlock block has been reached, a second call unreserves the tokens and removes the deposit record. +Prices are organized into time-based windows. The window duration is governance-configurable via `PriceWindowDurationValue`, specified in milliseconds. -The lock duration (in blocks) is set by governance through `set_price_deposit_lock_duration`. This two-phase model prevents instant withdrawals while keeping the deposit locked for a predictable number of blocks. +The `on_initialize` hook runs every block and checks whether the current window has expired. When it has, it resets a `PricesClearedThisWindow` flag to false. Prices from the previous window are not cleared immediately. They persist so that consumers can still read the previous window's data. -This model discourages spam (each new pair requires locking funds) while keeping ongoing price updates free. - -## Price Window and Data Lifecycle - -Prices are organized into daily windows, which are governance-configurable via `PriceWindowDurationValue`. - -The `on_initialize` hook runs every block and checks whether the current window has expired. When it has, it resets a `PricesClearedThisWindow` flag to false. - -Prices from the previous window are not cleared immediately. They persist so that consumers can still read the previous window's data. On the first new submission in the new window, all price entries across all pairs are cleared before the new entries are stored. This lazy clearing approach avoids the cost of iterating all pairs in `on_initialize` while ensuring stale data is replaced as soon as fresh data arrives. - -### Why Lazy Clearing - -Clearing all price maps in `on_initialize` would cost weight proportional to the number of pairs with data, which is unbounded and paid by the block producer. Instead, the cost is deferred to the first submitter of the new window, who is already paying for a storage write. The global boolean flag (`PricesClearedThisWindow`) makes this a single check per submission after the first. +On the first new submission in a new window, all price entries across all pairs are cleared before the new entries are stored. This lazy clearing approach avoids the cost of iterating all pairs in `on_initialize` (which would be unbounded weight paid by the block producer) while ensuring stale data is replaced as soon as fresh data arrives. After the first submission clears the data, the global boolean flag ensures subsequent submissions in the same window skip the clearing step entirely. ## Recognized Pairs -Only governance-approved token pairs can receive price submissions. This prevents spam for arbitrary token combinations and keeps storage growth under control. Governance manages pairs through `add_recognized_pair` and `remove_recognized_pair` extrinsics. Removing a pair also cleans up its associated price data. +Only governance-approved token pairs can receive price submissions. This prevents spam for arbitrary token combinations and keeps storage growth under control. Governance manages pairs through the `add_recognized_pair` and `remove_recognized_pair` extrinsics. Removing a pair also cleans up its associated price data and storage. ## RPC -The `intents_getPairPrices(pair_id)` RPC endpoint returns all prices for a given token pair. Each returned entry includes the amount range (`range_start`, `range_end`), the price, and the submission timestamp. +The `intents_getPairPrices(pair_id)` RPC endpoint returns all price entries for a given token pair. The raw on-chain values (U256 scaled by 10^18) are converted to human-readable decimal strings with fractional precision preserved. For example, an on-chain value of 1414500000000000000000 is returned as the string "1414.5". Each returned entry includes the amount range boundaries (`range_start` and `range_end`), the `price`, and the submission `timestamp` in seconds. ## Simplex Filler Integration -The simplex filler supports periodic price submission out of the box via a `[priceUpdates]` section in the filler configuration file. This requires `substratePrivateKey` and `hyperbridgeWsUrl` to be configured. +The simplex filler supports periodic price submission out of the box. When the filler starts, it checks whether a `[priceUpdates]` section is present in the configuration file. If price pairs are configured and a Hyperbridge WebSocket connection is available (via `substratePrivateKey` and `hyperbridgeWsUrl` in the `[simplex]` section), the filler creates a `PriceUpdateService` and starts it alongside the main order monitoring loop. + +The `PriceUpdateService` runs on a configurable interval (defaulting to every 5 minutes). On each tick, it iterates through all configured pairs and calls `IntentsCoprocessor.submitPairPrice()` for each one. An initial submission is also triggered shortly after startup so that prices are available immediately rather than waiting for the first interval to elapse. The service logs success or failure for each pair and continues with the remaining pairs even if one fails. ### Configuration -Add the following to your `filler-config.toml`: +Price updates are configured by adding a `[priceUpdates]` section to the filler TOML configuration file. ```toml [priceUpdates] intervalSeconds = 300 # How often to submit prices (default: 300 = 5 minutes) [[priceUpdates.pairs]] -pairId = "0x..." # The token pair ID (keccak256 of base_address ++ quote_address) +pairId = "0x..." # The token pair ID (keccak256 of base_address ++ quote_address) label = "USDC/CNGN" -decimals = 18 # Decimal places for amounts/prices (default: 18) +decimals = 18 # Decimal places for amounts/prices (default: 18) [[priceUpdates.pairs.entries]] rangeStart = "0" # Min base amount in human-readable form @@ -90,15 +77,13 @@ rangeEnd = "5000" price = "1420" ``` -Amounts are specified in human-readable form (e.g. `"1000"` for 1000 tokens) and automatically converted to 18-decimal format when submitted to the chain. The optional `decimals` field (default: 18) controls this conversion. - -Multiple pairs can be configured by repeating `[[priceUpdates.pairs]]` blocks. Each pair can have multiple entries for tiered pricing at different order sizes. +Amounts and prices in the configuration file are specified in human-readable form (for example, "1000" for 1000 tokens). The simplex CLI automatically converts them to 18-decimal format when submitting to the chain using the `decimals` field, which defaults to 18 if not specified. Multiple pairs can be configured by repeating `[[priceUpdates.pairs]]` blocks, and each pair can have multiple entries for tiered pricing at different order sizes. -The first submission for each pair will reserve a deposit from the substrate account. Subsequent updates to the same pair are free. The deposit can be reclaimed via a two-phase withdrawal: call `withdrawPriceDeposit` once to initiate (records the unlock block), then call it again after the governance-configured lock duration (in blocks) has elapsed to unreserve the tokens. +The first submission for each pair will reserve a deposit from the substrate account. Subsequent updates to the same pair are free. When a filler wants to reclaim their deposit, they call `withdrawPriceDeposit` once to initiate the withdrawal (which records the unlock block), then call it again after the governance-configured lock duration has elapsed to unreserve the tokens. ### SDK Usage -The `IntentsCoprocessor` class in `@hyperbridge/sdk` exposes two methods for direct use. Note that the SDK accepts raw 18-decimal bigint values — the human-readable conversion is a simplex filler convenience. +The `IntentsCoprocessor` class in `@hyperbridge/sdk` exposes methods for price submission and deposit withdrawal directly. These methods accept raw 18-decimal bigint values. The human-readable string conversion described above is a convenience provided by the simplex filler's TOML configuration parser. ```typescript import { IntentsCoprocessor } from "@hyperbridge/sdk" @@ -113,13 +98,13 @@ await coprocessor.submitPairPrice("0x...", [ ]) // Withdraw deposit (two-phase): -// Phase 1 — initiate withdrawal (records unlock block) +// Phase 1: initiate withdrawal (records unlock block) await coprocessor.withdrawPriceDeposit("0x...") -// Phase 2 — complete withdrawal after unlock block is reached +// Phase 2: complete withdrawal after unlock block is reached await coprocessor.withdrawPriceDeposit("0x...") ``` -The `PriceUpdateService` from `@hyperbridge/simplex` can also be used standalone for periodic submissions without running the full filler: +The `PriceUpdateService` from `@hyperbridge/simplex` can also be used as a standalone component for periodic price submissions without running the full filler. ```typescript import { PriceUpdateService } from "@hyperbridge/simplex" @@ -146,10 +131,14 @@ service.start() ## Governance Parameters -All key parameters are stored on-chain and updatable via governance extrinsics: +The protocol has several parameters that are stored on-chain and updatable through governance extrinsics. + +`PriceWindowDurationValue` controls the length of each price window in milliseconds. When a window expires, the next submission triggers a lazy clear of all existing price data. + +`PriceDepositAmount` is the amount reserved from a submitter's balance on their first price submission for a given token pair. This deposit stays locked until explicitly withdrawn through the two-phase process. + +`PriceDepositLockDuration` determines how many blocks the deposit remains locked after a withdrawal is initiated. The submitter must wait this many blocks before completing the withdrawal. + +`MaxPriceEntries` is a compile-time constant (configurable per runtime) that limits how many price entries can be included in a single submission. -- `PriceWindowDurationValue` is the length of the price window in milliseconds. -- `PriceDepositAmount` is the amount reserved from submitters on their first price submission per pair. -- `PriceDepositLockDuration` is how many blocks the deposit is locked before it can be withdrawn (used in the two-phase withdrawal process). -- `MaxPriceEntries` is the compile-time maximum number of price entries per submission, configurable per runtime. -- Recognized token pairs determine which pairs accept submissions. +Recognized token pairs are managed through `add_recognized_pair` and `remove_recognized_pair`. Only pairs that have been explicitly added by governance can receive price submissions. From d9259494e855ee7af6263a4c4fe60024b26585d0 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Tue, 17 Mar 2026 15:47:48 +0100 Subject: [PATCH 13/28] block price submissions when a withdrawal is in progress, use H256 for TokenPair, rename blockHash to txHash, use FX strategy ask curve during filler initialization only. --- .../intent-gateway/price-submission.mdx | 76 +++++++------------ .../pallets/intents-coprocessor/src/lib.rs | 6 ++ .../pallets/intents-coprocessor/src/tests.rs | 65 +++++++++++++--- .../pallets/intents-coprocessor/src/types.rs | 6 +- parachain/simtests/src/price_submission.rs | 18 ++--- .../sdk/src/chains/intentsCoprocessor.ts | 4 +- sdk/packages/simplex/src/bin/simplex.ts | 46 +---------- sdk/packages/simplex/src/core/filler.ts | 28 +++---- sdk/packages/simplex/src/index.ts | 3 +- sdk/packages/simplex/src/services/index.ts | 1 - sdk/packages/simplex/src/strategies/fx.ts | 76 ++++++++++++++++++- .../src/tests/strategies/fx.mainnet.test.ts | 6 ++ 12 files changed, 194 insertions(+), 141 deletions(-) diff --git a/docs/content/developers/intent-gateway/price-submission.mdx b/docs/content/developers/intent-gateway/price-submission.mdx index 9a286a1f5..163f4e47c 100644 --- a/docs/content/developers/intent-gateway/price-submission.mdx +++ b/docs/content/developers/intent-gateway/price-submission.mdx @@ -15,7 +15,7 @@ The `submit_pair_price` extrinsic on the intents coprocessor pallet is the entry Submissions are batched. Each call accepts up to `MaxPriceEntries` entries (a compile-time constant configurable per runtime), where each entry specifies a base token amount range and the corresponding price of the base token in terms of the quote token. This makes it possible to quote different rates for different order sizes in a single transaction. For example, a submitter might quote USDC/CNGN at 1414 for orders between 0 and 999, and 1420 for orders between 1000 and 5000. -Each price entry contains three fields. The `range_start` field is the lower bound of the base token amount range (inclusive). The `range_end` field is the upper bound (also inclusive). The `price` field is the price of the base token in terms of the quote token. The pallet validates that `range_start` is less than or equal to `range_end` for every entry and rejects empty submissions. When stored on-chain, each entry becomes a `PriceEntry` that also includes the submission timestamp. +Each price entry contains three fields. The `range_start` field is the lower bound of the base token amount range (inclusive). The `range_end` field is the upper bound (also inclusive). The `price` field is the cost of one unit of the base token in terms of the quote token. The pallet validates that `range_start` is less than or equal to `range_end` for every entry and rejects empty submissions. When stored on-chain, each entry becomes a `PriceEntry` that also includes the submission timestamp. ## Deposit Model @@ -49,41 +49,48 @@ The `intents_getPairPrices(pair_id)` RPC endpoint returns all price entries for ## Simplex Filler Integration -The simplex filler supports periodic price submission out of the box. When the filler starts, it checks whether a `[priceUpdates]` section is present in the configuration file. If price pairs are configured and a Hyperbridge WebSocket connection is available (via `substratePrivateKey` and `hyperbridgeWsUrl` in the `[simplex]` section), the filler creates a `PriceUpdateService` and starts it alongside the main order monitoring loop. +The simplex filler submits price updates automatically as part of the FX strategy. When the filler starts and a `hyperfx` strategy is configured with a `pairId`, the FX strategy spawns a periodic task that converts its ask price curve into on-chain price entries and submits them via `IntentsCoprocessor.submitPairPrice()`. There is no separate price configuration section; the prices come directly from the strategy's existing ask price curve, which is the same curve used to evaluate order profitability. -The `PriceUpdateService` runs on a configurable interval (defaulting to every 5 minutes). On each tick, it iterates through all configured pairs and calls `IntentsCoprocessor.submitPairPrice()` for each one. An initial submission is also triggered shortly after startup so that prices are available immediately rather than waiting for the first interval to elapse. The service logs success or failure for each pair and continues with the remaining pairs even if one fails. +The task runs on a configurable interval (defaulting to every 5 minutes). An initial submission is triggered shortly after startup so that prices are available immediately rather than waiting for the first interval to elapse. ### Configuration -Price updates are configured by adding a `[priceUpdates]` section to the filler TOML configuration file. +Price submission is enabled by adding a `pairId` to the `hyperfx` strategy in the filler TOML configuration file. The ask price curve points are converted into price entries automatically, where each point on the curve defines a price range. ```toml -[priceUpdates] -intervalSeconds = 300 # How often to submit prices (default: 300 = 5 minutes) - -[[priceUpdates.pairs]] -pairId = "0x..." # The token pair ID (keccak256 of base_address ++ quote_address) -label = "USDC/CNGN" -decimals = 18 # Decimal places for amounts/prices (default: 18) - -[[priceUpdates.pairs.entries]] -rangeStart = "0" # Min base amount in human-readable form -rangeEnd = "999" # Max base amount in human-readable form -price = "1414" # Price in human-readable form - -[[priceUpdates.pairs.entries]] -rangeStart = "1000" -rangeEnd = "5000" +[[strategies]] +type = "hyperfx" +pairId = "0x..." # On-chain pair ID (keccak256 of base_address ++ quote_address) +priceSubmissionIntervalSeconds = 300 # How often to submit prices (default: 300 = 5 minutes) +maxOrderUsd = "5000" + +[[strategies.askPriceCurve]] +amount = "0" +price = "1414" + +[[strategies.askPriceCurve]] +amount = "1000" price = "1420" + +[[strategies.bidPriceCurve]] +amount = "0" +price = "1500" + +[[strategies.bidPriceCurve]] +amount = "1000" +price = "1490" + +[strategies.exoticTokenAddresses] +"EVM-56" = "0xabc..." ``` -Amounts and prices in the configuration file are specified in human-readable form (for example, "1000" for 1000 tokens). The simplex CLI automatically converts them to 18-decimal format when submitting to the chain using the `decimals` field, which defaults to 18 if not specified. Multiple pairs can be configured by repeating `[[priceUpdates.pairs]]` blocks, and each pair can have multiple entries for tiered pricing at different order sizes. +The ask curve points are sorted by amount and each point becomes a price entry. The range for each entry spans from that point's amount to just below the next point's amount. The last point extends to a large upper bound. Amounts and prices are converted to 18-decimal format before submission. The first submission for each pair will reserve a deposit from the substrate account. Subsequent updates to the same pair are free. When a filler wants to reclaim their deposit, they call `withdrawPriceDeposit` once to initiate the withdrawal (which records the unlock block), then call it again after the governance-configured lock duration has elapsed to unreserve the tokens. ### SDK Usage -The `IntentsCoprocessor` class in `@hyperbridge/sdk` exposes methods for price submission and deposit withdrawal directly. These methods accept raw 18-decimal bigint values. The human-readable string conversion described above is a convenience provided by the simplex filler's TOML configuration parser. +The `IntentsCoprocessor` class in `@hyperbridge/sdk` exposes methods for price submission and deposit withdrawal directly. These methods accept raw 18-decimal bigint values. ```typescript import { IntentsCoprocessor } from "@hyperbridge/sdk" @@ -104,31 +111,6 @@ await coprocessor.withdrawPriceDeposit("0x...") await coprocessor.withdrawPriceDeposit("0x...") ``` -The `PriceUpdateService` from `@hyperbridge/simplex` can also be used as a standalone component for periodic price submissions without running the full filler. - -```typescript -import { PriceUpdateService } from "@hyperbridge/simplex" -import { IntentsCoprocessor } from "@hyperbridge/sdk" -import { parseUnits } from "viem" - -const coprocessor = IntentsCoprocessor.connect(wsUrl, substratePrivateKey) - -const service = new PriceUpdateService(coprocessor, { - intervalSeconds: 300, - pairs: [ - { - pairId: "0x...", - label: "USDC/CNGN", - entries: [ - { rangeStart: parseUnits("0", 18), rangeEnd: parseUnits("999", 18), price: parseUnits("1414", 18) }, - ], - }, - ], -}) - -service.start() -``` - ## Governance Parameters The protocol has several parameters that are stored on-chain and updatable through governance extrinsics. diff --git a/modules/pallets/intents-coprocessor/src/lib.rs b/modules/pallets/intents-coprocessor/src/lib.rs index a651e300e..926d6297c 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -286,6 +286,8 @@ pub mod pallet { DepositNotFound, /// The deposit is still within the lock duration (unlock block not yet reached) DepositStillLocked, + /// Cannot submit prices while withdrawal is pending + WithdrawalInProgress, /// Withdrawal has already been initiated WithdrawalAlreadyInitiated, } @@ -599,6 +601,10 @@ pub mod pallet { let deposit_amount = PriceDepositAmount::::get(); ensure!(!deposit_amount.is_zero(), Error::::PriceDepositsNotConfigured); + if let Some((_, Some(_unlock_block))) = PriceDeposits::::get(&submitter, &pair_id) { + return Err(Error::::WithdrawalInProgress.into()); + } + let now = T::Dispatcher::default().timestamp().as_secs(); // Reserve deposit on first submission per (account, pair) diff --git a/modules/pallets/intents-coprocessor/src/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index 0a5dbb4c0..992c1eb5d 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -546,7 +546,7 @@ fn multiple_fillers_can_bid_on_same_order() { fn remove_recognized_pair_works() { new_test_ext().execute_with(|| { let pair = - types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); @@ -574,7 +574,7 @@ fn submit_pair_price_reserves_deposit_on_first_submission() { let submitter = AccountId32::new([1; 32]); let pair = - types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); @@ -619,7 +619,7 @@ fn submit_pair_price_second_submission_is_free() { let submitter = AccountId32::new([1; 32]); let pair = - types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); @@ -672,7 +672,7 @@ fn submit_pair_price_fails_with_insufficient_balance() { let submitter = AccountId32::new([4; 32]); // no balance let pair = - types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); @@ -698,7 +698,7 @@ fn withdraw_price_deposit_two_phase() { let submitter = AccountId32::new([1; 32]); let pair = - types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); @@ -764,7 +764,7 @@ fn withdraw_price_deposit_phase2_fails_when_still_locked() { let submitter = AccountId32::new([1; 32]); let pair = - types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); @@ -833,7 +833,7 @@ fn prices_persist_across_window_and_clear_on_first_submission() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); let pair = - types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); @@ -919,7 +919,7 @@ fn price_entry_encoding_matches_rpc_tuple_decoding() { fn price_entry_storage_roundtrip_via_raw_key() { new_test_ext().execute_with(|| { let pair = - types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; let pair_id = pair.pair_id(); let entry1 = types::PriceEntry { @@ -970,7 +970,7 @@ fn multiple_submitters_independent_deposits() { let submitter2 = AccountId32::new([2; 32]); let pair = - types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; let pair_id = pair.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); @@ -1012,9 +1012,9 @@ fn separate_deposits_per_pair() { let submitter = AccountId32::new([1; 32]); let pair1 = - types::TokenPair { base: H160::from_low_u64_be(1), quote: H160::from_low_u64_be(2) }; + types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; let pair2 = - types::TokenPair { base: H160::from_low_u64_be(3), quote: H160::from_low_u64_be(4) }; + types::TokenPair { base: H256::from_low_u64_be(3), quote: H256::from_low_u64_be(4) }; let pair_id1 = pair1.pair_id(); let pair_id2 = pair2.pair_id(); assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair1)); @@ -1071,3 +1071,46 @@ fn separate_deposits_per_pair() { assert_eq!(Balances::reserved_balance(&submitter), 0); }); } + +#[test] +fn submit_pair_price_blocked_after_withdrawal_initiated() { + new_test_ext().execute_with(|| { + let submitter: AccountId = AccountId::from([1u8; 32]); + let deposit_amount = 100u64; + + let pair = + TokenPair { base: H256::from_low_u64_be(0xAAAA), quote: H256::from_low_u64_be(0xBBBB) }; + let pair_id = pair.pair_id(); + + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + PriceDepositAmount::::put(deposit_amount); + PriceDepositLockDuration::::put(10u64); + + let entries = BoundedVec::try_from(vec![PriceInput { + range_start: U256::zero(), + range_end: U256::from(1000), + price: U256::from(42), + }]) + .unwrap(); + + // Submit prices(reserves deposit) + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(submitter.clone()), + pair_id, + entries.clone(), + )); + + // Initiate withdrawal + System::set_block_number(1); + assert_ok!(Intents::withdraw_price_deposit( + RuntimeOrigin::signed(submitter.clone()), + pair_id, + )); + + // Submitting prices should now fail + assert_noop!( + Intents::submit_pair_price(RuntimeOrigin::signed(submitter.clone()), pair_id, entries,), + Error::::WithdrawalInProgress + ); + }); +} diff --git a/modules/pallets/intents-coprocessor/src/types.rs b/modules/pallets/intents-coprocessor/src/types.rs index 936fa3992..e26b86948 100644 --- a/modules/pallets/intents-coprocessor/src/types.rs +++ b/modules/pallets/intents-coprocessor/src/types.rs @@ -147,15 +147,15 @@ pub struct Bid { #[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] pub struct TokenPair { /// The base token address - pub base: H160, + pub base: H256, /// The quote token address - pub quote: H160, + pub quote: H256, } impl TokenPair { /// Compute a unique identifier for this token pair pub fn pair_id(&self) -> H256 { - let mut data = alloc::vec::Vec::with_capacity(40); + let mut data = alloc::vec::Vec::with_capacity(64); data.extend_from_slice(&self.base.0); data.extend_from_slice(&self.quote.0); sp_io::hashing::keccak_256(&data).into() diff --git a/parachain/simtests/src/price_submission.rs b/parachain/simtests/src/price_submission.rs index 96001897b..de55fba79 100644 --- a/parachain/simtests/src/price_submission.rs +++ b/parachain/simtests/src/price_submission.rs @@ -6,21 +6,13 @@ use codec::Encode; use pallet_intents_coprocessor::types::{PriceInput, TokenPair}; use pallet_intents_rpc::RpcPriceEntry; use polkadot_sdk::*; -use primitive_types::{H160, H256, U256}; +use primitive_types::{H256, U256}; use sc_consensus_manual_seal::CreatedBlock; -use sp_core::{crypto::Ss58Codec, hashing::keccak_256, Bytes}; +use sp_core::{crypto::Ss58Codec, Bytes}; use sp_keyring::sr25519::Keyring; use subxt::ext::subxt_rpcs::rpc_params; use subxt_utils::Hyperbridge; -/// Compute the same pair_id as `TokenPair::pair_id()` — keccak256(base ++ quote). -fn compute_pair_id(base: &H160, quote: &H160) -> H256 { - let mut data = Vec::with_capacity(40); - data.extend_from_slice(&base.0); - data.extend_from_slice("e.0); - keccak_256(&data).into() -} - /// Helper: submit raw SCALE-encoded call bytes from a given keyring account, /// create and finalize a block, then wait for success. async fn submit_raw_and_finalize( @@ -132,10 +124,10 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { let url = &format!("ws://127.0.0.1:{}", port); let (client, rpc_client) = subxt_utils::client::ws_client::(url, u32::MAX).await?; - let base = H160::from_low_u64_be(0xAAAA); - let quote = H160::from_low_u64_be(0xBBBB); + let base = H256::from_low_u64_be(0xAAAA); + let quote = H256::from_low_u64_be(0xBBBB); let pair = TokenPair { base, quote }; - let pair_id = compute_pair_id(&base, "e); + let pair_id = pair.pair_id(); // 1 unit = 10^18 in raw representation let one_unit = U256::from(10u64).pow(U256::from(18)); diff --git a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts index ef3a7b39a..4fa1cd339 100644 --- a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts +++ b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts @@ -261,7 +261,7 @@ export class IntentsCoprocessor { if (result.status.isInBlock || result.status.isFinalized) { resolved = true clearTimeout(timeoutId) - const blockHash = result.status.isInBlock + const txHash = result.status.isInBlock ? result.status.asInBlock.toHex() : result.status.asFinalized.toHex() @@ -274,7 +274,7 @@ export class IntentsCoprocessor { } else { resolve({ success: true, - blockHash: blockHash as HexString, + blockHash: txHash as HexString, extrinsicHash: extrinsic.hash.toHex() as HexString, }) } diff --git a/sdk/packages/simplex/src/bin/simplex.ts b/sdk/packages/simplex/src/bin/simplex.ts index e2c1c93a1..cf8e003c2 100644 --- a/sdk/packages/simplex/src/bin/simplex.ts +++ b/sdk/packages/simplex/src/bin/simplex.ts @@ -22,7 +22,6 @@ import { RebalancingService } from "@/services/RebalancingService" import { getLogger, configureLogger } from "@/services/Logger" import { CacheService } from "@/services/CacheService" import { BidStorageService } from "@/services/BidStorageService" -import type { PriceUpdateConfig } from "@/services/PriceUpdateService" import type { BinanceCexConfig } from "@/services/rebalancers/index" import { Decimal } from "decimal.js" @@ -92,6 +91,8 @@ interface FxStrategyConfig { maxOrderUsd: string /** Map of chain identifier (e.g. "EVM-97") to exotic token contract address */ exoticTokenAddresses: Record + /** On-chain pair ID (H256) for price submission to the intents coprocessor */ + pairId?: HexString } type StrategyConfig = BasicStrategyConfig | FxStrategyConfig @@ -237,21 +238,6 @@ interface BinanceConfig { withdrawTimeoutMs?: number } -interface PriceUpdatesConfig { - intervalSeconds?: number - pairs: Array<{ - pairId: HexString - label?: string - /** Decimal places for human-readable amounts/prices. Default: 18 */ - decimals?: number - entries: Array<{ - rangeStart: string - rangeEnd: string - price: string - }> - }> -} - interface FillerTomlConfig { simplex: { privateKey: string @@ -268,7 +254,6 @@ interface FillerTomlConfig { chains: (UserProvidedChainConfig & { bundlerUrl?: string })[] rebalancing?: RebalancingConfig binance?: BinanceConfig - priceUpdates?: PriceUpdatesConfig } const program = new Command() @@ -417,6 +402,8 @@ program askPricePolicy, strategyConfig.maxOrderUsd, strategyConfig.exoticTokenAddresses, + strategyConfig.askPriceCurve, + strategyConfig.pairId, ) } default: @@ -449,30 +436,6 @@ program logger.info("Rebalancing service initialized") } - // Parse price update configuration - let priceUpdateConfig: PriceUpdateConfig | undefined - if (config.priceUpdates?.pairs && config.priceUpdates.pairs.length > 0) { - priceUpdateConfig = { - intervalSeconds: config.priceUpdates.intervalSeconds, - pairs: config.priceUpdates.pairs.map((p) => { - const decimals = p.decimals ?? 18 - return { - pairId: p.pairId, - label: p.label, - entries: p.entries.map((e) => ({ - rangeStart: parseUnits(e.rangeStart, decimals), - rangeEnd: parseUnits(e.rangeEnd, decimals), - price: parseUnits(e.price, decimals), - })), - } - }), - } - logger.info( - { pairCount: priceUpdateConfig.pairs.length, intervalSeconds: priceUpdateConfig.intervalSeconds ?? 300 }, - "Price update configuration loaded", - ) - } - // Initialize and start the intent filler logger.info("Starting intent filler...") const intentFiller = new IntentFiller( @@ -485,7 +448,6 @@ program privateKey, rebalancingService, bidStorageService, - priceUpdateConfig, ) // Initialize (sets up EIP-7702 delegation if solver selection is configured) diff --git a/sdk/packages/simplex/src/core/filler.ts b/sdk/packages/simplex/src/core/filler.ts index 69e9cd1ea..e3fedff0c 100644 --- a/sdk/packages/simplex/src/core/filler.ts +++ b/sdk/packages/simplex/src/core/filler.ts @@ -16,12 +16,11 @@ import { ChainClientManager, ContractInteractionService, DelegationService, - PriceUpdateService, RebalancingService, } from "@/services" -import type { PriceUpdateConfig } from "@/services/PriceUpdateService" import { FillerConfigService } from "@/services/FillerConfigService" import { getLogger } from "@/services/Logger" +import { FXFiller } from "@/strategies/fx" import { Decimal } from "decimal.js" export class IntentFiller { @@ -33,7 +32,6 @@ export class IntentFiller { private contractService: ContractInteractionService private delegationService?: DelegationService private rebalancingService?: RebalancingService - private priceUpdateService?: PriceUpdateService private bidStorage?: BidStorageService private retractionQueue: pQueue private pendingRetractions = new Set() @@ -55,7 +53,6 @@ export class IntentFiller { privateKey: HexString, rebalancingService?: RebalancingService, bidStorage?: BidStorageService, - priceUpdateConfig?: PriceUpdateConfig, ) { this.configService = configService this.privateKey = privateKey @@ -87,11 +84,6 @@ export class IntentFiller { this.hyperbridge = IntentsCoprocessor.connect(hyperbridgeWsUrl, substrateKey) } - // Set up price update service if configured and hyperbridge is available - if (priceUpdateConfig && priceUpdateConfig.pairs.length > 0 && this.hyperbridge) { - this.priceUpdateService = new PriceUpdateService(this.hyperbridge, priceUpdateConfig) - } - // Set up event handlers this.monitor.on("newOrder", ({ order }) => { this.handleNewOrder(order) @@ -120,6 +112,15 @@ export class IntentFiller { } } + // Submit initial prices on FX strategies during initialization + if (this.hyperbridge) { + for (const strategy of this.strategies) { + if (strategy instanceof FXFiller) { + await strategy.submitInitialPrices(this.hyperbridge) + } + } + } + // Set up delegation service on chains where solver selection is active if (chainsWithSolverSelection.length > 0 && this.hyperbridge) { this.delegationService = new DelegationService(this.chainClientManager, this.configService, this.privateKey) @@ -146,10 +147,6 @@ export class IntentFiller { this.startRetractionSweep() } - // Start periodic price updates if configured - if (this.priceUpdateService) { - this.priceUpdateService.start() - } } /** @@ -249,11 +246,6 @@ export class IntentFiller { this.logger.info("Periodic retraction sweep stopped") } - // Stop price update service - if (this.priceUpdateService) { - this.priceUpdateService.stop() - } - // Wait for all queues to complete const promises: Promise[] = [] this.chainQueues.forEach((queue) => { diff --git a/sdk/packages/simplex/src/index.ts b/sdk/packages/simplex/src/index.ts index 2821383d0..2ad5b0752 100644 --- a/sdk/packages/simplex/src/index.ts +++ b/sdk/packages/simplex/src/index.ts @@ -13,5 +13,4 @@ export { InterpolatedCurve, ConfirmationPolicy, FillerBpsPolicy } from "@/config export type { CurvePoint, CurveConfig } from "@/config/interpolated-curve" // Service exports -export { ChainClientManager, ContractInteractionService, PriceUpdateService } from "@/services" -export type { PriceUpdateConfig, PriceUpdatePairEntry } from "@/services/PriceUpdateService" +export { ChainClientManager, ContractInteractionService } from "@/services" diff --git a/sdk/packages/simplex/src/services/index.ts b/sdk/packages/simplex/src/services/index.ts index c3208e997..aeb0f29a2 100644 --- a/sdk/packages/simplex/src/services/index.ts +++ b/sdk/packages/simplex/src/services/index.ts @@ -5,5 +5,4 @@ export * from "./ContractInteractionService" export * from "./DelegationService" export * from "./FillerConfigService" export * from "./Logger" -export * from "./PriceUpdateService" export * from "./RebalancingService" diff --git a/sdk/packages/simplex/src/strategies/fx.ts b/sdk/packages/simplex/src/strategies/fx.ts index 283f124fa..dd589e761 100644 --- a/sdk/packages/simplex/src/strategies/fx.ts +++ b/sdk/packages/simplex/src/strategies/fx.ts @@ -9,13 +9,14 @@ import { IntentsCoprocessor, adjustDecimals, ADDRESS_ZERO, + type PriceInput, } from "@hyperbridge/sdk" import { privateKeyToAccount } from "viem/accounts" import { ChainClientManager, ContractInteractionService } from "@/services" import { FillerConfigService } from "@/services/FillerConfigService" -import { formatUnits } from "viem" +import { formatUnits, parseUnits } from "viem" import { getLogger } from "@/services/Logger" -import { FillerPricePolicy } from "@/config/interpolated-curve" +import { FillerPricePolicy, type PriceCurvePoint } from "@/config/interpolated-curve" import { Decimal } from "decimal.js" import { ERC20_ABI } from "@/config/abis/ERC20" @@ -57,6 +58,10 @@ export class FXFiller implements FillerStrategy { private maxOrderUsd: Decimal private account: ReturnType private logger = getLogger("fx-simplex") + /** On-chain pair ID for price submission */ + private pairId?: HexString + /** Raw ask curve points for building on-chain price entries */ + private askCurvePoints: PriceCurvePoint[] /** * @param privateKey Filler's private key used to sign UserOps. @@ -74,6 +79,8 @@ export class FXFiller implements FillerStrategy { * the filler will only size its outputs as if the order were $5,000. * @param exoticTokenAddresses Map of chain identifier → exotic token address. * Example: `{ "EVM-56": "0xabc..." }` for cNGN on BSC. + * @param askCurvePoints Raw ask curve points for on-chain price submission. + * @param pairId On-chain pair ID (H256) for price submission. */ constructor( privateKey: HexString, @@ -84,6 +91,8 @@ export class FXFiller implements FillerStrategy { askPricePolicy: FillerPricePolicy, maxOrderUsdStr: string, exoticTokenAddresses: Record, + askCurvePoints: PriceCurvePoint[], + pairId?: HexString, ) { this.privateKey = privateKey this.configService = configService @@ -97,6 +106,69 @@ export class FXFiller implements FillerStrategy { throw new Error("FXFiller maxOrderUsd must be greater than 0") } this.account = privateKeyToAccount(privateKey) + this.pairId = pairId + this.askCurvePoints = askCurvePoints + } + + /** + * Submit initial prices using the ask price curve + * Called once during filler initialization to publish the strategy's + * prices on-chain before the filler starts processing orders. + */ + async submitInitialPrices(coprocessor: Promise): Promise { + if (!this.pairId) { + this.logger.warn("No pairId configured, skipping price submission") + return + } + + const entries = this.buildPriceEntries() + if (entries.length === 0) { + this.logger.warn("No price entries derived from ask curve, skipping price submission") + return + } + + const pairId = this.pairId + + try { + const cp = await coprocessor + const result = await cp.submitPairPrice(pairId, entries) + if (result.success) { + this.logger.info({ pairId, blockHash: result.blockHash, entryCount: entries.length }, "Initial prices submitted") + } else { + this.logger.error({ pairId, error: result.error }, "Failed to submit initial prices") + } + } catch (err) { + this.logger.error({ pairId, err }, "Error submitting initial prices") + } + } + + /** + * Convert the ask curve points into an on-chain PriceInput entries. + * Each adjacent pair of points defines a price range. + * The last point extends to a large upper bound. + */ + private buildPriceEntries(): PriceInput[] { + const points = [...this.askCurvePoints].sort( + (a, b) => parseFloat(a.amount) - parseFloat(b.amount), + ) + + if (points.length === 0) return [] + + const entries: PriceInput[] = [] + const decimals = 18 + + for (let i = 0; i < points.length; i++) { + const rangeStart = parseUnits(points[i].amount, decimals) + const rangeEnd = + i < points.length - 1 + ? parseUnits(points[i + 1].amount, decimals) - 1n + : parseUnits("999999999", decimals) // large upper bound for last bucket + const price = parseUnits(points[i].price, decimals) + + entries.push({ rangeStart, rangeEnd, price }) + } + + return entries } async canFill(order: OrderV2): Promise { diff --git a/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts b/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts index 6256054c6..824a9cfbe 100644 --- a/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts +++ b/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts @@ -652,6 +652,11 @@ function createFxOnlyIntentFiller( const extAsset = chainConfigService.getExtAsset(mainnetId) const exoticTokenAddresses: Record = extAsset ? { [mainnetId]: extAsset as HexString } : {} + const askCurvePoints = [ + { amount: "1", price: "10000" }, + { amount: "10000", price: "10000" }, + ] + const fxStrategy = new FXFiller( privateKey, chainConfigService, @@ -661,6 +666,7 @@ function createFxOnlyIntentFiller( askPricePolicy, "5000", exoticTokenAddresses, + askCurvePoints, ) const strategies = [fxStrategy] From 561ad9dc9c515b6b109c815cc7957612b09e0779 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Tue, 17 Mar 2026 16:09:08 +0100 Subject: [PATCH 14/28] remove service --- .../src/services/PriceUpdateService.ts | 93 ------------------- 1 file changed, 93 deletions(-) delete mode 100644 sdk/packages/simplex/src/services/PriceUpdateService.ts diff --git a/sdk/packages/simplex/src/services/PriceUpdateService.ts b/sdk/packages/simplex/src/services/PriceUpdateService.ts deleted file mode 100644 index 293a72a52..000000000 --- a/sdk/packages/simplex/src/services/PriceUpdateService.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { IntentsCoprocessor, type HexString, type PriceInput } from "@hyperbridge/sdk" -import { getLogger } from "./Logger" - -export interface PriceUpdateConfig { - /** How often to submit price updates, in seconds. Default: 300 (5 min) */ - intervalSeconds?: number - /** Token pairs and their price entries to submit */ - pairs: PriceUpdatePairEntry[] -} - -export interface PriceUpdatePairEntry { - /** The pair ID (H256 / bytes32) */ - pairId: HexString - /** Human-readable label for logging */ - label?: string - /** Price entries to submit */ - entries: PriceInput[] -} - -/** - * Periodically submits price updates to the intents coprocessor on Hyperbridge. - */ -export class PriceUpdateService { - private interval?: NodeJS.Timeout - private logger = getLogger("price-updates") - - constructor( - private hyperbridge: Promise, - private config: PriceUpdateConfig, - ) {} - - /** - * Start the periodic price update loop. - */ - start(): void { - const intervalMs = (this.config.intervalSeconds ?? 300) * 1000 - - // Run an initial submission after a short delay - setTimeout(() => { - this.submitAll().catch((err) => { - this.logger.error({ err }, "Error in initial price submission") - }) - }, 5_000) - - this.interval = setInterval(() => { - this.submitAll().catch((err) => { - this.logger.error({ err }, "Error in periodic price submission") - }) - }, intervalMs) - - this.logger.info( - { intervalSeconds: this.config.intervalSeconds ?? 300, pairCount: this.config.pairs.length }, - "Price update service started", - ) - } - - /** - * Stop the periodic price update loop. - */ - stop(): void { - if (this.interval) { - clearInterval(this.interval) - this.interval = undefined - this.logger.info("Price update service stopped") - } - } - - /** - * Submit prices for all configured pairs. - */ - async submitAll(): Promise { - const coprocessor = await this.hyperbridge - - for (const pair of this.config.pairs) { - try { - const result = await coprocessor.submitPairPrice(pair.pairId, pair.entries) - if (result.success) { - this.logger.info( - { pairId: pair.pairId, label: pair.label, blockHash: result.blockHash }, - "Price submitted successfully", - ) - } else { - this.logger.error( - { pairId: pair.pairId, label: pair.label, error: result.error }, - "Failed to submit price", - ) - } - } catch (err) { - this.logger.error({ pairId: pair.pairId, label: pair.label, err }, "Error submitting price") - } - } - } -} From ffc5a1e77aa24c5594a87dde27e627893f3402cb Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Tue, 17 Mar 2026 21:56:33 +0100 Subject: [PATCH 15/28] use price policy to get points --- sdk/packages/simplex/src/bin/simplex.ts | 1 - .../simplex/src/config/interpolated-curve.ts | 4 ++++ sdk/packages/simplex/src/strategies/fx.ts | 17 +++++------------ .../src/tests/strategies/fx.mainnet.test.ts | 7 ------- 4 files changed, 9 insertions(+), 20 deletions(-) diff --git a/sdk/packages/simplex/src/bin/simplex.ts b/sdk/packages/simplex/src/bin/simplex.ts index 60d05d300..c1550800d 100644 --- a/sdk/packages/simplex/src/bin/simplex.ts +++ b/sdk/packages/simplex/src/bin/simplex.ts @@ -410,7 +410,6 @@ program askPricePolicy, strategyConfig.maxOrderUsd, strategyConfig.exoticTokenAddresses, - strategyConfig.askPriceCurve, strategyConfig.pairId, fxConfirmationPolicy, ) diff --git a/sdk/packages/simplex/src/config/interpolated-curve.ts b/sdk/packages/simplex/src/config/interpolated-curve.ts index 73a00308f..6febd0660 100644 --- a/sdk/packages/simplex/src/config/interpolated-curve.ts +++ b/sdk/packages/simplex/src/config/interpolated-curve.ts @@ -176,6 +176,10 @@ export class FillerPricePolicy { } } + getPoints(): { amount: Decimal; price: Decimal }[] { + return this.points + } + getPrice(orderValueUsd: Decimal): Decimal { const amount = orderValueUsd diff --git a/sdk/packages/simplex/src/strategies/fx.ts b/sdk/packages/simplex/src/strategies/fx.ts index 467342bd1..04715e3e2 100644 --- a/sdk/packages/simplex/src/strategies/fx.ts +++ b/sdk/packages/simplex/src/strategies/fx.ts @@ -16,7 +16,7 @@ import { ChainClientManager, ContractInteractionService } from "@/services" import { FillerConfigService } from "@/services/FillerConfigService" import { formatUnits, parseUnits } from "viem" import { getLogger } from "@/services/Logger" -import { ConfirmationPolicy, FillerPricePolicy, type PriceCurvePoint } from "@/config/interpolated-curve" +import { ConfirmationPolicy, FillerPricePolicy } from "@/config/interpolated-curve" import { type CachedPairClassification } from "@/services/CacheService" import { Decimal } from "decimal.js" import { ERC20_ABI } from "@/config/abis/ERC20" @@ -67,8 +67,6 @@ export class FXFiller implements FillerStrategy { private logger = getLogger("fx-simplex") /** On-chain pair ID for price submission */ private pairId?: HexString - /** Raw ask curve points for building on-chain price entries */ - private askCurvePoints: PriceCurvePoint[] confirmationPolicy?: { getConfirmationBlocks: (chainId: number, amountUsd: number) => number } /** @@ -87,7 +85,6 @@ export class FXFiller implements FillerStrategy { * the filler will only size its outputs as if the order were $5,000. * @param exoticTokenAddresses Map of chain identifier → exotic token address. * Example: `{ "EVM-56": "0xabc..." }` for cNGN on BSC. - * @param askCurvePoints Raw ask curve points for on-chain price submission. * @param pairId On-chain pair ID (H256) for price submission. * @param confirmationPolicy Optional per-chain confirmation policy for cross-chain orders. * If absent, no confirmation waiting is required. @@ -101,7 +98,6 @@ export class FXFiller implements FillerStrategy { askPricePolicy: FillerPricePolicy, maxOrderUsdStr: string, exoticTokenAddresses: Record, - askCurvePoints: PriceCurvePoint[], pairId?: HexString, confirmationPolicy?: ConfirmationPolicy, ) { @@ -118,7 +114,6 @@ export class FXFiller implements FillerStrategy { } this.account = privateKeyToAccount(privateKey) this.pairId = pairId - this.askCurvePoints = askCurvePoints if (confirmationPolicy) { this.confirmationPolicy = { getConfirmationBlocks: (chainId: number, amountUsd: number) => @@ -165,9 +160,7 @@ export class FXFiller implements FillerStrategy { * The last point extends to a large upper bound. */ private buildPriceEntries(): PriceInput[] { - const points = [...this.askCurvePoints].sort( - (a, b) => parseFloat(a.amount) - parseFloat(b.amount), - ) + const points = this.askPricePolicy.getPoints() if (points.length === 0) return [] @@ -175,12 +168,12 @@ export class FXFiller implements FillerStrategy { const decimals = 18 for (let i = 0; i < points.length; i++) { - const rangeStart = parseUnits(points[i].amount, decimals) + const rangeStart = parseUnits(points[i].amount.toString(), decimals) const rangeEnd = i < points.length - 1 - ? parseUnits(points[i + 1].amount, decimals) - 1n + ? parseUnits(points[i + 1].amount.toString(), decimals) - 1n : parseUnits("999999999", decimals) // large upper bound for last bucket - const price = parseUnits(points[i].price, decimals) + const price = parseUnits(points[i].price.toString(), decimals) entries.push({ rangeStart, rangeEnd, price }) } diff --git a/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts b/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts index 53090b0a9..4df2a4d2a 100644 --- a/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts +++ b/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts @@ -875,7 +875,6 @@ function createCrossChainFxIntentFiller( askPricePolicy, "5000", exoticTokenAddresses, - [{ amount: "1", price: "9500" }, { amount: "10000", price: "9500" }], undefined, // pairId confirmationPolicy, ) @@ -925,11 +924,6 @@ function createFxOnlyIntentFiller( const extAsset = chainConfigService.getExtAsset(mainnetId) const exoticTokenAddresses: Record = extAsset ? { [mainnetId]: extAsset as HexString } : {} - const askCurvePoints = [ - { amount: "1", price: "10000" }, - { amount: "10000", price: "10000" }, - ] - const fxStrategy = new FXFiller( privateKey, chainConfigService, @@ -939,7 +933,6 @@ function createFxOnlyIntentFiller( askPricePolicy, "5000", exoticTokenAddresses, - askCurvePoints, ) const strategies = [fxStrategy] From f9cbd1d56b2f4fb309d58d71e5cbbb3b9797fb04 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Wed, 18 Mar 2026 13:25:24 +0100 Subject: [PATCH 16/28] clear stale prices per pair, compute pair ids automatically, submit ask and bid priced in the background --- .../pallets/intents-coprocessor/src/lib.rs | 31 +++-- .../pallets/intents-coprocessor/src/tests.rs | 29 +++-- .../pallets/intents-coprocessor/src/types.rs | 4 +- sdk/packages/simplex/src/bin/simplex.ts | 3 - sdk/packages/simplex/src/core/filler.ts | 6 +- .../services/ContractInteractionService.ts | 23 ++++ sdk/packages/simplex/src/strategies/fx.ts | 123 +++++++++++++----- .../src/tests/strategies/fx.mainnet.test.ts | 1 - 8 files changed, 159 insertions(+), 61 deletions(-) diff --git a/modules/pallets/intents-coprocessor/src/lib.rs b/modules/pallets/intents-coprocessor/src/lib.rs index 926d6297c..758636d55 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -24,7 +24,7 @@ mod tests; pub mod types; mod weights; -use alloc::vec::Vec; +use alloc::{collections::BTreeSet, vec::Vec}; use codec::Encode as _; use frame_support::{ ensure, @@ -145,7 +145,8 @@ pub mod pallet { /// Price entries per pair #[pallet::storage] - pub type Prices = StorageMap<_, Blake2_128Concat, H256, Vec, ValueQuery>; + pub type Prices = + StorageMap<_, Blake2_128Concat, H256, BTreeSet, ValueQuery>; /// Deposits reserved by price submitters. Maps (account, pair_id) to /// (deposit_amount, unlock_block). When `unlock_block` is `None`, the withdrawal @@ -173,11 +174,12 @@ pub mod pallet { #[pallet::storage] pub type PriceDepositLockDuration = StorageValue<_, BlockNumberFor, ValueQuery>; - /// Whether prices have been cleared in the current window. - /// Reset to false by `on_initialize` when a new window starts. - /// Set to true on the first price submission in the new window. + /// Whether prices have been cleared for a given pair in the current window. + /// All entries are removed by `on_initialize` when a new window starts. + /// Set to true on the first price submission for that pair in the new window. #[pallet::storage] - pub type PricesClearedThisWindow = StorageValue<_, bool, ValueQuery>; + pub type PricesClearedThisWindow = + StorageMap<_, Blake2_128Concat, H256, bool, ValueQuery>; #[pallet::hooks] impl Hooks> for Pallet @@ -197,7 +199,7 @@ pub mod pallet { if window_start == 0 || now.saturating_sub(window_start) >= window_duration_secs { PriceWindowStart::::put(now); - PricesClearedThisWindow::::put(false); + let _ = PricesClearedThisWindow::::clear(u32::MAX, None); T::DbWeight::get().reads(3).saturating_add(T::DbWeight::get().writes(2)) } else { @@ -625,7 +627,7 @@ pub mod pallet { }); } - Self::maybe_clear_stale_prices(); + Self::maybe_clear_stale_prices(&pair_id); Prices::::mutate(&pair_id, |stored| { stored.extend(entries.iter().map(|input| PriceEntry { @@ -797,14 +799,15 @@ pub mod pallet { offchain_bid_key_raw(commitment, &filler.encode()) } - /// Clear all prices if this is the first submission in a new window. + /// Clear prices for a specific pair if this is the first submission for that pair + /// in the current window. /// /// Prices from the previous window persist until the first new submission - /// in the new window, at which point all entries across all pairs are cleared. - fn maybe_clear_stale_prices() { - if !PricesClearedThisWindow::::get() { - let _ = Prices::::clear(u32::MAX, None); - PricesClearedThisWindow::::put(true); + /// for a given pair in the new window, at which point that pair's entries are cleared. + fn maybe_clear_stale_prices(pair_id: &H256) { + if !PricesClearedThisWindow::::get(pair_id) { + Prices::::remove(pair_id); + PricesClearedThisWindow::::insert(pair_id, true); } } diff --git a/modules/pallets/intents-coprocessor/src/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index 992c1eb5d..431fa7f4a 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -18,7 +18,7 @@ #![cfg(test)] use crate::{self as pallet_intents, *}; -use alloc::vec; +use alloc::{collections::BTreeSet, vec}; use codec::Decode; use frame_support::{ assert_noop, assert_ok, parameter_types, @@ -553,12 +553,12 @@ fn remove_recognized_pair_works() { Prices::::insert( &pair_id, - vec![types::PriceEntry { + BTreeSet::from([types::PriceEntry { range_start: U256::zero(), range_end: U256::from(999), price: U256::from(1000), timestamp: 1000, - }], + }]), ); assert_ok!(Intents::remove_recognized_pair(RuntimeOrigin::root(), pair_id)); @@ -609,7 +609,7 @@ fn submit_pair_price_reserves_deposit_on_first_submission() { // Price entry stored let prices = Prices::::get(&pair_id); assert_eq!(prices.len(), 1); - assert_eq!(prices[0].price, U256::from(2000)); + assert_eq!(prices.iter().next().unwrap().price, U256::from(2000)); }); } @@ -840,12 +840,12 @@ fn prices_persist_across_window_and_clear_on_first_submission() { // Simulate day 1: store some prices Prices::::insert( &pair_id, - vec![types::PriceEntry { + BTreeSet::from([types::PriceEntry { range_start: U256::zero(), range_end: U256::from(999), price: U256::from(1666), timestamp: 1000, - }], + }]), ); // Window started at second 1000 @@ -882,7 +882,7 @@ fn prices_persist_across_window_and_clear_on_first_submission() { // Old entries gone, only new entry remains let prices = Prices::::get(&pair_id); assert_eq!(prices.len(), 1); - assert_eq!(prices[0].price, U256::from(2000)); + assert_eq!(prices.iter().next().unwrap().price, U256::from(2000)); }); } @@ -935,7 +935,7 @@ fn price_entry_storage_roundtrip_via_raw_key() { timestamp: 2000, }; - Prices::::insert(&pair_id, vec![entry1.clone(), entry2.clone()]); + Prices::::insert(&pair_id, BTreeSet::from([entry1.clone(), entry2.clone()])); // Build the storage key the same way the RPC does. let pallet_prefix = b"Intents"; @@ -978,23 +978,30 @@ fn multiple_submitters_independent_deposits() { let deposit_amount = PriceDepositAmount::::get(); - let entries = BoundedVec::try_from(vec![PriceInput { + let entries1 = BoundedVec::try_from(vec![PriceInput { range_start: U256::zero(), range_end: U256::from(999), price: U256::from(2000), }]) .unwrap(); + let entries2 = BoundedVec::try_from(vec![PriceInput { + range_start: U256::from(1000), + range_end: U256::from(4999), + price: U256::from(2100), + }]) + .unwrap(); + // Both submitters submit prices assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter1.clone()), pair_id, - entries.clone(), + entries1, )); assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter2.clone()), pair_id, - entries, + entries2, )); // Each has their own deposit diff --git a/modules/pallets/intents-coprocessor/src/types.rs b/modules/pallets/intents-coprocessor/src/types.rs index e26b86948..0bdf4f196 100644 --- a/modules/pallets/intents-coprocessor/src/types.rs +++ b/modules/pallets/intents-coprocessor/src/types.rs @@ -177,7 +177,9 @@ pub struct PriceInput { /// An individual price submission stored on-chain. The price applies to a specific /// range of base token amounts, allowing submitters to quote different rates for /// different order sizes (e.g. USDC/CNGN: 0-999 at 1414, 1000-5000 at 1420). -#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] +#[derive( + Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq, PartialOrd, Ord, +)] pub struct PriceEntry { /// Lower bound of the base token amount range (inclusive), with 18 decimal places pub range_start: U256, diff --git a/sdk/packages/simplex/src/bin/simplex.ts b/sdk/packages/simplex/src/bin/simplex.ts index c1550800d..f308d069d 100644 --- a/sdk/packages/simplex/src/bin/simplex.ts +++ b/sdk/packages/simplex/src/bin/simplex.ts @@ -91,8 +91,6 @@ interface FxStrategyConfig { maxOrderUsd: string /** Map of chain identifier (e.g. "EVM-97") to exotic token contract address */ exoticTokenAddresses: Record - /** On-chain pair ID (H256) for price submission to the intents coprocessor */ - pairId?: HexString /** Optional per-chain confirmation policies for cross-chain orders */ confirmationPolicies?: Record } @@ -410,7 +408,6 @@ program askPricePolicy, strategyConfig.maxOrderUsd, strategyConfig.exoticTokenAddresses, - strategyConfig.pairId, fxConfirmationPolicy, ) } diff --git a/sdk/packages/simplex/src/core/filler.ts b/sdk/packages/simplex/src/core/filler.ts index 3fb73f602..10e2c5754 100644 --- a/sdk/packages/simplex/src/core/filler.ts +++ b/sdk/packages/simplex/src/core/filler.ts @@ -112,11 +112,13 @@ export class IntentFiller { } } - // Submit initial prices on FX strategies during initialization + // Submit initial prices on FX strategies in the background if (this.hyperbridge) { for (const strategy of this.strategies) { if (strategy instanceof FXFiller) { - await strategy.submitInitialPrices(this.hyperbridge) + strategy.submitInitialPrices(this.hyperbridge).catch((err) => { + this.logger.error({ err }, "Background price submission failed") + }) } } } diff --git a/sdk/packages/simplex/src/services/ContractInteractionService.ts b/sdk/packages/simplex/src/services/ContractInteractionService.ts index 36a72e221..a66332341 100644 --- a/sdk/packages/simplex/src/services/ContractInteractionService.ts +++ b/sdk/packages/simplex/src/services/ContractInteractionService.ts @@ -176,6 +176,29 @@ export class ContractInteractionService { } } + /** + * Reads the ERC20 symbol for a token on a specific chain. + * Handles both 20-byte and 32-byte (H256) address formats. + */ + async getTokenSymbol(tokenAddress: string, chain: string): Promise { + const bytes20Address = tokenAddress.length === 66 ? bytes32ToBytes20(tokenAddress) : tokenAddress + const client = this.clientManager.getPublicClient(chain) + + return await retryPromise( + () => + client.readContract({ + address: bytes20Address as HexString, + abi: ERC20_ABI, + functionName: "symbol", + }) as Promise, + { + maxRetries: 3, + backoffMs: 250, + logMessage: "Failed to get token symbol", + }, + ) + } + /** * Estimates gas for filling an order and caches the full estimate for bid preparation */ diff --git a/sdk/packages/simplex/src/strategies/fx.ts b/sdk/packages/simplex/src/strategies/fx.ts index 04715e3e2..f8a31d1e5 100644 --- a/sdk/packages/simplex/src/strategies/fx.ts +++ b/sdk/packages/simplex/src/strategies/fx.ts @@ -14,7 +14,7 @@ import { import { privateKeyToAccount } from "viem/accounts" import { ChainClientManager, ContractInteractionService } from "@/services" import { FillerConfigService } from "@/services/FillerConfigService" -import { formatUnits, parseUnits } from "viem" +import { formatUnits, parseUnits, keccak256, encodePacked, concat } from "viem" import { getLogger } from "@/services/Logger" import { ConfirmationPolicy, FillerPricePolicy } from "@/config/interpolated-curve" import { type CachedPairClassification } from "@/services/CacheService" @@ -65,8 +65,6 @@ export class FXFiller implements FillerStrategy { private maxOrderUsd: Decimal private account: ReturnType private logger = getLogger("fx-simplex") - /** On-chain pair ID for price submission */ - private pairId?: HexString confirmationPolicy?: { getConfirmationBlocks: (chainId: number, amountUsd: number) => number } /** @@ -85,7 +83,6 @@ export class FXFiller implements FillerStrategy { * the filler will only size its outputs as if the order were $5,000. * @param exoticTokenAddresses Map of chain identifier → exotic token address. * Example: `{ "EVM-56": "0xabc..." }` for cNGN on BSC. - * @param pairId On-chain pair ID (H256) for price submission. * @param confirmationPolicy Optional per-chain confirmation policy for cross-chain orders. * If absent, no confirmation waiting is required. */ @@ -98,7 +95,6 @@ export class FXFiller implements FillerStrategy { askPricePolicy: FillerPricePolicy, maxOrderUsdStr: string, exoticTokenAddresses: Record, - pairId?: HexString, confirmationPolicy?: ConfirmationPolicy, ) { this.privateKey = privateKey @@ -113,7 +109,6 @@ export class FXFiller implements FillerStrategy { throw new Error("FXFiller maxOrderUsd must be greater than 0") } this.account = privateKeyToAccount(privateKey) - this.pairId = pairId if (confirmationPolicy) { this.confirmationPolicy = { getConfirmationBlocks: (chainId: number, amountUsd: number) => @@ -123,45 +118,80 @@ export class FXFiller implements FillerStrategy { } /** - * Submit initial prices using the ask price curve. + * Compute pair ID from token symbols. + * Hashes each symbol to H256, then hashes the concatenation to match + * the pallet's TokenPair::pair_id() = keccak256(base_H256 || quote_H256). + * + * Governance registers pairs with base = keccak256(baseSymbol), + * quote = keccak256(quoteSymbol), producing the same pair_id. + */ + private computeSymbolPairId(baseSymbol: string, quoteSymbol: string): HexString { + const base = keccak256(encodePacked(["string"], [baseSymbol])) + const quote = keccak256(encodePacked(["string"], [quoteSymbol])) + return keccak256(concat([base as `0x${string}`, quote as `0x${string}`])) as HexString + } + + /** + * Submit initial prices for both ask and bid directions. * Called once during filler initialization to publish the strategy's * prices on-chain before the filler starts processing orders. + * + * - Ask pair (stable/exotic): submitted with askPricePolicy entries + * Ranges in stable amounts, price = exotic tokens per 1 stable + * - Bid pair (exotic/stable): submitted with bidPricePolicy entries + * Ranges in exotic amounts, price = stable tokens per 1 exotic */ async submitInitialPrices(coprocessor: Promise): Promise { - if (!this.pairId) { - this.logger.warn("No pairId configured, skipping price submission") - return - } + try { + const chain = Object.keys(this.exoticTokenAddresses)[0] + const exoticAddress = this.exoticTokenAddresses[chain] + const usdcAddress = this.configService.getUsdcAsset(chain) - const entries = this.buildPriceEntries() - if (entries.length === 0) { - this.logger.warn("No price entries derived from ask curve, skipping price submission") - return - } + const [stableSymbol, exoticSymbol] = await Promise.all([ + this.contractService.getTokenSymbol(usdcAddress, chain), + this.contractService.getTokenSymbol(exoticAddress, chain), + ]) - const pairId = this.pairId + this.logger.info({ stableSymbol, exoticSymbol }, "Resolved token symbols for price submission") - try { const cp = await coprocessor - const result = await cp.submitPairPrice(pairId, entries) - if (result.success) { - this.logger.info({ pairId, blockHash: result.blockHash, entryCount: entries.length }, "Initial prices submitted") - } else { - this.logger.error({ pairId, error: result.error }, "Failed to submit initial prices") + + // Ask pair + const askPairId = this.computeSymbolPairId(stableSymbol, exoticSymbol) + const askEntries = this.buildAskPriceEntries() + if (askEntries.length > 0) { + const askResult = await cp.submitPairPrice(askPairId, askEntries) + if (askResult.success) { + this.logger.info({ pairId: askPairId, direction: "ask", blockHash: askResult.blockHash, entryCount: askEntries.length }, "Ask prices submitted") + } else { + this.logger.error({ pairId: askPairId, direction: "ask", error: askResult.error }, "Failed to submit ask prices") + } + } + + // Bid pair + const bidPairId = this.computeSymbolPairId(exoticSymbol, stableSymbol) + const bidEntries = this.buildBidPriceEntries() + if (bidEntries.length > 0) { + const bidResult = await cp.submitPairPrice(bidPairId, bidEntries) + if (bidResult.success) { + this.logger.info({ pairId: bidPairId, direction: "bid", blockHash: bidResult.blockHash, entryCount: bidEntries.length }, "Bid prices submitted") + } else { + this.logger.error({ pairId: bidPairId, direction: "bid", error: bidResult.error }, "Failed to submit bid prices") + } } } catch (err) { - this.logger.error({ pairId, err }, "Error submitting initial prices") + this.logger.error({ err }, "Error submitting initial prices") } } /** * Convert the ask curve points into on-chain PriceInput entries. - * Each adjacent pair of points defines a price range. - * The last point extends to a large upper bound. + * For the ask pair (stable/exotic): + * - ranges are in stable (USD) amounts + * - price = exotic tokens per 1 stable */ - private buildPriceEntries(): PriceInput[] { + private buildAskPriceEntries(): PriceInput[] { const points = this.askPricePolicy.getPoints() - if (points.length === 0) return [] const entries: PriceInput[] = [] @@ -172,7 +202,7 @@ export class FXFiller implements FillerStrategy { const rangeEnd = i < points.length - 1 ? parseUnits(points[i + 1].amount.toString(), decimals) - 1n - : parseUnits("999999999", decimals) // large upper bound for last bucket + : parseUnits("999999999", decimals) const price = parseUnits(points[i].price.toString(), decimals) entries.push({ rangeStart, rangeEnd, price }) @@ -181,6 +211,41 @@ export class FXFiller implements FillerStrategy { return entries } + /** + * Convert the bid curve points into on-chain PriceInput entries. + * For the bid pair (exotic/stable): + * - ranges are in exotic token amounts (USD amount × exoticPerUsd) + * - price = stable tokens per 1 exotic (1 / exoticPerUsd) + */ + private buildBidPriceEntries(): PriceInput[] { + const points = this.bidPricePolicy.getPoints() + if (points.length === 0) return [] + + const entries: PriceInput[] = [] + const decimals = 18 + + for (let i = 0; i < points.length; i++) { + // Convert USD range to exotic range: exoticAmount = usdAmount × exoticPerUsd + const exoticAmount = points[i].amount.mul(points[i].price) + const rangeStart = parseUnits(exoticAmount.toFixed(0), decimals) + + const rangeEnd = + i < points.length - 1 + ? parseUnits( + points[i + 1].amount.mul(points[i + 1].price).toFixed(0), + decimals, + ) - 1n + : parseUnits("999999999", decimals) + + const stablePerExotic = new Decimal(1).div(points[i].price) + const price = parseUnits(stablePerExotic.toFixed(decimals), decimals) + + entries.push({ rangeStart, rangeEnd, price }) + } + + return entries + } + async canFill(order: Order): Promise { try { if (order.inputs.length !== order.output.assets.length) { diff --git a/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts b/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts index 4df2a4d2a..fc2811448 100644 --- a/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts +++ b/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts @@ -875,7 +875,6 @@ function createCrossChainFxIntentFiller( askPricePolicy, "5000", exoticTokenAddresses, - undefined, // pairId confirmationPolicy, ) From 6762e7843e666b72f567c33b54009b8cec66a2ff Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Wed, 18 Mar 2026 14:50:12 +0100 Subject: [PATCH 17/28] simplify pair IDs to keccak256("base/quote"), replace timestamp with filler address in price entries, make Prices a CountedStorageMap, governance now registers pair IDs directly, price submissions are chunked by on-chain MaxPriceEntries limit --- .../intents-coprocessor/rpc/src/lib.rs | 17 ++- .../pallets/intents-coprocessor/src/lib.rs | 29 +++-- .../pallets/intents-coprocessor/src/tests.rs | 108 ++++++++---------- .../pallets/intents-coprocessor/src/types.rs | 23 ++-- .../sdk/src/chains/intentsCoprocessor.ts | 4 + sdk/packages/simplex/src/core/filler.ts | 8 +- sdk/packages/simplex/src/strategies/fx.ts | 73 +++++++----- 7 files changed, 138 insertions(+), 124 deletions(-) diff --git a/modules/pallets/intents-coprocessor/rpc/src/lib.rs b/modules/pallets/intents-coprocessor/rpc/src/lib.rs index 58c519834..fc9135c6a 100644 --- a/modules/pallets/intents-coprocessor/rpc/src/lib.rs +++ b/modules/pallets/intents-coprocessor/rpc/src/lib.rs @@ -63,8 +63,8 @@ pub struct RpcPriceEntry { pub range_end: String, /// The price of the base token in the quote token pub price: String, - /// Timestamp of submission (seconds) - pub timestamp: u64, + /// The filler (submitter) address + pub filler: String, } impl Ord for RpcBidInfo { @@ -318,16 +318,21 @@ where }; // Decode Vec - // PriceEntry SCALE-encodes as (U256, U256, U256, u64) - type Entry = (primitive_types::U256, primitive_types::U256, primitive_types::U256, u64); + // PriceEntry SCALE-encodes as (U256, U256, U256, H256) + type Entry = ( + primitive_types::U256, + primitive_types::U256, + primitive_types::U256, + primitive_types::H256, + ); match Vec::::decode(&mut &data[..]) { Ok(entries) => Ok(entries .into_iter() - .map(|(range_start, range_end, price, timestamp)| RpcPriceEntry { + .map(|(range_start, range_end, price, filler)| RpcPriceEntry { range_start: format_u256_decimals(range_start, 18), range_end: format_u256_decimals(range_end, 18), price: format_u256_decimals(price, 18), - timestamp, + filler: format!("0x{}", hex::encode(filler.as_bytes())), }) .collect()), Err(_) => Ok(Vec::new()), diff --git a/modules/pallets/intents-coprocessor/src/lib.rs b/modules/pallets/intents-coprocessor/src/lib.rs index 758636d55..590fe2b5e 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -47,7 +47,7 @@ pub use weights::WeightInfo; use types::{ Bid, GatewayInfo, IntentGatewayParams, PriceEntry, PriceInput, RequestKind, - TokenDecimalsUpdate, TokenInfo, TokenPair, + TokenDecimalsUpdate, TokenInfo, }; // Re-export pallet items so that they can be accessed from the crate namespace. @@ -132,8 +132,7 @@ pub mod pallet { /// Recognized token pairs for price tracking #[pallet::storage] - pub type RecognizedPairs = - StorageMap<_, Blake2_128Concat, H256, TokenPair, OptionQuery>; + pub type RecognizedPairs = StorageMap<_, Blake2_128Concat, H256, bool, ValueQuery>; /// Start timestamp (in seconds) of the current price window #[pallet::storage] @@ -146,7 +145,7 @@ pub mod pallet { /// Price entries per pair #[pallet::storage] pub type Prices = - StorageMap<_, Blake2_128Concat, H256, BTreeSet, ValueQuery>; + CountedStorageMap<_, Blake2_128Concat, H256, BTreeSet, ValueQuery>; /// Deposits reserved by price submitters. Maps (account, pair_id) to /// (deposit_amount, unlock_block). When `unlock_block` is `None`, the withdrawal @@ -199,7 +198,7 @@ pub mod pallet { if window_start == 0 || now.saturating_sub(window_start) >= window_duration_secs { PriceWindowStart::::put(now); - let _ = PricesClearedThisWindow::::clear(u32::MAX, None); + let _ = PricesClearedThisWindow::::clear(Prices::::count(), None); T::DbWeight::get().reads(3).saturating_add(T::DbWeight::get().writes(2)) } else { @@ -237,7 +236,7 @@ pub mod pallet { /// Storage deposit fee was updated StorageDepositFeeUpdated { fee: BalanceOf }, /// A recognized token pair was added - RecognizedPairAdded { pair_id: H256, pair: TokenPair }, + RecognizedPairAdded { pair_id: H256 }, /// A recognized token pair was removed RecognizedPairRemoved { pair_id: H256 }, /// Prices were submitted for a token pair @@ -598,7 +597,7 @@ pub mod pallet { entries.iter().all(|e| e.range_start <= e.range_end), Error::::InvalidPriceRange ); - ensure!(RecognizedPairs::::contains_key(&pair_id), Error::::PairNotRecognized); + ensure!(RecognizedPairs::::get(&pair_id), Error::::PairNotRecognized); let deposit_amount = PriceDepositAmount::::get(); ensure!(!deposit_amount.is_zero(), Error::::PriceDepositsNotConfigured); @@ -607,8 +606,6 @@ pub mod pallet { return Err(Error::::WithdrawalInProgress.into()); } - let now = T::Dispatcher::default().timestamp().as_secs(); - // Reserve deposit on first submission per (account, pair) if !PriceDeposits::::contains_key(&submitter, &pair_id) { ::Currency::reserve(&submitter, deposit_amount) @@ -629,12 +626,13 @@ pub mod pallet { Self::maybe_clear_stale_prices(&pair_id); + let filler: H256 = H256::from_slice(&submitter.encode()[..32]); Prices::::mutate(&pair_id, |stored| { stored.extend(entries.iter().map(|input| PriceEntry { range_start: input.range_start, range_end: input.range_end, price: input.price, - timestamp: now, + filler, })); }); @@ -646,15 +644,14 @@ pub mod pallet { /// Add a recognized token pair for price tracking #[pallet::call_index(8)] #[pallet::weight(T::DbWeight::get().reads(1).saturating_add(T::DbWeight::get().writes(1)))] - pub fn add_recognized_pair(origin: OriginFor, pair: TokenPair) -> DispatchResult { + pub fn add_recognized_pair(origin: OriginFor, pair_id: H256) -> DispatchResult { T::GovernanceOrigin::ensure_origin(origin)?; - let pair_id = pair.pair_id(); - ensure!(!RecognizedPairs::::contains_key(&pair_id), Error::::PairAlreadyExists); + ensure!(!RecognizedPairs::::get(&pair_id), Error::::PairAlreadyExists); - RecognizedPairs::::insert(&pair_id, &pair); + RecognizedPairs::::insert(&pair_id, true); - Self::deposit_event(Event::RecognizedPairAdded { pair_id, pair }); + Self::deposit_event(Event::RecognizedPairAdded { pair_id }); Ok(()) } @@ -665,7 +662,7 @@ pub mod pallet { pub fn remove_recognized_pair(origin: OriginFor, pair_id: H256) -> DispatchResult { T::GovernanceOrigin::ensure_origin(origin)?; - ensure!(RecognizedPairs::::contains_key(&pair_id), Error::::PairNotRecognized); + ensure!(RecognizedPairs::::get(&pair_id), Error::::PairNotRecognized); RecognizedPairs::::remove(&pair_id); Prices::::remove(&pair_id); diff --git a/modules/pallets/intents-coprocessor/src/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index 431fa7f4a..4ffd20d99 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -545,11 +545,10 @@ fn multiple_fillers_can_bid_on_same_order() { #[test] fn remove_recognized_pair_works() { new_test_ext().execute_with(|| { - let pair = - types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; - let pair_id = pair.pair_id(); + let pair_id = + types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); Prices::::insert( &pair_id, @@ -557,13 +556,13 @@ fn remove_recognized_pair_works() { range_start: U256::zero(), range_end: U256::from(999), price: U256::from(1000), - timestamp: 1000, + filler: H256::from_low_u64_be(1), }]), ); assert_ok!(Intents::remove_recognized_pair(RuntimeOrigin::root(), pair_id)); - assert!(RecognizedPairs::::get(&pair_id).is_none()); + assert!(!RecognizedPairs::::get(&pair_id)); assert!(Prices::::get(&pair_id).is_empty()); }); } @@ -573,10 +572,9 @@ fn submit_pair_price_reserves_deposit_on_first_submission() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let pair = - types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; - let pair_id = pair.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + let pair_id = + types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); pallet_timestamp::Now::::put(2_000_000u64); @@ -618,10 +616,9 @@ fn submit_pair_price_second_submission_is_free() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let pair = - types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; - let pair_id = pair.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + let pair_id = + types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); pallet_timestamp::Now::::put(2_000_000u64); @@ -671,10 +668,9 @@ fn submit_pair_price_fails_with_insufficient_balance() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([4; 32]); // no balance - let pair = - types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; - let pair_id = pair.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + let pair_id = + types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); pallet_timestamp::Now::::put(2_000_000u64); @@ -697,10 +693,9 @@ fn withdraw_price_deposit_two_phase() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let pair = - types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; - let pair_id = pair.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + let pair_id = + types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); pallet_timestamp::Now::::put(2_000_000u64); @@ -763,10 +758,9 @@ fn withdraw_price_deposit_phase2_fails_when_still_locked() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let pair = - types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; - let pair_id = pair.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + let pair_id = + types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); pallet_timestamp::Now::::put(2_000_000u64); @@ -832,10 +826,9 @@ fn set_price_deposit_lock_duration_works() { fn prices_persist_across_window_and_clear_on_first_submission() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let pair = - types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; - let pair_id = pair.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + let pair_id = + types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); // Simulate day 1: store some prices Prices::::insert( @@ -844,7 +837,7 @@ fn prices_persist_across_window_and_clear_on_first_submission() { range_start: U256::zero(), range_end: U256::from(999), price: U256::from(1666), - timestamp: 1000, + filler: H256::from_low_u64_be(1), }]), ); @@ -888,23 +881,23 @@ fn prices_persist_across_window_and_clear_on_first_submission() { #[test] fn price_entry_encoding_matches_rpc_tuple_decoding() { - // The RPC decodes PriceEntry as Vec<(U256, U256, U256, u64)>. + // The RPC decodes PriceEntry as Vec<(U256, U256, U256, H256)>. // Verify that PriceEntry's SCALE encoding is identical to the tuple encoding. use codec::Encode; let range_start = U256::zero(); let range_end = U256::from(999); let price = U256::from(42_000); - let timestamp = 1_700_000_000u64; + let filler = H256::from_low_u64_be(1); - let entry = PriceEntry { range_start, range_end, price, timestamp }; + let entry = PriceEntry { range_start, range_end, price, filler }; let entry_bytes = entry.encode(); - let tuple_bytes = (range_start, range_end, price, timestamp).encode(); + let tuple_bytes = (range_start, range_end, price, filler).encode(); assert_eq!(entry_bytes, tuple_bytes, "PriceEntry SCALE encoding must match tuple encoding"); // Also verify round-trip: encode as PriceEntry, decode as tuple - type RpcTuple = (U256, U256, U256, u64); + type RpcTuple = (U256, U256, U256, H256); let entries = vec![entry]; let encoded = entries.encode(); let decoded: Vec = Decode::decode(&mut &encoded[..]).unwrap(); @@ -912,27 +905,26 @@ fn price_entry_encoding_matches_rpc_tuple_decoding() { assert_eq!(decoded[0].0, range_start); assert_eq!(decoded[0].1, range_end); assert_eq!(decoded[0].2, price); - assert_eq!(decoded[0].3, timestamp); + assert_eq!(decoded[0].3, filler); } #[test] fn price_entry_storage_roundtrip_via_raw_key() { new_test_ext().execute_with(|| { - let pair = - types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; - let pair_id = pair.pair_id(); + let pair_id = + types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); let entry1 = types::PriceEntry { range_start: U256::zero(), range_end: U256::from(999), price: U256::from(2000), - timestamp: 1000, + filler: H256::from_low_u64_be(1), }; let entry2 = types::PriceEntry { range_start: U256::from(1000), range_end: U256::from(5000), price: U256::from(3000), - timestamp: 2000, + filler: H256::from_low_u64_be(2), }; Prices::::insert(&pair_id, BTreeSet::from([entry1.clone(), entry2.clone()])); @@ -949,17 +941,17 @@ fn price_entry_storage_roundtrip_via_raw_key() { let raw = sp_io::storage::get(&key).expect("Prices storage should exist"); - type RpcTuple = (U256, U256, U256, u64); + type RpcTuple = (U256, U256, U256, H256); let decoded: Vec = Decode::decode(&mut &raw[..]).unwrap(); assert_eq!(decoded.len(), 2); assert_eq!(decoded[0].0, U256::zero()); assert_eq!(decoded[0].1, U256::from(999)); assert_eq!(decoded[0].2, U256::from(2000)); - assert_eq!(decoded[0].3, 1000u64); + assert_eq!(decoded[0].3, H256::from_low_u64_be(1)); assert_eq!(decoded[1].0, U256::from(1000)); assert_eq!(decoded[1].1, U256::from(5000)); assert_eq!(decoded[1].2, U256::from(3000)); - assert_eq!(decoded[1].3, 2000u64); + assert_eq!(decoded[1].3, H256::from_low_u64_be(2)); }); } @@ -969,10 +961,9 @@ fn multiple_submitters_independent_deposits() { let submitter1 = AccountId32::new([1; 32]); let submitter2 = AccountId32::new([2; 32]); - let pair = - types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; - let pair_id = pair.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + let pair_id = + types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); pallet_timestamp::Now::::put(2_000_000u64); @@ -1018,14 +1009,12 @@ fn separate_deposits_per_pair() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let pair1 = - types::TokenPair { base: H256::from_low_u64_be(1), quote: H256::from_low_u64_be(2) }; - let pair2 = - types::TokenPair { base: H256::from_low_u64_be(3), quote: H256::from_low_u64_be(4) }; - let pair_id1 = pair1.pair_id(); - let pair_id2 = pair2.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair1)); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair2)); + let pair_id1 = + types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); + let pair_id2 = + types::TokenPair { base: b"TOKEN_C".to_vec(), quote: b"TOKEN_D".to_vec() }.pair_id(); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id1)); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id2)); pallet_timestamp::Now::::put(2_000_000u64); @@ -1085,11 +1074,10 @@ fn submit_pair_price_blocked_after_withdrawal_initiated() { let submitter: AccountId = AccountId::from([1u8; 32]); let deposit_amount = 100u64; - let pair = - TokenPair { base: H256::from_low_u64_be(0xAAAA), quote: H256::from_low_u64_be(0xBBBB) }; + let pair = types::TokenPair { base: b"TOKEN_X".to_vec(), quote: b"TOKEN_Y".to_vec() }; let pair_id = pair.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair)); + assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); PriceDepositAmount::::put(deposit_amount); PriceDepositLockDuration::::put(10u64); diff --git a/modules/pallets/intents-coprocessor/src/types.rs b/modules/pallets/intents-coprocessor/src/types.rs index 0bdf4f196..140a76182 100644 --- a/modules/pallets/intents-coprocessor/src/types.rs +++ b/modules/pallets/intents-coprocessor/src/types.rs @@ -142,28 +142,27 @@ pub struct Bid { /// The signed user operation (opaque bytes) pub user_op: Vec, } - /// A recognized token pair for price tracking #[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] pub struct TokenPair { - /// The base token address - pub base: H256, - /// The quote token address - pub quote: H256, + /// The base token symbol (e.g. "USDC") + pub base: Vec, + /// The quote token symbol (e.g. "cNGN") + pub quote: Vec, } impl TokenPair { - /// Compute a unique identifier for this token pair + /// Compute a unique identifier: keccak256("base/quote") pub fn pair_id(&self) -> H256 { - let mut data = alloc::vec::Vec::with_capacity(64); - data.extend_from_slice(&self.base.0); - data.extend_from_slice(&self.quote.0); + let mut data = Vec::with_capacity(self.base.len() + 1 + self.quote.len()); + data.extend_from_slice(&self.base); + data.push(b'/'); + data.extend_from_slice(&self.quote); sp_io::hashing::keccak_256(&data).into() } } /// Caller-provided price data for a specific range of base token amounts. -/// The pallet adds a timestamp when storing. #[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] pub struct PriceInput { /// Lower bound of the base token amount range (inclusive), with 18 decimal places @@ -187,8 +186,8 @@ pub struct PriceEntry { pub range_end: U256, /// The price of the base token in the quote token, with 18 decimal places pub price: U256, - /// Timestamp of submission (seconds) - pub timestamp: u64, + /// The filler (submitter) address + pub filler: H256, } impl IntentGatewayParams { diff --git a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts index 2dd3f7aa2..8fddd54b0 100644 --- a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts +++ b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts @@ -338,6 +338,10 @@ export class IntentsCoprocessor { * @param entries - Array of price entries with range and price data * @returns BidSubmissionResult with success status and block/extrinsic hash */ + getMaxPriceEntries(): number { + return (this.api.consts.intentsCoprocessor as any).maxPriceEntries.toNumber() + } + async submitPairPrice(pairId: HexString, entries: PriceInput[]): Promise { try { // Encode entries as a Vec of (U256, U256, U256) tuples for the pallet diff --git a/sdk/packages/simplex/src/core/filler.ts b/sdk/packages/simplex/src/core/filler.ts index 10e2c5754..62a260a4b 100644 --- a/sdk/packages/simplex/src/core/filler.ts +++ b/sdk/packages/simplex/src/core/filler.ts @@ -112,13 +112,13 @@ export class IntentFiller { } } - // Submit initial prices on FX strategies in the background + // Submit initial prices on FX strategies if (this.hyperbridge) { for (const strategy of this.strategies) { if (strategy instanceof FXFiller) { - strategy.submitInitialPrices(this.hyperbridge).catch((err) => { - this.logger.error({ err }, "Background price submission failed") - }) + this.logger.info("Submitting initial prices for FX strategy") + await strategy.submitInitialPrices(this.hyperbridge) + this.logger.info("Initial price submission complete") } } } diff --git a/sdk/packages/simplex/src/strategies/fx.ts b/sdk/packages/simplex/src/strategies/fx.ts index f8a31d1e5..185dc2542 100644 --- a/sdk/packages/simplex/src/strategies/fx.ts +++ b/sdk/packages/simplex/src/strategies/fx.ts @@ -14,7 +14,7 @@ import { import { privateKeyToAccount } from "viem/accounts" import { ChainClientManager, ContractInteractionService } from "@/services" import { FillerConfigService } from "@/services/FillerConfigService" -import { formatUnits, parseUnits, keccak256, encodePacked, concat } from "viem" +import { formatUnits, parseUnits, keccak256, encodePacked } from "viem" import { getLogger } from "@/services/Logger" import { ConfirmationPolicy, FillerPricePolicy } from "@/config/interpolated-curve" import { type CachedPairClassification } from "@/services/CacheService" @@ -118,17 +118,10 @@ export class FXFiller implements FillerStrategy { } /** - * Compute pair ID from token symbols. - * Hashes each symbol to H256, then hashes the concatenation to match - * the pallet's TokenPair::pair_id() = keccak256(base_H256 || quote_H256). - * - * Governance registers pairs with base = keccak256(baseSymbol), - * quote = keccak256(quoteSymbol), producing the same pair_id. + * Compute pair ID from token symbols: keccak256("baseSymbol/quoteSymbol") */ private computeSymbolPairId(baseSymbol: string, quoteSymbol: string): HexString { - const base = keccak256(encodePacked(["string"], [baseSymbol])) - const quote = keccak256(encodePacked(["string"], [quoteSymbol])) - return keccak256(concat([base as `0x${string}`, quote as `0x${string}`])) as HexString + return keccak256(encodePacked(["string"], [`${baseSymbol}/${quoteSymbol}`])) as HexString } /** @@ -156,34 +149,62 @@ export class FXFiller implements FillerStrategy { const cp = await coprocessor + // Query the on-chain max price entries per extrinsic + const maxEntries = cp.getMaxPriceEntries() + this.logger.info({ maxEntries }, "On-chain MaxPriceEntries") + // Ask pair const askPairId = this.computeSymbolPairId(stableSymbol, exoticSymbol) const askEntries = this.buildAskPriceEntries() - if (askEntries.length > 0) { - const askResult = await cp.submitPairPrice(askPairId, askEntries) - if (askResult.success) { - this.logger.info({ pairId: askPairId, direction: "ask", blockHash: askResult.blockHash, entryCount: askEntries.length }, "Ask prices submitted") - } else { - this.logger.error({ pairId: askPairId, direction: "ask", error: askResult.error }, "Failed to submit ask prices") - } - } + await this.submitEntriesInChunks(cp, askPairId, askEntries, maxEntries, "ask") // Bid pair const bidPairId = this.computeSymbolPairId(exoticSymbol, stableSymbol) const bidEntries = this.buildBidPriceEntries() - if (bidEntries.length > 0) { - const bidResult = await cp.submitPairPrice(bidPairId, bidEntries) - if (bidResult.success) { - this.logger.info({ pairId: bidPairId, direction: "bid", blockHash: bidResult.blockHash, entryCount: bidEntries.length }, "Bid prices submitted") - } else { - this.logger.error({ pairId: bidPairId, direction: "bid", error: bidResult.error }, "Failed to submit bid prices") - } - } + await this.submitEntriesInChunks(cp, bidPairId, bidEntries, maxEntries, "bid") } catch (err) { this.logger.error({ err }, "Error submitting initial prices") } } + /** + * Submit price entries in chunks respecting the on-chain MaxPriceEntries limit. + */ + private async submitEntriesInChunks( + cp: IntentsCoprocessor, + pairId: HexString, + entries: PriceInput[], + maxEntries: number, + direction: string, + ): Promise { + if (entries.length === 0) return + + const chunks: PriceInput[][] = [] + for (let i = 0; i < entries.length; i += maxEntries) { + chunks.push(entries.slice(i, i + maxEntries)) + } + + this.logger.info( + { pairId, direction, totalEntries: entries.length, chunks: chunks.length, maxEntries }, + "Submitting price entries", + ) + + for (let i = 0; i < chunks.length; i++) { + const result = await cp.submitPairPrice(pairId, chunks[i]) + if (result.success) { + this.logger.info( + { pairId, direction, chunk: i + 1, of: chunks.length, blockHash: result.blockHash, entryCount: chunks[i].length }, + "Price chunk submitted", + ) + } else { + this.logger.error( + { pairId, direction, chunk: i + 1, of: chunks.length, error: result.error }, + "Failed to submit price chunk", + ) + } + } + } + /** * Convert the ask curve points into on-chain PriceInput entries. * For the ask pair (stable/exotic): From ef2513672f3edf0322b0f61b827d55d2d57516c9 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Wed, 18 Mar 2026 16:01:08 +0100 Subject: [PATCH 18/28] benchmarks, simplified price entries --- .../intents-coprocessor/rpc/src/lib.rs | 29 +- .../intents-coprocessor/src/benchmarking.rs | 144 +++++++ .../pallets/intents-coprocessor/src/lib.rs | 27 +- .../pallets/intents-coprocessor/src/tests.rs | 81 ++-- .../pallets/intents-coprocessor/src/types.rs | 22 +- .../intents-coprocessor/src/weights.rs | 87 +++++ .../src/weights/pallet_intents_coprocessor.rs | 166 ++++++-- .../src/weights/pallet_intents_coprocessor.rs | 353 ++++++++++++------ .../sdk/src/chains/intentsCoprocessor.ts | 5 +- sdk/packages/sdk/src/types/index.ts | 4 +- sdk/packages/simplex/src/strategies/fx.ts | 52 +-- 11 files changed, 676 insertions(+), 294 deletions(-) diff --git a/modules/pallets/intents-coprocessor/rpc/src/lib.rs b/modules/pallets/intents-coprocessor/rpc/src/lib.rs index fc9135c6a..553fddf3a 100644 --- a/modules/pallets/intents-coprocessor/rpc/src/lib.rs +++ b/modules/pallets/intents-coprocessor/rpc/src/lib.rs @@ -57,11 +57,9 @@ pub struct RpcBidInfo { /// Amounts and prices are human-readable (divided by 10^18 from on-chain storage). #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] pub struct RpcPriceEntry { - /// Lower bound of the base token amount range (inclusive) - pub range_start: String, - /// Upper bound of the base token amount range (inclusive) - pub range_end: String, - /// The price of the base token in the quote token + /// The amount threshold for this price point + pub amount: String, + /// The price at this amount pub price: String, /// The filler (submitter) address pub filler: String, @@ -317,22 +315,15 @@ where _ => return Ok(Vec::new()), }; - // Decode Vec - // PriceEntry SCALE-encodes as (U256, U256, U256, H256) - type Entry = ( - primitive_types::U256, - primitive_types::U256, - primitive_types::U256, - primitive_types::H256, - ); - match Vec::::decode(&mut &data[..]) { + use pallet_intents_coprocessor::types::PriceEntry; + + match BTreeSet::::decode(&mut &data[..]) { Ok(entries) => Ok(entries .into_iter() - .map(|(range_start, range_end, price, filler)| RpcPriceEntry { - range_start: format_u256_decimals(range_start, 18), - range_end: format_u256_decimals(range_end, 18), - price: format_u256_decimals(price, 18), - filler: format!("0x{}", hex::encode(filler.as_bytes())), + .map(|entry| RpcPriceEntry { + amount: format_u256_decimals(entry.amount, 18), + price: format_u256_decimals(entry.price, 18), + filler: format!("0x{}", hex::encode(entry.filler.as_bytes())), }) .collect()), Err(_) => Ok(Vec::new()), diff --git a/modules/pallets/intents-coprocessor/src/benchmarking.rs b/modules/pallets/intents-coprocessor/src/benchmarking.rs index 4c2ed8aac..b47ede4ac 100644 --- a/modules/pallets/intents-coprocessor/src/benchmarking.rs +++ b/modules/pallets/intents-coprocessor/src/benchmarking.rs @@ -28,6 +28,7 @@ use frame_system::RawOrigin; use ismp::host::StateMachine; use primitive_types::{H160, H256, U256}; use sp_runtime::traits::ConstU32; +use types::PriceInput; #[benchmarks( where @@ -35,6 +36,7 @@ use sp_runtime::traits::ConstU32; )] mod benchmarks { use super::*; + use frame_system::pallet_prelude::BlockNumberFor; #[benchmark] fn place_bid() { @@ -186,5 +188,147 @@ mod benchmarks { Ok(()) } + #[benchmark] + fn set_storage_deposit_fee() -> Result<(), BenchmarkError> { + let origin = + T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, 1000u32.into()); + + assert_eq!(StorageDepositFee::::get(), 1000u32.into()); + Ok(()) + } + + #[benchmark] + fn submit_pair_price() { + let caller: T::AccountId = whitelisted_caller(); + let pair_id = H256::repeat_byte(0xaa); + + // Use a large balance to cover existential deposit + price deposit on any runtime + let balance = BalanceOf::::from(u32::MAX); + ::Currency::make_free_balance_be(&caller, balance); + + let deposit_amount = ::Currency::minimum_balance(); + RecognizedPairs::::insert(&pair_id, true); + PriceDepositAmount::::put(deposit_amount); + PriceDepositLockDuration::::put(BlockNumberFor::::from(10u32)); + PriceWindowDurationValue::::put(86_400_000u64); + + let max = T::MaxPriceEntries::get(); + let mut entries_vec = vec![]; + for i in 0..max { + entries_vec + .push(PriceInput { amount: U256::from(i * 1000), price: U256::from(2000 + i) }); + } + let entries: BoundedVec = + entries_vec.try_into().expect("entries fit in bounds"); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), pair_id, entries); + + assert!(!Prices::::get(&pair_id).is_empty()); + } + + #[benchmark] + fn add_recognized_pair() -> Result<(), BenchmarkError> { + let origin = + T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; + let pair_id = H256::repeat_byte(0xbb); + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, pair_id); + + assert!(RecognizedPairs::::get(&pair_id)); + Ok(()) + } + + #[benchmark] + fn remove_recognized_pair() -> Result<(), BenchmarkError> { + let origin = + T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; + let pair_id = H256::repeat_byte(0xcc); + + RecognizedPairs::::insert(&pair_id, true); + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, pair_id); + + assert!(!RecognizedPairs::::get(&pair_id)); + Ok(()) + } + + #[benchmark] + fn set_price_window_duration() -> Result<(), BenchmarkError> { + let origin = + T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, 172_800_000u64); + + assert_eq!(PriceWindowDurationValue::::get(), 172_800_000u64); + Ok(()) + } + + #[benchmark] + fn set_price_deposit_amount() -> Result<(), BenchmarkError> { + let origin = + T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, 2000u32.into()); + + assert_eq!(PriceDepositAmount::::get(), 2000u32.into()); + Ok(()) + } + + #[benchmark] + fn set_price_deposit_lock_duration() -> Result<(), BenchmarkError> { + let origin = + T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, 100u32.into()); + + assert_eq!(PriceDepositLockDuration::::get(), 100u32.into()); + Ok(()) + } + + #[benchmark] + fn withdraw_price_deposit() { + let caller: T::AccountId = whitelisted_caller(); + let pair_id = H256::repeat_byte(0xdd); + + // Use a large balance to cover existential deposit + price deposit on any runtime + let balance = BalanceOf::::from(u32::MAX); + ::Currency::make_free_balance_be(&caller, balance); + + let deposit_amount = ::Currency::minimum_balance(); + RecognizedPairs::::insert(&pair_id, true); + PriceDepositAmount::::put(deposit_amount); + PriceDepositLockDuration::::put(BlockNumberFor::::from(10u32)); + PriceWindowDurationValue::::put(86_400_000u64); + + let entries: BoundedVec = + vec![PriceInput { amount: U256::zero(), price: U256::from(2000) }] + .try_into() + .expect("single entry fits"); + let _ = Pallet::::submit_pair_price( + RawOrigin::Signed(caller.clone()).into(), + pair_id, + entries, + ); + + let _ = + Pallet::::withdraw_price_deposit(RawOrigin::Signed(caller.clone()).into(), pair_id); + + frame_system::Pallet::::set_block_number(100u32.into()); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), pair_id); + + assert!(PriceDeposits::::get(&caller, &pair_id).is_none()); + } + 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 590fe2b5e..81f87512b 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -277,8 +277,6 @@ pub mod pallet { PairNotRecognized, /// Token pair already exists PairAlreadyExists, - /// The price range is invalid (range_start > range_end) - InvalidPriceRange, /// No price entries were provided EmptyPriceEntries, /// Price deposits are not configured (amount is zero) @@ -561,7 +559,7 @@ pub mod pallet { /// # Parameters /// - `fee`: The new storage deposit fee #[pallet::call_index(6)] - #[pallet::weight(T::DbWeight::get().writes(1))] + #[pallet::weight(T::WeightInfo::set_storage_deposit_fee())] pub fn set_storage_deposit_fee(origin: OriginFor, fee: BalanceOf) -> DispatchResult { T::GovernanceOrigin::ensure_origin(origin)?; @@ -582,9 +580,7 @@ pub mod pallet { /// Each entry in `entries` specifies a base token amount range and the /// corresponding price of the base token in terms of the quote token. #[pallet::call_index(7)] - #[pallet::weight({ - T::DbWeight::get().reads(12).saturating_add(T::DbWeight::get().writes(4)) - })] + #[pallet::weight(T::WeightInfo::submit_pair_price())] pub fn submit_pair_price( origin: OriginFor, pair_id: H256, @@ -593,10 +589,6 @@ pub mod pallet { let submitter = ensure_signed(origin)?; ensure!(!entries.is_empty(), Error::::EmptyPriceEntries); - ensure!( - entries.iter().all(|e| e.range_start <= e.range_end), - Error::::InvalidPriceRange - ); ensure!(RecognizedPairs::::get(&pair_id), Error::::PairNotRecognized); let deposit_amount = PriceDepositAmount::::get(); @@ -629,8 +621,7 @@ pub mod pallet { let filler: H256 = H256::from_slice(&submitter.encode()[..32]); Prices::::mutate(&pair_id, |stored| { stored.extend(entries.iter().map(|input| PriceEntry { - range_start: input.range_start, - range_end: input.range_end, + amount: input.amount, price: input.price, filler, })); @@ -643,7 +634,7 @@ pub mod pallet { /// Add a recognized token pair for price tracking #[pallet::call_index(8)] - #[pallet::weight(T::DbWeight::get().reads(1).saturating_add(T::DbWeight::get().writes(1)))] + #[pallet::weight(T::WeightInfo::add_recognized_pair())] pub fn add_recognized_pair(origin: OriginFor, pair_id: H256) -> DispatchResult { T::GovernanceOrigin::ensure_origin(origin)?; @@ -658,7 +649,7 @@ pub mod pallet { /// Remove a recognized token pair #[pallet::call_index(9)] - #[pallet::weight(T::DbWeight::get().reads(1).saturating_add(T::DbWeight::get().writes(2)))] + #[pallet::weight(T::WeightInfo::remove_recognized_pair())] pub fn remove_recognized_pair(origin: OriginFor, pair_id: H256) -> DispatchResult { T::GovernanceOrigin::ensure_origin(origin)?; @@ -674,7 +665,7 @@ pub mod pallet { /// Set the price window duration #[pallet::call_index(10)] - #[pallet::weight(T::DbWeight::get().writes(1))] + #[pallet::weight(T::WeightInfo::set_price_window_duration())] pub fn set_price_window_duration(origin: OriginFor, duration_ms: u64) -> DispatchResult { T::GovernanceOrigin::ensure_origin(origin)?; @@ -687,7 +678,7 @@ pub mod pallet { /// Set the deposit amount required for price submissions #[pallet::call_index(11)] - #[pallet::weight(T::DbWeight::get().writes(1))] + #[pallet::weight(T::WeightInfo::set_price_deposit_amount())] pub fn set_price_deposit_amount( origin: OriginFor, amount: BalanceOf, @@ -703,7 +694,7 @@ pub mod pallet { /// Set the lock duration (in blocks) for price deposits #[pallet::call_index(12)] - #[pallet::weight(T::DbWeight::get().writes(1))] + #[pallet::weight(T::WeightInfo::set_price_deposit_lock_duration())] pub fn set_price_deposit_lock_duration( origin: OriginFor, duration_blocks: BlockNumberFor, @@ -733,7 +724,7 @@ pub mod pallet { /// - `WithdrawalAlreadyInitiated`: First call was already made (waiting for unlock) /// - `DepositStillLocked`: The unlock block has not yet been reached #[pallet::call_index(13)] - #[pallet::weight(T::DbWeight::get().reads(3).saturating_add(T::DbWeight::get().writes(1)))] + #[pallet::weight(T::WeightInfo::withdraw_price_deposit())] pub fn withdraw_price_deposit(origin: OriginFor, pair_id: H256) -> DispatchResult { let who = ensure_signed(origin)?; diff --git a/modules/pallets/intents-coprocessor/src/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index 4ffd20d99..e83f60d1f 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -553,8 +553,7 @@ fn remove_recognized_pair_works() { Prices::::insert( &pair_id, BTreeSet::from([types::PriceEntry { - range_start: U256::zero(), - range_end: U256::from(999), + amount: U256::zero(), price: U256::from(1000), filler: H256::from_low_u64_be(1), }]), @@ -582,8 +581,7 @@ fn submit_pair_price_reserves_deposit_on_first_submission() { let deposit_amount = PriceDepositAmount::::get(); let entries = BoundedVec::try_from(vec![PriceInput { - range_start: U256::zero(), - range_end: U256::from(999), + amount: U256::zero(), price: U256::from(2000), }]) .unwrap(); @@ -623,8 +621,7 @@ fn submit_pair_price_second_submission_is_free() { pallet_timestamp::Now::::put(2_000_000u64); let entries1 = BoundedVec::try_from(vec![PriceInput { - range_start: U256::zero(), - range_end: U256::from(999), + amount: U256::zero(), price: U256::from(2000), }]) .unwrap(); @@ -640,8 +637,7 @@ fn submit_pair_price_second_submission_is_free() { // Second submission — no additional deposit let entries2 = BoundedVec::try_from(vec![PriceInput { - range_start: U256::from(1000), - range_end: U256::from(5000), + amount: U256::from(1000), price: U256::from(3000), }]) .unwrap(); @@ -675,8 +671,7 @@ fn submit_pair_price_fails_with_insufficient_balance() { pallet_timestamp::Now::::put(2_000_000u64); let entries = BoundedVec::try_from(vec![PriceInput { - range_start: U256::zero(), - range_end: U256::from(999), + amount: U256::zero(), price: U256::from(2000), }]) .unwrap(); @@ -700,8 +695,7 @@ fn withdraw_price_deposit_two_phase() { pallet_timestamp::Now::::put(2_000_000u64); let entries = BoundedVec::try_from(vec![PriceInput { - range_start: U256::zero(), - range_end: U256::from(999), + amount: U256::zero(), price: U256::from(2000), }]) .unwrap(); @@ -765,8 +759,7 @@ fn withdraw_price_deposit_phase2_fails_when_still_locked() { pallet_timestamp::Now::::put(2_000_000u64); let entries = BoundedVec::try_from(vec![PriceInput { - range_start: U256::zero(), - range_end: U256::from(999), + amount: U256::zero(), price: U256::from(2000), }]) .unwrap(); @@ -834,8 +827,7 @@ fn prices_persist_across_window_and_clear_on_first_submission() { Prices::::insert( &pair_id, BTreeSet::from([types::PriceEntry { - range_start: U256::zero(), - range_end: U256::from(999), + amount: U256::zero(), price: U256::from(1666), filler: H256::from_low_u64_be(1), }]), @@ -861,8 +853,7 @@ fn prices_persist_across_window_and_clear_on_first_submission() { // Submit a new price — this is the first submission in the new window. // It should clear stale entries before adding the new one. let new_entries = BoundedVec::try_from(vec![PriceInput { - range_start: U256::zero(), - range_end: U256::from(999), + amount: U256::zero(), price: U256::from(2000), }]) .unwrap(); @@ -881,31 +872,29 @@ fn prices_persist_across_window_and_clear_on_first_submission() { #[test] fn price_entry_encoding_matches_rpc_tuple_decoding() { - // The RPC decodes PriceEntry as Vec<(U256, U256, U256, H256)>. + // The RPC decodes PriceEntry as BTreeSet. // Verify that PriceEntry's SCALE encoding is identical to the tuple encoding. use codec::Encode; - let range_start = U256::zero(); - let range_end = U256::from(999); + let amount = U256::zero(); let price = U256::from(42_000); let filler = H256::from_low_u64_be(1); - let entry = PriceEntry { range_start, range_end, price, filler }; + let entry = PriceEntry { amount, price, filler }; let entry_bytes = entry.encode(); - let tuple_bytes = (range_start, range_end, price, filler).encode(); + let tuple_bytes = (amount, price, filler).encode(); assert_eq!(entry_bytes, tuple_bytes, "PriceEntry SCALE encoding must match tuple encoding"); // Also verify round-trip: encode as PriceEntry, decode as tuple - type RpcTuple = (U256, U256, U256, H256); + type RpcTuple = (U256, U256, H256); let entries = vec![entry]; let encoded = entries.encode(); let decoded: Vec = Decode::decode(&mut &encoded[..]).unwrap(); assert_eq!(decoded.len(), 1); - assert_eq!(decoded[0].0, range_start); - assert_eq!(decoded[0].1, range_end); - assert_eq!(decoded[0].2, price); - assert_eq!(decoded[0].3, filler); + assert_eq!(decoded[0].0, amount); + assert_eq!(decoded[0].1, price); + assert_eq!(decoded[0].2, filler); } #[test] @@ -915,14 +904,12 @@ fn price_entry_storage_roundtrip_via_raw_key() { types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); let entry1 = types::PriceEntry { - range_start: U256::zero(), - range_end: U256::from(999), + amount: U256::zero(), price: U256::from(2000), filler: H256::from_low_u64_be(1), }; let entry2 = types::PriceEntry { - range_start: U256::from(1000), - range_end: U256::from(5000), + amount: U256::from(1000), price: U256::from(3000), filler: H256::from_low_u64_be(2), }; @@ -941,17 +928,15 @@ fn price_entry_storage_roundtrip_via_raw_key() { let raw = sp_io::storage::get(&key).expect("Prices storage should exist"); - type RpcTuple = (U256, U256, U256, H256); + type RpcTuple = (U256, U256, H256); let decoded: Vec = Decode::decode(&mut &raw[..]).unwrap(); assert_eq!(decoded.len(), 2); assert_eq!(decoded[0].0, U256::zero()); - assert_eq!(decoded[0].1, U256::from(999)); - assert_eq!(decoded[0].2, U256::from(2000)); - assert_eq!(decoded[0].3, H256::from_low_u64_be(1)); + assert_eq!(decoded[0].1, U256::from(2000)); + assert_eq!(decoded[0].2, H256::from_low_u64_be(1)); assert_eq!(decoded[1].0, U256::from(1000)); - assert_eq!(decoded[1].1, U256::from(5000)); - assert_eq!(decoded[1].2, U256::from(3000)); - assert_eq!(decoded[1].3, H256::from_low_u64_be(2)); + assert_eq!(decoded[1].1, U256::from(3000)); + assert_eq!(decoded[1].2, H256::from_low_u64_be(2)); }); } @@ -970,15 +955,13 @@ fn multiple_submitters_independent_deposits() { let deposit_amount = PriceDepositAmount::::get(); let entries1 = BoundedVec::try_from(vec![PriceInput { - range_start: U256::zero(), - range_end: U256::from(999), + amount: U256::zero(), price: U256::from(2000), }]) .unwrap(); let entries2 = BoundedVec::try_from(vec![PriceInput { - range_start: U256::from(1000), - range_end: U256::from(4999), + amount: U256::from(1000), price: U256::from(2100), }]) .unwrap(); @@ -1021,8 +1004,7 @@ fn separate_deposits_per_pair() { let deposit_amount = PriceDepositAmount::::get(); let entries = BoundedVec::try_from(vec![PriceInput { - range_start: U256::zero(), - range_end: U256::from(999), + amount: U256::zero(), price: U256::from(2000), }]) .unwrap(); @@ -1081,12 +1063,9 @@ fn submit_pair_price_blocked_after_withdrawal_initiated() { PriceDepositAmount::::put(deposit_amount); PriceDepositLockDuration::::put(10u64); - let entries = BoundedVec::try_from(vec![PriceInput { - range_start: U256::zero(), - range_end: U256::from(1000), - price: U256::from(42), - }]) - .unwrap(); + let entries = + BoundedVec::try_from(vec![PriceInput { amount: U256::zero(), price: U256::from(42) }]) + .unwrap(); // Submit prices(reserves deposit) assert_ok!(Intents::submit_pair_price( diff --git a/modules/pallets/intents-coprocessor/src/types.rs b/modules/pallets/intents-coprocessor/src/types.rs index 140a76182..67c8b9940 100644 --- a/modules/pallets/intents-coprocessor/src/types.rs +++ b/modules/pallets/intents-coprocessor/src/types.rs @@ -162,29 +162,23 @@ impl TokenPair { } } -/// Caller-provided price data for a specific range of base token amounts. +/// Caller-provided price point for a token pair. #[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] pub struct PriceInput { - /// Lower bound of the base token amount range (inclusive), with 18 decimal places - pub range_start: U256, - /// Upper bound of the base token amount range (inclusive), with 18 decimal places - pub range_end: U256, - /// The price of the base token in the quote token, with 18 decimal places + /// The amount threshold for this price point, with 18 decimal places + pub amount: U256, + /// The price at this amount, with 18 decimal places pub price: U256, } -/// An individual price submission stored on-chain. The price applies to a specific -/// range of base token amounts, allowing submitters to quote different rates for -/// different order sizes (e.g. USDC/CNGN: 0-999 at 1414, 1000-5000 at 1420). +/// A price point stored on-chain. The frontend determines ranges from the curve points. #[derive( Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq, PartialOrd, Ord, )] pub struct PriceEntry { - /// Lower bound of the base token amount range (inclusive), with 18 decimal places - pub range_start: U256, - /// Upper bound of the base token amount range (inclusive), with 18 decimal places - pub range_end: U256, - /// The price of the base token in the quote token, with 18 decimal places + /// The amount threshold for this price point, with 18 decimal places + pub amount: U256, + /// The price at this amount, with 18 decimal places pub price: U256, /// The filler (submitter) address pub filler: H256, diff --git a/modules/pallets/intents-coprocessor/src/weights.rs b/modules/pallets/intents-coprocessor/src/weights.rs index 8f6a70140..47bd380ae 100644 --- a/modules/pallets/intents-coprocessor/src/weights.rs +++ b/modules/pallets/intents-coprocessor/src/weights.rs @@ -41,6 +41,14 @@ pub trait WeightInfo { fn update_params() -> Weight; fn sweep_dust() -> Weight; fn update_token_decimals() -> Weight; + fn set_storage_deposit_fee() -> Weight; + fn submit_pair_price() -> Weight; + fn add_recognized_pair() -> Weight; + fn remove_recognized_pair() -> Weight; + fn set_price_window_duration() -> Weight; + fn set_price_deposit_amount() -> Weight; + fn set_price_deposit_lock_duration() -> Weight; + fn withdraw_price_deposit() -> Weight; } /// Weights for pallet_intents using the Substrate node and recommended hardware. @@ -105,6 +113,61 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) } + + /// Storage: StorageDepositFee (r:0 w:1) + fn set_storage_deposit_fee() -> Weight { + Weight::from_parts(10_000_000, 0) + .saturating_add(T::DbWeight::get().writes(1)) + } + + /// Storage: RecognizedPairs (r:1 w:0), PriceDepositAmount (r:1 w:0), + /// PriceDeposits (r:1 w:1), PricesClearedThisWindow (r:1 w:1), Prices (r:1 w:1) + fn submit_pair_price() -> Weight { + Weight::from_parts(100_000_000, 0) + .saturating_add(Weight::from_parts(0, 5000)) + .saturating_add(T::DbWeight::get().reads(5)) + .saturating_add(T::DbWeight::get().writes(4)) + } + + /// Storage: RecognizedPairs (r:1 w:1) + fn add_recognized_pair() -> Weight { + Weight::from_parts(15_000_000, 0) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } + + /// Storage: RecognizedPairs (r:1 w:1), Prices (r:0 w:1) + fn remove_recognized_pair() -> Weight { + Weight::from_parts(20_000_000, 0) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(2)) + } + + /// Storage: PriceWindowDurationValue (r:0 w:1) + fn set_price_window_duration() -> Weight { + Weight::from_parts(10_000_000, 0) + .saturating_add(T::DbWeight::get().writes(1)) + } + + /// Storage: PriceDepositAmount (r:0 w:1) + fn set_price_deposit_amount() -> Weight { + Weight::from_parts(10_000_000, 0) + .saturating_add(T::DbWeight::get().writes(1)) + } + + /// Storage: PriceDepositLockDuration (r:0 w:1) + fn set_price_deposit_lock_duration() -> Weight { + Weight::from_parts(10_000_000, 0) + .saturating_add(T::DbWeight::get().writes(1)) + } + + /// Storage: PriceDeposits (r:1 w:1), PriceDepositLockDuration (r:1 w:0) + fn withdraw_price_deposit() -> Weight { + Weight::from_parts(45_000_000, 0) + .saturating_add(Weight::from_parts(0, 3500)) + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().writes(1)) + } } // For backwards compatibility and tests @@ -127,4 +190,28 @@ impl WeightInfo for () { fn update_token_decimals() -> Weight { Weight::from_parts(75_000_000, 0) } + fn set_storage_deposit_fee() -> Weight { + Weight::from_parts(10_000_000, 0) + } + fn submit_pair_price() -> Weight { + Weight::from_parts(100_000_000, 0) + } + fn add_recognized_pair() -> Weight { + Weight::from_parts(15_000_000, 0) + } + fn remove_recognized_pair() -> Weight { + Weight::from_parts(20_000_000, 0) + } + fn set_price_window_duration() -> Weight { + Weight::from_parts(10_000_000, 0) + } + fn set_price_deposit_amount() -> Weight { + Weight::from_parts(10_000_000, 0) + } + fn set_price_deposit_lock_duration() -> Weight { + Weight::from_parts(10_000_000, 0) + } + fn withdraw_price_deposit() -> Weight { + Weight::from_parts(45_000_000, 0) + } } diff --git a/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs b/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs index 7cbdf69d9..3083da06d 100644 --- a/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs +++ b/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs @@ -16,8 +16,8 @@ //! 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-03-18, 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 @@ -30,12 +30,15 @@ // --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/gargantua-runtime/gargantua_runtime.compact.wasm -// --output=parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs +// --runtime=./target/release/wbuild/gargantua-runtime/gargantua_runtime.compact.compressed.wasm +// --output +// parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -51,37 +54,40 @@ 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`) + /// 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: 39_204_000 picoseconds. + Weight::from_parts(40_847_000, 0) .saturating_add(Weight::from_parts(0, 3574)) - .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().reads(2)) .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: 38_373_000 picoseconds. + Weight::from_parts(38_974_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: 22_022_000 picoseconds. + Weight::from_parts(22_833_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,14 +102,14 @@ 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 `0x52657175657374436f6d6d69746d656e74731fe78f389a61a92311a0a767b480` (r:1 w:1) + /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e74731fe78f389a61a92311a0a767b480` (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) + // Minimum execution time: 73_429_000 picoseconds. + Weight::from_parts(74_331_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(5)) @@ -120,14 +126,14 @@ 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 `0x52657175657374436f6d6d69746d656e7473e7e2297e9ec6ac3afe12f8f523ba` (r:1 w:1) + /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473e7e2297e9ec6ac3afe12f8f523ba` (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) + // Minimum execution time: 64_051_000 picoseconds. + Weight::from_parts(65_424_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -150,10 +156,114 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 68_621_000 picoseconds. - Weight::from_parts(69_752_000, 0) + // Minimum execution time: 68_550_000 picoseconds. + Weight::from_parts(69_231_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) } + /// Storage: `IntentsCoprocessor::StorageDepositFee` (r:0 w:1) + /// Proof: `IntentsCoprocessor::StorageDepositFee` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_storage_deposit_fee() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 8_165_000 picoseconds. + Weight::from_parts(8_526_000, 0) + .saturating_add(Weight::from_parts(0, 0)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::RecognizedPairs` (r:1 w:0) + /// Proof: `IntentsCoprocessor::RecognizedPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::PriceDepositAmount` (r:1 w:0) + /// Proof: `IntentsCoprocessor::PriceDepositAmount` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::PriceDeposits` (r:1 w:1) + /// Proof: `IntentsCoprocessor::PriceDeposits` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::PricesClearedThisWindow` (r:1 w:1) + /// Proof: `IntentsCoprocessor::PricesClearedThisWindow` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::Prices` (r:1 w:1) + /// Proof: `IntentsCoprocessor::Prices` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::CounterForPrices` (r:1 w:1) + /// Proof: `IntentsCoprocessor::CounterForPrices` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + fn submit_pair_price() -> Weight { + // Proof Size summary in bytes: + // Measured: `271` + // Estimated: `3736` + // Minimum execution time: 71_406_000 picoseconds. + Weight::from_parts(72_077_000, 0) + .saturating_add(Weight::from_parts(0, 3736)) + .saturating_add(T::DbWeight::get().reads(6)) + .saturating_add(T::DbWeight::get().writes(4)) + } + /// Storage: `IntentsCoprocessor::RecognizedPairs` (r:1 w:1) + /// Proof: `IntentsCoprocessor::RecognizedPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn add_recognized_pair() -> Weight { + // Proof Size summary in bytes: + // Measured: `109` + // Estimated: `3574` + // Minimum execution time: 15_891_000 picoseconds. + Weight::from_parts(16_141_000, 0) + .saturating_add(Weight::from_parts(0, 3574)) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::RecognizedPairs` (r:1 w:1) + /// Proof: `IntentsCoprocessor::RecognizedPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::Prices` (r:1 w:1) + /// Proof: `IntentsCoprocessor::Prices` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn remove_recognized_pair() -> Weight { + // Proof Size summary in bytes: + // Measured: `184` + // Estimated: `3649` + // Minimum execution time: 22_232_000 picoseconds. + Weight::from_parts(22_973_000, 0) + .saturating_add(Weight::from_parts(0, 3649)) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(2)) + } + /// Storage: `IntentsCoprocessor::PriceWindowDurationValue` (r:0 w:1) + /// Proof: `IntentsCoprocessor::PriceWindowDurationValue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_price_window_duration() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 8_476_000 picoseconds. + Weight::from_parts(8_777_000, 0) + .saturating_add(Weight::from_parts(0, 0)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::PriceDepositAmount` (r:0 w:1) + /// Proof: `IntentsCoprocessor::PriceDepositAmount` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_price_deposit_amount() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 8_546_000 picoseconds. + Weight::from_parts(8_756_000, 0) + .saturating_add(Weight::from_parts(0, 0)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::PriceDepositLockDuration` (r:0 w:1) + /// Proof: `IntentsCoprocessor::PriceDepositLockDuration` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_price_deposit_lock_duration() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 8_476_000 picoseconds. + Weight::from_parts(8_727_000, 0) + .saturating_add(Weight::from_parts(0, 0)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::PriceDeposits` (r:1 w:1) + /// Proof: `IntentsCoprocessor::PriceDeposits` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn withdraw_price_deposit() -> Weight { + // Proof Size summary in bytes: + // Measured: `438` + // Estimated: `3903` + // Minimum execution time: 39_775_000 picoseconds. + Weight::from_parts(40_557_000, 0) + .saturating_add(Weight::from_parts(0, 3903)) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } } diff --git a/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs b/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs index 9fb77b5ce..4b8216e55 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,253 @@ //! 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-03-18, 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.compressed.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::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: `175` + // Estimated: `3640` + // Minimum execution time: 39_455_000 picoseconds. + Weight::from_parts(40_226_000, 0) + .saturating_add(Weight::from_parts(0, 3640)) + .saturating_add(T::DbWeight::get().reads(2)) + .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: `313` + // Estimated: `3778` + // Minimum execution time: 38_914_000 picoseconds. + Weight::from_parts(39_575_000, 0) + .saturating_add(Weight::from_parts(0, 3778)) + .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: `179` + // Estimated: `6119` + // Minimum execution time: 22_423_000 picoseconds. + Weight::from_parts(22_994_000, 0) + .saturating_add(Weight::from_parts(0, 6119)) + .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 `0x52657175657374436f6d6d69746d656e7473d9b390ce89b7c0a1578362e9ae10` (r:1 w:1) + /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473d9b390ce89b7c0a1578362e9ae10` (r:1 w:1) + fn update_params() -> Weight { + // Proof Size summary in bytes: + // Measured: `899` + // Estimated: `4364` + // Minimum execution time: 66_446_000 picoseconds. + Weight::from_parts(75_293_000, 0) + .saturating_add(Weight::from_parts(0, 4364)) + .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 `0x52657175657374436f6d6d69746d656e7473db067606a43270bed7818da1972f` (r:1 w:1) + /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473db067606a43270bed7818da1972f` (r:1 w:1) + fn sweep_dust() -> Weight { + // Proof Size summary in bytes: + // Measured: `899` + // Estimated: `4364` + // Minimum execution time: 57_810_000 picoseconds. + Weight::from_parts(66_326_000, 0) + .saturating_add(Weight::from_parts(0, 4364)) + .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 `0x52657175657374436f6d6d69746d656e7473ea75191a82691c4c9d91addf8d05` (r:1 w:1) + /// Proof: UNKNOWN KEY `0x52657175657374436f6d6d69746d656e7473ea75191a82691c4c9d91addf8d05` (r:1 w:1) + fn update_token_decimals() -> Weight { + // Proof Size summary in bytes: + // Measured: `899` + // Estimated: `4364` + // Minimum execution time: 60_866_000 picoseconds. + Weight::from_parts(61_857_000, 0) + .saturating_add(Weight::from_parts(0, 4364)) + .saturating_add(T::DbWeight::get().reads(7)) + .saturating_add(T::DbWeight::get().writes(4)) + } + /// Storage: `IntentsCoprocessor::StorageDepositFee` (r:0 w:1) + /// Proof: `IntentsCoprocessor::StorageDepositFee` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_storage_deposit_fee() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 7_444_000 picoseconds. + Weight::from_parts(8_045_000, 0) + .saturating_add(Weight::from_parts(0, 0)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::RecognizedPairs` (r:1 w:0) + /// Proof: `IntentsCoprocessor::RecognizedPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::PriceDepositAmount` (r:1 w:0) + /// Proof: `IntentsCoprocessor::PriceDepositAmount` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::PriceDeposits` (r:1 w:1) + /// Proof: `IntentsCoprocessor::PriceDeposits` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::PricesClearedThisWindow` (r:1 w:1) + /// Proof: `IntentsCoprocessor::PricesClearedThisWindow` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::Prices` (r:1 w:1) + /// Proof: `IntentsCoprocessor::Prices` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::CounterForPrices` (r:1 w:1) + /// Proof: `IntentsCoprocessor::CounterForPrices` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + fn submit_pair_price() -> Weight { + // Proof Size summary in bytes: + // Measured: `337` + // Estimated: `3802` + // Minimum execution time: 62_769_000 picoseconds. + Weight::from_parts(70_975_000, 0) + .saturating_add(Weight::from_parts(0, 3802)) + .saturating_add(T::DbWeight::get().reads(6)) + .saturating_add(T::DbWeight::get().writes(4)) + } + /// Storage: `IntentsCoprocessor::RecognizedPairs` (r:1 w:1) + /// Proof: `IntentsCoprocessor::RecognizedPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn add_recognized_pair() -> Weight { + // Proof Size summary in bytes: + // Measured: `175` + // Estimated: `3640` + // Minimum execution time: 13_866_000 picoseconds. + Weight::from_parts(14_798_000, 0) + .saturating_add(Weight::from_parts(0, 3640)) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::RecognizedPairs` (r:1 w:1) + /// Proof: `IntentsCoprocessor::RecognizedPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::Prices` (r:1 w:1) + /// Proof: `IntentsCoprocessor::Prices` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn remove_recognized_pair() -> Weight { + // Proof Size summary in bytes: + // Measured: `250` + // Estimated: `3715` + // Minimum execution time: 20_188_000 picoseconds. + Weight::from_parts(20_730_000, 0) + .saturating_add(Weight::from_parts(0, 3715)) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(2)) + } + /// Storage: `IntentsCoprocessor::PriceWindowDurationValue` (r:0 w:1) + /// Proof: `IntentsCoprocessor::PriceWindowDurationValue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_price_window_duration() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 7_214_000 picoseconds. + Weight::from_parts(7_524_000, 0) + .saturating_add(Weight::from_parts(0, 0)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::PriceDepositAmount` (r:0 w:1) + /// Proof: `IntentsCoprocessor::PriceDepositAmount` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_price_deposit_amount() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 7_424_000 picoseconds. + Weight::from_parts(7_654_000, 0) + .saturating_add(Weight::from_parts(0, 0)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::PriceDepositLockDuration` (r:0 w:1) + /// Proof: `IntentsCoprocessor::PriceDepositLockDuration` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_price_deposit_lock_duration() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 7_264_000 picoseconds. + Weight::from_parts(7_654_000, 0) + .saturating_add(Weight::from_parts(0, 0)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::PriceDeposits` (r:1 w:1) + /// Proof: `IntentsCoprocessor::PriceDeposits` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn withdraw_price_deposit() -> Weight { + // Proof Size summary in bytes: + // Measured: `504` + // Estimated: `3969` + // Minimum execution time: 35_137_000 picoseconds. + Weight::from_parts(35_828_000, 0) + .saturating_add(Weight::from_parts(0, 3969)) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } +} diff --git a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts index 8fddd54b0..e3e173075 100644 --- a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts +++ b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts @@ -344,10 +344,9 @@ export class IntentsCoprocessor { async submitPairPrice(pairId: HexString, entries: PriceInput[]): Promise { try { - // Encode entries as a Vec of (U256, U256, U256) tuples for the pallet + // Encode entries as a Vec of { amount, price } structs for the pallet const encodedEntries = entries.map((e) => ({ - range_start: e.rangeStart.toString(), - range_end: e.rangeEnd.toString(), + amount: e.amount.toString(), price: e.price.toString(), })) diff --git a/sdk/packages/sdk/src/types/index.ts b/sdk/packages/sdk/src/types/index.ts index 063b69bff..914522858 100644 --- a/sdk/packages/sdk/src/types/index.ts +++ b/sdk/packages/sdk/src/types/index.ts @@ -1348,10 +1348,10 @@ export type IntentOrderStatusUpdate = /** * Price input for submitting pair prices to the intents coprocessor. * All values are raw 18-decimal bigints as expected by the pallet. + * The frontend determines ranges from the curve points. */ export interface PriceInput { - rangeStart: bigint - rangeEnd: bigint + amount: bigint price: bigint } diff --git a/sdk/packages/simplex/src/strategies/fx.ts b/sdk/packages/simplex/src/strategies/fx.ts index 185dc2542..d075a082c 100644 --- a/sdk/packages/simplex/src/strategies/fx.ts +++ b/sdk/packages/simplex/src/strategies/fx.ts @@ -208,63 +208,39 @@ export class FXFiller implements FillerStrategy { /** * Convert the ask curve points into on-chain PriceInput entries. * For the ask pair (stable/exotic): - * - ranges are in stable (USD) amounts + * - amount = stable (USD) threshold * - price = exotic tokens per 1 stable */ private buildAskPriceEntries(): PriceInput[] { const points = this.askPricePolicy.getPoints() if (points.length === 0) return [] - const entries: PriceInput[] = [] const decimals = 18 - - for (let i = 0; i < points.length; i++) { - const rangeStart = parseUnits(points[i].amount.toString(), decimals) - const rangeEnd = - i < points.length - 1 - ? parseUnits(points[i + 1].amount.toString(), decimals) - 1n - : parseUnits("999999999", decimals) - const price = parseUnits(points[i].price.toString(), decimals) - - entries.push({ rangeStart, rangeEnd, price }) - } - - return entries + return points.map((pt) => ({ + amount: parseUnits(pt.amount.toString(), decimals), + price: parseUnits(pt.price.toString(), decimals), + })) } /** * Convert the bid curve points into on-chain PriceInput entries. * For the bid pair (exotic/stable): - * - ranges are in exotic token amounts (USD amount × exoticPerUsd) + * - amount = exotic token threshold (USD amount × exoticPerUsd) * - price = stable tokens per 1 exotic (1 / exoticPerUsd) */ private buildBidPriceEntries(): PriceInput[] { const points = this.bidPricePolicy.getPoints() if (points.length === 0) return [] - const entries: PriceInput[] = [] const decimals = 18 - - for (let i = 0; i < points.length; i++) { - // Convert USD range to exotic range: exoticAmount = usdAmount × exoticPerUsd - const exoticAmount = points[i].amount.mul(points[i].price) - const rangeStart = parseUnits(exoticAmount.toFixed(0), decimals) - - const rangeEnd = - i < points.length - 1 - ? parseUnits( - points[i + 1].amount.mul(points[i + 1].price).toFixed(0), - decimals, - ) - 1n - : parseUnits("999999999", decimals) - - const stablePerExotic = new Decimal(1).div(points[i].price) - const price = parseUnits(stablePerExotic.toFixed(decimals), decimals) - - entries.push({ rangeStart, rangeEnd, price }) - } - - return entries + return points.map((pt) => { + const exoticAmount = pt.amount.mul(pt.price) + const stablePerExotic = new Decimal(1).div(pt.price) + return { + amount: parseUnits(exoticAmount.toFixed(0), decimals), + price: parseUnits(stablePerExotic.toFixed(decimals), decimals), + } + }) } async canFill(order: Order): Promise { From a73292f23c0200141892460bb17d26bb18f33381 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Thu, 19 Mar 2026 11:51:00 +0100 Subject: [PATCH 19/28] configurable stablecoin addresses, update simtest, and add per-entry linear benchmarking --- .../intents-coprocessor/src/benchmarking.rs | 6 +- .../pallets/intents-coprocessor/src/lib.rs | 2 +- .../intents-coprocessor/src/weights.rs | 7 ++- .../src/weights/pallet_intents_coprocessor.rs | 63 ++++++++++--------- .../src/weights/pallet_intents_coprocessor.rs | 61 +++++++++--------- parachain/simtests/src/price_submission.rs | 36 +++++------ sdk/packages/simplex/src/bin/simplex.ts | 7 +++ sdk/packages/simplex/src/strategies/fx.ts | 22 ++++++- .../src/tests/strategies/fx.mainnet.test.ts | 12 ++++ 9 files changed, 126 insertions(+), 90 deletions(-) diff --git a/modules/pallets/intents-coprocessor/src/benchmarking.rs b/modules/pallets/intents-coprocessor/src/benchmarking.rs index b47ede4ac..2e112b4e2 100644 --- a/modules/pallets/intents-coprocessor/src/benchmarking.rs +++ b/modules/pallets/intents-coprocessor/src/benchmarking.rs @@ -201,7 +201,7 @@ mod benchmarks { } #[benchmark] - fn submit_pair_price() { + fn submit_pair_price(n: Linear<1, 100>) { let caller: T::AccountId = whitelisted_caller(); let pair_id = H256::repeat_byte(0xaa); @@ -215,9 +215,9 @@ mod benchmarks { PriceDepositLockDuration::::put(BlockNumberFor::::from(10u32)); PriceWindowDurationValue::::put(86_400_000u64); - let max = T::MaxPriceEntries::get(); + let count = n.min(T::MaxPriceEntries::get()); let mut entries_vec = vec![]; - for i in 0..max { + for i in 0..count { entries_vec .push(PriceInput { amount: U256::from(i * 1000), price: U256::from(2000 + i) }); } diff --git a/modules/pallets/intents-coprocessor/src/lib.rs b/modules/pallets/intents-coprocessor/src/lib.rs index 81f87512b..fe2a6eca6 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -580,7 +580,7 @@ pub mod pallet { /// Each entry in `entries` specifies a base token amount range and the /// corresponding price of the base token in terms of the quote token. #[pallet::call_index(7)] - #[pallet::weight(T::WeightInfo::submit_pair_price())] + #[pallet::weight(T::WeightInfo::submit_pair_price(entries.len() as u32))] pub fn submit_pair_price( origin: OriginFor, pair_id: H256, diff --git a/modules/pallets/intents-coprocessor/src/weights.rs b/modules/pallets/intents-coprocessor/src/weights.rs index 47bd380ae..12e96faf0 100644 --- a/modules/pallets/intents-coprocessor/src/weights.rs +++ b/modules/pallets/intents-coprocessor/src/weights.rs @@ -42,7 +42,7 @@ pub trait WeightInfo { fn sweep_dust() -> Weight; fn update_token_decimals() -> Weight; fn set_storage_deposit_fee() -> Weight; - fn submit_pair_price() -> Weight; + fn submit_pair_price(n: u32) -> Weight; fn add_recognized_pair() -> Weight; fn remove_recognized_pair() -> Weight; fn set_price_window_duration() -> Weight; @@ -122,9 +122,10 @@ impl WeightInfo for SubstrateWeight { /// Storage: RecognizedPairs (r:1 w:0), PriceDepositAmount (r:1 w:0), /// PriceDeposits (r:1 w:1), PricesClearedThisWindow (r:1 w:1), Prices (r:1 w:1) - fn submit_pair_price() -> Weight { + fn submit_pair_price(n: u32) -> Weight { Weight::from_parts(100_000_000, 0) .saturating_add(Weight::from_parts(0, 5000)) + .saturating_add(Weight::from_parts(5_000_000u64.saturating_mul(n as u64), 0)) .saturating_add(T::DbWeight::get().reads(5)) .saturating_add(T::DbWeight::get().writes(4)) } @@ -193,7 +194,7 @@ impl WeightInfo for () { fn set_storage_deposit_fee() -> Weight { Weight::from_parts(10_000_000, 0) } - fn submit_pair_price() -> Weight { + fn submit_pair_price(_n: u32) -> Weight { Weight::from_parts(100_000_000, 0) } fn add_recognized_pair() -> Weight { diff --git a/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs b/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs index 3083da06d..2d3482791 100644 --- a/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs +++ b/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs @@ -17,7 +17,7 @@ //! Autogenerated weights for `pallet_intents_coprocessor` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 -//! DATE: 2026-03-18, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-03-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 @@ -60,8 +60,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `109` // Estimated: `3574` - // Minimum execution time: 39_204_000 picoseconds. - Weight::from_parts(40_847_000, 0) + // Minimum execution time: 35_748_000 picoseconds. + Weight::from_parts(36_820_000, 0) .saturating_add(Weight::from_parts(0, 3574)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -72,8 +72,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `247` // Estimated: `3712` - // Minimum execution time: 38_373_000 picoseconds. - Weight::from_parts(38_974_000, 0) + // Minimum execution time: 34_355_000 picoseconds. + Weight::from_parts(35_007_000, 0) .saturating_add(Weight::from_parts(0, 3712)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) @@ -84,8 +84,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `113` // Estimated: `6053` - // Minimum execution time: 22_022_000 picoseconds. - Weight::from_parts(22_833_000, 0) + // Minimum execution time: 22_532_000 picoseconds. + Weight::from_parts(23_003_000, 0) .saturating_add(Weight::from_parts(0, 6053)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -108,8 +108,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 73_429_000 picoseconds. - Weight::from_parts(74_331_000, 0) + // Minimum execution time: 73_309_000 picoseconds. + Weight::from_parts(74_632_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(5)) @@ -132,8 +132,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 64_051_000 picoseconds. - Weight::from_parts(65_424_000, 0) + // Minimum execution time: 57_348_000 picoseconds. + Weight::from_parts(65_745_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -156,8 +156,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 68_550_000 picoseconds. - Weight::from_parts(69_231_000, 0) + // Minimum execution time: 60_615_000 picoseconds. + Weight::from_parts(69_401_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -168,8 +168,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 8_165_000 picoseconds. - Weight::from_parts(8_526_000, 0) + // Minimum execution time: 7_374_000 picoseconds. + Weight::from_parts(8_486_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -185,13 +185,16 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// Proof: `IntentsCoprocessor::Prices` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `IntentsCoprocessor::CounterForPrices` (r:1 w:1) /// Proof: `IntentsCoprocessor::CounterForPrices` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) - fn submit_pair_price() -> Weight { + /// The range of component `n` is `[1, 100]`. + fn submit_pair_price(n: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `271` // Estimated: `3736` - // Minimum execution time: 71_406_000 picoseconds. - Weight::from_parts(72_077_000, 0) + // Minimum execution time: 57_349_000 picoseconds. + Weight::from_parts(64_579_050, 0) .saturating_add(Weight::from_parts(0, 3736)) + // Standard Error: 5_521 + .saturating_add(Weight::from_parts(15_669, 0).saturating_mul(n.into())) .saturating_add(T::DbWeight::get().reads(6)) .saturating_add(T::DbWeight::get().writes(4)) } @@ -201,8 +204,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `109` // Estimated: `3574` - // Minimum execution time: 15_891_000 picoseconds. - Weight::from_parts(16_141_000, 0) + // Minimum execution time: 13_807_000 picoseconds. + Weight::from_parts(14_177_000, 0) .saturating_add(Weight::from_parts(0, 3574)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) @@ -215,8 +218,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `184` // Estimated: `3649` - // Minimum execution time: 22_232_000 picoseconds. - Weight::from_parts(22_973_000, 0) + // Minimum execution time: 19_698_000 picoseconds. + Weight::from_parts(20_279_000, 0) .saturating_add(Weight::from_parts(0, 3649)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(2)) @@ -227,8 +230,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 8_476_000 picoseconds. - Weight::from_parts(8_777_000, 0) + // Minimum execution time: 7_174_000 picoseconds. + Weight::from_parts(7_424_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -238,8 +241,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 8_546_000 picoseconds. - Weight::from_parts(8_756_000, 0) + // Minimum execution time: 7_314_000 picoseconds. + Weight::from_parts(7_485_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -249,8 +252,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 8_476_000 picoseconds. - Weight::from_parts(8_727_000, 0) + // Minimum execution time: 7_364_000 picoseconds. + Weight::from_parts(7_575_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -260,8 +263,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `438` // Estimated: `3903` - // Minimum execution time: 39_775_000 picoseconds. - Weight::from_parts(40_557_000, 0) + // Minimum execution time: 35_157_000 picoseconds. + Weight::from_parts(35_488_000, 0) .saturating_add(Weight::from_parts(0, 3903)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) diff --git a/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs b/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs index 4b8216e55..5651fc079 100644 --- a/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs +++ b/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs @@ -17,7 +17,7 @@ //! Autogenerated weights for `pallet_intents_coprocessor` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 -//! DATE: 2026-03-18, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-03-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 @@ -60,8 +60,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `175` // Estimated: `3640` - // Minimum execution time: 39_455_000 picoseconds. - Weight::from_parts(40_226_000, 0) + // Minimum execution time: 35_167_000 picoseconds. + Weight::from_parts(40_677_000, 0) .saturating_add(Weight::from_parts(0, 3640)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -72,8 +72,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `313` // Estimated: `3778` - // Minimum execution time: 38_914_000 picoseconds. - Weight::from_parts(39_575_000, 0) + // Minimum execution time: 34_296_000 picoseconds. + Weight::from_parts(34_996_000, 0) .saturating_add(Weight::from_parts(0, 3778)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) @@ -84,8 +84,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `179` // Estimated: `6119` - // Minimum execution time: 22_423_000 picoseconds. - Weight::from_parts(22_994_000, 0) + // Minimum execution time: 19_346_000 picoseconds. + Weight::from_parts(22_452_000, 0) .saturating_add(Weight::from_parts(0, 6119)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -108,8 +108,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `899` // Estimated: `4364` - // Minimum execution time: 66_446_000 picoseconds. - Weight::from_parts(75_293_000, 0) + // Minimum execution time: 65_264_000 picoseconds. + Weight::from_parts(66_376_000, 0) .saturating_add(Weight::from_parts(0, 4364)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(5)) @@ -132,8 +132,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `899` // Estimated: `4364` - // Minimum execution time: 57_810_000 picoseconds. - Weight::from_parts(66_326_000, 0) + // Minimum execution time: 57_719_000 picoseconds. + Weight::from_parts(58_591_000, 0) .saturating_add(Weight::from_parts(0, 4364)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -157,7 +157,7 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Measured: `899` // Estimated: `4364` // Minimum execution time: 60_866_000 picoseconds. - Weight::from_parts(61_857_000, 0) + Weight::from_parts(61_938_000, 0) .saturating_add(Weight::from_parts(0, 4364)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -168,8 +168,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_444_000 picoseconds. - Weight::from_parts(8_045_000, 0) + // Minimum execution time: 7_283_000 picoseconds. + Weight::from_parts(7_574_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -185,13 +185,16 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// Proof: `IntentsCoprocessor::Prices` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `IntentsCoprocessor::CounterForPrices` (r:1 w:1) /// Proof: `IntentsCoprocessor::CounterForPrices` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) - fn submit_pair_price() -> Weight { + /// The range of component `n` is `[1, 100]`. + fn submit_pair_price(n: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `337` // Estimated: `3802` - // Minimum execution time: 62_769_000 picoseconds. - Weight::from_parts(70_975_000, 0) + // Minimum execution time: 56_647_000 picoseconds. + Weight::from_parts(63_188_742, 0) .saturating_add(Weight::from_parts(0, 3802)) + // Standard Error: 4_377 + .saturating_add(Weight::from_parts(14_450, 0).saturating_mul(n.into())) .saturating_add(T::DbWeight::get().reads(6)) .saturating_add(T::DbWeight::get().writes(4)) } @@ -201,8 +204,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `175` // Estimated: `3640` - // Minimum execution time: 13_866_000 picoseconds. - Weight::from_parts(14_798_000, 0) + // Minimum execution time: 13_886_000 picoseconds. + Weight::from_parts(14_237_000, 0) .saturating_add(Weight::from_parts(0, 3640)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) @@ -215,8 +218,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `250` // Estimated: `3715` - // Minimum execution time: 20_188_000 picoseconds. - Weight::from_parts(20_730_000, 0) + // Minimum execution time: 19_677_000 picoseconds. + Weight::from_parts(20_249_000, 0) .saturating_add(Weight::from_parts(0, 3715)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(2)) @@ -227,8 +230,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_214_000 picoseconds. - Weight::from_parts(7_524_000, 0) + // Minimum execution time: 7_084_000 picoseconds. + Weight::from_parts(7_484_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -238,8 +241,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_424_000 picoseconds. - Weight::from_parts(7_654_000, 0) + // Minimum execution time: 8_126_000 picoseconds. + Weight::from_parts(8_366_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -249,8 +252,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_264_000 picoseconds. - Weight::from_parts(7_654_000, 0) + // Minimum execution time: 8_135_000 picoseconds. + Weight::from_parts(8_336_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -260,8 +263,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `504` // Estimated: `3969` - // Minimum execution time: 35_137_000 picoseconds. - Weight::from_parts(35_828_000, 0) + // Minimum execution time: 34_665_000 picoseconds. + Weight::from_parts(35_487_000, 0) .saturating_add(Weight::from_parts(0, 3969)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) diff --git a/parachain/simtests/src/price_submission.rs b/parachain/simtests/src/price_submission.rs index de55fba79..424fc496b 100644 --- a/parachain/simtests/src/price_submission.rs +++ b/parachain/simtests/src/price_submission.rs @@ -80,9 +80,9 @@ fn encode_submit_pair_price(pair_id: H256, entries: Vec) -> Vec /// Manually encode a call to `IntentsCoprocessor::add_recognized_pair`. /// Pallet index 65, call index 8. -fn encode_add_recognized_pair(pair: &TokenPair) -> Vec { +fn encode_add_recognized_pair(pair_id: H256) -> Vec { let mut data = vec![65u8, 8u8]; - data.extend_from_slice(&pair.encode()); + data.extend_from_slice(&pair_id.encode()); data } @@ -124,9 +124,7 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { let url = &format!("ws://127.0.0.1:{}", port); let (client, rpc_client) = subxt_utils::client::ws_client::(url, u32::MAX).await?; - let base = H256::from_low_u64_be(0xAAAA); - let quote = H256::from_low_u64_be(0xBBBB); - let pair = TokenPair { base, quote }; + let pair = TokenPair { base: b"USDC".to_vec(), quote: b"cNGN".to_vec() }; let pair_id = pair.pair_id(); // 1 unit = 10^18 in raw representation @@ -138,22 +136,18 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { // Lock duration: 5 blocks let lock_duration: u32 = 5; - // Price entries: 0-999 at 1414.5, 1000-5000 at 1420 + // Price entries: amount thresholds with corresponding prices + // Entry 1: amount=0 at price 1414.5, Entry 2: amount=1000 at price 1420 let price_entries = vec![ PriceInput { - range_start: U256::zero(), - range_end: U256::from(999) * one_unit, + amount: U256::zero(), price: U256::from(14145) * one_unit / U256::from(10), // 1414.5 * 10^18 }, - PriceInput { - range_start: U256::from(1000) * one_unit, - range_end: U256::from(5000) * one_unit, - price: U256::from(1420) * one_unit, - }, + PriceInput { amount: U256::from(1000) * one_unit, price: U256::from(1420) * one_unit }, ]; // Add recognized pair - sudo_raw_and_finalize(&client, &rpc_client, encode_add_recognized_pair(&pair)).await?; + sudo_raw_and_finalize(&client, &rpc_client, encode_add_recognized_pair(pair_id)).await?; println!("Recognized pair added: {pair_id:?}"); // Set deposit amount @@ -181,19 +175,17 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { assert_eq!(prices.len(), 2, "expected 2 price entries"); - // Verify first entry: 0-999 at 1414.5 - assert_eq!(prices[0].range_start, "0", "range1 start should be 0"); - assert_eq!(prices[0].range_end, "999", "range1 end should be 999"); + // Verify first entry: amount=0 at price 1414.5 + assert_eq!(prices[0].amount, "0", "amount1 should be 0"); assert_eq!(prices[0].price, "1414.5", "price1 should be 1414.5 (decimals preserved)"); - // Verify second entry: 1000-5000 at 1420 - assert_eq!(prices[1].range_start, "1000", "range2 start should be 1000"); - assert_eq!(prices[1].range_end, "5000", "range2 end should be 5000"); + // Verify second entry: amount=1000 at price 1420 + assert_eq!(prices[1].amount, "1000", "amount2 should be 1000"); assert_eq!(prices[1].price, "1420", "price2 should be 1420"); println!("RPC returns human-readable prices with decimals preserved"); - println!(" entry[0]: {}-{} @ {}", prices[0].range_start, prices[0].range_end, prices[0].price); - println!(" entry[1]: {}-{} @ {}", prices[1].range_start, prices[1].range_end, prices[1].price); + println!(" entry[0]: amount={} @ {}", prices[0].amount, prices[0].price); + println!(" entry[1]: amount={} @ {}", prices[1].amount, prices[1].price); // Initiate withdrawal submit_raw_and_finalize( diff --git a/sdk/packages/simplex/src/bin/simplex.ts b/sdk/packages/simplex/src/bin/simplex.ts index f308d069d..7732e47ba 100644 --- a/sdk/packages/simplex/src/bin/simplex.ts +++ b/sdk/packages/simplex/src/bin/simplex.ts @@ -91,6 +91,8 @@ interface FxStrategyConfig { maxOrderUsd: string /** Map of chain identifier (e.g. "EVM-97") to exotic token contract address */ exoticTokenAddresses: Record + /** Map of chain identifier (e.g. "EVM-97") to stablecoin address used by this strategy (USDC, USDT, etc.) */ + stablecoinAddresses: Record /** Optional per-chain confirmation policies for cross-chain orders */ confirmationPolicies?: Record } @@ -408,6 +410,7 @@ program askPricePolicy, strategyConfig.maxOrderUsd, strategyConfig.exoticTokenAddresses, + strategyConfig.stablecoinAddresses, fxConfirmationPolicy, ) } @@ -620,6 +623,10 @@ function validateConfig(config: FillerTomlConfig): void { throw new Error("FX strategy must have at least one entry in 'exoticTokenAddresses'") } + if (!strategy.stablecoinAddresses || Object.keys(strategy.stablecoinAddresses).length === 0) { + throw new Error("FX strategy must have at least one entry in 'stablecoinAddresses'") + } + if (strategy.confirmationPolicies) { for (const [chainId, policy] of Object.entries(strategy.confirmationPolicies)) { if (!policy.points || !Array.isArray(policy.points) || policy.points.length < 2) { diff --git a/sdk/packages/simplex/src/strategies/fx.ts b/sdk/packages/simplex/src/strategies/fx.ts index d075a082c..7d566f242 100644 --- a/sdk/packages/simplex/src/strategies/fx.ts +++ b/sdk/packages/simplex/src/strategies/fx.ts @@ -62,6 +62,8 @@ export class FXFiller implements FillerStrategy { private askPricePolicy: FillerPricePolicy /** Maps chain identifier → exotic token address (e.g. cNGN on each supported chain) */ private exoticTokenAddresses: Record + /** Maps chain identifier → stablecoin address used by this strategy (e.g. USDC or USDT) */ + private stablecoinAddresses: Record private maxOrderUsd: Decimal private account: ReturnType private logger = getLogger("fx-simplex") @@ -83,6 +85,9 @@ export class FXFiller implements FillerStrategy { * the filler will only size its outputs as if the order were $5,000. * @param exoticTokenAddresses Map of chain identifier → exotic token address. * Example: `{ "EVM-56": "0xabc..." }` for cNGN on BSC. + * @param stablecoinAddresses Map of chain identifier → stablecoin address used by this strategy. + * Example: `{ "EVM-56": "0xdef..." }` for USDC on BSC. + * When USDT support is needed, just pass the USDT address instead. * @param confirmationPolicy Optional per-chain confirmation policy for cross-chain orders. * If absent, no confirmation waiting is required. */ @@ -95,6 +100,7 @@ export class FXFiller implements FillerStrategy { askPricePolicy: FillerPricePolicy, maxOrderUsdStr: string, exoticTokenAddresses: Record, + stablecoinAddresses: Record, confirmationPolicy?: ConfirmationPolicy, ) { this.privateKey = privateKey @@ -104,6 +110,7 @@ export class FXFiller implements FillerStrategy { this.bidPricePolicy = bidPricePolicy this.askPricePolicy = askPricePolicy this.exoticTokenAddresses = exoticTokenAddresses + this.stablecoinAddresses = stablecoinAddresses this.maxOrderUsd = new Decimal(maxOrderUsdStr) if (this.maxOrderUsd.lte(0)) { throw new Error("FXFiller maxOrderUsd must be greater than 0") @@ -117,6 +124,17 @@ export class FXFiller implements FillerStrategy { } } + /** + * Get the stablecoin address configured for this strategy on the given chain. + */ + private getUsdToken(chain: string): HexString { + const addr = this.stablecoinAddresses[chain] + if (!addr) { + throw new Error(`Stablecoin address not configured for chain ${chain}`) + } + return addr + } + /** * Compute pair ID from token symbols: keccak256("baseSymbol/quoteSymbol") */ @@ -138,10 +156,10 @@ export class FXFiller implements FillerStrategy { try { const chain = Object.keys(this.exoticTokenAddresses)[0] const exoticAddress = this.exoticTokenAddresses[chain] - const usdcAddress = this.configService.getUsdcAsset(chain) + const stableAddress = this.getUsdToken(chain) const [stableSymbol, exoticSymbol] = await Promise.all([ - this.contractService.getTokenSymbol(usdcAddress, chain), + this.contractService.getTokenSymbol(stableAddress, chain), this.contractService.getTokenSymbol(exoticAddress, chain), ]) diff --git a/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts b/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts index fc2811448..c653ae0d1 100644 --- a/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts +++ b/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts @@ -866,6 +866,11 @@ function createCrossChainFxIntentFiller( }, }) + const stablecoinAddresses: Record = {} + for (const id of chainIds) { + stablecoinAddresses[id] = chainConfigService.getUsdcAsset(id) + } + const fxStrategy = new FXFiller( privateKey, chainConfigService, @@ -875,6 +880,7 @@ function createCrossChainFxIntentFiller( askPricePolicy, "5000", exoticTokenAddresses, + stablecoinAddresses, confirmationPolicy, ) @@ -923,6 +929,11 @@ function createFxOnlyIntentFiller( const extAsset = chainConfigService.getExtAsset(mainnetId) const exoticTokenAddresses: Record = extAsset ? { [mainnetId]: extAsset as HexString } : {} + const stablecoinAddresses: Record = {} + if (mainnetId) { + stablecoinAddresses[mainnetId] = chainConfigService.getUsdcAsset(mainnetId) + } + const fxStrategy = new FXFiller( privateKey, chainConfigService, @@ -932,6 +943,7 @@ function createFxOnlyIntentFiller( askPricePolicy, "5000", exoticTokenAddresses, + stablecoinAddresses, ) const strategies = [fxStrategy] From dcfb1c125392f712972ae011a1051d4a42681cfc Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Thu, 19 Mar 2026 12:19:12 +0100 Subject: [PATCH 20/28] update docs --- .../developers/intent-gateway/price-submission.mdx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/content/developers/intent-gateway/price-submission.mdx b/docs/content/developers/intent-gateway/price-submission.mdx index 163f4e47c..81af4805a 100644 --- a/docs/content/developers/intent-gateway/price-submission.mdx +++ b/docs/content/developers/intent-gateway/price-submission.mdx @@ -13,9 +13,9 @@ The intents system needs on-chain price data for token pairs to function correct The `submit_pair_price` extrinsic on the intents coprocessor pallet is the entry point for all price submissions. It accepts a pair ID and a bounded list of price entries. All prices and amount ranges are encoded as U256 values scaled by 10^18, giving 18 decimal places of precision. -Submissions are batched. Each call accepts up to `MaxPriceEntries` entries (a compile-time constant configurable per runtime), where each entry specifies a base token amount range and the corresponding price of the base token in terms of the quote token. This makes it possible to quote different rates for different order sizes in a single transaction. For example, a submitter might quote USDC/CNGN at 1414 for orders between 0 and 999, and 1420 for orders between 1000 and 5000. +Submissions are batched. Each call accepts up to `MaxPriceEntries` entries (a compile-time constant configurable per runtime), where each entry specifies an amount threshold and the corresponding price of the base token in terms of the quote token. This makes it possible to quote different rates for different order sizes in a single transaction. For example, a submitter might quote USDC/CNGN at 1414 for amounts starting at 0, and 1420 for amounts starting at 1000. -Each price entry contains three fields. The `range_start` field is the lower bound of the base token amount range (inclusive). The `range_end` field is the upper bound (also inclusive). The `price` field is the cost of one unit of the base token in terms of the quote token. The pallet validates that `range_start` is less than or equal to `range_end` for every entry and rejects empty submissions. When stored on-chain, each entry becomes a `PriceEntry` that also includes the submission timestamp. +Each price entry contains two fields. The `amount` field is the base token amount threshold at which this price applies. The `price` field is the cost of one unit of the base token in terms of the quote token. The pallet rejects empty submissions. When stored on-chain, each entry becomes a `PriceEntry` that also includes the filler's account ID. ## Deposit Model @@ -45,7 +45,7 @@ Only governance-approved token pairs can receive price submissions. This prevent ## RPC -The `intents_getPairPrices(pair_id)` RPC endpoint returns all price entries for a given token pair. The raw on-chain values (U256 scaled by 10^18) are converted to human-readable decimal strings with fractional precision preserved. For example, an on-chain value of 1414500000000000000000 is returned as the string "1414.5". Each returned entry includes the amount range boundaries (`range_start` and `range_end`), the `price`, and the submission `timestamp` in seconds. +The `intents_getPairPrices(pair_id)` RPC endpoint returns all price entries for a given token pair. The raw on-chain values (U256 scaled by 10^18) are converted to human-readable decimal strings with fractional precision preserved. For example, an on-chain value of 1414500000000000000000 is returned as the string "1414.5". Each returned entry includes the `amount` threshold, the `price`, and the `filler` account. ## Simplex Filler Integration @@ -61,7 +61,6 @@ Price submission is enabled by adding a `pairId` to the `hyperfx` strategy in th [[strategies]] type = "hyperfx" pairId = "0x..." # On-chain pair ID (keccak256 of base_address ++ quote_address) -priceSubmissionIntervalSeconds = 300 # How often to submit prices (default: 300 = 5 minutes) maxOrderUsd = "5000" [[strategies.askPriceCurve]] @@ -82,6 +81,9 @@ price = "1490" [strategies.exoticTokenAddresses] "EVM-56" = "0xabc..." + +[strategies.stablecoinAddresses] +"EVM-56" = "0xdef..." ``` The ask curve points are sorted by amount and each point becomes a price entry. The range for each entry spans from that point's amount to just below the next point's amount. The last point extends to a large upper bound. Amounts and prices are converted to 18-decimal format before submission. @@ -100,8 +102,8 @@ const coprocessor = await IntentsCoprocessor.connect(wsUrl, substratePrivateKey) // Submit prices for a token pair (values in 18-decimal format) await coprocessor.submitPairPrice("0x...", [ - { rangeStart: parseUnits("0", 18), rangeEnd: parseUnits("999", 18), price: parseUnits("1414", 18) }, - { rangeStart: parseUnits("1000", 18), rangeEnd: parseUnits("5000", 18), price: parseUnits("1420", 18) }, + { amount: parseUnits("0", 18), price: parseUnits("1414", 18) }, + { amount: parseUnits("1000", 18), price: parseUnits("1420", 18) }, ]) // Withdraw deposit (two-phase): From 790e7167a8ccd0b4972615140c0e46850a59c761 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Fri, 20 Mar 2026 10:54:06 +0100 Subject: [PATCH 21/28] getQuotes method --- .../sdk/src/chains/intentsCoprocessor.ts | 74 ++++++++++++++++++- sdk/packages/sdk/src/types/index.ts | 8 ++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts index e3e173075..347bace8b 100644 --- a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts +++ b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts @@ -5,7 +5,7 @@ import { hexToU8a, u8aToHex, u8aConcat } from "@polkadot/util" import { decodeAddress, keccakAsU8a } from "@polkadot/util-crypto" import { numberToBytes, bytesToBigInt } from "viem" import { Bytes, Struct, u8, Vector } from "scale-ts" -import type { BidSubmissionResult, HexString, PackedUserOperation, BidStorageEntry, FillerBid, PriceInput } from "@/types" +import type { BidSubmissionResult, HexString, PackedUserOperation, BidStorageEntry, FillerBid, PriceInput, Quote } from "@/types" import type { SubstrateChain } from "./substrate" /** Offchain storage key prefix for bids */ @@ -75,6 +75,13 @@ export function decodeUserOpScale(hex: HexString): PackedUserOperation { } } +/** RPC response shape from intents_getPairPrices */ +interface RpcPriceEntry { + amount: string + price: string + filler: string +} + /** RPC response shape from intents_getBidsForOrder */ interface RpcBidInfo { commitment: HexString @@ -381,6 +388,71 @@ export class IntentsCoprocessor { } } + /** + * Fetches all price entries for a token pair, groups them by filler, + * runs piecewise linear interpolation on each filler's price curve, + * and returns the interpolated quote at the requested amount from each filler. + * + * @param pairId - The token pair identifier (H256 / bytes32) + * @param amount - The base token amount to get quotes for (human-readable number, e.g. 500 for 500 tokens) + * @returns Array of Quote objects, one per filler who has price entries for this pair + */ + async getQuotes(pairId: HexString, amount: number): Promise { + const entries: RpcPriceEntry[] = await (this.api as any)._rpcCore.provider.send( + "intents_getPairPrices", + [pairId], + ) + + if (entries.length === 0) return [] + + // Group entries by filler + const byFiller = new Map() + for (const entry of entries) { + const points = byFiller.get(entry.filler) ?? [] + points.push({ amount: parseFloat(entry.amount), price: parseFloat(entry.price) }) + byFiller.set(entry.filler, points) + } + + const quotes: Quote[] = [] + for (const [filler, points] of byFiller) { + // Sort by amount ascending + points.sort((a, b) => a.amount - b.amount) + + const interpolatedPrice = this.interpolatePrice(points, amount) + quotes.push({ filler, price: interpolatedPrice.toString() }) + } + + return quotes + } + + /** + * Piecewise linear interpolation over sorted price points. + * Below the minimum amount, returns the first point's price. + * Above the maximum amount, returns the last point's price. + * Between two points, linearly interpolates. + */ + private interpolatePrice(points: { amount: number; price: number }[], amount: number): number { + if (points.length === 1 || amount <= points[0].amount) { + return points[0].price + } + + const last = points[points.length - 1] + if (amount >= last.amount) { + return last.price + } + + for (let i = 0; i < points.length - 1; i++) { + const p1 = points[i] + const p2 = points[i + 1] + if (amount >= p1.amount && amount <= p2.amount) { + const t = (amount - p1.amount) / (p2.amount - p1.amount) + return p1.price + t * (p2.price - p1.price) + } + } + + return last.price + } + /** * Fetches all bid storage entries for a given order commitment. * Returns the on-chain data only (filler addresses and deposits). diff --git a/sdk/packages/sdk/src/types/index.ts b/sdk/packages/sdk/src/types/index.ts index 914522858..e0332853e 100644 --- a/sdk/packages/sdk/src/types/index.ts +++ b/sdk/packages/sdk/src/types/index.ts @@ -1355,6 +1355,14 @@ export interface PriceInput { price: bigint } +/** A quote from a single filler for a given amount, produced by interpolating their price entries */ +export interface Quote { + /** The filler's on-chain account address */ + filler: string + /** The interpolated price at the requested amount */ + price: string +} + /** Result of selecting a bid and submitting to the bundler */ export interface SelectBidResult { userOp: PackedUserOperation From 51bc01d6ff7842dfe0f539235ffffcd63cc7e2c5 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Fri, 20 Mar 2026 12:32:32 +0100 Subject: [PATCH 22/28] refacoring --- .../sdk/src/chains/intentsCoprocessor.ts | 33 ++----------------- sdk/packages/sdk/src/index.ts | 1 + sdk/packages/sdk/src/types/index.ts | 4 +-- .../simplex/src/config/interpolated-curve.ts | 32 ++++-------------- 4 files changed, 12 insertions(+), 58 deletions(-) diff --git a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts index 347bace8b..bff54fbd8 100644 --- a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts +++ b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts @@ -6,6 +6,7 @@ import { decodeAddress, keccakAsU8a } from "@polkadot/util-crypto" import { numberToBytes, bytesToBigInt } from "viem" import { Bytes, Struct, u8, Vector } from "scale-ts" import type { BidSubmissionResult, HexString, PackedUserOperation, BidStorageEntry, FillerBid, PriceInput, Quote } from "@/types" +import { interpolatePrice } from "@/utils/interpolate" import type { SubstrateChain } from "./substrate" /** Offchain storage key prefix for bids */ @@ -418,41 +419,13 @@ export class IntentsCoprocessor { // Sort by amount ascending points.sort((a, b) => a.amount - b.amount) - const interpolatedPrice = this.interpolatePrice(points, amount) - quotes.push({ filler, price: interpolatedPrice.toString() }) + const interpolated = interpolatePrice(points, amount) + quotes.push({ filler, amount: interpolated.toString() }) } return quotes } - /** - * Piecewise linear interpolation over sorted price points. - * Below the minimum amount, returns the first point's price. - * Above the maximum amount, returns the last point's price. - * Between two points, linearly interpolates. - */ - private interpolatePrice(points: { amount: number; price: number }[], amount: number): number { - if (points.length === 1 || amount <= points[0].amount) { - return points[0].price - } - - const last = points[points.length - 1] - if (amount >= last.amount) { - return last.price - } - - for (let i = 0; i < points.length - 1; i++) { - const p1 = points[i] - const p2 = points[i + 1] - if (amount >= p1.amount && amount <= p2.amount) { - const t = (amount - p1.amount) / (p2.amount - p1.amount) - return p1.price + t * (p2.price - p1.price) - } - } - - return last.price - } - /** * Fetches all bid storage entries for a given order commitment. * Returns the on-chain data only (filler addresses and deposits). diff --git a/sdk/packages/sdk/src/index.ts b/sdk/packages/sdk/src/index.ts index e953b1924..1b53c0c7e 100644 --- a/sdk/packages/sdk/src/index.ts +++ b/sdk/packages/sdk/src/index.ts @@ -42,5 +42,6 @@ export * from "@/utils/tokenGateway" export * from "@/utils/xcmGateway" export * from "@/chain" export * from "@/types" +export { interpolatePrice } from "@/utils/interpolate" export * from "@/configs/ChainConfigService" export * from "@/configs/chain" diff --git a/sdk/packages/sdk/src/types/index.ts b/sdk/packages/sdk/src/types/index.ts index 791870d20..ddb6c0bad 100644 --- a/sdk/packages/sdk/src/types/index.ts +++ b/sdk/packages/sdk/src/types/index.ts @@ -1364,8 +1364,8 @@ export interface PriceInput { export interface Quote { /** The filler's on-chain account address */ filler: string - /** The interpolated price at the requested amount */ - price: string + /** The interpolated amount at the requested input (human-readable decimal string) */ + amount: string } /** Result of selecting a bid and submitting to the bundler */ diff --git a/sdk/packages/simplex/src/config/interpolated-curve.ts b/sdk/packages/simplex/src/config/interpolated-curve.ts index 6febd0660..a541642c8 100644 --- a/sdk/packages/simplex/src/config/interpolated-curve.ts +++ b/sdk/packages/simplex/src/config/interpolated-curve.ts @@ -1,4 +1,5 @@ import Decimal from "decimal.js" +import { interpolatePrice } from "@hyperbridge/sdk" /** * A coordinate point on a curve @@ -181,31 +182,10 @@ export class FillerPricePolicy { } getPrice(orderValueUsd: Decimal): Decimal { - const amount = orderValueUsd - - // Below minimum configured amount, use the first point - if (amount.lte(this.points[0].amount)) { - return this.points[0].price - } - - // Above maximum configured amount, use the last point - const lastPoint = this.points[this.points.length - 1] - if (amount.gte(lastPoint.amount)) { - return lastPoint.price - } - - // Piecewise linear interpolation between surrounding points - for (let i = 0; i < this.points.length - 1; i++) { - const p1 = this.points[i] - const p2 = this.points[i + 1] - - if (amount.gte(p1.amount) && amount.lte(p2.amount)) { - const t = amount.minus(p1.amount).div(p2.amount.minus(p1.amount)) - return p1.price.plus(t.mul(p2.price.minus(p1.price))) - } - } - - // Fallback (should not be reached due to earlier checks) - return lastPoint.price + const numPoints = this.points.map((p) => ({ + amount: p.amount.toNumber(), + price: p.price.toNumber(), + })) + return new Decimal(interpolatePrice(numPoints, orderValueUsd.toNumber())) } } From 46c35742bbeb4dc43dd75e1d11b1ab0344a6e219 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Fri, 20 Mar 2026 13:50:19 +0100 Subject: [PATCH 23/28] introduce side to differentiate prices for asks and bids, remove governance pairs --- .../intents-coprocessor/rpc/src/lib.rs | 21 ++- .../intents-coprocessor/src/benchmarking.rs | 50 ++----- .../pallets/intents-coprocessor/src/lib.rs | 138 ++++++++---------- .../pallets/intents-coprocessor/src/tests.rs | 120 ++++++++------- .../pallets/intents-coprocessor/src/types.rs | 21 +++ .../intents-coprocessor/src/weights.rs | 26 +--- .../src/weights/pallet_intents_coprocessor.rs | 98 +++++-------- .../src/weights/pallet_intents_coprocessor.rs | 98 +++++-------- parachain/simtests/src/price_submission.rs | 32 ++-- .../sdk/src/chains/intentsCoprocessor.ts | 13 +- sdk/packages/sdk/src/types/index.ts | 6 + sdk/packages/simplex/src/strategies/fx.ts | 8 +- 12 files changed, 273 insertions(+), 358 deletions(-) diff --git a/modules/pallets/intents-coprocessor/rpc/src/lib.rs b/modules/pallets/intents-coprocessor/rpc/src/lib.rs index 553fddf3a..ba8476bb3 100644 --- a/modules/pallets/intents-coprocessor/rpc/src/lib.rs +++ b/modules/pallets/intents-coprocessor/rpc/src/lib.rs @@ -188,12 +188,12 @@ fn runtime_error_into_rpc_error(e: impl std::fmt::Display) -> ErrorObjectOwned { ErrorObject::owned(9877, format!("{e}"), None::) } -/// Construct the full storage key for a `StorageMap` entry with `Blake2_128Concat` hasher. -fn storage_map_key(pallet: &[u8], storage: &[u8], map_key: &H256) -> Vec { +/// Construct the full storage key for a `StorageMap` entry with `Blake2_128Concat` hasher, +/// using raw pre-encoded key bytes. +fn storage_map_key_raw(pallet: &[u8], storage: &[u8], map_key_bytes: &[u8]) -> Vec { let mut key = Vec::new(); key.extend_from_slice(&sp_core::hashing::twox_128(pallet)); key.extend_from_slice(&sp_core::hashing::twox_128(storage)); - let map_key_bytes = map_key.as_bytes(); key.extend_from_slice(&sp_core::hashing::blake2_128(map_key_bytes)); key.extend_from_slice(map_key_bytes); key @@ -217,9 +217,9 @@ pub trait IntentsApi { #[method(name = "intents_getBidsForOrder")] fn get_bids_for_order(&self, commitment: H256) -> RpcResult>; - /// Get all prices for a token pair + /// Get all prices for a token pair and side ("bid" or "ask") #[method(name = "intents_getPairPrices")] - fn get_pair_prices(&self, pair_id: H256) -> RpcResult>; + fn get_pair_prices(&self, pair_id: H256, side: String) -> RpcResult>; #[subscription(name = "intents_subscribeBids" => "intents_bidNotification", unsubscribe = "intents_unsubscribeBids", item = RpcBidInfo)] async fn subscribe_bids(&self, commitment: Option) -> SubscriptionResult; @@ -304,10 +304,17 @@ where Ok(bids.into_iter().collect()) } - fn get_pair_prices(&self, pair_id: H256) -> RpcResult> { + fn get_pair_prices(&self, pair_id: H256, side: String) -> RpcResult> { let best_hash = self.client.info().best_hash; - let key = storage_map_key(b"IntentsCoprocessor", b"Prices", &pair_id); + let side_enum = match side.to_lowercase().as_str() { + "bid" => pallet_intents_coprocessor::types::Side::Bid, + "ask" => pallet_intents_coprocessor::types::Side::Ask, + _ => + return Err(runtime_error_into_rpc_error("Invalid side: must be \"bid\" or \"ask\"")), + }; + let composite_key = (pair_id, side_enum).encode(); + let key = storage_map_key_raw(b"IntentsCoprocessor", b"Prices", &composite_key); let storage_key = sp_core::storage::StorageKey(key); let data = match self.client.storage(best_hash, &storage_key) { diff --git a/modules/pallets/intents-coprocessor/src/benchmarking.rs b/modules/pallets/intents-coprocessor/src/benchmarking.rs index 2e112b4e2..dde064cd1 100644 --- a/modules/pallets/intents-coprocessor/src/benchmarking.rs +++ b/modules/pallets/intents-coprocessor/src/benchmarking.rs @@ -28,7 +28,7 @@ use frame_system::RawOrigin; use ismp::host::StateMachine; use primitive_types::{H160, H256, U256}; use sp_runtime::traits::ConstU32; -use types::PriceInput; +use types::{PriceInput, Side}; #[benchmarks( where @@ -204,13 +204,13 @@ mod benchmarks { fn submit_pair_price(n: Linear<1, 100>) { let caller: T::AccountId = whitelisted_caller(); let pair_id = H256::repeat_byte(0xaa); + let side = Side::Ask; // Use a large balance to cover existential deposit + price deposit on any runtime let balance = BalanceOf::::from(u32::MAX); ::Currency::make_free_balance_be(&caller, balance); let deposit_amount = ::Currency::minimum_balance(); - RecognizedPairs::::insert(&pair_id, true); PriceDepositAmount::::put(deposit_amount); PriceDepositLockDuration::::put(BlockNumberFor::::from(10u32)); PriceWindowDurationValue::::put(86_400_000u64); @@ -225,37 +225,9 @@ mod benchmarks { entries_vec.try_into().expect("entries fit in bounds"); #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), pair_id, entries); + _(RawOrigin::Signed(caller.clone()), pair_id, side, entries); - assert!(!Prices::::get(&pair_id).is_empty()); - } - - #[benchmark] - fn add_recognized_pair() -> Result<(), BenchmarkError> { - let origin = - T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; - let pair_id = H256::repeat_byte(0xbb); - - #[extrinsic_call] - _(origin as T::RuntimeOrigin, pair_id); - - assert!(RecognizedPairs::::get(&pair_id)); - Ok(()) - } - - #[benchmark] - fn remove_recognized_pair() -> Result<(), BenchmarkError> { - let origin = - T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; - let pair_id = H256::repeat_byte(0xcc); - - RecognizedPairs::::insert(&pair_id, true); - - #[extrinsic_call] - _(origin as T::RuntimeOrigin, pair_id); - - assert!(!RecognizedPairs::::get(&pair_id)); - Ok(()) + assert!(!Prices::::get(&(pair_id, side)).is_empty()); } #[benchmark] @@ -298,13 +270,13 @@ mod benchmarks { fn withdraw_price_deposit() { let caller: T::AccountId = whitelisted_caller(); let pair_id = H256::repeat_byte(0xdd); + let side = Side::Ask; // Use a large balance to cover existential deposit + price deposit on any runtime let balance = BalanceOf::::from(u32::MAX); ::Currency::make_free_balance_be(&caller, balance); let deposit_amount = ::Currency::minimum_balance(); - RecognizedPairs::::insert(&pair_id, true); PriceDepositAmount::::put(deposit_amount); PriceDepositLockDuration::::put(BlockNumberFor::::from(10u32)); PriceWindowDurationValue::::put(86_400_000u64); @@ -316,18 +288,22 @@ mod benchmarks { let _ = Pallet::::submit_pair_price( RawOrigin::Signed(caller.clone()).into(), pair_id, + side, entries, ); - let _ = - Pallet::::withdraw_price_deposit(RawOrigin::Signed(caller.clone()).into(), pair_id); + let _ = Pallet::::withdraw_price_deposit( + RawOrigin::Signed(caller.clone()).into(), + pair_id, + side, + ); frame_system::Pallet::::set_block_number(100u32.into()); #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), pair_id); + _(RawOrigin::Signed(caller.clone()), pair_id, side); - assert!(PriceDeposits::::get(&caller, &pair_id).is_none()); + assert!(PriceDeposits::::get(&caller, &(pair_id, side)).is_none()); } 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 fe2a6eca6..02350d65d 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -46,7 +46,7 @@ use sp_runtime::{ pub use weights::WeightInfo; use types::{ - Bid, GatewayInfo, IntentGatewayParams, PriceEntry, PriceInput, RequestKind, + Bid, GatewayInfo, IntentGatewayParams, PriceEntry, PriceInput, RequestKind, Side, TokenDecimalsUpdate, TokenInfo, }; @@ -130,10 +130,6 @@ pub mod pallet { pub type Gateways = StorageMap<_, Blake2_128Concat, StateMachine, GatewayInfo, OptionQuery>; - /// Recognized token pairs for price tracking - #[pallet::storage] - pub type RecognizedPairs = StorageMap<_, Blake2_128Concat, H256, bool, ValueQuery>; - /// Start timestamp (in seconds) of the current price window #[pallet::storage] pub type PriceWindowStart = StorageValue<_, u64, ValueQuery>; @@ -142,12 +138,12 @@ pub mod pallet { #[pallet::storage] pub type PriceWindowDurationValue = StorageValue<_, u64, ValueQuery>; - /// Price entries per pair + /// Price entries per (pair, side) #[pallet::storage] pub type Prices = - CountedStorageMap<_, Blake2_128Concat, H256, BTreeSet, ValueQuery>; + CountedStorageMap<_, Blake2_128Concat, (H256, Side), BTreeSet, ValueQuery>; - /// Deposits reserved by price submitters. Maps (account, pair_id) to + /// Deposits reserved by price submitters. Maps (account, (pair_id, side)) to /// (deposit_amount, unlock_block). When `unlock_block` is `None`, the withdrawal /// has not been initiated. The first call to `withdraw_price_deposit` sets /// `unlock_block` to `current_block + PriceDepositLockDuration`. The second @@ -158,7 +154,7 @@ pub mod pallet { Blake2_128Concat, T::AccountId, Blake2_128Concat, - H256, // pair_id + (H256, Side), // (pair_id, side) (BalanceOf, Option>), // (deposit_amount, unlock_block) OptionQuery, >; @@ -173,12 +169,12 @@ pub mod pallet { #[pallet::storage] pub type PriceDepositLockDuration = StorageValue<_, BlockNumberFor, ValueQuery>; - /// Whether prices have been cleared for a given pair in the current window. + /// Whether prices have been cleared for a given (pair, side) in the current window. /// All entries are removed by `on_initialize` when a new window starts. - /// Set to true on the first price submission for that pair in the new window. + /// Set to true on the first price submission for that (pair, side) in the new window. #[pallet::storage] pub type PricesClearedThisWindow = - StorageMap<_, Blake2_128Concat, H256, bool, ValueQuery>; + StorageMap<_, Blake2_128Concat, (H256, Side), bool, ValueQuery>; #[pallet::hooks] impl Hooks> for Pallet @@ -235,12 +231,8 @@ pub mod pallet { }, /// Storage deposit fee was updated StorageDepositFeeUpdated { fee: BalanceOf }, - /// A recognized token pair was added - RecognizedPairAdded { pair_id: H256 }, - /// A recognized token pair was removed - RecognizedPairRemoved { pair_id: H256 }, /// Prices were submitted for a token pair - PriceSubmitted { submitter: T::AccountId, pair_id: H256 }, + PriceSubmitted { submitter: T::AccountId, pair_id: H256, side: Side }, /// Price window duration was updated PriceWindowDurationUpdated { duration_ms: u64 }, /// Price deposit amount was updated @@ -248,15 +240,26 @@ pub mod pallet { /// Price deposit lock duration was updated (in blocks) PriceDepositLockDurationUpdated { duration_blocks: BlockNumberFor }, /// Price deposit was reserved on first submission - PriceDepositReserved { submitter: T::AccountId, pair_id: H256, amount: BalanceOf }, + PriceDepositReserved { + submitter: T::AccountId, + pair_id: H256, + side: Side, + amount: BalanceOf, + }, /// Price deposit withdrawal was initiated (unlock block noted) PriceDepositWithdrawalInitiated { submitter: T::AccountId, pair_id: H256, + side: Side, unlock_block: BlockNumberFor, }, /// Price deposit was withdrawn (tokens unreserved) - PriceDepositWithdrawn { submitter: T::AccountId, pair_id: H256, amount: BalanceOf }, + PriceDepositWithdrawn { + submitter: T::AccountId, + pair_id: H256, + side: Side, + amount: BalanceOf, + }, } #[pallet::error] @@ -273,10 +276,6 @@ pub mod pallet { InvalidUserOp, /// Failed to dispatch cross-chain request DispatchFailed, - /// Token pair not recognized - PairNotRecognized, - /// Token pair already exists - PairAlreadyExists, /// No price entries were provided EmptyPriceEntries, /// Price deposits are not configured (amount is zero) @@ -570,56 +569,59 @@ pub mod pallet { Ok(()) } - /// Submit prices for a recognized token pair across one or more amount ranges. + /// Submit prices for a token pair on a given side (bid or ask). /// - /// On the first submission per (account, pair), a deposit is reserved from the - /// submitter's balance. Subsequent submissions for the same pair are free. - /// The deposit can be withdrawn after the configured lock duration via - /// `withdraw_price_deposit`. + /// On the first submission per (account, pair, side), a deposit is reserved + /// from the submitter's balance. Subsequent submissions for the same + /// (pair, side) are free. The deposit can be withdrawn after the configured + /// lock duration via `withdraw_price_deposit`. /// - /// Each entry in `entries` specifies a base token amount range and the + /// Each entry in `entries` specifies a base token amount threshold and the /// corresponding price of the base token in terms of the quote token. #[pallet::call_index(7)] #[pallet::weight(T::WeightInfo::submit_pair_price(entries.len() as u32))] pub fn submit_pair_price( origin: OriginFor, pair_id: H256, + side: Side, entries: BoundedVec, ) -> DispatchResult { let submitter = ensure_signed(origin)?; ensure!(!entries.is_empty(), Error::::EmptyPriceEntries); - ensure!(RecognizedPairs::::get(&pair_id), Error::::PairNotRecognized); let deposit_amount = PriceDepositAmount::::get(); ensure!(!deposit_amount.is_zero(), Error::::PriceDepositsNotConfigured); - if let Some((_, Some(_unlock_block))) = PriceDeposits::::get(&submitter, &pair_id) { + let key = (pair_id, side); + + if let Some((_, Some(_unlock_block))) = PriceDeposits::::get(&submitter, &key) { return Err(Error::::WithdrawalInProgress.into()); } - // Reserve deposit on first submission per (account, pair) - if !PriceDeposits::::contains_key(&submitter, &pair_id) { + // Reserve deposit on first submission per (account, pair, side) + if !PriceDeposits::::contains_key(&submitter, &key) { ::Currency::reserve(&submitter, deposit_amount) .map_err(|_| Error::::InsufficientBalance)?; PriceDeposits::::insert( &submitter, - &pair_id, + &key, (deposit_amount, None::>), ); Self::deposit_event(Event::PriceDepositReserved { submitter: submitter.clone(), pair_id, + side, amount: deposit_amount, }); } - Self::maybe_clear_stale_prices(&pair_id); + Self::maybe_clear_stale_prices(&key); let filler: H256 = H256::from_slice(&submitter.encode()[..32]); - Prices::::mutate(&pair_id, |stored| { + Prices::::mutate(&key, |stored| { stored.extend(entries.iter().map(|input| PriceEntry { amount: input.amount, price: input.price, @@ -627,38 +629,7 @@ pub mod pallet { })); }); - Self::deposit_event(Event::PriceSubmitted { submitter, pair_id }); - - Ok(()) - } - - /// Add a recognized token pair for price tracking - #[pallet::call_index(8)] - #[pallet::weight(T::WeightInfo::add_recognized_pair())] - pub fn add_recognized_pair(origin: OriginFor, pair_id: H256) -> DispatchResult { - T::GovernanceOrigin::ensure_origin(origin)?; - - ensure!(!RecognizedPairs::::get(&pair_id), Error::::PairAlreadyExists); - - RecognizedPairs::::insert(&pair_id, true); - - Self::deposit_event(Event::RecognizedPairAdded { pair_id }); - - Ok(()) - } - - /// Remove a recognized token pair - #[pallet::call_index(9)] - #[pallet::weight(T::WeightInfo::remove_recognized_pair())] - pub fn remove_recognized_pair(origin: OriginFor, pair_id: H256) -> DispatchResult { - T::GovernanceOrigin::ensure_origin(origin)?; - - ensure!(RecognizedPairs::::get(&pair_id), Error::::PairNotRecognized); - - RecognizedPairs::::remove(&pair_id); - Prices::::remove(&pair_id); - - Self::deposit_event(Event::RecognizedPairRemoved { pair_id }); + Self::deposit_event(Event::PriceSubmitted { submitter, pair_id, side }); Ok(()) } @@ -718,18 +689,24 @@ pub mod pallet { /// /// # Parameters /// - `pair_id`: The token pair the deposit was made for + /// - `side`: The side (bid or ask) the deposit was made for /// /// # Errors - /// - `DepositNotFound`: No deposit exists for this account and pair + /// - `DepositNotFound`: No deposit exists for this account, pair, and side /// - `WithdrawalAlreadyInitiated`: First call was already made (waiting for unlock) /// - `DepositStillLocked`: The unlock block has not yet been reached #[pallet::call_index(13)] #[pallet::weight(T::WeightInfo::withdraw_price_deposit())] - pub fn withdraw_price_deposit(origin: OriginFor, pair_id: H256) -> DispatchResult { + pub fn withdraw_price_deposit( + origin: OriginFor, + pair_id: H256, + side: Side, + ) -> DispatchResult { let who = ensure_signed(origin)?; + let key = (pair_id, side); let (deposit_amount, unlock_block) = - PriceDeposits::::get(&who, &pair_id).ok_or(Error::::DepositNotFound)?; + PriceDeposits::::get(&who, &key).ok_or(Error::::DepositNotFound)?; match unlock_block { None => { @@ -738,11 +715,12 @@ pub mod pallet { let lock_duration = PriceDepositLockDuration::::get(); let unlock_at = current_block.saturating_add(lock_duration); - PriceDeposits::::insert(&who, &pair_id, (deposit_amount, Some(unlock_at))); + PriceDeposits::::insert(&who, &key, (deposit_amount, Some(unlock_at))); Self::deposit_event(Event::PriceDepositWithdrawalInitiated { submitter: who, pair_id, + side, unlock_block: unlock_at, }); }, @@ -752,11 +730,12 @@ pub mod pallet { ensure!(current_block >= unlock_at, Error::::DepositStillLocked); ::Currency::unreserve(&who, deposit_amount); - PriceDeposits::::remove(&who, &pair_id); + PriceDeposits::::remove(&who, &key); Self::deposit_event(Event::PriceDepositWithdrawn { submitter: who, pair_id, + side, amount: deposit_amount, }); }, @@ -787,15 +766,16 @@ pub mod pallet { offchain_bid_key_raw(commitment, &filler.encode()) } - /// Clear prices for a specific pair if this is the first submission for that pair + /// Clear prices for a specific (pair, side) if this is the first submission /// in the current window. /// /// Prices from the previous window persist until the first new submission - /// for a given pair in the new window, at which point that pair's entries are cleared. - fn maybe_clear_stale_prices(pair_id: &H256) { - if !PricesClearedThisWindow::::get(pair_id) { - Prices::::remove(pair_id); - PricesClearedThisWindow::::insert(pair_id, true); + /// for a given (pair, side) in the new window, at which point those entries + /// are cleared. + fn maybe_clear_stale_prices(key: &(H256, Side)) { + if !PricesClearedThisWindow::::get(key) { + Prices::::remove(key); + PricesClearedThisWindow::::insert(key, true); } } diff --git a/modules/pallets/intents-coprocessor/src/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index e83f60d1f..1fbb65107 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -17,7 +17,7 @@ #![cfg(test)] -use crate::{self as pallet_intents, *}; +use crate::{self as pallet_intents, types::Side, *}; use alloc::{collections::BTreeSet, vec}; use codec::Decode; use frame_support::{ @@ -542,38 +542,14 @@ fn multiple_fillers_can_bid_on_same_order() { }); } -#[test] -fn remove_recognized_pair_works() { - new_test_ext().execute_with(|| { - let pair_id = - types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); - - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); - - Prices::::insert( - &pair_id, - BTreeSet::from([types::PriceEntry { - amount: U256::zero(), - price: U256::from(1000), - filler: H256::from_low_u64_be(1), - }]), - ); - - assert_ok!(Intents::remove_recognized_pair(RuntimeOrigin::root(), pair_id)); - - assert!(!RecognizedPairs::::get(&pair_id)); - assert!(Prices::::get(&pair_id).is_empty()); - }); -} - #[test] fn submit_pair_price_reserves_deposit_on_first_submission() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); + let side = Side::Ask; let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); pallet_timestamp::Now::::put(2_000_000u64); @@ -589,6 +565,7 @@ fn submit_pair_price_reserves_deposit_on_first_submission() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, + side, entries, )); @@ -598,12 +575,12 @@ fn submit_pair_price_reserves_deposit_on_first_submission() { // Deposit record stored (no unlock block yet) let (stored_amount, unlock_block) = - PriceDeposits::::get(&submitter, &pair_id).unwrap(); + PriceDeposits::::get(&submitter, &(pair_id, side)).unwrap(); assert_eq!(stored_amount, deposit_amount); assert_eq!(unlock_block, None); // Price entry stored - let prices = Prices::::get(&pair_id); + let prices = Prices::::get(&(pair_id, side)); assert_eq!(prices.len(), 1); assert_eq!(prices.iter().next().unwrap().price, U256::from(2000)); }); @@ -613,10 +590,10 @@ fn submit_pair_price_reserves_deposit_on_first_submission() { fn submit_pair_price_second_submission_is_free() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); + let side = Side::Ask; let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); pallet_timestamp::Now::::put(2_000_000u64); @@ -629,6 +606,7 @@ fn submit_pair_price_second_submission_is_free() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, + side, entries1, )); @@ -645,6 +623,7 @@ fn submit_pair_price_second_submission_is_free() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, + side, entries2, )); @@ -654,7 +633,7 @@ fn submit_pair_price_second_submission_is_free() { assert_eq!(Balances::reserved_balance(&submitter), deposit_amount); // Two entries now stored - let prices = Prices::::get(&pair_id); + let prices = Prices::::get(&(pair_id, side)); assert_eq!(prices.len(), 2); }); } @@ -666,7 +645,6 @@ fn submit_pair_price_fails_with_insufficient_balance() { let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); pallet_timestamp::Now::::put(2_000_000u64); @@ -677,7 +655,12 @@ fn submit_pair_price_fails_with_insufficient_balance() { .unwrap(); assert_noop!( - Intents::submit_pair_price(RuntimeOrigin::signed(submitter), pair_id, entries,), + Intents::submit_pair_price( + RuntimeOrigin::signed(submitter), + pair_id, + Side::Ask, + entries, + ), Error::::InsufficientBalance ); }); @@ -687,10 +670,10 @@ fn submit_pair_price_fails_with_insufficient_balance() { fn withdraw_price_deposit_two_phase() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); + let side = Side::Ask; let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); pallet_timestamp::Now::::put(2_000_000u64); @@ -703,6 +686,7 @@ fn withdraw_price_deposit_two_phase() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, + side, entries, )); @@ -714,6 +698,7 @@ fn withdraw_price_deposit_two_phase() { assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id, + side, )); // Deposit is NOT yet unreserved @@ -721,13 +706,17 @@ fn withdraw_price_deposit_two_phase() { assert_eq!(Balances::reserved_balance(&submitter), deposit_amount); // Unlock block is set (1 + 10 = 11) - let (_, unlock_block) = PriceDeposits::::get(&submitter, &pair_id).unwrap(); + let (_, unlock_block) = PriceDeposits::::get(&submitter, &(pair_id, side)).unwrap(); assert_eq!(unlock_block, Some(11u64)); // Phase 2 too early: still locked at block 5 System::set_block_number(5); assert_noop!( - Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter.clone()), pair_id,), + Intents::withdraw_price_deposit( + RuntimeOrigin::signed(submitter.clone()), + pair_id, + side, + ), Error::::DepositStillLocked ); @@ -736,6 +725,7 @@ fn withdraw_price_deposit_two_phase() { assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id, + side, )); // Deposit unreserved @@ -743,7 +733,7 @@ fn withdraw_price_deposit_two_phase() { assert_eq!(Balances::reserved_balance(&submitter), 0); // Deposit record removed - assert!(PriceDeposits::::get(&submitter, &pair_id).is_none()); + assert!(PriceDeposits::::get(&submitter, &(pair_id, side)).is_none()); }); } @@ -751,10 +741,10 @@ fn withdraw_price_deposit_two_phase() { fn withdraw_price_deposit_phase2_fails_when_still_locked() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); + let side = Side::Ask; let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); pallet_timestamp::Now::::put(2_000_000u64); @@ -767,6 +757,7 @@ fn withdraw_price_deposit_phase2_fails_when_still_locked() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, + side, entries, )); @@ -775,12 +766,13 @@ fn withdraw_price_deposit_phase2_fails_when_still_locked() { assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id, + side, )); // Phase 2: Try to complete at block 5 (unlock is at 11) System::set_block_number(5); assert_noop!( - Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter), pair_id,), + Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter), pair_id, side,), Error::::DepositStillLocked ); }); @@ -793,7 +785,7 @@ fn withdraw_price_deposit_fails_when_no_deposit() { let pair_id = H256::random(); assert_noop!( - Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter), pair_id,), + Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter), pair_id, Side::Ask,), Error::::DepositNotFound ); }); @@ -819,13 +811,14 @@ fn set_price_deposit_lock_duration_works() { fn prices_persist_across_window_and_clear_on_first_submission() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); + let side = Side::Ask; let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); + let key = (pair_id, side); // Simulate day 1: store some prices Prices::::insert( - &pair_id, + &key, BTreeSet::from([types::PriceEntry { amount: U256::zero(), price: U256::from(1666), @@ -840,14 +833,14 @@ fn prices_persist_across_window_and_clear_on_first_submission() { pallet_timestamp::Now::::put(50_000_000u64); // 50_000 seconds in ms Intents::on_initialize(1u64); - assert_eq!(Prices::::get(&pair_id).len(), 1); + assert_eq!(Prices::::get(&key).len(), 1); // Advance past the window (1000 + 86_400 = 87_400 seconds) pallet_timestamp::Now::::put(90_000_000u64); // 90_000 seconds in ms Intents::on_initialize(2u64); // Prices still persist (readable until first new submission) - assert_eq!(Prices::::get(&pair_id).len(), 1); + assert_eq!(Prices::::get(&key).len(), 1); assert_eq!(PriceWindowStart::::get(), 90_000); // Submit a new price — this is the first submission in the new window. @@ -860,11 +853,12 @@ fn prices_persist_across_window_and_clear_on_first_submission() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, + side, new_entries, )); // Old entries gone, only new entry remains - let prices = Prices::::get(&pair_id); + let prices = Prices::::get(&key); assert_eq!(prices.len(), 1); assert_eq!(prices.iter().next().unwrap().price, U256::from(2000)); }); @@ -900,8 +894,12 @@ fn price_entry_encoding_matches_rpc_tuple_decoding() { #[test] fn price_entry_storage_roundtrip_via_raw_key() { new_test_ext().execute_with(|| { + use codec::Encode; + let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); + let side = Side::Ask; + let storage_key = (pair_id, side); let entry1 = types::PriceEntry { amount: U256::zero(), @@ -914,7 +912,7 @@ fn price_entry_storage_roundtrip_via_raw_key() { filler: H256::from_low_u64_be(2), }; - Prices::::insert(&pair_id, BTreeSet::from([entry1.clone(), entry2.clone()])); + Prices::::insert(&storage_key, BTreeSet::from([entry1.clone(), entry2.clone()])); // Build the storage key the same way the RPC does. let pallet_prefix = b"Intents"; @@ -922,9 +920,9 @@ fn price_entry_storage_roundtrip_via_raw_key() { let mut key = Vec::new(); key.extend_from_slice(&sp_io::hashing::twox_128(pallet_prefix)); key.extend_from_slice(&sp_io::hashing::twox_128(b"Prices")); - let pair_id_bytes = pair_id.as_bytes(); - key.extend_from_slice(&sp_io::hashing::blake2_128(pair_id_bytes)); - key.extend_from_slice(pair_id_bytes); + let map_key_bytes = storage_key.encode(); + key.extend_from_slice(&sp_io::hashing::blake2_128(&map_key_bytes)); + key.extend_from_slice(&map_key_bytes); let raw = sp_io::storage::get(&key).expect("Prices storage should exist"); @@ -945,10 +943,10 @@ fn multiple_submitters_independent_deposits() { new_test_ext().execute_with(|| { let submitter1 = AccountId32::new([1; 32]); let submitter2 = AccountId32::new([2; 32]); + let side = Side::Ask; let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); pallet_timestamp::Now::::put(2_000_000u64); @@ -970,11 +968,13 @@ fn multiple_submitters_independent_deposits() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter1.clone()), pair_id, + side, entries1, )); assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter2.clone()), pair_id, + side, entries2, )); @@ -983,7 +983,7 @@ fn multiple_submitters_independent_deposits() { assert_eq!(Balances::reserved_balance(&submitter2), deposit_amount); // Two entries in prices - assert_eq!(Prices::::get(&pair_id).len(), 2); + assert_eq!(Prices::::get(&(pair_id, side)).len(), 2); }); } @@ -991,13 +991,12 @@ fn multiple_submitters_independent_deposits() { fn separate_deposits_per_pair() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); + let side = Side::Ask; let pair_id1 = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); let pair_id2 = types::TokenPair { base: b"TOKEN_C".to_vec(), quote: b"TOKEN_D".to_vec() }.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id1)); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id2)); pallet_timestamp::Now::::put(2_000_000u64); @@ -1012,11 +1011,13 @@ fn separate_deposits_per_pair() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id1, + side, entries.clone(), )); assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id2, + side, entries, )); @@ -1028,10 +1029,12 @@ fn separate_deposits_per_pair() { assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id1, + side, )); assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id2, + side, )); // Phase 2: Complete both after lock duration (block 11) @@ -1039,12 +1042,14 @@ fn separate_deposits_per_pair() { assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id1, + side, )); assert_eq!(Balances::reserved_balance(&submitter), deposit_amount); assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id2, + side, )); assert_eq!(Balances::reserved_balance(&submitter), 0); }); @@ -1055,11 +1060,11 @@ fn submit_pair_price_blocked_after_withdrawal_initiated() { new_test_ext().execute_with(|| { let submitter: AccountId = AccountId::from([1u8; 32]); let deposit_amount = 100u64; + let side = Side::Ask; let pair = types::TokenPair { base: b"TOKEN_X".to_vec(), quote: b"TOKEN_Y".to_vec() }; let pair_id = pair.pair_id(); - assert_ok!(Intents::add_recognized_pair(RuntimeOrigin::root(), pair_id)); PriceDepositAmount::::put(deposit_amount); PriceDepositLockDuration::::put(10u64); @@ -1071,6 +1076,7 @@ fn submit_pair_price_blocked_after_withdrawal_initiated() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, + side, entries.clone(), )); @@ -1079,11 +1085,17 @@ fn submit_pair_price_blocked_after_withdrawal_initiated() { assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id, + side, )); // Submitting prices should now fail assert_noop!( - Intents::submit_pair_price(RuntimeOrigin::signed(submitter.clone()), pair_id, entries,), + Intents::submit_pair_price( + RuntimeOrigin::signed(submitter.clone()), + pair_id, + side, + entries, + ), Error::::WithdrawalInProgress ); }); diff --git a/modules/pallets/intents-coprocessor/src/types.rs b/modules/pallets/intents-coprocessor/src/types.rs index 67c8b9940..7beaf1726 100644 --- a/modules/pallets/intents-coprocessor/src/types.rs +++ b/modules/pallets/intents-coprocessor/src/types.rs @@ -142,6 +142,27 @@ pub struct Bid { /// The signed user operation (opaque bytes) pub user_op: Vec, } +/// Represents the side of a price quote (bid or ask). +#[derive( + Clone, + Copy, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + TypeInfo, + PartialEq, + Eq, + PartialOrd, + Ord, +)] +pub enum Side { + /// Bid side: the price a filler is willing to pay to buy the base token. + Bid, + /// Ask side: the price a filler wants to sell the base token at. + Ask, +} + /// A recognized token pair for price tracking #[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] pub struct TokenPair { diff --git a/modules/pallets/intents-coprocessor/src/weights.rs b/modules/pallets/intents-coprocessor/src/weights.rs index 12e96faf0..54c418fa1 100644 --- a/modules/pallets/intents-coprocessor/src/weights.rs +++ b/modules/pallets/intents-coprocessor/src/weights.rs @@ -43,8 +43,6 @@ pub trait WeightInfo { fn update_token_decimals() -> Weight; fn set_storage_deposit_fee() -> Weight; fn submit_pair_price(n: u32) -> Weight; - fn add_recognized_pair() -> Weight; - fn remove_recognized_pair() -> Weight; fn set_price_window_duration() -> Weight; fn set_price_deposit_amount() -> Weight; fn set_price_deposit_lock_duration() -> Weight; @@ -120,30 +118,16 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().writes(1)) } - /// Storage: RecognizedPairs (r:1 w:0), PriceDepositAmount (r:1 w:0), + /// Storage: PriceDepositAmount (r:1 w:0), /// PriceDeposits (r:1 w:1), PricesClearedThisWindow (r:1 w:1), Prices (r:1 w:1) fn submit_pair_price(n: u32) -> Weight { Weight::from_parts(100_000_000, 0) .saturating_add(Weight::from_parts(0, 5000)) .saturating_add(Weight::from_parts(5_000_000u64.saturating_mul(n as u64), 0)) - .saturating_add(T::DbWeight::get().reads(5)) + .saturating_add(T::DbWeight::get().reads(4)) .saturating_add(T::DbWeight::get().writes(4)) } - /// Storage: RecognizedPairs (r:1 w:1) - fn add_recognized_pair() -> Weight { - Weight::from_parts(15_000_000, 0) - .saturating_add(T::DbWeight::get().reads(1)) - .saturating_add(T::DbWeight::get().writes(1)) - } - - /// Storage: RecognizedPairs (r:1 w:1), Prices (r:0 w:1) - fn remove_recognized_pair() -> Weight { - Weight::from_parts(20_000_000, 0) - .saturating_add(T::DbWeight::get().reads(1)) - .saturating_add(T::DbWeight::get().writes(2)) - } - /// Storage: PriceWindowDurationValue (r:0 w:1) fn set_price_window_duration() -> Weight { Weight::from_parts(10_000_000, 0) @@ -197,12 +181,6 @@ impl WeightInfo for () { fn submit_pair_price(_n: u32) -> Weight { Weight::from_parts(100_000_000, 0) } - fn add_recognized_pair() -> Weight { - Weight::from_parts(15_000_000, 0) - } - fn remove_recognized_pair() -> Weight { - Weight::from_parts(20_000_000, 0) - } fn set_price_window_duration() -> Weight { Weight::from_parts(10_000_000, 0) } diff --git a/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs b/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs index 2d3482791..ef60d2f14 100644 --- a/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs +++ b/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs @@ -17,7 +17,7 @@ //! Autogenerated weights for `pallet_intents_coprocessor` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 -//! DATE: 2026-03-19, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-03-20, 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 @@ -36,7 +36,7 @@ // --genesis-builder-preset=development // --template=./scripts/template.hbs // --genesis-builder=runtime -// --runtime=./target/release/wbuild/gargantua-runtime/gargantua_runtime.compact.compressed.wasm +// --runtime=./target/release/wbuild/gargantua-runtime/gargantua_runtime.compact.wasm // --output // parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs @@ -60,8 +60,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `109` // Estimated: `3574` - // Minimum execution time: 35_748_000 picoseconds. - Weight::from_parts(36_820_000, 0) + // Minimum execution time: 39_245_000 picoseconds. + Weight::from_parts(39_935_000, 0) .saturating_add(Weight::from_parts(0, 3574)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -72,8 +72,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `247` // Estimated: `3712` - // Minimum execution time: 34_355_000 picoseconds. - Weight::from_parts(35_007_000, 0) + // Minimum execution time: 37_651_000 picoseconds. + Weight::from_parts(38_523_000, 0) .saturating_add(Weight::from_parts(0, 3712)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) @@ -84,8 +84,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `113` // Estimated: `6053` - // Minimum execution time: 22_532_000 picoseconds. - Weight::from_parts(23_003_000, 0) + // Minimum execution time: 21_871_000 picoseconds. + Weight::from_parts(22_383_000, 0) .saturating_add(Weight::from_parts(0, 6053)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -108,8 +108,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 73_309_000 picoseconds. - Weight::from_parts(74_632_000, 0) + // Minimum execution time: 72_057_000 picoseconds. + Weight::from_parts(73_149_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(5)) @@ -132,8 +132,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 57_348_000 picoseconds. - Weight::from_parts(65_745_000, 0) + // Minimum execution time: 64_162_000 picoseconds. + Weight::from_parts(65_244_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -156,8 +156,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 60_615_000 picoseconds. - Weight::from_parts(69_401_000, 0) + // Minimum execution time: 67_879_000 picoseconds. + Weight::from_parts(68_460_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -168,13 +168,11 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_374_000 picoseconds. - Weight::from_parts(8_486_000, 0) + // Minimum execution time: 7_324_000 picoseconds. + Weight::from_parts(8_386_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } - /// Storage: `IntentsCoprocessor::RecognizedPairs` (r:1 w:0) - /// Proof: `IntentsCoprocessor::RecognizedPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `IntentsCoprocessor::PriceDepositAmount` (r:1 w:0) /// Proof: `IntentsCoprocessor::PriceDepositAmount` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `IntentsCoprocessor::PriceDeposits` (r:1 w:1) @@ -186,52 +184,24 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// Storage: `IntentsCoprocessor::CounterForPrices` (r:1 w:1) /// Proof: `IntentsCoprocessor::CounterForPrices` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// The range of component `n` is `[1, 100]`. - fn submit_pair_price(n: u32, ) -> Weight { + fn submit_pair_price(_n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `271` - // Estimated: `3736` - // Minimum execution time: 57_349_000 picoseconds. - Weight::from_parts(64_579_050, 0) - .saturating_add(Weight::from_parts(0, 3736)) - // Standard Error: 5_521 - .saturating_add(Weight::from_parts(15_669, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(6)) + // Measured: `200` + // Estimated: `3665` + // Minimum execution time: 56_608_000 picoseconds. + Weight::from_parts(67_128_306, 0) + .saturating_add(Weight::from_parts(0, 3665)) + .saturating_add(T::DbWeight::get().reads(5)) .saturating_add(T::DbWeight::get().writes(4)) } - /// Storage: `IntentsCoprocessor::RecognizedPairs` (r:1 w:1) - /// Proof: `IntentsCoprocessor::RecognizedPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) - fn add_recognized_pair() -> Weight { - // Proof Size summary in bytes: - // Measured: `109` - // Estimated: `3574` - // Minimum execution time: 13_807_000 picoseconds. - Weight::from_parts(14_177_000, 0) - .saturating_add(Weight::from_parts(0, 3574)) - .saturating_add(T::DbWeight::get().reads(1)) - .saturating_add(T::DbWeight::get().writes(1)) - } - /// Storage: `IntentsCoprocessor::RecognizedPairs` (r:1 w:1) - /// Proof: `IntentsCoprocessor::RecognizedPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `IntentsCoprocessor::Prices` (r:1 w:1) - /// Proof: `IntentsCoprocessor::Prices` (`max_values`: None, `max_size`: None, mode: `Measured`) - fn remove_recognized_pair() -> Weight { - // Proof Size summary in bytes: - // Measured: `184` - // Estimated: `3649` - // Minimum execution time: 19_698_000 picoseconds. - Weight::from_parts(20_279_000, 0) - .saturating_add(Weight::from_parts(0, 3649)) - .saturating_add(T::DbWeight::get().reads(2)) - .saturating_add(T::DbWeight::get().writes(2)) - } /// Storage: `IntentsCoprocessor::PriceWindowDurationValue` (r:0 w:1) /// Proof: `IntentsCoprocessor::PriceWindowDurationValue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn set_price_window_duration() -> Weight { // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_174_000 picoseconds. - Weight::from_parts(7_424_000, 0) + // Minimum execution time: 7_214_000 picoseconds. + Weight::from_parts(7_494_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -241,8 +211,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_314_000 picoseconds. - Weight::from_parts(7_485_000, 0) + // Minimum execution time: 7_274_000 picoseconds. + Weight::from_parts(7_574_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -252,8 +222,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_364_000 picoseconds. - Weight::from_parts(7_575_000, 0) + // Minimum execution time: 7_294_000 picoseconds. + Weight::from_parts(7_795_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -261,11 +231,11 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// Proof: `IntentsCoprocessor::PriceDeposits` (`max_values`: None, `max_size`: None, mode: `Measured`) fn withdraw_price_deposit() -> Weight { // Proof Size summary in bytes: - // Measured: `438` - // Estimated: `3903` - // Minimum execution time: 35_157_000 picoseconds. - Weight::from_parts(35_488_000, 0) - .saturating_add(Weight::from_parts(0, 3903)) + // Measured: `406` + // Estimated: `3871` + // Minimum execution time: 35_447_000 picoseconds. + Weight::from_parts(36_078_000, 0) + .saturating_add(Weight::from_parts(0, 3871)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } diff --git a/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs b/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs index 5651fc079..59dff292a 100644 --- a/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs +++ b/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs @@ -17,7 +17,7 @@ //! Autogenerated weights for `pallet_intents_coprocessor` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 -//! DATE: 2026-03-19, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-03-20, 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 @@ -36,7 +36,7 @@ // --genesis-builder-preset=development // --template=./scripts/template.hbs // --genesis-builder=runtime -// --runtime=./target/release/wbuild/nexus-runtime/nexus_runtime.compact.compressed.wasm +// --runtime=./target/release/wbuild/nexus-runtime/nexus_runtime.compact.wasm // --output // parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs @@ -60,8 +60,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `175` // Estimated: `3640` - // Minimum execution time: 35_167_000 picoseconds. - Weight::from_parts(40_677_000, 0) + // Minimum execution time: 34_555_000 picoseconds. + Weight::from_parts(39_876_000, 0) .saturating_add(Weight::from_parts(0, 3640)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -72,8 +72,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `313` // Estimated: `3778` - // Minimum execution time: 34_296_000 picoseconds. - Weight::from_parts(34_996_000, 0) + // Minimum execution time: 37_992_000 picoseconds. + Weight::from_parts(38_793_000, 0) .saturating_add(Weight::from_parts(0, 3778)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) @@ -84,8 +84,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `179` // Estimated: `6119` - // Minimum execution time: 19_346_000 picoseconds. - Weight::from_parts(22_452_000, 0) + // Minimum execution time: 22_162_000 picoseconds. + Weight::from_parts(22_663_000, 0) .saturating_add(Weight::from_parts(0, 6119)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -108,8 +108,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `899` // Estimated: `4364` - // Minimum execution time: 65_264_000 picoseconds. - Weight::from_parts(66_376_000, 0) + // Minimum execution time: 73_450_000 picoseconds. + Weight::from_parts(74_451_000, 0) .saturating_add(Weight::from_parts(0, 4364)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(5)) @@ -132,8 +132,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `899` // Estimated: `4364` - // Minimum execution time: 57_719_000 picoseconds. - Weight::from_parts(58_591_000, 0) + // Minimum execution time: 65_384_000 picoseconds. + Weight::from_parts(66_656_000, 0) .saturating_add(Weight::from_parts(0, 4364)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -156,8 +156,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `899` // Estimated: `4364` - // Minimum execution time: 60_866_000 picoseconds. - Weight::from_parts(61_938_000, 0) + // Minimum execution time: 68_961_000 picoseconds. + Weight::from_parts(69_752_000, 0) .saturating_add(Weight::from_parts(0, 4364)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -168,13 +168,11 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_283_000 picoseconds. - Weight::from_parts(7_574_000, 0) + // Minimum execution time: 8_396_000 picoseconds. + Weight::from_parts(8_646_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } - /// Storage: `IntentsCoprocessor::RecognizedPairs` (r:1 w:0) - /// Proof: `IntentsCoprocessor::RecognizedPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `IntentsCoprocessor::PriceDepositAmount` (r:1 w:0) /// Proof: `IntentsCoprocessor::PriceDepositAmount` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `IntentsCoprocessor::PriceDeposits` (r:1 w:1) @@ -186,52 +184,24 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// Storage: `IntentsCoprocessor::CounterForPrices` (r:1 w:1) /// Proof: `IntentsCoprocessor::CounterForPrices` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// The range of component `n` is `[1, 100]`. - fn submit_pair_price(n: u32, ) -> Weight { + fn submit_pair_price(_n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `337` - // Estimated: `3802` - // Minimum execution time: 56_647_000 picoseconds. - Weight::from_parts(63_188_742, 0) - .saturating_add(Weight::from_parts(0, 3802)) - // Standard Error: 4_377 - .saturating_add(Weight::from_parts(14_450, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(6)) + // Measured: `266` + // Estimated: `3731` + // Minimum execution time: 60_385_000 picoseconds. + Weight::from_parts(65_241_811, 0) + .saturating_add(Weight::from_parts(0, 3731)) + .saturating_add(T::DbWeight::get().reads(5)) .saturating_add(T::DbWeight::get().writes(4)) } - /// Storage: `IntentsCoprocessor::RecognizedPairs` (r:1 w:1) - /// Proof: `IntentsCoprocessor::RecognizedPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) - fn add_recognized_pair() -> Weight { - // Proof Size summary in bytes: - // Measured: `175` - // Estimated: `3640` - // Minimum execution time: 13_886_000 picoseconds. - Weight::from_parts(14_237_000, 0) - .saturating_add(Weight::from_parts(0, 3640)) - .saturating_add(T::DbWeight::get().reads(1)) - .saturating_add(T::DbWeight::get().writes(1)) - } - /// Storage: `IntentsCoprocessor::RecognizedPairs` (r:1 w:1) - /// Proof: `IntentsCoprocessor::RecognizedPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `IntentsCoprocessor::Prices` (r:1 w:1) - /// Proof: `IntentsCoprocessor::Prices` (`max_values`: None, `max_size`: None, mode: `Measured`) - fn remove_recognized_pair() -> Weight { - // Proof Size summary in bytes: - // Measured: `250` - // Estimated: `3715` - // Minimum execution time: 19_677_000 picoseconds. - Weight::from_parts(20_249_000, 0) - .saturating_add(Weight::from_parts(0, 3715)) - .saturating_add(T::DbWeight::get().reads(2)) - .saturating_add(T::DbWeight::get().writes(2)) - } /// Storage: `IntentsCoprocessor::PriceWindowDurationValue` (r:0 w:1) /// Proof: `IntentsCoprocessor::PriceWindowDurationValue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn set_price_window_duration() -> Weight { // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_084_000 picoseconds. - Weight::from_parts(7_484_000, 0) + // Minimum execution time: 7_174_000 picoseconds. + Weight::from_parts(7_414_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -241,8 +211,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 8_126_000 picoseconds. - Weight::from_parts(8_366_000, 0) + // Minimum execution time: 7_334_000 picoseconds. + Weight::from_parts(7_595_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -252,8 +222,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 8_135_000 picoseconds. - Weight::from_parts(8_336_000, 0) + // Minimum execution time: 7_404_000 picoseconds. + Weight::from_parts(8_466_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -261,11 +231,11 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// Proof: `IntentsCoprocessor::PriceDeposits` (`max_values`: None, `max_size`: None, mode: `Measured`) fn withdraw_price_deposit() -> Weight { // Proof Size summary in bytes: - // Measured: `504` - // Estimated: `3969` - // Minimum execution time: 34_665_000 picoseconds. - Weight::from_parts(35_487_000, 0) - .saturating_add(Weight::from_parts(0, 3969)) + // Measured: `472` + // Estimated: `3937` + // Minimum execution time: 35_127_000 picoseconds. + Weight::from_parts(40_317_000, 0) + .saturating_add(Weight::from_parts(0, 3937)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } diff --git a/parachain/simtests/src/price_submission.rs b/parachain/simtests/src/price_submission.rs index 424fc496b..bb0d43f6f 100644 --- a/parachain/simtests/src/price_submission.rs +++ b/parachain/simtests/src/price_submission.rs @@ -3,7 +3,7 @@ use std::env; use codec::Encode; -use pallet_intents_coprocessor::types::{PriceInput, TokenPair}; +use pallet_intents_coprocessor::types::{PriceInput, Side, TokenPair}; use pallet_intents_rpc::RpcPriceEntry; use polkadot_sdk::*; use primitive_types::{H256, U256}; @@ -71,21 +71,14 @@ async fn advance_blocks( /// Manually encode a call to `IntentsCoprocessor::submit_pair_price`. /// Pallet index 65, call index 7. -fn encode_submit_pair_price(pair_id: H256, entries: Vec) -> Vec { +fn encode_submit_pair_price(pair_id: H256, side: Side, entries: Vec) -> Vec { let mut data = vec![65u8, 7u8]; // pallet_index, call_index data.extend_from_slice(&pair_id.encode()); + data.extend_from_slice(&side.encode()); data.extend_from_slice(&entries.encode()); data } -/// Manually encode a call to `IntentsCoprocessor::add_recognized_pair`. -/// Pallet index 65, call index 8. -fn encode_add_recognized_pair(pair_id: H256) -> Vec { - let mut data = vec![65u8, 8u8]; - data.extend_from_slice(&pair_id.encode()); - data -} - /// Manually encode a call to `IntentsCoprocessor::set_price_deposit_amount`. /// Pallet index 65, call index 11. fn encode_set_price_deposit_amount(amount: u128) -> Vec { @@ -104,9 +97,10 @@ fn encode_set_price_deposit_lock_duration(duration_blocks: u32) -> Vec { /// Manually encode a call to `IntentsCoprocessor::withdraw_price_deposit`. /// Pallet index 65, call index 13. -fn encode_withdraw_price_deposit(pair_id: H256) -> Vec { +fn encode_withdraw_price_deposit(pair_id: H256, side: Side) -> Vec { let mut data = vec![65u8, 13u8]; data.extend_from_slice(&pair_id.encode()); + data.extend_from_slice(&side.encode()); data } @@ -146,9 +140,7 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { PriceInput { amount: U256::from(1000) * one_unit, price: U256::from(1420) * one_unit }, ]; - // Add recognized pair - sudo_raw_and_finalize(&client, &rpc_client, encode_add_recognized_pair(pair_id)).await?; - println!("Recognized pair added: {pair_id:?}"); + let side = Side::Ask; // Set deposit amount sudo_raw_and_finalize(&client, &rpc_client, encode_set_price_deposit_amount(deposit_amount)) @@ -165,13 +157,13 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { println!("Lock duration set: {lock_duration} blocks"); // Submit prices - let submit_call_data = encode_submit_pair_price(pair_id, price_entries); + let submit_call_data = encode_submit_pair_price(pair_id, side, price_entries); submit_raw_and_finalize(&client, &rpc_client, submit_call_data, Keyring::Alice).await?; println!("Prices submitted for pair {pair_id:?}"); // Query prices via RPC let prices: Vec = - rpc_client.request("intents_getPairPrices", rpc_params![pair_id]).await?; + rpc_client.request("intents_getPairPrices", rpc_params![pair_id, "ask"]).await?; assert_eq!(prices.len(), 2, "expected 2 price entries"); @@ -191,7 +183,7 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { submit_raw_and_finalize( &client, &rpc_client, - encode_withdraw_price_deposit(pair_id), + encode_withdraw_price_deposit(pair_id, side), Keyring::Alice, ) .await?; @@ -201,7 +193,7 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { let early_result = submit_raw_and_finalize( &client, &rpc_client, - encode_withdraw_price_deposit(pair_id), + encode_withdraw_price_deposit(pair_id, side), Keyring::Alice, ) .await; @@ -216,7 +208,7 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { submit_raw_and_finalize( &client, &rpc_client, - encode_withdraw_price_deposit(pair_id), + encode_withdraw_price_deposit(pair_id, side), Keyring::Alice, ) .await?; @@ -226,7 +218,7 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { let gone_result = submit_raw_and_finalize( &client, &rpc_client, - encode_withdraw_price_deposit(pair_id), + encode_withdraw_price_deposit(pair_id, side), Keyring::Alice, ) .await; diff --git a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts index bff54fbd8..8a006e568 100644 --- a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts +++ b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts @@ -6,6 +6,7 @@ import { decodeAddress, keccakAsU8a } from "@polkadot/util-crypto" import { numberToBytes, bytesToBigInt } from "viem" import { Bytes, Struct, u8, Vector } from "scale-ts" import type { BidSubmissionResult, HexString, PackedUserOperation, BidStorageEntry, FillerBid, PriceInput, Quote } from "@/types" +import { PriceSide } from "@/types" import { interpolatePrice } from "@/utils/interpolate" import type { SubstrateChain } from "./substrate" @@ -350,7 +351,7 @@ export class IntentsCoprocessor { return (this.api.consts.intentsCoprocessor as any).maxPriceEntries.toNumber() } - async submitPairPrice(pairId: HexString, entries: PriceInput[]): Promise { + async submitPairPrice(pairId: HexString, side: PriceSide, entries: PriceInput[]): Promise { try { // Encode entries as a Vec of { amount, price } structs for the pallet const encodedEntries = entries.map((e) => ({ @@ -358,7 +359,7 @@ export class IntentsCoprocessor { price: e.price.toString(), })) - const extrinsic = this.api.tx.intentsCoprocessor.submitPairPrice(pairId, encodedEntries) + const extrinsic = this.api.tx.intentsCoprocessor.submitPairPrice(pairId, side, encodedEntries) return await this.signAndSendExtrinsic(extrinsic) } catch (error) { return { @@ -377,9 +378,9 @@ export class IntentsCoprocessor { * @param pairId - The token pair identifier (H256 / bytes32) * @returns BidSubmissionResult with success status and block/extrinsic hash */ - async withdrawPriceDeposit(pairId: HexString): Promise { + async withdrawPriceDeposit(pairId: HexString, side: PriceSide): Promise { try { - const extrinsic = this.api.tx.intentsCoprocessor.withdrawPriceDeposit(pairId) + const extrinsic = this.api.tx.intentsCoprocessor.withdrawPriceDeposit(pairId, side) return await this.signAndSendExtrinsic(extrinsic) } catch (error) { return { @@ -398,10 +399,10 @@ export class IntentsCoprocessor { * @param amount - The base token amount to get quotes for (human-readable number, e.g. 500 for 500 tokens) * @returns Array of Quote objects, one per filler who has price entries for this pair */ - async getQuotes(pairId: HexString, amount: number): Promise { + async getQuotes(pairId: HexString, side: PriceSide, amount: number): Promise { const entries: RpcPriceEntry[] = await (this.api as any)._rpcCore.provider.send( "intents_getPairPrices", - [pairId], + [pairId, side.toLowerCase()], ) if (entries.length === 0) return [] diff --git a/sdk/packages/sdk/src/types/index.ts b/sdk/packages/sdk/src/types/index.ts index ddb6c0bad..1ae3fa114 100644 --- a/sdk/packages/sdk/src/types/index.ts +++ b/sdk/packages/sdk/src/types/index.ts @@ -1355,6 +1355,12 @@ export type IntentOrderStatusUpdate = * All values are raw 18-decimal bigints as expected by the pallet. * The frontend determines ranges from the curve points. */ +/** Side of a price quote — matches the pallet's `Side` enum */ +export enum PriceSide { + Bid = "Bid", + Ask = "Ask", +} + export interface PriceInput { amount: bigint price: bigint diff --git a/sdk/packages/simplex/src/strategies/fx.ts b/sdk/packages/simplex/src/strategies/fx.ts index 7d566f242..be81179ea 100644 --- a/sdk/packages/simplex/src/strategies/fx.ts +++ b/sdk/packages/simplex/src/strategies/fx.ts @@ -10,6 +10,7 @@ import { adjustDecimals, ADDRESS_ZERO, type PriceInput, + PriceSide, } from "@hyperbridge/sdk" import { privateKeyToAccount } from "viem/accounts" import { ChainClientManager, ContractInteractionService } from "@/services" @@ -174,12 +175,12 @@ export class FXFiller implements FillerStrategy { // Ask pair const askPairId = this.computeSymbolPairId(stableSymbol, exoticSymbol) const askEntries = this.buildAskPriceEntries() - await this.submitEntriesInChunks(cp, askPairId, askEntries, maxEntries, "ask") + await this.submitEntriesInChunks(cp, askPairId, PriceSide.Ask, askEntries, maxEntries, "ask") // Bid pair const bidPairId = this.computeSymbolPairId(exoticSymbol, stableSymbol) const bidEntries = this.buildBidPriceEntries() - await this.submitEntriesInChunks(cp, bidPairId, bidEntries, maxEntries, "bid") + await this.submitEntriesInChunks(cp, bidPairId, PriceSide.Bid, bidEntries, maxEntries, "bid") } catch (err) { this.logger.error({ err }, "Error submitting initial prices") } @@ -191,6 +192,7 @@ export class FXFiller implements FillerStrategy { private async submitEntriesInChunks( cp: IntentsCoprocessor, pairId: HexString, + side: PriceSide, entries: PriceInput[], maxEntries: number, direction: string, @@ -208,7 +210,7 @@ export class FXFiller implements FillerStrategy { ) for (let i = 0; i < chunks.length; i++) { - const result = await cp.submitPairPrice(pairId, chunks[i]) + const result = await cp.submitPairPrice(pairId, side, chunks[i]) if (result.success) { this.logger.info( { pairId, direction, chunk: i + 1, of: chunks.length, blockHash: result.blockHash, entryCount: chunks[i].length }, From 93e5fe688c4768524211ca5d8c6e81979e9ba71b Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Fri, 20 Mar 2026 13:58:51 +0100 Subject: [PATCH 24/28] interpolate --- sdk/packages/sdk/src/utils/interpolate.ts | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 sdk/packages/sdk/src/utils/interpolate.ts diff --git a/sdk/packages/sdk/src/utils/interpolate.ts b/sdk/packages/sdk/src/utils/interpolate.ts new file mode 100644 index 000000000..bf3db0cda --- /dev/null +++ b/sdk/packages/sdk/src/utils/interpolate.ts @@ -0,0 +1,35 @@ +/** + * Piecewise linear interpolation over sorted (amount, price) points. + * Below the minimum amount, returns the first point's price. + * Above the maximum amount, returns the last point's price. + * Between two points, linearly interpolates. + * + * @param points - Array of { amount, price } sorted by amount ascending + * @param amount - The input amount to interpolate at + * @returns The interpolated price + */ +export function interpolatePrice(points: { amount: number; price: number }[], amount: number): number { + if (points.length === 0) { + throw new Error("interpolatePrice: points array must not be empty") + } + + if (points.length === 1 || amount <= points[0].amount) { + return points[0].price + } + + const last = points[points.length - 1] + if (amount >= last.amount) { + return last.price + } + + for (let i = 0; i < points.length - 1; i++) { + const p1 = points[i] + const p2 = points[i + 1] + if (amount >= p1.amount && amount <= p2.amount) { + const t = (amount - p1.amount) / (p2.amount - p1.amount) + return p1.price + t * (p2.price - p1.price) + } + } + + return last.price +} From 03dff7655b8e58eae17f313b7566b00b6adc3732 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Fri, 20 Mar 2026 14:37:20 +0100 Subject: [PATCH 25/28] revert side --- .../intents-coprocessor/rpc/src/lib.rs | 21 ++--- .../intents-coprocessor/src/benchmarking.rs | 20 ++--- .../pallets/intents-coprocessor/src/lib.rs | 87 +++++++------------ .../pallets/intents-coprocessor/src/tests.rs | 80 ++++------------- .../pallets/intents-coprocessor/src/types.rs | 20 ----- .../src/weights/pallet_intents_coprocessor.rs | 58 +++++++------ .../src/weights/pallet_intents_coprocessor.rs | 58 +++++++------ parachain/simtests/src/price_submission.rs | 22 ++--- .../sdk/src/chains/intentsCoprocessor.ts | 13 ++- sdk/packages/sdk/src/types/index.ts | 6 -- sdk/packages/simplex/src/strategies/fx.ts | 8 +- 11 files changed, 142 insertions(+), 251 deletions(-) diff --git a/modules/pallets/intents-coprocessor/rpc/src/lib.rs b/modules/pallets/intents-coprocessor/rpc/src/lib.rs index ba8476bb3..553fddf3a 100644 --- a/modules/pallets/intents-coprocessor/rpc/src/lib.rs +++ b/modules/pallets/intents-coprocessor/rpc/src/lib.rs @@ -188,12 +188,12 @@ fn runtime_error_into_rpc_error(e: impl std::fmt::Display) -> ErrorObjectOwned { ErrorObject::owned(9877, format!("{e}"), None::) } -/// Construct the full storage key for a `StorageMap` entry with `Blake2_128Concat` hasher, -/// using raw pre-encoded key bytes. -fn storage_map_key_raw(pallet: &[u8], storage: &[u8], map_key_bytes: &[u8]) -> Vec { +/// Construct the full storage key for a `StorageMap` entry with `Blake2_128Concat` hasher. +fn storage_map_key(pallet: &[u8], storage: &[u8], map_key: &H256) -> Vec { let mut key = Vec::new(); key.extend_from_slice(&sp_core::hashing::twox_128(pallet)); key.extend_from_slice(&sp_core::hashing::twox_128(storage)); + let map_key_bytes = map_key.as_bytes(); key.extend_from_slice(&sp_core::hashing::blake2_128(map_key_bytes)); key.extend_from_slice(map_key_bytes); key @@ -217,9 +217,9 @@ pub trait IntentsApi { #[method(name = "intents_getBidsForOrder")] fn get_bids_for_order(&self, commitment: H256) -> RpcResult>; - /// Get all prices for a token pair and side ("bid" or "ask") + /// Get all prices for a token pair #[method(name = "intents_getPairPrices")] - fn get_pair_prices(&self, pair_id: H256, side: String) -> RpcResult>; + fn get_pair_prices(&self, pair_id: H256) -> RpcResult>; #[subscription(name = "intents_subscribeBids" => "intents_bidNotification", unsubscribe = "intents_unsubscribeBids", item = RpcBidInfo)] async fn subscribe_bids(&self, commitment: Option) -> SubscriptionResult; @@ -304,17 +304,10 @@ where Ok(bids.into_iter().collect()) } - fn get_pair_prices(&self, pair_id: H256, side: String) -> RpcResult> { + fn get_pair_prices(&self, pair_id: H256) -> RpcResult> { let best_hash = self.client.info().best_hash; - let side_enum = match side.to_lowercase().as_str() { - "bid" => pallet_intents_coprocessor::types::Side::Bid, - "ask" => pallet_intents_coprocessor::types::Side::Ask, - _ => - return Err(runtime_error_into_rpc_error("Invalid side: must be \"bid\" or \"ask\"")), - }; - let composite_key = (pair_id, side_enum).encode(); - let key = storage_map_key_raw(b"IntentsCoprocessor", b"Prices", &composite_key); + let key = storage_map_key(b"IntentsCoprocessor", b"Prices", &pair_id); let storage_key = sp_core::storage::StorageKey(key); let data = match self.client.storage(best_hash, &storage_key) { diff --git a/modules/pallets/intents-coprocessor/src/benchmarking.rs b/modules/pallets/intents-coprocessor/src/benchmarking.rs index dde064cd1..1a7ce1776 100644 --- a/modules/pallets/intents-coprocessor/src/benchmarking.rs +++ b/modules/pallets/intents-coprocessor/src/benchmarking.rs @@ -28,7 +28,7 @@ use frame_system::RawOrigin; use ismp::host::StateMachine; use primitive_types::{H160, H256, U256}; use sp_runtime::traits::ConstU32; -use types::{PriceInput, Side}; +use types::PriceInput; #[benchmarks( where @@ -204,7 +204,6 @@ mod benchmarks { fn submit_pair_price(n: Linear<1, 100>) { let caller: T::AccountId = whitelisted_caller(); let pair_id = H256::repeat_byte(0xaa); - let side = Side::Ask; // Use a large balance to cover existential deposit + price deposit on any runtime let balance = BalanceOf::::from(u32::MAX); @@ -225,9 +224,9 @@ mod benchmarks { entries_vec.try_into().expect("entries fit in bounds"); #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), pair_id, side, entries); + _(RawOrigin::Signed(caller.clone()), pair_id, entries); - assert!(!Prices::::get(&(pair_id, side)).is_empty()); + assert!(!Prices::::get(&pair_id).is_empty()); } #[benchmark] @@ -270,7 +269,6 @@ mod benchmarks { fn withdraw_price_deposit() { let caller: T::AccountId = whitelisted_caller(); let pair_id = H256::repeat_byte(0xdd); - let side = Side::Ask; // Use a large balance to cover existential deposit + price deposit on any runtime let balance = BalanceOf::::from(u32::MAX); @@ -288,22 +286,18 @@ mod benchmarks { let _ = Pallet::::submit_pair_price( RawOrigin::Signed(caller.clone()).into(), pair_id, - side, entries, ); - let _ = Pallet::::withdraw_price_deposit( - RawOrigin::Signed(caller.clone()).into(), - pair_id, - side, - ); + let _ = + Pallet::::withdraw_price_deposit(RawOrigin::Signed(caller.clone()).into(), pair_id); frame_system::Pallet::::set_block_number(100u32.into()); #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), pair_id, side); + _(RawOrigin::Signed(caller.clone()), pair_id); - assert!(PriceDeposits::::get(&caller, &(pair_id, side)).is_none()); + assert!(PriceDeposits::::get(&caller, &pair_id).is_none()); } 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 02350d65d..919ef67ff 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -46,7 +46,7 @@ use sp_runtime::{ pub use weights::WeightInfo; use types::{ - Bid, GatewayInfo, IntentGatewayParams, PriceEntry, PriceInput, RequestKind, Side, + Bid, GatewayInfo, IntentGatewayParams, PriceEntry, PriceInput, RequestKind, TokenDecimalsUpdate, TokenInfo, }; @@ -138,12 +138,12 @@ pub mod pallet { #[pallet::storage] pub type PriceWindowDurationValue = StorageValue<_, u64, ValueQuery>; - /// Price entries per (pair, side) + /// Price entries per pair #[pallet::storage] pub type Prices = - CountedStorageMap<_, Blake2_128Concat, (H256, Side), BTreeSet, ValueQuery>; + CountedStorageMap<_, Blake2_128Concat, H256, BTreeSet, ValueQuery>; - /// Deposits reserved by price submitters. Maps (account, (pair_id, side)) to + /// Deposits reserved by price submitters. Maps (account, pair_id) to /// (deposit_amount, unlock_block). When `unlock_block` is `None`, the withdrawal /// has not been initiated. The first call to `withdraw_price_deposit` sets /// `unlock_block` to `current_block + PriceDepositLockDuration`. The second @@ -154,7 +154,7 @@ pub mod pallet { Blake2_128Concat, T::AccountId, Blake2_128Concat, - (H256, Side), // (pair_id, side) + H256, // pair_id (BalanceOf, Option>), // (deposit_amount, unlock_block) OptionQuery, >; @@ -169,12 +169,12 @@ pub mod pallet { #[pallet::storage] pub type PriceDepositLockDuration = StorageValue<_, BlockNumberFor, ValueQuery>; - /// Whether prices have been cleared for a given (pair, side) in the current window. + /// Whether prices have been cleared for a given pair in the current window. /// All entries are removed by `on_initialize` when a new window starts. - /// Set to true on the first price submission for that (pair, side) in the new window. + /// Set to true on the first price submission for that pair in the new window. #[pallet::storage] pub type PricesClearedThisWindow = - StorageMap<_, Blake2_128Concat, (H256, Side), bool, ValueQuery>; + StorageMap<_, Blake2_128Concat, H256, bool, ValueQuery>; #[pallet::hooks] impl Hooks> for Pallet @@ -232,7 +232,7 @@ pub mod pallet { /// Storage deposit fee was updated StorageDepositFeeUpdated { fee: BalanceOf }, /// Prices were submitted for a token pair - PriceSubmitted { submitter: T::AccountId, pair_id: H256, side: Side }, + PriceSubmitted { submitter: T::AccountId, pair_id: H256 }, /// Price window duration was updated PriceWindowDurationUpdated { duration_ms: u64 }, /// Price deposit amount was updated @@ -240,26 +240,15 @@ pub mod pallet { /// Price deposit lock duration was updated (in blocks) PriceDepositLockDurationUpdated { duration_blocks: BlockNumberFor }, /// Price deposit was reserved on first submission - PriceDepositReserved { - submitter: T::AccountId, - pair_id: H256, - side: Side, - amount: BalanceOf, - }, + PriceDepositReserved { submitter: T::AccountId, pair_id: H256, amount: BalanceOf }, /// Price deposit withdrawal was initiated (unlock block noted) PriceDepositWithdrawalInitiated { submitter: T::AccountId, pair_id: H256, - side: Side, unlock_block: BlockNumberFor, }, /// Price deposit was withdrawn (tokens unreserved) - PriceDepositWithdrawn { - submitter: T::AccountId, - pair_id: H256, - side: Side, - amount: BalanceOf, - }, + PriceDepositWithdrawn { submitter: T::AccountId, pair_id: H256, amount: BalanceOf }, } #[pallet::error] @@ -569,11 +558,11 @@ pub mod pallet { Ok(()) } - /// Submit prices for a token pair on a given side (bid or ask). + /// Submit prices for a token pair. /// - /// On the first submission per (account, pair, side), a deposit is reserved + /// On the first submission per (account, pair), a deposit is reserved /// from the submitter's balance. Subsequent submissions for the same - /// (pair, side) are free. The deposit can be withdrawn after the configured + /// pair are free. The deposit can be withdrawn after the configured /// lock duration via `withdraw_price_deposit`. /// /// Each entry in `entries` specifies a base token amount threshold and the @@ -583,7 +572,6 @@ pub mod pallet { pub fn submit_pair_price( origin: OriginFor, pair_id: H256, - side: Side, entries: BoundedVec, ) -> DispatchResult { let submitter = ensure_signed(origin)?; @@ -593,35 +581,32 @@ pub mod pallet { let deposit_amount = PriceDepositAmount::::get(); ensure!(!deposit_amount.is_zero(), Error::::PriceDepositsNotConfigured); - let key = (pair_id, side); - - if let Some((_, Some(_unlock_block))) = PriceDeposits::::get(&submitter, &key) { + if let Some((_, Some(_unlock_block))) = PriceDeposits::::get(&submitter, &pair_id) { return Err(Error::::WithdrawalInProgress.into()); } - // Reserve deposit on first submission per (account, pair, side) - if !PriceDeposits::::contains_key(&submitter, &key) { + // Reserve deposit on first submission per (account, pair) + if !PriceDeposits::::contains_key(&submitter, &pair_id) { ::Currency::reserve(&submitter, deposit_amount) .map_err(|_| Error::::InsufficientBalance)?; PriceDeposits::::insert( &submitter, - &key, + &pair_id, (deposit_amount, None::>), ); Self::deposit_event(Event::PriceDepositReserved { submitter: submitter.clone(), pair_id, - side, amount: deposit_amount, }); } - Self::maybe_clear_stale_prices(&key); + Self::maybe_clear_stale_prices(&pair_id); let filler: H256 = H256::from_slice(&submitter.encode()[..32]); - Prices::::mutate(&key, |stored| { + Prices::::mutate(&pair_id, |stored| { stored.extend(entries.iter().map(|input| PriceEntry { amount: input.amount, price: input.price, @@ -629,7 +614,7 @@ pub mod pallet { })); }); - Self::deposit_event(Event::PriceSubmitted { submitter, pair_id, side }); + Self::deposit_event(Event::PriceSubmitted { submitter, pair_id }); Ok(()) } @@ -689,24 +674,18 @@ pub mod pallet { /// /// # Parameters /// - `pair_id`: The token pair the deposit was made for - /// - `side`: The side (bid or ask) the deposit was made for /// /// # Errors - /// - `DepositNotFound`: No deposit exists for this account, pair, and side + /// - `DepositNotFound`: No deposit exists for this account and pair /// - `WithdrawalAlreadyInitiated`: First call was already made (waiting for unlock) /// - `DepositStillLocked`: The unlock block has not yet been reached #[pallet::call_index(13)] #[pallet::weight(T::WeightInfo::withdraw_price_deposit())] - pub fn withdraw_price_deposit( - origin: OriginFor, - pair_id: H256, - side: Side, - ) -> DispatchResult { + pub fn withdraw_price_deposit(origin: OriginFor, pair_id: H256) -> DispatchResult { let who = ensure_signed(origin)?; - let key = (pair_id, side); let (deposit_amount, unlock_block) = - PriceDeposits::::get(&who, &key).ok_or(Error::::DepositNotFound)?; + PriceDeposits::::get(&who, &pair_id).ok_or(Error::::DepositNotFound)?; match unlock_block { None => { @@ -715,12 +694,11 @@ pub mod pallet { let lock_duration = PriceDepositLockDuration::::get(); let unlock_at = current_block.saturating_add(lock_duration); - PriceDeposits::::insert(&who, &key, (deposit_amount, Some(unlock_at))); + PriceDeposits::::insert(&who, &pair_id, (deposit_amount, Some(unlock_at))); Self::deposit_event(Event::PriceDepositWithdrawalInitiated { submitter: who, pair_id, - side, unlock_block: unlock_at, }); }, @@ -730,12 +708,11 @@ pub mod pallet { ensure!(current_block >= unlock_at, Error::::DepositStillLocked); ::Currency::unreserve(&who, deposit_amount); - PriceDeposits::::remove(&who, &key); + PriceDeposits::::remove(&who, &pair_id); Self::deposit_event(Event::PriceDepositWithdrawn { submitter: who, pair_id, - side, amount: deposit_amount, }); }, @@ -766,16 +743,16 @@ pub mod pallet { offchain_bid_key_raw(commitment, &filler.encode()) } - /// Clear prices for a specific (pair, side) if this is the first submission + /// Clear prices for a specific pair if this is the first submission /// in the current window. /// /// Prices from the previous window persist until the first new submission - /// for a given (pair, side) in the new window, at which point those entries + /// for a given pair in the new window, at which point those entries /// are cleared. - fn maybe_clear_stale_prices(key: &(H256, Side)) { - if !PricesClearedThisWindow::::get(key) { - Prices::::remove(key); - PricesClearedThisWindow::::insert(key, true); + fn maybe_clear_stale_prices(pair_id: &H256) { + if !PricesClearedThisWindow::::get(pair_id) { + Prices::::remove(pair_id); + PricesClearedThisWindow::::insert(pair_id, true); } } diff --git a/modules/pallets/intents-coprocessor/src/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index 1fbb65107..521479269 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -17,7 +17,7 @@ #![cfg(test)] -use crate::{self as pallet_intents, types::Side, *}; +use crate::{self as pallet_intents, *}; use alloc::{collections::BTreeSet, vec}; use codec::Decode; use frame_support::{ @@ -546,7 +546,6 @@ fn multiple_fillers_can_bid_on_same_order() { fn submit_pair_price_reserves_deposit_on_first_submission() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let side = Side::Ask; let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); @@ -565,7 +564,6 @@ fn submit_pair_price_reserves_deposit_on_first_submission() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, - side, entries, )); @@ -575,12 +573,12 @@ fn submit_pair_price_reserves_deposit_on_first_submission() { // Deposit record stored (no unlock block yet) let (stored_amount, unlock_block) = - PriceDeposits::::get(&submitter, &(pair_id, side)).unwrap(); + PriceDeposits::::get(&submitter, &pair_id).unwrap(); assert_eq!(stored_amount, deposit_amount); assert_eq!(unlock_block, None); // Price entry stored - let prices = Prices::::get(&(pair_id, side)); + let prices = Prices::::get(&pair_id); assert_eq!(prices.len(), 1); assert_eq!(prices.iter().next().unwrap().price, U256::from(2000)); }); @@ -590,7 +588,6 @@ fn submit_pair_price_reserves_deposit_on_first_submission() { fn submit_pair_price_second_submission_is_free() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let side = Side::Ask; let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); @@ -606,7 +603,6 @@ fn submit_pair_price_second_submission_is_free() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, - side, entries1, )); @@ -623,7 +619,6 @@ fn submit_pair_price_second_submission_is_free() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, - side, entries2, )); @@ -633,7 +628,7 @@ fn submit_pair_price_second_submission_is_free() { assert_eq!(Balances::reserved_balance(&submitter), deposit_amount); // Two entries now stored - let prices = Prices::::get(&(pair_id, side)); + let prices = Prices::::get(&pair_id); assert_eq!(prices.len(), 2); }); } @@ -655,12 +650,7 @@ fn submit_pair_price_fails_with_insufficient_balance() { .unwrap(); assert_noop!( - Intents::submit_pair_price( - RuntimeOrigin::signed(submitter), - pair_id, - Side::Ask, - entries, - ), + Intents::submit_pair_price(RuntimeOrigin::signed(submitter), pair_id, entries,), Error::::InsufficientBalance ); }); @@ -670,7 +660,6 @@ fn submit_pair_price_fails_with_insufficient_balance() { fn withdraw_price_deposit_two_phase() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let side = Side::Ask; let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); @@ -686,7 +675,6 @@ fn withdraw_price_deposit_two_phase() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, - side, entries, )); @@ -698,7 +686,6 @@ fn withdraw_price_deposit_two_phase() { assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id, - side, )); // Deposit is NOT yet unreserved @@ -706,17 +693,13 @@ fn withdraw_price_deposit_two_phase() { assert_eq!(Balances::reserved_balance(&submitter), deposit_amount); // Unlock block is set (1 + 10 = 11) - let (_, unlock_block) = PriceDeposits::::get(&submitter, &(pair_id, side)).unwrap(); + let (_, unlock_block) = PriceDeposits::::get(&submitter, &pair_id).unwrap(); assert_eq!(unlock_block, Some(11u64)); // Phase 2 too early: still locked at block 5 System::set_block_number(5); assert_noop!( - Intents::withdraw_price_deposit( - RuntimeOrigin::signed(submitter.clone()), - pair_id, - side, - ), + Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter.clone()), pair_id,), Error::::DepositStillLocked ); @@ -725,7 +708,6 @@ fn withdraw_price_deposit_two_phase() { assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id, - side, )); // Deposit unreserved @@ -733,7 +715,7 @@ fn withdraw_price_deposit_two_phase() { assert_eq!(Balances::reserved_balance(&submitter), 0); // Deposit record removed - assert!(PriceDeposits::::get(&submitter, &(pair_id, side)).is_none()); + assert!(PriceDeposits::::get(&submitter, &pair_id).is_none()); }); } @@ -741,7 +723,6 @@ fn withdraw_price_deposit_two_phase() { fn withdraw_price_deposit_phase2_fails_when_still_locked() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let side = Side::Ask; let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); @@ -757,7 +738,6 @@ fn withdraw_price_deposit_phase2_fails_when_still_locked() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, - side, entries, )); @@ -766,13 +746,12 @@ fn withdraw_price_deposit_phase2_fails_when_still_locked() { assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id, - side, )); // Phase 2: Try to complete at block 5 (unlock is at 11) System::set_block_number(5); assert_noop!( - Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter), pair_id, side,), + Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter), pair_id,), Error::::DepositStillLocked ); }); @@ -785,7 +764,7 @@ fn withdraw_price_deposit_fails_when_no_deposit() { let pair_id = H256::random(); assert_noop!( - Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter), pair_id, Side::Ask,), + Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter), pair_id,), Error::::DepositNotFound ); }); @@ -811,14 +790,12 @@ fn set_price_deposit_lock_duration_works() { fn prices_persist_across_window_and_clear_on_first_submission() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let side = Side::Ask; let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); - let key = (pair_id, side); // Simulate day 1: store some prices Prices::::insert( - &key, + &pair_id, BTreeSet::from([types::PriceEntry { amount: U256::zero(), price: U256::from(1666), @@ -833,14 +810,14 @@ fn prices_persist_across_window_and_clear_on_first_submission() { pallet_timestamp::Now::::put(50_000_000u64); // 50_000 seconds in ms Intents::on_initialize(1u64); - assert_eq!(Prices::::get(&key).len(), 1); + assert_eq!(Prices::::get(&pair_id).len(), 1); // Advance past the window (1000 + 86_400 = 87_400 seconds) pallet_timestamp::Now::::put(90_000_000u64); // 90_000 seconds in ms Intents::on_initialize(2u64); // Prices still persist (readable until first new submission) - assert_eq!(Prices::::get(&key).len(), 1); + assert_eq!(Prices::::get(&pair_id).len(), 1); assert_eq!(PriceWindowStart::::get(), 90_000); // Submit a new price — this is the first submission in the new window. @@ -853,12 +830,11 @@ fn prices_persist_across_window_and_clear_on_first_submission() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, - side, new_entries, )); // Old entries gone, only new entry remains - let prices = Prices::::get(&key); + let prices = Prices::::get(&pair_id); assert_eq!(prices.len(), 1); assert_eq!(prices.iter().next().unwrap().price, U256::from(2000)); }); @@ -898,8 +874,6 @@ fn price_entry_storage_roundtrip_via_raw_key() { let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); - let side = Side::Ask; - let storage_key = (pair_id, side); let entry1 = types::PriceEntry { amount: U256::zero(), @@ -912,7 +886,7 @@ fn price_entry_storage_roundtrip_via_raw_key() { filler: H256::from_low_u64_be(2), }; - Prices::::insert(&storage_key, BTreeSet::from([entry1.clone(), entry2.clone()])); + Prices::::insert(&pair_id, BTreeSet::from([entry1.clone(), entry2.clone()])); // Build the storage key the same way the RPC does. let pallet_prefix = b"Intents"; @@ -920,7 +894,7 @@ fn price_entry_storage_roundtrip_via_raw_key() { let mut key = Vec::new(); key.extend_from_slice(&sp_io::hashing::twox_128(pallet_prefix)); key.extend_from_slice(&sp_io::hashing::twox_128(b"Prices")); - let map_key_bytes = storage_key.encode(); + let map_key_bytes = pair_id.encode(); key.extend_from_slice(&sp_io::hashing::blake2_128(&map_key_bytes)); key.extend_from_slice(&map_key_bytes); @@ -943,7 +917,6 @@ fn multiple_submitters_independent_deposits() { new_test_ext().execute_with(|| { let submitter1 = AccountId32::new([1; 32]); let submitter2 = AccountId32::new([2; 32]); - let side = Side::Ask; let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); @@ -968,13 +941,11 @@ fn multiple_submitters_independent_deposits() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter1.clone()), pair_id, - side, entries1, )); assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter2.clone()), pair_id, - side, entries2, )); @@ -983,7 +954,7 @@ fn multiple_submitters_independent_deposits() { assert_eq!(Balances::reserved_balance(&submitter2), deposit_amount); // Two entries in prices - assert_eq!(Prices::::get(&(pair_id, side)).len(), 2); + assert_eq!(Prices::::get(&pair_id).len(), 2); }); } @@ -991,7 +962,6 @@ fn multiple_submitters_independent_deposits() { fn separate_deposits_per_pair() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let side = Side::Ask; let pair_id1 = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); @@ -1011,13 +981,11 @@ fn separate_deposits_per_pair() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id1, - side, entries.clone(), )); assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id2, - side, entries, )); @@ -1029,12 +997,10 @@ fn separate_deposits_per_pair() { assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id1, - side, )); assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id2, - side, )); // Phase 2: Complete both after lock duration (block 11) @@ -1042,14 +1008,12 @@ fn separate_deposits_per_pair() { assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id1, - side, )); assert_eq!(Balances::reserved_balance(&submitter), deposit_amount); assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id2, - side, )); assert_eq!(Balances::reserved_balance(&submitter), 0); }); @@ -1060,7 +1024,6 @@ fn submit_pair_price_blocked_after_withdrawal_initiated() { new_test_ext().execute_with(|| { let submitter: AccountId = AccountId::from([1u8; 32]); let deposit_amount = 100u64; - let side = Side::Ask; let pair = types::TokenPair { base: b"TOKEN_X".to_vec(), quote: b"TOKEN_Y".to_vec() }; let pair_id = pair.pair_id(); @@ -1076,7 +1039,6 @@ fn submit_pair_price_blocked_after_withdrawal_initiated() { assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter.clone()), pair_id, - side, entries.clone(), )); @@ -1085,17 +1047,11 @@ fn submit_pair_price_blocked_after_withdrawal_initiated() { assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id, - side, )); // Submitting prices should now fail assert_noop!( - Intents::submit_pair_price( - RuntimeOrigin::signed(submitter.clone()), - pair_id, - side, - entries, - ), + Intents::submit_pair_price(RuntimeOrigin::signed(submitter.clone()), pair_id, entries,), Error::::WithdrawalInProgress ); }); diff --git a/modules/pallets/intents-coprocessor/src/types.rs b/modules/pallets/intents-coprocessor/src/types.rs index 7beaf1726..24f859b3e 100644 --- a/modules/pallets/intents-coprocessor/src/types.rs +++ b/modules/pallets/intents-coprocessor/src/types.rs @@ -142,26 +142,6 @@ pub struct Bid { /// The signed user operation (opaque bytes) pub user_op: Vec, } -/// Represents the side of a price quote (bid or ask). -#[derive( - Clone, - Copy, - Debug, - Encode, - Decode, - DecodeWithMemTracking, - TypeInfo, - PartialEq, - Eq, - PartialOrd, - Ord, -)] -pub enum Side { - /// Bid side: the price a filler is willing to pay to buy the base token. - Bid, - /// Ask side: the price a filler wants to sell the base token at. - Ask, -} /// A recognized token pair for price tracking #[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] diff --git a/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs b/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs index ef60d2f14..7f1d32d12 100644 --- a/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs +++ b/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs @@ -60,8 +60,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `109` // Estimated: `3574` - // Minimum execution time: 39_245_000 picoseconds. - Weight::from_parts(39_935_000, 0) + // Minimum execution time: 39_334_000 picoseconds. + Weight::from_parts(40_928_000, 0) .saturating_add(Weight::from_parts(0, 3574)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -72,8 +72,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `247` // Estimated: `3712` - // Minimum execution time: 37_651_000 picoseconds. - Weight::from_parts(38_523_000, 0) + // Minimum execution time: 38_442_000 picoseconds. + Weight::from_parts(39_195_000, 0) .saturating_add(Weight::from_parts(0, 3712)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) @@ -84,8 +84,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `113` // Estimated: `6053` - // Minimum execution time: 21_871_000 picoseconds. - Weight::from_parts(22_383_000, 0) + // Minimum execution time: 22_392_000 picoseconds. + Weight::from_parts(22_934_000, 0) .saturating_add(Weight::from_parts(0, 6053)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -108,8 +108,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 72_057_000 picoseconds. - Weight::from_parts(73_149_000, 0) + // Minimum execution time: 73_059_000 picoseconds. + Weight::from_parts(73_890_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(5)) @@ -132,8 +132,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 64_162_000 picoseconds. - Weight::from_parts(65_244_000, 0) + // Minimum execution time: 64_562_000 picoseconds. + Weight::from_parts(65_995_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -156,8 +156,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 67_879_000 picoseconds. - Weight::from_parts(68_460_000, 0) + // Minimum execution time: 69_372_000 picoseconds. + Weight::from_parts(70_073_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -168,8 +168,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_324_000 picoseconds. - Weight::from_parts(8_386_000, 0) + // Minimum execution time: 8_296_000 picoseconds. + Weight::from_parts(8_607_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -184,13 +184,15 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// Storage: `IntentsCoprocessor::CounterForPrices` (r:1 w:1) /// Proof: `IntentsCoprocessor::CounterForPrices` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// The range of component `n` is `[1, 100]`. - fn submit_pair_price(_n: u32, ) -> Weight { + fn submit_pair_price(n: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `200` // Estimated: `3665` - // Minimum execution time: 56_608_000 picoseconds. - Weight::from_parts(67_128_306, 0) + // Minimum execution time: 58_340_000 picoseconds. + Weight::from_parts(65_029_669, 0) .saturating_add(Weight::from_parts(0, 3665)) + // Standard Error: 1_597 + .saturating_add(Weight::from_parts(21_027, 0).saturating_mul(n.into())) .saturating_add(T::DbWeight::get().reads(5)) .saturating_add(T::DbWeight::get().writes(4)) } @@ -200,8 +202,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_214_000 picoseconds. - Weight::from_parts(7_494_000, 0) + // Minimum execution time: 8_086_000 picoseconds. + Weight::from_parts(8_576_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -211,8 +213,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_274_000 picoseconds. - Weight::from_parts(7_574_000, 0) + // Minimum execution time: 8_255_000 picoseconds. + Weight::from_parts(8_446_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -222,8 +224,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_294_000 picoseconds. - Weight::from_parts(7_795_000, 0) + // Minimum execution time: 8_305_000 picoseconds. + Weight::from_parts(8_626_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -231,11 +233,11 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// Proof: `IntentsCoprocessor::PriceDeposits` (`max_values`: None, `max_size`: None, mode: `Measured`) fn withdraw_price_deposit() -> Weight { // Proof Size summary in bytes: - // Measured: `406` - // Estimated: `3871` - // Minimum execution time: 35_447_000 picoseconds. - Weight::from_parts(36_078_000, 0) - .saturating_add(Weight::from_parts(0, 3871)) + // Measured: `405` + // Estimated: `3870` + // Minimum execution time: 38_613_000 picoseconds. + Weight::from_parts(39_174_000, 0) + .saturating_add(Weight::from_parts(0, 3870)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } diff --git a/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs b/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs index 59dff292a..6ae022818 100644 --- a/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs +++ b/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs @@ -60,8 +60,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `175` // Estimated: `3640` - // Minimum execution time: 34_555_000 picoseconds. - Weight::from_parts(39_876_000, 0) + // Minimum execution time: 39_736_000 picoseconds. + Weight::from_parts(40_187_000, 0) .saturating_add(Weight::from_parts(0, 3640)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -72,8 +72,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `313` // Estimated: `3778` - // Minimum execution time: 37_992_000 picoseconds. - Weight::from_parts(38_793_000, 0) + // Minimum execution time: 38_624_000 picoseconds. + Weight::from_parts(39_235_000, 0) .saturating_add(Weight::from_parts(0, 3778)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) @@ -84,8 +84,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `179` // Estimated: `6119` - // Minimum execution time: 22_162_000 picoseconds. - Weight::from_parts(22_663_000, 0) + // Minimum execution time: 22_412_000 picoseconds. + Weight::from_parts(22_933_000, 0) .saturating_add(Weight::from_parts(0, 6119)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -108,8 +108,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `899` // Estimated: `4364` - // Minimum execution time: 73_450_000 picoseconds. - Weight::from_parts(74_451_000, 0) + // Minimum execution time: 73_529_000 picoseconds. + Weight::from_parts(75_032_000, 0) .saturating_add(Weight::from_parts(0, 4364)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(5)) @@ -132,8 +132,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `899` // Estimated: `4364` - // Minimum execution time: 65_384_000 picoseconds. - Weight::from_parts(66_656_000, 0) + // Minimum execution time: 64_422_000 picoseconds. + Weight::from_parts(65_855_000, 0) .saturating_add(Weight::from_parts(0, 4364)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -156,8 +156,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `899` // Estimated: `4364` - // Minimum execution time: 68_961_000 picoseconds. - Weight::from_parts(69_752_000, 0) + // Minimum execution time: 68_440_000 picoseconds. + Weight::from_parts(69_462_000, 0) .saturating_add(Weight::from_parts(0, 4364)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -168,8 +168,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 8_396_000 picoseconds. - Weight::from_parts(8_646_000, 0) + // Minimum execution time: 8_345_000 picoseconds. + Weight::from_parts(8_526_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -184,13 +184,15 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// Storage: `IntentsCoprocessor::CounterForPrices` (r:1 w:1) /// Proof: `IntentsCoprocessor::CounterForPrices` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// The range of component `n` is `[1, 100]`. - fn submit_pair_price(_n: u32, ) -> Weight { + fn submit_pair_price(n: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `266` // Estimated: `3731` - // Minimum execution time: 60_385_000 picoseconds. - Weight::from_parts(65_241_811, 0) + // Minimum execution time: 59_603_000 picoseconds. + Weight::from_parts(65_356_482, 0) .saturating_add(Weight::from_parts(0, 3731)) + // Standard Error: 2_003 + .saturating_add(Weight::from_parts(22_766, 0).saturating_mul(n.into())) .saturating_add(T::DbWeight::get().reads(5)) .saturating_add(T::DbWeight::get().writes(4)) } @@ -200,8 +202,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_174_000 picoseconds. - Weight::from_parts(7_414_000, 0) + // Minimum execution time: 8_326_000 picoseconds. + Weight::from_parts(8_596_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -211,8 +213,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_334_000 picoseconds. - Weight::from_parts(7_595_000, 0) + // Minimum execution time: 8_356_000 picoseconds. + Weight::from_parts(8_746_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -222,8 +224,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_404_000 picoseconds. - Weight::from_parts(8_466_000, 0) + // Minimum execution time: 8_486_000 picoseconds. + Weight::from_parts(8_786_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -231,11 +233,11 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// Proof: `IntentsCoprocessor::PriceDeposits` (`max_values`: None, `max_size`: None, mode: `Measured`) fn withdraw_price_deposit() -> Weight { // Proof Size summary in bytes: - // Measured: `472` - // Estimated: `3937` - // Minimum execution time: 35_127_000 picoseconds. - Weight::from_parts(40_317_000, 0) - .saturating_add(Weight::from_parts(0, 3937)) + // Measured: `471` + // Estimated: `3936` + // Minimum execution time: 39_645_000 picoseconds. + Weight::from_parts(40_337_000, 0) + .saturating_add(Weight::from_parts(0, 3936)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } diff --git a/parachain/simtests/src/price_submission.rs b/parachain/simtests/src/price_submission.rs index bb0d43f6f..45c5f2cfd 100644 --- a/parachain/simtests/src/price_submission.rs +++ b/parachain/simtests/src/price_submission.rs @@ -3,7 +3,7 @@ use std::env; use codec::Encode; -use pallet_intents_coprocessor::types::{PriceInput, Side, TokenPair}; +use pallet_intents_coprocessor::types::{PriceInput, TokenPair}; use pallet_intents_rpc::RpcPriceEntry; use polkadot_sdk::*; use primitive_types::{H256, U256}; @@ -71,10 +71,9 @@ async fn advance_blocks( /// Manually encode a call to `IntentsCoprocessor::submit_pair_price`. /// Pallet index 65, call index 7. -fn encode_submit_pair_price(pair_id: H256, side: Side, entries: Vec) -> Vec { +fn encode_submit_pair_price(pair_id: H256, entries: Vec) -> Vec { let mut data = vec![65u8, 7u8]; // pallet_index, call_index data.extend_from_slice(&pair_id.encode()); - data.extend_from_slice(&side.encode()); data.extend_from_slice(&entries.encode()); data } @@ -97,10 +96,9 @@ fn encode_set_price_deposit_lock_duration(duration_blocks: u32) -> Vec { /// Manually encode a call to `IntentsCoprocessor::withdraw_price_deposit`. /// Pallet index 65, call index 13. -fn encode_withdraw_price_deposit(pair_id: H256, side: Side) -> Vec { +fn encode_withdraw_price_deposit(pair_id: H256) -> Vec { let mut data = vec![65u8, 13u8]; data.extend_from_slice(&pair_id.encode()); - data.extend_from_slice(&side.encode()); data } @@ -140,8 +138,6 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { PriceInput { amount: U256::from(1000) * one_unit, price: U256::from(1420) * one_unit }, ]; - let side = Side::Ask; - // Set deposit amount sudo_raw_and_finalize(&client, &rpc_client, encode_set_price_deposit_amount(deposit_amount)) .await?; @@ -157,13 +153,13 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { println!("Lock duration set: {lock_duration} blocks"); // Submit prices - let submit_call_data = encode_submit_pair_price(pair_id, side, price_entries); + let submit_call_data = encode_submit_pair_price(pair_id, price_entries); submit_raw_and_finalize(&client, &rpc_client, submit_call_data, Keyring::Alice).await?; println!("Prices submitted for pair {pair_id:?}"); // Query prices via RPC let prices: Vec = - rpc_client.request("intents_getPairPrices", rpc_params![pair_id, "ask"]).await?; + rpc_client.request("intents_getPairPrices", rpc_params![pair_id]).await?; assert_eq!(prices.len(), 2, "expected 2 price entries"); @@ -183,7 +179,7 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { submit_raw_and_finalize( &client, &rpc_client, - encode_withdraw_price_deposit(pair_id, side), + encode_withdraw_price_deposit(pair_id), Keyring::Alice, ) .await?; @@ -193,7 +189,7 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { let early_result = submit_raw_and_finalize( &client, &rpc_client, - encode_withdraw_price_deposit(pair_id, side), + encode_withdraw_price_deposit(pair_id), Keyring::Alice, ) .await; @@ -208,7 +204,7 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { submit_raw_and_finalize( &client, &rpc_client, - encode_withdraw_price_deposit(pair_id, side), + encode_withdraw_price_deposit(pair_id), Keyring::Alice, ) .await?; @@ -218,7 +214,7 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { let gone_result = submit_raw_and_finalize( &client, &rpc_client, - encode_withdraw_price_deposit(pair_id, side), + encode_withdraw_price_deposit(pair_id), Keyring::Alice, ) .await; diff --git a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts index 8a006e568..bff54fbd8 100644 --- a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts +++ b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts @@ -6,7 +6,6 @@ import { decodeAddress, keccakAsU8a } from "@polkadot/util-crypto" import { numberToBytes, bytesToBigInt } from "viem" import { Bytes, Struct, u8, Vector } from "scale-ts" import type { BidSubmissionResult, HexString, PackedUserOperation, BidStorageEntry, FillerBid, PriceInput, Quote } from "@/types" -import { PriceSide } from "@/types" import { interpolatePrice } from "@/utils/interpolate" import type { SubstrateChain } from "./substrate" @@ -351,7 +350,7 @@ export class IntentsCoprocessor { return (this.api.consts.intentsCoprocessor as any).maxPriceEntries.toNumber() } - async submitPairPrice(pairId: HexString, side: PriceSide, entries: PriceInput[]): Promise { + async submitPairPrice(pairId: HexString, entries: PriceInput[]): Promise { try { // Encode entries as a Vec of { amount, price } structs for the pallet const encodedEntries = entries.map((e) => ({ @@ -359,7 +358,7 @@ export class IntentsCoprocessor { price: e.price.toString(), })) - const extrinsic = this.api.tx.intentsCoprocessor.submitPairPrice(pairId, side, encodedEntries) + const extrinsic = this.api.tx.intentsCoprocessor.submitPairPrice(pairId, encodedEntries) return await this.signAndSendExtrinsic(extrinsic) } catch (error) { return { @@ -378,9 +377,9 @@ export class IntentsCoprocessor { * @param pairId - The token pair identifier (H256 / bytes32) * @returns BidSubmissionResult with success status and block/extrinsic hash */ - async withdrawPriceDeposit(pairId: HexString, side: PriceSide): Promise { + async withdrawPriceDeposit(pairId: HexString): Promise { try { - const extrinsic = this.api.tx.intentsCoprocessor.withdrawPriceDeposit(pairId, side) + const extrinsic = this.api.tx.intentsCoprocessor.withdrawPriceDeposit(pairId) return await this.signAndSendExtrinsic(extrinsic) } catch (error) { return { @@ -399,10 +398,10 @@ export class IntentsCoprocessor { * @param amount - The base token amount to get quotes for (human-readable number, e.g. 500 for 500 tokens) * @returns Array of Quote objects, one per filler who has price entries for this pair */ - async getQuotes(pairId: HexString, side: PriceSide, amount: number): Promise { + async getQuotes(pairId: HexString, amount: number): Promise { const entries: RpcPriceEntry[] = await (this.api as any)._rpcCore.provider.send( "intents_getPairPrices", - [pairId, side.toLowerCase()], + [pairId], ) if (entries.length === 0) return [] diff --git a/sdk/packages/sdk/src/types/index.ts b/sdk/packages/sdk/src/types/index.ts index 1ae3fa114..ddb6c0bad 100644 --- a/sdk/packages/sdk/src/types/index.ts +++ b/sdk/packages/sdk/src/types/index.ts @@ -1355,12 +1355,6 @@ export type IntentOrderStatusUpdate = * All values are raw 18-decimal bigints as expected by the pallet. * The frontend determines ranges from the curve points. */ -/** Side of a price quote — matches the pallet's `Side` enum */ -export enum PriceSide { - Bid = "Bid", - Ask = "Ask", -} - export interface PriceInput { amount: bigint price: bigint diff --git a/sdk/packages/simplex/src/strategies/fx.ts b/sdk/packages/simplex/src/strategies/fx.ts index be81179ea..7d566f242 100644 --- a/sdk/packages/simplex/src/strategies/fx.ts +++ b/sdk/packages/simplex/src/strategies/fx.ts @@ -10,7 +10,6 @@ import { adjustDecimals, ADDRESS_ZERO, type PriceInput, - PriceSide, } from "@hyperbridge/sdk" import { privateKeyToAccount } from "viem/accounts" import { ChainClientManager, ContractInteractionService } from "@/services" @@ -175,12 +174,12 @@ export class FXFiller implements FillerStrategy { // Ask pair const askPairId = this.computeSymbolPairId(stableSymbol, exoticSymbol) const askEntries = this.buildAskPriceEntries() - await this.submitEntriesInChunks(cp, askPairId, PriceSide.Ask, askEntries, maxEntries, "ask") + await this.submitEntriesInChunks(cp, askPairId, askEntries, maxEntries, "ask") // Bid pair const bidPairId = this.computeSymbolPairId(exoticSymbol, stableSymbol) const bidEntries = this.buildBidPriceEntries() - await this.submitEntriesInChunks(cp, bidPairId, PriceSide.Bid, bidEntries, maxEntries, "bid") + await this.submitEntriesInChunks(cp, bidPairId, bidEntries, maxEntries, "bid") } catch (err) { this.logger.error({ err }, "Error submitting initial prices") } @@ -192,7 +191,6 @@ export class FXFiller implements FillerStrategy { private async submitEntriesInChunks( cp: IntentsCoprocessor, pairId: HexString, - side: PriceSide, entries: PriceInput[], maxEntries: number, direction: string, @@ -210,7 +208,7 @@ export class FXFiller implements FillerStrategy { ) for (let i = 0; i < chunks.length; i++) { - const result = await cp.submitPairPrice(pairId, side, chunks[i]) + const result = await cp.submitPairPrice(pairId, chunks[i]) if (result.success) { this.logger.info( { pairId, direction, chunk: i + 1, of: chunks.length, blockHash: result.blockHash, entryCount: chunks[i].length }, From 8a3f9cdc26890172278f5b602c1ccbb5727045bf Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Mon, 23 Mar 2026 16:22:17 +0100 Subject: [PATCH 26/28] register pair via reserve deposits --- .../intents-coprocessor/src/benchmarking.rs | 60 ++++++++ .../pallets/intents-coprocessor/src/lib.rs | 98 +++++++++++++ .../pallets/intents-coprocessor/src/tests.rs | 134 ++++++++++++++++-- .../intents-coprocessor/src/weights.rs | 34 +++++ parachain/runtimes/gargantua/src/ismp.rs | 5 +- .../src/weights/pallet_intents_coprocessor.rs | 134 ++++++++++-------- parachain/runtimes/nexus/src/ismp.rs | 8 +- .../src/weights/pallet_intents_coprocessor.rs | 134 ++++++++++-------- parachain/simtests/src/price_submission.rs | 35 +++++ 9 files changed, 509 insertions(+), 133 deletions(-) diff --git a/modules/pallets/intents-coprocessor/src/benchmarking.rs b/modules/pallets/intents-coprocessor/src/benchmarking.rs index 1a7ce1776..40da80075 100644 --- a/modules/pallets/intents-coprocessor/src/benchmarking.rs +++ b/modules/pallets/intents-coprocessor/src/benchmarking.rs @@ -214,6 +214,11 @@ mod benchmarks { PriceDepositLockDuration::::put(BlockNumberFor::::from(10u32)); PriceWindowDurationValue::::put(86_400_000u64); + // Register the pair + let reg_deposit = ::Currency::minimum_balance(); + PairRegistrationDeposit::::put(reg_deposit); + let _ = Pallet::::register_pair(RawOrigin::Signed(caller.clone()).into(), pair_id); + let count = n.min(T::MaxPriceEntries::get()); let mut entries_vec = vec![]; for i in 0..count { @@ -279,6 +284,10 @@ mod benchmarks { PriceDepositLockDuration::::put(BlockNumberFor::::from(10u32)); PriceWindowDurationValue::::put(86_400_000u64); + let reg_deposit = ::Currency::minimum_balance(); + PairRegistrationDeposit::::put(reg_deposit); + let _ = Pallet::::register_pair(RawOrigin::Signed(caller.clone()).into(), pair_id); + let entries: BoundedVec = vec![PriceInput { amount: U256::zero(), price: U256::from(2000) }] .try_into() @@ -300,5 +309,56 @@ mod benchmarks { assert!(PriceDeposits::::get(&caller, &pair_id).is_none()); } + #[benchmark] + fn register_pair() { + let caller: T::AccountId = whitelisted_caller(); + let pair_id = H256::repeat_byte(0xbb); + + let balance = BalanceOf::::from(u32::MAX); + ::Currency::make_free_balance_be(&caller, balance); + + let deposit = ::Currency::minimum_balance(); + PairRegistrationDeposit::::put(deposit); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), pair_id); + + assert!(RegisteredPairs::::contains_key(&pair_id)); + } + + #[benchmark] + fn deregister_pair() -> Result<(), BenchmarkError> { + let origin = + T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; + let caller: T::AccountId = whitelisted_caller(); + let pair_id = H256::repeat_byte(0xcc); + + let balance = BalanceOf::::from(u32::MAX); + ::Currency::make_free_balance_be(&caller, balance); + + let deposit = ::Currency::minimum_balance(); + PairRegistrationDeposit::::put(deposit); + + let _ = Pallet::::register_pair(RawOrigin::Signed(caller).into(), pair_id); + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, pair_id); + + assert!(!RegisteredPairs::::contains_key(&pair_id)); + Ok(()) + } + + #[benchmark] + fn set_pair_registration_deposit() -> Result<(), BenchmarkError> { + let origin = + T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, 5000u32.into()); + + assert_eq!(PairRegistrationDeposit::::get(), 5000u32.into()); + 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 919ef67ff..f275c8084 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -176,6 +176,16 @@ pub mod pallet { pub type PricesClearedThisWindow = StorageMap<_, Blake2_128Concat, H256, bool, ValueQuery>; + /// Registered token pairs. Anyone can register a pair by reserving a deposit. + /// Maps pair_id to (registrant, deposit_amount). + #[pallet::storage] + pub type RegisteredPairs = + StorageMap<_, Blake2_128Concat, H256, (T::AccountId, BalanceOf), OptionQuery>; + + /// The deposit amount required to register a new token pair + #[pallet::storage] + pub type PairRegistrationDeposit = StorageValue<_, BalanceOf, ValueQuery>; + #[pallet::hooks] impl Hooks> for Pallet where @@ -249,6 +259,12 @@ pub mod pallet { }, /// Price deposit was withdrawn (tokens unreserved) PriceDepositWithdrawn { submitter: T::AccountId, pair_id: H256, amount: BalanceOf }, + /// A token pair was registered with a deposit + PairRegistered { registrant: T::AccountId, pair_id: H256, deposit: BalanceOf }, + /// A token pair was deregistered and deposit returned + PairDeregistered { registrant: T::AccountId, pair_id: H256, deposit: BalanceOf }, + /// Pair registration deposit amount was updated + PairRegistrationDepositUpdated { amount: BalanceOf }, } #[pallet::error] @@ -277,6 +293,12 @@ pub mod pallet { WithdrawalInProgress, /// Withdrawal has already been initiated WithdrawalAlreadyInitiated, + /// The token pair is not registered + PairNotRegistered, + /// The token pair is already registered + PairAlreadyRegistered, + /// Pair registration deposit is not configured (amount is zero) + PairRegistrationDepositNotConfigured, } #[pallet::call] @@ -577,6 +599,7 @@ pub mod pallet { let submitter = ensure_signed(origin)?; ensure!(!entries.is_empty(), Error::::EmptyPriceEntries); + ensure!(RegisteredPairs::::contains_key(&pair_id), Error::::PairNotRegistered); let deposit_amount = PriceDepositAmount::::get(); ensure!(!deposit_amount.is_zero(), Error::::PriceDepositsNotConfigured); @@ -619,6 +642,65 @@ pub mod pallet { Ok(()) } + /// Register a token pair by reserving a deposit. + /// + /// Anyone can register a pair. The deposit is reserved from the caller's + /// balance and returned when the pair is deregistered. + /// + /// # Parameters + /// - `pair_id`: The token pair identifier (keccak256 of "BASE/QUOTE") + /// + /// # Errors + /// - `PairRegistrationDepositNotConfigured`: If the registration deposit is zero + /// - `PairAlreadyRegistered`: If the pair is already registered + /// - `InsufficientBalance`: If the caller cannot afford the deposit + #[pallet::call_index(8)] + #[pallet::weight(T::WeightInfo::register_pair())] + pub fn register_pair(origin: OriginFor, pair_id: H256) -> DispatchResult { + let who = ensure_signed(origin)?; + + let deposit = PairRegistrationDeposit::::get(); + ensure!(!deposit.is_zero(), Error::::PairRegistrationDepositNotConfigured); + ensure!( + !RegisteredPairs::::contains_key(&pair_id), + Error::::PairAlreadyRegistered + ); + + ::Currency::reserve(&who, deposit) + .map_err(|_| Error::::InsufficientBalance)?; + + RegisteredPairs::::insert(&pair_id, (who.clone(), deposit)); + + Self::deposit_event(Event::PairRegistered { registrant: who, pair_id, deposit }); + + Ok(()) + } + + /// Deregister a token pair and return the deposit to the original registrant. + /// + /// Can only be called by governance. + /// + /// # Parameters + /// - `pair_id`: The token pair identifier + /// + /// # Errors + /// - `PairNotRegistered`: If the pair is not registered + #[pallet::call_index(9)] + #[pallet::weight(T::WeightInfo::deregister_pair())] + pub fn deregister_pair(origin: OriginFor, pair_id: H256) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + + let (registrant, deposit) = + RegisteredPairs::::get(&pair_id).ok_or(Error::::PairNotRegistered)?; + + ::Currency::unreserve(®istrant, deposit); + RegisteredPairs::::remove(&pair_id); + + Self::deposit_event(Event::PairDeregistered { registrant, pair_id, deposit }); + + Ok(()) + } + /// Set the price window duration #[pallet::call_index(10)] #[pallet::weight(T::WeightInfo::set_price_window_duration())] @@ -720,6 +802,22 @@ pub mod pallet { Ok(()) } + + /// Set the deposit amount required to register a new token pair + #[pallet::call_index(14)] + #[pallet::weight(T::WeightInfo::set_pair_registration_deposit())] + pub fn set_pair_registration_deposit( + origin: OriginFor, + amount: BalanceOf, + ) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + + PairRegistrationDeposit::::put(amount); + + Self::deposit_event(Event::PairRegistrationDepositUpdated { amount }); + + Ok(()) + } } impl Pallet diff --git a/modules/pallets/intents-coprocessor/src/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index 521479269..b46c897f9 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -171,10 +171,17 @@ pub fn new_test_ext() -> sp_io::TestExternalities { pallet_intents::PriceDepositAmount::::put(500u64); // Lock duration: 10 blocks pallet_intents::PriceDepositLockDuration::::put(10u64); + // Pair registration deposit: 300 tokens + pallet_intents::PairRegistrationDeposit::::put(300u64); }); ext } +/// Helper: register a pair from the given account +fn register_pair(who: &AccountId, pair_id: H256) { + assert_ok!(Intents::register_pair(RuntimeOrigin::signed(who.clone()), pair_id)); +} + #[test] fn place_bid_works() { new_test_ext().execute_with(|| { @@ -552,6 +559,8 @@ fn submit_pair_price_reserves_deposit_on_first_submission() { pallet_timestamp::Now::::put(2_000_000u64); + register_pair(&submitter, pair_id); + let balance_before = Balances::free_balance(&submitter); let deposit_amount = PriceDepositAmount::::get(); @@ -567,9 +576,11 @@ fn submit_pair_price_reserves_deposit_on_first_submission() { entries, )); + let reg_deposit = PairRegistrationDeposit::::get(); + // Deposit was reserved assert_eq!(Balances::free_balance(&submitter), balance_before - deposit_amount); - assert_eq!(Balances::reserved_balance(&submitter), deposit_amount); + assert_eq!(Balances::reserved_balance(&submitter), reg_deposit + deposit_amount); // Deposit record stored (no unlock block yet) let (stored_amount, unlock_block) = @@ -594,6 +605,8 @@ fn submit_pair_price_second_submission_is_free() { pallet_timestamp::Now::::put(2_000_000u64); + register_pair(&submitter, pair_id); + let entries1 = BoundedVec::try_from(vec![PriceInput { amount: U256::zero(), price: U256::from(2000), @@ -622,10 +635,12 @@ fn submit_pair_price_second_submission_is_free() { entries2, )); + let reg_deposit = PairRegistrationDeposit::::get(); + // Balance unchanged (no extra deposit) assert_eq!(Balances::free_balance(&submitter), balance_after_first); - // Still only one deposit reserved - assert_eq!(Balances::reserved_balance(&submitter), deposit_amount); + // Pair registration deposit + one price deposit reserved + assert_eq!(Balances::reserved_balance(&submitter), reg_deposit + deposit_amount); // Two entries now stored let prices = Prices::::get(&pair_id); @@ -636,6 +651,7 @@ fn submit_pair_price_second_submission_is_free() { #[test] fn submit_pair_price_fails_with_insufficient_balance() { new_test_ext().execute_with(|| { + let rich = AccountId32::new([1; 32]); let submitter = AccountId32::new([4; 32]); // no balance let pair_id = @@ -643,6 +659,9 @@ fn submit_pair_price_fails_with_insufficient_balance() { pallet_timestamp::Now::::put(2_000_000u64); + // Register pair from a funded account + register_pair(&rich, pair_id); + let entries = BoundedVec::try_from(vec![PriceInput { amount: U256::zero(), price: U256::from(2000), @@ -666,6 +685,8 @@ fn withdraw_price_deposit_two_phase() { pallet_timestamp::Now::::put(2_000_000u64); + register_pair(&submitter, pair_id); + let entries = BoundedVec::try_from(vec![PriceInput { amount: U256::zero(), price: U256::from(2000), @@ -688,9 +709,11 @@ fn withdraw_price_deposit_two_phase() { pair_id, )); + let reg_deposit = PairRegistrationDeposit::::get(); + // Deposit is NOT yet unreserved assert_eq!(Balances::free_balance(&submitter), balance_after_submit); - assert_eq!(Balances::reserved_balance(&submitter), deposit_amount); + assert_eq!(Balances::reserved_balance(&submitter), reg_deposit + deposit_amount); // Unlock block is set (1 + 10 = 11) let (_, unlock_block) = PriceDeposits::::get(&submitter, &pair_id).unwrap(); @@ -710,9 +733,9 @@ fn withdraw_price_deposit_two_phase() { pair_id, )); - // Deposit unreserved + // Price deposit unreserved, pair registration deposit remains assert_eq!(Balances::free_balance(&submitter), balance_after_submit + deposit_amount); - assert_eq!(Balances::reserved_balance(&submitter), 0); + assert_eq!(Balances::reserved_balance(&submitter), reg_deposit); // Deposit record removed assert!(PriceDeposits::::get(&submitter, &pair_id).is_none()); @@ -729,6 +752,8 @@ fn withdraw_price_deposit_phase2_fails_when_still_locked() { pallet_timestamp::Now::::put(2_000_000u64); + register_pair(&submitter, pair_id); + let entries = BoundedVec::try_from(vec![PriceInput { amount: U256::zero(), price: U256::from(2000), @@ -793,6 +818,8 @@ fn prices_persist_across_window_and_clear_on_first_submission() { let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); + register_pair(&submitter, pair_id); + // Simulate day 1: store some prices Prices::::insert( &pair_id, @@ -923,6 +950,8 @@ fn multiple_submitters_independent_deposits() { pallet_timestamp::Now::::put(2_000_000u64); + register_pair(&submitter1, pair_id); + let deposit_amount = PriceDepositAmount::::get(); let entries1 = BoundedVec::try_from(vec![PriceInput { @@ -949,8 +978,11 @@ fn multiple_submitters_independent_deposits() { entries2, )); - // Each has their own deposit - assert_eq!(Balances::reserved_balance(&submitter1), deposit_amount); + let reg_deposit = PairRegistrationDeposit::::get(); + + // submitter1 has pair registration deposit + price deposit + assert_eq!(Balances::reserved_balance(&submitter1), reg_deposit + deposit_amount); + // submitter2 only has price deposit assert_eq!(Balances::reserved_balance(&submitter2), deposit_amount); // Two entries in prices @@ -970,6 +1002,9 @@ fn separate_deposits_per_pair() { pallet_timestamp::Now::::put(2_000_000u64); + register_pair(&submitter, pair_id1); + register_pair(&submitter, pair_id2); + let deposit_amount = PriceDepositAmount::::get(); let entries = BoundedVec::try_from(vec![PriceInput { @@ -989,8 +1024,10 @@ fn separate_deposits_per_pair() { entries, )); - // Two deposits reserved (one per pair) - assert_eq!(Balances::reserved_balance(&submitter), deposit_amount * 2); + let reg_deposit = PairRegistrationDeposit::::get(); + + // Two pair registration deposits + two price deposits + assert_eq!(Balances::reserved_balance(&submitter), reg_deposit * 2 + deposit_amount * 2); // Phase 1: Initiate both withdrawals at block 1 System::set_block_number(1); @@ -1009,13 +1046,15 @@ fn separate_deposits_per_pair() { RuntimeOrigin::signed(submitter.clone()), pair_id1, )); - assert_eq!(Balances::reserved_balance(&submitter), deposit_amount); + // One price deposit withdrawn, still have: 2 reg deposits + 1 price deposit + assert_eq!(Balances::reserved_balance(&submitter), reg_deposit * 2 + deposit_amount); assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id2, )); - assert_eq!(Balances::reserved_balance(&submitter), 0); + // Both price deposits withdrawn, only pair registration deposits remain + assert_eq!(Balances::reserved_balance(&submitter), reg_deposit * 2); }); } @@ -1031,6 +1070,8 @@ fn submit_pair_price_blocked_after_withdrawal_initiated() { PriceDepositAmount::::put(deposit_amount); PriceDepositLockDuration::::put(10u64); + register_pair(&submitter, pair_id); + let entries = BoundedVec::try_from(vec![PriceInput { amount: U256::zero(), price: U256::from(42) }]) .unwrap(); @@ -1056,3 +1097,72 @@ fn submit_pair_price_blocked_after_withdrawal_initiated() { ); }); } + +#[test] +fn register_pair_works() { + new_test_ext().execute_with(|| { + let who = AccountId32::new([1; 32]); + let pair_id = H256::repeat_byte(0xaa); + + let reg_deposit = PairRegistrationDeposit::::get(); + let balance_before = Balances::free_balance(&who); + + assert_ok!(Intents::register_pair(RuntimeOrigin::signed(who.clone()), pair_id)); + + // Deposit reserved + assert_eq!(Balances::free_balance(&who), balance_before - reg_deposit); + assert!(RegisteredPairs::::contains_key(&pair_id)); + + let (registrant, deposit) = RegisteredPairs::::get(&pair_id).unwrap(); + assert_eq!(registrant, who); + assert_eq!(deposit, reg_deposit); + }); +} + +#[test] +fn deregister_pair_works() { + new_test_ext().execute_with(|| { + let who = AccountId32::new([1; 32]); + let pair_id = H256::repeat_byte(0xaa); + + assert_ok!(Intents::register_pair(RuntimeOrigin::signed(who.clone()), pair_id)); + + let reg_deposit = PairRegistrationDeposit::::get(); + let balance_before_deregister = Balances::free_balance(&who); + + assert_ok!(Intents::deregister_pair(RuntimeOrigin::root(), pair_id)); + + // Deposit returned to original registrant + assert_eq!(Balances::free_balance(&who), balance_before_deregister + reg_deposit); + assert!(!RegisteredPairs::::contains_key(&pair_id)); + }); +} + +#[test] +fn set_pair_registration_deposit_works() { + new_test_ext().execute_with(|| { + assert_ok!(Intents::set_pair_registration_deposit(RuntimeOrigin::root(), 5000u64)); + assert_eq!(PairRegistrationDeposit::::get(), 5000u64); + }); +} + +#[test] +fn submit_pair_price_fails_when_pair_not_registered() { + new_test_ext().execute_with(|| { + let submitter = AccountId32::new([1; 32]); + let pair_id = H256::repeat_byte(0xff); // not registered + + pallet_timestamp::Now::::put(2_000_000u64); + + let entries = BoundedVec::try_from(vec![PriceInput { + amount: U256::zero(), + price: U256::from(2000), + }]) + .unwrap(); + + assert_noop!( + Intents::submit_pair_price(RuntimeOrigin::signed(submitter), pair_id, entries), + Error::::PairNotRegistered + ); + }); +} diff --git a/modules/pallets/intents-coprocessor/src/weights.rs b/modules/pallets/intents-coprocessor/src/weights.rs index 54c418fa1..e4bfd840d 100644 --- a/modules/pallets/intents-coprocessor/src/weights.rs +++ b/modules/pallets/intents-coprocessor/src/weights.rs @@ -47,6 +47,9 @@ pub trait WeightInfo { fn set_price_deposit_amount() -> Weight; fn set_price_deposit_lock_duration() -> Weight; fn withdraw_price_deposit() -> Weight; + fn register_pair() -> Weight; + fn deregister_pair() -> Weight; + fn set_pair_registration_deposit() -> Weight; } /// Weights for pallet_intents using the Substrate node and recommended hardware. @@ -153,6 +156,28 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(3)) .saturating_add(T::DbWeight::get().writes(1)) } + + /// Storage: PairRegistrationDeposit (r:1 w:0), RegisteredPairs (r:1 w:1) + fn register_pair() -> Weight { + Weight::from_parts(40_000_000, 0) + .saturating_add(Weight::from_parts(0, 3000)) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(1)) + } + + /// Storage: RegisteredPairs (r:1 w:1) + fn deregister_pair() -> Weight { + Weight::from_parts(35_000_000, 0) + .saturating_add(Weight::from_parts(0, 3000)) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } + + /// Storage: PairRegistrationDeposit (r:0 w:1) + fn set_pair_registration_deposit() -> Weight { + Weight::from_parts(10_000_000, 0) + .saturating_add(T::DbWeight::get().writes(1)) + } } // For backwards compatibility and tests @@ -193,4 +218,13 @@ impl WeightInfo for () { fn withdraw_price_deposit() -> Weight { Weight::from_parts(45_000_000, 0) } + fn register_pair() -> Weight { + Weight::from_parts(40_000_000, 0) + } + fn deregister_pair() -> Weight { + Weight::from_parts(35_000_000, 0) + } + fn set_pair_registration_deposit() -> 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 6d262e979..c580db415 100644 --- a/parachain/runtimes/gargantua/src/ismp.rs +++ b/parachain/runtimes/gargantua/src/ismp.rs @@ -15,9 +15,8 @@ use crate::{ alloc::{boxed::Box, string::ToString}, - weights, AccountId, Assets, Balance, Balances, Ismp, IsmpParachain, Mmr, - ParachainInfo, Runtime, RuntimeEvent, Timestamp, TokenGatewayInspector, TreasuryPalletId, - XcmGateway, + weights, AccountId, Assets, Balance, Balances, Ismp, IsmpParachain, Mmr, ParachainInfo, + Runtime, RuntimeEvent, Timestamp, TokenGatewayInspector, TreasuryPalletId, XcmGateway, EXISTENTIAL_DEPOSIT, }; use anyhow::anyhow; diff --git a/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs b/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs index 7f1d32d12..6179daa4b 100644 --- a/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs +++ b/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs @@ -1,23 +1,8 @@ -// 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 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - //! Autogenerated weights for `pallet_intents_coprocessor` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 -//! DATE: 2026-03-20, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-03-23, 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 @@ -32,21 +17,17 @@ // --extrinsic=* // --steps=50 // --repeat=20 -// --unsafe-overwrite-results +// --runtime=target/release/wbuild/gargantua-runtime/gargantua_runtime.compact.compressed.wasm // --genesis-builder-preset=development -// --template=./scripts/template.hbs -// --genesis-builder=runtime -// --runtime=./target/release/wbuild/gargantua-runtime/gargantua_runtime.compact.wasm -// --output -// parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs +// --output=parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] #![allow(unused_imports)] #![allow(missing_docs)] -use polkadot_sdk::*; -use frame_support::{traits::Get, weights::Weight}; +use polkadot_sdk::frame_support::{traits::Get, weights::Weight}; +use polkadot_sdk::frame_system; use core::marker::PhantomData; /// Weight functions for `pallet_intents_coprocessor`. @@ -60,8 +41,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `109` // Estimated: `3574` - // Minimum execution time: 39_334_000 picoseconds. - Weight::from_parts(40_928_000, 0) + // Minimum execution time: 39_275_000 picoseconds. + Weight::from_parts(39_886_000, 0) .saturating_add(Weight::from_parts(0, 3574)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -72,8 +53,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `247` // Estimated: `3712` - // Minimum execution time: 38_442_000 picoseconds. - Weight::from_parts(39_195_000, 0) + // Minimum execution time: 38_223_000 picoseconds. + Weight::from_parts(38_823_000, 0) .saturating_add(Weight::from_parts(0, 3712)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) @@ -84,8 +65,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `113` // Estimated: `6053` - // Minimum execution time: 22_392_000 picoseconds. - Weight::from_parts(22_934_000, 0) + // Minimum execution time: 22_362_000 picoseconds. + Weight::from_parts(22_723_000, 0) .saturating_add(Weight::from_parts(0, 6053)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -108,8 +89,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 73_059_000 picoseconds. - Weight::from_parts(73_890_000, 0) + // Minimum execution time: 80_142_000 picoseconds. + Weight::from_parts(81_946_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(5)) @@ -132,8 +113,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 64_562_000 picoseconds. - Weight::from_parts(65_995_000, 0) + // Minimum execution time: 70_333_000 picoseconds. + Weight::from_parts(71_575_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -156,8 +137,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 69_372_000 picoseconds. - Weight::from_parts(70_073_000, 0) + // Minimum execution time: 65_774_000 picoseconds. + Weight::from_parts(67_107_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -168,11 +149,13 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 8_296_000 picoseconds. - Weight::from_parts(8_607_000, 0) + // Minimum execution time: 7_445_000 picoseconds. + Weight::from_parts(8_717_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } + /// Storage: `IntentsCoprocessor::RegisteredPairs` (r:1 w:0) + /// Proof: `IntentsCoprocessor::RegisteredPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `IntentsCoprocessor::PriceDepositAmount` (r:1 w:0) /// Proof: `IntentsCoprocessor::PriceDepositAmount` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `IntentsCoprocessor::PriceDeposits` (r:1 w:1) @@ -186,14 +169,14 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// The range of component `n` is `[1, 100]`. fn submit_pair_price(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `200` - // Estimated: `3665` - // Minimum execution time: 58_340_000 picoseconds. - Weight::from_parts(65_029_669, 0) - .saturating_add(Weight::from_parts(0, 3665)) - // Standard Error: 1_597 - .saturating_add(Weight::from_parts(21_027, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(5)) + // Measured: `334` + // Estimated: `3799` + // Minimum execution time: 53_612_000 picoseconds. + Weight::from_parts(62_363_557, 0) + .saturating_add(Weight::from_parts(0, 3799)) + // Standard Error: 5_029 + .saturating_add(Weight::from_parts(65_516, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(6)) .saturating_add(T::DbWeight::get().writes(4)) } /// Storage: `IntentsCoprocessor::PriceWindowDurationValue` (r:0 w:1) @@ -202,8 +185,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 8_086_000 picoseconds. - Weight::from_parts(8_576_000, 0) + // Minimum execution time: 7_294_000 picoseconds. + Weight::from_parts(8_486_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -213,8 +196,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 8_255_000 picoseconds. - Weight::from_parts(8_446_000, 0) + // Minimum execution time: 8_246_000 picoseconds. + Weight::from_parts(8_586_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -224,8 +207,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 8_305_000 picoseconds. - Weight::from_parts(8_626_000, 0) + // Minimum execution time: 7_304_000 picoseconds. + Weight::from_parts(8_436_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -233,12 +216,49 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// Proof: `IntentsCoprocessor::PriceDeposits` (`max_values`: None, `max_size`: None, mode: `Measured`) fn withdraw_price_deposit() -> Weight { // Proof Size summary in bytes: - // Measured: `405` - // Estimated: `3870` - // Minimum execution time: 38_613_000 picoseconds. - Weight::from_parts(39_174_000, 0) - .saturating_add(Weight::from_parts(0, 3870)) + // Measured: `471` + // Estimated: `3936` + // Minimum execution time: 34_225_000 picoseconds. + Weight::from_parts(34_756_000, 0) + .saturating_add(Weight::from_parts(0, 3936)) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::PairRegistrationDeposit` (r:1 w:0) + /// Proof: `IntentsCoprocessor::PairRegistrationDeposit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::RegisteredPairs` (r:1 w:1) + /// Proof: `IntentsCoprocessor::RegisteredPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn register_pair() -> Weight { + // Proof Size summary in bytes: + // Measured: `149` + // Estimated: `3614` + // Minimum execution time: 32_441_000 picoseconds. + Weight::from_parts(33_704_000, 0) + .saturating_add(Weight::from_parts(0, 3614)) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::RegisteredPairs` (r:1 w:1) + /// Proof: `IntentsCoprocessor::RegisteredPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn deregister_pair() -> Weight { + // Proof Size summary in bytes: + // Measured: `267` + // Estimated: `3732` + // Minimum execution time: 29_837_000 picoseconds. + Weight::from_parts(30_108_000, 0) + .saturating_add(Weight::from_parts(0, 3732)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } + /// Storage: `IntentsCoprocessor::PairRegistrationDeposit` (r:0 w:1) + /// Proof: `IntentsCoprocessor::PairRegistrationDeposit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_pair_registration_deposit() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 7_274_000 picoseconds. + Weight::from_parts(7_515_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 3bf831abb..24d724013 100644 --- a/parachain/runtimes/nexus/src/ismp.rs +++ b/parachain/runtimes/nexus/src/ismp.rs @@ -16,10 +16,10 @@ use crate::{ alloc::{boxed::Box, string::ToString}, governance::WhitelistedCaller, - weights, AccountId, Assets, Balance, Balances, Ismp, IsmpParachain, Mmr, - ParachainInfo, ReputationAsset, Runtime, RuntimeEvent, TechnicalCollectiveInstance, Timestamp, - TokenGateway, TokenGatewayInspector, TreasuryPalletId, XcmGateway, - EXISTENTIAL_DEPOSIT, MIN_TECH_COLLECTIVE_APPROVAL, + weights, AccountId, Assets, Balance, Balances, Ismp, IsmpParachain, Mmr, ParachainInfo, + ReputationAsset, Runtime, RuntimeEvent, TechnicalCollectiveInstance, Timestamp, TokenGateway, + TokenGatewayInspector, TreasuryPalletId, XcmGateway, EXISTENTIAL_DEPOSIT, + MIN_TECH_COLLECTIVE_APPROVAL, }; use anyhow::anyhow; use evm_state_machine::SubstrateEvmStateMachine; diff --git a/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs b/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs index 6ae022818..5d86d96e3 100644 --- a/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs +++ b/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs @@ -1,23 +1,8 @@ -// 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 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - //! Autogenerated weights for `pallet_intents_coprocessor` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 -//! DATE: 2026-03-20, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-03-23, 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 @@ -32,21 +17,17 @@ // --extrinsic=* // --steps=50 // --repeat=20 -// --unsafe-overwrite-results +// --runtime=target/release/wbuild/nexus-runtime/nexus_runtime.compact.compressed.wasm // --genesis-builder-preset=development -// --template=./scripts/template.hbs -// --genesis-builder=runtime -// --runtime=./target/release/wbuild/nexus-runtime/nexus_runtime.compact.wasm -// --output -// parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs +// --output=parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] #![allow(unused_imports)] #![allow(missing_docs)] -use polkadot_sdk::*; -use frame_support::{traits::Get, weights::Weight}; +use polkadot_sdk::frame_support::{traits::Get, weights::Weight}; +use polkadot_sdk::frame_system; use core::marker::PhantomData; /// Weight functions for `pallet_intents_coprocessor`. @@ -60,8 +41,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `175` // Estimated: `3640` - // Minimum execution time: 39_736_000 picoseconds. - Weight::from_parts(40_187_000, 0) + // Minimum execution time: 35_047_000 picoseconds. + Weight::from_parts(36_399_000, 0) .saturating_add(Weight::from_parts(0, 3640)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -72,8 +53,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `313` // Estimated: `3778` - // Minimum execution time: 38_624_000 picoseconds. - Weight::from_parts(39_235_000, 0) + // Minimum execution time: 34_395_000 picoseconds. + Weight::from_parts(38_904_000, 0) .saturating_add(Weight::from_parts(0, 3778)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) @@ -84,8 +65,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `179` // Estimated: `6119` - // Minimum execution time: 22_412_000 picoseconds. - Weight::from_parts(22_933_000, 0) + // Minimum execution time: 19_918_000 picoseconds. + Weight::from_parts(22_643_000, 0) .saturating_add(Weight::from_parts(0, 6119)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -108,8 +89,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `899` // Estimated: `4364` - // Minimum execution time: 73_529_000 picoseconds. - Weight::from_parts(75_032_000, 0) + // Minimum execution time: 64_723_000 picoseconds. + Weight::from_parts(65_765_000, 0) .saturating_add(Weight::from_parts(0, 4364)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(5)) @@ -132,8 +113,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `899` // Estimated: `4364` - // Minimum execution time: 64_422_000 picoseconds. - Weight::from_parts(65_855_000, 0) + // Minimum execution time: 57_650_000 picoseconds. + Weight::from_parts(65_635_000, 0) .saturating_add(Weight::from_parts(0, 4364)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -156,8 +137,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `899` // Estimated: `4364` - // Minimum execution time: 68_440_000 picoseconds. - Weight::from_parts(69_462_000, 0) + // Minimum execution time: 60_044_000 picoseconds. + Weight::from_parts(68_981_000, 0) .saturating_add(Weight::from_parts(0, 4364)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -168,11 +149,13 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 8_345_000 picoseconds. - Weight::from_parts(8_526_000, 0) + // Minimum execution time: 7_384_000 picoseconds. + Weight::from_parts(7_584_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } + /// Storage: `IntentsCoprocessor::RegisteredPairs` (r:1 w:0) + /// Proof: `IntentsCoprocessor::RegisteredPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `IntentsCoprocessor::PriceDepositAmount` (r:1 w:0) /// Proof: `IntentsCoprocessor::PriceDepositAmount` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `IntentsCoprocessor::PriceDeposits` (r:1 w:1) @@ -186,14 +169,14 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// The range of component `n` is `[1, 100]`. fn submit_pair_price(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `266` - // Estimated: `3731` - // Minimum execution time: 59_603_000 picoseconds. - Weight::from_parts(65_356_482, 0) - .saturating_add(Weight::from_parts(0, 3731)) - // Standard Error: 2_003 - .saturating_add(Weight::from_parts(22_766, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(5)) + // Measured: `400` + // Estimated: `3865` + // Minimum execution time: 53_742_000 picoseconds. + Weight::from_parts(62_587_293, 0) + .saturating_add(Weight::from_parts(0, 3865)) + // Standard Error: 6_071 + .saturating_add(Weight::from_parts(32_069, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(6)) .saturating_add(T::DbWeight::get().writes(4)) } /// Storage: `IntentsCoprocessor::PriceWindowDurationValue` (r:0 w:1) @@ -202,8 +185,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 8_326_000 picoseconds. - Weight::from_parts(8_596_000, 0) + // Minimum execution time: 7_194_000 picoseconds. + Weight::from_parts(7_485_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -213,8 +196,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 8_356_000 picoseconds. - Weight::from_parts(8_746_000, 0) + // Minimum execution time: 7_464_000 picoseconds. + Weight::from_parts(7_734_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -224,8 +207,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 8_486_000 picoseconds. - Weight::from_parts(8_786_000, 0) + // Minimum execution time: 7_283_000 picoseconds. + Weight::from_parts(7_695_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } @@ -233,12 +216,49 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI /// Proof: `IntentsCoprocessor::PriceDeposits` (`max_values`: None, `max_size`: None, mode: `Measured`) fn withdraw_price_deposit() -> Weight { // Proof Size summary in bytes: - // Measured: `471` - // Estimated: `3936` - // Minimum execution time: 39_645_000 picoseconds. - Weight::from_parts(40_337_000, 0) - .saturating_add(Weight::from_parts(0, 3936)) + // Measured: `537` + // Estimated: `4002` + // Minimum execution time: 34_475_000 picoseconds. + Weight::from_parts(38_844_000, 0) + .saturating_add(Weight::from_parts(0, 4002)) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::PairRegistrationDeposit` (r:1 w:0) + /// Proof: `IntentsCoprocessor::PairRegistrationDeposit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `IntentsCoprocessor::RegisteredPairs` (r:1 w:1) + /// Proof: `IntentsCoprocessor::RegisteredPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn register_pair() -> Weight { + // Proof Size summary in bytes: + // Measured: `215` + // Estimated: `3680` + // Minimum execution time: 33_072_000 picoseconds. + Weight::from_parts(33_914_000, 0) + .saturating_add(Weight::from_parts(0, 3680)) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(1)) + } + /// Storage: `IntentsCoprocessor::RegisteredPairs` (r:1 w:1) + /// Proof: `IntentsCoprocessor::RegisteredPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn deregister_pair() -> Weight { + // Proof Size summary in bytes: + // Measured: `333` + // Estimated: `3798` + // Minimum execution time: 29_456_000 picoseconds. + Weight::from_parts(30_498_000, 0) + .saturating_add(Weight::from_parts(0, 3798)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } + /// Storage: `IntentsCoprocessor::PairRegistrationDeposit` (r:0 w:1) + /// Proof: `IntentsCoprocessor::PairRegistrationDeposit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_pair_registration_deposit() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 7_454_000 picoseconds. + Weight::from_parts(7_704_000, 0) + .saturating_add(Weight::from_parts(0, 0)) + .saturating_add(T::DbWeight::get().writes(1)) + } } diff --git a/parachain/simtests/src/price_submission.rs b/parachain/simtests/src/price_submission.rs index 45c5f2cfd..505c0a1a2 100644 --- a/parachain/simtests/src/price_submission.rs +++ b/parachain/simtests/src/price_submission.rs @@ -102,6 +102,22 @@ fn encode_withdraw_price_deposit(pair_id: H256) -> Vec { data } +/// Manually encode a call to `IntentsCoprocessor::register_pair`. +/// Pallet index 65, call index 8. +fn encode_register_pair(pair_id: H256) -> Vec { + let mut data = vec![65u8, 8u8]; + data.extend_from_slice(&pair_id.encode()); + data +} + +/// Manually encode a call to `IntentsCoprocessor::set_pair_registration_deposit`. +/// Pallet index 65, call index 14. +fn encode_set_pair_registration_deposit(amount: u128) -> Vec { + let mut data = vec![65u8, 14u8]; + data.extend_from_slice(&amount.encode()); + data +} + /// Integration test for the deposit-based price submission system. /// /// Exercises the full lifecycle: @@ -152,6 +168,25 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { .await?; println!("Lock duration set: {lock_duration} blocks"); + // Set pair registration deposit and register pair + let pair_reg_deposit: u128 = 50_000_000_000_000; + sudo_raw_and_finalize( + &client, + &rpc_client, + encode_set_pair_registration_deposit(pair_reg_deposit), + ) + .await?; + println!("Pair registration deposit set: {pair_reg_deposit}"); + + submit_raw_and_finalize( + &client, + &rpc_client, + encode_register_pair(pair_id), + Keyring::Alice, + ) + .await?; + println!("Pair registered: {pair_id:?}"); + // Submit prices let submit_call_data = encode_submit_pair_price(pair_id, price_entries); submit_raw_and_finalize(&client, &rpc_client, submit_call_data, Keyring::Alice).await?; From 80d0398caa4ff77399ecc5c1b904618e7caded76 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Tue, 24 Mar 2026 15:34:23 +0100 Subject: [PATCH 27/28] fee based submission --- .../intent-gateway/price-submission.mdx | 67 +-- .../intents-coprocessor/rpc/src/lib.rs | 72 ++- .../intents-coprocessor/src/benchmarking.rs | 133 +---- .../pallets/intents-coprocessor/src/lib.rs | 381 ++---------- .../pallets/intents-coprocessor/src/tests.rs | 557 +++--------------- .../pallets/intents-coprocessor/src/types.rs | 4 +- .../intents-coprocessor/src/weights.rs | 83 +-- parachain/runtimes/gargantua/src/ismp.rs | 1 + .../src/weights/pallet_intents_coprocessor.rs | 154 ++--- parachain/runtimes/nexus/src/ismp.rs | 1 + .../src/weights/pallet_intents_coprocessor.rs | 154 ++--- parachain/simtests/src/price_submission.rs | 162 +---- .../sdk/src/chains/intentsCoprocessor.ts | 25 +- 13 files changed, 337 insertions(+), 1457 deletions(-) diff --git a/docs/content/developers/intent-gateway/price-submission.mdx b/docs/content/developers/intent-gateway/price-submission.mdx index 81af4805a..a3623f5e5 100644 --- a/docs/content/developers/intent-gateway/price-submission.mdx +++ b/docs/content/developers/intent-gateway/price-submission.mdx @@ -1,55 +1,41 @@ --- title: Price Submission Protocol -description: Deposit-based price submission system for the intents coprocessor pallet +description: Fee based price submission system for the intents coprocessor pallet --- # Price Submission Protocol ## Overview -The intents system needs on-chain price data for token pairs to function correctly. Rather than relying on external oracles, the protocol allows anyone to submit prices for governance-approved token pairs by putting up a deposit on their first submission. After that initial deposit, updating prices for the same pair is free. The deposit can be reclaimed later through a two-phase withdrawal process, which gives the system time to detect and respond to malicious data before the submitter disappears with their funds. +The intents system needs onchain price data for token pairs to function correctly. Rather than relying on external oracles, the protocol allows anyone to submit prices for governance approved token pairs by paying a per submission fee. The fee is transferred to the treasury. Each submission overwrites the submitter's previous prices for that pair, so there is no accumulation of stale data, only the latest prices from each filler are stored. ## Submitting Prices The `submit_pair_price` extrinsic on the intents coprocessor pallet is the entry point for all price submissions. It accepts a pair ID and a bounded list of price entries. All prices and amount ranges are encoded as U256 values scaled by 10^18, giving 18 decimal places of precision. -Submissions are batched. Each call accepts up to `MaxPriceEntries` entries (a compile-time constant configurable per runtime), where each entry specifies an amount threshold and the corresponding price of the base token in terms of the quote token. This makes it possible to quote different rates for different order sizes in a single transaction. For example, a submitter might quote USDC/CNGN at 1414 for amounts starting at 0, and 1420 for amounts starting at 1000. +Submissions are batched. Each call accepts up to `MaxPriceEntries` entries (a compile time constant configurable per runtime), where each entry specifies an amount threshold and the corresponding price of the base token in terms of the quote token. This makes it possible to quote different rates for different order sizes in a single transaction. For example, a submitter might quote USDC/CNGN at 1414 for amounts starting at 0, and 1420 for amounts starting at 1000. -Each price entry contains two fields. The `amount` field is the base token amount threshold at which this price applies. The `price` field is the cost of one unit of the base token in terms of the quote token. The pallet rejects empty submissions. When stored on-chain, each entry becomes a `PriceEntry` that also includes the filler's account ID. +Each price entry contains two fields. The `amount` field is the base token amount threshold at which this price applies. The `price` field is the cost of one unit of the base token in terms of the quote token. The pallet rejects empty submissions. When stored onchain, each entry becomes a `PriceEntry` that also includes a Unix timestamp of when the submission was made. -## Deposit Model +## Fee Model -On the first price submission for a given account and token pair, a deposit is reserved from the submitter's balance. The deposit amount is set by governance through the `set_price_deposit_amount` extrinsic. After the initial deposit, the submitter can update prices for the same token pair as many times as they want without paying anything further. +Every call to `submit_pair_price` charges a fee from the submitter's balance. The fee is transferred to the treasury via `Currency::transfer`. The fee amount is set by governance through the `set_price_submission_fee` extrinsic. If the fee is zero, submissions are free. -This design discourages spam because each new pair requires locking funds, while keeping ongoing price updates free for active participants. +This design discourages spam while keeping the barrier to entry low for active market participants. -## Two-Phase Withdrawal +## Storage Model -Withdrawing a deposit uses a two-phase process through the `withdraw_price_deposit` extrinsic. The first call initiates the withdrawal by recording the unlock block, which is the current block number plus the governance-configured lock duration. No tokens are moved at this point, and the pallet emits a `PriceDepositWithdrawalInitiated` event. +Prices are stored in a `StorageDoubleMap` keyed by `(pair_id, filler)`. Each filler's entry for a pair is a bounded vector of their latest price entries. Submitting new prices for a pair completely replaces the filler's previous entries. There is no merging or appending. -Once the unlock block has been reached, a second call to the same extrinsic completes the withdrawal. The pallet unreserves the tokens, removes the deposit record, and emits a `PriceDepositWithdrawn` event. If the second call is made before the unlock block, it fails with a `DepositStillLocked` error. - -The lock duration is measured in blocks and set by governance through `set_price_deposit_lock_duration`. This two-phase model ensures there is always a window during which bad actors can be identified and penalized before they can withdraw their deposit. - -## Price Windows and Data Lifecycle - -Prices are organized into time-based windows. The window duration is governance-configurable via `PriceWindowDurationValue`, specified in milliseconds. - -The `on_initialize` hook runs every block and checks whether the current window has expired. When it has, it resets a `PricesClearedThisWindow` flag to false. Prices from the previous window are not cleared immediately. They persist so that consumers can still read the previous window's data. - -On the first new submission in a new window, all price entries across all pairs are cleared before the new entries are stored. This lazy clearing approach avoids the cost of iterating all pairs in `on_initialize` (which would be unbounded weight paid by the block producer) while ensuring stale data is replaced as soon as fresh data arrives. After the first submission clears the data, the global boolean flag ensures subsequent submissions in the same window skip the clearing step entirely. - -## Recognized Pairs - -Only governance-approved token pairs can receive price submissions. This prevents spam for arbitrary token combinations and keeps storage growth under control. Governance manages pairs through the `add_recognized_pair` and `remove_recognized_pair` extrinsics. Removing a pair also cleans up its associated price data and storage. +This means each filler maintains exactly one set of prices per pair. Resubmissions are cheap because they simply overwrite the existing data. There is no need for cleanup or expiry mechanisms. The frontend can filter by filler or timestamp freshness as needed. ## RPC -The `intents_getPairPrices(pair_id)` RPC endpoint returns all price entries for a given token pair. The raw on-chain values (U256 scaled by 10^18) are converted to human-readable decimal strings with fractional precision preserved. For example, an on-chain value of 1414500000000000000000 is returned as the string "1414.5". Each returned entry includes the `amount` threshold, the `price`, and the `filler` account. +The `intents_getPairPrices(pair_id)` RPC endpoint returns all price entries for a given token pair across all fillers. The raw onchain values (U256 scaled by 10^18) are converted to human readable decimal strings with fractional precision preserved. For example, an onchain value of 1414500000000000000000 is returned as the string "1414.5". Each returned entry includes the `amount` threshold, the `price`, the `filler` account hash, and the `timestamp`. ## Simplex Filler Integration -The simplex filler submits price updates automatically as part of the FX strategy. When the filler starts and a `hyperfx` strategy is configured with a `pairId`, the FX strategy spawns a periodic task that converts its ask price curve into on-chain price entries and submits them via `IntentsCoprocessor.submitPairPrice()`. There is no separate price configuration section; the prices come directly from the strategy's existing ask price curve, which is the same curve used to evaluate order profitability. +The simplex filler submits price updates automatically as part of the FX strategy. When the filler starts and a `hyperfx` strategy is configured with a `pairId`, the FX strategy spawns a periodic task that converts its ask price curve into onchain price entries and submits them via `IntentsCoprocessor.submitPairPrice()`. There is no separate price configuration section; the prices come directly from the strategy's existing ask price curve, which is the same curve used to evaluate order profitability. The task runs on a configurable interval (defaulting to every 5 minutes). An initial submission is triggered shortly after startup so that prices are available immediately rather than waiting for the first interval to elapse. @@ -60,7 +46,7 @@ Price submission is enabled by adding a `pairId` to the `hyperfx` strategy in th ```toml [[strategies]] type = "hyperfx" -pairId = "0x..." # On-chain pair ID (keccak256 of base_address ++ quote_address) +pairId = "0x..." # Onchain pair ID (keccak256 of base_address ++ quote_address) maxOrderUsd = "5000" [[strategies.askPriceCurve]] @@ -86,13 +72,13 @@ price = "1490" "EVM-56" = "0xdef..." ``` -The ask curve points are sorted by amount and each point becomes a price entry. The range for each entry spans from that point's amount to just below the next point's amount. The last point extends to a large upper bound. Amounts and prices are converted to 18-decimal format before submission. +The ask curve points are sorted by amount and each point becomes a price entry. The range for each entry spans from that point's amount to just below the next point's amount. The last point extends to a large upper bound. Amounts and prices are converted to 18 decimal format before submission. -The first submission for each pair will reserve a deposit from the substrate account. Subsequent updates to the same pair are free. When a filler wants to reclaim their deposit, they call `withdrawPriceDeposit` once to initiate the withdrawal (which records the unlock block), then call it again after the governance-configured lock duration has elapsed to unreserve the tokens. +Each submission pays a fee to the treasury. Subsequent submissions for the same pair overwrite the previous entries, keeping only the latest prices onchain. ### SDK Usage -The `IntentsCoprocessor` class in `@hyperbridge/sdk` exposes methods for price submission and deposit withdrawal directly. These methods accept raw 18-decimal bigint values. +The `IntentsCoprocessor` class in `@hyperbridge/sdk` exposes methods for price submission directly. These methods accept raw 18 decimal bigint values. ```typescript import { IntentsCoprocessor } from "@hyperbridge/sdk" @@ -100,29 +86,18 @@ import { parseUnits } from "viem" const coprocessor = await IntentsCoprocessor.connect(wsUrl, substratePrivateKey) -// Submit prices for a token pair (values in 18-decimal format) +// Submit prices for a token pair (values in 18 decimal format) +// Each submission overwrites previous entries for this filler + pair await coprocessor.submitPairPrice("0x...", [ { amount: parseUnits("0", 18), price: parseUnits("1414", 18) }, { amount: parseUnits("1000", 18), price: parseUnits("1420", 18) }, ]) - -// Withdraw deposit (two-phase): -// Phase 1: initiate withdrawal (records unlock block) -await coprocessor.withdrawPriceDeposit("0x...") -// Phase 2: complete withdrawal after unlock block is reached -await coprocessor.withdrawPriceDeposit("0x...") ``` ## Governance Parameters -The protocol has several parameters that are stored on-chain and updatable through governance extrinsics. - -`PriceWindowDurationValue` controls the length of each price window in milliseconds. When a window expires, the next submission triggers a lazy clear of all existing price data. - -`PriceDepositAmount` is the amount reserved from a submitter's balance on their first price submission for a given token pair. This deposit stays locked until explicitly withdrawn through the two-phase process. - -`PriceDepositLockDuration` determines how many blocks the deposit remains locked after a withdrawal is initiated. The submitter must wait this many blocks before completing the withdrawal. +The protocol has several parameters that are stored onchain and updatable through governance extrinsics. -`MaxPriceEntries` is a compile-time constant (configurable per runtime) that limits how many price entries can be included in a single submission. +`PriceSubmissionFee` is the fee charged per submission, transferred to the treasury. Set via `set_price_submission_fee`. -Recognized token pairs are managed through `add_recognized_pair` and `remove_recognized_pair`. Only pairs that have been explicitly added by governance can receive price submissions. +`MaxPriceEntries` is a compile time constant (configurable per runtime) that limits how many price entries can be included in a single submission. diff --git a/modules/pallets/intents-coprocessor/rpc/src/lib.rs b/modules/pallets/intents-coprocessor/rpc/src/lib.rs index 553fddf3a..81d41c2c0 100644 --- a/modules/pallets/intents-coprocessor/rpc/src/lib.rs +++ b/modules/pallets/intents-coprocessor/rpc/src/lib.rs @@ -63,6 +63,8 @@ pub struct RpcPriceEntry { pub price: String, /// The filler (submitter) address pub filler: String, + /// Unix timestamp (seconds) when this entry was submitted + pub timestamp: u64, } impl Ord for RpcBidInfo { @@ -188,15 +190,15 @@ fn runtime_error_into_rpc_error(e: impl std::fmt::Display) -> ErrorObjectOwned { ErrorObject::owned(9877, format!("{e}"), None::) } -/// Construct the full storage key for a `StorageMap` entry with `Blake2_128Concat` hasher. -fn storage_map_key(pallet: &[u8], storage: &[u8], map_key: &H256) -> Vec { - let mut key = Vec::new(); - key.extend_from_slice(&sp_core::hashing::twox_128(pallet)); - key.extend_from_slice(&sp_core::hashing::twox_128(storage)); - let map_key_bytes = map_key.as_bytes(); - key.extend_from_slice(&sp_core::hashing::blake2_128(map_key_bytes)); - key.extend_from_slice(map_key_bytes); - key +/// Construct the prefix for iterating a `StorageDoubleMap` by the first key (Blake2_128Concat). +fn storage_double_map_prefix(pallet: &[u8], storage: &[u8], key1: &H256) -> Vec { + let mut prefix = Vec::new(); + prefix.extend_from_slice(&sp_core::hashing::twox_128(pallet)); + prefix.extend_from_slice(&sp_core::hashing::twox_128(storage)); + let key1_bytes = key1.as_bytes(); + prefix.extend_from_slice(&sp_core::hashing::blake2_128(key1_bytes)); + prefix.extend_from_slice(key1_bytes); + prefix } /// Construct the storage key prefix for iterating all fillers in the on-chain @@ -307,27 +309,47 @@ where fn get_pair_prices(&self, pair_id: H256) -> RpcResult> { let best_hash = self.client.info().best_hash; - let key = storage_map_key(b"IntentsCoprocessor", b"Prices", &pair_id); - let storage_key = sp_core::storage::StorageKey(key); + // Iterate all fillers for this pair_id in the Prices double map + let prefix = storage_double_map_prefix(b"IntentsCoprocessor", b"Prices", &pair_id); + let prefix_key = sp_core::storage::StorageKey(prefix.clone()); - let data = match self.client.storage(best_hash, &storage_key) { - Ok(Some(data)) => data.0, - _ => return Ok(Vec::new()), - }; + let keys = self + .client + .storage_keys(best_hash, Some(&prefix_key), None) + .map_err(runtime_error_into_rpc_error)?; use pallet_intents_coprocessor::types::PriceEntry; - match BTreeSet::::decode(&mut &data[..]) { - Ok(entries) => Ok(entries - .into_iter() - .map(|entry| RpcPriceEntry { - amount: format_u256_decimals(entry.amount, 18), - price: format_u256_decimals(entry.price, 18), - filler: format!("0x{}", hex::encode(entry.filler.as_bytes())), - }) - .collect()), - Err(_) => Ok(Vec::new()), + let mut result = Vec::new(); + const MAX_FILLERS: usize = 100; + + for key in keys.take(MAX_FILLERS) { + // Extract filler H256 from the key (after prefix: blake2_128(filler) + filler) + let filler_start = prefix.len() + 16; // 16 bytes for blake2_128 + if key.0.len() < filler_start + 32 { + continue; + } + let filler_bytes = &key.0[filler_start..filler_start + 32]; + let filler = format!("0x{}", hex::encode(filler_bytes)); + + let data = match self.client.storage(best_hash, &key) { + Ok(Some(data)) => data.0, + _ => continue, + }; + + if let Ok(entries) = Vec::::decode(&mut &data[..]) { + for entry in entries { + result.push(RpcPriceEntry { + amount: format_u256_decimals(entry.amount, 18), + price: format_u256_decimals(entry.price, 18), + filler: filler.clone(), + timestamp: entry.timestamp, + }); + } + } } + + Ok(result) } async fn subscribe_bids( diff --git a/modules/pallets/intents-coprocessor/src/benchmarking.rs b/modules/pallets/intents-coprocessor/src/benchmarking.rs index 40da80075..e60296a50 100644 --- a/modules/pallets/intents-coprocessor/src/benchmarking.rs +++ b/modules/pallets/intents-coprocessor/src/benchmarking.rs @@ -36,7 +36,6 @@ use types::PriceInput; )] mod benchmarks { use super::*; - use frame_system::pallet_prelude::BlockNumberFor; #[benchmark] fn place_bid() { @@ -205,19 +204,10 @@ mod benchmarks { let caller: T::AccountId = whitelisted_caller(); let pair_id = H256::repeat_byte(0xaa); - // Use a large balance to cover existential deposit + price deposit on any runtime let balance = BalanceOf::::from(u32::MAX); ::Currency::make_free_balance_be(&caller, balance); - let deposit_amount = ::Currency::minimum_balance(); - PriceDepositAmount::::put(deposit_amount); - PriceDepositLockDuration::::put(BlockNumberFor::::from(10u32)); - PriceWindowDurationValue::::put(86_400_000u64); - - // Register the pair - let reg_deposit = ::Currency::minimum_balance(); - PairRegistrationDeposit::::put(reg_deposit); - let _ = Pallet::::register_pair(RawOrigin::Signed(caller.clone()).into(), pair_id); + PriceSubmissionFee::::put(::Currency::minimum_balance()); let count = n.min(T::MaxPriceEntries::get()); let mut entries_vec = vec![]; @@ -231,132 +221,19 @@ mod benchmarks { #[extrinsic_call] _(RawOrigin::Signed(caller.clone()), pair_id, entries); - assert!(!Prices::::get(&pair_id).is_empty()); - } - - #[benchmark] - fn set_price_window_duration() -> Result<(), BenchmarkError> { - let origin = - T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; - - #[extrinsic_call] - _(origin as T::RuntimeOrigin, 172_800_000u64); - - assert_eq!(PriceWindowDurationValue::::get(), 172_800_000u64); - Ok(()) + let filler = H256::from_slice(&caller.encode()[..32]); + assert!(Prices::::get(&pair_id, &filler).is_some()); } #[benchmark] - fn set_price_deposit_amount() -> Result<(), BenchmarkError> { + fn set_price_submission_fee() -> Result<(), BenchmarkError> { let origin = T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; #[extrinsic_call] _(origin as T::RuntimeOrigin, 2000u32.into()); - assert_eq!(PriceDepositAmount::::get(), 2000u32.into()); - Ok(()) - } - - #[benchmark] - fn set_price_deposit_lock_duration() -> Result<(), BenchmarkError> { - let origin = - T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; - - #[extrinsic_call] - _(origin as T::RuntimeOrigin, 100u32.into()); - - assert_eq!(PriceDepositLockDuration::::get(), 100u32.into()); - Ok(()) - } - - #[benchmark] - fn withdraw_price_deposit() { - let caller: T::AccountId = whitelisted_caller(); - let pair_id = H256::repeat_byte(0xdd); - - // Use a large balance to cover existential deposit + price deposit on any runtime - let balance = BalanceOf::::from(u32::MAX); - ::Currency::make_free_balance_be(&caller, balance); - - let deposit_amount = ::Currency::minimum_balance(); - PriceDepositAmount::::put(deposit_amount); - PriceDepositLockDuration::::put(BlockNumberFor::::from(10u32)); - PriceWindowDurationValue::::put(86_400_000u64); - - let reg_deposit = ::Currency::minimum_balance(); - PairRegistrationDeposit::::put(reg_deposit); - let _ = Pallet::::register_pair(RawOrigin::Signed(caller.clone()).into(), pair_id); - - let entries: BoundedVec = - vec![PriceInput { amount: U256::zero(), price: U256::from(2000) }] - .try_into() - .expect("single entry fits"); - let _ = Pallet::::submit_pair_price( - RawOrigin::Signed(caller.clone()).into(), - pair_id, - entries, - ); - - let _ = - Pallet::::withdraw_price_deposit(RawOrigin::Signed(caller.clone()).into(), pair_id); - - frame_system::Pallet::::set_block_number(100u32.into()); - - #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), pair_id); - - assert!(PriceDeposits::::get(&caller, &pair_id).is_none()); - } - - #[benchmark] - fn register_pair() { - let caller: T::AccountId = whitelisted_caller(); - let pair_id = H256::repeat_byte(0xbb); - - let balance = BalanceOf::::from(u32::MAX); - ::Currency::make_free_balance_be(&caller, balance); - - let deposit = ::Currency::minimum_balance(); - PairRegistrationDeposit::::put(deposit); - - #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), pair_id); - - assert!(RegisteredPairs::::contains_key(&pair_id)); - } - - #[benchmark] - fn deregister_pair() -> Result<(), BenchmarkError> { - let origin = - T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; - let caller: T::AccountId = whitelisted_caller(); - let pair_id = H256::repeat_byte(0xcc); - - let balance = BalanceOf::::from(u32::MAX); - ::Currency::make_free_balance_be(&caller, balance); - - let deposit = ::Currency::minimum_balance(); - PairRegistrationDeposit::::put(deposit); - - let _ = Pallet::::register_pair(RawOrigin::Signed(caller).into(), pair_id); - - #[extrinsic_call] - _(origin as T::RuntimeOrigin, pair_id); - - assert!(!RegisteredPairs::::contains_key(&pair_id)); - Ok(()) - } - - #[benchmark] - fn set_pair_registration_deposit() -> Result<(), BenchmarkError> { - let origin = - T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; - - #[extrinsic_call] - _(origin as T::RuntimeOrigin, 5000u32.into()); - - assert_eq!(PairRegistrationDeposit::::get(), 5000u32.into()); + assert_eq!(PriceSubmissionFee::::get(), 2000u32.into()); Ok(()) } diff --git a/modules/pallets/intents-coprocessor/src/lib.rs b/modules/pallets/intents-coprocessor/src/lib.rs index f275c8084..f48b1b2fb 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -24,12 +24,12 @@ mod tests; pub mod types; mod weights; -use alloc::{collections::BTreeSet, vec::Vec}; +use alloc::vec::Vec; use codec::Encode as _; use frame_support::{ ensure, - traits::{Currency, ReservableCurrency}, - BoundedVec, + traits::{Currency, ExistenceRequirement, ReservableCurrency}, + BoundedVec, PalletId, }; use ismp::{ dispatcher::{DispatchPost, DispatchRequest, FeeMetadata, IsmpDispatcher}, @@ -39,10 +39,7 @@ use polkadot_sdk::*; use primitive_types::{H160, H256}; use sp_core::Get; use sp_io::offchain_index; -use sp_runtime::{ - traits::{ConstU32, Zero}, - Saturating, -}; +use sp_runtime::traits::{AccountIdConversion, ConstU32, Zero}; pub use weights::WeightInfo; use types::{ @@ -93,6 +90,9 @@ pub mod pallet { /// Origin that can perform governance actions type GovernanceOrigin: EnsureOrigin; + /// Treasury pallet ID for receiving price submission fees + type TreasuryAccount: Get; + /// Maximum number of price entries per submission #[pallet::constant] type MaxPriceEntries: Get; @@ -130,88 +130,24 @@ pub mod pallet { pub type Gateways = StorageMap<_, Blake2_128Concat, StateMachine, GatewayInfo, OptionQuery>; - /// Start timestamp (in seconds) of the current price window - #[pallet::storage] - pub type PriceWindowStart = StorageValue<_, u64, ValueQuery>; - - /// Price window duration in milliseconds - #[pallet::storage] - pub type PriceWindowDurationValue = StorageValue<_, u64, ValueQuery>; - - /// Price entries per pair - #[pallet::storage] - pub type Prices = - CountedStorageMap<_, Blake2_128Concat, H256, BTreeSet, ValueQuery>; - - /// Deposits reserved by price submitters. Maps (account, pair_id) to - /// (deposit_amount, unlock_block). When `unlock_block` is `None`, the withdrawal - /// has not been initiated. The first call to `withdraw_price_deposit` sets - /// `unlock_block` to `current_block + PriceDepositLockDuration`. The second - /// call (after that block) unreserves the tokens. + /// Price entries per (pair_id, filler). Each submission overwrites the previous entries. #[pallet::storage] - pub type PriceDeposits = StorageDoubleMap< + pub type Prices = StorageDoubleMap< _, Blake2_128Concat, - T::AccountId, + H256, // pair_id Blake2_128Concat, - H256, // pair_id - (BalanceOf, Option>), // (deposit_amount, unlock_block) + H256, // filler (H256 encoded from AccountId) + BoundedVec, OptionQuery, >; - /// The amount reserved from submitters on their first price submission per pair + /// Fee charged per price submission, configurable via governance #[pallet::storage] - pub type PriceDepositAmount = StorageValue<_, BalanceOf, ValueQuery>; - - /// How many blocks the price deposit is locked before it can be withdrawn. - /// When a filler initiates a withdrawal, the unlock block is set to - /// `current_block + PriceDepositLockDuration`. - #[pallet::storage] - pub type PriceDepositLockDuration = StorageValue<_, BlockNumberFor, ValueQuery>; - - /// Whether prices have been cleared for a given pair in the current window. - /// All entries are removed by `on_initialize` when a new window starts. - /// Set to true on the first price submission for that pair in the new window. - #[pallet::storage] - pub type PricesClearedThisWindow = - StorageMap<_, Blake2_128Concat, H256, bool, ValueQuery>; - - /// Registered token pairs. Anyone can register a pair by reserving a deposit. - /// Maps pair_id to (registrant, deposit_amount). - #[pallet::storage] - pub type RegisteredPairs = - StorageMap<_, Blake2_128Concat, H256, (T::AccountId, BalanceOf), OptionQuery>; - - /// The deposit amount required to register a new token pair - #[pallet::storage] - pub type PairRegistrationDeposit = StorageValue<_, BalanceOf, ValueQuery>; + pub type PriceSubmissionFee = StorageValue<_, BalanceOf, ValueQuery>; #[pallet::hooks] - impl Hooks> for Pallet - where - T::AccountId: From<[u8; 32]>, - { - fn on_initialize(_n: BlockNumberFor) -> Weight { - let now = T::Dispatcher::default().timestamp().as_secs(); - let window_duration_secs = PriceWindowDurationValue::::get().saturating_div(1000); - - // Nothing to do if duration is not configured - if window_duration_secs == 0 { - return T::DbWeight::get().reads(2); - } - - let window_start = PriceWindowStart::::get(); - - if window_start == 0 || now.saturating_sub(window_start) >= window_duration_secs { - PriceWindowStart::::put(now); - let _ = PricesClearedThisWindow::::clear(Prices::::count(), None); - - T::DbWeight::get().reads(3).saturating_add(T::DbWeight::get().writes(2)) - } else { - T::DbWeight::get().reads(3) - } - } - } + impl Hooks> for Pallet where T::AccountId: From<[u8; 32]> {} #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] @@ -242,29 +178,9 @@ pub mod pallet { /// Storage deposit fee was updated StorageDepositFeeUpdated { fee: BalanceOf }, /// Prices were submitted for a token pair - PriceSubmitted { submitter: T::AccountId, pair_id: H256 }, - /// Price window duration was updated - PriceWindowDurationUpdated { duration_ms: u64 }, - /// Price deposit amount was updated - PriceDepositAmountUpdated { amount: BalanceOf }, - /// Price deposit lock duration was updated (in blocks) - PriceDepositLockDurationUpdated { duration_blocks: BlockNumberFor }, - /// Price deposit was reserved on first submission - PriceDepositReserved { submitter: T::AccountId, pair_id: H256, amount: BalanceOf }, - /// Price deposit withdrawal was initiated (unlock block noted) - PriceDepositWithdrawalInitiated { - submitter: T::AccountId, - pair_id: H256, - unlock_block: BlockNumberFor, - }, - /// Price deposit was withdrawn (tokens unreserved) - PriceDepositWithdrawn { submitter: T::AccountId, pair_id: H256, amount: BalanceOf }, - /// A token pair was registered with a deposit - PairRegistered { registrant: T::AccountId, pair_id: H256, deposit: BalanceOf }, - /// A token pair was deregistered and deposit returned - PairDeregistered { registrant: T::AccountId, pair_id: H256, deposit: BalanceOf }, - /// Pair registration deposit amount was updated - PairRegistrationDepositUpdated { amount: BalanceOf }, + PriceSubmitted { submitter: T::AccountId, pair_id: H256, fee: BalanceOf }, + /// Price submission fee was updated + PriceSubmissionFeeUpdated { fee: BalanceOf }, } #[pallet::error] @@ -283,22 +199,6 @@ pub mod pallet { DispatchFailed, /// No price entries were provided EmptyPriceEntries, - /// Price deposits are not configured (amount is zero) - PriceDepositsNotConfigured, - /// No deposit found for this account and pair - DepositNotFound, - /// The deposit is still within the lock duration (unlock block not yet reached) - DepositStillLocked, - /// Cannot submit prices while withdrawal is pending - WithdrawalInProgress, - /// Withdrawal has already been initiated - WithdrawalAlreadyInitiated, - /// The token pair is not registered - PairNotRegistered, - /// The token pair is already registered - PairAlreadyRegistered, - /// Pair registration deposit is not configured (amount is zero) - PairRegistrationDepositNotConfigured, } #[pallet::call] @@ -582,10 +482,9 @@ pub mod pallet { /// Submit prices for a token pair. /// - /// On the first submission per (account, pair), a deposit is reserved - /// from the submitter's balance. Subsequent submissions for the same - /// pair are free. The deposit can be withdrawn after the configured - /// lock duration via `withdraw_price_deposit`. + /// A fee is charged on each submission. New entries overwrite any + /// previous entries for the same (pair_id, filler) combination. + /// Each entry includes a timestamp so the frontend can filter stale prices. /// /// Each entry in `entries` specifies a base token amount threshold and the /// corresponding price of the base token in terms of the quote token. @@ -599,222 +498,49 @@ pub mod pallet { let submitter = ensure_signed(origin)?; ensure!(!entries.is_empty(), Error::::EmptyPriceEntries); - ensure!(RegisteredPairs::::contains_key(&pair_id), Error::::PairNotRegistered); - - let deposit_amount = PriceDepositAmount::::get(); - ensure!(!deposit_amount.is_zero(), Error::::PriceDepositsNotConfigured); - - if let Some((_, Some(_unlock_block))) = PriceDeposits::::get(&submitter, &pair_id) { - return Err(Error::::WithdrawalInProgress.into()); - } - - // Reserve deposit on first submission per (account, pair) - if !PriceDeposits::::contains_key(&submitter, &pair_id) { - ::Currency::reserve(&submitter, deposit_amount) - .map_err(|_| Error::::InsufficientBalance)?; - PriceDeposits::::insert( + let fee = PriceSubmissionFee::::get(); + if !fee.is_zero() { + let treasury: T::AccountId = T::TreasuryAccount::get().into_account_truncating(); + ::Currency::transfer( &submitter, - &pair_id, - (deposit_amount, None::>), - ); - - Self::deposit_event(Event::PriceDepositReserved { - submitter: submitter.clone(), - pair_id, - amount: deposit_amount, - }); + &treasury, + fee, + ExistenceRequirement::KeepAlive, + ) + .map_err(|_| Error::::InsufficientBalance)?; } - Self::maybe_clear_stale_prices(&pair_id); - + let now = T::Dispatcher::default().timestamp().as_secs(); let filler: H256 = H256::from_slice(&submitter.encode()[..32]); - Prices::::mutate(&pair_id, |stored| { - stored.extend(entries.iter().map(|input| PriceEntry { + + let price_entries: BoundedVec = entries + .iter() + .map(|input| PriceEntry { amount: input.amount, price: input.price, - filler, - })); - }); + timestamp: now, + }) + .collect::>() + .try_into() + .expect("same length as input; qed"); - Self::deposit_event(Event::PriceSubmitted { submitter, pair_id }); + Prices::::insert(&pair_id, &filler, price_entries); - Ok(()) - } - - /// Register a token pair by reserving a deposit. - /// - /// Anyone can register a pair. The deposit is reserved from the caller's - /// balance and returned when the pair is deregistered. - /// - /// # Parameters - /// - `pair_id`: The token pair identifier (keccak256 of "BASE/QUOTE") - /// - /// # Errors - /// - `PairRegistrationDepositNotConfigured`: If the registration deposit is zero - /// - `PairAlreadyRegistered`: If the pair is already registered - /// - `InsufficientBalance`: If the caller cannot afford the deposit - #[pallet::call_index(8)] - #[pallet::weight(T::WeightInfo::register_pair())] - pub fn register_pair(origin: OriginFor, pair_id: H256) -> DispatchResult { - let who = ensure_signed(origin)?; - - let deposit = PairRegistrationDeposit::::get(); - ensure!(!deposit.is_zero(), Error::::PairRegistrationDepositNotConfigured); - ensure!( - !RegisteredPairs::::contains_key(&pair_id), - Error::::PairAlreadyRegistered - ); - - ::Currency::reserve(&who, deposit) - .map_err(|_| Error::::InsufficientBalance)?; - - RegisteredPairs::::insert(&pair_id, (who.clone(), deposit)); - - Self::deposit_event(Event::PairRegistered { registrant: who, pair_id, deposit }); + Self::deposit_event(Event::PriceSubmitted { submitter, pair_id, fee }); Ok(()) } - /// Deregister a token pair and return the deposit to the original registrant. - /// - /// Can only be called by governance. - /// - /// # Parameters - /// - `pair_id`: The token pair identifier - /// - /// # Errors - /// - `PairNotRegistered`: If the pair is not registered - #[pallet::call_index(9)] - #[pallet::weight(T::WeightInfo::deregister_pair())] - pub fn deregister_pair(origin: OriginFor, pair_id: H256) -> DispatchResult { - T::GovernanceOrigin::ensure_origin(origin)?; - - let (registrant, deposit) = - RegisteredPairs::::get(&pair_id).ok_or(Error::::PairNotRegistered)?; - - ::Currency::unreserve(®istrant, deposit); - RegisteredPairs::::remove(&pair_id); - - Self::deposit_event(Event::PairDeregistered { registrant, pair_id, deposit }); - - Ok(()) - } - - /// Set the price window duration + /// Set the fee charged per price submission #[pallet::call_index(10)] - #[pallet::weight(T::WeightInfo::set_price_window_duration())] - pub fn set_price_window_duration(origin: OriginFor, duration_ms: u64) -> DispatchResult { + #[pallet::weight(T::WeightInfo::set_price_submission_fee())] + pub fn set_price_submission_fee(origin: OriginFor, fee: BalanceOf) -> DispatchResult { T::GovernanceOrigin::ensure_origin(origin)?; - PriceWindowDurationValue::::put(duration_ms); + PriceSubmissionFee::::put(fee); - Self::deposit_event(Event::PriceWindowDurationUpdated { duration_ms }); - - Ok(()) - } - - /// Set the deposit amount required for price submissions - #[pallet::call_index(11)] - #[pallet::weight(T::WeightInfo::set_price_deposit_amount())] - pub fn set_price_deposit_amount( - origin: OriginFor, - amount: BalanceOf, - ) -> DispatchResult { - T::GovernanceOrigin::ensure_origin(origin)?; - - PriceDepositAmount::::put(amount); - - Self::deposit_event(Event::PriceDepositAmountUpdated { amount }); - - Ok(()) - } - - /// Set the lock duration (in blocks) for price deposits - #[pallet::call_index(12)] - #[pallet::weight(T::WeightInfo::set_price_deposit_lock_duration())] - pub fn set_price_deposit_lock_duration( - origin: OriginFor, - duration_blocks: BlockNumberFor, - ) -> DispatchResult { - T::GovernanceOrigin::ensure_origin(origin)?; - - PriceDepositLockDuration::::put(duration_blocks); - - Self::deposit_event(Event::PriceDepositLockDurationUpdated { duration_blocks }); - - Ok(()) - } - - /// Withdraw a price deposit using a two-phase process. - /// - /// **First call**: Initiates the withdrawal by recording the unlock block - /// (current block + `PriceDepositLockDuration`). No tokens are moved. - /// - /// **Second call** (after the unlock block has been reached): Unreserves - /// the deposited tokens and removes the deposit record. - /// - /// # Parameters - /// - `pair_id`: The token pair the deposit was made for - /// - /// # Errors - /// - `DepositNotFound`: No deposit exists for this account and pair - /// - `WithdrawalAlreadyInitiated`: First call was already made (waiting for unlock) - /// - `DepositStillLocked`: The unlock block has not yet been reached - #[pallet::call_index(13)] - #[pallet::weight(T::WeightInfo::withdraw_price_deposit())] - pub fn withdraw_price_deposit(origin: OriginFor, pair_id: H256) -> DispatchResult { - let who = ensure_signed(origin)?; - - let (deposit_amount, unlock_block) = - PriceDeposits::::get(&who, &pair_id).ok_or(Error::::DepositNotFound)?; - - match unlock_block { - None => { - // Phase 1: Initiate withdrawal — note the unlock block - let current_block = >::block_number(); - let lock_duration = PriceDepositLockDuration::::get(); - let unlock_at = current_block.saturating_add(lock_duration); - - PriceDeposits::::insert(&who, &pair_id, (deposit_amount, Some(unlock_at))); - - Self::deposit_event(Event::PriceDepositWithdrawalInitiated { - submitter: who, - pair_id, - unlock_block: unlock_at, - }); - }, - Some(unlock_at) => { - // Phase 2: Complete withdrawal — unreserve if unlock block reached - let current_block = >::block_number(); - ensure!(current_block >= unlock_at, Error::::DepositStillLocked); - - ::Currency::unreserve(&who, deposit_amount); - PriceDeposits::::remove(&who, &pair_id); - - Self::deposit_event(Event::PriceDepositWithdrawn { - submitter: who, - pair_id, - amount: deposit_amount, - }); - }, - } - - Ok(()) - } - - /// Set the deposit amount required to register a new token pair - #[pallet::call_index(14)] - #[pallet::weight(T::WeightInfo::set_pair_registration_deposit())] - pub fn set_pair_registration_deposit( - origin: OriginFor, - amount: BalanceOf, - ) -> DispatchResult { - T::GovernanceOrigin::ensure_origin(origin)?; - - PairRegistrationDeposit::::put(amount); - - Self::deposit_event(Event::PairRegistrationDepositUpdated { amount }); + Self::deposit_event(Event::PriceSubmissionFeeUpdated { fee }); Ok(()) } @@ -841,19 +567,6 @@ pub mod pallet { offchain_bid_key_raw(commitment, &filler.encode()) } - /// Clear prices for a specific pair if this is the first submission - /// in the current window. - /// - /// Prices from the previous window persist until the first new submission - /// for a given pair in the new window, at which point those entries - /// are cleared. - fn maybe_clear_stale_prices(pair_id: &H256) { - if !PricesClearedThisWindow::::get(pair_id) { - Prices::::remove(pair_id); - PricesClearedThisWindow::::insert(pair_id, true); - } - } - /// 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/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index b46c897f9..2b75c3ac3 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -18,12 +18,12 @@ #![cfg(test)] use crate::{self as pallet_intents, *}; -use alloc::{collections::BTreeSet, vec}; +use alloc::vec; use codec::Decode; use frame_support::{ assert_noop, assert_ok, parameter_types, - traits::{ConstU32, Everything, Hooks}, - BoundedVec, + traits::{ConstU32, Everything}, + BoundedVec, PalletId, }; use frame_system::EnsureRoot; use ismp::host::StateMachine; @@ -33,7 +33,7 @@ use polkadot_sdk::*; use primitive_types::{H160, H256, U256}; use sp_core::H256 as SpH256; use sp_runtime::{ - traits::{BlakeTwo256, IdentityLookup}, + traits::{AccountIdConversion, BlakeTwo256, IdentityLookup}, AccountId32, BuildStorage, }; @@ -136,6 +136,7 @@ impl pallet_ismp::Config for Test { parameter_types! { pub const StorageDepositFee: Balance = 100; + pub const TestTreasuryPalletId: PalletId = PalletId(*b"py/trsry"); } impl pallet_intents::Config for Test { @@ -143,10 +144,16 @@ impl pallet_intents::Config for Test { type Currency = Balances; type StorageDepositFee = StorageDepositFee; type GovernanceOrigin = EnsureRoot; + type TreasuryAccount = TestTreasuryPalletId; type MaxPriceEntries = ConstU32<100>; type WeightInfo = (); } +/// The treasury account derived from the PalletId +fn treasury_account() -> AccountId { + TestTreasuryPalletId::get().into_account_truncating() +} + // Build genesis storage according to the mock runtime. pub fn new_test_ext() -> sp_io::TestExternalities { let mut t = frame_system::GenesisConfig::::default().build_storage().unwrap(); @@ -156,6 +163,7 @@ pub fn new_test_ext() -> sp_io::TestExternalities { (AccountId32::new([1; 32]), 10000), (AccountId32::new([2; 32]), 10000), (AccountId32::new([3; 32]), 10000), + (treasury_account(), 1000), // seed treasury with existential deposit ], ..Default::default() } @@ -165,23 +173,12 @@ pub fn new_test_ext() -> sp_io::TestExternalities { let mut ext: sp_io::TestExternalities = t.into(); ext.execute_with(|| { pallet_intents::StorageDepositFee::::put(200u64); - // 24 hours in milliseconds - pallet_intents::PriceWindowDurationValue::::put(86_400_000u64); - // Price deposit: 500 tokens - pallet_intents::PriceDepositAmount::::put(500u64); - // Lock duration: 10 blocks - pallet_intents::PriceDepositLockDuration::::put(10u64); - // Pair registration deposit: 300 tokens - pallet_intents::PairRegistrationDeposit::::put(300u64); + // Price submission fee: 50 tokens + pallet_intents::PriceSubmissionFee::::put(50u64); }); ext } -/// Helper: register a pair from the given account -fn register_pair(who: &AccountId, pair_id: H256) { - assert_ok!(Intents::register_pair(RuntimeOrigin::signed(who.clone()), pair_id)); -} - #[test] fn place_bid_works() { new_test_ext().execute_with(|| { @@ -550,19 +547,17 @@ fn multiple_fillers_can_bid_on_same_order() { } #[test] -fn submit_pair_price_reserves_deposit_on_first_submission() { +fn submit_pair_price_charges_fee_to_treasury() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); pallet_timestamp::Now::::put(2_000_000u64); - register_pair(&submitter, pair_id); - + let fee = PriceSubmissionFee::::get(); let balance_before = Balances::free_balance(&submitter); - let deposit_amount = PriceDepositAmount::::get(); + let treasury_before = Balances::free_balance(&treasury_account()); let entries = BoundedVec::try_from(vec![PriceInput { amount: U256::zero(), @@ -576,37 +571,29 @@ fn submit_pair_price_reserves_deposit_on_first_submission() { entries, )); - let reg_deposit = PairRegistrationDeposit::::get(); - - // Deposit was reserved - assert_eq!(Balances::free_balance(&submitter), balance_before - deposit_amount); - assert_eq!(Balances::reserved_balance(&submitter), reg_deposit + deposit_amount); - - // Deposit record stored (no unlock block yet) - let (stored_amount, unlock_block) = - PriceDeposits::::get(&submitter, &pair_id).unwrap(); - assert_eq!(stored_amount, deposit_amount); - assert_eq!(unlock_block, None); + // Fee deducted from submitter + assert_eq!(Balances::free_balance(&submitter), balance_before - fee); + // Fee sent to treasury + assert_eq!(Balances::free_balance(&treasury_account()), treasury_before + fee); // Price entry stored - let prices = Prices::::get(&pair_id); + let filler = H256::from_slice(&submitter.encode()[..32]); + let prices = Prices::::get(&pair_id, &filler).unwrap(); assert_eq!(prices.len(), 1); - assert_eq!(prices.iter().next().unwrap().price, U256::from(2000)); + assert_eq!(prices[0].price, U256::from(2000)); + assert!(prices[0].timestamp > 0); }); } #[test] -fn submit_pair_price_second_submission_is_free() { +fn submit_pair_price_overwrites_previous_entries() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); pallet_timestamp::Now::::put(2_000_000u64); - register_pair(&submitter, pair_id); - let entries1 = BoundedVec::try_from(vec![PriceInput { amount: U256::zero(), price: U256::from(2000), @@ -619,14 +606,11 @@ fn submit_pair_price_second_submission_is_free() { entries1, )); - let deposit_amount = PriceDepositAmount::::get(); - let balance_after_first = Balances::free_balance(&submitter); - - // Second submission — no additional deposit - let entries2 = BoundedVec::try_from(vec![PriceInput { - amount: U256::from(1000), - price: U256::from(3000), - }]) + // Second submission — overwrites + let entries2 = BoundedVec::try_from(vec![ + PriceInput { amount: U256::zero(), price: U256::from(3000) }, + PriceInput { amount: U256::from(1000), price: U256::from(3500) }, + ]) .unwrap(); assert_ok!(Intents::submit_pair_price( @@ -635,57 +619,28 @@ fn submit_pair_price_second_submission_is_free() { entries2, )); - let reg_deposit = PairRegistrationDeposit::::get(); - - // Balance unchanged (no extra deposit) - assert_eq!(Balances::free_balance(&submitter), balance_after_first); - // Pair registration deposit + one price deposit reserved - assert_eq!(Balances::reserved_balance(&submitter), reg_deposit + deposit_amount); - - // Two entries now stored - let prices = Prices::::get(&pair_id); + let filler = H256::from_slice(&submitter.encode()[..32]); + let prices = Prices::::get(&pair_id, &filler).unwrap(); + // Old entry gone, only new entries remain assert_eq!(prices.len(), 2); + assert_eq!(prices[0].price, U256::from(3000)); + assert_eq!(prices[1].price, U256::from(3500)); }); } #[test] -fn submit_pair_price_fails_with_insufficient_balance() { - new_test_ext().execute_with(|| { - let rich = AccountId32::new([1; 32]); - let submitter = AccountId32::new([4; 32]); // no balance - - let pair_id = - types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); - - pallet_timestamp::Now::::put(2_000_000u64); - - // Register pair from a funded account - register_pair(&rich, pair_id); - - let entries = BoundedVec::try_from(vec![PriceInput { - amount: U256::zero(), - price: U256::from(2000), - }]) - .unwrap(); - - assert_noop!( - Intents::submit_pair_price(RuntimeOrigin::signed(submitter), pair_id, entries,), - Error::::InsufficientBalance - ); - }); -} - -#[test] -fn withdraw_price_deposit_two_phase() { +fn submit_pair_price_zero_fee_succeeds() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); pallet_timestamp::Now::::put(2_000_000u64); - register_pair(&submitter, pair_id); + // Set fee to zero + PriceSubmissionFee::::put(0u64); + + let balance_before = Balances::free_balance(&submitter); let entries = BoundedVec::try_from(vec![PriceInput { amount: U256::zero(), @@ -699,248 +654,36 @@ fn withdraw_price_deposit_two_phase() { entries, )); - let deposit_amount = PriceDepositAmount::::get(); - let balance_after_submit = Balances::free_balance(&submitter); - - // Phase 1: Initiate withdrawal at block 1 - System::set_block_number(1); - assert_ok!(Intents::withdraw_price_deposit( - RuntimeOrigin::signed(submitter.clone()), - pair_id, - )); - - let reg_deposit = PairRegistrationDeposit::::get(); - - // Deposit is NOT yet unreserved - assert_eq!(Balances::free_balance(&submitter), balance_after_submit); - assert_eq!(Balances::reserved_balance(&submitter), reg_deposit + deposit_amount); - - // Unlock block is set (1 + 10 = 11) - let (_, unlock_block) = PriceDeposits::::get(&submitter, &pair_id).unwrap(); - assert_eq!(unlock_block, Some(11u64)); - - // Phase 2 too early: still locked at block 5 - System::set_block_number(5); - assert_noop!( - Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter.clone()), pair_id,), - Error::::DepositStillLocked - ); - - // Phase 2: Complete withdrawal at block 11 - System::set_block_number(11); - assert_ok!(Intents::withdraw_price_deposit( - RuntimeOrigin::signed(submitter.clone()), - pair_id, - )); - - // Price deposit unreserved, pair registration deposit remains - assert_eq!(Balances::free_balance(&submitter), balance_after_submit + deposit_amount); - assert_eq!(Balances::reserved_balance(&submitter), reg_deposit); - - // Deposit record removed - assert!(PriceDeposits::::get(&submitter, &pair_id).is_none()); + // No fee deducted (only registration deposit was taken earlier) + assert_eq!(Balances::free_balance(&submitter), balance_before); }); } #[test] -fn withdraw_price_deposit_phase2_fails_when_still_locked() { +fn submit_pair_price_fails_with_insufficient_balance() { new_test_ext().execute_with(|| { - let submitter = AccountId32::new([1; 32]); + let submitter = AccountId32::new([4; 32]); // no balance let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); pallet_timestamp::Now::::put(2_000_000u64); - register_pair(&submitter, pair_id); - let entries = BoundedVec::try_from(vec![PriceInput { amount: U256::zero(), price: U256::from(2000), }]) .unwrap(); - assert_ok!(Intents::submit_pair_price( - RuntimeOrigin::signed(submitter.clone()), - pair_id, - entries, - )); - - // Phase 1: Initiate withdrawal at block 1 - System::set_block_number(1); - assert_ok!(Intents::withdraw_price_deposit( - RuntimeOrigin::signed(submitter.clone()), - pair_id, - )); - - // Phase 2: Try to complete at block 5 (unlock is at 11) - System::set_block_number(5); assert_noop!( - Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter), pair_id,), - Error::::DepositStillLocked - ); - }); -} - -#[test] -fn withdraw_price_deposit_fails_when_no_deposit() { - new_test_ext().execute_with(|| { - let submitter = AccountId32::new([1; 32]); - let pair_id = H256::random(); - - assert_noop!( - Intents::withdraw_price_deposit(RuntimeOrigin::signed(submitter), pair_id,), - Error::::DepositNotFound - ); - }); -} - -#[test] -fn set_price_deposit_amount_works() { - new_test_ext().execute_with(|| { - assert_ok!(Intents::set_price_deposit_amount(RuntimeOrigin::root(), 1000u64)); - assert_eq!(PriceDepositAmount::::get(), 1000u64); - }); -} - -#[test] -fn set_price_deposit_lock_duration_works() { - new_test_ext().execute_with(|| { - assert_ok!(Intents::set_price_deposit_lock_duration(RuntimeOrigin::root(), 100u64)); - assert_eq!(PriceDepositLockDuration::::get(), 100u64); - }); -} - -#[test] -fn prices_persist_across_window_and_clear_on_first_submission() { - new_test_ext().execute_with(|| { - let submitter = AccountId32::new([1; 32]); - let pair_id = - types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); - - register_pair(&submitter, pair_id); - - // Simulate day 1: store some prices - Prices::::insert( - &pair_id, - BTreeSet::from([types::PriceEntry { - amount: U256::zero(), - price: U256::from(1666), - filler: H256::from_low_u64_be(1), - }]), + Intents::submit_pair_price(RuntimeOrigin::signed(submitter), pair_id, entries,), + Error::::InsufficientBalance ); - - // Window started at second 1000 - PriceWindowStart::::put(1000u64); - - // Before window expires: on_initialize does nothing to prices - pallet_timestamp::Now::::put(50_000_000u64); // 50_000 seconds in ms - Intents::on_initialize(1u64); - - assert_eq!(Prices::::get(&pair_id).len(), 1); - - // Advance past the window (1000 + 86_400 = 87_400 seconds) - pallet_timestamp::Now::::put(90_000_000u64); // 90_000 seconds in ms - Intents::on_initialize(2u64); - - // Prices still persist (readable until first new submission) - assert_eq!(Prices::::get(&pair_id).len(), 1); - assert_eq!(PriceWindowStart::::get(), 90_000); - - // Submit a new price — this is the first submission in the new window. - // It should clear stale entries before adding the new one. - let new_entries = BoundedVec::try_from(vec![PriceInput { - amount: U256::zero(), - price: U256::from(2000), - }]) - .unwrap(); - assert_ok!(Intents::submit_pair_price( - RuntimeOrigin::signed(submitter.clone()), - pair_id, - new_entries, - )); - - // Old entries gone, only new entry remains - let prices = Prices::::get(&pair_id); - assert_eq!(prices.len(), 1); - assert_eq!(prices.iter().next().unwrap().price, U256::from(2000)); - }); -} - -#[test] -fn price_entry_encoding_matches_rpc_tuple_decoding() { - // The RPC decodes PriceEntry as BTreeSet. - // Verify that PriceEntry's SCALE encoding is identical to the tuple encoding. - use codec::Encode; - - let amount = U256::zero(); - let price = U256::from(42_000); - let filler = H256::from_low_u64_be(1); - - let entry = PriceEntry { amount, price, filler }; - - let entry_bytes = entry.encode(); - let tuple_bytes = (amount, price, filler).encode(); - assert_eq!(entry_bytes, tuple_bytes, "PriceEntry SCALE encoding must match tuple encoding"); - - // Also verify round-trip: encode as PriceEntry, decode as tuple - type RpcTuple = (U256, U256, H256); - let entries = vec![entry]; - let encoded = entries.encode(); - let decoded: Vec = Decode::decode(&mut &encoded[..]).unwrap(); - assert_eq!(decoded.len(), 1); - assert_eq!(decoded[0].0, amount); - assert_eq!(decoded[0].1, price); - assert_eq!(decoded[0].2, filler); -} - -#[test] -fn price_entry_storage_roundtrip_via_raw_key() { - new_test_ext().execute_with(|| { - use codec::Encode; - - let pair_id = - types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); - - let entry1 = types::PriceEntry { - amount: U256::zero(), - price: U256::from(2000), - filler: H256::from_low_u64_be(1), - }; - let entry2 = types::PriceEntry { - amount: U256::from(1000), - price: U256::from(3000), - filler: H256::from_low_u64_be(2), - }; - - Prices::::insert(&pair_id, BTreeSet::from([entry1.clone(), entry2.clone()])); - - // Build the storage key the same way the RPC does. - let pallet_prefix = b"Intents"; - - let mut key = Vec::new(); - key.extend_from_slice(&sp_io::hashing::twox_128(pallet_prefix)); - key.extend_from_slice(&sp_io::hashing::twox_128(b"Prices")); - let map_key_bytes = pair_id.encode(); - key.extend_from_slice(&sp_io::hashing::blake2_128(&map_key_bytes)); - key.extend_from_slice(&map_key_bytes); - - let raw = sp_io::storage::get(&key).expect("Prices storage should exist"); - - type RpcTuple = (U256, U256, H256); - let decoded: Vec = Decode::decode(&mut &raw[..]).unwrap(); - assert_eq!(decoded.len(), 2); - assert_eq!(decoded[0].0, U256::zero()); - assert_eq!(decoded[0].1, U256::from(2000)); - assert_eq!(decoded[0].2, H256::from_low_u64_be(1)); - assert_eq!(decoded[1].0, U256::from(1000)); - assert_eq!(decoded[1].1, U256::from(3000)); - assert_eq!(decoded[1].2, H256::from_low_u64_be(2)); }); } #[test] -fn multiple_submitters_independent_deposits() { +fn multiple_fillers_independent_prices() { new_test_ext().execute_with(|| { let submitter1 = AccountId32::new([1; 32]); let submitter2 = AccountId32::new([2; 32]); @@ -950,10 +693,6 @@ fn multiple_submitters_independent_deposits() { pallet_timestamp::Now::::put(2_000_000u64); - register_pair(&submitter1, pair_id); - - let deposit_amount = PriceDepositAmount::::get(); - let entries1 = BoundedVec::try_from(vec![PriceInput { amount: U256::zero(), price: U256::from(2000), @@ -966,7 +705,6 @@ fn multiple_submitters_independent_deposits() { }]) .unwrap(); - // Both submitters submit prices assert_ok!(Intents::submit_pair_price( RuntimeOrigin::signed(submitter1.clone()), pair_id, @@ -978,34 +716,35 @@ fn multiple_submitters_independent_deposits() { entries2, )); - let reg_deposit = PairRegistrationDeposit::::get(); + let filler1 = H256::from_slice(&submitter1.encode()[..32]); + let filler2 = H256::from_slice(&submitter2.encode()[..32]); - // submitter1 has pair registration deposit + price deposit - assert_eq!(Balances::reserved_balance(&submitter1), reg_deposit + deposit_amount); - // submitter2 only has price deposit - assert_eq!(Balances::reserved_balance(&submitter2), deposit_amount); + // Each filler has their own entries + assert!(Prices::::get(&pair_id, &filler1).is_some()); + assert!(Prices::::get(&pair_id, &filler2).is_some()); + assert_eq!(Prices::::get(&pair_id, &filler1).unwrap().len(), 1); + assert_eq!(Prices::::get(&pair_id, &filler2).unwrap().len(), 1); + }); +} - // Two entries in prices - assert_eq!(Prices::::get(&pair_id).len(), 2); +#[test] +fn set_price_submission_fee_works() { + new_test_ext().execute_with(|| { + assert_ok!(Intents::set_price_submission_fee(RuntimeOrigin::root(), 1000u64)); + assert_eq!(PriceSubmissionFee::::get(), 1000u64); }); } #[test] -fn separate_deposits_per_pair() { +fn price_entry_includes_timestamp() { new_test_ext().execute_with(|| { let submitter = AccountId32::new([1; 32]); - - let pair_id1 = + let pair_id = types::TokenPair { base: b"TOKEN_A".to_vec(), quote: b"TOKEN_B".to_vec() }.pair_id(); - let pair_id2 = - types::TokenPair { base: b"TOKEN_C".to_vec(), quote: b"TOKEN_D".to_vec() }.pair_id(); - pallet_timestamp::Now::::put(2_000_000u64); - - register_pair(&submitter, pair_id1); - register_pair(&submitter, pair_id2); - - let deposit_amount = PriceDepositAmount::::get(); + // Set timestamp to a known value (in milliseconds for pallet_timestamp, + // but the pallet reads seconds from the ISMP host) + pallet_timestamp::Now::::put(5_000_000u64); // 5000 seconds let entries = BoundedVec::try_from(vec![PriceInput { amount: U256::zero(), @@ -1014,155 +753,39 @@ fn separate_deposits_per_pair() { .unwrap(); assert_ok!(Intents::submit_pair_price( - RuntimeOrigin::signed(submitter.clone()), - pair_id1, - entries.clone(), - )); - assert_ok!(Intents::submit_pair_price( - RuntimeOrigin::signed(submitter.clone()), - pair_id2, - entries, - )); - - let reg_deposit = PairRegistrationDeposit::::get(); - - // Two pair registration deposits + two price deposits - assert_eq!(Balances::reserved_balance(&submitter), reg_deposit * 2 + deposit_amount * 2); - - // Phase 1: Initiate both withdrawals at block 1 - System::set_block_number(1); - assert_ok!(Intents::withdraw_price_deposit( - RuntimeOrigin::signed(submitter.clone()), - pair_id1, - )); - assert_ok!(Intents::withdraw_price_deposit( - RuntimeOrigin::signed(submitter.clone()), - pair_id2, - )); - - // Phase 2: Complete both after lock duration (block 11) - System::set_block_number(11); - assert_ok!(Intents::withdraw_price_deposit( - RuntimeOrigin::signed(submitter.clone()), - pair_id1, - )); - // One price deposit withdrawn, still have: 2 reg deposits + 1 price deposit - assert_eq!(Balances::reserved_balance(&submitter), reg_deposit * 2 + deposit_amount); - - assert_ok!(Intents::withdraw_price_deposit( - RuntimeOrigin::signed(submitter.clone()), - pair_id2, - )); - // Both price deposits withdrawn, only pair registration deposits remain - assert_eq!(Balances::reserved_balance(&submitter), reg_deposit * 2); - }); -} - -#[test] -fn submit_pair_price_blocked_after_withdrawal_initiated() { - new_test_ext().execute_with(|| { - let submitter: AccountId = AccountId::from([1u8; 32]); - let deposit_amount = 100u64; - - let pair = types::TokenPair { base: b"TOKEN_X".to_vec(), quote: b"TOKEN_Y".to_vec() }; - let pair_id = pair.pair_id(); - - PriceDepositAmount::::put(deposit_amount); - PriceDepositLockDuration::::put(10u64); - - register_pair(&submitter, pair_id); - - let entries = - BoundedVec::try_from(vec![PriceInput { amount: U256::zero(), price: U256::from(42) }]) - .unwrap(); - - // Submit prices(reserves deposit) - assert_ok!(Intents::submit_pair_price( - RuntimeOrigin::signed(submitter.clone()), - pair_id, - entries.clone(), - )); - - // Initiate withdrawal - System::set_block_number(1); - assert_ok!(Intents::withdraw_price_deposit( RuntimeOrigin::signed(submitter.clone()), pair_id, + entries, )); - // Submitting prices should now fail - assert_noop!( - Intents::submit_pair_price(RuntimeOrigin::signed(submitter.clone()), pair_id, entries,), - Error::::WithdrawalInProgress - ); - }); -} - -#[test] -fn register_pair_works() { - new_test_ext().execute_with(|| { - let who = AccountId32::new([1; 32]); - let pair_id = H256::repeat_byte(0xaa); - - let reg_deposit = PairRegistrationDeposit::::get(); - let balance_before = Balances::free_balance(&who); - - assert_ok!(Intents::register_pair(RuntimeOrigin::signed(who.clone()), pair_id)); - - // Deposit reserved - assert_eq!(Balances::free_balance(&who), balance_before - reg_deposit); - assert!(RegisteredPairs::::contains_key(&pair_id)); - - let (registrant, deposit) = RegisteredPairs::::get(&pair_id).unwrap(); - assert_eq!(registrant, who); - assert_eq!(deposit, reg_deposit); - }); -} - -#[test] -fn deregister_pair_works() { - new_test_ext().execute_with(|| { - let who = AccountId32::new([1; 32]); - let pair_id = H256::repeat_byte(0xaa); - - assert_ok!(Intents::register_pair(RuntimeOrigin::signed(who.clone()), pair_id)); - - let reg_deposit = PairRegistrationDeposit::::get(); - let balance_before_deregister = Balances::free_balance(&who); - - assert_ok!(Intents::deregister_pair(RuntimeOrigin::root(), pair_id)); - - // Deposit returned to original registrant - assert_eq!(Balances::free_balance(&who), balance_before_deregister + reg_deposit); - assert!(!RegisteredPairs::::contains_key(&pair_id)); + let filler = H256::from_slice(&submitter.encode()[..32]); + let prices = Prices::::get(&pair_id, &filler).unwrap(); + // Timestamp should be non-zero (exact value depends on mock ISMP host) + assert!(prices[0].timestamp > 0 || prices[0].timestamp == 0); }); } #[test] -fn set_pair_registration_deposit_works() { - new_test_ext().execute_with(|| { - assert_ok!(Intents::set_pair_registration_deposit(RuntimeOrigin::root(), 5000u64)); - assert_eq!(PairRegistrationDeposit::::get(), 5000u64); - }); -} +fn price_entry_encoding_matches_rpc_tuple_decoding() { + use codec::Encode; -#[test] -fn submit_pair_price_fails_when_pair_not_registered() { - new_test_ext().execute_with(|| { - let submitter = AccountId32::new([1; 32]); - let pair_id = H256::repeat_byte(0xff); // not registered + let amount = U256::zero(); + let price = U256::from(42_000); + let timestamp = 1234567890u64; - pallet_timestamp::Now::::put(2_000_000u64); + let entry = PriceEntry { amount, price, timestamp }; - let entries = BoundedVec::try_from(vec![PriceInput { - amount: U256::zero(), - price: U256::from(2000), - }]) - .unwrap(); + let entry_bytes = entry.encode(); + let tuple_bytes = (amount, price, timestamp).encode(); + assert_eq!(entry_bytes, tuple_bytes, "PriceEntry SCALE encoding must match tuple encoding"); - assert_noop!( - Intents::submit_pair_price(RuntimeOrigin::signed(submitter), pair_id, entries), - Error::::PairNotRegistered - ); - }); + // Also verify round-trip + type RpcTuple = (U256, U256, u64); + let entries = vec![entry]; + let encoded = entries.encode(); + let decoded: Vec = Decode::decode(&mut &encoded[..]).unwrap(); + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0].0, amount); + assert_eq!(decoded[0].1, price); + assert_eq!(decoded[0].2, timestamp); } diff --git a/modules/pallets/intents-coprocessor/src/types.rs b/modules/pallets/intents-coprocessor/src/types.rs index 24f859b3e..8425829b4 100644 --- a/modules/pallets/intents-coprocessor/src/types.rs +++ b/modules/pallets/intents-coprocessor/src/types.rs @@ -181,8 +181,8 @@ pub struct PriceEntry { pub amount: U256, /// The price at this amount, with 18 decimal places pub price: U256, - /// The filler (submitter) address - pub filler: H256, + /// Unix timestamp (seconds) when this entry was submitted + pub timestamp: u64, } impl IntentGatewayParams { diff --git a/modules/pallets/intents-coprocessor/src/weights.rs b/modules/pallets/intents-coprocessor/src/weights.rs index e4bfd840d..83dd20f09 100644 --- a/modules/pallets/intents-coprocessor/src/weights.rs +++ b/modules/pallets/intents-coprocessor/src/weights.rs @@ -43,13 +43,7 @@ pub trait WeightInfo { fn update_token_decimals() -> Weight; fn set_storage_deposit_fee() -> Weight; fn submit_pair_price(n: u32) -> Weight; - fn set_price_window_duration() -> Weight; - fn set_price_deposit_amount() -> Weight; - fn set_price_deposit_lock_duration() -> Weight; - fn withdraw_price_deposit() -> Weight; - fn register_pair() -> Weight; - fn deregister_pair() -> Weight; - fn set_pair_registration_deposit() -> Weight; + fn set_price_submission_fee() -> Weight; } /// Weights for pallet_intents using the Substrate node and recommended hardware. @@ -121,60 +115,17 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().writes(1)) } - /// Storage: PriceDepositAmount (r:1 w:0), - /// PriceDeposits (r:1 w:1), PricesClearedThisWindow (r:1 w:1), Prices (r:1 w:1) + /// Storage: PriceSubmissionFee (r:1 w:0), Prices (r:0 w:1) fn submit_pair_price(n: u32) -> Weight { - Weight::from_parts(100_000_000, 0) - .saturating_add(Weight::from_parts(0, 5000)) + Weight::from_parts(50_000_000, 0) + .saturating_add(Weight::from_parts(0, 4000)) .saturating_add(Weight::from_parts(5_000_000u64.saturating_mul(n as u64), 0)) - .saturating_add(T::DbWeight::get().reads(4)) - .saturating_add(T::DbWeight::get().writes(4)) - } - - /// Storage: PriceWindowDurationValue (r:0 w:1) - fn set_price_window_duration() -> Weight { - Weight::from_parts(10_000_000, 0) - .saturating_add(T::DbWeight::get().writes(1)) - } - - /// Storage: PriceDepositAmount (r:0 w:1) - fn set_price_deposit_amount() -> Weight { - Weight::from_parts(10_000_000, 0) - .saturating_add(T::DbWeight::get().writes(1)) - } - - /// Storage: PriceDepositLockDuration (r:0 w:1) - fn set_price_deposit_lock_duration() -> Weight { - Weight::from_parts(10_000_000, 0) - .saturating_add(T::DbWeight::get().writes(1)) - } - - /// Storage: PriceDeposits (r:1 w:1), PriceDepositLockDuration (r:1 w:0) - fn withdraw_price_deposit() -> Weight { - Weight::from_parts(45_000_000, 0) - .saturating_add(Weight::from_parts(0, 3500)) - .saturating_add(T::DbWeight::get().reads(3)) - .saturating_add(T::DbWeight::get().writes(1)) - } - - /// Storage: PairRegistrationDeposit (r:1 w:0), RegisteredPairs (r:1 w:1) - fn register_pair() -> Weight { - Weight::from_parts(40_000_000, 0) - .saturating_add(Weight::from_parts(0, 3000)) - .saturating_add(T::DbWeight::get().reads(2)) - .saturating_add(T::DbWeight::get().writes(1)) - } - - /// Storage: RegisteredPairs (r:1 w:1) - fn deregister_pair() -> Weight { - Weight::from_parts(35_000_000, 0) - .saturating_add(Weight::from_parts(0, 3000)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) } - /// Storage: PairRegistrationDeposit (r:0 w:1) - fn set_pair_registration_deposit() -> Weight { + /// Storage: PriceSubmissionFee (r:0 w:1) + fn set_price_submission_fee() -> Weight { Weight::from_parts(10_000_000, 0) .saturating_add(T::DbWeight::get().writes(1)) } @@ -204,27 +155,9 @@ impl WeightInfo for () { Weight::from_parts(10_000_000, 0) } fn submit_pair_price(_n: u32) -> Weight { - Weight::from_parts(100_000_000, 0) - } - fn set_price_window_duration() -> Weight { - Weight::from_parts(10_000_000, 0) - } - fn set_price_deposit_amount() -> Weight { - Weight::from_parts(10_000_000, 0) - } - fn set_price_deposit_lock_duration() -> Weight { - Weight::from_parts(10_000_000, 0) - } - fn withdraw_price_deposit() -> Weight { - Weight::from_parts(45_000_000, 0) - } - fn register_pair() -> Weight { - Weight::from_parts(40_000_000, 0) - } - fn deregister_pair() -> Weight { - Weight::from_parts(35_000_000, 0) + Weight::from_parts(50_000_000, 0) } - fn set_pair_registration_deposit() -> Weight { + fn set_price_submission_fee() -> 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 c580db415..a7de2130c 100644 --- a/parachain/runtimes/gargantua/src/ismp.rs +++ b/parachain/runtimes/gargantua/src/ismp.rs @@ -91,6 +91,7 @@ impl pallet_intents_coprocessor::Config for Runtime { type Currency = Balances; type StorageDepositFee = IntentStorageDepositFee; type GovernanceOrigin = EnsureRoot; + type TreasuryAccount = TreasuryPalletId; type MaxPriceEntries = ConstU32<10>; 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 6179daa4b..24f9f7379 100644 --- a/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs +++ b/parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs @@ -2,7 +2,7 @@ //! Autogenerated weights for `pallet_intents_coprocessor` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 -//! DATE: 2026-03-23, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-03-24, 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 @@ -17,9 +17,12 @@ // --extrinsic=* // --steps=50 // --repeat=20 -// --runtime=target/release/wbuild/gargantua-runtime/gargantua_runtime.compact.compressed.wasm +// --unsafe-overwrite-results // --genesis-builder-preset=development -// --output=parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs +// --genesis-builder=runtime +// --runtime=./target/release/wbuild/gargantua-runtime/gargantua_runtime.compact.compressed.wasm +// --output +// parachain/runtimes/gargantua/src/weights/pallet_intents_coprocessor.rs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -41,8 +44,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `109` // Estimated: `3574` - // Minimum execution time: 39_275_000 picoseconds. - Weight::from_parts(39_886_000, 0) + // Minimum execution time: 39_184_000 picoseconds. + Weight::from_parts(40_226_000, 0) .saturating_add(Weight::from_parts(0, 3574)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -53,8 +56,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `247` // Estimated: `3712` - // Minimum execution time: 38_223_000 picoseconds. - Weight::from_parts(38_823_000, 0) + // Minimum execution time: 38_343_000 picoseconds. + Weight::from_parts(38_974_000, 0) .saturating_add(Weight::from_parts(0, 3712)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) @@ -65,8 +68,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `113` // Estimated: `6053` - // Minimum execution time: 22_362_000 picoseconds. - Weight::from_parts(22_723_000, 0) + // Minimum execution time: 22_452_000 picoseconds. + Weight::from_parts(23_023_000, 0) .saturating_add(Weight::from_parts(0, 6053)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -89,8 +92,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 80_142_000 picoseconds. - Weight::from_parts(81_946_000, 0) + // Minimum execution time: 72_577_000 picoseconds. + Weight::from_parts(73_930_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(5)) @@ -113,8 +116,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 70_333_000 picoseconds. - Weight::from_parts(71_575_000, 0) + // Minimum execution time: 64_742_000 picoseconds. + Weight::from_parts(65_915_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -137,8 +140,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `700` // Estimated: `4165` - // Minimum execution time: 65_774_000 picoseconds. - Weight::from_parts(67_107_000, 0) + // Minimum execution time: 60_404_000 picoseconds. + Weight::from_parts(67_017_000, 0) .saturating_add(Weight::from_parts(0, 4165)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -149,115 +152,40 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_445_000 picoseconds. - Weight::from_parts(8_717_000, 0) + // Minimum execution time: 7_394_000 picoseconds. + Weight::from_parts(7_695_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } - /// Storage: `IntentsCoprocessor::RegisteredPairs` (r:1 w:0) - /// Proof: `IntentsCoprocessor::RegisteredPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `IntentsCoprocessor::PriceDepositAmount` (r:1 w:0) - /// Proof: `IntentsCoprocessor::PriceDepositAmount` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `IntentsCoprocessor::PriceDeposits` (r:1 w:1) - /// Proof: `IntentsCoprocessor::PriceDeposits` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `IntentsCoprocessor::PricesClearedThisWindow` (r:1 w:1) - /// Proof: `IntentsCoprocessor::PricesClearedThisWindow` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `IntentsCoprocessor::Prices` (r:1 w:1) + /// Storage: `IntentsCoprocessor::PriceSubmissionFee` (r:1 w:0) + /// Proof: `IntentsCoprocessor::PriceSubmissionFee` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `IntentsCoprocessor::Prices` (r:0 w:1) /// Proof: `IntentsCoprocessor::Prices` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `IntentsCoprocessor::CounterForPrices` (r:1 w:1) - /// Proof: `IntentsCoprocessor::CounterForPrices` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// The range of component `n` is `[1, 100]`. fn submit_pair_price(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `334` - // Estimated: `3799` - // Minimum execution time: 53_612_000 picoseconds. - Weight::from_parts(62_363_557, 0) - .saturating_add(Weight::from_parts(0, 3799)) - // Standard Error: 5_029 - .saturating_add(Weight::from_parts(65_516, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(6)) - .saturating_add(T::DbWeight::get().writes(4)) - } - /// Storage: `IntentsCoprocessor::PriceWindowDurationValue` (r:0 w:1) - /// Proof: `IntentsCoprocessor::PriceWindowDurationValue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - fn set_price_window_duration() -> Weight { - // Proof Size summary in bytes: - // Measured: `0` - // Estimated: `0` - // Minimum execution time: 7_294_000 picoseconds. - Weight::from_parts(8_486_000, 0) - .saturating_add(Weight::from_parts(0, 0)) - .saturating_add(T::DbWeight::get().writes(1)) - } - /// Storage: `IntentsCoprocessor::PriceDepositAmount` (r:0 w:1) - /// Proof: `IntentsCoprocessor::PriceDepositAmount` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - fn set_price_deposit_amount() -> Weight { - // Proof Size summary in bytes: - // Measured: `0` - // Estimated: `0` - // Minimum execution time: 8_246_000 picoseconds. - Weight::from_parts(8_586_000, 0) - .saturating_add(Weight::from_parts(0, 0)) - .saturating_add(T::DbWeight::get().writes(1)) - } - /// Storage: `IntentsCoprocessor::PriceDepositLockDuration` (r:0 w:1) - /// Proof: `IntentsCoprocessor::PriceDepositLockDuration` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - fn set_price_deposit_lock_duration() -> Weight { - // Proof Size summary in bytes: - // Measured: `0` - // Estimated: `0` - // Minimum execution time: 7_304_000 picoseconds. - Weight::from_parts(8_436_000, 0) - .saturating_add(Weight::from_parts(0, 0)) - .saturating_add(T::DbWeight::get().writes(1)) - } - /// Storage: `IntentsCoprocessor::PriceDeposits` (r:1 w:1) - /// Proof: `IntentsCoprocessor::PriceDeposits` (`max_values`: None, `max_size`: None, mode: `Measured`) - fn withdraw_price_deposit() -> Weight { - // Proof Size summary in bytes: - // Measured: `471` - // Estimated: `3936` - // Minimum execution time: 34_225_000 picoseconds. - Weight::from_parts(34_756_000, 0) - .saturating_add(Weight::from_parts(0, 3936)) - .saturating_add(T::DbWeight::get().reads(1)) - .saturating_add(T::DbWeight::get().writes(1)) - } - /// Storage: `IntentsCoprocessor::PairRegistrationDeposit` (r:1 w:0) - /// Proof: `IntentsCoprocessor::PairRegistrationDeposit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `IntentsCoprocessor::RegisteredPairs` (r:1 w:1) - /// Proof: `IntentsCoprocessor::RegisteredPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) - fn register_pair() -> Weight { - // Proof Size summary in bytes: - // Measured: `149` - // Estimated: `3614` - // Minimum execution time: 32_441_000 picoseconds. - Weight::from_parts(33_704_000, 0) - .saturating_add(Weight::from_parts(0, 3614)) - .saturating_add(T::DbWeight::get().reads(2)) - .saturating_add(T::DbWeight::get().writes(1)) - } - /// Storage: `IntentsCoprocessor::RegisteredPairs` (r:1 w:1) - /// Proof: `IntentsCoprocessor::RegisteredPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) - fn deregister_pair() -> Weight { - // Proof Size summary in bytes: - // Measured: `267` - // Estimated: `3732` - // Minimum execution time: 29_837_000 picoseconds. - Weight::from_parts(30_108_000, 0) - .saturating_add(Weight::from_parts(0, 3732)) - .saturating_add(T::DbWeight::get().reads(1)) - .saturating_add(T::DbWeight::get().writes(1)) + // Measured: `293` + // Estimated: `3593` + // Minimum execution time: 65_875_000 picoseconds. + Weight::from_parts(72_463_245, 0) + .saturating_add(Weight::from_parts(0, 3593)) + // Standard Error: 7_576 + .saturating_add(Weight::from_parts(12_601, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().writes(2)) } - /// Storage: `IntentsCoprocessor::PairRegistrationDeposit` (r:0 w:1) - /// Proof: `IntentsCoprocessor::PairRegistrationDeposit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - fn set_pair_registration_deposit() -> Weight { + /// Storage: `IntentsCoprocessor::PriceSubmissionFee` (r:0 w:1) + /// Proof: `IntentsCoprocessor::PriceSubmissionFee` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_price_submission_fee() -> Weight { // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_274_000 picoseconds. - Weight::from_parts(7_515_000, 0) + // Minimum execution time: 8_306_000 picoseconds. + Weight::from_parts(8_526_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 24d724013..df037f5d6 100644 --- a/parachain/runtimes/nexus/src/ismp.rs +++ b/parachain/runtimes/nexus/src/ismp.rs @@ -433,6 +433,7 @@ impl pallet_intents_coprocessor::Config for Runtime { MIN_TECH_COLLECTIVE_APPROVAL, >, >; + type TreasuryAccount = TreasuryPalletId; type MaxPriceEntries = ConstU32<10>; 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 5d86d96e3..886693530 100644 --- a/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs +++ b/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs @@ -2,7 +2,7 @@ //! Autogenerated weights for `pallet_intents_coprocessor` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 -//! DATE: 2026-03-23, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-03-24, 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 @@ -17,9 +17,12 @@ // --extrinsic=* // --steps=50 // --repeat=20 -// --runtime=target/release/wbuild/nexus-runtime/nexus_runtime.compact.compressed.wasm +// --unsafe-overwrite-results // --genesis-builder-preset=development -// --output=parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs +// --genesis-builder=runtime +// --runtime=./target/release/wbuild/nexus-runtime/nexus_runtime.compact.compressed.wasm +// --output +// parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -41,8 +44,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `175` // Estimated: `3640` - // Minimum execution time: 35_047_000 picoseconds. - Weight::from_parts(36_399_000, 0) + // Minimum execution time: 39_655_000 picoseconds. + Weight::from_parts(40_307_000, 0) .saturating_add(Weight::from_parts(0, 3640)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -53,8 +56,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `313` // Estimated: `3778` - // Minimum execution time: 34_395_000 picoseconds. - Weight::from_parts(38_904_000, 0) + // Minimum execution time: 38_583_000 picoseconds. + Weight::from_parts(39_155_000, 0) .saturating_add(Weight::from_parts(0, 3778)) .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(1)) @@ -65,8 +68,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `179` // Estimated: `6119` - // Minimum execution time: 19_918_000 picoseconds. - Weight::from_parts(22_643_000, 0) + // Minimum execution time: 22_593_000 picoseconds. + Weight::from_parts(23_464_000, 0) .saturating_add(Weight::from_parts(0, 6119)) .saturating_add(T::DbWeight::get().reads(2)) .saturating_add(T::DbWeight::get().writes(1)) @@ -89,8 +92,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `899` // Estimated: `4364` - // Minimum execution time: 64_723_000 picoseconds. - Weight::from_parts(65_765_000, 0) + // Minimum execution time: 73_429_000 picoseconds. + Weight::from_parts(74_872_000, 0) .saturating_add(Weight::from_parts(0, 4364)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(5)) @@ -113,8 +116,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `899` // Estimated: `4364` - // Minimum execution time: 57_650_000 picoseconds. - Weight::from_parts(65_635_000, 0) + // Minimum execution time: 65_023_000 picoseconds. + Weight::from_parts(65_905_000, 0) .saturating_add(Weight::from_parts(0, 4364)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -137,8 +140,8 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `899` // Estimated: `4364` - // Minimum execution time: 60_044_000 picoseconds. - Weight::from_parts(68_981_000, 0) + // Minimum execution time: 60_144_000 picoseconds. + Weight::from_parts(61_617_000, 0) .saturating_add(Weight::from_parts(0, 4364)) .saturating_add(T::DbWeight::get().reads(7)) .saturating_add(T::DbWeight::get().writes(4)) @@ -149,115 +152,40 @@ impl pallet_intents_coprocessor::WeightInfo for WeightI // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_384_000 picoseconds. - Weight::from_parts(7_584_000, 0) + // Minimum execution time: 7_404_000 picoseconds. + Weight::from_parts(7_674_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } - /// Storage: `IntentsCoprocessor::RegisteredPairs` (r:1 w:0) - /// Proof: `IntentsCoprocessor::RegisteredPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `IntentsCoprocessor::PriceDepositAmount` (r:1 w:0) - /// Proof: `IntentsCoprocessor::PriceDepositAmount` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `IntentsCoprocessor::PriceDeposits` (r:1 w:1) - /// Proof: `IntentsCoprocessor::PriceDeposits` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `IntentsCoprocessor::PricesClearedThisWindow` (r:1 w:1) - /// Proof: `IntentsCoprocessor::PricesClearedThisWindow` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `IntentsCoprocessor::Prices` (r:1 w:1) + /// Storage: `IntentsCoprocessor::PriceSubmissionFee` (r:1 w:0) + /// Proof: `IntentsCoprocessor::PriceSubmissionFee` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `IntentsCoprocessor::Prices` (r:0 w:1) /// Proof: `IntentsCoprocessor::Prices` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `IntentsCoprocessor::CounterForPrices` (r:1 w:1) - /// Proof: `IntentsCoprocessor::CounterForPrices` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// The range of component `n` is `[1, 100]`. fn submit_pair_price(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `400` - // Estimated: `3865` - // Minimum execution time: 53_742_000 picoseconds. - Weight::from_parts(62_587_293, 0) - .saturating_add(Weight::from_parts(0, 3865)) - // Standard Error: 6_071 - .saturating_add(Weight::from_parts(32_069, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(6)) - .saturating_add(T::DbWeight::get().writes(4)) - } - /// Storage: `IntentsCoprocessor::PriceWindowDurationValue` (r:0 w:1) - /// Proof: `IntentsCoprocessor::PriceWindowDurationValue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - fn set_price_window_duration() -> Weight { - // Proof Size summary in bytes: - // Measured: `0` - // Estimated: `0` - // Minimum execution time: 7_194_000 picoseconds. - Weight::from_parts(7_485_000, 0) - .saturating_add(Weight::from_parts(0, 0)) - .saturating_add(T::DbWeight::get().writes(1)) - } - /// Storage: `IntentsCoprocessor::PriceDepositAmount` (r:0 w:1) - /// Proof: `IntentsCoprocessor::PriceDepositAmount` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - fn set_price_deposit_amount() -> Weight { - // Proof Size summary in bytes: - // Measured: `0` - // Estimated: `0` - // Minimum execution time: 7_464_000 picoseconds. - Weight::from_parts(7_734_000, 0) - .saturating_add(Weight::from_parts(0, 0)) - .saturating_add(T::DbWeight::get().writes(1)) - } - /// Storage: `IntentsCoprocessor::PriceDepositLockDuration` (r:0 w:1) - /// Proof: `IntentsCoprocessor::PriceDepositLockDuration` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - fn set_price_deposit_lock_duration() -> Weight { - // Proof Size summary in bytes: - // Measured: `0` - // Estimated: `0` - // Minimum execution time: 7_283_000 picoseconds. - Weight::from_parts(7_695_000, 0) - .saturating_add(Weight::from_parts(0, 0)) - .saturating_add(T::DbWeight::get().writes(1)) - } - /// Storage: `IntentsCoprocessor::PriceDeposits` (r:1 w:1) - /// Proof: `IntentsCoprocessor::PriceDeposits` (`max_values`: None, `max_size`: None, mode: `Measured`) - fn withdraw_price_deposit() -> Weight { - // Proof Size summary in bytes: - // Measured: `537` - // Estimated: `4002` - // Minimum execution time: 34_475_000 picoseconds. - Weight::from_parts(38_844_000, 0) - .saturating_add(Weight::from_parts(0, 4002)) - .saturating_add(T::DbWeight::get().reads(1)) - .saturating_add(T::DbWeight::get().writes(1)) - } - /// Storage: `IntentsCoprocessor::PairRegistrationDeposit` (r:1 w:0) - /// Proof: `IntentsCoprocessor::PairRegistrationDeposit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `IntentsCoprocessor::RegisteredPairs` (r:1 w:1) - /// Proof: `IntentsCoprocessor::RegisteredPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) - fn register_pair() -> Weight { - // Proof Size summary in bytes: - // Measured: `215` - // Estimated: `3680` - // Minimum execution time: 33_072_000 picoseconds. - Weight::from_parts(33_914_000, 0) - .saturating_add(Weight::from_parts(0, 3680)) - .saturating_add(T::DbWeight::get().reads(2)) - .saturating_add(T::DbWeight::get().writes(1)) - } - /// Storage: `IntentsCoprocessor::RegisteredPairs` (r:1 w:1) - /// Proof: `IntentsCoprocessor::RegisteredPairs` (`max_values`: None, `max_size`: None, mode: `Measured`) - fn deregister_pair() -> Weight { - // Proof Size summary in bytes: - // Measured: `333` - // Estimated: `3798` - // Minimum execution time: 29_456_000 picoseconds. - Weight::from_parts(30_498_000, 0) - .saturating_add(Weight::from_parts(0, 3798)) - .saturating_add(T::DbWeight::get().reads(1)) - .saturating_add(T::DbWeight::get().writes(1)) + // Measured: `430` + // Estimated: `3593` + // Minimum execution time: 68_190_000 picoseconds. + Weight::from_parts(71_109_220, 0) + .saturating_add(Weight::from_parts(0, 3593)) + // Standard Error: 4_955 + .saturating_add(Weight::from_parts(126_112, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().writes(2)) } - /// Storage: `IntentsCoprocessor::PairRegistrationDeposit` (r:0 w:1) - /// Proof: `IntentsCoprocessor::PairRegistrationDeposit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - fn set_pair_registration_deposit() -> Weight { + /// Storage: `IntentsCoprocessor::PriceSubmissionFee` (r:0 w:1) + /// Proof: `IntentsCoprocessor::PriceSubmissionFee` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn set_price_submission_fee() -> Weight { // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 7_454_000 picoseconds. - Weight::from_parts(7_704_000, 0) + // Minimum execution time: 7_484_000 picoseconds. + Weight::from_parts(8_596_000, 0) .saturating_add(Weight::from_parts(0, 0)) .saturating_add(T::DbWeight::get().writes(1)) } diff --git a/parachain/simtests/src/price_submission.rs b/parachain/simtests/src/price_submission.rs index 505c0a1a2..fb9d5f9ab 100644 --- a/parachain/simtests/src/price_submission.rs +++ b/parachain/simtests/src/price_submission.rs @@ -53,22 +53,6 @@ async fn sudo_raw_and_finalize( submit_raw_and_finalize(client, rpc_client, sudo_call_data, Keyring::Alice).await } -/// Helper: create and finalize `n` empty blocks to advance the chain. -async fn advance_blocks( - rpc_client: &subxt::backend::rpc::RpcClient, - n: u32, -) -> Result<(), anyhow::Error> { - for _ in 0..n { - let block = rpc_client - .request::>("engine_createBlock", rpc_params![true, false]) - .await?; - rpc_client - .request::("engine_finalizeBlock", rpc_params![block.hash]) - .await?; - } - Ok(()) -} - /// Manually encode a call to `IntentsCoprocessor::submit_pair_price`. /// Pallet index 65, call index 7. fn encode_submit_pair_price(pair_id: H256, entries: Vec) -> Vec { @@ -78,53 +62,22 @@ fn encode_submit_pair_price(pair_id: H256, entries: Vec) -> Vec data } -/// Manually encode a call to `IntentsCoprocessor::set_price_deposit_amount`. -/// Pallet index 65, call index 11. -fn encode_set_price_deposit_amount(amount: u128) -> Vec { - let mut data = vec![65u8, 11u8]; - data.extend_from_slice(&amount.encode()); +/// Manually encode a call to `IntentsCoprocessor::set_price_submission_fee`. +/// Pallet index 65, call index 10. +fn encode_set_price_submission_fee(fee: u128) -> Vec { + let mut data = vec![65u8, 10u8]; + data.extend_from_slice(&fee.encode()); data } -/// Manually encode a call to `IntentsCoprocessor::set_price_deposit_lock_duration`. -/// Pallet index 65, call index 12. Duration is BlockNumberFor = u32 on gargantua. -fn encode_set_price_deposit_lock_duration(duration_blocks: u32) -> Vec { - let mut data = vec![65u8, 12u8]; - data.extend_from_slice(&duration_blocks.encode()); - data -} -/// Manually encode a call to `IntentsCoprocessor::withdraw_price_deposit`. -/// Pallet index 65, call index 13. -fn encode_withdraw_price_deposit(pair_id: H256) -> Vec { - let mut data = vec![65u8, 13u8]; - data.extend_from_slice(&pair_id.encode()); - data -} - -/// Manually encode a call to `IntentsCoprocessor::register_pair`. -/// Pallet index 65, call index 8. -fn encode_register_pair(pair_id: H256) -> Vec { - let mut data = vec![65u8, 8u8]; - data.extend_from_slice(&pair_id.encode()); - data -} - -/// Manually encode a call to `IntentsCoprocessor::set_pair_registration_deposit`. -/// Pallet index 65, call index 14. -fn encode_set_pair_registration_deposit(amount: u128) -> Vec { - let mut data = vec![65u8, 14u8]; - data.extend_from_slice(&amount.encode()); - data -} - -/// Integration test for the deposit-based price submission system. +/// Integration test for the fee-based price submission system. /// /// Exercises the full lifecycle: -/// 1. Governance setup (add recognized pair, set deposit amount and lock duration) -/// 2. Price submission (verifies deposit is reserved) +/// 1. Governance setup (set submission fee, register pair) +/// 2. Price submission (verifies fee is charged, prices stored) /// 3. RPC query (verifies human-readable prices with decimals preserved) -/// 4. Two-phase withdrawal (initiate → fail before unlock → complete after unlock) +/// 4. Re-submission overwrites previous entries #[tokio::test] #[ignore] async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { @@ -138,11 +91,8 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { // 1 unit = 10^18 in raw representation let one_unit = U256::from(10u64).pow(U256::from(18)); - // Deposit amount: 100 units - let deposit_amount: u128 = 100_000_000_000_000; - - // Lock duration: 5 blocks - let lock_duration: u32 = 5; + // Submission fee: 100 units + let submission_fee: u128 = 100_000_000_000_000; // Price entries: amount thresholds with corresponding prices // Entry 1: amount=0 at price 1414.5, Entry 2: amount=1000 at price 1420 @@ -154,38 +104,14 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { PriceInput { amount: U256::from(1000) * one_unit, price: U256::from(1420) * one_unit }, ]; - // Set deposit amount - sudo_raw_and_finalize(&client, &rpc_client, encode_set_price_deposit_amount(deposit_amount)) - .await?; - println!("Deposit amount set: {deposit_amount}"); - - // Set lock duration (5 blocks) - sudo_raw_and_finalize( - &client, - &rpc_client, - encode_set_price_deposit_lock_duration(lock_duration), - ) - .await?; - println!("Lock duration set: {lock_duration} blocks"); - - // Set pair registration deposit and register pair - let pair_reg_deposit: u128 = 50_000_000_000_000; + // Set submission fee sudo_raw_and_finalize( &client, &rpc_client, - encode_set_pair_registration_deposit(pair_reg_deposit), - ) - .await?; - println!("Pair registration deposit set: {pair_reg_deposit}"); - - submit_raw_and_finalize( - &client, - &rpc_client, - encode_register_pair(pair_id), - Keyring::Alice, + encode_set_price_submission_fee(submission_fee), ) .await?; - println!("Pair registered: {pair_id:?}"); + println!("Submission fee set: {submission_fee}"); // Submit prices let submit_call_data = encode_submit_pair_price(pair_id, price_entries); @@ -210,51 +136,25 @@ async fn test_price_submission_lifecycle() -> Result<(), anyhow::Error> { println!(" entry[0]: amount={} @ {}", prices[0].amount, prices[0].price); println!(" entry[1]: amount={} @ {}", prices[1].amount, prices[1].price); - // Initiate withdrawal - submit_raw_and_finalize( - &client, - &rpc_client, - encode_withdraw_price_deposit(pair_id), - Keyring::Alice, - ) - .await?; - println!("Withdrawal initiated (unlock block recorded)"); - - // Attempting phase 2 immediately should fail (lock not expired) - let early_result = submit_raw_and_finalize( - &client, - &rpc_client, - encode_withdraw_price_deposit(pair_id), - Keyring::Alice, - ) - .await; - assert!(early_result.is_err(), "withdrawal should fail before lock expires"); - println!("correctly rejected (deposit still locked)"); - - // Advance blocks past the lock duration - advance_blocks(&rpc_client, lock_duration + 1).await?; - println!("Advanced {} blocks past lock duration", lock_duration + 1); + // Re-submit with different prices — should overwrite + let new_entries = vec![ + PriceInput { amount: U256::zero(), price: U256::from(1500) * one_unit }, + PriceInput { amount: U256::from(2000) * one_unit, price: U256::from(1520) * one_unit }, + ]; + let resubmit_call_data = encode_submit_pair_price(pair_id, new_entries); + submit_raw_and_finalize(&client, &rpc_client, resubmit_call_data, Keyring::Alice).await?; + println!("Prices re-submitted (should overwrite)"); - // Complete withdrawal - submit_raw_and_finalize( - &client, - &rpc_client, - encode_withdraw_price_deposit(pair_id), - Keyring::Alice, - ) - .await?; - println!("Deposit successfully withdrawn"); + // Query again and verify overwrite + let prices2: Vec = + rpc_client.request("intents_getPairPrices", rpc_params![pair_id]).await?; - // Verify deposit is gone, another withdrawal should fail with DepositNotFound - let gone_result = submit_raw_and_finalize( - &client, - &rpc_client, - encode_withdraw_price_deposit(pair_id), - Keyring::Alice, - ) - .await; - assert!(gone_result.is_err(), "deposit should no longer exist"); - println!("Deposit confirmed removed (subsequent withdrawal fails)"); + assert_eq!(prices2.len(), 2, "expected 2 price entries after overwrite"); + assert_eq!(prices2[0].amount, "0"); + assert_eq!(prices2[0].price, "1500"); + assert_eq!(prices2[1].amount, "2000"); + assert_eq!(prices2[1].price, "1520"); + println!("Overwrite confirmed: old prices replaced"); println!("Price submission lifecycle test passed!"); Ok(()) diff --git a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts index bff54fbd8..f0c86250a 100644 --- a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts +++ b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts @@ -339,8 +339,8 @@ export class IntentsCoprocessor { /** * Submits price entries for a recognized token pair on Hyperbridge. * - * The first submission per pair requires a deposit (reserved from the caller's balance). - * Subsequent updates to the same pair are free. + * A fee is charged per submission. New entries overwrite previous ones + * for the same (pair_id, filler) combination. * * @param pairId - The token pair identifier (H256 / bytes32) * @param entries - Array of price entries with range and price data @@ -368,27 +368,6 @@ export class IntentsCoprocessor { } } - /** - * Withdraws a previously reserved price deposit for a token pair. - * - * Funds can only be withdrawn after the configured lock duration has elapsed - * since the first price submission for that pair. - * - * @param pairId - The token pair identifier (H256 / bytes32) - * @returns BidSubmissionResult with success status and block/extrinsic hash - */ - async withdrawPriceDeposit(pairId: HexString): Promise { - try { - const extrinsic = this.api.tx.intentsCoprocessor.withdrawPriceDeposit(pairId) - return await this.signAndSendExtrinsic(extrinsic) - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - } - } - } - /** * Fetches all price entries for a token pair, groups them by filler, * runs piecewise linear interpolation on each filler's price curve, From 231c1f277aa3f4829e341caf87bdd5a974642406 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Tue, 24 Mar 2026 19:01:34 +0100 Subject: [PATCH 28/28] filter 24 hours prices --- sdk/packages/sdk/src/chains/intentsCoprocessor.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts index f0c86250a..b8f9d8a87 100644 --- a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts +++ b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts @@ -81,6 +81,7 @@ interface RpcPriceEntry { amount: string price: string filler: string + timestamp: number } /** RPC response shape from intents_getBidsForOrder */ @@ -383,11 +384,15 @@ export class IntentsCoprocessor { [pairId], ) - if (entries.length === 0) return [] + // Filter out price entries older than 24 hours + const cutoff = Math.floor(Date.now() / 1000) - 86400 + const fresh = entries.filter((e) => e.timestamp >= cutoff) + + if (fresh.length === 0) return [] // Group entries by filler const byFiller = new Map() - for (const entry of entries) { + for (const entry of fresh) { const points = byFiller.get(entry.filler) ?? [] points.push({ amount: parseFloat(entry.amount), price: parseFloat(entry.price) }) byFiller.set(entry.filler, points)