feat(modules/amm): add tokenHoldings — list the wallet's token holdings

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.
This commit is contained in:
r4bbit
2026-08-11 12:04:48 +02:00
parent e0ae3208a1
commit 2a3be1278a
10 changed files with 214 additions and 2 deletions
+7
View File
@@ -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
+2
View File
@@ -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();
+5
View File
@@ -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())
}
+2
View File
@@ -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);
/**
+7 -1
View File
@@ -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)
+11
View File
@@ -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<AccountRead>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProgramIdRequest {
+132
View File
@@ -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<Value, String> {
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::<Vec<_>>();
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());
}
}
+7 -1
View File
@@ -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::<CreatePoolPlanRequest>(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::<TokenHoldingsRequest>(request_json, api::token_holdings)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_program_id(request_json: *const c_char) -> *mut c_char {
call::<ProgramIdRequest>(request_json, api::program_id)
+31
View File
@@ -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,
+10
View File
@@ -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