refactor(amm): enrich resolvePool into resolvePoolAccount

Return the pool's full derived state from one read instead of just existence +
reserves, so callers get the derived accounts (for future account views / oracle
setup) without re-deriving.

FFI resolve_pool: drop the `exists` boolean — the presence of data is the signal.
An existing pool returns { status:"ok", ..., poolId, defAHex, defBHex, vaultAId,
vaultBId, lpDefinitionId, reserveA, reserveB, liquiditySupply, feeBps }; a missing /
uninitialized pool is the { status:"error", error:"no_pool", poolId } error (still
carrying the derived poolId for address derivation).

Module: resolvePool -> resolvePoolAccount — { status:"error", error } envelope for
hard failures, and orient reserves + defs + vaults to the caller's requested order.

Backend + QML: rename the slot; SwapCard and NewPositionFlow switch the existence
check from pool.exists to pool.status === "ok" (reserve field names unchanged, so
no other consumer edits). no_pool still routes to create-pool; hard errors still
surface.
This commit is contained in:
r4bbit
2026-08-13 16:55:45 +02:00
parent cbb75c38fd
commit 92f55652a8
9 changed files with 102 additions and 66 deletions
+3 -3
View File
@@ -30,7 +30,7 @@ Rectangle {
property string editingSide: "sell"
property real slippageTolerancePercent: 0.5
// ── Pool resolution (backend.resolvePool) ───────────────────────────────
// ── Pool resolution (backend.resolvePoolAccount) ───────────────────────────────
// Existence and fee drive the UI; the swap quotes read the pool and
// price/orient the swap server-side, so the client no longer prices against
// the reserves. The raw reserves are still surfaced (observability only, not
@@ -128,13 +128,13 @@ Rectangle {
}
root.poolLoading = true
logos.watch(root.backend.resolvePool(reqSell, reqBuy),
logos.watch(root.backend.resolvePoolAccount(reqSell, reqBuy),
function (pool) {
if (isStale())
return
root.poolLoading = false
root.poolResolved = true
root.poolExists = !!(pool && pool.exists)
root.poolExists = !!(pool && pool.status === "ok")
root.poolReserveA = (pool && pool.reserveA) || "0"
root.poolReserveB = (pool && pool.reserveB) || "0"
// feeBps === 0 is a legitimate zero-fee pool; only fall back
+9 -9
View File
@@ -84,22 +84,22 @@ QtObject {
return
}
// Route on pool existence (read the pool account), like the swap card. resolvePool
// returns the reserves oriented to our requested token order (reserveA is tokenAId's).
root.runtime.watch(root.backend.resolvePool(built.request.tokenAId, built.request.tokenBId),
// Route on pool existence (read the pool account), like the swap card.
// resolvePoolAccount returns status:"ok" with reserves oriented to our requested token
// order (reserveA is tokenAId's), or status:"error" (no_pool / hard failure).
root.runtime.watch(root.backend.resolvePoolAccount(built.request.tokenAId, built.request.tokenBId),
function(pool) {
if (serial !== root.quoteSerial)
return
if (pool && pool.exists) {
if (pool && pool.status === "ok") {
root.poolExists = true
root.requestAddQuote(serial, built, pool)
return
}
// resolvePool returns exists:false for BOTH the normal "no pool yet" case and
// hard failures (no_program_bin, amm_not_initialized, bad_config). Only the
// former — an empty error or no_pool — is a create-pool signal; surface any other
// pool.error as a quote error instead of masking it as a create quote (which
// would hide the backend failure and enable the wrong flow).
// A status:"error" result is EITHER the normal "no pool yet" case (error
// "no_pool") or a hard failure (no_program_bin, amm_not_initialized, bad_config).
// Only the former is a create-pool signal; surface any other pool.error as a quote
// error instead of masking it as a create quote (which would enable the wrong flow).
var poolError = pool ? String(pool.error || "") : ""
if (poolError.length === 0 || poolError === "no_pool") {
root.poolExists = false
+2 -2
View File
@@ -177,9 +177,9 @@ void AmmUiBackend::syncWalletState()
setSequencerReachable(state.sequencerReachable);
}
QVariantMap AmmUiBackend::resolvePool(QString defAHex, QString defBHex)
QVariantMap AmmUiBackend::resolvePoolAccount(QString defAHex, QString defBHex)
{
return m_logos->amm_module.resolvePool(defAHex, defBHex);
return m_logos->amm_module.resolvePoolAccount(defAHex, defBHex);
}
QString AmmUiBackend::swapExactInput(QString defAHex, QString defBHex, QString userInputHoldingHex,
+1 -1
View File
@@ -54,7 +54,7 @@ public slots:
void disconnectWallet() override;
// AMM — all forwarded to the amm_module core module.
QVariantMap resolvePool(QString defAHex, QString defBHex) override;
QVariantMap resolvePoolAccount(QString defAHex, QString defBHex) override;
QString swapExactInput(QString defAHex, QString defBHex, QString userInputHoldingHex,
QString userOutputHoldingHex, QString amountInDecimal,
QString minOutDecimal, QString deadlineDecimal) override;
+8 -7
View File
@@ -41,13 +41,14 @@ class AmmUiBackend
SLOT(void disconnectWallet())
// AMM
// Derives the AMM pool's PDAs (config/pool/vaults/current-tick) from the
// deployed AMM program binary (see AMM_PROGRAM_BIN — a RISC Zero
// ProgramBinary .bin, not a raw ELF) and reads the pool's
// on-chain reserves. Returns `{ exists: false }` if the AMM program bin
// isn't configured, the AMM isn't initialized, or the pool has no
// liquidity yet.
SLOT(QVariantMap resolvePool(QString defAHex, QString defBHex))
// Derives the AMM pool PDA for (defAHex, defBHex) from the deployed AMM program
// binary (see AMM_PROGRAM_BIN — a RISC Zero ProgramBinary .bin, not a raw ELF)
// and reads/decodes the pool account. On success `{ status:"ok", error:"", poolId,
// defAHex, defBHex, vaultAId, vaultBId, lpDefinitionId, reserveA, reserveB,
// liquiditySupply, feeBps }` (A/B oriented to the requested order); otherwise
// `{ status:"error", error:<code> }` — `no_pool` for the ordinary "no pool /
// no liquidity yet" state, else no_program_bin / amm_not_initialized / bad_config.
SLOT(QVariantMap resolvePoolAccount(QString defAHex, QString defBHex))
// Submits a real on-chain SwapExactInput transaction against the pool for
// (defAHex, defBHex). amountInDecimal/minOutDecimal are decimal-string
// u128 amounts in base units; deadlineDecimal is a decimal-string u64 unix
+9 -6
View File
@@ -16,10 +16,13 @@ transport-independent JSON FFI), and this module sequences those pure ops with
chain I/O delegated to the `logos_execution_zone` wallet module. Its public
methods (the module API is generated from the header) are:
- `resolvePool(defAHex, defBHex)` — derives the pool PDAs
(config/pool/vaults/current-tick) and reads the pool's on-chain reserves.
Returns `{ exists: false, error }` when the AMM isn't configured/initialized or
the pool has no liquidity.
- `resolvePoolAccount(defAHex, defBHex)` — derives the pool PDA and reads/decodes
the pool account (reserves in canonical `a`/`b` order, fee tier). On success
`{ status: "ok", error: "", poolId, defAHex, defBHex, vaultAId, vaultBId,
lpDefinitionId, reserveA, reserveB, liquiditySupply, feeBps }`; an absent /
uninitialized pool or one with no liquidity is `{ status: "error", error:
"no_pool", poolId }` (other codes: `no_program_bin`, `amm_not_initialized`,
`bad_config`).
- `swapExactInput(defAHex, defBHex, userInputHoldingHex, userOutputHoldingHex, amountIn, minOut, deadline)`
— submits an on-chain `SwapExactInput` transaction (defA = token in,
defB = token out); returns the tx hash (or empty on failure). See
@@ -99,8 +102,8 @@ Both are absolute-path env vars set on the **process that hosts the module**
(the `logoscore` daemon, or Basecamp) — not on the `call`:
- `AMM_PROGRAM_BIN` — the deployed `amm.bin`. Required; its ELF determines the
program id and every derived PDA. Without it, `resolvePool` returns
`{ exists: false, error: "no_program_bin" }`.
program id and every derived PDA. Without it, `resolvePoolAccount` returns
`{ status: "error", error: "no_program_bin" }`.
- `TOKENS_CONFIG` — JSON array of `{ symbol, name, definitionId, holding, decimals }`
consumed by `tokenList()`.
+46 -12
View File
@@ -71,28 +71,45 @@ pub(super) fn swap_pair(request: SwapPairRequest) -> Result<Value, String> {
/// Decodes a pool account: whether it holds liquidity, its canonical token ids
/// (`defAHex`/`defBHex`), its reserves (same canonical `a`/`b` order — the
/// caller matches a reserve to its own direction by comparing its token id
/// against `defAHex`), and fee tier. Absent/empty/uninitialized pool →
/// `{ exists: false }`.
/// against `defAHex`), and fee tier. On success returns a `{ status: "ok",
/// error: "", poolId, defAHex, defBHex, vaultAId, vaultBId, lpDefinitionId,
/// reserveA, reserveB, liquiditySupply, feeBps }` envelope; an absent / empty /
/// uninitialized pool is the `{ status: "error", error: "no_pool", poolId }`
/// envelope (the derived `poolId` is still carried, for address derivation).
pub(super) fn resolve_pool(request: ResolvePoolRequest) -> Result<Value, String> {
// The pool's presence IS the signal: existing pools return their decoded state; a missing /
// uninitialized pool is the `no_pool` error (still carrying the derived `poolId` for address
// derivation). The read's id is hex (the module reads by hex); undecodable ⇒ "".
let pool_id = account_id_from_hex(&request.pool.id, "pool id")
.map(|id| id.to_string())
.unwrap_or_default();
let missing = || json!({ "status": "error", "error": "no_pool", "poolId": pool_id });
if request.pool.status != "ok" {
return Ok(json!({ "exists": false }));
return Ok(missing());
}
let Ok((_, pool_account)) = decode_account(&request.pool) else {
return Ok(json!({ "exists": false }));
return Ok(missing());
};
let Ok(pool) = PoolDefinition::try_from(&pool_account.data) else {
return Ok(json!({ "exists": false }));
return Ok(missing());
};
if pool.liquidity_pool_supply == 0 {
return Ok(json!({ "exists": false }));
return Ok(missing());
}
let fee_bps = u32::try_from(pool.fees).map_err(|_| String::from("invalid_fee_tier"))?;
Ok(json!({
"exists": true,
"status": "ok",
"error": "",
"poolId": pool_id,
"defAHex": account_id_hex(pool.definition_token_a_id),
"defBHex": account_id_hex(pool.definition_token_b_id),
"vaultAId": pool.vault_a_id.to_string(),
"vaultBId": pool.vault_b_id.to_string(),
"lpDefinitionId": pool.liquidity_pool_id.to_string(),
"reserveA": pool.reserve_a.to_string(),
"reserveB": pool.reserve_b.to_string(),
"liquiditySupply": pool.liquidity_pool_supply.to_string(),
"feeBps": fee_bps,
}))
}
@@ -441,17 +458,22 @@ mod tests {
}
#[test]
fn resolve_pool_reports_canonical_token_ids() {
fn resolve_pool_reports_canonical_token_ids_and_derived_accounts() {
let def_a = AccountId::new([0xAA; 32]);
let def_b = AccountId::new([0xBB; 32]);
let vault_a = AccountId::new([0xC1; 32]);
let vault_b = AccountId::new([0xC2; 32]);
let lp = AccountId::new([0xD0; 32]);
let pool = PoolDefinition {
definition_token_a_id: def_a,
definition_token_b_id: def_b,
vault_a_id: vault_a,
vault_b_id: vault_b,
liquidity_pool_id: lp,
liquidity_pool_supply: 1_000,
reserve_a: 111,
reserve_b: 222,
fees: 30,
..Default::default()
};
let value = resolve_pool(ResolvePoolRequest {
@@ -459,19 +481,24 @@ mod tests {
})
.unwrap();
assert_eq!(value["exists"], true);
assert_eq!(value["status"], "ok");
assert_eq!(value["poolId"], AccountId::new([0x11; 32]).to_string());
// The Swap UI matches reserveA/reserveB to its own sell/buy direction by
// comparing the sold token's id against defAHex — so both ids must be
// present in canonical order.
assert_eq!(value["defAHex"], account_id_hex(def_a));
assert_eq!(value["defBHex"], account_id_hex(def_b));
assert_eq!(value["vaultAId"], vault_a.to_string());
assert_eq!(value["vaultBId"], vault_b.to_string());
assert_eq!(value["lpDefinitionId"], lp.to_string());
assert_eq!(value["reserveA"], "111");
assert_eq!(value["reserveB"], "222");
assert_eq!(value["liquiditySupply"], "1000");
assert_eq!(value["feeBps"], 30);
}
#[test]
fn resolve_pool_absent_when_no_liquidity() {
fn resolve_pool_absent_is_a_no_pool_error_carrying_the_pool_id() {
let pool = PoolDefinition {
liquidity_pool_supply: 0,
..Default::default()
@@ -480,7 +507,14 @@ mod tests {
pool: pool_read(&pool),
})
.unwrap();
assert_eq!(value, json!({ "exists": false }));
assert_eq!(
value,
json!({
"status": "error",
"error": "no_pool",
"poolId": AccountId::new([0x11; 32]).to_string(),
})
);
}
#[test]
+15 -17
View File
@@ -341,15 +341,15 @@ nlohmann::json AmmModuleImpl::readConfig(const std::string& amm_program_id) {
return readPublicAccount(jStr(configResult.value, "configId"));
}
LogosMap AmmModuleImpl::resolvePool(const std::string& def_a_hex,
const std::string& def_b_hex) {
LogosMap AmmModuleImpl::resolvePoolAccount(const std::string& def_a_hex,
const std::string& def_b_hex) {
// A hard failure carries a stable `error` code (no_program_bin /
// amm_not_initialized / bad_config) so SwapCard can surface it via poolError.
// `no_pool` is the ordinary "no pool / no liquidity yet" state, which SwapCard
// treats as its normal empty state (SwapCard.qml `error !== "no_pool"`). The
// underlying FFI error string is AMM_TRACE'd to the daemon log.
// amm_not_initialized / bad_config) so callers can surface it. `no_pool` is the
// ordinary "no pool / no liquidity yet" state — also a `status:"error"` result
// (SwapCard treats it as its normal empty state via `error !== "no_pool"`, the
// flow routes it to create-pool). The underlying FFI error is AMM_TRACE'd.
auto failed = [](const std::string& error) {
return LogosMap{{"exists", false}, {"error", error}};
return LogosMap{{"status", "error"}, {"error", error}};
};
const std::string amm_program_id = ammProgramId();
@@ -387,20 +387,18 @@ LogosMap AmmModuleImpl::resolvePool(const std::string& def_a_hex,
const FfiResult resolveResult = call(amm_resolve_pool, json{{"pool", pool}});
if (!resolveResult.ok)
return failed("bad_config"); // amm_resolve_pool op failed
// resolve_pool returns { exists:false } (no error) for a missing pool / no
// liquidity; re-tag it "no_pool" — the code SwapCard expects for that state.
// resolve_pool returns status:"error"/no_pool for a missing pool (pass through) or
// status:"ok" with the decoded state. It labels reserves/vaults in the pool's STORED
// order (reserveA is defAHex's); orient them to the CALLER's requested order so reserveA /
// vaultAId are token_a's — the stored order needn't match (it can be non-canonical, e.g.
// the testnet setup's pool). Callers then read A/B as their own token-a/token-b directly.
json resolved = resolveResult.value;
if (!resolved.value("exists", false))
return failed("no_pool");
// resolve_pool labels the reserves in the pool's STORED order (reserveA is defAHex's).
// Orient them to the CALLER's requested order so reserveA is token_a's reserve — the
// pool's stored order needn't match (it can be non-canonical, e.g. the testnet setup's
// pool). Both callers then read reserveA/reserveB as their own token-a/token-b directly.
if (jStr(resolved, "defAHex") != token_a) {
if (jStr(resolved, "status") == "ok" && jStr(resolved, "defAHex") != token_a) {
resolved["reserveA"].swap(resolved["reserveB"]);
resolved["defAHex"].swap(resolved["defBHex"]);
resolved["vaultAId"].swap(resolved["vaultBId"]);
}
return resolved; // { exists:true, reserveA, reserveB, feeBps } in the caller's order
return resolved;
}
LogosMap AmmModuleImpl::swapExactInQuote(const std::string& token_in_hex,
+9 -9
View File
@@ -30,15 +30,15 @@ public:
AmmModuleImpl() = default;
~AmmModuleImpl() = default;
/// Derives the pool PDAs (config / pool / vaults / current-tick) for the
/// (def_a_hex, def_b_hex) pair and reads the pool's on-chain reserves.
/// On success: `{ exists:true, reserveA, reserveB, feeBps }` (reserveA/
/// reserveB in the pool's canonical def order). Otherwise
/// `{ exists:false, error:<code> }`: `no_program_bin` (AMM_PROGRAM_BIN
/// unset/unreadable/bad), `amm_not_initialized` (config undecodable),
/// `bad_config` (bad ids / internal decode failure), `same_token_pair`, or
/// `no_pool` for the ordinary "no pool / no liquidity yet" state.
LogosMap resolvePool(const std::string& def_a_hex, const std::string& def_b_hex);
/// Derives the pool PDA for the (def_a_hex, def_b_hex) pair and reads/decodes the
/// pool account. On success: `{ status:"ok", error:"", poolId, defAHex, defBHex,
/// vaultAId, vaultBId, lpDefinitionId, reserveA, reserveB, liquiditySupply, feeBps }`
/// — the A/B fields oriented to the caller's requested order (A is def_a_hex's).
/// Otherwise `{ status:"error", error:<code> }`: `no_program_bin` (AMM_PROGRAM_BIN
/// unset/unreadable/bad), `amm_not_initialized` (config undecodable), `bad_config`
/// (bad ids / internal decode failure), `same_token_pair`, or `no_pool` for the
/// ordinary "no pool / no liquidity yet" state (still carries `poolId`).
LogosMap resolvePoolAccount(const std::string& def_a_hex, const std::string& def_b_hex);
/// Prices a `SwapExactInput` for the (token_in_hex, token_out_hex) pair:
/// reads the pool and returns `{ status:"ok", error:"", expectedOutRaw,