Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
}
144 changes: 144 additions & 0 deletions docs/content/developers/intent-gateway/price-submission.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
---
title: Price Submission Protocol
description: Deposit-based price submission system for the intents coprocessor pallet
---

# Price Submission Protocol

## Overview

The intents system needs on-chain price data for token pairs to function correctly. Rather than relying on external oracles, the protocol allows anyone to submit prices for governance-approved token pairs by putting up a deposit on their first submission. After that initial deposit, updating prices for the same pair is free. The deposit can be reclaimed later through a two-phase withdrawal process, which gives the system time to detect and respond to malicious data before the submitter disappears with their funds.

## 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 a base token amount range and the corresponding price of the base token in terms of the quote token. This makes it possible to quote different rates for different order sizes in a single transaction. For example, a submitter might quote USDC/CNGN at 1414 for orders between 0 and 999, and 1420 for orders between 1000 and 5000.

Each price entry contains three fields. The `range_start` field is the lower bound of the base token amount range (inclusive). The `range_end` field is the upper bound (also inclusive). The `price` field is the price of the base token in terms of the quote token. The pallet validates that `range_start` is less than or equal to `range_end` for every entry and rejects empty submissions. When stored on-chain, each entry becomes a `PriceEntry` that also includes the submission timestamp.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Each price entry contains three fields. The `range_start` field is the lower bound of the base token amount range (inclusive). The `range_end` field is the upper bound (also inclusive). The `price` field is the price of the base token in terms of the quote token. The pallet validates that `range_start` is less than or equal to `range_end` for every entry and rejects empty submissions. When stored on-chain, each entry becomes a `PriceEntry` that also includes the submission timestamp.
Each price entry contains three fields. The `range_start` field is the lower bound of the base token amount range (inclusive). The `range_end` field is the upper bound (also inclusive). The `price` field is the cost of one unit of the base token in terms of the quote token. The pallet validates that `range_start` is less than or equal to `range_end` for every entry and rejects empty submissions. When stored on-chain, each entry becomes a `PriceEntry` that also includes the submission timestamp.


## Deposit Model

On the first price submission for a given account and token pair, a deposit is reserved from the submitter's balance. The deposit amount is set by governance through the `set_price_deposit_amount` extrinsic. After the initial deposit, the submitter can update prices for the same token pair as many times as they want without paying anything further.

This design discourages spam because each new pair requires locking funds, while keeping ongoing price updates free for active participants.

## Two-Phase Withdrawal

Withdrawing a deposit uses a two-phase process through the `withdraw_price_deposit` extrinsic. The first call initiates the withdrawal by recording the unlock block, which is the current block number plus the governance-configured lock duration. No tokens are moved at this point, and the pallet emits a `PriceDepositWithdrawalInitiated` event.

Once the unlock block has been reached, a second call to the same extrinsic completes the withdrawal. The pallet unreserves the tokens, removes the deposit record, and emits a `PriceDepositWithdrawn` event. If the second call is made before the unlock block, it fails with a `DepositStillLocked` error.

The lock duration is measured in blocks and set by governance through `set_price_deposit_lock_duration`. This two-phase model ensures there is always a window during which bad actors can be identified and penalized before they can withdraw their deposit.

## Price Windows and Data Lifecycle

Prices are organized into time-based windows. The window duration is governance-configurable via `PriceWindowDurationValue`, specified in milliseconds.

The `on_initialize` hook runs every block and checks whether the current window has expired. When it has, it resets a `PricesClearedThisWindow` flag to false. Prices from the previous window are not cleared immediately. They persist so that consumers can still read the previous window's data.

On the first new submission in a new window, all price entries across all pairs are cleared before the new entries are stored. This lazy clearing approach avoids the cost of iterating all pairs in `on_initialize` (which would be unbounded weight paid by the block producer) while ensuring stale data is replaced as soon as fresh data arrives. After the first submission clears the data, the global boolean flag ensures subsequent submissions in the same window skip the clearing step entirely.

## Recognized Pairs

