refactor(amm): consolidate the two client FFIs into one JSON crate

The AMM host FFI was split across two crates with two ABI styles: the
typed-C `amm_client_ffi` (swap primitives, under programs/) and the
JSON/wire `amm_client` (new-position flow, under apps/). Fold both into a
single `amm_client` crate exposing one JSON C ABI, and delete
`programs/amm/client-ffi`.

Rust:
- Re-express the swap path as `api/swap.rs` operations on the existing
  `call::<T>` dispatch — swap_pair, resolve_pool, swap_plan, program_id —
  reusing `pair::derive_pair` (no more duplicated PDA derivation) and
  `risc0_zkvm::serde` for the SwapExactInput words (the same encoding the
  guest decodes). The account list and signer flags stay byte-identical
  to the old typed path.
- Generate a single header (`include/amm_client.h`) covering all ops via
  cbindgen; bump cbindgen 0.27 -> 0.28 for `#[unsafe(no_mangle)]` support.

C++:
- Extend the `AmmClient` wrapper with the four swap ops.
- Add `SwapRuntime` (mirrors `NewPositionRuntime`): reads accounts through
  the wallet, drives the swap ops, submits the transaction.
- `AmmUiBackend` swap methods now delegate to `SwapRuntime`, dropping ~390
  lines of typed-FFI and byte-twiddling. `program_id` becomes a JSON op,
  and the swap clock is derived via `derive_pair` (clock_core::CLOCK_01)
  instead of a hardcoded base58 literal — same account, verified.
