diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 9f79802..285a898 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -1,12 +1,30 @@ -cmake_minimum_required(VERSION 3.14) +cmake_minimum_required(VERSION 3.21) project(AmmUiPlugin LANGUAGES CXX) +find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2) +qt_standard_project_setup(REQUIRES 6.8) + +find_package(PkgConfig REQUIRED) +pkg_check_modules(BASE58 REQUIRED IMPORTED_TARGET libbase58) + +include(CTest) + 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( @@ -18,14 +36,21 @@ logos_module( src/AmmUiPlugin.cpp src/AmmUiBackend.h src/AmmUiBackend.cpp - src/AccountModel.h - src/AccountModel.cpp + src/ActiveNetwork.h + src/ActiveNetwork.cpp + src/AmmClient.h + src/AmmClient.cpp + src/NewPositionRuntime.h + src/NewPositionRuntime.cpp FIND_PACKAGES Qt6Gui Qt6Network LINK_LIBRARIES Qt6::Gui Qt6::Network + PkgConfig::BASE58 + LINK_TARGETS + logos_wallet_access EXTERNAL_LIBS amm_client ) @@ -35,3 +60,25 @@ qt_add_resources(amm_ui_module_plugin amm_ui_config FILES config/networks.json ) + +if(BUILD_TESTING) + add_executable(amm_active_network_test + tests/cpp/ActiveNetworkTest.cpp + src/ActiveNetwork.cpp + ) + target_include_directories(amm_active_network_test PRIVATE src) + target_link_libraries(amm_active_network_test PRIVATE Qt6::Core) + add_test(NAME amm_active_network COMMAND amm_active_network_test) + + add_executable(amm_new_position_runtime_test + tests/cpp/NewPositionRuntimeTest.cpp + src/NewPositionRuntime.cpp + ) + target_include_directories(amm_new_position_runtime_test PRIVATE src) + target_link_libraries(amm_new_position_runtime_test PRIVATE + Qt6::Core + PkgConfig::BASE58 + logos_wallet_access + ) + add_test(NAME amm_new_position_runtime COMMAND amm_new_position_runtime_test) +endif() diff --git a/apps/amm/VALIDATION.md b/apps/amm/VALIDATION.md index c10a5db..af13c1d 100644 --- a/apps/amm/VALIDATION.md +++ b/apps/amm/VALIDATION.md @@ -1,16 +1,25 @@ # AMM UI Validation -Validation for the New Position flow covers deterministic quote math, account -construction, QML checks, and the module build. +Validation for the New Position flow covers the shipped Rust client, QML +syntax, and the complete module build. ## Commands Run from the repository root: ```bash -node apps/amm/tests/new-position-validation.mjs -qmllint apps/amm/qml/pages/LiquidityPage.qml apps/amm/qml/components/liquidity/NewPositionForm.qml apps/amm/qml/components/liquidity/NewPositionConfirmationDialog.qml -nix build ./apps/amm#packages.x86_64-linux.default --no-link +cargo +1.94.0 test -p amm_client +cargo +1.94.0 clippy -p amm_client --all-targets -- -D warnings +logos_qml=$(nix build github:logos-co/logos-design-system/6176f0d7a5dfeb64a7f0f98e7ca2bf71a4804772 --no-link --print-out-paths) +amm_qml=$(nix build ./apps/amm#packages.x86_64-linux.default --no-link --print-out-paths) +qt_qml=$(nix-store --query --requisites "$amm_qml" | rg -m1 -- '-qtdeclarative-[0-9]') +qt_svg=$(nix-store --query --requisites "$amm_qml" | rg -m1 -- '-qtsvg-[0-9]') +export QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software QT_PLUGIN_PATH="$qt_svg/lib/qt-6/plugins" +amm_import="$amm_qml/lib/qml" +test -f "$amm_import/Logos/Wallet/qmldir" +"$qt_qml/bin/qmltestrunner" -import "$qt_qml/lib/qt-6/qml" -import "$logos_qml/lib" -import "$amm_import" -input apps/shared/wallet/tests/qml +"$qt_qml/bin/qmltestrunner" -import "$qt_qml/lib/qt-6/qml" -import "$logos_qml/lib" -import "$amm_import" -input apps/amm/tests/qml +"$qt_qml/bin/qmllint" -I "$qt_qml/lib/qt-6/qml" -I "$logos_qml/lib" -I "$amm_qml/lib" -I "$amm_import" apps/amm/qml/pages/LiquidityPage.qml apps/amm/qml/components/liquidity/NewPositionForm.qml apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml ``` When shared AMM program types or instruction signatures change, also run the @@ -23,24 +32,23 @@ cargo +1.94.0 build -p idl-gen --release diff artifacts/amm-idl.json /tmp/amm-idl.json ``` -Live wallet/sequencer validation is manual until transaction dispatch has real -token holding IDs. Use a local devnet wallet/sequencer when available, open the -AMM UI, create or adopt one active account, preview both the active LOGOS/USDC -pool and the missing USDC/WETH pool, then submit and verify either a successful -transaction or the deterministic `submit_unavailable` backend response. +Live wallet/sequencer validation uses the configured Network and an External +Wallet Provider. Open the AMM UI, select two TokenProgram-scoped fungible token +definitions, preview an active or missing Pool, submit, and verify the returned +transaction ID is displayed. ## Acceptance Checklist -- Active wallet context exposes only holdings for the active account. +- Context exposes Wallet-Scoped Holdings and configured token definitions. - Unsupported fee tiers are disabled and explain why. - Active pool fee tier is fixed to the stored pool fee. -- Missing pool flow locks the user-selected price and scales from the minimum - deposit that mints more than `MINIMUM_LIQUIDITY`. -- Active pool flow keeps the reserve ratio and previews LP with - `floor(min(supply * amount_a / reserve_a, supply * amount_b / reserve_b))`. +- Missing pool flow accepts an editable `X Token A = Y Token B` ratio and + scales either deposit from the minimum that mints more than + `MINIMUM_LIQUIDITY`. +- Active Pool flow keeps the reserve ratio and previews expected LP output. - Decimal inference keeps small supplies at 0 decimals, medium supplies at 6, large supplies at 9, and massive supplies at 18. -- Human amount parse/format is reversible for 0, 6, 9, and 18 decimals. +- Human amount parse/format preserves 0, 6, 9, and 18 decimal values. - `new_definition` and `add_liquidity` account lists match the committed AMM IDL order. - Submit re-quotes and surfaces `quote_changed` when the request no longer diff --git a/apps/amm/client/src/account.rs b/apps/amm/client/src/account.rs index 9d75a6e..79432c5 100644 --- a/apps/amm/client/src/account.rs +++ b/apps/amm/client/src/account.rs @@ -4,8 +4,24 @@ use nssa_core::{ account::{Account, AccountId, Data, Nonce}, program::ProgramId, }; +use serde::Deserialize; -use crate::model::AccountRead; +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AccountRead { + pub(crate) id: String, + pub(crate) status: String, + #[serde(default)] + pub(crate) account: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct WalletAccount { + pub(crate) program_owner: String, + pub(crate) balance: String, + pub(crate) nonce: String, + pub(crate) data: String, +} pub fn parse_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> { if value.len() != 64 @@ -123,8 +139,6 @@ pub fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), String #[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"), diff --git a/apps/amm/client/src/lib.rs b/apps/amm/client/src/lib.rs index fa017f7..293cfab 100644 --- a/apps/amm/client/src/lib.rs +++ b/apps/amm/client/src/lib.rs @@ -1,7 +1,6 @@ #![deny(unsafe_op_in_unsafe_fn)] mod account; -mod model; mod protocol; use std::{ @@ -9,13 +8,39 @@ use std::{ panic::{catch_unwind, AssertUnwindSafe}, }; -use serde::de::DeserializeOwned; +use serde::{de::DeserializeOwned, Serialize}; -use crate::model::{ - ConfigIdRequest, ContextRequest, Envelope, PairIdsRequest, PlanRequest, QuoteRequest, - TokenIdsRequest, +use crate::protocol::{ + ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest, QuoteRequest, TokenIdsRequest, }; +#[derive(Serialize)] +struct Envelope { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl Envelope { + fn success(value: serde_json::Value) -> Self { + Self { + ok: true, + value: Some(value), + error: None, + } + } + + fn failure(error: impl Into) -> Self { + Self { + ok: false, + value: None, + error: Some(error.into()), + } + } +} + fn call( request: *const c_char, operation: fn(T) -> Result, diff --git a/apps/amm/client/src/model.rs b/apps/amm/client/src/model.rs deleted file mode 100644 index a94617c..0000000 --- a/apps/amm/client/src/model.rs +++ /dev/null @@ -1,158 +0,0 @@ -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 index 809e332..78d4d0d 100644 --- a/apps/amm/client/src/protocol.rs +++ b/apps/amm/client/src/protocol.rs @@ -14,32 +14,141 @@ use nssa_core::{ program::ProgramId, Commitment, }; +use serde::Deserialize; 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, - }, +use crate::account::{ + account_id_from_hex, account_id_hex, decode_account, parse_base58_id, parse_program_id, + program_id_base58, program_id_bytes, AccountRead, }; +pub(crate) const SCHEMA: &str = "new-position.v1"; 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(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ConfigIdRequest { + pub(crate) amm_program_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TokenIdsRequest { + pub(crate) amm_program_id: String, + pub(crate) config: AccountRead, + #[serde(default)] + pub(crate) wallet_accounts: Vec, + #[serde(default)] + pub(crate) configured_token_ids: Vec, + #[serde(default)] + pub(crate) recent_token_ids: Vec, + #[serde(default)] + pub(crate) resolved_token_ids: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ContextRequest { + pub(crate) network_id: String, + pub(crate) network_fingerprint: String, + pub(crate) amm_program_id: String, + pub(crate) wallet_available: bool, + pub(crate) config: AccountRead, + #[serde(default)] + pub(crate) wallet_accounts: Vec, + #[serde(default)] + pub(crate) token_definitions: Vec, + #[serde(default)] + pub(crate) configured_token_ids: Vec, + #[serde(default)] + pub(crate) recent_token_ids: Vec, + #[serde(default)] + pub(crate) resolved_token_ids: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PairIdsRequest { + pub(crate) amm_program_id: String, + pub(crate) config: AccountRead, + pub(crate) token_a_id: String, + pub(crate) token_b_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PositionRequest { + pub(crate) schema: String, + pub(crate) token_a_id: String, + pub(crate) token_b_id: String, + pub(crate) fee_bps: u32, + #[serde(default)] + pub(crate) amount_a_raw: Option, + #[serde(default)] + pub(crate) amount_b_raw: Option, + #[serde(default)] + pub(crate) max_amount_a_raw: Option, + #[serde(default)] + pub(crate) max_amount_b_raw: Option, + #[serde(default)] + pub(crate) slippage_bps: Option, + #[serde(default)] + pub(crate) initial_price_real_raw: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PairSnapshot { + pub(crate) config: AccountRead, + pub(crate) token_a: AccountRead, + pub(crate) token_b: AccountRead, + pub(crate) pool: AccountRead, + pub(crate) vault_a: AccountRead, + pub(crate) vault_b: AccountRead, + pub(crate) lp_definition: AccountRead, + pub(crate) lp_lock_holding: AccountRead, + pub(crate) current_tick: AccountRead, + pub(crate) clock: AccountRead, + pub(crate) wallet_available: bool, + #[serde(default)] + pub(crate) wallet_accounts: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct QuoteRequest { + pub(crate) network_id: String, + pub(crate) network_fingerprint: String, + pub(crate) amm_program_id: String, + pub(crate) request: PositionRequest, + pub(crate) snapshot: PairSnapshot, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanRequest { + pub(crate) network_id: String, + pub(crate) network_fingerprint: String, + pub(crate) amm_program_id: String, + pub(crate) request: PositionRequest, + pub(crate) snapshot: PairSnapshot, + pub(crate) quote_hash: String, + pub(crate) now_ms: u64, + #[serde(default)] + pub(crate) fresh_lp: Option, +} + #[derive(Clone)] struct SelectedHolding { id: AccountId, + definition_id: AccountId, balance: u128, account: Account, } @@ -65,10 +174,16 @@ struct EvaluatedQuote { } enum QuoteComputation { - Fatal { value: Value }, + Failed(QuoteFailure), Evaluated(EvaluatedQuote), } +struct QuoteFailure { + code: &'static str, + fields: Vec<&'static str>, + details: Value, +} + struct NewPositionPlan { accounts: AccountPlan, branch: QuoteBranch, @@ -95,20 +210,43 @@ struct AccountPlanHoldings<'a> { } impl QuoteComputation { - fn into_value(self) -> Value { + fn into_value(self, request: &PositionRequest) -> Value { match self { - Self::Fatal { value } | Self::Evaluated(EvaluatedQuote { value, .. }) => value, + Self::Failed(failure) => failure.into_value(request), + Self::Evaluated(EvaluatedQuote { value, .. }) => value, } } fn quote_hash(&self) -> Option<&str> { match self { - Self::Fatal { .. } => None, + Self::Failed(_) => None, Self::Evaluated(quote) => Some("e.quote_hash), } } } +impl QuoteFailure { + fn into_value(self, request: &PositionRequest) -> Value { + json!({ + "schema": SCHEMA, + "status": "error", + "canSubmit": false, + "code": self.code, + "poolStatus": "unavailable_pool", + "tokenAId": request.token_a_id, + "tokenBId": request.token_b_id, + "accountPreview": [], + "errors": [issue( + self.code, + "Position quote is unavailable.", + &self.fields, + self.details, + )], + "warnings": [], + }) + } +} + impl NewPositionPlan { fn new(accounts: AccountPlan, branch: QuoteBranch) -> Result { accounts.validate_ready()?; @@ -164,6 +302,12 @@ impl AccountPlan { .any(|row| row.role == "user_holding_lp" && row.account_id.is_none()) } + fn contains(&self, account_id: AccountId) -> bool { + self.rows + .iter() + .any(|row| row.account_id == Some(account_id)) + } + fn validate_ready(&self) -> Result<(), String> { if self.rows.iter().any(|row| { row.account_id.is_none() && !(row.role == "user_holding_lp" && row.signer && row.init) @@ -246,8 +390,8 @@ struct PairIds { #[derive(BorshSerialize)] enum RequestCommitment { Missing { - initial_price: u128, - deposit_scale_bps: u32, + amount_a: u128, + amount_b: u128, }, Active { max_a: u128, @@ -314,6 +458,7 @@ pub fn token_ids(request: TokenIdsRequest) -> Result { return Ok(manifest_error("config_unavailable")); }; + let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id); 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") { @@ -329,19 +474,7 @@ pub fn token_ids(request: TokenIdsRequest) -> Result { 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); - } - } + token_ids.extend(holdings.into_iter().map(|holding| holding.definition_id)); Ok(json!({ "status": "ok", @@ -355,8 +488,12 @@ fn manifest_error(code: &str) -> Value { 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")?; + let Ok(token_a) = parse_base58_id(&request.token_a_id, "token A id") else { + return Ok(json!({ "status": "error", "code": "invalid_token_id" })); + }; + let Ok(token_b) = parse_base58_id(&request.token_b_id, "token B id") else { + return Ok(json!({ "status": "error", "code": "invalid_token_id" })); + }; if token_a == token_b { return Ok(json!({ "status": "error", "code": "same_token_pair" })); } @@ -364,7 +501,9 @@ pub fn pair_ids(request: PairIdsRequest) -> Result { return Ok(json!({ "status": "error", "code": "non_canonical_pair" })); } - let pair = derive_pair(amm_program, token_a, token_b, &request.config)?; + let Ok(pair) = derive_pair(amm_program, token_a, token_b, &request.config) else { + return Ok(json!({ "status": "error", "code": "config_unavailable" })); + }; Ok(pair_json(pair)) } @@ -438,8 +577,8 @@ pub fn context(request: ContextRequest) -> Result { 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 source_map = token_sources(&request, &holdings); let mut rows = Vec::new(); let mut warnings = Vec::new(); @@ -448,73 +587,22 @@ pub fn context(request: ContextRequest) -> Result { .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 (name, total_supply, metadata_id) = + match fungible_definition(read, token_id, config.token_program_id) { + Ok(definition) => definition, + Err(error) => { + rows.push(unavailable_token_row(token_id, sources, error.code)); + if error.warn { + warnings.push(issue( + error.code, + "Token definition could not be read.", + &[], + json!({ "tokenId": token_id.to_string() }), + )); + } + continue; + } + }; let selected = select_holding(&holdings, token_id); let mut row = json!({ @@ -588,14 +676,17 @@ fn fee_tiers() -> Value { ]) } -fn token_sources(request: &ContextRequest) -> BTreeMap> { +fn token_sources( + request: &ContextRequest, + holdings: &[SelectedHolding], +) -> 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")); + .insert(String::from("config")); } } for (ids, source) in [ @@ -608,18 +699,11 @@ fn token_sources(request: &ContextRequest) -> BTreeMap> { } } } - 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")); - } + for holding in holdings { + sources + .entry(holding.definition_id) + .or_default() + .insert(String::from("holding")); } sources .into_iter() @@ -640,6 +724,54 @@ fn unavailable_token_row(token_id: AccountId, sources: Vec, code: &str) }) } +struct DefinitionError { + code: &'static str, + warn: bool, +} + +fn fungible_definition( + read: Option<&AccountRead>, + token_id: AccountId, + token_program: ProgramId, +) -> Result<(String, u128, Option), DefinitionError> { + let Some(read) = read else { + return Err(DefinitionError { + code: "token_definition_unreadable", + warn: true, + }); + }; + let Ok((id, account)) = decode_account(read) else { + return Err(DefinitionError { + code: "token_definition_unreadable", + warn: true, + }); + }; + if id != token_id { + return Err(DefinitionError { + code: "token_definition_unreadable", + warn: false, + }); + } + if account.program_owner != token_program { + return Err(DefinitionError { + code: "token_program_mismatch", + warn: false, + }); + } + match TokenDefinition::try_from(&account.data) { + Ok(TokenDefinition::Fungible { + name, + total_supply, + metadata_id, + .. + }) => Ok((name, total_supply, metadata_id)), + _ => Err(DefinitionError { + code: "token_not_fungible", + warn: false, + }), + } +} + fn wallet_holdings(reads: &[AccountRead], token_program: ProgramId) -> Vec { reads .iter() @@ -648,13 +780,16 @@ fn wallet_holdings(reads: &[AccountRead], token_program: ProgramId) -> Vec Option { holdings .iter() - .filter(|holding| { - matches!( - TokenHolding::try_from(&holding.account.data), - Ok(TokenHolding::Fungible { definition_id: id, .. }) if id == definition_id - ) - }) + .filter(|holding| holding.definition_id == definition_id) .max_by(|left, right| { left.balance .cmp(&right.balance) @@ -683,13 +813,12 @@ fn select_holding( } pub fn quote(request: QuoteRequest) -> Result { - Ok(compute_quote(&request)?.into_value()) + Ok(compute_quote(&request)?.into_value(&request.request)) } 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 }), @@ -698,29 +827,14 @@ fn compute_quote(input: &QuoteRequest) -> Result { 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!({}), - )) - } + Err(_) => return Ok(fatal_quote("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!({}), - )) - } + Err(_) => return Ok(fatal_quote("invalid_token_id", &["tokenBId"], json!({}))), }; if token_a == token_b { return Ok(fatal_quote( - &input.request, "same_token_pair", &["tokenAId", "tokenBId"], json!({}), @@ -728,7 +842,6 @@ fn compute_quote(input: &QuoteRequest) -> Result { } if !is_canonical_pair(token_a, token_b) { return Ok(fatal_quote( - &input.request, "non_canonical_pair", &["tokenAId", "tokenBId"], json!({}), @@ -736,7 +849,6 @@ fn compute_quote(input: &QuoteRequest) -> Result { } 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 }), @@ -745,45 +857,32 @@ fn compute_quote(input: &QuoteRequest) -> Result { 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!({}), - )) - } + Err(_) => return Ok(fatal_quote("config_unavailable", &[], json!({}))), }; if let Some(error) = AccountPlan::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)); + for (read, token_id, field) in [ + (&input.snapshot.token_a, token_a, "tokenAId"), + (&input.snapshot.token_b, token_b, "tokenBId"), + ] { + if let Err(error) = fungible_definition(Some(read), token_id, pair.token_program) { + return Ok(fatal_quote( + error.code, + &[field], + json!({ "tokenId": token_id.to_string() }), + )); + } } 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() }), @@ -797,56 +896,6 @@ fn compute_quote(input: &QuoteRequest) -> Result { } } -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, @@ -861,7 +910,6 @@ fn compute_missing_quote( ] { let Ok((_, account)) = decode_account(read) else { return Ok(fatal_quote( - &input.request, "account_read_failed", &[], json!({ "role": role }), @@ -869,7 +917,6 @@ fn compute_missing_quote( }; if account != Account::default() { return Ok(fatal_quote( - &input.request, "pool_unavailable", &[], json!({ "role": role }), @@ -878,75 +925,65 @@ fn compute_missing_quote( } 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", - ) { + let requested_price = match raw_value(input.request.initial_price_real_raw.as_deref()) { Ok(value) if value > 0 => value, Ok(_) => { return Ok(fatal_quote( - &input.request, "amount_must_be_positive", &["initialPriceRealRaw"], json!({}), )) } - Err(code) => { + Err(code) => return Ok(fatal_quote(code, &["initialPriceRealRaw"], json!({}))), + }; + let (minimum_a, minimum_b) = minimum_opening_pair(requested_price)?; + let direct_amounts = + input.request.amount_a_raw.is_some() || input.request.amount_b_raw.is_some(); + let (amount_a, amount_b) = if direct_amounts { + let amount_a = match raw_value(input.request.amount_a_raw.as_deref()) { + Ok(value) if value > 0 => value, + Ok(_) => { + return Ok(fatal_quote( + "amount_must_be_positive", + &["amountARaw"], + json!({}), + )) + } + Err(code) => return Ok(fatal_quote(code, &["amountARaw"], json!({}))), + }; + let amount_b = match raw_value(input.request.amount_b_raw.as_deref()) { + Ok(value) if value > 0 => value, + Ok(_) => { + return Ok(fatal_quote( + "amount_must_be_positive", + &["amountBRaw"], + json!({}), + )) + } + Err(code) => return Ok(fatal_quote(code, &["amountBRaw"], json!({}))), + }; + if spot_price_q64_64(amount_a, amount_b) != requested_price { return Ok(fatal_quote( - &input.request, - code, - &["initialPriceRealRaw"], + "deposit_ratio_mismatch", + &["amountARaw", "amountBRaw"], 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!({}), - )); + (amount_a, amount_b) + } else { + (minimum_a, minimum_b) }; let initial_lp = isqrt_product(amount_a, amount_b); if initial_lp <= MINIMUM_LIQUIDITY { return Ok(fatal_quote( - &input.request, "amount_too_low", - &["depositScaleBps"], + &["amountARaw", "amountBRaw"], json!({ "minimumLiquidityRaw": MINIMUM_LIQUIDITY.to_string() }), )); } @@ -962,16 +999,9 @@ fn compute_missing_quote( amount_a, &holding_b, amount_b, - "depositScaleBps", + ["amountARaw", "amountBRaw"], ); - 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 can_submit = funding.is_empty(); let mut account_plan = missing_account_plan( input, pair, @@ -993,10 +1023,7 @@ fn compute_missing_quote( 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, - }, + request: RequestCommitment::Missing { amount_a, amount_b }, max_a: amount_a, max_b: amount_b, actual_a: amount_a, @@ -1028,9 +1055,9 @@ fn compute_missing_quote( "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, + "initialPriceRealRaw": spot_price_q64_64(amount_a, amount_b).to_string(), + "minimumAmountARaw": minimum_a.to_string(), + "minimumAmountBRaw": minimum_b.to_string(), "requiresFreshLp": true, "accountPreview": preview, "errors": funding, @@ -1059,7 +1086,6 @@ fn compute_active_quote( ) -> Result { if pool_account.program_owner != amm_program { return Ok(fatal_quote( - &input.request, "pool_unavailable", &[], json!({ "reason": "owner_mismatch" }), @@ -1067,7 +1093,6 @@ fn compute_active_quote( } let Ok(pool) = PoolDefinition::try_from(&pool_account.data) else { return Ok(fatal_quote( - &input.request, "pool_unavailable", &[], json!({ "reason": "invalid_pool_data" }), @@ -1083,18 +1108,16 @@ fn compute_active_quote( 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!({}))); + return Ok(fatal_quote("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() }), @@ -1102,55 +1125,37 @@ fn compute_active_quote( } 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") { + let max_a = match raw_value(input.request.max_amount_a_raw.as_deref()) { 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!({}), - )) - } + Err(code) => return Ok(fatal_quote(code, &["maxAmountARaw"], json!({}))), }; - let max_b = match raw_value(input.request.max_amount_b_raw.as_deref(), "maxAmountBRaw") { + let max_b = match raw_value(input.request.max_amount_b_raw.as_deref()) { 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!({}), - )) - } + Err(code) => return Ok(fatal_quote(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 }), @@ -1168,7 +1173,6 @@ fn compute_active_quote( 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!({}), @@ -1180,7 +1184,6 @@ fn compute_active_quote( ); if expected_lp == 0 { return Ok(fatal_quote( - &input.request, "amount_too_low", &["maxAmountARaw", "maxAmountBRaw"], json!({}), @@ -1192,12 +1195,7 @@ fn compute_active_quote( FEE_BPS_DENOMINATOR, ); if minimum_lp == 0 { - return Ok(fatal_quote( - &input.request, - "minimum_lp_zero", - &["slippageBps"], - json!({}), - )); + return Ok(fatal_quote("minimum_lp_zero", &["slippageBps"], json!({}))); } let (actual_a, actual_b, reserve_a, reserve_b) = if stored_reversed { ( @@ -1227,12 +1225,12 @@ fn compute_active_quote( input.snapshot.wallet_available, pair, &holding_a, - max_a, + actual_a, &holding_b, - max_b, - "", + actual_b, + ["maxAmountARaw", "maxAmountBRaw"], ); - let can_submit = funding.is_empty() && input.snapshot.wallet_available; + let can_submit = funding.is_empty(); let warnings = if slippage_bps >= HIGH_SLIPPAGE_BPS { vec![issue( "high_slippage", @@ -1282,7 +1280,7 @@ fn compute_active_quote( lp_guard: minimum_lp, requires_fresh_lp, sources, - funding: funding_commitments(pair, &holding_a, max_a, &holding_b, max_b), + funding: funding_commitments(pair, &holding_a, actual_a, &holding_b, actual_b), warnings: warning_codes, }; let quote_hash = hash_quote(&commitment)?; @@ -1356,7 +1354,6 @@ fn validate_active_accounts( || pool.liquidity_pool_id != pair.lp_definition { return Some(fatal_quote( - &input.request, "pool_unavailable", &[], json!({ "reason": "pool_account_mismatch" }), @@ -1368,7 +1365,6 @@ fn validate_active_accounts( ); 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" }), @@ -1381,7 +1377,6 @@ fn validate_active_accounts( }; if vault_a_balance < reserve_a || vault_b_balance < reserve_b { return Some(fatal_quote( - &input.request, "pool_unavailable", &[], json!({ "reason": "vault_below_reserve" }), @@ -1389,7 +1384,6 @@ fn validate_active_accounts( } 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" }), @@ -1403,7 +1397,6 @@ fn validate_active_accounts( ) { return Some(fatal_quote( - &input.request, "pool_unavailable", &[], json!({ "reason": "invalid_lp_definition" }), @@ -1411,7 +1404,6 @@ fn validate_active_accounts( } 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" }), @@ -1422,7 +1414,6 @@ fn validate_active_accounts( || CurrentTickAccount::try_from(&tick_account.data).is_err() { return Some(fatal_quote( - &input.request, "pool_unavailable", &[], json!({ "reason": "invalid_current_tick" }), @@ -1430,7 +1421,6 @@ fn validate_active_accounts( } if !valid_clock(&input.snapshot.clock, pair.clock) { return Some(fatal_quote( - &input.request, "account_read_failed", &[], json!({ "role": "clock" }), @@ -1464,7 +1454,7 @@ fn valid_clock(read: &AccountRead, expected_id: AccountId) -> bool { id == expected_id && borsh::from_slice::(account.data.as_ref()).is_ok() } -fn raw_value(value: Option<&str>, _field: &str) -> Result { +fn raw_value(value: Option<&str>) -> Result { let Some(value) = value else { return Err("amount_required"); }; @@ -1478,13 +1468,14 @@ fn raw_value(value: Option<&str>, _field: &str) -> Result { } fn minimum_opening_pair(price: u128) -> Result<(u128, u128), String> { - let target_product = U256::from(MINIMUM_LIQUIDITY) - .checked_mul(U256::from(MINIMUM_LIQUIDITY)) + let minimum_initial_lp = U256::from(MINIMUM_LIQUIDITY + 1); + let target_product = minimum_initial_lp + .checked_mul(minimum_initial_lp) .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 + 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(( @@ -1494,7 +1485,7 @@ fn minimum_opening_pair(price: u128) -> Result<(u128, u128), String> { } 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 + 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(( @@ -1520,39 +1511,6 @@ 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, @@ -1560,7 +1518,7 @@ fn funding_issues( requested_a: u128, holding_b: &Option, requested_b: u128, - derived_field: &str, + fields: [&str; 2], ) -> Vec { if !wallet_available { return vec![issue( @@ -1572,26 +1530,8 @@ fn funding_issues( } 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 - }, - ), + (pair.token_a, holding_a, requested_a, fields[0]), + (pair.token_b, holding_b, requested_b, fields[1]), ] { let available = holding.as_ref().map_or(0, |value| value.balance); if available < requested { @@ -1674,26 +1614,12 @@ fn issue(code: &str, message: &str, fields: &[&str], details: Value) -> Value { }) } -fn fatal_quote( - request: &PositionRequest, - code: &str, - fields: &[&str], - details: Value, -) -> QuoteComputation { - QuoteComputation::Fatal { - 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": [], - }), - } +fn fatal_quote(code: &'static str, fields: &[&'static str], details: Value) -> QuoteComputation { + QuoteComputation::Failed(QuoteFailure { + code, + fields: fields.to_vec(), + details, + }) } fn missing_account_plan( @@ -1928,18 +1854,18 @@ pub fn plan(input: PlanRequest) -> Result { "status": "error", "code": "quote_changed", "recoverable": true, - "quote": quote.into_value(), + "quote": quote.into_value("e_input.request), })); } let evaluated = match quote { QuoteComputation::Evaluated(evaluated) => evaluated, - QuoteComputation::Fatal { value } => { + QuoteComputation::Failed(failure) => { return Ok(json!({ "schema": SCHEMA, "status": "error", "code": "quote_not_submittable", "recoverable": true, - "quote": value, + "quote": failure.into_value("e_input.request), })) } }; @@ -1963,7 +1889,7 @@ pub fn plan(input: PlanRequest) -> Result { let Ok((id, account)) = decode_account(read) else { return Ok(plan_error("wallet_submission_failed")); }; - if account != Account::default() { + if account != Account::default() || plan.accounts.contains(id) { return Ok(plan_error("wallet_submission_failed")); } Some(id) @@ -1978,8 +1904,6 @@ pub fn plan(input: PlanRequest) -> Result { if clock_timestamp >= deadline { return Ok(plan_error("transaction_deadline_expired")); } - let amm_program = parse_program_id(&input.amm_program_id)?; - let NewPositionPlan { accounts, branch } = plan; let (account_ids, signing_requirements) = accounts.wallet_args(fresh_lp)?; let instruction = match branch { @@ -2023,7 +1947,6 @@ pub fn plan(input: PlanRequest) -> Result { "signingRequirements": signing_requirements, "instruction": instruction, "deadlineMs": deadline.to_string(), - "ammProgramId": program_id_base58(amm_program), })) } @@ -2165,11 +2088,73 @@ mod tests { token_a_id: pair.token_a.to_string(), token_b_id: pair.token_b.to_string(), fee_bps: 30, + amount_a_raw: None, + amount_b_raw: None, 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), + } + } + + fn amm_program_id() -> String { + hex::encode(program_id_bytes(AMM_PROGRAM)) + } + + struct Scenario { + pair: PairIds, + request: PositionRequest, + snapshot: PairSnapshot, + network_id: &'static str, + network_fingerprint: &'static str, + } + + impl Scenario { + fn devnet() -> Self { + Self::new("devnet", "channel:test") + } + + fn testnet() -> Self { + Self::new("testnet", "block10:test") + } + + fn new(network_id: &'static str, network_fingerprint: &'static str) -> Self { + let pair = ids(); + Self { + pair, + request: request(pair), + snapshot: base_snapshot(pair), + network_id, + network_fingerprint, + } + } + + fn quote_request(&self) -> QuoteRequest { + QuoteRequest { + network_id: String::from(self.network_id), + network_fingerprint: String::from(self.network_fingerprint), + amm_program_id: amm_program_id(), + request: self.request.clone(), + snapshot: self.snapshot.clone(), + } + } + + fn quote(&self) -> Value { + quote(self.quote_request()).unwrap() + } + + fn plan(self, quote_hash: impl Into, fresh_lp: Option) -> Value { + plan(PlanRequest { + network_id: String::from(self.network_id), + network_fingerprint: String::from(self.network_fingerprint), + amm_program_id: amm_program_id(), + request: self.request, + snapshot: self.snapshot, + quote_hash: quote_hash.into(), + now_ms: 2_000, + fresh_lp, + }) + .unwrap() } } @@ -2199,15 +2184,9 @@ mod tests { #[test] fn account_plan_sources_follow_pool_branch() { - let pair = ids(); - let snapshot = base_snapshot(pair); - let input = QuoteRequest { - 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, - }; + let scenario = Scenario::devnet(); + let pair = scenario.pair; + let input = scenario.quote_request(); 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); @@ -2280,7 +2259,7 @@ mod tests { #[test] fn minimum_pair_exceeds_protocol_lock() { - for price in [1, Q64 / 10, Q64, Q64 * 2, u128::MAX] { + for price in [1, Q64 / 2_500, 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); @@ -2307,6 +2286,7 @@ mod tests { let definition = AccountId::new([9; 32]); let holding = |id: u8, balance| SelectedHolding { id: AccountId::new([id; 32]), + definition_id: definition, balance, account: account( TOKEN_PROGRAM, @@ -2329,17 +2309,9 @@ mod tests { 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), + amm_program_id: amm_program_id(), + config: account_read(config_id, &config_account()), token_a_id: token_a.to_string(), token_b_id: token_b.to_string(), }) @@ -2354,40 +2326,133 @@ mod tests { } #[test] - fn context_selects_tokens_without_holdings() { - let token_id = AccountId::new([3; 32]); + fn pair_manifest_reports_invalid_token_as_domain_error() { + let pair = ids(); + let result = pair_ids(PairIdsRequest { + amm_program_id: amm_program_id(), + config: account_read(pair.config, &config_account()), + token_a_id: String::from("not-a-token-id"), + token_b_id: pair.token_b.to_string(), + }) + .expect("invalid user input is a domain result"); + + assert_eq!(result["status"], "error"); + assert_eq!(result["code"], "invalid_token_id"); + } + + #[test] + fn pair_manifest_reports_unavailable_config_as_domain_error() { + let pair = ids(); + let result = pair_ids(PairIdsRequest { + amm_program_id: amm_program_id(), + config: default_read(pair.config), + token_a_id: pair.token_a.to_string(), + token_b_id: pair.token_b.to_string(), + }) + .expect("unavailable chain state is a domain result"); + + assert_eq!(result["status"], "error"); + assert_eq!(result["code"], "config_unavailable"); + } + + #[test] + fn token_manifest_includes_compatible_wallet_holdings() { 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 configured = AccountId::new([1; 32]); + let held = AccountId::new([2; 32]); + let recent = AccountId::new([3; 32]); + let resolved = AccountId::new([4; 32]); + + let value = token_ids(TokenIdsRequest { + amm_program_id: amm_program_id(), + config: account_read(config_id, &config_account()), + wallet_accounts: vec![account_read( + AccountId::new([5; 32]), + &token_holding(held, 9), + )], + configured_token_ids: vec![account_id_hex(configured)], + recent_token_ids: vec![recent.to_string()], + resolved_token_ids: vec![resolved.to_string()], + }) + .unwrap(); + + assert_eq!( + value["tokenIds"], + json!([ + account_id_hex(configured), + account_id_hex(held), + account_id_hex(recent), + account_id_hex(resolved), + ]) + ); + } + + #[test] + fn wrong_program_holdings_do_not_contribute_token_candidates() { + let config_id = compute_config_pda(AMM_PROGRAM); + let config = config_account(); + let definition = AccountId::new([2; 32]); + let wrong_owner_holding = account( + [99; 8], + Data::from(&TokenHolding::Fungible { + definition_id: definition, + balance: 9, }), ); + let wallet_accounts = vec![account_read(AccountId::new([3; 32]), &wrong_owner_holding)]; + + let manifest = token_ids(TokenIdsRequest { + amm_program_id: amm_program_id(), + config: account_read(config_id, &config), + wallet_accounts: wallet_accounts.clone(), + configured_token_ids: Vec::new(), + recent_token_ids: Vec::new(), + resolved_token_ids: Vec::new(), + }) + .unwrap(); + assert_eq!(manifest["tokenIds"], json!([])); + 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)), + amm_program_id: amm_program_id(), wallet_available: true, config: account_read(config_id, &config), + wallet_accounts, + token_definitions: vec![account_read( + definition, + &token_definition("Token", 1_000_000), + )], + configured_token_ids: Vec::new(), + recent_token_ids: Vec::new(), + resolved_token_ids: Vec::new(), + }) + .unwrap(); + assert_eq!(value["tokens"], json!([])); + } + + #[test] + fn context_selects_tokens_without_holdings() { + let token_id = AccountId::new([3; 32]); + let config_id = compute_config_pda(AMM_PROGRAM); + let value = context(ContextRequest { + network_id: String::from("testnet"), + network_fingerprint: String::from("block10:abc"), + amm_program_id: amm_program_id(), + wallet_available: true, + config: account_read(config_id, &config_account()), wallet_accounts: Vec::new(), - token_definitions: vec![account_read(token_id, &definition)], + token_definitions: vec![account_read( + token_id, + &token_definition("Token", 1_000_000), + )], 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_eq!(value["tokens"][0]["sources"], json!(["config"])); assert!(value["tokens"][0].get("holdingId").is_none()); } @@ -2402,17 +2467,8 @@ mod tests { #[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(); + let scenario = Scenario::devnet(); + let quote_value = scenario.quote(); assert_eq!(quote_value["status"], "ok"); assert_eq!(quote_value["poolStatus"], "missing_pool"); assert_eq!(quote_value["canSubmit"], true); @@ -2420,17 +2476,7 @@ mod tests { 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(); + let plan_value = scenario.plan(quote_hash, Some(default_read(fresh_lp))); 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)); @@ -2441,22 +2487,43 @@ mod tests { } #[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(); + fn missing_pool_plan_rejects_fresh_lp_account_collision() { + let scenario = Scenario::devnet(); + let pool = scenario.pair.pool; + let quote_value = scenario.quote(); + let quote_hash = quote_value["quoteHash"].as_str().unwrap().to_owned(); - let mut advanced = snapshot; - advanced.clock = account_read( - pair.clock, + let plan_value = scenario.plan(quote_hash, Some(default_read(pool))); + + assert_eq!(plan_value["status"], "error"); + assert_eq!(plan_value["code"], "wallet_submission_failed"); + } + + #[test] + fn missing_pool_quote_accepts_large_direct_raw_amounts() { + let mut scenario = Scenario::devnet(); + let amount_a = 100_000_000; + let amount_b = 150_000_000; + scenario.request.amount_a_raw = Some(amount_a.to_string()); + scenario.request.amount_b_raw = Some(amount_b.to_string()); + scenario.request.initial_price_real_raw = + Some(spot_price_q64_64(amount_a, amount_b).to_string()); + + let quote_value = scenario.quote(); + + assert_eq!(quote_value["status"], "ok"); + assert_eq!(quote_value["actualAmountARaw"], amount_a.to_string()); + assert_eq!(quote_value["actualAmountBRaw"], amount_b.to_string()); + assert!(quote_value.get("depositScaleBps").is_none()); + } + + #[test] + fn advancing_clock_does_not_stale_quote() { + let mut scenario = Scenario::testnet(); + let quote_value = scenario.quote(); + + scenario.snapshot.clock = account_read( + scenario.pair.clock, &account( [44; 8], Data::try_from( @@ -2469,25 +2536,18 @@ mod tests { .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(); + let plan_value = scenario.plan( + quote_value["quoteHash"].as_str().unwrap(), + Some(default_read(AccountId::new([63; 32]))), + ); 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 mut scenario = Scenario::testnet(); + let pair = scenario.pair; let pool = PoolDefinition { definition_token_a_id: pair.token_a, definition_token_b_id: pair.token_b, @@ -2499,10 +2559,12 @@ mod tests { 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( + scenario.snapshot.pool = account_read(pair.pool, &account(AMM_PROGRAM, Data::from(&pool))); + scenario.snapshot.vault_a = + account_read(pair.vault_a, &token_holding(pair.token_a, pool.reserve_a)); + scenario.snapshot.vault_b = + account_read(pair.vault_b, &token_holding(pair.token_b, pool.reserve_b)); + scenario.snapshot.lp_definition = account_read( pair.lp_definition, &account( TOKEN_PROGRAM, @@ -2514,7 +2576,7 @@ mod tests { }), ), ); - snapshot.current_tick = account_read( + scenario.snapshot.current_tick = account_read( pair.current_tick, &account( TWAP_PROGRAM, @@ -2524,44 +2586,37 @@ mod tests { }), ), ); + scenario.snapshot.wallet_accounts = vec![ + account_read( + AccountId::new([61; 32]), + &token_holding(pair.token_a, 1_000), + ), + account_read( + AccountId::new([62; 32]), + &token_holding(pair.token_b, 2_000), + ), + ]; let lp_holding = AccountId::new([64; 32]); - snapshot.wallet_accounts.push(account_read( + scenario.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); + scenario.request.initial_price_real_raw = None; + scenario.request.max_amount_a_raw = Some(String::from("1000")); + scenario.request.max_amount_b_raw = Some(String::from("3000")); + scenario.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(); + let quote_value = scenario.quote(); 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); + assert_eq!(quote_value["canSubmit"], true); + assert_eq!(quote_value["errors"], json!([])); - 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(); + let plan_value = scenario.plan(quote_value["quoteHash"].as_str().unwrap(), None); 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)); @@ -2571,32 +2626,13 @@ mod tests { #[test] fn matching_unfunded_quote_has_no_transaction_plan() { - let pair = ids(); - let request = request(pair); - let mut snapshot = base_snapshot(pair); - snapshot.wallet_available = false; - snapshot.wallet_accounts.clear(); - 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(); + let mut scenario = Scenario::devnet(); + scenario.snapshot.wallet_available = false; + scenario.snapshot.wallet_accounts.clear(); + let quote_value = scenario.quote(); assert_eq!(quote_value["canSubmit"], false); - 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: quote_value["quoteHash"].as_str().unwrap().to_owned(), - now_ms: 2_000, - fresh_lp: None, - }) - .unwrap(); + let plan_value = scenario.plan(quote_value["quoteHash"].as_str().unwrap(), None); assert_eq!(plan_value["status"], "error"); assert_eq!(plan_value["code"], "quote_not_submittable"); @@ -2605,18 +2641,7 @@ mod tests { #[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(); + let value = Scenario::devnet().plan("sha256:deadbeef", None); 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 index d7b7771..75e03eb 100644 --- a/apps/amm/config/networks.json +++ b/apps/amm/config/networks.json @@ -1,7 +1,18 @@ { "testnet": { "checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a", - "ammProgramId": "d0c4c0ea9384928a97b65a63fcf9641639bb802e4e91819743360708ff12101e", - "tokenDefinitionIds": [] + "ammProgramId": "77eeaa23668ad2675fb768cd7ecb1893387be464b9a51f16756006c1d307db07", + "tokenDefinitionIds": [ + "7b464ff9dd0d3bc07f7e2e0b0667ccd066d85ad12be4c79fc55687a863910aa6", + "48c81cf032e601ca367fc9816b957dbf5c0e4c11cf7008e8f4581ec1a67aab42", + "159caef810ea545951b3bd913efe625ee45008c80865c330e72a72ed48b61649", + "75f33110b185717209e3955f228d4a4448801d0ce8ba438a4a268050eeff3f44", + "fbd107ca4bb66bc58f59ac2d32a759be3ee0fb453f8fecd1991c11837d9660c7", + "5547fcb72644d95a385d313b887a96be41ff263bce6150b49fd87276839822bf", + "fa43e74a97d79c5f907ff3edabda5ad89bfbd3b0922572e675d4ad3c7b6029c7", + "4f3231d8a01e1d79f163bc27fce0c860a4a2f6890280e9d135eafbde0d68ed79", + "fa32f354408857006f8ea396b0419823bd04436eadb2d273d2618a46b4793ed8", + "00fe99e4fbd4c71f92e47c384c6235244c8cce39b6d6367e1e338eca0ffe01cb" + ] } } diff --git a/apps/amm/flake.lock b/apps/amm/flake.lock index 7e483ac..f5272d3 100644 --- a/apps/amm/flake.lock +++ b/apps/amm/flake.lock @@ -14468,11 +14468,11 @@ ] }, "locked": { - "lastModified": 1782082589, - "narHash": "sha256-Qeqxp0HYb3oTpmfD5YlFPJzpAJa7Ilb9o4sMeVvmHRI=", + "lastModified": 1782920676, + "narHash": "sha256-Vb81kiYbi8yYDZbUSW6v7QhV6uO/pj7F+lCAW1coAsY=", "owner": "logos-co", "repo": "logos-protocol", - "rev": "315a3a2e0af61bc47aad5601ee44cd7689975820", + "rev": "d7ad26d369c4e464a99f2a357f10c5947c7174e1", "type": "github" }, "original": { @@ -26810,7 +26810,8 @@ "inputs": { "amm_client": "amm_client", "logos-module-builder": "logos-module-builder", - "logos_execution_zone": "logos_execution_zone" + "logos_execution_zone": "logos_execution_zone", + "shared_wallet": "shared_wallet" } }, "rust-overlay": { @@ -26896,6 +26897,18 @@ "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/metadata.json b/apps/amm/metadata.json index fc147f6..f0e663c 100644 --- a/apps/amm/metadata.json +++ b/apps/amm/metadata.json @@ -11,8 +11,8 @@ "nix": { "packages": { - "build": [], - "runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"] + "build": ["pkg-config"], + "runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp", "libbase58"] }, "external_libraries": [ { "name": "amm_client" } diff --git a/apps/amm/qml/Main.qml b/apps/amm/qml/Main.qml index 3350071..4dbc205 100644 --- a/apps/amm/qml/Main.qml +++ b/apps/amm/qml/Main.qml @@ -45,7 +45,7 @@ Item { height: show ? 32 : 0 visible: height > 0 clip: true - color: Theme.palette.error + color: Theme.palette.warning Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } @@ -56,7 +56,7 @@ Item { elide: Text.ElideMiddle font.pixelSize: 12 font.weight: Font.Medium - color: Theme.palette.text + color: Theme.palette.background text: qsTr("Unable to connect to network") } } @@ -88,12 +88,8 @@ Item { LiquidityPage { anchors.fill: parent backend: root.ready ? root.backend : null + runtime: logos visible: navbar.currentIndex === 1 } - - CreatePoolPage { - anchors.fill: parent - visible: navbar.currentIndex === 2 - } } } diff --git a/apps/amm/qml/NavBar.qml b/apps/amm/qml/NavBar.qml index aa85a39..3c06f91 100644 --- a/apps/amm/qml/NavBar.qml +++ b/apps/amm/qml/NavBar.qml @@ -10,7 +10,7 @@ Item { id: root property int currentIndex: 0 - readonly property var tabs: ["Trade", "Liquidity", "Create Pool"] + readonly property var tabs: ["Trade", "Liquidity"] // Wallet wiring, passed down from Main.qml. property var backend: null diff --git a/apps/amm/qml/components/liquidity/AddLiquidityForm.qml b/apps/amm/qml/components/liquidity/AddLiquidityForm.qml deleted file mode 100644 index db8c59b..0000000 --- a/apps/amm/qml/components/liquidity/AddLiquidityForm.qml +++ /dev/null @@ -1,222 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 -import "../shared" -import "../../state" - -Rectangle { - id: root - - required property DummyPoolState poolState - - property real slippageTolerancePercent: 0.5 - property string amountA: "" - property string amountB: "" - property string lastEditedToken: "A" - readonly property real parsedA: root.poolState.parseAmount(root.amountA) - readonly property real parsedB: root.poolState.parseAmount(root.amountB) - readonly property var preview: root.poolState.addLiquidityPreview(root.parsedA, root.parsedB) - readonly property int minLpReceived: root.poolState.minReceivedAmount(root.preview.deltaLp, root.slippageTolerancePercent) - readonly property bool hasAnyAmount: root.parsedA > 0 || root.parsedB > 0 - readonly property bool amountAOverBalance: root.parsedA > root.poolState.walletBalanceA - readonly property bool amountBOverBalance: root.parsedB > root.poolState.walletBalanceB - readonly property bool minReceivedIsZero: root.hasAnyAmount && root.minLpReceived === 0 - readonly property bool zeroTokenDeposit: root.hasAnyAmount && (root.preview.actualA === 0 || root.preview.actualB === 0) - readonly property bool zeroLpDeposit: root.preview.actualA > 0 && root.preview.actualB > 0 && root.preview.deltaLp === 0 - readonly property bool canSubmit: root.hasAnyAmount && !root.amountAOverBalance && !root.amountBOverBalance && !root.minReceivedIsZero && !root.zeroTokenDeposit && !root.zeroLpDeposit - readonly property string submitButtonText: !root.hasAnyAmount ? qsTr("Enter an amount") : root.amountAOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenA) : root.amountBOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenB) : root.zeroTokenDeposit ? qsTr("Amount rounds to zero") : root.zeroLpDeposit ? qsTr("LP output is 0") : root.minReceivedIsZero ? qsTr("Minimum received is 0") : qsTr("Add Liquidity") - readonly property string warningText: root.zeroTokenDeposit ? qsTr("Deposit would be rejected because one token amount rounds to zero") : root.zeroLpDeposit ? qsTr("Deposit would mint 0 LP tokens") : "" - - signal slippageToleranceChangeRequested(real tolerancePercent) - signal addLiquidityRequested(var snapshot) - - color: "#00000000" - implicitHeight: content.implicitHeight - radius: 0 - border.width: 0 - - ColumnLayout { - id: content - - anchors.fill: parent - spacing: 10 - - TokenAmountInput { - balance: root.poolState.formatTokenAmount(root.poolState.walletBalanceA, root.poolState.tokenA) - errorText: root.amountAOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenA) : "" - helperText: root.lastEditedToken === "B" && root.amountA.length > 0 ? qsTr("Calculated from current pool ratio") : "" - label: qsTr("Token A amount") - token: root.poolState.tokenA - text: root.amountA - - Layout.fillWidth: true - - onEditingChanged: function (value) { - root.updateFromTokenA(value); - } - onMaxClicked: root.useMax("A") - } - - TokenAmountInput { - balance: root.poolState.formatTokenAmount(root.poolState.walletBalanceB, root.poolState.tokenB) - errorText: root.amountBOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenB) : "" - helperText: root.lastEditedToken === "A" && root.amountB.length > 0 ? qsTr("Calculated from current pool ratio") : "" - label: qsTr("Token B amount") - token: root.poolState.tokenB - text: root.amountB - - Layout.fillWidth: true - - onEditingChanged: function (value) { - root.updateFromTokenB(value); - } - onMaxClicked: root.useMax("B") - } - - SummaryRow { - label: qsTr("Current price") - value: qsTr("1 %1 = %2 %3").arg(root.poolState.tokenB).arg(root.poolState.formatInteger(root.poolState.tokenAPerTokenB)).arg(root.poolState.tokenA) - - Layout.fillWidth: true - } - - SummaryRow { - estimated: true - estimateHelp: qsTr("Estimated with the same integer floor math used by the add-liquidity contract path.") - label: qsTr("Estimated LP tokens") - value: root.poolState.formatLpAmount(root.preview.deltaLp) - visible: root.hasAnyAmount - - Layout.fillWidth: true - } - - SlippageToleranceControl { - tolerancePercent: root.slippageTolerancePercent - - Layout.fillWidth: true - - onToleranceChangeRequested: function (tolerancePercent) { - root.slippageToleranceChangeRequested(tolerancePercent); - } - } - - SummaryRow { - label: qsTr("Min LP received") - value: root.poolState.formatLpAmount(root.minLpReceived) - visible: root.hasAnyAmount - - Layout.fillWidth: true - } - - Text { - color: "#F08A76" - font.pixelSize: 12 - lineHeight: 1.25 - text: qsTr("Minimum received is 0. Increase amount or lower slippage.") - visible: root.minReceivedIsZero - wrapMode: Text.WordWrap - - Layout.fillWidth: true - } - - Text { - color: "#F08A76" - font.pixelSize: 12 - lineHeight: 1.25 - text: root.warningText - visible: root.warningText.length > 0 - wrapMode: Text.WordWrap - - Layout.fillWidth: true - } - - Button { - id: submitButton - - activeFocusOnTab: true - enabled: root.canSubmit - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: root.submitButtonText - - Accessible.name: submitButton.text - - Layout.fillWidth: true - Layout.minimumHeight: 44 - Layout.preferredHeight: 44 - - onClicked: root.addLiquidityRequested(root.submitSnapshot()) - - contentItem: Text { - color: submitButton.enabled ? "#151515" : "#7D756E" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 13 - horizontalAlignment: Text.AlignHCenter - text: submitButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: submitButton.enabled ? "#F26A21" : "#343434" - border.width: 1 - color: submitButton.enabled ? submitButton.pressed ? "#D95C1E" : submitButton.hovered || submitButton.activeFocus ? "#FF8A3D" : "#F26A21" : "#181818" - radius: 6 - } - } - } - - function setAmounts(nextA, nextB, intentToken, showZero) { - root.lastEditedToken = intentToken; - root.amountA = nextA > 0 || showZero ? root.poolState.formatInputAmount(nextA) : ""; - root.amountB = nextB > 0 || showZero ? root.poolState.formatInputAmount(nextB) : ""; - } - - function updateFromTokenA(value) { - if (value.length === 0) { - setAmounts(0, 0, "A", false); - return; - } - - const nextA = root.poolState.parseAmount(value); - setAmounts(nextA, root.poolState.amountBForA(nextA), "A", true); - } - - function updateFromTokenB(value) { - if (value.length === 0) { - setAmounts(0, 0, "B", false); - return; - } - - const nextB = root.poolState.parseAmount(value); - setAmounts(root.poolState.amountAForB(nextB), nextB, "B", true); - } - - function useMax(intentToken) { - const capped = root.poolState.maxAddLiquidityForBalances(); - setAmounts(capped.actualA, capped.actualB, intentToken, false); - } - - function resetForm() { - root.amountA = ""; - root.amountB = ""; - root.lastEditedToken = "A"; - } - - function submitSnapshot() { - return { - "action": "add", - "actualA": root.preview.actualA, - "actualB": root.preview.actualB, - "currentRatio": qsTr("1 %1 = %2 %3").arg(root.poolState.tokenB).arg(root.poolState.formatInteger(root.poolState.tokenAPerTokenB)).arg(root.poolState.tokenA), - "deltaLp": root.preview.deltaLp, - "depositA": root.poolState.formatTokenAmount(root.preview.actualA, root.poolState.tokenA), - "depositB": root.poolState.formatTokenAmount(root.preview.actualB, root.poolState.tokenB), - "feeTier": root.poolState.feeTier, - "minLpReceived": root.poolState.formatLpAmount(root.minLpReceived), - "slippageTolerance": root.poolState.formatPercent(root.slippageTolerancePercent), - "tokenA": root.poolState.tokenA, - "tokenB": root.poolState.tokenB - }; - } -} diff --git a/apps/amm/qml/components/liquidity/AmmActionCard.qml b/apps/amm/qml/components/liquidity/AmmActionCard.qml new file mode 100644 index 0000000..d8f3151 --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmActionCard.qml @@ -0,0 +1,15 @@ +import QtQuick + +Rectangle { + required property var theme + + implicitWidth: 480 + radius: 24 + color: theme.colors.cardBg + border.color: theme.colors.border + border.width: 1 + + Behavior on color { + ColorAnimation { duration: 180 } + } +} diff --git a/apps/amm/qml/components/liquidity/AmmPairSeparator.qml b/apps/amm/qml/components/liquidity/AmmPairSeparator.qml new file mode 100644 index 0000000..8c578ca --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmPairSeparator.qml @@ -0,0 +1,61 @@ +import QtQuick +import QtQuick.Controls + +Item { + id: root + + required property var theme + property string symbol: "\u2193" + property string accessibleName: qsTr("Swap token order") + + signal clicked + + implicitHeight: 40 + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: root.theme.colors.divider + } + + Button { + id: button + + anchors.centerIn: parent + width: 36 + height: 36 + hoverEnabled: true + enabled: root.enabled + + Accessible.name: root.accessibleName + ToolTip.visible: hovered + ToolTip.text: Accessible.name + + onClicked: root.clicked() + + contentItem: Text { + text: root.symbol + color: button.enabled + ? root.theme.colors.textPrimary + : root.theme.colors.textPlaceholder + font.pixelSize: 16 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 18 + color: button.hovered || button.activeFocus + ? root.theme.colors.panelHoverBg + : root.theme.colors.panelBg + border.color: root.theme.colors.borderStrong + border.width: 1 + + Behavior on color { + ColorAnimation { duration: 120 } + } + } + } +} diff --git a/apps/amm/qml/components/liquidity/AmmPrimaryButton.qml b/apps/amm/qml/components/liquidity/AmmPrimaryButton.qml new file mode 100644 index 0000000..f75f3d1 --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmPrimaryButton.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtQuick.Controls + +Button { + id: root + + required property var theme + + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + implicitHeight: 56 + + Accessible.name: text + + contentItem: Text { + text: root.text + color: root.enabled ? "#FFFFFF" : root.theme.colors.textSecondary + font.pixelSize: 17 + font.weight: Font.Medium + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + radius: 20 + color: !root.enabled + ? root.theme.colors.panelBg + : root.pressed + ? root.theme.colors.ctaPressedBg + : root.hovered || root.activeFocus + ? root.theme.colors.ctaHoverBg + : root.theme.colors.ctaBg + border.color: root.activeFocus && root.enabled + ? root.theme.colors.textPrimary : "transparent" + border.width: 1 + + Behavior on color { + ColorAnimation { duration: 120 } + } + } +} diff --git a/apps/amm/qml/components/liquidity/AmmTheme.qml b/apps/amm/qml/components/liquidity/AmmTheme.qml new file mode 100644 index 0000000..06a847b --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmTheme.qml @@ -0,0 +1,50 @@ +import QtQml + +QtObject { + property bool isDark: true + readonly property var colors: isDark ? dark : light + + readonly property var light: ({ + "background": "#F4EDE3", + "cardBg": "#FFFFFF", + "inputBg": "#EFE7DB", + "panelBg": "#E7E1D8", + "panelHoverBg": "#D9D0C2", + "textPrimary": "#151515", + "textSecondary": "#7D756E", + "textPlaceholder": "#A9A098", + "border": Qt.rgba(0, 0, 0, 0.08), + "borderStrong": Qt.rgba(0, 0, 0, 0.10), + "divider": Qt.rgba(0, 0, 0, 0.06), + "ctaBg": "#F26A21", + "ctaHoverBg": "#D95C1E", + "ctaPressedBg": "#C85018", + "selection": "#F2D8C7", + "noTokenCircle": "#A9A098", + "success": "#4F9B64", + "warning": "#B8732A", + "error": "#D85F4B" + }) + + readonly property var dark: ({ + "background": "#151515", + "cardBg": "#1B1B1B", + "inputBg": "#101010", + "panelBg": "#181818", + "panelHoverBg": "#202020", + "textPrimary": "#E7E1D8", + "textSecondary": "#A9A098", + "textPlaceholder": "#8E8780", + "border": Qt.rgba(1, 1, 1, 0.08), + "borderStrong": Qt.rgba(1, 1, 1, 0.10), + "divider": Qt.rgba(1, 1, 1, 0.06), + "ctaBg": "#F26A21", + "ctaHoverBg": "#FF8A3D", + "ctaPressedBg": "#D95C1E", + "selection": "#211914", + "noTokenCircle": "#343434", + "success": "#78C88D", + "warning": "#F2B366", + "error": "#F08A76" + }) +} diff --git a/apps/amm/qml/components/liquidity/AmmTokenAccessory.qml b/apps/amm/qml/components/liquidity/AmmTokenAccessory.qml new file mode 100644 index 0000000..df3919e --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmTokenAccessory.qml @@ -0,0 +1,46 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property var theme + property bool hasToken: false + property string tokenColor: root.theme.colors.noTokenCircle + property string tokenLetter: "" + property string tokenText: qsTr("Select token") + property string balance: "" + property string accessibleName: qsTr("Select token") + property bool invalid: false + + signal clicked + + spacing: 2 + + Text { + Layout.fillWidth: true + visible: root.balance.length > 0 + text: qsTr("Balance %1").arg(root.balance) + color: root.theme.colors.textSecondary + font.pixelSize: 10 + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + } + + AmmTokenSelectButton { + objectName: "tokenSelectButton" + Layout.fillWidth: true + theme: root.theme + enabled: root.enabled + invalid: root.invalid + hasToken: root.hasToken + tokenColor: root.tokenColor + tokenLetter: root.tokenLetter + text: root.tokenText + maximumTextWidth: 112 + Accessible.name: root.accessibleName + onClicked: root.clicked() + } +} diff --git a/apps/amm/qml/components/liquidity/AmmTokenAmountSurface.qml b/apps/amm/qml/components/liquidity/AmmTokenAmountSurface.qml new file mode 100644 index 0000000..39ccd5d --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmTokenAmountSurface.qml @@ -0,0 +1,195 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: root + + required property var theme + property string label: "" + property string amount: "" + property string supportingText: "" + property string errorText: "" + property string supportingActionText: "" + property bool readOnly: false + property bool muted: false + property bool invalid: root.errorText.length > 0 + property Component accessory + property real accessoryWidth: 0 + property real accessoryHeight: 0 + property Component adjustment + property real adjustmentWidth: 0 + property real adjustmentHeight: 0 + + signal amountEdited(string value) + signal amountEditingFinished(string value) + signal supportingActionClicked + + implicitHeight: Math.max(110, contentRow.implicitHeight + 24) + radius: 16 + color: root.muted ? root.theme.colors.panelBg : root.theme.colors.inputBg + border.color: root.invalid + ? root.theme.colors.error + : amountInput.activeFocus + ? root.theme.colors.ctaBg + : "transparent" + border.width: 1 + + Binding { + target: amountInput + property: "text" + value: root.amount + } + + RowLayout { + id: contentRow + + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.topMargin: 12 + anchors.bottomMargin: 12 + spacing: 10 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: root.label + color: root.theme.colors.textSecondary + font.pixelSize: 13 + elide: Text.ElideRight + Layout.fillWidth: true + } + + Text { + Layout.fillWidth: true + visible: root.errorText.length > 0 + text: root.errorText + color: root.theme.colors.error + font.pixelSize: 11 + wrapMode: Text.Wrap + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 44 + + TextInput { + id: amountInput + + anchors.fill: parent + readOnly: root.readOnly + color: root.readOnly || root.muted + ? root.theme.colors.textSecondary + : root.theme.colors.textPrimary + font.pixelSize: { + var length = Math.max(1, String(root.amount || "0").length) + return Math.max(14, Math.min(34, Math.floor(width / (length * 0.68)))) + } + font.weight: Font.Bold + horizontalAlignment: Text.AlignLeft + selectionColor: root.theme.colors.selection + selectedTextColor: root.theme.colors.textPrimary + clip: true + inputMethodHints: Qt.ImhFormattedNumbersOnly + maximumLength: 80 + validator: RegularExpressionValidator { + regularExpression: /^[0-9]*\.?[0-9]*$/ + } + + Accessible.name: root.label + + onTextEdited: { + if (!root.readOnly) + root.amountEdited(text) + } + onEditingFinished: { + if (!root.readOnly) + root.amountEditingFinished(text) + } + } + + Text { + anchors.fill: parent + text: "0" + color: root.theme.colors.textPlaceholder + font: amountInput.font + visible: amountInput.text === "" && !amountInput.activeFocus + verticalAlignment: Text.AlignVCenter + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Loader { + active: root.adjustment !== null + visible: active + sourceComponent: root.adjustment + Layout.preferredWidth: active ? root.adjustmentWidth : 0 + Layout.preferredHeight: active ? root.adjustmentHeight : 0 + } + + Button { + id: supportingAction + + visible: root.supportingActionText.length > 0 + enabled: visible && !root.readOnly + implicitWidth: 40 + implicitHeight: 24 + text: root.supportingActionText + hoverEnabled: true + Accessible.name: text + onClicked: root.supportingActionClicked() + + contentItem: Text { + text: supportingAction.text + color: supportingAction.enabled + ? root.theme.colors.ctaBg + : root.theme.colors.textPlaceholder + font.pixelSize: 11 + font.weight: Font.Bold + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 6 + color: supportingAction.hovered || supportingAction.activeFocus + ? root.theme.colors.selection : "transparent" + } + } + + Text { + Layout.fillWidth: true + text: root.supportingText + color: root.theme.colors.textSecondary + font.pixelSize: 11 + horizontalAlignment: root.adjustment !== null + || root.supportingActionText.length > 0 + ? Text.AlignRight : Text.AlignLeft + elide: Text.ElideRight + visible: text.length > 0 + } + } + } + + Loader { + sourceComponent: root.accessory + visible: status === Loader.Ready + Layout.alignment: Qt.AlignTop + Layout.topMargin: 17 + Layout.preferredWidth: root.accessoryWidth + Layout.preferredHeight: root.accessoryHeight + } + } + + Behavior on color { + ColorAnimation { duration: 180 } + } +} diff --git a/apps/amm/qml/components/liquidity/AmmTokenSelectButton.qml b/apps/amm/qml/components/liquidity/AmmTokenSelectButton.qml new file mode 100644 index 0000000..859af85 --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmTokenSelectButton.qml @@ -0,0 +1,80 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Button { + id: root + + required property var theme + property bool hasToken: false + property string tokenColor: root.theme.colors.noTokenCircle + property string tokenLetter: "" + property bool showIndicator: true + property bool invalid: false + property real maximumTextWidth: 112 + + implicitWidth: contentRow.implicitWidth + 24 + implicitHeight: 40 + leftPadding: 12 + rightPadding: 12 + hoverEnabled: true + + contentItem: RowLayout { + id: contentRow + + spacing: 6 + + Rectangle { + Layout.preferredWidth: 24 + Layout.preferredHeight: 24 + visible: root.hasToken + radius: 12 + color: root.tokenColor + + Text { + anchors.centerIn: parent + text: root.tokenLetter + color: "#FFFFFF" + font.pixelSize: 10 + font.weight: Font.Bold + } + } + + Text { + Layout.maximumWidth: root.maximumTextWidth + text: root.text + color: root.enabled + ? root.theme.colors.textPrimary + : root.theme.colors.textPlaceholder + font.pixelSize: 15 + font.weight: root.hasToken ? Font.Medium : Font.Normal + elide: Text.ElideRight + } + + Text { + visible: root.showIndicator + text: "\u25BE" + color: root.enabled + ? root.theme.colors.textSecondary + : root.theme.colors.textPlaceholder + font.pixelSize: 10 + } + } + + background: Rectangle { + radius: 20 + color: root.pressed + ? root.theme.colors.selection + : root.hovered || root.activeFocus + ? root.theme.colors.panelHoverBg + : root.theme.colors.panelBg + border.width: root.invalid || root.activeFocus ? 1 : 0 + border.color: root.invalid ? root.theme.colors.error : root.theme.colors.ctaBg + + Behavior on color { + ColorAnimation { duration: 120 } + } + } +} diff --git a/apps/amm/qml/components/liquidity/AmountMath.js b/apps/amm/qml/components/liquidity/AmountMath.js index 5ce9f02..2779ef6 100644 --- a/apps/amm/qml/components/liquidity/AmountMath.js +++ b/apps/amm/qml/components/liquidity/AmountMath.js @@ -113,26 +113,17 @@ function divide(left, right) { 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) { +function increment(value) { 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) + for (var i = digits.length - 1; i >= 0; --i) { + if (digits[i] !== "9") { + digits[i] = String(Number(digits[i]) + 1) + return digits.join("") + } + digits[i] = "0" } - while (carry > 0) { - digits.unshift(String(carry % 10)) - carry = Math.floor(carry / 10) - } - return normalize(digits.join("")) + digits.unshift("1") + return digits.join("") } function pow10(exponent) { @@ -172,6 +163,17 @@ function parseHuman(text, decimals) { return { "ok": true, "code": "", "raw": raw } } +function trimHumanPrecision(text, decimals) { + var value = String(text) + var match = /^(0|[1-9][0-9]*)(?:\.([0-9]*))?$/.exec(value) + if (!match) + return value + var fraction = match[2] || "" + if (fraction.length <= decimals) + return value + return decimals > 0 ? match[1] + "." + fraction.slice(0, decimals) : match[1] +} + function formatRaw(rawValue, decimals) { if (!isUnsigned(rawValue)) return "" @@ -204,19 +206,36 @@ function parsePrice(text) { } } -function priceToQ64(text, canonicalDecimalsA, canonicalDecimalsB, displayIsCanonical) { - var parsed = parsePrice(text) - if (!parsed.ok) - return { "ok": false, "code": parsed.code, "raw": "" } +function parseRatio(amountA, amountB) { + var parsedA = parsePrice(amountA) + if (!parsedA.ok) + return { "ok": false, "code": parsedA.code } + var parsedB = parsePrice(amountB) + if (!parsedB.ok) + return { "ok": false, "code": parsedB.code } + + return { + "ok": true, + "code": "", + "numerator": multiply(parsedB.numerator, pow10(parsedA.scale)), + "denominator": multiply(parsedA.numerator, pow10(parsedB.scale)) + } +} + +function ratioToQ64(amountA, amountB, canonicalDecimalsA, canonicalDecimalsB, + displayIsCanonical) { + var ratio = parseRatio(amountA, amountB) + if (!ratio.ok) + return { "ok": false, "code": ratio.code, "raw": "" } var numerator var denominator if (displayIsCanonical) { - numerator = multiply(multiply(parsed.numerator, Q64), pow10(canonicalDecimalsB)) - denominator = pow10(parsed.scale + canonicalDecimalsA) + numerator = multiply(multiply(ratio.numerator, Q64), pow10(canonicalDecimalsB)) + denominator = multiply(ratio.denominator, pow10(canonicalDecimalsA)) } else { - numerator = multiply(multiply(pow10(parsed.scale), Q64), pow10(canonicalDecimalsB)) - denominator = multiply(parsed.numerator, pow10(canonicalDecimalsA)) + numerator = multiply(multiply(ratio.denominator, Q64), pow10(canonicalDecimalsB)) + denominator = multiply(ratio.numerator, pow10(canonicalDecimalsA)) } var raw = divide(numerator, denominator).quotient if (raw === "0") @@ -226,6 +245,39 @@ function priceToQ64(text, canonicalDecimalsA, canonicalDecimalsB, displayIsCanon return { "ok": true, "code": "", "raw": raw } } +function pairAmount(raw, fromA, decimalsA, decimalsB, priceAmountA, priceAmountB) { + if (!isUnsigned(raw)) + return { "ok": false, "code": "invalid_raw_amount", "raw": "" } + var ratio = parseRatio(priceAmountA, priceAmountB) + if (!ratio.ok) + return { "ok": false, "code": ratio.code, "raw": "" } + + var numerator + var denominator + if (fromA) { + numerator = multiply(multiply(raw, ratio.numerator), pow10(decimalsB)) + denominator = multiply(ratio.denominator, pow10(decimalsA)) + } else { + numerator = multiply(multiply(raw, ratio.denominator), pow10(decimalsA)) + denominator = multiply(ratio.numerator, pow10(decimalsB)) + } + var result = divide(numerator, denominator) + if (!result.valid) + return { "ok": false, "code": "invalid_raw_amount", "raw": "" } + var rounded = compare(multiply(result.remainder, "2"), denominator) >= 0 + ? increment(result.quotient) : result.quotient + if (rounded === "0") + return { "ok": false, "code": "amount_too_low", "raw": "" } + if (compare(rounded, U128_MAX) > 0) + return { "ok": false, "code": "invalid_raw_amount", "raw": "" } + return { "ok": true, "code": "", "raw": rounded } +} + +function ratioValue(amountA, amountB, precision) { + var ratio = parseRatio(amountA, amountB) + return ratio.ok ? formatRatio(ratio.numerator, ratio.denominator, precision) : "" +} + function formatRatio(numerator, denominator, precision) { var result = divide(numerator, denominator) if (!result.valid) @@ -260,10 +312,6 @@ 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 diff --git a/apps/amm/qml/components/liquidity/LiquidityActionTabs.qml b/apps/amm/qml/components/liquidity/LiquidityActionTabs.qml deleted file mode 100644 index 5a7c1d1..0000000 --- a/apps/amm/qml/components/liquidity/LiquidityActionTabs.qml +++ /dev/null @@ -1,91 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 - -Rectangle { - id: root - - property int currentIndex: 0 - - signal tabRequested(int index) - - color: "#181818" - implicitHeight: 42 - radius: 8 - border.color: "#303030" - border.width: 1 - - RowLayout { - anchors.fill: parent - anchors.margins: 4 - spacing: 4 - - Button { - id: addTab - - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Add liquidity") - - Accessible.name: addTab.text - Accessible.role: Accessible.PageTab - - Layout.fillHeight: true - Layout.fillWidth: true - - onClicked: root.tabRequested(0) - - contentItem: Text { - color: root.currentIndex === 0 ? "#F2D8C7" : addTab.hovered || addTab.activeFocus ? "#E7E1D8" : "#8E8780" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 12 - horizontalAlignment: Text.AlignHCenter - text: addTab.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: addTab.activeFocus || root.currentIndex === 0 ? "#F26A21" : "#181818" - border.width: 1 - color: addTab.pressed ? "#2A1D16" : root.currentIndex === 0 ? "#211914" : addTab.hovered || addTab.activeFocus ? "#202020" : "#121212" - radius: 6 - } - } - - Button { - id: removeTab - - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Remove liquidity") - - Accessible.name: removeTab.text - Accessible.role: Accessible.PageTab - - Layout.fillHeight: true - Layout.fillWidth: true - - onClicked: root.tabRequested(1) - - contentItem: Text { - color: root.currentIndex === 1 ? "#F2D8C7" : removeTab.hovered || removeTab.activeFocus ? "#E7E1D8" : "#8E8780" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 12 - horizontalAlignment: Text.AlignHCenter - text: removeTab.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: removeTab.activeFocus || root.currentIndex === 1 ? "#F26A21" : "#181818" - border.width: 1 - color: removeTab.pressed ? "#2A1D16" : root.currentIndex === 1 ? "#211914" : removeTab.hovered || removeTab.activeFocus ? "#202020" : "#121212" - radius: 6 - } - } - } -} diff --git a/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml index 68af40d..cbcc3b6 100644 --- a/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml +++ b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml @@ -5,87 +5,47 @@ ColumnLayout { id: root property var snapshot: ({}) - readonly property bool isAdd: root.snapshot.action === "add" spacing: 8 - ColumnLayout { - Layout.fillWidth: true - spacing: 8 - visible: root.isAdd - - SummaryRow { - Layout.fillWidth: true - label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "") - value: root.snapshot.depositA || "" - } - - SummaryRow { - Layout.fillWidth: true - label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "") - value: root.snapshot.depositB || "" - } - - SummaryRow { - Layout.fillWidth: true - label: qsTr("Receive at least") - value: root.snapshot.minLpReceived || "" - } - - SummaryRow { - Layout.fillWidth: true - label: qsTr("Current ratio") - value: root.snapshot.currentRatio || "" - } - - SummaryRow { - Layout.fillWidth: true - label: qsTr("Fee tier") - value: root.snapshot.feeTier || "" - } - - SummaryRow { - Layout.fillWidth: true - label: qsTr("Slippage tolerance") - value: root.snapshot.slippageTolerance || "" - } + function actionText(instruction) { + if (instruction === "NewDefinition") + return qsTr("Create pool") + if (instruction === "AddLiquidity") + return qsTr("Add liquidity") + return instruction || "-" } - ColumnLayout { + SummaryRow { Layout.fillWidth: true - spacing: 8 - visible: !root.isAdd + label: qsTr("Pair") + value: root.snapshot.pairText || "-" + } - SummaryRow { - Layout.fillWidth: true - label: qsTr("Burn LP") - value: qsTr("%1 (%2)") - .arg(root.snapshot.burnText || "") - .arg(root.snapshot.burnPercent || "") - } + SummaryRow { + Layout.fillWidth: true + label: qsTr("Action") + value: root.actionText(root.snapshot.instruction) + } - SummaryRow { - Layout.fillWidth: true - label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "") - value: root.snapshot.minTokenAReceived || "" - } + SummaryRow { + Layout.fillWidth: true + label: qsTr("Fee") + value: root.snapshot.feeText || "-" + } - SummaryRow { - Layout.fillWidth: true - label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "") - value: root.snapshot.minTokenBReceived || "" - } + SummaryRow { + Layout.fillWidth: true + label: qsTr("Deposit") + value: qsTr("%1 + %2") + .arg(root.snapshot.depositAText || "-") + .arg(root.snapshot.depositBText || "-") + valueWrapAnywhere: true + } - SummaryRow { - Layout.fillWidth: true - label: qsTr("Slippage tolerance") - value: root.snapshot.slippageTolerance || "" - } - - SummaryRow { - Layout.fillWidth: true - label: qsTr("Post-removal share") - value: root.snapshot.postRemovalShare || "" - } + SummaryRow { + Layout.fillWidth: true + label: qsTr("Expected LP") + value: root.snapshot.expectedLpText || "-" } } diff --git a/apps/amm/qml/components/liquidity/NewPositionConfirmationDialog.qml b/apps/amm/qml/components/liquidity/NewPositionConfirmationDialog.qml deleted file mode 100644 index 7d460b3..0000000 --- a/apps/amm/qml/components/liquidity/NewPositionConfirmationDialog.qml +++ /dev/null @@ -1,201 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Controls -import Logos.Theme - -FocusScope { - id: root - - property var snapshot: ({}) - property bool open: false - property bool busy: false - - signal canceled - signal confirmed(var snapshot) - - visible: open - focus: open - z: 200 - - Keys.onEscapePressed: function(event) { - event.accepted = true - if (!root.busy) - root.cancel() - } - - function openWithSnapshot(nextSnapshot) { - root.snapshot = nextSnapshot || ({}) - root.open = true - root.forceActiveFocus() - } - - function cancel() { - if (root.busy) - return - root.open = false - root.canceled() - } - - function confirm() { - if (!root.busy) - root.confirmed(root.snapshot) - } - - function closeAfterSuccess() { - root.open = false - root.snapshot = ({}) - } - - function closeAfterFailure() { - root.open = false - } - - Rectangle { - anchors.fill: parent - color: "#B0000000" - - MouseArea { - anchors.fill: parent - } - } - - Rectangle { - anchors.centerIn: parent - width: Math.min(520, parent.width - 32) - implicitHeight: dialogContent.implicitHeight + 40 - radius: 8 - color: Theme.palette.backgroundElevated - border.color: Theme.palette.borderSecondary - border.width: 1 - - ColumnLayout { - id: dialogContent - - anchors.fill: parent - anchors.margins: 20 - spacing: 14 - - RowLayout { - Layout.fillWidth: true - - Text { - 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 { - Layout.fillWidth: true - spacing: 10 - - LogosButton { - text: qsTr("Cancel") - enabled: !root.busy - Layout.fillWidth: true - Layout.minimumHeight: 44 - radius: 6 - onClicked: root.cancel() - } - - LogosButton { - text: root.busy ? qsTr("Submitting…") : qsTr("Submit") - enabled: !root.busy - Layout.fillWidth: true - Layout.minimumHeight: 44 - radius: 6 - onClicked: root.confirm() - } - } - } - } - - 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 f5e4bba..d567948 100644 --- a/apps/amm/qml/components/liquidity/NewPositionForm.qml +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -6,35 +6,49 @@ import QtQuick.Layouts import Logos.Controls import Logos.Icons -import Logos.Theme +import Logos.Wallet import "AmountMath.js" as AmountMath -import "../wallet" -Rectangle { +AmmActionCard { id: root + theme: fallbackTheme + + AmmTheme { + id: fallbackTheme + } + property var newPositionContext: ({}) - property var quotePayload: ({}) - property bool contextLoading: false - property bool quoteLoading: false - property bool submitting: false - property bool quoteStale: false + property var flowState: ({}) property string selectedTokenAId: "" property string selectedTokenBId: "" property int selectedFeeBps: 30 property int slippageBps: 50 property string amountA: "" property string amountB: "" - property string initialPrice: "1" - property string depositScaleBps: "10000" - property string submitError: "" - property string transactionId: "" - property string refreshWarning: "" + property string priceAmountA: "1" + property string priceAmountB: "1" + property string minimumAmountARaw: "" + property string minimumAmountBRaw: "" property var localErrors: [] - property string priceTargetLp: "" - property int priceAdjustmentCount: 0 + property string resolvingTokenId: "" + property string resolvingTokenSide: "" + property string tokenResolutionError: "" + property string tokenResolutionErrorSide: "" + property string tokenResolutionMessage: "" + property string confirmedPoolStatus: "" + property var activePoolQuote: ({}) + readonly property var quotePayload: root.flowState.quote || ({}) + readonly property bool contextLoading: root.flowState.contextLoading === true + readonly property bool quoteLoading: root.flowState.quoteLoading === true + readonly property bool submitting: root.flowState.submitting === true + readonly property bool quoteStale: root.flowState.quoteStale === true + readonly property bool poolCreationPending: root.flowState.poolCreationPending === true + readonly property string submitError: root.flowState.errorCode + ? root.issueText(root.flowState.errorCode) : "" + readonly property string transactionId: String(root.flowState.transactionId || "") readonly property var emptyToken: ({ "definitionId": "", "name": "", @@ -53,55 +67,89 @@ Rectangle { 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 initialPrice: AmountMath.ratioValue(root.priceAmountA, + root.priceAmountB, + 12) + readonly property string inverseInitialPrice: AmountMath.ratioValue(root.priceAmountB, + root.priceAmountA, + 12) 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 poolFeeBps: root.knownPoolFeeBps() - readonly property bool compact: root.width < 640 + readonly property bool compact: root.width < 420 readonly property bool hasPair: root.selectedTokenAId.length > 0 && root.selectedTokenBId.length > 0 && root.selectedTokenAId !== root.selectedTokenBId + readonly property bool resolvingToken: root.resolvingTokenId.length > 0 readonly property bool canConfirm: root.quotePayload.schema === "new-position.v1" && root.quotePayload.status === "ok" && root.quotePayload.canSubmit === true + && root.quoteMatchesPair() && String(root.quotePayload.quoteHash || "").length > 0 && !root.contextLoading && !root.quoteLoading && !root.quoteStale && !root.submitting + && !root.poolCreationPending - signal quoteRequested(bool immediate) + signal quoteRequested(bool immediate, var quoteRequest) signal confirmationRequested(var snapshot) signal tokenResolveRequested(string tokenId) signal draftChanged signal refreshRequested - implicitHeight: content.implicitHeight + 48 - implicitWidth: 760 - radius: 8 - color: Theme.palette.backgroundSecondary - border.color: Theme.palette.borderSecondary - border.width: 1 + implicitHeight: content.implicitHeight + 32 + implicitWidth: 480 Component.onCompleted: Qt.callLater(root.ensurePair) - onNewPositionContextChanged: Qt.callLater(root.ensurePair) - onQuotePayloadChanged: Qt.callLater(root.applyQuoteSideEffects) + onNewPositionContextChanged: Qt.callLater(root.applyContextChange) + function applyContextChange() { + if (root.resolvingToken) + root.finishTokenResolution() + else + root.ensurePair() + } + onQuotePayloadChanged: { + if (root.quoteStale) + return + root.rememberPoolStatus() + root.rememberActivePoolQuote() + Qt.callLater(root.applyQuoteSideEffects) + } - TextEdit { - id: clipboardProxy - visible: false + Component { + id: priceAmountAAdjustment + + PriceRatioAdjustment { + amount: root.priceAmountA + enabled: !root.submitting + fieldName: "priceAmountAField" + invalid: root.fieldHasError("initialPrice") + onEdited: function(value) { root.editPrice("A", value) } + } + } + + Component { + id: priceAmountBAdjustment + + PriceRatioAdjustment { + amount: root.priceAmountB + enabled: !root.submitting + fieldName: "priceAmountBField" + invalid: root.fieldHasError("initialPrice") + onEdited: function(value) { root.editPrice("B", value) } + } } ColumnLayout { id: content anchors.fill: parent - anchors.margins: 24 - spacing: 18 + anchors.margins: 16 + spacing: 16 RowLayout { Layout.fillWidth: true @@ -113,15 +161,15 @@ Rectangle { Text { text: qsTr("New position") - color: Theme.palette.text - font.pixelSize: 22 + color: root.theme.colors.textPrimary + font.pixelSize: 18 font.weight: Font.DemiBold font.letterSpacing: 0 } Text { text: root.contextStatusText() - color: Theme.palette.textSecondary + color: root.theme.colors.textSecondary font.pixelSize: 12 elide: Text.ElideRight Layout.fillWidth: true @@ -135,9 +183,9 @@ Rectangle { implicitHeight: 24 } - WalletIconButton { + LogosIconButton { iconSource: LogosIcons.refresh - iconColor: Theme.palette.textSecondary + iconColor: root.theme.colors.textSecondary iconSize: 18 Layout.preferredWidth: 36 Layout.preferredHeight: 36 @@ -153,8 +201,8 @@ Rectangle { Layout.fillWidth: true implicitHeight: networkMessage.implicitHeight + 20 radius: 6 - color: Theme.palette.backgroundTertiary - border.color: Theme.palette.error + color: root.theme.colors.panelBg + border.color: root.theme.colors.error visible: root.contextBlocksForm() Text { @@ -162,137 +210,144 @@ Rectangle { anchors.fill: parent anchors.margins: 10 text: root.contextErrorText() - color: Theme.palette.text + color: root.theme.colors.textPrimary font.pixelSize: 12 wrapMode: Text.Wrap verticalAlignment: Text.AlignVCenter } } - GridLayout { + ColumnLayout { Layout.fillWidth: true - columns: root.compact ? 1 : 3 - columnSpacing: 10 - rowSpacing: 8 + spacing: 0 - LogosComboBox { - id: tokenASelector + TokenAmountInput { + id: tokenAInput + objectName: "tokenAAmountInput" Layout.fillWidth: true - Layout.minimumHeight: 48 - model: root.tokens - currentIndex: root.tokenIndex(root.selectedTokenAId) - displayText: root.tokenLabel(root.tokenA) - enabled: root.tokens.length > 0 && !root.submitting - - onActivated: function(index) { - root.selectToken("A", root.tokens[index].definitionId) + theme: root.theme + text: root.amountA + label: qsTr("Token A amount") + balance: root.contextLoading ? "" : root.balanceText(root.tokenA, root.decimalsA) + helperText: root.missingPool && !root.compact + ? root.minimumAmountText("A") : "" + errorText: root.formErrorText() + invalid: root.fieldHasError("amountA") + readOnly: root.submitting || (!root.activePool && !root.missingPool) + showMaxButton: root.activePool + tokenData: root.tokenA.definitionId ? root.tokenA : null + tokens: root.tokens + selectedTokenId: root.selectedTokenAId + tokenInvalid: root.tokenHasError("A") + tokenSelectionEnabled: !root.contextLoading && !root.submitting + adjustment: root.missingPool ? priceAmountAAdjustment : null + adjustmentWidth: root.missingPool ? (root.compact ? 100 : 142) : 0 + adjustmentHeight: root.missingPool ? 24 : 0 + disabledReasonForCode: function(code) { return root.issueText(code) } + detailForToken: function(token) { return root.tokenBalanceDetail(token) } + onEditingChanged: function(value) { + if (root.activePool) + root.editActiveAmount("A", value) + else + root.editMissingAmount("A", value) } - - 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) + onEditingCommitted: function(value) { + if (root.activePool) + root.finishActiveAmount("A", value) + else + root.finishMissingAmount("A", value) } + onMaxClicked: root.useMaximum() + onTokenSelected: function(tokenId) { root.resolveToken("A", tokenId) } + onTokenEntered: function(value) { root.resolveToken("A", value) } } - Button { - text: "↔" + AmmPairSeparator { + Layout.fillWidth: true + theme: root.theme 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 + TokenAmountInput { + id: tokenBInput + objectName: "tokenBAmountInput" 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) + theme: root.theme + text: root.amountB + label: qsTr("Token B amount") + balance: root.contextLoading ? "" : root.balanceText(root.tokenB, root.decimalsB) + helperText: root.missingPool && !root.compact + ? root.minimumAmountText("B") : "" + invalid: root.fieldHasError("amountB") + readOnly: root.submitting || (!root.activePool && !root.missingPool) + showMaxButton: root.activePool + tokenData: root.tokenB.definitionId ? root.tokenB : null + tokens: root.tokens + selectedTokenId: root.selectedTokenBId + tokenInvalid: root.tokenHasError("B") + tokenSelectionEnabled: !root.contextLoading && !root.submitting + adjustment: root.missingPool ? priceAmountBAdjustment : null + adjustmentWidth: root.missingPool ? (root.compact ? 100 : 142) : 0 + adjustmentHeight: root.missingPool ? 24 : 0 + disabledReasonForCode: function(code) { return root.issueText(code) } + detailForToken: function(token) { return root.tokenBalanceDetail(token) } + onEditingChanged: function(value) { + if (root.activePool) + root.editActiveAmount("B", value) + else + root.editMissingAmount("B", value) } - - 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) + onEditingCommitted: function(value) { + if (root.activePool) + root.finishActiveAmount("B", value) + else + root.finishMissingAmount("B", value) } + onMaxClicked: root.useMaximum() + onTokenSelected: function(tokenId) { root.resolveToken("B", tokenId) } + onTokenEntered: function(value) { root.resolveToken("B", value) } } } - RowLayout { + Text { Layout.fillWidth: true - spacing: 8 + visible: root.resolvingToken || root.tokenResolutionMessage.length > 0 + text: root.resolvingToken + ? qsTr("Resolving TokenDefinition...") + : root.tokenResolutionMessage + color: root.theme.colors.textSecondary + font.pixelSize: 11 + wrapMode: Text.Wrap + } - 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 = "" - } - } + Text { + Layout.fillWidth: true + visible: text.length > 0 + text: root.activePool ? root.activePriceText() + : root.missingPool ? root.depositMultiplierText() : "" + color: root.theme.colors.textSecondary + font.pixelSize: 12 + horizontalAlignment: Text.AlignRight + wrapMode: Text.Wrap } Rectangle { Layout.fillWidth: true implicitHeight: 1 - color: Theme.palette.borderSecondary + color: root.theme.colors.divider } ColumnLayout { Layout.fillWidth: true spacing: 8 + visible: !root.contextLoading Text { text: qsTr("Fee tier") - color: Theme.palette.text + color: root.theme.colors.textPrimary font.pixelSize: 13 font.weight: Font.Medium } @@ -307,12 +362,18 @@ Rectangle { model: root.feeTiers Item { + id: feeTierOption + required property var modelData readonly property string disabledReason: root.feeDisabledReason(modelData) + readonly property bool invalid: root.fieldHasError("feeBps") + && feeTierButton.checked Layout.fillWidth: true implicitHeight: 40 Button { + id: feeTierButton + anchors.fill: parent text: parent.modelData.label || root.feeLabel(parent.modelData.feeBps) checkable: true @@ -321,8 +382,10 @@ Rectangle { onClicked: root.selectFee(parent.modelData.feeBps) contentItem: Text { - text: parent.text - color: parent.enabled ? Theme.palette.text : Theme.palette.textMuted + text: feeTierButton.text + color: feeTierButton.enabled + ? root.theme.colors.textPrimary + : root.theme.colors.textPlaceholder font.pixelSize: 12 font.weight: Font.Medium horizontalAlignment: Text.AlignHCenter @@ -331,12 +394,14 @@ Rectangle { background: Rectangle { radius: 6 - color: parent.checked - ? Theme.palette.overlayOrange - : Theme.palette.backgroundButton - border.color: parent.checked - ? Theme.palette.primary - : Theme.palette.border + color: feeTierButton.checked + ? root.theme.colors.selection + : root.theme.colors.inputBg + border.color: feeTierOption.invalid + ? root.theme.colors.error + : feeTierButton.checked + ? root.theme.colors.ctaBg + : root.theme.colors.borderStrong border.width: 1 } } @@ -356,247 +421,51 @@ Rectangle { } } - ColumnLayout { + RowLayout { Layout.fillWidth: true - spacing: 12 + spacing: 10 visible: root.activePool - RowLayout { + Text { + text: qsTr("Slippage") + color: root.theme.colors.textSecondary + font.pixelSize: 12 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 - } + Item { + Layout.preferredWidth: slippageControl.implicitWidth + Layout.preferredHeight: slippageControl.implicitHeight LogosSpinBox { + id: slippageControl + + anchors.fill: parent from: 0 to: 5000 stepSize: 10 editable: true value: root.slippageBps enabled: !root.submitting - textFromValue: function(value) { return qsTr("%1 bps").arg(value) } + textFromValue: function(value) { return root.formatBps(value) } valueFromText: function(text) { - var parsed = Number(String(text).replace(/[^0-9]/g, "")) - return isNaN(parsed) ? root.slippageBps : parsed + var parsed = AmountMath.parseHuman(String(text).replace("%", "").trim(), 2) + return parsed.ok ? AmountMath.toU32(parsed.raw) : root.slippageBps } 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 - } - - background: Rectangle { - radius: 6 - color: Theme.palette.backgroundButton - border.color: Theme.palette.border - border.width: 1 + root.requestQuote(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 - } - - background: Rectangle { - radius: 6 - color: Theme.palette.backgroundButton - border.color: Theme.palette.border - 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 - wrapMode: Text.Wrap - } - - Rectangle { - Layout.fillWidth: true - implicitHeight: missingDepositRows.implicitHeight + 20 - radius: 6 - color: Theme.palette.backgroundTertiary - - ColumnLayout { - id: missingDepositRows + Rectangle { anchors.fill: parent - anchors.margins: 10 - spacing: 8 - - LabelValueRow { - label: qsTr("%1 deposit").arg(root.shortTokenName(root.tokenA)) - value: root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A") - } - - LabelValueRow { - label: qsTr("%1 deposit").arg(root.shortTokenName(root.tokenB)) - value: root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "B") - } + visible: root.fieldHasError("slippageBps") + color: "transparent" + radius: 6 + border.color: root.theme.colors.error + border.width: 1 } } } @@ -605,11 +474,13 @@ Rectangle { Layout.fillWidth: true spacing: 9 visible: root.quotePayload.status === "ok" + && root.quoteMatchesPair() + && !root.quoteStale Rectangle { Layout.fillWidth: true implicitHeight: 1 - color: Theme.palette.borderSecondary + color: root.theme.colors.divider } LabelValueRow { @@ -617,6 +488,30 @@ Rectangle { value: root.depositSummary() } + LabelValueRow { + visible: root.missingPool + label: qsTr("Initial price") + value: root.initialPriceText(false) + } + + LabelValueRow { + visible: root.missingPool + label: qsTr("Inverse price") + value: root.initialPriceText(true) + } + + LabelValueRow { + visible: root.missingPool + label: qsTr("Deposit multiplier") + value: root.depositMultiplierValue() + } + + LabelValueRow { + visible: root.missingPool + label: qsTr("Deposit scale") + value: root.depositBasisPointsText() + } + LabelValueRow { label: qsTr("Expected LP") value: root.rawLpText(root.quotePayload.expectedLpRaw) @@ -632,6 +527,7 @@ Rectangle { LabelValueRow { label: qsTr("Pool") value: String(root.quotePayload.poolId || "") + valueWrapAnywhere: true } LogosButton { @@ -658,6 +554,7 @@ Rectangle { 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") + valueWrapAnywhere: true } } } @@ -667,8 +564,8 @@ Rectangle { Layout.fillWidth: true implicitHeight: warningTextItem.implicitHeight + 20 radius: 6 - color: Theme.palette.backgroundTertiary - border.color: Theme.palette.primary + color: root.theme.colors.panelBg + border.color: root.theme.colors.ctaBg visible: root.warningText().length > 0 Text { @@ -676,115 +573,28 @@ Rectangle { anchors.fill: parent anchors.margins: 10 text: root.warningText() - color: Theme.palette.text + color: root.theme.colors.textPrimary font.pixelSize: 12 wrapMode: Text.Wrap verticalAlignment: Text.AlignVCenter } } - Rectangle { + SubmittedTransaction { 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 + title: qsTr("Position submitted") + transactionId: root.transactionId 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 { - Layout.fillWidth: true - spacing: 6 - - Text { - text: root.transactionId - color: Theme.palette.text - font.family: "monospace" - font.pixelSize: 11 - wrapMode: Text.WrapAnywhere - Layout.fillWidth: true - } - - LogosCopyButton { - Accessible.name: qsTr("Copy transaction ID") - ToolTip.visible: hovered - ToolTip.text: Accessible.name - onCopyText: root.copyToClipboard(root.transactionId) - } - } - - RowLayout { - Layout.fillWidth: true - visible: root.refreshWarning.length > 0 - - Text { - text: root.refreshWarning - color: Theme.palette.textSecondary - font.pixelSize: 11 - wrapMode: Text.Wrap - Layout.fillWidth: true - } - - 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 + AmmPrimaryButton { Layout.fillWidth: true - } - - LogosButton { - Layout.fillWidth: true - Layout.minimumHeight: 48 - radius: 6 + Layout.minimumHeight: 56 + theme: root.theme text: root.submitting ? qsTr("Submitting…") + : root.contextLoading ? qsTr("Loading…") + : root.poolCreationPending ? qsTr("Waiting for pool") : root.missingPool ? qsTr("Create pool") : qsTr("Add liquidity") enabled: root.canConfirm onClicked: root.confirmationRequested(root.submissionSnapshot()) @@ -794,12 +604,13 @@ Rectangle { component LabelValueRow: RowLayout { required property string label required property string value + property bool valueWrapAnywhere: false Layout.fillWidth: true spacing: 12 Text { text: parent.label - color: Theme.palette.textSecondary + color: root.theme.colors.textSecondary font.pixelSize: 12 Layout.fillWidth: true wrapMode: Text.Wrap @@ -807,12 +618,70 @@ Rectangle { Text { text: parent.value - color: Theme.palette.text + color: root.theme.colors.textPrimary font.pixelSize: 12 font.weight: Font.Medium horizontalAlignment: Text.AlignRight - wrapMode: Text.WrapAnywhere - Layout.maximumWidth: root.compact ? 190 : 430 + wrapMode: parent.valueWrapAnywhere ? Text.WrapAnywhere : Text.Wrap + Layout.maximumWidth: root.compact ? 190 : 280 + } + } + + component PriceRatioAdjustment: RowLayout { + id: ratioAdjustment + + required property string amount + required property string fieldName + required property bool invalid + + signal edited(string value) + + implicitWidth: 142 + implicitHeight: 24 + spacing: 6 + + Text { + text: qsTr("Price") + color: root.theme.colors.textSecondary + font.pixelSize: 11 + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 24 + radius: 6 + color: root.theme.colors.panelBg + border.color: ratioAdjustment.invalid + ? root.theme.colors.error + : ratioInput.activeFocus + ? root.theme.colors.ctaBg + : root.theme.colors.borderStrong + border.width: 1 + + TextInput { + id: ratioInput + + objectName: ratioAdjustment.fieldName + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + text: ratioAdjustment.amount + enabled: ratioAdjustment.enabled + color: enabled ? root.theme.colors.textPrimary + : root.theme.colors.textSecondary + font.pixelSize: 12 + selectionColor: root.theme.colors.selection + selectedTextColor: root.theme.colors.textPrimary + horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter + inputMethodHints: Qt.ImhFormattedNumbersOnly + maximumLength: 80 + validator: RegularExpressionValidator { + regularExpression: /[0-9]*([.][0-9]*)?/ + } + Accessible.name: qsTr("Initial price ratio amount") + onTextEdited: ratioAdjustment.edited(text) + } } } @@ -824,14 +693,6 @@ Rectangle { 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) { @@ -841,11 +702,97 @@ Rectangle { return result } + function isBase58TokenId(tokenId) { + return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(String(tokenId || "")) + } + + function resolveToken(side, value) { + var tokenId = String(value || "").trim() + root.tokenResolutionError = "" + root.tokenResolutionErrorSide = "" + root.tokenResolutionMessage = "" + if (!root.isBase58TokenId(tokenId)) { + root.tokenResolutionError = root.issueText("invalid_token_id") + root.tokenResolutionErrorSide = side + return + } + + var current = root.tokenById(tokenId) + if (current.definitionId === tokenId) { + if (current.selectable === true) + root.selectToken(side, tokenId) + else { + root.tokenResolutionError = root.issueText(current.code || current.status) + root.tokenResolutionErrorSide = side + } + return + } + root.resolvingTokenId = tokenId + root.resolvingTokenSide = side + root.tokenResolveRequested(tokenId) + } + + function finishTokenResolution(finalResponse) { + if (!root.resolvingToken) + return + var token = root.tokenById(root.resolvingTokenId) + if (!token || !token.definitionId) { + if (finalResponse === true) { + var currentStatus = String(root.newPositionContext.status || "") + var code = currentStatus !== "ready" && currentStatus !== "no_wallet" + && currentStatus !== "loading" + ? root.newPositionContext.code || currentStatus + : "token_definition_unreadable" + root.failTokenResolution(code) + } + return + } + var status = String(root.newPositionContext.status || "") + if (status === "loading") + return + if (status !== "ready" && status !== "no_wallet") { + root.failTokenResolution(root.newPositionContext.code || status) + return + } + var side = root.resolvingTokenSide + root.resolvingTokenId = "" + root.resolvingTokenSide = "" + if (token.selectable !== true) { + root.tokenResolutionError = root.issueText(token.code || token.status) + root.tokenResolutionErrorSide = side + return + } + + var decimals = AmountMath.implyDecimals(token.totalSupplyRaw || "0") + root.tokenResolutionMessage = qsTr("%1 - supply %2 (%3 implied decimals)") + .arg(token.name || root.shortId(token.definitionId)) + .arg(AmountMath.formatRaw(token.totalSupplyRaw || "0", decimals)) + .arg(decimals) + root.selectToken(side, token.definitionId) + } + + function failTokenResolution(code) { + if (!root.resolvingToken) + return + var side = root.resolvingTokenSide + root.resolvingTokenId = "" + root.resolvingTokenSide = "" + root.tokenResolutionError = root.issueText(code) + root.tokenResolutionErrorSide = side + } + function ensurePair() { + var status = String(root.newPositionContext.status || "") + if (status !== "ready" && status !== "no_wallet") + return + var previousA = root.selectedTokenAId + var previousB = root.selectedTokenBId var selectable = root.selectableTokenIds() if (selectable.length === 0) { root.selectedTokenAId = "" root.selectedTokenBId = "" + if (previousA.length > 0 || previousB.length > 0) + root.resetPairDraft() return } if (selectable.indexOf(root.selectedTokenAId) < 0) @@ -854,13 +801,17 @@ Rectangle { || root.selectedTokenBId === root.selectedTokenAId) { root.selectedTokenBId = selectable.length > 1 ? selectable[1] : "" } - if (root.hasPair) - root.quoteRequested(true) + if (root.selectedTokenAId !== previousA || root.selectedTokenBId !== previousB) + root.resetPairDraft() + else if (root.hasPair) + root.requestQuote(true) } function selectToken(side, tokenId) { if (tokenId.length === 0) return + root.tokenResolutionError = "" + root.tokenResolutionErrorSide = "" if (side === "A") { if (tokenId === root.selectedTokenBId) root.selectedTokenBId = root.selectedTokenAId @@ -880,44 +831,102 @@ Rectangle { var amount = root.amountA root.amountA = root.amountB root.amountB = amount - if (root.activePool && root.quotePayload.initialPriceRealRaw) - root.initialPrice = root.activePriceValue() + var minimum = root.minimumAmountARaw + root.minimumAmountARaw = root.minimumAmountBRaw + root.minimumAmountBRaw = minimum + var priceAmount = root.priceAmountA + root.priceAmountA = root.priceAmountB + root.priceAmountB = priceAmount root.noteDraftChanged() - root.quoteRequested(true) + root.requestQuote(true) } function resetPairDraft() { + root.confirmedPoolStatus = "" + root.activePoolQuote = ({}) root.amountA = "" root.amountB = "" - root.initialPrice = "1" - root.depositScaleBps = "10000" - root.priceTargetLp = "" - root.priceAdjustmentCount = 0 + root.priceAmountA = "1" + root.priceAmountB = "1" + root.minimumAmountARaw = "" + root.minimumAmountBRaw = "" root.localErrors = [] root.noteDraftChanged() - root.quoteRequested(true) + root.requestQuote(true) + } + + function acceptPoolActivation(quote) { + if (!quote || quote.schema !== "new-position.v1" + || quote.status !== "ok" + || quote.poolStatus !== "active_pool" + || !root.quoteMatchesSelectedPair(quote)) { + return false + } + root.confirmedPoolStatus = "active_pool" + root.activePoolQuote = quote + root.amountA = "" + root.amountB = "" + root.minimumAmountARaw = "" + root.minimumAmountBRaw = "" + root.localErrors = [] + return true } function noteDraftChanged() { - root.submitError = "" - root.refreshWarning = "" root.draftChanged() } function effectivePoolStatus() { + if (root.quoteStale || !root.quoteMatchesPair()) + return root.confirmedPoolStatus 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 "" + return root.confirmedPoolStatus + } + + function rememberPoolStatus() { + if (!root.quoteMatchesPair()) + return + var status = String(root.quotePayload.poolStatus || "") + if (status === "active_pool" || status === "missing_pool") + root.confirmedPoolStatus = status + else if (root.quotePayload.code === "fee_tier_mismatch") + root.confirmedPoolStatus = "active_pool" + } + + function rememberActivePoolQuote() { + if (root.quotePayload.status !== "ok" + || root.quotePayload.poolStatus !== "active_pool" + || !root.quoteMatchesPair()) { + return + } + var reserveA = String(root.quotePayload.reserveARaw || "") + var reserveB = String(root.quotePayload.reserveBRaw || "") + if (AmountMath.isUnsigned(reserveA) && reserveA !== "0" + && AmountMath.isUnsigned(reserveB) && reserveB !== "0") { + root.activePoolQuote = root.quotePayload + } } function knownPoolFeeBps() { - var direct = Number(root.quotePayload.poolFeeBps || 0) + var direct = root.feeBpsFromQuote(root.quotePayload) if (direct > 0) return direct - var errors = root.quotePayload.errors || [] + if (root.quoteMatchesSelectedPair(root.activePoolQuote)) + return Number(root.activePoolQuote.poolFeeBps || 0) + return 0 + } + + function feeBpsFromQuote(quote) { + if (!root.quoteMatchesSelectedPair(quote)) + return 0 + var direct = Number(quote.poolFeeBps || 0) + if (direct > 0) + return direct + var errors = quote.errors || [] for (var i = 0; i < errors.length; ++i) { var value = Number(errors[i].details ? errors[i].details.poolFeeBps : 0) if (value > 0) @@ -926,10 +935,22 @@ Rectangle { return 0 } + function quoteMatchesPair() { + return root.quoteMatchesSelectedPair(root.quotePayload) + } + + function quoteMatchesSelectedPair(quote) { + var tokenAId = String(quote.tokenAId || "") + var tokenBId = String(quote.tokenBId || "") + return root.hasPair + && ((tokenAId === root.selectedTokenAId && tokenBId === root.selectedTokenBId) + || (tokenAId === root.selectedTokenBId && tokenBId === root.selectedTokenAId)) + } + function selectFee(feeBps) { root.selectedFeeBps = feeBps root.noteDraftChanged() - root.quoteRequested(true) + root.requestQuote(true) } function feeDisabledReason(tier) { @@ -950,7 +971,7 @@ Rectangle { return "0.30%" if (feeBps === 100) return "1.00%" - return qsTr("%1 bps").arg(feeBps) + return root.formatBps(feeBps) } function buildQuoteRequest() { @@ -983,19 +1004,68 @@ Rectangle { request.slippageBps = root.slippageBps } } else { - var price = AmountMath.priceToQ64(root.initialPrice, + var priceFromAmounts = false + var price = AmountMath.ratioToQ64(root.priceAmountA, + root.priceAmountB, 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.missingPool && root.minimumAmountARaw.length > 0) { + var parsedMissingA = AmountMath.parseHuman(root.amountA, root.decimalsA) + var parsedMissingB = AmountMath.parseHuman(root.amountB, root.decimalsB) + if (!parsedMissingA.ok) + errors.push(root.localIssue(parsedMissingA.code, ["amountA"])) + if (!parsedMissingB.ok) + errors.push(root.localIssue(parsedMissingB.code, ["amountB"])) + if (parsedMissingA.ok && parsedMissingB.ok) { + if (AmountMath.compare(parsedMissingA.raw, root.minimumAmountARaw) < 0) + errors.push(root.localIssue("amount_too_low", ["amountA"])) + if (AmountMath.compare(parsedMissingB.raw, root.minimumAmountBRaw) < 0) + errors.push(root.localIssue("amount_too_low", ["amountB"])) + var pairedB = AmountMath.pairAmount(parsedMissingA.raw, + true, + root.decimalsA, + root.decimalsB, + root.priceAmountA, + root.priceAmountB) + var pairedA = AmountMath.pairAmount(parsedMissingB.raw, + false, + root.decimalsA, + root.decimalsB, + root.priceAmountA, + root.priceAmountB) + var matchesA = pairedA.ok + && AmountMath.normalize(pairedA.raw) + === AmountMath.normalize(parsedMissingA.raw) + var matchesB = pairedB.ok + && AmountMath.normalize(pairedB.raw) + === AmountMath.normalize(parsedMissingB.raw) + if (!matchesA && !matchesB) { + errors.push(root.localIssue("deposit_ratio_mismatch", ["amountA", "amountB"])) + } + if (errors.length === 0) { + request.amountARaw = root.displayIsCanonical + ? parsedMissingA.raw : parsedMissingB.raw + request.amountBRaw = root.displayIsCanonical + ? parsedMissingB.raw : parsedMissingA.raw + var actualPrice = AmountMath.ratioToQ64(root.amountA, + root.amountB, + root.canonicalDecimalsA, + root.canonicalDecimalsB, + root.displayIsCanonical) + if (actualPrice.ok) { + request.initialPriceRealRaw = actualPrice.raw + priceFromAmounts = true + } else { + errors.push(root.localIssue(actualPrice.code, ["initialPrice"])) + } + } + } } + if (price.ok && !priceFromAmounts) + request.initialPriceRealRaw = price.raw if (!root.missingPool) { var probeA = root.probeRaw(root.tokenA, root.decimalsA) @@ -1010,18 +1080,36 @@ Rectangle { return { "ok": errors.length === 0, "errors": errors, "request": request } } - 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") + function requestQuote(immediate) { + if (root.activePool && root.amountA.length === 0 && root.amountB.length === 0) { + root.localErrors = [] + return + } + root.quoteRequested(immediate, root.buildQuoteRequest()) } - function validScale() { - if (!/^[0-9]+$/.test(root.depositScaleBps)) - return -1 - var scale = AmountMath.toU32(root.depositScaleBps) - return scale >= 10000 ? scale : -1 + function probeRaw(token, decimals) { + var balance = String(token.balanceRaw || "0") + var simulated = AmountMath.multiply(AmountMath.pow10(decimals), "1000") + if (AmountMath.isUnsigned(balance) && AmountMath.compare(balance, simulated) > 0) + return balance + return simulated + } + + function poolProbeRequest(request) { + var probe = {} + for (var field in request) + probe[field] = request[field] + var amountA = root.probeRaw(root.tokenA, root.decimalsA) + var amountB = root.probeRaw(root.tokenB, root.decimalsB) + probe.maxAmountARaw = root.displayIsCanonical ? amountA : amountB + probe.maxAmountBRaw = root.displayIsCanonical ? amountB : amountA + probe.slippageBps = root.slippageBps + return probe + } + + function formatBps(value) { + return qsTr("%1%").arg(AmountMath.formatRaw(String(value), 2)) } function localIssue(code, fields) { @@ -1029,19 +1117,25 @@ Rectangle { } function canonicalFieldToDisplay(field) { + if (field === "tokenAId") + return root.displayIsCanonical ? "tokenAId" : "tokenBId" + if (field === "tokenBId") + return root.displayIsCanonical ? "tokenBId" : "tokenAId" if (field === "maxAmountARaw") return root.displayIsCanonical ? "amountA" : "amountB" if (field === "maxAmountBRaw") return root.displayIsCanonical ? "amountB" : "amountA" + if (field === "amountARaw") + return root.displayIsCanonical ? "amountA" : "amountB" + if (field === "amountBRaw") + 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 || []] + var collections = [root.localErrors, root.currentQuoteErrors()] for (var c = 0; c < collections.length; ++c) { for (var i = 0; i < collections[c].length; ++i) { var fields = collections[c][i].blockingFields || [] @@ -1054,6 +1148,39 @@ Rectangle { return "" } + function fieldHasError(field) { + return root.fieldError(field).length > 0 + } + + function tokenHasError(side) { + if (root.tokenResolutionError.length > 0 + && root.tokenResolutionErrorSide === side) { + return true + } + return root.fieldHasError(side === "A" ? "tokenAId" : "tokenBId") + } + + function formErrorText() { + if (root.tokenResolutionError.length > 0) + return root.tokenResolutionError + if (root.submitError.length > 0) + return root.submitError + var collections = [root.localErrors, root.currentQuoteErrors()] + for (var c = 0; c < collections.length; ++c) { + for (var i = 0; i < collections[c].length; ++i) { + var code = String(collections[c][i].code || "") + if (code.length > 0) + return root.issueText(code) + } + } + return root.quoteError() + } + + function currentQuoteErrors() { + return !root.quoteStale && root.quoteMatchesPair() + ? root.quotePayload.errors || [] : [] + } + function issueText(code) { var messages = { "amount_required": qsTr("Enter a value."), @@ -1063,10 +1190,10 @@ Rectangle { "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."), + "invalid_token_id": qsTr("Enter a valid base58 TokenDefinition ID."), + "deposit_ratio_mismatch": qsTr("Deposit amounts must match the initial price."), "minimum_lp_zero": qsTr("Slippage leaves no minimum LP output."), - "invalid_slippage": qsTr("Slippage must be between 0 and 5000 bps."), + "invalid_slippage": qsTr("Slippage must be between 0% and 50%."), "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."), @@ -1081,6 +1208,9 @@ Rectangle { "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."), + "network_unknown": qsTr("Network identity could not be verified. Refresh and retry."), + "network_mismatch": qsTr("Connected wallet uses a different network."), + "config_missing": qsTr("Network configuration is missing."), "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."), @@ -1096,9 +1226,21 @@ Rectangle { else root.amountB = value - 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) + root.localErrors = [] + root.noteDraftChanged() + } + + function finishActiveAmount(side, value) { + var decimals = side === "A" ? root.decimalsA : root.decimalsB + value = AmountMath.trimHumanPrecision(value, decimals) + if (side === "A") + root.amountA = value + else + root.amountB = value + + var reserveA = root.poolReserve("A") + var reserveB = root.poolReserve("B") + var parsed = AmountMath.parseHuman(value, decimals) if (parsed.ok && reserveA !== "" && reserveB !== "" && reserveA !== "0" && reserveB !== "0") { if (side === "A") { @@ -1109,13 +1251,12 @@ Rectangle { root.amountA = AmountMath.formatRaw(rawA, root.decimalsA) } } - root.noteDraftChanged() - root.quoteRequested(false) + root.requestQuote(true) } function useMaximum() { - var reserveA = root.displayRaw("reserveARaw", "reserveBRaw", "A") - var reserveB = root.displayRaw("reserveARaw", "reserveBRaw", "B") + var reserveA = root.poolReserve("A") + var reserveB = root.poolReserve("B") if (!reserveA || !reserveB || reserveA === "0" || reserveB === "0") return var balanceA = String(root.tokenA.balanceRaw || "0") @@ -1126,45 +1267,70 @@ Rectangle { root.amountA = AmountMath.formatRaw(rawA, root.decimalsA) root.amountB = AmountMath.formatRaw(rawB, root.decimalsB) root.noteDraftChanged() - root.quoteRequested(true) + root.requestQuote(true) } - function editPrice(value) { - if (root.priceTargetLp.length === 0 - && root.missingPool - && root.quotePayload.status === "ok") { - root.priceTargetLp = String(root.quotePayload.expectedLpRaw || "") - root.priceAdjustmentCount = 0 + function editPrice(side, value) { + if (side === "A") + root.priceAmountA = value + else + root.priceAmountB = value + root.minimumAmountARaw = "" + root.minimumAmountBRaw = "" + root.amountA = "" + root.amountB = "" + root.noteDraftChanged() + root.requestQuote(false) + } + + function editMissingAmount(side, value) { + if (side === "A") + root.amountA = value + else + root.amountB = value + + root.localErrors = [] + root.noteDraftChanged() + } + + function finishMissingAmount(side, value) { + var decimals = side === "A" ? root.decimalsA : root.decimalsB + value = AmountMath.trimHumanPrecision(value, decimals) + if (side === "A") + root.amountA = value + else + root.amountB = value + + var parsed = AmountMath.parseHuman(value, decimals) + if (parsed.ok) { + var paired = AmountMath.pairAmount(parsed.raw, + side === "A", + root.decimalsA, + root.decimalsB, + root.priceAmountA, + root.priceAmountB) + if (paired.ok) { + if (side === "A") + root.amountB = AmountMath.formatRaw(paired.raw, root.decimalsB) + else + root.amountA = AmountMath.formatRaw(paired.raw, root.decimalsA) + } } - 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) + root.requestQuote(true) } function applyQuoteSideEffects() { + if (root.quoteStale) + return if (root.poolFeeBps > 0 && root.selectedFeeBps !== root.poolFeeBps) { root.selectedFeeBps = root.poolFeeBps - root.quoteRequested(true) + if (root.amountA.length === 0 && root.amountB.length === 0) { + root.amountA = AmountMath.formatRaw( + root.probeRaw(root.tokenA, root.decimalsA), root.decimalsA) + root.amountB = AmountMath.formatRaw( + root.probeRaw(root.tokenB, root.decimalsB), root.decimalsB) + } + root.requestQuote(true) return } @@ -1176,37 +1342,39 @@ Rectangle { 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 + if (root.missingPool) { + var rawA = root.displayRaw("actualAmountARaw", "actualAmountBRaw", "A") + var rawB = root.displayRaw("actualAmountARaw", "actualAmountBRaw", "B") + var minimumA = root.displayRaw("minimumAmountARaw", "minimumAmountBRaw", "A") + var minimumB = root.displayRaw("minimumAmountARaw", "minimumAmountBRaw", "B") + root.minimumAmountARaw = minimumA.length > 0 ? minimumA : rawA + root.minimumAmountBRaw = minimumB.length > 0 ? minimumB : rawB + if (rawA.length > 0 && rawB.length > 0) { + root.amountA = AmountMath.formatRaw(rawA, root.decimalsA) + root.amountB = AmountMath.formatRaw(rawB, root.decimalsB) + } } - 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 displayRaw(canonicalAField, canonicalBField, side) { + return root.displayQuoteRaw(root.quotePayload, canonicalAField, canonicalBField, side) + } + + function displayQuoteRaw(quote, canonicalAField, canonicalBField, side) { + if (!root.quoteMatchesSelectedPair(quote)) + return "" if (side === "A") - return String(root.quotePayload[root.displayIsCanonical ? canonicalAField : canonicalBField] || "") - return String(root.quotePayload[root.displayIsCanonical ? canonicalBField : canonicalAField] || "") + return String(quote[root.displayIsCanonical ? canonicalAField : canonicalBField] || "") + return String(quote[root.displayIsCanonical ? canonicalBField : canonicalAField] || "") + } + + function poolReserve(side) { + var reserve = root.displayRaw("reserveARaw", "reserveBRaw", side) + if (AmountMath.isUnsigned(reserve) && reserve !== "0") + return reserve + if (!root.quoteMatchesSelectedPair(root.activePoolQuote)) + return "" + return root.displayQuoteRaw(root.activePoolQuote, "reserveARaw", "reserveBRaw", side) } function quoteAmount(canonicalAField, canonicalBField, side) { @@ -1224,13 +1392,28 @@ Rectangle { return amountA + " + " + amountB } + function initialPriceText(inverse) { + var price = inverse ? root.inverseInitialPrice : root.initialPrice + if (price.length === 0) + return "—" + var from = inverse ? root.tokenB : root.tokenA + var to = inverse ? root.tokenA : root.tokenB + return qsTr("1 %1 = %2 %3") + .arg(root.shortTokenName(from)) + .arg(price) + .arg(root.shortTokenName(to)) + } + 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 || ""), + var priceRaw = String(root.quotePayload.initialPriceRealRaw || "") + if (priceRaw.length === 0 && root.quoteMatchesSelectedPair(root.activePoolQuote)) + priceRaw = String(root.activePoolQuote.initialPriceRealRaw || "") + return AmountMath.priceFromQ64(priceRaw, root.canonicalDecimalsA, root.canonicalDecimalsB, root.displayIsCanonical) @@ -1247,7 +1430,8 @@ Rectangle { } function accountPreview() { - return root.quotePayload.accountPreview || [] + return !root.quoteStale && root.quoteMatchesPair() + ? root.quotePayload.accountPreview || [] : [] } function quoteError() { @@ -1259,26 +1443,18 @@ Rectangle { } function warningText() { - var warnings = root.quotePayload.warnings || [] + var warnings = !root.quoteStale && root.quoteMatchesPair() + ? 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, + "poolProbeRequest": root.poolProbeRequest(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), @@ -1289,33 +1465,6 @@ Rectangle { } } - function resetAfterSubmit() { - root.amountA = "" - root.amountB = "" - root.initialPrice = "" - root.depositScaleBps = "10000" - root.localErrors = [] - root.submitError = "" - root.priceTargetLp = "" - root.priceAdjustmentCount = 0 - } - - 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 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 shortTokenName(token) { if (token && token.name && token.name.length > 0) return token.name @@ -1326,21 +1475,49 @@ Rectangle { return AmountMath.formatRaw(String(token.balanceRaw || "0"), decimals) } + function tokenBalanceDetail(token) { + var decimals = AmountMath.implyDecimals(token.totalSupplyRaw || "0") + return qsTr("Available %1").arg(root.balanceText(token, decimals)) + } + function shortId(value) { var text = String(value || "") return text.length > 14 ? text.slice(0, 7) + "…" + text.slice(-5) : text } - function scaleHelpText() { - var error = root.fieldError("depositScale") - if (error.length > 0) - return error - var funded = root.quotePayload.maxFundedScaleBps - if (funded === null || funded === undefined) + function depositMultiplierText() { + var multiplier = root.depositMultiplierValue() + var basisPoints = root.depositBasisPointsText() + return multiplier.length > 0 ? qsTr("%1 · %2").arg(multiplier).arg(basisPoints) : "" + } + + function depositMultiplierValue() { + var scale = root.depositScaleValue() + return scale.length > 0 + ? qsTr("%1x minimum").arg(AmountMath.formatRaw(scale, 4)) : "" + } + + function depositBasisPointsText() { + var scale = root.depositScaleValue() + return scale.length > 0 ? qsTr("%1 basis points").arg(scale) : "" + } + + function depositScaleValue() { + var parsed = AmountMath.parseHuman(root.amountA, root.decimalsA) + if (!parsed.ok || !AmountMath.isUnsigned(root.minimumAmountARaw) + || root.minimumAmountARaw === "0" + || AmountMath.compare(parsed.raw, root.minimumAmountARaw) < 0) { 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 AmountMath.divide(AmountMath.multiply(parsed.raw, "10000"), + root.minimumAmountARaw).quotient + } + + function minimumAmountText(side) { + var raw = side === "A" ? root.minimumAmountARaw : root.minimumAmountBRaw + var decimals = side === "A" ? root.decimalsA : root.decimalsB + return raw.length > 0 + ? qsTr("Min %1").arg(AmountMath.formatRaw(raw, decimals)) : "" } function contextStatusText() { diff --git a/apps/amm/qml/components/liquidity/PoolPositionSummary.qml b/apps/amm/qml/components/liquidity/PoolPositionSummary.qml deleted file mode 100644 index 2466ae7..0000000 --- a/apps/amm/qml/components/liquidity/PoolPositionSummary.qml +++ /dev/null @@ -1,98 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Layouts 1.15 -import "../../state" - -Rectangle { - id: root - - required property DummyPoolState poolState - readonly property string estimateHelp: qsTr("This value is an estimate from the current dummy reserves and your share of total LP supply.") - - color: "#151515" - implicitHeight: content.implicitHeight + 20 - radius: 8 - border.color: "#303030" - border.width: 1 - - ColumnLayout { - id: content - - anchors.fill: parent - anchors.margins: 10 - spacing: 6 - - RowLayout { - spacing: 10 - - Layout.fillWidth: true - - ColumnLayout { - spacing: 2 - - Layout.fillWidth: true - - Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 13 - text: root.poolState.userLpBalance > 0 ? qsTr("Your position") : qsTr("No position") - - Layout.fillWidth: true - } - - Text { - color: "#8E8780" - font.pixelSize: 11 - text: qsTr("%1 LP tokens").arg(root.poolState.formatInteger(root.poolState.userLpBalance)) - visible: root.poolState.userLpBalance > 0 - - Layout.fillWidth: true - } - } - - Rectangle { - color: "#211914" - radius: 10 - border.color: "#49301F" - border.width: 1 - - Layout.preferredHeight: 24 - Layout.preferredWidth: shareText.implicitWidth + 18 - - Text { - id: shareText - - anchors.centerIn: parent - color: "#F2D8C7" - font.bold: true - font.pixelSize: 11 - text: root.poolState.userLpBalance > 0 ? root.poolState.formatPoolShare(root.poolState.poolShare) : root.poolState.feeTier - } - } - } - - SummaryRow { - estimated: true - estimateHelp: root.estimateHelp - label: qsTr("Owned") - value: qsTr("%1 + %2").arg(root.poolState.formatCompactTokenAmount(root.poolState.userOwnedA, root.poolState.tokenA)).arg(root.poolState.formatCompactTokenAmount(root.poolState.userOwnedB, root.poolState.tokenB)) - visible: root.poolState.userLpBalance > 0 - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Pool") - value: qsTr("%1 / %2").arg(root.poolState.formatCompactTokenAmount(root.poolState.reserveA, root.poolState.tokenA)).arg(root.poolState.formatCompactTokenAmount(root.poolState.reserveB, root.poolState.tokenB)) - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Fee") - value: root.poolState.feeTier - - Layout.fillWidth: true - } - } -} diff --git a/apps/amm/qml/components/liquidity/RemoveLiquidityForm.qml b/apps/amm/qml/components/liquidity/RemoveLiquidityForm.qml deleted file mode 100644 index b916a74..0000000 --- a/apps/amm/qml/components/liquidity/RemoveLiquidityForm.qml +++ /dev/null @@ -1,492 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 -import "../shared" -import "../../state" - -Rectangle { - id: root - - required property DummyPoolState poolState - - property real slippageTolerancePercent: 0.5 - property int burnAmount: 0 - readonly property int maxBurnAmount: root.poolState.clampBurnAmount(root.poolState.userLpBalance) - readonly property bool hasLpTokens: root.maxBurnAmount > 0 - readonly property int preset25Amount: root.poolState.burnAmountForPercent(25) - readonly property int preset50Amount: root.poolState.burnAmountForPercent(50) - readonly property int preset75Amount: root.poolState.burnAmountForPercent(75) - readonly property real removePercent: root.maxBurnAmount > 0 ? root.burnAmount * 100 / root.maxBurnAmount : 0 - readonly property var preview: root.poolState.removeLiquidityPreview(root.burnAmount) - readonly property int minTokenAReceived: root.poolState.minReceivedAmount(root.preview.withdrawA, root.slippageTolerancePercent) - readonly property int minTokenBReceived: root.poolState.minReceivedAmount(root.preview.withdrawB, root.slippageTolerancePercent) - readonly property bool minReceivedIsZero: root.burnAmount > 0 && (root.minTokenAReceived === 0 || root.minTokenBReceived === 0) - readonly property bool canSubmit: root.hasLpTokens && root.burnAmount > 0 && !root.minReceivedIsZero - readonly property string estimateHelp: qsTr("Estimated with the same integer floor math used by the remove-liquidity contract path.") - readonly property string submitButtonText: !root.hasLpTokens ? qsTr("No LP balance") : root.burnAmount === 0 ? qsTr("Enter an amount") : root.minReceivedIsZero ? qsTr("Minimum received is 0") : qsTr("Remove Liquidity") - - signal slippageToleranceChangeRequested(real tolerancePercent) - signal removeLiquidityRequested(var snapshot) - - color: "#00000000" - implicitHeight: content.implicitHeight - radius: 0 - border.width: 0 - - onMaxBurnAmountChanged: { - if (root.burnAmount > root.maxBurnAmount) { - root.setBurnAmount(root.maxBurnAmount); - } - } - - ColumnLayout { - id: content - - anchors.fill: parent - spacing: 10 - - Text { - color: "#F26A21" - font.pixelSize: 12 - text: qsTr("No LP tokens") - visible: !root.hasLpTokens - - Layout.fillWidth: true - } - - Text { - color: "#A9A098" - font.pixelSize: 12 - lineHeight: 1.25 - text: qsTr("Add liquidity first to receive LP tokens before removing from this pool.") - visible: !root.hasLpTokens - wrapMode: Text.WordWrap - - Layout.fillWidth: true - } - - Rectangle { - color: root.hasLpTokens ? "#151515" : "#121212" - radius: 8 - border.color: burnField.activeFocus ? "#F26A21" : "#343434" - border.width: 1 - - Layout.fillWidth: true - Layout.preferredHeight: inputContent.implicitHeight + 20 - - ColumnLayout { - id: inputContent - - anchors.fill: parent - anchors.margins: 10 - spacing: 8 - - RowLayout { - spacing: 8 - - Layout.fillWidth: true - - Text { - color: "#A9A098" - elide: Text.ElideRight - font.pixelSize: 12 - text: qsTr("LP tokens to burn") - - Layout.fillWidth: true - } - - Text { - color: "#A9A098" - elide: Text.ElideRight - font.pixelSize: 11 - horizontalAlignment: Text.AlignRight - text: qsTr("Available LP: %1").arg(root.poolState.formatInteger(root.poolState.userLpBalance)) - - Layout.maximumWidth: 170 - } - } - - TextField { - id: burnField - - activeFocusOnTab: root.hasLpTokens - color: "#E7E1D8" - enabled: root.hasLpTokens - font.bold: true - font.pixelSize: 18 - inputMethodHints: Qt.ImhDigitsOnly - placeholderText: qsTr("0") - selectByMouse: true - selectedTextColor: "#151515" - selectionColor: "#F26A21" - text: root.burnAmount > 0 ? String(root.burnAmount) : "" - validator: RegularExpressionValidator { - regularExpression: /[0-9]*/ - } - - Accessible.name: qsTr("LP tokens to burn") - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onTextEdited: root.setBurnAmount(text) - - background: Rectangle { - border.color: burnField.activeFocus ? "#F26A21" : "#343434" - border.width: 1 - color: burnField.activeFocus ? "#1F1B18" : "#101010" - radius: 6 - } - } - } - } - - RowLayout { - spacing: 6 - - Layout.fillWidth: true - - Button { - id: preset25 - - activeFocusOnTab: root.hasLpTokens - enabled: root.hasLpTokens - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("25%") - - Accessible.name: qsTr("Remove 25 percent") - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.setBurnPercent(25) - - contentItem: Text { - color: preset25.enabled && (preset25.hovered || preset25.activeFocus || root.preset25Amount === root.burnAmount) ? "#151515" : "#A9A098" - font.bold: true - font.pixelSize: 11 - horizontalAlignment: Text.AlignHCenter - text: preset25.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: preset25.activeFocus || root.preset25Amount === root.burnAmount ? "#F26A21" : "#343434" - border.width: 1 - color: preset25.pressed ? "#D95C1E" : root.preset25Amount === root.burnAmount ? "#F26A21" : preset25.hovered || preset25.activeFocus ? "#E7E1D8" : "#151515" - radius: 6 - } - } - - Button { - id: preset50 - - activeFocusOnTab: root.hasLpTokens - enabled: root.hasLpTokens - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("50%") - - Accessible.name: qsTr("Remove 50 percent") - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.setBurnPercent(50) - - contentItem: Text { - color: preset50.enabled && (preset50.hovered || preset50.activeFocus || root.preset50Amount === root.burnAmount) ? "#151515" : "#A9A098" - font.bold: true - font.pixelSize: 11 - horizontalAlignment: Text.AlignHCenter - text: preset50.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: preset50.activeFocus || root.preset50Amount === root.burnAmount ? "#F26A21" : "#343434" - border.width: 1 - color: preset50.pressed ? "#D95C1E" : root.preset50Amount === root.burnAmount ? "#F26A21" : preset50.hovered || preset50.activeFocus ? "#E7E1D8" : "#151515" - radius: 6 - } - } - - Button { - id: preset75 - - activeFocusOnTab: root.hasLpTokens - enabled: root.hasLpTokens - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("75%") - - Accessible.name: qsTr("Remove 75 percent") - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.setBurnPercent(75) - - contentItem: Text { - color: preset75.enabled && (preset75.hovered || preset75.activeFocus || root.preset75Amount === root.burnAmount) ? "#151515" : "#A9A098" - font.bold: true - font.pixelSize: 11 - horizontalAlignment: Text.AlignHCenter - text: preset75.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: preset75.activeFocus || root.preset75Amount === root.burnAmount ? "#F26A21" : "#343434" - border.width: 1 - color: preset75.pressed ? "#D95C1E" : root.preset75Amount === root.burnAmount ? "#F26A21" : preset75.hovered || preset75.activeFocus ? "#E7E1D8" : "#151515" - radius: 6 - } - } - - Button { - id: presetMax - - activeFocusOnTab: root.hasLpTokens - enabled: root.hasLpTokens - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("MAX") - - Accessible.name: qsTr("Remove maximum LP balance") - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.setBurnAmount(root.maxBurnAmount) - - contentItem: Text { - color: presetMax.enabled && (presetMax.hovered || presetMax.activeFocus || root.burnAmount === root.maxBurnAmount) ? "#151515" : "#A9A098" - font.bold: true - font.pixelSize: 11 - horizontalAlignment: Text.AlignHCenter - text: presetMax.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: presetMax.activeFocus || root.burnAmount === root.maxBurnAmount ? "#F26A21" : "#343434" - border.width: 1 - color: presetMax.pressed ? "#D95C1E" : root.burnAmount === root.maxBurnAmount ? "#F26A21" : presetMax.hovered || presetMax.activeFocus ? "#E7E1D8" : "#151515" - radius: 6 - } - } - } - - ColumnLayout { - spacing: 6 - - Layout.fillWidth: true - - RowLayout { - spacing: 8 - - Layout.fillWidth: true - - Text { - color: "#A9A098" - font.pixelSize: 12 - text: qsTr("Pool share to remove") - - Layout.fillWidth: true - } - - Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 12 - horizontalAlignment: Text.AlignRight - text: root.poolState.formatPercent(root.removePercent) - - Layout.maximumWidth: 72 - } - } - - Slider { - id: burnSlider - - activeFocusOnTab: root.hasLpTokens - enabled: root.hasLpTokens - from: 0 - stepSize: 1 - to: 100 - value: root.removePercent - - Accessible.name: qsTr("Pool share to remove") - Accessible.role: Accessible.Slider - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onMoved: root.setBurnPercent(Math.round(value)) - - background: Rectangle { - color: "#343434" - implicitHeight: 4 - radius: 2 - x: burnSlider.leftPadding - y: burnSlider.topPadding + burnSlider.availableHeight / 2 - height / 2 - - width: burnSlider.availableWidth - - Rectangle { - color: burnSlider.enabled ? "#F26A21" : "#56504A" - height: parent.height - radius: 2 - width: burnSlider.visualPosition * parent.width - } - } - - handle: Rectangle { - border.color: burnSlider.activeFocus ? "#E7E1D8" : "#F26A21" - border.width: 1 - color: burnSlider.enabled ? "#F26A21" : "#56504A" - height: 18 - radius: 9 - width: 18 - x: burnSlider.leftPadding + burnSlider.visualPosition * (burnSlider.availableWidth - width) - y: burnSlider.topPadding + burnSlider.availableHeight / 2 - height / 2 - } - } - } - - SummaryRow { - estimated: true - estimateHelp: root.estimateHelp - label: qsTr("Withdraw %1").arg(root.poolState.tokenA) - value: root.poolState.formatTokenAmount(root.preview.withdrawA, root.poolState.tokenA) - visible: root.burnAmount > 0 - - Layout.fillWidth: true - } - - SummaryRow { - estimated: true - estimateHelp: root.estimateHelp - label: qsTr("Withdraw %1").arg(root.poolState.tokenB) - value: root.poolState.formatTokenAmount(root.preview.withdrawB, root.poolState.tokenB) - visible: root.burnAmount > 0 - - Layout.fillWidth: true - } - - SlippageToleranceControl { - tolerancePercent: root.slippageTolerancePercent - - Layout.fillWidth: true - - onToleranceChangeRequested: function (tolerancePercent) { - root.slippageToleranceChangeRequested(tolerancePercent); - } - } - - SummaryRow { - label: qsTr("Min %1 received").arg(root.poolState.tokenA) - value: root.poolState.formatTokenAmount(root.minTokenAReceived, root.poolState.tokenA) - visible: root.burnAmount > 0 - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Min %1 received").arg(root.poolState.tokenB) - value: root.poolState.formatTokenAmount(root.minTokenBReceived, root.poolState.tokenB) - visible: root.burnAmount > 0 - - Layout.fillWidth: true - } - - Text { - color: "#F08A76" - font.pixelSize: 12 - lineHeight: 1.25 - text: qsTr("Minimum received is 0. Increase amount or lower slippage.") - visible: root.minReceivedIsZero - wrapMode: Text.WordWrap - - Layout.fillWidth: true - } - - SummaryRow { - estimated: true - estimateHelp: root.estimateHelp - label: qsTr("Position after") - value: root.poolState.formatPoolShare(root.preview.newUserShare) - visible: root.burnAmount > 0 - - Layout.fillWidth: true - } - - Button { - id: submitButton - - activeFocusOnTab: root.hasLpTokens - enabled: root.canSubmit - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: root.submitButtonText - - Accessible.name: submitButton.text - - Layout.fillWidth: true - Layout.minimumHeight: 44 - Layout.preferredHeight: 44 - - onClicked: root.removeLiquidityRequested(root.submitSnapshot()) - - contentItem: Text { - color: submitButton.enabled ? "#151515" : "#7D756E" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 13 - horizontalAlignment: Text.AlignHCenter - text: submitButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: submitButton.enabled ? "#F26A21" : "#343434" - border.width: 1 - color: submitButton.enabled ? submitButton.pressed ? "#D95C1E" : submitButton.hovered || submitButton.activeFocus ? "#FF8A3D" : "#F26A21" : "#181818" - radius: 6 - } - } - } - - function setBurnAmount(value) { - root.burnAmount = root.poolState.clampBurnAmount(value); - } - - function setBurnPercent(percent) { - root.setBurnAmount(root.poolState.burnAmountForPercent(percent)); - } - - function resetForm() { - root.setBurnAmount(0); - } - - function submitSnapshot() { - return { - "action": "remove", - "burnAmount": root.preview.burnedLp, - "burnPercent": root.poolState.formatPercent(root.removePercent), - "burnText": root.poolState.formatLpAmount(root.preview.burnedLp), - "minTokenAReceived": root.poolState.formatTokenAmount(root.minTokenAReceived, root.poolState.tokenA), - "minTokenBReceived": root.poolState.formatTokenAmount(root.minTokenBReceived, root.poolState.tokenB), - "postRemovalShare": root.poolState.formatPoolShare(root.preview.newUserShare), - "slippageTolerance": root.poolState.formatPercent(root.slippageTolerancePercent), - "tokenA": root.poolState.tokenA, - "tokenB": root.poolState.tokenB, - "withdrawA": root.preview.withdrawA, - "withdrawB": root.preview.withdrawB, - "withdrawAText": root.poolState.formatTokenAmount(root.preview.withdrawA, root.poolState.tokenA), - "withdrawBText": root.poolState.formatTokenAmount(root.preview.withdrawB, root.poolState.tokenB) - }; - } -} diff --git a/apps/amm/qml/components/liquidity/SummaryRow.qml b/apps/amm/qml/components/liquidity/SummaryRow.qml index 0c34046..849ecb6 100644 --- a/apps/amm/qml/components/liquidity/SummaryRow.qml +++ b/apps/amm/qml/components/liquidity/SummaryRow.qml @@ -6,6 +6,7 @@ Item { property string label: "" property string value: "" + property bool valueWrapAnywhere: false property bool estimated: false property string estimateHelp: qsTr("This value is derived from your LP token balance, total LP supply, and current pool reserves.") @@ -36,14 +37,16 @@ Item { Text { color: "#E7E1D8" - elide: Text.ElideRight + elide: root.valueWrapAnywhere ? Text.ElideNone : Text.ElideRight font.bold: true font.pixelSize: 12 horizontalAlignment: Text.AlignRight text: root.value verticalAlignment: Text.AlignVCenter + wrapMode: root.valueWrapAnywhere ? Text.WrapAtWordBoundaryOrAnywhere : Text.NoWrap Layout.maximumWidth: Math.max(178, root.width * 0.55) + Layout.preferredWidth: Math.min(implicitWidth, Math.max(178, root.width * 0.55)) } EstimateInfoButton { diff --git a/apps/amm/qml/components/liquidity/TokenAmountInput.qml b/apps/amm/qml/components/liquidity/TokenAmountInput.qml index 49c7509..311276c 100644 --- a/apps/amm/qml/components/liquidity/TokenAmountInput.qml +++ b/apps/amm/qml/components/liquidity/TokenAmountInput.qml @@ -1,165 +1,135 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts +pragma ComponentBehavior: Bound -Rectangle { +import QtQuick + +import "../shared" + +AmmTokenAmountSurface { id: root - property alias text: amountField.text + property string text: "" property string balance: "" - property string errorText: "" property string helperText: "" - property string label: "" - property bool readOnly: false property bool showMaxButton: true - property string token: "" + property var tokenData: null + property var tokens: [] + property string selectedTokenId: "" + property bool tokenInvalid: false + property bool tokenSelectionEnabled: true + property bool editPending: false + property string pendingValue: "" + property var disabledReasonForCode: function(code) { + return qsTr("This token is unavailable (%1).").arg(code || "unknown") + } + property var detailForToken: function(token) { return "" } + property alias popup: tokenModal + property alias query: tokenModal.searchText + readonly property var rows: tokenModal.rows signal editingChanged(string value) + signal editingCommitted(string value) signal maxClicked + signal tokenSelected(string tokenId) + signal tokenEntered(string value) - color: "#151515" - implicitHeight: content.implicitHeight + 20 - radius: 8 - border.color: root.errorText.length > 0 ? "#D85F4B" : amountField.activeFocus ? "#F26A21" : "#343434" - border.width: 1 + amount: root.text + supportingText: root.helperText + supportingActionText: root.showMaxButton ? qsTr("MAX") : "" + accessory: tokenActions + accessoryWidth: width < 360 ? 132 : 180 + accessoryHeight: root.balance.length > 0 ? 58 : 40 - Accessible.name: root.label - Accessible.role: Accessible.EditableText - - ColumnLayout { - id: content - - anchors.fill: parent - anchors.margins: 10 - spacing: 8 - - RowLayout { - spacing: 8 - - Layout.fillWidth: true - - Text { - color: "#A9A098" - elide: Text.ElideRight - font.pixelSize: 12 - text: root.label - - Layout.fillWidth: true - } - - Text { - color: "#E7E1D8" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 12 - horizontalAlignment: Text.AlignRight - text: root.token - - Layout.maximumWidth: 76 - } - } - - RowLayout { - spacing: 8 - - Layout.fillWidth: true - - TextField { - id: amountField - - activeFocusOnTab: true - color: "#E7E1D8" - font.bold: true - font.pixelSize: 18 - inputMethodHints: Qt.ImhFormattedNumbersOnly - maximumLength: 80 - placeholderText: qsTr("0") - readOnly: root.readOnly - selectByMouse: true - selectedTextColor: "#151515" - selectionColor: "#F26A21" - validator: RegularExpressionValidator { - regularExpression: /[0-9]*([.][0-9]*)?/ - } - - Accessible.name: root.label - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onTextEdited: { - if (!root.readOnly) - root.editingChanged(text); - } - - background: Rectangle { - border.color: amountField.activeFocus ? "#F26A21" : "#343434" - border.width: 1 - color: amountField.activeFocus ? "#1F1B18" : "#101010" - radius: 6 - } - } - - Button { - id: maxButton - - activeFocusOnTab: true - enabled: root.showMaxButton && !root.readOnly - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("MAX") - visible: root.showMaxButton - - Accessible.name: qsTr("Use maximum %1 balance").arg(root.token) - - Layout.minimumHeight: 44 - Layout.preferredWidth: visible ? 58 : 0 - - onClicked: root.maxClicked() - - contentItem: Text { - color: maxButton.activeFocus || maxButton.hovered ? "#151515" : "#F26A21" - font.bold: true - font.pixelSize: 11 - horizontalAlignment: Text.AlignHCenter - text: maxButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: "#F26A21" - border.width: 1 - color: maxButton.pressed ? "#D95C1E" : maxButton.hovered || maxButton.activeFocus ? "#F26A21" : "#201712" - radius: 6 - } - } - } - - RowLayout { - spacing: 8 - - Layout.fillWidth: true - - Text { - color: root.errorText.length > 0 ? "#F08A76" : root.helperText.length > 0 ? "#F26A21" : "#A9A098" - elide: Text.ElideRight - font.pixelSize: 11 - text: root.errorText.length > 0 ? root.errorText : root.helperText - visible: text.length > 0 - - Layout.fillWidth: true - } - - Text { - color: "#A9A098" - elide: Text.ElideRight - font.pixelSize: 11 - horizontalAlignment: Text.AlignRight - text: qsTr("Balance %1").arg(root.balance) - - Layout.alignment: Qt.AlignRight - Layout.maximumWidth: 150 - } + onAmountEdited: function(value) { + root.pendingValue = value + root.editPending = true + root.editingChanged(value) + commitTimer.restart() + } + onAmountEditingFinished: function(value) { + root.pendingValue = value + root.commitPendingEdit() + } + onSupportingActionClicked: root.maxClicked() + onTextChanged: { + if (root.editPending && root.text !== root.pendingValue) { + commitTimer.stop() + root.editPending = false } } + + Timer { + id: commitTimer + + interval: 250 + repeat: false + onTriggered: root.commitPendingEdit() + } + + Component { + id: tokenActions + + AmmTokenAccessory { + theme: root.theme + enabled: root.tokenSelectionEnabled + invalid: root.tokenInvalid + hasToken: root.tokenData !== null + tokenColor: root.tokenColor(root.tokenData) + tokenLetter: root.tokenLetter(root.tokenData) + tokenText: root.tokenText(root.tokenData) + balance: root.balance + accessibleName: qsTr("Select %1").arg(root.label) + onClicked: tokenModal.open() + } + } + + TokenSelectorModal { + id: tokenModal + + theme: root.theme + tokens: root.tokens + title: qsTr("Select a token") + searchPlaceholder: qsTr("Search name or address") + popularTitle: qsTr("Quick select") + listTitle: qsTr("All tokens") + allowCustomEntry: true + disabledReasonForCode: root.disabledReasonForCode + detailForToken: root.detailForToken + + onTokenSelected: function(token) { + root.tokenSelected(String(token.definitionId || token.address || "")) + } + onTokenEntered: function(value) { root.tokenEntered(value) } + } + + function acceptInput(value) { + tokenModal.acceptInput(value) + } + + function commitPendingEdit() { + if (!root.editPending) + return + commitTimer.stop() + root.editPending = false + root.editingCommitted(root.pendingValue) + } + + function tokenText(token) { + if (!token) + return qsTr("Select token") + return String(token.symbol || token.name || root.shortId(root.selectedTokenId)) + } + + function tokenLetter(token) { + var text = root.tokenText(token) + return token ? String(token.letter || text.charAt(0).toUpperCase()) : "" + } + + function tokenColor(token) { + return token && token.color ? token.color : root.theme.colors.noTokenCircle + } + + function shortId(value) { + var text = String(value || "") + return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text + } } diff --git a/apps/amm/qml/components/liquidity/TokenSelectorModal.qml b/apps/amm/qml/components/liquidity/TokenSelectorModal.qml new file mode 100644 index 0000000..c07b725 --- /dev/null +++ b/apps/amm/qml/components/liquidity/TokenSelectorModal.qml @@ -0,0 +1,489 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Popup { + id: root + + required property var theme + property var tokens: [] + property string searchText: "" + property string title: qsTr("Select a token") + property string searchPlaceholder: qsTr("Search tokens") + property string popularTitle: qsTr("Popular tokens") + property string listTitle: qsTr("Tokens by 24H volume") + property bool allowCustomEntry: false + property var disabledReasonForCode: function(code) { + return qsTr("This token is unavailable (%1).").arg(code || "unknown") + } + property var detailForToken: function(token) { + return token && token.balance !== undefined + ? qsTr("Balance %1").arg(token.balance) : "" + } + readonly property var rows: root.filteredTokens() + readonly property var popularTokens: root.selectableTokens().slice(0, 5) + readonly property bool compact: root.height < 360 + readonly property bool showPopular: root.popularTokens.length > 0 && root.height >= 440 + + signal tokenSelected(var token) + signal tokenEntered(string value) + + parent: Overlay.overlay + x: parent ? Math.round((parent.width - width) / 2) : 0 + y: parent ? Math.round((parent.height - height) / 2) : 0 + width: parent && parent.width > 32 + ? Math.max(0, Math.min(480, parent.width - 32)) : 280 + height: parent && parent.height > 32 + ? Math.max(0, Math.min(600, parent.height - 32)) : 360 + padding: root.compact ? 8 : 20 + modal: true + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + onOpened: { + root.searchText = "" + searchField.text = "" + Qt.callLater(searchField.forceActiveFocus) + } + + Overlay.modal: Rectangle { + color: Qt.rgba(0, 0, 0, 0.4) + } + + background: Rectangle { + radius: 24 + color: root.theme.colors.cardBg + border.color: root.theme.colors.border + border.width: 1 + + Behavior on color { + ColorAnimation { duration: 180 } + } + } + + contentItem: ColumnLayout { + spacing: root.compact ? 8 : 16 + + RowLayout { + Layout.fillWidth: true + + Text { + Layout.fillWidth: true + text: root.title + color: root.theme.colors.textPrimary + font.pixelSize: 18 + font.weight: Font.Bold + } + + Button { + id: closeButton + + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + text: "\u00D7" + hoverEnabled: true + Accessible.name: qsTr("Close token selector") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + onClicked: root.close() + + contentItem: Text { + text: closeButton.text + color: root.theme.colors.textSecondary + font.pixelSize: 16 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 16 + color: closeButton.hovered || closeButton.activeFocus + ? root.theme.colors.panelHoverBg + : root.theme.colors.panelBg + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: root.compact ? 40 : 48 + radius: 16 + color: root.theme.colors.inputBg + border.color: searchField.activeFocus + ? root.theme.colors.ctaBg + : root.theme.colors.border + border.width: 1 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 8 + + Text { + text: "\u2315" + color: root.theme.colors.textSecondary + font.pixelSize: 20 + } + + TextField { + id: searchField + + objectName: "tokenSearchField" + + Layout.fillWidth: true + color: root.theme.colors.textPrimary + placeholderText: root.searchPlaceholder + placeholderTextColor: root.theme.colors.textPlaceholder + selectionColor: root.theme.colors.selection + selectedTextColor: root.theme.colors.textPrimary + font.pixelSize: 15 + selectByMouse: true + background: null + + onTextEdited: root.searchText = text + onAccepted: root.acceptInput(text) + } + } + } + + Text { + Layout.fillWidth: true + visible: root.showPopular + text: root.popularTitle + color: root.theme.colors.textSecondary + font.pixelSize: 13 + } + + Flow { + Layout.fillWidth: true + visible: root.showPopular + spacing: 8 + + Repeater { + model: root.popularTokens + + delegate: AmmTokenSelectButton { + required property var modelData + + theme: root.theme + hasToken: true + tokenColor: root.tokenColor(modelData) + tokenLetter: root.tokenLetter(modelData) + showIndicator: false + text: root.tokenSymbol(modelData).length > 0 + ? root.tokenSymbol(modelData) : root.tokenName(modelData) + maximumTextWidth: 84 + width: Math.min(140, implicitWidth) + onClicked: root.chooseToken(modelData) + } + } + } + + Text { + Layout.fillWidth: true + visible: !root.compact + text: root.listTitle + color: root.theme.colors.textSecondary + font.pixelSize: 13 + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + ListView { + id: tokenList + + objectName: "tokenList" + + anchors.fill: parent + clip: true + spacing: 2 + model: root.rows + boundsBehavior: Flickable.StopAtBounds + ScrollIndicator.vertical: ScrollIndicator { } + + delegate: Item { + id: tokenRow + + required property var modelData + readonly property bool selectable: root.isSelectable(modelData) + readonly property string disabledReason: root.disabledReasonForCode( + modelData.code + || modelData.status) + readonly property bool pointerHovered: rowHover.containsMouse + readonly property bool disabledReasonVisible: (pointerHovered || activeFocus) + && !selectable + + width: ListView.view.width + height: 56 + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: root.tokenName(modelData) + Accessible.description: selectable + ? root.detailForToken(modelData) + : disabledReason + Accessible.onPressAction: tokenRow.activate() + Keys.onReturnPressed: function(event) { + tokenRow.activate() + event.accepted = true + } + Keys.onEnterPressed: function(event) { + tokenRow.activate() + event.accepted = true + } + Keys.onSpacePressed: function(event) { + tokenRow.activate() + event.accepted = true + } + + Rectangle { + anchors.fill: parent + radius: 12 + color: tokenRow.pointerHovered || tokenRow.activeFocus + ? root.theme.colors.panelBg : "transparent" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 12 + + Rectangle { + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + radius: 18 + color: root.tokenColor(tokenRow.modelData) + + Text { + anchors.centerIn: parent + text: root.tokenLetter(tokenRow.modelData) + color: "#FFFFFF" + font.pixelSize: 14 + font.weight: Font.Bold + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + Layout.fillWidth: true + text: root.tokenName(tokenRow.modelData) + color: tokenRow.selectable + ? root.theme.colors.textPrimary + : root.theme.colors.textPlaceholder + font.pixelSize: 15 + elide: Text.ElideRight + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Text { + visible: text.length > 0 + text: root.tokenSymbol(tokenRow.modelData) + color: root.theme.colors.textSecondary + font.pixelSize: 12 + } + + Text { + Layout.fillWidth: true + text: root.shortAddress(root.tokenAddress(tokenRow.modelData)) + color: root.theme.colors.textPlaceholder + font.family: "monospace" + font.pixelSize: 11 + elide: Text.ElideMiddle + } + } + } + + Text { + Layout.maximumWidth: 118 + text: root.detailForToken(tokenRow.modelData) + color: root.theme.colors.textSecondary + font.pixelSize: 11 + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + } + } + } + + MouseArea { + id: rowHover + + anchors.fill: parent + hoverEnabled: true + cursorShape: tokenRow.selectable + ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: tokenRow.activate() + } + + ToolTip.visible: tokenRow.disabledReasonVisible + ToolTip.text: tokenRow.disabledReason + + function activate() { + if (tokenRow.selectable) + root.chooseToken(tokenRow.modelData) + } + } + } + + Button { + id: customEntryButton + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + visible: root.rows.length === 0 + && root.allowCustomEntry + && root.searchText.trim().length > 0 + implicitHeight: 56 + text: qsTr("Use TokenDefinition address") + onClicked: root.acceptInput(root.searchText) + + contentItem: Column { + leftPadding: 8 + spacing: 2 + + Text { + width: parent.width - 16 + text: qsTr("Use TokenDefinition address") + color: root.theme.colors.textPrimary + font.pixelSize: 14 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width - 16 + text: root.searchText + color: root.theme.colors.textSecondary + font.family: "monospace" + font.pixelSize: 11 + elide: Text.ElideMiddle + } + } + + background: Rectangle { + radius: 12 + color: customEntryButton.hovered || customEntryButton.activeFocus + ? root.theme.colors.panelBg : "transparent" + } + } + + Text { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 16 + visible: root.rows.length === 0 + && (!root.allowCustomEntry + || root.searchText.trim().length === 0) + text: qsTr("No tokens found") + color: root.theme.colors.textSecondary + font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter + } + } + } + + function filteredTokens() { + var needle = root.searchText.trim().toLowerCase() + if (needle.length === 0) + return root.tokens + var result = [] + for (var i = 0; i < root.tokens.length; ++i) { + var token = root.tokens[i] + if (root.tokenName(token).toLowerCase().indexOf(needle) >= 0 + || root.tokenSymbol(token).toLowerCase().indexOf(needle) >= 0 + || root.tokenAddress(token).toLowerCase().indexOf(needle) >= 0) + result.push(token) + } + return result + } + + function selectableTokens() { + var result = [] + for (var i = 0; i < root.tokens.length; ++i) { + if (root.isSelectable(root.tokens[i])) + result.push(root.tokens[i]) + } + return result + } + + function acceptInput(value) { + var entered = String(value || "").trim() + if (entered.length === 0) + return + var lowered = entered.toLowerCase() + for (var i = 0; i < root.tokens.length; ++i) { + var token = root.tokens[i] + if (root.tokenAddress(token).toLowerCase() === lowered + || root.tokenName(token).toLowerCase() === lowered + || root.tokenSymbol(token).toLowerCase() === lowered) { + if (root.isSelectable(token)) + root.chooseToken(token) + else { + root.tokenEntered(root.tokenAddress(token)) + root.close() + } + return + } + } + if (root.rows.length === 1 && root.isSelectable(root.rows[0])) { + root.chooseToken(root.rows[0]) + return + } + if (root.allowCustomEntry) { + root.tokenEntered(entered) + root.close() + } + } + + function chooseToken(token) { + if (!root.isSelectable(token)) + return + root.tokenSelected(token) + root.close() + } + + function isSelectable(token) { + return token && token.selectable !== false + } + + function tokenName(token) { + if (!token) + return qsTr("Unknown token") + return String(token.name || token.symbol || qsTr("Unknown token")) + } + + function tokenSymbol(token) { + return token ? String(token.symbol || "") : "" + } + + function tokenAddress(token) { + return token ? String(token.definitionId || token.address || "") : "" + } + + function tokenColor(token) { + return token && token.color ? token.color : root.theme.colors.noTokenCircle + } + + function tokenLetter(token) { + if (token && token.letter) + return String(token.letter) + var label = root.tokenSymbol(token) || root.tokenName(token) + return label.length > 0 ? label.charAt(0).toUpperCase() : "" + } + + function shortAddress(value) { + var text = String(value || "") + return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text + } +} diff --git a/apps/amm/qml/components/pool/PoolStepRail.qml b/apps/amm/qml/components/pool/PoolStepRail.qml deleted file mode 100644 index a2ee12e..0000000 --- a/apps/amm/qml/components/pool/PoolStepRail.qml +++ /dev/null @@ -1,116 +0,0 @@ -import QtQuick -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// Vertical progress rail for the pool-creation flow (Uniswap-style): numbered -// steps connected by a line, with the active step highlighted and completed -// steps marked done. Read currentStep to drive which step is active. Clicking an -// already-reached step (index <= currentStep) emits stepClicked so the page can -// navigate back to it. -Item { - id: root - - property int currentStep: 0 - readonly property var steps: [ - { title: qsTr("Select token pair"), subtitle: qsTr("Pick the two tokens for the pool.") }, - { title: qsTr("Deposit amounts"), subtitle: qsTr("Set the initial liquidity.") } - ] - - signal stepClicked(int index) - - implicitWidth: 240 - implicitHeight: column.implicitHeight - - ColumnLayout { - id: column - anchors.fill: parent - spacing: 0 - - Repeater { - model: root.steps - - delegate: Item { - id: stepItem - - readonly property bool active: index === root.currentStep - readonly property bool done: index < root.currentStep - readonly property bool last: index === root.steps.length - 1 - // Only steps already reached can be clicked (no jumping ahead). - readonly property bool reachable: index <= root.currentStep - - Layout.fillWidth: true - implicitHeight: stepRow.implicitHeight - - RowLayout { - id: stepRow - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - spacing: Theme.spacing.medium - - // Indicator: numbered dot + connector line down to the next dot. - Item { - Layout.preferredWidth: 28 - Layout.fillHeight: true - - Rectangle { - id: dot - width: 28 - height: 28 - radius: 14 - color: (stepItem.active || stepItem.done) ? Theme.palette.primary : Theme.palette.backgroundSecondary - border.width: 1 - border.color: (stepItem.active || stepItem.done) ? Theme.palette.primary : Theme.palette.border - - LogosText { - anchors.centerIn: parent - text: stepItem.done ? "✓" : (index + 1) - font.pixelSize: Theme.typography.secondaryText - font.bold: true - color: (stepItem.active || stepItem.done) ? Theme.palette.background : Theme.palette.textSecondary - } - } - Rectangle { - visible: !stepItem.last - width: 2 - anchors.top: dot.bottom - anchors.bottom: parent.bottom - anchors.horizontalCenter: dot.horizontalCenter - color: stepItem.done ? Theme.palette.primary : Theme.palette.border - } - } - - // Step text. - ColumnLayout { - Layout.fillWidth: true - Layout.bottomMargin: stepItem.last ? 0 : Theme.spacing.xlarge - spacing: 2 - - LogosText { - text: modelData.title - font.pixelSize: Theme.typography.primaryText - font.bold: stepItem.active - color: (stepItem.active || stepItem.done) ? Theme.palette.text : Theme.palette.textSecondary - } - LogosText { - Layout.fillWidth: true - text: modelData.subtitle - wrapMode: Text.WordWrap - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - } - } - - MouseArea { - anchors.fill: parent - enabled: stepItem.reachable - cursorShape: stepItem.reachable ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: root.stepClicked(index) - } - } - } - } -} diff --git a/apps/amm/qml/components/wallet/AccountControl.qml b/apps/amm/qml/components/wallet/AccountControl.qml deleted file mode 100644 index bc3ce8a..0000000 --- a/apps/amm/qml/components/wallet/AccountControl.qml +++ /dev/null @@ -1,461 +0,0 @@ -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/CreatePoolPage.qml b/apps/amm/qml/pages/CreatePoolPage.qml deleted file mode 100644 index e4732b6..0000000 --- a/apps/amm/qml/pages/CreatePoolPage.qml +++ /dev/null @@ -1,270 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -import "../components/pool" - -// Two-column pool-creation flow (Uniswap-style): a vertical step rail on the -// left and the active step's panel on the right. Step 1 selects the token pair; -// step 2 enters deposit amounts. Both panels stay alive so the entered values -// persist when navigating between steps via the rail. -Item { - id: root - - readonly property int pageMargin: 24 - // Breathing room below the navbar. The Trade page fully centers its card - // (gap = leftover space / 2); this uses a quarter of the leftover so it sits - // roughly half as far down, scaling with the window, with a sensible floor. - readonly property int topMargin: Math.max(48, Math.round((scroll.height - content.implicitHeight) / 4)) - readonly property int contentWidth: 760 - - // 0x123456…cdef style truncation for showing token addresses compactly. - function truncated(addr) { - const a = (addr || "").trim() - return a.length > 13 ? (a.substring(0, 6) + "…" + a.substring(a.length - 4)) : a - } - - // Numbers only (digits + a single decimal point) for the deposit amounts. - RegularExpressionValidator { - id: amountValidator - regularExpression: /^[0-9]*\.?[0-9]*$/ - } - - Rectangle { - anchors.fill: parent - color: Theme.palette.background - } - - Flickable { - id: scroll - anchors.fill: parent - clip: true - contentWidth: width - contentHeight: Math.max(height, content.implicitHeight + root.topMargin + root.pageMargin) - flickableDirection: Flickable.VerticalFlick - - RowLayout { - id: content - x: Math.max(root.pageMargin, (scroll.width - width) / 2) - y: root.topMargin - width: Math.min(scroll.width - root.pageMargin * 2, root.contentWidth) - spacing: Theme.spacing.xxlarge - - PoolStepRail { - id: rail - currentStep: 0 - Layout.preferredWidth: 240 - Layout.alignment: Qt.AlignTop - // Jump back to an already-reached step (e.g. step 1 to re-pick - // tokens). Selections persist because both panels stay alive. - onStepClicked: (index) => { rail.currentStep = index } - } - - // ── Right: active step's panel (both kept alive to preserve state) ── - Item { - id: rightPane - Layout.fillWidth: true - Layout.alignment: Qt.AlignTop - Layout.preferredHeight: rail.currentStep === 0 - ? selectCard.implicitHeight - : depositCard.implicitHeight - - // ── Step 1: select pair ────────────────────────────────── - Rectangle { - id: selectCard - width: parent.width - visible: rail.currentStep === 0 - implicitHeight: selectCol.implicitHeight + Theme.spacing.large * 2 - radius: Theme.spacing.radiusLarge - color: Theme.palette.backgroundSecondary - border.width: 1 - border.color: Theme.palette.borderSecondary - - ColumnLayout { - id: selectCol - anchors.fill: parent - anchors.margins: Theme.spacing.large - spacing: Theme.spacing.medium - - LogosText { - text: qsTr("Select pair") - font.pixelSize: Theme.typography.panelTitleText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - LogosText { - Layout.fillWidth: true - text: qsTr("Choose the two tokens for your pool by entering each token's address.") - wrapMode: Text.WordWrap - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - - LogosText { - Layout.topMargin: Theme.spacing.small - text: qsTr("Token A address") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - LogosTextField { - id: tokenAField - Layout.fillWidth: true - placeholderText: "0x…" - } - - LogosText { - Layout.topMargin: Theme.spacing.small - text: qsTr("Token B address") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - LogosTextField { - id: tokenBField - Layout.fillWidth: true - placeholderText: "0x…" - } - - LogosButton { - Layout.fillWidth: true - Layout.topMargin: Theme.spacing.medium - height: 44 - text: qsTr("Continue") - enabled: tokenAField.text.trim().length > 0 - && tokenBField.text.trim().length > 0 - && tokenAField.text.trim() !== tokenBField.text.trim() - onClicked: rail.currentStep = 1 - } - } - } - - // ── Step 2: deposit amounts ────────────────────────────── - Rectangle { - id: depositCard - width: parent.width - visible: rail.currentStep === 1 - implicitHeight: depositCol.implicitHeight + Theme.spacing.large * 2 - radius: Theme.spacing.radiusLarge - color: Theme.palette.backgroundSecondary - border.width: 1 - border.color: Theme.palette.borderSecondary - - ColumnLayout { - id: depositCol - anchors.fill: parent - anchors.margins: Theme.spacing.large - spacing: Theme.spacing.medium - - LogosText { - text: qsTr("Deposit amounts") - font.pixelSize: Theme.typography.panelTitleText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - LogosText { - Layout.fillWidth: true - text: qsTr("Set the initial liquidity for the pool.") - wrapMode: Text.WordWrap - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - - // Token pair carried over from step 1. - Rectangle { - Layout.fillWidth: true - Layout.topMargin: Theme.spacing.small - implicitHeight: pairCol.implicitHeight + Theme.spacing.medium * 2 - radius: Theme.spacing.radiusLarge - color: Theme.palette.backgroundTertiary - border.width: 1 - border.color: Theme.palette.borderSecondary - - ColumnLayout { - id: pairCol - anchors.fill: parent - anchors.margins: Theme.spacing.medium - spacing: Theme.spacing.small - - LogosText { - text: qsTr("Selected pair") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - RowLayout { - Layout.fillWidth: true - LogosText { - text: qsTr("Token A") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - Item { Layout.fillWidth: true } - LogosText { - text: root.truncated(tokenAField.text) - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.text - } - } - RowLayout { - Layout.fillWidth: true - LogosText { - text: qsTr("Token B") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - Item { Layout.fillWidth: true } - LogosText { - text: root.truncated(tokenBField.text) - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.text - } - } - } - } - - // Amount inputs, one per token. - LogosText { - Layout.topMargin: Theme.spacing.small - text: qsTr("Token A amount") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - LogosTextField { - id: amountAField - Layout.fillWidth: true - placeholderText: "0.0" - Component.onCompleted: textInput.validator = amountValidator - } - - LogosText { - Layout.topMargin: Theme.spacing.small - text: qsTr("Token B amount") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - LogosTextField { - id: amountBField - Layout.fillWidth: true - placeholderText: "0.0" - Component.onCompleted: textInput.validator = amountValidator - } - - LogosButton { - Layout.fillWidth: true - Layout.topMargin: Theme.spacing.medium - height: 44 - text: qsTr("Create pool") - enabled: parseFloat(amountAField.text) > 0 - && parseFloat(amountBField.text) > 0 - // Wiring to the AMM new_definition instruction is a follow-up. - onClicked: console.log("create pool", - tokenAField.text, amountAField.text, - tokenBField.text, amountBField.text) - } - } - } - } - } - } -} diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index debc5d1..503a680 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -1,56 +1,35 @@ import QtQuick import QtQml +import Logos.Wallet import "../components/liquidity" +import "../state" Item { id: root property var backend: null - property var newPositionContext: ({ - "schema": "new-position.v1", - "status": "loading", - "tokens": [], - "feeTiers": [] - }) - property var newPositionQuote: ({}) - property var resolvedTokenIds: [] - property int quoteSerial: 0 - 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: "" + property var runtime: null + readonly property NewPositionFlow flow: newPositionFlow readonly property int pageMargin: 16 - readonly property int preferredWidth: 800 + readonly property int preferredWidth: 480 - Component.onCompleted: { - root.componentReady = true - root.refreshNewPositionContext() + AmmTheme { + id: theme } - onBackendChanged: { - if (root.componentReady) - root.refreshNewPositionContext() - } + NewPositionFlow { + id: newPositionFlow - Connections { - target: root.backend - ignoreUnknownSignals: true - - function onNewPositionContextChanged(value) { - root.adoptContext(value) - } + backend: root.backend + runtime: root.runtime + active: root.visible } Rectangle { anchors.fill: parent - color: "#151515" + color: theme.colors.background } Flickable { @@ -60,7 +39,7 @@ Item { clip: true contentWidth: width contentHeight: Math.max(height, form.y + form.implicitHeight + root.pageMargin) - enabled: !confirmationDialog.open + enabled: !confirmationDialog.opened flickableDirection: Flickable.VerticalFlick NewPositionForm { @@ -69,18 +48,12 @@ Item { 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)) + theme: theme + newPositionContext: newPositionFlow.newPositionContext + flowState: newPositionFlow.viewState - newPositionContext: root.newPositionContext - quotePayload: root.newPositionQuote - contextLoading: root.contextLoading - quoteLoading: root.quoteLoading - quoteStale: root.quoteStale - submitting: root.submitting - transactionId: root.transactionId - refreshWarning: root.refreshWarning - - onQuoteRequested: function(immediate) { - root.scheduleQuote(immediate) + onQuoteRequested: function(immediate, quoteRequest) { + newPositionFlow.scheduleQuote(immediate, quoteRequest) } onConfirmationRequested: function(snapshot) { @@ -88,217 +61,51 @@ Item { } onTokenResolveRequested: function(tokenId) { - root.resolveToken(tokenId) + newPositionFlow.resolveToken(tokenId) } - onDraftChanged: { - root.transactionId = "" - root.refreshWarning = "" - } - - onRefreshRequested: root.refreshNewPositionContext() + onDraftChanged: newPositionFlow.draftChanged() + onRefreshRequested: newPositionFlow.refreshContext(true) } } - Timer { - id: quoteDebounce - interval: 250 - repeat: false - onTriggered: root.requestQuoteNow(root.quoteSerial) + Connections { + target: newPositionFlow + + function onTokenResolutionFinished(finalResponse) { + form.finishTokenResolution(finalResponse) + } + + function onTokenResolutionFailed(code) { + form.failTokenResolution(code) + } + + function onPoolActivated(quote) { + form.acceptPoolActivation(quote) + } + + function onQuoteRefreshRequested(immediate) { + form.requestQuote(immediate) + } + } - NewPositionConfirmationDialog { + Component { + id: liquidityConfirmationSummary + + LiquidityConfirmationSummary { } + } + + TransactionConfirmationDialog { id: confirmationDialog - anchors.fill: parent - busy: root.submitting + title: qsTr("Confirm new position") + confirmText: qsTr("Submit") + busy: newPositionFlow.submitting + summary: liquidityConfirmationSummary 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 - form.submitError = "" - - if (!root.backend || typeof logos === "undefined") { - root.finishSubmitFailure(root.quoteError("wallet_unavailable")) - return - } - - logos.watch(root.backend.submitNewPosition(snapshot.request, snapshot.quoteHash), - function(result) { - 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 - } - root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed")) - }, - function(error) { - root.finishSubmitFailure(root.quoteError("wallet_submission_failed")) - }) - } - - 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 contextError(code) { - return { - "schema": "new-position.v1", - "status": "error", - "code": code, - "tokens": [], - "feeTiers": [] - } - } - - function quoteError(code) { - return { - "schema": "new-position.v1", - "status": "error", - "canSubmit": false, - "code": code, - "poolStatus": "unavailable_pool", - "errors": [{ - "code": code, - "blockingFields": [], - "details": ({}) - }], - "warnings": [], - "accountPreview": [] + newPositionFlow.confirm(snapshot) } } } diff --git a/apps/amm/qml/state/DummyPoolState.qml b/apps/amm/qml/state/DummyPoolState.qml deleted file mode 100644 index 7e4d573..0000000 --- a/apps/amm/qml/state/DummyPoolState.qml +++ /dev/null @@ -1,205 +0,0 @@ -import QtQuick 2.15 - -QtObject { - id: root - - property string tokenA: "USDC" - property string tokenB: "ETH" - property string feeTier: "0.30%" - property real userLpBalance: 1118033 - property real reserveA: 1000000 - property real reserveB: 500 - property real totalLpSupply: 22360679 - property real walletBalanceA: 60000 - property real walletBalanceB: 20 - readonly property real minimumLiquidity: 1000 - - readonly property real poolShare: totalLpSupply > 0 ? userLpBalance / totalLpSupply : 0 - readonly property real userOwnedA: reserveA * poolShare - readonly property real userOwnedB: reserveB * poolShare - readonly property real tokenAPerTokenB: reserveB > 0 ? Math.floor(reserveA / reserveB) : 0 - - function applyAddLiquidity(actualA, actualB, mintedLp) { - const safeA = Math.max(0, Number(actualA) || 0); - const safeB = Math.max(0, Number(actualB) || 0); - const safeLp = Math.max(0, Number(mintedLp) || 0); - - reserveA += safeA; - reserveB += safeB; - totalLpSupply += safeLp; - userLpBalance += safeLp; - } - - function applyRemoveLiquidity(withdrawA, withdrawB, burnedLp) { - const safeA = Math.max(0, Number(withdrawA) || 0); - const safeB = Math.max(0, Number(withdrawB) || 0); - const safeLp = Math.max(0, Number(burnedLp) || 0); - - reserveA = Math.max(0, reserveA - safeA); - reserveB = Math.max(0, reserveB - safeB); - totalLpSupply = Math.max(0, totalLpSupply - safeLp); - userLpBalance = Math.max(0, userLpBalance - safeLp); - } - - function resetDummyState() { - tokenA = "USDC"; - tokenB = "ETH"; - feeTier = "0.30%"; - userLpBalance = 1118033; - reserveA = 1000000; - reserveB = 500; - totalLpSupply = 22360679; - walletBalanceA = 60000; - walletBalanceB = 20; - } - - function parseAmount(value) { - return Math.max(0, Number(value) || 0); - } - - function floorAmount(value) { - return Math.floor(parseAmount(value)); - } - - function amountBForA(amountA) { - if (reserveA <= 0) { - return 0; - } - - return reserveB * parseAmount(amountA) / reserveA; - } - - function amountAForB(amountB) { - if (reserveB <= 0) { - return 0; - } - - return reserveA * parseAmount(amountB) / reserveB; - } - - function addLiquidityPreview(maxA, maxB) { - const safeMaxA = parseAmount(maxA); - const safeMaxB = parseAmount(maxB); - const idealA = reserveB > 0 ? reserveA * safeMaxB / reserveB : 0; - const idealB = reserveA > 0 ? reserveB * safeMaxA / reserveA : 0; - const actualA = Math.min(idealA, safeMaxA); - const actualB = Math.min(idealB, safeMaxB); - const lpFromA = reserveA > 0 ? Math.floor(totalLpSupply * actualA / reserveA) : 0; - const lpFromB = reserveB > 0 ? Math.floor(totalLpSupply * actualB / reserveB) : 0; - - return { - "actualA": actualA, - "actualB": actualB, - "deltaLp": Math.min(lpFromA, lpFromB), - "idealA": idealA, - "idealB": idealB - }; - } - - function maxAddLiquidityForBalances() { - return addLiquidityPreview(walletBalanceA, walletBalanceB); - } - - function clampBurnAmount(value) { - return Math.min(floorAmount(value), Math.max(0, floorAmount(userLpBalance))); - } - - function clampSlippageTolerancePercent(value) { - return Math.max(0.01, Math.min(50, Number(value) || 0)); - } - - function minReceivedAmount(previewAmount, slippageTolerancePercent) { - const safeAmount = floorAmount(previewAmount); - const safeSlippage = clampSlippageTolerancePercent(slippageTolerancePercent); - - return Math.floor(safeAmount * (1 - safeSlippage / 100)); - } - - function burnAmountForPercent(percent) { - const safePercent = Math.max(0, Math.min(100, Number(percent) || 0)); - - if (safePercent === 100) { - return clampBurnAmount(userLpBalance); - } - - return clampBurnAmount(Math.floor(userLpBalance * safePercent / 100)); - } - - function removeLiquidityPreview(burnedLp) { - const safeBurnedLp = totalLpSupply > 0 ? Math.min(clampBurnAmount(burnedLp), floorAmount(totalLpSupply)) : 0; - const withdrawA = totalLpSupply > 0 ? Math.floor(reserveA * safeBurnedLp / totalLpSupply) : 0; - const withdrawB = totalLpSupply > 0 ? Math.floor(reserveB * safeBurnedLp / totalLpSupply) : 0; - const newTotalLpSupply = Math.max(0, floorAmount(totalLpSupply) - safeBurnedLp); - const newUserLpBalance = Math.max(0, floorAmount(userLpBalance) - safeBurnedLp); - - return { - "burnedLp": safeBurnedLp, - "newReserveA": Math.max(0, reserveA - withdrawA), - "newReserveB": Math.max(0, reserveB - withdrawB), - "newTotalLpSupply": newTotalLpSupply, - "newUserLpBalance": newUserLpBalance, - "newUserShare": newTotalLpSupply > 0 ? newUserLpBalance / newTotalLpSupply : 0, - "withdrawA": withdrawA, - "withdrawB": withdrawB - }; - } - - function formatInteger(value) { - const rounded = Math.round(Number(value) || 0); - return rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); - } - - function formatDecimal(value) { - const amount = Number(value) || 0; - - if (Math.abs(amount - Math.round(amount)) < 0.000001) { - return formatInteger(amount); - } - - return amount.toFixed(6).replace(/0+$/, "").replace(/[.]$/, ""); - } - - function formatCompactDecimal(value) { - const amount = Number(value) || 0; - - if (Math.abs(amount) >= 1000 || Math.abs(amount - Math.round(amount)) < 0.000001) { - return formatInteger(amount); - } - - if (Math.abs(amount) >= 1) { - return amount.toFixed(2).replace(/0+$/, "").replace(/[.]$/, ""); - } - - return amount.toFixed(6).replace(/0+$/, "").replace(/[.]$/, ""); - } - - function formatInputAmount(value) { - return formatDecimal(value); - } - - function formatTokenAmount(value, token) { - return formatDecimal(value) + " " + token; - } - - function formatCompactTokenAmount(value, token) { - return formatCompactDecimal(value) + " " + token; - } - - function formatLpAmount(value) { - return formatInteger(value) + " LP"; - } - - function formatPoolShare(value) { - return "\u2248 " + (Math.max(0, Number(value) || 0) * 100).toFixed(2) + "%"; - } - - function formatPercent(value) { - const amount = Math.max(0, Number(value) || 0); - - if (Math.abs(amount - Math.round(amount)) < 0.000001) { - return Math.round(amount).toString() + "%"; - } - - return amount.toFixed(2).replace(/0+$/, "").replace(/[.]$/, "") + "%"; - } -} diff --git a/apps/amm/qml/state/NewPositionFlow.qml b/apps/amm/qml/state/NewPositionFlow.qml new file mode 100644 index 0000000..6edba5c --- /dev/null +++ b/apps/amm/qml/state/NewPositionFlow.qml @@ -0,0 +1,375 @@ +import QtQml + +QtObject { + id: root + + property var backend: null + property var runtime: null + property bool active: false + + readonly property bool walletStateReady: root.backend !== null + && root.backend.walletStateReady === true + readonly property var newPositionContext: root.walletStateReady + && root.backend.newPositionContext + ? root.backend.newPositionContext + : root.loadingContext() + readonly property var viewState: ({ + "quote": root.newPositionQuote, + "contextLoading": root.contextLoading || !root.walletStateReady + || root.newPositionContext.status === "loading", + "quoteLoading": root.quoteLoading, + "quoteStale": root.quoteStale, + "submitting": root.submitting, + "poolCreationPending": root.selectedPoolCreationPending(), + "transactionId": root.transactionId, + "errorCode": root.flowErrorCode || root.contextErrorCode + || root.quoteErrorCode + }) + + property var newPositionQuote: ({}) + property var resolvedTokenIds: [] + property int contextSerial: 0 + property int quoteSerial: 0 + property bool contextLoading: false + property bool quoteLoading: false + property bool quoteStale: true + property bool submitting: false + property string transactionId: "" + property string flowErrorCode: "" + property string contextErrorCode: "" + property string quoteErrorCode: "" + property var pendingQuoteRequest: ({ "ok": false, "request": ({}) }) + property var pendingPoolProbes: [] + property bool poolProbeInFlight: false + + signal tokenResolutionFinished(bool finalResponse) + signal tokenResolutionFailed(string code) + signal poolActivated(var quote) + signal quoteRefreshRequested(bool immediate) + signal submitSucceeded + signal submitFailed + + objectName: "newPositionFlow" + + property Timer quoteDebounce: Timer { + interval: 250 + repeat: false + onTriggered: root.requestQuoteNow(root.quoteSerial) + } + + property Timer poolPoller: Timer { + interval: 5000 + repeat: true + running: root.pendingPoolProbes.length > 0 + onTriggered: root.pollPendingPool() + } + + onNewPositionContextChanged: root.invalidateQuote() + + onWalletStateReadyChanged: { + ++root.contextSerial + if (!root.walletStateReady) + root.contextLoading = false + root.invalidateQuote() + } + + onActiveChanged: { + if (!root.active) + return + Qt.callLater(function() { + if (root.active && root.walletStateReady) + root.quoteRefreshRequested(true) + }) + } + + function contextHints(refreshWalletAccounts) { + const request = root.pendingQuoteRequest.request || {} + const recent = [] + if (request.tokenAId) + recent.push(request.tokenAId) + if (request.tokenBId && request.tokenBId !== request.tokenAId) + recent.push(request.tokenBId) + return { + "recentTokenIds": recent, + "resolvedTokenIds": root.resolvedTokenIds, + "refreshWalletAccounts": refreshWalletAccounts === true + } + } + + function refreshContext(refreshWalletAccounts, completed) { + const serial = ++root.contextSerial + root.contextLoading = true + if (!root.walletStateReady || root.runtime === null) { + root.contextLoading = false + return + } + + root.runtime.watch(root.backend.refreshNewPositionContext( + root.contextHints(refreshWalletAccounts)), + function() { + root.finishContextRefresh(serial, completed) + }, + function(error) { + root.failContextRefresh(serial) + }) + } + + function finishContextRefresh(serial, completed) { + if (serial !== root.contextSerial) + return + root.contextLoading = false + root.contextErrorCode = "" + Qt.callLater(function() { + if (serial !== root.contextSerial) + return + root.tokenResolutionFinished(true) + if (completed) + completed() + }) + } + + function failContextRefresh(serial) { + if (serial !== root.contextSerial) + return + root.contextLoading = false + root.contextErrorCode = "backend_error" + root.tokenResolutionFailed("backend_error") + } + + function resolveToken(tokenId) { + const value = String(tokenId || "").trim() + if (value.length === 0) + return + if (root.resolvedTokenIds.indexOf(value) < 0) { + const next = root.resolvedTokenIds.slice(0) + next.push(value) + root.resolvedTokenIds = next + } + root.refreshContext(false) + } + + function scheduleQuote(immediate, quoteRequest) { + ++root.quoteSerial + root.pendingQuoteRequest = quoteRequest + root.quoteStale = true + root.quoteLoading = root.walletStateReady && root.active + root.quoteDebounce.stop() + if (!root.walletStateReady || !root.active) + return + if (immediate) + root.requestQuoteNow(root.quoteSerial) + else + root.quoteDebounce.restart() + } + + function requestQuoteNow(serial) { + if (serial !== root.quoteSerial) + return + const built = root.pendingQuoteRequest + if (!built.ok) { + root.quoteLoading = false + return + } + if (!root.walletStateReady || !root.active || root.runtime === null) { + root.quoteLoading = false + return + } + + root.runtime.watch(root.backend.quoteNewPosition(built.request), + function(quote) { + if (serial !== root.quoteSerial) + return + root.quoteLoading = false + root.quoteStale = false + root.quoteErrorCode = "" + 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 + root.quoteErrorCode = "backend_error" + }) + } + + function confirm(snapshot) { + if (root.submitting) + return + root.submitting = true + root.flowErrorCode = "" + + if (!root.backend || root.runtime === null) { + root.finishSubmitFailure(root.quoteError("wallet_unavailable")) + return + } + + root.runtime.watch(root.backend.submitNewPosition(snapshot.request, snapshot.quoteHash), + function(result) { + if (result && result.schema === "new-position.v1" + && result.status === "submitted" + && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test( + String(result.transactionId || ""))) { + if (snapshot.request.initialPriceRealRaw !== undefined) + root.watchPoolCreation(snapshot.poolProbeRequest, result.deadlineMs) + root.submitting = false + root.transactionId = result.transactionId + root.flowErrorCode = "" + root.contextErrorCode = "" + root.quoteErrorCode = "" + root.invalidateQuote() + root.submitSucceeded() + return + } + root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed")) + }, + function(error) { + root.finishSubmitFailure(root.quoteError("wallet_submission_failed")) + }) + } + + function finishSubmitFailure(result) { + root.submitting = false + const hasFreshQuote = result && result.quote + && result.quote.schema === "new-position.v1" + if (hasFreshQuote) { + root.newPositionQuote = result.quote + root.quoteLoading = false + root.quoteStale = false + } + const code = result && result.code ? result.code : "wallet_submission_failed" + root.flowErrorCode = code + root.submitFailed() + if (hasFreshQuote) + return + root.scheduleQuote(true, root.pendingQuoteRequest) + } + + function watchPoolCreation(request, deadlineMs) { + const key = root.pairKey(request) + let deadline = Number(deadlineMs) + if (!isFinite(deadline) || deadline <= 0) + deadline = 0 + const pending = root.pendingPoolProbes.filter(function(item) { + return item.key !== key + }) + pending.push({ + "key": key, + "request": request, + "deadlineMs": deadline + }) + root.pendingPoolProbes = pending + Qt.callLater(root.pollPendingPool) + } + + function pollPendingPool() { + if (root.poolProbeInFlight || root.pendingPoolProbes.length === 0 + || !root.walletStateReady || root.runtime === null) { + return + } + const pending = root.pendingPoolProbes[0] + root.poolProbeInFlight = true + root.runtime.watch(root.backend.quoteNewPosition(pending.request), + function(quote) { + root.finishPoolProbe(pending, quote) + }, + function(error) { + root.finishPoolProbe(pending, null) + }) + } + + function finishPoolProbe(pending, quote) { + root.poolProbeInFlight = false + if (quote && quote.schema === "new-position.v1" + && quote.poolStatus === "active_pool") { + root.removePendingPool(pending.key) + if (root.matchesSelectedPair(pending.request)) { + root.poolActivated(quote) + root.invalidateQuote() + root.refreshContext(true) + } + return + } + if (pending.deadlineMs > 0 && Date.now() >= pending.deadlineMs) { + root.removePendingPool(pending.key) + return + } + root.rotatePendingPool() + } + + function pairKey(request) { + return String(request.tokenAId || "") + ":" + String(request.tokenBId || "") + } + + function matchesSelectedPair(request) { + return root.pairKey(root.pendingQuoteRequest.request || {}) === root.pairKey(request) + } + + function selectedPoolCreationPending() { + const request = root.pendingQuoteRequest.request || {} + if (!request.tokenAId || !request.tokenBId) + return false + const selected = root.pairKey(request) + return root.pendingPoolProbes.some(function(item) { + return item.key === selected + }) + } + + function removePendingPool(key) { + root.pendingPoolProbes = root.pendingPoolProbes.filter(function(item) { + return item.key !== key + }) + } + + function rotatePendingPool() { + if (root.pendingPoolProbes.length < 2) + return + const pending = root.pendingPoolProbes.slice(1) + pending.push(root.pendingPoolProbes[0]) + root.pendingPoolProbes = pending + } + + function draftChanged() { + root.invalidateQuote() + root.transactionId = "" + root.flowErrorCode = "" + root.contextErrorCode = "" + root.quoteErrorCode = "" + } + + function invalidateQuote() { + ++root.quoteSerial + root.quoteDebounce.stop() + root.quoteLoading = false + root.quoteStale = true + } + + function loadingContext() { + return { + "schema": "new-position.v1", + "status": "loading", + "tokens": [], + "feeTiers": [] + } + } + + function quoteError(code) { + return { + "schema": "new-position.v1", + "status": "error", + "canSubmit": false, + "code": code, + "poolStatus": "unavailable_pool", + "errors": [{ + "code": code, + "blockingFields": [], + "details": ({}) + }], + "warnings": [], + "accountPreview": [] + } + } +} diff --git a/apps/amm/src/ActiveNetwork.cpp b/apps/amm/src/ActiveNetwork.cpp new file mode 100644 index 0000000..c92bc73 --- /dev/null +++ b/apps/amm/src/ActiveNetwork.cpp @@ -0,0 +1,141 @@ +#include "ActiveNetwork.h" + +#include +#include +#include +#include + +namespace { + const char NETWORK_ENV[] = "AMM_UI_NETWORK"; + const char DEVNET_FILE_ENV[] = "AMM_UI_DEVNET_FILE"; + + bool isLowerHex(const QString& value, int size) + { + if (value.size() != size) + return false; + for (const QChar character : value) { + const bool isAsciiDigit = character >= QLatin1Char('0') + && character <= QLatin1Char('9'); + if (!isAsciiDigit + && (character < QLatin1Char('a') || character > QLatin1Char('f'))) { + return false; + } + } + return true; + } +} + +bool ActiveNetwork::load() +{ + m_network = {}; + m_network.status = QStringLiteral("config_missing"); + m_expectedIdentity.clear(); + + const QByteArray selected = qgetenv(NETWORK_ENV); + m_network.id = selected.isEmpty() + ? QStringLiteral("testnet") + : QString::fromLocal8Bit(selected).trimmed(); + + QJsonObject entry; + if (isDevnet()) { + const QString path = QString::fromLocal8Bit(qgetenv(DEVNET_FILE_ENV)); + QFile file(path); + if (path.isEmpty() || !file.open(QIODevice::ReadOnly)) + return false; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) + return false; + entry = document.object(); + m_expectedIdentity = entry.value(QStringLiteral("channelId")).toString(); + } else { + QFile file(QStringLiteral(":/amm/config/networks.json")); + if (!file.open(QIODevice::ReadOnly)) + return false; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) + return false; + const QJsonObject networks = document.object(); + entry = networks.value(m_network.id).toObject(); + m_expectedIdentity = entry.value(QStringLiteral("checkpointHash")).toString(); + } + + m_network.ammProgramId = entry.value(QStringLiteral("ammProgramId")).toString(); + if (!isValidIdentity(m_expectedIdentity) + || !isLowerHex(m_network.ammProgramId, 64)) { + return false; + } + + for (const QJsonValue& value : entry.value(QStringLiteral("tokenDefinitionIds")).toArray()) { + const QString id = value.toString(); + if (!isLowerHex(id, 64)) { + m_network.tokenIds.clear(); + return false; + } + m_network.tokenIds.append(id); + } + m_network.status = QStringLiteral("network_unknown"); + return true; +} + +bool ActiveNetwork::isConfigured() const +{ + return m_network.status != QStringLiteral("config_missing"); +} + +bool ActiveNetwork::isDevnet() const +{ + return m_network.id == QStringLiteral("devnet"); +} + +bool ActiveNetwork::needsIdentityProbe() const +{ + return m_network.status == QStringLiteral("loading") + || m_network.status == QStringLiteral("network_unknown"); +} + +void ActiveNetwork::sequencerChanged(bool available) +{ + if (isConfigured()) + clearIdentity(available ? QStringLiteral("loading") : QStringLiteral("network_unknown")); +} + +void ActiveNetwork::reachabilityChanged(bool reachable, bool wasReachable) +{ + if (!isConfigured()) + return; + if (!reachable) + clearIdentity(QStringLiteral("network_unknown")); + else if (!wasReachable) + clearIdentity(QStringLiteral("loading")); +} + +void ActiveNetwork::beginIdentityProbe() +{ + if (isConfigured()) + clearIdentity(QStringLiteral("loading")); +} + +void ActiveNetwork::finishIdentityProbe(const QString& identity) +{ + if (identity.isEmpty()) { + clearIdentity(QStringLiteral("network_unknown")); + } else if (identity != m_expectedIdentity) { + clearIdentity(QStringLiteral("network_mismatch")); + } else { + m_network.status = QStringLiteral("ready"); + m_network.fingerprint = (isDevnet() ? QStringLiteral("channel:") + : QStringLiteral("block10:")) + + identity; + } +} + +bool ActiveNetwork::isValidIdentity(const QString& value) +{ + return isLowerHex(value, 64); +} + +void ActiveNetwork::clearIdentity(const QString& status) +{ + m_network.status = status; + m_network.fingerprint.clear(); +} diff --git a/apps/amm/src/ActiveNetwork.h b/apps/amm/src/ActiveNetwork.h new file mode 100644 index 0000000..9b1db9b --- /dev/null +++ b/apps/amm/src/ActiveNetwork.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +struct ActiveNetworkSnapshot { + QString id; + QString status; + QString fingerprint; + QString ammProgramId; + QStringList tokenIds; +}; + +class ActiveNetwork final { +public: + bool load(); + + const QString& status() const { return m_network.status; } + bool isConfigured() const; + bool isDevnet() const; + bool needsIdentityProbe() const; + ActiveNetworkSnapshot snapshot() const { return m_network; } + + void sequencerChanged(bool available); + void reachabilityChanged(bool reachable, bool wasReachable); + void beginIdentityProbe(); + void finishIdentityProbe(const QString& identity); + + static bool isValidIdentity(const QString& value); + +private: + void clearIdentity(const QString& status); + + ActiveNetworkSnapshot m_network; + QString m_expectedIdentity; +}; diff --git a/apps/amm/src/AmmClient.cpp b/apps/amm/src/AmmClient.cpp new file mode 100644 index 0000000..17d6d61 --- /dev/null +++ b/apps/amm/src/AmmClient.cpp @@ -0,0 +1,71 @@ +#include "AmmClient.h" + +#include +#include +#include + +#include "amm_client.h" + +namespace { + using Operation = char* (*)(const char*); + + AmmClientResult call(Operation operation, const QJsonObject& request) + { + const QByteArray payload = QJsonDocument(request).toJson(QJsonDocument::Compact); + char* raw = operation(payload.constData()); + if (!raw) { + qWarning() << "AmmClient: bundled client returned a null response"; + return {}; + } + const QByteArray response(raw); + amm_free(raw); + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(response, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + qWarning() << "AmmClient: bundled client returned invalid JSON"; + return {}; + } + const QJsonObject envelope = document.object(); + if (!envelope.value(QStringLiteral("ok")).toBool()) { + qWarning() << "AmmClient: bundled client failure:" + << envelope.value(QStringLiteral("error")).toString(); + return {}; + } + if (!envelope.value(QStringLiteral("value")).isObject()) { + qWarning() << "AmmClient: bundled client value is not an object"; + return {}; + } + return { true, envelope.value(QStringLiteral("value")).toObject() }; + } +} + +AmmClientResult BundledAmmClient::configId(const QJsonObject& request) const +{ + return call(amm_config_id, request); +} + +AmmClientResult BundledAmmClient::tokenIds(const QJsonObject& request) const +{ + return call(amm_token_ids, request); +} + +AmmClientResult BundledAmmClient::pairIds(const QJsonObject& request) const +{ + return call(amm_pair_ids, request); +} + +AmmClientResult BundledAmmClient::context(const QJsonObject& request) const +{ + return call(amm_context, request); +} + +AmmClientResult BundledAmmClient::quote(const QJsonObject& request) const +{ + return call(amm_quote, request); +} + +AmmClientResult BundledAmmClient::plan(const QJsonObject& request) const +{ + return call(amm_plan, request); +} diff --git a/apps/amm/src/AmmClient.h b/apps/amm/src/AmmClient.h new file mode 100644 index 0000000..6c69f48 --- /dev/null +++ b/apps/amm/src/AmmClient.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +struct AmmClientResult { + bool ok = false; + QJsonObject value; +}; + +class AmmClient { +public: + virtual ~AmmClient() = default; + + virtual AmmClientResult configId(const QJsonObject& request) const = 0; + virtual AmmClientResult tokenIds(const QJsonObject& request) const = 0; + virtual AmmClientResult pairIds(const QJsonObject& request) const = 0; + virtual AmmClientResult context(const QJsonObject& request) const = 0; + virtual AmmClientResult quote(const QJsonObject& request) const = 0; + virtual AmmClientResult plan(const QJsonObject& request) const = 0; +}; + +class BundledAmmClient final : public AmmClient { +public: + AmmClientResult configId(const QJsonObject& request) const override; + AmmClientResult tokenIds(const QJsonObject& request) const override; + AmmClientResult pairIds(const QJsonObject& request) const override; + AmmClientResult context(const QJsonObject& request) const override; + AmmClientResult quote(const QJsonObject& request) const override; + AmmClientResult plan(const QJsonObject& request) const override; +}; diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 15669dc..65cf790 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -1,13 +1,5 @@ #include "AmmUiBackend.h" -#include -#include -#include -#include -#include -#include -#include -#include #include #include #include @@ -15,147 +7,19 @@ #include #include #include -#include -#include -#include #include #include +#include "AmmClient.h" +#include "LogosWalletProvider.h" +#include "NewPositionRuntime.h" +#include "WalletController.h" #include "logos_api.h" -#include "logos_sdk.h" - -#include "amm_client.h" namespace { - const char SETTINGS_ORG[] = "Logos"; - const char SETTINGS_APP[] = "AmmUI"; - const char DISCONNECTED_KEY[] = "disconnected"; - const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; - 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"; - - 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; - } - - bool isLowerHex(const QString& value, int size) - { - if (value.size() != size) - return false; - for (const QChar character : value) { - if (!character.isDigit() - && (character < QLatin1Char('a') || character > QLatin1Char('f'))) { - return false; - } - } - return true; - } - - bool isHex(const QString& value, int size) - { - 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 true; - } - - QJsonObject issue(const QString& code, const QJsonArray& blockingFields = {}) - { - return { - { QStringLiteral("code"), code }, - { QStringLiteral("recoverable"), true }, - { QStringLiteral("blockingFields"), blockingFields }, - { QStringLiteral("details"), QJsonObject() }, - }; - } - - QJsonObject publicError(const QString& code, - const QJsonArray& blockingFields = {}, - const QJsonObject& details = {}) - { - 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() }, - }; - } - - QJsonObject contextState(const QString& status, - const QString& networkId, - const QString& fingerprint = {}) - { - 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() }, - }; - } - - 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 {}; - } - - 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(); - } QByteArray jsonRpcBody(const QString& method, const QJsonArray& params) { @@ -187,720 +51,175 @@ namespace { if (parseError.error != QJsonParseError::NoError || !document.isObject()) return {}; const QString channel = document.object().value(QStringLiteral("result")).toString(); - return isLowerHex(channel, 64) ? channel : QString(); + return ActiveNetwork::isValidIdentity(channel) ? channel : QString(); } - QJsonArray variantStringArray(const QVariant& value) - { - QJsonArray result; - for (const QVariant& item : value.toList()) - result.append(item.toString()); - return result; - } - - QStringList jsonStringList(const QJsonArray& values) - { - QStringList result; - result.reserve(values.size()); - for (const QJsonValue& value : values) - result.append(value.toString()); - return result; - } - - 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; - } -} - -QString AmmUiBackend::defaultWalletHome() -{ - const QByteArray override = qgetenv(WALLET_HOME_ENV); - if (!override.isEmpty()) - return QString::fromLocal8Bit(override); - // LEZ's canonical wallet home, shared with the wallet UI and other LEZ apps - // (matches lez/wallet get_home_default_path()). - return QDir::homePath() + QStringLiteral("/.lee/wallet"); -} - -QString AmmUiBackend::defaultConfigPath() const -{ - return defaultWalletHome() + QStringLiteral("/wallet_config.json"); -} - -QString AmmUiBackend::defaultStoragePath() const -{ - return defaultWalletHome() + QStringLiteral("/storage.json"); } AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) : AmmUiBackendSimpleSource(parent), - m_accountModel(new AccountModel(this)), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)), - m_logos(new LogosModules(m_logosAPI)), - m_net(new QNetworkAccessManager(this)), - m_reachabilityTimer(new QTimer(this)) + m_wallet(std::make_unique(m_logosAPI)), + m_walletController(std::make_unique( + *m_wallet, QStringLiteral("AmmUI"))), + m_ammClient(std::make_unique()), + m_newPosition(std::make_unique(m_wallet.get(), m_ammClient.get())), + m_net(new QNetworkAccessManager(this)) { - // PROP defaults via the generated setters. - setIsWalletOpen(false); - setLastSyncedBlock(0); - setCurrentBlockHeight(0); - setWalletHome(defaultWalletHome()); - // Assume reachable until a probe proves otherwise (avoids a startup flash). - setSequencerReachable(true); - loadNetworkConfig(); - setNewPositionContext(contextState( - m_networkStatus == QStringLiteral("network_unknown") - ? QStringLiteral("loading") - : m_networkStatus, - m_networkId, - m_networkFingerprint).toVariantMap()); + setWalletStateReady(false); + m_network.load(); + setNewPositionContext(m_newPosition->context( + QVariantMap(), m_network.snapshot(), false, false)); - // 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 - // thus a sequencer address) is open. - m_reachabilityTimer->setInterval(10000); - connect(m_reachabilityTimer, &QTimer::timeout, this, [this]() { checkReachability(); }); - m_reachabilityTimer->start(); - - // Always resolve against the canonical wallet home (LEE_WALLET_HOME_DIR or - // ~/.lee/wallet). We intentionally don't seed config/storage paths from - // QSettings anymore: a previously-persisted per-app path (~/.lee/amm-wallet) - // would otherwise override the default and pin the app to the old keystore. - - // A wallet exists on disk if its storage file is present (drives whether - // the navbar "Connect" reconnects or offers to create a wallet). - const QString effectiveStorage = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - setWalletExists(QFileInfo::exists(effectiveStorage)); - - // ui-host runs our constructor inside initLogos(), synchronously, BEFORE - // it enables remoting and emits READY. Any blocking RPC here would stall - // ui-host startup past its ready watchdog. Defer the open+refresh chain to - // the first event-loop tick so ui-host finishes wiring itself up first. - QTimer::singleShot(0, this, [this]() { openOrAdoptWallet(); }); - - // Save wallet on quit; host may not call destructors so this is best-effort. - connect(qApp, &QCoreApplication::aboutToQuit, this, - [this]() { saveWallet(); }, Qt::DirectConnection); + connect(m_walletController.get(), &WalletController::stateChanged, + this, &AmmUiBackend::syncWalletState); + syncWalletState(); + m_walletController->start(); + QTimer::singleShot(0, this, [this]() { + setWalletStateReady(true); + syncWalletState(); + }); } -AmmUiBackend::~AmmUiBackend() +AmmUiBackend::~AmmUiBackend() = default; + +WalletAccountModel* AmmUiBackend::accountModel() const { - saveWallet(); - delete m_logos; -} - -void AmmUiBackend::openOrAdoptWallet() -{ - // Respect an explicit user disconnect: stay locked, show "Connect". - if (QSettings(SETTINGS_ORG, SETTINGS_APP).value(DISCONNECTED_KEY, false).toBool()) - return; - - // In Basecamp the logos_execution_zone module is a single shared instance, - // so the wallet may already be open (e.g. opened by the dedicated wallet - // app). Adopt that wallet instead of fighting over it: mirror its state - // rather than re-opening from disk, which could clobber unsaved in-memory - // accounts the other app holds. A freshly-created shared wallet can be open - // with zero accounts, so we can't key off list_accounts() alone (see - // sharedWalletIsOpen). - if (sharedWalletIsOpen()) { - const QJsonArray existing = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); - qDebug() << "AmmUiBackend: adopting already-open shared wallet" - << existing.size() << "accounts"; - setIsWalletOpen(true); - m_accountModel->replaceFromJsonArray(existing); - refreshBalances(); - refreshSequencerAddr(); - return; - } - - // Standalone (own core instance): auto-open a previously-created wallet. - // Use persisted paths if the user picked custom ones, else the per-app - // default. Only open if the storage actually exists, otherwise stay closed - // so QML shows the "Connect" entry point (no noisy FFI errors on first run). - const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath(); - const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - if (!QFileInfo::exists(stg)) - return; // No wallet yet — QML shows "Connect". - - qDebug() << "AmmUiBackend: opening wallet with config" << cfg << "storage" << stg; - const int err = m_logos->logos_execution_zone.open(cfg, stg); - if (err == WALLET_FFI_SUCCESS) { - persistConfigPath(cfg); - persistStoragePath(stg); - setIsWalletOpen(true); - refreshAccounts(); - refreshBlockHeights(); - refreshSequencerAddr(); - } else { - qWarning() << "AmmUiBackend: wallet open failed, code" << err; - } -} - -bool AmmUiBackend::sharedWalletIsOpen() -{ - // list_accounts() is non-empty only once the wallet holds accounts, so it - // can't distinguish "no wallet open" from "open but empty" (a wallet that - // was just created and hasn't had an account added yet). Fall back to a - // handle-dependent, account-independent signal: an open wallet always has a - // sequencer address (from its config, defaulted on open), while a closed - // core returns an empty string. This lets us adopt a freshly-created shared - // wallet instead of falling through and re-opening it from disk. - if (!QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()).isEmpty()) - return true; - return !m_logos->logos_execution_zone.get_sequencer_addr().isEmpty(); + return m_walletController->accountModel(); } QString AmmUiBackend::createNewDefault(QString password) { - QDir().mkpath(defaultWalletHome()); - return createNew(defaultConfigPath(), defaultStoragePath(), password); + setWalletStateReady(false); + const QString mnemonic = m_walletController->createDefaultWallet(password); + setWalletStateReady(true); + syncWalletState(); + return mnemonic; } QString AmmUiBackend::createNew(QString configPath, QString storagePath, QString password) { - const QString localConfig = toLocalPath(configPath); - const QString localStorage = toLocalPath(storagePath); - // create_new returns the new wallet's BIP39 mnemonic (empty on failure). We - // hand it back to the caller instead of discarding it: wallet creation is - // the only moment the seed phrase is recoverable, so the UI must force a - // backup step before the user can proceed. - const QString mnemonic = m_logos->logos_execution_zone.create_new(localConfig, localStorage, password); - if (mnemonic.isEmpty()) { - qWarning() << "AmmUiBackend: create_new failed (empty mnemonic)"; - return QString(); - } - - persistConfigPath(localConfig); - persistStoragePath(localStorage); - setWalletExists(true); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); - setIsWalletOpen(true); - refreshAccounts(); - refreshBlockHeights(); - refreshSequencerAddr(); + setWalletStateReady(false); + const QString mnemonic = + m_walletController->createWallet(configPath, storagePath, password); + setWalletStateReady(true); + syncWalletState(); return mnemonic; } bool AmmUiBackend::openExisting() { - // Adopt a shared open wallet (Basecamp), else open our own from disk. A - // freshly-created shared wallet can be open with zero accounts, so probe - // open-ness rather than keying off list_accounts() alone. - if (sharedWalletIsOpen()) { - const QJsonArray existing = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); - setIsWalletOpen(true); - m_accountModel->replaceFromJsonArray(existing); - refreshBalances(); - refreshSequencerAddr(); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); - return true; - } - - const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath(); - const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - if (!QFileInfo::exists(stg)) - return false; - - const int err = m_logos->logos_execution_zone.open(cfg, stg); - if (err != WALLET_FFI_SUCCESS) { - qWarning() << "AmmUiBackend: openExisting failed, code" << err; - return false; - } - persistConfigPath(cfg); - persistStoragePath(stg); - setIsWalletOpen(true); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); - refreshAccounts(); - refreshBlockHeights(); - refreshSequencerAddr(); - return true; + setWalletStateReady(false); + const bool opened = m_walletController->open(); + setWalletStateReady(true); + syncWalletState(); + return opened; } void AmmUiBackend::disconnectWallet() { - // UI-local lock: persist wallet state, drop our view of it, and remember - // the choice. We do NOT close the core module's wallet handle — in Basecamp - // that instance is shared with other apps. - saveWallet(); - setIsWalletOpen(false); - m_accountModel->replaceFromJsonArray(QJsonArray()); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, true); + m_walletController->disconnect(); + setWalletStateReady(true); + m_newPosition->clearWalletAccounts(); refreshNewPositionContext(QVariantMap()); } QString AmmUiBackend::createAccountPublic() { - const QString result = m_logos->logos_execution_zone.create_account_public(); - if (!result.isEmpty()) - refreshAccounts(); - return result; + return m_walletController->createAccount(true); } QString AmmUiBackend::createAccountPrivate() { - const QString result = m_logos->logos_execution_zone.create_account_private(); - if (!result.isEmpty()) - refreshAccounts(); - return result; + return m_walletController->createAccount(false); } void AmmUiBackend::refreshAccounts() { - const QJsonArray arr = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); - m_accountModel->replaceFromJsonArray(arr); - refreshBalances(); + m_walletController->refresh(); } void AmmUiBackend::refreshBalances() { - refreshBlockHeights(); - if (currentBlockHeight() > 0) - m_logos->logos_execution_zone.sync_to_block(static_cast(currentBlockHeight())); - - for (int i = 0; i < m_accountModel->count(); ++i) { - const QModelIndex idx = m_accountModel->index(i, 0); - const QString addr = m_accountModel->data(idx, AccountModel::AddressRole).toString(); - const bool isPub = m_accountModel->data(idx, AccountModel::IsPublicRole).toBool(); - m_accountModel->setBalanceByAddress(addr, getBalance(addr, isPub)); - } - saveWallet(); - refreshNewPositionContext(QVariantMap()); + m_walletController->refresh(); } QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic) { - return m_logos->logos_execution_zone.get_balance(accountIdHex, isPublic); + return m_walletController->balance(accountIdHex, isPublic); } - -QJsonObject AmmUiBackend::readAccount(const QString& accountId) const +void AmmUiBackend::refreshNewPositionContext(QVariantMap request) { - QJsonObject read { - { QStringLiteral("id"), accountId }, - { QStringLiteral("status"), QStringLiteral("read_failed") }, - }; - if (!isLowerHex(accountId, 64)) - return read; - - 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; - - 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; + const bool refreshWalletAccounts = + request.take(QStringLiteral("refreshWalletAccounts")).toBool(); + if (request.contains(QStringLiteral("recentTokenIds")) + || request.contains(QStringLiteral("resolvedTokenIds"))) { + m_newPositionHints = request; } - const QString data = account.value(QStringLiteral("data")).toString(); - if (data.size() % 2 != 0) { - return read; + else { + request = m_newPositionHints; } - 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; -} - -QJsonArray AmmUiBackend::walletAccountReads() const -{ - QJsonArray reads; - if (!isWalletOpen()) - return reads; - - 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)); - } - return reads; -} - -QJsonObject AmmUiBackend::buildQuoteInput(const QVariantMap& request, - QJsonObject* error) const -{ - 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(); + if (m_network.status() == QStringLiteral("network_unknown")) + probeNetworkIdentity(); + setNewPositionContext(m_newPosition->context( + request, m_network.snapshot(), isWalletOpen(), refreshWalletAccounts)); } 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(); + return m_newPosition->quote(request, m_network.snapshot(), isWalletOpen()); } 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) { - QJsonObject result = publicError(QStringLiteral("quote_changed")); - result.insert(QStringLiteral("quote"), quote); - 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(); - } - - 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; - } - - 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(); + return m_newPosition->submit( + request, quoteHash, m_network.snapshot(), isWalletOpen()); } -void AmmUiBackend::refreshBlockHeights() +void AmmUiBackend::syncWalletState() { - const int lastVal = m_logos->logos_execution_zone.get_last_synced_block(); - const int currentVal = m_logos->logos_execution_zone.get_current_block_height(); - if (lastSyncedBlock() != lastVal) - setLastSyncedBlock(lastVal); - if (currentBlockHeight() != currentVal) - setCurrentBlockHeight(currentVal); -} + const WalletUiState& state = m_walletController->state(); + const bool walletWasOpen = isWalletOpen(); + const bool wasReachable = sequencerReachable(); + const QString previousAddress = sequencerAddr(); -void AmmUiBackend::refreshSequencerAddr() -{ - 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(); - } + setIsWalletOpen(state.isWalletOpen); + setWalletExists(state.walletExists); + setConfigPath(state.configPath); + setStoragePath(state.storagePath); + setWalletHome(state.walletHome); + setLastSyncedBlock(state.lastSyncedBlock); + setCurrentBlockHeight(state.currentBlockHeight); + setSequencerAddr(state.sequencerAddress); + setSequencerReachable(state.sequencerReachable); + + const bool addressChanged = previousAddress != state.sequencerAddress; + if (addressChanged) + m_network.sequencerChanged(!state.sequencerAddress.isEmpty()); + if (addressChanged || wasReachable != state.sequencerReachable) { + m_network.reachabilityChanged(state.sequencerReachable, wasReachable); } - checkReachability(); -} + if (walletWasOpen && !state.isWalletOpen) + m_newPosition->clearWalletAccounts(); -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 address = sequencerAddr(); - if (address.isEmpty()) - return; - - 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(); - }); + publishNetworkContext(); + if (state.sequencerReachable && m_network.needsIdentityProbe()) + probeNetworkIdentity(); } void AmmUiBackend::probeNetworkIdentity() { if (m_identityProbeInFlight - || m_networkStatus == QStringLiteral("config_missing") + || !m_network.isConfigured() || sequencerAddr().isEmpty()) { return; } m_identityProbeInFlight = true; + m_network.beginIdentityProbe(); + publishNetworkContext(); const QString address = sequencerAddr(); - const bool devnet = m_networkId == QStringLiteral("devnet"); + const bool devnet = m_network.isDevnet(); const QString method = devnet ? QStringLiteral("getChannelId") : QStringLiteral("getBlock"); @@ -915,6 +234,11 @@ void AmmUiBackend::probeNetworkIdentity() connect(reply, &QNetworkReply::finished, this, [this, reply, address, devnet]() { m_identityProbeInFlight = false; if (address != sequencerAddr()) { + reply->deleteLater(); + probeNetworkIdentity(); + return; + } + if (!sequencerReachable()) { reply->deleteLater(); return; } @@ -923,99 +247,14 @@ void AmmUiBackend::probeNetworkIdentity() 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; - } + m_network.finishIdentityProbe(actual); reply->deleteLater(); - refreshNewPositionContext(QVariantMap()); + publishNetworkContext(); }); } -void AmmUiBackend::saveWallet() +void AmmUiBackend::publishNetworkContext() { - if (isWalletOpen()) - m_logos->logos_execution_zone.save(); -} - -// These only update the in-session PROPs (so subsequent open/refresh calls -// reuse the same path). They are no longer written to QSettings: the app -// always resolves against the canonical wallet home, so there's nothing to -// remember across launches. -void AmmUiBackend::persistConfigPath(const QString& path) -{ - setConfigPath(toLocalPath(path)); -} - -void AmmUiBackend::persistStoragePath(const QString& path) -{ - setStoragePath(toLocalPath(path)); -} - -bool AmmUiBackend::changeSequencerAddr(QString url) -{ - QString normalized = url.trimmed(); - if (normalized.isEmpty()) { - qWarning() << "AmmUiBackend: refusing to set empty sequencer_addr"; - return false; - } - - // The wallet config parses sequencer_addr as a strict URL — a missing - // scheme makes the whole config fail to deserialize (and would leave the - // wallet unopenable). Default to http:// so users can type just host:port, - // then validate before writing anything. - if (!normalized.contains(QStringLiteral("://"))) - normalized.prepend(QStringLiteral("http://")); - - const QUrl parsed(normalized, QUrl::StrictMode); - if (!parsed.isValid() || parsed.host().isEmpty() - || (parsed.scheme() != QStringLiteral("http") - && parsed.scheme() != QStringLiteral("https"))) { - qWarning() << "AmmUiBackend: invalid sequencer URL" << url; - return false; - } - normalized = parsed.toString(); - - const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath(); - - // Preserve the other config fields (poll timeouts, retries) — only swap the - // endpoint. The wallet reads this file on open via from_path_or_initialize_default. - QJsonObject obj; - QFile in(cfg); - if (in.open(QIODevice::ReadOnly)) { - obj = QJsonDocument::fromJson(in.readAll()).object(); - in.close(); - } - obj.insert(QStringLiteral("sequencer_addr"), normalized); - - QFile out(cfg); - if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - qWarning() << "AmmUiBackend: cannot write wallet config" << cfg; - return false; - } - out.write(QJsonDocument(obj).toJson(QJsonDocument::Indented)); - out.close(); - - // Config is now the source of truth — reflect the change in the UI. - if (sequencerAddr() != normalized) - setSequencerAddr(normalized); - checkReachability(); - - // The module can't re-open an already-open wallet, so the new endpoint only - // takes effect on the next launch. The UI confirms a restart before calling - // this and closes the app afterwards. - return true; -} - -void AmmUiBackend::copyToClipboard(QString text) -{ - if (QGuiApplication::clipboard()) - QGuiApplication::clipboard()->setText(text); + setNewPositionContext(m_newPosition->context( + m_newPositionHints, m_network.snapshot(), isWalletOpen(), false)); } diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index cab6d35..725652e 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -1,35 +1,36 @@ #ifndef AMM_UI_BACKEND_H #define AMM_UI_BACKEND_H +#include + #include -#include -#include #include -#include #include #include "rep_AmmUiBackend_source.h" -#include "AccountModel.h" +#include "ActiveNetwork.h" +#include "WalletAccountModel.h" class LogosAPI; -struct LogosModules; +class AmmClient; +class LogosWalletProvider; +class NewPositionRuntime; class QNetworkAccessManager; -class QTimer; +class WalletController; // Source-side implementation of the AmmUiBackend .rep interface. // Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and -// SLOTs from AmmUiBackend.rep — all the simple ones flow over QtRO. Talks to -// the core logos_execution_zone wallet module via LogosModules. +// SLOTs from AmmUiBackend.rep — all the simple ones flow over QtRO. class AmmUiBackend : public AmmUiBackendSimpleSource { Q_OBJECT - Q_PROPERTY(AccountModel* accountModel READ accountModel CONSTANT) + Q_PROPERTY(WalletAccountModel* accountModel READ accountModel CONSTANT) public: explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr); ~AmmUiBackend() override; - AccountModel* accountModel() const { return m_accountModel; } + WalletAccountModel* accountModel() const; public slots: // Overrides of the pure-virtual slots generated from the .rep. @@ -38,7 +39,7 @@ public slots: void refreshAccounts() override; void refreshBalances() override; QString getBalance(QString accountIdHex, bool isPublic) override; - QVariantMap refreshNewPositionContext(QVariantMap request) override; + void 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 @@ -47,52 +48,23 @@ public slots: QString createNew(QString configPath, QString storagePath, QString password) override; bool openExisting() override; void disconnectWallet() override; - bool changeSequencerAddr(QString url) override; - void copyToClipboard(QString text) override; private: - // Per-app wallet home (kept distinct from the wallet's canonical - // ~/.lee/wallet so standalone instances stay isolated; Basecamp sharing - // is handled by adopting an already-open shared wallet on startup). - static QString defaultWalletHome(); - QString defaultConfigPath() const; - QString defaultStoragePath() const; - - void persistConfigPath(const QString& path); - void persistStoragePath(const QString& path); - void openOrAdoptWallet(); - // True when the shared core already has a wallet open — including a freshly - // created one with zero accounts. See the definition for why list_accounts() - // alone is insufficient. - bool sharedWalletIsOpen(); - void refreshBlockHeights(); - void refreshSequencerAddr(); - void saveWallet(); - bool loadNetworkConfig(); + void syncWalletState(); 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(); - - AccountModel* m_accountModel; + void publishNetworkContext(); LogosAPI* m_logosAPI; - LogosModules* m_logos; + std::unique_ptr m_wallet; + std::unique_ptr m_walletController; + std::unique_ptr m_ammClient; + std::unique_ptr m_newPosition; 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; + ActiveNetwork m_network; + QVariantMap m_newPositionHints; 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 02b79a1..f0d0b69 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -5,6 +5,9 @@ class AmmUiBackend { PROP(bool isWalletOpen READONLY) + // False while startup or reconnect is still resolving wallet state. This + // stays distinct from isWalletOpen because a disconnected wallet is ready. + PROP(bool walletStateReady READONLY) PROP(bool walletExists READONLY) PROP(QString configPath READONLY) PROP(QString storagePath READONLY) @@ -27,12 +30,12 @@ class AmmUiBackend // The QVariant payloads are stable maps/lists so the UI never assembles AMM // transactions or duplicates quote state. PROP(QVariantMap newPositionContext READONLY) - SLOT(QVariantMap refreshNewPositionContext(QVariantMap request)) + SLOT(void 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() + // fresh wallet at the canonical walletHome with no path picking. createNew() // keeps explicit paths for an "advanced" flow. Both return the new wallet's // BIP39 mnemonic (empty on failure) so the UI can force a seed-phrase backup // before the wallet is usable — this is the only chance to record it. @@ -44,5 +47,4 @@ class AmmUiBackend // Close this app's wallet view (lock); does not delete the wallet and, in // Basecamp, does not close the wallet other apps share. SLOT(void disconnectWallet()) - } diff --git a/apps/amm/src/NewPositionRuntime.cpp b/apps/amm/src/NewPositionRuntime.cpp new file mode 100644 index 0000000..3f016ef --- /dev/null +++ b/apps/amm/src/NewPositionRuntime.cpp @@ -0,0 +1,390 @@ +#include "NewPositionRuntime.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "AmmClient.h" +#include "WalletProvider.h" + +namespace { + const char SCHEMA[] = "new-position.v1"; + constexpr qsizetype HASH_BYTES = 32; + constexpr std::size_t BASE58_BUFFER_SIZE = 45; + + QString base58TransactionId(const QString& transactionHash) + { + const QByteArray hex = transactionHash.toLatin1(); + const QByteArray bytes = QByteArray::fromHex(hex); + if (bytes.size() != HASH_BYTES || bytes.toHex() != hex) + return {}; + + std::array encoded {}; + std::size_t size = encoded.size(); + if (!b58enc(encoded.data(), &size, bytes.constData(), + static_cast(bytes.size()))) { + return {}; + } + return QString::fromLatin1( + encoded.data(), static_cast(size - 1)); + } + + QJsonObject issue(const QString& code, + const QJsonArray& blockingFields = {}) + { + return { + { QStringLiteral("code"), code }, + { QStringLiteral("recoverable"), true }, + { QStringLiteral("blockingFields"), blockingFields }, + { QStringLiteral("details"), QJsonObject() }, + }; + } + + QJsonObject publicError(const QString& code, + const QJsonArray& blockingFields = {}, + const QJsonObject& details = {}) + { + 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() }, + }; + } + + QJsonObject contextState(const QString& status, + const ActiveNetworkSnapshot& network, + const QString& code = {}) + { + QJsonObject state { + { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, + { QStringLiteral("status"), status }, + { QStringLiteral("networkId"), network.id }, + { QStringLiteral("networkFingerprint"), network.fingerprint }, + { QStringLiteral("tokens"), QJsonArray() }, + { QStringLiteral("feeTiers"), QJsonArray() }, + { QStringLiteral("warnings"), QJsonArray() }, + }; + if (!code.isEmpty()) + state.insert(QStringLiteral("code"), code); + return state; + } + + QJsonArray variantStringArray(const QVariant& value) + { + QJsonArray result; + for (const QVariant& item : value.toList()) + result.append(item.toString()); + return result; + } + + QStringList jsonStringList(const QJsonArray& values) + { + QStringList result; + result.reserve(values.size()); + for (const QJsonValue& value : values) + result.append(value.toString()); + return result; + } + + QVector jsonBoolList(const QJsonArray& values) + { + QVector result; + result.reserve(values.size()); + for (const QJsonValue& value : values) + result.append(value.toBool()); + return result; + } + + QVector jsonUIntList(const QJsonArray& values) + { + QVector result; + result.reserve(values.size()); + for (const QJsonValue& value : values) + result.append(static_cast(value.toInteger())); + return result; + } + + QJsonObject accountReadJson(const WalletAccountRead& read) + { + QJsonObject result { + { QStringLiteral("id"), read.accountId }, + { QStringLiteral("status"), read.status }, + }; + if (read.ok()) { + result.insert(QStringLiteral("account"), QJsonObject { + { QStringLiteral("program_owner"), read.programOwner }, + { QStringLiteral("balance"), read.balanceHex }, + { QStringLiteral("nonce"), read.nonceHex }, + { QStringLiteral("data"), read.dataHex }, + }); + } + return result; + } + + QJsonArray accountReadsJson(const QVector& reads) + { + QJsonArray result; + for (const WalletAccountRead& read : reads) + result.append(accountReadJson(read)); + return result; + } +} + +NewPositionRuntime::NewPositionRuntime(WalletProvider* wallet, AmmClient* client) + : m_wallet(wallet), + m_client(client) +{ +} + +void NewPositionRuntime::clearWalletAccounts() +{ + m_wallet->clearSnapshot(); +} + +QJsonArray NewPositionRuntime::walletAccountReads(bool walletOpen, bool refresh) const +{ + if (!walletOpen) + return {}; + return accountReadsJson(m_wallet->snapshot(refresh).publicAccountReads); +} + +QJsonObject NewPositionRuntime::buildQuoteInput(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool freshWalletAccounts, + QJsonObject* error) const +{ + if (network.status != QStringLiteral("ready")) { + *error = publicError(network.status); + return {}; + } + const AmmClientResult configResult = m_client->configId( + QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } }); + if (!configResult.ok) { + *error = publicError(QStringLiteral("backend_error")); + return {}; + } + const QJsonObject configManifest = configResult.value; + const QJsonObject config = accountReadJson(m_wallet->readPublicAccount( + configManifest.value(QStringLiteral("configId")).toString())); + const QJsonObject requestObject = QJsonObject::fromVariantMap(request); + const AmmClientResult pairResult = m_client->pairIds( + QJsonObject { + { QStringLiteral("ammProgramId"), network.ammProgramId }, + { QStringLiteral("config"), config }, + { QStringLiteral("tokenAId"), requestObject.value(QStringLiteral("tokenAId")) }, + { QStringLiteral("tokenBId"), requestObject.value(QStringLiteral("tokenBId")) }, + }); + if (!pairResult.ok) { + *error = publicError(QStringLiteral("backend_error")); + return {}; + } + const QJsonObject pairManifest = pairResult.value; + if (pairManifest.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) { + *error = publicError(pairManifest.value(QStringLiteral("code")).toString()); + return {}; + } + + const QJsonArray walletAccounts = walletAccountReads(walletOpen, freshWalletAccounts); + const QJsonObject snapshot { + { QStringLiteral("config"), config }, + { QStringLiteral("tokenA"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("tokenAId")).toString())) }, + { QStringLiteral("tokenB"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("tokenBId")).toString())) }, + { QStringLiteral("pool"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("poolId")).toString())) }, + { QStringLiteral("vaultA"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("vaultAId")).toString())) }, + { QStringLiteral("vaultB"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("vaultBId")).toString())) }, + { QStringLiteral("lpDefinition"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("lpDefinitionId")).toString())) }, + { QStringLiteral("lpLockHolding"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("lpLockHoldingId")).toString())) }, + { QStringLiteral("currentTick"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("currentTickId")).toString())) }, + { QStringLiteral("clock"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("clockId")).toString())) }, + { QStringLiteral("walletAvailable"), walletOpen }, + { QStringLiteral("walletAccounts"), walletAccounts }, + }; + return { + { QStringLiteral("networkId"), network.id }, + { QStringLiteral("networkFingerprint"), network.fingerprint }, + { QStringLiteral("ammProgramId"), network.ammProgramId }, + { QStringLiteral("request"), requestObject }, + { QStringLiteral("snapshot"), snapshot }, + }; +} + +QVariantMap NewPositionRuntime::context(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool refreshWalletAccounts) +{ + if (network.status != QStringLiteral("ready")) + return contextState(network.status, network).toVariantMap(); + + const QJsonArray walletAccounts = walletAccountReads(walletOpen, refreshWalletAccounts); + + const QJsonObject hints = QJsonObject::fromVariantMap(request); + const AmmClientResult configResult = m_client->configId( + QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } }); + if (!configResult.ok) + return contextState( + QStringLiteral("error"), network, QStringLiteral("backend_error")).toVariantMap(); + + const QJsonObject configManifest = configResult.value; + const QJsonObject config = accountReadJson(m_wallet->readPublicAccount( + configManifest.value(QStringLiteral("configId")).toString())); + QJsonArray configured; + for (const QString& id : network.tokenIds) + configured.append(id); + const QJsonArray recent = variantStringArray(hints.value(QStringLiteral("recentTokenIds")).toVariant()); + const QJsonArray resolved = variantStringArray(hints.value(QStringLiteral("resolvedTokenIds")).toVariant()); + + const AmmClientResult tokenResult = m_client->tokenIds( + QJsonObject { + { QStringLiteral("ammProgramId"), network.ammProgramId }, + { QStringLiteral("config"), config }, + { QStringLiteral("walletAccounts"), walletAccounts }, + { QStringLiteral("configuredTokenIds"), configured }, + { QStringLiteral("recentTokenIds"), recent }, + { QStringLiteral("resolvedTokenIds"), resolved }, + }); + const QJsonObject tokenManifest = tokenResult.value; + if (!tokenResult.ok + || tokenManifest.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) { + const QString code = tokenResult.ok + ? tokenManifest.value(QStringLiteral("code")).toString() + : QStringLiteral("backend_error"); + return contextState( + QStringLiteral("error"), + network, + code.isEmpty() ? QStringLiteral("backend_error") : code).toVariantMap(); + } + + QJsonArray definitions; + for (const QJsonValue& id : tokenManifest.value(QStringLiteral("tokenIds")).toArray()) + definitions.append(accountReadJson(m_wallet->readPublicAccount(id.toString()))); + + const AmmClientResult contextResult = m_client->context( + QJsonObject { + { QStringLiteral("networkId"), network.id }, + { QStringLiteral("networkFingerprint"), network.fingerprint }, + { QStringLiteral("ammProgramId"), network.ammProgramId }, + { QStringLiteral("walletAvailable"), walletOpen }, + { QStringLiteral("config"), config }, + { QStringLiteral("walletAccounts"), walletAccounts }, + { QStringLiteral("tokenDefinitions"), definitions }, + { QStringLiteral("configuredTokenIds"), configured }, + { QStringLiteral("recentTokenIds"), recent }, + { QStringLiteral("resolvedTokenIds"), resolved }, + }); + return (contextResult.ok + ? contextResult.value + : contextState( + QStringLiteral("error"), network, QStringLiteral("backend_error"))).toVariantMap(); +} + +QVariantMap NewPositionRuntime::quote(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen) +{ + QJsonObject error; + const QJsonObject input = buildQuoteInput(request, network, walletOpen, false, &error); + if (!error.isEmpty()) + return error.toVariantMap(); + + const AmmClientResult result = m_client->quote(input); + return (result.ok ? result.value : publicError(QStringLiteral("backend_error"))).toVariantMap(); +} + +QVariantMap NewPositionRuntime::submit(const QVariantMap& request, + const QString& quoteHash, + const ActiveNetworkSnapshot& network, + bool walletOpen) +{ + if (m_submitInFlight) + return publicError(QStringLiteral("submit_in_progress")).toVariantMap(); + if (!walletOpen) + return publicError(QStringLiteral("wallet_unavailable")).toVariantMap(); + QScopedValueRollback submitGuard(m_submitInFlight, true); + + QJsonObject error; + const QJsonObject input = buildQuoteInput(request, network, walletOpen, true, &error); + if (!error.isEmpty()) + return error.toVariantMap(); + + const AmmClientResult quoteResult = m_client->quote(input); + if (!quoteResult.ok) + return publicError(QStringLiteral("backend_error")).toVariantMap(); + const QJsonObject quote = quoteResult.value; + if (quote.value(QStringLiteral("quoteHash")).toString() != quoteHash) { + QJsonObject result = publicError(QStringLiteral("quote_changed")); + result.insert(QStringLiteral("quote"), quote); + 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(); + } + + QJsonValue freshLp; + if (quote.value(QStringLiteral("requiresFreshLp")).toBool(false)) { + const WalletAccountCreation creation = m_wallet->createAccount(true); + if (!creation.ok() || !creation.publicAccount.ok()) + return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); + freshLp = accountReadJson(creation.publicAccount); + } + + QJsonObject planInput = input; + planInput.insert(QStringLiteral("quoteHash"), quoteHash); + planInput.insert(QStringLiteral("nowMs"), QDateTime::currentMSecsSinceEpoch()); + if (!freshLp.isUndefined()) + planInput.insert(QStringLiteral("freshLp"), freshLp); + + const AmmClientResult planResult = m_client->plan(planInput); + if (!planResult.ok) + return publicError(QStringLiteral("backend_error")).toVariantMap(); + const QJsonObject plan = planResult.value; + 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 QVector signingRequirements = jsonBoolList(plan.value(QStringLiteral("signingRequirements")).toArray()); + const QVector 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 WalletSubmission submission = m_wallet->submitPublicTransaction({ + programId, + accountIds, + signingRequirements, + instruction, + }); + if (!submission.accepted()) + return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); + const QString transactionId = base58TransactionId(submission.nativeHash); + if (transactionId.isEmpty()) + return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); + + return QJsonObject { + { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, + { QStringLiteral("status"), QStringLiteral("submitted") }, + { QStringLiteral("transactionId"), transactionId }, + { QStringLiteral("deadlineMs"), plan.value(QStringLiteral("deadlineMs")) }, + }.toVariantMap(); +} diff --git a/apps/amm/src/NewPositionRuntime.h b/apps/amm/src/NewPositionRuntime.h new file mode 100644 index 0000000..99e190e --- /dev/null +++ b/apps/amm/src/NewPositionRuntime.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include + +#include "ActiveNetwork.h" + +class AmmClient; +class WalletProvider; + +class NewPositionRuntime { +public: + NewPositionRuntime(WalletProvider* wallet, AmmClient* client); + + void clearWalletAccounts(); + + QVariantMap context(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool refreshWalletAccounts); + QVariantMap quote(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen); + QVariantMap submit(const QVariantMap& request, + const QString& quoteHash, + const ActiveNetworkSnapshot& network, + bool walletOpen); + +private: + QJsonArray walletAccountReads(bool walletOpen, bool refresh) const; + QJsonObject buildQuoteInput(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool freshWalletAccounts, + QJsonObject* error) const; + + WalletProvider* m_wallet; + AmmClient* m_client; + bool m_submitInFlight = false; +}; diff --git a/apps/amm/tests/cpp/ActiveNetworkTest.cpp b/apps/amm/tests/cpp/ActiveNetworkTest.cpp new file mode 100644 index 0000000..239f3e8 --- /dev/null +++ b/apps/amm/tests/cpp/ActiveNetworkTest.cpp @@ -0,0 +1,65 @@ +#include "ActiveNetwork.h" + +#include +#include +#include +#include + +namespace { + bool expect(bool condition, const char* message) + { + if (!condition) + qCritical("%s", message); + return condition; + } +} + +int main() +{ + const QString identity(64, QLatin1Char('a')); + const QString programId(64, QLatin1Char('b')); + const QString tokenId(64, QLatin1Char('c')); + + QTemporaryFile config; + if (!config.open()) + return 1; + config.write(QJsonDocument(QJsonObject { + { QStringLiteral("channelId"), identity }, + { QStringLiteral("ammProgramId"), programId }, + { QStringLiteral("tokenDefinitionIds"), QJsonArray { tokenId } }, + }).toJson(QJsonDocument::Compact)); + config.flush(); + + qputenv("AMM_UI_NETWORK", "devnet"); + qputenv("AMM_UI_DEVNET_FILE", config.fileName().toLocal8Bit()); + + ActiveNetwork network; + if (!expect(network.load(), "devnet config should load")) + return 1; + if (!expect(network.snapshot().status == QStringLiteral("network_unknown"), + "loaded network should await identity")) + return 1; + + network.sequencerChanged(true); + network.finishIdentityProbe(QString(64, QLatin1Char('d'))); + if (!expect(network.status() == QStringLiteral("network_mismatch"), + "wrong identity should block the network")) + return 1; + + network.reachabilityChanged(false, true); + network.reachabilityChanged(true, false); + network.finishIdentityProbe(identity); + const ActiveNetworkSnapshot snapshot = network.snapshot(); + if (!expect(snapshot.status == QStringLiteral("ready"), + "matching identity should ready the network")) + return 1; + if (!expect(snapshot.fingerprint == QStringLiteral("channel:") + identity, + "devnet fingerprint should use channel identity")) + return 1; + if (!expect(snapshot.ammProgramId == programId + && snapshot.tokenIds == QStringList { tokenId }, + "network snapshot should preserve configured program and tokens")) + return 1; + + return 0; +} diff --git a/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp b/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp new file mode 100644 index 0000000..38f174f --- /dev/null +++ b/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp @@ -0,0 +1,230 @@ +#include "AmmClient.h" +#include "NewPositionRuntime.h" +#include "WalletProvider.h" + +#include + +namespace { + bool expect(bool condition, const char* message) + { + if (!condition) + qCritical("%s", message); + return condition; + } + + class FakeWallet final : public WalletProvider { + public: + WalletSession connect(const WalletPaths&) override + { + return {}; + } + + WalletCreation createWallet(const WalletPaths&, const QString&) override + { + return {}; + } + + WalletSnapshot snapshot(bool) override + { + return {}; + } + + void clearSnapshot() override {} + + WalletAccountCreation createAccount(bool isPublic) override + { + ++createdAccounts; + WalletAccountCreation creation; + creation.accountId = QStringLiteral("fresh-lp"); + if (isPublic) + creation.publicAccount = readPublicAccount(creation.accountId); + return creation; + } + + WalletAccountRead readPublicAccount(const QString& accountId) const override + { + WalletAccountRead read; + read.accountId = accountId; + read.status = QStringLiteral("ok"); + return read; + } + + WalletSubmission submitPublicTransaction(const WalletTransaction& transaction) override + { + ++submissions; + submitted = transaction; + return { WalletFailure::None, transactionHash }; + } + + void disconnect() override {} + + QString transactionHash = QStringLiteral( + "000102030405060708090a0b0c0d0e0f" + "101112131415161718191a1b1c1d1e1f"); + int createdAccounts = 0; + int submissions = 0; + WalletTransaction submitted; + }; + + class FakeAmmClient final : public AmmClient { + public: + AmmClientResult configId(const QJsonObject&) const override + { + return success({ { QStringLiteral("configId"), QStringLiteral("config") } }); + } + + AmmClientResult tokenIds(const QJsonObject&) const override + { + return success({ { QStringLiteral("status"), QStringLiteral("ok") } }); + } + + AmmClientResult pairIds(const QJsonObject&) const override + { + return success({ + { QStringLiteral("status"), QStringLiteral("ok") }, + { QStringLiteral("tokenAId"), QStringLiteral("token-a") }, + { QStringLiteral("tokenBId"), QStringLiteral("token-b") }, + { QStringLiteral("poolId"), QStringLiteral("pool") }, + { QStringLiteral("vaultAId"), QStringLiteral("vault-a") }, + { QStringLiteral("vaultBId"), QStringLiteral("vault-b") }, + { QStringLiteral("lpDefinitionId"), QStringLiteral("lp") }, + { QStringLiteral("lpLockHoldingId"), QStringLiteral("lp-lock") }, + { QStringLiteral("currentTickId"), QStringLiteral("tick") }, + { QStringLiteral("clockId"), QStringLiteral("clock") }, + }); + } + + AmmClientResult context(const QJsonObject&) const override + { + return success({}); + } + + AmmClientResult quote(const QJsonObject&) const override + { + return success({ + { QStringLiteral("schema"), QStringLiteral("new-position.v1") }, + { QStringLiteral("status"), QStringLiteral("ok") }, + { QStringLiteral("canSubmit"), true }, + { QStringLiteral("quoteHash"), quoteHash }, + { QStringLiteral("requiresFreshLp"), requiresFreshLp }, + }); + } + + AmmClientResult plan(const QJsonObject& request) const override + { + sawFreshLp = request.contains(QStringLiteral("freshLp")); + return success({ + { QStringLiteral("status"), QStringLiteral("ready") }, + { QStringLiteral("accountIds"), QJsonArray { QStringLiteral("account") } }, + { QStringLiteral("signingRequirements"), QJsonArray { true } }, + { QStringLiteral("instruction"), QJsonArray { 1 } }, + { QStringLiteral("programId"), QStringLiteral("program") }, + { QStringLiteral("deadlineMs"), + QString::number(QDateTime::currentMSecsSinceEpoch() + 60'000) }, + }); + } + + static AmmClientResult success(const QJsonObject& value) + { + return { true, value }; + } + + QString quoteHash = QStringLiteral("sha256:expected"); + bool requiresFreshLp = true; + mutable bool sawFreshLp = false; + }; + + ActiveNetworkSnapshot readyNetwork() + { + return { + QStringLiteral("testnet"), + QStringLiteral("ready"), + QStringLiteral("block10:identity"), + QStringLiteral("program"), + {}, + }; + } +} + +int main() +{ + const QVariantMap request { + { QStringLiteral("schema"), QStringLiteral("new-position.v1") }, + { QStringLiteral("tokenAId"), QStringLiteral("token-a") }, + { QStringLiteral("tokenBId"), QStringLiteral("token-b") }, + { QStringLiteral("feeBps"), 30 }, + }; + + FakeWallet wallet; + FakeAmmClient client; + NewPositionRuntime runtime(&wallet, &client); + const QVariantMap result = runtime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(result.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted"), + "valid plan should submit")) + return 1; + if (!expect(result.value(QStringLiteral("transactionId")).toString() + == QStringLiteral("1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"), + "native hash should become the expected base58 transaction ID")) + return 1; + if (!expect(wallet.createdAccounts == 1 && client.sawFreshLp, + "fresh LP account should enter the plan")) + return 1; + if (!expect(wallet.submissions == 1 + && wallet.submitted.accountIds + == QStringList { QStringLiteral("account") } + && wallet.submitted.signingRequirements.size() == 1 + && wallet.submitted.signingRequirements.constFirst() + && wallet.submitted.instruction.size() == 1 + && wallet.submitted.instruction.constFirst() == 1 + && wallet.submitted.programId == QStringLiteral("program"), + "runtime should dispatch the unchanged plan once")) + return 1; + + FakeWallet orderedBytesWallet; + orderedBytesWallet.transactionHash = QStringLiteral( + "0102030405060708090a0b0c0d0e0f10" + "1112131415161718191a1b1c1d1e1f20"); + FakeAmmClient orderedBytesClient; + orderedBytesClient.requiresFreshLp = false; + NewPositionRuntime orderedBytesRuntime(&orderedBytesWallet, &orderedBytesClient); + const QVariantMap orderedBytes = orderedBytesRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(orderedBytes.value(QStringLiteral("transactionId")).toString() + == QStringLiteral("4wBqpZM9xaSheZzJSMawUKKwhdpChKbZ5eu5ky4Vigw"), + "ordered native hash bytes should preserve byte order")) + return 1; + + FakeWallet invalidHashWallet; + invalidHashWallet.transactionHash = QStringLiteral("not-a-hash"); + FakeAmmClient invalidHashClient; + invalidHashClient.requiresFreshLp = false; + NewPositionRuntime invalidHashRuntime(&invalidHashWallet, &invalidHashClient); + const QVariantMap invalidHash = invalidHashRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(invalidHash.value(QStringLiteral("code")).toString() + == QStringLiteral("wallet_submission_failed") + && !invalidHash.contains(QStringLiteral("transactionId")), + "invalid native hash should fail without a hex fallback")) + return 1; + if (!expect(invalidHashWallet.submissions == 1, + "hash conversion should happen after wallet submission")) + return 1; + + FakeWallet staleWallet; + FakeAmmClient staleClient; + staleClient.quoteHash = QStringLiteral("sha256:changed"); + NewPositionRuntime staleRuntime(&staleWallet, &staleClient); + const QVariantMap stale = staleRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(stale.value(QStringLiteral("code")).toString() + == QStringLiteral("quote_changed"), + "changed quote should stop submission")) + return 1; + if (!expect(staleWallet.createdAccounts == 0 && staleWallet.submissions == 0, + "stale quote should have no wallet side effects")) + return 1; + + return 0; +} diff --git a/apps/amm/tests/new-position-validation.mjs b/apps/amm/tests/new-position-validation.mjs deleted file mode 100755 index b661b91..0000000 --- a/apps/amm/tests/new-position-validation.mjs +++ /dev/null @@ -1,312 +0,0 @@ -#!/usr/bin/env node - -import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import { readFileSync } from 'node:fs'; - -const MINIMUM_LIQUIDITY = 1000; -const ACTIVE_POOL_RESERVES = { - LOGOS: 10_000_000, - USDC: 1_250_000, -}; -const ACTIVE_POOL_LP_SUPPLY = 6_875_000; -const SUPPORTED_FEE_TIERS = new Set([1, 5, 30, 100]); -const ROLE_TO_IDL_NAME = { - Config: 'config', - Pool: 'pool', - 'Vault A': 'vault_a', - 'Vault B': 'vault_b', - 'LP definition': 'pool_definition_lp', - 'LP lock holding': 'lp_lock_holding', - 'User holding A': 'user_holding_a', - 'User holding B': 'user_holding_b', - 'User LP holding': 'user_holding_lp', - 'Current tick': 'current_tick_account', - Clock: 'clock', -}; - -function stableId(key) { - return createHash('sha256').update(key).digest('hex'); -} - -function inferDecimals(totalSupply) { - const supply = BigInt(totalSupply); - const digits = supply.toString().length; - - if (supply < 10_000_000n) - return 0; - if (digits <= 14) - return 6; - if (digits <= 21) - return 9; - return 18; -} - -function parseHumanAmount(input, decimals) { - const text = String(input).replaceAll(',', '').trim(); - assert.match(text, /^\d+(\.\d+)?$/, `invalid amount: ${input}`); - - const [whole, fraction = ''] = text.split('.'); - assert.ok(fraction.length <= decimals, `too many decimal places: ${input}`); - - const scale = 10n ** BigInt(decimals); - const paddedFraction = fraction.padEnd(decimals, '0') || '0'; - return BigInt(whole) * scale + BigInt(paddedFraction); -} - -function formatBaseUnits(units, decimals) { - const value = BigInt(units); - if (decimals === 0) - return value.toString(); - - const scale = 10n ** BigInt(decimals); - const whole = value / scale; - const fraction = (value % scale).toString().padStart(decimals, '0').replace(/0+$/, ''); - return fraction.length > 0 ? `${whole}.${fraction}` : whole.toString(); -} - -function parsePositiveAmount(value) { - const parsed = Number(String(value).replaceAll(',', '').trim()); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; -} - -function unorderedPairKey(symbolA, symbolB) { - return symbolA < symbolB ? `${symbolA}/${symbolB}` : `${symbolB}/${symbolA}`; -} - -function activeRatio(symbolA, symbolB) { - if (symbolA === 'USDC' && symbolB === 'LOGOS') - return ACTIVE_POOL_RESERVES.LOGOS / ACTIVE_POOL_RESERVES.USDC; - if (symbolA === 'LOGOS' && symbolB === 'USDC') - return ACTIVE_POOL_RESERVES.USDC / ACTIVE_POOL_RESERVES.LOGOS; - return 1; -} - -function defaultInitialPrice(symbolA, symbolB) { - if (symbolA === 'USDC' && symbolB === 'WETH') - return 2500; - if (symbolA === 'WETH' && symbolB === 'USDC') - return 0.0004; - return 1; -} - -function poolContext(symbolA, symbolB) { - const pairKey = unorderedPairKey(symbolA, symbolB); - const poolId = stableId(`devnet:amm-pool:${pairKey}`); - - if (pairKey === 'LOGOS/USDC') - return { poolId, poolStatus: 'active_pool', instruction: 'add_liquidity', storedFeeBps: 30 }; - if (pairKey === 'USDC/WETH') - return { poolId, poolStatus: 'missing_pool', instruction: 'new_definition', storedFeeBps: 0 }; - return { poolId, poolStatus: 'unavailable_pool', instruction: '', storedFeeBps: 0 }; -} - -function accountChange(role, id, action) { - return { role, id, action }; -} - -function accountChanges(request, context) { - const { tokenA, tokenB } = request; - const { poolId } = context; - const missingPool = context.poolStatus === 'missing_pool'; - const changes = [ - accountChange('Config', stableId('devnet:amm-config'), 'Read'), - accountChange('Pool', poolId, missingPool ? 'Create' : 'Update'), - accountChange('Vault A', stableId(`devnet:vault:${poolId}:${tokenA}`), missingPool ? 'Update or create' : 'Update'), - accountChange('Vault B', stableId(`devnet:vault:${poolId}:${tokenB}`), missingPool ? 'Update or create' : 'Update'), - accountChange('LP definition', stableId(`devnet:lp-definition:${poolId}`), missingPool ? 'Create' : 'Update'), - ]; - - if (missingPool) - changes.push(accountChange('LP lock holding', stableId(`devnet:lp-lock:${poolId}`), 'Create')); - - changes.push( - accountChange('User holding A', stableId(`devnet:user-holding:${tokenA}`), 'Update'), - accountChange('User holding B', stableId(`devnet:user-holding:${tokenB}`), 'Update'), - accountChange('User LP holding', stableId(`devnet:user-lp:${poolId}`), 'Update or create'), - accountChange('Current tick', stableId(`devnet:current-tick:${poolId}`), missingPool ? 'Create' : 'Update'), - accountChange('Clock', stableId('devnet:clock:canonical'), 'Read'), - ); - - return changes; -} - -function quoteNewPosition(request) { - assert.ok(SUPPORTED_FEE_TIERS.has(request.feeBps), 'unsupported fee tier'); - - const context = poolContext(request.tokenA, request.tokenB); - const slippageBps = Math.max(1, Math.min(5000, request.slippageBps)); - - if (context.poolStatus === 'active_pool') { - assert.equal(request.feeBps, context.storedFeeBps, 'active pool fee tier mismatch'); - - const inputA = parsePositiveAmount(request.amountA); - const inputB = parsePositiveAmount(request.amountB); - const editA = request.editedSide !== 'B'; - const ratio = activeRatio(request.tokenA, request.tokenB); - const amountA = editA ? inputA : inputB / ratio; - const amountB = editA ? inputA * ratio : inputB; - const reserveA = ACTIVE_POOL_RESERVES[request.tokenA]; - const reserveB = ACTIVE_POOL_RESERVES[request.tokenB]; - const expectedLp = Math.floor(Math.min( - ACTIVE_POOL_LP_SUPPLY * amountA / reserveA, - ACTIVE_POOL_LP_SUPPLY * amountB / reserveB, - )); - - return { - accountChanges: accountChanges(request, context), - deposit: { maxA: amountA, maxB: amountB, actualA: amountA, actualB: amountB }, - instruction: context.instruction, - lp: { - expected: expectedLp, - locked: 0, - minimum: Math.floor(expectedLp * (10000 - slippageBps) / 10000), - }, - poolStatus: context.poolStatus, - }; - } - - if (context.poolStatus === 'missing_pool') { - const parsedPrice = parsePositiveAmount(request.initialPrice); - const price = parsedPrice > 0 ? parsedPrice : defaultInitialPrice(request.tokenA, request.tokenB); - const baseA = price >= 1 ? price : 1; - const baseB = price >= 1 ? 1 : 1 / price; - const minimumScale = Math.floor(MINIMUM_LIQUIDITY / Math.sqrt(baseA * baseB)) + 1; - const scale = minimumScale * Math.max(1, request.depositScale); - const amountA = baseA * scale; - const amountB = baseB * scale; - const initialLp = Math.floor(Math.sqrt(amountA * amountB)); - const userLp = Math.max(0, initialLp - MINIMUM_LIQUIDITY); - - return { - accountChanges: accountChanges(request, context), - deposit: { maxA: amountA, maxB: amountB, actualA: amountA, actualB: amountB }, - instruction: context.instruction, - lp: { - expected: userLp, - locked: MINIMUM_LIQUIDITY, - minimum: Math.floor(userLp * (10000 - slippageBps) / 10000), - }, - poolStatus: context.poolStatus, - }; - } - - throw new Error(`unsupported pool: ${request.tokenA}/${request.tokenB}`); -} - -function idlAccounts(instructionName) { - const idl = JSON.parse(readFileSync(new URL('../../../artifacts/amm-idl.json', import.meta.url), 'utf8')); - const instruction = idl.instructions.find((item) => item.name === instructionName); - assert.ok(instruction, `missing IDL instruction: ${instructionName}`); - return instruction.accounts.map((account) => account.name); -} - -function assertAccountOrderMatchesIdl(quote, instructionName) { - const actual = quote.accountChanges.map((change) => { - assert.ok(ROLE_TO_IDL_NAME[change.role], `unmapped account role: ${change.role}`); - return ROLE_TO_IDL_NAME[change.role]; - }); - assert.deepEqual(actual, idlAccounts(instructionName)); -} - -function testDecimals() { - assert.equal(inferDecimals(9_999_999n), 0); - assert.equal(inferDecimals(1_000_000_000_000n), 6); - assert.equal(inferDecimals(1_000_000_000_000_000_000n), 9); - assert.equal(inferDecimals(1_000_000_000_000_000_000_000_000n), 18); - assert.ok([9_999_999n, 1_000_000_000_000n, 1_000_000_000_000_000_000n] - .every((supply) => inferDecimals(supply) !== 8)); - - for (const [amount, decimals] of [ - ['12345', 0], - ['12.3456', 6], - ['0.000000001', 9], - ['1.000000000000000001', 18], - ]) { - assert.equal(formatBaseUnits(parseHumanAmount(amount, decimals), decimals), amount); - } - - assert.throws(() => parseHumanAmount('1.1', 0), /too many decimal places/); - assert.throws(() => parseHumanAmount('1.0000000001', 9), /too many decimal places/); -} - -function testMissingPool() { - const quote1x = quoteNewPosition({ - amountA: '', - amountB: '', - depositScale: 1, - editedSide: 'A', - feeBps: 30, - initialPrice: '2500', - slippageBps: 50, - tokenA: 'USDC', - tokenB: 'WETH', - }); - assert.equal(quote1x.poolStatus, 'missing_pool'); - assert.equal(quote1x.instruction, 'new_definition'); - assert.equal(quote1x.deposit.maxA, 52_500); - assert.equal(quote1x.deposit.maxB, 21); - assert.equal(quote1x.lp.expected, 50); - assert.equal(quote1x.lp.locked, 1000); - assert.equal(quote1x.lp.minimum, 49); - assertAccountOrderMatchesIdl(quote1x, 'new_definition'); - - const quote2x = quoteNewPosition({ - amountA: '', - amountB: '', - depositScale: 2, - editedSide: 'A', - feeBps: 30, - initialPrice: '2500', - slippageBps: 50, - tokenA: 'USDC', - tokenB: 'WETH', - }); - assert.equal(quote2x.deposit.maxA / quote2x.deposit.maxB, 2500); - assert.equal(quote2x.deposit.maxA, 105_000); - assert.equal(quote2x.deposit.maxB, 42); - assert.equal(quote2x.lp.expected, 1100); - assert.equal(quote2x.lp.minimum, 1094); -} - -function testActivePool() { - const quoteByA = quoteNewPosition({ - amountA: '100', - amountB: '', - depositScale: 1, - editedSide: 'A', - feeBps: 30, - initialPrice: '', - slippageBps: 50, - tokenA: 'USDC', - tokenB: 'LOGOS', - }); - assert.equal(quoteByA.poolStatus, 'active_pool'); - assert.equal(quoteByA.instruction, 'add_liquidity'); - assert.equal(quoteByA.deposit.maxA, 100); - assert.equal(quoteByA.deposit.maxB, 800); - assert.equal(quoteByA.lp.expected, 550); - assert.equal(quoteByA.lp.minimum, 547); - assertAccountOrderMatchesIdl(quoteByA, 'add_liquidity'); - - const quoteByB = quoteNewPosition({ - amountA: '', - amountB: '100', - depositScale: 1, - editedSide: 'B', - feeBps: 30, - initialPrice: '', - slippageBps: 50, - tokenA: 'LOGOS', - tokenB: 'USDC', - }); - assert.equal(quoteByB.deposit.maxA, 800); - assert.equal(quoteByB.deposit.maxB, 100); - assert.equal(quoteByB.lp.expected, 550); -} - -testDecimals(); -testMissingPool(); -testActivePool(); - -console.log('New Position validation harness passed'); diff --git a/apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml b/apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml new file mode 100644 index 0000000..11ca026 --- /dev/null +++ b/apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml @@ -0,0 +1,29 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest + +import "../../qml/components/liquidity" as Liquidity + +TestCase { + id: testCase + + name: "LiquidityConfirmationSummary" + + Component { + id: summaryComponent + + Liquidity.LiquidityConfirmationSummary { + width: 480 + } + } + + function test_protocolActionsUseDisplayLabels() { + var summary = createTemporaryObject(summaryComponent, testCase) + verify(summary) + + compare(summary.actionText("NewDefinition"), "Create pool") + compare(summary.actionText("AddLiquidity"), "Add liquidity") + compare(summary.actionText(""), "-") + } +} diff --git a/apps/amm/tests/qml/tst_LiquidityPage.qml b/apps/amm/tests/qml/tst_LiquidityPage.qml new file mode 100644 index 0000000..f4980e4 --- /dev/null +++ b/apps/amm/tests/qml/tst_LiquidityPage.qml @@ -0,0 +1,309 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest + +import "../../qml/pages" as Pages + +TestCase { + id: testCase + + name: "LiquidityPage" + readonly property string submittedTransactionId: + "1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE" + + Component { + id: backendComponent + + QtObject { + property bool walletStateReady: false + property var submitResult: ({}) + property var quoteResult: ({ + "schema": "new-position.v1", + "status": "ok", + "poolStatus": "missing_pool" + }) + property var newPositionContext: ({ + "schema": "new-position.v1", + "status": "ready", + "tokens": [], + "feeTiers": [] + }) + + function submitNewPosition(request, quoteHash) { + return submitResult + } + + function quoteNewPosition(request) { + return quoteResult + } + } + } + + Component { + id: runtimeComponent + + QtObject { + function watch(value, succeeded, failed) { + succeeded(value) + } + } + } + + Component { + id: pageComponent + + Pages.LiquidityPage { + visible: false + width: 800 + height: 600 + } + } + + function test_contextWaitsForWalletState() { + var backend = createTemporaryObject(backendComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend + }) + verify(backend) + verify(page) + + compare(page.flow.newPositionContext.status, "loading") + + backend.walletStateReady = true + tryCompare(page.flow.newPositionContext, "status", "ready") + } + + function test_contextRefreshControlsWalletScan() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true + }) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend + }) + verify(backend) + verify(page) + + compare(page.flow.contextHints(false).refreshWalletAccounts, false) + compare(page.flow.contextHints(true).refreshWalletAccounts, true) + } + + function test_staleContextCompletionCannotFinishNewerRefresh() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true + }) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend + }) + verify(backend) + verify(page) + + page.flow.contextSerial = 2 + page.flow.contextLoading = true + page.flow.contextErrorCode = "newer_request_pending" + + page.flow.finishContextRefresh(1, null) + page.flow.failContextRefresh(1) + + compare(page.flow.contextLoading, true) + compare(page.flow.contextErrorCode, "newer_request_pending") + + page.flow.finishContextRefresh(2, null) + compare(page.flow.contextLoading, false) + compare(page.flow.contextErrorCode, "") + } + + function test_submitFailureKeepsReturnedFreshQuoteWithoutRequery() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true + }) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend + }) + verify(backend) + verify(page) + + page.flow.quoteSerial = 7 + page.flow.finishSubmitFailure({ + "schema": "new-position.v1", + "status": "error", + "code": "quote_not_submittable", + "quote": { + "schema": "new-position.v1", + "status": "ok", + "canSubmit": false, + "quoteHash": "sha256:fresh" + } + }) + + compare(page.flow.quoteSerial, 7) + compare(page.flow.newPositionQuote.quoteHash, "sha256:fresh") + compare(page.flow.quoteStale, false) + } + + function test_base58SubmittedResultEntersSuccessState() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true, + "submitResult": { + "schema": "new-position.v1", + "status": "submitted", + "transactionId": submittedTransactionId, + "deadlineMs": String(Date.now() + 60000) + } + }) + var runtime = createTemporaryObject(runtimeComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend, + "runtime": runtime + }) + verify(backend) + verify(runtime) + verify(page) + + page.flow.confirm({ + "request": ({}), + "quoteHash": "sha256:expected" + }) + + compare(page.flow.transactionId, submittedTransactionId) + compare(page.flow.flowErrorCode, "") + compare(page.flow.submitting, false) + } + + function test_base58MissingPoolSubmissionStartsPoolWatch() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true, + "submitResult": { + "schema": "new-position.v1", + "status": "submitted", + "transactionId": submittedTransactionId, + "deadlineMs": String(Date.now() + 60000) + } + }) + var runtime = createTemporaryObject(runtimeComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend, + "runtime": runtime + }) + verify(backend) + verify(runtime) + verify(page) + + var probe = { + "tokenAId": "22222222222222222222222222222222", + "tokenBId": "33333333333333333333333333333333" + } + page.flow.pendingQuoteRequest = { "ok": true, "request": probe } + page.flow.confirm({ + "request": { + "initialPriceRealRaw": "18446744073709551616" + }, + "poolProbeRequest": probe, + "quoteHash": "sha256:expected" + }) + wait(0) + + compare(page.flow.transactionId, submittedTransactionId) + compare(page.flow.pendingPoolProbes.length, 1) + compare(page.flow.selectedPoolCreationPending(), true) + compare(page.flow.poolProbeInFlight, false) + } + + function test_nativeHexSubmittedResultDoesNotEnterSuccessState() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true, + "submitResult": { + "schema": "new-position.v1", + "status": "submitted", + "transactionId": "000102030405060708090a0b0c0d0e0f" + + "101112131415161718191a1b1c1d1e1f" + } + }) + var runtime = createTemporaryObject(runtimeComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend, + "runtime": runtime + }) + verify(backend) + verify(runtime) + verify(page) + + page.flow.confirm({ + "request": ({}), + "quoteHash": "sha256:expected" + }) + + compare(page.flow.transactionId, "") + compare(page.flow.flowErrorCode, "wallet_submission_failed") + compare(page.flow.submitting, false) + } + + function test_poolProbeDoesNotPublishProbeAmountsAsCurrentQuote() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true + }) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend + }) + verify(backend) + verify(page) + + var request = { + "tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "tokenBId": "22222222222222222222222222222222" + } + var pending = { "key": page.flow.pairKey(request), "request": request } + page.flow.pendingQuoteRequest = { "ok": true, "request": request } + page.flow.pendingPoolProbes = [pending] + page.flow.newPositionQuote = { + "schema": "new-position.v1", + "status": "ok", + "poolStatus": "missing_pool", + "tokenAId": request.tokenAId, + "tokenBId": request.tokenBId + } + page.flow.quoteStale = false + + page.flow.finishPoolProbe(pending, { + "schema": "new-position.v1", + "status": "ok", + "poolStatus": "active_pool", + "tokenAId": request.tokenAId, + "tokenBId": request.tokenBId + }) + + compare(page.flow.pendingPoolProbes.length, 0) + compare(page.flow.newPositionQuote.poolStatus, "missing_pool") + verify(page.flow.quoteStale) + } + + function test_poolProbeStopsBlockingAfterTransactionDeadline() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true + }) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend + }) + verify(backend) + verify(page) + + var request = { + "tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "tokenBId": "22222222222222222222222222222222" + } + var pending = { + "key": page.flow.pairKey(request), + "request": request, + "deadlineMs": Date.now() - 1 + } + page.flow.pendingQuoteRequest = { "ok": true, "request": request } + page.flow.pendingPoolProbes = [pending] + page.flow.poolProbeInFlight = true + + page.flow.finishPoolProbe(pending, null) + + compare(page.flow.pendingPoolProbes.length, 0) + compare(page.flow.poolProbeInFlight, false) + verify(!page.flow.selectedPoolCreationPending()) + } +} diff --git a/apps/amm/tests/qml/tst_NewPositionForm.qml b/apps/amm/tests/qml/tst_NewPositionForm.qml new file mode 100644 index 0000000..a8cc48d --- /dev/null +++ b/apps/amm/tests/qml/tst_NewPositionForm.qml @@ -0,0 +1,629 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest + +import "../../qml/components/liquidity" as Liquidity + +TestCase { + id: testCase + + name: "NewPositionForm" + + readonly property string tokenLow: "22222222222222222222222222222222" + readonly property string tokenHigh: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + readonly property string tokenThird: "33333333333333333333333333333333" + readonly property string submittedTransactionId: + "1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE" + + Component { + id: formComponent + + Liquidity.NewPositionForm { + visible: false + width: 760 + newPositionContext: testCase.readyContext() + } + } + + Component { + id: clipboardSinkComponent + + TextEdit {} + } + + SignalSpy { + id: quoteRequestedSpy + signalName: "quoteRequested" + } + + function readyContext() { + return { + "status": "ready", + "tokens": [ + { + "definitionId": tokenLow, + "name": "Low", + "totalSupplyRaw": "1000000", + "balanceRaw": "1000", + "selectable": true + }, + { + "definitionId": tokenHigh, + "name": "High", + "totalSupplyRaw": "1000000000000", + "balanceRaw": "5000000000", + "selectable": true + } + ] + } + } + + function equalDecimalsContext() { + return { + "status": "ready", + "tokens": [ + { + "definitionId": tokenLow, + "name": "Sir Mints-a-Lot", + "totalSupplyRaw": "1000000000000", + "balanceRaw": "1000000000", + "selectable": true + }, + { + "definitionId": tokenHigh, + "name": "Aurora", + "totalSupplyRaw": "1000000000000", + "balanceRaw": "1000000000", + "selectable": true + } + ] + } + } + + function flowState(quote) { + return { + "quote": quote || ({}), + "contextLoading": false, + "quoteLoading": false, + "quoteStale": false, + "submitting": false + } + } + + function createForm() { + var form = createTemporaryObject(formComponent, testCase, { + "flowState": flowState(({})) + }) + verify(form) + wait(0) + compare(form.selectedTokenAId, tokenLow) + compare(form.selectedTokenBId, tokenHigh) + return form + } + + function test_displayOrderMapsToCanonicalRequestAfterSwap() { + var form = createForm() + var built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.tokenAId, tokenHigh) + compare(built.request.tokenBId, tokenLow) + + form.swapTokens() + compare(form.selectedTokenAId, tokenHigh) + compare(form.selectedTokenBId, tokenLow) + built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.tokenAId, tokenHigh) + compare(built.request.tokenBId, tokenLow) + } + + function test_missingPoolKeepsMinimumRatioWhileEditingDeposit() { + var quote = { + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "2000000", + "minimumAmountBRaw": "3", + "actualAmountARaw": "2000000", + "actualAmountBRaw": "3" + } + var form = createForm() + form.priceAmountA = "3" + form.priceAmountB = "2" + form.flowState = flowState(quote) + wait(0) + compare(form.amountA, "3") + compare(form.amountB, "2") + + form.editMissingAmount("A", "6") + compare(form.amountA, "6") + compare(form.amountB, "2") + + form.finishMissingAmount("A", "6") + compare(form.amountB, "4") + + var built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.amountARaw, "4000000") + compare(built.request.amountBRaw, "6") + } + + function test_missingPoolAcceptsLargeDirectAmountsFromEitherSide() { + var form = createTemporaryObject(formComponent, testCase, { + "flowState": flowState(({})), + "newPositionContext": equalDecimalsContext() + }) + verify(form) + wait(0) + form.priceAmountA = "15" + form.priceAmountB = "10" + form.flowState = flowState({ + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "26", + "minimumAmountBRaw": "39", + "actualAmountARaw": "26", + "actualAmountBRaw": "39" + }) + wait(0) + + form.finishMissingAmount("A", "150") + compare(form.amountA, "150") + compare(form.amountB, "100") + var built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.amountARaw, "100000000") + compare(built.request.amountBRaw, "150000000") + compare(built.request.initialPriceRealRaw, "27670116110564327424") + verify(!built.request.hasOwnProperty("depositScaleBps")) + + form.finishMissingAmount("B", "200") + compare(form.amountA, "300") + compare(form.amountB, "200") + built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.amountARaw, "200000000") + compare(built.request.amountBRaw, "300000000") + } + + function test_missingPoolRoundsPairedAmountAndTrimsInputPrecision() { + var form = createTemporaryObject(formComponent, testCase, { + "flowState": flowState(({})), + "newPositionContext": equalDecimalsContext() + }) + verify(form) + wait(0) + form.priceAmountA = "15" + form.priceAmountB = "10" + form.flowState = flowState({ + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "26", + "minimumAmountBRaw": "39", + "actualAmountARaw": "26", + "actualAmountBRaw": "39" + }) + wait(0) + + var cases = [ + { "input": "1", "paired": "0.666667", "rawA": "666667", "rawB": "1000000", + "price": "27670102275513189667" }, + { "input": "2", "paired": "1.333333", "rawA": "1333333", "rawB": "2000000", + "price": "27670123028095084447" }, + { "input": "3", "paired": "2", "rawA": "2000000", "rawB": "3000000", + "price": "27670116110564327424" }, + { "input": "4", "paired": "2.666667", "rawA": "2666667", "rawB": "4000000", + "price": "27670112651800245948" } + ] + for (var i = 0; i < cases.length; ++i) { + form.finishMissingAmount("A", cases[i].input) + compare(form.amountB, cases[i].paired) + var built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.amountARaw, cases[i].rawA) + compare(built.request.amountBRaw, cases[i].rawB) + compare(built.request.initialPriceRealRaw, cases[i].price) + } + + form.finishMissingAmount("A", "1.1234567") + compare(form.amountA, "1.123456") + compare(form.amountB, "0.748971") + verify(form.buildQuoteRequest().ok) + } + + function test_missingPoolUsesTradeStyleInputsWithInlinePrice() { + var form = createForm() + form.flowState = flowState({ + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "2000000", + "minimumAmountBRaw": "3", + "actualAmountARaw": "2000000", + "actualAmountBRaw": "3" + }) + wait(0) + + var amountAInput = findChild(form, "tokenAAmountInput") + var amountBInput = findChild(form, "tokenBAmountInput") + verify(amountAInput) + verify(amountBInput) + compare(amountAInput.selectedTokenId, tokenLow) + compare(amountBInput.selectedTokenId, tokenHigh) + verify(amountAInput.height <= 114) + verify(amountBInput.height <= 114) + verify(findChild(form, "priceAmountAField")) + verify(findChild(form, "priceAmountBField")) + + form.width = 328 + wait(0) + compare(amountAInput.adjustmentWidth, 100) + compare(amountBInput.adjustmentWidth, 100) + } + + function test_errorMessageStaysAbovePairAndMarksCausingControl() { + var form = createForm() + form.flowState = flowState({ + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "2000000", + "minimumAmountBRaw": "3", + "actualAmountARaw": "2000000", + "actualAmountBRaw": "3" + }) + wait(0) + var amountAInput = findChild(form, "tokenAAmountInput") + var amountBInput = findChild(form, "tokenBAmountInput") + + form.localErrors = [form.localIssue("amount_exceeds_balance", ["amountB"])] + wait(0) + + compare(amountAInput.errorText, form.issueText("amount_exceeds_balance")) + compare(amountAInput.invalid, false) + compare(amountBInput.invalid, true) + + form.resolveToken("B", "invalid") + wait(0) + compare(amountAInput.errorText, form.issueText("invalid_token_id")) + compare(amountAInput.tokenInvalid, false) + compare(amountBInput.tokenInvalid, true) + } + + function test_staleMissingPoolQuoteDoesNotReplaceDraft() { + var quote = { + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "2000000", + "minimumAmountBRaw": "3", + "actualAmountARaw": "2000000", + "actualAmountBRaw": "3" + } + var form = createForm() + form.flowState = flowState(quote) + wait(0) + compare(form.amountA, "3") + + form.editMissingAmount("A", "2") + form.flowState = { + "quote": { + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "2000000", + "minimumAmountBRaw": "3", + "actualAmountARaw": "2000000", + "actualAmountBRaw": "3" + }, + "contextLoading": false, + "quoteLoading": false, + "quoteStale": true, + "submitting": false + } + wait(0) + + compare(form.amountA, "2") + } + + function test_poolActivationClearsCreationDraftWithoutPublishingProbeAmounts() { + var form = createForm() + form.confirmedPoolStatus = "missing_pool" + form.amountA = "3" + form.amountB = "2" + form.minimumAmountARaw = "3" + form.minimumAmountBRaw = "2000000" + + verify(form.acceptPoolActivation({ + "schema": "new-position.v1", + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "active_pool", + "reserveARaw": "3000000", + "reserveBRaw": "2" + })) + + verify(form.activePool) + compare(form.activePoolQuote.poolStatus, "active_pool") + compare(form.amountA, "") + compare(form.amountB, "") + compare(form.minimumAmountARaw, "") + compare(form.minimumAmountBRaw, "") + quoteRequestedSpy.target = form + quoteRequestedSpy.clear() + form.requestQuote(true) + compare(quoteRequestedSpy.count, 0) + compare(form.localErrors.length, 0) + } + + function test_staleQuoteErrorsDoNotMarkCurrentDraft() { + var quote = { + "schema": "new-position.v1", + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "active_pool", + "reserveARaw": "2000000", + "reserveBRaw": "10", + "maxAmountARaw": "4000000", + "maxAmountBRaw": "20", + "errors": [{ + "code": "amount_exceeds_balance", + "blockingFields": ["maxAmountARaw"] + }] + } + var form = createForm() + form.flowState = flowState(quote) + wait(0) + compare(form.fieldError("amountB"), form.issueText("amount_exceeds_balance")) + + var staleState = flowState(quote) + staleState.quoteStale = true + form.flowState = staleState + wait(0) + + compare(form.fieldError("amountB"), "") + compare(form.formErrorText(), "") + compare(form.accountPreview().length, 0) + } + + function test_activePoolEditUsesDisplayReserveRatio() { + var quote = { + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "active_pool", + "reserveARaw": "2000000", + "reserveBRaw": "10", + "maxAmountARaw": "4000000", + "maxAmountBRaw": "20" + } + var form = createForm() + form.flowState = flowState(quote) + wait(0) + compare(form.amountA, "20") + compare(form.amountB, "4") + + quoteRequestedSpy.target = form + quoteRequestedSpy.clear() + form.editActiveAmount("A", "5") + compare(form.amountA, "5") + compare(form.amountB, "4") + compare(quoteRequestedSpy.count, 0) + + form.finishActiveAmount("A", "5") + compare(form.amountB, "1") + compare(quoteRequestedSpy.count, 1) + + var built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.maxAmountARaw, "1000000") + compare(built.request.maxAmountBRaw, "5") + + form.finishActiveAmount("B", "1.1234567") + compare(form.amountB, "1.123456") + } + + function test_activePoolEditRecoversAfterInvalidQuote() { + var form = createForm() + form.flowState = flowState({ + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "active_pool", + "poolFeeBps": 30, + "reserveARaw": "2000000", + "reserveBRaw": "10", + "maxAmountARaw": "4000000", + "maxAmountBRaw": "20" + }) + wait(0) + + form.amountA = "0" + form.amountB = "0" + form.flowState = flowState({ + "status": "error", + "code": "value_must_be_positive", + "tokenAId": tokenHigh, + "tokenBId": tokenLow + }) + wait(0) + + compare(form.poolFeeBps, 30) + form.finishActiveAmount("B", "1") + compare(form.amountA, "5") + + var built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.maxAmountARaw, "1000000") + compare(built.request.maxAmountBRaw, "5") + } + + function test_quoteStateChangeDoesNotRequestAnotherQuote() { + var form = createForm() + quoteRequestedSpy.target = form + quoteRequestedSpy.clear() + + form.flowState = { + "quote": ({}), + "contextLoading": false, + "quoteLoading": true, + "quoteStale": true, + "submitting": false + } + wait(0) + + compare(quoteRequestedSpy.count, 0) + } + + function test_existingPoolFeeCorrectionKeepsQuoteRequestValid() { + var form = createForm() + quoteRequestedSpy.target = form + quoteRequestedSpy.clear() + + form.flowState = flowState({ + "status": "error", + "code": "fee_tier_mismatch", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "errors": [{ + "code": "fee_tier_mismatch", + "details": { "poolFeeBps": "5" } + }] + }) + wait(0) + + compare(form.selectedFeeBps, 5) + compare(quoteRequestedSpy.count, 1) + verify(quoteRequestedSpy.signalArguments[0][1].ok) + compare(quoteRequestedSpy.signalArguments[0][1].request.maxAmountARaw, "5000000000") + compare(quoteRequestedSpy.signalArguments[0][1].request.maxAmountBRaw, "1000") + } + + function test_contextFailureFinishesTokenResolution() { + var form = createForm() + form.resolvingTokenId = tokenThird + form.resolvingTokenSide = "A" + + form.newPositionContext = { + "status": "error", + "code": "config_unavailable", + "tokens": [] + } + form.finishTokenResolution(true) + wait(0) + + compare(form.resolvingTokenId, "") + compare(form.resolvingTokenSide, "") + compare(form.tokenResolutionError, form.issueText("config_unavailable")) + } + + function test_staleContextDoesNotFinishNewerTokenResolution() { + var form = createForm() + form.resolvingTokenId = tokenThird + form.resolvingTokenSide = "B" + + form.newPositionContext = { + "status": "ready", + "tokens": [{ + "definitionId": tokenLow, + "name": "Earlier token", + "selectable": true + }] + } + wait(0) + + compare(form.resolvingTokenId, tokenThird) + compare(form.resolvingTokenSide, "B") + compare(form.tokenResolutionError, "") + } + + function test_replacingSelectedTokensClearsPairDraft() { + var form = createForm() + form.amountA = "12" + form.amountB = "34" + form.minimumAmountARaw = "12" + form.minimumAmountBRaw = "34" + form.confirmedPoolStatus = "active_pool" + + form.newPositionContext = { + "status": "ready", + "tokens": [ + { + "definitionId": tokenHigh, + "name": "High", + "totalSupplyRaw": "1000000000000", + "balanceRaw": "5000000000", + "selectable": true + }, + { + "definitionId": tokenThird, + "name": "Third", + "totalSupplyRaw": "1000000", + "balanceRaw": "100", + "selectable": true + } + ] + } + wait(0) + + compare(form.selectedTokenAId, tokenHigh) + compare(form.selectedTokenBId, tokenThird) + compare(form.amountA, "") + compare(form.amountB, "") + compare(form.minimumAmountARaw, "") + compare(form.minimumAmountBRaw, "") + compare(form.confirmedPoolStatus, "") + } + + function test_networkFailurePreservesPairDraft() { + var form = createForm() + form.amountA = "12" + form.amountB = "34" + form.confirmedPoolStatus = "active_pool" + + form.newPositionContext = { + "status": "network_mismatch", + "tokens": [] + } + wait(0) + + compare(form.selectedTokenAId, tokenLow) + compare(form.selectedTokenBId, tokenHigh) + compare(form.amountA, "12") + compare(form.amountB, "34") + compare(form.confirmedPoolStatus, "active_pool") + verify(form.contextBlocksForm()) + } + + function test_submittedBase58TransactionIdIsCopied() { + var state = flowState(({})) + state.transactionId = submittedTransactionId + var form = createTemporaryObject(formComponent, testCase, { + "flowState": state + }) + var sink = createTemporaryObject(clipboardSinkComponent, testCase) + verify(form) + verify(sink) + wait(0) + + compare(form.transactionId, submittedTransactionId) + var copyButton = findChild(form, "copySubmittedTransactionButton") + verify(copyButton) + copyButton.clicked() + verify(copyButton.copied) + sink.paste() + tryCompare(sink, "text", submittedTransactionId) + } +} diff --git a/apps/amm/tests/qml/tst_ResponsivePopups.qml b/apps/amm/tests/qml/tst_ResponsivePopups.qml new file mode 100644 index 0000000..0c56b7f --- /dev/null +++ b/apps/amm/tests/qml/tst_ResponsivePopups.qml @@ -0,0 +1,47 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest + +import "../../qml/components/liquidity" as Liquidity + +TestCase { + id: testCase + + name: "ResponsivePopups" + + Liquidity.AmmTheme { + id: theme + } + + Component { + id: viewportComponent + + Item { + width: 320 + height: 300 + } + } + + Component { + id: tokenSelectorComponent + + Liquidity.TokenSelectorModal { + theme: theme + } + } + + function test_tokenSelectorStaysInsideShortViewport() { + var viewport = createTemporaryObject(viewportComponent, testCase) + var selector = createTemporaryObject(tokenSelectorComponent, viewport, { + "parent": viewport + }) + verify(viewport) + verify(selector) + + verify(selector.x >= 0) + verify(selector.y >= 0) + verify(selector.x + selector.width <= viewport.width) + verify(selector.y + selector.height <= viewport.height) + } +} diff --git a/apps/amm/tests/qml/tst_TokenAmountInput.qml b/apps/amm/tests/qml/tst_TokenAmountInput.qml new file mode 100644 index 0000000..7a10ae3 --- /dev/null +++ b/apps/amm/tests/qml/tst_TokenAmountInput.qml @@ -0,0 +1,280 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest + +import "../../qml/components/liquidity" as Liquidity + +TestCase { + id: testCase + + name: "TokenAmountInput" + readonly property string enabledId: "22222222222222222222222222222222" + readonly property string disabledId: "33333333333333333333333333333333" + + Component { + id: inputComponent + + Liquidity.TokenAmountInput { + visible: false + width: 400 + theme: inputTheme + tokens: [ + { + "definitionId": testCase.disabledId, + "name": "Disabled", + "selectable": false, + "code": "token_not_fungible" + }, + { + "definitionId": testCase.enabledId, + "name": "Enabled", + "selectable": true + } + ] + + Liquidity.AmmTheme { + id: inputTheme + } + } + } + + SignalSpy { + id: commitSpy + signalName: "editingCommitted" + } + + SignalSpy { + id: selectedSpy + signalName: "tokenSelected" + } + + SignalSpy { + id: enteredSpy + signalName: "tokenEntered" + } + + function test_inactivityCommitsLatestEditOnce() { + var input = createTemporaryObject(inputComponent, testCase) + verify(input) + commitSpy.target = input + commitSpy.clear() + + input.amountEdited("1") + wait(150) + compare(commitSpy.count, 0) + + input.amountEdited("12") + wait(200) + compare(commitSpy.count, 0) + tryCompare(commitSpy, "count", 1, 150) + compare(commitSpy.signalArguments[0][0], "12") + } + + function test_editingFinishedCommitsWithoutDuplicate() { + var input = createTemporaryObject(inputComponent, testCase) + verify(input) + commitSpy.target = input + commitSpy.clear() + + input.amountEdited("7") + input.amountEditingFinished("7") + compare(commitSpy.count, 1) + compare(commitSpy.signalArguments[0][0], "7") + + wait(300) + compare(commitSpy.count, 1) + } + + function test_compactWidthShrinksTokenAccessory() { + var input = createTemporaryObject(inputComponent, testCase, { "width": 296 }) + verify(input) + compare(input.accessoryWidth, 132) + + input.width = 416 + compare(input.accessoryWidth, 180) + } + + function test_disabledTokenIsRejectedByTypedInput() { + var input = createTemporaryObject(inputComponent, testCase) + verify(input) + selectedSpy.target = input + enteredSpy.target = input + selectedSpy.clear() + enteredSpy.clear() + + input.acceptInput("Disabled") + + compare(selectedSpy.count, 0) + compare(enteredSpy.count, 1) + compare(enteredSpy.signalArguments[0][0], disabledId) + } + + function test_enabledTokenIsSelectedByTypedInput() { + var input = createTemporaryObject(inputComponent, testCase) + verify(input) + selectedSpy.target = input + enteredSpy.target = input + selectedSpy.clear() + enteredSpy.clear() + + input.acceptInput("Enabled") + + compare(selectedSpy.count, 1) + compare(selectedSpy.signalArguments[0][0], enabledId) + compare(enteredSpy.count, 0) + } + + function test_partialNameSelectsSoleMatchInsteadOfCustomAddress() { + var input = createTemporaryObject(inputComponent, testCase) + verify(input) + selectedSpy.target = input + enteredSpy.target = input + selectedSpy.clear() + enteredSpy.clear() + input.query = "Enabl" + tryCompare(input.rows, "length", 1) + + input.acceptInput("Enabl") + + compare(selectedSpy.count, 1) + compare(selectedSpy.signalArguments[0][0], enabledId) + compare(enteredSpy.count, 0) + } + + function test_disabledTokenCanExplainWhyItIsUnavailable() { + var input = createTemporaryObject(inputComponent, testCase, { + "visible": true, + "width": 320 + }) + verify(input) + + input.popup.open() + tryCompare(input.popup, "visible", true) + var tokenList = findChild(input, "tokenList") + verify(tokenList) + tryCompare(tokenList, "count", 2) + tryVerify(function() { return tokenList.itemAtIndex(0) !== null }) + var option = tokenList.itemAtIndex(0) + + mouseMove(option, option.width / 2, option.height / 2) + tryCompare(option, "pointerHovered", true) + compare(option.disabledReasonVisible, true) + + selectedSpy.target = input + enteredSpy.target = input + selectedSpy.clear() + enteredSpy.clear() + mouseClick(option, option.width / 2, option.height / 2) + compare(selectedSpy.count, 0) + compare(enteredSpy.count, 0) + } + + function test_tokenListSupportsKeyboardSelection() { + var input = createTemporaryObject(inputComponent, testCase, { + "visible": true, + "width": 320 + }) + verify(input) + selectedSpy.target = input + selectedSpy.clear() + + input.popup.open() + tryCompare(input.popup, "visible", true) + var tokenList = findChild(input, "tokenList") + verify(tokenList) + tryVerify(function() { return tokenList.itemAtIndex(1) !== null }) + var option = tokenList.itemAtIndex(1) + option.forceActiveFocus() + tryCompare(option, "activeFocus", true) + + keyClick(Qt.Key_Space) + + compare(selectedSpy.count, 1) + compare(selectedSpy.signalArguments[0][0], enabledId) + } + + function test_disabledReasonAppearsOnKeyboardFocus() { + var input = createTemporaryObject(inputComponent, testCase, { + "visible": true, + "width": 320 + }) + verify(input) + + input.popup.open() + tryCompare(input.popup, "visible", true) + var tokenList = findChild(input, "tokenList") + verify(tokenList) + tryVerify(function() { return tokenList.itemAtIndex(0) !== null }) + var option = tokenList.itemAtIndex(0) + option.forceActiveFocus() + + tryCompare(option, "activeFocus", true) + compare(option.disabledReasonVisible, true) + } + + function test_searchModelContainsOnlyMatchingRows() { + var input = createTemporaryObject(inputComponent, testCase) + verify(input) + compare(input.rows.length, 2) + + input.query = "Enabled" + + tryVerify(function() { return input.rows.length === 1 }) + compare(input.rows[0].definitionId, enabledId) + } + + function test_typingKeepsSearchTextWhileModelFilters() { + var input = createTemporaryObject(inputComponent, testCase, { + "visible": true, + "width": 320 + }) + verify(input) + input.popup.open() + tryCompare(input.popup, "visible", true) + var editor = findChild(input, "tokenSearchField") + verify(editor) + + tryCompare(editor, "activeFocus", true) + keyClick(Qt.Key_E) + keyClick(Qt.Key_N) + keyClick(Qt.Key_A) + keyClick(Qt.Key_B) + keyClick(Qt.Key_L) + keyClick(Qt.Key_E) + keyClick(Qt.Key_D) + + compare(editor.text.toLowerCase(), "enabled") + compare(input.rows.length, 1) + compare(input.rows[0].definitionId, enabledId) + } + + function test_clickOpensSharedTokenModal() { + var input = createTemporaryObject(inputComponent, testCase, { + "visible": true, + "width": 320 + }) + verify(input) + var button = findChild(input, "tokenSelectButton") + verify(button) + + button.click() + + tryCompare(input.popup, "visible", true) + verify(findChild(input, "tokenSearchField")) + verify(findChild(input, "tokenList")) + } + + function test_unlistedAddressCanBeEntered() { + var input = createTemporaryObject(inputComponent, testCase) + verify(input) + enteredSpy.target = input + enteredSpy.clear() + var unlistedId = "44444444444444444444444444444444" + + input.acceptInput(unlistedId) + + compare(enteredSpy.count, 1) + compare(enteredSpy.signalArguments[0][0], unlistedId) + } +}