Skip to content
Open
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b0e0383
allow fillers to sumbmit pair price using membership and non membersh…
dharjeezy Mar 11, 2026
fad61b6
vec
dharjeezy Mar 11, 2026
c8257eb
Merge branch 'main' of github.com:polytope-labs/hyperbridge into dami…
dharjeezy Mar 12, 2026
004dd9c
dual-path price submission system for verified prices and unverified …
dharjeezy Mar 12, 2026
cb2c312
TreasuryAccount in config
dharjeezy Mar 12, 2026
9d13e85
fmt
dharjeezy Mar 12, 2026
6e47e72
introduce price range input, include docs
dharjeezy Mar 13, 2026
6b7dcc1
cross-chain filler verification by inspecting RedeemEscrow messages …
dharjeezy Mar 13, 2026
00d16dd
fully reserve deposit price submission
dharjeezy Mar 16, 2026
4fd12d4
simplex integration for price update
dharjeezy Mar 16, 2026
fb824aa
address concerns
dharjeezy Mar 16, 2026
789d109
tested out implementation
dharjeezy Mar 17, 2026
dae9ad7
update to doc
dharjeezy Mar 17, 2026
d925949
block price submissions when a withdrawal is in progress, use H256 fo…
dharjeezy Mar 17, 2026
9307348
Merge main: resolve conflicts in FX strategy and simplex config
dharjeezy Mar 17, 2026
561ad9d
remove service
dharjeezy Mar 17, 2026
ffc5a1e
use price policy to get points
dharjeezy Mar 17, 2026
f9cbd1d
clear stale prices per pair, compute pair ids automatically, submit a…
dharjeezy Mar 18, 2026
6762e78
simplify pair IDs to keccak256("base/quote"), replace timestamp with …
dharjeezy Mar 18, 2026
ef25136
benchmarks, simplified price entries
dharjeezy Mar 18, 2026
a73292f
configurable stablecoin addresses, update simtest, and add per-entry …
dharjeezy Mar 19, 2026
dcfb1c1
update docs
dharjeezy Mar 19, 2026
790e716
getQuotes method
dharjeezy Mar 20, 2026
37d57f3
Merge branches 'dami/filler-price-pair-update' and 'main' of github.c…
dharjeezy Mar 20, 2026
51bc01d
refacoring
dharjeezy Mar 20, 2026
46c3574
introduce side to differentiate prices for asks and bids,
dharjeezy Mar 20, 2026
93e5fe6
interpolate
dharjeezy Mar 20, 2026
03dff76
revert side
dharjeezy Mar 20, 2026
8a3f9cd
register pair via reserve deposits
dharjeezy Mar 23, 2026
b243ee4
Merge branch 'main' of github.com:polytope-labs/hyperbridge into dami…
dharjeezy Mar 24, 2026
80d0398
fee based submission
dharjeezy Mar 24, 2026
231c1f2
filter 24 hours prices
dharjeezy Mar 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion docs/content/developers/intent-gateway/meta.json
Original file line number Diff line number Diff line change
@@ -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
}
103 changes: 103 additions & 0 deletions docs/content/developers/intent-gateway/price-submission.mdx
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions modules/pallets/intents-coprocessor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -49,6 +51,7 @@ std = [
"scale-info/std",
"anyhow/std",
"alloy-primitives/std",
"crypto-utils/std",
"sp-io/std",
"pallet-ismp/std",
]
Expand Down
1 change: 1 addition & 0 deletions modules/pallets/intents-coprocessor/rpc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
93 changes: 93 additions & 0 deletions modules/pallets/intents-coprocessor/rpc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ pub struct RpcBidInfo {
pub user_op: Vec<u8>,
}

/// 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)
Expand Down Expand Up @@ -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::<String>)
}

/// 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<u8> {
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<u8> {
Expand All @@ -176,6 +219,10 @@ pub trait IntentsApi {
#[method(name = "intents_getBidsForOrder")]
fn get_bids_for_order(&self, commitment: H256) -> RpcResult<Vec<RpcBidInfo>>;

/// Get all prices for a token pair
#[method(name = "intents_getPairPrices")]
fn get_pair_prices(&self, pair_id: H256) -> RpcResult<Vec<RpcPriceEntry>>;

#[subscription(name = "intents_subscribeBids" => "intents_bidNotification", unsubscribe = "intents_unsubscribeBids", item = RpcBidInfo)]
async fn subscribe_bids(&self, commitment: Option<H256>) -> SubscriptionResult;
}
Expand Down Expand Up @@ -259,6 +306,52 @@ where
Ok(bids.into_iter().collect())
}

fn get_pair_prices(&self, pair_id: H256) -> RpcResult<Vec<RpcPriceEntry>> {
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::<PriceEntry>::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,
Expand Down
51 changes: 51 additions & 0 deletions modules/pallets/intents-coprocessor/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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::<T>::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::<T>::from(u32::MAX);
<T as Config>::Currency::make_free_balance_be(&caller, balance);

PriceSubmissionFee::<T>::put(<T as Config>::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<PriceInput, T::MaxPriceEntries> =
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::<T>::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::<T>::get(), 2000u32.into());
Ok(())
}

impl_benchmark_test_suite!(Pallet, crate::tests::new_test_ext(), crate::tests::Test);
}
Loading
Loading