This commit is contained in:
r4bbit
2026-08-03 13:43:32 +02:00
parent 8358cfa2f1
commit 0b2437d1fc
27 changed files with 680 additions and 1069 deletions
+2 -1
View File
@@ -41,6 +41,8 @@ logos_module(
src/AmmClient.cpp
src/NewPositionRuntime.h
src/NewPositionRuntime.cpp
src/SwapRuntime.h
src/SwapRuntime.cpp
FIND_PACKAGES
Qt6Gui
LINK_LIBRARIES
@@ -49,7 +51,6 @@ logos_module(
LINK_TARGETS
logos_wallet_access
EXTERNAL_LIBS
amm_client_ffi
amm_client
)
+4
View File
@@ -13,6 +13,7 @@ borsh = { workspace = true }
clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0" }
hex = "0.4"
nssa_core = { workspace = true }
risc0-binfmt = { version = "=3.0.4", default-features = false }
risc0-zkvm = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -20,5 +21,8 @@ sha2 = "0.10"
token_core = { workspace = true }
twap_oracle_core = { workspace = true }
[build-dependencies]
cbindgen = "0.28"
[dev-dependencies]
pretty_assertions = "1"
+9
View File
@@ -0,0 +1,9 @@
fn main() {
let crate_dir =
std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo");
cbindgen::generate(&crate_dir)
.expect("cbindgen")
.write_to_file(format!("{crate_dir}/include/amm_client.h"));
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=cbindgen.toml");
}
+8
View File
@@ -0,0 +1,8 @@
language = "C"
include_guard = "AMM_CLIENT_H"
pragma_once = true
cpp_compat = true
autogen_warning = "/* Generated by cbindgen. Do not edit. */"
[export]
prefix = ""
+33 -4
View File
@@ -1,20 +1,49 @@
#ifndef AMM_CLIENT_H
#define AMM_CLIENT_H
#pragma once
/* Generated by cbindgen. Do not edit. */
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#ifdef __cplusplus
extern "C" {
#endif
#endif // __cplusplus
char *amm_config_id(const char *request_json);
char *amm_token_ids(const char *request_json);
char *amm_pair_ids(const char *request_json);
char *amm_context(const char *request_json);
char *amm_quote(const char *request_json);
char *amm_plan(const char *request_json);
char *amm_swap_pair(const char *request_json);
char *amm_resolve_pool(const char *request_json);
char *amm_swap_plan(const char *request_json);
char *amm_program_id(const char *request_json);
/**
* Releases a string returned by an `amm_*` operation.
*
* # Safety
* `value` must be null or a pointer returned by this library that has not been freed.
*/
void amm_free(char *value);
#ifdef __cplusplus
}
#endif
} // extern "C"
#endif // __cplusplus
#endif
#endif /* AMM_CLIENT_H */
+23 -1
View File
@@ -13,6 +13,7 @@ mod position;
mod quote;
mod quote_error;
mod request;
mod swap;
#[cfg(test)]
mod tests;
@@ -21,7 +22,8 @@ use std::{error::Error, fmt};
pub use request::{
ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest,
QuoteRequest, TokenIdsRequest,
ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapPairRequest, SwapPlanRequest,
TokenIdsRequest,
};
use serde_json::Value;
@@ -95,3 +97,23 @@ pub fn quote(request: QuoteRequest) -> AmmResult {
pub fn plan(request: PlanRequest) -> AmmResult {
plan::plan(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)
}
/// Decodes a pool account: existence, reserves (canonical order), fee tier.
pub fn resolve_pool(request: ResolvePoolRequest) -> AmmResult {
swap::resolve_pool(request).map_err(Into::into)
}
/// Builds the `SwapExactInput` wallet submission for a token pair.
pub fn swap_plan(request: SwapPlanRequest) -> AmmResult {
swap::swap_plan(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)
}
+35
View File
@@ -52,6 +52,41 @@ pub struct PairIdsRequest {
pub token_b_id: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SwapPairRequest {
pub amm_program_id: String,
pub token_in_id: String,
pub token_out_id: String,
pub config: AccountRead,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ResolvePoolRequest {
pub pool: AccountRead,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SwapPlanRequest {
pub amm_program_id: String,
pub token_in_id: String,
pub token_out_id: String,
pub config: AccountRead,
pub user_input_holding_id: String,
pub user_output_holding_id: String,
pub amount_in: String,
pub min_out: String,
pub deadline_ms: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProgramIdRequest {
pub elf: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PositionRequest {
+268
View File
@@ -0,0 +1,268 @@
//! Swap operations — pool discovery/decode, swap-transaction planning, and
//! program-id derivation. Same transport-independent pattern as the
//! new-position ops: pure functions returning JSON `Value`, PDAs reused from
//! `pair::derive_pair` so the swap path never re-derives seeds.
use amm_core::PoolDefinition;
use nssa_core::account::AccountId;
use risc0_binfmt::ProgramBinary;
use serde_json::{json, Value};
use super::{
pair::{derive_pair, is_canonical_pair},
ProgramIdRequest, ResolvePoolRequest, SwapPairRequest, SwapPlanRequest,
};
use crate::account::{
account_id_from_hex, account_id_hex, decode_account, parse_program_id, program_id_bytes,
};
/// Orders `(token_in, token_out)` into the pool's canonical `(token_a, token_b)`
/// so derived vault PDAs line up with the pool's stored `vault_a`/`vault_b`.
fn canonical_pair(token_in: AccountId, token_out: AccountId) -> (AccountId, AccountId) {
if is_canonical_pair(token_in, token_out) {
(token_in, token_out)
} else {
(token_out, token_in)
}
}
fn parse_u128(value: &str, label: &str) -> Result<u128, String> {
value
.parse::<u128>()
.map_err(|error| format!("invalid {label}: {error}"))
}
fn parse_u64(value: &str, label: &str) -> Result<u64, String> {
value
.parse::<u64>()
.map_err(|error| format!("invalid {label}: {error}"))
}
/// Derives the canonical account ids for a swap pair — reuses `derive_pair`
/// after ordering `(token_in, token_out)` into `(token_a, token_b)`, so a
/// caller can read the pool account before decoding it. Like `pair_ids`, but
/// accepts the tokens in either order.
pub(super) fn swap_pair(request: SwapPairRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
let token_in = account_id_from_hex(&request.token_in_id, "token in id")?;
let token_out = account_id_from_hex(&request.token_out_id, "token out id")?;
if token_in == token_out {
return Ok(json!({ "status": "error", "code": "same_token_pair" }));
}
let (token_a, token_b) = canonical_pair(token_in, token_out);
let Ok(pair) = derive_pair(amm_program, token_a, token_b, &request.config) else {
return Ok(json!({ "status": "error", "code": "config_unavailable" }));
};
Ok(json!({
"status": "ok",
"configId": account_id_hex(pair.config),
"poolId": account_id_hex(pair.pool),
"vaultAId": account_id_hex(pair.vault_a),
"vaultBId": account_id_hex(pair.vault_b),
"currentTickId": account_id_hex(pair.current_tick),
"clockId": account_id_hex(pair.clock),
}))
}
/// 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 }`.
pub(super) fn resolve_pool(request: ResolvePoolRequest) -> Result<Value, String> {
if request.pool.status != "ok" {
return Ok(json!({ "exists": false }));
}
let Ok((_, pool_account)) = decode_account(&request.pool) else {
return Ok(json!({ "exists": false }));
};
let Ok(pool) = PoolDefinition::try_from(&pool_account.data) else {
return Ok(json!({ "exists": false }));
};
if pool.liquidity_pool_supply == 0 {
return Ok(json!({ "exists": false }));
}
let fee_bps =
u32::try_from(pool.fees).map_err(|_| String::from("pool fee tier exceeds u32"))?;
Ok(json!({
"exists": true,
"defAHex": account_id_hex(pool.definition_token_a_id),
"defBHex": account_id_hex(pool.definition_token_b_id),
"reserveA": pool.reserve_a.to_string(),
"reserveB": pool.reserve_b.to_string(),
"feeBps": fee_bps,
}))
}
/// Builds the `SwapExactInput` submission for a pair: the fixed 8-account IDL
/// order (vaults canonical, only the user's input holding signs) and the
/// instruction words (`risc0_zkvm::serde` — the same encoding the guest
/// decodes). Mirrors `plan.rs`'s `ready` output shape.
pub(super) fn swap_plan(request: SwapPlanRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
let token_in = account_id_from_hex(&request.token_in_id, "token in id")?;
let token_out = account_id_from_hex(&request.token_out_id, "token out id")?;
// Domain errors (a bad pair, an unavailable config) mirror `swap_pair`'s
// `{ status: "error", code }` shape rather than `Err`, which is reserved for
// malformed inputs. `SwapRuntime::swap` treats any non-"ready" status as a
// failed plan, so both map to the same UI outcome.
if token_in == token_out {
return Ok(json!({ "status": "error", "code": "same_token_pair" }));
}
let (token_a, token_b) = canonical_pair(token_in, token_out);
let Ok(pair) = derive_pair(amm_program, token_a, token_b, &request.config) else {
return Ok(json!({ "status": "error", "code": "config_unavailable" }));
};
let user_input_holding =
account_id_from_hex(&request.user_input_holding_id, "user input holding id")?;
let user_output_holding =
account_id_from_hex(&request.user_output_holding_id, "user output holding id")?;
let swap_amount_in = parse_u128(&request.amount_in, "amountIn")?;
let min_amount_out = parse_u128(&request.min_out, "minOut")?;
let deadline = parse_u64(&request.deadline_ms, "deadlineMs")?;
let instruction = risc0_zkvm::serde::to_vec(&amm_core::Instruction::SwapExactInput {
swap_amount_in,
min_amount_out,
deadline,
})
.map_err(|error| format!("instruction serialization failed: {error}"))?;
// Fixed IDL account order for SwapExactInput; only user_input_holding signs.
let account_ids = [
pair.config,
pair.pool,
pair.vault_a,
pair.vault_b,
user_input_holding,
user_output_holding,
pair.current_tick,
pair.clock,
];
let signing_requirements = [false, false, false, false, true, false, false, false];
Ok(json!({
"status": "ready",
"programId": request.amm_program_id,
"accountIds": account_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(),
"signingRequirements": signing_requirements,
"instruction": instruction,
"deadlineMs": deadline.to_string(),
}))
}
/// Computes the AMM `ProgramId` (RISC Zero Image ID) of a deployed program
/// binary. `elf` is the hex-encoded `.bin` (a RISC Zero `ProgramBinary`, not a
/// raw ELF) — decoded, image-id computed, returned as 64-char lowercase hex.
pub(super) fn program_id(request: ProgramIdRequest) -> Result<Value, String> {
let elf = hex::decode(&request.elf).map_err(|error| format!("invalid elf hex: {error}"))?;
let binary = ProgramBinary::decode(&elf).map_err(|error| format!("{error:?}"))?;
let image_id: nssa_core::program::ProgramId = binary
.compute_image_id()
.map_err(|error| format!("{error:?}"))?
.into();
Ok(json!({ "programId": hex::encode(program_id_bytes(image_id)) }))
}
#[cfg(test)]
mod tests {
use amm_core::PoolDefinition;
use nssa_core::account::AccountId;
use super::*;
use crate::account::{AccountRead, WalletAccount};
fn pool_read(pool: &PoolDefinition) -> AccountRead {
AccountRead {
id: "11".repeat(32),
status: String::from("ok"),
account: Some(WalletAccount {
program_owner: "00".repeat(32),
balance: "0".repeat(32),
nonce: "0".repeat(32),
data: hex::encode(borsh::to_vec(pool).unwrap()),
}),
}
}
#[test]
fn resolve_pool_reports_canonical_token_ids() {
let def_a = AccountId::new([0xAA; 32]);
let def_b = AccountId::new([0xBB; 32]);
let pool = PoolDefinition {
definition_token_a_id: def_a,
definition_token_b_id: def_b,
liquidity_pool_supply: 1_000,
reserve_a: 111,
reserve_b: 222,
fees: 30,
..Default::default()
};
let value = resolve_pool(ResolvePoolRequest {
pool: pool_read(&pool),
})
.unwrap();
assert_eq!(value["exists"], true);
// 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["reserveA"], "111");
assert_eq!(value["reserveB"], "222");
assert_eq!(value["feeBps"], 30);
}
#[test]
fn resolve_pool_absent_when_no_liquidity() {
let pool = PoolDefinition {
liquidity_pool_supply: 0,
..Default::default()
};
let value = resolve_pool(ResolvePoolRequest {
pool: pool_read(&pool),
})
.unwrap();
assert_eq!(value, json!({ "exists": false }));
}
#[test]
fn same_token_is_a_recoverable_domain_error_in_both_ops() {
let program = "00".repeat(32);
let same = "aa".repeat(32);
let expected = json!({ "status": "error", "code": "same_token_pair" });
// Not reached before the same-token check, so its contents don't matter.
let dummy_config = AccountRead {
id: String::new(),
status: String::from("read_failed"),
account: None,
};
let pair = swap_pair(SwapPairRequest {
amm_program_id: program.clone(),
token_in_id: same.clone(),
token_out_id: same.clone(),
config: dummy_config.clone(),
})
.unwrap();
assert_eq!(pair, expected);
let plan = swap_plan(SwapPlanRequest {
amm_program_id: program,
token_in_id: same.clone(),
token_out_id: same,
config: dummy_config,
user_input_holding_id: String::new(),
user_output_holding_id: String::new(),
amount_in: String::new(),
min_out: String::new(),
deadline_ms: String::new(),
})
.unwrap();
assert_eq!(plan, expected);
}
}
+22 -1
View File
@@ -7,7 +7,8 @@ use serde::{de::DeserializeOwned, Serialize};
use crate::api::{
self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest,
QuoteRequest, TokenIdsRequest,
ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapPairRequest, SwapPlanRequest,
TokenIdsRequest,
};
#[derive(Serialize)]
@@ -106,6 +107,26 @@ pub extern "C" fn amm_plan(request_json: *const c_char) -> *mut c_char {
call::<PlanRequest>(request_json, api::plan)
}
#[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)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_resolve_pool(request_json: *const c_char) -> *mut c_char {
call::<ResolvePoolRequest>(request_json, api::resolve_pool)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_swap_plan(request_json: *const c_char) -> *mut c_char {
call::<SwapPlanRequest>(request_json, api::swap_plan)
}
#[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)
}
/// Releases a string returned by an `amm_*` operation.
///
/// # Safety
+5 -3
View File
@@ -6,7 +6,9 @@ mod ffi;
pub mod api;
pub use api::{
config_id, context, pair_ids, plan, quote, token_ids, AccountRead, AmmApiError, AmmResponse,
AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest,
PositionRequest, QuoteRequest, TokenIdsRequest, WalletAccount, NEW_POSITION_SCHEMA,
config_id, context, pair_ids, plan, program_id, quote, resolve_pool, swap_pair, swap_plan,
token_ids, AccountRead, AmmApiError, AmmResponse, AmmResult, ConfigIdRequest, ContextRequest,
PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest, ProgramIdRequest, QuoteRequest,
ResolvePoolRequest, SwapPairRequest, SwapPlanRequest, TokenIdsRequest, WalletAccount,
NEW_POSITION_SCHEMA,
};
+3 -3
View File
@@ -30,17 +30,17 @@
amm_client.url = "path:../..";
};
# NOTE: this flake is no longer built standalone. The amm_client_ffi crate
# NOTE: this flake is no longer built standalone. The amm_client crate
# (the Rust C FFI library the AmmUiBackend C++ code links against) lives in
# the repo-root flake, and referencing it from here would require either a
# hardcoded absolute `git+file://` path or a `path:../..` input — the latter
# fails flake evaluation because the app directory is copied into the Nix
# store as its own flake root, so `../..` can't escape it there. Instead,
# the repo-root flake.nix builds this module directly (src = ./apps/amm)
# and resolves amm_client_ffi via `self`. The repo-root flake exposes the UI
# and resolves amm_client via `self`. The repo-root flake exposes the UI
# as a named attribute (there is no bare `default`): run it from the repo root
# with `nix run .#amm-ui`, and build just the FFI crate with
# `nix build .#amm_client_ffi`.
# `nix build .#amm_client`.
outputs = inputs@{ logos-module-builder, shared_wallet, ... }:
logos-module-builder.lib.mkLogosQmlModule {
src = ./.;
-1
View File
@@ -15,7 +15,6 @@
"runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp", "libbase58"]
},
"external_libraries": [
{ "name": "amm_client_ffi" },
{ "name": "amm_client" }
],
"cmake": {
+20
View File
@@ -69,3 +69,23 @@ AmmClientResult BundledAmmClient::plan(const QJsonObject& request) const
{
return call(amm_plan, request);
}
AmmClientResult BundledAmmClient::swapPair(const QJsonObject& request) const
{
return call(amm_swap_pair, request);
}
AmmClientResult BundledAmmClient::resolvePool(const QJsonObject& request) const
{
return call(amm_resolve_pool, request);
}
AmmClientResult BundledAmmClient::swapPlan(const QJsonObject& request) const
{
return call(amm_swap_plan, request);
}
AmmClientResult BundledAmmClient::programId(const QJsonObject& request) const
{
return call(amm_program_id, request);
}
+8
View File
@@ -17,6 +17,10 @@ public:
virtual AmmClientResult context(const QJsonObject& request) const = 0;
virtual AmmClientResult quote(const QJsonObject& request) const = 0;
virtual AmmClientResult plan(const QJsonObject& request) const = 0;
virtual AmmClientResult swapPair(const QJsonObject& request) const = 0;
virtual AmmClientResult resolvePool(const QJsonObject& request) const = 0;
virtual AmmClientResult swapPlan(const QJsonObject& request) const = 0;
virtual AmmClientResult programId(const QJsonObject& request) const = 0;
};
class BundledAmmClient final : public AmmClient {
@@ -27,4 +31,8 @@ public:
AmmClientResult context(const QJsonObject& request) const override;
AmmClientResult quote(const QJsonObject& request) const override;
AmmClientResult plan(const QJsonObject& request) const override;
AmmClientResult swapPair(const QJsonObject& request) const override;
AmmClientResult resolvePool(const QJsonObject& request) const override;
AmmClientResult swapPlan(const QJsonObject& request) const override;
AmmClientResult programId(const QJsonObject& request) const override;
};
+18 -382
View File
@@ -21,37 +21,11 @@
#include "AmmClient.h"
#include "LogosWalletProvider.h"
#include "NewPositionRuntime.h"
#include "SwapRuntime.h"
#include "WalletController.h"
#include "logos_api.h"
#include "logos_sdk.h"
extern "C" {
#include "amm_client_ffi.h"
}
// Debug tracing for the swap path (resolvePool / swapExactInput), gated behind
// the AMM_DEBUG env var so normal runs stay quiet. When disabled, the streamed
// arguments — including the account_id_to_base58 round-trips used to render
// accounts — are NOT evaluated, so there's no per-call overhead.
//
// NOTE: `send_generic_public_transaction(account_ids, signing_requirements,
// instruction, program_id_hex)`. `instruction` is a byte string (`bstr`):
// declaring it as Vec<u32> makes the module's Qt/QtRO glue downgrade it to an
// opaque `any` the separate module process can't deserialize, silently dropping
// every argument. We send `instruction` as the little-endian bytes of the u32
// words, and the deployed program by its id hex (not the raw ELF). Requires the
// wallet module built with the byte-string param; see
// docs/amm-swap-qtro-serialization-bug.md.
static bool ammDebugEnabled()
{
static const bool on = qEnvironmentVariableIsSet("AMM_DEBUG");
return on;
}
// AMM_DBG() << ... behaves like qWarning().noquote() but only when AMM_DEBUG is
// set; otherwise the whole statement (and its arguments) is skipped.
#define AMM_DBG() \
if (ammDebugEnabled()) qWarning().noquote()
namespace {
// Absolute path to the deployed AMM program's RISC Zero program binary
// (amm.bin — the `ProgramBinary` `.bin` from the docker guest build, decoded
@@ -67,78 +41,6 @@ namespace {
// (see apps/amm/README.md). Config-driven so the Swap view's token picker
// doesn't need a hardcoded/dummy token list.
const char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG";
QString bytes32ToHex(const uint8_t (&b)[32]) {
return QString::fromLatin1(QByteArray(reinterpret_cast<const char*>(b), 32).toHex());
}
bool hexToBytes32(const QString& hex, uint8_t (&out)[32]) {
const QByteArray bytes = QByteArray::fromHex(hex.toUtf8());
if (bytes.size() != 32)
return false;
for (int i = 0; i < 32; ++i)
out[i] = static_cast<uint8_t>(bytes[i]);
return true;
}
// Little-endian 16-byte u128 -> decimal string. QString has no direct u128
// constructor, so accumulate into unsigned __int128 and extract digits.
QString u128leToDecimal(const uint8_t (&le)[16]) {
unsigned __int128 value = 0;
for (int i = 15; i >= 0; --i)
value = (value << 8) | static_cast<unsigned __int128>(le[i]);
if (value == 0)
return QStringLiteral("0");
QString digits;
while (value > 0) {
const unsigned int digit = static_cast<unsigned int>(value % 10);
digits.prepend(QChar(static_cast<char16_t>('0' + digit)));
value /= 10;
}
return digits;
}
// Decimal string -> little-endian 16-byte u128. Inverse of u128leToDecimal.
// Returns false (leaving `out` unwritten) on an empty string or a
// non-digit character.
bool decimalToU128Le(const QString& decimal, uint8_t (&out)[16]) {
const QString trimmed = decimal.trimmed();
if (trimmed.isEmpty())
return false;
const unsigned __int128 kMax = ~static_cast<unsigned __int128>(0);
unsigned __int128 value = 0;
for (const QChar ch : trimmed) {
if (!ch.isDigit())
return false;
const unsigned int d = static_cast<unsigned int>(ch.digitValue());
if (value > (kMax - d) / 10)
return false; // would overflow u128
value = value * 10 + d;
}
for (int i = 0; i < 16; ++i) {
out[i] = static_cast<uint8_t>(value & 0xFF);
value >>= 8;
}
return true;
}
// Base58 address of the fixed clock account consumed by SwapExactInput's
// deadline check. Resolved to hex via the wallet module rather than
// hardcoding a guessed hex encoding.
const char CLOCK_ACCOUNT_BASE58[] = "4BdcjoXkq786TMWcBGGHqcxeLYMZmn17rL4eM9ZyRWNU";
// Extracts the hex-encoded raw account bytes from a get_account_public()
// JSON reply. Returns an empty string if the account has no data (i.e. is
// uninitialized/nonexistent) — note the module always includes the "data"
// key, set to "" rather than omitted, when there's nothing to read.
QString accountDataHex(const QString& accountJson) {
const QJsonObject obj = QJsonDocument::fromJson(accountJson.toUtf8()).object();
return obj.value(QStringLiteral("data")).toString();
}
}
AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent)
@@ -149,7 +51,8 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent)
m_walletController(std::make_unique<WalletController>(
*m_wallet, QStringLiteral("AmmUI"))),
m_ammClient(std::make_unique<BundledAmmClient>()),
m_newPosition(std::make_unique<NewPositionRuntime>(m_wallet.get(), m_ammClient.get()))
m_newPosition(std::make_unique<NewPositionRuntime>(m_wallet.get(), m_ammClient.get())),
m_swap(std::make_unique<SwapRuntime>(m_wallet.get(), m_ammClient.get()))
{
setWalletStateReady(false);
setNewPositionContext(m_newPosition->context(
@@ -291,24 +194,16 @@ QString AmmUiBackend::ammProgramIdHex()
const QByteArray elf = loadAmmElf();
if (elf.isEmpty())
return QString();
ProgramId ammId{};
if (!amm_client_program_id_from_elf(reinterpret_cast<const uint8_t*>(elf.constData()),
static_cast<uintptr_t>(elf.size()), &ammId)) {
qWarning() << "AmmUiBackend::ammProgramIdHex: amm_client_program_id_from_elf failed";
// Hand the deployed program binary to the amm_client program_id op, which
// decodes it and computes the Image ID — 64-char lowercase hex, little-endian
// per u32 word (matches `spel program-id` and the on-chain *_program_id fields).
const AmmClientResult result = m_ammClient->programId(
QJsonObject { { QStringLiteral("elf"), QString::fromLatin1(elf.toHex()) } });
if (!result.ok) {
qWarning() << "AmmUiBackend::ammProgramIdHex: amm_program_id failed";
return QString();
}
// 32 bytes, little-endian per u32 word, lowercase hex — same encoding as
// swapExactInput's program-id and `spel program-id`.
QByteArray bytes;
bytes.reserve(32);
for (int i = 0; i < 8; ++i) {
const uint32_t word = ammId[i];
bytes.append(static_cast<char>(word & 0xff));
bytes.append(static_cast<char>((word >> 8) & 0xff));
bytes.append(static_cast<char>((word >> 16) & 0xff));
bytes.append(static_cast<char>((word >> 24) & 0xff));
}
return QString::fromLatin1(bytes.toHex());
return result.value.value(QStringLiteral("programId")).toString();
}
ActiveNetworkSnapshot AmmUiBackend::networkSnapshot()
@@ -395,278 +290,19 @@ QByteArray AmmUiBackend::loadAmmElf()
QVariantMap AmmUiBackend::resolvePool(QString defAHex, QString defBHex)
{
QVariantMap out;
out[QStringLiteral("exists")] = false;
// 1. Load the deployed AMM program's ELF. We can't derive this ourselves —
// it must match whatever is actually deployed on the target sequencer.
const QByteArray elf = loadAmmElf();
if (elf.isEmpty()) {
out[QStringLiteral("error")] = QStringLiteral("no_program_bin");
return out;
}
// 2. Derive the AMM program id from the ELF bytes.
ProgramId ammId{};
if (!amm_client_program_id_from_elf(reinterpret_cast<const uint8_t*>(elf.constData()),
static_cast<uintptr_t>(elf.size()), &ammId)) {
qWarning() << "AmmUiBackend::resolvePool: amm_client_program_id_from_elf failed";
out[QStringLiteral("error")] = QStringLiteral("bad_program_bin");
return out;
}
// 3. Read + decode the AMM config account to discover the TWAP oracle
// program id (needed for the current-tick PDA below). No data means the
// AMM hasn't been initialized on this sequencer yet.
uint8_t configPda[32];
amm_client_config_pda(&ammId, &configPda);
const QString configHex = bytes32ToHex(configPda);
// Debug: log every derived account (base58 + hex) and every on-chain read so
// a failed resolve can be traced directly against `spel inspect`.
// account_id_to_base58 is a LOCAL encode (safe even when on-chain reads
// time out).
auto b58 = [this](const QString& hex) {
const QString s = m_logos->logos_execution_zone.account_id_to_base58(hex);
return s.isEmpty() ? hex : QStringLiteral("%1 (hex %2)").arg(s, hex);
};
AMM_DBG() << "[amm-debug] resolvePool: defA=" << b58(defAHex)
<< "defB=" << b58(defBHex);
AMM_DBG() << "[amm-debug] resolvePool: reading config account" << b58(configHex);
const QString configJson = m_logos->logos_execution_zone.get_account_public(configHex);
AMM_DBG() << "[amm-debug] resolvePool: config get_account_public ->"
<< configJson.size() << "chars; raw:" << configJson.left(400);
const QString configDataHex = accountDataHex(configJson);
if (configDataHex.isEmpty()) {
qWarning() << "AmmUiBackend::resolvePool: AMM config read returned no data"
<< "(a failed/timed-out RPC or an uninitialized account)";
out[QStringLiteral("error")] = QStringLiteral("amm_not_initialized");
return out;
}
const QByteArray configBytes = QByteArray::fromHex(configDataHex.toUtf8());
FfiConfigView configView{};
if (!amm_client_decode_config(reinterpret_cast<const uint8_t*>(configBytes.constData()),
static_cast<uintptr_t>(configBytes.size()), &configView)) {
qWarning() << "AmmUiBackend::resolvePool: amm_client_decode_config failed";
out[QStringLiteral("error")] = QStringLiteral("bad_config");
return out;
}
// 4. Derive the pool PDA and read + decode its account. No data means
// there's no pool (or no liquidity) for this token pair yet.
uint8_t defA[32];
uint8_t defB[32];
if (!hexToBytes32(defAHex, defA) || !hexToBytes32(defBHex, defB)) {
qWarning() << "AmmUiBackend::resolvePool: invalid defAHex/defBHex";
out[QStringLiteral("error")] = QStringLiteral("bad_config");
return out;
}
uint8_t poolPda[32];
amm_client_pool_pda(&ammId, &defA, &defB, &poolPda);
const QString poolHex = bytes32ToHex(poolPda);
AMM_DBG() << "[amm-debug] resolvePool: reading pool account" << b58(poolHex);
const QString poolJson = m_logos->logos_execution_zone.get_account_public(poolHex);
AMM_DBG() << "[amm-debug] resolvePool: pool get_account_public ->"
<< poolJson.size() << "chars; raw:" << poolJson.left(400);
const QString poolDataHex = accountDataHex(poolJson);
if (poolDataHex.isEmpty()) {
// Not a warning: this is the normal "no liquidity yet" state.
out[QStringLiteral("error")] = QStringLiteral("no_pool");
return out;
}
const QByteArray poolBytes = QByteArray::fromHex(poolDataHex.toUtf8());
FfiPoolView poolView{};
if (!amm_client_decode_pool(reinterpret_cast<const uint8_t*>(poolBytes.constData()),
static_cast<uintptr_t>(poolBytes.size()), &poolView)) {
qWarning() << "AmmUiBackend::resolvePool: amm_client_decode_pool failed";
out[QStringLiteral("error")] = QStringLiteral("bad_config");
return out;
}
// 5. Derive the TWAP oracle's current-tick PDA for this pool.
uint8_t currentTickPda[32];
amm_client_current_tick_pda(&configView.twap_oracle_program_id, &poolPda, &currentTickPda);
// 6. Assemble the result.
out[QStringLiteral("exists")] = true;
out[QStringLiteral("configHex")] = configHex;
out[QStringLiteral("poolIdHex")] = poolHex;
out[QStringLiteral("defAHex")] = bytes32ToHex(poolView.def_a);
out[QStringLiteral("defBHex")] = bytes32ToHex(poolView.def_b);
out[QStringLiteral("vaultAHex")] = bytes32ToHex(poolView.vault_a);
out[QStringLiteral("vaultBHex")] = bytes32ToHex(poolView.vault_b);
out[QStringLiteral("currentTickHex")] = bytes32ToHex(currentTickPda);
out[QStringLiteral("reserveA")] = u128leToDecimal(poolView.reserve_a);
out[QStringLiteral("reserveB")] = u128leToDecimal(poolView.reserve_b);
out[QStringLiteral("feeBps")] = static_cast<int>(poolView.fees);
AMM_DBG() << "[amm-debug] resolvePool: RESOLVED"
<< "pool=" << b58(poolHex)
<< "poolDefA=" << b58(out[QStringLiteral("defAHex")].toString())
<< "poolDefB=" << b58(out[QStringLiteral("defBHex")].toString())
<< "vaultA=" << b58(out[QStringLiteral("vaultAHex")].toString())
<< "vaultB=" << b58(out[QStringLiteral("vaultBHex")].toString())
<< "currentTick=" << b58(out[QStringLiteral("currentTickHex")].toString())
<< "reserveA=" << out[QStringLiteral("reserveA")].toString()
<< "reserveB=" << out[QStringLiteral("reserveB")].toString()
<< "feeBps=" << out[QStringLiteral("feeBps")].toInt();
return out;
return m_swap->resolvePool(defAHex, defBHex, networkSnapshot());
}
QString AmmUiBackend::swapExactInput(QString defAHex, QString defBHex, QString userInputHoldingHex,
QString userOutputHoldingHex, QString amountInDecimal,
QString minOutDecimal, QString deadlineDecimal)
{
AMM_DBG() << "[amm-debug] swapExactInput: ARGS"
<< "defA=" << defAHex << "defB=" << defBHex
<< "userInputHolding=" << userInputHoldingHex
<< "userOutputHolding=" << userOutputHoldingHex
<< "amountIn=" << amountInDecimal << "minOut=" << minOutDecimal
<< "deadline=" << deadlineDecimal;
// 1. Resolve the pool's PDAs; refuse if there's no pool (or no liquidity)
// for this token pair.
const QVariantMap pool = resolvePool(defAHex, defBHex);
if (!pool.value(QStringLiteral("exists")).toBool()) {
qWarning() << "AmmUiBackend::swapExactInput: no pool for the given token pair";
return QString();
}
// 2. Load the deployed AMM program's ELF (must match resolvePool's — both
// read the same AMM_PROGRAM_BIN — since the instruction is proven against
// this exact binary's image id).
const QByteArray elf = loadAmmElf();
if (elf.isEmpty()) {
qWarning() << "AmmUiBackend::swapExactInput: failed to load AMM_PROGRAM_BIN";
return QString();
}
// 3. Convert amounts/deadline to the wire types amm_client_swap_words expects.
uint8_t amtIn[16];
uint8_t minOut[16];
if (!decimalToU128Le(amountInDecimal, amtIn) || !decimalToU128Le(minOutDecimal, minOut)) {
qWarning() << "AmmUiBackend::swapExactInput: invalid amountInDecimal/minOutDecimal";
return QString();
}
bool deadlineOk = false;
const quint64 deadline = deadlineDecimal.toULongLong(&deadlineOk);
if (!deadlineOk) {
qWarning() << "AmmUiBackend::swapExactInput: invalid deadlineDecimal";
return QString();
}
// 4. Build the RISC0 instruction words for SwapExactInput.
const AmmWords w = amm_client_swap_words(&amtIn, &minOut, deadline);
if (!w.ok) {
qWarning() << "AmmUiBackend::swapExactInput: amm_client_swap_words failed";
return QString();
}
const std::vector<uint32_t> instruction(w.ptr, w.ptr + w.len);
amm_client_free_words(w);
// 5. Assemble the account id list (exact IDL order) and parallel signer
// flags. The swap's direction is derived from the input holding's own
// token, so the two user holdings occupy fixed role slots: user_input_holding
// (the token being sold) then user_output_holding (received). Only the input
// holding signs — the guest debits it via the downstream token transfer; the
// output holding only receives and needs no signature.
const QString clockHex =
m_logos->logos_execution_zone.account_id_from_base58(QString::fromLatin1(CLOCK_ACCOUNT_BASE58));
if (clockHex.isEmpty()) {
qWarning() << "AmmUiBackend::swapExactInput: failed to resolve clock account id";
return QString();
}
const QStringList accounts = {
pool.value(QStringLiteral("configHex")).toString(),
pool.value(QStringLiteral("poolIdHex")).toString(),
pool.value(QStringLiteral("vaultAHex")).toString(),
pool.value(QStringLiteral("vaultBHex")).toString(),
userInputHoldingHex, // user_input_holding — the token being sold (signed)
userOutputHoldingHex, // user_output_holding — receives the token being bought
pool.value(QStringLiteral("currentTickHex")).toString(),
clockHex,
};
const QVariantList signers = { false, false, false, false, true, false, false, false };
// Debug: dump the exact accounts/signers we submit (base58 for `spel`
// comparison), plus the instruction/elf sizes.
auto b58s = [this](const QString& hex) {
const QString s = m_logos->logos_execution_zone.account_id_to_base58(hex);
return s.isEmpty() ? hex : QStringLiteral("%1 (hex %2)").arg(s, hex);
};
static const char* const kSlot[] = { "config", "pool",
"vault_a", "vault_b",
"user_input_holding", "user_output_holding",
"current_tick", "clock" };
AMM_DBG() << "[amm-debug] swapExactInput: SUBMIT"
<< "instruction_words=" << static_cast<int>(instruction.size())
<< "elf_bytes=" << elf.size();
for (int i = 0; i < accounts.size(); ++i) {
AMM_DBG() << "[amm-debug] account[" << i << "]"
<< (i < 8 ? kSlot[i] : "?") << "=" << b58s(accounts[i])
<< "signer=" << (i < signers.size() && signers[i].toBool());
}
// 6. Submit through the wallet module's generic public-transaction entry
// point. The module API takes `instruction` (as bytes — see the note atop
// this file) plus the program's id as hex, not the raw ELF: the program is
// already deployed and referenced by id. There is no program_dependencies
// arg — the sequencer resolves the AMM's chained token/twap calls on-chain.
// Serialize the u32 instruction words to bytes explicitly as little-endian,
// matching the protocol's LE-u32 wire format. A reinterpret_cast of the
// in-memory vector would be host-endian-dependent and byte-swap on a
// big-endian host, making the guest decode a different instruction.
QByteArray instructionBytes;
instructionBytes.reserve(static_cast<int>(instruction.size() * sizeof(uint32_t)));
for (const uint32_t w : instruction) {
instructionBytes.append(static_cast<char>(w & 0xFF));
instructionBytes.append(static_cast<char>((w >> 8) & 0xFF));
instructionBytes.append(static_cast<char>((w >> 16) & 0xFF));
instructionBytes.append(static_cast<char>((w >> 24) & 0xFF));
}
// Derive the deployed AMM program id from the ELF and hex-encode it as the
// canonical 32-byte hex (little-endian per u32 word — matches `spel
// program-id` and the on-chain config's *_program_id fields).
ProgramId ammId;
if (!amm_client_program_id_from_elf(reinterpret_cast<const uint8_t*>(elf.constData()),
static_cast<uintptr_t>(elf.size()), &ammId)) {
qWarning() << "AmmUiBackend::swapExactInput: amm_client_program_id_from_elf failed";
return QString();
}
QByteArray programIdBytes;
programIdBytes.reserve(32);
for (int i = 0; i < 8; ++i) {
const uint32_t w = ammId[i];
programIdBytes.append(static_cast<char>(w & 0xff));
programIdBytes.append(static_cast<char>((w >> 8) & 0xff));
programIdBytes.append(static_cast<char>((w >> 16) & 0xff));
programIdBytes.append(static_cast<char>((w >> 24) & 0xff));
}
const QString programIdHex = QString::fromLatin1(programIdBytes.toHex());
AMM_DBG() << "[amm-debug] swapExactInput: program_id_hex=" << programIdHex;
// `instruction` is a QVariant (bstr); `program_id_hex` is a plain QString
// (tstr) in the generated proxy — pass it directly, not QVariant-wrapped.
const QString resultJson = m_logos->logos_execution_zone.send_generic_public_transaction(
accounts, signers, QVariant::fromValue(instructionBytes), programIdHex);
AMM_DBG() << "[amm-debug] swapExactInput: send_generic_public_transaction ->"
<< resultJson.size() << "chars; raw:" << resultJson.left(600);
const QJsonObject obj = QJsonDocument::fromJson(resultJson.toUtf8()).object();
if (!obj.value(QStringLiteral("success")).toBool()) {
qWarning() << "AmmUiBackend::swapExactInput: transaction failed:" << resultJson;
return QString();
}
refreshBalances();
return obj.value(QStringLiteral("tx_hash")).toString();
const QString txHash = m_swap->swap(defAHex, defBHex, userInputHoldingHex, userOutputHoldingHex,
amountInDecimal, minOutDecimal, deadlineDecimal,
networkSnapshot(), isWalletOpen());
if (!txHash.isEmpty())
refreshBalances();
return txHash;
}
QVariantList AmmUiBackend::tokenList()
+2 -4
View File
@@ -15,15 +15,12 @@
#include "ActiveNetwork.h"
#include "WalletAccountModel.h"
extern "C" {
#include "amm_client_ffi.h"
}
class LogosAPI;
struct LogosModules;
class AmmClient;
class LogosWalletProvider;
class NewPositionRuntime;
class SwapRuntime;
class WalletController;
// Source-side implementation of the AmmUiBackend .rep interface.
@@ -100,6 +97,7 @@ private:
std::unique_ptr<WalletController> m_walletController;
std::unique_ptr<AmmClient> m_ammClient;
std::unique_ptr<NewPositionRuntime> m_newPosition;
std::unique_ptr<SwapRuntime> m_swap;
QVariantMap m_newPositionHints;
+169
View File
@@ -0,0 +1,169 @@
#include "SwapRuntime.h"
#include <QJsonArray>
#include <QStringList>
#include <QVector>
#include "AmmClient.h"
#include "WalletProvider.h"
namespace {
QJsonObject accountReadJson(const WalletAccountRead& read)
{
QJsonObject result {
{ QStringLiteral("id"), read.accountId },
{ QStringLiteral("status"), read.status },
};
if (read.ok()) {
result.insert(QStringLiteral("account"), QJsonObject {
{ QStringLiteral("program_owner"), read.programOwner },
{ QStringLiteral("balance"), read.balanceHex },
{ QStringLiteral("nonce"), read.nonceHex },
{ QStringLiteral("data"), read.dataHex },
});
}
return result;
}
QStringList jsonStringList(const QJsonArray& values)
{
QStringList result;
result.reserve(values.size());
for (const QJsonValue& value : values)
result.append(value.toString());
return result;
}
QVector<bool> jsonBoolList(const QJsonArray& values)
{
QVector<bool> result;
result.reserve(values.size());
for (const QJsonValue& value : values)
result.append(value.toBool());
return result;
}
QVector<quint32> jsonUIntList(const QJsonArray& values)
{
QVector<quint32> result;
result.reserve(values.size());
for (const QJsonValue& value : values)
result.append(static_cast<quint32>(value.toInteger()));
return result;
}
}
SwapRuntime::SwapRuntime(WalletProvider* wallet, AmmClient* client)
: m_wallet(wallet),
m_client(client)
{
}
QJsonObject SwapRuntime::readConfig(const ActiveNetworkSnapshot& network) const
{
const AmmClientResult configResult = m_client->configId(
QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } });
if (!configResult.ok)
return {};
return accountReadJson(m_wallet->readPublicAccount(
configResult.value.value(QStringLiteral("configId")).toString()));
}
QVariantMap SwapRuntime::resolvePool(const QString& tokenInId,
const QString& tokenOutId,
const ActiveNetworkSnapshot& network)
{
const QVariantMap absent { { QStringLiteral("exists"), false } };
// Attaches a diagnostic code the Swap UI surfaces verbatim (SwapCard.qml:
// pool.error). The normal "no pool / no liquidity yet" state stays
// code-less — that's the bare `absent` / resolve_pool `{exists:false}`
// below, which the UI renders as its neutral "no pool" message, not an error.
const auto failure = [](const QString& code) {
return QVariantMap {
{ QStringLiteral("exists"), false },
{ QStringLiteral("error"), code },
};
};
// Network still resolving AMM_PROGRAM_BIN: transient startup state, not a
// diagnostic — surface nothing so the UI keeps its "loading" affordance.
if (network.status != QStringLiteral("ready"))
return absent;
// readConfig returns {} only when the config_id op itself fails (a client
// bug, not a chain state) — as opposed to the config account merely being
// unreadable, which surfaces as swap_pair's `config_unavailable` below.
const QJsonObject config = readConfig(network);
if (config.isEmpty())
return failure(QStringLiteral("backend_error"));
const AmmClientResult pairResult = m_client->swapPair(QJsonObject {
{ QStringLiteral("ammProgramId"), network.ammProgramId },
{ QStringLiteral("tokenInId"), tokenInId },
{ QStringLiteral("tokenOutId"), tokenOutId },
{ QStringLiteral("config"), config },
});
if (!pairResult.ok)
return failure(QStringLiteral("backend_error"));
if (pairResult.value.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) {
// swap_pair reports `config_unavailable` when the AMM config account is
// missing/uninitialized on this network, `same_token_pair` for an
// invalid pair, etc. Propagate its code rather than flattening to
// "no pool", so a misconfigured network is distinguishable from an
// empty one.
const QString code = pairResult.value.value(QStringLiteral("code")).toString();
return failure(code.isEmpty() ? QStringLiteral("backend_error") : code);
}
const QJsonObject pool = accountReadJson(m_wallet->readPublicAccount(
pairResult.value.value(QStringLiteral("poolId")).toString()));
const AmmClientResult resolveResult =
m_client->resolvePool(QJsonObject { { QStringLiteral("pool"), pool } });
if (!resolveResult.ok)
return failure(QStringLiteral("backend_error"));
return resolveResult.value.toVariantMap();
}
QString SwapRuntime::swap(const QString& tokenInId,
const QString& tokenOutId,
const QString& userInputHoldingId,
const QString& userOutputHoldingId,
const QString& amountInDecimal,
const QString& minOutDecimal,
const QString& deadlineMs,
const ActiveNetworkSnapshot& network,
bool walletOpen)
{
if (network.status != QStringLiteral("ready") || !walletOpen)
return {};
const QJsonObject config = readConfig(network);
if (config.isEmpty())
return {};
const AmmClientResult planResult = m_client->swapPlan(QJsonObject {
{ QStringLiteral("ammProgramId"), network.ammProgramId },
{ QStringLiteral("tokenInId"), tokenInId },
{ QStringLiteral("tokenOutId"), tokenOutId },
{ QStringLiteral("config"), config },
{ QStringLiteral("userInputHoldingId"), userInputHoldingId },
{ QStringLiteral("userOutputHoldingId"), userOutputHoldingId },
{ QStringLiteral("amountIn"), amountInDecimal },
{ QStringLiteral("minOut"), minOutDecimal },
{ QStringLiteral("deadlineMs"), deadlineMs },
});
if (!planResult.ok
|| planResult.value.value(QStringLiteral("status")).toString() != QStringLiteral("ready"))
return {};
const QJsonObject plan = planResult.value;
const WalletSubmission submission = m_wallet->submitPublicTransaction({
plan.value(QStringLiteral("programId")).toString(),
jsonStringList(plan.value(QStringLiteral("accountIds")).toArray()),
jsonBoolList(plan.value(QStringLiteral("signingRequirements")).toArray()),
jsonUIntList(plan.value(QStringLiteral("instruction")).toArray()),
});
if (!submission.accepted())
return {};
return submission.nativeHash;
}
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <QJsonObject>
#include <QString>
#include <QVariantMap>
#include "ActiveNetwork.h"
class AmmClient;
class WalletProvider;
// Off-chain orchestration for the Swap view: reads accounts through the wallet,
// drives the amm_client swap ops, and submits the swap transaction. The AMM
// domain logic lives in the amm_client crate; this class only glues account
// reads and submission to it. Mirrors NewPositionRuntime.
class SwapRuntime {
public:
SwapRuntime(WalletProvider* wallet, AmmClient* client);
// { exists, reserveA, reserveB, feeBps } for the (tokenIn, tokenOut) pool.
// reserveA/reserveB follow the pool's canonical def order (the caller maps
// them to sell/buy). exists=false when the pool is absent or has no liquidity.
QVariantMap resolvePool(const QString& tokenInId,
const QString& tokenOutId,
const ActiveNetworkSnapshot& network);
// Builds and submits a SwapExactInput transaction; returns the native tx
// hash on success, an empty string on any failure.
QString swap(const QString& tokenInId,
const QString& tokenOutId,
const QString& userInputHoldingId,
const QString& userOutputHoldingId,
const QString& amountInDecimal,
const QString& minOutDecimal,
const QString& deadlineMs,
const ActiveNetworkSnapshot& network,
bool walletOpen);
private:
// Derives the config account id (config_id) and reads it. Returns an empty
// object only when the config_id op itself fails.
QJsonObject readConfig(const ActiveNetworkSnapshot& network) const;
WalletProvider* m_wallet;
AmmClient* m_client;
};