From 8dd1189584dde358ff98cd37ee987df604333951 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 | 944 ++++--- apps/amm/flake.nix | 8 + 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, 6417 insertions(+), 2591 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 7c3a140..6806a84 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 c508ac9..35a1dee 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 8f79e67..7e483ac 100644 --- a/apps/amm/flake.lock +++ b/apps/amm/flake.lock @@ -1,6 +1,36 @@ { "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, "narHash": "sha256-D+BsdpxmtUwtqGoY0IXPhHgTlmqgcZKCEo1oMyn7ep0=", @@ -76,7 +106,7 @@ }, "logos-blockchain-circuits": { "inputs": { - "nixpkgs": "nixpkgs_124" + "nixpkgs": "nixpkgs_125" }, "locked": { "lastModified": 1781004244, @@ -2225,7 +2255,7 @@ }, "logos-execution-zone": { "inputs": { - "crane": "crane", + "crane": "crane_2", "logos-blockchain-circuits": "logos-blockchain-circuits", "logos-liblogos": "logos-liblogos_4", "nixpkgs": [ @@ -2238,17 +2268,17 @@ "rust-rapidsnark": "rust-rapidsnark" }, "locked": { - "lastModified": 1784202021, - "narHash": "sha256-BG94Ob5tbJyw7GclNBdnOWqcBLRld8sc+xeOU8LI5RQ=", + "lastModified": 1782428741, + "narHash": "sha256-ltLcysXUdVUXAe25Tl8x7e7ZsTzj1sHlyS3glp97TAo=", "owner": "logos-blockchain", "repo": "logos-execution-zone", - "rev": "a7e06a660940a00093b1760560d37ff84aff5a05", + "rev": "e37876a64028a335eb693198a1ed6a0e875ec5b4", "type": "github" }, "original": { "owner": "logos-blockchain", "repo": "logos-execution-zone", - "rev": "a7e06a660940a00093b1760560d37ff84aff5a05", + "rev": "e37876a64028a335eb693198a1ed6a0e875ec5b4", "type": "github" } }, @@ -4763,7 +4793,7 @@ }, "logos-nix": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs_2" }, "locked": { "lastModified": 1774455309, @@ -4781,7 +4811,7 @@ }, "logos-nix_10": { "inputs": { - "nixpkgs": "nixpkgs_10" + "nixpkgs": "nixpkgs_11" }, "locked": { "lastModified": 1774455309, @@ -4799,7 +4829,7 @@ }, "logos-nix_100": { "inputs": { - "nixpkgs": "nixpkgs_100" + "nixpkgs": "nixpkgs_101" }, "locked": { "lastModified": 1773955630, @@ -4817,7 +4847,7 @@ }, "logos-nix_101": { "inputs": { - "nixpkgs": "nixpkgs_101" + "nixpkgs": "nixpkgs_102" }, "locked": { "lastModified": 1773955630, @@ -4835,7 +4865,7 @@ }, "logos-nix_102": { "inputs": { - "nixpkgs": "nixpkgs_102" + "nixpkgs": "nixpkgs_103" }, "locked": { "lastModified": 1773955630, @@ -4853,7 +4883,7 @@ }, "logos-nix_103": { "inputs": { - "nixpkgs": "nixpkgs_103" + "nixpkgs": "nixpkgs_104" }, "locked": { "lastModified": 1773955630, @@ -4871,7 +4901,7 @@ }, "logos-nix_104": { "inputs": { - "nixpkgs": "nixpkgs_104" + "nixpkgs": "nixpkgs_105" }, "locked": { "lastModified": 1773955630, @@ -4889,7 +4919,7 @@ }, "logos-nix_105": { "inputs": { - "nixpkgs": "nixpkgs_105" + "nixpkgs": "nixpkgs_106" }, "locked": { "lastModified": 1774455309, @@ -4907,7 +4937,7 @@ }, "logos-nix_106": { "inputs": { - "nixpkgs": "nixpkgs_106" + "nixpkgs": "nixpkgs_107" }, "locked": { "lastModified": 1773955630, @@ -4925,7 +4955,7 @@ }, "logos-nix_107": { "inputs": { - "nixpkgs": "nixpkgs_107" + "nixpkgs": "nixpkgs_108" }, "locked": { "lastModified": 1774455309, @@ -4943,7 +4973,7 @@ }, "logos-nix_108": { "inputs": { - "nixpkgs": "nixpkgs_108" + "nixpkgs": "nixpkgs_109" }, "locked": { "lastModified": 1774455309, @@ -4961,7 +4991,7 @@ }, "logos-nix_109": { "inputs": { - "nixpkgs": "nixpkgs_109" + "nixpkgs": "nixpkgs_110" }, "locked": { "lastModified": 1773955630, @@ -4979,7 +5009,7 @@ }, "logos-nix_11": { "inputs": { - "nixpkgs": "nixpkgs_11" + "nixpkgs": "nixpkgs_12" }, "locked": { "lastModified": 1773955630, @@ -4997,7 +5027,7 @@ }, "logos-nix_110": { "inputs": { - "nixpkgs": "nixpkgs_110" + "nixpkgs": "nixpkgs_111" }, "locked": { "lastModified": 1773955630, @@ -5015,7 +5045,7 @@ }, "logos-nix_111": { "inputs": { - "nixpkgs": "nixpkgs_111" + "nixpkgs": "nixpkgs_112" }, "locked": { "lastModified": 1774455309, @@ -5033,7 +5063,7 @@ }, "logos-nix_112": { "inputs": { - "nixpkgs": "nixpkgs_112" + "nixpkgs": "nixpkgs_113" }, "locked": { "lastModified": 1774455309, @@ -5051,7 +5081,7 @@ }, "logos-nix_113": { "inputs": { - "nixpkgs": "nixpkgs_113" + "nixpkgs": "nixpkgs_114" }, "locked": { "lastModified": 1773955630, @@ -5069,7 +5099,7 @@ }, "logos-nix_114": { "inputs": { - "nixpkgs": "nixpkgs_114" + "nixpkgs": "nixpkgs_115" }, "locked": { "lastModified": 1773955630, @@ -5087,7 +5117,7 @@ }, "logos-nix_115": { "inputs": { - "nixpkgs": "nixpkgs_115" + "nixpkgs": "nixpkgs_116" }, "locked": { "lastModified": 1774455309, @@ -5105,7 +5135,7 @@ }, "logos-nix_116": { "inputs": { - "nixpkgs": "nixpkgs_116" + "nixpkgs": "nixpkgs_117" }, "locked": { "lastModified": 1774455309, @@ -5123,7 +5153,7 @@ }, "logos-nix_117": { "inputs": { - "nixpkgs": "nixpkgs_117" + "nixpkgs": "nixpkgs_118" }, "locked": { "lastModified": 1773955630, @@ -5141,7 +5171,7 @@ }, "logos-nix_118": { "inputs": { - "nixpkgs": "nixpkgs_118" + "nixpkgs": "nixpkgs_119" }, "locked": { "lastModified": 1773955630, @@ -5159,7 +5189,7 @@ }, "logos-nix_119": { "inputs": { - "nixpkgs": "nixpkgs_119" + "nixpkgs": "nixpkgs_120" }, "locked": { "lastModified": 1773955630, @@ -5177,7 +5207,7 @@ }, "logos-nix_12": { "inputs": { - "nixpkgs": "nixpkgs_12" + "nixpkgs": "nixpkgs_13" }, "locked": { "lastModified": 1774455309, @@ -5195,7 +5225,7 @@ }, "logos-nix_120": { "inputs": { - "nixpkgs": "nixpkgs_120" + "nixpkgs": "nixpkgs_121" }, "locked": { "lastModified": 1773955630, @@ -5213,7 +5243,7 @@ }, "logos-nix_121": { "inputs": { - "nixpkgs": "nixpkgs_121" + "nixpkgs": "nixpkgs_122" }, "locked": { "lastModified": 1774455309, @@ -5231,7 +5261,7 @@ }, "logos-nix_122": { "inputs": { - "nixpkgs": "nixpkgs_122" + "nixpkgs": "nixpkgs_123" }, "locked": { "lastModified": 1773955630, @@ -5249,7 +5279,7 @@ }, "logos-nix_123": { "inputs": { - "nixpkgs": "nixpkgs_123" + "nixpkgs": "nixpkgs_124" }, "locked": { "lastModified": 1773955630, @@ -5267,7 +5297,7 @@ }, "logos-nix_124": { "inputs": { - "nixpkgs": "nixpkgs_125" + "nixpkgs": "nixpkgs_126" }, "locked": { "lastModified": 1774455309, @@ -5285,7 +5315,7 @@ }, "logos-nix_125": { "inputs": { - "nixpkgs": "nixpkgs_126" + "nixpkgs": "nixpkgs_127" }, "locked": { "lastModified": 1774455309, @@ -5303,7 +5333,7 @@ }, "logos-nix_126": { "inputs": { - "nixpkgs": "nixpkgs_127" + "nixpkgs": "nixpkgs_128" }, "locked": { "lastModified": 1774455309, @@ -5321,7 +5351,7 @@ }, "logos-nix_127": { "inputs": { - "nixpkgs": "nixpkgs_128" + "nixpkgs": "nixpkgs_129" }, "locked": { "lastModified": 1774455309, @@ -5339,7 +5369,7 @@ }, "logos-nix_128": { "inputs": { - "nixpkgs": "nixpkgs_129" + "nixpkgs": "nixpkgs_130" }, "locked": { "lastModified": 1773955630, @@ -5357,7 +5387,7 @@ }, "logos-nix_129": { "inputs": { - "nixpkgs": "nixpkgs_130" + "nixpkgs": "nixpkgs_131" }, "locked": { "lastModified": 1774455309, @@ -5375,7 +5405,7 @@ }, "logos-nix_13": { "inputs": { - "nixpkgs": "nixpkgs_13" + "nixpkgs": "nixpkgs_14" }, "locked": { "lastModified": 1773955630, @@ -5393,7 +5423,7 @@ }, "logos-nix_130": { "inputs": { - "nixpkgs": "nixpkgs_131" + "nixpkgs": "nixpkgs_132" }, "locked": { "lastModified": 1774455309, @@ -5411,7 +5441,7 @@ }, "logos-nix_131": { "inputs": { - "nixpkgs": "nixpkgs_132" + "nixpkgs": "nixpkgs_133" }, "locked": { "lastModified": 1774455309, @@ -5429,7 +5459,7 @@ }, "logos-nix_132": { "inputs": { - "nixpkgs": "nixpkgs_133" + "nixpkgs": "nixpkgs_134" }, "locked": { "lastModified": 1774455309, @@ -5447,7 +5477,7 @@ }, "logos-nix_133": { "inputs": { - "nixpkgs": "nixpkgs_134" + "nixpkgs": "nixpkgs_135" }, "locked": { "lastModified": 1774455309, @@ -5465,7 +5495,7 @@ }, "logos-nix_134": { "inputs": { - "nixpkgs": "nixpkgs_135" + "nixpkgs": "nixpkgs_136" }, "locked": { "lastModified": 1774455309, @@ -5483,7 +5513,7 @@ }, "logos-nix_135": { "inputs": { - "nixpkgs": "nixpkgs_136" + "nixpkgs": "nixpkgs_137" }, "locked": { "lastModified": 1773955630, @@ -5501,7 +5531,7 @@ }, "logos-nix_136": { "inputs": { - "nixpkgs": "nixpkgs_137" + "nixpkgs": "nixpkgs_138" }, "locked": { "lastModified": 1774455309, @@ -5519,7 +5549,7 @@ }, "logos-nix_137": { "inputs": { - "nixpkgs": "nixpkgs_138" + "nixpkgs": "nixpkgs_139" }, "locked": { "lastModified": 1773955630, @@ -5537,7 +5567,7 @@ }, "logos-nix_138": { "inputs": { - "nixpkgs": "nixpkgs_139" + "nixpkgs": "nixpkgs_140" }, "locked": { "lastModified": 1774455309, @@ -5555,7 +5585,7 @@ }, "logos-nix_139": { "inputs": { - "nixpkgs": "nixpkgs_140" + "nixpkgs": "nixpkgs_141" }, "locked": { "lastModified": 1773955630, @@ -5573,7 +5603,7 @@ }, "logos-nix_14": { "inputs": { - "nixpkgs": "nixpkgs_14" + "nixpkgs": "nixpkgs_15" }, "locked": { "lastModified": 1774455309, @@ -5591,7 +5621,7 @@ }, "logos-nix_140": { "inputs": { - "nixpkgs": "nixpkgs_141" + "nixpkgs": "nixpkgs_142" }, "locked": { "lastModified": 1774455309, @@ -5609,7 +5639,7 @@ }, "logos-nix_141": { "inputs": { - "nixpkgs": "nixpkgs_142" + "nixpkgs": "nixpkgs_143" }, "locked": { "lastModified": 1774455309, @@ -5627,7 +5657,7 @@ }, "logos-nix_142": { "inputs": { - "nixpkgs": "nixpkgs_143" + "nixpkgs": "nixpkgs_144" }, "locked": { "lastModified": 1773955630, @@ -5645,7 +5675,7 @@ }, "logos-nix_143": { "inputs": { - "nixpkgs": "nixpkgs_144" + "nixpkgs": "nixpkgs_145" }, "locked": { "lastModified": 1774455309, @@ -5663,7 +5693,7 @@ }, "logos-nix_144": { "inputs": { - "nixpkgs": "nixpkgs_145" + "nixpkgs": "nixpkgs_146" }, "locked": { "lastModified": 1773955630, @@ -5681,7 +5711,7 @@ }, "logos-nix_145": { "inputs": { - "nixpkgs": "nixpkgs_146" + "nixpkgs": "nixpkgs_147" }, "locked": { "lastModified": 1774455309, @@ -5699,7 +5729,7 @@ }, "logos-nix_146": { "inputs": { - "nixpkgs": "nixpkgs_147" + "nixpkgs": "nixpkgs_148" }, "locked": { "lastModified": 1773955630, @@ -5717,7 +5747,7 @@ }, "logos-nix_147": { "inputs": { - "nixpkgs": "nixpkgs_148" + "nixpkgs": "nixpkgs_149" }, "locked": { "lastModified": 1774455309, @@ -5735,7 +5765,7 @@ }, "logos-nix_148": { "inputs": { - "nixpkgs": "nixpkgs_149" + "nixpkgs": "nixpkgs_150" }, "locked": { "lastModified": 1773955630, @@ -5753,7 +5783,7 @@ }, "logos-nix_149": { "inputs": { - "nixpkgs": "nixpkgs_150" + "nixpkgs": "nixpkgs_151" }, "locked": { "lastModified": 1773955630, @@ -5771,7 +5801,7 @@ }, "logos-nix_15": { "inputs": { - "nixpkgs": "nixpkgs_15" + "nixpkgs": "nixpkgs_16" }, "locked": { "lastModified": 1773955630, @@ -5789,7 +5819,7 @@ }, "logos-nix_150": { "inputs": { - "nixpkgs": "nixpkgs_151" + "nixpkgs": "nixpkgs_152" }, "locked": { "lastModified": 1774455309, @@ -5807,7 +5837,7 @@ }, "logos-nix_151": { "inputs": { - "nixpkgs": "nixpkgs_152" + "nixpkgs": "nixpkgs_153" }, "locked": { "lastModified": 1773955630, @@ -5825,7 +5855,7 @@ }, "logos-nix_152": { "inputs": { - "nixpkgs": "nixpkgs_153" + "nixpkgs": "nixpkgs_154" }, "locked": { "lastModified": 1773955630, @@ -5843,7 +5873,7 @@ }, "logos-nix_153": { "inputs": { - "nixpkgs": "nixpkgs_154" + "nixpkgs": "nixpkgs_155" }, "locked": { "lastModified": 1773955630, @@ -5861,7 +5891,7 @@ }, "logos-nix_154": { "inputs": { - "nixpkgs": "nixpkgs_155" + "nixpkgs": "nixpkgs_156" }, "locked": { "lastModified": 1773955630, @@ -5879,7 +5909,7 @@ }, "logos-nix_155": { "inputs": { - "nixpkgs": "nixpkgs_156" + "nixpkgs": "nixpkgs_157" }, "locked": { "lastModified": 1774455309, @@ -5897,7 +5927,7 @@ }, "logos-nix_156": { "inputs": { - "nixpkgs": "nixpkgs_157" + "nixpkgs": "nixpkgs_158" }, "locked": { "lastModified": 1773955630, @@ -5915,7 +5945,7 @@ }, "logos-nix_157": { "inputs": { - "nixpkgs": "nixpkgs_158" + "nixpkgs": "nixpkgs_159" }, "locked": { "lastModified": 1774455309, @@ -5933,7 +5963,7 @@ }, "logos-nix_158": { "inputs": { - "nixpkgs": "nixpkgs_159" + "nixpkgs": "nixpkgs_160" }, "locked": { "lastModified": 1774455309, @@ -5951,7 +5981,7 @@ }, "logos-nix_159": { "inputs": { - "nixpkgs": "nixpkgs_160" + "nixpkgs": "nixpkgs_161" }, "locked": { "lastModified": 1773955630, @@ -5969,7 +5999,7 @@ }, "logos-nix_16": { "inputs": { - "nixpkgs": "nixpkgs_16" + "nixpkgs": "nixpkgs_17" }, "locked": { "lastModified": 1774455309, @@ -5987,7 +6017,7 @@ }, "logos-nix_160": { "inputs": { - "nixpkgs": "nixpkgs_161" + "nixpkgs": "nixpkgs_162" }, "locked": { "lastModified": 1773955630, @@ -6005,7 +6035,7 @@ }, "logos-nix_161": { "inputs": { - "nixpkgs": "nixpkgs_162" + "nixpkgs": "nixpkgs_163" }, "locked": { "lastModified": 1773955630, @@ -6023,7 +6053,7 @@ }, "logos-nix_162": { "inputs": { - "nixpkgs": "nixpkgs_163" + "nixpkgs": "nixpkgs_164" }, "locked": { "lastModified": 1773955630, @@ -6041,7 +6071,7 @@ }, "logos-nix_163": { "inputs": { - "nixpkgs": "nixpkgs_164" + "nixpkgs": "nixpkgs_165" }, "locked": { "lastModified": 1773955630, @@ -6059,7 +6089,7 @@ }, "logos-nix_164": { "inputs": { - "nixpkgs": "nixpkgs_165" + "nixpkgs": "nixpkgs_166" }, "locked": { "lastModified": 1774455309, @@ -6077,7 +6107,7 @@ }, "logos-nix_165": { "inputs": { - "nixpkgs": "nixpkgs_166" + "nixpkgs": "nixpkgs_167" }, "locked": { "lastModified": 1773955630, @@ -6095,7 +6125,7 @@ }, "logos-nix_166": { "inputs": { - "nixpkgs": "nixpkgs_167" + "nixpkgs": "nixpkgs_168" }, "locked": { "lastModified": 1774455309, @@ -6113,7 +6143,7 @@ }, "logos-nix_167": { "inputs": { - "nixpkgs": "nixpkgs_168" + "nixpkgs": "nixpkgs_169" }, "locked": { "lastModified": 1774455309, @@ -6131,7 +6161,7 @@ }, "logos-nix_168": { "inputs": { - "nixpkgs": "nixpkgs_169" + "nixpkgs": "nixpkgs_170" }, "locked": { "lastModified": 1773955630, @@ -6149,7 +6179,7 @@ }, "logos-nix_169": { "inputs": { - "nixpkgs": "nixpkgs_170" + "nixpkgs": "nixpkgs_171" }, "locked": { "lastModified": 1773955630, @@ -6167,7 +6197,7 @@ }, "logos-nix_17": { "inputs": { - "nixpkgs": "nixpkgs_17" + "nixpkgs": "nixpkgs_18" }, "locked": { "lastModified": 1773955630, @@ -6185,7 +6215,7 @@ }, "logos-nix_170": { "inputs": { - "nixpkgs": "nixpkgs_171" + "nixpkgs": "nixpkgs_172" }, "locked": { "lastModified": 1774455309, @@ -6203,7 +6233,7 @@ }, "logos-nix_171": { "inputs": { - "nixpkgs": "nixpkgs_172" + "nixpkgs": "nixpkgs_173" }, "locked": { "lastModified": 1774455309, @@ -6221,7 +6251,7 @@ }, "logos-nix_172": { "inputs": { - "nixpkgs": "nixpkgs_173" + "nixpkgs": "nixpkgs_174" }, "locked": { "lastModified": 1773955630, @@ -6239,7 +6269,7 @@ }, "logos-nix_173": { "inputs": { - "nixpkgs": "nixpkgs_174" + "nixpkgs": "nixpkgs_175" }, "locked": { "lastModified": 1773955630, @@ -6257,7 +6287,7 @@ }, "logos-nix_174": { "inputs": { - "nixpkgs": "nixpkgs_175" + "nixpkgs": "nixpkgs_176" }, "locked": { "lastModified": 1774455309, @@ -6275,7 +6305,7 @@ }, "logos-nix_175": { "inputs": { - "nixpkgs": "nixpkgs_176" + "nixpkgs": "nixpkgs_177" }, "locked": { "lastModified": 1774455309, @@ -6293,7 +6323,7 @@ }, "logos-nix_176": { "inputs": { - "nixpkgs": "nixpkgs_177" + "nixpkgs": "nixpkgs_178" }, "locked": { "lastModified": 1773955630, @@ -6311,7 +6341,7 @@ }, "logos-nix_177": { "inputs": { - "nixpkgs": "nixpkgs_178" + "nixpkgs": "nixpkgs_179" }, "locked": { "lastModified": 1773955630, @@ -6329,7 +6359,7 @@ }, "logos-nix_178": { "inputs": { - "nixpkgs": "nixpkgs_179" + "nixpkgs": "nixpkgs_180" }, "locked": { "lastModified": 1773955630, @@ -6347,7 +6377,7 @@ }, "logos-nix_179": { "inputs": { - "nixpkgs": "nixpkgs_180" + "nixpkgs": "nixpkgs_181" }, "locked": { "lastModified": 1773955630, @@ -6365,7 +6395,7 @@ }, "logos-nix_18": { "inputs": { - "nixpkgs": "nixpkgs_18" + "nixpkgs": "nixpkgs_19" }, "locked": { "lastModified": 1773955630, @@ -6383,7 +6413,7 @@ }, "logos-nix_180": { "inputs": { - "nixpkgs": "nixpkgs_181" + "nixpkgs": "nixpkgs_182" }, "locked": { "lastModified": 1774455309, @@ -6401,7 +6431,7 @@ }, "logos-nix_181": { "inputs": { - "nixpkgs": "nixpkgs_182" + "nixpkgs": "nixpkgs_183" }, "locked": { "lastModified": 1773955630, @@ -6419,7 +6449,7 @@ }, "logos-nix_182": { "inputs": { - "nixpkgs": "nixpkgs_183" + "nixpkgs": "nixpkgs_184" }, "locked": { "lastModified": 1773955630, @@ -6437,7 +6467,7 @@ }, "logos-nix_183": { "inputs": { - "nixpkgs": "nixpkgs_184" + "nixpkgs": "nixpkgs_185" }, "locked": { "lastModified": 1774455309, @@ -6455,7 +6485,7 @@ }, "logos-nix_184": { "inputs": { - "nixpkgs": "nixpkgs_185" + "nixpkgs": "nixpkgs_186" }, "locked": { "lastModified": 1773955630, @@ -6473,7 +6503,7 @@ }, "logos-nix_185": { "inputs": { - "nixpkgs": "nixpkgs_186" + "nixpkgs": "nixpkgs_187" }, "locked": { "lastModified": 1774455309, @@ -6491,7 +6521,7 @@ }, "logos-nix_186": { "inputs": { - "nixpkgs": "nixpkgs_187" + "nixpkgs": "nixpkgs_188" }, "locked": { "lastModified": 1773955630, @@ -6509,7 +6539,7 @@ }, "logos-nix_187": { "inputs": { - "nixpkgs": "nixpkgs_188" + "nixpkgs": "nixpkgs_189" }, "locked": { "lastModified": 1774455309, @@ -6527,7 +6557,7 @@ }, "logos-nix_188": { "inputs": { - "nixpkgs": "nixpkgs_189" + "nixpkgs": "nixpkgs_190" }, "locked": { "lastModified": 1773955630, @@ -6545,7 +6575,7 @@ }, "logos-nix_189": { "inputs": { - "nixpkgs": "nixpkgs_190" + "nixpkgs": "nixpkgs_191" }, "locked": { "lastModified": 1774455309, @@ -6563,7 +6593,7 @@ }, "logos-nix_19": { "inputs": { - "nixpkgs": "nixpkgs_19" + "nixpkgs": "nixpkgs_20" }, "locked": { "lastModified": 1774455309, @@ -6581,7 +6611,7 @@ }, "logos-nix_190": { "inputs": { - "nixpkgs": "nixpkgs_191" + "nixpkgs": "nixpkgs_192" }, "locked": { "lastModified": 1773955630, @@ -6599,7 +6629,7 @@ }, "logos-nix_191": { "inputs": { - "nixpkgs": "nixpkgs_192" + "nixpkgs": "nixpkgs_193" }, "locked": { "lastModified": 1774455309, @@ -6617,7 +6647,7 @@ }, "logos-nix_192": { "inputs": { - "nixpkgs": "nixpkgs_193" + "nixpkgs": "nixpkgs_194" }, "locked": { "lastModified": 1773955630, @@ -6635,7 +6665,7 @@ }, "logos-nix_193": { "inputs": { - "nixpkgs": "nixpkgs_194" + "nixpkgs": "nixpkgs_195" }, "locked": { "lastModified": 1773955630, @@ -6653,7 +6683,7 @@ }, "logos-nix_194": { "inputs": { - "nixpkgs": "nixpkgs_195" + "nixpkgs": "nixpkgs_196" }, "locked": { "lastModified": 1774455309, @@ -6671,7 +6701,7 @@ }, "logos-nix_195": { "inputs": { - "nixpkgs": "nixpkgs_196" + "nixpkgs": "nixpkgs_197" }, "locked": { "lastModified": 1773955630, @@ -6689,7 +6719,7 @@ }, "logos-nix_196": { "inputs": { - "nixpkgs": "nixpkgs_197" + "nixpkgs": "nixpkgs_198" }, "locked": { "lastModified": 1773955630, @@ -6707,7 +6737,7 @@ }, "logos-nix_197": { "inputs": { - "nixpkgs": "nixpkgs_198" + "nixpkgs": "nixpkgs_199" }, "locked": { "lastModified": 1773955630, @@ -6725,7 +6755,7 @@ }, "logos-nix_198": { "inputs": { - "nixpkgs": "nixpkgs_199" + "nixpkgs": "nixpkgs_200" }, "locked": { "lastModified": 1773955630, @@ -6743,7 +6773,7 @@ }, "logos-nix_199": { "inputs": { - "nixpkgs": "nixpkgs_200" + "nixpkgs": "nixpkgs_201" }, "locked": { "lastModified": 1774455309, @@ -6761,7 +6791,7 @@ }, "logos-nix_2": { "inputs": { - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs_3" }, "locked": { "lastModified": 1773955630, @@ -6779,7 +6809,7 @@ }, "logos-nix_20": { "inputs": { - "nixpkgs": "nixpkgs_20" + "nixpkgs": "nixpkgs_21" }, "locked": { "lastModified": 1773955630, @@ -6797,7 +6827,7 @@ }, "logos-nix_200": { "inputs": { - "nixpkgs": "nixpkgs_201" + "nixpkgs": "nixpkgs_202" }, "locked": { "lastModified": 1773955630, @@ -6815,7 +6845,7 @@ }, "logos-nix_201": { "inputs": { - "nixpkgs": "nixpkgs_202" + "nixpkgs": "nixpkgs_203" }, "locked": { "lastModified": 1774455309, @@ -6833,7 +6863,7 @@ }, "logos-nix_202": { "inputs": { - "nixpkgs": "nixpkgs_203" + "nixpkgs": "nixpkgs_204" }, "locked": { "lastModified": 1774455309, @@ -6851,7 +6881,7 @@ }, "logos-nix_203": { "inputs": { - "nixpkgs": "nixpkgs_204" + "nixpkgs": "nixpkgs_205" }, "locked": { "lastModified": 1773955630, @@ -6869,7 +6899,7 @@ }, "logos-nix_204": { "inputs": { - "nixpkgs": "nixpkgs_205" + "nixpkgs": "nixpkgs_206" }, "locked": { "lastModified": 1773955630, @@ -6887,7 +6917,7 @@ }, "logos-nix_205": { "inputs": { - "nixpkgs": "nixpkgs_206" + "nixpkgs": "nixpkgs_207" }, "locked": { "lastModified": 1773955630, @@ -6905,7 +6935,7 @@ }, "logos-nix_206": { "inputs": { - "nixpkgs": "nixpkgs_207" + "nixpkgs": "nixpkgs_208" }, "locked": { "lastModified": 1773955630, @@ -6923,7 +6953,7 @@ }, "logos-nix_207": { "inputs": { - "nixpkgs": "nixpkgs_208" + "nixpkgs": "nixpkgs_209" }, "locked": { "lastModified": 1773955630, @@ -6941,7 +6971,7 @@ }, "logos-nix_208": { "inputs": { - "nixpkgs": "nixpkgs_209" + "nixpkgs": "nixpkgs_210" }, "locked": { "lastModified": 1774455309, @@ -6959,7 +6989,7 @@ }, "logos-nix_209": { "inputs": { - "nixpkgs": "nixpkgs_210" + "nixpkgs": "nixpkgs_211" }, "locked": { "lastModified": 1773955630, @@ -6977,7 +7007,7 @@ }, "logos-nix_21": { "inputs": { - "nixpkgs": "nixpkgs_21" + "nixpkgs": "nixpkgs_22" }, "locked": { "lastModified": 1773955630, @@ -6995,7 +7025,7 @@ }, "logos-nix_210": { "inputs": { - "nixpkgs": "nixpkgs_211" + "nixpkgs": "nixpkgs_212" }, "locked": { "lastModified": 1774455309, @@ -7013,7 +7043,7 @@ }, "logos-nix_211": { "inputs": { - "nixpkgs": "nixpkgs_212" + "nixpkgs": "nixpkgs_213" }, "locked": { "lastModified": 1774455309, @@ -7031,7 +7061,7 @@ }, "logos-nix_212": { "inputs": { - "nixpkgs": "nixpkgs_213" + "nixpkgs": "nixpkgs_214" }, "locked": { "lastModified": 1773955630, @@ -7049,7 +7079,7 @@ }, "logos-nix_213": { "inputs": { - "nixpkgs": "nixpkgs_214" + "nixpkgs": "nixpkgs_215" }, "locked": { "lastModified": 1773955630, @@ -7067,7 +7097,7 @@ }, "logos-nix_214": { "inputs": { - "nixpkgs": "nixpkgs_215" + "nixpkgs": "nixpkgs_216" }, "locked": { "lastModified": 1774455309, @@ -7085,7 +7115,7 @@ }, "logos-nix_215": { "inputs": { - "nixpkgs": "nixpkgs_216" + "nixpkgs": "nixpkgs_217" }, "locked": { "lastModified": 1774455309, @@ -7103,7 +7133,7 @@ }, "logos-nix_216": { "inputs": { - "nixpkgs": "nixpkgs_217" + "nixpkgs": "nixpkgs_218" }, "locked": { "lastModified": 1773955630, @@ -7121,7 +7151,7 @@ }, "logos-nix_217": { "inputs": { - "nixpkgs": "nixpkgs_218" + "nixpkgs": "nixpkgs_219" }, "locked": { "lastModified": 1773955630, @@ -7139,7 +7169,7 @@ }, "logos-nix_218": { "inputs": { - "nixpkgs": "nixpkgs_219" + "nixpkgs": "nixpkgs_220" }, "locked": { "lastModified": 1774455309, @@ -7157,7 +7187,7 @@ }, "logos-nix_219": { "inputs": { - "nixpkgs": "nixpkgs_220" + "nixpkgs": "nixpkgs_221" }, "locked": { "lastModified": 1774455309, @@ -7175,7 +7205,7 @@ }, "logos-nix_22": { "inputs": { - "nixpkgs": "nixpkgs_22" + "nixpkgs": "nixpkgs_23" }, "locked": { "lastModified": 1773955630, @@ -7193,7 +7223,7 @@ }, "logos-nix_220": { "inputs": { - "nixpkgs": "nixpkgs_221" + "nixpkgs": "nixpkgs_222" }, "locked": { "lastModified": 1773955630, @@ -7211,7 +7241,7 @@ }, "logos-nix_221": { "inputs": { - "nixpkgs": "nixpkgs_222" + "nixpkgs": "nixpkgs_223" }, "locked": { "lastModified": 1773955630, @@ -7229,7 +7259,7 @@ }, "logos-nix_222": { "inputs": { - "nixpkgs": "nixpkgs_223" + "nixpkgs": "nixpkgs_224" }, "locked": { "lastModified": 1773955630, @@ -7247,7 +7277,7 @@ }, "logos-nix_223": { "inputs": { - "nixpkgs": "nixpkgs_224" + "nixpkgs": "nixpkgs_225" }, "locked": { "lastModified": 1773955630, @@ -7265,7 +7295,7 @@ }, "logos-nix_224": { "inputs": { - "nixpkgs": "nixpkgs_225" + "nixpkgs": "nixpkgs_226" }, "locked": { "lastModified": 1774455309, @@ -7283,7 +7313,7 @@ }, "logos-nix_225": { "inputs": { - "nixpkgs": "nixpkgs_226" + "nixpkgs": "nixpkgs_227" }, "locked": { "lastModified": 1773955630, @@ -7301,7 +7331,7 @@ }, "logos-nix_226": { "inputs": { - "nixpkgs": "nixpkgs_227" + "nixpkgs": "nixpkgs_228" }, "locked": { "lastModified": 1773955630, @@ -7319,7 +7349,7 @@ }, "logos-nix_227": { "inputs": { - "nixpkgs": "nixpkgs_228" + "nixpkgs": "nixpkgs_229" }, "locked": { "lastModified": 1774455309, @@ -7337,7 +7367,7 @@ }, "logos-nix_228": { "inputs": { - "nixpkgs": "nixpkgs_229" + "nixpkgs": "nixpkgs_230" }, "locked": { "lastModified": 1773955630, @@ -7355,7 +7385,7 @@ }, "logos-nix_229": { "inputs": { - "nixpkgs": "nixpkgs_230" + "nixpkgs": "nixpkgs_231" }, "locked": { "lastModified": 1774455309, @@ -7373,7 +7403,7 @@ }, "logos-nix_23": { "inputs": { - "nixpkgs": "nixpkgs_23" + "nixpkgs": "nixpkgs_24" }, "locked": { "lastModified": 1773955630, @@ -7391,7 +7421,7 @@ }, "logos-nix_230": { "inputs": { - "nixpkgs": "nixpkgs_231" + "nixpkgs": "nixpkgs_232" }, "locked": { "lastModified": 1774455309, @@ -7409,7 +7439,7 @@ }, "logos-nix_231": { "inputs": { - "nixpkgs": "nixpkgs_232" + "nixpkgs": "nixpkgs_233" }, "locked": { "lastModified": 1773955630, @@ -7427,7 +7457,7 @@ }, "logos-nix_232": { "inputs": { - "nixpkgs": "nixpkgs_233" + "nixpkgs": "nixpkgs_234" }, "locked": { "lastModified": 1773955630, @@ -7445,7 +7475,7 @@ }, "logos-nix_233": { "inputs": { - "nixpkgs": "nixpkgs_234" + "nixpkgs": "nixpkgs_235" }, "locked": { "lastModified": 1773955630, @@ -7463,7 +7493,7 @@ }, "logos-nix_234": { "inputs": { - "nixpkgs": "nixpkgs_235" + "nixpkgs": "nixpkgs_236" }, "locked": { "lastModified": 1773955630, @@ -7481,7 +7511,7 @@ }, "logos-nix_235": { "inputs": { - "nixpkgs": "nixpkgs_236" + "nixpkgs": "nixpkgs_237" }, "locked": { "lastModified": 1773955630, @@ -7499,7 +7529,7 @@ }, "logos-nix_236": { "inputs": { - "nixpkgs": "nixpkgs_237" + "nixpkgs": "nixpkgs_238" }, "locked": { "lastModified": 1774455309, @@ -7517,7 +7547,7 @@ }, "logos-nix_237": { "inputs": { - "nixpkgs": "nixpkgs_238" + "nixpkgs": "nixpkgs_239" }, "locked": { "lastModified": 1773955630, @@ -7535,7 +7565,7 @@ }, "logos-nix_238": { "inputs": { - "nixpkgs": "nixpkgs_239" + "nixpkgs": "nixpkgs_240" }, "locked": { "lastModified": 1774455309, @@ -7553,7 +7583,7 @@ }, "logos-nix_239": { "inputs": { - "nixpkgs": "nixpkgs_240" + "nixpkgs": "nixpkgs_241" }, "locked": { "lastModified": 1774455309, @@ -7571,7 +7601,7 @@ }, "logos-nix_24": { "inputs": { - "nixpkgs": "nixpkgs_24" + "nixpkgs": "nixpkgs_25" }, "locked": { "lastModified": 1774455309, @@ -7589,7 +7619,7 @@ }, "logos-nix_240": { "inputs": { - "nixpkgs": "nixpkgs_241" + "nixpkgs": "nixpkgs_242" }, "locked": { "lastModified": 1773955630, @@ -7607,7 +7637,7 @@ }, "logos-nix_241": { "inputs": { - "nixpkgs": "nixpkgs_242" + "nixpkgs": "nixpkgs_243" }, "locked": { "lastModified": 1773955630, @@ -7625,7 +7655,7 @@ }, "logos-nix_242": { "inputs": { - "nixpkgs": "nixpkgs_243" + "nixpkgs": "nixpkgs_244" }, "locked": { "lastModified": 1774455309, @@ -7643,7 +7673,7 @@ }, "logos-nix_243": { "inputs": { - "nixpkgs": "nixpkgs_244" + "nixpkgs": "nixpkgs_245" }, "locked": { "lastModified": 1774455309, @@ -7661,7 +7691,7 @@ }, "logos-nix_244": { "inputs": { - "nixpkgs": "nixpkgs_245" + "nixpkgs": "nixpkgs_246" }, "locked": { "lastModified": 1773955630, @@ -7679,7 +7709,7 @@ }, "logos-nix_245": { "inputs": { - "nixpkgs": "nixpkgs_246" + "nixpkgs": "nixpkgs_247" }, "locked": { "lastModified": 1773955630, @@ -7697,7 +7727,7 @@ }, "logos-nix_246": { "inputs": { - "nixpkgs": "nixpkgs_247" + "nixpkgs": "nixpkgs_248" }, "locked": { "lastModified": 1774455309, @@ -7715,7 +7745,7 @@ }, "logos-nix_247": { "inputs": { - "nixpkgs": "nixpkgs_248" + "nixpkgs": "nixpkgs_249" }, "locked": { "lastModified": 1774455309, @@ -7733,7 +7763,7 @@ }, "logos-nix_248": { "inputs": { - "nixpkgs": "nixpkgs_249" + "nixpkgs": "nixpkgs_250" }, "locked": { "lastModified": 1773955630, @@ -7751,7 +7781,7 @@ }, "logos-nix_249": { "inputs": { - "nixpkgs": "nixpkgs_250" + "nixpkgs": "nixpkgs_251" }, "locked": { "lastModified": 1773955630, @@ -7769,7 +7799,7 @@ }, "logos-nix_25": { "inputs": { - "nixpkgs": "nixpkgs_25" + "nixpkgs": "nixpkgs_26" }, "locked": { "lastModified": 1773955630, @@ -7787,7 +7817,7 @@ }, "logos-nix_250": { "inputs": { - "nixpkgs": "nixpkgs_251" + "nixpkgs": "nixpkgs_252" }, "locked": { "lastModified": 1773955630, @@ -7805,7 +7835,7 @@ }, "logos-nix_251": { "inputs": { - "nixpkgs": "nixpkgs_252" + "nixpkgs": "nixpkgs_253" }, "locked": { "lastModified": 1773955630, @@ -7823,7 +7853,7 @@ }, "logos-nix_252": { "inputs": { - "nixpkgs": "nixpkgs_253" + "nixpkgs": "nixpkgs_254" }, "locked": { "lastModified": 1774455309, @@ -7841,7 +7871,7 @@ }, "logos-nix_253": { "inputs": { - "nixpkgs": "nixpkgs_254" + "nixpkgs": "nixpkgs_255" }, "locked": { "lastModified": 1773955630, @@ -7859,7 +7889,7 @@ }, "logos-nix_254": { "inputs": { - "nixpkgs": "nixpkgs_255" + "nixpkgs": "nixpkgs_256" }, "locked": { "lastModified": 1773955630, @@ -7877,7 +7907,7 @@ }, "logos-nix_255": { "inputs": { - "nixpkgs": "nixpkgs_256" + "nixpkgs": "nixpkgs_257" }, "locked": { "lastModified": 1774455309, @@ -7895,7 +7925,7 @@ }, "logos-nix_256": { "inputs": { - "nixpkgs": "nixpkgs_257" + "nixpkgs": "nixpkgs_258" }, "locked": { "lastModified": 1774455309, @@ -7913,7 +7943,7 @@ }, "logos-nix_257": { "inputs": { - "nixpkgs": "nixpkgs_258" + "nixpkgs": "nixpkgs_259" }, "locked": { "lastModified": 1773955630, @@ -7931,7 +7961,7 @@ }, "logos-nix_258": { "inputs": { - "nixpkgs": "nixpkgs_259" + "nixpkgs": "nixpkgs_260" }, "locked": { "lastModified": 1774455309, @@ -7949,7 +7979,7 @@ }, "logos-nix_259": { "inputs": { - "nixpkgs": "nixpkgs_260" + "nixpkgs": "nixpkgs_261" }, "locked": { "lastModified": 1774455309, @@ -7967,7 +7997,7 @@ }, "logos-nix_26": { "inputs": { - "nixpkgs": "nixpkgs_26" + "nixpkgs": "nixpkgs_27" }, "locked": { "lastModified": 1774455309, @@ -7985,7 +8015,7 @@ }, "logos-nix_260": { "inputs": { - "nixpkgs": "nixpkgs_261" + "nixpkgs": "nixpkgs_262" }, "locked": { "lastModified": 1774455309, @@ -8003,7 +8033,7 @@ }, "logos-nix_261": { "inputs": { - "nixpkgs": "nixpkgs_262" + "nixpkgs": "nixpkgs_263" }, "locked": { "lastModified": 1774455309, @@ -8021,7 +8051,7 @@ }, "logos-nix_262": { "inputs": { - "nixpkgs": "nixpkgs_263" + "nixpkgs": "nixpkgs_264" }, "locked": { "lastModified": 1773955630, @@ -8039,7 +8069,7 @@ }, "logos-nix_263": { "inputs": { - "nixpkgs": "nixpkgs_264" + "nixpkgs": "nixpkgs_265" }, "locked": { "lastModified": 1773955630, @@ -8057,7 +8087,7 @@ }, "logos-nix_264": { "inputs": { - "nixpkgs": "nixpkgs_265" + "nixpkgs": "nixpkgs_266" }, "locked": { "lastModified": 1773955630, @@ -8075,7 +8105,7 @@ }, "logos-nix_265": { "inputs": { - "nixpkgs": "nixpkgs_266" + "nixpkgs": "nixpkgs_267" }, "locked": { "lastModified": 1773955630, @@ -8093,7 +8123,7 @@ }, "logos-nix_266": { "inputs": { - "nixpkgs": "nixpkgs_267" + "nixpkgs": "nixpkgs_268" }, "locked": { "lastModified": 1774455309, @@ -8111,7 +8141,7 @@ }, "logos-nix_267": { "inputs": { - "nixpkgs": "nixpkgs_268" + "nixpkgs": "nixpkgs_269" }, "locked": { "lastModified": 1774455309, @@ -8129,7 +8159,7 @@ }, "logos-nix_268": { "inputs": { - "nixpkgs": "nixpkgs_269" + "nixpkgs": "nixpkgs_270" }, "locked": { "lastModified": 1773955630, @@ -8147,7 +8177,7 @@ }, "logos-nix_269": { "inputs": { - "nixpkgs": "nixpkgs_271" + "nixpkgs": "nixpkgs_272" }, "locked": { "lastModified": 1774455309, @@ -8165,7 +8195,7 @@ }, "logos-nix_27": { "inputs": { - "nixpkgs": "nixpkgs_27" + "nixpkgs": "nixpkgs_28" }, "locked": { "lastModified": 1774455309, @@ -8183,7 +8213,7 @@ }, "logos-nix_270": { "inputs": { - "nixpkgs": "nixpkgs_272" + "nixpkgs": "nixpkgs_273" }, "locked": { "lastModified": 1773955630, @@ -8201,7 +8231,7 @@ }, "logos-nix_271": { "inputs": { - "nixpkgs": "nixpkgs_273" + "nixpkgs": "nixpkgs_274" }, "locked": { "lastModified": 1774455309, @@ -8219,7 +8249,7 @@ }, "logos-nix_272": { "inputs": { - "nixpkgs": "nixpkgs_274" + "nixpkgs": "nixpkgs_275" }, "locked": { "lastModified": 1773955630, @@ -8237,7 +8267,7 @@ }, "logos-nix_273": { "inputs": { - "nixpkgs": "nixpkgs_275" + "nixpkgs": "nixpkgs_276" }, "locked": { "lastModified": 1774455309, @@ -8255,7 +8285,7 @@ }, "logos-nix_274": { "inputs": { - "nixpkgs": "nixpkgs_276" + "nixpkgs": "nixpkgs_277" }, "locked": { "lastModified": 1773955630, @@ -8273,7 +8303,7 @@ }, "logos-nix_275": { "inputs": { - "nixpkgs": "nixpkgs_277" + "nixpkgs": "nixpkgs_278" }, "locked": { "lastModified": 1774455309, @@ -8291,7 +8321,7 @@ }, "logos-nix_276": { "inputs": { - "nixpkgs": "nixpkgs_278" + "nixpkgs": "nixpkgs_279" }, "locked": { "lastModified": 1774455309, @@ -8309,7 +8339,7 @@ }, "logos-nix_277": { "inputs": { - "nixpkgs": "nixpkgs_279" + "nixpkgs": "nixpkgs_280" }, "locked": { "lastModified": 1774455309, @@ -8327,7 +8357,7 @@ }, "logos-nix_278": { "inputs": { - "nixpkgs": "nixpkgs_280" + "nixpkgs": "nixpkgs_281" }, "locked": { "lastModified": 1774455309, @@ -8345,7 +8375,7 @@ }, "logos-nix_279": { "inputs": { - "nixpkgs": "nixpkgs_281" + "nixpkgs": "nixpkgs_282" }, "locked": { "lastModified": 1773955630, @@ -8363,7 +8393,7 @@ }, "logos-nix_28": { "inputs": { - "nixpkgs": "nixpkgs_28" + "nixpkgs": "nixpkgs_29" }, "locked": { "lastModified": 1773955630, @@ -8381,7 +8411,7 @@ }, "logos-nix_280": { "inputs": { - "nixpkgs": "nixpkgs_282" + "nixpkgs": "nixpkgs_283" }, "locked": { "lastModified": 1774455309, @@ -8399,7 +8429,7 @@ }, "logos-nix_281": { "inputs": { - "nixpkgs": "nixpkgs_283" + "nixpkgs": "nixpkgs_284" }, "locked": { "lastModified": 1773955630, @@ -8417,7 +8447,7 @@ }, "logos-nix_282": { "inputs": { - "nixpkgs": "nixpkgs_284" + "nixpkgs": "nixpkgs_285" }, "locked": { "lastModified": 1774455309, @@ -8435,7 +8465,7 @@ }, "logos-nix_283": { "inputs": { - "nixpkgs": "nixpkgs_285" + "nixpkgs": "nixpkgs_286" }, "locked": { "lastModified": 1773955630, @@ -8453,7 +8483,7 @@ }, "logos-nix_284": { "inputs": { - "nixpkgs": "nixpkgs_286" + "nixpkgs": "nixpkgs_287" }, "locked": { "lastModified": 1774455309, @@ -8471,7 +8501,7 @@ }, "logos-nix_285": { "inputs": { - "nixpkgs": "nixpkgs_287" + "nixpkgs": "nixpkgs_288" }, "locked": { "lastModified": 1773955630, @@ -8489,7 +8519,7 @@ }, "logos-nix_286": { "inputs": { - "nixpkgs": "nixpkgs_288" + "nixpkgs": "nixpkgs_289" }, "locked": { "lastModified": 1773955630, @@ -8507,7 +8537,7 @@ }, "logos-nix_287": { "inputs": { - "nixpkgs": "nixpkgs_289" + "nixpkgs": "nixpkgs_290" }, "locked": { "lastModified": 1774455309, @@ -8525,7 +8555,7 @@ }, "logos-nix_288": { "inputs": { - "nixpkgs": "nixpkgs_290" + "nixpkgs": "nixpkgs_291" }, "locked": { "lastModified": 1773955630, @@ -8543,7 +8573,7 @@ }, "logos-nix_289": { "inputs": { - "nixpkgs": "nixpkgs_291" + "nixpkgs": "nixpkgs_292" }, "locked": { "lastModified": 1773955630, @@ -8561,7 +8591,7 @@ }, "logos-nix_29": { "inputs": { - "nixpkgs": "nixpkgs_29" + "nixpkgs": "nixpkgs_30" }, "locked": { "lastModified": 1773955630, @@ -8579,7 +8609,7 @@ }, "logos-nix_290": { "inputs": { - "nixpkgs": "nixpkgs_292" + "nixpkgs": "nixpkgs_293" }, "locked": { "lastModified": 1773955630, @@ -8597,7 +8627,7 @@ }, "logos-nix_291": { "inputs": { - "nixpkgs": "nixpkgs_293" + "nixpkgs": "nixpkgs_294" }, "locked": { "lastModified": 1773955630, @@ -8615,7 +8645,7 @@ }, "logos-nix_292": { "inputs": { - "nixpkgs": "nixpkgs_294" + "nixpkgs": "nixpkgs_295" }, "locked": { "lastModified": 1774455309, @@ -8633,7 +8663,7 @@ }, "logos-nix_293": { "inputs": { - "nixpkgs": "nixpkgs_295" + "nixpkgs": "nixpkgs_296" }, "locked": { "lastModified": 1773955630, @@ -8651,7 +8681,7 @@ }, "logos-nix_294": { "inputs": { - "nixpkgs": "nixpkgs_296" + "nixpkgs": "nixpkgs_297" }, "locked": { "lastModified": 1774455309, @@ -8669,7 +8699,7 @@ }, "logos-nix_295": { "inputs": { - "nixpkgs": "nixpkgs_297" + "nixpkgs": "nixpkgs_298" }, "locked": { "lastModified": 1774455309, @@ -8687,7 +8717,7 @@ }, "logos-nix_296": { "inputs": { - "nixpkgs": "nixpkgs_298" + "nixpkgs": "nixpkgs_299" }, "locked": { "lastModified": 1773955630, @@ -8705,7 +8735,7 @@ }, "logos-nix_297": { "inputs": { - "nixpkgs": "nixpkgs_299" + "nixpkgs": "nixpkgs_300" }, "locked": { "lastModified": 1773955630, @@ -8723,7 +8753,7 @@ }, "logos-nix_298": { "inputs": { - "nixpkgs": "nixpkgs_300" + "nixpkgs": "nixpkgs_301" }, "locked": { "lastModified": 1773955630, @@ -8741,7 +8771,7 @@ }, "logos-nix_299": { "inputs": { - "nixpkgs": "nixpkgs_301" + "nixpkgs": "nixpkgs_302" }, "locked": { "lastModified": 1773955630, @@ -8759,7 +8789,7 @@ }, "logos-nix_3": { "inputs": { - "nixpkgs": "nixpkgs_3" + "nixpkgs": "nixpkgs_4" }, "locked": { "lastModified": 1774455309, @@ -8777,7 +8807,7 @@ }, "logos-nix_30": { "inputs": { - "nixpkgs": "nixpkgs_30" + "nixpkgs": "nixpkgs_31" }, "locked": { "lastModified": 1773955630, @@ -8795,7 +8825,7 @@ }, "logos-nix_300": { "inputs": { - "nixpkgs": "nixpkgs_302" + "nixpkgs": "nixpkgs_303" }, "locked": { "lastModified": 1773955630, @@ -8813,7 +8843,7 @@ }, "logos-nix_301": { "inputs": { - "nixpkgs": "nixpkgs_303" + "nixpkgs": "nixpkgs_304" }, "locked": { "lastModified": 1774455309, @@ -8831,7 +8861,7 @@ }, "logos-nix_302": { "inputs": { - "nixpkgs": "nixpkgs_304" + "nixpkgs": "nixpkgs_305" }, "locked": { "lastModified": 1773955630, @@ -8849,7 +8879,7 @@ }, "logos-nix_303": { "inputs": { - "nixpkgs": "nixpkgs_305" + "nixpkgs": "nixpkgs_306" }, "locked": { "lastModified": 1774455309, @@ -8867,7 +8897,7 @@ }, "logos-nix_304": { "inputs": { - "nixpkgs": "nixpkgs_306" + "nixpkgs": "nixpkgs_307" }, "locked": { "lastModified": 1774455309, @@ -8885,7 +8915,7 @@ }, "logos-nix_305": { "inputs": { - "nixpkgs": "nixpkgs_307" + "nixpkgs": "nixpkgs_308" }, "locked": { "lastModified": 1773955630, @@ -8903,7 +8933,7 @@ }, "logos-nix_306": { "inputs": { - "nixpkgs": "nixpkgs_308" + "nixpkgs": "nixpkgs_309" }, "locked": { "lastModified": 1773955630, @@ -8921,7 +8951,7 @@ }, "logos-nix_307": { "inputs": { - "nixpkgs": "nixpkgs_309" + "nixpkgs": "nixpkgs_310" }, "locked": { "lastModified": 1774455309, @@ -8939,7 +8969,7 @@ }, "logos-nix_308": { "inputs": { - "nixpkgs": "nixpkgs_310" + "nixpkgs": "nixpkgs_311" }, "locked": { "lastModified": 1774455309, @@ -8957,7 +8987,7 @@ }, "logos-nix_309": { "inputs": { - "nixpkgs": "nixpkgs_311" + "nixpkgs": "nixpkgs_312" }, "locked": { "lastModified": 1773955630, @@ -8975,7 +9005,7 @@ }, "logos-nix_31": { "inputs": { - "nixpkgs": "nixpkgs_31" + "nixpkgs": "nixpkgs_32" }, "locked": { "lastModified": 1773955630, @@ -8993,7 +9023,7 @@ }, "logos-nix_310": { "inputs": { - "nixpkgs": "nixpkgs_312" + "nixpkgs": "nixpkgs_313" }, "locked": { "lastModified": 1773955630, @@ -9011,7 +9041,7 @@ }, "logos-nix_311": { "inputs": { - "nixpkgs": "nixpkgs_313" + "nixpkgs": "nixpkgs_314" }, "locked": { "lastModified": 1774455309, @@ -9029,7 +9059,7 @@ }, "logos-nix_312": { "inputs": { - "nixpkgs": "nixpkgs_314" + "nixpkgs": "nixpkgs_315" }, "locked": { "lastModified": 1774455309, @@ -9047,7 +9077,7 @@ }, "logos-nix_313": { "inputs": { - "nixpkgs": "nixpkgs_315" + "nixpkgs": "nixpkgs_316" }, "locked": { "lastModified": 1773955630, @@ -9065,7 +9095,7 @@ }, "logos-nix_314": { "inputs": { - "nixpkgs": "nixpkgs_316" + "nixpkgs": "nixpkgs_317" }, "locked": { "lastModified": 1773955630, @@ -9083,7 +9113,7 @@ }, "logos-nix_315": { "inputs": { - "nixpkgs": "nixpkgs_317" + "nixpkgs": "nixpkgs_318" }, "locked": { "lastModified": 1773955630, @@ -9101,7 +9131,7 @@ }, "logos-nix_316": { "inputs": { - "nixpkgs": "nixpkgs_318" + "nixpkgs": "nixpkgs_319" }, "locked": { "lastModified": 1773955630, @@ -9119,7 +9149,7 @@ }, "logos-nix_317": { "inputs": { - "nixpkgs": "nixpkgs_319" + "nixpkgs": "nixpkgs_320" }, "locked": { "lastModified": 1774455309, @@ -9137,7 +9167,7 @@ }, "logos-nix_318": { "inputs": { - "nixpkgs": "nixpkgs_320" + "nixpkgs": "nixpkgs_321" }, "locked": { "lastModified": 1773955630, @@ -9155,7 +9185,7 @@ }, "logos-nix_319": { "inputs": { - "nixpkgs": "nixpkgs_321" + "nixpkgs": "nixpkgs_322" }, "locked": { "lastModified": 1773955630, @@ -9173,7 +9203,7 @@ }, "logos-nix_32": { "inputs": { - "nixpkgs": "nixpkgs_32" + "nixpkgs": "nixpkgs_33" }, "locked": { "lastModified": 1773955630, @@ -9191,7 +9221,7 @@ }, "logos-nix_320": { "inputs": { - "nixpkgs": "nixpkgs_322" + "nixpkgs": "nixpkgs_323" }, "locked": { "lastModified": 1774455309, @@ -9209,7 +9239,7 @@ }, "logos-nix_321": { "inputs": { - "nixpkgs": "nixpkgs_323" + "nixpkgs": "nixpkgs_324" }, "locked": { "lastModified": 1773955630, @@ -9227,7 +9257,7 @@ }, "logos-nix_322": { "inputs": { - "nixpkgs": "nixpkgs_324" + "nixpkgs": "nixpkgs_325" }, "locked": { "lastModified": 1774455309, @@ -9245,7 +9275,7 @@ }, "logos-nix_323": { "inputs": { - "nixpkgs": "nixpkgs_325" + "nixpkgs": "nixpkgs_326" }, "locked": { "lastModified": 1773955630, @@ -9263,7 +9293,7 @@ }, "logos-nix_324": { "inputs": { - "nixpkgs": "nixpkgs_326" + "nixpkgs": "nixpkgs_327" }, "locked": { "lastModified": 1774455309, @@ -9281,7 +9311,7 @@ }, "logos-nix_325": { "inputs": { - "nixpkgs": "nixpkgs_327" + "nixpkgs": "nixpkgs_328" }, "locked": { "lastModified": 1773955630, @@ -9299,7 +9329,7 @@ }, "logos-nix_326": { "inputs": { - "nixpkgs": "nixpkgs_328" + "nixpkgs": "nixpkgs_329" }, "locked": { "lastModified": 1774455309, @@ -9317,7 +9347,7 @@ }, "logos-nix_327": { "inputs": { - "nixpkgs": "nixpkgs_329" + "nixpkgs": "nixpkgs_330" }, "locked": { "lastModified": 1773955630, @@ -9335,7 +9365,7 @@ }, "logos-nix_328": { "inputs": { - "nixpkgs": "nixpkgs_330" + "nixpkgs": "nixpkgs_331" }, "locked": { "lastModified": 1774455309, @@ -9353,7 +9383,7 @@ }, "logos-nix_329": { "inputs": { - "nixpkgs": "nixpkgs_331" + "nixpkgs": "nixpkgs_332" }, "locked": { "lastModified": 1773955630, @@ -9371,7 +9401,7 @@ }, "logos-nix_33": { "inputs": { - "nixpkgs": "nixpkgs_33" + "nixpkgs": "nixpkgs_34" }, "locked": { "lastModified": 1774455309, @@ -9389,7 +9419,7 @@ }, "logos-nix_330": { "inputs": { - "nixpkgs": "nixpkgs_332" + "nixpkgs": "nixpkgs_333" }, "locked": { "lastModified": 1773955630, @@ -9407,7 +9437,7 @@ }, "logos-nix_331": { "inputs": { - "nixpkgs": "nixpkgs_333" + "nixpkgs": "nixpkgs_334" }, "locked": { "lastModified": 1774455309, @@ -9425,7 +9455,7 @@ }, "logos-nix_332": { "inputs": { - "nixpkgs": "nixpkgs_334" + "nixpkgs": "nixpkgs_335" }, "locked": { "lastModified": 1773955630, @@ -9443,7 +9473,7 @@ }, "logos-nix_333": { "inputs": { - "nixpkgs": "nixpkgs_335" + "nixpkgs": "nixpkgs_336" }, "locked": { "lastModified": 1773955630, @@ -9461,7 +9491,7 @@ }, "logos-nix_334": { "inputs": { - "nixpkgs": "nixpkgs_336" + "nixpkgs": "nixpkgs_337" }, "locked": { "lastModified": 1773955630, @@ -9479,7 +9509,7 @@ }, "logos-nix_335": { "inputs": { - "nixpkgs": "nixpkgs_337" + "nixpkgs": "nixpkgs_338" }, "locked": { "lastModified": 1773955630, @@ -9497,7 +9527,7 @@ }, "logos-nix_336": { "inputs": { - "nixpkgs": "nixpkgs_338" + "nixpkgs": "nixpkgs_339" }, "locked": { "lastModified": 1774455309, @@ -9515,7 +9545,7 @@ }, "logos-nix_337": { "inputs": { - "nixpkgs": "nixpkgs_339" + "nixpkgs": "nixpkgs_340" }, "locked": { "lastModified": 1773955630, @@ -9533,7 +9563,7 @@ }, "logos-nix_338": { "inputs": { - "nixpkgs": "nixpkgs_340" + "nixpkgs": "nixpkgs_341" }, "locked": { "lastModified": 1774455309, @@ -9551,7 +9581,7 @@ }, "logos-nix_339": { "inputs": { - "nixpkgs": "nixpkgs_341" + "nixpkgs": "nixpkgs_342" }, "locked": { "lastModified": 1774455309, @@ -9569,7 +9599,7 @@ }, "logos-nix_34": { "inputs": { - "nixpkgs": "nixpkgs_34" + "nixpkgs": "nixpkgs_35" }, "locked": { "lastModified": 1773955630, @@ -9587,7 +9617,7 @@ }, "logos-nix_340": { "inputs": { - "nixpkgs": "nixpkgs_342" + "nixpkgs": "nixpkgs_343" }, "locked": { "lastModified": 1773955630, @@ -9605,7 +9635,7 @@ }, "logos-nix_341": { "inputs": { - "nixpkgs": "nixpkgs_343" + "nixpkgs": "nixpkgs_344" }, "locked": { "lastModified": 1773955630, @@ -9623,7 +9653,7 @@ }, "logos-nix_342": { "inputs": { - "nixpkgs": "nixpkgs_344" + "nixpkgs": "nixpkgs_345" }, "locked": { "lastModified": 1773955630, @@ -9641,7 +9671,7 @@ }, "logos-nix_343": { "inputs": { - "nixpkgs": "nixpkgs_345" + "nixpkgs": "nixpkgs_346" }, "locked": { "lastModified": 1773955630, @@ -9659,7 +9689,7 @@ }, "logos-nix_344": { "inputs": { - "nixpkgs": "nixpkgs_346" + "nixpkgs": "nixpkgs_347" }, "locked": { "lastModified": 1773955630, @@ -9677,7 +9707,7 @@ }, "logos-nix_345": { "inputs": { - "nixpkgs": "nixpkgs_347" + "nixpkgs": "nixpkgs_348" }, "locked": { "lastModified": 1774455309, @@ -9695,7 +9725,7 @@ }, "logos-nix_346": { "inputs": { - "nixpkgs": "nixpkgs_348" + "nixpkgs": "nixpkgs_349" }, "locked": { "lastModified": 1773955630, @@ -9713,7 +9743,7 @@ }, "logos-nix_347": { "inputs": { - "nixpkgs": "nixpkgs_349" + "nixpkgs": "nixpkgs_350" }, "locked": { "lastModified": 1774455309, @@ -9731,7 +9761,7 @@ }, "logos-nix_348": { "inputs": { - "nixpkgs": "nixpkgs_350" + "nixpkgs": "nixpkgs_351" }, "locked": { "lastModified": 1774455309, @@ -9749,7 +9779,7 @@ }, "logos-nix_349": { "inputs": { - "nixpkgs": "nixpkgs_351" + "nixpkgs": "nixpkgs_352" }, "locked": { "lastModified": 1773955630, @@ -9767,7 +9797,7 @@ }, "logos-nix_35": { "inputs": { - "nixpkgs": "nixpkgs_35" + "nixpkgs": "nixpkgs_36" }, "locked": { "lastModified": 1774455309, @@ -9785,7 +9815,7 @@ }, "logos-nix_350": { "inputs": { - "nixpkgs": "nixpkgs_352" + "nixpkgs": "nixpkgs_353" }, "locked": { "lastModified": 1773955630, @@ -9803,7 +9833,7 @@ }, "logos-nix_351": { "inputs": { - "nixpkgs": "nixpkgs_353" + "nixpkgs": "nixpkgs_354" }, "locked": { "lastModified": 1774455309, @@ -9821,7 +9851,7 @@ }, "logos-nix_352": { "inputs": { - "nixpkgs": "nixpkgs_354" + "nixpkgs": "nixpkgs_355" }, "locked": { "lastModified": 1774455309, @@ -9839,7 +9869,7 @@ }, "logos-nix_353": { "inputs": { - "nixpkgs": "nixpkgs_355" + "nixpkgs": "nixpkgs_356" }, "locked": { "lastModified": 1773955630, @@ -9857,7 +9887,7 @@ }, "logos-nix_354": { "inputs": { - "nixpkgs": "nixpkgs_356" + "nixpkgs": "nixpkgs_357" }, "locked": { "lastModified": 1773955630, @@ -9875,7 +9905,7 @@ }, "logos-nix_355": { "inputs": { - "nixpkgs": "nixpkgs_357" + "nixpkgs": "nixpkgs_358" }, "locked": { "lastModified": 1774455309, @@ -9893,7 +9923,7 @@ }, "logos-nix_356": { "inputs": { - "nixpkgs": "nixpkgs_358" + "nixpkgs": "nixpkgs_359" }, "locked": { "lastModified": 1774455309, @@ -9911,7 +9941,7 @@ }, "logos-nix_357": { "inputs": { - "nixpkgs": "nixpkgs_359" + "nixpkgs": "nixpkgs_360" }, "locked": { "lastModified": 1773955630, @@ -9929,7 +9959,7 @@ }, "logos-nix_358": { "inputs": { - "nixpkgs": "nixpkgs_360" + "nixpkgs": "nixpkgs_361" }, "locked": { "lastModified": 1773955630, @@ -9947,7 +9977,7 @@ }, "logos-nix_359": { "inputs": { - "nixpkgs": "nixpkgs_361" + "nixpkgs": "nixpkgs_362" }, "locked": { "lastModified": 1773955630, @@ -9965,7 +9995,7 @@ }, "logos-nix_36": { "inputs": { - "nixpkgs": "nixpkgs_36" + "nixpkgs": "nixpkgs_37" }, "locked": { "lastModified": 1774455309, @@ -9983,7 +10013,7 @@ }, "logos-nix_360": { "inputs": { - "nixpkgs": "nixpkgs_362" + "nixpkgs": "nixpkgs_363" }, "locked": { "lastModified": 1773955630, @@ -10001,7 +10031,7 @@ }, "logos-nix_361": { "inputs": { - "nixpkgs": "nixpkgs_363" + "nixpkgs": "nixpkgs_364" }, "locked": { "lastModified": 1774455309, @@ -10019,7 +10049,7 @@ }, "logos-nix_362": { "inputs": { - "nixpkgs": "nixpkgs_364" + "nixpkgs": "nixpkgs_365" }, "locked": { "lastModified": 1773955630, @@ -10037,7 +10067,7 @@ }, "logos-nix_363": { "inputs": { - "nixpkgs": "nixpkgs_365" + "nixpkgs": "nixpkgs_366" }, "locked": { "lastModified": 1773955630, @@ -10055,7 +10085,7 @@ }, "logos-nix_364": { "inputs": { - "nixpkgs": "nixpkgs_366" + "nixpkgs": "nixpkgs_367" }, "locked": { "lastModified": 1774455309, @@ -10073,7 +10103,7 @@ }, "logos-nix_365": { "inputs": { - "nixpkgs": "nixpkgs_367" + "nixpkgs": "nixpkgs_368" }, "locked": { "lastModified": 1773955630, @@ -10091,7 +10121,7 @@ }, "logos-nix_366": { "inputs": { - "nixpkgs": "nixpkgs_368" + "nixpkgs": "nixpkgs_369" }, "locked": { "lastModified": 1774455309, @@ -10109,7 +10139,7 @@ }, "logos-nix_367": { "inputs": { - "nixpkgs": "nixpkgs_369" + "nixpkgs": "nixpkgs_370" }, "locked": { "lastModified": 1774455309, @@ -10127,7 +10157,7 @@ }, "logos-nix_368": { "inputs": { - "nixpkgs": "nixpkgs_370" + "nixpkgs": "nixpkgs_371" }, "locked": { "lastModified": 1773955630, @@ -10145,7 +10175,7 @@ }, "logos-nix_369": { "inputs": { - "nixpkgs": "nixpkgs_371" + "nixpkgs": "nixpkgs_372" }, "locked": { "lastModified": 1773955630, @@ -10163,7 +10193,7 @@ }, "logos-nix_37": { "inputs": { - "nixpkgs": "nixpkgs_37" + "nixpkgs": "nixpkgs_38" }, "locked": { "lastModified": 1773955630, @@ -10181,7 +10211,7 @@ }, "logos-nix_370": { "inputs": { - "nixpkgs": "nixpkgs_372" + "nixpkgs": "nixpkgs_373" }, "locked": { "lastModified": 1773955630, @@ -10199,7 +10229,7 @@ }, "logos-nix_371": { "inputs": { - "nixpkgs": "nixpkgs_373" + "nixpkgs": "nixpkgs_374" }, "locked": { "lastModified": 1773955630, @@ -10217,7 +10247,7 @@ }, "logos-nix_372": { "inputs": { - "nixpkgs": "nixpkgs_374" + "nixpkgs": "nixpkgs_375" }, "locked": { "lastModified": 1774455309, @@ -10235,7 +10265,7 @@ }, "logos-nix_373": { "inputs": { - "nixpkgs": "nixpkgs_375" + "nixpkgs": "nixpkgs_376" }, "locked": { "lastModified": 1774455309, @@ -10253,7 +10283,7 @@ }, "logos-nix_374": { "inputs": { - "nixpkgs": "nixpkgs_376" + "nixpkgs": "nixpkgs_377" }, "locked": { "lastModified": 1773955630, @@ -10271,7 +10301,7 @@ }, "logos-nix_375": { "inputs": { - "nixpkgs": "nixpkgs_377" + "nixpkgs": "nixpkgs_378" }, "locked": { "lastModified": 1774455309, @@ -10289,7 +10319,7 @@ }, "logos-nix_376": { "inputs": { - "nixpkgs": "nixpkgs_378" + "nixpkgs": "nixpkgs_379" }, "locked": { "lastModified": 1773955630, @@ -10307,7 +10337,7 @@ }, "logos-nix_377": { "inputs": { - "nixpkgs": "nixpkgs_379" + "nixpkgs": "nixpkgs_380" }, "locked": { "lastModified": 1774455309, @@ -10325,7 +10355,7 @@ }, "logos-nix_378": { "inputs": { - "nixpkgs": "nixpkgs_380" + "nixpkgs": "nixpkgs_381" }, "locked": { "lastModified": 1774455309, @@ -10343,7 +10373,7 @@ }, "logos-nix_379": { "inputs": { - "nixpkgs": "nixpkgs_381" + "nixpkgs": "nixpkgs_382" }, "locked": { "lastModified": 1773955630, @@ -10361,7 +10391,7 @@ }, "logos-nix_38": { "inputs": { - "nixpkgs": "nixpkgs_38" + "nixpkgs": "nixpkgs_39" }, "locked": { "lastModified": 1773955630, @@ -10379,7 +10409,7 @@ }, "logos-nix_380": { "inputs": { - "nixpkgs": "nixpkgs_382" + "nixpkgs": "nixpkgs_383" }, "locked": { "lastModified": 1773955630, @@ -10397,7 +10427,7 @@ }, "logos-nix_381": { "inputs": { - "nixpkgs": "nixpkgs_383" + "nixpkgs": "nixpkgs_384" }, "locked": { "lastModified": 1774455309, @@ -10415,7 +10445,7 @@ }, "logos-nix_382": { "inputs": { - "nixpkgs": "nixpkgs_384" + "nixpkgs": "nixpkgs_385" }, "locked": { "lastModified": 1774455309, @@ -10433,7 +10463,7 @@ }, "logos-nix_383": { "inputs": { - "nixpkgs": "nixpkgs_385" + "nixpkgs": "nixpkgs_386" }, "locked": { "lastModified": 1773955630, @@ -10451,7 +10481,7 @@ }, "logos-nix_384": { "inputs": { - "nixpkgs": "nixpkgs_386" + "nixpkgs": "nixpkgs_387" }, "locked": { "lastModified": 1773955630, @@ -10469,7 +10499,7 @@ }, "logos-nix_385": { "inputs": { - "nixpkgs": "nixpkgs_387" + "nixpkgs": "nixpkgs_388" }, "locked": { "lastModified": 1774455309, @@ -10487,7 +10517,7 @@ }, "logos-nix_386": { "inputs": { - "nixpkgs": "nixpkgs_388" + "nixpkgs": "nixpkgs_389" }, "locked": { "lastModified": 1774455309, @@ -10505,7 +10535,7 @@ }, "logos-nix_387": { "inputs": { - "nixpkgs": "nixpkgs_389" + "nixpkgs": "nixpkgs_390" }, "locked": { "lastModified": 1773955630, @@ -10523,7 +10553,7 @@ }, "logos-nix_388": { "inputs": { - "nixpkgs": "nixpkgs_390" + "nixpkgs": "nixpkgs_391" }, "locked": { "lastModified": 1773955630, @@ -10541,7 +10571,7 @@ }, "logos-nix_389": { "inputs": { - "nixpkgs": "nixpkgs_391" + "nixpkgs": "nixpkgs_392" }, "locked": { "lastModified": 1773955630, @@ -10559,7 +10589,7 @@ }, "logos-nix_39": { "inputs": { - "nixpkgs": "nixpkgs_39" + "nixpkgs": "nixpkgs_40" }, "locked": { "lastModified": 1774455309, @@ -10577,7 +10607,7 @@ }, "logos-nix_390": { "inputs": { - "nixpkgs": "nixpkgs_392" + "nixpkgs": "nixpkgs_393" }, "locked": { "lastModified": 1773955630, @@ -10595,7 +10625,7 @@ }, "logos-nix_391": { "inputs": { - "nixpkgs": "nixpkgs_393" + "nixpkgs": "nixpkgs_394" }, "locked": { "lastModified": 1774455309, @@ -10613,7 +10643,7 @@ }, "logos-nix_392": { "inputs": { - "nixpkgs": "nixpkgs_394" + "nixpkgs": "nixpkgs_395" }, "locked": { "lastModified": 1773955630, @@ -10631,7 +10661,7 @@ }, "logos-nix_393": { "inputs": { - "nixpkgs": "nixpkgs_395" + "nixpkgs": "nixpkgs_396" }, "locked": { "lastModified": 1773955630, @@ -10649,7 +10679,7 @@ }, "logos-nix_394": { "inputs": { - "nixpkgs": "nixpkgs_396" + "nixpkgs": "nixpkgs_397" }, "locked": { "lastModified": 1774455309, @@ -10667,7 +10697,7 @@ }, "logos-nix_395": { "inputs": { - "nixpkgs": "nixpkgs_397" + "nixpkgs": "nixpkgs_398" }, "locked": { "lastModified": 1773955630, @@ -10685,7 +10715,7 @@ }, "logos-nix_396": { "inputs": { - "nixpkgs": "nixpkgs_398" + "nixpkgs": "nixpkgs_399" }, "locked": { "lastModified": 1773955630, @@ -10703,7 +10733,7 @@ }, "logos-nix_4": { "inputs": { - "nixpkgs": "nixpkgs_4" + "nixpkgs": "nixpkgs_5" }, "locked": { "lastModified": 1773955630, @@ -10721,7 +10751,7 @@ }, "logos-nix_40": { "inputs": { - "nixpkgs": "nixpkgs_40" + "nixpkgs": "nixpkgs_41" }, "locked": { "lastModified": 1774455309, @@ -10739,7 +10769,7 @@ }, "logos-nix_41": { "inputs": { - "nixpkgs": "nixpkgs_41" + "nixpkgs": "nixpkgs_42" }, "locked": { "lastModified": 1773955630, @@ -10757,7 +10787,7 @@ }, "logos-nix_42": { "inputs": { - "nixpkgs": "nixpkgs_42" + "nixpkgs": "nixpkgs_43" }, "locked": { "lastModified": 1773955630, @@ -10775,7 +10805,7 @@ }, "logos-nix_43": { "inputs": { - "nixpkgs": "nixpkgs_43" + "nixpkgs": "nixpkgs_44" }, "locked": { "lastModified": 1774455309, @@ -10793,7 +10823,7 @@ }, "logos-nix_44": { "inputs": { - "nixpkgs": "nixpkgs_44" + "nixpkgs": "nixpkgs_45" }, "locked": { "lastModified": 1774455309, @@ -10811,7 +10841,7 @@ }, "logos-nix_45": { "inputs": { - "nixpkgs": "nixpkgs_45" + "nixpkgs": "nixpkgs_46" }, "locked": { "lastModified": 1773955630, @@ -10829,7 +10859,7 @@ }, "logos-nix_46": { "inputs": { - "nixpkgs": "nixpkgs_46" + "nixpkgs": "nixpkgs_47" }, "locked": { "lastModified": 1773955630, @@ -10847,7 +10877,7 @@ }, "logos-nix_47": { "inputs": { - "nixpkgs": "nixpkgs_47" + "nixpkgs": "nixpkgs_48" }, "locked": { "lastModified": 1773955630, @@ -10865,7 +10895,7 @@ }, "logos-nix_48": { "inputs": { - "nixpkgs": "nixpkgs_48" + "nixpkgs": "nixpkgs_49" }, "locked": { "lastModified": 1773955630, @@ -10883,7 +10913,7 @@ }, "logos-nix_49": { "inputs": { - "nixpkgs": "nixpkgs_49" + "nixpkgs": "nixpkgs_50" }, "locked": { "lastModified": 1774455309, @@ -10901,7 +10931,7 @@ }, "logos-nix_5": { "inputs": { - "nixpkgs": "nixpkgs_5" + "nixpkgs": "nixpkgs_6" }, "locked": { "lastModified": 1774455309, @@ -10919,7 +10949,7 @@ }, "logos-nix_50": { "inputs": { - "nixpkgs": "nixpkgs_50" + "nixpkgs": "nixpkgs_51" }, "locked": { "lastModified": 1773955630, @@ -10937,7 +10967,7 @@ }, "logos-nix_51": { "inputs": { - "nixpkgs": "nixpkgs_51" + "nixpkgs": "nixpkgs_52" }, "locked": { "lastModified": 1773955630, @@ -10955,7 +10985,7 @@ }, "logos-nix_52": { "inputs": { - "nixpkgs": "nixpkgs_52" + "nixpkgs": "nixpkgs_53" }, "locked": { "lastModified": 1774455309, @@ -10973,7 +11003,7 @@ }, "logos-nix_53": { "inputs": { - "nixpkgs": "nixpkgs_53" + "nixpkgs": "nixpkgs_54" }, "locked": { "lastModified": 1773955630, @@ -10991,7 +11021,7 @@ }, "logos-nix_54": { "inputs": { - "nixpkgs": "nixpkgs_54" + "nixpkgs": "nixpkgs_55" }, "locked": { "lastModified": 1774455309, @@ -11009,7 +11039,7 @@ }, "logos-nix_55": { "inputs": { - "nixpkgs": "nixpkgs_55" + "nixpkgs": "nixpkgs_56" }, "locked": { "lastModified": 1773955630, @@ -11027,7 +11057,7 @@ }, "logos-nix_56": { "inputs": { - "nixpkgs": "nixpkgs_56" + "nixpkgs": "nixpkgs_57" }, "locked": { "lastModified": 1774455309, @@ -11045,7 +11075,7 @@ }, "logos-nix_57": { "inputs": { - "nixpkgs": "nixpkgs_57" + "nixpkgs": "nixpkgs_58" }, "locked": { "lastModified": 1773955630, @@ -11063,7 +11093,7 @@ }, "logos-nix_58": { "inputs": { - "nixpkgs": "nixpkgs_58" + "nixpkgs": "nixpkgs_59" }, "locked": { "lastModified": 1774455309, @@ -11081,7 +11111,7 @@ }, "logos-nix_59": { "inputs": { - "nixpkgs": "nixpkgs_59" + "nixpkgs": "nixpkgs_60" }, "locked": { "lastModified": 1773955630, @@ -11099,7 +11129,7 @@ }, "logos-nix_6": { "inputs": { - "nixpkgs": "nixpkgs_6" + "nixpkgs": "nixpkgs_7" }, "locked": { "lastModified": 1773955630, @@ -11117,7 +11147,7 @@ }, "logos-nix_60": { "inputs": { - "nixpkgs": "nixpkgs_60" + "nixpkgs": "nixpkgs_61" }, "locked": { "lastModified": 1774455309, @@ -11135,7 +11165,7 @@ }, "logos-nix_61": { "inputs": { - "nixpkgs": "nixpkgs_61" + "nixpkgs": "nixpkgs_62" }, "locked": { "lastModified": 1773955630, @@ -11153,7 +11183,7 @@ }, "logos-nix_62": { "inputs": { - "nixpkgs": "nixpkgs_62" + "nixpkgs": "nixpkgs_63" }, "locked": { "lastModified": 1773955630, @@ -11171,7 +11201,7 @@ }, "logos-nix_63": { "inputs": { - "nixpkgs": "nixpkgs_63" + "nixpkgs": "nixpkgs_64" }, "locked": { "lastModified": 1774455309, @@ -11189,7 +11219,7 @@ }, "logos-nix_64": { "inputs": { - "nixpkgs": "nixpkgs_64" + "nixpkgs": "nixpkgs_65" }, "locked": { "lastModified": 1773955630, @@ -11207,7 +11237,7 @@ }, "logos-nix_65": { "inputs": { - "nixpkgs": "nixpkgs_65" + "nixpkgs": "nixpkgs_66" }, "locked": { "lastModified": 1773955630, @@ -11225,7 +11255,7 @@ }, "logos-nix_66": { "inputs": { - "nixpkgs": "nixpkgs_66" + "nixpkgs": "nixpkgs_67" }, "locked": { "lastModified": 1773955630, @@ -11243,7 +11273,7 @@ }, "logos-nix_67": { "inputs": { - "nixpkgs": "nixpkgs_67" + "nixpkgs": "nixpkgs_68" }, "locked": { "lastModified": 1773955630, @@ -11261,7 +11291,7 @@ }, "logos-nix_68": { "inputs": { - "nixpkgs": "nixpkgs_68" + "nixpkgs": "nixpkgs_69" }, "locked": { "lastModified": 1774455309, @@ -11279,7 +11309,7 @@ }, "logos-nix_69": { "inputs": { - "nixpkgs": "nixpkgs_69" + "nixpkgs": "nixpkgs_70" }, "locked": { "lastModified": 1773955630, @@ -11297,7 +11327,7 @@ }, "logos-nix_7": { "inputs": { - "nixpkgs": "nixpkgs_7" + "nixpkgs": "nixpkgs_8" }, "locked": { "lastModified": 1774455309, @@ -11315,7 +11345,7 @@ }, "logos-nix_70": { "inputs": { - "nixpkgs": "nixpkgs_70" + "nixpkgs": "nixpkgs_71" }, "locked": { "lastModified": 1774455309, @@ -11333,7 +11363,7 @@ }, "logos-nix_71": { "inputs": { - "nixpkgs": "nixpkgs_71" + "nixpkgs": "nixpkgs_72" }, "locked": { "lastModified": 1774455309, @@ -11351,7 +11381,7 @@ }, "logos-nix_72": { "inputs": { - "nixpkgs": "nixpkgs_72" + "nixpkgs": "nixpkgs_73" }, "locked": { "lastModified": 1773955630, @@ -11369,7 +11399,7 @@ }, "logos-nix_73": { "inputs": { - "nixpkgs": "nixpkgs_73" + "nixpkgs": "nixpkgs_74" }, "locked": { "lastModified": 1773955630, @@ -11387,7 +11417,7 @@ }, "logos-nix_74": { "inputs": { - "nixpkgs": "nixpkgs_74" + "nixpkgs": "nixpkgs_75" }, "locked": { "lastModified": 1773955630, @@ -11405,7 +11435,7 @@ }, "logos-nix_75": { "inputs": { - "nixpkgs": "nixpkgs_75" + "nixpkgs": "nixpkgs_76" }, "locked": { "lastModified": 1773955630, @@ -11423,7 +11453,7 @@ }, "logos-nix_76": { "inputs": { - "nixpkgs": "nixpkgs_76" + "nixpkgs": "nixpkgs_77" }, "locked": { "lastModified": 1773955630, @@ -11441,7 +11471,7 @@ }, "logos-nix_77": { "inputs": { - "nixpkgs": "nixpkgs_77" + "nixpkgs": "nixpkgs_78" }, "locked": { "lastModified": 1774455309, @@ -11459,7 +11489,7 @@ }, "logos-nix_78": { "inputs": { - "nixpkgs": "nixpkgs_78" + "nixpkgs": "nixpkgs_79" }, "locked": { "lastModified": 1773955630, @@ -11477,7 +11507,7 @@ }, "logos-nix_79": { "inputs": { - "nixpkgs": "nixpkgs_79" + "nixpkgs": "nixpkgs_80" }, "locked": { "lastModified": 1774455309, @@ -11495,7 +11525,7 @@ }, "logos-nix_8": { "inputs": { - "nixpkgs": "nixpkgs_8" + "nixpkgs": "nixpkgs_9" }, "locked": { "lastModified": 1774455309, @@ -11513,7 +11543,7 @@ }, "logos-nix_80": { "inputs": { - "nixpkgs": "nixpkgs_80" + "nixpkgs": "nixpkgs_81" }, "locked": { "lastModified": 1774455309, @@ -11531,7 +11561,7 @@ }, "logos-nix_81": { "inputs": { - "nixpkgs": "nixpkgs_81" + "nixpkgs": "nixpkgs_82" }, "locked": { "lastModified": 1773955630, @@ -11549,7 +11579,7 @@ }, "logos-nix_82": { "inputs": { - "nixpkgs": "nixpkgs_82" + "nixpkgs": "nixpkgs_83" }, "locked": { "lastModified": 1773955630, @@ -11567,7 +11597,7 @@ }, "logos-nix_83": { "inputs": { - "nixpkgs": "nixpkgs_83" + "nixpkgs": "nixpkgs_84" }, "locked": { "lastModified": 1774455309, @@ -11585,7 +11615,7 @@ }, "logos-nix_84": { "inputs": { - "nixpkgs": "nixpkgs_84" + "nixpkgs": "nixpkgs_85" }, "locked": { "lastModified": 1774455309, @@ -11603,7 +11633,7 @@ }, "logos-nix_85": { "inputs": { - "nixpkgs": "nixpkgs_85" + "nixpkgs": "nixpkgs_86" }, "locked": { "lastModified": 1773955630, @@ -11621,7 +11651,7 @@ }, "logos-nix_86": { "inputs": { - "nixpkgs": "nixpkgs_86" + "nixpkgs": "nixpkgs_87" }, "locked": { "lastModified": 1773955630, @@ -11639,7 +11669,7 @@ }, "logos-nix_87": { "inputs": { - "nixpkgs": "nixpkgs_87" + "nixpkgs": "nixpkgs_88" }, "locked": { "lastModified": 1774455309, @@ -11657,7 +11687,7 @@ }, "logos-nix_88": { "inputs": { - "nixpkgs": "nixpkgs_88" + "nixpkgs": "nixpkgs_89" }, "locked": { "lastModified": 1774455309, @@ -11675,7 +11705,7 @@ }, "logos-nix_89": { "inputs": { - "nixpkgs": "nixpkgs_89" + "nixpkgs": "nixpkgs_90" }, "locked": { "lastModified": 1773955630, @@ -11693,7 +11723,7 @@ }, "logos-nix_9": { "inputs": { - "nixpkgs": "nixpkgs_9" + "nixpkgs": "nixpkgs_10" }, "locked": { "lastModified": 1774455309, @@ -11711,7 +11741,7 @@ }, "logos-nix_90": { "inputs": { - "nixpkgs": "nixpkgs_90" + "nixpkgs": "nixpkgs_91" }, "locked": { "lastModified": 1773955630, @@ -11729,7 +11759,7 @@ }, "logos-nix_91": { "inputs": { - "nixpkgs": "nixpkgs_91" + "nixpkgs": "nixpkgs_92" }, "locked": { "lastModified": 1773955630, @@ -11747,7 +11777,7 @@ }, "logos-nix_92": { "inputs": { - "nixpkgs": "nixpkgs_92" + "nixpkgs": "nixpkgs_93" }, "locked": { "lastModified": 1773955630, @@ -11765,7 +11795,7 @@ }, "logos-nix_93": { "inputs": { - "nixpkgs": "nixpkgs_93" + "nixpkgs": "nixpkgs_94" }, "locked": { "lastModified": 1774455309, @@ -11783,7 +11813,7 @@ }, "logos-nix_94": { "inputs": { - "nixpkgs": "nixpkgs_94" + "nixpkgs": "nixpkgs_95" }, "locked": { "lastModified": 1773955630, @@ -11801,7 +11831,7 @@ }, "logos-nix_95": { "inputs": { - "nixpkgs": "nixpkgs_95" + "nixpkgs": "nixpkgs_96" }, "locked": { "lastModified": 1773955630, @@ -11819,7 +11849,7 @@ }, "logos-nix_96": { "inputs": { - "nixpkgs": "nixpkgs_96" + "nixpkgs": "nixpkgs_97" }, "locked": { "lastModified": 1774455309, @@ -11837,7 +11867,7 @@ }, "logos-nix_97": { "inputs": { - "nixpkgs": "nixpkgs_97" + "nixpkgs": "nixpkgs_98" }, "locked": { "lastModified": 1773955630, @@ -11855,7 +11885,7 @@ }, "logos-nix_98": { "inputs": { - "nixpkgs": "nixpkgs_98" + "nixpkgs": "nixpkgs_99" }, "locked": { "lastModified": 1774455309, @@ -11873,7 +11903,7 @@ }, "logos-nix_99": { "inputs": { - "nixpkgs": "nixpkgs_99" + "nixpkgs": "nixpkgs_100" }, "locked": { "lastModified": 1774455309, @@ -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": { @@ -20098,11 +20128,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1759036355, - "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "lastModified": 1783776592, + "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", "type": "github" }, "original": { @@ -20545,22 +20575,6 @@ } }, "nixpkgs_124": { - "locked": { - "lastModified": 1767313136, - "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-25.05", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_125": { "locked": { "lastModified": 1759036355, "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", @@ -20576,6 +20590,22 @@ "type": "github" } }, + "nixpkgs_125": { + "locked": { + "lastModified": 1767313136, + "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_126": { "locked": { "lastModified": 1759036355, @@ -23137,22 +23167,6 @@ } }, "nixpkgs_270": { - "locked": { - "lastModified": 1767313136, - "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-25.05", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_271": { "locked": { "lastModified": 1759036355, "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", @@ -23168,6 +23182,22 @@ "type": "github" } }, + "nixpkgs_271": { + "locked": { + "lastModified": 1767313136, + "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_272": { "locked": { "lastModified": 1759036355, @@ -25408,6 +25438,22 @@ "type": "github" } }, + "nixpkgs_399": { + "locked": { + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_4": { "locked": { "lastModified": 1759036355, @@ -26762,9 +26808,9 @@ }, "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" } }, "rust-overlay": { @@ -26834,7 +26880,7 @@ }, "rust-rapidsnark": { "inputs": { - "nixpkgs": "nixpkgs_270" + "nixpkgs": "nixpkgs_271" }, "locked": { "lastModified": 1781090841, @@ -26850,18 +26896,6 @@ "rev": "e91187f8ccb5bbfc7bb00dac88169112428da78f", "type": "github" } - }, - "shared_wallet": { - "flake": false, - "locked": { - "path": "../shared/wallet", - "type": "path" - }, - "original": { - "path": "../shared/wallet", - "type": "path" - }, - "parent": [] } }, "root": "root", diff --git a/apps/amm/flake.nix b/apps/amm/flake.nix index f237473..9750ffd 100644 --- a/apps/amm/flake.nix +++ b/apps/amm/flake.nix @@ -26,6 +26,8 @@ inputs.logos-execution-zone.url = "github:logos-blockchain/logos-execution-zone?rev=a7e06a660940a00093b1760560d37ff84aff5a05"; }; + + amm_client.url = "path:../.."; }; outputs = inputs@{ logos-module-builder, shared_wallet, ... }: @@ -36,6 +38,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; + }); + }; +}