From cb1b457ad39c22aa5627a90e173878a89ab318f2 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:25:48 +0200 Subject: [PATCH] feat(amm): expose supported fee tiers via feeTiers() op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The liquidity form's fee-tier selector was fed from the module's newPositionContext, which hardcoded an empty list — leaving the selector blank. Source the tiers from the program instead so the UI can never drift from what the guest accepts. Add amm_core::SUPPORTED_FEE_TIERS: the canonical ascending list of raw bps ([1, 5, 30, 100]), built from the existing FEE_TIER_BPS_* constants. is_supported_fee_tier's match is left unchanged and the new const is unused on-chain, so the guest ImageID is unaffected; a drift-guard test locks the list to the check (every entry accepted, neighbours rejected, ascending/deduped). Wire it through the stack: - FFI: amm_fee_tiers op reading SUPPORTED_FEE_TIERS -> { feeTiers: [...] } (empty FeeTiersRequest, cbindgen header regenerated). - Module: LogosList feeTiers() unwrapping the list, like tokenHoldings. - Backend: QVariantList feeTiers() QtRO slot forwarding to the module. - QML: LiquidityPage fetches backend.feeTiers() once (wallet-independent) and injects it into NewPositionForm, which wraps each int into a { feeBps } row for the existing delegate. Drop the now-dead feeTiers key from the flow's loadingContext(). --- .../components/liquidity/NewPositionForm.qml | 11 +++-- apps/amm/qml/pages/LiquidityPage.qml | 20 +++++++-- apps/amm/qml/state/NewPositionFlow.qml | 3 +- apps/amm/src/AmmUiBackend.cpp | 8 ++++ apps/amm/src/AmmUiBackend.h | 2 + apps/amm/src/AmmUiBackend.rep | 6 +++ modules/amm/ffi/include/amm_ffi.h | 2 + modules/amm/ffi/src/api/fee.rs | 43 +++++++++++++++++++ modules/amm/ffi/src/api/mod.rs | 10 ++++- modules/amm/ffi/src/api/request.rs | 6 +++ modules/amm/ffi/src/ffi.rs | 15 ++++--- modules/amm/ffi/src/lib.rs | 12 +++--- modules/amm/src/amm_module_impl.cpp | 14 ++++++ modules/amm/src/amm_module_impl.h | 7 +++ programs/amm/core/src/lib.rs | 31 +++++++++++++ 15 files changed, 169 insertions(+), 21 deletions(-) create mode 100644 modules/amm/ffi/src/api/fee.rs diff --git a/apps/amm/qml/components/liquidity/NewPositionForm.qml b/apps/amm/qml/components/liquidity/NewPositionForm.qml index f9914bf..c7d3be0 100644 --- a/apps/amm/qml/components/liquidity/NewPositionForm.qml +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -67,8 +67,13 @@ AmmActionCard { }) readonly property var tokens: root.newPositionContext && root.newPositionContext.tokens ? root.newPositionContext.tokens : [] - readonly property var feeTiers: root.newPositionContext && root.newPositionContext.feeTiers - ? root.newPositionContext.feeTiers : [] + // Supported fee tiers as raw bps, injected from backend.feeTiers() (amm_core's + // SUPPORTED_FEE_TIERS). The selector's delegate wants { feeBps } rows, so wrap + // each int; labels are derived locally via feeLabel(). + property var feeTiers: [] + readonly property var feeTierModel: (root.feeTiers || []).map(function(bps) { + return { "feeBps": Number(bps) } + }) readonly property var tokenA: root.tokenById(root.selectedTokenAId) readonly property var tokenB: root.tokenById(root.selectedTokenBId) readonly property int decimalsA: 0 @@ -407,7 +412,7 @@ AmmActionCard { rowSpacing: 8 Repeater { - model: root.feeTiers + model: root.feeTierModel Item { id: feeTierOption diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index 536ddf1..c84fb7b 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -21,6 +21,11 @@ Item { // account selectors; refetched when the wallet opens. property var holdings: [] + // The AMM's supported fee tiers (backend.feeTiers()) feeding the fee selector. + // Program-derived and wallet-independent, so it's fetched once when the backend + // becomes available. + property var feeTiers: [] + function refreshHoldings() { if (!root.backend || root.runtime === null) return @@ -29,9 +34,17 @@ Item { function(err) { console.warn("tokenHoldings error:", err) }) } -onBackendChanged: root.refreshHoldings() - onRuntimeChanged: root.refreshHoldings() - Component.onCompleted: root.refreshHoldings() + function refreshFeeTiers() { + if (!root.backend || root.runtime === null || root.feeTiers.length > 0) + return + root.runtime.watch(root.backend.feeTiers(), + function(list) { root.feeTiers = list }, + function(err) { console.warn("feeTiers error:", err) }) + } + +onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers() } + onRuntimeChanged: { root.refreshHoldings(); root.refreshFeeTiers() } + Component.onCompleted: { root.refreshHoldings(); root.refreshFeeTiers() } Connections { target: root.backend @@ -237,6 +250,7 @@ onBackendChanged: root.refreshHoldings() : qsTr("Choose two tokens and a fee tier for this position.") showRefreshAction: false holdings: root.holdings + feeTiers: root.feeTiers newPositionContext: newPositionFlow.newPositionContext flowState: newPositionFlow.viewState diff --git a/apps/amm/qml/state/NewPositionFlow.qml b/apps/amm/qml/state/NewPositionFlow.qml index 5ceb24d..fccb056 100644 --- a/apps/amm/qml/state/NewPositionFlow.qml +++ b/apps/amm/qml/state/NewPositionFlow.qml @@ -466,8 +466,7 @@ QtObject { function loadingContext() { return { "status": "loading", - "tokens": [], - "feeTiers": [] + "tokens": [] } } diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 8ba7a13..465a405 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -315,6 +315,14 @@ QVariantList AmmUiBackend::poolList() return readPoolsConfig(); } +QVariantList AmmUiBackend::feeTiers() +{ + // Pure, input-free enumeration of the AMM's supported fee tiers (raw bps) — + // no wallet or module connection state involved. + return m_logos->amm_module.feeTiers(); +} + + QVariantMap AmmUiBackend::createPool(QVariantMap request) { // Same connected-state submit guard as the swaps — this app's lock is diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 03bfa65..fccd4b5 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -85,6 +85,8 @@ public slots: // Reads the known-pools list from AMM_POOLS_CONFIG (app config JSON, read // here rather than in the amm_module — pool discovery is an app detail). QVariantList poolList() override; + // The AMM's supported fee tiers (raw bps) for the fee selector. + QVariantList feeTiers() override; private: void syncWalletState(); diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index 09440fb..1527d2b 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -144,4 +144,10 @@ class AmmUiBackend // list if AMM_POOLS_CONFIG is unset/unreadable/invalid. This is app config, not // an on-chain read, so it lives in the backend rather than the amm_module. SLOT(QVariantList poolList()) + + // The AMM's supported fee tiers as raw basis points, ascending: [1, 5, 30, + // 100]. Input-free — sourced from amm_core's SUPPORTED_FEE_TIERS (the set the + // guest enforces), so the fee selector never hardcodes or drifts. The QML + // formats labels and decides selectability. + SLOT(QVariantList feeTiers()) } diff --git a/modules/amm/ffi/include/amm_ffi.h b/modules/amm/ffi/include/amm_ffi.h index 69e6738..9558b4d 100644 --- a/modules/amm/ffi/include/amm_ffi.h +++ b/modules/amm/ffi/include/amm_ffi.h @@ -54,6 +54,8 @@ char *amm_token_holdings(const char *request_json); char *amm_program_id(const char *request_json); +char *amm_fee_tiers(const char *request_json); + /** * Releases a string returned by an `amm_*` operation. * diff --git a/modules/amm/ffi/src/api/fee.rs b/modules/amm/ffi/src/api/fee.rs new file mode 100644 index 0000000..a5964da --- /dev/null +++ b/modules/amm/ffi/src/api/fee.rs @@ -0,0 +1,43 @@ +//! Supported fee tiers. The single source of truth is `amm_core::SUPPORTED_FEE_TIERS` — the guest +//! enforces the same set via `is_supported_fee_tier`, so exposing the list here keeps the UI from +//! drifting. Raw basis points only; the app formats labels and decides selectability. + +use amm_core::SUPPORTED_FEE_TIERS; +use serde_json::{json, Value}; + +use super::FeeTiersRequest; + +/// The AMM's supported fee tiers as raw basis points, ascending. Pure — no inputs. Wrapped in +/// `{ feeTiers: [...] }` so the op returns an object (the module unwraps it to a bare list). +pub(super) fn fee_tiers(_request: FeeTiersRequest) -> Result { + let tiers: Vec = SUPPORTED_FEE_TIERS + .iter() + .map(|&bps| u64::try_from(bps).map_err(|_| format!("fee tier {bps} overflows u64"))) + .collect::>()?; + Ok(json!({ "feeTiers": tiers })) +} + +#[cfg(test)] +mod tests { + use amm_core::is_supported_fee_tier; + + use super::*; + + #[test] + fn fee_tiers_are_amm_core_supported_and_ascending() { + let value = fee_tiers(FeeTiersRequest {}).unwrap(); + let tiers: Vec = value["feeTiers"] + .as_array() + .unwrap() + .iter() + .map(|tier| tier.as_u64().unwrap()) + .collect(); + assert_eq!(tiers, vec![1, 5, 30, 100]); + for tier in &tiers { + assert!( + is_supported_fee_tier(u128::from(*tier)), + "{tier} bps unsupported" + ); + } + } +} diff --git a/modules/amm/ffi/src/api/mod.rs b/modules/amm/ffi/src/api/mod.rs index 09ebf54..c923fec 100644 --- a/modules/amm/ffi/src/api/mod.rs +++ b/modules/amm/ffi/src/api/mod.rs @@ -2,6 +2,7 @@ mod config; mod context; +mod fee; mod holding; mod liquidity; mod pair; @@ -18,8 +19,8 @@ use std::{error::Error, fmt}; pub use request::{ AddLiquidityPlanRequest, AddLiquidityQuoteRequest, ConfigIdRequest, ContextRequest, - CreatePoolPlanRequest, CreatePoolQuoteRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, - RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest, ResolvePoolRequest, + CreatePoolPlanRequest, CreatePoolQuoteRequest, FeeTiersRequest, PairIdsRequest, PoolIdRequest, + ProgramIdRequest, RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, SyncReservesPlanRequest, TokenHoldingsRequest, TokenIdsRequest, @@ -156,3 +157,8 @@ pub fn token_holdings(request: TokenHoldingsRequest) -> AmmResult { pub fn program_id(request: ProgramIdRequest) -> AmmResult { swap::program_id(request).map_err(Into::into) } + +/// Lists the AMM's supported fee tiers (raw bps) from `amm_core` — no inputs. +pub fn fee_tiers(request: FeeTiersRequest) -> AmmResult { + fee::fee_tiers(request).map_err(Into::into) +} diff --git a/modules/amm/ffi/src/api/request.rs b/modules/amm/ffi/src/api/request.rs index a5e47e8..ad8c3a6 100644 --- a/modules/amm/ffi/src/api/request.rs +++ b/modules/amm/ffi/src/api/request.rs @@ -287,3 +287,9 @@ pub struct TokenHoldingsRequest { pub struct ProgramIdRequest { pub elf: String, } + +/// No inputs — `fee_tiers` enumerates `amm_core::SUPPORTED_FEE_TIERS`. An empty struct so the +/// op keeps the uniform `call::` request-decoding path (the module sends `{}`). +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct FeeTiersRequest {} diff --git a/modules/amm/ffi/src/ffi.rs b/modules/amm/ffi/src/ffi.rs index 908ea3a..bef9ed2 100644 --- a/modules/amm/ffi/src/ffi.rs +++ b/modules/amm/ffi/src/ffi.rs @@ -7,11 +7,11 @@ use serde::{de::DeserializeOwned, Serialize}; use crate::api::{ self, AddLiquidityPlanRequest, AddLiquidityQuoteRequest, AmmApiError, AmmResult, - ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, CreatePoolQuoteRequest, PairIdsRequest, - PoolIdRequest, ProgramIdRequest, RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest, - ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, - SwapExactOutQuoteRequest, SwapPairRequest, SyncReservesPlanRequest, TokenHoldingsRequest, - TokenIdsRequest, + ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, CreatePoolQuoteRequest, + FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, RemoveLiquidityPlanRequest, + RemoveLiquidityQuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest, + SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, + SyncReservesPlanRequest, TokenHoldingsRequest, TokenIdsRequest, }; #[derive(Serialize)] @@ -180,6 +180,11 @@ pub extern "C" fn amm_program_id(request_json: *const c_char) -> *mut c_char { call::(request_json, api::program_id) } +#[unsafe(no_mangle)] +pub extern "C" fn amm_fee_tiers(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::fee_tiers) +} + /// Releases a string returned by an `amm_*` operation. /// /// # Safety diff --git a/modules/amm/ffi/src/lib.rs b/modules/amm/ffi/src/lib.rs index 858e0e7..8d06b9e 100644 --- a/modules/amm/ffi/src/lib.rs +++ b/modules/amm/ffi/src/lib.rs @@ -6,11 +6,11 @@ mod ffi; pub mod api; pub use api::{ - config_id, context, create_pool_plan, create_pool_quote, pair_ids, pool_id, program_id, - resolve_pool, swap_exact_in_plan, swap_exact_in_quote, swap_exact_out_plan, + config_id, context, create_pool_plan, create_pool_quote, fee_tiers, pair_ids, pool_id, + program_id, resolve_pool, swap_exact_in_plan, swap_exact_in_quote, swap_exact_out_plan, swap_exact_out_quote, swap_pair, token_ids, AccountRead, AmmApiError, AmmResponse, AmmResult, - ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, CreatePoolQuoteRequest, PairIdsRequest, - PoolIdRequest, ProgramIdRequest, ResolvePoolRequest, SwapExactInPlanRequest, - SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, - TokenIdsRequest, WalletAccount, + ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, CreatePoolQuoteRequest, + FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, ResolvePoolRequest, + SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, + SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, WalletAccount, }; diff --git a/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp index 88d073d..ff592d1 100644 --- a/modules/amm/src/amm_module_impl.cpp +++ b/modules/amm/src/amm_module_impl.cpp @@ -1314,6 +1314,20 @@ LogosList AmmModuleImpl::tokenHoldings(bool wallet_open) { return out; } +LogosList AmmModuleImpl::feeTiers() { + // Pure enumeration of amm_core::SUPPORTED_FEE_TIERS — no program id, config, + // or wallet read needed. The FFI wraps the list as { feeTiers: [...] }. + const FfiResult result = call(amm_fee_tiers, json::object()); + if (!result.ok) + return LogosList::array(); + + LogosList out = LogosList::array(); + const auto it = result.value.find("feeTiers"); + if (it != result.value.end() && it->is_array()) + for (const auto& tier : *it) out.push_back(tier); + return out; +} + LogosMap AmmModuleImpl::newPositionContext(const LogosMap& request, bool wallet_open, bool refresh_wallet_accounts) { diff --git a/modules/amm/src/amm_module_impl.h b/modules/amm/src/amm_module_impl.h index 2ea4656..2067604 100644 --- a/modules/amm/src/amm_module_impl.h +++ b/modules/amm/src/amm_module_impl.h @@ -204,6 +204,13 @@ public: /// token_holdings.rs.) LogosList tokenHoldings(bool wallet_open); + /// Lists the AMM's supported fee tiers as raw basis points, ascending: + /// `[1, 5, 30, 100]`. Pure and input-free — the list is `amm_core`'s + /// `SUPPORTED_FEE_TIERS` (the same set the guest enforces), so the UI's fee + /// selector never hardcodes or drifts from the program. The app formats the + /// labels and decides selectability. + LogosList feeTiers(); + /// Reads the token list config at TOKENS_CONFIG (a JSON array of /// { symbol, name, definitionId, holding, decimals }) and returns it, /// normalizing definitionId/holding to lowercase hex. Empty list if diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index 6951535..ce25cb0 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -257,6 +257,17 @@ pub const FEE_TIER_BPS_5: u128 = 5; pub const FEE_TIER_BPS_30: u128 = 30; pub const FEE_TIER_BPS_100: u128 = 100; +/// The supported fee tiers (raw bps), ascending — the canonical list for off-chain callers +/// (the module/UI) to enumerate without hardcoding. The guest's check stays `is_supported_fee_tier` +/// below (its `match` is unchanged, so this addition does not affect the program ImageID — the +/// const is unused on-chain); `fee_tiers_list_matches_the_check` locks the two together. +pub const SUPPORTED_FEE_TIERS: [u128; 4] = [ + FEE_TIER_BPS_1, + FEE_TIER_BPS_5, + FEE_TIER_BPS_30, + FEE_TIER_BPS_100, +]; + pub fn is_supported_fee_tier(fees: u128) -> bool { matches!( fees, @@ -677,6 +688,26 @@ mod tests { /// `1.0` in Q64.64 is `2^64`. const ONE_Q64_64: u128 = 1u128 << 64; + #[test] + fn fee_tiers_list_matches_the_check() { + // The enumerated list must agree with the guest's `is_supported_fee_tier` match: every + // listed tier is accepted, and nothing adjacent to them is (guards a drifted list). + for tier in SUPPORTED_FEE_TIERS { + assert!( + is_supported_fee_tier(tier), + "{tier} listed but not accepted" + ); + } + for probe in [0u128, 2, 4, 6, 29, 31, 99, 101, 10_000] { + assert!( + !is_supported_fee_tier(probe), + "{probe} accepted but not listed" + ); + } + // Ascending and de-duplicated. + assert!(SUPPORTED_FEE_TIERS.windows(2).all(|w| w[0] < w[1])); + } + #[test] fn equal_reserves_map_to_unit_price() { assert_eq!(spot_price_q64_64(1_000, 1_000), ONE_Q64_64);