diff --git a/Cargo.lock b/Cargo.lock index 89602ed51..d4f419acf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15069,6 +15069,8 @@ dependencies = [ "alloy-sol-macro 1.5.7", "alloy-sol-types 1.5.7", "anyhow", + "crypto-utils", + "hex-literal 0.4.1", "ismp", "ismp-testsuite", "log", @@ -15091,6 +15093,7 @@ dependencies = [ "pallet-intents-coprocessor", "parity-scale-codec", "polkadot-sdk", + "primitive-types 0.13.1", "serde", "tokio", ] 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..a3623f5e5 --- /dev/null +++ b/docs/content/developers/intent-gateway/price-submission.mdx @@ -0,0 +1,103 @@ +--- +title: Price Submission Protocol +description: Fee based price submission system for the intents coprocessor pallet +--- + +# Price Submission Protocol + +## Overview + +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. + +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. + +## Fee Model + +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 while keeping the barrier to entry low for active market participants. + +## Storage Model + +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. + +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 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 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. + +### Configuration + +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 +[[strategies]] +type = "hyperfx" +pairId = "0x..." # Onchain pair ID (keccak256 of base_address ++ quote_address) +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..." + +[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. + +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 directly. These methods accept raw 18 decimal bigint values. + +```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) +// 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) }, +]) +``` + +## Governance Parameters + +The protocol has several parameters that are stored onchain and updatable through governance extrinsics. + +`PriceSubmissionFee` is the fee charged per submission, transferred to the treasury. Set via `set_price_submission_fee`. + +`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/Cargo.toml b/modules/pallets/intents-coprocessor/Cargo.toml index 4d46096cf..da1c7a3f7 100644 --- a/modules/pallets/intents-coprocessor/Cargo.toml +++ b/modules/pallets/intents-coprocessor/Cargo.toml @@ -28,7 +28,9 @@ anyhow = { workspace = true } alloy-primitives = { workspace = true } 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 } @@ -49,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..81d41c2c0 100644 --- a/modules/pallets/intents-coprocessor/rpc/src/lib.rs +++ b/modules/pallets/intents-coprocessor/rpc/src/lib.rs @@ -53,6 +53,20 @@ pub struct RpcBidInfo { pub user_op: Vec, } +/// 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 { + /// 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, + /// Unix timestamp (seconds) when this entry was submitted + pub timestamp: u64, +} + impl Ord for RpcBidInfo { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.filler.cmp(&other.filler) @@ -154,10 +168,39 @@ 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::) } +/// 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 /// `Bids` double-map for a given order commitment. fn bids_storage_prefix(commitment: &H256) -> Vec { @@ -176,6 +219,10 @@ pub trait IntentsApi { #[method(name = "intents_getBidsForOrder")] fn get_bids_for_order(&self, commitment: H256) -> RpcResult>; + /// Get all prices for a token pair + #[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 +306,52 @@ where Ok(bids.into_iter().collect()) } + fn get_pair_prices(&self, pair_id: H256) -> RpcResult> { + let best_hash = self.client.info().best_hash; + + // 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 keys = self + .client + .storage_keys(best_hash, Some(&prefix_key), None) + .map_err(runtime_error_into_rpc_error)?; + + use pallet_intents_coprocessor::types::PriceEntry; + + 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( &self, pending: PendingSubscriptionSink, diff --git a/modules/pallets/intents-coprocessor/src/benchmarking.rs b/modules/pallets/intents-coprocessor/src/benchmarking.rs index 4c2ed8aac..e60296a50 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 @@ -186,5 +187,55 @@ 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(n: Linear<1, 100>) { + let caller: T::AccountId = whitelisted_caller(); + let pair_id = H256::repeat_byte(0xaa); + + let balance = BalanceOf::::from(u32::MAX); + ::Currency::make_free_balance_be(&caller, balance); + + PriceSubmissionFee::::put(::Currency::minimum_balance()); + + let count = n.min(T::MaxPriceEntries::get()); + let mut entries_vec = vec![]; + for i in 0..count { + 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); + + let filler = H256::from_slice(&caller.encode()[..32]); + assert!(Prices::::get(&pair_id, &filler).is_some()); + } + + #[benchmark] + 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!(PriceSubmissionFee::::get(), 2000u32.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 962d73e22..f48b1b2fb 100644 --- a/modules/pallets/intents-coprocessor/src/lib.rs +++ b/modules/pallets/intents-coprocessor/src/lib.rs @@ -28,21 +28,24 @@ 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}, - host::StateMachine, + host::{IsmpHost, StateMachine}, }; 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::{AccountIdConversion, ConstU32, Zero}; pub use weights::WeightInfo; -use types::{Bid, GatewayInfo, IntentGatewayParams, RequestKind, TokenDecimalsUpdate, TokenInfo}; +use types::{ + Bid, GatewayInfo, IntentGatewayParams, PriceEntry, PriceInput, RequestKind, + TokenDecimalsUpdate, TokenInfo, +}; // Re-export pallet items so that they can be accessed from the crate namespace. pub use pallet::*; @@ -62,6 +65,7 @@ pub fn offchain_bid_key_raw(commitment: &H256, filler_encoded: &[u8]) -> Vec pub mod pallet { use super::*; use crate::alloc::string::ToString; + use alloc::vec; use frame_support::pallet_prelude::*; use frame_system::pallet_prelude::*; @@ -86,6 +90,13 @@ 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; + /// Weight information for extrinsics in this pallet type WeightInfo: WeightInfo; } @@ -119,6 +130,25 @@ pub mod pallet { pub type Gateways = StorageMap<_, Blake2_128Concat, StateMachine, GatewayInfo, OptionQuery>; + /// Price entries per (pair_id, filler). Each submission overwrites the previous entries. + #[pallet::storage] + pub type Prices = StorageDoubleMap< + _, + Blake2_128Concat, + H256, // pair_id + Blake2_128Concat, + H256, // filler (H256 encoded from AccountId) + BoundedVec, + OptionQuery, + >; + + /// Fee charged per price submission, configurable via governance + #[pallet::storage] + pub type PriceSubmissionFee = StorageValue<_, BalanceOf, ValueQuery>; + + #[pallet::hooks] + impl Hooks> for Pallet where T::AccountId: From<[u8; 32]> {} + #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { @@ -147,6 +177,10 @@ 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, fee: BalanceOf }, + /// Price submission fee was updated + PriceSubmissionFeeUpdated { fee: BalanceOf }, } #[pallet::error] @@ -163,6 +197,8 @@ pub mod pallet { InvalidUserOp, /// Failed to dispatch cross-chain request DispatchFailed, + /// No price entries were provided + EmptyPriceEntries, } #[pallet::call] @@ -433,7 +469,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)?; @@ -443,6 +479,71 @@ pub mod pallet { Ok(()) } + + /// Submit prices for a token pair. + /// + /// 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. + #[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, + entries: BoundedVec, + ) -> DispatchResult { + let submitter = ensure_signed(origin)?; + + ensure!(!entries.is_empty(), Error::::EmptyPriceEntries); + + let fee = PriceSubmissionFee::::get(); + if !fee.is_zero() { + let treasury: T::AccountId = T::TreasuryAccount::get().into_account_truncating(); + ::Currency::transfer( + &submitter, + &treasury, + fee, + ExistenceRequirement::KeepAlive, + ) + .map_err(|_| Error::::InsufficientBalance)?; + } + + let now = T::Dispatcher::default().timestamp().as_secs(); + let filler: H256 = H256::from_slice(&submitter.encode()[..32]); + + let price_entries: BoundedVec = entries + .iter() + .map(|input| PriceEntry { + amount: input.amount, + price: input.price, + timestamp: now, + }) + .collect::>() + .try_into() + .expect("same length as input; qed"); + + Prices::::insert(&pair_id, &filler, price_entries); + + Self::deposit_event(Event::PriceSubmitted { submitter, pair_id, fee }); + + Ok(()) + } + + /// Set the fee charged per price submission + #[pallet::call_index(10)] + #[pallet::weight(T::WeightInfo::set_price_submission_fee())] + pub fn set_price_submission_fee(origin: OriginFor, fee: BalanceOf) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + + PriceSubmissionFee::::put(fee); + + Self::deposit_event(Event::PriceSubmissionFeeUpdated { fee }); + + Ok(()) + } } impl Pallet diff --git a/modules/pallets/intents-coprocessor/src/tests.rs b/modules/pallets/intents-coprocessor/src/tests.rs index 08a3f757a..2b75c3ac3 100644 --- a/modules/pallets/intents-coprocessor/src/tests.rs +++ b/modules/pallets/intents-coprocessor/src/tests.rs @@ -19,10 +19,11 @@ use crate::{self as pallet_intents, *}; use alloc::vec; +use codec::Decode; use frame_support::{ assert_noop, assert_ok, parameter_types, traits::{ConstU32, Everything}, - BoundedVec, + BoundedVec, PalletId, }; use frame_system::EnsureRoot; use ismp::host::StateMachine; @@ -32,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, }; @@ -135,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 { @@ -142,9 +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(); @@ -154,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() } @@ -163,6 +173,8 @@ pub fn new_test_ext() -> sp_io::TestExternalities { let mut ext: sp_io::TestExternalities = t.into(); ext.execute_with(|| { pallet_intents::StorageDepositFee::::put(200u64); + // Price submission fee: 50 tokens + pallet_intents::PriceSubmissionFee::::put(50u64); }); ext } @@ -533,3 +545,247 @@ fn multiple_fillers_can_bid_on_same_order() { assert!(Bids::::contains_key(&commitment, &filler2)); }); } + +#[test] +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); + + let fee = PriceSubmissionFee::::get(); + let balance_before = Balances::free_balance(&submitter); + let treasury_before = Balances::free_balance(&treasury_account()); + + 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, + )); + + // 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 filler = H256::from_slice(&submitter.encode()[..32]); + let prices = Prices::::get(&pair_id, &filler).unwrap(); + assert_eq!(prices.len(), 1); + assert_eq!(prices[0].price, U256::from(2000)); + assert!(prices[0].timestamp > 0); + }); +} + +#[test] +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); + + let entries1 = 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, + entries1, + )); + + // 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( + RuntimeOrigin::signed(submitter.clone()), + pair_id, + entries2, + )); + + 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_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); + + // Set fee to zero + PriceSubmissionFee::::put(0u64); + + let balance_before = Balances::free_balance(&submitter); + + 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, + )); + + // No fee deducted (only registration deposit was taken earlier) + assert_eq!(Balances::free_balance(&submitter), balance_before); + }); +} + +#[test] +fn submit_pair_price_fails_with_insufficient_balance() { + new_test_ext().execute_with(|| { + 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); + + 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 multiple_fillers_independent_prices() { + new_test_ext().execute_with(|| { + let submitter1 = AccountId32::new([1; 32]); + let submitter2 = AccountId32::new([2; 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); + + let entries1 = BoundedVec::try_from(vec![PriceInput { + amount: U256::zero(), + price: U256::from(2000), + }]) + .unwrap(); + + let entries2 = BoundedVec::try_from(vec![PriceInput { + amount: U256::from(1000), + price: U256::from(2100), + }]) + .unwrap(); + + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(submitter1.clone()), + pair_id, + entries1, + )); + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(submitter2.clone()), + pair_id, + entries2, + )); + + let filler1 = H256::from_slice(&submitter1.encode()[..32]); + let filler2 = H256::from_slice(&submitter2.encode()[..32]); + + // 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); + }); +} + +#[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 price_entry_includes_timestamp() { + 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(); + + // 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(), + price: U256::from(2000), + }]) + .unwrap(); + + assert_ok!(Intents::submit_pair_price( + RuntimeOrigin::signed(submitter.clone()), + pair_id, + entries, + )); + + 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 price_entry_encoding_matches_rpc_tuple_decoding() { + use codec::Encode; + + let amount = U256::zero(); + let price = U256::from(42_000); + let timestamp = 1234567890u64; + + let entry = PriceEntry { amount, price, timestamp }; + + 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"); + + // 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 8c773acff..8425829b4 100644 --- a/modules/pallets/intents-coprocessor/src/types.rs +++ b/modules/pallets/intents-coprocessor/src/types.rs @@ -18,7 +18,6 @@ use alloc::{vec, vec::Vec}; use alloy_sol_types::SolValue; use codec::{Decode, DecodeWithMemTracking, Encode}; - use primitive_types::{H160, H256, U256}; use scale_info::TypeInfo; @@ -144,6 +143,48 @@ 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 symbol (e.g. "USDC") + pub base: Vec, + /// The quote token symbol (e.g. "cNGN") + pub quote: Vec, +} + +impl TokenPair { + /// Compute a unique identifier: keccak256("base/quote") + pub fn pair_id(&self) -> H256 { + 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 point for a token pair. +#[derive(Clone, Debug, Encode, Decode, DecodeWithMemTracking, TypeInfo, PartialEq, Eq)] +pub struct PriceInput { + /// 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, +} + +/// 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 { + /// 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, + /// Unix timestamp (seconds) when this entry was submitted + pub timestamp: u64, +} + impl IntentGatewayParams { /// Apply an update to the current parameters, returning a new instance pub fn update(&self, update: ParamsUpdate) -> Self { @@ -241,6 +282,7 @@ mod sol_types { bytes sourceChain; TokenDecimal[] tokens; } + } } diff --git a/modules/pallets/intents-coprocessor/src/weights.rs b/modules/pallets/intents-coprocessor/src/weights.rs index 8f6a70140..83dd20f09 100644 --- a/modules/pallets/intents-coprocessor/src/weights.rs +++ b/modules/pallets/intents-coprocessor/src/weights.rs @@ -41,6 +41,9 @@ 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(n: u32) -> Weight; + fn set_price_submission_fee() -> Weight; } /// Weights for pallet_intents using the Substrate node and recommended hardware. @@ -105,6 +108,27 @@ 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: PriceSubmissionFee (r:1 w:0), Prices (r:0 w:1) + fn submit_pair_price(n: u32) -> Weight { + 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(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } + + /// 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)) + } } // For backwards compatibility and tests @@ -127,4 +151,13 @@ 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(_n: u32) -> Weight { + Weight::from_parts(50_000_000, 0) + } + 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 aebc07f77..a7de2130c 100644 --- a/parachain/runtimes/gargantua/src/ismp.rs +++ b/parachain/runtimes/gargantua/src/ismp.rs @@ -91,6 +91,8 @@ 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 7cbdf69d9..24f9f7379 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 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-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 @@ -30,20 +15,22 @@ // --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)] #![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`. @@ -51,37 +38,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_184_000 picoseconds. + Weight::from_parts(40_226_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_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)) } - /// 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_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)) } /// Storage: `IntentsCoprocessor::Gateways` (r:1 w:1) @@ -96,14 +86,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: 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)) @@ -120,14 +110,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_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)) @@ -150,10 +140,53 @@ 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: 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)) } + /// 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_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::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`) + /// The range of component `n` is `[1, 100]`. + fn submit_pair_price(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // 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::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: 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 51a570271..df037f5d6 100644 --- a/parachain/runtimes/nexus/src/ismp.rs +++ b/parachain/runtimes/nexus/src/ismp.rs @@ -433,6 +433,8 @@ 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; } impl IsmpModule for ProxyModule { diff --git a/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs b/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs index 9fb77b5ce..886693530 100644 --- a/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs +++ b/parachain/runtimes/nexus/src/weights/pallet_intents_coprocessor.rs @@ -1,158 +1,192 @@ -// This file is part of Substrate. - -// Copyright (C) Parity Technologies (UK) 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-02-04, 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` +//! 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 +// --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 polkadot_sdk::frame_support::{traits::Get, weights::Weight}; +use polkadot_sdk::frame_system; 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_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)) + } + /// 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_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)) + } + /// 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_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)) + } + /// 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: 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)) + } + /// 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: 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)) + } + /// 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_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)) + } + /// 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_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::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`) + /// The range of component `n` is `[1, 100]`. + fn submit_pair_price(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // 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::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_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/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..fb9d5f9ab --- /dev/null +++ b/parachain/simtests/src/price_submission.rs @@ -0,0 +1,161 @@ +#![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::{H256, U256}; +use sc_consensus_manual_seal::CreatedBlock; +use sp_core::{crypto::Ss58Codec, Bytes}; +use sp_keyring::sr25519::Keyring; +use subxt::ext::subxt_rpcs::rpc_params; +use subxt_utils::Hyperbridge; + +/// 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 +} + +/// 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::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 +} + + +/// Integration test for the fee-based price submission system. +/// +/// Exercises the full lifecycle: +/// 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. Re-submission overwrites previous entries +#[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 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 + let one_unit = U256::from(10u64).pow(U256::from(18)); + + // 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 + let price_entries = vec![ + PriceInput { + amount: U256::zero(), + price: U256::from(14145) * one_unit / U256::from(10), // 1414.5 * 10^18 + }, + PriceInput { amount: U256::from(1000) * one_unit, price: U256::from(1420) * one_unit }, + ]; + + // Set submission fee + sudo_raw_and_finalize( + &client, + &rpc_client, + encode_set_price_submission_fee(submission_fee), + ) + .await?; + println!("Submission fee set: {submission_fee}"); + + // 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: 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: 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]: amount={} @ {}", prices[0].amount, prices[0].price); + println!(" entry[1]: amount={} @ {}", prices[1].amount, prices[1].price); + + // 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)"); + + // Query again and verify overwrite + let prices2: Vec = + rpc_client.request("intents_getPairPrices", rpc_params![pair_id]).await?; + + 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 c97ff8fbd..b8f9d8a87 100644 --- a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts +++ b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts @@ -5,7 +5,8 @@ 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, Quote } from "@/types" +import { interpolatePrice } from "@/utils/interpolate" import type { SubstrateChain } from "./substrate" /** Offchain storage key prefix for bids */ @@ -75,6 +76,14 @@ export function decodeUserOpScale(hex: HexString): PackedUserOperation { } } +/** RPC response shape from intents_getPairPrices */ +interface RpcPriceEntry { + amount: string + price: string + filler: string + timestamp: number +} + /** RPC response shape from intents_getBidsForOrder */ interface RpcBidInfo { commitment: HexString @@ -246,11 +255,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 txHash = 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: txHash as HexString, + extrinsicHash: extrinsic.hash.toHex() as HexString, + }) + } } else if (result.dispatchError) { resolved = true clearTimeout(timeoutId) @@ -316,6 +337,79 @@ export class IntentsCoprocessor { } } + /** + * Submits price entries for a recognized token pair on Hyperbridge. + * + * 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 + * @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 { amount, price } structs for the pallet + const encodedEntries = entries.map((e) => ({ + amount: e.amount.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", + } + } + } + + /** + * 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], + ) + + // 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 fresh) { + 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 interpolated = interpolatePrice(points, amount) + quotes.push({ filler, amount: interpolated.toString() }) + } + + return quotes + } + /** * 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 97a268de8..62cd3552e 100644 --- a/sdk/packages/sdk/src/types/index.ts +++ b/sdk/packages/sdk/src/types/index.ts @@ -1376,6 +1376,24 @@ export type IntentOrderStatusUpdate = error: string } +/** + * 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 { + amount: bigint + 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 amount at the requested input (human-readable decimal string) */ + amount: string +} + /** Result of selecting a bid and submitting to the bundler */ export interface SelectBidResult { userOp: PackedUserOperation 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 +} diff --git a/sdk/packages/simplex/filler-config-example.toml b/sdk/packages/simplex/filler-config-example.toml index a79bcdc93..c9eb7d78e 100644 --- a/sdk/packages/simplex/filler-config-example.toml +++ b/sdk/packages/simplex/filler-config-example.toml @@ -88,6 +88,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 configured simplex signer (privateKey or mpcVault) diff --git a/sdk/packages/simplex/src/bin/simplex.ts b/sdk/packages/simplex/src/bin/simplex.ts index dc49381be..db4987da4 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, @@ -93,6 +94,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 } @@ -417,6 +420,7 @@ program askPricePolicy, strategyConfig.maxOrderUsd, strategyConfig.exoticTokenAddresses, + strategyConfig.stablecoinAddresses, fxConfirmationPolicy, ) } @@ -660,6 +664,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/config/interpolated-curve.ts b/sdk/packages/simplex/src/config/interpolated-curve.ts index 73a00308f..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 @@ -176,32 +177,15 @@ 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))) - } - } + getPoints(): { amount: Decimal; price: Decimal }[] { + return this.points + } - // Fallback (should not be reached due to earlier checks) - return lastPoint.price + getPrice(orderValueUsd: Decimal): Decimal { + const numPoints = this.points.map((p) => ({ + amount: p.amount.toNumber(), + price: p.price.toNumber(), + })) + return new Decimal(interpolatePrice(numPoints, orderValueUsd.toNumber())) } } diff --git a/sdk/packages/simplex/src/core/filler.ts b/sdk/packages/simplex/src/core/filler.ts index 9b412f5d7..a5f179149 100644 --- a/sdk/packages/simplex/src/core/filler.ts +++ b/sdk/packages/simplex/src/core/filler.ts @@ -20,6 +20,7 @@ import { import { FillerConfigService } from "@/services/FillerConfigService" import { getLogger } from "@/services/Logger" import type { SigningAccount } from "@/services/wallet" +import { FXFiller } from "@/strategies/fx" import { Decimal } from "decimal.js" export class IntentFiller { @@ -112,6 +113,17 @@ export class IntentFiller { } } + // Submit initial prices on FX strategies + if (this.hyperbridge) { + for (const strategy of this.strategies) { + if (strategy instanceof FXFiller) { + this.logger.info("Submitting initial prices for FX strategy") + await strategy.submitInitialPrices(this.hyperbridge) + this.logger.info("Initial price submission complete") + } + } + } + // 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.signer) @@ -148,6 +160,7 @@ export class IntentFiller { if (this.bidStorage && this.hyperbridge) { this.startRetractionSweep() } + } /** diff --git a/sdk/packages/simplex/src/services/ContractInteractionService.ts b/sdk/packages/simplex/src/services/ContractInteractionService.ts index f3302ac28..19abf766c 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 7869d939c..afbfc1d55 100644 --- a/sdk/packages/simplex/src/strategies/fx.ts +++ b/sdk/packages/simplex/src/strategies/fx.ts @@ -9,10 +9,11 @@ import { IntentsCoprocessor, adjustDecimals, ADDRESS_ZERO, + type PriceInput, } from "@hyperbridge/sdk" import { ChainClientManager, ContractInteractionService } from "@/services" import { FillerConfigService } from "@/services/FillerConfigService" -import { formatUnits } 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" @@ -60,6 +61,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 signer: SigningAccount private logger = getLogger("fx-simplex") @@ -81,6 +84,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. */ @@ -93,6 +99,7 @@ export class FXFiller implements FillerStrategy { askPricePolicy: FillerPricePolicy, maxOrderUsdStr: string, exoticTokenAddresses: Record, + stablecoinAddresses: Record, confirmationPolicy?: ConfirmationPolicy, ) { this.configService = configService @@ -101,6 +108,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") @@ -114,6 +122,143 @@ 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") + */ + private computeSymbolPairId(baseSymbol: string, quoteSymbol: string): HexString { + return keccak256(encodePacked(["string"], [`${baseSymbol}/${quoteSymbol}`])) 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 { + try { + const chain = Object.keys(this.exoticTokenAddresses)[0] + const exoticAddress = this.exoticTokenAddresses[chain] + const stableAddress = this.getUsdToken(chain) + + const [stableSymbol, exoticSymbol] = await Promise.all([ + this.contractService.getTokenSymbol(stableAddress, chain), + this.contractService.getTokenSymbol(exoticAddress, chain), + ]) + + this.logger.info({ stableSymbol, exoticSymbol }, "Resolved token symbols for price submission") + + 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() + 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, 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): + * - amount = stable (USD) threshold + * - price = exotic tokens per 1 stable + */ + private buildAskPriceEntries(): PriceInput[] { + const points = this.askPricePolicy.getPoints() + if (points.length === 0) return [] + + const decimals = 18 + 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): + * - 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 decimals = 18 + 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 { 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 b1d238113..d2170fd76 100644 --- a/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts +++ b/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts @@ -884,6 +884,11 @@ function createCrossChainFxIntentFiller( }, }) + const stablecoinAddresses: Record = {} + for (const id of chainIds) { + stablecoinAddresses[id] = chainConfigService.getUsdcAsset(id) + } + const fxStrategy = new FXFiller( fillerSigner, chainConfigService, @@ -893,6 +898,7 @@ function createCrossChainFxIntentFiller( askPricePolicy, "5000", exoticTokenAddresses, + stablecoinAddresses, confirmationPolicy, ) @@ -942,6 +948,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( signer, chainConfigService, @@ -951,6 +962,7 @@ function createFxOnlyIntentFiller( askPricePolicy, "5000", exoticTokenAddresses, + stablecoinAddresses, ) const strategies = [fxStrategy]