mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
feat(amm): source liquidity tokens app-side + add custom tokens by id
Move the liquidity token selector off the module's stateful newPositionContext
onto a lean, app-owned surface, and let users add unlisted tokens by id.
FFI: new stateless `resolve_tokens` op — the app passes an explicit id set and
gets uniform selector rows `{ definitionId (base58), name, totalSupply, holdingId,
balance }`, held tokens first, unresolvable/non-fungible ids omitted. Reuses the
per-token definition/holding logic from `context`, without the network/status
envelope. Unit-tested.
Module: `resolveTokens(request, wallet_open)` reads the definitions + wallet and
calls the op (ids wrapped in a map — the universal-module glue only marshals
map/scalar inputs, not bare lists).
Backend: the app owns the id set — configured tokens (TOKENS_CONFIG) plus the
user's persisted custom ids. Held-but-unlisted tokens are NOT auto-listed (the
list mirrors the swap side); a token you hold still shows its balance once listed.
`addCustomToken` validates a pasted id by resolving its on-chain definition, then
persists it to CUSTOM_TOKEN_CONFIG (defaulting to the per-user app-data store, with
a HOME fallback so persistence never silently no-ops on an empty path).
QML: NewPositionForm/LiquidityPage take tokens/walletReady/loadingTokens as inputs
and drive selection + custom-token resolution through the backend; dropped all
newPositionContext reads and the selectable/status/code row fields.
Tests: custom-token.mjs creates token D on-chain (left out of the token config)
and verifies pasting its id resolves, selects, and persists it across a reload.
The setup script mints token D and initializes/prints the isolated
CUSTOM_TOKEN_CONFIG store
This commit is contained in:
@@ -22,6 +22,8 @@ char *amm_pair_ids(const char *request_json);
|
||||
|
||||
char *amm_context(const char *request_json);
|
||||
|
||||
char *amm_resolve_tokens(const char *request_json);
|
||||
|
||||
char *amm_swap_pair(const char *request_json);
|
||||
|
||||
char *amm_resolve_pool(const char *request_json);
|
||||
|
||||
@@ -11,7 +11,7 @@ use super::{
|
||||
config::load_config,
|
||||
holding::{select_holding, wallet_holdings, SelectedHolding},
|
||||
quote_error::issue,
|
||||
ContextRequest, TokenIdsRequest,
|
||||
ContextRequest, ResolveTokensRequest, TokenIdsRequest,
|
||||
};
|
||||
use crate::account::{
|
||||
account_id_from_hex, account_id_hex, decode_account, parse_base58_id, parse_program_id,
|
||||
@@ -133,6 +133,74 @@ pub(super) fn context(request: ContextRequest) -> Result<Value, String> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Resolves an explicit, app-provided set of token ids into selector rows — the lean,
|
||||
/// stateless successor to `context`. The app owns the id set (its configured tokens plus any
|
||||
/// custom/pasted ids it remembers), so there is no network envelope and no process-cached wallet
|
||||
/// state here: the module reads the definitions + wallet fresh and passes them in, exactly like
|
||||
/// `context` did per token.
|
||||
///
|
||||
/// `token_ids` are hex (the module normalizes base58→hex at the boundary); `token_definitions`
|
||||
/// are the corresponding read accounts, keyed by hex id. Every returned row has the same shape —
|
||||
/// `{ definitionId (base58), name, totalSupply, holdingId, balance }` — so the app never branches
|
||||
/// per row; when the wallet doesn't hold the token, `holdingId` is `""` and `balance` is `"0"`.
|
||||
/// A requested id whose definition is unreadable or non-fungible is omitted; the app treats a
|
||||
/// requested id with no returned row as unresolved/unavailable.
|
||||
pub(super) fn resolve_tokens(request: ResolveTokensRequest) -> Result<Value, String> {
|
||||
let amm_program = parse_program_id(&request.amm_program_id)?;
|
||||
let Ok(config) = load_config(amm_program, &request.config) else {
|
||||
return Ok(json!({ "status": "error", "code": "config_unavailable", "tokens": [] }));
|
||||
};
|
||||
|
||||
let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id);
|
||||
|
||||
// De-duplicate the requested ids, dropping any malformed ones. Order is irrelevant —
|
||||
// the rows are sorted below (held first, then by id) like `context`.
|
||||
let mut token_ids = BTreeSet::new();
|
||||
for id in &request.token_ids {
|
||||
if let Ok(id) = account_id_from_hex(id, "token id") {
|
||||
token_ids.insert(id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut rows = Vec::new();
|
||||
for token_id in token_ids {
|
||||
let read = request
|
||||
.token_definitions
|
||||
.iter()
|
||||
.find(|read| account_id_from_hex(&read.id, "token definition id") == Ok(token_id));
|
||||
// Only readable, fungible definitions become rows; anything else is omitted.
|
||||
let Ok((name, total_supply, _metadata_id)) =
|
||||
fungible_definition(read, token_id, config.token_program_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Uniform shape — every row carries holdingId/balance so the app never branches per row.
|
||||
// A token the wallet doesn't hold gets an empty id and "0" balance.
|
||||
let selected = select_holding(&holdings, token_id);
|
||||
rows.push(json!({
|
||||
"definitionId": token_id.to_string(),
|
||||
"name": name,
|
||||
"totalSupply": total_supply.to_string(),
|
||||
"holdingId": selected.as_ref().map(|holding| holding.id.to_string()).unwrap_or_default(),
|
||||
"balance": selected
|
||||
.as_ref()
|
||||
.map_or_else(|| String::from("0"), |holding| holding.balance.to_string()),
|
||||
}));
|
||||
}
|
||||
|
||||
rows.sort_by(|left, right| {
|
||||
let held = |row: &Value| !row["holdingId"].as_str().unwrap_or_default().is_empty();
|
||||
held(right).cmp(&held(left)).then_with(|| {
|
||||
left["definitionId"]
|
||||
.as_str()
|
||||
.cmp(&right["definitionId"].as_str())
|
||||
})
|
||||
});
|
||||
|
||||
Ok(json!({ "status": "ok", "tokens": rows }))
|
||||
}
|
||||
|
||||
fn context_error(request: &ContextRequest, code: &str) -> Value {
|
||||
json!({
|
||||
"status": "error",
|
||||
|
||||
@@ -21,7 +21,7 @@ pub use request::{
|
||||
AddLiquidityPlanRequest, AddLiquidityQuoteRequest, ConfigIdRequest, ContextRequest,
|
||||
CreatePoolPlanRequest, CreatePoolQuoteRequest, FeeTiersRequest, PairIdsRequest, PoolIdRequest,
|
||||
ProgramIdRequest, RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest, ResolvePoolRequest,
|
||||
SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
|
||||
ResolveTokensRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
|
||||
SwapExactOutQuoteRequest, SwapPairRequest, SyncReservesPlanRequest, TokenHoldingsRequest,
|
||||
TokenIdsRequest,
|
||||
};
|
||||
@@ -83,6 +83,11 @@ pub fn context(request: ContextRequest) -> AmmResult {
|
||||
context::context(request).map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Resolves an app-provided set of token ids into selector rows (lean successor to `context`).
|
||||
pub fn resolve_tokens(request: ResolveTokensRequest) -> AmmResult {
|
||||
context::resolve_tokens(request).map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Derives the canonical account ids for a swap pair (tokens in either order).
|
||||
pub fn swap_pair(request: SwapPairRequest) -> AmmResult {
|
||||
swap::swap_pair(request).map_err(Into::into)
|
||||
|
||||
@@ -43,6 +43,23 @@ pub struct ContextRequest {
|
||||
pub resolved_token_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Resolves an app-provided set of token ids into selector rows (the lean successor to
|
||||
/// `ContextRequest`). `token_ids` are hex — the module normalizes base58→hex and reads each
|
||||
/// definition into `token_definitions` (keyed by hex id) plus the wallet accounts; the FFI is
|
||||
/// stateless and reads nothing itself.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolveTokensRequest {
|
||||
pub amm_program_id: String,
|
||||
pub config: AccountRead,
|
||||
#[serde(default)]
|
||||
pub token_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub wallet_accounts: Vec<AccountRead>,
|
||||
#[serde(default)]
|
||||
pub token_definitions: Vec<AccountRead>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PairIdsRequest {
|
||||
|
||||
@@ -14,13 +14,13 @@ use token_core::{TokenDefinition, TokenHolding};
|
||||
use twap_oracle_core::compute_current_tick_account_pda;
|
||||
|
||||
use super::{
|
||||
context::{context, token_ids},
|
||||
context::{context, resolve_tokens, token_ids},
|
||||
holding::{select_holding, SelectedHolding},
|
||||
pair::{is_canonical_pair, pair_ids, PairIds},
|
||||
quote::{div_ceil_u256, minimum_opening_pair, Q64},
|
||||
swap::{swap_exact_in_plan, swap_exact_out_plan},
|
||||
ContextRequest, PairIdsRequest, SwapExactInPlanRequest, SwapExactOutPlanRequest,
|
||||
TokenIdsRequest,
|
||||
ContextRequest, PairIdsRequest, ResolveTokensRequest, SwapExactInPlanRequest,
|
||||
SwapExactOutPlanRequest, TokenIdsRequest,
|
||||
};
|
||||
use crate::{
|
||||
account::{account_id_hex, account_read, decode_account, program_id_bytes},
|
||||
@@ -291,6 +291,55 @@ fn context_selects_tokens_without_holdings() {
|
||||
assert!(value["tokens"][0].get("holdingId").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_tokens_returns_lean_rows_held_first_and_omits_unresolvable() {
|
||||
let held = AccountId::new([2; 32]);
|
||||
let listed = AccountId::new([5; 32]);
|
||||
let missing = AccountId::new([9; 32]); // requested but no definition read supplied
|
||||
let config_id = compute_config_pda(AMM_PROGRAM);
|
||||
|
||||
let value = resolve_tokens(ResolveTokensRequest {
|
||||
amm_program_id: amm_program_id(),
|
||||
config: account_read(config_id, &config_account()),
|
||||
token_ids: vec![
|
||||
account_id_hex(held),
|
||||
account_id_hex(listed),
|
||||
account_id_hex(missing),
|
||||
],
|
||||
wallet_accounts: vec![account_read(
|
||||
AccountId::new([6; 32]),
|
||||
&token_holding(held, 42),
|
||||
)],
|
||||
token_definitions: vec![
|
||||
account_read(held, &token_definition("Held", 1_000)),
|
||||
account_read(listed, &token_definition("Listed", 2_000)),
|
||||
],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Held token sorts first; the requested id with no readable definition is omitted. Every row
|
||||
// carries the same fields — the non-held token gets an empty holdingId and "0" balance.
|
||||
assert_eq!(
|
||||
value["tokens"],
|
||||
json!([
|
||||
{
|
||||
"definitionId": held.to_string(),
|
||||
"name": "Held",
|
||||
"totalSupply": "1000",
|
||||
"holdingId": AccountId::new([6; 32]).to_string(),
|
||||
"balance": "42",
|
||||
},
|
||||
{
|
||||
"definitionId": listed.to_string(),
|
||||
"name": "Listed",
|
||||
"totalSupply": "2000",
|
||||
"holdingId": "",
|
||||
"balance": "0",
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_pool_snapshot_defaults_remain_real_accounts() {
|
||||
let id = AccountId::new([5; 32]);
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::api::{
|
||||
self, AddLiquidityPlanRequest, AddLiquidityQuoteRequest, AmmApiError, AmmResult,
|
||||
ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, CreatePoolQuoteRequest,
|
||||
FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, RemoveLiquidityPlanRequest,
|
||||
RemoveLiquidityQuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest,
|
||||
RemoveLiquidityQuoteRequest, ResolvePoolRequest, ResolveTokensRequest, SwapExactInPlanRequest,
|
||||
SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest,
|
||||
SyncReservesPlanRequest, TokenHoldingsRequest, TokenIdsRequest,
|
||||
};
|
||||
@@ -100,6 +100,11 @@ pub extern "C" fn amm_context(request_json: *const c_char) -> *mut c_char {
|
||||
call::<ContextRequest>(request_json, api::context)
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn amm_resolve_tokens(request_json: *const c_char) -> *mut c_char {
|
||||
call::<ResolveTokensRequest>(request_json, api::resolve_tokens)
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn amm_swap_pair(request_json: *const c_char) -> *mut c_char {
|
||||
call::<SwapPairRequest>(request_json, api::swap_pair)
|
||||
|
||||
@@ -7,10 +7,11 @@ pub mod api;
|
||||
|
||||
pub use api::{
|
||||
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,
|
||||
FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, ResolvePoolRequest,
|
||||
SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
|
||||
SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, WalletAccount,
|
||||
program_id, resolve_pool, resolve_tokens, 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, FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest,
|
||||
ResolvePoolRequest, ResolveTokensRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest,
|
||||
SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest,
|
||||
WalletAccount,
|
||||
};
|
||||
|
||||
@@ -1328,6 +1328,62 @@ LogosList AmmModuleImpl::feeTiers() {
|
||||
return out;
|
||||
}
|
||||
|
||||
LogosList AmmModuleImpl::resolveTokens(const LogosMap& request, 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 the FFI needs to decode definitions/holdings.
|
||||
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"));
|
||||
|
||||
// Normalize the app-provided ids (base58 or hex) → hex, de-dup, and read each
|
||||
// definition account. The FFI is stateless, so it gets the reads pre-fetched.
|
||||
const auto token_ids_it = request.find("tokenIds");
|
||||
const json token_ids = (token_ids_it != request.end() && token_ids_it->is_array())
|
||||
? *token_ids_it
|
||||
: json::array();
|
||||
|
||||
std::vector<std::string> ids_vec;
|
||||
ids_vec.reserve(token_ids.size());
|
||||
for (const auto& raw : token_ids) {
|
||||
if (!raw.is_string()) continue;
|
||||
const std::string hex = normalizeAccountId(raw.get<std::string>());
|
||||
if (!hex.empty()) ids_vec.push_back(hex);
|
||||
}
|
||||
std::sort(ids_vec.begin(), ids_vec.end());
|
||||
ids_vec.erase(std::unique(ids_vec.begin(), ids_vec.end()), ids_vec.end());
|
||||
|
||||
json ids = json::array();
|
||||
json definitions = json::array();
|
||||
for (const auto& hex : ids_vec) {
|
||||
ids.push_back(hex);
|
||||
definitions.push_back(readPublicAccount(hex));
|
||||
}
|
||||
|
||||
// Fresh wallet read — the selector wants current holdings/balances.
|
||||
const json wallet_accounts = walletAccountReads(wallet_open, /*refresh=*/true);
|
||||
|
||||
const FfiResult result = call(amm_resolve_tokens, json{
|
||||
{"ammProgramId", amm_program_id},
|
||||
{"config", config},
|
||||
{"tokenIds", ids},
|
||||
{"walletAccounts", wallet_accounts},
|
||||
{"tokenDefinitions", definitions},
|
||||
});
|
||||
if (!result.ok)
|
||||
return LogosList::array();
|
||||
|
||||
LogosList out = LogosList::array();
|
||||
const auto it = result.value.find("tokens");
|
||||
if (it != result.value.end() && it->is_array())
|
||||
for (const auto& row : *it) out.push_back(row);
|
||||
return out;
|
||||
}
|
||||
|
||||
LogosMap AmmModuleImpl::newPositionContext(const LogosMap& request,
|
||||
bool wallet_open,
|
||||
bool refresh_wallet_accounts) {
|
||||
|
||||
@@ -217,6 +217,20 @@ public:
|
||||
/// TOKENS_CONFIG is unset / unreadable / not a JSON array.
|
||||
LogosList tokenList();
|
||||
|
||||
/// Resolves an app-provided set of token ids into liquidity selector rows.
|
||||
/// `request` carries `{ tokenIds: [<definition id>, …] }` (base58 or hex,
|
||||
/// normalized to hex here) — the app owns the set: its configured tokens plus any
|
||||
/// custom/pasted ids it remembers (held-but-unlisted tokens are not auto-added by
|
||||
/// the app). Reads each definition and (when `wallet_open`) the wallet, then returns
|
||||
/// `[{ definitionId (base58), name, totalSupply, holdingId, balance }]`. Every
|
||||
/// row has the same fields — a token the wallet doesn't hold gets `holdingId:""`
|
||||
/// and `balance:"0"` — held tokens first. A requested id whose definition is
|
||||
/// unreadable / non-fungible is omitted (the app treats a missing row as
|
||||
/// unresolved). Empty list if AMM_PROGRAM_BIN is unset or the config read fails.
|
||||
/// (`tokenIds` is wrapped in a map, not passed as a bare list, because the
|
||||
/// universal-module glue only supports map/scalar inputs.)
|
||||
LogosList resolveTokens(const LogosMap& request, bool wallet_open);
|
||||
|
||||
/// New-position (add-liquidity) view state: reads the AMM config + the
|
||||
/// user's wallet accounts and returns the new-position context map the
|
||||
/// UI renders (available tokens, fee tiers, warnings). `wallet_open` gates
|
||||
|
||||
Reference in New Issue
Block a user