Only governance-approved token pairs can receive price submissions. This prevents spam for arbitrary token combinations and keeps storage growth under control. Governance manages pairs through the `add_recognized_pair` and `remove_recognized_pair` extrinsics. Removing a pair also cleans up its associated price data and storage.

## RPC

The `intents_getPairPrices(pair_id)` RPC endpoint returns all price entries for a given token pair. The raw on-chain values (U256 scaled by 10^18) are converted to human-readable decimal strings with fractional precision preserved. For example, an on-chain value of 1414500000000000000000 is returned as the string "1414.5". Each returned entry includes the amount range boundaries (`range_start` and `range_end`), the `price`, and the submission `timestamp` in seconds.

## Simplex Filler Integration

The simplex filler supports periodic price submission out of the box. When the filler starts, it checks whether a `[priceUpdates]` section is present in the configuration file. If price pairs are configured and a Hyperbridge WebSocket connection is available (via `substratePrivateKey` and `hyperbridgeWsUrl` in the `[simplex]` section), the filler creates a `PriceUpdateService` and starts it alongside the main order monitoring loop.

The `PriceUpdateService` runs on a configurable interval (defaulting to every 5 minutes). On each tick, it iterates through all configured pairs and calls `IntentsCoprocessor.submitPairPrice()` for each one. An initial submission is also triggered shortly after startup so that prices are available immediately rather than waiting for the first interval to elapse. The service logs success or failure for each pair and continues with the remaining pairs even if one fails.

### Configuration

Price updates are configured by adding a `[priceUpdates]` section to the filler TOML configuration file.

```toml
[priceUpdates]
Copy link
Copy Markdown
Member

@Wizdave97 Wizdave97 Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not need this, the fx strategy config already has a price policy, we should just use the values specified there to submit price updates

intervalSeconds = 300 # How often to submit prices (default: 300 = 5 minutes)

[[priceUpdates.pairs]]
pairId = "0x..." # The token pair ID (keccak256 of base_address ++ quote_address)
label = "USDC/CNGN"
decimals = 18 # Decimal places for amounts/prices (default: 18)

[[priceUpdates.pairs.entries]]
rangeStart = "0" # Min base amount in human-readable form
rangeEnd = "999" # Max base amount in human-readable form
price = "1414" # Price in human-readable form

[[priceUpdates.pairs.entries]]
rangeStart = "1000"
rangeEnd = "5000"
price = "1420"
```

Amounts and prices in the configuration file are specified in human-readable form (for example, "1000" for 1000 tokens). The simplex CLI automatically converts them to 18-decimal format when submitting to the chain using the `decimals` field, which defaults to 18 if not specified. Multiple pairs can be configured by repeating `[[priceUpdates.pairs]]` blocks, and each pair can have multiple entries for tiered pricing at different order sizes.

The first submission for each pair will reserve a deposit from the substrate account. Subsequent updates to the same pair are free. When a filler wants to reclaim their deposit, they call `withdrawPriceDeposit` once to initiate the withdrawal (which records the unlock block), then call it again after the governance-configured lock duration has elapsed to unreserve the tokens.

### SDK Usage

The `IntentsCoprocessor` class in `@hyperbridge/sdk` exposes methods for price submission and deposit withdrawal directly. These methods accept raw 18-decimal bigint values. The human-readable string conversion described above is a convenience provided by the simplex filler's TOML configuration parser.

```typescript
import { IntentsCoprocessor } from "@hyperbridge/sdk"
import { parseUnits } from "viem"

const coprocessor = await IntentsCoprocessor.connect(wsUrl, substratePrivateKey)

// Submit prices for a token pair (values in 18-decimal format)
await coprocessor.submitPairPrice("0x...", [
{ rangeStart: parseUnits("0", 18), rangeEnd: parseUnits("999", 18), price: parseUnits("1414", 18) },
{ rangeStart: parseUnits("1000", 18), rangeEnd: parseUnits("5000", 18), price: parseUnits("1420", 18) },
])

// Withdraw deposit (two-phase):
// Phase 1: initiate withdrawal (records unlock block)
await coprocessor.withdrawPriceDeposit("0x...")
// Phase 2: complete withdrawal after unlock block is reached
await coprocessor.withdrawPriceDeposit("0x...")
```

