From 576fb4b709637412bb23d2b25e919e31764568ab Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Sun, 12 Jul 2026 02:15:53 -0300 Subject: [PATCH] feat(amm-ui): implement new position flow #216 Replace prototype data and floating-point quotes with live wallet and chain reads, exact Rust quote planning, and optimistic external-wallet submission. Add canonical display-order mapping, responsive QML states, network-scoped program configuration, and packaged Rust client support. --- Cargo.lock | 41 + Cargo.toml | 1 + apps/amm/CMakeLists.txt | 27 +- apps/amm/client/Cargo.toml | 24 + apps/amm/client/include/amm_client.h | 20 + apps/amm/client/src/account.rs | 138 + apps/amm/client/src/lib.rs | 122 + apps/amm/client/src/model.rs | 158 ++ apps/amm/client/src/protocol.rs | 2445 +++++++++++++++++ apps/amm/config/networks.json | 7 + apps/amm/flake.lock | 68 +- apps/amm/flake.nix | 14 +- apps/amm/metadata.json | 4 +- .../qml/components/liquidity/AmountMath.js | 271 ++ .../NewPositionConfirmationDialog.qml | 330 +-- .../components/liquidity/NewPositionForm.qml | 2311 ++++++++-------- .../components/liquidity/TokenAmountInput.qml | 7 +- .../qml/components/wallet/AccountControl.qml | 461 ++++ apps/amm/qml/pages/LiquidityPage.qml | 396 +-- apps/amm/src/AmmUiBackend.cpp | 1171 ++++---- apps/amm/src/AmmUiBackend.h | 26 +- apps/amm/src/AmmUiBackend.rep | 8 +- flake.lock | 43 + flake.nix | 45 + 24 files changed, 5981 insertions(+), 2157 deletions(-) create mode 100644 apps/amm/client/Cargo.toml create mode 100644 apps/amm/client/include/amm_client.h create mode 100644 apps/amm/client/src/account.rs create mode 100644 apps/amm/client/src/lib.rs create mode 100644 apps/amm/client/src/model.rs create mode 100644 apps/amm/client/src/protocol.rs create mode 100644 apps/amm/config/networks.json create mode 100644 apps/amm/qml/components/liquidity/AmountMath.js create mode 100644 apps/amm/qml/components/wallet/AccountControl.qml create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/Cargo.lock b/Cargo.lock index a4aec91..bc912cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,25 @@ dependencies = [ "risc0-zkvm", ] +[[package]] +name = "amm_client" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "amm_core", + "borsh", + "clock_core", + "hex", + "lee_core", + "pretty_assertions", + "risc0-zkvm", + "serde", + "serde_json", + "sha2", + "token_core", + "twap_oracle_core", +] + [[package]] name = "amm_core" version = "0.1.0" @@ -1193,6 +1212,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.9.0" @@ -2627,6 +2652,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "primitive-types" version = "0.12.2" @@ -4738,6 +4773,12 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 43959d9..c5ce578 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "apps/amm/client", "programs/token/core", "programs/token", "programs/token/methods", diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 38c8081..9f79802 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -1,25 +1,12 @@ -cmake_minimum_required(VERSION 3.21) +cmake_minimum_required(VERSION 3.14) project(AmmUiPlugin LANGUAGES CXX) -find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2) -qt_standard_project_setup(REQUIRES 6.8) - if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT}) include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake) else() message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.") endif() -set(LOGOS_WALLET_SOURCE_DIR - "${CMAKE_CURRENT_SOURCE_DIR}/../shared/wallet" - CACHE PATH "Path to the shared Logos wallet module" -) -set(LOGOS_WALLET_GENERATED_DIR - "${CMAKE_CURRENT_SOURCE_DIR}/generated_code" - CACHE PATH "Path to generated Logos SDK sources" -) -add_subdirectory("${LOGOS_WALLET_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/shared-wallet") - # ui_qml module with a hand-written C++ backend (QtRO .rep view contract + # generated *SimpleSource/*ViewPluginBase). Mirrors the LEZ wallet UI module. logos_module( @@ -31,12 +18,20 @@ logos_module( src/AmmUiPlugin.cpp src/AmmUiBackend.h src/AmmUiBackend.cpp + src/AccountModel.h + src/AccountModel.cpp FIND_PACKAGES Qt6Gui Qt6Network LINK_LIBRARIES Qt6::Gui Qt6::Network - LINK_TARGETS - logos_wallet_access + EXTERNAL_LIBS + amm_client +) + +qt_add_resources(amm_ui_module_plugin amm_ui_config + PREFIX "/amm" + FILES + config/networks.json ) diff --git a/apps/amm/client/Cargo.toml b/apps/amm/client/Cargo.toml new file mode 100644 index 0000000..4078f46 --- /dev/null +++ b/apps/amm/client/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "amm_client" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +alloy-primitives = { version = "1", default-features = false } +amm_core = { workspace = true } +borsh = { workspace = true } +clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc6" } +hex = "0.4" +nssa_core = { workspace = true } +risc0-zkvm = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = "0.10" +token_core = { workspace = true } +twap_oracle_core = { workspace = true } + +[dev-dependencies] +pretty_assertions = "1" diff --git a/apps/amm/client/include/amm_client.h b/apps/amm/client/include/amm_client.h new file mode 100644 index 0000000..83c56a6 --- /dev/null +++ b/apps/amm/client/include/amm_client.h @@ -0,0 +1,20 @@ +#ifndef AMM_CLIENT_H +#define AMM_CLIENT_H + +#ifdef __cplusplus +extern "C" { +#endif + +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); +void amm_free(char *value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/apps/amm/client/src/account.rs b/apps/amm/client/src/account.rs new file mode 100644 index 0000000..9d75a6e --- /dev/null +++ b/apps/amm/client/src/account.rs @@ -0,0 +1,138 @@ +use std::str::FromStr; + +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; + +use crate::model::AccountRead; + +pub fn parse_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!( + "{label} must be 64 lowercase hexadecimal characters" + )); + } + + let mut bytes = [0_u8; 32]; + hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?; + Ok(bytes) +} + +pub fn parse_program_id(value: &str) -> Result { + let bytes = parse_hex_32(value, "program id")?; + let mut program_id = [0_u32; 8]; + for (word, chunk) in program_id.iter_mut().zip(bytes.chunks_exact(4)) { + let chunk: [u8; 4] = chunk + .try_into() + .map_err(|_| String::from("program id word has invalid length"))?; + *word = u32::from_le_bytes(chunk); + } + Ok(program_id) +} + +#[cfg(test)] +pub fn program_id_hex(program_id: ProgramId) -> String { + let bytes = program_id + .iter() + .flat_map(|word| word.to_le_bytes()) + .collect::>(); + hex::encode(bytes) +} + +pub fn program_id_base58(program_id: ProgramId) -> String { + AccountId::new(program_id_bytes(program_id)).to_string() +} + +pub fn program_id_bytes(program_id: ProgramId) -> [u8; 32] { + let mut bytes = [0_u8; 32]; + for (chunk, word) in bytes.chunks_exact_mut(4).zip(program_id) { + chunk.copy_from_slice(&word.to_le_bytes()); + } + bytes +} + +pub fn parse_base58_id(value: &str, label: &str) -> Result { + AccountId::from_str(value).map_err(|error| format!("invalid {label}: {error}")) +} + +pub fn account_id_from_hex(value: &str, label: &str) -> Result { + Ok(AccountId::new(parse_hex_32(value, label)?)) +} + +pub fn account_id_hex(account_id: AccountId) -> String { + hex::encode(account_id.into_value()) +} + +fn parse_le_u128(value: &str, label: &str) -> Result { + if value.len() != 32 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!( + "{label} must be 32 lowercase hexadecimal characters" + )); + } + let mut bytes = [0_u8; 16]; + hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?; + Ok(u128::from_le_bytes(bytes)) +} + +pub fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), String> { + if read.status != "ok" { + return Err(String::from("account read failed")); + } + let account_id = account_id_from_hex(&read.id, "account id")?; + let source = read + .account + .as_ref() + .ok_or_else(|| String::from("successful account read has no account"))?; + let program_owner = parse_program_id(&source.program_owner)?; + let balance = parse_le_u128(&source.balance, "account balance")?; + let nonce = parse_le_u128(&source.nonce, "account nonce")?; + if source.data.len() % 2 != 0 + || !source + .data + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(String::from( + "account data must be lowercase even-length hexadecimal", + )); + } + let data = + hex::decode(&source.data).map_err(|error| format!("invalid account data: {error}"))?; + let data = + Data::try_from(data).map_err(|error| format!("account data is too large: {error}"))?; + + Ok(( + account_id, + Account { + program_owner, + balance, + data, + nonce: Nonce(nonce), + }, + )) +} + +#[cfg(test)] +pub fn account_read(id: AccountId, account: &Account) -> AccountRead { + use crate::model::WalletAccount; + + AccountRead { + id: account_id_hex(id), + status: String::from("ok"), + account: Some(WalletAccount { + program_owner: program_id_hex(account.program_owner), + balance: hex::encode(account.balance.to_le_bytes()), + nonce: hex::encode(account.nonce.0.to_le_bytes()), + data: hex::encode(account.data.as_ref()), + }), + } +} diff --git a/apps/amm/client/src/lib.rs b/apps/amm/client/src/lib.rs new file mode 100644 index 0000000..fa017f7 --- /dev/null +++ b/apps/amm/client/src/lib.rs @@ -0,0 +1,122 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +mod account; +mod model; +mod protocol; + +use std::{ + ffi::{c_char, CStr, CString}, + panic::{catch_unwind, AssertUnwindSafe}, +}; + +use serde::de::DeserializeOwned; + +use crate::model::{ + ConfigIdRequest, ContextRequest, Envelope, PairIdsRequest, PlanRequest, QuoteRequest, + TokenIdsRequest, +}; + +fn call( + request: *const c_char, + operation: fn(T) -> Result, +) -> *mut c_char { + let result = catch_unwind(AssertUnwindSafe(|| { + let request = request_text(request)?; + let request = serde_json::from_str::(&request) + .map_err(|error| format!("invalid request JSON: {error}"))?; + operation(request) + })); + + let envelope = match result { + Ok(Ok(value)) => Envelope::success(value), + Ok(Err(error)) => Envelope::failure(error), + Err(_) => Envelope::failure("internal panic"), + }; + encode_envelope(&envelope) +} + +fn request_text(request: *const c_char) -> Result { + if request.is_null() { + return Err(String::from("request pointer is null")); + } + // SAFETY: The C++ caller passes a live NUL-terminated UTF-8 buffer for this call. + let request = unsafe { CStr::from_ptr(request) }; + request + .to_str() + .map(String::from) + .map_err(|error| format!("request is not UTF-8: {error}")) +} + +fn encode_envelope(envelope: &Envelope) -> *mut c_char { + let json = serde_json::to_string(envelope).unwrap_or_else(|_| { + String::from(r#"{"ok":false,"error":"response serialization failed"}"#) + }); + match CString::new(json) { + Ok(value) => value.into_raw(), + Err(_) => CString::new(r#"{"ok":false,"error":"response contains NUL"}"#) + .map_or(std::ptr::null_mut(), CString::into_raw), + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_config_id(request_json: *const c_char) -> *mut c_char { + call::(request_json, protocol::config_id) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_token_ids(request_json: *const c_char) -> *mut c_char { + call::(request_json, protocol::token_ids) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_pair_ids(request_json: *const c_char) -> *mut c_char { + call::(request_json, protocol::pair_ids) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_context(request_json: *const c_char) -> *mut c_char { + call::(request_json, protocol::context) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_quote(request_json: *const c_char) -> *mut c_char { + call::(request_json, protocol::quote) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_plan(request_json: *const c_char) -> *mut c_char { + call::(request_json, protocol::plan) +} + +/// 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. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn amm_free(value: *mut c_char) { + if value.is_null() { + return; + } + // SAFETY: The caller contract requires a pointer produced by CString::into_raw above. + drop(unsafe { CString::from_raw(value) }); +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + + use super::*; + + #[test] + fn malformed_json_uses_boundary_failure_envelope() { + let request = CString::new("{").unwrap(); + let response = amm_config_id(request.as_ptr()); + assert!(!response.is_null()); + // SAFETY: response was returned by amm_config_id and remains live until amm_free. + let text = unsafe { CStr::from_ptr(response) }.to_str().unwrap(); + let value: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!(value["ok"], false); + // SAFETY: response was allocated by this library and has not been freed. + unsafe { amm_free(response) }; + } +} diff --git a/apps/amm/client/src/model.rs b/apps/amm/client/src/model.rs new file mode 100644 index 0000000..a94617c --- /dev/null +++ b/apps/amm/client/src/model.rs @@ -0,0 +1,158 @@ +use serde::{Deserialize, Serialize}; + +pub const SCHEMA: &str = "new-position.v1"; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountRead { + pub id: String, + pub status: String, + #[serde(default)] + pub account: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct WalletAccount { + pub program_owner: String, + pub balance: String, + pub nonce: String, + pub data: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigIdRequest { + pub amm_program_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenIdsRequest { + pub amm_program_id: String, + pub config: AccountRead, + #[serde(default)] + pub wallet_accounts: Vec, + #[serde(default)] + pub configured_token_ids: Vec, + #[serde(default)] + pub recent_token_ids: Vec, + #[serde(default)] + pub resolved_token_ids: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContextRequest { + pub network_id: String, + pub network_fingerprint: String, + pub amm_program_id: String, + pub wallet_available: bool, + pub config: AccountRead, + #[serde(default)] + pub wallet_accounts: Vec, + #[serde(default)] + pub token_definitions: Vec, + #[serde(default)] + pub configured_token_ids: Vec, + #[serde(default)] + pub recent_token_ids: Vec, + #[serde(default)] + pub resolved_token_ids: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PairIdsRequest { + pub amm_program_id: String, + pub config: AccountRead, + pub token_a_id: String, + pub token_b_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PositionRequest { + pub schema: String, + pub token_a_id: String, + pub token_b_id: String, + pub fee_bps: u32, + #[serde(default)] + pub max_amount_a_raw: Option, + #[serde(default)] + pub max_amount_b_raw: Option, + #[serde(default)] + pub slippage_bps: Option, + #[serde(default)] + pub initial_price_real_raw: Option, + #[serde(default)] + pub deposit_scale_bps: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PairSnapshot { + pub config: AccountRead, + pub token_a: AccountRead, + pub token_b: AccountRead, + pub pool: AccountRead, + pub vault_a: AccountRead, + pub vault_b: AccountRead, + pub lp_definition: AccountRead, + pub lp_lock_holding: AccountRead, + pub current_tick: AccountRead, + pub clock: AccountRead, + pub wallet_available: bool, + #[serde(default)] + pub wallet_accounts: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuoteRequest { + pub network_id: String, + pub network_fingerprint: String, + pub amm_program_id: String, + pub request: PositionRequest, + pub snapshot: PairSnapshot, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanRequest { + pub network_id: String, + pub network_fingerprint: String, + pub amm_program_id: String, + pub request: PositionRequest, + pub snapshot: PairSnapshot, + pub quote_hash: String, + pub now_ms: u64, + #[serde(default)] + pub fresh_lp: Option, +} + +#[derive(Debug, Serialize)] +pub struct Envelope { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl Envelope { + pub fn success(value: serde_json::Value) -> Self { + Self { + ok: true, + value: Some(value), + error: None, + } + } + + pub fn failure(error: impl Into) -> Self { + Self { + ok: false, + value: None, + error: Some(error.into()), + } + } +} diff --git a/apps/amm/client/src/protocol.rs b/apps/amm/client/src/protocol.rs new file mode 100644 index 0000000..b6d451e --- /dev/null +++ b/apps/amm/client/src/protocol.rs @@ -0,0 +1,2445 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use alloy_primitives::U256; +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, + compute_vault_pda, is_supported_fee_tier, isqrt_product, mul_div_floor, spot_price_q64_64, + AmmConfig, PoolDefinition, FEE_BPS_DENOMINATOR, FEE_TIER_BPS_1, FEE_TIER_BPS_100, + FEE_TIER_BPS_30, FEE_TIER_BPS_5, MINIMUM_LIQUIDITY, +}; +use borsh::BorshSerialize; +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use nssa_core::{ + account::{Account, AccountId}, + program::ProgramId, + Commitment, +}; +use serde_json::{json, Value}; +use sha2::{Digest as _, Sha256}; +use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount}; + +use crate::{ + account::{ + account_id_from_hex, account_id_hex, decode_account, parse_base58_id, parse_program_id, + program_id_base58, program_id_bytes, + }, + model::{ + AccountRead, ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, + PositionRequest, QuoteRequest, TokenIdsRequest, SCHEMA, + }, +}; + +const DEADLINE_WINDOW_MS: u64 = 1_200_000; +const DEFAULT_SLIPPAGE_BPS: u32 = 50; +const MAX_SLIPPAGE_BPS: u32 = 5_000; +const HIGH_SLIPPAGE_BPS: u32 = 2_000; +const MIN_DEPOSIT_SCALE_BPS: u32 = 10_000; +const Q64: u128 = 1_u128 << 64; + +#[derive(Clone)] +struct SelectedHolding { + id: AccountId, + balance: u128, + account: Account, +} + +#[derive(Clone)] +enum QuoteBranch { + Missing { + amount_a: u128, + amount_b: u128, + }, + Active { + max_a: u128, + max_b: u128, + minimum_lp: u128, + stored_reversed: bool, + }, +} + +struct QuoteComputation { + value: Value, + quote_hash: Option, + can_submit: bool, + requires_fresh_lp: bool, + pair: Option, + holding_a: Option, + holding_b: Option, + lp_holding: Option, + branch: Option, +} + +#[derive(Clone, Copy)] +struct PairIds { + token_a: AccountId, + token_b: AccountId, + config: AccountId, + pool: AccountId, + vault_a: AccountId, + vault_b: AccountId, + lp_definition: AccountId, + lp_lock_holding: AccountId, + current_tick: AccountId, + clock: AccountId, + token_program: ProgramId, + twap_program: ProgramId, +} + +#[derive(BorshSerialize)] +enum RequestCommitment { + Missing { + initial_price: u128, + deposit_scale_bps: u32, + }, + Active { + max_a: u128, + max_b: u128, + slippage_bps: u32, + }, +} + +#[derive(BorshSerialize)] +struct SourceCommitment { + role: String, + commitment: [u8; 32], +} + +#[derive(BorshSerialize)] +struct FundingCommitment { + token_id: [u8; 32], + holding_id: Option<[u8; 32]>, + available: u128, + requested: u128, +} + +#[derive(BorshSerialize)] +struct QuoteCommitment { + schema: String, + network_id: String, + network_fingerprint: String, + amm_program_id: [u8; 32], + token_a_id: [u8; 32], + token_b_id: [u8; 32], + fee_bps: u32, + pool_status: u8, + request: RequestCommitment, + max_a: u128, + max_b: u128, + actual_a: u128, + actual_b: u128, + expected_lp: u128, + lp_guard: u128, + requires_fresh_lp: bool, + sources: Vec, + funding: Vec, + warnings: Vec, +} + +pub fn config_id(request: ConfigIdRequest) -> Result { + let amm_program = parse_program_id(&request.amm_program_id)?; + Ok(json!({ + "status": "ok", + "configId": account_id_hex(compute_config_pda(amm_program)), + })) +} + +pub fn token_ids(request: TokenIdsRequest) -> Result { + let amm_program = parse_program_id(&request.amm_program_id)?; + let config_id = compute_config_pda(amm_program); + let Ok((read_id, config_account)) = decode_account(&request.config) else { + return Ok(manifest_error("config_unavailable")); + }; + if read_id != config_id || config_account.program_owner != amm_program { + return Ok(manifest_error("config_unavailable")); + } + let Ok(config) = AmmConfig::try_from(&config_account.data) else { + return Ok(manifest_error("config_unavailable")); + }; + + let mut token_ids = BTreeSet::new(); + for id in &request.configured_token_ids { + if let Ok(id) = account_id_from_hex(id, "configured token id") { + token_ids.insert(id); + } + } + for id in request + .recent_token_ids + .iter() + .chain(&request.resolved_token_ids) + { + if let Ok(id) = parse_base58_id(id, "token id") { + token_ids.insert(id); + } + } + for read in &request.wallet_accounts { + let Ok((_, account)) = decode_account(read) else { + continue; + }; + if account.program_owner != config.token_program_id { + continue; + } + if let Ok(TokenHolding::Fungible { definition_id, .. }) = + TokenHolding::try_from(&account.data) + { + token_ids.insert(definition_id); + } + } + + Ok(json!({ + "status": "ok", + "tokenIds": token_ids.into_iter().map(account_id_hex).collect::>(), + })) +} + +fn manifest_error(code: &str) -> Value { + json!({ "status": "error", "code": code, "tokenIds": [] }) +} + +pub fn pair_ids(request: PairIdsRequest) -> Result { + let amm_program = parse_program_id(&request.amm_program_id)?; + let token_a = parse_base58_id(&request.token_a_id, "token A id")?; + let token_b = parse_base58_id(&request.token_b_id, "token B id")?; + if token_a == token_b { + return Ok(json!({ "status": "error", "code": "same_token_pair" })); + } + if !is_canonical_pair(token_a, token_b) { + return Ok(json!({ "status": "error", "code": "non_canonical_pair" })); + } + + let pair = derive_pair(amm_program, token_a, token_b, &request.config)?; + Ok(pair_json(pair)) +} + +fn is_canonical_pair(token_a: AccountId, token_b: AccountId) -> bool { + token_a.value() > token_b.value() +} + +fn derive_pair( + amm_program: ProgramId, + token_a: AccountId, + token_b: AccountId, + config_read: &AccountRead, +) -> Result { + let config_id = compute_config_pda(amm_program); + let (read_id, config_account) = decode_account(config_read)?; + if read_id != config_id + || config_account.program_owner != amm_program + || config_account == Account::default() + { + return Err(String::from("AMM config is unavailable")); + } + let config = AmmConfig::try_from(&config_account.data) + .map_err(|_| String::from("AMM config is invalid"))?; + let pool = compute_pool_pda(amm_program, token_a, token_b); + Ok(PairIds { + token_a, + token_b, + config: config_id, + pool, + vault_a: compute_vault_pda(amm_program, pool, token_a), + vault_b: compute_vault_pda(amm_program, pool, token_b), + lp_definition: compute_liquidity_token_pda(amm_program, pool), + lp_lock_holding: compute_lp_lock_holding_pda(amm_program, pool), + current_tick: compute_current_tick_account_pda(config.twap_oracle_program_id, pool), + clock: CLOCK_01_PROGRAM_ACCOUNT_ID, + token_program: config.token_program_id, + twap_program: config.twap_oracle_program_id, + }) +} + +fn pair_json(pair: PairIds) -> Value { + json!({ + "status": "ok", + "tokenAId": account_id_hex(pair.token_a), + "tokenBId": account_id_hex(pair.token_b), + "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), + "lpDefinitionId": account_id_hex(pair.lp_definition), + "lpLockHoldingId": account_id_hex(pair.lp_lock_holding), + "currentTickId": account_id_hex(pair.current_tick), + "clockId": account_id_hex(pair.clock), + }) +} + +pub fn context(request: ContextRequest) -> Result { + let amm_program = parse_program_id(&request.amm_program_id)?; + let config_id = compute_config_pda(amm_program); + let config = match decode_account(&request.config) { + Ok((id, account)) + if id == config_id + && account.program_owner == amm_program + && account != Account::default() => + { + AmmConfig::try_from(&account.data).ok() + } + _ => None, + }; + let Some(config) = config else { + return Ok(context_error(&request, "config_unavailable")); + }; + + let source_map = token_sources(&request); + let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id); + let mut rows = Vec::new(); + let mut warnings = Vec::new(); + + for (token_id, sources) in source_map { + let read = request + .token_definitions + .iter() + .find(|read| account_id_from_hex(&read.id, "token definition id") == Ok(token_id)); + let Some(read) = read else { + rows.push(unavailable_token_row( + token_id, + sources, + "token_definition_unreadable", + )); + warnings.push(issue( + "token_definition_unreadable", + "Token definition could not be read.", + &[], + json!({ "tokenId": token_id.to_string() }), + )); + continue; + }; + let Ok((read_id, account)) = decode_account(read) else { + rows.push(unavailable_token_row( + token_id, + sources, + "token_definition_unreadable", + )); + warnings.push(issue( + "token_definition_unreadable", + "Token definition could not be read.", + &[], + json!({ "tokenId": token_id.to_string() }), + )); + continue; + }; + if read_id != token_id { + rows.push(unavailable_token_row( + token_id, + sources, + "token_definition_unreadable", + )); + continue; + } + if account.program_owner != config.token_program_id { + rows.push(unavailable_token_row( + token_id, + sources, + "token_program_mismatch", + )); + continue; + } + + let Ok(definition) = TokenDefinition::try_from(&account.data) else { + rows.push(unavailable_token_row( + token_id, + sources, + "token_not_fungible", + )); + continue; + }; + let TokenDefinition::Fungible { + name, + total_supply, + metadata_id, + .. + } = definition + else { + rows.push(unavailable_token_row( + token_id, + sources, + "token_not_fungible", + )); + continue; + }; + + let selected = select_holding(&holdings, token_id); + let mut row = json!({ + "definitionId": token_id.to_string(), + "name": name, + "metadataId": metadata_id.map(|id| id.to_string()), + "totalSupplyRaw": total_supply.to_string(), + "ownerProgramId": program_id_base58(config.token_program_id), + "public": true, + "fungible": true, + "selectable": true, + "status": "available", + "code": "available", + "sources": sources, + }); + if let Some(selected) = selected { + row["holdingId"] = json!(selected.id.to_string()); + row["balanceRaw"] = json!(selected.balance.to_string()); + } + rows.push(row); + } + + rows.sort_by(|left, right| { + let left_holding = left.get("holdingId").is_some(); + let right_holding = right.get("holdingId").is_some(); + right_holding.cmp(&left_holding).then_with(|| { + left["definitionId"] + .as_str() + .cmp(&right["definitionId"].as_str()) + }) + }); + + Ok(json!({ + "schema": SCHEMA, + "status": if request.wallet_available { "ready" } else { "no_wallet" }, + "networkId": request.network_id, + "networkFingerprint": request.network_fingerprint, + "walletAvailable": request.wallet_available, + "minimumLiquidityRaw": MINIMUM_LIQUIDITY.to_string(), + "programIds": { + "amm": program_id_base58(amm_program), + "token": program_id_base58(config.token_program_id), + "twapOracle": program_id_base58(config.twap_oracle_program_id), + }, + "tokens": rows, + "feeTiers": fee_tiers(), + "warnings": warnings, + })) +} + +fn context_error(request: &ContextRequest, code: &str) -> Value { + json!({ + "schema": SCHEMA, + "status": "error", + "code": code, + "networkId": request.network_id, + "networkFingerprint": request.network_fingerprint, + "walletAvailable": request.wallet_available, + "tokens": [], + "feeTiers": fee_tiers(), + "warnings": [], + }) +} + +fn fee_tiers() -> Value { + json!([ + { "feeBps": FEE_TIER_BPS_1, "label": "0.01%", "enabled": true }, + { "feeBps": FEE_TIER_BPS_5, "label": "0.05%", "enabled": true }, + { "feeBps": FEE_TIER_BPS_30, "label": "0.30%", "enabled": true }, + { "feeBps": FEE_TIER_BPS_100, "label": "1.00%", "enabled": true }, + ]) +} + +fn token_sources(request: &ContextRequest) -> BTreeMap> { + let mut sources: BTreeMap> = BTreeMap::new(); + for id in &request.configured_token_ids { + if let Ok(id) = account_id_from_hex(id, "configured token id") { + sources + .entry(id) + .or_default() + .insert(String::from("configured")); + } + } + for (ids, source) in [ + (&request.recent_token_ids, "recent"), + (&request.resolved_token_ids, "resolved"), + ] { + for id in ids { + if let Ok(id) = parse_base58_id(id, "token id") { + sources.entry(id).or_default().insert(String::from(source)); + } + } + } + for read in &request.wallet_accounts { + let Ok((_, account)) = decode_account(read) else { + continue; + }; + if let Ok(TokenHolding::Fungible { definition_id, .. }) = + TokenHolding::try_from(&account.data) + { + sources + .entry(definition_id) + .or_default() + .insert(String::from("holding")); + } + } + sources + .into_iter() + .map(|(id, values)| (id, values.into_iter().collect())) + .collect() +} + +fn unavailable_token_row(token_id: AccountId, sources: Vec, code: &str) -> Value { + json!({ + "definitionId": token_id.to_string(), + "name": "", + "metadataId": Value::Null, + "totalSupplyRaw": "0", + "selectable": false, + "status": code, + "code": code, + "sources": sources, + }) +} + +fn wallet_holdings(reads: &[AccountRead], token_program: ProgramId) -> Vec { + reads + .iter() + .filter_map(|read| { + let (id, account) = decode_account(read).ok()?; + if account.program_owner != token_program { + return None; + } + let TokenHolding::Fungible { balance, .. } = + TokenHolding::try_from(&account.data).ok()? + else { + return None; + }; + Some(SelectedHolding { + id, + balance, + account, + }) + }) + .collect() +} + +fn select_holding( + holdings: &[SelectedHolding], + definition_id: AccountId, +) -> Option { + holdings + .iter() + .filter(|holding| { + matches!( + TokenHolding::try_from(&holding.account.data), + Ok(TokenHolding::Fungible { definition_id: id, .. }) if id == definition_id + ) + }) + .max_by(|left, right| { + left.balance + .cmp(&right.balance) + .then_with(|| right.id.cmp(&left.id)) + }) + .cloned() +} + +pub fn quote(request: QuoteRequest) -> Result { + Ok(compute_quote(&request)?.value) +} + +fn compute_quote(input: &QuoteRequest) -> Result { + if input.request.schema != SCHEMA { + return Ok(fatal_quote( + &input.request, + "unsupported_schema", + &["schema"], + json!({ "received": input.request.schema }), + )); + } + let amm_program = parse_program_id(&input.amm_program_id)?; + let token_a = match parse_base58_id(&input.request.token_a_id, "token A id") { + Ok(id) => id, + Err(_) => { + return Ok(fatal_quote( + &input.request, + "invalid_token_id", + &["tokenAId"], + json!({}), + )) + } + }; + let token_b = match parse_base58_id(&input.request.token_b_id, "token B id") { + Ok(id) => id, + Err(_) => { + return Ok(fatal_quote( + &input.request, + "invalid_token_id", + &["tokenBId"], + json!({}), + )) + } + }; + if token_a == token_b { + return Ok(fatal_quote( + &input.request, + "same_token_pair", + &["tokenAId", "tokenBId"], + json!({}), + )); + } + if !is_canonical_pair(token_a, token_b) { + return Ok(fatal_quote( + &input.request, + "non_canonical_pair", + &["tokenAId", "tokenBId"], + json!({}), + )); + } + if !is_supported_fee_tier(u128::from(input.request.fee_bps)) { + return Ok(fatal_quote( + &input.request, + "invalid_fee_tier", + &["feeBps"], + json!({ "feeBps": input.request.fee_bps }), + )); + } + + let pair = match derive_pair(amm_program, token_a, token_b, &input.snapshot.config) { + Ok(pair) => pair, + Err(_) => { + return Ok(fatal_quote( + &input.request, + "config_unavailable", + &[], + json!({}), + )) + } + }; + if let Some(error) = validate_snapshot_ids(&pair, &input.snapshot) { + return Ok(fatal_quote( + &input.request, + "account_read_failed", + &[], + json!({ "role": error }), + )); + } + if let Some(error) = validate_token_definition( + &input.snapshot.token_a, + token_a, + pair.token_program, + "tokenAId", + ) { + return Ok(error_quote(&input.request, error)); + } + if let Some(error) = validate_token_definition( + &input.snapshot.token_b, + token_b, + pair.token_program, + "tokenBId", + ) { + return Ok(error_quote(&input.request, error)); + } + + let (_, pool_account) = match decode_account(&input.snapshot.pool) { + Ok(value) => value, + Err(_) => { + return Ok(fatal_quote( + &input.request, + "account_read_failed", + &[], + json!({ "role": "pool", "accountId": pair.pool.to_string() }), + )) + } + }; + if pool_account == Account::default() { + compute_missing_quote(input, amm_program, pair) + } else { + compute_active_quote(input, amm_program, pair, pool_account) + } +} + +fn validate_snapshot_ids(pair: &PairIds, snapshot: &PairSnapshot) -> Option<&'static str> { + let expected = [ + (&snapshot.config, pair.config, "config"), + (&snapshot.token_a, pair.token_a, "token_a"), + (&snapshot.token_b, pair.token_b, "token_b"), + (&snapshot.pool, pair.pool, "pool"), + (&snapshot.vault_a, pair.vault_a, "vault_a"), + (&snapshot.vault_b, pair.vault_b, "vault_b"), + (&snapshot.lp_definition, pair.lp_definition, "lp_definition"), + ( + &snapshot.lp_lock_holding, + pair.lp_lock_holding, + "lp_lock_holding", + ), + (&snapshot.current_tick, pair.current_tick, "current_tick"), + (&snapshot.clock, pair.clock, "clock"), + ]; + expected.into_iter().find_map(|(read, id, role)| { + match account_id_from_hex(&read.id, "account id") { + Ok(actual) if actual == id => None, + _ => Some(role), + } + }) +} + +struct DomainError { + code: &'static str, + field: &'static str, + details: Value, +} + +fn validate_token_definition( + read: &AccountRead, + token_id: AccountId, + token_program: ProgramId, + field: &'static str, +) -> Option { + let Ok((id, account)) = decode_account(read) else { + return Some(DomainError { + code: "token_definition_unreadable", + field, + details: json!({ "tokenId": token_id.to_string() }), + }); + }; + if id != token_id { + return Some(DomainError { + code: "token_definition_unreadable", + field, + details: json!({ "tokenId": token_id.to_string() }), + }); + } + if account.program_owner != token_program { + return Some(DomainError { + code: "token_program_mismatch", + field, + details: json!({ "tokenId": token_id.to_string() }), + }); + } + if !matches!( + TokenDefinition::try_from(&account.data), + Ok(TokenDefinition::Fungible { .. }) + ) { + return Some(DomainError { + code: "token_not_fungible", + field, + details: json!({ "tokenId": token_id.to_string() }), + }); + } + None +} + +fn error_quote(request: &PositionRequest, error: DomainError) -> QuoteComputation { + fatal_quote(request, error.code, &[error.field], error.details) +} + +fn compute_missing_quote( + input: &QuoteRequest, + amm_program: ProgramId, + pair: PairIds, +) -> Result { + for (read, role) in [ + (&input.snapshot.vault_a, "vault_a"), + (&input.snapshot.vault_b, "vault_b"), + (&input.snapshot.lp_definition, "lp_definition"), + (&input.snapshot.lp_lock_holding, "lp_lock_holding"), + (&input.snapshot.current_tick, "current_tick"), + ] { + let Ok((_, account)) = decode_account(read) else { + return Ok(fatal_quote( + &input.request, + "account_read_failed", + &[], + json!({ "role": role }), + )); + }; + if account != Account::default() { + return Ok(fatal_quote( + &input.request, + "pool_unavailable", + &[], + json!({ "role": role }), + )); + } + } + if !valid_clock(&input.snapshot.clock, pair.clock) { + return Ok(fatal_quote( + &input.request, + "account_read_failed", + &[], + json!({ "role": "clock" }), + )); + } + + let initial_price = match raw_value( + input.request.initial_price_real_raw.as_deref(), + "initialPriceRealRaw", + ) { + Ok(value) if value > 0 => value, + Ok(_) => { + return Ok(fatal_quote( + &input.request, + "amount_must_be_positive", + &["initialPriceRealRaw"], + json!({}), + )) + } + Err(code) => { + return Ok(fatal_quote( + &input.request, + code, + &["initialPriceRealRaw"], + json!({}), + )) + } + }; + let Some(scale_bps) = input.request.deposit_scale_bps else { + return Ok(fatal_quote( + &input.request, + "invalid_deposit_scale", + &["depositScaleBps"], + json!({}), + )); + }; + if scale_bps < MIN_DEPOSIT_SCALE_BPS { + return Ok(fatal_quote( + &input.request, + "invalid_deposit_scale", + &["depositScaleBps"], + json!({ "minimum": MIN_DEPOSIT_SCALE_BPS }), + )); + } + + let (base_a, base_b) = minimum_opening_pair(initial_price)?; + let Some(amount_a) = scale_amount(base_a, scale_bps) else { + return Ok(fatal_quote( + &input.request, + "amount_overflow", + &["depositScaleBps"], + json!({}), + )); + }; + let Some(amount_b) = scale_amount(base_b, scale_bps) else { + return Ok(fatal_quote( + &input.request, + "amount_overflow", + &["depositScaleBps"], + json!({}), + )); + }; + let initial_lp = isqrt_product(amount_a, amount_b); + if initial_lp <= MINIMUM_LIQUIDITY { + return Ok(fatal_quote( + &input.request, + "amount_too_low", + &["depositScaleBps"], + json!({ "minimumLiquidityRaw": MINIMUM_LIQUIDITY.to_string() }), + )); + } + let expected_lp = initial_lp - MINIMUM_LIQUIDITY; + + let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program); + let holding_a = select_holding(&holdings, pair.token_a); + let holding_b = select_holding(&holdings, pair.token_b); + let funding = funding_issues( + input.snapshot.wallet_available, + pair, + &holding_a, + amount_a, + &holding_b, + amount_b, + "depositScaleBps", + ); + let max_funded_scale = max_funded_scale( + input.snapshot.wallet_available, + base_a, + base_b, + holding_a.as_ref(), + holding_b.as_ref(), + ); + let can_submit = funding.is_empty() && input.snapshot.wallet_available; + let sources = quote_sources_missing(input, &holding_a, &holding_b)?; + let funding_commitment = funding_commitments(pair, &holding_a, amount_a, &holding_b, amount_b); + let commitment = QuoteCommitment { + schema: String::from(SCHEMA), + network_id: input.network_id.clone(), + network_fingerprint: input.network_fingerprint.clone(), + amm_program_id: program_id_bytes(amm_program), + token_a_id: pair.token_a.into_value(), + token_b_id: pair.token_b.into_value(), + fee_bps: input.request.fee_bps, + pool_status: 0, + request: RequestCommitment::Missing { + initial_price, + deposit_scale_bps: scale_bps, + }, + max_a: amount_a, + max_b: amount_b, + actual_a: amount_a, + actual_b: amount_b, + expected_lp, + lp_guard: MINIMUM_LIQUIDITY, + requires_fresh_lp: true, + sources, + funding: funding_commitment, + warnings: Vec::new(), + }; + let quote_hash = hash_quote(&commitment)?; + let preview = missing_preview(pair, amm_program, holding_a.as_ref(), holding_b.as_ref()); + let value = json!({ + "schema": SCHEMA, + "status": "ok", + "canSubmit": can_submit, + "code": if can_submit { "ready" } else { "funding_required" }, + "poolStatus": "missing_pool", + "instruction": "NewDefinition", + "quoteHash": quote_hash, + "feeBps": input.request.fee_bps, + "poolId": pair.pool.to_string(), + "tokenAId": pair.token_a.to_string(), + "tokenBId": pair.token_b.to_string(), + "maxAmountARaw": amount_a.to_string(), + "maxAmountBRaw": amount_b.to_string(), + "actualAmountARaw": amount_a.to_string(), + "actualAmountBRaw": amount_b.to_string(), + "expectedLpRaw": expected_lp.to_string(), + "lockedLpRaw": MINIMUM_LIQUIDITY.to_string(), + "initialPriceRealRaw": initial_price.to_string(), + "depositScaleBps": scale_bps, + "maxFundedScaleBps": max_funded_scale, + "requiresFreshLp": true, + "accountPreview": preview, + "errors": funding, + "warnings": [], + }); + Ok(QuoteComputation { + value, + quote_hash: Some(quote_hash), + can_submit, + requires_fresh_lp: true, + pair: Some(pair), + holding_a, + holding_b, + lp_holding: None, + branch: Some(QuoteBranch::Missing { amount_a, amount_b }), + }) +} + +fn compute_active_quote( + input: &QuoteRequest, + amm_program: ProgramId, + pair: PairIds, + pool_account: Account, +) -> Result { + if pool_account.program_owner != amm_program { + return Ok(fatal_quote( + &input.request, + "pool_unavailable", + &[], + json!({ "reason": "owner_mismatch" }), + )); + } + let Ok(pool) = PoolDefinition::try_from(&pool_account.data) else { + return Ok(fatal_quote( + &input.request, + "pool_unavailable", + &[], + json!({ "reason": "invalid_pool_data" }), + )); + }; + let stored_reversed = if pool.definition_token_a_id == pair.token_a + && pool.definition_token_b_id == pair.token_b + { + false + } else if pool.definition_token_a_id == pair.token_b + && pool.definition_token_b_id == pair.token_a + { + true + } else { + return Ok(fatal_quote( + &input.request, + "pool_unavailable", + &[], + json!({ "reason": "pair_mismatch" }), + )); + }; + if pool.reserve_a == 0 || pool.reserve_b == 0 || pool.liquidity_pool_supply == 0 { + return Ok(fatal_quote(&input.request, "pool_inactive", &[], json!({}))); + } + if pool.fees != u128::from(input.request.fee_bps) { + return Ok(fatal_quote( + &input.request, + "fee_tier_mismatch", + &["feeBps"], + json!({ "poolFeeBps": pool.fees.to_string() }), + )); + } + if !is_supported_fee_tier(pool.fees) { + return Ok(fatal_quote( + &input.request, + "pool_unavailable", + &[], + json!({ "reason": "unsupported_pool_fee" }), + )); + } + + let max_a = match raw_value(input.request.max_amount_a_raw.as_deref(), "maxAmountARaw") { + Ok(value) if value > 0 => value, + Ok(_) => { + return Ok(fatal_quote( + &input.request, + "amount_must_be_positive", + &["maxAmountARaw"], + json!({}), + )) + } + Err(code) => { + return Ok(fatal_quote( + &input.request, + code, + &["maxAmountARaw"], + json!({}), + )) + } + }; + let max_b = match raw_value(input.request.max_amount_b_raw.as_deref(), "maxAmountBRaw") { + Ok(value) if value > 0 => value, + Ok(_) => { + return Ok(fatal_quote( + &input.request, + "amount_must_be_positive", + &["maxAmountBRaw"], + json!({}), + )) + } + Err(code) => { + return Ok(fatal_quote( + &input.request, + code, + &["maxAmountBRaw"], + json!({}), + )) + } + }; + let slippage_bps = input.request.slippage_bps.unwrap_or(DEFAULT_SLIPPAGE_BPS); + if slippage_bps > MAX_SLIPPAGE_BPS { + return Ok(fatal_quote( + &input.request, + "invalid_slippage", + &["slippageBps"], + json!({ "maximum": MAX_SLIPPAGE_BPS }), + )); + } + + let (stored_max_a, stored_max_b) = if stored_reversed { + (max_b, max_a) + } else { + (max_a, max_b) + }; + let ideal_a = mul_div_floor(pool.reserve_a, stored_max_b, pool.reserve_b); + let ideal_b = mul_div_floor(pool.reserve_b, stored_max_a, pool.reserve_a); + let stored_actual_a = stored_max_a.min(ideal_a); + let stored_actual_b = stored_max_b.min(ideal_b); + if stored_actual_a == 0 || stored_actual_b == 0 { + return Ok(fatal_quote( + &input.request, + "amount_too_low", + &["maxAmountARaw", "maxAmountBRaw"], + json!({}), + )); + } + let expected_lp = + mul_div_floor(pool.liquidity_pool_supply, stored_actual_a, pool.reserve_a).min( + mul_div_floor(pool.liquidity_pool_supply, stored_actual_b, pool.reserve_b), + ); + if expected_lp == 0 { + return Ok(fatal_quote( + &input.request, + "amount_too_low", + &["maxAmountARaw", "maxAmountBRaw"], + json!({}), + )); + } + let minimum_lp = mul_div_floor( + expected_lp, + FEE_BPS_DENOMINATOR - u128::from(slippage_bps), + FEE_BPS_DENOMINATOR, + ); + if minimum_lp == 0 { + return Ok(fatal_quote( + &input.request, + "minimum_lp_zero", + &["slippageBps"], + json!({}), + )); + } + let (actual_a, actual_b, reserve_a, reserve_b) = if stored_reversed { + ( + stored_actual_b, + stored_actual_a, + pool.reserve_b, + pool.reserve_a, + ) + } else { + ( + stored_actual_a, + stored_actual_b, + pool.reserve_a, + pool.reserve_b, + ) + }; + + if let Some(error) = validate_active_accounts(input, pair, &pool, stored_reversed) { + return Ok(error); + } + let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program); + let holding_a = select_holding(&holdings, pair.token_a); + let holding_b = select_holding(&holdings, pair.token_b); + let lp_holding = select_holding(&holdings, pair.lp_definition); + let requires_fresh_lp = lp_holding.is_none(); + let funding = funding_issues( + input.snapshot.wallet_available, + pair, + &holding_a, + max_a, + &holding_b, + max_b, + "", + ); + let can_submit = funding.is_empty() && input.snapshot.wallet_available; + let warnings = if slippage_bps >= HIGH_SLIPPAGE_BPS { + vec![issue( + "high_slippage", + "High slippage tolerance.", + &["slippageBps"], + json!({ "slippageBps": slippage_bps }), + )] + } else { + Vec::new() + }; + let warning_codes = warnings + .iter() + .filter_map(|warning| warning["code"].as_str().map(String::from)) + .collect(); + let sources = quote_sources_active(input, &holding_a, &holding_b, &lp_holding)?; + let commitment = QuoteCommitment { + schema: String::from(SCHEMA), + network_id: input.network_id.clone(), + network_fingerprint: input.network_fingerprint.clone(), + amm_program_id: program_id_bytes(amm_program), + token_a_id: pair.token_a.into_value(), + token_b_id: pair.token_b.into_value(), + fee_bps: input.request.fee_bps, + pool_status: 1, + request: RequestCommitment::Active { + max_a, + max_b, + slippage_bps, + }, + max_a, + max_b, + actual_a, + actual_b, + expected_lp, + lp_guard: minimum_lp, + requires_fresh_lp, + sources, + funding: funding_commitments(pair, &holding_a, max_a, &holding_b, max_b), + warnings: warning_codes, + }; + let quote_hash = hash_quote(&commitment)?; + let preview = active_preview( + pair, + amm_program, + &pool, + stored_reversed, + holding_a.as_ref(), + holding_b.as_ref(), + lp_holding.as_ref(), + ); + let value = json!({ + "schema": SCHEMA, + "status": "ok", + "canSubmit": can_submit, + "code": if can_submit { "ready" } else { "funding_required" }, + "poolStatus": "active_pool", + "instruction": "AddLiquidity", + "quoteHash": quote_hash, + "feeBps": input.request.fee_bps, + "poolFeeBps": pool.fees.to_string(), + "poolId": pair.pool.to_string(), + "tokenAId": pair.token_a.to_string(), + "tokenBId": pair.token_b.to_string(), + "maxAmountARaw": max_a.to_string(), + "maxAmountBRaw": max_b.to_string(), + "actualAmountARaw": actual_a.to_string(), + "actualAmountBRaw": actual_b.to_string(), + "reserveARaw": reserve_a.to_string(), + "reserveBRaw": reserve_b.to_string(), + "liquiditySupplyRaw": pool.liquidity_pool_supply.to_string(), + "expectedLpRaw": expected_lp.to_string(), + "minimumLpRaw": minimum_lp.to_string(), + "initialPriceRealRaw": spot_price_q64_64(reserve_a, reserve_b).to_string(), + "requiresFreshLp": requires_fresh_lp, + "accountPreview": preview, + "errors": funding, + "warnings": warnings, + }); + Ok(QuoteComputation { + value, + quote_hash: Some(quote_hash), + can_submit, + requires_fresh_lp, + pair: Some(pair), + holding_a, + holding_b, + lp_holding, + branch: Some(QuoteBranch::Active { + max_a, + max_b, + minimum_lp, + stored_reversed, + }), + }) +} + +fn validate_active_accounts( + input: &QuoteRequest, + pair: PairIds, + pool: &PoolDefinition, + stored_reversed: bool, +) -> Option { + let expected_vault_a = if stored_reversed { + pair.vault_b + } else { + pair.vault_a + }; + let expected_vault_b = if stored_reversed { + pair.vault_a + } else { + pair.vault_b + }; + if pool.vault_a_id != expected_vault_a + || pool.vault_b_id != expected_vault_b + || pool.liquidity_pool_id != pair.lp_definition + { + return Some(fatal_quote( + &input.request, + "pool_unavailable", + &[], + json!({ "reason": "pool_account_mismatch" }), + )); + } + let (canonical_vault_a, canonical_vault_b) = ( + decode_holding(&input.snapshot.vault_a, pair.token_program, pair.token_a), + decode_holding(&input.snapshot.vault_b, pair.token_program, pair.token_b), + ); + let (Ok(vault_a_balance), Ok(vault_b_balance)) = (canonical_vault_a, canonical_vault_b) else { + return Some(fatal_quote( + &input.request, + "account_read_failed", + &[], + json!({ "role": "vault" }), + )); + }; + let (reserve_a, reserve_b) = if stored_reversed { + (pool.reserve_b, pool.reserve_a) + } else { + (pool.reserve_a, pool.reserve_b) + }; + if vault_a_balance < reserve_a || vault_b_balance < reserve_b { + return Some(fatal_quote( + &input.request, + "pool_unavailable", + &[], + json!({ "reason": "vault_below_reserve" }), + )); + } + let Ok((lp_id, lp_account)) = decode_account(&input.snapshot.lp_definition) else { + return Some(fatal_quote( + &input.request, + "account_read_failed", + &[], + json!({ "role": "lp_definition" }), + )); + }; + if lp_id != pair.lp_definition + || lp_account.program_owner != pair.token_program + || !matches!( + TokenDefinition::try_from(&lp_account.data), + Ok(TokenDefinition::Fungible { .. }) + ) + { + return Some(fatal_quote( + &input.request, + "pool_unavailable", + &[], + json!({ "reason": "invalid_lp_definition" }), + )); + } + let Ok((tick_id, tick_account)) = decode_account(&input.snapshot.current_tick) else { + return Some(fatal_quote( + &input.request, + "account_read_failed", + &[], + json!({ "role": "current_tick" }), + )); + }; + if tick_id != pair.current_tick + || tick_account.program_owner != pair.twap_program + || CurrentTickAccount::try_from(&tick_account.data).is_err() + { + return Some(fatal_quote( + &input.request, + "pool_unavailable", + &[], + json!({ "reason": "invalid_current_tick" }), + )); + } + if !valid_clock(&input.snapshot.clock, pair.clock) { + return Some(fatal_quote( + &input.request, + "account_read_failed", + &[], + json!({ "role": "clock" }), + )); + } + None +} + +fn decode_holding( + read: &AccountRead, + token_program: ProgramId, + definition_id: AccountId, +) -> Result { + let (_, account) = decode_account(read)?; + if account.program_owner != token_program { + return Err(String::from("holding owner mismatch")); + } + match TokenHolding::try_from(&account.data) { + Ok(TokenHolding::Fungible { + definition_id: id, + balance, + }) if id == definition_id => Ok(balance), + _ => Err(String::from("invalid fungible holding")), + } +} + +fn valid_clock(read: &AccountRead, expected_id: AccountId) -> bool { + let Ok((id, account)) = decode_account(read) else { + return false; + }; + id == expected_id && borsh::from_slice::(account.data.as_ref()).is_ok() +} + +fn raw_value(value: Option<&str>, _field: &str) -> Result { + let Some(value) = value else { + return Err("amount_required"); + }; + if value.is_empty() { + return Err("amount_required"); + } + if !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err("invalid_raw_amount"); + } + value.parse().map_err(|_| "invalid_raw_amount") +} + +fn minimum_opening_pair(price: u128) -> Result<(u128, u128), String> { + let target_product = U256::from(MINIMUM_LIQUIDITY) + .checked_mul(U256::from(MINIMUM_LIQUIDITY)) + .ok_or_else(|| String::from("minimum liquidity product overflow"))?; + if price >= Q64 { + let amount_a = binary_search_min(1, MINIMUM_LIQUIDITY + 1, |amount_a| { + let amount_b = div_ceil_u256(U256::from(amount_a) * U256::from(price), U256::from(Q64)); + U256::from(amount_a) * amount_b > target_product + }); + let amount_b = div_ceil_u256(U256::from(amount_a) * U256::from(price), U256::from(Q64)); + Ok(( + amount_a, + u128::try_from(amount_b).map_err(|_| String::from("opening amount overflow"))?, + )) + } else { + let amount_b = binary_search_min(1, MINIMUM_LIQUIDITY + 1, |amount_b| { + let amount_a = div_ceil_u256(U256::from(amount_b) * U256::from(Q64), U256::from(price)); + amount_a * U256::from(amount_b) > target_product + }); + let amount_a = div_ceil_u256(U256::from(amount_b) * U256::from(Q64), U256::from(price)); + Ok(( + u128::try_from(amount_a).map_err(|_| String::from("opening amount overflow"))?, + amount_b, + )) + } +} + +fn binary_search_min(mut low: u128, mut high: u128, predicate: impl Fn(u128) -> bool) -> u128 { + while low < high { + let mid = low + (high - low) / 2; + if predicate(mid) { + high = mid; + } else { + low = mid + 1; + } + } + low +} + +fn div_ceil_u256(numerator: U256, denominator: U256) -> U256 { + numerator.div_ceil(denominator) +} + +fn scale_amount(base: u128, scale_bps: u32) -> Option { + let scaled = div_ceil_u256( + U256::from(base) * U256::from(scale_bps), + U256::from(MIN_DEPOSIT_SCALE_BPS), + ); + u128::try_from(scaled).ok() +} + +fn max_funded_scale( + wallet_available: bool, + base_a: u128, + base_b: u128, + holding_a: Option<&SelectedHolding>, + holding_b: Option<&SelectedHolding>, +) -> Value { + if !wallet_available { + return Value::Null; + } + let (Some(holding_a), Some(holding_b)) = (holding_a, holding_b) else { + return json!(0); + }; + let scale_a = + U256::from(holding_a.balance) * U256::from(MIN_DEPOSIT_SCALE_BPS) / U256::from(base_a); + let scale_b = + U256::from(holding_b.balance) * U256::from(MIN_DEPOSIT_SCALE_BPS) / U256::from(base_b); + let funded = scale_a.min(scale_b); + if funded < U256::from(MIN_DEPOSIT_SCALE_BPS) { + return json!(0); + } + let capped = funded.min(U256::from(u32::MAX)); + json!(u32::try_from(capped).unwrap_or(u32::MAX)) +} + +fn funding_issues( + wallet_available: bool, + pair: PairIds, + holding_a: &Option, + requested_a: u128, + holding_b: &Option, + requested_b: u128, + derived_field: &str, +) -> Vec { + if !wallet_available { + return vec![issue( + "no_wallet", + "Connect a wallet to submit.", + &[], + json!({}), + )]; + } + let mut errors = Vec::new(); + for (token_id, holding, requested, field) in [ + ( + pair.token_a, + holding_a, + requested_a, + if derived_field.is_empty() { + "maxAmountARaw" + } else { + derived_field + }, + ), + ( + pair.token_b, + holding_b, + requested_b, + if derived_field.is_empty() { + "maxAmountBRaw" + } else { + derived_field + }, + ), + ] { + let available = holding.as_ref().map_or(0, |value| value.balance); + if available < requested { + errors.push(issue( + "amount_exceeds_balance", + "Amount exceeds the selected wallet holding balance.", + &[field], + json!({ + "requestedRaw": requested.to_string(), + "availableRaw": available.to_string(), + "holdingFound": holding.is_some(), + "tokenId": token_id.to_string(), + }), + )); + } + } + errors +} + +fn funding_commitments( + pair: PairIds, + holding_a: &Option, + requested_a: u128, + holding_b: &Option, + requested_b: u128, +) -> Vec { + [ + (pair.token_a, holding_a, requested_a), + (pair.token_b, holding_b, requested_b), + ] + .into_iter() + .map(|(token_id, holding, requested)| FundingCommitment { + token_id: token_id.into_value(), + holding_id: holding.as_ref().map(|value| value.id.into_value()), + available: holding.as_ref().map_or(0, |value| value.balance), + requested, + }) + .collect() +} + +fn quote_sources_missing( + input: &QuoteRequest, + holding_a: &Option, + holding_b: &Option, +) -> Result, String> { + let mut sources = sources_from_reads(&[ + ("config", &input.snapshot.config), + ("token_a", &input.snapshot.token_a), + ("token_b", &input.snapshot.token_b), + ("pool", &input.snapshot.pool), + ("vault_a", &input.snapshot.vault_a), + ("vault_b", &input.snapshot.vault_b), + ("lp_definition", &input.snapshot.lp_definition), + ("lp_lock_holding", &input.snapshot.lp_lock_holding), + ("current_tick", &input.snapshot.current_tick), + ])?; + append_holding_source(&mut sources, "holding_a", holding_a); + append_holding_source(&mut sources, "holding_b", holding_b); + Ok(sources) +} + +fn quote_sources_active( + input: &QuoteRequest, + holding_a: &Option, + holding_b: &Option, + lp_holding: &Option, +) -> Result, String> { + let mut sources = sources_from_reads(&[ + ("config", &input.snapshot.config), + ("token_a", &input.snapshot.token_a), + ("token_b", &input.snapshot.token_b), + ("pool", &input.snapshot.pool), + ("vault_a", &input.snapshot.vault_a), + ("vault_b", &input.snapshot.vault_b), + ("lp_definition", &input.snapshot.lp_definition), + ("current_tick", &input.snapshot.current_tick), + ])?; + append_holding_source(&mut sources, "holding_a", holding_a); + append_holding_source(&mut sources, "holding_b", holding_b); + append_holding_source(&mut sources, "holding_lp", lp_holding); + Ok(sources) +} + +fn sources_from_reads(reads: &[(&str, &AccountRead)]) -> Result, String> { + reads + .iter() + .map(|(role, read)| { + let (id, account) = decode_account(read)?; + Ok(SourceCommitment { + role: String::from(*role), + commitment: Commitment::new(&id, &account).to_byte_array(), + }) + }) + .collect() +} + +fn append_holding_source( + sources: &mut Vec, + role: &str, + holding: &Option, +) { + if let Some(holding) = holding { + sources.push(SourceCommitment { + role: String::from(role), + commitment: Commitment::new(&holding.id, &holding.account).to_byte_array(), + }); + } +} + +fn hash_quote(commitment: &QuoteCommitment) -> Result { + let bytes = borsh::to_vec(commitment) + .map_err(|error| format!("quote commitment serialization failed: {error}"))?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} + +fn issue(code: &str, message: &str, fields: &[&str], details: Value) -> Value { + json!({ + "code": code, + "message": message, + "details": details, + "recoverable": true, + "blockingFields": fields, + }) +} + +fn fatal_quote( + request: &PositionRequest, + code: &str, + fields: &[&str], + details: Value, +) -> QuoteComputation { + QuoteComputation { + value: json!({ + "schema": SCHEMA, + "status": "error", + "canSubmit": false, + "code": code, + "poolStatus": "unavailable_pool", + "tokenAId": request.token_a_id, + "tokenBId": request.token_b_id, + "accountPreview": [], + "errors": [issue(code, "Position quote is unavailable.", fields, details)], + "warnings": [], + }), + quote_hash: None, + can_submit: false, + requires_fresh_lp: false, + pair: None, + holding_a: None, + holding_b: None, + lp_holding: None, + branch: None, + } +} + +fn preview_row( + order: usize, + role: &str, + account_id: Option, + expected_program: Option, + action: &str, + signer: bool, + init: bool, +) -> Value { + json!({ + "order": order, + "role": role, + "accountId": account_id.map(|id| id.to_string()), + "expectedProgramId": expected_program.map(program_id_base58), + "action": action, + "writable": action != "read", + "signer": signer, + "init": init, + }) +} + +fn missing_preview( + pair: PairIds, + amm_program: ProgramId, + holding_a: Option<&SelectedHolding>, + holding_b: Option<&SelectedHolding>, +) -> Vec { + vec![ + preview_row( + 0, + "config", + Some(pair.config), + Some(amm_program), + "read", + false, + false, + ), + preview_row( + 1, + "pool", + Some(pair.pool), + Some(amm_program), + "create", + false, + true, + ), + preview_row( + 2, + "vault_a", + Some(pair.vault_a), + Some(pair.token_program), + "create", + false, + true, + ), + preview_row( + 3, + "vault_b", + Some(pair.vault_b), + Some(pair.token_program), + "create", + false, + true, + ), + preview_row( + 4, + "lp_definition", + Some(pair.lp_definition), + Some(pair.token_program), + "create", + false, + true, + ), + preview_row( + 5, + "lp_lock_holding", + Some(pair.lp_lock_holding), + Some(pair.token_program), + "create", + false, + true, + ), + preview_row( + 6, + "user_holding_a", + holding_a.map(|value| value.id), + Some(pair.token_program), + "update", + true, + false, + ), + preview_row( + 7, + "user_holding_b", + holding_b.map(|value| value.id), + Some(pair.token_program), + "update", + true, + false, + ), + preview_row( + 8, + "user_holding_lp", + None, + Some(pair.token_program), + "create", + true, + true, + ), + preview_row( + 9, + "current_tick", + Some(pair.current_tick), + Some(pair.twap_program), + "create", + false, + true, + ), + preview_row(10, "clock", Some(pair.clock), None, "read", false, false), + ] +} + +fn active_preview( + pair: PairIds, + amm_program: ProgramId, + pool: &PoolDefinition, + stored_reversed: bool, + holding_a: Option<&SelectedHolding>, + holding_b: Option<&SelectedHolding>, + lp_holding: Option<&SelectedHolding>, +) -> Vec { + let (stored_holding_a, stored_holding_b) = if stored_reversed { + (holding_b, holding_a) + } else { + (holding_a, holding_b) + }; + vec![ + preview_row( + 0, + "config", + Some(pair.config), + Some(amm_program), + "read", + false, + false, + ), + preview_row( + 1, + "pool", + Some(pair.pool), + Some(amm_program), + "update", + false, + false, + ), + preview_row( + 2, + "vault_a", + Some(pool.vault_a_id), + Some(pair.token_program), + "update", + false, + false, + ), + preview_row( + 3, + "vault_b", + Some(pool.vault_b_id), + Some(pair.token_program), + "update", + false, + false, + ), + preview_row( + 4, + "lp_definition", + Some(pair.lp_definition), + Some(pair.token_program), + "update", + false, + false, + ), + preview_row( + 5, + "user_holding_a", + stored_holding_a.map(|value| value.id), + Some(pair.token_program), + "update", + true, + false, + ), + preview_row( + 6, + "user_holding_b", + stored_holding_b.map(|value| value.id), + Some(pair.token_program), + "update", + true, + false, + ), + preview_row( + 7, + "user_holding_lp", + lp_holding.map(|value| value.id), + Some(pair.token_program), + if lp_holding.is_some() { + "update" + } else { + "create" + }, + lp_holding.is_none(), + lp_holding.is_none(), + ), + preview_row( + 8, + "current_tick", + Some(pair.current_tick), + Some(pair.twap_program), + "update", + false, + false, + ), + preview_row(9, "clock", Some(pair.clock), None, "read", false, false), + ] +} + +pub fn plan(input: PlanRequest) -> Result { + let quote_input = QuoteRequest { + network_id: input.network_id, + network_fingerprint: input.network_fingerprint, + amm_program_id: input.amm_program_id.clone(), + request: input.request, + snapshot: input.snapshot, + }; + let quote = compute_quote("e_input)?; + if quote.quote_hash.as_deref() != Some(input.quote_hash.as_str()) { + return Ok(json!({ + "schema": SCHEMA, + "status": "error", + "code": "quote_changed", + "recoverable": true, + "quote": quote.value, + })); + } + if !quote.can_submit { + return Ok(json!({ + "schema": SCHEMA, + "status": "error", + "code": "quote_not_submittable", + "recoverable": true, + "quote": quote.value, + })); + } + let pair = quote + .pair + .ok_or_else(|| String::from("submittable quote has no pair"))?; + let holding_a = quote + .holding_a + .ok_or_else(|| String::from("submittable quote has no token A holding"))?; + let holding_b = quote + .holding_b + .ok_or_else(|| String::from("submittable quote has no token B holding"))?; + let fresh_lp = if quote.requires_fresh_lp { + let Some(read) = input.fresh_lp.as_ref() else { + return Ok(json!({ + "schema": SCHEMA, + "status": "needs_fresh_lp", + "code": "fresh_lp_required", + })); + }; + let Ok((id, account)) = decode_account(read) else { + return Ok(plan_error("wallet_submission_failed")); + }; + if account != Account::default() { + return Ok(plan_error("wallet_submission_failed")); + } + Some(id) + } else { + None + }; + let lp_id = quote + .lp_holding + .as_ref() + .map(|holding| holding.id) + .or(fresh_lp) + .ok_or_else(|| String::from("transaction plan has no LP holding"))?; + let deadline = input + .now_ms + .checked_add(DEADLINE_WINDOW_MS) + .ok_or_else(|| String::from("transaction deadline overflow"))?; + let clock_timestamp = clock_timestamp("e_input.snapshot.clock)?; + if clock_timestamp >= deadline { + return Ok(plan_error("transaction_deadline_expired")); + } + let amm_program = parse_program_id(&input.amm_program_id)?; + + let (account_ids, signing_requirements, instruction) = match quote + .branch + .ok_or_else(|| String::from("submittable quote has no branch"))? + { + QuoteBranch::Missing { amount_a, amount_b } => { + let instruction = amm_core::Instruction::NewDefinition { + token_a_amount: amount_a, + token_b_amount: amount_b, + fees: u128::from(quote_input.request.fee_bps), + deadline, + }; + ( + vec![ + pair.config, + pair.pool, + pair.vault_a, + pair.vault_b, + pair.lp_definition, + pair.lp_lock_holding, + holding_a.id, + holding_b.id, + lp_id, + pair.current_tick, + pair.clock, + ], + vec![ + false, false, false, false, false, false, true, true, true, false, false, + ], + risc0_zkvm::serde::to_vec(&instruction) + .map_err(|error| format!("instruction serialization failed: {error}"))?, + ) + } + QuoteBranch::Active { + max_a, + max_b, + minimum_lp, + stored_reversed, + } => { + let (_, pool_account) = decode_account("e_input.snapshot.pool)?; + let pool = PoolDefinition::try_from(&pool_account.data) + .map_err(|_| String::from("active pool data changed"))?; + let (stored_max_a, stored_max_b, stored_holding_a, stored_holding_b) = + if stored_reversed { + (max_b, max_a, holding_b.id, holding_a.id) + } else { + (max_a, max_b, holding_a.id, holding_b.id) + }; + let instruction = amm_core::Instruction::AddLiquidity { + min_amount_liquidity: minimum_lp, + max_amount_to_add_token_a: stored_max_a, + max_amount_to_add_token_b: stored_max_b, + deadline, + }; + ( + vec![ + pair.config, + pair.pool, + pool.vault_a_id, + pool.vault_b_id, + pair.lp_definition, + stored_holding_a, + stored_holding_b, + lp_id, + pair.current_tick, + pair.clock, + ], + vec![ + false, + false, + false, + false, + false, + true, + true, + fresh_lp.is_some(), + false, + false, + ], + risc0_zkvm::serde::to_vec(&instruction) + .map_err(|error| format!("instruction serialization failed: {error}"))?, + ) + } + }; + + Ok(json!({ + "schema": SCHEMA, + "status": "ready", + "programId": input.amm_program_id, + "accountIds": account_ids.into_iter().map(account_id_hex).collect::>(), + "signingRequirements": signing_requirements, + "instruction": instruction, + "deadlineMs": deadline.to_string(), + "ammProgramId": program_id_base58(amm_program), + })) +} + +fn plan_error(code: &str) -> Value { + json!({ + "schema": SCHEMA, + "status": "error", + "code": code, + "recoverable": true, + }) +} + +fn clock_timestamp(read: &AccountRead) -> Result { + let (_, account) = decode_account(read)?; + borsh::from_slice::(account.data.as_ref()) + .map(|clock| clock.timestamp) + .map_err(|error| format!("invalid clock account: {error}")) +} + +#[cfg(test)] +mod tests { + use nssa_core::account::{Data, Nonce}; + use pretty_assertions::assert_eq; + + use super::*; + use crate::account::account_read; + + const AMM_PROGRAM: ProgramId = [11; 8]; + const TOKEN_PROGRAM: ProgramId = [22; 8]; + const TWAP_PROGRAM: ProgramId = [33; 8]; + + fn account(owner: ProgramId, data: Data) -> Account { + Account { + program_owner: owner, + balance: 0, + data, + nonce: Nonce(0), + } + } + + fn default_read(id: AccountId) -> AccountRead { + account_read(id, &Account::default()) + } + + fn config_account() -> Account { + account( + AMM_PROGRAM, + Data::from(&AmmConfig { + token_program_id: TOKEN_PROGRAM, + twap_oracle_program_id: TWAP_PROGRAM, + authority: AccountId::new([7; 32]), + }), + ) + } + + fn token_definition(name: &str, supply: u128) -> Account { + account( + TOKEN_PROGRAM, + Data::from(&TokenDefinition::Fungible { + name: String::from(name), + total_supply: supply, + metadata_id: None, + authority: None, + }), + ) + } + + fn token_holding(definition_id: AccountId, balance: u128) -> Account { + account( + TOKEN_PROGRAM, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + ) + } + + fn clock_account() -> Account { + account( + [44; 8], + Data::try_from( + ClockAccountData { + block_id: 10, + timestamp: 1_000, + } + .to_bytes(), + ) + .unwrap(), + ) + } + + fn ids() -> PairIds { + let token_a = AccountId::new([2; 32]); + let token_b = AccountId::new([1; 32]); + let config = compute_config_pda(AMM_PROGRAM); + let pool = compute_pool_pda(AMM_PROGRAM, token_a, token_b); + PairIds { + token_a, + token_b, + config, + pool, + vault_a: compute_vault_pda(AMM_PROGRAM, pool, token_a), + vault_b: compute_vault_pda(AMM_PROGRAM, pool, token_b), + lp_definition: compute_liquidity_token_pda(AMM_PROGRAM, pool), + lp_lock_holding: compute_lp_lock_holding_pda(AMM_PROGRAM, pool), + current_tick: compute_current_tick_account_pda(TWAP_PROGRAM, pool), + clock: CLOCK_01_PROGRAM_ACCOUNT_ID, + token_program: TOKEN_PROGRAM, + twap_program: TWAP_PROGRAM, + } + } + + fn base_snapshot(pair: PairIds) -> PairSnapshot { + let holding_a_id = AccountId::new([61; 32]); + let holding_b_id = AccountId::new([62; 32]); + PairSnapshot { + config: account_read(pair.config, &config_account()), + token_a: account_read(pair.token_a, &token_definition("A", 1_000_000)), + token_b: account_read(pair.token_b, &token_definition("B", 2_000_000)), + pool: default_read(pair.pool), + vault_a: default_read(pair.vault_a), + vault_b: default_read(pair.vault_b), + lp_definition: default_read(pair.lp_definition), + lp_lock_holding: default_read(pair.lp_lock_holding), + current_tick: default_read(pair.current_tick), + clock: account_read(pair.clock, &clock_account()), + wallet_available: true, + wallet_accounts: vec![ + account_read(holding_a_id, &token_holding(pair.token_a, 1_000_000)), + account_read(holding_b_id, &token_holding(pair.token_b, 1_000_000)), + ], + } + } + + fn request(pair: PairIds) -> PositionRequest { + assert!(is_canonical_pair(pair.token_a, pair.token_b)); + PositionRequest { + schema: String::from(SCHEMA), + token_a_id: pair.token_a.to_string(), + token_b_id: pair.token_b.to_string(), + fee_bps: 30, + max_amount_a_raw: None, + max_amount_b_raw: None, + slippage_bps: None, + initial_price_real_raw: Some(Q64.to_string()), + deposit_scale_bps: Some(MIN_DEPOSIT_SCALE_BPS), + } + } + + #[test] + fn minimum_pair_exceeds_protocol_lock() { + for price in [1, Q64 / 10, Q64, Q64 * 2, u128::MAX] { + let (amount_a, amount_b) = minimum_opening_pair(price).unwrap(); + assert!(amount_a > 0); + assert!(amount_b > 0); + assert!(isqrt_product(amount_a, amount_b) > MINIMUM_LIQUIDITY); + } + } + + #[test] + fn minimum_pair_is_minimal_on_price_base_side() { + let (amount_a, amount_b) = minimum_opening_pair(Q64 * 2).unwrap(); + assert!(isqrt_product(amount_a, amount_b) > MINIMUM_LIQUIDITY); + let previous_b = div_ceil_u256( + U256::from(amount_a - 1) * U256::from(Q64 * 2), + U256::from(Q64), + ); + assert!( + U256::from(amount_a - 1) * previous_b + <= U256::from(MINIMUM_LIQUIDITY * MINIMUM_LIQUIDITY) + ); + } + + #[test] + fn highest_balance_holding_wins_then_lowest_id() { + let definition = AccountId::new([9; 32]); + let holding = |id: u8, balance| SelectedHolding { + id: AccountId::new([id; 32]), + balance, + account: account( + TOKEN_PROGRAM, + Data::from(&TokenHolding::Fungible { + definition_id: definition, + balance, + }), + ), + }; + let selected = select_holding( + &[holding(4, 10), holding(2, 20), holding(1, 20)], + definition, + ) + .unwrap(); + assert_eq!(selected.id, AccountId::new([1; 32])); + } + + #[test] + fn pair_manifest_uses_canonical_ids_and_current_program_types() { + let token_a = AccountId::new([2; 32]); + let token_b = AccountId::new([1; 32]); + let config_id = compute_config_pda(AMM_PROGRAM); + let config = account( + AMM_PROGRAM, + Data::from(&AmmConfig { + token_program_id: TOKEN_PROGRAM, + twap_oracle_program_id: TWAP_PROGRAM, + authority: AccountId::new([7; 32]), + }), + ); + let result = pair_ids(PairIdsRequest { + amm_program_id: hex::encode(program_id_bytes(AMM_PROGRAM)), + config: account_read(config_id, &config), + token_a_id: token_a.to_string(), + token_b_id: token_b.to_string(), + }) + .unwrap(); + assert_eq!(result["status"], "ok"); + assert_eq!(result["tokenAId"], account_id_hex(token_a)); + assert_eq!(result["tokenBId"], account_id_hex(token_b)); + assert_eq!( + result["poolId"], + account_id_hex(compute_pool_pda(AMM_PROGRAM, token_a, token_b)) + ); + } + + #[test] + fn context_selects_tokens_without_holdings() { + let token_id = AccountId::new([3; 32]); + let config_id = compute_config_pda(AMM_PROGRAM); + let config = account( + AMM_PROGRAM, + Data::from(&AmmConfig { + token_program_id: TOKEN_PROGRAM, + twap_oracle_program_id: TWAP_PROGRAM, + authority: AccountId::new([7; 32]), + }), + ); + let definition = account( + TOKEN_PROGRAM, + Data::from(&TokenDefinition::Fungible { + name: String::from("Token"), + total_supply: 1_000_000, + metadata_id: None, + authority: None, + }), + ); + let value = context(ContextRequest { + network_id: String::from("testnet"), + network_fingerprint: String::from("block10:abc"), + amm_program_id: hex::encode(program_id_bytes(AMM_PROGRAM)), + wallet_available: true, + config: account_read(config_id, &config), + wallet_accounts: Vec::new(), + token_definitions: vec![account_read(token_id, &definition)], + configured_token_ids: vec![account_id_hex(token_id)], + recent_token_ids: Vec::new(), + resolved_token_ids: Vec::new(), + }) + .unwrap(); + assert_eq!(value["tokens"][0]["selectable"], true); + assert!(value["tokens"][0].get("holdingId").is_none()); + } + + #[test] + fn missing_pool_snapshot_defaults_remain_real_accounts() { + let id = AccountId::new([5; 32]); + let read = default_read(id); + let (decoded_id, decoded) = decode_account(&read).unwrap(); + assert_eq!(decoded_id, id); + assert_eq!(decoded, Account::default()); + } + + #[test] + fn missing_pool_quote_and_plan_use_current_account_order() { + let pair = ids(); + let snapshot = base_snapshot(pair); + let request = request(pair); + let quote_value = quote(QuoteRequest { + network_id: String::from("devnet"), + network_fingerprint: String::from("channel:test"), + amm_program_id: hex::encode(program_id_bytes(AMM_PROGRAM)), + request: request.clone(), + snapshot: snapshot.clone(), + }) + .unwrap(); + assert_eq!(quote_value["status"], "ok"); + assert_eq!(quote_value["poolStatus"], "missing_pool"); + assert_eq!(quote_value["canSubmit"], true); + assert_eq!(quote_value["accountPreview"].as_array().unwrap().len(), 11); + let quote_hash = quote_value["quoteHash"].as_str().unwrap().to_owned(); + + let fresh_lp = AccountId::new([63; 32]); + let plan_value = plan(PlanRequest { + network_id: String::from("devnet"), + network_fingerprint: String::from("channel:test"), + amm_program_id: hex::encode(program_id_bytes(AMM_PROGRAM)), + request, + snapshot, + quote_hash, + now_ms: 2_000, + fresh_lp: Some(default_read(fresh_lp)), + }) + .unwrap(); + assert_eq!(plan_value["status"], "ready"); + assert_eq!(plan_value["accountIds"].as_array().unwrap().len(), 11); + assert_eq!(plan_value["accountIds"][8], account_id_hex(fresh_lp)); + assert_eq!(plan_value["signingRequirements"][6], true); + assert_eq!(plan_value["signingRequirements"][7], true); + assert_eq!(plan_value["signingRequirements"][8], true); + } + + #[test] + fn advancing_clock_does_not_stale_quote() { + let pair = ids(); + let request = request(pair); + let snapshot = base_snapshot(pair); + let quote_value = quote(QuoteRequest { + network_id: String::from("testnet"), + network_fingerprint: String::from("block10:test"), + amm_program_id: hex::encode(program_id_bytes(AMM_PROGRAM)), + request: request.clone(), + snapshot: snapshot.clone(), + }) + .unwrap(); + + let mut advanced = snapshot; + advanced.clock = account_read( + pair.clock, + &account( + [44; 8], + Data::try_from( + ClockAccountData { + block_id: 11, + timestamp: 1_500, + } + .to_bytes(), + ) + .unwrap(), + ), + ); + let plan_value = plan(PlanRequest { + network_id: String::from("testnet"), + network_fingerprint: String::from("block10:test"), + amm_program_id: hex::encode(program_id_bytes(AMM_PROGRAM)), + request, + snapshot: advanced, + quote_hash: quote_value["quoteHash"].as_str().unwrap().to_owned(), + now_ms: 2_000, + fresh_lp: Some(default_read(AccountId::new([63; 32]))), + }) + .unwrap(); + + assert_eq!(plan_value["status"], "ready"); + } + + #[test] + fn active_pool_quote_uses_ratio_and_existing_lp_holding() { + let pair = ids(); + let mut snapshot = base_snapshot(pair); + let pool = PoolDefinition { + definition_token_a_id: pair.token_a, + definition_token_b_id: pair.token_b, + vault_a_id: pair.vault_a, + vault_b_id: pair.vault_b, + liquidity_pool_id: pair.lp_definition, + liquidity_pool_supply: 10_000, + reserve_a: 10_000, + reserve_b: 20_000, + fees: 30, + }; + snapshot.pool = account_read(pair.pool, &account(AMM_PROGRAM, Data::from(&pool))); + snapshot.vault_a = account_read(pair.vault_a, &token_holding(pair.token_a, pool.reserve_a)); + snapshot.vault_b = account_read(pair.vault_b, &token_holding(pair.token_b, pool.reserve_b)); + snapshot.lp_definition = account_read( + pair.lp_definition, + &account( + TOKEN_PROGRAM, + Data::from(&TokenDefinition::Fungible { + name: String::from("LP"), + total_supply: pool.liquidity_pool_supply, + metadata_id: None, + authority: Some(pair.lp_definition), + }), + ), + ); + snapshot.current_tick = account_read( + pair.current_tick, + &account( + TWAP_PROGRAM, + Data::from(&CurrentTickAccount { + tick: 0, + last_updated: 1_000, + }), + ), + ); + let lp_holding = AccountId::new([64; 32]); + snapshot.wallet_accounts.push(account_read( + lp_holding, + &token_holding(pair.lp_definition, 500), + )); + let mut request = request(pair); + request.initial_price_real_raw = None; + request.deposit_scale_bps = None; + request.max_amount_a_raw = Some(String::from("1000")); + request.max_amount_b_raw = Some(String::from("3000")); + request.slippage_bps = Some(50); + + let quote_value = quote(QuoteRequest { + network_id: String::from("testnet"), + network_fingerprint: String::from("block10:test"), + amm_program_id: hex::encode(program_id_bytes(AMM_PROGRAM)), + request: request.clone(), + snapshot: snapshot.clone(), + }) + .unwrap(); + assert_eq!(quote_value["poolStatus"], "active_pool"); + assert_eq!(quote_value["actualAmountARaw"], "1000"); + assert_eq!(quote_value["actualAmountBRaw"], "2000"); + assert_eq!(quote_value["expectedLpRaw"], "1000"); + assert_eq!(quote_value["minimumLpRaw"], "995"); + assert_eq!(quote_value["requiresFreshLp"], false); + + let plan_value = plan(PlanRequest { + network_id: String::from("testnet"), + network_fingerprint: String::from("block10:test"), + amm_program_id: hex::encode(program_id_bytes(AMM_PROGRAM)), + request, + snapshot, + quote_hash: quote_value["quoteHash"].as_str().unwrap().to_owned(), + now_ms: 2_000, + fresh_lp: None, + }) + .unwrap(); + assert_eq!(plan_value["status"], "ready"); + assert_eq!(plan_value["accountIds"].as_array().unwrap().len(), 10); + assert_eq!(plan_value["accountIds"][7], account_id_hex(lp_holding)); + assert_eq!(plan_value["signingRequirements"][7], false); + } + + #[test] + fn stale_hash_returns_recomputed_quote_without_plan() { + let pair = ids(); + let value = plan(PlanRequest { + network_id: String::from("devnet"), + network_fingerprint: String::from("channel:test"), + amm_program_id: hex::encode(program_id_bytes(AMM_PROGRAM)), + request: request(pair), + snapshot: base_snapshot(pair), + quote_hash: String::from("sha256:deadbeef"), + now_ms: 2_000, + fresh_lp: None, + }) + .unwrap(); + assert_eq!(value["status"], "error"); + assert_eq!(value["code"], "quote_changed"); + assert_eq!(value["quote"]["status"], "ok"); + } +} diff --git a/apps/amm/config/networks.json b/apps/amm/config/networks.json new file mode 100644 index 0000000..d7b7771 --- /dev/null +++ b/apps/amm/config/networks.json @@ -0,0 +1,7 @@ +{ + "testnet": { + "checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a", + "ammProgramId": "d0c4c0ea9384928a97b65a63fcf9641639bb802e4e91819743360708ff12101e", + "tokenDefinitionIds": [] + } +} diff --git a/apps/amm/flake.lock b/apps/amm/flake.lock index e0014b3..7e483ac 100644 --- a/apps/amm/flake.lock +++ b/apps/amm/flake.lock @@ -1,5 +1,35 @@ { "nodes": { + "amm_client": { + "inputs": { + "crane": "crane", + "nixpkgs": "nixpkgs" + }, + "locked": { + "path": "../..", + "type": "path" + }, + "original": { + "path": "../..", + "type": "path" + }, + "parent": [] + }, + "crane": { + "locked": { + "lastModified": 1783203018, + "narHash": "sha256-G6R9IT/xwFuu+CYBWDUAok6AdC4ERC4ZfPPFtEpxnZE=", + "owner": "ipetkov", + "repo": "crane", + "rev": "80db5bdc391be8a1794f6d8a2d56e3a84ebcede2", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, "crane_2": { "locked": { "lastModified": 1780532242, @@ -14438,11 +14468,11 @@ ] }, "locked": { - "lastModified": 1782920676, - "narHash": "sha256-Vb81kiYbi8yYDZbUSW6v7QhV6uO/pj7F+lCAW1coAsY=", + "lastModified": 1782082589, + "narHash": "sha256-Qeqxp0HYb3oTpmfD5YlFPJzpAJa7Ilb9o4sMeVvmHRI=", "owner": "logos-co", "repo": "logos-protocol", - "rev": "d7ad26d369c4e464a99f2a357f10c5947c7174e1", + "rev": "315a3a2e0af61bc47aad5601ee44cd7689975820", "type": "github" }, "original": { @@ -20096,6 +20126,22 @@ "type": "github" } }, + "nixpkgs": { + "locked": { + "lastModified": 1783776592, + "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_10": { "locked": { "lastModified": 1759036355, @@ -26762,23 +26808,11 @@ }, "root": { "inputs": { + "amm_client": "amm_client", "logos-module-builder": "logos-module-builder", - "logos_execution_zone": "logos_execution_zone", - "shared_wallet": "shared_wallet" + "logos_execution_zone": "logos_execution_zone" } }, - "shared_wallet": { - "flake": false, - "locked": { - "path": "../shared/wallet", - "type": "path" - }, - "original": { - "path": "../shared/wallet", - "type": "path" - }, - "parent": [] - }, "rust-overlay": { "inputs": { "nixpkgs": [ diff --git a/apps/amm/flake.nix b/apps/amm/flake.nix index 7f345e1..c8eb4e0 100644 --- a/apps/amm/flake.nix +++ b/apps/amm/flake.nix @@ -10,11 +10,11 @@ flake = false; }; - # Core wallet module (the LEZ wallet FFI Qt plugin). The input name must - # match the metadata.json `dependencies` entry so the builder can resolve - # it as a module dependency. This revision exposes generic transaction - # submission by deployed program ID. + # Core wallet module. This revision submits generic public transactions by + # deployed program ID and keeps the `logos_execution_zone` module identity. logos_execution_zone.url = "github:logos-blockchain/logos-execution-zone-module?rev=d70225ced646934d2294fd9e8f8b03615c104b80"; + + amm_client.url = "path:../.."; }; outputs = inputs@{ logos-module-builder, shared_wallet, ... }: @@ -25,6 +25,12 @@ preConfigure = '' cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${shared_wallet}") ''; + externalLibInputs = { + amm_client = { + input = inputs.amm_client; + packages.default = "amm_client"; + }; + }; postInstall = '' # The builder installs the view under lib/qml after this hook. Its # import descriptor points back to this compiled shared QML module. diff --git a/apps/amm/metadata.json b/apps/amm/metadata.json index 7250885..fc147f6 100644 --- a/apps/amm/metadata.json +++ b/apps/amm/metadata.json @@ -14,7 +14,9 @@ "build": [], "runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"] }, - "external_libraries": [], + "external_libraries": [ + { "name": "amm_client" } + ], "cmake": { "find_packages": [], "extra_sources": [], diff --git a/apps/amm/qml/components/liquidity/AmountMath.js b/apps/amm/qml/components/liquidity/AmountMath.js new file mode 100644 index 0000000..5ce9f02 --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmountMath.js @@ -0,0 +1,271 @@ +.pragma library + +var base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +var U128_MAX = "340282366920938463463374607431768211455" +var U32_MAX = "4294967295" +var Q64 = "18446744073709551616" + +function normalize(value) { + var text = String(value === undefined || value === null ? "" : value) + var index = 0 + while (index + 1 < text.length && text.charAt(index) === "0") + ++index + return text.length > 0 ? text.slice(index) : "0" +} + +function isUnsigned(value) { + return /^[0-9]+$/.test(String(value)) +} + +function compare(left, right) { + var a = normalize(left) + var b = normalize(right) + if (a.length !== b.length) + return a.length < b.length ? -1 : 1 + if (a === b) + return 0 + return a < b ? -1 : 1 +} + +function compareBase58Ids(left, right) { + var leftStart = 0 + var rightStart = 0 + while (leftStart < left.length && left[leftStart] === "1") + ++leftStart + while (rightStart < right.length && right[rightStart] === "1") + ++rightStart + + var leftLength = left.length - leftStart + var rightLength = right.length - rightStart + if (leftLength !== rightLength) + return leftLength < rightLength ? -1 : 1 + + for (var i = 0; i < leftLength; ++i) { + var leftDigit = base58Alphabet.indexOf(left[leftStart + i]) + var rightDigit = base58Alphabet.indexOf(right[rightStart + i]) + if (leftDigit < 0 || rightDigit < 0) + return 0 + if (leftDigit !== rightDigit) + return leftDigit < rightDigit ? -1 : 1 + } + return 0 +} + +function subtract(left, right) { + var a = normalize(left) + var b = normalize(right) + var result = [] + var borrow = 0 + var j = b.length - 1 + for (var i = a.length - 1; i >= 0; --i) { + var digit = Number(a.charAt(i)) - borrow - (j >= 0 ? Number(b.charAt(j)) : 0) + if (digit < 0) { + digit += 10 + borrow = 1 + } else { + borrow = 0 + } + result.unshift(String(digit)) + --j + } + return normalize(result.join("")) +} + +function multiply(left, right) { + var a = normalize(left) + var b = normalize(right) + if (a === "0" || b === "0") + return "0" + + var result = [] + for (var z = 0; z < a.length + b.length; ++z) + result.push(0) + + for (var i = a.length - 1; i >= 0; --i) { + for (var j = b.length - 1; j >= 0; --j) { + var low = i + j + 1 + var high = i + j + var product = Number(a.charAt(i)) * Number(b.charAt(j)) + result[low] + result[low] = product % 10 + result[high] += Math.floor(product / 10) + } + } + return normalize(result.join("")) +} + +function divide(left, right) { + var numerator = normalize(left) + var denominator = normalize(right) + if (denominator === "0") + return { "quotient": "0", "remainder": numerator, "valid": false } + + var quotient = "" + var remainder = "0" + for (var i = 0; i < numerator.length; ++i) { + remainder = normalize((remainder === "0" ? "" : remainder) + numerator.charAt(i)) + var digit = 0 + while (compare(remainder, denominator) >= 0) { + remainder = subtract(remainder, denominator) + ++digit + } + quotient += String(digit) + } + return { "quotient": normalize(quotient), "remainder": remainder, "valid": true } +} + +function divideCeil(left, right) { + var result = divide(left, right) + if (!result.valid || result.remainder === "0") + return result.quotient + return addSmall(result.quotient, 1) +} + +function addSmall(value, increment) { + var digits = normalize(value).split("") + var carry = increment + for (var i = digits.length - 1; i >= 0 && carry > 0; --i) { + var sum = Number(digits[i]) + carry + digits[i] = String(sum % 10) + carry = Math.floor(sum / 10) + } + while (carry > 0) { + digits.unshift(String(carry % 10)) + carry = Math.floor(carry / 10) + } + return normalize(digits.join("")) +} + +function pow10(exponent) { + var value = "1" + for (var i = 0; i < exponent; ++i) + value += "0" + return value +} + +function implyDecimals(totalSupplyRaw) { + if (!isUnsigned(totalSupplyRaw)) + return 0 + var digits = normalize(totalSupplyRaw).length + if (digits <= 9) + return 0 + if (digits <= 14) + return 6 + if (digits <= 21) + return 9 + return 18 +} + +function parseHuman(text, decimals) { + var value = String(text) + if (value.length === 0) + return { "ok": false, "code": "amount_required", "raw": "" } + var match = /^(0|[1-9][0-9]*)(?:\.([0-9]*))?$/.exec(value) + if (!match) + return { "ok": false, "code": "invalid_amount_format", "raw": "" } + + var fraction = match[2] || "" + if (fraction.length > decimals) + return { "ok": false, "code": "invalid_amount_precision", "raw": "" } + var raw = normalize(match[1] + fraction + pow10(decimals - fraction.length).slice(1)) + if (compare(raw, U128_MAX) > 0) + return { "ok": false, "code": "invalid_raw_amount", "raw": "" } + return { "ok": true, "code": "", "raw": raw } +} + +function formatRaw(rawValue, decimals) { + if (!isUnsigned(rawValue)) + return "" + var raw = normalize(rawValue) + if (decimals === 0) + return raw + while (raw.length <= decimals) + raw = "0" + raw + var whole = raw.slice(0, raw.length - decimals) + var fraction = raw.slice(raw.length - decimals).replace(/0+$/, "") + return fraction.length > 0 ? whole + "." + fraction : whole +} + +function parsePrice(text) { + var value = String(text) + if (value.length === 0) + return { "ok": false, "code": "amount_required" } + var match = /^(0|[1-9][0-9]*)(?:\.([0-9]*))?$/.exec(value) + if (!match) + return { "ok": false, "code": "invalid_amount_format" } + var fraction = match[2] || "" + var numerator = normalize(match[1] + fraction) + if (numerator === "0") + return { "ok": false, "code": "amount_must_be_positive" } + return { + "ok": true, + "code": "", + "numerator": numerator, + "scale": fraction.length + } +} + +function priceToQ64(text, canonicalDecimalsA, canonicalDecimalsB, displayIsCanonical) { + var parsed = parsePrice(text) + if (!parsed.ok) + return { "ok": false, "code": parsed.code, "raw": "" } + + var numerator + var denominator + if (displayIsCanonical) { + numerator = multiply(multiply(parsed.numerator, Q64), pow10(canonicalDecimalsB)) + denominator = pow10(parsed.scale + canonicalDecimalsA) + } else { + numerator = multiply(multiply(pow10(parsed.scale), Q64), pow10(canonicalDecimalsB)) + denominator = multiply(parsed.numerator, pow10(canonicalDecimalsA)) + } + var raw = divide(numerator, denominator).quotient + if (raw === "0") + return { "ok": false, "code": "amount_too_low", "raw": "" } + if (compare(raw, U128_MAX) > 0) + return { "ok": false, "code": "invalid_raw_amount", "raw": "" } + return { "ok": true, "code": "", "raw": raw } +} + +function formatRatio(numerator, denominator, precision) { + var result = divide(numerator, denominator) + if (!result.valid) + return "" + var fraction = "" + var remainder = result.remainder + for (var i = 0; i < precision && remainder !== "0"; ++i) { + var digit = divide(multiply(remainder, "10"), denominator) + fraction += digit.quotient + remainder = digit.remainder + } + fraction = fraction.replace(/0+$/, "") + return fraction.length > 0 ? result.quotient + "." + fraction : result.quotient +} + +function priceFromQ64(rawValue, canonicalDecimalsA, canonicalDecimalsB, displayIsCanonical) { + if (!isUnsigned(rawValue) || normalize(rawValue) === "0") + return "" + var numerator + var denominator + if (displayIsCanonical) { + numerator = multiply(rawValue, pow10(canonicalDecimalsA)) + denominator = multiply(Q64, pow10(canonicalDecimalsB)) + } else { + numerator = multiply(Q64, pow10(canonicalDecimalsB)) + denominator = multiply(rawValue, pow10(canonicalDecimalsA)) + } + return formatRatio(numerator, denominator, 12) +} + +function mulDivFloor(left, right, denominator) { + return divide(multiply(left, right), denominator).quotient +} + +function mulDivCeil(left, right, denominator) { + return divideCeil(multiply(left, right), denominator) +} + +function toU32(value) { + if (!isUnsigned(value) || compare(value, U32_MAX) > 0) + return -1 + return Number(normalize(value)) +} diff --git a/apps/amm/qml/components/liquidity/NewPositionConfirmationDialog.qml b/apps/amm/qml/components/liquidity/NewPositionConfirmationDialog.qml index e217b63..7d460b3 100644 --- a/apps/amm/qml/components/liquidity/NewPositionConfirmationDialog.qml +++ b/apps/amm/qml/components/liquidity/NewPositionConfirmationDialog.qml @@ -1,6 +1,11 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Logos.Controls +import Logos.Theme FocusScope { id: root @@ -8,45 +13,50 @@ FocusScope { property var snapshot: ({}) property bool open: false property bool busy: false - property string errorText: "" signal canceled signal confirmed(var snapshot) - visible: root.open - z: 20 + visible: open + focus: open + z: 200 - Keys.onEscapePressed: root.cancel() + Keys.onEscapePressed: function(event) { + event.accepted = true + if (!root.busy) + root.cancel() + } function openWithSnapshot(nextSnapshot) { - root.snapshot = nextSnapshot; - root.errorText = ""; - root.open = true; - root.forceActiveFocus(); - cancelButton.forceActiveFocus(); + root.snapshot = nextSnapshot || ({}) + root.open = true + root.forceActiveFocus() } function cancel() { if (root.busy) - return; - root.open = false; - root.canceled(); + return + root.open = false + root.canceled() } function confirm() { - if (root.busy) - return; - root.errorText = ""; - root.confirmed(root.snapshot); + if (!root.busy) + root.confirmed(root.snapshot) } function closeAfterSuccess() { - root.open = false; + root.open = false + root.snapshot = ({}) + } + + function closeAfterFailure() { + root.open = false } Rectangle { anchors.fill: parent - color: "#99000000" + color: "#B0000000" MouseArea { anchors.fill: parent @@ -54,212 +64,138 @@ FocusScope { } Rectangle { - id: panel - anchors.centerIn: parent - color: "#1D1D1D" - implicitHeight: dialogContent.implicitHeight + 24 + width: Math.min(520, parent.width - 32) + implicitHeight: dialogContent.implicitHeight + 40 radius: 8 - width: Math.max(0, Math.min(420, root.width - 32)) - border.color: "#343434" + color: Theme.palette.backgroundElevated + border.color: Theme.palette.borderSecondary border.width: 1 ColumnLayout { id: dialogContent anchors.fill: parent - anchors.margins: 12 - spacing: 12 - - Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 16 - text: qsTr("Confirm new position") + anchors.margins: 20 + spacing: 14 + RowLayout { Layout.fillWidth: true - } - - Rectangle { - color: "#151515" - radius: 8 - border.color: "#343434" - border.width: 1 - - Layout.fillWidth: true - Layout.preferredHeight: summaryLayout.implicitHeight + 20 - - ColumnLayout { - id: summaryLayout - - anchors.fill: parent - anchors.margins: 10 - spacing: 8 - - SummaryRow { - label: qsTr("Pair") - value: root.snapshot.pairText || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Instruction") - value: root.snapshot.instructionText || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Fee tier") - value: root.snapshot.feeLabel || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "") - value: root.snapshot.depositA || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "") - value: root.snapshot.depositB || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Expected LP") - value: root.snapshot.expectedLp || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Minimum LP") - value: root.snapshot.minimumLp || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Quote hash") - value: root.snapshot.shortQuoteHash || "" - - Layout.fillWidth: true - } - } - } - - Text { - color: "#A9A098" - font.pixelSize: 12 - lineHeight: 1.25 - text: qsTr("Submit will re-quote against current wallet and chain state before dispatch.") - wrapMode: Text.WordWrap - - Layout.fillWidth: true - } - - Rectangle { - color: "#211914" - radius: 8 - border.color: "#49301F" - border.width: 1 - visible: root.errorText.length > 0 - - Layout.fillWidth: true - Layout.preferredHeight: visible ? submitError.implicitHeight + 20 : 0 Text { - id: submitError - - anchors.fill: parent - anchors.margins: 10 - color: "#F08A76" - font.pixelSize: 12 - lineHeight: 1.2 - text: root.errorText - wrapMode: Text.WordWrap + text: qsTr("Confirm new position") + color: Theme.palette.text + font.pixelSize: 19 + font.weight: Font.DemiBold + font.letterSpacing: 0 + Layout.fillWidth: true } + + BusyIndicator { + running: root.busy + visible: running + implicitWidth: 24 + implicitHeight: 24 + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: summary.implicitHeight + 24 + radius: 6 + color: Theme.palette.backgroundTertiary + + ColumnLayout { + id: summary + anchors.fill: parent + anchors.margins: 12 + spacing: 9 + + SummaryLine { + label: qsTr("Pair") + value: root.snapshot.pairText || "—" + } + + SummaryLine { + label: qsTr("Action") + value: root.snapshot.instruction || "—" + } + + SummaryLine { + label: qsTr("Fee") + value: root.snapshot.feeText || "—" + } + + SummaryLine { + label: qsTr("Deposit") + value: qsTr("%1 + %2") + .arg(root.snapshot.depositAText || "—") + .arg(root.snapshot.depositBText || "—") + } + + SummaryLine { + label: qsTr("Expected LP") + value: root.snapshot.expectedLpText || "—" + } + } + } + + Text { + text: root.busy + ? qsTr("Waiting for wallet submission") + : qsTr("Your wallet will review and submit this transaction.") + color: Theme.palette.textSecondary + font.pixelSize: 12 + wrapMode: Text.Wrap + Layout.fillWidth: true } RowLayout { - spacing: 8 - Layout.fillWidth: true + spacing: 10 - Button { - id: cancelButton - - activeFocusOnTab: true - enabled: !root.busy - focusPolicy: Qt.StrongFocus - hoverEnabled: true + LogosButton { text: qsTr("Cancel") - - Accessible.name: cancelButton.text - + enabled: !root.busy Layout.fillWidth: true Layout.minimumHeight: 44 - + radius: 6 onClicked: root.cancel() - - contentItem: Text { - color: cancelButton.hovered || cancelButton.activeFocus ? "#151515" : "#E7E1D8" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 13 - horizontalAlignment: Text.AlignHCenter - text: cancelButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: cancelButton.activeFocus ? "#F26A21" : "#343434" - border.width: 1 - color: cancelButton.pressed ? "#343434" : cancelButton.hovered || cancelButton.activeFocus ? "#E7E1D8" : "#151515" - radius: 6 - } } - Button { - id: confirmButton - - activeFocusOnTab: true + LogosButton { + text: root.busy ? qsTr("Submitting…") : qsTr("Submit") enabled: !root.busy - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: root.busy ? qsTr("Submitting") : qsTr("Submit") - - Accessible.name: confirmButton.text - Layout.fillWidth: true Layout.minimumHeight: 44 - + radius: 6 onClicked: root.confirm() - - contentItem: Text { - color: confirmButton.enabled ? "#151515" : "#7D756E" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 13 - horizontalAlignment: Text.AlignHCenter - text: confirmButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: confirmButton.enabled ? "#F26A21" : "#343434" - border.width: 1 - color: confirmButton.enabled ? confirmButton.pressed ? "#D95C1E" : confirmButton.hovered || confirmButton.activeFocus ? "#FF8A3D" : "#F26A21" : "#181818" - radius: 6 - } } } } } + + component SummaryLine: RowLayout { + required property string label + required property string value + Layout.fillWidth: true + spacing: 12 + + Text { + text: parent.label + color: Theme.palette.textSecondary + font.pixelSize: 12 + Layout.fillWidth: true + } + + Text { + text: parent.value + color: Theme.palette.text + font.pixelSize: 12 + font.weight: Font.Medium + horizontalAlignment: Text.AlignRight + wrapMode: Text.WrapAnywhere + Layout.maximumWidth: 320 + } + } } diff --git a/apps/amm/qml/components/liquidity/NewPositionForm.qml b/apps/amm/qml/components/liquidity/NewPositionForm.qml index 582a12e..f5e4bba 100644 --- a/apps/amm/qml/components/liquidity/NewPositionForm.qml +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -1,7 +1,15 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 -import "../shared" +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Logos.Controls +import Logos.Icons +import Logos.Theme + +import "AmountMath.js" as AmountMath +import "../wallet" Rectangle { id: root @@ -11,1312 +19,1345 @@ Rectangle { property bool contextLoading: false property bool quoteLoading: false property bool submitting: false - property int selectedTokenAIndex: 0 - property int selectedTokenBIndex: 1 + property bool quoteStale: false + property string selectedTokenAId: "" + property string selectedTokenBId: "" property int selectedFeeBps: 30 - property real slippageTolerancePercent: 0.5 + property int slippageBps: 50 property string amountA: "" property string amountB: "" - property string editedSide: "A" - property string initialPrice: "2500" - property int depositScale: 1 + property string initialPrice: "1" + property string depositScaleBps: "10000" property string submitError: "" + property string transactionId: "" + property string refreshWarning: "" + property var localErrors: [] + property string priceTargetLp: "" + property int priceAdjustmentCount: 0 readonly property var emptyToken: ({ - "symbol": "", - "balance": 0, - "balanceText": "", - "accent": "#343434" - }) - readonly property var holdings: root.newPositionContext && root.newPositionContext.holdings ? root.newPositionContext.holdings : [] - readonly property var feeTiers: root.newPositionContext && root.newPositionContext.feeTiers ? root.newPositionContext.feeTiers : [] - readonly property string activeAccount: root.newPositionContext && root.newPositionContext.activeAccountDisplay ? root.newPositionContext.activeAccountDisplay : qsTr("Not connected") - readonly property var tokenA: root.holdings[root.selectedTokenAIndex] || root.emptyToken - readonly property var tokenB: root.holdings[root.selectedTokenBIndex] || root.emptyToken - readonly property var quote: root.completeQuote(root.quotePayload) - readonly property string poolStatus: root.quote.poolStatus || "unavailable_pool" + "definitionId": "", + "name": "", + "totalSupplyRaw": "0", + "balanceRaw": "0", + "selectable": false + }) + readonly property var tokens: root.newPositionContext && root.newPositionContext.tokens + ? root.newPositionContext.tokens : [] + readonly property var feeTiers: root.newPositionContext && root.newPositionContext.feeTiers + ? root.newPositionContext.feeTiers : [] + readonly property var tokenA: root.tokenById(root.selectedTokenAId) + readonly property var tokenB: root.tokenById(root.selectedTokenBId) + readonly property int decimalsA: AmountMath.implyDecimals(root.tokenA.totalSupplyRaw || "0") + readonly property int decimalsB: AmountMath.implyDecimals(root.tokenB.totalSupplyRaw || "0") + readonly property bool displayIsCanonical: root.selectedTokenAId.length > 0 + && AmountMath.compareBase58Ids(root.selectedTokenAId, + root.selectedTokenBId) > 0 + readonly property var canonicalTokenA: root.displayIsCanonical ? root.tokenA : root.tokenB + readonly property var canonicalTokenB: root.displayIsCanonical ? root.tokenB : root.tokenA + readonly property int canonicalDecimalsA: root.displayIsCanonical ? root.decimalsA : root.decimalsB + readonly property int canonicalDecimalsB: root.displayIsCanonical ? root.decimalsB : root.decimalsA + readonly property string poolStatus: root.effectivePoolStatus() readonly property bool activePool: root.poolStatus === "active_pool" readonly property bool missingPool: root.poolStatus === "missing_pool" - readonly property int slippageBps: Math.round(root.slippageTolerancePercent * 100) - readonly property bool canConfirm: !root.contextLoading && !root.quoteLoading && !root.submitting && root.quote.status === "ok" - readonly property bool compact: root.width < 820 - readonly property bool hasActiveInput: root.amountA.length > 0 || root.amountB.length > 0 - readonly property bool showQuoteError: !root.quoteLoading && root.quote.status === "error" && (root.poolStatus === "unavailable_pool" || root.missingPool || root.hasActiveInput) + readonly property int poolFeeBps: root.knownPoolFeeBps() + readonly property bool compact: root.width < 640 + readonly property bool hasPair: root.selectedTokenAId.length > 0 + && root.selectedTokenBId.length > 0 + && root.selectedTokenAId !== root.selectedTokenBId + readonly property bool canConfirm: root.quotePayload.schema === "new-position.v1" + && root.quotePayload.status === "ok" + && root.quotePayload.canSubmit === true + && String(root.quotePayload.quoteHash || "").length > 0 + && !root.contextLoading + && !root.quoteLoading + && !root.quoteStale + && !root.submitting - signal quoteRequested(var request) + signal quoteRequested(bool immediate) signal confirmationRequested(var snapshot) + signal tokenResolveRequested(string tokenId) + signal draftChanged + signal refreshRequested - color: "#1B1B1B" - implicitHeight: content.implicitHeight + 24 - radius: 12 - border.color: "#303030" + implicitHeight: content.implicitHeight + 48 + implicitWidth: 760 + radius: 8 + color: Theme.palette.backgroundSecondary + border.color: Theme.palette.borderSecondary border.width: 1 - Component.onCompleted: root.afterPairChanged() + Component.onCompleted: Qt.callLater(root.ensurePair) + onNewPositionContextChanged: Qt.callLater(root.ensurePair) + onQuotePayloadChanged: Qt.callLater(root.applyQuoteSideEffects) - onNewPositionContextChanged: root.afterPairChanged() - onSelectedTokenAIndexChanged: root.afterPairChanged() - onSelectedTokenBIndexChanged: root.afterPairChanged() - onPoolStatusChanged: root.normalizeFeeSelection() - onSelectedFeeBpsChanged: root.requestQuote() - onSlippageTolerancePercentChanged: root.requestQuote() - onInitialPriceChanged: root.requestQuote() - onDepositScaleChanged: root.requestQuote() - onQuoteChanged: root.applyQuoteSideEffects() + TextEdit { + id: clipboardProxy + visible: false + } ColumnLayout { id: content anchors.fill: parent - anchors.margins: 12 - spacing: 12 + anchors.margins: 24 + spacing: 18 RowLayout { + Layout.fillWidth: true spacing: 12 - Layout.fillWidth: true - ColumnLayout { - spacing: 2 - Layout.fillWidth: true + spacing: 3 Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 18 text: qsTr("New position") - - Layout.fillWidth: true + color: Theme.palette.text + font.pixelSize: 22 + font.weight: Font.DemiBold + font.letterSpacing: 0 } Text { - color: "#A9A098" - elide: Text.ElideRight + text: root.contextStatusText() + color: Theme.palette.textSecondary font.pixelSize: 12 - text: qsTr("Active account %1").arg(root.activeAccount) - + elide: Text.ElideRight Layout.fillWidth: true } } - Rectangle { - color: "#211914" - radius: 12 - border.color: "#49301F" - border.width: 1 + BusyIndicator { + running: root.contextLoading || root.quoteLoading + visible: running + implicitWidth: 24 + implicitHeight: 24 + } - Layout.preferredHeight: 28 - Layout.preferredWidth: pairText.implicitWidth + 20 - - Text { - id: pairText - - anchors.centerIn: parent - color: "#F2D8C7" - font.bold: true - font.pixelSize: 12 - text: qsTr("%1 / %2").arg(root.tokenA.symbol).arg(root.tokenB.symbol) - } + WalletIconButton { + iconSource: LogosIcons.refresh + iconColor: Theme.palette.textSecondary + iconSize: 18 + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + enabled: !root.contextLoading && !root.submitting + Accessible.name: qsTr("Refresh position data") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + onClicked: root.refreshRequested() } } Rectangle { - color: root.statusBackgroundColor() - radius: 8 - border.color: root.statusBorderColor() - border.width: 1 - Layout.fillWidth: true - Layout.preferredHeight: statusRow.implicitHeight + 18 - - RowLayout { - id: statusRow + implicitHeight: networkMessage.implicitHeight + 20 + radius: 6 + color: Theme.palette.backgroundTertiary + border.color: Theme.palette.error + visible: root.contextBlocksForm() + Text { + id: networkMessage anchors.fill: parent anchors.margins: 10 - spacing: 10 - - Rectangle { - color: root.statusColor() - radius: 5 - - Layout.preferredHeight: 10 - Layout.preferredWidth: 10 - } - - ColumnLayout { - spacing: 2 - - Layout.fillWidth: true - - Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 13 - text: root.quote.statusLabel - - Layout.fillWidth: true - } - - Text { - color: "#A9A098" - font.pixelSize: 12 - lineHeight: 1.2 - text: root.quote.statusDetail - wrapMode: Text.WordWrap - - Layout.fillWidth: true - } - } + text: root.contextErrorText() + color: Theme.palette.text + font.pixelSize: 12 + wrapMode: Text.Wrap + verticalAlignment: Text.AlignVCenter } } GridLayout { - columns: 1 - columnSpacing: 12 - rowSpacing: 12 - Layout.fillWidth: true + columns: root.compact ? 1 : 3 + columnSpacing: 10 + rowSpacing: 8 - Rectangle { - color: "#151515" - radius: 8 - border.color: "#303030" - border.width: 1 + LogosComboBox { + id: tokenASelector Layout.fillWidth: true - Layout.preferredHeight: selectionContent.implicitHeight + 20 + Layout.minimumHeight: 48 + model: root.tokens + currentIndex: root.tokenIndex(root.selectedTokenAId) + displayText: root.tokenLabel(root.tokenA) + enabled: root.tokens.length > 0 && !root.submitting - ColumnLayout { - id: selectionContent + onActivated: function(index) { + root.selectToken("A", root.tokens[index].definitionId) + } - anchors.fill: parent - anchors.margins: 10 - spacing: 12 + delegate: ItemDelegate { + required property var modelData + width: tokenASelector.width + enabled: modelData.selectable === true + text: root.tokenLabel(modelData) + ToolTip.visible: hovered + ToolTip.text: root.tokenDetail(modelData) + } + } - Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 14 - text: qsTr("Pair") + Button { + text: "↔" + enabled: root.hasPair && !root.submitting + flat: true + font.pixelSize: 20 + Accessible.name: qsTr("Swap token order") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + Layout.alignment: Qt.AlignCenter + Layout.preferredWidth: 44 + Layout.preferredHeight: 44 + onClicked: root.swapTokens() + contentItem: Text { + text: parent.text + color: parent.enabled ? Theme.palette.text : Theme.palette.textMuted + font.pixelSize: 20 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 6 + color: Theme.palette.backgroundButton + border.color: Theme.palette.border + border.width: 1 + } + } + + LogosComboBox { + id: tokenBSelector + + Layout.fillWidth: true + Layout.minimumHeight: 48 + model: root.tokens + currentIndex: root.tokenIndex(root.selectedTokenBId) + displayText: root.tokenLabel(root.tokenB) + enabled: root.tokens.length > 0 && !root.submitting + + onActivated: function(index) { + root.selectToken("B", root.tokens[index].definitionId) + } + + delegate: ItemDelegate { + required property var modelData + width: tokenBSelector.width + enabled: modelData.selectable === true + text: root.tokenLabel(modelData) + ToolTip.visible: hovered + ToolTip.text: root.tokenDetail(modelData) + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + LogosTextField { + id: tokenIdField + Layout.fillWidth: true + placeholderText: qsTr("Token definition ID") + enabled: !root.contextLoading && !root.submitting + textInput.maximumLength: 64 + textInput.selectByMouse: true + } + + LogosButton { + id: addTokenButton + text: qsTr("Add") + enabled: tokenIdField.text.length > 0 && !root.contextLoading && !root.submitting + Layout.preferredWidth: 80 + Layout.preferredHeight: 40 + radius: 6 + onClicked: { + root.tokenResolveRequested(tokenIdField.text) + tokenIdField.text = "" + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 1 + color: Theme.palette.borderSecondary + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: qsTr("Fee tier") + color: Theme.palette.text + font.pixelSize: 13 + font.weight: Font.Medium + } + + GridLayout { + Layout.fillWidth: true + columns: root.compact ? 2 : 4 + columnSpacing: 8 + rowSpacing: 8 + + Repeater { + model: root.feeTiers + + Item { + required property var modelData + readonly property string disabledReason: root.feeDisabledReason(modelData) Layout.fillWidth: true - } + implicitHeight: 40 - GridLayout { - columns: root.compact ? 1 : 2 - columnSpacing: 8 - rowSpacing: 8 + Button { + anchors.fill: parent + text: parent.modelData.label || root.feeLabel(parent.modelData.feeBps) + checkable: true + checked: root.selectedFeeBps === parent.modelData.feeBps + enabled: parent.disabledReason.length === 0 && !root.submitting + onClicked: root.selectFee(parent.modelData.feeBps) - Layout.fillWidth: true - - ColumnLayout { - spacing: 6 - - Layout.fillWidth: true - - Text { - color: "#A9A098" + contentItem: Text { + text: parent.text + color: parent.enabled ? Theme.palette.text : Theme.palette.textMuted font.pixelSize: 12 - text: qsTr("Token A") - - Layout.fillWidth: true + font.weight: Font.Medium + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter } - Repeater { - model: root.holdings - - delegate: Button { - id: tokenAButton - - readonly property var holding: modelData - readonly property bool selected: root.selectedTokenAIndex === index - readonly property bool duplicate: root.selectedTokenBIndex === index - - activeFocusOnTab: true - enabled: !duplicate && !root.contextLoading && !root.submitting - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: holding.symbol - - Accessible.name: qsTr("Select %1 as token A").arg(holding.symbol) - - Layout.fillWidth: true - Layout.minimumHeight: 48 - - onClicked: root.chooseToken("A", index) - - contentItem: RowLayout { - spacing: 8 - - Rectangle { - color: tokenAButton.holding.accent - radius: 5 - - Layout.preferredHeight: 10 - Layout.preferredWidth: 10 - } - - ColumnLayout { - spacing: 1 - - Layout.fillWidth: true - - Text { - color: tokenAButton.enabled ? "#E7E1D8" : "#6E6862" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 12 - text: tokenAButton.holding.symbol - - Layout.fillWidth: true - } - - Text { - color: tokenAButton.enabled ? "#A9A098" : "#5D5751" - elide: Text.ElideRight - font.pixelSize: 10 - text: tokenAButton.holding.balanceText - - Layout.fillWidth: true - } - } - } - - background: Rectangle { - border.color: tokenAButton.activeFocus || tokenAButton.selected ? "#F26A21" : "#343434" - border.width: 1 - color: tokenAButton.selected ? "#211914" : tokenAButton.hovered || tokenAButton.activeFocus ? "#202020" : "#101010" - radius: 6 - } - } - } - } - - ColumnLayout { - spacing: 6 - - Layout.fillWidth: true - - Text { - color: "#A9A098" - font.pixelSize: 12 - text: qsTr("Token B") - - Layout.fillWidth: true - } - - Repeater { - model: root.holdings - - delegate: Button { - id: tokenBButton - - readonly property var holding: modelData - readonly property bool selected: root.selectedTokenBIndex === index - readonly property bool duplicate: root.selectedTokenAIndex === index - - activeFocusOnTab: true - enabled: !duplicate && !root.contextLoading && !root.submitting - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: holding.symbol - - Accessible.name: qsTr("Select %1 as token B").arg(holding.symbol) - - Layout.fillWidth: true - Layout.minimumHeight: 48 - - onClicked: root.chooseToken("B", index) - - contentItem: RowLayout { - spacing: 8 - - Rectangle { - color: tokenBButton.holding.accent - radius: 5 - - Layout.preferredHeight: 10 - Layout.preferredWidth: 10 - } - - ColumnLayout { - spacing: 1 - - Layout.fillWidth: true - - Text { - color: tokenBButton.enabled ? "#E7E1D8" : "#6E6862" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 12 - text: tokenBButton.holding.symbol - - Layout.fillWidth: true - } - - Text { - color: tokenBButton.enabled ? "#A9A098" : "#5D5751" - elide: Text.ElideRight - font.pixelSize: 10 - text: tokenBButton.holding.balanceText - - Layout.fillWidth: true - } - } - } - - background: Rectangle { - border.color: tokenBButton.activeFocus || tokenBButton.selected ? "#F26A21" : "#343434" - border.width: 1 - color: tokenBButton.selected ? "#211914" : tokenBButton.hovered || tokenBButton.activeFocus ? "#202020" : "#101010" - radius: 6 - } - } - } - } - } - - Text { - color: "#A9A098" - font.pixelSize: 12 - lineHeight: 1.2 - text: root.contextLoading ? qsTr("Loading wallet holdings.") : root.newPositionContext.statusDetail || qsTr("Connect a wallet to load token holdings.") - visible: root.holdings.length === 0 || root.contextLoading - wrapMode: Text.WordWrap - - Layout.fillWidth: true - } - - Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 14 - text: qsTr("Fee tier") - - Layout.fillWidth: true - } - - GridLayout { - columns: root.compact ? 2 : 3 - columnSpacing: 6 - rowSpacing: 6 - - Layout.fillWidth: true - - Repeater { - model: root.feeTiers - - delegate: Rectangle { - id: feeChip - - readonly property var tier: modelData - readonly property string disabledReason: root.feeTierDisabledReason(tier) - readonly property bool chipEnabled: disabledReason.length === 0 - readonly property bool selected: root.selectedFeeBps === tier.bps - - activeFocusOnTab: chipEnabled && !root.submitting - color: selected ? "#211914" : feeMouse.containsMouse && chipEnabled ? "#202020" : "#101010" + background: Rectangle { radius: 6 - border.color: selected || activeFocus ? "#F26A21" : chipEnabled ? "#343434" : "#2A2A2A" + color: parent.checked + ? Theme.palette.overlayOrange + : Theme.palette.backgroundButton + border.color: parent.checked + ? Theme.palette.primary + : Theme.palette.border border.width: 1 - opacity: chipEnabled ? 1 : 0.58 - - Accessible.name: qsTr("Fee tier %1").arg(tier.label) - Accessible.role: Accessible.Button - Accessible.description: disabledReason - - Layout.fillWidth: true - Layout.minimumHeight: 42 - - Keys.onSpacePressed: { - if (chipEnabled && !root.submitting) - root.selectFeeTier(tier.bps); - } - Keys.onReturnPressed: { - if (chipEnabled && !root.submitting) - root.selectFeeTier(tier.bps); - } - - Text { - anchors.centerIn: parent - color: feeChip.selected ? "#F2D8C7" : feeChip.chipEnabled ? "#E7E1D8" : "#7D756E" - font.bold: true - font.pixelSize: 12 - text: feeChip.tier.label - } - - MouseArea { - id: feeMouse - - anchors.fill: parent - cursorShape: feeChip.chipEnabled && !root.submitting ? Qt.PointingHandCursor : Qt.ArrowCursor - hoverEnabled: true - - onClicked: { - if (feeChip.chipEnabled && !root.submitting) { - feeChip.forceActiveFocus(); - root.selectFeeTier(feeChip.tier.bps); - } - } - } - - ToolTip.delay: 300 - ToolTip.text: disabledReason - ToolTip.visible: feeMouse.containsMouse && disabledReason.length > 0 } } + + MouseArea { + id: disabledFeeHover + anchors.fill: parent + enabled: parent.disabledReason.length > 0 + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + ToolTip.visible: disabledFeeHover.containsMouse + ToolTip.text: disabledReason + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 12 + visible: root.activePool + + RowLayout { + Layout.fillWidth: true + + Text { + text: qsTr("Deposit amounts") + color: Theme.palette.text + font.pixelSize: 13 + font.weight: Font.Medium + Layout.fillWidth: true + } + + Text { + text: root.activePriceText() + color: Theme.palette.textSecondary + font.pixelSize: 12 + elide: Text.ElideRight + horizontalAlignment: Text.AlignRight + Layout.maximumWidth: root.compact ? 180 : 360 + } + } + + GridLayout { + Layout.fillWidth: true + columns: root.compact ? 1 : 2 + columnSpacing: 10 + rowSpacing: 10 + + TokenAmountInput { + Layout.fillWidth: true + text: root.amountA + label: root.tokenA.name || qsTr("Token A") + token: root.shortTokenName(root.tokenA) + balance: root.balanceText(root.tokenA, root.decimalsA) + errorText: root.fieldError("amountA") + readOnly: root.submitting + onEditingChanged: function(value) { root.editActiveAmount("A", value) } + onMaxClicked: root.useMaximum() + } + + TokenAmountInput { + Layout.fillWidth: true + text: root.amountB + label: root.tokenB.name || qsTr("Token B") + token: root.shortTokenName(root.tokenB) + balance: root.balanceText(root.tokenB, root.decimalsB) + errorText: root.fieldError("amountB") + readOnly: root.submitting + onEditingChanged: function(value) { root.editActiveAmount("B", value) } + onMaxClicked: root.useMaximum() + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Text { + text: qsTr("Slippage") + color: Theme.palette.textSecondary + font.pixelSize: 12 + Layout.fillWidth: true + } + + LogosSpinBox { + from: 0 + to: 5000 + stepSize: 10 + editable: true + value: root.slippageBps + enabled: !root.submitting + textFromValue: function(value) { return qsTr("%1 bps").arg(value) } + valueFromText: function(text) { + var parsed = Number(String(text).replace(/[^0-9]/g, "")) + return isNaN(parsed) ? root.slippageBps : parsed + } + onValueModified: { + root.slippageBps = value + root.noteDraftChanged() + root.quoteRequested(true) + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 12 + visible: root.missingPool + + Text { + text: qsTr("Initial price") + color: Theme.palette.text + font.pixelSize: 13 + font.weight: Font.Medium + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: qsTr("1 %1 =").arg(root.shortTokenName(root.tokenA)) + color: Theme.palette.textSecondary + font.pixelSize: 13 + } + + LogosTextField { + Layout.fillWidth: true + text: root.initialPrice + enabled: !root.submitting + textInput.inputMethodHints: Qt.ImhFormattedNumbersOnly + textInput.maximumLength: 80 + textInput.validator: RegularExpressionValidator { regularExpression: /[0-9]*([.][0-9]*)?/ } + textInput.onTextEdited: root.editPrice(textInput.text) + } + + Text { + text: root.shortTokenName(root.tokenB) + color: Theme.palette.textSecondary + font.pixelSize: 13 + } + } + + Text { + text: root.fieldError("initialPrice") + color: Theme.palette.error + font.pixelSize: 11 + visible: text.length > 0 + Layout.fillWidth: true + wrapMode: Text.Wrap + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: qsTr("Deposit scale") + color: Theme.palette.textSecondary + font.pixelSize: 12 + Layout.fillWidth: true + } + + Button { + text: "−" + enabled: Number(root.depositScaleBps) > 10000 && !root.submitting + Accessible.name: qsTr("Decrease deposit scale") + implicitWidth: 36 + onClicked: root.stepScale(-100) + + contentItem: Text { + text: parent.text + color: parent.enabled ? Theme.palette.text : Theme.palette.textMuted + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter } - SummaryRow { - label: qsTr("Pool id") - value: root.quote.pool.id.length > 0 ? root.quote.pool.id : "-" + background: Rectangle { + radius: 6 + color: Theme.palette.backgroundButton + border.color: Theme.palette.border + border.width: 1 + } + } - Layout.fillWidth: true + LogosTextField { + id: scaleField + text: root.depositScaleBps + enabled: !root.submitting + textInput.horizontalAlignment: Text.AlignHCenter + textInput.maximumLength: 10 + textInput.validator: RegularExpressionValidator { regularExpression: /[0-9]{0,10}/ } + textInput.inputMethodHints: Qt.ImhDigitsOnly + Layout.preferredWidth: 116 + textInput.onTextEdited: root.editScale(textInput.text) + } + + Text { + text: qsTr("bps") + color: Theme.palette.textSecondary + font.pixelSize: 12 + } + + Button { + text: "+" + enabled: Number(root.depositScaleBps) <= 4294967195 && !root.submitting + Accessible.name: qsTr("Increase deposit scale") + implicitWidth: 36 + onClicked: root.stepScale(100) + + contentItem: Text { + text: parent.text + color: parent.enabled ? Theme.palette.text : Theme.palette.textMuted + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter } - SummaryRow { - label: qsTr("Pool price") - value: root.quote.pool.priceText - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Reserves") - value: root.quote.pool.reserveText - - Layout.fillWidth: true + background: Rectangle { + radius: 6 + color: Theme.palette.backgroundButton + border.color: Theme.palette.border + border.width: 1 } } } - Rectangle { - color: "#151515" - radius: 8 - border.color: "#303030" - border.width: 1 - + Text { + text: root.scaleHelpText() + color: root.fieldError("depositScale").length > 0 ? Theme.palette.error : Theme.palette.textSecondary + font.pixelSize: 11 + visible: text.length > 0 Layout.fillWidth: true - Layout.preferredHeight: depositContent.implicitHeight + 20 + wrapMode: Text.Wrap + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: missingDepositRows.implicitHeight + 20 + radius: 6 + color: Theme.palette.backgroundTertiary ColumnLayout { - id: depositContent - + id: missingDepositRows anchors.fill: parent anchors.margins: 10 - spacing: 12 + spacing: 8 - Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 14 - text: root.activePool ? qsTr("Ratio-locked deposit") : root.missingPool ? qsTr("Initial price deposit") : qsTr("Deposit unavailable") - - Layout.fillWidth: true + LabelValueRow { + label: qsTr("%1 deposit").arg(root.shortTokenName(root.tokenA)) + value: root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A") } - ColumnLayout { - spacing: 10 - visible: root.activePool - - Layout.fillWidth: true - Layout.preferredHeight: visible ? implicitHeight : 0 - - TokenAmountInput { - balance: root.tokenA.balanceText - errorText: root.showQuoteError && root.editedSide === "A" ? root.quote.error : "" - helperText: root.editedSide === "B" && root.amountA.length > 0 ? qsTr("Locked by pool ratio") : "" - label: qsTr("%1 deposit").arg(root.tokenA.symbol) - token: root.tokenA.symbol - text: root.amountA - - Layout.fillWidth: true - - onEditingChanged: function (value) { - root.editActiveAmount("A", value); - } - onMaxClicked: root.useMaxActive("A") - } - - TokenAmountInput { - balance: root.tokenB.balanceText - errorText: root.showQuoteError && root.editedSide === "B" ? root.quote.error : "" - helperText: root.editedSide === "A" && root.amountB.length > 0 ? qsTr("Locked by pool ratio") : "" - label: qsTr("%1 deposit").arg(root.tokenB.symbol) - token: root.tokenB.symbol - text: root.amountB - - Layout.fillWidth: true - - onEditingChanged: function (value) { - root.editActiveAmount("B", value); - } - onMaxClicked: root.useMaxActive("B") - } + LabelValueRow { + label: qsTr("%1 deposit").arg(root.shortTokenName(root.tokenB)) + value: root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "B") } + } + } + } - ColumnLayout { - spacing: 10 - visible: root.missingPool + ColumnLayout { + Layout.fillWidth: true + spacing: 9 + visible: root.quotePayload.status === "ok" - Layout.fillWidth: true - Layout.preferredHeight: visible ? implicitHeight : 0 + Rectangle { + Layout.fillWidth: true + implicitHeight: 1 + color: Theme.palette.borderSecondary + } - Text { - color: "#A9A098" - font.pixelSize: 12 - lineHeight: 1.2 - text: qsTr("Price is locked before deposit sizing. Scaling preserves the same initial price.") - wrapMode: Text.WordWrap + LabelValueRow { + label: root.activePool ? qsTr("Expected spend") : qsTr("Opening deposit") + value: root.depositSummary() + } - Layout.fillWidth: true - } + LabelValueRow { + label: qsTr("Expected LP") + value: root.rawLpText(root.quotePayload.expectedLpRaw) + } - Rectangle { - color: priceField.activeFocus ? "#1F1B18" : "#101010" - radius: 6 - border.color: priceField.activeFocus ? "#F26A21" : "#343434" - border.width: 1 + LabelValueRow { + label: root.activePool ? qsTr("Minimum LP") : qsTr("Locked LP") + value: root.rawLpText(root.activePool + ? root.quotePayload.minimumLpRaw + : root.quotePayload.lockedLpRaw) + } - Layout.fillWidth: true - Layout.minimumHeight: 64 + LabelValueRow { + label: qsTr("Pool") + value: String(root.quotePayload.poolId || "") + } - ColumnLayout { - anchors.fill: parent - anchors.margins: 10 - spacing: 4 + LogosButton { + id: accountPlanButton + text: qsTr("Account plan (%1)").arg(root.accountPreview().length) + enabled: root.accountPreview().length > 0 + property bool checked: false + implicitWidth: 150 + implicitHeight: 36 + radius: 6 + Layout.alignment: Qt.AlignLeft + onClicked: checked = !checked + } - Text { - color: "#A9A098" - font.pixelSize: 12 - text: qsTr("Initial price: 1 %1 in %2").arg(root.tokenB.symbol).arg(root.tokenA.symbol) + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + visible: accountPlanButton.checked - Layout.fillWidth: true - } + Repeater { + model: root.accountPreview() - TextField { - id: priceField - - activeFocusOnTab: true - color: "#E7E1D8" - font.bold: true - font.pixelSize: 18 - inputMethodHints: Qt.ImhFormattedNumbersOnly - placeholderText: qsTr("0") - selectByMouse: true - selectedTextColor: "#151515" - selectionColor: "#F26A21" - text: root.initialPrice - validator: RegularExpressionValidator { - regularExpression: /[0-9]*([.][0-9]*)?/ - } - - Accessible.name: qsTr("Initial pool price") - - Layout.fillWidth: true - - onTextEdited: { - root.submitError = ""; - root.initialPrice = text; - } - - background: Item {} - } - } - } - - RowLayout { - spacing: 6 - - Layout.fillWidth: true - - Repeater { - model: [1, 2, 5] - - delegate: Button { - id: scaleButton - - readonly property int scaleValue: modelData - readonly property bool selected: root.depositScale === scaleValue - - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("%1x").arg(scaleValue) - - Accessible.name: qsTr("Scale deposit to %1 times minimum").arg(scaleValue) - - Layout.fillWidth: true - Layout.minimumHeight: 42 - - onClicked: { - root.submitError = ""; - root.depositScale = scaleValue; - } - - contentItem: Text { - color: scaleButton.selected ? "#F2D8C7" : scaleButton.hovered || scaleButton.activeFocus ? "#E7E1D8" : "#A9A098" - font.bold: true - font.pixelSize: 12 - horizontalAlignment: Text.AlignHCenter - text: scaleButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: scaleButton.activeFocus || scaleButton.selected ? "#F26A21" : "#343434" - border.width: 1 - color: scaleButton.selected ? "#211914" : scaleButton.hovered || scaleButton.activeFocus ? "#202020" : "#101010" - radius: 6 - } - } - } - } - - TokenAmountInput { - balance: root.tokenA.balanceText - helperText: qsTr("Minimum deposit at locked price") - label: qsTr("%1 deposit").arg(root.tokenA.symbol) - readOnly: true - showMaxButton: false - token: root.tokenA.symbol - text: root.quote.deposit.maxA.input - - Layout.fillWidth: true - } - - TokenAmountInput { - balance: root.tokenB.balanceText - helperText: qsTr("Scaled with %1").arg(root.tokenA.symbol) - label: qsTr("%1 deposit").arg(root.tokenB.symbol) - readOnly: true - showMaxButton: false - token: root.tokenB.symbol - text: root.quote.deposit.maxB.input - - Layout.fillWidth: true - } - } - - Rectangle { - color: "#211914" - radius: 8 - border.color: "#49301F" - border.width: 1 - visible: root.showQuoteError || root.submitError.length > 0 - - Layout.fillWidth: true - Layout.preferredHeight: visible ? errorText.implicitHeight + 20 : 0 - - Text { - id: errorText - - anchors.fill: parent - anchors.margins: 10 - color: "#F08A76" - font.pixelSize: 12 - lineHeight: 1.2 - text: root.submitError.length > 0 ? root.submitError : root.quote.error - wrapMode: Text.WordWrap - } - } - - SlippageToleranceControl { - tolerancePercent: root.slippageTolerancePercent - visible: root.activePool || root.missingPool - - Layout.fillWidth: true - Layout.preferredHeight: visible ? implicitHeight : 0 - - onToleranceChangeRequested: function (tolerancePercent) { - root.submitError = ""; - root.slippageTolerancePercent = Math.max(0.01, Math.min(50, Number(tolerancePercent) || 0)); - } - } - - Button { - id: confirmButton - - activeFocusOnTab: true - enabled: root.canConfirm - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: root.submitting ? qsTr("Submitting") : root.quoteLoading ? qsTr("Updating preview") : root.canConfirm ? qsTr("Preview and confirm") : qsTr("Preview unavailable") - - Accessible.name: confirmButton.text - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.confirmationRequested(root.submitSnapshot()) - - contentItem: Text { - color: confirmButton.enabled ? "#151515" : "#7D756E" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 13 - horizontalAlignment: Text.AlignHCenter - text: confirmButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: confirmButton.enabled ? "#F26A21" : "#343434" - border.width: 1 - color: confirmButton.enabled ? confirmButton.pressed ? "#D95C1E" : confirmButton.hovered || confirmButton.activeFocus ? "#FF8A3D" : "#F26A21" : "#181818" - radius: 6 - } + LabelValueRow { + required property var modelData + label: qsTr("%1. %2 · %3").arg(modelData.order + 1).arg(modelData.role).arg(modelData.action) + value: modelData.accountId ? modelData.accountId : qsTr("Assigned by wallet") } } } } Rectangle { - color: "#151515" - radius: 8 - border.color: "#303030" - border.width: 1 - Layout.fillWidth: true - Layout.preferredHeight: previewContent.implicitHeight + 20 - - ColumnLayout { - id: previewContent + implicitHeight: warningTextItem.implicitHeight + 20 + radius: 6 + color: Theme.palette.backgroundTertiary + border.color: Theme.palette.primary + visible: root.warningText().length > 0 + Text { + id: warningTextItem anchors.fill: parent anchors.margins: 10 - spacing: 10 + text: root.warningText() + color: Theme.palette.text + font.pixelSize: 12 + wrapMode: Text.Wrap + verticalAlignment: Text.AlignVCenter + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: quoteErrorText.implicitHeight + 20 + radius: 6 + color: Theme.palette.backgroundTertiary + border.color: Theme.palette.error + visible: root.quoteError().length > 0 + + Text { + id: quoteErrorText + anchors.fill: parent + anchors.margins: 10 + text: root.quoteError() + color: Theme.palette.text + font.pixelSize: 12 + wrapMode: Text.Wrap + verticalAlignment: Text.AlignVCenter + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: successColumn.implicitHeight + 24 + radius: 6 + color: Theme.palette.backgroundTertiary + border.color: Theme.palette.success + visible: root.transactionId.length > 0 + + ColumnLayout { + id: successColumn + anchors.fill: parent + anchors.margins: 12 + spacing: 7 + + Text { + text: qsTr("Position submitted") + color: Theme.palette.success + font.pixelSize: 14 + font.weight: Font.DemiBold + } RowLayout { - spacing: 8 - Layout.fillWidth: true + spacing: 6 Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 14 - text: qsTr("Preview") - - Layout.fillWidth: true - } - - Text { - color: root.quoteLoading ? "#F2B366" : root.quote.status === "ok" ? "#8FD6A4" : "#F08A76" - font.bold: true + text: root.transactionId + color: Theme.palette.text + font.family: "monospace" font.pixelSize: 11 - horizontalAlignment: Text.AlignRight - text: root.quoteLoading ? qsTr("Updating") : root.quote.status === "ok" ? qsTr("Ready") : qsTr("Needs input") + wrapMode: Text.WrapAnywhere + Layout.fillWidth: true + } - Layout.maximumWidth: 120 + LogosCopyButton { + Accessible.name: qsTr("Copy transaction ID") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + onCopyText: root.copyToClipboard(root.transactionId) } } - GridLayout { - columns: root.compact ? 1 : 2 - columnSpacing: 16 - rowSpacing: 8 - + RowLayout { Layout.fillWidth: true + visible: root.refreshWarning.length > 0 - ColumnLayout { - spacing: 8 - + Text { + text: root.refreshWarning + color: Theme.palette.textSecondary + font.pixelSize: 11 + wrapMode: Text.Wrap Layout.fillWidth: true - - SummaryRow { - label: qsTr("Instruction") - value: root.instructionText() - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Deposit %1").arg(root.tokenA.symbol) - value: root.quote.deposit.actualA.display - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Deposit %1").arg(root.tokenB.symbol) - value: root.quote.deposit.actualB.display - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Expected LP") - value: root.quote.lp.expected.display - estimated: true - estimateHelp: qsTr("Backend preview quote. Final submission rechecks the quote hash.") - - Layout.fillWidth: true - } } - ColumnLayout { - spacing: 8 - - Layout.fillWidth: true - - SummaryRow { - label: qsTr("Minimum LP") - value: root.quote.lp.minimum.display - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Locked LP") - value: root.quote.lp.locked.display - visible: root.missingPool - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Current position") - value: root.quote.position.userLp - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Pool share") - value: root.quote.position.share - - Layout.fillWidth: true - } - } - } - - Rectangle { - color: "#101010" - radius: 8 - border.color: "#303030" - border.width: 1 - visible: root.quote.accountChanges.length > 0 - - Layout.fillWidth: true - Layout.preferredHeight: visible ? accountList.implicitHeight + 16 : 0 - - ColumnLayout { - id: accountList - - anchors.fill: parent - anchors.margins: 8 - spacing: 6 - - Text { - color: "#A9A098" - font.pixelSize: 12 - text: qsTr("Account changes") - - Layout.fillWidth: true - } - - Repeater { - model: root.quote.accountChanges - - delegate: Rectangle { - id: accountRow - - readonly property var accountChange: modelData - - color: "#151515" - radius: 6 - border.color: "#2C2C2C" - border.width: 1 - - Layout.fillWidth: true - Layout.preferredHeight: rowLayout.implicitHeight + 12 - - RowLayout { - id: rowLayout - - anchors.fill: parent - anchors.leftMargin: 8 - anchors.rightMargin: 8 - spacing: 8 - - Rectangle { - color: root.accountActionColor(accountRow.accountChange.action) - radius: 4 - - Layout.preferredHeight: 8 - Layout.preferredWidth: 8 - } - - Text { - color: "#E7E1D8" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 12 - text: accountRow.accountChange.role - - Layout.preferredWidth: 120 - } - - Text { - color: "#A9A098" - elide: Text.ElideMiddle - font.pixelSize: 11 - text: accountRow.accountChange.id - - Layout.fillWidth: true - } - - Text { - color: "#F2D8C7" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 11 - horizontalAlignment: Text.AlignRight - text: accountRow.accountChange.action - - Layout.preferredWidth: 100 - } - } - } - } + WalletIconButton { + iconSource: LogosIcons.refresh + iconColor: Theme.palette.textSecondary + iconSize: 16 + Accessible.name: qsTr("Refresh balances") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + onClicked: root.refreshRequested() } } } } + + Text { + text: root.submitError + color: Theme.palette.error + font.pixelSize: 12 + visible: text.length > 0 + wrapMode: Text.Wrap + Layout.fillWidth: true + } + + LogosButton { + Layout.fillWidth: true + Layout.minimumHeight: 48 + radius: 6 + text: root.submitting + ? qsTr("Submitting…") + : root.missingPool ? qsTr("Create pool") : qsTr("Add liquidity") + enabled: root.canConfirm + onClicked: root.confirmationRequested(root.submissionSnapshot()) + } } - function buildRequest() { - return { - "amountA": root.amountA, - "amountB": root.amountB, - "depositScale": root.depositScale, - "editedSide": root.editedSide, - "feeBps": root.selectedFeeBps, - "initialPrice": root.initialPrice, - "slippageBps": root.slippageBps, - "tokenA": root.tokenA.symbol, - "tokenB": root.tokenB.symbol - }; + component LabelValueRow: RowLayout { + required property string label + required property string value + Layout.fillWidth: true + spacing: 12 + + Text { + text: parent.label + color: Theme.palette.textSecondary + font.pixelSize: 12 + Layout.fillWidth: true + wrapMode: Text.Wrap + } + + Text { + text: parent.value + color: Theme.palette.text + font.pixelSize: 12 + font.weight: Font.Medium + horizontalAlignment: Text.AlignRight + wrapMode: Text.WrapAnywhere + Layout.maximumWidth: root.compact ? 190 : 430 + } } - function chooseToken(side, index) { - root.submitError = ""; + function tokenById(tokenId) { + for (var i = 0; i < root.tokens.length; ++i) { + if (root.tokens[i].definitionId === tokenId) + return root.tokens[i] + } + return root.emptyToken + } + function tokenIndex(tokenId) { + for (var i = 0; i < root.tokens.length; ++i) { + if (root.tokens[i].definitionId === tokenId) + return i + } + return -1 + } + + function selectableTokenIds() { + var result = [] + for (var i = 0; i < root.tokens.length; ++i) { + if (root.tokens[i].selectable === true) + result.push(root.tokens[i].definitionId) + } + return result + } + + function ensurePair() { + var selectable = root.selectableTokenIds() + if (selectable.length === 0) { + root.selectedTokenAId = "" + root.selectedTokenBId = "" + return + } + if (selectable.indexOf(root.selectedTokenAId) < 0) + root.selectedTokenAId = selectable[0] + if (selectable.indexOf(root.selectedTokenBId) < 0 + || root.selectedTokenBId === root.selectedTokenAId) { + root.selectedTokenBId = selectable.length > 1 ? selectable[1] : "" + } + if (root.hasPair) + root.quoteRequested(true) + } + + function selectToken(side, tokenId) { + if (tokenId.length === 0) + return if (side === "A") { - root.selectedTokenAIndex = index; - if (root.selectedTokenBIndex === index) - root.selectedTokenBIndex = root.nextTokenIndex(index); - return; + if (tokenId === root.selectedTokenBId) + root.selectedTokenBId = root.selectedTokenAId + root.selectedTokenAId = tokenId + } else { + if (tokenId === root.selectedTokenAId) + root.selectedTokenAId = root.selectedTokenBId + root.selectedTokenBId = tokenId + } + root.resetPairDraft() + } + + function swapTokens() { + var tokenId = root.selectedTokenAId + root.selectedTokenAId = root.selectedTokenBId + root.selectedTokenBId = tokenId + var amount = root.amountA + root.amountA = root.amountB + root.amountB = amount + if (root.activePool && root.quotePayload.initialPriceRealRaw) + root.initialPrice = root.activePriceValue() + root.noteDraftChanged() + root.quoteRequested(true) + } + + function resetPairDraft() { + root.amountA = "" + root.amountB = "" + root.initialPrice = "1" + root.depositScaleBps = "10000" + root.priceTargetLp = "" + root.priceAdjustmentCount = 0 + root.localErrors = [] + root.noteDraftChanged() + root.quoteRequested(true) + } + + function noteDraftChanged() { + root.submitError = "" + root.refreshWarning = "" + root.draftChanged() + } + + function effectivePoolStatus() { + var status = String(root.quotePayload.poolStatus || "") + if (status === "active_pool" || status === "missing_pool") + return status + if (root.quotePayload.code === "fee_tier_mismatch") + return "active_pool" + return "" + } + + function knownPoolFeeBps() { + var direct = Number(root.quotePayload.poolFeeBps || 0) + if (direct > 0) + return direct + var errors = root.quotePayload.errors || [] + for (var i = 0; i < errors.length; ++i) { + var value = Number(errors[i].details ? errors[i].details.poolFeeBps : 0) + if (value > 0) + return value + } + return 0 + } + + function selectFee(feeBps) { + root.selectedFeeBps = feeBps + root.noteDraftChanged() + root.quoteRequested(true) + } + + function feeDisabledReason(tier) { + if (tier.enabled === false) + return tier.disabledReason || qsTr("This fee tier is unavailable.") + if (root.poolFeeBps > 0 && Number(tier.feeBps) !== root.poolFeeBps) + return qsTr("Existing pool uses %1. Fee tier is fixed for this pair.") + .arg(root.feeLabel(root.poolFeeBps)) + return "" + } + + function feeLabel(feeBps) { + if (feeBps === 1) + return "0.01%" + if (feeBps === 5) + return "0.05%" + if (feeBps === 30) + return "0.30%" + if (feeBps === 100) + return "1.00%" + return qsTr("%1 bps").arg(feeBps) + } + + function buildQuoteRequest() { + var errors = [] + if (!root.hasPair) { + errors.push(root.localIssue("token_pair_required", ["tokenAId", "tokenBId"])) + root.localErrors = errors + return { "ok": false, "errors": errors, "request": ({}) } } - root.selectedTokenBIndex = index; - if (root.selectedTokenAIndex === index) - root.selectedTokenAIndex = root.nextTokenIndex(index); - } - - function nextTokenIndex(index) { - return root.holdings.length > 0 ? (index + 1) % root.holdings.length : 0; - } - - function requestQuote() { - root.quoteRequested(root.buildRequest()); - } - - function formatAmount(value) { - const amount = Math.max(0, Number(value) || 0); - - if (amount >= 1000) - return amount.toFixed(2).replace(/\.00$/, ""); - - if (amount >= 1) - return amount.toFixed(4).replace(/0+$/, "").replace(/[.]$/, ""); - - return amount.toFixed(6).replace(/0+$/, "").replace(/[.]$/, ""); - } - - function formatTokenAmount(value, symbol) { - return qsTr("%1 %2").arg(root.formatAmount(value)).arg(symbol); - } - - function amountValue(value, symbol) { - const amount = Math.max(0, Number(value) || 0); - return { - "value": amount, - "input": root.formatAmount(amount), - "display": root.formatTokenAmount(amount, symbol), - "symbol": symbol - }; - } - - function completeAmount(payload, symbol) { - const value = payload || root.amountValue(0, symbol); - const amount = Math.max(0, Number(value.value) || 0); - const input = value.input || root.formatAmount(amount); - return { - "value": amount, - "input": input, - "display": value.display || root.formatTokenAmount(amount, symbol), - "symbol": value.symbol || symbol - }; - } - - function completeQuote(payload) { - const quote = payload || {}; - const pool = quote.pool || {}; - const deposit = quote.deposit || {}; - const lp = quote.lp || {}; - const position = quote.position || {}; - const tokenA = root.tokenA.symbol; - const tokenB = root.tokenB.symbol; - - return { - "status": quote.status || "error", - "error": quote.error || "", - "poolStatus": quote.poolStatus || "unavailable_pool", - "statusLabel": quote.statusLabel || qsTr("Unavailable"), - "statusDetail": quote.statusDetail || quote.error || qsTr("Connect a wallet to preview this position."), - "instruction": quote.instruction || "", - "storedFeeBps": quote.storedFeeBps || 0, - "feeBps": quote.feeBps || root.selectedFeeBps, - "feeLabel": quote.feeLabel || root.feeLabel(root.selectedFeeBps), - "quoteHash": quote.quoteHash || "", - "pool": { - "id": pool.id || "", - "priceText": pool.priceText || "", - "reserveText": pool.reserveText || "" - }, - "deposit": { - "maxA": root.completeAmount(deposit.maxA, tokenA), - "maxB": root.completeAmount(deposit.maxB, tokenB), - "actualA": root.completeAmount(deposit.actualA, tokenA), - "actualB": root.completeAmount(deposit.actualB, tokenB) - }, - "lp": { - "expected": root.completeAmount(lp.expected, "LP"), - "minimum": root.completeAmount(lp.minimum, "LP"), - "locked": root.completeAmount(lp.locked, "LP") - }, - "position": { - "userLp": position.userLp || "0 LP", - "share": position.share || "-", - "ownedA": position.ownedA || root.formatTokenAmount(0, tokenA), - "ownedB": position.ownedB || root.formatTokenAmount(0, tokenB) - }, - "accountChanges": quote.accountChanges || [] - }; - } - - function feeLabel(bps) { - if (bps === 1) - return "0.01%"; - if (bps === 5) - return "0.05%"; - if (bps === 30) - return "0.30%"; - if (bps === 100) - return "1.00%"; - return qsTr("%1 bps").arg(bps); - } - - function activeRatio(symbolA, symbolB) { - if (symbolA === "USDC" && symbolB === "LOGOS") - return 8; - - if (symbolA === "LOGOS" && symbolB === "USDC") - return 0.125; - - return 1; - } - - function defaultInitialPrice(symbolA, symbolB) { - if (symbolA === "USDC" && symbolB === "WETH") - return 2500; - - if (symbolA === "WETH" && symbolB === "USDC") - return 0.0004; - - return 1; - } - - function afterPairChanged() { - if (root.holdings.length < 2) { - root.selectedTokenAIndex = 0; - root.selectedTokenBIndex = 0; - root.submitError = ""; - root.requestQuote(); - return; + var canonicalAId = root.displayIsCanonical ? root.selectedTokenAId : root.selectedTokenBId + var canonicalBId = root.displayIsCanonical ? root.selectedTokenBId : root.selectedTokenAId + var request = { + "schema": "new-position.v1", + "tokenAId": canonicalAId, + "tokenBId": canonicalBId, + "feeBps": root.selectedFeeBps } - if (root.selectedTokenAIndex >= root.holdings.length) - root.selectedTokenAIndex = 0; - if (root.selectedTokenBIndex >= root.holdings.length) - root.selectedTokenBIndex = root.nextTokenIndex(root.selectedTokenAIndex); + if (root.activePool) { + var parsedA = AmountMath.parseHuman(root.amountA, root.decimalsA) + var parsedB = AmountMath.parseHuman(root.amountB, root.decimalsB) + if (!parsedA.ok) + errors.push(root.localIssue(parsedA.code, ["amountA"])) + if (!parsedB.ok) + errors.push(root.localIssue(parsedB.code, ["amountB"])) + if (errors.length === 0) { + request.maxAmountARaw = root.displayIsCanonical ? parsedA.raw : parsedB.raw + request.maxAmountBRaw = root.displayIsCanonical ? parsedB.raw : parsedA.raw + request.slippageBps = root.slippageBps + } + } else { + var price = AmountMath.priceToQ64(root.initialPrice, + root.canonicalDecimalsA, + root.canonicalDecimalsB, + root.displayIsCanonical) + if (!price.ok) + errors.push(root.localIssue(price.code, ["initialPrice"])) + var scale = root.validScale() + if (scale < 0) + errors.push(root.localIssue("invalid_deposit_scale", ["depositScale"])) + if (errors.length === 0) { + request.initialPriceRealRaw = price.raw + request.depositScaleBps = scale + } - if (root.selectedTokenAIndex === root.selectedTokenBIndex) - root.selectedTokenBIndex = root.nextTokenIndex(root.selectedTokenAIndex); - - if (root.tokenA.symbol.length === 0 || root.tokenB.symbol.length === 0) { - root.requestQuote(); - return; + if (!root.missingPool) { + var probeA = root.probeRaw(root.tokenA, root.decimalsA) + var probeB = root.probeRaw(root.tokenB, root.decimalsB) + request.maxAmountARaw = root.displayIsCanonical ? probeA : probeB + request.maxAmountBRaw = root.displayIsCanonical ? probeB : probeA + request.slippageBps = root.slippageBps + } } - root.submitError = ""; - root.amountA = ""; - root.amountB = ""; - root.editedSide = "A"; - root.initialPrice = root.formatAmount(root.defaultInitialPrice(root.tokenA.symbol, root.tokenB.symbol)); - root.depositScale = 1; - root.normalizeFeeSelection(); - root.requestQuote(); + root.localErrors = errors + return { "ok": errors.length === 0, "errors": errors, "request": request } } - function normalizeFeeSelection() { - if (root.tokenA.symbol.length === 0 || root.tokenB.symbol.length === 0) - return; + function probeRaw(token, decimals) { + var balance = String(token.balanceRaw || "0") + if (AmountMath.isUnsigned(balance) && AmountMath.compare(balance, "0") > 0) + return balance + return AmountMath.multiply(AmountMath.pow10(decimals), "1000") + } - if (root.poolStatus === "active_pool" && root.quote.storedFeeBps > 0) { - root.selectedFeeBps = root.quote.storedFeeBps; - return; + function validScale() { + if (!/^[0-9]+$/.test(root.depositScaleBps)) + return -1 + var scale = AmountMath.toU32(root.depositScaleBps) + return scale >= 10000 ? scale : -1 + } + + function localIssue(code, fields) { + return { "code": code, "blockingFields": fields, "details": ({}) } + } + + function canonicalFieldToDisplay(field) { + if (field === "maxAmountARaw") + return root.displayIsCanonical ? "amountA" : "amountB" + if (field === "maxAmountBRaw") + return root.displayIsCanonical ? "amountB" : "amountA" + if (field === "initialPriceRealRaw") + return "initialPrice" + if (field === "depositScaleBps") + return "depositScale" + return field + } + + function fieldError(field) { + var collections = [root.localErrors, root.quotePayload.errors || []] + for (var c = 0; c < collections.length; ++c) { + for (var i = 0; i < collections[c].length; ++i) { + var fields = collections[c][i].blockingFields || [] + for (var f = 0; f < fields.length; ++f) { + if (root.canonicalFieldToDisplay(fields[f]) === field) + return root.issueText(collections[c][i].code) + } + } } - - if (root.selectedFeeBps === 25) - root.selectedFeeBps = 30; + return "" } - function selectFeeTier(bps) { - root.submitError = ""; - root.selectedFeeBps = bps; - } - - function feeTierDisabledReason(tier) { - if (!tier.supported) - return qsTr("Unsupported by the AMM program. Valid fee tiers are 0.01%, 0.05%, 0.30%, and 1.00%."); - - if (root.poolStatus === "active_pool" && tier.bps !== root.quote.storedFeeBps) - return qsTr("Existing pool uses %1. Fee tier is fixed by pool configuration.").arg(root.feeLabel(root.quote.storedFeeBps)); - - if (root.poolStatus === "unavailable_pool") - return qsTr("Fee selection is disabled until this pair can be quoted."); - - return ""; + function issueText(code) { + var messages = { + "amount_required": qsTr("Enter a value."), + "amount_must_be_positive": qsTr("Value must be greater than zero."), + "invalid_amount_format": qsTr("Use plain dot-decimal format."), + "invalid_amount_precision": qsTr("Too many decimal places for this token."), + "invalid_raw_amount": qsTr("Value is outside the supported range."), + "amount_exceeds_balance": qsTr("Amount exceeds the selected holding balance."), + "amount_too_low": qsTr("Value is too low for this pool."), + "invalid_deposit_scale": qsTr("Scale must be an integer of at least 10000 bps."), + "amount_overflow": qsTr("Deposit scale produces an amount outside the supported range."), + "minimum_lp_zero": qsTr("Slippage leaves no minimum LP output."), + "invalid_slippage": qsTr("Slippage must be between 0 and 5000 bps."), + "fee_tier_mismatch": qsTr("Select the existing pool fee tier."), + "no_wallet": qsTr("Connect a wallet to submit this position."), + "wallet_unavailable": qsTr("Wallet is unavailable."), + "wallet_submission_failed": qsTr("Wallet submission failed. Review and retry manually."), + "signature_rejected": qsTr("Wallet approval was rejected."), + "quote_changed": qsTr("Pool or wallet state changed. Review the refreshed quote."), + "quote_not_submittable": qsTr("Current quote cannot be submitted."), + "submit_in_progress": qsTr("A submission is already in progress."), + "transaction_deadline_expired": qsTr("Wallet approval expired. Retry to create a fresh request."), + "high_slippage": qsTr("High slippage tolerance."), + "token_definition_unreadable": qsTr("A token definition could not be read."), + "token_program_mismatch": qsTr("Token belongs to a different TokenProgram."), + "token_not_fungible": qsTr("Token is not a public fungible token."), + "backend_error": qsTr("Position backend failed. Refresh and retry."), + "account_read_failed": qsTr("Required on-chain state could not be read."), + "pool_unavailable": qsTr("Pool state is unavailable."), + "config_unavailable": qsTr("AMM configuration is unavailable."), + "same_token_pair": qsTr("Select two different tokens."), + "token_pair_required": qsTr("Select two tokens.") + } + return messages[code] || qsTr("Position quote is unavailable (%1).").arg(code || "unknown") } function editActiveAmount(side, value) { - root.submitError = ""; - root.editedSide = side; - if (side === "A") - root.amountA = value; + root.amountA = value else - root.amountB = value; + root.amountB = value - root.requestQuote(); + var reserveA = root.displayRaw("reserveARaw", "reserveBRaw", "A") + var reserveB = root.displayRaw("reserveARaw", "reserveBRaw", "B") + var parsed = AmountMath.parseHuman(value, side === "A" ? root.decimalsA : root.decimalsB) + if (parsed.ok && reserveA !== "" && reserveB !== "" + && reserveA !== "0" && reserveB !== "0") { + if (side === "A") { + var rawB = AmountMath.mulDivFloor(parsed.raw, reserveB, reserveA) + root.amountB = AmountMath.formatRaw(rawB, root.decimalsB) + } else { + var rawA = AmountMath.mulDivFloor(parsed.raw, reserveA, reserveB) + root.amountA = AmountMath.formatRaw(rawA, root.decimalsA) + } + } + root.noteDraftChanged() + root.quoteRequested(false) + } + + function useMaximum() { + var reserveA = root.displayRaw("reserveARaw", "reserveBRaw", "A") + var reserveB = root.displayRaw("reserveARaw", "reserveBRaw", "B") + if (!reserveA || !reserveB || reserveA === "0" || reserveB === "0") + return + var balanceA = String(root.tokenA.balanceRaw || "0") + var balanceB = String(root.tokenB.balanceRaw || "0") + var fitA = AmountMath.mulDivFloor(balanceB, reserveA, reserveB) + var rawA = AmountMath.compare(balanceA, fitA) < 0 ? balanceA : fitA + var rawB = AmountMath.mulDivFloor(rawA, reserveB, reserveA) + root.amountA = AmountMath.formatRaw(rawA, root.decimalsA) + root.amountB = AmountMath.formatRaw(rawB, root.decimalsB) + root.noteDraftChanged() + root.quoteRequested(true) + } + + function editPrice(value) { + if (root.priceTargetLp.length === 0 + && root.missingPool + && root.quotePayload.status === "ok") { + root.priceTargetLp = String(root.quotePayload.expectedLpRaw || "") + root.priceAdjustmentCount = 0 + } + root.initialPrice = value + root.noteDraftChanged() + root.quoteRequested(false) + } + + function editScale(value) { + root.depositScaleBps = value + root.priceTargetLp = "" + root.priceAdjustmentCount = 0 + root.noteDraftChanged() + root.quoteRequested(false) + } + + function stepScale(delta) { + var current = root.validScale() + if (current < 0) + current = 10000 + var next = Math.max(10000, Math.min(4294967295, current + delta)) + root.depositScaleBps = String(next) + root.priceTargetLp = "" + root.priceAdjustmentCount = 0 + root.noteDraftChanged() + root.quoteRequested(true) } function applyQuoteSideEffects() { - root.normalizeFeeSelection(); - if (root.quote.status !== "ok" || root.quote.poolStatus !== "active_pool") - return; + if (root.poolFeeBps > 0 && root.selectedFeeBps !== root.poolFeeBps) { + root.selectedFeeBps = root.poolFeeBps + root.quoteRequested(true) + return + } - if (root.editedSide === "A") - root.amountB = root.quote.deposit.maxB.input; - else - root.amountA = root.quote.deposit.maxA.input; + if (root.quotePayload.status !== "ok") + return + + if (root.activePool && root.amountA.length === 0 && root.amountB.length === 0) { + root.amountA = AmountMath.formatRaw(root.displayRaw("maxAmountARaw", "maxAmountBRaw", "A"), root.decimalsA) + root.amountB = AmountMath.formatRaw(root.displayRaw("maxAmountARaw", "maxAmountBRaw", "B"), root.decimalsB) + } + + if (!root.missingPool || root.priceTargetLp.length === 0 + || root.priceAdjustmentCount >= 3) + return + var currentLp = String(root.quotePayload.expectedLpRaw || "0") + if (currentLp === "0") { + root.priceTargetLp = "" + return + } + var desired = AmountMath.mulDivCeil(root.depositScaleBps, + root.priceTargetLp, + currentLp) + if (AmountMath.compare(desired, "10000") < 0) + desired = "10000" + var funded = root.quotePayload.maxFundedScaleBps + if (funded !== null && funded !== undefined && Number(funded) >= 10000 + && AmountMath.compare(desired, String(funded)) > 0) { + desired = String(funded) + } + if (AmountMath.toU32(desired) >= 10000 && desired !== root.depositScaleBps) { + root.depositScaleBps = desired + ++root.priceAdjustmentCount + root.quoteRequested(true) + return + } + root.priceTargetLp = "" } - function useMaxActive(side) { - const ratio = root.activeRatio(root.tokenA.symbol, root.tokenB.symbol); - const maxA = Math.min(root.tokenA.balance, root.tokenB.balance / ratio); - const maxB = Math.min(root.tokenB.balance, root.tokenA.balance * ratio); - + function displayRaw(canonicalAField, canonicalBField, side) { if (side === "A") - root.editActiveAmount("A", root.formatAmount(maxA)); - else - root.editActiveAmount("B", root.formatAmount(maxB)); + return String(root.quotePayload[root.displayIsCanonical ? canonicalAField : canonicalBField] || "") + return String(root.quotePayload[root.displayIsCanonical ? canonicalBField : canonicalAField] || "") } - function setSubmitError(message) { - root.submitError = message; + function quoteAmount(canonicalAField, canonicalBField, side) { + var token = side === "A" ? root.tokenA : root.tokenB + var decimals = side === "A" ? root.decimalsA : root.decimalsB + var raw = root.displayRaw(canonicalAField, canonicalBField, side) + return raw.length > 0 + ? qsTr("%1 %2").arg(AmountMath.formatRaw(raw, decimals)).arg(root.shortTokenName(token)) + : "—" } - function resetAfterSubmit() { - root.submitError = ""; + function depositSummary() { + var amountA = root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A") + var amountB = root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "B") + return amountA + " + " + amountB + } - if (root.activePool) { - root.amountA = ""; - root.amountB = ""; - root.editedSide = "A"; + function rawLpText(raw) { + return raw !== undefined && raw !== null && String(raw).length > 0 + ? qsTr("%1 raw LP").arg(String(raw)) : "—" + } + + function activePriceValue() { + return AmountMath.priceFromQ64(String(root.quotePayload.initialPriceRealRaw || ""), + root.canonicalDecimalsA, + root.canonicalDecimalsB, + root.displayIsCanonical) + } + + function activePriceText() { + var price = root.activePriceValue() + if (price.length === 0) + return "" + return qsTr("1 %1 = %2 %3") + .arg(root.shortTokenName(root.tokenA)) + .arg(price) + .arg(root.shortTokenName(root.tokenB)) + } + + function accountPreview() { + return root.quotePayload.accountPreview || [] + } + + function quoteError() { + if (root.quoteLoading || root.quoteStale) + return "" + if (root.quotePayload.status === "error") + return root.issueText(root.quotePayload.code) + return "" + } + + function warningText() { + var warnings = root.quotePayload.warnings || [] + if (warnings.length === 0) + warnings = root.newPositionContext.warnings || [] + return warnings.length > 0 ? root.issueText(warnings[0].code) : "" + } + + function copyToClipboard(text) { + if (!text) + return + clipboardProxy.text = text + clipboardProxy.selectAll() + clipboardProxy.copy() + clipboardProxy.deselect() + clipboardProxy.text = "" + } + + function submissionSnapshot() { + var built = root.buildQuoteRequest() + return { + "request": built.request, + "quoteHash": String(root.quotePayload.quoteHash || ""), + "pairText": qsTr("%1 / %2").arg(root.shortTokenName(root.tokenA)).arg(root.shortTokenName(root.tokenB)), + "feeText": root.feeLabel(root.selectedFeeBps), + "depositAText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A"), + "depositBText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "B"), + "expectedLpText": root.rawLpText(root.quotePayload.expectedLpRaw), + "instruction": String(root.quotePayload.instruction || "") } } - function submitSnapshot() { - return { - "depositA": root.quote.deposit.actualA.display, - "depositB": root.quote.deposit.actualB.display, - "expectedLp": root.quote.lp.expected.display, - "feeLabel": root.quote.feeLabel, - "instructionText": root.instructionText(), - "minimumLp": root.quote.lp.minimum.display, - "pairText": qsTr("%1 / %2").arg(root.tokenA.symbol).arg(root.tokenB.symbol), - "quote": root.quote, - "quoteHash": root.quote.quoteHash, - "request": root.buildRequest(), - "shortQuoteHash": root.quote.quoteHash.substring(0, 18), - "tokenA": root.tokenA.symbol, - "tokenB": root.tokenB.symbol - }; + function resetAfterSubmit() { + root.amountA = "" + root.amountB = "" + root.initialPrice = "" + root.depositScaleBps = "10000" + root.localErrors = [] + root.submitError = "" + root.priceTargetLp = "" + root.priceAdjustmentCount = 0 } - function instructionText() { - if (root.quote.instruction === "new_definition") - return qsTr("Create pool"); - - if (root.quote.instruction === "add_liquidity") - return qsTr("Add liquidity"); - - return qsTr("Unavailable"); + function tokenLabel(token) { + if (!token || !token.definitionId) + return qsTr("Select token") + var name = token.name && token.name.length > 0 ? token.name : qsTr("Unknown token") + return qsTr("%1 · %2").arg(name).arg(root.shortId(token.definitionId)) } - function statusColor() { - if (root.poolStatus === "active_pool") - return "#8FD6A4"; - - if (root.poolStatus === "missing_pool") - return "#F2B366"; - - return "#F08A76"; + function tokenDetail(token) { + if (!token || !token.definitionId) + return "" + return qsTr("%1\n%2 implied decimals\nBalance %3") + .arg(token.definitionId) + .arg(AmountMath.implyDecimals(token.totalSupplyRaw || "0")) + .arg(root.balanceText(token, AmountMath.implyDecimals(token.totalSupplyRaw || "0"))) } - function statusBackgroundColor() { - if (root.poolStatus === "active_pool") - return "#162218"; - - if (root.poolStatus === "missing_pool") - return "#211914"; - - return "#241817"; + function shortTokenName(token) { + if (token && token.name && token.name.length > 0) + return token.name + return token && token.definitionId ? root.shortId(token.definitionId) : "—" } - function statusBorderColor() { - if (root.poolStatus === "active_pool") - return "#2E5A39"; - - if (root.poolStatus === "missing_pool") - return "#49301F"; - - return "#5A3028"; + function balanceText(token, decimals) { + return AmountMath.formatRaw(String(token.balanceRaw || "0"), decimals) } - function accountActionColor(action) { - if (action === qsTr("Create")) - return "#F2B366"; + function shortId(value) { + var text = String(value || "") + return text.length > 14 ? text.slice(0, 7) + "…" + text.slice(-5) : text + } - if (action === qsTr("Read")) - return "#8BA8E8"; + function scaleHelpText() { + var error = root.fieldError("depositScale") + if (error.length > 0) + return error + var funded = root.quotePayload.maxFundedScaleBps + if (funded === null || funded === undefined) + return "" + if (Number(funded) === 0) + return qsTr("Current holdings cannot fund the minimum deposit. Simulation remains available.") + return qsTr("Current holdings fund up to %1 bps.").arg(funded) + } - return "#8FD6A4"; + function contextStatusText() { + var network = String(root.newPositionContext.networkId || "") + if (root.newPositionContext.status === "no_wallet") + return qsTr("%1 · simulation only").arg(network || qsTr("Wallet disconnected")) + if (root.newPositionContext.status === "ready") + return qsTr("%1 · wallet ready").arg(network) + return network.length > 0 ? network : qsTr("Loading network") + } + + function contextBlocksForm() { + var status = String(root.newPositionContext.status || "") + return status !== "" && status !== "ready" && status !== "no_wallet" && status !== "loading" + } + + function contextErrorText() { + return root.issueText(root.newPositionContext.code || root.newPositionContext.status) } } diff --git a/apps/amm/qml/components/liquidity/TokenAmountInput.qml b/apps/amm/qml/components/liquidity/TokenAmountInput.qml index 05600e4..49c7509 100644 --- a/apps/amm/qml/components/liquidity/TokenAmountInput.qml +++ b/apps/amm/qml/components/liquidity/TokenAmountInput.qml @@ -1,6 +1,6 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts Rectangle { id: root @@ -72,6 +72,7 @@ Rectangle { font.bold: true font.pixelSize: 18 inputMethodHints: Qt.ImhFormattedNumbersOnly + maximumLength: 80 placeholderText: qsTr("0") readOnly: root.readOnly selectByMouse: true diff --git a/apps/amm/qml/components/wallet/AccountControl.qml b/apps/amm/qml/components/wallet/AccountControl.qml new file mode 100644 index 0000000..bc3ce8a --- /dev/null +++ b/apps/amm/qml/components/wallet/AccountControl.qml @@ -0,0 +1,461 @@ +import QtQuick +import QtQml +import QtQuick.Controls +import QtQuick.Layouts + +import Logos.Theme +import Logos.Controls + +// Header wallet control (Uniswap-style), with two states: +// - not connected → a "Connect" button that opens the create-wallet modal +// - connected → a single button showing the active account address; +// clicking it opens a popup (top-right, just under the +// button) holding the account selector, create-account and +// disconnect actions. +// The selected account address is exposed via selectedAddress for the +// trade/liquidity flows to use as the "from" account. +Item { + id: root + + // Backend replica (logos.module("amm_ui")) and its account model. + property var backend: null + property var accountModel: null + + readonly property bool connected: backend !== null && backend.isWalletOpen + + // Index of the active account. selectedAddress/selectedName are derived from + // the model mirror below so they stay valid while the popup (and its list) + // is closed. + property int selectedIndex: 0 + + // Non-visual mirror of the account model: realizes every row regardless of + // popup visibility, so the active account is addressable by index at all + // times (a ListView only realizes rows while it is shown). + Instantiator { + id: accounts + model: root.accountModel + delegate: QtObject { + readonly property string address: model.address ?? "" + readonly property string name: model.name ?? "" + readonly property string balance: model.balance ?? "" + readonly property bool isPublic: model.isPublic ?? false + } + } + + function entryAt(i) { + return (i >= 0 && i < accounts.count) ? accounts.objectAt(i) : null + } + + readonly property string selectedAddress: { + const e = root.entryAt(root.selectedIndex) + return e ? e.address : "" + } + readonly property string selectedName: { + const e = root.entryAt(root.selectedIndex) + return e ? e.name : "" + } + readonly property string selectedBalance: { + const e = root.entryAt(root.selectedIndex) + return e ? e.balance : "" + } + readonly property bool selectedIsPublic: { + const e = root.entryAt(root.selectedIndex) + return e ? e.isPublic : false + } + + // Keep the selection within bounds as accounts are added/removed. + function clampSelection() { + if (accounts.count === 0) { root.selectedIndex = 0; return } + if (root.selectedIndex < 0) root.selectedIndex = 0 + else if (root.selectedIndex >= accounts.count) root.selectedIndex = accounts.count - 1 + } + Connections { + target: root.accountModel + ignoreUnknownSignals: true + function onModelReset() { root.clampSelection() } + function onRowsInserted() { root.clampSelection() } + function onRowsRemoved() { root.clampSelection() } + } + + // 0x123456…cdef style truncation for the connected button label. + function truncated(addr) { + if (!addr) return "" + return addr.length > 13 ? (addr.substring(0, 6) + "…" + addr.substring(addr.length - 4)) : addr + } + + // Copy on the QML/view side. Routing this through the backend would call + // QGuiApplication::clipboard() in the (headless) module host process, which + // has no clipboard — that call tears the backend down, dropping the wallet + // connection. A hidden TextEdit copies via the GUI process that owns it. + TextEdit { id: clipboardProxy; visible: false } + function copyToClipboard(text) { + if (!text) return + clipboardProxy.text = text + clipboardProxy.selectAll() + clipboardProxy.copy() + clipboardProxy.deselect() + clipboardProxy.text = "" + } + + function showWalletMessage(title, message) { + walletMessageDialog.title = title + walletMessageDialog.message = message + walletMessageDialog.open() + } + + function finishAccountCreation(accountId, fallbackError) { + createAccountDialog.busy = false + if (accountId && accountId.length > 0) + createAccountDialog.close() + else + createAccountDialog.createError = fallbackError + } + + implicitWidth: root.connected ? connectedButton.width : connectButton.width + implicitHeight: 40 + + // ── Disconnected: Connect ──────────────────────────────────────────── + LogosButton { + id: connectButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + height: 40 + visible: !root.connected + enabled: root.backend !== null + text: qsTr("Connect") + onClicked: { + // Re-open an existing wallet; only show the create modal on first run. + if (root.backend && root.backend.walletExists) + logos.watch(root.backend.openExisting(), + function(ok) { + if (!ok) + root.showWalletMessage( + qsTr("Unable to connect wallet"), + qsTr("The existing wallet could not be opened. Check the wallet files and try again.")) + }, + function(error) { + root.showWalletMessage( + qsTr("Unable to connect wallet"), + qsTr("Error opening wallet: %1").arg(error)) + }) + else + createWalletDialog.open() + } + } + + // ── Connected: address pill that toggles the wallet menu ───────────── + Rectangle { + id: connectedButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: root.connected + implicitHeight: 40 + implicitWidth: connectedRow.implicitWidth + Theme.spacing.medium * 2 + radius: height / 2 + // Keep an opaque dark fill in both states: the navbar is white, and the + // active "muted" fill is translucent gray, which renders light over white + // and makes the white label unreadable. Signal "open" with an accent + // border instead. + color: Theme.palette.backgroundSecondary + border.width: 1 + border.color: walletMenu.opened ? Theme.palette.overlayOrange : "transparent" + + RowLayout { + id: connectedRow + anchors.centerIn: parent + spacing: Theme.spacing.small + + Rectangle { + Layout.preferredWidth: 8 + Layout.preferredHeight: 8 + radius: 4 + color: "#39c06a" + } + LogosText { + text: root.truncated(root.selectedAddress) || qsTr("Connected") + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.text + } + LogosText { + text: walletMenu.opened ? "▴" : "▾" + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + // CloseOnPressOutside already dismisses the popup on this same press + // (the button is outside it), so `opened` is false by the time this + // fires. Without the recency guard the dismissing click would just + // reopen it. If it just closed, leave it closed. + onClicked: { + if (walletMenu.opened || (Date.now() - walletMenu.lastClosedMs) < 200) + walletMenu.close() + else + walletMenu.open() + } + } + } + + // ── Wallet menu popup (top-right, under the connected button) ───────── + Popup { + id: walletMenu + parent: connectedButton + y: connectedButton.height + Theme.spacing.small + x: connectedButton.width - width // right-align under the button + width: 360 + padding: Theme.spacing.medium + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + // Timestamp of the last dismissal, used by the toggle button to tell a + // genuine "open" click from the press that just closed the popup. + property real lastClosedMs: 0 + onClosed: { + walletMenu.lastClosedMs = Date.now() + // Always reopen on the main (selected-account) view. + if (viewStack.depth > 1) + viewStack.pop(null, StackView.Immediate) + } + + background: Rectangle { + color: Theme.palette.backgroundTertiary + border.width: 1 + border.color: Theme.palette.backgroundElevated + radius: Theme.spacing.radiusLarge + } + + // Two stacked views: the main view (active account + actions) and the + // accounts view (full list + create). The popup height follows the + // active view's natural height, animated so the resize isn't abrupt. + contentItem: StackView { + id: viewStack + clip: true + implicitWidth: walletMenu.availableWidth + implicitHeight: currentItem ? currentItem.implicitHeight : 0 + initialItem: mainView + + Behavior on implicitHeight { + NumberAnimation { duration: 160; easing.type: Easing.OutCubic } + } + + pushEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } } + pushExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } } + popEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } } + popExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } } + } + + // ── Main view: account icon + power icon, then the active account ── + Component { + id: mainView + + ColumnLayout { + spacing: Theme.spacing.medium + + // Top-right actions: open the account list / disconnect. + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + + Item { Layout.fillWidth: true } + + WalletIconButton { + iconSource: Qt.resolvedUrl("icons/account.svg") + onClicked: viewStack.push(accountsView) + } + WalletIconButton { + iconSource: Qt.resolvedUrl("icons/power.svg") + onClicked: { + walletMenu.close() + if (root.backend) root.backend.disconnectWallet() + } + } + } + + // Active account card. + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: cardColumn.implicitHeight + Theme.spacing.medium * 2 + radius: Theme.spacing.radiusLarge + color: Theme.palette.backgroundMuted + + ColumnLayout { + id: cardColumn + anchors.fill: parent + anchors.margins: Theme.spacing.medium + spacing: Theme.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + + LogosText { + text: root.selectedName + font.pixelSize: Theme.typography.secondaryText + font.bold: true + } + Rectangle { + Layout.preferredWidth: tagLabel.implicitWidth + Theme.spacing.small * 2 + Layout.preferredHeight: tagLabel.implicitHeight + 4 + radius: 4 + color: Theme.palette.backgroundSecondary + LogosText { + id: tagLabel + anchors.centerIn: parent + text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private") + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + } + Item { Layout.fillWidth: true } + LogosText { + text: root.selectedBalance.length > 0 ? root.selectedBalance : "—" + font.bold: true + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 0 + LogosText { + Layout.fillWidth: true + verticalAlignment: Text.AlignVCenter + text: root.selectedAddress + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textMuted + elide: Text.ElideMiddle + } + LogosCopyButton { + Layout.preferredHeight: 40 + Layout.preferredWidth: 40 + visible: root.selectedAddress.length > 0 + onCopyText: root.copyToClipboard(root.selectedAddress) + icon.color: Theme.palette.textMuted + } + } + } + } + } + } + + // ── Accounts view: back + full list + create ────────────────────── + Component { + id: accountsView + + ColumnLayout { + spacing: Theme.spacing.medium + + // Header: back to the main view + title. + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + + WalletIconButton { + iconSource: Qt.resolvedUrl("icons/back.svg") + onClicked: viewStack.pop() + } + LogosText { + Layout.fillWidth: true + text: qsTr("Accounts") + font.bold: true + color: Theme.palette.text + } + } + + // Account list: tap a row to make it the active account, then + // return to the main view so the selection is reflected. + ListView { + Layout.fillWidth: true + Layout.preferredHeight: Math.min(contentHeight, 260) + clip: true + model: root.accountModel + spacing: Theme.spacing.small + ScrollIndicator.vertical: ScrollIndicator { } + + delegate: AccountDelegate { + width: ListView.view.width + highlighted: index === root.selectedIndex + onClicked: { + root.selectedIndex = index + viewStack.pop() + } + onCopyRequested: (text) => root.copyToClipboard(text) + } + } + + LogosButton { + Layout.fillWidth: true + height: 40 + text: qsTr("Add") + // Leave the wallet menu open behind the (modal) dialog. + onClicked: createAccountDialog.open() + } + } + } + + } + + // ── Dialogs ────────────────────────────────────────────────────────── + CreateWalletDialog { + id: createWalletDialog + walletHome: root.backend ? root.backend.walletHome : "" + onCreateWallet: function(password) { + if (!root.backend) return + // createNewDefault returns the new wallet's seed phrase (empty on + // failure). On success we hand it to the dialog, which switches to + // its backup page — we do NOT close here, so the user can't skip it. + logos.watch(root.backend.createNewDefault(password), + function(mnemonic) { + if (mnemonic && mnemonic.length > 0) + createWalletDialog.mnemonic = mnemonic + else + createWalletDialog.createError = qsTr("Failed to create wallet. Please try again.") + }, + function(error) { + createWalletDialog.createError = qsTr("Error creating wallet: %1").arg(error) + }) + } + onCopyRequested: function(text) { + if (root.backend) root.backend.copyToClipboard(text) + } + } + + CreateAccountDialog { + id: createAccountDialog + onCreatePublicRequested: { + if (!root.backend) { + root.finishAccountCreation("", qsTr("Wallet backend is unavailable. Reconnect and try again.")) + return + } + createAccountDialog.createError = "" + logos.watch(root.backend.createAccountPublic(), + function(id) { + root.finishAccountCreation(id, qsTr("Failed to create account. Please try again.")) + }, + function(error) { + createAccountDialog.busy = false + createAccountDialog.createError = qsTr("Error creating account: %1").arg(error) + }) + } + onCreatePrivateRequested: { + if (!root.backend) { + root.finishAccountCreation("", qsTr("Wallet backend is unavailable. Reconnect and try again.")) + return + } + createAccountDialog.createError = "" + logos.watch(root.backend.createAccountPrivate(), + function(id) { + root.finishAccountCreation(id, qsTr("Failed to create private account. Please try again.")) + }, + function(error) { + createAccountDialog.busy = false + createAccountDialog.createError = qsTr("Error creating private account: %1").arg(error) + }) + } + } + + WalletMessageDialog { + id: walletMessageDialog + } +} diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index 6be0b7b..debc5d1 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -1,39 +1,50 @@ -import QtQuick 2.15 -import QtQml 2.15 -import "../components/shared" +import QtQuick +import QtQml + import "../components/liquidity" Item { id: root property var backend: null - property var newPositionContext: ({}) - property var newPositionQuote: root.errorQuote(qsTr("Wallet backend is unavailable."), root.currentRequest()) + property var newPositionContext: ({ + "schema": "new-position.v1", + "status": "loading", + "tokens": [], + "feeTiers": [] + }) + property var newPositionQuote: ({}) + property var resolvedTokenIds: [] property int quoteSerial: 0 - property bool formReady: false + property bool componentReady: false property bool contextLoading: false property bool quoteLoading: false + property bool quoteStale: true property bool submitting: false + property bool refreshingAfterSuccess: false + property string transactionId: "" + property string refreshWarning: "" + property string lastContextJson: "" readonly property int pageMargin: 16 - readonly property int preferredCardWidth: 960 - readonly property int pageCardY: newPositionForm.implicitHeight + root.pageMargin * 2 <= scroll.height ? Math.max(root.pageMargin, Math.round((scroll.height - newPositionForm.implicitHeight) / 4)) : root.pageMargin + readonly property int preferredWidth: 800 - width: parent ? parent.width : implicitWidth - height: parent ? parent.height : implicitHeight - implicitWidth: root.preferredCardWidth + root.pageMargin * 2 - implicitHeight: newPositionForm.implicitHeight + root.pageMargin * 2 + Component.onCompleted: { + root.componentReady = true + root.refreshNewPositionContext() + } - Component.onCompleted: root.refreshNewPositionContext() - onBackendChanged: root.refreshNewPositionContext() + onBackendChanged: { + if (root.componentReady) + root.refreshNewPositionContext() + } Connections { target: root.backend ignoreUnknownSignals: true function onNewPositionContextChanged(value) { - root.newPositionContext = value; - root.requestQuote(root.currentRequest()); + root.adoptContext(value) } } @@ -47,49 +58,53 @@ Item { anchors.fill: parent clip: true - contentHeight: Math.max(height, newPositionForm.y + newPositionForm.implicitHeight + root.pageMargin) contentWidth: width + contentHeight: Math.max(height, form.y + form.implicitHeight + root.pageMargin) enabled: !confirmationDialog.open flickableDirection: Flickable.VerticalFlick NewPositionForm { - id: newPositionForm + id: form + + x: Math.max(root.pageMargin, (scroll.width - width) / 2) + y: root.pageMargin + width: Math.max(0, Math.min(root.preferredWidth, scroll.width - root.pageMargin * 2)) newPositionContext: root.newPositionContext quotePayload: root.newPositionQuote contextLoading: root.contextLoading quoteLoading: root.quoteLoading + quoteStale: root.quoteStale submitting: root.submitting - width: Math.max(0, Math.min(scroll.width - root.pageMargin * 2, root.preferredCardWidth)) - x: Math.max(root.pageMargin, (scroll.width - width) / 2) - y: root.pageCardY + transactionId: root.transactionId + refreshWarning: root.refreshWarning - Component.onCompleted: { - root.formReady = true; - root.refreshNewPositionContext(); + onQuoteRequested: function(immediate) { + root.scheduleQuote(immediate) } - onQuoteRequested: function (request) { - root.requestQuote(request); + onConfirmationRequested: function(snapshot) { + confirmationDialog.openWithSnapshot(snapshot) } - onConfirmationRequested: function (snapshot) { - confirmationDialog.openWithSnapshot(snapshot); + onTokenResolveRequested: function(tokenId) { + root.resolveToken(tokenId) } + + onDraftChanged: { + root.transactionId = "" + root.refreshWarning = "" + } + + onRefreshRequested: root.refreshNewPositionContext() } } - SuccessToast { - id: successToast - - width: Math.max(0, Math.min(380, root.width - 32)) - z: 30 - - anchors { - bottom: parent.bottom - bottomMargin: 18 - horizontalCenter: parent.horizontalCenter - } + Timer { + id: quoteDebounce + interval: 250 + repeat: false + onTriggered: root.requestQuoteNow(root.quoteSerial) } NewPositionConfirmationDialog { @@ -98,163 +113,192 @@ Item { anchors.fill: parent busy: root.submitting - onConfirmed: function (snapshot) { - root.confirmNewPosition(snapshot); + onConfirmed: function(snapshot) { + root.confirmNewPosition(snapshot) } } + function contextHints() { + var recent = [] + if (form.selectedTokenAId.length > 0) + recent.push(form.selectedTokenAId) + if (form.selectedTokenBId.length > 0 + && form.selectedTokenBId !== form.selectedTokenAId) { + recent.push(form.selectedTokenBId) + } + return { + "recentTokenIds": recent, + "resolvedTokenIds": root.resolvedTokenIds + } + } + + function refreshNewPositionContext() { + root.contextLoading = true + if (!root.backend || typeof logos === "undefined") { + root.contextLoading = false + root.newPositionContext = root.contextError("wallet_unavailable") + return + } + + logos.watch(root.backend.refreshNewPositionContext(root.contextHints()), + function(context) { + root.adoptContext(context) + if (root.refreshingAfterSuccess) { + root.refreshingAfterSuccess = false + root.refreshWarning = context.status === "ready" || context.status === "no_wallet" + ? "" + : qsTr("Balances could not be refreshed.") + } + }, + function(error) { + root.contextLoading = false + if (root.refreshingAfterSuccess) { + root.refreshingAfterSuccess = false + root.refreshWarning = qsTr("Balances could not be refreshed.") + } else { + root.newPositionContext = root.contextError("backend_error") + } + }) + } + + function adoptContext(context) { + if (!context || context.schema !== "new-position.v1") + context = root.contextError("unsupported_schema") + var serialized = JSON.stringify(context) + root.contextLoading = false + if (serialized === root.lastContextJson) + return + root.lastContextJson = serialized + root.newPositionContext = context + root.quoteStale = true + ++root.quoteSerial + } + + function resolveToken(tokenId) { + var value = String(tokenId || "").trim() + if (value.length === 0) + return + if (root.resolvedTokenIds.indexOf(value) < 0) { + var next = root.resolvedTokenIds.slice(0) + next.push(value) + root.resolvedTokenIds = next + } + root.refreshNewPositionContext() + } + + function scheduleQuote(immediate) { + ++root.quoteSerial + root.quoteStale = true + root.quoteLoading = true + quoteDebounce.stop() + if (immediate) + root.requestQuoteNow(root.quoteSerial) + else + quoteDebounce.restart() + } + + function requestQuoteNow(serial) { + if (serial !== root.quoteSerial) + return + var built = form.buildQuoteRequest() + if (!built.ok) { + root.quoteLoading = false + return + } + if (!root.backend || typeof logos === "undefined") { + root.quoteLoading = false + root.newPositionQuote = root.quoteError("wallet_unavailable") + return + } + + logos.watch(root.backend.quoteNewPosition(built.request), + function(quote) { + if (serial !== root.quoteSerial) + return + root.quoteLoading = false + root.quoteStale = false + if (!quote || quote.schema !== "new-position.v1") + root.newPositionQuote = root.quoteError("unsupported_schema") + else + root.newPositionQuote = quote + }, + function(error) { + if (serial !== root.quoteSerial) + return + root.quoteLoading = false + root.quoteStale = true + form.submitError = qsTr("Quote request failed. Refresh and retry.") + }) + } + function confirmNewPosition(snapshot) { if (root.submitting) - return; - - root.submitting = true; - confirmationDialog.errorText = ""; - newPositionForm.setSubmitError(""); + return + root.submitting = true + form.submitError = "" if (!root.backend || typeof logos === "undefined") { - root.submitting = false; - root.showSubmitError(qsTr("Wallet backend is unavailable.")); - return; + root.finishSubmitFailure(root.quoteError("wallet_unavailable")) + return } logos.watch(root.backend.submitNewPosition(snapshot.request, snapshot.quoteHash), function(result) { - root.submitting = false; - if (result.status !== "ok") { - root.showSubmitError(result.error); - return; + if (result && result.schema === "new-position.v1" + && result.status === "submitted" + && /^[0-9a-f]{64}$/.test(String(result.transactionId || ""))) { + root.submitting = false + confirmationDialog.closeAfterSuccess() + root.transactionId = result.transactionId + root.refreshWarning = "" + form.resetAfterSubmit() + root.newPositionQuote = ({}) + root.quoteStale = true + root.refreshingAfterSuccess = true + root.refreshNewPositionContext() + return } - - confirmationDialog.closeAfterSuccess(); - newPositionForm.resetAfterSubmit(); - successToast.show(result.message, result.detail); + root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed")) }, function(error) { - root.submitting = false; - root.showSubmitError(qsTr("Error submitting position: %1").arg(error)); - }); + root.finishSubmitFailure(root.quoteError("wallet_submission_failed")) + }) } - function refreshNewPositionContext() { - root.contextLoading = true; - - if (!root.backend || typeof logos === "undefined") { - root.contextLoading = false; - root.newPositionContext = { - "activeAccountDisplay": qsTr("Not connected"), - "holdings": [], - "feeTiers": [] - }; - root.newPositionQuote = root.errorQuote(qsTr("Wallet backend is unavailable."), root.currentRequest()); - return; - } - - logos.watch(root.backend.refreshNewPositionContext(), - function(context) { - root.contextLoading = false; - root.newPositionContext = context; - root.requestQuote(root.currentRequest()); - }, - function(error) { - root.contextLoading = false; - root.newPositionQuote = root.errorQuote(qsTr("Error loading position context: %1").arg(error), root.currentRequest()); - }); + function finishSubmitFailure(result) { + root.submitting = false + confirmationDialog.closeAfterFailure() + if (result && result.quote && result.quote.schema === "new-position.v1") + root.newPositionQuote = result.quote + var code = result && result.code ? result.code : "wallet_submission_failed" + form.submitError = form.issueText(code) + root.scheduleQuote(true) } - function requestQuote(request) { - root.quoteLoading = true; - - if (!root.backend || typeof logos === "undefined") { - root.quoteLoading = false; - root.newPositionQuote = root.errorQuote(qsTr("Wallet backend is unavailable."), request); - return; - } - - const serial = ++root.quoteSerial; - logos.watch(root.backend.quoteNewPosition(request), - function(quote) { - if (serial === root.quoteSerial) { - root.quoteLoading = false; - root.newPositionQuote = quote; - } - }, - function(error) { - if (serial === root.quoteSerial) { - root.quoteLoading = false; - root.newPositionQuote = root.errorQuote(qsTr("Error loading quote: %1").arg(error), request); - } - }); - } - - function showSubmitError(message) { - const text = message && message.length > 0 ? message : qsTr("Position submission failed."); - confirmationDialog.errorText = text; - newPositionForm.setSubmitError(text); - } - - function amountValue(symbol) { - return { - "value": 0, - "input": "0", - "display": qsTr("0 %1").arg(symbol), - "symbol": symbol - }; - } - - function currentRequest() { - if (root.formReady) - return newPositionForm.buildRequest(); - - return { - "amountA": "", - "amountB": "", - "depositScale": 1, - "editedSide": "A", - "feeBps": 30, - "initialPrice": "1", - "slippageBps": 50, - "tokenA": "", - "tokenB": "" - }; - } - - function errorQuote(message, request) { - const tokenA = request ? request.tokenA : ""; - const tokenB = request ? request.tokenB : ""; + function contextError(code) { return { + "schema": "new-position.v1", "status": "error", - "error": message, + "code": code, + "tokens": [], + "feeTiers": [] + } + } + + function quoteError(code) { + return { + "schema": "new-position.v1", + "status": "error", + "canSubmit": false, + "code": code, "poolStatus": "unavailable_pool", - "statusLabel": qsTr("Unavailable"), - "statusDetail": message, - "instruction": "", - "storedFeeBps": 0, - "feeBps": request ? request.feeBps : 0, - "feeLabel": "", - "quoteHash": "", - "pool": { - "id": "", - "priceText": "", - "reserveText": "" - }, - "deposit": { - "maxA": root.amountValue(tokenA), - "maxB": root.amountValue(tokenB), - "actualA": root.amountValue(tokenA), - "actualB": root.amountValue(tokenB) - }, - "lp": { - "expected": root.amountValue("LP"), - "minimum": root.amountValue("LP"), - "locked": root.amountValue("LP") - }, - "position": { - "userLp": "0 LP", - "share": "-", - "ownedA": qsTr("0 %1").arg(tokenA), - "ownedB": qsTr("0 %1").arg(tokenB) - }, - "accountChanges": [] - }; + "errors": [{ + "code": code, + "blockingFields": [], + "details": ({}) + }], + "warnings": [], + "accountPreview": [] + } } } diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 0e77f4d..15669dc 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -2,7 +2,7 @@ #include #include -#include +#include #include #include #include @@ -11,9 +11,12 @@ #include #include #include +#include #include #include #include +#include +#include #include #include #include @@ -21,425 +24,205 @@ #include "logos_api.h" #include "logos_sdk.h" -#include -#include +#include "amm_client.h" namespace { const char SETTINGS_ORG[] = "Logos"; const char SETTINGS_APP[] = "AmmUI"; - // Sticky "user pressed Disconnect" flag so the wallet stays locked across - // relaunches until the user reconnects. const char DISCONNECTED_KEY[] = "disconnected"; - const int WALLET_FFI_SUCCESS = 0; - - // Wallet home env override. Mirrors LEZ's own var so the app shares the - // canonical wallet (~/.lee/wallet) used by the wallet UI and other apps. const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; - constexpr double MINIMUM_LIQUIDITY = 1000.0; - constexpr double ACTIVE_POOL_USDC_RESERVE = 1'250'000.0; - constexpr double ACTIVE_POOL_LOGOS_RESERVE = 10'000'000.0; - constexpr double ACTIVE_POOL_LP_SUPPLY = 6'875'000.0; + const char NETWORK_ENV[] = "AMM_UI_NETWORK"; + const char DEVNET_FILE_ENV[] = "AMM_UI_DEVNET_FILE"; + const int WALLET_FFI_SUCCESS = 0; + const int CHECKPOINT_BLOCK_ID = 10; + const int BLOCK_HASH_OFFSET = 40; + const int BLOCK_HASH_SIZE = 32; + const char SCHEMA[] = "new-position.v1"; - // Normalise file:// URLs and OS paths to a plain local path. - QString toLocalPath(const QString& path) { - if (path.startsWith("file://") || path.contains("/")) + using AmmClientOperation = char* (*)(const char*); + + QString toLocalPath(const QString& path) + { + if (path.startsWith(QStringLiteral("file://")) || path.contains(QLatin1Char('/'))) return QUrl::fromUserInput(path).toLocalFile(); return path; } - QString stableId(const QString& key) + bool isLowerHex(const QString& value, int size) { - const QByteArray digest = - QCryptographicHash::hash(key.toUtf8(), QCryptographicHash::Sha256).toHex(); - return QString::fromLatin1(digest); - } - - QString shortId(const QString& id) - { - if (id.length() <= 14) - return id; - return id.left(6) + QStringLiteral("...") + id.right(4); - } - - double parsePositiveAmount(QString value) - { - value.remove(QLatin1Char(',')); - bool ok = false; - const double parsed = value.toDouble(&ok); - return ok && std::isfinite(parsed) && parsed > 0.0 ? parsed : 0.0; - } - - QString formatAmount(double amount) - { - amount = std::max(0.0, amount); - const int decimals = amount >= 1000.0 ? 2 : amount >= 1.0 ? 4 : 6; - QString text = QString::number(amount, 'f', decimals); - while (text.contains(QLatin1Char('.')) && text.endsWith(QLatin1Char('0'))) - text.chop(1); - if (text.endsWith(QLatin1Char('.'))) - text.chop(1); - return text; - } - - QString formatTokenAmount(double amount, const QString& symbol) - { - return QStringLiteral("%1 %2").arg(formatAmount(amount), symbol); - } - - QVariantMap amountValue(double amount, const QString& symbol) - { - QVariantMap value; - value.insert(QStringLiteral("value"), amount); - value.insert(QStringLiteral("input"), formatAmount(amount)); - value.insert(QStringLiteral("display"), formatTokenAmount(amount, symbol)); - value.insert(QStringLiteral("symbol"), symbol); - return value; - } - - QString feeLabel(int bps) - { - if (bps == 1) - return QStringLiteral("0.01%"); - if (bps == 5) - return QStringLiteral("0.05%"); - if (bps == 30) - return QStringLiteral("0.30%"); - if (bps == 100) - return QStringLiteral("1.00%"); - return QStringLiteral("%1 bps").arg(bps); - } - - bool isSupportedFeeTier(int bps) - { - return bps == 1 || bps == 5 || bps == 30 || bps == 100; - } - - QVariantList feeTiers() - { - QVariantList tiers; - for (const int bps : {1, 5, 25, 30, 100}) { - QVariantMap tier; - tier.insert(QStringLiteral("bps"), bps); - tier.insert(QStringLiteral("label"), feeLabel(bps)); - tier.insert(QStringLiteral("supported"), isSupportedFeeTier(bps)); - tiers.append(tier); + if (value.size() != size) + return false; + for (const QChar character : value) { + if (!character.isDigit() + && (character < QLatin1Char('a') || character > QLatin1Char('f'))) { + return false; + } } - return tiers; + return true; } - double devnetBalance(const QString& symbol) + bool isHex(const QString& value, int size) { - if (symbol == QStringLiteral("USDC")) - return 125000.0; - if (symbol == QStringLiteral("LOGOS")) - return 850000.0; - if (symbol == QStringLiteral("WETH")) - return 100.0; - return 0.0; - } - - QString accentForSymbol(const QString& symbol) - { - if (symbol == QStringLiteral("USDC")) - return QStringLiteral("#2E7CF6"); - if (symbol == QStringLiteral("LOGOS")) - return QStringLiteral("#F26A21"); - if (symbol == QStringLiteral("WETH")) - return QStringLiteral("#B7C2D8"); - return QStringLiteral("#343434"); - } - - QVariantMap devnetHolding(const QString& owner, const QString& symbol, const QString& name) - { - const double balance = devnetBalance(symbol); - const QString definitionId = stableId(QStringLiteral("devnet:token-definition:%1").arg(symbol)); - QVariantMap holding; - holding.insert(QStringLiteral("symbol"), symbol); - holding.insert(QStringLiteral("name"), name); - holding.insert(QStringLiteral("definitionId"), definitionId); - holding.insert(QStringLiteral("holdingId"), - stableId(QStringLiteral("devnet:token-holding:%1:%2").arg(owner, symbol))); - holding.insert(QStringLiteral("balance"), balance); - holding.insert(QStringLiteral("balanceText"), formatTokenAmount(balance, symbol)); - holding.insert(QStringLiteral("accent"), accentForSymbol(symbol)); - return holding; - } - - QVariantList devnetHoldings(const QString& owner) - { - if (owner.isEmpty()) - return {}; - - QVariantList holdings; - holdings.append(devnetHolding(owner, QStringLiteral("USDC"), QStringLiteral("USD Coin"))); - holdings.append(devnetHolding(owner, QStringLiteral("LOGOS"), QStringLiteral("Logos"))); - holdings.append(devnetHolding(owner, QStringLiteral("WETH"), QStringLiteral("Wrapped Ether"))); - return holdings; - } - - QVariantMap holdingBySymbol(const QVariantList& holdings, const QString& symbol) - { - for (const QVariant& item : holdings) { - const QVariantMap holding = item.toMap(); - if (holding.value(QStringLiteral("symbol")).toString() == symbol) - return holding; + if (value.size() != size) + return false; + for (const QChar character : value) { + const QChar lower = character.toLower(); + if (!character.isDigit() + && (lower < QLatin1Char('a') || lower > QLatin1Char('f'))) { + return false; + } } - return {}; + return true; } - QString unorderedPairKey(const QString& symbolA, const QString& symbolB) + QJsonObject issue(const QString& code, const QJsonArray& blockingFields = {}) { - return symbolA < symbolB - ? QStringLiteral("%1/%2").arg(symbolA, symbolB) - : QStringLiteral("%1/%2").arg(symbolB, symbolA); - } - - double activeRatio(const QString& symbolA, const QString& symbolB) - { - if (symbolA == QStringLiteral("USDC") && symbolB == QStringLiteral("LOGOS")) - return ACTIVE_POOL_LOGOS_RESERVE / ACTIVE_POOL_USDC_RESERVE; - if (symbolA == QStringLiteral("LOGOS") && symbolB == QStringLiteral("USDC")) - return ACTIVE_POOL_USDC_RESERVE / ACTIVE_POOL_LOGOS_RESERVE; - return 1.0; - } - - double activePoolReserve(const QString& symbol) - { - if (symbol == QStringLiteral("USDC")) - return ACTIVE_POOL_USDC_RESERVE; - if (symbol == QStringLiteral("LOGOS")) - return ACTIVE_POOL_LOGOS_RESERVE; - return 0.0; - } - - double defaultInitialPrice(const QString& symbolA, const QString& symbolB) - { - if (symbolA == QStringLiteral("USDC") && symbolB == QStringLiteral("WETH")) - return 2500.0; - if (symbolA == QStringLiteral("WETH") && symbolB == QStringLiteral("USDC")) - return 0.0004; - return 1.0; - } - - QVariantMap poolContext(const QString& symbolA, const QString& symbolB) - { - QVariantMap context; - context.insert(QStringLiteral("poolStatus"), QStringLiteral("unavailable_pool")); - context.insert(QStringLiteral("statusLabel"), QStringLiteral("Unavailable")); - context.insert(QStringLiteral("detail"), QStringLiteral("Choose two different assets from the active account.")); - context.insert(QStringLiteral("instruction"), QString()); - context.insert(QStringLiteral("storedFeeBps"), 0); - context.insert(QStringLiteral("poolId"), QString()); - context.insert(QStringLiteral("priceText"), QString()); - context.insert(QStringLiteral("reserveText"), QString()); - - if (symbolA.isEmpty() || symbolB.isEmpty() || symbolA == symbolB) - return context; - - const QString pairKey = unorderedPairKey(symbolA, symbolB); - const QString poolId = stableId(QStringLiteral("devnet:amm-pool:%1").arg(pairKey)); - context.insert(QStringLiteral("poolId"), poolId); - - if (pairKey == QStringLiteral("LOGOS/USDC")) { - context.insert(QStringLiteral("poolStatus"), QStringLiteral("active_pool")); - context.insert(QStringLiteral("statusLabel"), QStringLiteral("Active pool")); - context.insert(QStringLiteral("detail"), QStringLiteral("Deposits are quoted against the existing pool ratio. Nonmatching fee tiers are locked.")); - context.insert(QStringLiteral("instruction"), QStringLiteral("add_liquidity")); - context.insert(QStringLiteral("storedFeeBps"), 30); - context.insert(QStringLiteral("priceText"), - symbolA == QStringLiteral("USDC") - ? QStringLiteral("1 USDC = 8 LOGOS") - : QStringLiteral("1 LOGOS = 0.125 USDC")); - context.insert(QStringLiteral("reserveText"), - symbolA == QStringLiteral("USDC") - ? QStringLiteral("1,250,000 USDC / 10,000,000 LOGOS") - : QStringLiteral("10,000,000 LOGOS / 1,250,000 USDC")); - return context; - } - - if (pairKey == QStringLiteral("USDC/WETH")) { - context.insert(QStringLiteral("poolStatus"), QStringLiteral("missing_pool")); - context.insert(QStringLiteral("statusLabel"), QStringLiteral("Missing pool")); - context.insert(QStringLiteral("detail"), QStringLiteral("Set the initial price first, then scale both deposits together.")); - context.insert(QStringLiteral("instruction"), QStringLiteral("new_definition")); - context.insert(QStringLiteral("priceText"), QStringLiteral("No reserves yet")); - context.insert(QStringLiteral("reserveText"), QStringLiteral("Pool account is empty")); - return context; - } - - context.insert(QStringLiteral("detail"), QStringLiteral("A pool account exists, but it cannot be quoted safely for this devnet state.")); - context.insert(QStringLiteral("priceText"), QStringLiteral("Quote disabled")); - context.insert(QStringLiteral("reserveText"), QStringLiteral("Unsupported stored pool state")); - return context; - } - - QString quoteHash(const QVariantMap& request) - { - const QStringList parts = { - request.value(QStringLiteral("tokenA")).toString(), - request.value(QStringLiteral("tokenB")).toString(), - QString::number(request.value(QStringLiteral("feeBps")).toInt()), - request.value(QStringLiteral("editedSide")).toString(), - request.value(QStringLiteral("amountA")).toString(), - request.value(QStringLiteral("amountB")).toString(), - request.value(QStringLiteral("initialPrice")).toString(), - QString::number(request.value(QStringLiteral("depositScale")).toInt()), - QString::number(request.value(QStringLiteral("slippageBps")).toInt()), + return { + { QStringLiteral("code"), code }, + { QStringLiteral("recoverable"), true }, + { QStringLiteral("blockingFields"), blockingFields }, + { QStringLiteral("details"), QJsonObject() }, }; - const QByteArray digest = - QCryptographicHash::hash(parts.join(QLatin1Char('|')).toUtf8(), - QCryptographicHash::Sha256).toHex(); - return QStringLiteral("sha256-%1").arg(QString::fromLatin1(digest)); } - QVariantMap accountChange(const QString& role, const QString& id, const QString& action) + QJsonObject publicError(const QString& code, + const QJsonArray& blockingFields = {}, + const QJsonObject& details = {}) { - QVariantMap change; - change.insert(QStringLiteral("role"), role); - change.insert(QStringLiteral("id"), id); - change.insert(QStringLiteral("action"), action); - return change; + QJsonObject error = issue(code, blockingFields); + error.insert(QStringLiteral("details"), details); + return { + { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, + { QStringLiteral("status"), QStringLiteral("error") }, + { QStringLiteral("canSubmit"), false }, + { QStringLiteral("code"), code }, + { QStringLiteral("errors"), QJsonArray { error } }, + { QStringLiteral("warnings"), QJsonArray() }, + { QStringLiteral("accountPreview"), QJsonArray() }, + }; } - QVariantList accountChanges(const QVariantMap& request, const QVariantMap& context) + QJsonObject contextState(const QString& status, + const QString& networkId, + const QString& fingerprint = {}) { - const QString tokenA = request.value(QStringLiteral("tokenA")).toString(); - const QString tokenB = request.value(QStringLiteral("tokenB")).toString(); - const QString poolId = context.value(QStringLiteral("poolId")).toString(); - const bool missingPool = - context.value(QStringLiteral("poolStatus")).toString() == QStringLiteral("missing_pool"); + return { + { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, + { QStringLiteral("status"), status }, + { QStringLiteral("networkId"), networkId }, + { QStringLiteral("networkFingerprint"), fingerprint }, + { QStringLiteral("tokens"), QJsonArray() }, + { QStringLiteral("feeTiers"), QJsonArray { + QJsonObject { { QStringLiteral("feeBps"), 1 }, { QStringLiteral("label"), QStringLiteral("0.01%") }, { QStringLiteral("enabled"), true } }, + QJsonObject { { QStringLiteral("feeBps"), 5 }, { QStringLiteral("label"), QStringLiteral("0.05%") }, { QStringLiteral("enabled"), true } }, + QJsonObject { { QStringLiteral("feeBps"), 30 }, { QStringLiteral("label"), QStringLiteral("0.30%") }, { QStringLiteral("enabled"), true } }, + QJsonObject { { QStringLiteral("feeBps"), 100 }, { QStringLiteral("label"), QStringLiteral("1.00%") }, { QStringLiteral("enabled"), true } }, + } }, + { QStringLiteral("warnings"), QJsonArray() }, + }; + } - QVariantList changes; - changes.append(accountChange(QStringLiteral("Config"), - stableId(QStringLiteral("devnet:amm-config")), - QStringLiteral("Read"))); - changes.append(accountChange(QStringLiteral("Pool"), - poolId, - missingPool ? QStringLiteral("Create") : QStringLiteral("Update"))); - changes.append(accountChange(QStringLiteral("Vault A"), - stableId(QStringLiteral("devnet:vault:%1:%2").arg(poolId, tokenA)), - missingPool ? QStringLiteral("Update or create") : QStringLiteral("Update"))); - changes.append(accountChange(QStringLiteral("Vault B"), - stableId(QStringLiteral("devnet:vault:%1:%2").arg(poolId, tokenB)), - missingPool ? QStringLiteral("Update or create") : QStringLiteral("Update"))); - changes.append(accountChange(QStringLiteral("LP definition"), - stableId(QStringLiteral("devnet:lp-definition:%1").arg(poolId)), - missingPool ? QStringLiteral("Create") : QStringLiteral("Update"))); - if (missingPool) { - changes.append(accountChange(QStringLiteral("LP lock holding"), - stableId(QStringLiteral("devnet:lp-lock:%1").arg(poolId)), - QStringLiteral("Create"))); + QJsonObject callClient(AmmClientOperation operation, + const QJsonObject& request, + bool* ok) + { + *ok = false; + const QByteArray payload = QJsonDocument(request).toJson(QJsonDocument::Compact); + char* raw = operation(payload.constData()); + if (!raw) { + qWarning() << "AmmUiBackend: AMM client returned a null response"; + return {}; } - changes.append(accountChange(QStringLiteral("User holding A"), - stableId(QStringLiteral("devnet:user-holding:%1").arg(tokenA)), - QStringLiteral("Update"))); - changes.append(accountChange(QStringLiteral("User holding B"), - stableId(QStringLiteral("devnet:user-holding:%1").arg(tokenB)), - QStringLiteral("Update"))); - changes.append(accountChange(QStringLiteral("User LP holding"), - stableId(QStringLiteral("devnet:user-lp:%1").arg(poolId)), - QStringLiteral("Update or create"))); - changes.append(accountChange(QStringLiteral("Current tick"), - stableId(QStringLiteral("devnet:current-tick:%1").arg(poolId)), - missingPool ? QStringLiteral("Create") : QStringLiteral("Update"))); - changes.append(accountChange(QStringLiteral("Clock"), - stableId(QStringLiteral("devnet:clock:canonical")), - QStringLiteral("Read"))); - return changes; + + const QByteArray response(raw); + amm_free(raw); + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(response, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + qWarning() << "AmmUiBackend: invalid AMM client response"; + return {}; + } + + const QJsonObject envelope = document.object(); + if (!envelope.value(QStringLiteral("ok")).toBool(false)) { + qWarning() << "AmmUiBackend: AMM client boundary failure:" + << envelope.value(QStringLiteral("error")).toString(); + return {}; + } + if (!envelope.value(QStringLiteral("value")).isObject()) { + qWarning() << "AmmUiBackend: AMM client value is not an object"; + return {}; + } + *ok = true; + return envelope.value(QStringLiteral("value")).toObject(); } - QVariantMap baseQuote(const QVariantMap& context, const QVariantMap& request) + QByteArray jsonRpcBody(const QString& method, const QJsonArray& params) { - QVariantMap quote; - quote.insert(QStringLiteral("poolStatus"), context.value(QStringLiteral("poolStatus"))); - quote.insert(QStringLiteral("statusLabel"), context.value(QStringLiteral("statusLabel"))); - quote.insert(QStringLiteral("statusDetail"), context.value(QStringLiteral("detail"))); - quote.insert(QStringLiteral("instruction"), context.value(QStringLiteral("instruction"))); - quote.insert(QStringLiteral("storedFeeBps"), context.value(QStringLiteral("storedFeeBps"))); - quote.insert(QStringLiteral("feeBps"), request.value(QStringLiteral("feeBps")).toInt()); - quote.insert(QStringLiteral("feeLabel"), feeLabel(request.value(QStringLiteral("feeBps")).toInt())); - quote.insert(QStringLiteral("quoteHash"), quoteHash(request)); - - QVariantMap pool; - pool.insert(QStringLiteral("id"), context.value(QStringLiteral("poolId"))); - pool.insert(QStringLiteral("priceText"), context.value(QStringLiteral("priceText"))); - pool.insert(QStringLiteral("reserveText"), context.value(QStringLiteral("reserveText"))); - quote.insert(QStringLiteral("pool"), pool); - return quote; + return QJsonDocument(QJsonObject { + { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, + { QStringLiteral("id"), 1 }, + { QStringLiteral("method"), method }, + { QStringLiteral("params"), params }, + }).toJson(QJsonDocument::Compact); } - QVariantMap quoteOk(const QVariantMap& context, - const QVariantMap& request, - double maxA, - double maxB, - double actualA, - double actualB, - double expectedLp, - double minimumLp, - double lockedLp, - const QVariantMap& position) + QString blockHashFromResponse(const QByteArray& payload) { - QVariantMap quote = baseQuote(context, request); - const QString tokenA = request.value(QStringLiteral("tokenA")).toString(); - const QString tokenB = request.value(QStringLiteral("tokenB")).toString(); - quote.insert(QStringLiteral("status"), QStringLiteral("ok")); - quote.insert(QStringLiteral("error"), QString()); - - QVariantMap deposit; - deposit.insert(QStringLiteral("maxA"), amountValue(maxA, tokenA)); - deposit.insert(QStringLiteral("maxB"), amountValue(maxB, tokenB)); - deposit.insert(QStringLiteral("actualA"), amountValue(actualA, tokenA)); - deposit.insert(QStringLiteral("actualB"), amountValue(actualB, tokenB)); - quote.insert(QStringLiteral("deposit"), deposit); - - QVariantMap lp; - lp.insert(QStringLiteral("expected"), amountValue(expectedLp, QStringLiteral("LP"))); - lp.insert(QStringLiteral("minimum"), amountValue(minimumLp, QStringLiteral("LP"))); - lp.insert(QStringLiteral("locked"), amountValue(lockedLp, QStringLiteral("LP"))); - quote.insert(QStringLiteral("lp"), lp); - - quote.insert(QStringLiteral("position"), position); - quote.insert(QStringLiteral("accountChanges"), accountChanges(request, context)); - - QVariantMap transaction; - transaction.insert(QStringLiteral("instruction"), context.value(QStringLiteral("instruction"))); - transaction.insert(QStringLiteral("ready"), false); - transaction.insert(QStringLiteral("reason"), QStringLiteral("Submission requires real token holding account ids from wallet discovery.")); - quote.insert(QStringLiteral("transaction"), transaction); - return quote; + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + return {}; + const QByteArray block = + QByteArray::fromBase64(document.object().value(QStringLiteral("result")).toString().toLatin1()); + if (block.size() < BLOCK_HASH_OFFSET + BLOCK_HASH_SIZE) + return {}; + return QString::fromLatin1(block.mid(BLOCK_HASH_OFFSET, BLOCK_HASH_SIZE).toHex()); } - QVariantMap quoteError(const QVariantMap& context, const QVariantMap& request, const QString& errorText) + QString channelIdFromResponse(const QByteArray& payload) { - QVariantMap quote = baseQuote(context, request); - const QString tokenA = request.value(QStringLiteral("tokenA")).toString(); - const QString tokenB = request.value(QStringLiteral("tokenB")).toString(); - quote.insert(QStringLiteral("status"), QStringLiteral("error")); - quote.insert(QStringLiteral("error"), errorText); + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + return {}; + const QString channel = document.object().value(QStringLiteral("result")).toString(); + return isLowerHex(channel, 64) ? channel : QString(); + } - QVariantMap deposit; - deposit.insert(QStringLiteral("maxA"), amountValue(0, tokenA)); - deposit.insert(QStringLiteral("maxB"), amountValue(0, tokenB)); - deposit.insert(QStringLiteral("actualA"), amountValue(0, tokenA)); - deposit.insert(QStringLiteral("actualB"), amountValue(0, tokenB)); - quote.insert(QStringLiteral("deposit"), deposit); + QJsonArray variantStringArray(const QVariant& value) + { + QJsonArray result; + for (const QVariant& item : value.toList()) + result.append(item.toString()); + return result; + } - QVariantMap lp; - lp.insert(QStringLiteral("expected"), amountValue(0, QStringLiteral("LP"))); - lp.insert(QStringLiteral("minimum"), amountValue(0, QStringLiteral("LP"))); - const bool missingPool = - context.value(QStringLiteral("poolStatus")).toString() == QStringLiteral("missing_pool"); - lp.insert(QStringLiteral("locked"), amountValue(missingPool ? 1000.0 : 0.0, QStringLiteral("LP"))); - quote.insert(QStringLiteral("lp"), lp); + QStringList jsonStringList(const QJsonArray& values) + { + QStringList result; + result.reserve(values.size()); + for (const QJsonValue& value : values) + result.append(value.toString()); + return result; + } - QVariantMap position; - position.insert(QStringLiteral("userLp"), QStringLiteral("0 LP")); - position.insert(QStringLiteral("share"), QStringLiteral("-")); - position.insert(QStringLiteral("ownedA"), formatTokenAmount(0, tokenA)); - position.insert(QStringLiteral("ownedB"), formatTokenAmount(0, tokenB)); - quote.insert(QStringLiteral("position"), position); - quote.insert(QStringLiteral("accountChanges"), QVariantList()); - return quote; + QVariantList jsonBoolList(const QJsonArray& values) + { + QVariantList result; + result.reserve(values.size()); + for (const QJsonValue& value : values) + result.append(value.toBool()); + return result; + } + + QVariantList jsonUIntList(const QJsonArray& values) + { + QVariantList result; + result.reserve(values.size()); + for (const QJsonValue& value : values) + result.append(QVariant::fromValue(static_cast(value.toInteger()))); + return result; } } @@ -478,7 +261,13 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) setWalletHome(defaultWalletHome()); // Assume reachable until a probe proves otherwise (avoids a startup flash). setSequencerReachable(true); - setNewPositionContext(buildNewPositionContext()); + loadNetworkConfig(); + setNewPositionContext(contextState( + m_networkStatus == QStringLiteral("network_unknown") + ? QStringLiteral("loading") + : m_networkStatus, + m_networkId, + m_networkFingerprint).toVariantMap()); // Periodically re-probe the sequencer so the banner reacts to a node going // up/down while the app is running. Probes are no-ops until a wallet (and @@ -535,7 +324,6 @@ void AmmUiBackend::openOrAdoptWallet() m_accountModel->replaceFromJsonArray(existing); refreshBalances(); refreshSequencerAddr(); - refreshNewPositionContext(); return; } @@ -559,7 +347,6 @@ void AmmUiBackend::openOrAdoptWallet() refreshSequencerAddr(); } else { qWarning() << "AmmUiBackend: wallet open failed, code" << err; - refreshNewPositionContext(); } } @@ -605,7 +392,6 @@ QString AmmUiBackend::createNew(QString configPath, QString storagePath, QString refreshAccounts(); refreshBlockHeights(); refreshSequencerAddr(); - refreshNewPositionContext(); return mnemonic; } @@ -621,7 +407,6 @@ bool AmmUiBackend::openExisting() refreshBalances(); refreshSequencerAddr(); QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); - refreshNewPositionContext(); return true; } @@ -642,7 +427,6 @@ bool AmmUiBackend::openExisting() refreshAccounts(); refreshBlockHeights(); refreshSequencerAddr(); - refreshNewPositionContext(); return true; } @@ -655,7 +439,7 @@ void AmmUiBackend::disconnectWallet() setIsWalletOpen(false); m_accountModel->replaceFromJsonArray(QJsonArray()); QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, true); - refreshNewPositionContext(); + refreshNewPositionContext(QVariantMap()); } QString AmmUiBackend::createAccountPublic() @@ -679,7 +463,6 @@ void AmmUiBackend::refreshAccounts() const QJsonArray arr = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); m_accountModel->replaceFromJsonArray(arr); refreshBalances(); - refreshNewPositionContext(); } void AmmUiBackend::refreshBalances() @@ -695,7 +478,7 @@ void AmmUiBackend::refreshBalances() m_accountModel->setBalanceByAddress(addr, getBalance(addr, isPub)); } saveWallet(); - refreshNewPositionContext(); + refreshNewPositionContext(QVariantMap()); } QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic) @@ -703,224 +486,311 @@ QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic) return m_logos->logos_execution_zone.get_balance(accountIdHex, isPublic); } -QString AmmUiBackend::activeAccountAddress() const + +QJsonObject AmmUiBackend::readAccount(const QString& accountId) const { - if (m_accountModel->count() <= 0) - return {}; - const QModelIndex idx = m_accountModel->index(0, 0); - return m_accountModel->data(idx, AccountModel::AddressRole).toString(); -} + QJsonObject read { + { QStringLiteral("id"), accountId }, + { QStringLiteral("status"), QStringLiteral("read_failed") }, + }; + if (!isLowerHex(accountId, 64)) + return read; -QVariantMap AmmUiBackend::buildNewPositionContext() const -{ - QVariantMap context; - context.insert(QStringLiteral("minimumLiquidity"), MINIMUM_LIQUIDITY); - context.insert(QStringLiteral("feeTiers"), feeTiers()); + const QString payload = m_logos->logos_execution_zone.get_account_public(accountId); + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(payload.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + return read; - QVariantMap network; - network.insert(QStringLiteral("id"), QStringLiteral("devnet")); - network.insert(QStringLiteral("name"), QStringLiteral("Local devnet")); - network.insert(QStringLiteral("selector"), QStringLiteral("temporary-file")); - context.insert(QStringLiteral("network"), network); - - QVariantMap programIds; - programIds.insert(QStringLiteral("amm"), stableId(QStringLiteral("devnet:program:amm"))); - programIds.insert(QStringLiteral("token"), stableId(QStringLiteral("devnet:program:token"))); - programIds.insert(QStringLiteral("twapOracle"), stableId(QStringLiteral("devnet:program:twap-oracle"))); - context.insert(QStringLiteral("programIds"), programIds); - - QVariantMap activeAccount; - const bool hasAccount = isWalletOpen() && m_accountModel->count() > 0; - if (hasAccount) { - const QModelIndex idx = m_accountModel->index(0, 0); - const QString address = m_accountModel->data(idx, AccountModel::AddressRole).toString(); - activeAccount.insert(QStringLiteral("name"), m_accountModel->data(idx, AccountModel::NameRole).toString()); - activeAccount.insert(QStringLiteral("address"), address); - activeAccount.insert(QStringLiteral("display"), shortId(address)); - activeAccount.insert(QStringLiteral("isPublic"), m_accountModel->data(idx, AccountModel::IsPublicRole).toBool()); - activeAccount.insert(QStringLiteral("balance"), m_accountModel->data(idx, AccountModel::BalanceRole).toString()); - context.insert(QStringLiteral("holdings"), devnetHoldings(address)); - context.insert(QStringLiteral("status"), QStringLiteral("ready")); - context.insert(QStringLiteral("statusDetail"), QStringLiteral("Devnet token holdings resolved for the active wallet account.")); - } else { - activeAccount.insert(QStringLiteral("name"), QString()); - activeAccount.insert(QStringLiteral("address"), QString()); - activeAccount.insert(QStringLiteral("display"), QStringLiteral("Not connected")); - activeAccount.insert(QStringLiteral("isPublic"), true); - activeAccount.insert(QStringLiteral("balance"), QString()); - context.insert(QStringLiteral("holdings"), QVariantList()); - context.insert(QStringLiteral("status"), isWalletOpen() ? QStringLiteral("no_account") : QStringLiteral("no_wallet")); - context.insert(QStringLiteral("statusDetail"), - isWalletOpen() - ? QStringLiteral("Create an account before opening a liquidity position.") - : QStringLiteral("Connect a wallet before opening a liquidity position.")); + const QJsonObject account = document.object(); + if (!isLowerHex(account.value(QStringLiteral("program_owner")).toString(), 64) + || !isLowerHex(account.value(QStringLiteral("balance")).toString(), 32) + || !isLowerHex(account.value(QStringLiteral("nonce")).toString(), 32)) { + return read; } - context.insert(QStringLiteral("activeAccount"), activeAccount); - context.insert(QStringLiteral("activeAccountDisplay"), activeAccount.value(QStringLiteral("display"))); - return context; + const QString data = account.value(QStringLiteral("data")).toString(); + if (data.size() % 2 != 0) { + return read; + } + for (const QChar character : data) { + if (!character.isDigit() + && (character < QLatin1Char('a') || character > QLatin1Char('f'))) { + return read; + } + } + + read.insert(QStringLiteral("status"), QStringLiteral("ok")); + read.insert(QStringLiteral("account"), account); + return read; } -QVariant AmmUiBackend::refreshNewPositionContext() +QJsonArray AmmUiBackend::walletAccountReads() const { - const QVariantMap context = buildNewPositionContext(); - setNewPositionContext(context); - return context; -} - -QVariantMap AmmUiBackend::quoteNewPositionMap(const QVariantMap& request) const -{ - const QString tokenA = request.value(QStringLiteral("tokenA")).toString(); - const QString tokenB = request.value(QStringLiteral("tokenB")).toString(); - const QVariantMap context = poolContext(tokenA, tokenB); - + QJsonArray reads; if (!isWalletOpen()) - return quoteError(context, request, tr("Connect a wallet to preview this position.")); + return reads; - const QString owner = activeAccountAddress(); - if (owner.isEmpty()) - return quoteError(context, request, tr("Create an account before previewing this position.")); - - const QVariantList holdings = devnetHoldings(owner); - const QVariantMap holdingA = holdingBySymbol(holdings, tokenA); - const QVariantMap holdingB = holdingBySymbol(holdings, tokenB); - if (holdingA.isEmpty() || holdingB.isEmpty()) - return quoteError(context, request, tr("Choose two token holdings from the active account.")); - - const int feeBps = request.value(QStringLiteral("feeBps")).toInt(); - if (!isSupportedFeeTier(feeBps)) - return quoteError(context, request, tr("Fee tier is not supported by the AMM program.")); - - const QString poolStatus = context.value(QStringLiteral("poolStatus")).toString(); - const int storedFeeBps = context.value(QStringLiteral("storedFeeBps")).toInt(); - if (poolStatus == QStringLiteral("active_pool") && feeBps != storedFeeBps) - return quoteError(context, request, - tr("Existing pool uses %1.").arg(feeLabel(storedFeeBps))); - - const int slippageBps = - std::max(1, std::min(5000, request.value(QStringLiteral("slippageBps")).toInt())); - - if (poolStatus == QStringLiteral("active_pool")) { - const double inputA = parsePositiveAmount(request.value(QStringLiteral("amountA")).toString()); - const double inputB = parsePositiveAmount(request.value(QStringLiteral("amountB")).toString()); - const bool editA = request.value(QStringLiteral("editedSide")).toString() != QStringLiteral("B"); - const double ratio = activeRatio(tokenA, tokenB); - const double amountA = editA ? inputA : inputB / ratio; - const double amountB = editA ? inputA * ratio : inputB; - const double reserveA = activePoolReserve(tokenA); - const double reserveB = activePoolReserve(tokenB); - const double expectedLp = std::floor(std::min( - ACTIVE_POOL_LP_SUPPLY * amountA / reserveA, - ACTIVE_POOL_LP_SUPPLY * amountB / reserveB)); - const double minimumLp = std::floor(expectedLp * (10000 - slippageBps) / 10000.0); - - if (inputA <= 0.0 && inputB <= 0.0) - return quoteError(context, request, tr("Enter a deposit amount to preview LP output.")); - if (amountA > holdingA.value(QStringLiteral("balance")).toDouble()) - return quoteError(context, request, tr("Insufficient %1 balance.").arg(tokenA)); - if (amountB > holdingB.value(QStringLiteral("balance")).toDouble()) - return quoteError(context, request, tr("Insufficient %1 balance.").arg(tokenB)); - if (minimumLp <= 0.0) - return quoteError(context, request, tr("LP minimum rounds to zero. Increase deposit amount.")); - - QVariantMap position; - position.insert(QStringLiteral("userLp"), QStringLiteral("148320 LP")); - position.insert(QStringLiteral("share"), QStringLiteral("1.18%")); - position.insert(QStringLiteral("ownedA"), formatTokenAmount(tokenA == QStringLiteral("USDC") ? 14750.0 : 118000.0, tokenA)); - position.insert(QStringLiteral("ownedB"), formatTokenAmount(tokenB == QStringLiteral("LOGOS") ? 118000.0 : 14750.0, tokenB)); - return quoteOk(context, request, amountA, amountB, amountA, amountB, expectedLp, minimumLp, 0.0, position); + const QVariantList accounts = m_logos->logos_execution_zone.list_accounts(); + for (const QVariant& value : accounts) { + const QVariantMap entry = value.toMap(); + if (!entry.value(QStringLiteral("is_public"), true).toBool()) + continue; + const QString id = entry.value(QStringLiteral("account_id")).toString(); + if (isLowerHex(id, 64)) + reads.append(readAccount(id)); } - - if (poolStatus == QStringLiteral("missing_pool")) { - const double price = - parsePositiveAmount(request.value(QStringLiteral("initialPrice")).toString()) > 0.0 - ? parsePositiveAmount(request.value(QStringLiteral("initialPrice")).toString()) - : defaultInitialPrice(tokenA, tokenB); - const double scaleMultiplier = - std::max(1, request.value(QStringLiteral("depositScale")).toInt()); - const double baseA = price >= 1.0 ? price : 1.0; - const double baseB = price >= 1.0 ? 1.0 : 1.0 / price; - const double minimumScale = - std::floor(MINIMUM_LIQUIDITY / std::sqrt(baseA * baseB)) + 1.0; - const double scale = minimumScale * scaleMultiplier; - const double amountA = baseA * scale; - const double amountB = baseB * scale; - const double initialLp = std::floor(std::sqrt(amountA * amountB)); - const double userLp = std::max(0.0, initialLp - MINIMUM_LIQUIDITY); - const double minimumLp = std::floor(userLp * (10000 - slippageBps) / 10000.0); - - if (amountA > holdingA.value(QStringLiteral("balance")).toDouble()) - return quoteError(context, request, tr("Initial deposit exceeds %1 balance.").arg(tokenA)); - if (amountB > holdingB.value(QStringLiteral("balance")).toDouble()) - return quoteError(context, request, tr("Initial deposit exceeds %1 balance.").arg(tokenB)); - if (userLp <= 0.0) - return quoteError(context, request, tr("Deposit must mint more than the locked minimum liquidity.")); - - QVariantMap position; - position.insert(QStringLiteral("userLp"), QStringLiteral("0 LP")); - position.insert(QStringLiteral("share"), QStringLiteral("New pool")); - position.insert(QStringLiteral("ownedA"), formatTokenAmount(0, tokenA)); - position.insert(QStringLiteral("ownedB"), formatTokenAmount(0, tokenB)); - - QVariantMap quote = - quoteOk(context, request, amountA, amountB, amountA, amountB, userLp, minimumLp, MINIMUM_LIQUIDITY, position); - QVariantMap pool = quote.value(QStringLiteral("pool")).toMap(); - pool.insert(QStringLiteral("priceText"), - QStringLiteral("1 %1 = %2 %3") - .arg(tokenB, formatAmount(price), tokenA)); - quote.insert(QStringLiteral("pool"), pool); - return quote; - } - - return quoteError(context, request, context.value(QStringLiteral("detail")).toString()); + return reads; } -QVariant AmmUiBackend::quoteNewPosition(QVariant request) +QJsonObject AmmUiBackend::buildQuoteInput(const QVariantMap& request, + QJsonObject* error) const { - return quoteNewPositionMap(request.toMap()); -} - -QVariant AmmUiBackend::submitNewPosition(QVariant request, QString quoteHash) -{ - const QVariantMap quote = quoteNewPositionMap(request.toMap()); - if (quote.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) { - QVariantMap result; - result.insert(QStringLiteral("status"), QStringLiteral("error")); - result.insert(QStringLiteral("code"), QStringLiteral("quote_error")); - result.insert(QStringLiteral("error"), quote.value(QStringLiteral("error"))); - result.insert(QStringLiteral("quote"), quote); - return result; + if (m_networkStatus != QStringLiteral("ready")) { + *error = publicError(m_networkStatus); + return {}; + } + bool ok = false; + const QJsonObject configManifest = callClient( + amm_config_id, + QJsonObject { { QStringLiteral("ammProgramId"), m_ammProgramId } }, + &ok); + if (!ok) { + *error = publicError(QStringLiteral("backend_error")); + return {}; + } + const QJsonObject config = readAccount(configManifest.value(QStringLiteral("configId")).toString()); + const QJsonObject requestObject = QJsonObject::fromVariantMap(request); + const QJsonObject pairManifest = callClient( + amm_pair_ids, + QJsonObject { + { QStringLiteral("ammProgramId"), m_ammProgramId }, + { QStringLiteral("config"), config }, + { QStringLiteral("tokenAId"), requestObject.value(QStringLiteral("tokenAId")) }, + { QStringLiteral("tokenBId"), requestObject.value(QStringLiteral("tokenBId")) }, + }, + &ok); + if (!ok) { + *error = publicError(QStringLiteral("backend_error")); + return {}; + } + if (pairManifest.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) { + *error = publicError(pairManifest.value(QStringLiteral("code")).toString()); + return {}; } + const QJsonObject snapshot { + { QStringLiteral("config"), config }, + { QStringLiteral("tokenA"), readAccount(pairManifest.value(QStringLiteral("tokenAId")).toString()) }, + { QStringLiteral("tokenB"), readAccount(pairManifest.value(QStringLiteral("tokenBId")).toString()) }, + { QStringLiteral("pool"), readAccount(pairManifest.value(QStringLiteral("poolId")).toString()) }, + { QStringLiteral("vaultA"), readAccount(pairManifest.value(QStringLiteral("vaultAId")).toString()) }, + { QStringLiteral("vaultB"), readAccount(pairManifest.value(QStringLiteral("vaultBId")).toString()) }, + { QStringLiteral("lpDefinition"), readAccount(pairManifest.value(QStringLiteral("lpDefinitionId")).toString()) }, + { QStringLiteral("lpLockHolding"), readAccount(pairManifest.value(QStringLiteral("lpLockHoldingId")).toString()) }, + { QStringLiteral("currentTick"), readAccount(pairManifest.value(QStringLiteral("currentTickId")).toString()) }, + { QStringLiteral("clock"), readAccount(pairManifest.value(QStringLiteral("clockId")).toString()) }, + { QStringLiteral("walletAvailable"), isWalletOpen() }, + { QStringLiteral("walletAccounts"), walletAccountReads() }, + }; + return { + { QStringLiteral("networkId"), m_networkId }, + { QStringLiteral("networkFingerprint"), m_networkFingerprint }, + { QStringLiteral("ammProgramId"), m_ammProgramId }, + { QStringLiteral("request"), requestObject }, + { QStringLiteral("snapshot"), snapshot }, + }; +} + +QVariantMap AmmUiBackend::refreshNewPositionContext(QVariantMap request) +{ + if (m_networkStatus != QStringLiteral("ready")) { + if (m_networkStatus == QStringLiteral("network_unknown")) + probeNetworkIdentity(); + const QJsonObject context = + contextState(m_networkStatus, m_networkId, m_networkFingerprint); + setNewPositionContext(context.toVariantMap()); + return context.toVariantMap(); + } + const QJsonObject hints = QJsonObject::fromVariantMap(request); + bool ok = false; + const QJsonObject configManifest = callClient( + amm_config_id, + QJsonObject { { QStringLiteral("ammProgramId"), m_ammProgramId } }, + &ok); + if (!ok) { + const QJsonObject context = contextState( + QStringLiteral("error"), m_networkId, m_networkFingerprint); + setNewPositionContext(context.toVariantMap()); + return context.toVariantMap(); + } + const QJsonObject config = readAccount(configManifest.value(QStringLiteral("configId")).toString()); + const QJsonArray walletAccounts = walletAccountReads(); + QJsonArray configured; + for (const QString& id : m_configuredTokenIds) + configured.append(id); + const QJsonArray recent = variantStringArray(hints.value(QStringLiteral("recentTokenIds")).toVariant()); + const QJsonArray resolved = variantStringArray(hints.value(QStringLiteral("resolvedTokenIds")).toVariant()); + + const QJsonObject tokenManifest = callClient( + amm_token_ids, + QJsonObject { + { QStringLiteral("ammProgramId"), m_ammProgramId }, + { QStringLiteral("config"), config }, + { QStringLiteral("walletAccounts"), walletAccounts }, + { QStringLiteral("configuredTokenIds"), configured }, + { QStringLiteral("recentTokenIds"), recent }, + { QStringLiteral("resolvedTokenIds"), resolved }, + }, + &ok); + if (!ok || tokenManifest.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) { + const QString code = ok + ? tokenManifest.value(QStringLiteral("code")).toString() + : QStringLiteral("backend_error"); + const QJsonObject context = + contextState(code.isEmpty() ? QStringLiteral("error") : code, + m_networkId, + m_networkFingerprint); + setNewPositionContext(context.toVariantMap()); + return context.toVariantMap(); + } + + QJsonArray definitions; + for (const QJsonValue& id : tokenManifest.value(QStringLiteral("tokenIds")).toArray()) + definitions.append(readAccount(id.toString())); + + const QJsonObject context = callClient( + amm_context, + QJsonObject { + { QStringLiteral("networkId"), m_networkId }, + { QStringLiteral("networkFingerprint"), m_networkFingerprint }, + { QStringLiteral("ammProgramId"), m_ammProgramId }, + { QStringLiteral("walletAvailable"), isWalletOpen() }, + { QStringLiteral("config"), config }, + { QStringLiteral("walletAccounts"), walletAccounts }, + { QStringLiteral("tokenDefinitions"), definitions }, + { QStringLiteral("configuredTokenIds"), configured }, + { QStringLiteral("recentTokenIds"), recent }, + { QStringLiteral("resolvedTokenIds"), resolved }, + }, + &ok); + const QJsonObject result = ok + ? context + : contextState(QStringLiteral("error"), m_networkId, m_networkFingerprint); + setNewPositionContext(result.toVariantMap()); + return result.toVariantMap(); +} + +QVariantMap AmmUiBackend::quoteNewPosition(QVariantMap request) +{ + QJsonObject error; + const QJsonObject input = buildQuoteInput(request, &error); + if (!error.isEmpty()) + return error.toVariantMap(); + + bool ok = false; + const QJsonObject quote = callClient(amm_quote, input, &ok); + return (ok ? quote : publicError(QStringLiteral("backend_error"))).toVariantMap(); +} + +QVariantMap AmmUiBackend::submitNewPosition(QVariantMap request, QString quoteHash) +{ + if (m_submitInFlight) + return publicError(QStringLiteral("submit_in_progress")).toVariantMap(); + if (!isWalletOpen()) + return publicError(QStringLiteral("wallet_unavailable")).toVariantMap(); + QScopedValueRollback submitGuard(m_submitInFlight, true); + + QJsonObject error; + const QJsonObject input = buildQuoteInput(request, &error); + if (!error.isEmpty()) + return error.toVariantMap(); + + bool ok = false; + const QJsonObject quote = callClient(amm_quote, input, &ok); + if (!ok) + return publicError(QStringLiteral("backend_error")).toVariantMap(); if (quote.value(QStringLiteral("quoteHash")).toString() != quoteHash) { - QVariantMap result; - result.insert(QStringLiteral("status"), QStringLiteral("error")); - result.insert(QStringLiteral("code"), QStringLiteral("quote_changed")); - result.insert(QStringLiteral("error"), tr("Quote changed. Refresh preview before submitting.")); + QJsonObject result = publicError(QStringLiteral("quote_changed")); result.insert(QStringLiteral("quote"), quote); - return result; + return result.toVariantMap(); + } + if (!quote.value(QStringLiteral("canSubmit")).toBool(false)) { + QJsonObject result = publicError(QStringLiteral("quote_not_submittable")); + result.insert(QStringLiteral("quote"), quote); + return result.toVariantMap(); } - const QVariantMap transaction = quote.value(QStringLiteral("transaction")).toMap(); - if (!transaction.value(QStringLiteral("ready")).toBool()) { - QVariantMap result; - result.insert(QStringLiteral("status"), QStringLiteral("error")); - result.insert(QStringLiteral("code"), QStringLiteral("submit_unavailable")); - result.insert(QStringLiteral("error"), transaction.value(QStringLiteral("reason")).toString()); - result.insert(QStringLiteral("quote"), quote); - return result; + QJsonValue freshLp; + if (quote.value(QStringLiteral("requiresFreshLp")).toBool(false)) { + const QString accountId = m_logos->logos_execution_zone.create_account_public(); + if (!isLowerHex(accountId, 64) + || m_logos->logos_execution_zone.save() != WALLET_FFI_SUCCESS) { + return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); + } + const QJsonObject read = readAccount(accountId); + if (read.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) + return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); + freshLp = read; } - QVariantMap result; - result.insert(QStringLiteral("status"), QStringLiteral("ok")); - result.insert(QStringLiteral("message"), - quote.value(QStringLiteral("instruction")).toString() == QStringLiteral("new_definition") - ? tr("Pool creation submitted") - : tr("Liquidity deposit submitted")); - result.insert(QStringLiteral("detail"), - QStringLiteral("%1 / %2") - .arg(request.toMap().value(QStringLiteral("tokenA")).toString(), - request.toMap().value(QStringLiteral("tokenB")).toString())); - return result; + QJsonObject planInput = input; + planInput.insert(QStringLiteral("quoteHash"), quoteHash); + planInput.insert(QStringLiteral("nowMs"), QDateTime::currentMSecsSinceEpoch()); + if (!freshLp.isUndefined()) + planInput.insert(QStringLiteral("freshLp"), freshLp); + + const QJsonObject plan = callClient(amm_plan, planInput, &ok); + if (!ok) + return publicError(QStringLiteral("backend_error")).toVariantMap(); + if (plan.value(QStringLiteral("status")).toString() != QStringLiteral("ready")) { + const QString code = plan.value(QStringLiteral("code")).toString(); + return publicError(code.isEmpty() ? QStringLiteral("wallet_submission_failed") : code) + .toVariantMap(); + } + + const QStringList accountIds = + jsonStringList(plan.value(QStringLiteral("accountIds")).toArray()); + const QVariantList signingRequirements = + jsonBoolList(plan.value(QStringLiteral("signingRequirements")).toArray()); + const QVariantList instruction = + jsonUIntList(plan.value(QStringLiteral("instruction")).toArray()); + const QString programId = plan.value(QStringLiteral("programId")).toString(); + bool deadlineValid = false; + const qulonglong deadline = + plan.value(QStringLiteral("deadlineMs")).toString().toULongLong(&deadlineValid); + if (!deadlineValid + || static_cast(QDateTime::currentMSecsSinceEpoch()) >= deadline) { + return publicError(QStringLiteral("transaction_deadline_expired")).toVariantMap(); + } + const QString response = m_logos->logos_execution_zone.send_generic_public_transaction( + accountIds, + signingRequirements, + QVariant::fromValue(instruction), + programId); + + QJsonParseError parseError; + const QJsonDocument responseDocument = QJsonDocument::fromJson(response.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !responseDocument.isObject()) + return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); + const QJsonObject walletResult = responseDocument.object(); + const QJsonValue success = walletResult.value(QStringLiteral("success")); + const QJsonValue providerError = walletResult.value(QStringLiteral("error")); + const QString transactionId = walletResult.value(QStringLiteral("tx_hash")).toString(); + const bool providerErrorClear = providerError.isUndefined() + || providerError.isNull() + || (providerError.isString() && providerError.toString().isEmpty()); + if (!success.isBool() + || !success.toBool() + || !providerErrorClear + || !isHex(transactionId, 64)) { + return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); + } + + return QJsonObject { + { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, + { QStringLiteral("status"), QStringLiteral("submitted") }, + { QStringLiteral("transactionId"), transactionId.toLower() }, + }.toVariantMap(); } void AmmUiBackend::refreshBlockHeights() @@ -935,32 +805,137 @@ void AmmUiBackend::refreshBlockHeights() void AmmUiBackend::refreshSequencerAddr() { - const QString addr = m_logos->logos_execution_zone.get_sequencer_addr(); - if (sequencerAddr() != addr) - setSequencerAddr(addr); - // Probe right away so the banner reflects the (possibly new) endpoint - // without waiting for the next periodic tick. + const QString address = m_logos->logos_execution_zone.get_sequencer_addr(); + if (sequencerAddr() != address) { + setSequencerAddr(address); + if (m_networkStatus != QStringLiteral("config_missing")) { + m_networkStatus = QStringLiteral("network_unknown"); + m_networkFingerprint.clear(); + } + } checkReachability(); } +bool AmmUiBackend::loadNetworkConfig() +{ + const QByteArray selected = qgetenv(NETWORK_ENV); + m_networkId = selected.isEmpty() + ? QStringLiteral("testnet") + : QString::fromLocal8Bit(selected).trimmed(); + + QJsonObject entry; + if (m_networkId == QStringLiteral("devnet")) { + const QString path = QString::fromLocal8Bit(qgetenv(DEVNET_FILE_ENV)); + QFile file(path); + if (path.isEmpty() || !file.open(QIODevice::ReadOnly)) { + m_networkStatus = QStringLiteral("config_missing"); + return false; + } + entry = QJsonDocument::fromJson(file.readAll()).object(); + m_expectedNetworkIdentity = entry.value(QStringLiteral("channelId")).toString(); + } else { + QFile file(QStringLiteral(":/amm/config/networks.json")); + if (!file.open(QIODevice::ReadOnly)) { + m_networkStatus = QStringLiteral("config_missing"); + return false; + } + const QJsonObject networks = QJsonDocument::fromJson(file.readAll()).object(); + entry = networks.value(m_networkId).toObject(); + m_expectedNetworkIdentity = entry.value(QStringLiteral("checkpointHash")).toString(); + } + + m_ammProgramId = entry.value(QStringLiteral("ammProgramId")).toString(); + if (!isLowerHex(m_expectedNetworkIdentity, 64) + || !isLowerHex(m_ammProgramId, 64)) { + m_networkStatus = QStringLiteral("config_missing"); + return false; + } + + m_configuredTokenIds.clear(); + for (const QJsonValue& value : entry.value(QStringLiteral("tokenDefinitionIds")).toArray()) { + const QString id = value.toString(); + if (!isLowerHex(id, 64)) { + m_networkStatus = QStringLiteral("config_missing"); + m_configuredTokenIds.clear(); + return false; + } + m_configuredTokenIds.append(id); + } + m_networkStatus = QStringLiteral("network_unknown"); + return true; +} + void AmmUiBackend::checkReachability() { - const QString addr = sequencerAddr(); - if (addr.isEmpty()) + const QString address = sequencerAddr(); + if (address.isEmpty()) return; - QNetworkRequest req{QUrl(addr)}; - req.setTransferTimeout(4000); - QNetworkReply* reply = m_net->get(req); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - // Any HTTP response (even a 404) means the node is up; only a transport - // failure (connection refused, host not found, timeout) counts as down. + QNetworkRequest request{QUrl(address)}; + request.setTransferTimeout(4000); + QNetworkReply* reply = m_net->get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply, address]() { + if (address != sequencerAddr()) { + reply->deleteLater(); + return; + } const bool gotHttpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid(); const bool reachable = gotHttpStatus || reply->error() == QNetworkReply::NoError; if (sequencerReachable() != reachable) setSequencerReachable(reachable); reply->deleteLater(); + + if (reachable && m_networkStatus == QStringLiteral("network_unknown")) + probeNetworkIdentity(); + }); +} + +void AmmUiBackend::probeNetworkIdentity() +{ + if (m_identityProbeInFlight + || m_networkStatus == QStringLiteral("config_missing") + || sequencerAddr().isEmpty()) { + return; + } + m_identityProbeInFlight = true; + const QString address = sequencerAddr(); + const bool devnet = m_networkId == QStringLiteral("devnet"); + const QString method = devnet + ? QStringLiteral("getChannelId") + : QStringLiteral("getBlock"); + const QJsonArray params = devnet + ? QJsonArray() + : QJsonArray { CHECKPOINT_BLOCK_ID }; + + QNetworkRequest request{QUrl(address)}; + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + request.setTransferTimeout(4000); + QNetworkReply* reply = m_net->post(request, jsonRpcBody(method, params)); + connect(reply, &QNetworkReply::finished, this, [this, reply, address, devnet]() { + m_identityProbeInFlight = false; + if (address != sequencerAddr()) { + reply->deleteLater(); + return; + } + + const QByteArray payload = reply->readAll(); + const QString actual = devnet + ? channelIdFromResponse(payload) + : blockHashFromResponse(payload); + if (actual.isEmpty()) { + m_networkStatus = QStringLiteral("network_unknown"); + m_networkFingerprint.clear(); + } else if (actual != m_expectedNetworkIdentity) { + m_networkStatus = QStringLiteral("network_mismatch"); + m_networkFingerprint.clear(); + } else { + m_networkStatus = QStringLiteral("ready"); + m_networkFingerprint = + (devnet ? QStringLiteral("channel:") : QStringLiteral("block10:")) + actual; + } + reply->deleteLater(); + refreshNewPositionContext(QVariantMap()); }); } diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 6764676..cab6d35 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -2,7 +2,10 @@ #define AMM_UI_BACKEND_H #include +#include +#include #include +#include #include #include "rep_AmmUiBackend_source.h" @@ -35,9 +38,9 @@ public slots: void refreshAccounts() override; void refreshBalances() override; QString getBalance(QString accountIdHex, bool isPublic) override; - QVariant refreshNewPositionContext() override; - QVariant quoteNewPosition(QVariant request) override; - QVariant submitNewPosition(QVariant request, QString quoteHash) override; + QVariantMap refreshNewPositionContext(QVariantMap request) override; + QVariantMap quoteNewPosition(QVariantMap request) override; + QVariantMap submitNewPosition(QVariantMap request, QString quoteHash) override; // Return the new wallet's BIP39 mnemonic (empty string on failure) so the // UI can force a one-time seed-phrase backup step. QString createNewDefault(QString password) override; @@ -65,9 +68,11 @@ private: void refreshBlockHeights(); void refreshSequencerAddr(); void saveWallet(); - QString activeAccountAddress() const; - QVariantMap buildNewPositionContext() const; - QVariantMap quoteNewPositionMap(const QVariantMap& request) const; + bool loadNetworkConfig(); + void probeNetworkIdentity(); + QJsonObject readAccount(const QString& accountId) const; + QJsonArray walletAccountReads() const; + QJsonObject buildQuoteInput(const QVariantMap& request, QJsonObject* error) const; // Probe the configured sequencer over HTTP and update sequencerReachable. void checkReachability(); @@ -79,6 +84,15 @@ private: QNetworkAccessManager* m_net; QTimer* m_reachabilityTimer; + + QString m_networkId; + QString m_networkStatus = QStringLiteral("config_missing"); + QString m_networkFingerprint; + QString m_expectedNetworkIdentity; + QString m_ammProgramId; + QStringList m_configuredTokenIds; + bool m_identityProbeInFlight = false; + bool m_submitInFlight = false; }; #endif // AMM_UI_BACKEND_H diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index 27efe2b..02b79a1 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -26,10 +26,10 @@ class AmmUiBackend // New Position backend surface. QML calls these through logos.watch(...). // The QVariant payloads are stable maps/lists so the UI never assembles AMM // transactions or duplicates quote state. - PROP(QVariant newPositionContext READONLY) - SLOT(QVariant refreshNewPositionContext()) - SLOT(QVariant quoteNewPosition(QVariant request)) - SLOT(QVariant submitNewPosition(QVariant request, QString quoteHash)) + PROP(QVariantMap newPositionContext READONLY) + SLOT(QVariantMap refreshNewPositionContext(QVariantMap request)) + SLOT(QVariantMap quoteNewPosition(QVariantMap request)) + SLOT(QVariantMap submitNewPosition(QVariantMap request, QString quoteHash)) // Wallet lifecycle. createNewDefault() is the happy path: it creates a // fresh per-app wallet at walletHome with no path picking. createNew() diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..abde028 --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1783203018, + "narHash": "sha256-G6R9IT/xwFuu+CYBWDUAok6AdC4ERC4ZfPPFtEpxnZE=", + "owner": "ipetkov", + "repo": "crane", + "rev": "80db5bdc391be8a1794f6d8a2d56e3a84ebcede2", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1783776592, + "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..41ee985 --- /dev/null +++ b/flake.nix @@ -0,0 +1,45 @@ +{ + description = "LEZ program client libraries"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + crane.url = "github:ipetkov/crane"; + }; + + outputs = { nixpkgs, crane, ... }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + in { + packages = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + craneLib = crane.mkLib pkgs; + src = craneLib.cleanCargoSource ./.; + commonArgs = { + inherit src; + pname = "amm_client"; + version = "0.1.0"; + strictDeps = true; + cargoExtraArgs = "-p amm_client"; + }; + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + ammClient = craneLib.buildPackage (commonArgs // { + inherit cargoArtifacts; + doCheck = false; + postInstall = '' + install -Dm644 ${./apps/amm/client/include/amm_client.h} \ + $out/include/amm_client.h + ''; + }); + in { + default = ammClient; + amm_client = ammClient; + }); + }; +}