diff --git a/Cargo.lock b/Cargo.lock
index d7722f3628..e9b5509c72 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5383,13 +5383,14 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rain-erc"
-version = "0.1.1"
+version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1c1743f958ef5d54d361f3b7e2ff9109adfe0549934c46fac729f8d69ec09677"
+checksum = "0dde8f670648bca26ec1fc4565a7f32631c040204137aee953b8444c6d0d7ff2"
dependencies = [
"alloy",
"getrandom 0.2.16",
"getrandom 0.3.3",
+ "serde",
"thiserror 1.0.69",
]
@@ -5608,6 +5609,7 @@ dependencies = [
"itertools 0.14.0",
"once_cell",
"proptest",
+ "rain-erc",
"rain-error-decoding",
"rain-math-float",
"rain-metaboard-subgraph",
diff --git a/Cargo.toml b/Cargo.toml
index 4506ae851b..f849d8c77a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -48,7 +48,7 @@ derive_builder = "0.20.0"
thirtyfour = "0.31.0"
test-context = "0.3.0"
portpicker = "0.1.1"
-rain-erc = "0.1.1"
+rain-erc = "0.1.5"
rain-math-float = "0.1.7"
rain-error-decoding = "0.1.2"
# Direct dep required: the #[wasm_bindgen] macro emits `::wasm_bindgen` paths, and
diff --git a/crates/common/ARCHITECTURE.md b/crates/common/ARCHITECTURE.md
index 7b5c197631..1d3f168573 100644
--- a/crates/common/ARCHITECTURE.md
+++ b/crates/common/ARCHITECTURE.md
@@ -38,7 +38,11 @@ require its `COMMIT_SHA` env var.
prepare batch withdraw calldata; expose WASM‑friendly structs. The `local_db/`
subtree is split into `state.rs` (runtime state, query routing via
`LocalDbState`/`QuerySource`/`SyncReadiness`) and `status.rs` (UI
- status‑reporting types).
+ status‑reporting types). The `markets/` subtree discovers direct quote-token
+ pairs from active indexed orders and assembles normalized order books, trades,
+ and rolling statistics without application-specific token semantics. Registry
+ token data identifies each network's quote token and enriches indexed
+ metadata; it does not decide which base tokens are listed.
- `dotrain_order` — Parse and validate a DOTRAIN config; compose
scenarios/deployments to Rainlang; fetch authoring metadata and pragma words;
merge additional settings.
diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml
index 205a09e0c5..c3fe8011b8 100644
--- a/crates/common/Cargo.toml
+++ b/crates/common/Cargo.toml
@@ -43,6 +43,7 @@ csv = { workspace = true }
chrono = { workspace = true }
futures = { workspace = true }
rain-error-decoding = { workspace = true }
+rain-erc = { workspace = true }
rain-interpreter-eval = { workspace = true }
wasm-bindgen = { workspace = true }
wasm-bindgen-utils = { workspace = true }
diff --git a/crates/common/src/local_db/query/fetch_latest_trades_per_token/mod.rs b/crates/common/src/local_db/query/fetch_latest_trades_per_token/mod.rs
new file mode 100644
index 0000000000..3f65059ff9
--- /dev/null
+++ b/crates/common/src/local_db/query/fetch_latest_trades_per_token/mod.rs
@@ -0,0 +1,118 @@
+use crate::local_db::query::fetch_order_trades::LocalDbOrderTrade;
+use crate::local_db::query::{SqlBuildError, SqlStatement, SqlValue};
+use alloy::primitives::Address;
+
+const QUERY_TEMPLATE: &str = include_str!("query.sql");
+const RAINDEXES_CLAUSE: &str = "/*RAINDEXES_CLAUSE*/";
+const RAINDEXES_CLAUSE_BODY: &str = "AND tws.raindex_address IN ({list})";
+const PAIR_CLAUSE: &str = "/*PAIR_CLAUSE*/";
+
+#[derive(Debug, Clone)]
+pub struct FetchLatestTradesPerTokenArgs {
+ pub chain_id: u32,
+ pub raindex_addresses: Vec
,
+ pub quote_token: Address,
+ pub base_tokens: Vec,
+}
+
+pub fn build_fetch_latest_trades_per_token_stmt(
+ args: &FetchLatestTradesPerTokenArgs,
+) -> Result {
+ let mut stmt = SqlStatement::new(QUERY_TEMPLATE);
+
+ let quote_partition = push_param(&mut stmt, args.quote_token);
+ stmt.replace("/*QUOTE_PARTITION*/", "e_partition)?;
+
+ let chain_id = push_param(&mut stmt, args.chain_id);
+ stmt.replace("/*CHAIN_ID*/", &chain_id)?;
+
+ let mut raindexes = args.raindex_addresses.clone();
+ raindexes.sort_unstable();
+ raindexes.dedup();
+ stmt.bind_list_clause(
+ RAINDEXES_CLAUSE,
+ RAINDEXES_CLAUSE_BODY,
+ raindexes.into_iter().map(SqlValue::from),
+ )?;
+
+ let mut base_tokens = args.base_tokens.clone();
+ base_tokens.sort_unstable();
+ base_tokens.dedup();
+ if base_tokens.is_empty() {
+ stmt.replace(PAIR_CLAUSE, "AND 1 = 0")?;
+ return Ok(stmt);
+ }
+
+ let input_quote = push_param(&mut stmt, args.quote_token);
+ let output_bases = push_list(&mut stmt, &base_tokens);
+ let output_quote = push_param(&mut stmt, args.quote_token);
+ let input_bases = push_list(&mut stmt, &base_tokens);
+ stmt.replace(
+ PAIR_CLAUSE,
+ &format!(
+ "AND ((tws.input_token = {input_quote} AND tws.output_token IN ({output_bases})) \
+ OR (tws.output_token = {output_quote} AND tws.input_token IN ({input_bases})))"
+ ),
+ )?;
+ Ok(stmt)
+}
+
+fn push_param(stmt: &mut SqlStatement, value: impl Into) -> String {
+ let placeholder = format!("?{}", stmt.params().len() + 1);
+ stmt.push(value.into());
+ placeholder
+}
+
+fn push_list(stmt: &mut SqlStatement, values: &[Address]) -> String {
+ values
+ .iter()
+ .map(|value| push_param(stmt, *value))
+ .collect::>()
+ .join(", ")
+}
+
+pub type LatestTradeRow = LocalDbOrderTrade;
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use alloy::primitives::address;
+
+ #[test]
+ fn query_selects_one_latest_trade_per_base_token() {
+ let quote = address!("2222222222222222222222222222222222222222");
+ let base_a = address!("1111111111111111111111111111111111111111");
+ let base_b = address!("3333333333333333333333333333333333333333");
+ let raindex = address!("4444444444444444444444444444444444444444");
+
+ let stmt = build_fetch_latest_trades_per_token_stmt(&FetchLatestTradesPerTokenArgs {
+ chain_id: 8453,
+ raindex_addresses: vec![raindex, raindex],
+ quote_token: quote,
+ base_tokens: vec![base_b, base_a, base_a],
+ })
+ .unwrap();
+
+ assert!(stmt.sql.contains("ROW_NUMBER() OVER"));
+ assert!(stmt.sql.contains("market_rank = 1"));
+ assert!(stmt.sql.contains("tws.raindex_address IN (?3)"));
+ assert!(stmt.sql.contains("tws.output_token IN (?5, ?6)"));
+ assert!(stmt.sql.contains("tws.input_token IN (?8, ?9)"));
+ assert_eq!(stmt.params().len(), 9);
+ assert!(!stmt.sql.contains("/*"));
+ }
+
+ #[test]
+ fn empty_base_tokens_build_a_match_none_query() {
+ let stmt = build_fetch_latest_trades_per_token_stmt(&FetchLatestTradesPerTokenArgs {
+ chain_id: 8453,
+ raindex_addresses: vec![],
+ quote_token: Address::ZERO,
+ base_tokens: vec![],
+ })
+ .unwrap();
+
+ assert!(stmt.sql.contains("AND 1 = 0"));
+ assert!(!stmt.sql.contains("/*"));
+ }
+}
diff --git a/crates/common/src/local_db/query/fetch_latest_trades_per_token/query.sql b/crates/common/src/local_db/query/fetch_latest_trades_per_token/query.sql
new file mode 100644
index 0000000000..13b57755dd
--- /dev/null
+++ b/crates/common/src/local_db/query/fetch_latest_trades_per_token/query.sql
@@ -0,0 +1,63 @@
+WITH ranked_trades AS (
+ SELECT
+ tws.*,
+ ROW_NUMBER() OVER (
+ PARTITION BY CASE
+ WHEN tws.input_token = /*QUOTE_PARTITION*/ THEN tws.output_token
+ ELSE tws.input_token
+ END
+ ORDER BY
+ tws.block_timestamp DESC,
+ tws.block_number DESC,
+ tws.log_index DESC,
+ tws.trade_kind,
+ tws.trade_side
+ ) AS market_rank
+ FROM derived_trades tws
+ WHERE tws.chain_id = /*CHAIN_ID*/
+ /*RAINDEXES_CLAUSE*/
+ /*PAIR_CLAUSE*/
+)
+SELECT
+ tws.chain_id,
+ tws.trade_kind,
+ tws.raindex_address AS raindex,
+ tws.order_hash,
+ tws.order_owner,
+ tws.order_nonce,
+ tws.transaction_hash,
+ tws.log_index,
+ tws.block_number,
+ tws.block_timestamp,
+ tws.transaction_sender,
+ tws.input_vault_id,
+ tws.input_token,
+ tok_in.name AS input_token_name,
+ tok_in.symbol AS input_token_symbol,
+ tok_in.decimals AS input_token_decimals,
+ tws.input_delta,
+ tws.input_running_balance,
+ tws.output_vault_id,
+ tws.output_token,
+ tok_out.name AS output_token_name,
+ tok_out.symbol AS output_token_symbol,
+ tok_out.decimals AS output_token_decimals,
+ tws.output_delta,
+ tws.output_running_balance,
+ tws.trade_id
+FROM ranked_trades tws
+LEFT JOIN erc20_tokens tok_in
+ ON tok_in.chain_id = tws.chain_id
+ AND tok_in.raindex_address = tws.raindex_address
+ AND tok_in.token_address = tws.input_token
+LEFT JOIN erc20_tokens tok_out
+ ON tok_out.chain_id = tws.chain_id
+ AND tok_out.raindex_address = tws.raindex_address
+ AND tok_out.token_address = tws.output_token
+WHERE tws.market_rank = 1
+ORDER BY
+ tws.block_timestamp DESC,
+ tws.block_number DESC,
+ tws.log_index DESC,
+ tws.trade_kind,
+ tws.trade_side;
diff --git a/crates/common/src/local_db/query/mod.rs b/crates/common/src/local_db/query/mod.rs
index 1b292d20a6..ef317e1518 100644
--- a/crates/common/src/local_db/query/mod.rs
+++ b/crates/common/src/local_db/query/mod.rs
@@ -7,6 +7,7 @@ pub mod fetch_all_tokens;
pub mod fetch_db_metadata;
pub mod fetch_erc20_tokens_by_addresses;
pub mod fetch_last_synced_block;
+pub mod fetch_latest_trades_per_token;
pub mod fetch_order_trades;
pub mod fetch_order_trades_count;
pub mod fetch_order_vaults_volume;
diff --git a/crates/common/src/raindex_client/local_db/query/fetch_latest_trades_per_token.rs b/crates/common/src/raindex_client/local_db/query/fetch_latest_trades_per_token.rs
new file mode 100644
index 0000000000..378633039d
--- /dev/null
+++ b/crates/common/src/raindex_client/local_db/query/fetch_latest_trades_per_token.rs
@@ -0,0 +1,28 @@
+use crate::local_db::query::fetch_latest_trades_per_token::{
+ build_fetch_latest_trades_per_token_stmt, FetchLatestTradesPerTokenArgs, LatestTradeRow,
+};
+use crate::local_db::query::{LocalDbQueryError, LocalDbQueryExecutor};
+use crate::utils::timing::Timing;
+
+pub async fn fetch_latest_trades_per_token(
+ exec: &E,
+ args: FetchLatestTradesPerTokenArgs,
+) -> Result, LocalDbQueryError> {
+ if args.base_tokens.is_empty() {
+ return Ok(Vec::new());
+ }
+ let started = Timing::now();
+ let base_tokens_count = args.base_tokens.len();
+ let raindexes_count = args.raindex_addresses.len();
+ let stmt = build_fetch_latest_trades_per_token_stmt(&args)?;
+ let trades = exec.query_json::>(&stmt).await?;
+ tracing::info!(
+ chain_id = args.chain_id,
+ base_tokens_count,
+ raindexes_count,
+ rows = trades.len(),
+ duration_ms = started.elapsed_ms(),
+ "local DB latest market trades fetch completed"
+ );
+ Ok(trades)
+}
diff --git a/crates/common/src/raindex_client/local_db/query/mod.rs b/crates/common/src/raindex_client/local_db/query/mod.rs
index 99659a8310..b291d6b76c 100644
--- a/crates/common/src/raindex_client/local_db/query/mod.rs
+++ b/crates/common/src/raindex_client/local_db/query/mod.rs
@@ -3,6 +3,7 @@ pub mod create_tables;
pub mod fetch_all_tokens;
pub mod fetch_erc20_tokens_by_addresses;
pub mod fetch_last_synced_block;
+pub mod fetch_latest_trades_per_token;
pub mod fetch_order_trades;
pub mod fetch_order_trades_count;
pub mod fetch_order_vaults_volume;
diff --git a/crates/common/src/raindex_client/markets/catalog.rs b/crates/common/src/raindex_client/markets/catalog.rs
new file mode 100644
index 0000000000..db9510c635
--- /dev/null
+++ b/crates/common/src/raindex_client/markets/catalog.rs
@@ -0,0 +1,681 @@
+use super::*;
+use crate::raindex_client::vaults::RaindexVaultToken;
+use std::collections::BTreeSet;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct IndexedToken {
+ address: Address,
+ name: Option,
+ symbol: Option,
+ decimals: u8,
+}
+
+impl From for IndexedToken {
+ fn from(token: RaindexVaultToken) -> Self {
+ Self {
+ address: token.raw_address(),
+ name: token.raw_name().map(ToOwned::to_owned),
+ symbol: token.raw_symbol().map(ToOwned::to_owned),
+ decimals: token.raw_decimals(),
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+struct IndexedOrder {
+ chain_id: u32,
+ raindex: Address,
+ inputs: Vec,
+ outputs: Vec,
+}
+
+impl From for IndexedOrder {
+ fn from(order: RaindexOrder) -> Self {
+ Self {
+ chain_id: order.chain_id(),
+ raindex: order.raw_raindex(),
+ inputs: order
+ .input_vaults()
+ .iter()
+ .map(|vault| vault.token().into())
+ .collect(),
+ outputs: order
+ .output_vaults()
+ .iter()
+ .map(|vault| vault.token().into())
+ .collect(),
+ }
+ }
+}
+
+struct MarketAccumulator {
+ base: RaindexMarketToken,
+ quote: RaindexMarketToken,
+ raindex_addresses: BTreeSet,
+}
+
+pub(super) async fn discover_markets(
+ client: &RaindexClient,
+ options: &MarketListOptions,
+) -> Result, RaindexError> {
+ let registry_tokens = client.get_all_tokens()?.into_values().collect::>();
+ let quotes = quote_tokens(®istry_tokens, options)?;
+ if quotes.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let chain_ids = quotes.keys().copied().collect::>();
+ let quote_addresses = quotes
+ .values()
+ .map(|quote| quote.address)
+ .collect::>();
+ let orders = fetch_active_orders(client, chain_ids, quote_addresses).await?;
+ markets_from_indexed_orders(
+ orders.into_iter().map(IndexedOrder::from),
+ ®istry_tokens,
+ "es,
+ options,
+ )
+}
+
+async fn fetch_active_orders(
+ client: &RaindexClient,
+ chain_ids: Vec,
+ quote_addresses: Vec,
+) -> Result, RaindexError> {
+ let mut orders = Vec::new();
+ for filters in active_order_filters(quote_addresses) {
+ orders.extend(fetch_market_orders_paginated(client, chain_ids.clone(), filters).await?);
+ }
+ Ok(orders)
+}
+
+fn active_order_filters(quote_addresses: Vec) -> [GetOrdersFilters; 2] {
+ [
+ GetOrdersFilters {
+ active: Some(true),
+ tokens: Some(GetOrdersTokenFilter {
+ inputs: Some(quote_addresses.clone()),
+ outputs: None,
+ }),
+ ..Default::default()
+ },
+ GetOrdersFilters {
+ active: Some(true),
+ tokens: Some(GetOrdersTokenFilter {
+ inputs: None,
+ outputs: Some(quote_addresses),
+ }),
+ ..Default::default()
+ },
+ ]
+}
+
+fn quote_tokens(
+ tokens: &[TokenCfg],
+ options: &MarketListOptions,
+) -> Result, RaindexError> {
+ let selected_chains = options
+ .chain_ids
+ .as_ref()
+ .map(|ids| ids.iter().copied().collect::>());
+ let mut quotes = BTreeMap::>::new();
+ for token in tokens.iter().filter(|token| {
+ extension_bool(token, MARKET_QUOTE_EXTENSION)
+ && selected_chains
+ .as_ref()
+ .is_none_or(|chains| chains.contains(&token.network.chain_id))
+ }) {
+ quotes
+ .entry(token.network.chain_id)
+ .or_default()
+ .entry(token.address)
+ .or_insert_with(|| token.clone());
+ }
+
+ quotes
+ .into_iter()
+ .map(|(chain_id, tokens)| match tokens.into_values().collect::>().as_slice() {
+ [quote] => Ok((chain_id, quote.clone())),
+ _ => Err(RaindexError::PreflightError(format!(
+ "multiple tokens with {MARKET_QUOTE_EXTENSION}=true are configured for chain {chain_id}"
+ ))),
+ })
+ .collect()
+}
+
+fn markets_from_indexed_orders(
+ orders: impl IntoIterator- ,
+ registry_tokens: &[TokenCfg],
+ quotes: &BTreeMap,
+ options: &MarketListOptions,
+) -> Result, RaindexError> {
+ let registry_lookup = registry_token_lookup(registry_tokens)?;
+ let mut accumulators = BTreeMap::<(u32, Address), MarketAccumulator>::new();
+
+ for order in orders {
+ let Some(quote_cfg) = quotes.get(&order.chain_id) else {
+ continue;
+ };
+ let quote_address = quote_cfg.address;
+ let quote = market_token(quote_cfg)?;
+
+ if order
+ .inputs
+ .iter()
+ .any(|token| token.address == quote_address)
+ {
+ for token in order
+ .outputs
+ .iter()
+ .filter(|token| token.address != quote_address)
+ {
+ add_market(
+ &mut accumulators,
+ order.chain_id,
+ order.raindex,
+ token,
+ quote.clone(),
+ ®istry_lookup,
+ )?;
+ }
+ }
+ if order
+ .outputs
+ .iter()
+ .any(|token| token.address == quote_address)
+ {
+ for token in order
+ .inputs
+ .iter()
+ .filter(|token| token.address != quote_address)
+ {
+ add_market(
+ &mut accumulators,
+ order.chain_id,
+ order.raindex,
+ token,
+ quote.clone(),
+ ®istry_lookup,
+ )?;
+ }
+ }
+ }
+
+ let selected_tickers = options
+ .ticker_ids
+ .as_ref()
+ .map(|ids| ids.iter().cloned().collect::>());
+ let mut markets = accumulators
+ .into_values()
+ .map(|accumulator| {
+ let id = format!(
+ "{}:{:#x}:{:#x}",
+ accumulator.base.chain_id, accumulator.base.address, accumulator.quote.address
+ );
+ let ticker_id = format!(
+ "{:#x}_{:#x}",
+ accumulator.base.address, accumulator.quote.address
+ );
+ RaindexMarket {
+ id,
+ ticker_id,
+ chain_id: accumulator.base.chain_id,
+ base: accumulator.base,
+ quote: accumulator.quote,
+ raindex_addresses: accumulator.raindex_addresses.into_iter().collect(),
+ }
+ })
+ .filter(|market| {
+ selected_tickers
+ .as_ref()
+ .is_none_or(|tickers| tickers.contains(&market.ticker_id))
+ })
+ .collect::>();
+ markets.sort_by(|a, b| {
+ (a.chain_id, a.base.symbol.as_str(), a.base.address).cmp(&(
+ b.chain_id,
+ b.base.symbol.as_str(),
+ b.base.address,
+ ))
+ });
+ Ok(markets)
+}
+
+fn add_market(
+ markets: &mut BTreeMap<(u32, Address), MarketAccumulator>,
+ chain_id: u32,
+ raindex: Address,
+ indexed_token: &IndexedToken,
+ quote: RaindexMarketToken,
+ registry_lookup: &HashMap<(u32, Address), TokenCfg>,
+) -> Result<(), RaindexError> {
+ let base = match registry_lookup.get(&(chain_id, indexed_token.address)) {
+ Some(token) => market_token(token)?,
+ None => indexed_market_token(chain_id, indexed_token),
+ };
+ if base.address == quote.address {
+ return Ok(());
+ }
+ markets
+ .entry((chain_id, base.address))
+ .and_modify(|market| {
+ market.raindex_addresses.insert(raindex);
+ })
+ .or_insert_with(|| MarketAccumulator {
+ base,
+ quote,
+ raindex_addresses: BTreeSet::from([raindex]),
+ });
+ Ok(())
+}
+
+fn registry_token_lookup(
+ tokens: &[TokenCfg],
+) -> Result, RaindexError> {
+ let mut sorted = tokens.to_vec();
+ sorted.sort_by_key(|token| (token.network.chain_id, token.address, token.key.clone()));
+ let mut lookup = sorted
+ .iter()
+ .map(|token| ((token.network.chain_id, token.address), token.clone()))
+ .collect::>();
+
+ // Variant declarations intentionally override an exact-address registry entry:
+ // an indexed legacy or underlying token still belongs to the declared canonical market.
+ for token in sorted {
+ for address in [
+ extension_address(&token, "unwrappedAddress")?,
+ extension_address(&token, "legacyAddress")?,
+ ]
+ .into_iter()
+ .flatten()
+ {
+ lookup.insert((token.network.chain_id, address), token.clone());
+ }
+ }
+ Ok(lookup)
+}
+
+fn indexed_market_token(chain_id: u32, token: &IndexedToken) -> RaindexMarketToken {
+ let address = token.address;
+ RaindexMarketToken {
+ chain_id,
+ address,
+ name: token
+ .name
+ .clone()
+ .or_else(|| token.symbol.clone())
+ .unwrap_or_else(|| format!("{address:#x}")),
+ symbol: token
+ .symbol
+ .clone()
+ .or_else(|| token.name.clone())
+ .unwrap_or_else(|| format!("{address:#x}")),
+ decimals: Some(token.decimals),
+ logo_uri: None,
+ extensions: None,
+ unwrapped_address: None,
+ legacy_address: None,
+ receipt_address: None,
+ variants: vec![address],
+ }
+}
+
+fn market_token(token: &TokenCfg) -> Result {
+ let unwrapped_address = extension_address(token, "unwrappedAddress")?;
+ let legacy_address = extension_address(token, "legacyAddress")?;
+ let receipt_address = extension_address(token, "receiptAddress")?;
+ let mut variants = [Some(token.address), unwrapped_address, legacy_address]
+ .into_iter()
+ .flatten()
+ .collect::>();
+ variants.sort_unstable();
+ variants.dedup();
+
+ Ok(RaindexMarketToken {
+ chain_id: token.network.chain_id,
+ address: token.address,
+ name: token.label.clone().unwrap_or_else(|| token.key.clone()),
+ symbol: token.symbol.clone().unwrap_or_else(|| token.key.clone()),
+ decimals: token.decimals,
+ logo_uri: token.logo_uri.as_ref().map(ToString::to_string),
+ extensions: token.extensions.clone(),
+ unwrapped_address,
+ legacy_address,
+ receipt_address,
+ variants,
+ })
+}
+
+fn extension_string<'a>(token: &'a TokenCfg, key: &str) -> Option<&'a str> {
+ token
+ .extensions
+ .as_ref()
+ .and_then(|extensions| extensions.get(key))
+ .and_then(Value::as_str)
+}
+
+fn extension_bool(token: &TokenCfg, key: &str) -> bool {
+ token
+ .extensions
+ .as_ref()
+ .and_then(|extensions| extensions.get(key))
+ .and_then(Value::as_bool)
+ .unwrap_or(false)
+}
+
+fn extension_address(token: &TokenCfg, key: &str) -> Result