The `PriceUpdateService` from `@hyperbridge/simplex` can also be used as a standalone component for periodic price submissions without running the full filler.

```typescript
import { PriceUpdateService } from "@hyperbridge/simplex"
import { IntentsCoprocessor } from "@hyperbridge/sdk"
import { parseUnits } from "viem"

const coprocessor = IntentsCoprocessor.connect(wsUrl, substratePrivateKey)

const service = new PriceUpdateService(coprocessor, {
intervalSeconds: 300,
pairs: [
{
pairId: "0x...",
label: "USDC/CNGN",
entries: [
{ rangeStart: parseUnits("0", 18), rangeEnd: parseUnits("999", 18), price: parseUnits("1414", 18) },
],
},
],
})

service.start()
```

## Governance Parameters

The protocol has several parameters that are stored on-chain and updatable through governance extrinsics.

`PriceWindowDurationValue` controls the length of each price window in milliseconds. When a window expires, the next submission triggers a lazy clear of all existing price data.

`PriceDepositAmount` is the amount reserved from a submitter's balance on their first price submission for a given token pair. This deposit stays locked until explicitly withdrawn through the two-phase process.

`PriceDepositLockDuration` determines how many blocks the deposit remains locked after a withdrawal is initiated. The submitter must wait this many blocks before completing the withdrawal.

`MaxPriceEntries` is a compile-time constant (configurable per runtime) that limits how many price entries can be included in a single submission.

Recognized token pairs are managed through `add_recognized_pair` and `remove_recognized_pair`. Only pairs that have been explicitly added by governance can receive price submissions.
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
75 changes: 75 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 {
/// Lower bound of the base token amount range (inclusive)
pub range_start: String,
/// Upper bound of the base token amount range (inclusive)
pub range_end: String,
/// The price of the base token in the quote token
pub price: String,
/// Timestamp of submission (seconds)
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 full storage key for a `StorageMap` entry with `Blake2_128Concat` hasher.
fn storage_map_key(pallet: &[u8], storage: &[u8], map_key: &H256) -> Vec<u8> {
let mut key = Vec::new();
key.extend_from_slice(&sp_core::hashing::twox_128(pallet));
key.extend_from_slice(&sp_core::hashing::twox_128(storage));
let map_key_bytes = map_key.as_bytes();
key.extend_from_slice(&sp_core::hashing::blake2_128(map_key_bytes));
key.extend_from_slice(map_key_bytes);
key
}

/// Construct the storage key prefix for iterating all fillers in the on-chain
/// `Bids` double-map for a given order commitment.
fn bids_storage_prefix(commitment: &H256) -> Vec<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,34 @@ 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;

let key = storage_map_key(b"IntentsCoprocessor", b"Prices", &pair_id);
let storage_key = sp_core::storage::StorageKey(key);

let data = match self.client.storage(best_hash, &storage_key) {
Ok(Some(data)) => data.0,
_ => return Ok(Vec::new()),
};

// Decode Vec<PriceEntry>
// PriceEntry SCALE-encodes as (U256, U256, U256, u64)
type Entry = (primitive_types::U256, primitive_types::U256, primitive_types::U256, u64);
match Vec::<Entry>::decode(&mut &data[..]) {
Ok(entries) => Ok(entries
.into_iter()
.map(|(range_start, range_end, price, timestamp)| RpcPriceEntry {
range_start: format_u256_decimals(range_start, 18),
range_end: format_u256_decimals(range_end, 18),
price: format_u256_decimals(price, 18),
timestamp,
})
.collect()),
Err(_) => Ok(Vec::new()),
}
}

async fn subscribe_bids(
&self,
pending: PendingSubscriptionSink,
Expand Down
Loading
Loading