From 2a3be1278aea89d6167c44baf4114e11177fea97 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:44:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(modules/amm):=20add=20tokenHoldings=20?= =?UTF-8?q?=E2=80=94=20list=20the=20wallet's=20token=20holdings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thin source for the account selector: an amm_ffi op that decodes the wallet's fungible TokenHoldings (owned by the configured token program) into [{ accountId (hex), accountType:\"TokenHolding\", definitionId (base58), definitionIdHex (hex), balanceRaw }] — one row per holding account, every token, including zero-balance holdings; narrowing to a specific token is the selector's job. Exposed via AmmModuleImpl::tokenHoldings and the AmmUiBackend tokenHoldings() slot, both gated on wallet-open. --- apps/amm/src/AmmUiBackend.cpp | 7 ++ apps/amm/src/AmmUiBackend.h | 2 + apps/amm/src/AmmUiBackend.rep | 5 + modules/amm/ffi/include/amm_ffi.h | 2 + modules/amm/ffi/src/api/mod.rs | 8 +- modules/amm/ffi/src/api/request.rs | 11 ++ modules/amm/ffi/src/api/token_holdings.rs | 132 ++++++++++++++++++++++ modules/amm/ffi/src/ffi.rs | 8 +- modules/amm/src/amm_module_impl.cpp | 31 +++++ modules/amm/src/amm_module_impl.h | 10 ++ 10 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 modules/amm/ffi/src/api/token_holdings.rs diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 328553a..bc6ea04 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -279,6 +279,13 @@ QVariantMap AmmUiBackend::liquidityQuote(QVariantMap request) return m_logos->amm_module.liquidityQuote(request); } +QVariantList AmmUiBackend::tokenHoldings() +{ + // Read-only list of the wallet's token holdings for the account selector. Gated + // by this app's wallet-open state (a closed wallet has nothing to list). + return m_logos->amm_module.tokenHoldings(isWalletOpen()); +} + 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 196a2a4..705daaa 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -77,6 +77,8 @@ public slots: // accounts here. QVariantMap liquidityQuote(QVariantMap request) override; QVariantMap createPool(QVariantMap request) override; + // Lists the wallet's fungible token holdings for the account selector. + QVariantList tokenHoldings() override; private: void syncWalletState(); diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index b270256..b47c3c7 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -114,4 +114,9 @@ class AmmUiBackend // invalid_account_id, bad_amount, bad_fee_bps_amount, invalid_fee_tier, // wallet_submission_failed, backend_error). SLOT(QVariantMap createPool(QVariantMap request)) + // Lists the connected wallet's fungible token holdings for the account + // selector: [{ accountId, accountType:"TokenHolding", definitionId, + // definitionIdHex, balanceRaw }] — one row per holding account, every token, + // including zero-balance holdings. The selector narrows to a token by id. + SLOT(QVariantList tokenHoldings()) } diff --git a/modules/amm/ffi/include/amm_ffi.h b/modules/amm/ffi/include/amm_ffi.h index 3cd866c..0a06723 100644 --- a/modules/amm/ffi/include/amm_ffi.h +++ b/modules/amm/ffi/include/amm_ffi.h @@ -44,6 +44,8 @@ char *amm_liquidity_quote(const char *request_json); char *amm_create_pool_plan(const char *request_json); +char *amm_token_holdings(const char *request_json); + char *amm_program_id(const char *request_json); /** diff --git a/modules/amm/ffi/src/api/mod.rs b/modules/amm/ffi/src/api/mod.rs index 7ed1e89..104b3a7 100644 --- a/modules/amm/ffi/src/api/mod.rs +++ b/modules/amm/ffi/src/api/mod.rs @@ -15,6 +15,7 @@ mod quote; mod quote_error; mod request; mod swap; +mod token_holdings; #[cfg(test)] mod tests; @@ -25,7 +26,7 @@ pub use request::{ ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest, PairSnapshot, PlanRequest, PoolIdRequest, PositionRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, - SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, + SwapExactOutQuoteRequest, SwapPairRequest, TokenHoldingsRequest, TokenIdsRequest, }; use serde_json::Value; @@ -140,6 +141,11 @@ pub fn create_pool_plan(request: CreatePoolPlanRequest) -> AmmResult { liquidity::create_pool_plan(request).map_err(Into::into) } +/// Lists the wallet's fungible token holdings for the account selector. +pub fn token_holdings(request: TokenHoldingsRequest) -> AmmResult { + token_holdings::token_holdings(request).map_err(Into::into) +} + /// Derives the AMM `ProgramId` (Image ID) from a deployed program binary. pub fn program_id(request: ProgramIdRequest) -> AmmResult { swap::program_id(request).map_err(Into::into) diff --git a/modules/amm/ffi/src/api/request.rs b/modules/amm/ffi/src/api/request.rs index 104a4d6..bb4f24f 100644 --- a/modules/amm/ffi/src/api/request.rs +++ b/modules/amm/ffi/src/api/request.rs @@ -168,6 +168,17 @@ pub struct CreatePoolPlanRequest { pub user_holding_lp_id: String, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TokenHoldingsRequest { + pub amm_program_id: String, + /// AMM config account read — decoded for the `token_program_id` that identifies + /// which wallet accounts are token holdings. + pub config: AccountRead, + #[serde(default)] + pub wallet_accounts: Vec, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProgramIdRequest { diff --git a/modules/amm/ffi/src/api/token_holdings.rs b/modules/amm/ffi/src/api/token_holdings.rs new file mode 100644 index 0000000..7127b93 --- /dev/null +++ b/modules/amm/ffi/src/api/token_holdings.rs @@ -0,0 +1,132 @@ +//! Lists the wallet's fungible token holdings for the account selector. +//! +//! A flat, decoded view of every `TokenHolding` the wallet owns (owned by the +//! configured token program), each row carrying the id in **both** encodings so +//! the swap view (hex ids) and the liquidity view (base58 ids) can each filter by +//! their own token id via the selector's `stateField`. Pure over the wallet reads + +//! config — no chain access of its own. +//! +//! Thin stopgap: token holdings are wallet/token-program data, not AMM data. This +//! lives here only because `amm_ffi` is the one place wired to decode `TokenHolding` +//! (via `token_core`); it should move to a dedicated token-program logos module once +//! one exists. + +use serde_json::{json, Value}; + +use super::{config::load_config, holding::wallet_holdings, TokenHoldingsRequest}; +use crate::account::{account_id_hex, parse_program_id}; + +pub(super) fn token_holdings(request: TokenHoldingsRequest) -> Result { + let amm_program = parse_program_id(&request.amm_program_id)?; + let config = load_config(amm_program, &request.config)?; + let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id); + let rows = holdings + .into_iter() + .map(|holding| { + json!({ + "accountId": account_id_hex(holding.id), + "accountType": "TokenHolding", + // Both encodings: the swap view filters on definitionIdHex, the + // liquidity view on the base58 definitionId. + "definitionId": holding.definition_id.to_string(), + "definitionIdHex": account_id_hex(holding.definition_id), + "balanceRaw": holding.balance.to_string(), + }) + }) + .collect::>(); + Ok(json!({ "holdings": rows })) +} + +#[cfg(test)] +mod tests { + use amm_core::{compute_config_pda, AmmConfig}; + use nssa_core::{ + account::{Account, AccountId, Data}, + program::ProgramId, + }; + use token_core::TokenHolding; + + use super::*; + use crate::account::{account_read, AccountRead}; + + fn token_program() -> ProgramId { + parse_program_id(&"01".repeat(32)).unwrap() + } + + fn holding_read(id: AccountId, definition: AccountId, balance: u128) -> AccountRead { + let account = Account { + program_owner: token_program(), + data: (&TokenHolding::Fungible { + definition_id: definition, + balance, + }) + .into(), + ..Account::default() + }; + account_read(id, &account) + } + + fn config_read(amm: ProgramId) -> AccountRead { + let account = Account { + program_owner: amm, + data: Data::from(&AmmConfig { + token_program_id: token_program(), + twap_oracle_program_id: parse_program_id(&"02".repeat(32)).unwrap(), + authority: AccountId::new([0x09; 32]), + }), + ..Account::default() + }; + account_read(compute_config_pda(amm), &account) + } + + #[test] + fn lists_wallet_token_holdings_with_both_id_encodings() { + let amm = parse_program_id(&"00".repeat(32)).unwrap(); + let def = AccountId::new([0xAA; 32]); + let holding_id = AccountId::new([0x01; 32]); + + let value = token_holdings(TokenHoldingsRequest { + amm_program_id: "00".repeat(32), + config: config_read(amm), + wallet_accounts: vec![holding_read(holding_id, def, 500)], + }) + .unwrap(); + + let rows = value["holdings"].as_array().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["accountId"], account_id_hex(holding_id)); + assert_eq!(rows[0]["accountType"], "TokenHolding"); + assert_eq!(rows[0]["definitionId"], def.to_string()); + assert_eq!(rows[0]["definitionIdHex"], account_id_hex(def)); + assert_eq!(rows[0]["balanceRaw"], "500"); + } + + #[test] + fn skips_non_token_accounts_and_fails_closed_on_bad_config() { + let amm = parse_program_id(&"00".repeat(32)).unwrap(); + // A non-token-program account is not a holding. + let foreign = Account { + program_owner: parse_program_id(&"ee".repeat(32)).unwrap(), + ..Account::default() + }; + let value = token_holdings(TokenHoldingsRequest { + amm_program_id: "00".repeat(32), + config: config_read(amm), + wallet_accounts: vec![account_read(AccountId::new([0x02; 32]), &foreign)], + }) + .unwrap(); + assert!(value["holdings"].as_array().unwrap().is_empty()); + + // A read-failed config fails closed as Err. + assert!(token_holdings(TokenHoldingsRequest { + amm_program_id: "00".repeat(32), + config: AccountRead { + id: String::new(), + status: String::from("read_failed"), + account: None, + }, + wallet_accounts: vec![], + }) + .is_err()); + } +} diff --git a/modules/amm/ffi/src/ffi.rs b/modules/amm/ffi/src/ffi.rs index 140c835..ee6c9d9 100644 --- a/modules/amm/ffi/src/ffi.rs +++ b/modules/amm/ffi/src/ffi.rs @@ -9,7 +9,8 @@ use crate::api::{ self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest, PlanRequest, PoolIdRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, - SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, + SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, TokenHoldingsRequest, + TokenIdsRequest, }; #[derive(Serialize)] @@ -153,6 +154,11 @@ pub extern "C" fn amm_create_pool_plan(request_json: *const c_char) -> *mut c_ch call::(request_json, api::create_pool_plan) } +#[unsafe(no_mangle)] +pub extern "C" fn amm_token_holdings(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::token_holdings) +} + #[unsafe(no_mangle)] pub extern "C" fn amm_program_id(request_json: *const c_char) -> *mut c_char { call::(request_json, api::program_id) diff --git a/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp index d18a646..f109dec 100644 --- a/modules/amm/src/amm_module_impl.cpp +++ b/modules/amm/src/amm_module_impl.cpp @@ -919,6 +919,37 @@ LogosList AmmModuleImpl::tokenList() { return out; } +LogosList AmmModuleImpl::tokenHoldings(bool wallet_open) { + const std::string amm_program_id = ammProgramId(); + if (amm_program_id.empty()) + return LogosList::array(); + + // The config gives the token_program_id that identifies which wallet accounts + // are token holdings (decoded by the FFI op). + const FfiResult configResult = + call(amm_config_id, json{{"ammProgramId", amm_program_id}}); + if (!configResult.ok) + return LogosList::array(); + const json config = readPublicAccount(jStr(configResult.value, "configId")); + + // Fresh wallet read each call — the selector wants current holdings/balances. + const json wallet_accounts = walletAccountReads(wallet_open, /*refresh=*/true); + + const FfiResult result = call(amm_token_holdings, json{ + {"ammProgramId", amm_program_id}, + {"config", config}, + {"walletAccounts", wallet_accounts}, + }); + if (!result.ok) + return LogosList::array(); + + LogosList out = LogosList::array(); + const auto it = result.value.find("holdings"); + if (it != result.value.end() && it->is_array()) + for (const auto& holding : *it) out.push_back(holding); + return out; +} + nlohmann::json AmmModuleImpl::buildQuoteInput(const LogosMap& request, const Network& net, bool wallet_open, diff --git a/modules/amm/src/amm_module_impl.h b/modules/amm/src/amm_module_impl.h index 2ef461d..72b32a9 100644 --- a/modules/amm/src/amm_module_impl.h +++ b/modules/amm/src/amm_module_impl.h @@ -134,6 +134,16 @@ public: /// a code so the create-pool UI can tell the user why. LogosMap createPool(const LogosMap& request); + /// Lists the connected wallet's fungible token holdings for the account + /// selector: `[{ accountId (hex), accountType:"TokenHolding", definitionId + /// (base58), definitionIdHex (hex), balanceRaw }]` — one row per holding + /// account, every token, including zero-balance holdings. Narrowing to a + /// specific token is the selector's job. `wallet_open` gates the wallet read; + /// an empty list on a closed wallet, unset AMM_PROGRAM_BIN, or a decode failure. + /// (Thin stopgap — token-holding listing is wallet/token data; see + /// token_holdings.rs.) + LogosList tokenHoldings(bool wallet_open); + /// 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