diff --git a/Cargo.lock b/Cargo.lock index 7c3a1403..6806a84f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,25 @@ dependencies = [ "risc0-zkvm", ] +[[package]] +name = "amm_client" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "amm_core", + "borsh", + "clock_core", + "hex", + "lee_core", + "pretty_assertions", + "risc0-zkvm", + "serde", + "serde_json", + "sha2", + "token_core", + "twap_oracle_core", +] + [[package]] name = "amm_core" version = "0.1.0" @@ -1193,6 +1212,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.9.0" @@ -2627,6 +2652,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "primitive-types" version = "0.12.2" @@ -4738,6 +4773,12 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index c508ac99..35a1dee8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "apps/amm/client", "programs/token/core", "programs/token", "programs/token/methods", diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 10f4346d..3e102048 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,12 +36,54 @@ 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 + src/SequencerClient.h + src/SequencerClient.cpp FIND_PACKAGES Qt6Gui Qt6Network LINK_LIBRARIES Qt6::Gui Qt6::Network + PkgConfig::BASE58 + LINK_TARGETS + logos_wallet_access + EXTERNAL_LIBS + amm_client ) + +qt_add_resources(amm_ui_module_plugin amm_ui_config + PREFIX "/amm" + FILES + config/networks.json +) + +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 + src/SequencerClient.cpp + src/SequencerClient.h + ) + target_include_directories(amm_new_position_runtime_test PRIVATE src) + target_link_libraries(amm_new_position_runtime_test PRIVATE + Qt6::Core + Qt6::Network + PkgConfig::BASE58 + logos_wallet_access + ) + add_test(NAME amm_new_position_runtime COMMAND amm_new_position_runtime_test) +endif() diff --git a/apps/amm/MACOS.md b/apps/amm/MACOS.md new file mode 100644 index 00000000..0036bc9e --- /dev/null +++ b/apps/amm/MACOS.md @@ -0,0 +1,173 @@ +# AMM UI on macOS + +The AMM flake exposes native packages for Apple Silicon and Intel macOS. Nix +provides the Rust, C++, Qt, and Apple SDK build dependencies. The only required +host toolchain is Apple's Metal compiler, which Apple distributes through +Xcode rather than through a redistributable package. + +The standalone build needs no input overrides, temporary source pins, or +`DYLD_LIBRARY_PATH`. + +## One-time prerequisites + +### Nix + +Install Nix in multi-user mode, make sure its daemon is available, and enable +flakes: + +```sh +mkdir -p ~/.config/nix +printf '%s\n' 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf +nix --version +nix store info +``` + +Nix normally has `sandbox = false` on macOS. This build must retain that +setting because RISC Zero invokes Apple's host-installed Metal toolchain. The +preflight script below reports an actionable error if the daemon is configured +otherwise. + +### Xcode and the Metal Toolchain + +Apple Command Line Tools alone do not include `metal` and `metallib`. Install +the newest full Xcode release supported by the installed macOS from the App +Store or [Apple Developer Downloads](https://developer.apple.com/download/all/). +This requires an Apple account and cannot be automated by the repository. + +After installation: + +```sh +sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer +sudo xcodebuild -license accept +sudo xcodebuild -runFirstLaunch +xcodebuild -downloadComponent MetalToolchain +xcrun --sdk macosx --find metal +xcrun --sdk macosx --find metallib +``` + +The last two commands must print executable paths. Do not use +`RISC0_SKIP_BUILD_KERNELS=1`: the pinned dependency graph requires the real +Metal kernels. + +### Disk space and preflight + +Leave at least 60 GiB free before downloading Xcode. Once Xcode is installed, +keep at least 40 GiB free for the first Nix build. Later builds reuse the Nix +store. + +Run the complete machine check from `apps/amm`: + +```sh +bash scripts/check-macos-prerequisites.sh +``` + +## Build and run + +From `apps/amm`: + +```sh +nix build . +nix run . +``` + +The first cold build is large because it includes Qt, the wallet, the AMM +client, and the RISC Zero Metal kernels. The first start opens without a +wallet; select **Connect** to create or open `~/.lee/wallet/`. + +## Why the flake is structured this way + +Nix 2.25 rejects committed lock entries for unlocked relative flake inputs such +as `path:../shared/wallet` and `path:../..`. Newer Nix versions may accept that +layout, which is why it can appear to work on one developer's Mac and fail on +another. + +The portable layout avoids both relative flake inputs: + +- the shared wallet is passed to CMake as an ordinary Nix source path; +- the repository-root AMM client package is imported directly with the same + locked `nixpkgs` and Crane inputs; +- `flake.lock` contains only immutable upstream sources. + +The app output uses the builder's full-library QML package layout. The current +`qt-plugin` layout copies the UI plugin and replica factory but omits sibling +external libraries such as `libamm_client`. The module metadata still selects +the C++ backend, so this packaging choice does not change process isolation or +runtime behavior. + +Do not replace these with a pull-request URL, self-reference, or local +`path:` flake input. A normal `nix flake lock`, `nix build`, and `nix run` +must work without overrides. + +## Known-good validation host + +This configuration has been exercised on: + +- Apple Silicon (`arm64`) +- macOS 26.5.2 +- Xcode 26.6 (build 17F113) +- Metal Toolchain 32023.883 +- Nix 2.25.3 + +The flake also evaluates its complete package and app outputs for +`x86_64-darwin`; an Intel Mac is still required for native runtime validation. + +## Recovery + +### Nix is unavailable + +Open a new terminal after installing Nix. For the standard multi-user +installation, check the store and daemon before trying to bootstrap it again: + +```sh +test -x /nix/var/nix/profiles/default/bin/nix +nix store info +sudo launchctl print system/org.nixos.nix-daemon +``` + +`launchctl bootstrap` can report an I/O error when a service is already loaded +or in an inconsistent state; that message alone does not prove the daemon is +stopped. + +### `xcrun` cannot find `metal` + +The selected developer directory points at Command Line Tools, or Xcode lacks +the separately downloaded Metal Toolchain component. Repeat the Xcode setup +and verification commands above. + +Apple does not distribute the Metal compiler as a standalone redistributable +SDK, so Nix cannot install this host component. + +### The build reports that the macOS sandbox blocks Metal + +Confirm the active setting: + +```sh +nix config show sandbox +``` + +For this native macOS build it must be `false`. If a machine-wide policy set it +to `true`, update that Nix installation's daemon configuration and restart the +daemon before retrying. + +### The first build runs out of space + +Remove only disposable build outputs, then collect dead Nix store paths: + +```sh +rm -rf /private/tmp/amm-build +nix-store --gc +``` + +Do not remove `/nix/store` directly. + +### A lock error mentions an unlocked input + +Restore the committed app lock and verify it without rewriting it: + +```sh +git restore flake.lock +nix flake metadata --no-update-lock-file . +``` + +If the error still names `path:../shared/wallet` or `path:../..`, the checkout +does not contain the portable flake fix described above. diff --git a/apps/amm/README.md b/apps/amm/README.md index dbac9120..95a7e939 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -27,10 +27,12 @@ Account/keystore sharing follows the runtime: startup the backend **adopts** the already-open wallet (see `openOrAdoptWallet()`), surfacing **shared** accounts across apps. -> Follow-up: the wallet FFI requires explicit `config_path`/`storage_path` even -> though the wallet crate already defines defaults (`~/.lee/wallet`, -> `from_path_or_initialize_default`). A `wallet_ffi_create_new_default()` / -> `_open_default()` upstream would let the app drop its path handling entirely. +> Follow-up: the app reconstructs the wallet paths itself because the +> `logos_execution_zone` module only exposes path-taking `create_new`/`open`. +> LEZ's wallet FFI now provides path-free variants (`wallet_ffi_create_new_default`, +> `wallet_ffi_open_default`, plus `wallet_ffi_default_config_path` / +> `_storage_path` / `wallet_ffi_wallet_exists_default`). Once the module surfaces +> those over QtRO, the app can drop its `defaultWalletHome/Config/Storage` logic. ## Setup @@ -48,6 +50,9 @@ nix profile install 'github:logos-co/logos-package-manager#cli' This makes `lgpm` available as a global command. +For macOS prerequisites, the Metal toolchain requirement, and common Nix +recovery steps, see [macOS setup](MACOS.md). + ## Running the UI standalone Start the UI with: @@ -74,10 +79,10 @@ nix build '.#lgx' --out-link result-lgx # Portable variant (self-contained, works without nix) nix build '.#lgx-portable' --out-link result-lgx-portable -# The core wallet module it depends on. Use the same rev this app pins as the -# `logos_execution_zone` input in flake.nix so the ImageID/ABI match. -nix build 'github:logos-blockchain/logos-execution-zone-module?rev=d2e9400ac06c3cdbfc2405b4f153fff9841a453c#lgx' \ - --out-link result-core +# The matching core wallet module. This uses the public-testnet RPC contract +# and carries the macOS Metal workaround without pulling newer wallet RPCs. +nix build '.#logos_execution_zone-lgx' --out-link result-core +nix build '.#logos_execution_zone-lgx-portable' --out-link result-core-portable ``` ### 2. Install into Basecamp @@ -99,6 +104,7 @@ lgpm --ui-plugins-dir "$BASECAMP_DIR/plugins" \ ``` > **Note:** Use matching variants throughout — dev with dev, portable with portable. Mixing variants causes loading failures. The portable build uses the `LogosBasecamp` data directory instead of `LogosBasecampDev`. +> For the portable pair, install the core module from `result-core-portable/`. ### 3. Launch Basecamp @@ -125,6 +131,11 @@ core module from `result-core/` and the UI plugin from `result-lgx/`: 4. Choose the core module `.lgx` from `result-core/`, then the UI plugin `.lgx` from `result-lgx/` +## Validation + +New Position validation commands and acceptance criteria live in +[VALIDATION.md](VALIDATION.md). + ## Updating Dependencies To update the pinned versions of dependencies in `flake.lock`: diff --git a/apps/amm/VALIDATION.md b/apps/amm/VALIDATION.md new file mode 100644 index 00000000..8663bfa5 --- /dev/null +++ b/apps/amm/VALIDATION.md @@ -0,0 +1,52 @@ +# AMM UI Validation + +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 +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 +affected Rust checks: + +```bash +RISC0_DEV_MODE=1 cargo +1.94.0 test -p amm_program +cargo +1.94.0 build -p idl-gen --release +./target/release/idl-gen programs/amm/methods/guest/src/bin/amm.rs > /tmp/amm-idl.json +diff artifacts/amm-idl.json /tmp/amm-idl.json +``` + +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 + +- 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 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. +- `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 + matches the preview hash. diff --git a/apps/amm/client/Cargo.toml b/apps/amm/client/Cargo.toml new file mode 100644 index 00000000..eb5014cd --- /dev/null +++ b/apps/amm/client/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "amm_client" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +alloy-primitives = { version = "1", default-features = false } +amm_core = { workspace = true } +borsh = { workspace = true } +clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0" } +hex = "0.4" +nssa_core = { workspace = true } +risc0-zkvm = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = "0.10" +token_core = { workspace = true } +twap_oracle_core = { workspace = true } + +[dev-dependencies] +pretty_assertions = "1" diff --git a/apps/amm/client/include/amm_client.h b/apps/amm/client/include/amm_client.h new file mode 100644 index 00000000..79bde2ea --- /dev/null +++ b/apps/amm/client/include/amm_client.h @@ -0,0 +1,21 @@ +#ifndef AMM_CLIENT_H +#define AMM_CLIENT_H + +#ifdef __cplusplus +extern "C" { +#endif + +char *amm_config_id(const char *request_json); +char *amm_token_ids(const char *request_json); +char *amm_pair_ids(const char *request_json); +char *amm_context(const char *request_json); +char *amm_quote(const char *request_json); +char *amm_plan(const char *request_json); +char *amm_normalize_account_rpc(const char *request_json); +void amm_free(char *value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/apps/amm/client/src/account.rs b/apps/amm/client/src/account.rs new file mode 100644 index 00000000..a70feb17 --- /dev/null +++ b/apps/amm/client/src/account.rs @@ -0,0 +1,228 @@ +use std::str::FromStr; + +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::api::NormalizeAccountRpcRequest; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AccountRead { + pub id: String, + pub status: String, + #[serde(default)] + pub account: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub struct WalletAccount { + pub program_owner: String, + pub balance: String, + pub nonce: String, + pub data: String, +} + +pub(crate) fn parse_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!( + "{label} must be 64 lowercase hexadecimal characters" + )); + } + + let mut bytes = [0_u8; 32]; + hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?; + Ok(bytes) +} + +pub(crate) fn parse_program_id(value: &str) -> Result { + let bytes = parse_hex_32(value, "program id")?; + let mut program_id = [0_u32; 8]; + for (word, chunk) in program_id.iter_mut().zip(bytes.chunks_exact(4)) { + let chunk: [u8; 4] = chunk + .try_into() + .map_err(|_| String::from("program id word has invalid length"))?; + *word = u32::from_le_bytes(chunk); + } + Ok(program_id) +} + +#[cfg(test)] +pub(crate) fn program_id_hex(program_id: ProgramId) -> String { + let bytes = program_id + .iter() + .flat_map(|word| word.to_le_bytes()) + .collect::>(); + hex::encode(bytes) +} + +pub(crate) fn program_id_base58(program_id: ProgramId) -> String { + AccountId::new(program_id_bytes(program_id)).to_string() +} + +pub(crate) fn program_id_bytes(program_id: ProgramId) -> [u8; 32] { + let mut bytes = [0_u8; 32]; + for (chunk, word) in bytes.chunks_exact_mut(4).zip(program_id) { + chunk.copy_from_slice(&word.to_le_bytes()); + } + bytes +} + +pub(crate) fn parse_base58_id(value: &str, label: &str) -> Result { + AccountId::from_str(value).map_err(|error| format!("invalid {label}: {error}")) +} + +pub(crate) fn account_id_from_hex(value: &str, label: &str) -> Result { + Ok(AccountId::new(parse_hex_32(value, label)?)) +} + +pub(crate) fn account_id_hex(account_id: AccountId) -> String { + hex::encode(account_id.into_value()) +} + +fn parse_le_u128(value: &str, label: &str) -> Result { + if value.len() != 32 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!( + "{label} must be 32 lowercase hexadecimal characters" + )); + } + let mut bytes = [0_u8; 16]; + hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?; + Ok(u128::from_le_bytes(bytes)) +} + +pub(crate) fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), String> { + if read.status != "ok" { + return Err(String::from("account read failed")); + } + let account_id = account_id_from_hex(&read.id, "account id")?; + let source = read + .account + .as_ref() + .ok_or_else(|| String::from("successful account read has no account"))?; + let program_owner = parse_program_id(&source.program_owner)?; + let balance = parse_le_u128(&source.balance, "account balance")?; + let nonce = parse_le_u128(&source.nonce, "account nonce")?; + if source.data.len() % 2 != 0 + || !source + .data + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(String::from( + "account data must be lowercase even-length hexadecimal", + )); + } + let data = + hex::decode(&source.data).map_err(|error| format!("invalid account data: {error}"))?; + let data = + Data::try_from(data).map_err(|error| format!("account data is too large: {error}"))?; + + Ok(( + account_id, + Account { + program_owner, + balance, + data, + nonce: Nonce(nonce), + }, + )) +} + +#[derive(Deserialize)] +struct RpcEnvelope { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(Deserialize)] +struct RpcAccount { + program_owner: ProgramId, + balance: u128, + data: Vec, + nonce: u128, +} + +pub(crate) fn normalize_account_rpc(request: NormalizeAccountRpcRequest) -> Result { + parse_hex_32(&request.account_id, "account id")?; + let envelope: RpcEnvelope = serde_json::from_str(&request.response) + .map_err(|error| format!("invalid sequencer response: {error}"))?; + if envelope.error.is_some() { + return Err(String::from("sequencer returned an account-read error")); + } + let account = envelope + .result + .ok_or_else(|| String::from("sequencer returned no account"))?; + let data = Data::try_from(account.data) + .map_err(|error| format!("account data is too large: {error}"))?; + + Ok(json!({ + "id": request.account_id, + "status": "ok", + "account": { + "program_owner": hex::encode(program_id_bytes(account.program_owner)), + "balance": hex::encode(account.balance.to_le_bytes()), + "nonce": hex::encode(account.nonce.to_le_bytes()), + "data": hex::encode(data.as_ref()), + }, + })) +} + +#[cfg(test)] +pub(crate) fn account_read(id: AccountId, account: &Account) -> AccountRead { + AccountRead { + id: account_id_hex(id), + status: String::from("ok"), + account: Some(WalletAccount { + program_owner: program_id_hex(account.program_owner), + balance: hex::encode(account.balance.to_le_bytes()), + nonce: hex::encode(account.nonce.0.to_le_bytes()), + data: hex::encode(account.data.as_ref()), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_rpc_u128_without_precision_loss() { + let account_id = hex::encode([7_u8; 32]); + let value = normalize_account_rpc(NormalizeAccountRpcRequest { + account_id: account_id.clone(), + response: String::from( + r#"{"jsonrpc":"2.0","id":1,"result":{"program_owner":[1,2,3,4,5,6,7,8],"balance":340282366920938463463374607431768211455,"data":[0,255],"nonce":18446744073709551617}}"#, + ), + }) + .unwrap(); + + assert_eq!(value["id"], account_id); + assert_eq!( + value["account"]["balance"], + "ffffffffffffffffffffffffffffffff" + ); + assert_eq!( + value["account"]["nonce"], + "01000000000000000100000000000000" + ); + assert_eq!(value["account"]["data"], "00ff"); + assert_eq!( + value["account"]["program_owner"], + "0100000002000000030000000400000005000000060000000700000008000000" + ); + } +} diff --git a/apps/amm/client/src/api/accounts.rs b/apps/amm/client/src/api/accounts.rs new file mode 100644 index 00000000..a1c3c881 --- /dev/null +++ b/apps/amm/client/src/api/accounts.rs @@ -0,0 +1,226 @@ +use amm_core::PoolDefinition; +use nssa_core::program::ProgramId; + +use super::{ + funding::{append_holding_source, sources_from_reads}, + pair::PairIds, + position::{AccountPlan, AccountPlanHoldings, AccountPlanRow}, + QuoteRequest, +}; + +pub(super) fn missing_account_plan( + input: &QuoteRequest, + pair: PairIds, + amm_program: ProgramId, + holdings: AccountPlanHoldings<'_>, +) -> Result { + let mut sources = sources_from_reads(&[ + ("config", &input.snapshot.config), + ("token_a", &input.snapshot.token_a), + ("token_b", &input.snapshot.token_b), + ("pool", &input.snapshot.pool), + ("vault_a", &input.snapshot.vault_a), + ("vault_b", &input.snapshot.vault_b), + ("lp_definition", &input.snapshot.lp_definition), + ("lp_lock_holding", &input.snapshot.lp_lock_holding), + ("current_tick", &input.snapshot.current_tick), + ])?; + append_holding_source(&mut sources, "holding_a", holdings.token_a); + append_holding_source(&mut sources, "holding_b", holdings.token_b); + Ok(AccountPlan { + rows: vec![ + AccountPlanRow::new( + "config", + Some(pair.config), + Some(amm_program), + "read", + false, + false, + ), + AccountPlanRow::new( + "pool", + Some(pair.pool), + Some(amm_program), + "create", + false, + true, + ), + AccountPlanRow::new( + "vault_a", + Some(pair.vault_a), + Some(pair.token_program), + "create", + false, + true, + ), + AccountPlanRow::new( + "vault_b", + Some(pair.vault_b), + Some(pair.token_program), + "create", + false, + true, + ), + AccountPlanRow::new( + "lp_definition", + Some(pair.lp_definition), + Some(pair.token_program), + "create", + false, + true, + ), + AccountPlanRow::new( + "lp_lock_holding", + Some(pair.lp_lock_holding), + Some(pair.token_program), + "create", + false, + true, + ), + AccountPlanRow::new( + "user_holding_a", + holdings.token_a.map(|value| value.id), + Some(pair.token_program), + "update", + true, + false, + ), + AccountPlanRow::new( + "user_holding_b", + holdings.token_b.map(|value| value.id), + Some(pair.token_program), + "update", + true, + false, + ), + AccountPlanRow::new( + "user_holding_lp", + None, + Some(pair.token_program), + "create", + true, + true, + ), + AccountPlanRow::new( + "current_tick", + Some(pair.current_tick), + Some(pair.twap_program), + "create", + false, + true, + ), + AccountPlanRow::new("clock", Some(pair.clock), None, "read", false, false), + ], + sources, + }) +} + +pub(super) fn active_account_plan( + input: &QuoteRequest, + pair: PairIds, + amm_program: ProgramId, + pool: &PoolDefinition, + stored_reversed: bool, + holdings: AccountPlanHoldings<'_>, +) -> Result { + let (stored_holding_a, stored_holding_b) = if stored_reversed { + (holdings.token_b, holdings.token_a) + } else { + (holdings.token_a, holdings.token_b) + }; + let mut sources = sources_from_reads(&[ + ("config", &input.snapshot.config), + ("token_a", &input.snapshot.token_a), + ("token_b", &input.snapshot.token_b), + ("pool", &input.snapshot.pool), + ("vault_a", &input.snapshot.vault_a), + ("vault_b", &input.snapshot.vault_b), + ("lp_definition", &input.snapshot.lp_definition), + ("current_tick", &input.snapshot.current_tick), + ])?; + append_holding_source(&mut sources, "holding_a", holdings.token_a); + append_holding_source(&mut sources, "holding_b", holdings.token_b); + append_holding_source(&mut sources, "holding_lp", holdings.lp); + Ok(AccountPlan { + rows: vec![ + AccountPlanRow::new( + "config", + Some(pair.config), + Some(amm_program), + "read", + false, + false, + ), + AccountPlanRow::new( + "pool", + Some(pair.pool), + Some(amm_program), + "update", + false, + false, + ), + AccountPlanRow::new( + "vault_a", + Some(pool.vault_a_id), + Some(pair.token_program), + "update", + false, + false, + ), + AccountPlanRow::new( + "vault_b", + Some(pool.vault_b_id), + Some(pair.token_program), + "update", + false, + false, + ), + AccountPlanRow::new( + "lp_definition", + Some(pair.lp_definition), + Some(pair.token_program), + "update", + false, + false, + ), + AccountPlanRow::new( + "user_holding_a", + stored_holding_a.map(|value| value.id), + Some(pair.token_program), + "update", + true, + false, + ), + AccountPlanRow::new( + "user_holding_b", + stored_holding_b.map(|value| value.id), + Some(pair.token_program), + "update", + true, + false, + ), + AccountPlanRow::new( + "user_holding_lp", + holdings.lp.map(|value| value.id), + Some(pair.token_program), + if holdings.lp.is_some() { + "update" + } else { + "create" + }, + holdings.lp.is_none(), + holdings.lp.is_none(), + ), + AccountPlanRow::new( + "current_tick", + Some(pair.current_tick), + Some(pair.twap_program), + "update", + false, + false, + ), + AccountPlanRow::new("clock", Some(pair.clock), None, "read", false, false), + ], + sources, + }) +} diff --git a/apps/amm/client/src/api/clock.rs b/apps/amm/client/src/api/clock.rs new file mode 100644 index 00000000..358750db --- /dev/null +++ b/apps/amm/client/src/api/clock.rs @@ -0,0 +1,12 @@ +use borsh::from_slice; +use clock_core::ClockAccountData; +use nssa_core::account::AccountId; + +use crate::account::{decode_account, AccountRead}; + +pub(super) fn decode_clock(read: &AccountRead) -> Result<(AccountId, ClockAccountData), String> { + let (id, account) = decode_account(read)?; + let clock = from_slice(account.data.as_ref()) + .map_err(|error| format!("invalid clock account: {error}"))?; + Ok((id, clock)) +} diff --git a/apps/amm/client/src/api/commitment.rs b/apps/amm/client/src/api/commitment.rs new file mode 100644 index 00000000..4a6dc9fa --- /dev/null +++ b/apps/amm/client/src/api/commitment.rs @@ -0,0 +1,51 @@ +use borsh::BorshSerialize; + +#[derive(BorshSerialize)] +pub(super) enum RequestCommitment { + Missing { + amount_a: u128, + amount_b: u128, + }, + Active { + max_a: u128, + max_b: u128, + slippage_bps: u32, + }, +} + +#[derive(BorshSerialize)] +pub(super) struct SourceCommitment { + pub(super) role: String, + pub(super) commitment: [u8; 32], +} + +#[derive(BorshSerialize)] +pub(super) struct FundingCommitment { + pub(super) token_id: [u8; 32], + pub(super) holding_id: Option<[u8; 32]>, + pub(super) available: u128, + pub(super) requested: u128, +} + +#[derive(BorshSerialize)] +pub(super) struct QuoteCommitment { + pub(super) schema: String, + pub(super) network_id: String, + pub(super) network_fingerprint: String, + pub(super) amm_program_id: [u8; 32], + pub(super) token_a_id: [u8; 32], + pub(super) token_b_id: [u8; 32], + pub(super) fee_bps: u32, + pub(super) pool_status: u8, + pub(super) request: RequestCommitment, + pub(super) max_a: u128, + pub(super) max_b: u128, + pub(super) actual_a: u128, + pub(super) actual_b: u128, + pub(super) expected_lp: u128, + pub(super) lp_guard: u128, + pub(super) requires_fresh_lp: bool, + pub(super) sources: Vec, + pub(super) funding: Vec, + pub(super) warnings: Vec, +} diff --git a/apps/amm/client/src/api/config.rs b/apps/amm/client/src/api/config.rs new file mode 100644 index 00000000..563537df --- /dev/null +++ b/apps/amm/client/src/api/config.rs @@ -0,0 +1,25 @@ +use amm_core::{compute_config_pda, AmmConfig}; +use nssa_core::{account::Account, program::ProgramId}; +use serde_json::{json, Value}; + +use super::ConfigIdRequest; +use crate::account::{account_id_hex, decode_account, parse_program_id, AccountRead}; + +pub(super) fn config_id(request: ConfigIdRequest) -> Result { + let amm_program = parse_program_id(&request.amm_program_id)?; + Ok(json!({ + "status": "ok", + "configId": account_id_hex(compute_config_pda(amm_program)), + })) +} + +pub(super) fn load_config(amm_program: ProgramId, read: &AccountRead) -> Result { + let (id, account) = decode_account(read)?; + if id != compute_config_pda(amm_program) + || account.program_owner != amm_program + || account == Account::default() + { + return Err(String::from("AMM config is unavailable")); + } + AmmConfig::try_from(&account.data).map_err(|_| String::from("AMM config is invalid")) +} diff --git a/apps/amm/client/src/api/context.rs b/apps/amm/client/src/api/context.rs new file mode 100644 index 00000000..896c1551 --- /dev/null +++ b/apps/amm/client/src/api/context.rs @@ -0,0 +1,262 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use amm_core::{ + FEE_TIER_BPS_1, FEE_TIER_BPS_100, FEE_TIER_BPS_30, FEE_TIER_BPS_5, MINIMUM_LIQUIDITY, +}; +use nssa_core::{account::AccountId, program::ProgramId}; +use serde_json::{json, Value}; +use token_core::TokenDefinition; + +use super::{ + config::load_config, + holding::{holding_options, wallet_holdings, SelectedHolding}, + quote_error::issue, + ContextRequest, TokenIdsRequest, SCHEMA, +}; +use crate::account::{ + account_id_from_hex, account_id_hex, decode_account, parse_base58_id, parse_program_id, + program_id_base58, AccountRead, +}; + +pub(super) fn token_ids(request: TokenIdsRequest) -> Result { + let amm_program = parse_program_id(&request.amm_program_id)?; + let Ok(config) = load_config(amm_program, &request.config) else { + 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") { + token_ids.insert(id); + } + } + for id in request + .recent_token_ids + .iter() + .chain(&request.resolved_token_ids) + { + if let Ok(id) = parse_base58_id(id, "token id") { + token_ids.insert(id); + } + } + token_ids.extend(holdings.into_iter().map(|holding| holding.definition_id)); + + Ok(json!({ + "status": "ok", + "tokenIds": token_ids.into_iter().map(account_id_hex).collect::>(), + })) +} + +fn manifest_error(code: &str) -> Value { + json!({ "status": "error", "code": code, "tokenIds": [] }) +} + +pub(super) fn context(request: ContextRequest) -> Result { + let amm_program = parse_program_id(&request.amm_program_id)?; + let Ok(config) = load_config(amm_program, &request.config) else { + return Ok(context_error(&request, "config_unavailable")); + }; + + 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(); + + for (token_id, sources) in source_map { + let read = request + .token_definitions + .iter() + .find(|read| account_id_from_hex(&read.id, "token definition id") == Ok(token_id)); + let (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 options = holding_options(&holdings, token_id); + let total_balance = options.iter().fold(0_u128, |total, holding| { + total.saturating_add(holding.balance) + }); + let row = json!({ + "definitionId": token_id.to_string(), + "name": name, + "metadataId": metadata_id.map(|id| id.to_string()), + "totalSupplyRaw": total_supply.to_string(), + "ownerProgramId": program_id_base58(config.token_program_id), + "public": true, + "fungible": true, + "selectable": true, + "status": "available", + "code": "available", + "sources": sources, + "balanceRaw": total_balance.to_string(), + "holdings": options.into_iter().map(|holding| json!({ + "holdingId": holding.id.to_string(), + "balanceRaw": holding.balance.to_string(), + })).collect::>(), + }); + rows.push(row); + } + + rows.sort_by(|left, right| { + let left_holding = left["holdings"] + .as_array() + .is_some_and(|rows| !rows.is_empty()); + let right_holding = right["holdings"] + .as_array() + .is_some_and(|rows| !rows.is_empty()); + right_holding.cmp(&left_holding).then_with(|| { + left["definitionId"] + .as_str() + .cmp(&right["definitionId"].as_str()) + }) + }); + + Ok(json!({ + "schema": SCHEMA, + "status": if request.wallet_available { "ready" } else { "no_wallet" }, + "networkId": request.network_id, + "networkFingerprint": request.network_fingerprint, + "walletAvailable": request.wallet_available, + "minimumLiquidityRaw": MINIMUM_LIQUIDITY.to_string(), + "programIds": { + "amm": program_id_base58(amm_program), + "token": program_id_base58(config.token_program_id), + "twapOracle": program_id_base58(config.twap_oracle_program_id), + }, + "tokens": rows, + "feeTiers": fee_tiers(), + "warnings": warnings, + })) +} + +fn context_error(request: &ContextRequest, code: &str) -> Value { + json!({ + "schema": SCHEMA, + "status": "error", + "code": code, + "networkId": request.network_id, + "networkFingerprint": request.network_fingerprint, + "walletAvailable": request.wallet_available, + "tokens": [], + "feeTiers": fee_tiers(), + "warnings": [], + }) +} + +fn fee_tiers() -> Value { + json!([ + { "feeBps": FEE_TIER_BPS_1, "label": "0.01%", "enabled": true }, + { "feeBps": FEE_TIER_BPS_5, "label": "0.05%", "enabled": true }, + { "feeBps": FEE_TIER_BPS_30, "label": "0.30%", "enabled": true }, + { "feeBps": FEE_TIER_BPS_100, "label": "1.00%", "enabled": true }, + ]) +} + +fn token_sources( + request: &ContextRequest, + 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("config")); + } + } + for (ids, source) in [ + (&request.recent_token_ids, "recent"), + (&request.resolved_token_ids, "resolved"), + ] { + for id in ids { + if let Ok(id) = parse_base58_id(id, "token id") { + sources.entry(id).or_default().insert(String::from(source)); + } + } + } + for holding in holdings { + sources + .entry(holding.definition_id) + .or_default() + .insert(String::from("holding")); + } + sources + .into_iter() + .map(|(id, values)| (id, values.into_iter().collect())) + .collect() +} + +fn unavailable_token_row(token_id: AccountId, sources: Vec, code: &str) -> Value { + json!({ + "definitionId": token_id.to_string(), + "name": "", + "metadataId": Value::Null, + "totalSupplyRaw": "0", + "selectable": false, + "status": code, + "code": code, + "sources": sources, + }) +} + +pub(super) struct DefinitionError { + pub(super) code: &'static str, + pub(super) warn: bool, +} + +pub(super) 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, + }), + } +} diff --git a/apps/amm/client/src/api/funding.rs b/apps/amm/client/src/api/funding.rs new file mode 100644 index 00000000..b328c4c4 --- /dev/null +++ b/apps/amm/client/src/api/funding.rs @@ -0,0 +1,106 @@ +use nssa_core::Commitment; +use serde_json::{json, Value}; +use sha2::{Digest as _, Sha256}; + +use super::{ + commitment::{FundingCommitment, QuoteCommitment, SourceCommitment}, + holding::SelectedHolding, + pair::PairIds, + quote_error::issue, +}; +use crate::account::{decode_account, AccountRead}; + +pub(super) fn funding_issues( + wallet_available: bool, + pair: PairIds, + holding_a: &Option, + requested_a: u128, + holding_b: &Option, + requested_b: u128, + fields: [&str; 2], +) -> Vec { + if !wallet_available { + return vec![issue( + "no_wallet", + "Connect a wallet to submit.", + &[], + json!({}), + )]; + } + let mut errors = Vec::new(); + for (token_id, holding, requested, field) in [ + (pair.token_a, holding_a, requested_a, 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 { + errors.push(issue( + "amount_exceeds_balance", + "Amount exceeds the selected wallet holding balance.", + &[field], + json!({ + "requestedRaw": requested.to_string(), + "availableRaw": available.to_string(), + "holdingFound": holding.is_some(), + "tokenId": token_id.to_string(), + }), + )); + } + } + errors +} + +pub(super) fn funding_commitments( + pair: PairIds, + holding_a: &Option, + requested_a: u128, + holding_b: &Option, + requested_b: u128, +) -> Vec { + [ + (pair.token_a, holding_a, requested_a), + (pair.token_b, holding_b, requested_b), + ] + .into_iter() + .map(|(token_id, holding, requested)| FundingCommitment { + token_id: token_id.into_value(), + holding_id: holding.as_ref().map(|value| value.id.into_value()), + available: holding.as_ref().map_or(0, |value| value.balance), + requested, + }) + .collect() +} + +pub(super) fn sources_from_reads( + reads: &[(&str, &AccountRead)], +) -> Result, String> { + reads + .iter() + .map(|(role, read)| { + let (id, account) = decode_account(read)?; + Ok(SourceCommitment { + role: String::from(*role), + commitment: Commitment::new(&id, &account).to_byte_array(), + }) + }) + .collect() +} + +pub(super) fn append_holding_source( + sources: &mut Vec, + role: &str, + holding: Option<&SelectedHolding>, +) { + if let Some(holding) = holding { + sources.push(SourceCommitment { + role: String::from(role), + commitment: Commitment::new(&holding.id, &holding.account).to_byte_array(), + }); + } +} + +pub(super) fn hash_quote(commitment: &QuoteCommitment) -> Result { + let bytes = borsh::to_vec(commitment) + .map_err(|error| format!("quote commitment serialization failed: {error}"))?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} diff --git a/apps/amm/client/src/api/holding.rs b/apps/amm/client/src/api/holding.rs new file mode 100644 index 00000000..3219e7b3 --- /dev/null +++ b/apps/amm/client/src/api/holding.rs @@ -0,0 +1,74 @@ +use nssa_core::{ + account::{Account, AccountId}, + program::ProgramId, +}; +use token_core::TokenHolding; + +use crate::account::{decode_account, parse_base58_id, AccountRead}; + +#[derive(Clone)] +pub(super) struct SelectedHolding { + pub(super) id: AccountId, + pub(super) definition_id: AccountId, + pub(super) balance: u128, + pub(super) account: Account, +} + +pub(super) fn wallet_holdings( + reads: &[AccountRead], + token_program: ProgramId, +) -> Vec { + reads + .iter() + .filter_map(|read| decode_fungible_holding(read, token_program).ok()) + .collect() +} + +pub(super) fn decode_fungible_holding( + read: &AccountRead, + token_program: ProgramId, +) -> Result { + let (id, account) = decode_account(read)?; + if account.program_owner != token_program { + return Err(String::from("holding owner mismatch")); + } + let TokenHolding::Fungible { + definition_id, + balance, + } = TokenHolding::try_from(&account.data) + .map_err(|_| String::from("invalid fungible holding"))? + else { + return Err(String::from("invalid fungible holding")); + }; + Ok(SelectedHolding { + id, + definition_id, + balance, + account, + }) +} + +pub(super) fn select_holding( + holdings: &[SelectedHolding], + definition_id: AccountId, + requested_id: Option<&str>, +) -> Option { + let requested_id = parse_base58_id(requested_id?, "holding id").ok()?; + holdings + .iter() + .find(|holding| holding.definition_id == definition_id && holding.id == requested_id) + .cloned() +} + +pub(super) fn holding_options( + holdings: &[SelectedHolding], + definition_id: AccountId, +) -> Vec { + let mut options = holdings + .iter() + .filter(|holding| holding.definition_id == definition_id) + .cloned() + .collect::>(); + options.sort_by_key(|holding| holding.id); + options +} diff --git a/apps/amm/client/src/api/mod.rs b/apps/amm/client/src/api/mod.rs new file mode 100644 index 00000000..b30e9945 --- /dev/null +++ b/apps/amm/client/src/api/mod.rs @@ -0,0 +1,102 @@ +//! Transport-independent AMM client operations. + +mod accounts; +mod clock; +mod commitment; +mod config; +mod context; +mod funding; +mod holding; +mod pair; +mod plan; +mod position; +mod quote; +mod quote_error; +mod request; + +#[cfg(test)] +mod tests; + +use std::{error::Error, fmt}; + +pub use request::{ + ConfigIdRequest, ContextRequest, NormalizeAccountRpcRequest, PairIdsRequest, PairSnapshot, + PlanRequest, PositionRequest, QuoteRequest, TokenIdsRequest, +}; +use serde_json::Value; + +pub use crate::account::{AccountRead, WalletAccount}; + +/// Schema identifier expected by position quote and plan requests. +pub const NEW_POSITION_SCHEMA: &str = "new-position.v2"; + +pub(crate) const SCHEMA: &str = NEW_POSITION_SCHEMA; + +/// JSON response shared by direct Rust callers and transport adapters. +pub type AmmResponse = Value; + +/// Result returned by AMM client operations. +pub type AmmResult = Result; + +/// Failure produced before an AMM response can be constructed. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AmmApiError { + message: String, +} + +impl AmmApiError { + /// Returns the stable human-readable failure detail. + #[must_use] + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for AmmApiError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for AmmApiError {} + +impl From for AmmApiError { + fn from(message: String) -> Self { + Self { message } + } +} + +/// Derives the AMM configuration account ID. +pub fn config_id(request: ConfigIdRequest) -> AmmResult { + config::config_id(request).map_err(Into::into) +} + +/// Discovers token definition IDs available to the active wallet and app. +pub fn token_ids(request: TokenIdsRequest) -> AmmResult { + context::token_ids(request).map_err(Into::into) +} + +/// Derives canonical accounts for one token pair. +pub fn pair_ids(request: PairIdsRequest) -> AmmResult { + pair::pair_ids(request).map_err(Into::into) +} + +/// Builds network, token, holding, and fee-tier context. +pub fn context(request: ContextRequest) -> AmmResult { + context::context(request).map_err(Into::into) +} + +/// Evaluates a pool-creation or add-liquidity request. +pub fn quote(request: QuoteRequest) -> AmmResult { + quote::quote(request).map_err(Into::into) +} + +/// Materializes a previously quoted request into wallet submission arguments. +pub fn plan(request: PlanRequest) -> AmmResult { + plan::plan(request).map_err(Into::into) +} + +/// Converts one raw sequencer `getAccount` response into precision-safe account-read JSON. +pub fn normalize_account_rpc(request: NormalizeAccountRpcRequest) -> AmmResult { + crate::account::normalize_account_rpc(request).map_err(Into::into) +} diff --git a/apps/amm/client/src/api/pair.rs b/apps/amm/client/src/api/pair.rs new file mode 100644 index 00000000..f6cb3c74 --- /dev/null +++ b/apps/amm/client/src/api/pair.rs @@ -0,0 +1,93 @@ +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, + compute_vault_pda, +}; +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; +use nssa_core::{account::AccountId, program::ProgramId}; +use serde_json::{json, Value}; +use twap_oracle_core::compute_current_tick_account_pda; + +use super::{config::load_config, PairIdsRequest}; +use crate::account::{account_id_hex, parse_base58_id, parse_program_id, AccountRead}; + +#[derive(Clone, Copy)] +pub(super) struct PairIds { + pub(super) token_a: AccountId, + pub(super) token_b: AccountId, + pub(super) config: AccountId, + pub(super) pool: AccountId, + pub(super) vault_a: AccountId, + pub(super) vault_b: AccountId, + pub(super) lp_definition: AccountId, + pub(super) lp_lock_holding: AccountId, + pub(super) current_tick: AccountId, + pub(super) clock: AccountId, + pub(super) token_program: ProgramId, + pub(super) twap_program: ProgramId, +} + +pub(super) fn pair_ids(request: PairIdsRequest) -> Result { + let amm_program = parse_program_id(&request.amm_program_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" })); + } + if !is_canonical_pair(token_a, token_b) { + return Ok(json!({ "status": "error", "code": "non_canonical_pair" })); + } + + 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)) +} + +pub(super) fn is_canonical_pair(token_a: AccountId, token_b: AccountId) -> bool { + token_a.value() > token_b.value() +} + +pub(super) fn derive_pair( + amm_program: ProgramId, + token_a: AccountId, + token_b: AccountId, + config_read: &AccountRead, +) -> Result { + let config_id = compute_config_pda(amm_program); + let config = load_config(amm_program, config_read)?; + let pool = compute_pool_pda(amm_program, token_a, token_b); + Ok(PairIds { + token_a, + token_b, + config: config_id, + pool, + vault_a: compute_vault_pda(amm_program, pool, token_a), + vault_b: compute_vault_pda(amm_program, pool, token_b), + lp_definition: compute_liquidity_token_pda(amm_program, pool), + lp_lock_holding: compute_lp_lock_holding_pda(amm_program, pool), + current_tick: compute_current_tick_account_pda(config.twap_oracle_program_id, pool), + clock: CLOCK_01_PROGRAM_ACCOUNT_ID, + token_program: config.token_program_id, + twap_program: config.twap_oracle_program_id, + }) +} + +fn pair_json(pair: PairIds) -> Value { + json!({ + "status": "ok", + "tokenAId": account_id_hex(pair.token_a), + "tokenBId": account_id_hex(pair.token_b), + "configId": account_id_hex(pair.config), + "poolId": account_id_hex(pair.pool), + "vaultAId": account_id_hex(pair.vault_a), + "vaultBId": account_id_hex(pair.vault_b), + "lpDefinitionId": account_id_hex(pair.lp_definition), + "lpLockHoldingId": account_id_hex(pair.lp_lock_holding), + "currentTickId": account_id_hex(pair.current_tick), + "clockId": account_id_hex(pair.clock), + }) +} diff --git a/apps/amm/client/src/api/plan.rs b/apps/amm/client/src/api/plan.rs new file mode 100644 index 00000000..97edcf2d --- /dev/null +++ b/apps/amm/client/src/api/plan.rs @@ -0,0 +1,141 @@ +use nssa_core::account::Account; +use serde_json::{json, Value}; + +use super::{ + clock::decode_clock, + position::{NewPositionPlan, QuoteBranch, QuoteComputation}, + quote::compute_quote, + PlanRequest, QuoteRequest, SCHEMA, +}; +use crate::account::{account_id_hex, decode_account, AccountRead}; + +const DEADLINE_WINDOW_MS: u64 = 1_200_000; + +pub(super) fn plan(input: PlanRequest) -> Result { + let quote_input = QuoteRequest { + network_id: input.network_id, + network_fingerprint: input.network_fingerprint, + amm_program_id: input.amm_program_id.clone(), + request: input.request, + snapshot: input.snapshot, + }; + let quote = compute_quote("e_input)?; + if quote.quote_hash() != Some(input.quote_hash.as_str()) { + return Ok(json!({ + "schema": SCHEMA, + "status": "error", + "code": "quote_changed", + "recoverable": true, + "quote": quote.into_value("e_input.request), + })); + } + let evaluated = match quote { + QuoteComputation::Evaluated(evaluated) => evaluated, + QuoteComputation::Failed(failure) => { + return Ok(json!({ + "schema": SCHEMA, + "status": "error", + "code": "quote_not_submittable", + "recoverable": true, + "quote": failure.into_value("e_input.request), + })) + } + }; + let Some(plan) = evaluated.plan else { + return Ok(json!({ + "schema": SCHEMA, + "status": "error", + "code": "quote_not_submittable", + "recoverable": true, + "quote": evaluated.value, + })); + }; + let fresh_lp = if plan.requires_fresh_lp() { + let Some(read) = input.fresh_lp.as_ref() else { + return Ok(json!({ + "schema": SCHEMA, + "status": "needs_fresh_lp", + "code": "fresh_lp_required", + })); + }; + let Ok((id, account)) = decode_account(read) else { + return Ok(plan_error("wallet_submission_failed")); + }; + if account != Account::default() || plan.accounts.contains(id) { + return Ok(plan_error("wallet_submission_failed")); + } + Some(id) + } else { + None + }; + let deadline = input + .now_ms + .checked_add(DEADLINE_WINDOW_MS) + .ok_or_else(|| String::from("transaction deadline overflow"))?; + let clock_timestamp = clock_timestamp("e_input.snapshot.clock)?; + if clock_timestamp >= deadline { + return Ok(plan_error("transaction_deadline_expired")); + } + let NewPositionPlan { accounts, branch } = plan; + let affected_account_ids = accounts.writable_account_ids(fresh_lp)?; + let (account_ids, signing_requirements) = accounts.wallet_args(fresh_lp)?; + let instruction = match branch { + QuoteBranch::Missing { amount_a, amount_b } => { + let instruction = amm_core::Instruction::NewDefinition { + token_a_amount: amount_a, + token_b_amount: amount_b, + fees: u128::from(quote_input.request.fee_bps), + deadline, + }; + risc0_zkvm::serde::to_vec(&instruction) + .map_err(|error| format!("instruction serialization failed: {error}"))? + } + QuoteBranch::Active { + max_a, + max_b, + minimum_lp, + stored_reversed, + } => { + let (stored_max_a, stored_max_b) = if stored_reversed { + (max_b, max_a) + } else { + (max_a, max_b) + }; + let instruction = amm_core::Instruction::AddLiquidity { + min_amount_liquidity: minimum_lp, + max_amount_to_add_token_a: stored_max_a, + max_amount_to_add_token_b: stored_max_b, + deadline, + }; + risc0_zkvm::serde::to_vec(&instruction) + .map_err(|error| format!("instruction serialization failed: {error}"))? + } + }; + + Ok(json!({ + "schema": SCHEMA, + "status": "ready", + "programId": input.amm_program_id, + "accountIds": account_ids.into_iter().map(account_id_hex).collect::>(), + "affectedAccountIds": affected_account_ids + .into_iter() + .map(account_id_hex) + .collect::>(), + "signingRequirements": signing_requirements, + "instruction": instruction, + "deadlineMs": deadline.to_string(), + })) +} + +fn plan_error(code: &str) -> Value { + json!({ + "schema": SCHEMA, + "status": "error", + "code": code, + "recoverable": true, + }) +} + +fn clock_timestamp(read: &AccountRead) -> Result { + decode_clock(read).map(|(_, clock)| clock.timestamp) +} diff --git a/apps/amm/client/src/api/position.rs b/apps/amm/client/src/api/position.rs new file mode 100644 index 00000000..d36ad056 --- /dev/null +++ b/apps/amm/client/src/api/position.rs @@ -0,0 +1,246 @@ +use nssa_core::{account::AccountId, program::ProgramId}; +use serde_json::{json, Value}; + +use super::{ + commitment::SourceCommitment, holding::SelectedHolding, pair::PairIds, quote_error::issue, + PairSnapshot, PositionRequest, SCHEMA, +}; +use crate::account::{account_id_from_hex, program_id_base58}; + +#[derive(Clone)] +pub(super) enum QuoteBranch { + Missing { + amount_a: u128, + amount_b: u128, + }, + Active { + max_a: u128, + max_b: u128, + minimum_lp: u128, + stored_reversed: bool, + }, +} + +pub(super) struct EvaluatedQuote { + pub(super) value: Value, + pub(super) quote_hash: String, + pub(super) plan: Option, +} + +pub(super) enum QuoteComputation { + Failed(QuoteFailure), + Evaluated(EvaluatedQuote), +} + +pub(super) struct QuoteFailure { + pub(super) code: &'static str, + pub(super) fields: Vec<&'static str>, + pub(super) details: Value, +} + +pub(super) struct NewPositionPlan { + pub(super) accounts: AccountPlan, + pub(super) branch: QuoteBranch, +} + +pub(super) struct AccountPlan { + pub(super) rows: Vec, + pub(super) sources: Vec, +} + +pub(super) struct AccountPlanRow { + pub(super) role: &'static str, + pub(super) account_id: Option, + pub(super) expected_program: Option, + pub(super) action: &'static str, + pub(super) signer: bool, + pub(super) init: bool, +} + +pub(super) struct AccountPlanHoldings<'a> { + pub(super) token_a: Option<&'a SelectedHolding>, + pub(super) token_b: Option<&'a SelectedHolding>, + pub(super) lp: Option<&'a SelectedHolding>, +} + +impl QuoteComputation { + pub(super) fn into_value(self, request: &PositionRequest) -> Value { + match self { + Self::Failed(failure) => failure.into_value(request), + Self::Evaluated(EvaluatedQuote { value, .. }) => value, + } + } + + pub(super) fn quote_hash(&self) -> Option<&str> { + match self { + Self::Failed(_) => None, + Self::Evaluated(quote) => Some("e.quote_hash), + } + } +} + +impl QuoteFailure { + pub(super) 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 { + pub(super) fn new(accounts: AccountPlan, branch: QuoteBranch) -> Result { + accounts.validate_ready()?; + Ok(Self { accounts, branch }) + } + + pub(super) fn requires_fresh_lp(&self) -> bool { + self.accounts.requires_fresh_lp() + } +} + +impl AccountPlan { + pub(super) fn validate_snapshot_ids( + pair: &PairIds, + snapshot: &PairSnapshot, + ) -> Option<&'static str> { + let expected = [ + (&snapshot.config, pair.config, "config"), + (&snapshot.token_a, pair.token_a, "token_a"), + (&snapshot.token_b, pair.token_b, "token_b"), + (&snapshot.pool, pair.pool, "pool"), + (&snapshot.vault_a, pair.vault_a, "vault_a"), + (&snapshot.vault_b, pair.vault_b, "vault_b"), + (&snapshot.lp_definition, pair.lp_definition, "lp_definition"), + ( + &snapshot.lp_lock_holding, + pair.lp_lock_holding, + "lp_lock_holding", + ), + (&snapshot.current_tick, pair.current_tick, "current_tick"), + (&snapshot.clock, pair.clock, "clock"), + ]; + expected.into_iter().find_map(|(read, id, role)| { + match account_id_from_hex(&read.id, "account id") { + Ok(actual) if actual == id => None, + _ => Some(role), + } + }) + } + + pub(super) fn preview(&self) -> Vec { + self.rows + .iter() + .enumerate() + .map(|(order, row)| row.preview(order)) + .collect() + } + + pub(super) fn take_sources(&mut self) -> Vec { + std::mem::take(&mut self.sources) + } + + pub(super) fn requires_fresh_lp(&self) -> bool { + self.rows + .iter() + .any(|row| row.role == "user_holding_lp" && row.account_id.is_none()) + } + + pub(super) fn contains(&self, account_id: AccountId) -> bool { + self.rows + .iter() + .any(|row| row.account_id == Some(account_id)) + } + + pub(super) 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) + }) { + return Err(String::from("submittable quote has an unresolved account")); + } + Ok(()) + } + + pub(super) fn wallet_args( + &self, + fresh_lp: Option, + ) -> Result<(Vec, Vec), String> { + let mut account_ids = Vec::with_capacity(self.rows.len()); + let mut signing_requirements = Vec::with_capacity(self.rows.len()); + for row in &self.rows { + let account_id = match row.account_id { + Some(account_id) => account_id, + None if row.role == "user_holding_lp" => { + fresh_lp.ok_or_else(|| String::from("transaction plan has no LP holding"))? + } + None => return Err(String::from("transaction plan has an unresolved account")), + }; + account_ids.push(account_id); + signing_requirements.push(row.signer); + } + Ok((account_ids, signing_requirements)) + } + + pub(super) fn writable_account_ids( + &self, + fresh_lp: Option, + ) -> Result, String> { + self.rows + .iter() + .filter(|row| row.action != "read") + .map(|row| match row.account_id { + Some(account_id) => Ok(account_id), + None if row.role == "user_holding_lp" => { + fresh_lp.ok_or_else(|| String::from("transaction plan has no LP holding")) + } + None => Err(String::from("transaction plan has an unresolved account")), + }) + .collect() + } +} + +impl AccountPlanRow { + pub(super) fn new( + role: &'static str, + account_id: Option, + expected_program: Option, + action: &'static str, + signer: bool, + init: bool, + ) -> Self { + Self { + role, + account_id, + expected_program, + action, + signer, + init, + } + } + + fn preview(&self, order: usize) -> Value { + json!({ + "order": order, + "role": self.role, + "accountId": self.account_id.map(|id| id.to_string()), + "expectedProgramId": self.expected_program.map(program_id_base58), + "action": self.action, + "writable": self.action != "read", + "signer": self.signer, + "init": self.init, + }) + } +} diff --git a/apps/amm/client/src/api/quote.rs b/apps/amm/client/src/api/quote.rs new file mode 100644 index 00000000..739b942c --- /dev/null +++ b/apps/amm/client/src/api/quote.rs @@ -0,0 +1,815 @@ +use alloy_primitives::U256; +use amm_core::{ + is_supported_fee_tier, isqrt_product, mul_div_floor, spot_price_q64_64, PoolDefinition, + FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY, +}; +use nssa_core::{ + account::{Account, AccountId}, + program::ProgramId, +}; +use serde_json::{json, Value}; +use token_core::TokenDefinition; +use twap_oracle_core::CurrentTickAccount; + +use super::{ + accounts::{active_account_plan, missing_account_plan}, + clock::decode_clock, + commitment::{QuoteCommitment, RequestCommitment}, + context::fungible_definition, + funding::{funding_commitments, funding_issues, hash_quote}, + holding::{decode_fungible_holding, holding_options, select_holding, wallet_holdings}, + pair::{derive_pair, is_canonical_pair, PairIds}, + position::{ + AccountPlan, AccountPlanHoldings, EvaluatedQuote, NewPositionPlan, QuoteBranch, + QuoteComputation, + }, + quote_error::{fatal_quote, issue}, + QuoteRequest, SCHEMA, +}; +use crate::account::{ + decode_account, parse_base58_id, parse_program_id, program_id_bytes, AccountRead, +}; + +const DEFAULT_SLIPPAGE_BPS: u32 = 50; +const MAX_SLIPPAGE_BPS: u32 = 5_000; +const HIGH_SLIPPAGE_BPS: u32 = 2_000; +pub(super) const Q64: u128 = 1_u128 << 64; + +pub(super) fn quote(request: QuoteRequest) -> Result { + Ok(compute_quote(&request)?.into_value(&request.request)) +} + +pub(super) fn compute_quote(input: &QuoteRequest) -> Result { + if input.request.schema != SCHEMA { + return Ok(fatal_quote( + "unsupported_schema", + &["schema"], + json!({ "received": input.request.schema }), + )); + } + let amm_program = parse_program_id(&input.amm_program_id)?; + let token_a = match parse_base58_id(&input.request.token_a_id, "token A id") { + Ok(id) => id, + Err(_) => return Ok(fatal_quote("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("invalid_token_id", &["tokenBId"], json!({}))), + }; + if token_a == token_b { + return Ok(fatal_quote( + "same_token_pair", + &["tokenAId", "tokenBId"], + json!({}), + )); + } + if !is_canonical_pair(token_a, token_b) { + return Ok(fatal_quote( + "non_canonical_pair", + &["tokenAId", "tokenBId"], + json!({}), + )); + } + if !is_supported_fee_tier(u128::from(input.request.fee_bps)) { + return Ok(fatal_quote( + "invalid_fee_tier", + &["feeBps"], + json!({ "feeBps": input.request.fee_bps }), + )); + } + + let pair = match derive_pair(amm_program, token_a, token_b, &input.snapshot.config) { + Ok(pair) => pair, + Err(_) => return Ok(fatal_quote("config_unavailable", &[], json!({}))), + }; + if let Some(error) = AccountPlan::validate_snapshot_ids(&pair, &input.snapshot) { + return Ok(fatal_quote( + "account_read_failed", + &[], + json!({ "role": 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( + "account_read_failed", + &[], + json!({ "role": "pool", "accountId": pair.pool.to_string() }), + )) + } + }; + if pool_account == Account::default() { + compute_missing_quote(input, amm_program, pair) + } else { + compute_active_quote(input, amm_program, pair, pool_account) + } +} + +fn compute_missing_quote( + input: &QuoteRequest, + amm_program: ProgramId, + pair: PairIds, +) -> Result { + for (read, role) in [ + (&input.snapshot.vault_a, "vault_a"), + (&input.snapshot.vault_b, "vault_b"), + (&input.snapshot.lp_definition, "lp_definition"), + (&input.snapshot.lp_lock_holding, "lp_lock_holding"), + (&input.snapshot.current_tick, "current_tick"), + ] { + let Ok((_, account)) = decode_account(read) else { + return Ok(fatal_quote( + "account_read_failed", + &[], + json!({ "role": role }), + )); + }; + if account != Account::default() { + return Ok(fatal_quote( + "pool_unavailable", + &[], + json!({ "role": role }), + )); + } + } + if !valid_clock(&input.snapshot.clock, pair.clock) { + return Ok(fatal_quote( + "account_read_failed", + &[], + json!({ "role": "clock" }), + )); + } + + 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( + "amount_must_be_positive", + &["initialPriceRealRaw"], + json!({}), + )) + } + 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( + "deposit_ratio_mismatch", + &["amountARaw", "amountBRaw"], + 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( + "amount_too_low", + &["amountARaw", "amountBRaw"], + json!({ "minimumLiquidityRaw": MINIMUM_LIQUIDITY.to_string() }), + )); + } + let expected_lp = initial_lp - MINIMUM_LIQUIDITY; + + let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program); + let holding_a = select_holding( + &holdings, + pair.token_a, + input.request.holding_a_id.as_deref(), + ); + let holding_b = select_holding( + &holdings, + pair.token_b, + input.request.holding_b_id.as_deref(), + ); + let mut funding = holding_selection_issues(input, pair, &holdings, &holding_a, &holding_b); + funding.extend(funding_issues( + input.snapshot.wallet_available, + pair, + &holding_a, + amount_a, + &holding_b, + amount_b, + ["amountARaw", "amountBRaw"], + )); + let can_submit = funding.is_empty(); + let mut account_plan = missing_account_plan( + input, + pair, + amm_program, + AccountPlanHoldings { + token_a: holding_a.as_ref(), + token_b: holding_b.as_ref(), + lp: None, + }, + )?; + let sources = account_plan.take_sources(); + let funding_commitment = funding_commitments(pair, &holding_a, amount_a, &holding_b, amount_b); + let commitment = QuoteCommitment { + schema: String::from(SCHEMA), + network_id: input.network_id.clone(), + network_fingerprint: input.network_fingerprint.clone(), + amm_program_id: program_id_bytes(amm_program), + token_a_id: pair.token_a.into_value(), + token_b_id: pair.token_b.into_value(), + fee_bps: input.request.fee_bps, + pool_status: 0, + request: RequestCommitment::Missing { amount_a, amount_b }, + max_a: amount_a, + max_b: amount_b, + actual_a: amount_a, + actual_b: amount_b, + expected_lp, + lp_guard: MINIMUM_LIQUIDITY, + requires_fresh_lp: true, + sources, + funding: funding_commitment, + warnings: Vec::new(), + }; + let quote_hash = hash_quote(&commitment)?; + let preview = account_plan.preview(); + let value = json!({ + "schema": SCHEMA, + "status": "ok", + "canSubmit": can_submit, + "code": if can_submit { "ready" } else { "funding_required" }, + "poolStatus": "missing_pool", + "instruction": "NewDefinition", + "quoteHash": quote_hash, + "feeBps": input.request.fee_bps, + "poolId": pair.pool.to_string(), + "tokenAId": pair.token_a.to_string(), + "tokenBId": pair.token_b.to_string(), + "maxAmountARaw": amount_a.to_string(), + "maxAmountBRaw": amount_b.to_string(), + "actualAmountARaw": amount_a.to_string(), + "actualAmountBRaw": amount_b.to_string(), + "expectedLpRaw": expected_lp.to_string(), + "lockedLpRaw": MINIMUM_LIQUIDITY.to_string(), + "initialPriceRealRaw": 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, + "warnings": [], + }); + let plan = if can_submit { + Some(NewPositionPlan::new( + account_plan, + QuoteBranch::Missing { amount_a, amount_b }, + )?) + } else { + None + }; + Ok(QuoteComputation::Evaluated(EvaluatedQuote { + value, + quote_hash, + plan, + })) +} + +fn compute_active_quote( + input: &QuoteRequest, + amm_program: ProgramId, + pair: PairIds, + pool_account: Account, +) -> Result { + if pool_account.program_owner != amm_program { + return Ok(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "owner_mismatch" }), + )); + } + let Ok(pool) = PoolDefinition::try_from(&pool_account.data) else { + return Ok(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "invalid_pool_data" }), + )); + }; + let stored_reversed = if pool.definition_token_a_id == pair.token_a + && pool.definition_token_b_id == pair.token_b + { + false + } else if pool.definition_token_a_id == pair.token_b + && pool.definition_token_b_id == pair.token_a + { + true + } else { + return Ok(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "pair_mismatch" }), + )); + }; + if pool.reserve_a == 0 || pool.reserve_b == 0 || pool.liquidity_pool_supply == 0 { + return Ok(fatal_quote("pool_inactive", &[], json!({}))); + } + if pool.fees != u128::from(input.request.fee_bps) { + return Ok(fatal_quote( + "fee_tier_mismatch", + &["feeBps"], + json!({ "poolFeeBps": pool.fees.to_string() }), + )); + } + if !is_supported_fee_tier(pool.fees) { + return Ok(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "unsupported_pool_fee" }), + )); + } + + 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( + "amount_must_be_positive", + &["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()) { + Ok(value) if value > 0 => value, + Ok(_) => { + return Ok(fatal_quote( + "amount_must_be_positive", + &["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( + "invalid_slippage", + &["slippageBps"], + json!({ "maximum": MAX_SLIPPAGE_BPS }), + )); + } + + let (stored_max_a, stored_max_b) = if stored_reversed { + (max_b, max_a) + } else { + (max_a, max_b) + }; + let ideal_a = mul_div_floor(pool.reserve_a, stored_max_b, pool.reserve_b); + let ideal_b = mul_div_floor(pool.reserve_b, stored_max_a, pool.reserve_a); + let stored_actual_a = stored_max_a.min(ideal_a); + let stored_actual_b = stored_max_b.min(ideal_b); + if stored_actual_a == 0 || stored_actual_b == 0 { + return Ok(fatal_quote( + "amount_too_low", + &["maxAmountARaw", "maxAmountBRaw"], + json!({}), + )); + } + let expected_lp = + mul_div_floor(pool.liquidity_pool_supply, stored_actual_a, pool.reserve_a).min( + mul_div_floor(pool.liquidity_pool_supply, stored_actual_b, pool.reserve_b), + ); + if expected_lp == 0 { + return Ok(fatal_quote( + "amount_too_low", + &["maxAmountARaw", "maxAmountBRaw"], + json!({}), + )); + } + let minimum_lp = mul_div_floor( + expected_lp, + FEE_BPS_DENOMINATOR - u128::from(slippage_bps), + FEE_BPS_DENOMINATOR, + ); + if minimum_lp == 0 { + return Ok(fatal_quote("minimum_lp_zero", &["slippageBps"], json!({}))); + } + let (actual_a, actual_b, reserve_a, reserve_b) = if stored_reversed { + ( + stored_actual_b, + stored_actual_a, + pool.reserve_b, + pool.reserve_a, + ) + } else { + ( + stored_actual_a, + stored_actual_b, + pool.reserve_a, + pool.reserve_b, + ) + }; + + if let Some(error) = validate_active_accounts(input, pair, &pool, stored_reversed) { + return Ok(error); + } + let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program); + let holding_a = select_holding( + &holdings, + pair.token_a, + input.request.holding_a_id.as_deref(), + ); + let holding_b = select_holding( + &holdings, + pair.token_b, + input.request.holding_b_id.as_deref(), + ); + let lp_options = holding_options(&holdings, pair.lp_definition); + let lp_holding = if input.request.create_fresh_lp { + None + } else { + select_holding( + &holdings, + pair.lp_definition, + input.request.lp_holding_id.as_deref(), + ) + }; + let lp_destination_selected = + input.request.create_fresh_lp || lp_holding.is_some() || lp_options.is_empty(); + let requires_fresh_lp = input.request.create_fresh_lp || lp_options.is_empty(); + let mut funding = holding_selection_issues(input, pair, &holdings, &holding_a, &holding_b); + funding.extend(funding_issues( + input.snapshot.wallet_available, + pair, + &holding_a, + actual_a, + &holding_b, + actual_b, + ["maxAmountARaw", "maxAmountBRaw"], + )); + if !lp_destination_selected { + funding.push(issue( + "lp_destination_required", + "Select an LP token destination.", + &["lpHoldingId", "createFreshLp"], + json!({ "available": lp_options.len() }), + )); + } + let can_submit = funding.is_empty(); + let warnings = if slippage_bps >= HIGH_SLIPPAGE_BPS { + vec![issue( + "high_slippage", + "High slippage tolerance.", + &["slippageBps"], + json!({ "slippageBps": slippage_bps }), + )] + } else { + Vec::new() + }; + let warning_codes = warnings + .iter() + .filter_map(|warning| warning["code"].as_str().map(String::from)) + .collect(); + let mut account_plan = active_account_plan( + input, + pair, + amm_program, + &pool, + stored_reversed, + AccountPlanHoldings { + token_a: holding_a.as_ref(), + token_b: holding_b.as_ref(), + lp: lp_holding.as_ref(), + }, + )?; + let sources = account_plan.take_sources(); + let commitment = QuoteCommitment { + schema: String::from(SCHEMA), + network_id: input.network_id.clone(), + network_fingerprint: input.network_fingerprint.clone(), + amm_program_id: program_id_bytes(amm_program), + token_a_id: pair.token_a.into_value(), + token_b_id: pair.token_b.into_value(), + fee_bps: input.request.fee_bps, + pool_status: 1, + request: RequestCommitment::Active { + max_a, + max_b, + slippage_bps, + }, + max_a, + max_b, + actual_a, + actual_b, + expected_lp, + lp_guard: minimum_lp, + requires_fresh_lp, + sources, + funding: funding_commitments(pair, &holding_a, actual_a, &holding_b, actual_b), + warnings: warning_codes, + }; + let quote_hash = hash_quote(&commitment)?; + let preview = account_plan.preview(); + let value = json!({ + "schema": SCHEMA, + "status": "ok", + "canSubmit": can_submit, + "code": if can_submit { "ready" } else { "funding_required" }, + "poolStatus": "active_pool", + "instruction": "AddLiquidity", + "quoteHash": quote_hash, + "feeBps": input.request.fee_bps, + "poolFeeBps": pool.fees.to_string(), + "poolId": pair.pool.to_string(), + "tokenAId": pair.token_a.to_string(), + "tokenBId": pair.token_b.to_string(), + "maxAmountARaw": max_a.to_string(), + "maxAmountBRaw": max_b.to_string(), + "actualAmountARaw": actual_a.to_string(), + "actualAmountBRaw": actual_b.to_string(), + "reserveARaw": reserve_a.to_string(), + "reserveBRaw": reserve_b.to_string(), + "liquiditySupplyRaw": pool.liquidity_pool_supply.to_string(), + "expectedLpRaw": expected_lp.to_string(), + "minimumLpRaw": minimum_lp.to_string(), + "initialPriceRealRaw": spot_price_q64_64(reserve_a, reserve_b).to_string(), + "requiresFreshLp": requires_fresh_lp, + "lpDestinationRequired": !lp_destination_selected, + "lpHoldingOptions": lp_options.iter().map(|holding| json!({ + "holdingId": holding.id.to_string(), + "balanceRaw": holding.balance.to_string(), + })).collect::>(), + "selectedLpHoldingId": lp_holding.as_ref().map(|holding| holding.id.to_string()), + "accountPreview": preview, + "errors": funding, + "warnings": warnings, + }); + let plan = if can_submit { + Some(NewPositionPlan::new( + account_plan, + QuoteBranch::Active { + max_a, + max_b, + minimum_lp, + stored_reversed, + }, + )?) + } else { + None + }; + Ok(QuoteComputation::Evaluated(EvaluatedQuote { + value, + quote_hash, + plan, + })) +} + +fn holding_selection_issues( + input: &QuoteRequest, + pair: PairIds, + holdings: &[super::holding::SelectedHolding], + holding_a: &Option, + holding_b: &Option, +) -> Vec { + if !input.snapshot.wallet_available { + return Vec::new(); + } + + let mut errors = Vec::new(); + for (definition_id, requested, selected, field) in [ + ( + pair.token_a, + input.request.holding_a_id.as_deref(), + holding_a, + "holdingAId", + ), + ( + pair.token_b, + input.request.holding_b_id.as_deref(), + holding_b, + "holdingBId", + ), + ] { + let options = holding_options(holdings, definition_id); + if !options.is_empty() && selected.is_none() { + errors.push(issue( + if requested.is_some() { + "invalid_holding_selection" + } else { + "holding_selection_required" + }, + "Select a wallet holding for this token.", + &[field], + json!({ + "tokenId": definition_id.to_string(), + "available": options.len(), + }), + )); + } + } + errors +} + +fn validate_active_accounts( + input: &QuoteRequest, + pair: PairIds, + pool: &PoolDefinition, + stored_reversed: bool, +) -> Option { + let expected_vault_a = if stored_reversed { + pair.vault_b + } else { + pair.vault_a + }; + let expected_vault_b = if stored_reversed { + pair.vault_a + } else { + pair.vault_b + }; + if pool.vault_a_id != expected_vault_a + || pool.vault_b_id != expected_vault_b + || pool.liquidity_pool_id != pair.lp_definition + { + return Some(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "pool_account_mismatch" }), + )); + } + let (canonical_vault_a, canonical_vault_b) = ( + decode_holding(&input.snapshot.vault_a, pair.token_program, pair.token_a), + decode_holding(&input.snapshot.vault_b, pair.token_program, pair.token_b), + ); + let (Ok(vault_a_balance), Ok(vault_b_balance)) = (canonical_vault_a, canonical_vault_b) else { + return Some(fatal_quote( + "account_read_failed", + &[], + json!({ "role": "vault" }), + )); + }; + let (reserve_a, reserve_b) = if stored_reversed { + (pool.reserve_b, pool.reserve_a) + } else { + (pool.reserve_a, pool.reserve_b) + }; + if vault_a_balance < reserve_a || vault_b_balance < reserve_b { + return Some(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "vault_below_reserve" }), + )); + } + let Ok((lp_id, lp_account)) = decode_account(&input.snapshot.lp_definition) else { + return Some(fatal_quote( + "account_read_failed", + &[], + json!({ "role": "lp_definition" }), + )); + }; + if lp_id != pair.lp_definition + || lp_account.program_owner != pair.token_program + || !matches!( + TokenDefinition::try_from(&lp_account.data), + Ok(TokenDefinition::Fungible { .. }) + ) + { + return Some(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "invalid_lp_definition" }), + )); + } + let Ok((tick_id, tick_account)) = decode_account(&input.snapshot.current_tick) else { + return Some(fatal_quote( + "account_read_failed", + &[], + json!({ "role": "current_tick" }), + )); + }; + if tick_id != pair.current_tick + || tick_account.program_owner != pair.twap_program + || CurrentTickAccount::try_from(&tick_account.data).is_err() + { + return Some(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "invalid_current_tick" }), + )); + } + if !valid_clock(&input.snapshot.clock, pair.clock) { + return Some(fatal_quote( + "account_read_failed", + &[], + json!({ "role": "clock" }), + )); + } + None +} + +fn decode_holding( + read: &AccountRead, + token_program: ProgramId, + definition_id: AccountId, +) -> Result { + let holding = decode_fungible_holding(read, token_program)?; + if holding.definition_id != definition_id { + return Err(String::from("invalid fungible holding")); + } + Ok(holding.balance) +} + +fn valid_clock(read: &AccountRead, expected_id: AccountId) -> bool { + matches!(decode_clock(read), Ok((id, _)) if id == expected_id) +} + +fn raw_value(value: Option<&str>) -> Result { + let Some(value) = value else { + return Err("amount_required"); + }; + if value.is_empty() { + return Err("amount_required"); + } + if !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err("invalid_raw_amount"); + } + value.parse().map_err(|_| "invalid_raw_amount") +} + +pub(super) fn minimum_opening_pair(price: u128) -> Result<(u128, u128), String> { + 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 + }); + let amount_b = div_ceil_u256(U256::from(amount_a) * U256::from(price), U256::from(Q64)); + Ok(( + amount_a, + u128::try_from(amount_b).map_err(|_| String::from("opening amount overflow"))?, + )) + } else { + let amount_b = binary_search_min(1, MINIMUM_LIQUIDITY + 1, |amount_b| { + let amount_a = div_ceil_u256(U256::from(amount_b) * U256::from(Q64), U256::from(price)); + amount_a * U256::from(amount_b) >= target_product + }); + let amount_a = div_ceil_u256(U256::from(amount_b) * U256::from(Q64), U256::from(price)); + Ok(( + u128::try_from(amount_a).map_err(|_| String::from("opening amount overflow"))?, + amount_b, + )) + } +} + +fn binary_search_min(mut low: u128, mut high: u128, predicate: impl Fn(u128) -> bool) -> u128 { + while low < high { + let mid = low + (high - low) / 2; + if predicate(mid) { + high = mid; + } else { + low = mid + 1; + } + } + low +} + +pub(super) fn div_ceil_u256(numerator: U256, denominator: U256) -> U256 { + numerator.div_ceil(denominator) +} diff --git a/apps/amm/client/src/api/quote_error.rs b/apps/amm/client/src/api/quote_error.rs new file mode 100644 index 00000000..df10ffd7 --- /dev/null +++ b/apps/amm/client/src/api/quote_error.rs @@ -0,0 +1,25 @@ +use serde_json::{json, Value}; + +use super::position::{QuoteComputation, QuoteFailure}; + +pub(super) fn issue(code: &str, message: &str, fields: &[&str], details: Value) -> Value { + json!({ + "code": code, + "message": message, + "details": details, + "recoverable": true, + "blockingFields": fields, + }) +} + +pub(super) fn fatal_quote( + code: &'static str, + fields: &[&'static str], + details: Value, +) -> QuoteComputation { + QuoteComputation::Failed(QuoteFailure { + code, + fields: fields.to_vec(), + details, + }) +} diff --git a/apps/amm/client/src/api/request.rs b/apps/amm/client/src/api/request.rs new file mode 100644 index 00000000..3fd112f3 --- /dev/null +++ b/apps/amm/client/src/api/request.rs @@ -0,0 +1,131 @@ +use serde::Deserialize; + +use crate::account::AccountRead; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct NormalizeAccountRpcRequest { + pub account_id: String, + pub response: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ConfigIdRequest { + pub amm_program_id: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[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(Clone, Debug, Deserialize, Eq, PartialEq)] +#[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(Clone, Debug, Deserialize, Eq, PartialEq)] +#[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, Eq, PartialEq)] +#[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 holding_a_id: Option, + #[serde(default)] + pub holding_b_id: Option, + #[serde(default)] + pub lp_holding_id: Option, + #[serde(default)] + pub create_fresh_lp: bool, + #[serde(default)] + pub amount_a_raw: Option, + #[serde(default)] + pub amount_b_raw: Option, + #[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, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[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(Clone, Debug, Deserialize, Eq, PartialEq)] +#[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(Clone, Debug, Deserialize, Eq, PartialEq)] +#[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, +} diff --git a/apps/amm/client/src/api/tests.rs b/apps/amm/client/src/api/tests.rs new file mode 100644 index 00000000..eaa2eff6 --- /dev/null +++ b/apps/amm/client/src/api/tests.rs @@ -0,0 +1,768 @@ +use alloy_primitives::U256; +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, + compute_vault_pda, isqrt_product, spot_price_q64_64, AmmConfig, PoolDefinition, + MINIMUM_LIQUIDITY, +}; +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use pretty_assertions::assert_eq; +use serde_json::{json, Value}; +use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount}; + +use super::{ + accounts::{active_account_plan, missing_account_plan}, + context::{context, token_ids}, + holding::{select_holding, wallet_holdings, SelectedHolding}, + pair::{is_canonical_pair, pair_ids, PairIds}, + plan::plan, + position::AccountPlanHoldings, + quote::{div_ceil_u256, minimum_opening_pair, quote, Q64}, + ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest, QuoteRequest, + TokenIdsRequest, SCHEMA, +}; +use crate::{ + account::{account_id_hex, account_read, decode_account, parse_base58_id, program_id_bytes}, + AccountRead, +}; + +const AMM_PROGRAM: ProgramId = [11; 8]; +const TOKEN_PROGRAM: ProgramId = [22; 8]; +const TWAP_PROGRAM: ProgramId = [33; 8]; + +fn account(owner: ProgramId, data: Data) -> Account { + Account { + program_owner: owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn default_read(id: AccountId) -> AccountRead { + account_read(id, &Account::default()) +} + +fn config_account() -> Account { + account( + AMM_PROGRAM, + Data::from(&AmmConfig { + token_program_id: TOKEN_PROGRAM, + twap_oracle_program_id: TWAP_PROGRAM, + authority: AccountId::new([7; 32]), + }), + ) +} + +fn token_definition(name: &str, supply: u128) -> Account { + account( + TOKEN_PROGRAM, + Data::from(&TokenDefinition::Fungible { + name: String::from(name), + total_supply: supply, + metadata_id: None, + authority: None, + }), + ) +} + +fn token_holding(definition_id: AccountId, balance: u128) -> Account { + account( + TOKEN_PROGRAM, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + ) +} + +fn clock_account() -> Account { + account( + [44; 8], + Data::try_from( + ClockAccountData { + block_id: 10, + timestamp: 1_000, + } + .to_bytes(), + ) + .unwrap(), + ) +} + +fn ids() -> PairIds { + let token_a = AccountId::new([2; 32]); + let token_b = AccountId::new([1; 32]); + let config = compute_config_pda(AMM_PROGRAM); + let pool = compute_pool_pda(AMM_PROGRAM, token_a, token_b); + PairIds { + token_a, + token_b, + config, + pool, + vault_a: compute_vault_pda(AMM_PROGRAM, pool, token_a), + vault_b: compute_vault_pda(AMM_PROGRAM, pool, token_b), + lp_definition: compute_liquidity_token_pda(AMM_PROGRAM, pool), + lp_lock_holding: compute_lp_lock_holding_pda(AMM_PROGRAM, pool), + current_tick: compute_current_tick_account_pda(TWAP_PROGRAM, pool), + clock: CLOCK_01_PROGRAM_ACCOUNT_ID, + token_program: TOKEN_PROGRAM, + twap_program: TWAP_PROGRAM, + } +} + +fn base_snapshot(pair: PairIds) -> PairSnapshot { + let holding_a_id = AccountId::new([61; 32]); + let holding_b_id = AccountId::new([62; 32]); + PairSnapshot { + config: account_read(pair.config, &config_account()), + token_a: account_read(pair.token_a, &token_definition("A", 1_000_000)), + token_b: account_read(pair.token_b, &token_definition("B", 2_000_000)), + pool: default_read(pair.pool), + vault_a: default_read(pair.vault_a), + vault_b: default_read(pair.vault_b), + lp_definition: default_read(pair.lp_definition), + lp_lock_holding: default_read(pair.lp_lock_holding), + current_tick: default_read(pair.current_tick), + clock: account_read(pair.clock, &clock_account()), + wallet_available: true, + wallet_accounts: vec![ + account_read(holding_a_id, &token_holding(pair.token_a, 1_000_000)), + account_read(holding_b_id, &token_holding(pair.token_b, 1_000_000)), + ], + } +} + +fn request(pair: PairIds) -> PositionRequest { + assert!(is_canonical_pair(pair.token_a, pair.token_b)); + PositionRequest { + schema: String::from(SCHEMA), + token_a_id: pair.token_a.to_string(), + token_b_id: pair.token_b.to_string(), + fee_bps: 30, + holding_a_id: Some(AccountId::new([61; 32]).to_string()), + holding_b_id: Some(AccountId::new([62; 32]).to_string()), + lp_holding_id: None, + create_fresh_lp: false, + 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()), + } +} + +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() + } +} + +fn assert_preview_matches_plan( + quote_value: &Value, + plan_value: &Value, + fresh_lp: Option, +) { + let preview = quote_value["accountPreview"].as_array().unwrap(); + let account_ids = plan_value["accountIds"].as_array().unwrap(); + let signing_requirements = plan_value["signingRequirements"].as_array().unwrap(); + assert_eq!(preview.len(), account_ids.len()); + assert_eq!(preview.len(), signing_requirements.len()); + + for (order, row) in preview.iter().enumerate() { + assert_eq!(row["order"], order); + assert_eq!(row["signer"], signing_requirements[order]); + if let Some(account_id) = row["accountId"].as_str() { + let account_id = parse_base58_id(account_id, "preview account id").unwrap(); + assert_eq!(account_ids[order], account_id_hex(account_id)); + } else { + assert_eq!(row["role"], "user_holding_lp"); + assert_eq!(account_ids[order], account_id_hex(fresh_lp.unwrap())); + } + } +} + +#[test] +fn account_plan_sources_follow_pool_branch() { + 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, + input.request.holding_a_id.as_deref(), + ); + let holding_b = select_holding( + &holdings, + pair.token_b, + input.request.holding_b_id.as_deref(), + ); + + let missing = missing_account_plan( + &input, + pair, + AMM_PROGRAM, + AccountPlanHoldings { + token_a: holding_a.as_ref(), + token_b: holding_b.as_ref(), + lp: None, + }, + ) + .unwrap(); + assert_eq!( + missing + .sources + .iter() + .map(|source| source.role.as_str()) + .collect::>(), + vec![ + "config", + "token_a", + "token_b", + "pool", + "vault_a", + "vault_b", + "lp_definition", + "lp_lock_holding", + "current_tick", + "holding_a", + "holding_b", + ] + ); + + let active = active_account_plan( + &input, + pair, + AMM_PROGRAM, + &PoolDefinition::default(), + false, + AccountPlanHoldings { + token_a: holding_a.as_ref(), + token_b: holding_b.as_ref(), + lp: None, + }, + ) + .unwrap(); + assert_eq!( + active + .sources + .iter() + .map(|source| source.role.as_str()) + .collect::>(), + vec![ + "config", + "token_a", + "token_b", + "pool", + "vault_a", + "vault_b", + "lp_definition", + "current_tick", + "holding_a", + "holding_b", + ] + ); +} + +#[test] +fn minimum_pair_exceeds_protocol_lock() { + 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); + assert!(isqrt_product(amount_a, amount_b) > MINIMUM_LIQUIDITY); + } +} + +#[test] +fn minimum_pair_is_minimal_on_price_base_side() { + let (amount_a, amount_b) = minimum_opening_pair(Q64 * 2).unwrap(); + assert!(isqrt_product(amount_a, amount_b) > MINIMUM_LIQUIDITY); + let previous_b = div_ceil_u256( + U256::from(amount_a - 1) * U256::from(Q64 * 2), + U256::from(Q64), + ); + assert!( + U256::from(amount_a - 1) * previous_b <= U256::from(MINIMUM_LIQUIDITY * MINIMUM_LIQUIDITY) + ); +} + +#[test] +fn holding_selection_uses_requested_id() { + 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, + Data::from(&TokenHolding::Fungible { + definition_id: definition, + balance, + }), + ), + }; + let selected = select_holding( + &[holding(4, 10), holding(2, 20), holding(1, 20)], + definition, + Some(&AccountId::new([4; 32]).to_string()), + ) + .unwrap(); + assert_eq!(selected.id, AccountId::new([4; 32])); +} + +#[test] +fn pair_manifest_uses_canonical_ids_and_current_program_types() { + let token_a = AccountId::new([2; 32]); + let token_b = AccountId::new([1; 32]); + let config_id = compute_config_pda(AMM_PROGRAM); + let result = pair_ids(PairIdsRequest { + 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(), + }) + .unwrap(); + assert_eq!(result["status"], "ok"); + assert_eq!(result["tokenAId"], account_id_hex(token_a)); + assert_eq!(result["tokenBId"], account_id_hex(token_b)); + assert_eq!( + result["poolId"], + account_id_hex(compute_pool_pda(AMM_PROGRAM, token_a, token_b)) + ); +} + +#[test] +fn 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 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: 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, + &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()); +} + +#[test] +fn context_lists_all_holdings_without_preselecting_one() { + let token_id = AccountId::new([3; 32]); + let first = AccountId::new([4; 32]); + let second = AccountId::new([5; 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![ + account_read(second, &token_holding(token_id, 20)), + account_read(first, &token_holding(token_id, 10)), + ], + 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!(value["tokens"][0].get("holdingId").is_none()); + assert_eq!(value["tokens"][0]["balanceRaw"], "30"); + assert_eq!( + value["tokens"][0]["holdings"], + json!([ + { "holdingId": first.to_string(), "balanceRaw": "10" }, + { "holdingId": second.to_string(), "balanceRaw": "20" }, + ]) + ); +} + +#[test] +fn missing_pool_snapshot_defaults_remain_real_accounts() { + let id = AccountId::new([5; 32]); + let read = default_read(id); + let (decoded_id, decoded) = decode_account(&read).unwrap(); + assert_eq!(decoded_id, id); + assert_eq!(decoded, Account::default()); +} + +#[test] +fn missing_pool_quote_and_plan_use_current_account_order() { + let 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); + assert_eq!(quote_value["accountPreview"].as_array().unwrap().len(), 11); + let quote_hash = quote_value["quoteHash"].as_str().unwrap().to_owned(); + let config_id = scenario.pair.config; + let clock_id = scenario.pair.clock; + + let fresh_lp = AccountId::new([63; 32]); + 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)); + assert_eq!(plan_value["signingRequirements"][6], true); + assert_eq!(plan_value["signingRequirements"][7], true); + assert_eq!(plan_value["signingRequirements"][8], true); + let affected = plan_value["affectedAccountIds"].as_array().unwrap(); + assert_eq!(affected.len(), 9); + assert!(!affected.contains(&json!(account_id_hex(config_id)))); + assert!(!affected.contains(&json!(account_id_hex(clock_id)))); + assert_preview_matches_plan("e_value, &plan_value, Some(fresh_lp)); +} + +#[test] +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 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( + ClockAccountData { + block_id: 11, + timestamp: 1_500, + } + .to_bytes(), + ) + .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 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, + vault_a_id: pair.vault_a, + vault_b_id: pair.vault_b, + liquidity_pool_id: pair.lp_definition, + liquidity_pool_supply: 10_000, + reserve_a: 10_000, + reserve_b: 20_000, + fees: 30, + }; + 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, + Data::from(&TokenDefinition::Fungible { + name: String::from("LP"), + total_supply: pool.liquidity_pool_supply, + metadata_id: None, + authority: Some(pair.lp_definition), + }), + ), + ); + scenario.snapshot.current_tick = account_read( + pair.current_tick, + &account( + TWAP_PROGRAM, + Data::from(&CurrentTickAccount { + tick: 0, + last_updated: 1_000, + }), + ), + ); + 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]); + scenario.snapshot.wallet_accounts.push(account_read( + lp_holding, + &token_holding(pair.lp_definition, 500), + )); + 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 awaiting_destination = scenario.quote(); + assert_eq!(awaiting_destination["lpDestinationRequired"], true); + assert_eq!(awaiting_destination["canSubmit"], false); + assert_eq!( + awaiting_destination["errors"][0]["code"], + "lp_destination_required" + ); + scenario.request.lp_holding_id = Some(lp_holding.to_string()); + + 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 = 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)); + assert_eq!(plan_value["signingRequirements"][7], false); + assert_preview_matches_plan("e_value, &plan_value, None); +} + +#[test] +fn matching_unfunded_quote_has_no_transaction_plan() { + 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 = scenario.plan(quote_value["quoteHash"].as_str().unwrap(), None); + + assert_eq!(plan_value["status"], "error"); + assert_eq!(plan_value["code"], "quote_not_submittable"); + assert_eq!(plan_value["quote"], quote_value); +} + +#[test] +fn stale_hash_returns_recomputed_quote_without_plan() { + 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/client/src/ffi.rs b/apps/amm/client/src/ffi.rs new file mode 100644 index 00000000..13bbf03d --- /dev/null +++ b/apps/amm/client/src/ffi.rs @@ -0,0 +1,145 @@ +use std::{ + ffi::{c_char, CStr, CString}, + panic::{catch_unwind, AssertUnwindSafe}, +}; + +use serde::{de::DeserializeOwned, Serialize}; + +use crate::api::{ + self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, NormalizeAccountRpcRequest, + 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) -> AmmResult) -> *mut c_char { + let result = catch_unwind(AssertUnwindSafe(|| { + let request = request_text(request).map_err(AmmApiError::from)?; + let request = serde_json::from_str::(&request) + .map_err(|error| AmmApiError::from(format!("invalid request JSON: {error}")))?; + operation(request) + })); + + let envelope = match result { + Ok(Ok(value)) => Envelope::success(value), + Ok(Err(error)) => Envelope::failure(error.to_string()), + Err(_) => Envelope::failure("internal panic"), + }; + encode_envelope(&envelope) +} + +fn request_text(request: *const c_char) -> Result { + if request.is_null() { + return Err(String::from("request pointer is null")); + } + // SAFETY: The C++ caller passes a live NUL-terminated UTF-8 buffer for this call. + let request = unsafe { CStr::from_ptr(request) }; + request + .to_str() + .map(String::from) + .map_err(|error| format!("request is not UTF-8: {error}")) +} + +fn encode_envelope(envelope: &Envelope) -> *mut c_char { + let json = serde_json::to_string(envelope).unwrap_or_else(|_| { + String::from(r#"{"ok":false,"error":"response serialization failed"}"#) + }); + match CString::new(json) { + Ok(value) => value.into_raw(), + Err(_) => CString::new(r#"{"ok":false,"error":"response contains NUL"}"#) + .map_or(std::ptr::null_mut(), CString::into_raw), + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_config_id(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::config_id) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_token_ids(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::token_ids) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_pair_ids(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::pair_ids) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_context(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::context) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_quote(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::quote) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_plan(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::plan) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_normalize_account_rpc(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::normalize_account_rpc) +} + +/// Releases a string returned by an `amm_*` operation. +/// +/// # Safety +/// `value` must be null or a pointer returned by this library that has not been freed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn amm_free(value: *mut c_char) { + if value.is_null() { + return; + } + // SAFETY: The caller contract requires a pointer produced by CString::into_raw above. + drop(unsafe { CString::from_raw(value) }); +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + + use super::*; + + #[test] + fn malformed_json_uses_boundary_failure_envelope() { + let request = CString::new("{").unwrap(); + let response = amm_config_id(request.as_ptr()); + assert!(!response.is_null()); + // SAFETY: response was returned by amm_config_id and remains live until amm_free. + let text = unsafe { CStr::from_ptr(response) }.to_str().unwrap(); + let value: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!(value["ok"], false); + // SAFETY: response was allocated by this library and has not been freed. + unsafe { amm_free(response) }; + } +} diff --git a/apps/amm/client/src/lib.rs b/apps/amm/client/src/lib.rs new file mode 100644 index 00000000..9b407f7b --- /dev/null +++ b/apps/amm/client/src/lib.rs @@ -0,0 +1,12 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +mod account; +mod ffi; + +pub mod api; + +pub use api::{ + config_id, context, pair_ids, plan, quote, token_ids, AccountRead, AmmApiError, AmmResponse, + AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, + PositionRequest, QuoteRequest, TokenIdsRequest, WalletAccount, NEW_POSITION_SCHEMA, +}; diff --git a/apps/amm/client/tests/public_api.rs b/apps/amm/client/tests/public_api.rs new file mode 100644 index 00000000..878f6f69 --- /dev/null +++ b/apps/amm/client/tests/public_api.rs @@ -0,0 +1,13 @@ +use amm_client::{config_id, ConfigIdRequest, NEW_POSITION_SCHEMA}; + +#[test] +fn direct_rust_api_does_not_require_ffi() { + let response = config_id(ConfigIdRequest { + amm_program_id: "0000000000000000000000000000000000000000000000000000000000000000".into(), + }) + .expect("valid program ID should produce a response"); + + assert_eq!(response["status"], "ok"); + assert!(response["configId"].is_string()); + assert_eq!(NEW_POSITION_SCHEMA, "new-position.v2"); +} diff --git a/apps/amm/config/networks.json b/apps/amm/config/networks.json new file mode 100644 index 00000000..75e03eba --- /dev/null +++ b/apps/amm/config/networks.json @@ -0,0 +1,18 @@ +{ + "testnet": { + "checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a", + "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 9bc7175f..da8aca62 100644 --- a/apps/amm/flake.lock +++ b/apps/amm/flake.lock @@ -1,6 +1,22 @@ { "nodes": { "crane": { + "locked": { + "lastModified": 1779041105, + "narHash": "sha256-nnGD2f8OlAZT2i5OfwikJsw+ifWfiA4d6A8BWlgOXV0=", + "owner": "ipetkov", + "repo": "crane", + "rev": "10e6e3cb966f7cfcc789fe5eee7a85f3188ce08b", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "ref": "v0.23.4", + "repo": "crane", + "type": "github" + } + }, + "crane_2": { "locked": { "lastModified": 1780532242, "narHash": "sha256-D+BsdpxmtUwtqGoY0IXPhHgTlmqgcZKCEo1oMyn7ep0=", @@ -2225,7 +2241,7 @@ }, "logos-execution-zone": { "inputs": { - "crane": "crane", + "crane": "crane_2", "logos-blockchain-circuits": "logos-blockchain-circuits", "logos-liblogos": "logos-liblogos_4", "nixpkgs": [ @@ -2238,17 +2254,17 @@ "rust-rapidsnark": "rust-rapidsnark" }, "locked": { - "lastModified": 1782310268, - "narHash": "sha256-tIIIO+V+w/C/KLtKnLIv8O8/GoJ4PzgJSWrlQCXJ2P4=", + "lastModified": 1782428741, + "narHash": "sha256-ltLcysXUdVUXAe25Tl8x7e7ZsTzj1sHlyS3glp97TAo=", "owner": "logos-blockchain", - "repo": "lssa", - "rev": "fb8cbac40e0bda4f152415ff4f181cdc6bca6d4a", + "repo": "logos-execution-zone", + "rev": "e37876a64028a335eb693198a1ed6a0e875ec5b4", "type": "github" }, "original": { "owner": "logos-blockchain", - "repo": "lssa", - "rev": "fb8cbac40e0bda4f152415ff4f181cdc6bca6d4a", + "repo": "logos-execution-zone", + "rev": "e37876a64028a335eb693198a1ed6a0e875ec5b4", "type": "github" } }, @@ -14438,11 +14454,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": { @@ -16410,17 +16426,17 @@ "nix-bundle-lgx": "nix-bundle-lgx_28" }, "locked": { - "lastModified": 1782313954, - "narHash": "sha256-psh5EtcIZh6kzFD4pQDT8bae9JS9eVtk5nPWxR2aGSA=", + "lastModified": 1782741385, + "narHash": "sha256-kN6xb1b/Jvu9Ninaqtmi/C4fx8poisGYbThIBIXwvHw=", "owner": "logos-blockchain", "repo": "logos-execution-zone-module", - "rev": "d2e9400ac06c3cdbfc2405b4f153fff9841a453c", + "rev": "d70225ced646934d2294fd9e8f8b03615c104b80", "type": "github" }, "original": { "owner": "logos-blockchain", "repo": "logos-execution-zone-module", - "rev": "d2e9400ac06c3cdbfc2405b4f153fff9841a453c", + "rev": "d70225ced646934d2294fd9e8f8b03615c104b80", "type": "github" } }, @@ -25408,6 +25424,22 @@ "type": "github" } }, + "nixpkgs_399": { + "locked": { + "lastModified": 1782841183, + "narHash": "sha256-Ndt/5R7UN4rBdhFR1lxHZZZ42cD6vGlnuxC2VvvsKE4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c9bfd86ed684d27e63b0ff9ebb18699f84f27a3b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11-small", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_4": { "locked": { "lastModified": 1759036355, @@ -26762,8 +26794,10 @@ }, "root": { "inputs": { + "crane": "crane", "logos-module-builder": "logos-module-builder", - "logos_execution_zone": "logos_execution_zone" + "logos_execution_zone": "logos_execution_zone", + "nixpkgs": "nixpkgs_399" } }, "rust-overlay": { diff --git a/apps/amm/flake.nix b/apps/amm/flake.nix index 0811f1bb..d5da8922 100644 --- a/apps/amm/flake.nix +++ b/apps/amm/flake.nix @@ -3,18 +3,160 @@ inputs = { logos-module-builder.url = "github:logos-co/logos-module-builder"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11-small"; + crane.url = "github:ipetkov/crane/v0.23.4"; # Core wallet module (the LEZ wallet FFI Qt plugin). The input name must # match the metadata.json `dependencies` entry so the builder can resolve - # it as a module dependency. This rev pins LEZ (lssa) at fb8cbac4, which - # includes the macOS Metal-build fix, so no `--override-input` is needed. - logos_execution_zone.url = "github:logos-blockchain/logos-execution-zone-module?rev=d2e9400ac06c3cdbfc2405b4f153fff9841a453c"; + # it as a module dependency. Public testnet still exposes the v0.2.0-rc6 + # wallet RPC contract. + logos_execution_zone = { + url = "github:logos-blockchain/logos-execution-zone-module?rev=d70225ced646934d2294fd9e8f8b03615c104b80"; + inputs.logos-execution-zone.url = + "github:logos-blockchain/logos-execution-zone?rev=e37876a64028a335eb693198a1ed6a0e875ec5b4"; + }; }; - outputs = inputs@{ logos-module-builder, ... }: - logos-module-builder.lib.mkLogosQmlModule { - src = ./.; - configFile = ./metadata.json; - flakeInputs = inputs; + outputs = inputs@{ + logos-module-builder, + logos_execution_zone, + nixpkgs, + crane, + ... + }: + let + # Preserve the macOS Metal workaround without moving the wallet past the + # RPC contract currently deployed on public testnet. Crane builds the + # dependency artifacts separately, so both derivations need the setting. + withXcrunNoCache = package: + package.overrideAttrs (previous: + { + xcrun_nocache = "1"; + } + // (if previous ? cargoArtifacts && previous.cargoArtifacts ? overrideAttrs + then { + cargoArtifacts = previous.cargoArtifacts.overrideAttrs (_: { + xcrun_nocache = "1"; + }); + } + else { })); + + executionZone = logos_execution_zone.inputs.logos-execution-zone; + testnetExecutionZone = executionZone // { + packages = builtins.mapAttrs + (system: packages: + if builtins.match ".*-darwin" system == null then packages + else + let + wallet = withXcrunNoCache packages.wallet; + in + packages // { + inherit wallet; + default = wallet; + }) + executionZone.packages; + }; + + # Evaluate the pinned module source with the testnet-compatible wallet. + # This is ordinary flake composition, not a relative flake input, keeping + # Nix 2.25 lock and build behavior portable. + logosExecutionZoneModule = + (import "${logos_execution_zone.outPath}/flake.nix").outputs { + logos-module-builder = logos_execution_zone.inputs.logos-module-builder; + logos-execution-zone = testnetExecutionZone; + nix-bundle-lgx = logos_execution_zone.inputs.nix-bundle-lgx; + }; + + # Name must match metadata.json so the builder resolves the core module. + moduleInputs = inputs // { + logos_execution_zone = logosExecutionZoneModule; + }; + + ammClientInput = (import ../../flake.nix).outputs { + inherit nixpkgs crane; + }; + moduleBuild = logos-module-builder.lib.mkLogosQmlModule { + src = ./.; + configFile = ./metadata.json; + flakeInputs = moduleInputs; + preConfigure = '' + cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${../shared/wallet}") + ''; + externalLibInputs = { + amm_client = ammClientInput; + }; + postInstall = '' + # The builder installs the view under lib/qml after this hook. Its + # import descriptor points back to this compiled shared QML module. + test -f ${./qml}/Logos/Wallet/qmldir + + walletQmlDir="shared-wallet/qml/Logos/Wallet" + if [ ! -d "$walletQmlDir" ]; then + echo "Built Logos.Wallet QML module not found" + exit 1 + fi + walletQmlInstallDir="$out/lib/Logos/Wallet" + mkdir -p "$walletQmlInstallDir" + cp -r "$walletQmlDir/." "$walletQmlInstallDir/" + test -f "$walletQmlInstallDir/qmldir" + ''; + }; + + publicApps = logos-module-builder.lib.common.forAllSystems nixpkgs ({ system, pkgs }: + { + default = logos-module-builder.lib.mkStandaloneApp { + inherit pkgs; + standalone = + logos-module-builder.inputs.logos-standalone-app.packages.${system}.default; + plugin = moduleBuild.packages.${system}.default; + metadataFile = ./metadata.json; + + # The qt-plugin layout only copies the plugin and replica factory. + # Keep the complete lib tree so sibling external libraries such as + # libamm_client remain available through the plugin's loader path. + format = "qml"; + + moduleDeps = + logos-module-builder.lib.common.collectAllModuleDeps + system moduleInputs moduleBuild.config.dependencies; + }; + } + ); + + publicPackages = builtins.mapAttrs + (system: packages: packages // { + logos_execution_zone-lgx = + logosExecutionZoneModule.packages.${system}.lgx; + logos_execution_zone-lgx-portable = + logosExecutionZoneModule.packages.${system}.lgx-portable; + }) + moduleBuild.packages; + + linuxChecks = + let + pkgs = nixpkgs.legacyPackages.x86_64-linux; + walletModule = logosExecutionZoneModule.packages.x86_64-linux.lib; + in + { + wallet-rpc-contract = pkgs.runCommand "amm-wallet-rpc-contract" { } '' + wallet="${walletModule}/lib/libwallet_ffi.so" + test -f "$wallet" + ${pkgs.binutils}/bin/strings "$wallet" > methods + ${pkgs.gnugrep}/bin/grep -Fq sendTransaction methods + ${pkgs.gnugrep}/bin/grep -Fq getProofForCommitment methods + if ${pkgs.gnugrep}/bin/grep -Fq getProofsAndRoot methods; then + echo "wallet uses unsupported testnet RPC method getProofsAndRoot" + exit 1 + fi + touch "$out" + ''; + }; + in + moduleBuild // { + apps = publicApps; + packages = publicPackages; + checks = (moduleBuild.checks or { }) // { + x86_64-linux = (moduleBuild.checks.x86_64-linux or { }) // linuxChecks; + }; }; } diff --git a/apps/amm/metadata.json b/apps/amm/metadata.json index 72508854..f0e663c9 100644 --- a/apps/amm/metadata.json +++ b/apps/amm/metadata.json @@ -11,10 +11,12 @@ "nix": { "packages": { - "build": [], - "runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"] + "build": ["pkg-config"], + "runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp", "libbase58"] }, - "external_libraries": [], + "external_libraries": [ + { "name": "amm_client" } + ], "cmake": { "find_packages": [], "extra_sources": [], diff --git a/apps/amm/qml/Logos/Wallet/qmldir b/apps/amm/qml/Logos/Wallet/qmldir new file mode 100644 index 00000000..866351b9 --- /dev/null +++ b/apps/amm/qml/Logos/Wallet/qmldir @@ -0,0 +1,5 @@ +module Logos.Wallet +optional plugin logos_wallet_qmlplugin ../../../Logos/Wallet +classname Logos_WalletPlugin +prefer :/qt/qml/Logos/Wallet/ +depends QtQuick diff --git a/apps/amm/qml/Main.qml b/apps/amm/qml/Main.qml index 792f011a..4dbc2058 100644 --- a/apps/amm/qml/Main.qml +++ b/apps/amm/qml/Main.qml @@ -87,6 +87,8 @@ Item { LiquidityPage { anchors.fill: parent + backend: root.ready ? root.backend : null + runtime: logos visible: navbar.currentIndex === 1 } } diff --git a/apps/amm/qml/NavBar.qml b/apps/amm/qml/NavBar.qml index f5b78570..1501f0ac 100644 --- a/apps/amm/qml/NavBar.qml +++ b/apps/amm/qml/NavBar.qml @@ -1,9 +1,6 @@ import QtQuick 2.15 -import QtQuick.Layouts 1.15 - import Logos.Theme - -import "components/wallet" +import Logos.Wallet // Self-contained navigation bar — styling is independent of any view's theme. // Use currentIndex to read the active tab; tabChanged(index) fires on selection. @@ -12,6 +9,7 @@ Item { property int currentIndex: 0 readonly property var tabs: ["Trade", "Liquidity"] + readonly property bool compactLayout: width < 480 // Wallet wiring, passed down from Main.qml. property var backend: null @@ -37,68 +35,80 @@ Item { color: Theme.palette.borderSecondary } - RowLayout { - anchors.fill: parent - anchors.leftMargin: 20 - anchors.rightMargin: 20 - spacing: 4 + // App identity + Text { + id: appTitle + + objectName: "navAppTitle" + anchors.left: parent.left + anchors.leftMargin: 20 + anchors.verticalCenter: parent.verticalCenter + visible: root.width >= 600 + text: "Logos AMM" + color: Theme.palette.text + font.pixelSize: 17 + font.weight: Font.Bold + } - // App identity - Text { - text: "Logos AMM" - color: Theme.palette.text - font.pixelSize: 17 - font.weight: Font.Bold - } + // Tab pills stay centered independently of title and wallet widths. + Row { + anchors.centerIn: parent + spacing: 4 - Item { Layout.fillWidth: true } + Repeater { + model: root.tabs - // Tab pills - Row { - spacing: 4 + delegate: Rectangle { + required property int index + required property string modelData + readonly property bool active: root.currentIndex === index - Repeater { - model: root.tabs + objectName: "navTab" + index + height: 36 + width: tabLabel.implicitWidth + (root.compactLayout ? 16 : 28) + radius: 18 + color: active ? Theme.palette.backgroundSecondary : "transparent" - delegate: Rectangle { - readonly property bool active: root.currentIndex === index + Behavior on color { ColorAnimation { duration: 150 } } - height: 36 - width: tabLabel.implicitWidth + 28 - radius: 18 - color: active ? Theme.palette.backgroundSecondary : "transparent" + Text { + id: tabLabel + anchors.centerIn: parent + text: modelData + color: active ? Theme.palette.text : Theme.palette.textSecondary + font.pixelSize: root.compactLayout ? 13 : 14 + font.weight: active ? Font.Medium : Font.Normal Behavior on color { ColorAnimation { duration: 150 } } + } - Text { - id: tabLabel - anchors.centerIn: parent - text: modelData - color: active ? Theme.palette.text : Theme.palette.textSecondary - font.pixelSize: 14 - font.weight: active ? Font.Medium : Font.Normal - - Behavior on color { ColorAnimation { duration: 150 } } - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { - root.currentIndex = index - root.tabChanged(index) - } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + root.currentIndex = index + root.tabChanged(index) } } } } + } - // Wallet / account control on the far right. - AccountControl { - id: accountControl - Layout.leftMargin: 12 - backend: root.backend - accountModel: root.accountModel + // Wallet / account control on the far right. + WalletControl { + id: accountControl + + anchors.right: parent.right + anchors.rightMargin: root.compactLayout ? 8 : 20 + anchors.verticalCenter: parent.verticalCenter + width: implicitWidth + height: implicitHeight + compact: root.compactLayout + wallet: root.backend + accountModel: root.accountModel + viewportWidth: root.width + watchCall: function(result, success, failure) { + logos.watch(result, success, failure) } } } diff --git a/apps/amm/qml/components/liquidity/AddLiquidityForm.qml b/apps/amm/qml/components/liquidity/AddLiquidityForm.qml deleted file mode 100644 index db8c59b3..00000000 --- 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 00000000..d8f3151d --- /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 00000000..8c578cac --- /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 00000000..f75f3d18 --- /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 00000000..06a847b8 --- /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 00000000..df3919e3 --- /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 00000000..39ccd5d7 --- /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 00000000..859af85c --- /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 new file mode 100644 index 00000000..11309831 --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmountMath.js @@ -0,0 +1,295 @@ +.pragma library + +var base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +var U128_MAX = "340282366920938463463374607431768211455" +var U32_MAX = "4294967295" +var Q64 = "18446744073709551616" + +function normalize(value) { + var text = String(value === undefined || value === null ? "" : value) + var index = 0 + while (index + 1 < text.length && text.charAt(index) === "0") + ++index + return text.length > 0 ? text.slice(index) : "0" +} + +function isUnsigned(value) { + return /^[0-9]+$/.test(String(value)) +} + +function compare(left, right) { + var a = normalize(left) + var b = normalize(right) + if (a.length !== b.length) + return a.length < b.length ? -1 : 1 + if (a === b) + return 0 + return a < b ? -1 : 1 +} + +function compareBase58Ids(left, right) { + var leftStart = 0 + var rightStart = 0 + while (leftStart < left.length && left[leftStart] === "1") + ++leftStart + while (rightStart < right.length && right[rightStart] === "1") + ++rightStart + + var leftLength = left.length - leftStart + var rightLength = right.length - rightStart + if (leftLength !== rightLength) + return leftLength < rightLength ? -1 : 1 + + for (var i = 0; i < leftLength; ++i) { + var leftDigit = base58Alphabet.indexOf(left[leftStart + i]) + var rightDigit = base58Alphabet.indexOf(right[rightStart + i]) + if (leftDigit < 0 || rightDigit < 0) + return 0 + if (leftDigit !== rightDigit) + return leftDigit < rightDigit ? -1 : 1 + } + return 0 +} + +function subtract(left, right) { + var a = normalize(left) + var b = normalize(right) + var result = [] + var borrow = 0 + var j = b.length - 1 + for (var i = a.length - 1; i >= 0; --i) { + var digit = Number(a.charAt(i)) - borrow - (j >= 0 ? Number(b.charAt(j)) : 0) + if (digit < 0) { + digit += 10 + borrow = 1 + } else { + borrow = 0 + } + result.unshift(String(digit)) + --j + } + return normalize(result.join("")) +} + +function multiply(left, right) { + var a = normalize(left) + var b = normalize(right) + if (a === "0" || b === "0") + return "0" + + var result = [] + for (var z = 0; z < a.length + b.length; ++z) + result.push(0) + + for (var i = a.length - 1; i >= 0; --i) { + for (var j = b.length - 1; j >= 0; --j) { + var low = i + j + 1 + var high = i + j + var product = Number(a.charAt(i)) * Number(b.charAt(j)) + result[low] + result[low] = product % 10 + result[high] += Math.floor(product / 10) + } + } + return normalize(result.join("")) +} + +function divide(left, right) { + var numerator = normalize(left) + var denominator = normalize(right) + if (denominator === "0") + return { "quotient": "0", "remainder": numerator, "valid": false } + + var quotient = "" + var remainder = "0" + for (var i = 0; i < numerator.length; ++i) { + remainder = normalize((remainder === "0" ? "" : remainder) + numerator.charAt(i)) + var digit = 0 + while (compare(remainder, denominator) >= 0) { + remainder = subtract(remainder, denominator) + ++digit + } + quotient += String(digit) + } + return { "quotient": normalize(quotient), "remainder": remainder, "valid": true } +} + +function increment(value) { + var digits = normalize(value).split("") + 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" + } + digits.unshift("1") + return digits.join("") +} + +function pow10(exponent) { + var value = "1" + for (var i = 0; i < exponent; ++i) + value += "0" + return value +} + +function parseHuman(text, decimals) { + var value = String(text) + if (value.length === 0) + return { "ok": false, "code": "amount_required", "raw": "" } + var match = /^(0|[1-9][0-9]*)(?:\.([0-9]*))?$/.exec(value) + if (!match) + return { "ok": false, "code": "invalid_amount_format", "raw": "" } + + var fraction = match[2] || "" + if (fraction.length > decimals) + return { "ok": false, "code": "invalid_amount_precision", "raw": "" } + var raw = normalize(match[1] + fraction + pow10(decimals - fraction.length).slice(1)) + if (compare(raw, U128_MAX) > 0) + return { "ok": false, "code": "invalid_raw_amount", "raw": "" } + return { "ok": true, "code": "", "raw": raw } +} + +function formatRaw(rawValue, decimals) { + if (!isUnsigned(rawValue)) + return "" + var raw = normalize(rawValue) + if (decimals === 0) + return raw + while (raw.length <= decimals) + raw = "0" + raw + var whole = raw.slice(0, raw.length - decimals) + var fraction = raw.slice(raw.length - decimals).replace(/0+$/, "") + return fraction.length > 0 ? whole + "." + fraction : whole +} + +function parsePrice(text) { + var value = String(text) + if (value.length === 0) + return { "ok": false, "code": "amount_required" } + var match = /^(0|[1-9][0-9]*)(?:\.([0-9]*))?$/.exec(value) + if (!match) + return { "ok": false, "code": "invalid_amount_format" } + var fraction = match[2] || "" + var numerator = normalize(match[1] + fraction) + if (numerator === "0") + return { "ok": false, "code": "amount_must_be_positive" } + return { + "ok": true, + "code": "", + "numerator": numerator, + "scale": fraction.length + } +} + +function 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(ratio.numerator, Q64), pow10(canonicalDecimalsB)) + denominator = multiply(ratio.denominator, pow10(canonicalDecimalsA)) + } else { + numerator = multiply(multiply(ratio.denominator, Q64), pow10(canonicalDecimalsB)) + denominator = multiply(ratio.numerator, pow10(canonicalDecimalsA)) + } + var raw = divide(numerator, denominator).quotient + if (raw === "0") + return { "ok": false, "code": "amount_too_low", "raw": "" } + if (compare(raw, U128_MAX) > 0) + return { "ok": false, "code": "invalid_raw_amount", "raw": "" } + return { "ok": true, "code": "", "raw": raw } +} + +function 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) + return "" + var fraction = "" + var remainder = result.remainder + for (var i = 0; i < precision && remainder !== "0"; ++i) { + var digit = divide(multiply(remainder, "10"), denominator) + fraction += digit.quotient + remainder = digit.remainder + } + fraction = fraction.replace(/0+$/, "") + return fraction.length > 0 ? result.quotient + "." + fraction : result.quotient +} + +function priceFromQ64(rawValue, canonicalDecimalsA, canonicalDecimalsB, displayIsCanonical) { + if (!isUnsigned(rawValue) || normalize(rawValue) === "0") + return "" + var numerator + var denominator + if (displayIsCanonical) { + numerator = multiply(rawValue, pow10(canonicalDecimalsA)) + denominator = multiply(Q64, pow10(canonicalDecimalsB)) + } else { + numerator = multiply(Q64, pow10(canonicalDecimalsB)) + denominator = multiply(rawValue, pow10(canonicalDecimalsA)) + } + return formatRatio(numerator, denominator, 12) +} + +function mulDivFloor(left, right, denominator) { + return divide(multiply(left, right), denominator).quotient +} + +function toU32(value) { + if (!isUnsigned(value) || compare(value, U32_MAX) > 0) + return -1 + return Number(normalize(value)) +} diff --git a/apps/amm/qml/components/liquidity/LiquidityActionTabs.qml b/apps/amm/qml/components/liquidity/LiquidityActionTabs.qml deleted file mode 100644 index 5a7c1d1f..00000000 --- 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/LiquidityConfirmationDialog.qml b/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml deleted file mode 100644 index 16b0dc66..00000000 --- a/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml +++ /dev/null @@ -1,238 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 - -FocusScope { - id: root - - property var snapshot: ({}) - property bool open: false - readonly property bool isAdd: root.snapshot.action === "add" - - signal canceled - signal confirmed(var snapshot) - - visible: root.open - z: 20 - - Keys.onEscapePressed: root.cancel() - - function openWithSnapshot(nextSnapshot) { - root.snapshot = nextSnapshot; - root.open = true; - root.forceActiveFocus(); - cancelButton.forceActiveFocus(); - } - - function cancel() { - root.open = false; - root.canceled(); - } - - function confirm() { - const confirmedSnapshot = root.snapshot; - root.open = false; - root.confirmed(confirmedSnapshot); - } - - Rectangle { - anchors.fill: parent - color: "#99000000" - - MouseArea { - anchors.fill: parent - } - } - - Rectangle { - id: panel - - anchors.centerIn: parent - color: "#1D1D1D" - implicitHeight: dialogContent.implicitHeight + 24 - radius: 8 - width: Math.max(0, Math.min(360, root.width - 32)) - border.color: "#343434" - border.width: 1 - - ColumnLayout { - id: dialogContent - - anchors.fill: parent - anchors.margins: 12 - spacing: 12 - - Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 16 - text: root.isAdd ? qsTr("Confirm add liquidity") : qsTr("Confirm remove liquidity") - - Layout.fillWidth: true - } - - ColumnLayout { - spacing: 8 - visible: root.isAdd - - Layout.fillWidth: true - - SummaryRow { - label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "") - value: root.snapshot.depositA || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "") - value: root.snapshot.depositB || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Receive at least") - value: root.snapshot.minLpReceived || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Current ratio") - value: root.snapshot.currentRatio || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Fee tier") - value: root.snapshot.feeTier || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Slippage tolerance") - value: root.snapshot.slippageTolerance || "" - - Layout.fillWidth: true - } - } - - ColumnLayout { - spacing: 8 - visible: !root.isAdd - - Layout.fillWidth: true - - SummaryRow { - label: qsTr("Burn LP") - value: qsTr("%1 (%2)").arg(root.snapshot.burnText || "").arg(root.snapshot.burnPercent || "") - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "") - value: root.snapshot.minTokenAReceived || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "") - value: root.snapshot.minTokenBReceived || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Slippage tolerance") - value: root.snapshot.slippageTolerance || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Post-removal share") - value: root.snapshot.postRemovalShare || "" - - Layout.fillWidth: true - } - } - - RowLayout { - spacing: 8 - - Layout.fillWidth: true - - Button { - id: cancelButton - - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Cancel") - - Accessible.name: cancelButton.text - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.cancel() - - contentItem: Text { - color: cancelButton.hovered || cancelButton.activeFocus ? "#151515" : "#E7E1D8" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 13 - horizontalAlignment: Text.AlignHCenter - text: cancelButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: cancelButton.activeFocus ? "#F26A21" : "#343434" - border.width: 1 - color: cancelButton.pressed ? "#343434" : cancelButton.hovered || cancelButton.activeFocus ? "#E7E1D8" : "#151515" - radius: 6 - } - } - - Button { - id: confirmButton - - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Confirm") - - Accessible.name: confirmButton.text - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.confirm() - - contentItem: Text { - color: "#151515" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 13 - horizontalAlignment: Text.AlignHCenter - text: confirmButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: "#F26A21" - border.width: 1 - color: confirmButton.pressed ? "#D95C1E" : confirmButton.hovered || confirmButton.activeFocus ? "#FF8A3D" : "#F26A21" - radius: 6 - } - } - } - } - } -} diff --git a/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml new file mode 100644 index 00000000..7d6287fe --- /dev/null +++ b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml @@ -0,0 +1,141 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ColumnLayout { + id: root + + property var snapshot: ({}) + + signal snapshotEdited(var snapshot) + + spacing: 8 + + function actionText(instruction) { + if (instruction === "NewDefinition") + return qsTr("Create pool") + if (instruction === "AddLiquidity") + return qsTr("Add liquidity") + return instruction || "-" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Pair") + value: root.snapshot.pairText || "-" + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + visible: root.snapshot.instruction === "AddLiquidity" + + Text { + text: qsTr("LP destination") + color: "#a1a1aa" + font.pixelSize: 12 + } + + ComboBox { + id: lpDestinationPicker + + objectName: "lpDestinationPicker" + Layout.fillWidth: true + model: root.destinationRows() + enabled: model.length > 1 + currentIndex: root.destinationIndex() + displayText: currentIndex >= 0 + ? model[currentIndex].label : qsTr("Select destination") + Accessible.name: qsTr("LP token destination") + onActivated: function(index) { + root.selectDestination(model[index]) + } + + delegate: ItemDelegate { + required property var modelData + width: lpDestinationPicker.width + text: modelData.label + } + } + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Action") + value: root.actionText(root.snapshot.instruction) + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Fee") + value: root.snapshot.feeText || "-" + } + + 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("Expected LP") + value: root.snapshot.expectedLpText || "-" + } + + function destinationRows() { + var rows = [] + var options = root.snapshot.lpHoldingOptions || [] + for (var i = 0; i < options.length; ++i) { + var id = String(options[i].holdingId || "") + rows.push({ + "holdingId": id, + "createFresh": false, + "label": qsTr("%1 · balance %2") + .arg(root.shortId(id)) + .arg(String(options[i].balanceRaw || "0")) + }) + } + rows.push({ + "holdingId": "", + "createFresh": true, + "label": qsTr("Create new LP holding") + }) + return rows + } + + function destinationIndex() { + var rows = root.destinationRows() + for (var i = 0; i < rows.length; ++i) { + if (rows[i].createFresh === (root.snapshot.createFreshLp === true) + && (rows[i].createFresh + || rows[i].holdingId === root.snapshot.selectedLpHoldingId)) { + return i + } + } + return -1 + } + + function selectDestination(destination) { + var next = JSON.parse(JSON.stringify(root.snapshot || ({}))) + next.request = next.request || ({}) + delete next.request.lpHoldingId + next.request.createFreshLp = destination.createFresh === true + if (!destination.createFresh) + next.request.lpHoldingId = destination.holdingId + next.selectedLpHoldingId = destination.holdingId + next.createFreshLp = destination.createFresh === true + next.lpDestinationRequired = false + next.quoteReady = false + root.snapshotEdited(next) + } + + function shortId(value) { + return value.length > 14 ? value.slice(0, 7) + "…" + value.slice(-5) : value + } +} diff --git a/apps/amm/qml/components/liquidity/NewPositionForm.qml b/apps/amm/qml/components/liquidity/NewPositionForm.qml new file mode 100644 index 00000000..8282849e --- /dev/null +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -0,0 +1,1660 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Logos.Controls +import Logos.Icons +import Logos.Wallet + +import "AmountMath.js" as AmountMath + +AmmActionCard { + id: root + + theme: fallbackTheme + + AmmTheme { + id: fallbackTheme + } + + property var newPositionContext: ({}) + property var flowState: ({}) + property string selectedTokenAId: "" + property string selectedTokenBId: "" + property string selectedHoldingAId: "" + property string selectedHoldingBId: "" + property int selectedFeeBps: 30 + property int slippageBps: 50 + property string amountA: "" + property string amountB: "" + property string priceAmountA: "1" + property string priceAmountB: "1" + property string minimumAmountARaw: "" + property string minimumAmountBRaw: "" + property var localErrors: [] + property string resolvingTokenId: "" + property string resolvingTokenSide: "" + property string tokenResolutionError: "" + property string tokenResolutionErrorSide: "" + property string tokenResolutionMessage: "" + property string confirmedPoolStatus: "" + property var activePoolQuote: ({}) + property string headingText: qsTr("New position") + property string headingDetail: "" + property bool showRefreshAction: true + + 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 walletCanSubmit: root.flowState.walletCanSubmit === true + readonly property string walletSyncStatus: String( + root.flowState.walletSyncStatus || "closed") + 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": "", + "totalSupplyRaw": "0", + "balanceRaw": "0", + "selectable": false + }) + readonly property var tokens: root.newPositionContext && root.newPositionContext.tokens + ? root.newPositionContext.tokens : [] + readonly property var feeTiers: root.newPositionContext && root.newPositionContext.feeTiers + ? root.newPositionContext.feeTiers : [] + readonly property var tokenA: root.tokenById(root.selectedTokenAId) + readonly property var tokenB: root.tokenById(root.selectedTokenBId) + readonly property var holdingsA: root.tokenHoldings(root.tokenA) + readonly property var holdingsB: root.tokenHoldings(root.tokenB) + readonly property var holdingA: root.holdingById(root.holdingsA, + root.selectedHoldingAId) + readonly property var holdingB: root.holdingById(root.holdingsB, + root.selectedHoldingBId) + readonly property int decimalsA: 0 + readonly property int decimalsB: 0 + readonly property bool displayIsCanonical: root.selectedTokenAId.length > 0 + && AmountMath.compareBase58Ids(root.selectedTokenAId, + root.selectedTokenBId) > 0 + 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 < 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 lpDestinationRequired: root.quotePayload.status === "ok" + && root.quotePayload.lpDestinationRequired === true + && root.onlyLpDestinationBlocks() + readonly property bool canConfirm: root.quotePayload.schema === "new-position.v2" + && root.quotePayload.status === "ok" + && (root.quotePayload.canSubmit === true + || root.lpDestinationRequired) + && root.quoteMatchesPair() + && String(root.quotePayload.quoteHash || "").length > 0 + && !root.contextLoading + && !root.quoteLoading + && !root.quoteStale + && !root.submitting + && !root.poolCreationPending + && root.walletCanSubmit + + signal quoteRequested(bool immediate, var quoteRequest) + signal confirmationRequested(var snapshot) + signal tokenResolveRequested(string tokenId) + signal draftChanged + signal refreshRequested + + readonly property int contentPadding: width >= 600 ? 24 : 16 + + implicitHeight: content.implicitHeight + root.contentPadding * 2 + implicitWidth: 480 + + Component.onCompleted: Qt.callLater(root.reconcileSelection) + onNewPositionContextChanged: Qt.callLater(root.applyContextChange) + function applyContextChange() { + if (root.resolvingToken) + root.finishTokenResolution() + else + root.reconcileSelection() + root.reconcileHoldings() + } + onQuotePayloadChanged: { + if (root.quoteStale) + return + root.rememberPoolStatus() + root.rememberActivePoolQuote() + Qt.callLater(root.applyQuoteSideEffects) + } + + 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: root.contentPadding + spacing: 16 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + ColumnLayout { + Layout.fillWidth: true + spacing: 3 + + Text { + text: root.headingText + color: root.theme.colors.textPrimary + font.pixelSize: 18 + font.weight: Font.DemiBold + font.letterSpacing: 0 + } + + Text { + text: root.headingDetail.length > 0 + ? root.headingDetail : root.contextStatusText() + color: root.theme.colors.textSecondary + font.pixelSize: 12 + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + BusyIndicator { + running: root.contextLoading || root.quoteLoading + visible: running + implicitWidth: 24 + implicitHeight: 24 + } + + LogosIconButton { + iconSource: LogosIcons.refresh + iconColor: root.theme.colors.textSecondary + iconSize: 18 + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + enabled: !root.contextLoading && !root.submitting + visible: root.showRefreshAction + Accessible.name: qsTr("Refresh position data") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + onClicked: root.refreshRequested() + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: networkMessage.implicitHeight + 20 + radius: 6 + color: root.theme.colors.panelBg + border.color: root.theme.colors.error + visible: root.contextBlocksForm() + + Text { + id: networkMessage + anchors.fill: parent + anchors.margins: 10 + text: root.contextErrorText() + color: root.theme.colors.textPrimary + font.pixelSize: 12 + wrapMode: Text.Wrap + verticalAlignment: Text.AlignVCenter + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + TokenAmountInput { + id: tokenAInput + + objectName: "tokenAAmountInput" + Layout.fillWidth: true + theme: root.theme + text: root.amountA + label: qsTr("Token A amount") + balance: root.contextLoading ? "" : root.holdingBalanceText(root.holdingA, + 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 + holdings: root.holdingsA + selectedHoldingId: root.selectedHoldingAId + holdingSelectionEnabled: !root.submitting + 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) + } + 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) } + onHoldingSelected: function(holdingId) { + root.selectHolding("A", holdingId) + } + } + + AmmPairSeparator { + Layout.fillWidth: true + theme: root.theme + enabled: root.hasPair && !root.submitting + onClicked: root.swapTokens() + } + + TokenAmountInput { + id: tokenBInput + + objectName: "tokenBAmountInput" + Layout.fillWidth: true + theme: root.theme + text: root.amountB + label: qsTr("Token B amount") + balance: root.contextLoading ? "" : root.holdingBalanceText(root.holdingB, + 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 + holdings: root.holdingsB + selectedHoldingId: root.selectedHoldingBId + holdingSelectionEnabled: !root.submitting + 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) + } + 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) } + onHoldingSelected: function(holdingId) { + root.selectHolding("B", holdingId) + } + } + } + + Text { + Layout.fillWidth: true + 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 + } + + 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: root.theme.colors.divider + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 8 + visible: !root.contextLoading + + Text { + text: qsTr("Fee tier") + color: root.theme.colors.textPrimary + font.pixelSize: 13 + font.weight: Font.Medium + } + + GridLayout { + Layout.fillWidth: true + columns: root.compact ? 2 : 4 + columnSpacing: 8 + rowSpacing: 8 + + Repeater { + model: root.feeTiers + + Item { + 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 + checked: root.selectedFeeBps === parent.modelData.feeBps + enabled: parent.disabledReason.length === 0 && !root.submitting + onClicked: root.selectFee(parent.modelData.feeBps) + + contentItem: Text { + text: feeTierButton.text + color: feeTierButton.enabled + ? root.theme.colors.textPrimary + : root.theme.colors.textPlaceholder + font.pixelSize: 12 + font.weight: Font.Medium + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 6 + 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 + } + } + + MouseArea { + id: disabledFeeHover + anchors.fill: parent + enabled: parent.disabledReason.length > 0 + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + ToolTip.visible: disabledFeeHover.containsMouse + ToolTip.text: disabledReason + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + visible: root.activePool + + Text { + text: qsTr("Slippage") + color: root.theme.colors.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 root.formatBps(value) } + valueFromText: function(text) { + 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.requestQuote(true) + } + } + + Rectangle { + anchors.fill: parent + visible: root.fieldHasError("slippageBps") + color: "transparent" + radius: 6 + border.color: root.theme.colors.error + border.width: 1 + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 9 + visible: root.quotePayload.status === "ok" + && root.quoteMatchesPair() + && !root.quoteStale + + Rectangle { + Layout.fillWidth: true + implicitHeight: 1 + color: root.theme.colors.divider + } + + LabelValueRow { + label: root.activePool ? qsTr("Expected spend") : qsTr("Opening deposit") + 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) + } + + LabelValueRow { + label: root.activePool ? qsTr("Minimum LP") : qsTr("Locked LP") + value: root.rawLpText(root.activePool + ? root.quotePayload.minimumLpRaw + : root.quotePayload.lockedLpRaw) + } + + LabelValueRow { + label: qsTr("Pool") + value: String(root.quotePayload.poolId || "") + valueWrapAnywhere: true + } + + LogosButton { + id: accountPlanButton + text: qsTr("Account plan (%1)").arg(root.accountPreview().length) + enabled: root.accountPreview().length > 0 + property bool checked: false + implicitWidth: 150 + implicitHeight: 36 + radius: 6 + Layout.alignment: Qt.AlignLeft + onClicked: checked = !checked + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + visible: accountPlanButton.checked + + Repeater { + model: root.accountPreview() + + LabelValueRow { + required property var modelData + label: qsTr("%1. %2 · %3").arg(modelData.order + 1).arg(modelData.role).arg(modelData.action) + value: modelData.accountId ? modelData.accountId : qsTr("Assigned by wallet") + valueWrapAnywhere: true + } + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: warningTextItem.implicitHeight + 20 + radius: 6 + color: root.theme.colors.panelBg + border.color: root.theme.colors.ctaBg + visible: root.warningText().length > 0 + + Text { + id: warningTextItem + anchors.fill: parent + anchors.margins: 10 + text: root.warningText() + color: root.theme.colors.textPrimary + font.pixelSize: 12 + wrapMode: Text.Wrap + verticalAlignment: Text.AlignVCenter + } + } + + SubmittedTransaction { + Layout.fillWidth: true + title: qsTr("Position submitted") + transactionId: root.transactionId + visible: root.transactionId.length > 0 + } + + AmmPrimaryButton { + Layout.fillWidth: true + Layout.minimumHeight: 56 + theme: root.theme + text: root.submitting + ? qsTr("Submitting…") + : root.contextLoading ? qsTr("Loading…") + : !root.walletCanSubmit && root.walletSyncStatus === "syncing" + ? qsTr("Syncing wallet…") + : !root.walletCanSubmit && root.walletSyncStatus === "opening" + ? qsTr("Opening wallet…") + : root.poolCreationPending ? qsTr("Waiting for pool") + : root.missingPool ? qsTr("Create pool") : qsTr("Add liquidity") + enabled: root.canConfirm + onClicked: root.confirmationRequested(root.submissionSnapshot()) + } + } + + 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: root.theme.colors.textSecondary + font.pixelSize: 12 + Layout.fillWidth: true + wrapMode: Text.Wrap + } + + Text { + text: parent.value + color: root.theme.colors.textPrimary + font.pixelSize: 12 + font.weight: Font.Medium + horizontalAlignment: Text.AlignRight + 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) + } + } + } + + function tokenById(tokenId) { + for (var i = 0; i < root.tokens.length; ++i) { + if (root.tokens[i].definitionId === tokenId) + return root.tokens[i] + } + return root.emptyToken + } + + function tokenHoldings(token) { + return token && token.holdings ? token.holdings : [] + } + + function holdingById(holdings, holdingId) { + for (var i = 0; i < holdings.length; ++i) { + if (String(holdings[i].holdingId || "") === holdingId) + return holdings[i] + } + return ({}) + } + + function reconcileHoldings() { + root.selectedHoldingAId = root.reconciledHoldingId( + root.holdingsA, root.selectedHoldingAId) + root.selectedHoldingBId = root.reconciledHoldingId( + root.holdingsB, root.selectedHoldingBId) + } + + function reconciledHoldingId(holdings, selectedId) { + if (holdings.length === 1) + return String(holdings[0].holdingId || "") + return root.holdingById(holdings, selectedId).holdingId ? selectedId : "" + } + + function selectHolding(side, holdingId) { + if (side === "A") + root.selectedHoldingAId = holdingId + else + root.selectedHoldingBId = holdingId + root.noteDraftChanged() + root.requestQuote(true) + } + + function selectableTokenIds() { + var result = [] + for (var i = 0; i < root.tokens.length; ++i) { + if (root.tokens[i].selectable === true) + result.push(root.tokens[i].definitionId) + } + return result + } + + function 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 + } + + root.tokenResolutionMessage = qsTr("%1 - raw supply %2") + .arg(token.name || root.shortId(token.definitionId)) + .arg(AmountMath.formatRaw(token.totalSupplyRaw || "0", 0)) + 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 reconcileSelection() { + 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 (root.selectedTokenAId.length > 0 + && selectable.indexOf(root.selectedTokenAId) < 0) { + root.selectedTokenAId = "" + } + if (root.selectedTokenBId.length > 0 + && selectable.indexOf(root.selectedTokenBId) < 0) { + root.selectedTokenBId = "" + } + if (root.selectedTokenBId === root.selectedTokenAId) + root.selectedTokenBId = "" + 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 + root.selectedTokenAId = tokenId + } else { + if (tokenId === root.selectedTokenAId) + root.selectedTokenAId = root.selectedTokenBId + root.selectedTokenBId = tokenId + } + root.selectedHoldingAId = "" + root.selectedHoldingBId = "" + root.reconcileHoldings() + root.resetPairDraft() + } + + function swapTokens() { + var tokenId = root.selectedTokenAId + root.selectedTokenAId = root.selectedTokenBId + root.selectedTokenBId = tokenId + var holdingId = root.selectedHoldingAId + root.selectedHoldingAId = root.selectedHoldingBId + root.selectedHoldingBId = holdingId + var amount = root.amountA + root.amountA = root.amountB + root.amountB = amount + 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.requestQuote(true) + } + + function resetPairDraft() { + root.confirmedPoolStatus = "" + root.activePoolQuote = ({}) + root.amountA = "" + root.amountB = "" + root.priceAmountA = "1" + root.priceAmountB = "1" + root.minimumAmountARaw = "" + root.minimumAmountBRaw = "" + root.localErrors = [] + root.noteDraftChanged() + root.requestQuote(true) + } + + function acceptPoolActivation(quote) { + if (!quote || quote.schema !== "new-position.v2" + || 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.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 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 = root.feeBpsFromQuote(root.quotePayload) + if (direct > 0) + return direct + 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) + return value + } + 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.requestQuote(true) + } + + function feeDisabledReason(tier) { + if (tier.enabled === false) + return tier.disabledReason || qsTr("This fee tier is unavailable.") + if (root.poolFeeBps > 0 && Number(tier.feeBps) !== root.poolFeeBps) + return qsTr("Existing pool uses %1. Fee tier is fixed for this pair.") + .arg(root.feeLabel(root.poolFeeBps)) + return "" + } + + function feeLabel(feeBps) { + if (feeBps === 1) + return "0.01%" + if (feeBps === 5) + return "0.05%" + if (feeBps === 30) + return "0.30%" + if (feeBps === 100) + return "1.00%" + return root.formatBps(feeBps) + } + + function buildQuoteRequest() { + var errors = [] + if (!root.hasPair) { + errors.push(root.localIssue("token_pair_required", ["tokenAId", "tokenBId"])) + root.localErrors = errors + return { "ok": false, "errors": errors, "request": ({}) } + } + + var request = root.pairRequest() + if (root.holdingsA.length > 0 && root.selectedHoldingAId.length === 0) + errors.push(root.localIssue("holding_selection_required", ["holdingAId"])) + if (root.holdingsB.length > 0 && root.selectedHoldingBId.length === 0) + errors.push(root.localIssue("holding_selection_required", ["holdingBId"])) + + if (root.activePool) { + var parsedA = AmountMath.parseHuman(root.amountA, root.decimalsA) + var parsedB = AmountMath.parseHuman(root.amountB, root.decimalsB) + if (!parsedA.ok) + errors.push(root.localIssue(parsedA.code, ["amountA"])) + if (!parsedB.ok) + errors.push(root.localIssue(parsedB.code, ["amountB"])) + if (errors.length === 0) { + request.maxAmountARaw = root.displayIsCanonical ? parsedA.raw : parsedB.raw + request.maxAmountBRaw = root.displayIsCanonical ? parsedB.raw : parsedA.raw + request.slippageBps = root.slippageBps + } + } else { + var 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"])) + 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) + var probeB = root.probeRaw(root.tokenB, root.decimalsB) + request.maxAmountARaw = root.displayIsCanonical ? probeA : probeB + request.maxAmountBRaw = root.displayIsCanonical ? probeB : probeA + request.slippageBps = root.slippageBps + } + } + + root.localErrors = errors + return { "ok": errors.length === 0, "errors": errors, "request": request } + } + + function pairRequest() { + var request = { + "schema": "new-position.v2", + "tokenAId": root.displayIsCanonical + ? root.selectedTokenAId : root.selectedTokenBId, + "tokenBId": root.displayIsCanonical + ? root.selectedTokenBId : root.selectedTokenAId, + "feeBps": root.selectedFeeBps + } + var holdingAId = root.displayIsCanonical + ? root.selectedHoldingAId : root.selectedHoldingBId + var holdingBId = root.displayIsCanonical + ? root.selectedHoldingBId : root.selectedHoldingAId + if (holdingAId.length > 0) + request.holdingAId = holdingAId + if (holdingBId.length > 0) + request.holdingBId = holdingBId + return request + } + + function requestQuote(immediate) { + if (root.activePool && root.amountA.length === 0 && root.amountB.length === 0) { + root.localErrors = [] + return + } + root.quoteRequested(immediate, root.buildQuoteRequest()) + } + + function probeRaw(token, decimals) { + var holdings = root.tokenHoldings(token) + var selectedId = token.definitionId === root.tokenA.definitionId + ? root.selectedHoldingAId : root.selectedHoldingBId + var selected = root.holdingById(holdings, selectedId) + var balance = String(selected.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) { + return { "code": code, "blockingFields": fields, "details": ({}) } + } + + 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" + return field + } + + function fieldError(field) { + 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 || [] + for (var f = 0; f < fields.length; ++f) { + if (root.canonicalFieldToDisplay(fields[f]) === field) + return root.issueText(collections[c][i].code) + } + } + } + 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."), + "amount_must_be_positive": qsTr("Value must be greater than zero."), + "invalid_amount_format": qsTr("Use plain dot-decimal format."), + "invalid_amount_precision": qsTr("Token amounts must use whole raw units."), + "invalid_raw_amount": qsTr("Value is outside the supported range."), + "amount_exceeds_balance": qsTr("Amount exceeds the selected holding balance."), + "holding_selection_required": qsTr("Select a wallet holding for this token."), + "invalid_holding_selection": qsTr("Selected wallet holding is unavailable."), + "lp_destination_required": qsTr("Select where LP tokens should be deposited."), + "amount_too_low": qsTr("Value is too low for this pool."), + "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 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."), + "wallet_syncing": qsTr("Wallet is still syncing. Review the quote while it finishes."), + "wallet_submission_failed": qsTr("Wallet submission failed. Review and retry manually."), + "submission_status_unknown": qsTr("Connection was lost before submission status was known. Check wallet activity before retrying."), + "signature_rejected": qsTr("Wallet approval was rejected."), + "quote_changed": qsTr("Pool or wallet state changed. Review the refreshed quote."), + "quote_not_submittable": qsTr("Current quote cannot be submitted."), + "submit_in_progress": qsTr("A submission is already in progress."), + "transaction_deadline_expired": qsTr("Wallet approval expired. Retry to create a fresh request."), + "high_slippage": qsTr("High slippage tolerance."), + "token_definition_unreadable": qsTr("A token definition could not be read."), + "token_program_mismatch": qsTr("Token belongs to a different TokenProgram."), + "token_not_fungible": qsTr("Token is not a public fungible token."), + "backend_error": qsTr("Position backend failed. Refresh and retry."), + "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."), + "sequencer_config_required": qsTr("Sequencer endpoint is missing from wallet_config.json."), + "account_read_failed": qsTr("Required on-chain state could not be read."), + "pool_unavailable": qsTr("Pool state is unavailable."), + "config_unavailable": qsTr("AMM configuration is unavailable."), + "same_token_pair": qsTr("Select two different tokens."), + "token_pair_required": qsTr("Select two tokens.") + } + return messages[code] || qsTr("Position quote is unavailable (%1).").arg(code || "unknown") + } + + function editActiveAmount(side, value) { + if (side === "A") + root.amountA = value + else + root.amountB = value + + root.localErrors = [] + root.noteDraftChanged() + } + + function finishActiveAmount(side, value) { + var decimals = side === "A" ? root.decimalsA : root.decimalsB + 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") { + var rawB = AmountMath.mulDivFloor(parsed.raw, reserveB, reserveA) + root.amountB = AmountMath.formatRaw(rawB, root.decimalsB) + } else { + var rawA = AmountMath.mulDivFloor(parsed.raw, reserveA, reserveB) + root.amountA = AmountMath.formatRaw(rawA, root.decimalsA) + } + } + root.requestQuote(true) + } + + function useMaximum() { + var reserveA = root.poolReserve("A") + var reserveB = root.poolReserve("B") + if (!reserveA || !reserveB || reserveA === "0" || reserveB === "0") + return + var balanceA = String(root.holdingA.balanceRaw || "0") + var balanceB = String(root.holdingB.balanceRaw || "0") + var fitA = AmountMath.mulDivFloor(balanceB, reserveA, reserveB) + var rawA = AmountMath.compare(balanceA, fitA) < 0 ? balanceA : fitA + var rawB = AmountMath.mulDivFloor(rawA, reserveB, reserveA) + root.amountA = AmountMath.formatRaw(rawA, root.decimalsA) + root.amountB = AmountMath.formatRaw(rawB, root.decimalsB) + root.noteDraftChanged() + root.requestQuote(true) + } + + 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 + 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.requestQuote(true) + } + + function applyQuoteSideEffects() { + if (root.quoteStale) + return + if (root.poolFeeBps > 0 && root.selectedFeeBps !== root.poolFeeBps) { + root.selectedFeeBps = root.poolFeeBps + root.localErrors = [] + root.quoteRequested(true, { + "ok": true, + "errors": [], + "request": root.poolProbeRequest(root.pairRequest()) + }) + return + } + + if (root.quotePayload.status !== "ok") + 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) + } + } + } + + 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(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) { + var token = side === "A" ? root.tokenA : root.tokenB + var decimals = side === "A" ? root.decimalsA : root.decimalsB + var raw = root.displayRaw(canonicalAField, canonicalBField, side) + return raw.length > 0 + ? qsTr("%1 %2").arg(AmountMath.formatRaw(raw, decimals)).arg(root.shortTokenName(token)) + : "—" + } + + function depositSummary() { + var amountA = root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A") + var amountB = root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "B") + 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() { + 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) + } + + function activePriceText() { + var price = root.activePriceValue() + if (price.length === 0) + return "" + return qsTr("1 %1 = %2 %3") + .arg(root.shortTokenName(root.tokenA)) + .arg(price) + .arg(root.shortTokenName(root.tokenB)) + } + + function accountPreview() { + return !root.quoteStale && root.quoteMatchesPair() + ? root.quotePayload.accountPreview || [] : [] + } + + function quoteError() { + if (root.quoteLoading || root.quoteStale) + return "" + if (root.quotePayload.status === "error") + return root.issueText(root.quotePayload.code) + return "" + } + + function warningText() { + var warnings = !root.quoteStale && root.quoteMatchesPair() + ? root.quotePayload.warnings || [] : [] + if (warnings.length === 0) + warnings = root.newPositionContext.warnings || [] + return warnings.length > 0 ? root.issueText(warnings[0].code) : "" + } + + function submissionSnapshot() { + var built = root.buildQuoteRequest() + var poolProbe = root.poolProbeRequest(built.request) + poolProbe.poolId = String(root.quotePayload.poolId || "") + return { + "request": built.request, + "poolProbeRequest": poolProbe, + "quoteHash": String(root.quotePayload.quoteHash || ""), + "pairText": qsTr("%1 / %2").arg(root.shortTokenName(root.tokenA)).arg(root.shortTokenName(root.tokenB)), + "feeText": root.feeLabel(root.selectedFeeBps), + "depositAText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A"), + "depositBText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "B"), + "expectedLpText": root.rawLpText(root.quotePayload.expectedLpRaw), + "instruction": String(root.quotePayload.instruction || ""), + "lpHoldingOptions": root.quotePayload.lpHoldingOptions || [], + "selectedLpHoldingId": String(root.quotePayload.selectedLpHoldingId || ""), + "createFreshLp": root.quotePayload.requiresFreshLp === true + && root.quotePayload.lpDestinationRequired !== true, + "lpDestinationRequired": root.quotePayload.lpDestinationRequired === true, + "quoteReady": root.quotePayload.canSubmit === true + } + } + + function onlyLpDestinationBlocks() { + var errors = root.quotePayload.errors || [] + if (errors.length === 0) + return false + for (var i = 0; i < errors.length; ++i) { + if (errors[i].code !== "lp_destination_required") + return false + } + return true + } + + function shortTokenName(token) { + if (token && token.name && token.name.length > 0) + return token.name + return token && token.definitionId ? root.shortId(token.definitionId) : "—" + } + + function balanceText(token, decimals) { + return AmountMath.formatRaw(String(token.balanceRaw || "0"), decimals) + } + + function holdingBalanceText(holding, decimals) { + if (!holding || holding.balanceRaw === undefined) + return "" + return AmountMath.formatRaw(String(holding.balanceRaw || "0"), decimals) + } + + function tokenBalanceDetail(token) { + return qsTr("Available %1").arg(root.balanceText(token, 0)) + } + + function shortId(value) { + var text = String(value || "") + return text.length > 14 ? text.slice(0, 7) + "…" + text.slice(-5) : text + } + + 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 "" + } + 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() { + var network = String(root.newPositionContext.networkId || "") + if (root.newPositionContext.status === "no_wallet") + return qsTr("%1 · simulation only").arg(network || qsTr("Wallet disconnected")) + if (root.newPositionContext.status === "ready") + return qsTr("%1 · wallet ready").arg(network) + return network.length > 0 ? network : qsTr("Loading network") + } + + function contextBlocksForm() { + var status = String(root.newPositionContext.status || "") + return status !== "" && status !== "ready" && status !== "no_wallet" && status !== "loading" + } + + function contextErrorText() { + return root.issueText(root.newPositionContext.code || root.newPositionContext.status) + } +} diff --git a/apps/amm/qml/components/liquidity/PoolPositionSummary.qml b/apps/amm/qml/components/liquidity/PoolPositionSummary.qml deleted file mode 100644 index 2466ae7a..00000000 --- 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 b916a744..00000000 --- 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 0c340465..849ecb62 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 120be579..1e472685 100644 --- a/apps/amm/qml/components/liquidity/TokenAmountInput.qml +++ b/apps/amm/qml/components/liquidity/TokenAmountInput.qml @@ -1,156 +1,187 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 +pragma ComponentBehavior: Bound -Rectangle { +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +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 string token: "" + property bool showMaxButton: true + property var tokenData: null + property var tokens: [] + property string selectedTokenId: "" + property bool tokenInvalid: false + property bool tokenSelectionEnabled: true + property var holdings: [] + property string selectedHoldingId: "" + property bool holdingSelectionEnabled: 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) + signal holdingSelected(string holdingId) + + amount: root.text + supportingText: root.helperText + supportingActionText: root.showMaxButton ? qsTr("MAX") : "" + accessory: tokenActions + accessoryWidth: width < 360 ? 132 : 180 + accessoryHeight: root.holdings.length > 1 ? 88 + : root.balance.length > 0 ? 58 : 40 + + 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 + } + } - color: "#151515" - implicitHeight: content.implicitHeight + 20 - radius: 8 - border.color: root.errorText.length > 0 ? "#D85F4B" : amountField.activeFocus ? "#F26A21" : "#343434" - border.width: 1 - - Accessible.name: root.label - Accessible.role: Accessible.EditableText - - ColumnLayout { - id: content - - anchors.fill: parent - anchors.margins: 10 - spacing: 8 + Timer { + id: commitTimer - RowLayout { - spacing: 8 + interval: 250 + repeat: false + onTriggered: root.commitPendingEdit() + } - Layout.fillWidth: true + Component { + id: tokenActions - Text { - color: "#A9A098" - elide: Text.ElideRight - font.pixelSize: 12 - text: root.label + ColumnLayout { + spacing: 4 + AmmTokenAccessory { Layout.fillWidth: true + 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() } - 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 - placeholderText: qsTr("0") - selectByMouse: true - selectedTextColor: "#151515" - selectionColor: "#F26A21" - validator: RegularExpressionValidator { - regularExpression: /[0-9]*([.][0-9]*)?/ - } - - Accessible.name: root.label + ComboBox { + id: holdingPicker + objectName: "holdingPicker" Layout.fillWidth: true - Layout.minimumHeight: 44 - - onTextEdited: root.editingChanged(text) - - background: Rectangle { - border.color: amountField.activeFocus ? "#F26A21" : "#343434" - border.width: 1 - color: amountField.activeFocus ? "#1F1B18" : "#101010" - radius: 6 + visible: root.holdings.length > 1 + enabled: root.holdingSelectionEnabled && root.holdings.length > 1 + model: root.holdings + currentIndex: root.holdingIndex() + displayText: currentIndex >= 0 + ? root.holdingLabel(root.holdings[currentIndex]) + : qsTr("Select holding") + Accessible.name: qsTr("Wallet holding for %1").arg(root.label) + onActivated: function(index) { + root.holdingSelected(String(root.holdings[index].holdingId || "")) } - } - - Button { - id: maxButton - - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("MAX") - Accessible.name: qsTr("Use maximum %1 balance").arg(root.token) - - Layout.minimumHeight: 44 - Layout.preferredWidth: 58 - - 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 + delegate: ItemDelegate { + required property var modelData + width: holdingPicker.width + text: root.holdingLabel(modelData) } } } + } - RowLayout { - spacing: 8 + 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) } + } - Layout.fillWidth: true + function acceptInput(value) { + tokenModal.acceptInput(value) + } - 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 + function commitPendingEdit() { + if (!root.editPending) + return + commitTimer.stop() + root.editPending = false + root.editingCommitted(root.pendingValue) + } - Layout.fillWidth: true - } + function tokenText(token) { + if (!token) + return qsTr("Select token") + return String(token.symbol || token.name || root.shortId(root.selectedTokenId)) + } - Text { - color: "#A9A098" - elide: Text.ElideRight - font.pixelSize: 11 - horizontalAlignment: Text.AlignRight - text: qsTr("Balance %1").arg(root.balance) + function tokenLetter(token) { + var text = root.tokenText(token) + return token ? String(token.letter || text.charAt(0).toUpperCase()) : "" + } - Layout.alignment: Qt.AlignRight - Layout.maximumWidth: 150 - } + 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 + } + + function holdingIndex() { + for (var i = 0; i < root.holdings.length; ++i) { + if (String(root.holdings[i].holdingId || "") === root.selectedHoldingId) + return i } + return -1 + } + + function holdingLabel(holding) { + if (!holding) + return qsTr("Select holding") + return qsTr("%1 · %2") + .arg(root.shortId(String(holding.holdingId || ""))) + .arg(String(holding.balanceRaw || "0")) } } diff --git a/apps/amm/qml/components/liquidity/TokenSelectorModal.qml b/apps/amm/qml/components/liquidity/TokenSelectorModal.qml new file mode 100644 index 00000000..c07b7253 --- /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/swap/SwapCard.qml b/apps/amm/qml/components/swap/SwapCard.qml index 5a3f6011..1b19f1ed 100644 --- a/apps/amm/qml/components/swap/SwapCard.qml +++ b/apps/amm/qml/components/swap/SwapCard.qml @@ -22,17 +22,28 @@ Rectangle { } signal requestTokenSelect(string side) - signal submitRequested(var snapshot) - - function setToken(side, token) { - if (side === "sell") root.sellToken = token - else root.buyToken = token + signal previewRequested(var snapshot) + + function isSameToken(left, right) { + if (!left || !right) + return false + if (left.address && right.address) + return String(left.address) === String(right.address) + return left === right } - function resetAmounts() { - root.sellInput = "" - root.buyInput = "" - root.editingSide = "sell" + function setToken(side, token) { + if (side === "sell") { + const previousSellToken = root.sellToken + if (root.isSameToken(token, root.buyToken)) + root.buyToken = previousSellToken + root.sellToken = token + } else { + const previousBuyToken = root.buyToken + if (root.isSameToken(token, root.sellToken)) + root.sellToken = previousBuyToken + root.buyToken = token + } } readonly property real sellReserve: sellToken ? (sellToken.reserve || 0) : 0 @@ -58,22 +69,32 @@ Rectangle { readonly property real feeAmount: swapState.feeAmount(parsedSellAmount) readonly property real minReceivedAmount: swapState.minReceived(parsedBuyAmount, slippageTolerancePercent) + readonly property real maxSentAmount: swapState.maxSent(parsedSellAmount, slippageTolerancePercent) readonly property real priceImpactPercent: swapState.priceImpactPercent(parsedSellAmount, parsedBuyAmount, sellReserve, buyReserve) - readonly property string swapMode: editingSide === "buy" ? "swap-exact-output" : "swap-exact-input" - readonly property string swapModeText: editingSide === "buy" ? qsTr("Exact output") : qsTr("Exact input") + readonly property bool exactOutput: editingSide === "buy" + readonly property string swapMode: exactOutput ? "swap-exact-output" : "swap-exact-input" + readonly property string swapModeText: exactOutput ? qsTr("Exact output") : qsTr("Exact input") + readonly property string limitLabel: exactOutput ? qsTr("Max sent") : qsTr("Min received") + readonly property string limitText: exactOutput + ? swapState.formatTokenAmount(maxSentAmount, sellToken ? sellToken.symbol : "") + : swapState.formatTokenAmount(minReceivedAmount, buyToken ? buyToken.symbol : "") + readonly property real sellBalanceRequirement: exactOutput + ? maxSentAmount : parsedSellAmount readonly property bool hasAmount: editingSide === "sell" ? parsedSellInput > 0 : parsedBuyInput > 0 readonly property bool tokensSelected: sellToken !== null && buyToken !== null - readonly property bool insufficientBalance: hasAmount && sellToken !== null && parsedSellAmount > (sellToken.balance || 0) - readonly property bool insufficientLiquidity: hasAmount && buyToken !== null && parsedBuyAmount > (buyToken.reserve || 0) - readonly property bool canSubmit: tokensSelected && hasAmount && parsedSellAmount > 0 && parsedBuyAmount > 0 && !insufficientBalance && !insufficientLiquidity + readonly property bool sameTokenSelected: isSameToken(sellToken, buyToken) + readonly property bool insufficientBalance: hasAmount && sellToken !== null && sellBalanceRequirement > (sellToken.balance || 0) + readonly property bool insufficientLiquidity: hasAmount && buyToken !== null && parsedBuyAmount >= (buyToken.reserve || 0) + readonly property bool canSubmit: tokensSelected && !sameTokenSelected && hasAmount && parsedSellAmount > 0 && parsedBuyAmount > 0 && !insufficientBalance && !insufficientLiquidity readonly property string submitButtonText: { + if (sameTokenSelected) return qsTr("Select different tokens") if (!hasAmount || !tokensSelected) return qsTr("Enter an amount") if (insufficientBalance) return qsTr("Insufficient balance") if (insufficientLiquidity) return qsTr("Insufficient liquidity") - return qsTr("Swap") + return qsTr("Preview swap") } function formatAmountValue(val) { @@ -108,7 +129,8 @@ Rectangle { "buyToken": buyToken ? buyToken.symbol : "", "sellAmount": formatAmountValue(parsedSellAmount), "buyAmount": formatAmountValue(parsedBuyAmount), - "minReceived": formatAmountValue(minReceivedAmount), + "minReceived": exactOutput ? "" : formatAmountValue(minReceivedAmount), + "maxSent": exactOutput ? formatAmountValue(maxSentAmount) : "", "feeAmount": swapState.formatTokenAmount(feeAmount, sellToken ? sellToken.symbol : ""), "priceImpactPercent": swapState.formatPercent(priceImpactPercent), "priceImpactPercentValue": priceImpactPercent, @@ -217,7 +239,8 @@ Rectangle { feeText: swapState.formatTokenAmount(root.feeAmount, root.sellToken ? root.sellToken.symbol : "") priceImpactText: swapState.formatPercent(root.priceImpactPercent) priceImpactPercent: root.priceImpactPercent - minReceivedText: swapState.formatTokenAmount(root.minReceivedAmount, root.buyToken ? root.buyToken.symbol : "") + limitLabel: root.limitLabel + limitText: root.limitText } SlippageToleranceControl { @@ -235,6 +258,7 @@ Rectangle { Rectangle { id: ctaBox + objectName: "swapPreviewButton" Layout.fillWidth: true Layout.topMargin: 8 Layout.bottomMargin: 8 @@ -262,7 +286,7 @@ Rectangle { enabled: root.canSubmit cursorShape: root.canSubmit ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: { - if (root.canSubmit) root.submitRequested(root.buildSnapshot()) + if (root.canSubmit) root.previewRequested(root.buildSnapshot()) } } } diff --git a/apps/amm/qml/components/swap/SwapConfirmationDialog.qml b/apps/amm/qml/components/swap/SwapConfirmationDialog.qml deleted file mode 100644 index 54f61b44..00000000 --- a/apps/amm/qml/components/swap/SwapConfirmationDialog.qml +++ /dev/null @@ -1,225 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 - -FocusScope { - id: root - - property var theme - property var snapshot: ({}) - property bool open: false - - signal canceled - signal confirmed(var snapshot) - - visible: root.open - z: 20 - - Keys.onEscapePressed: root.cancel() - - function openWithSnapshot(nextSnapshot) { - root.snapshot = nextSnapshot; - root.open = true; - root.forceActiveFocus(); - cancelButton.forceActiveFocus(); - } - - function cancel() { - root.open = false; - root.canceled(); - } - - function confirm() { - const confirmedSnapshot = root.snapshot; - root.open = false; - root.confirmed(confirmedSnapshot); - } - - Rectangle { - anchors.fill: parent - color: "#99000000" - - MouseArea { - anchors.fill: parent - } - } - - Rectangle { - id: panel - - anchors.centerIn: parent - color: root.theme.colors.cardBg - implicitHeight: dialogContent.implicitHeight + 32 - radius: 16 - width: Math.max(0, Math.min(380, root.width - 32)) - border.color: root.theme.colors.border - border.width: 1 - - MouseArea { - anchors.fill: parent - } - - ColumnLayout { - id: dialogContent - - anchors.fill: parent - anchors.margins: 16 - spacing: 14 - - Text { - color: root.theme.colors.textPrimary - font.bold: true - font.pixelSize: 17 - text: qsTr("Confirm swap") - Layout.fillWidth: true - } - - ColumnLayout { - spacing: 10 - Layout.fillWidth: true - - Rectangle { - Layout.fillWidth: true - color: root.theme.colors.inputBg - radius: 12 - implicitHeight: payColumn.implicitHeight + 24 - - ColumnLayout { - id: payColumn - anchors.fill: parent - anchors.margins: 12 - spacing: 4 - - Text { - text: qsTr("You pay") - color: root.theme.colors.textSecondary - font.pixelSize: 12 - Layout.fillWidth: true - } - Text { - text: qsTr("%1 %2") - .arg(root.snapshot.sellAmount || "") - .arg(root.snapshot.sellToken || "") - color: root.theme.colors.textPrimary - font.bold: true - font.pixelSize: 18 - elide: Text.ElideRight - Layout.fillWidth: true - } - } - } - - Rectangle { - Layout.fillWidth: true - color: root.theme.colors.inputBg - radius: 12 - implicitHeight: receiveColumn.implicitHeight + 24 - - ColumnLayout { - id: receiveColumn - anchors.fill: parent - anchors.margins: 12 - spacing: 4 - - Text { - text: qsTr("You receive at least") - color: root.theme.colors.textSecondary - font.pixelSize: 12 - Layout.fillWidth: true - } - Text { - text: qsTr("%1 %2") - .arg(root.snapshot.minReceived || "") - .arg(root.snapshot.buyToken || "") - color: root.theme.colors.textPrimary - font.bold: true - font.pixelSize: 18 - elide: Text.ElideRight - Layout.fillWidth: true - } - } - } - } - - SwapSummary { - Layout.fillWidth: true - theme: root.theme - swapModeText: root.snapshot.swapModeText || "" - feeText: root.snapshot.feeAmount || "" - priceImpactText: root.snapshot.priceImpactPercent || "" - priceImpactPercent: Number(root.snapshot.priceImpactPercentValue) || 0 - slippageText: root.snapshot.slippageTolerance || "" - minReceivedText: qsTr("%1 %2") - .arg(root.snapshot.minReceived || "") - .arg(root.snapshot.buyToken || "") - } - - RowLayout { - spacing: 10 - Layout.fillWidth: true - Layout.topMargin: 4 - - Button { - id: cancelButton - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Cancel") - Layout.fillWidth: true - Layout.minimumHeight: 48 - onClicked: root.cancel() - - contentItem: Text { - color: root.theme.colors.textPrimary - elide: Text.ElideRight - font.bold: true - font.pixelSize: 14 - horizontalAlignment: Text.AlignHCenter - text: cancelButton.text - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - border.color: root.theme.colors.borderStrong - border.width: 1 - color: cancelButton.pressed - ? root.theme.colors.panelHoverBg - : cancelButton.hovered || cancelButton.activeFocus - ? root.theme.colors.panelBg - : "transparent" - radius: 14 - } - } - - Button { - id: confirmButton - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Confirm Swap") - Layout.fillWidth: true - Layout.minimumHeight: 48 - onClicked: root.confirm() - - contentItem: Text { - color: "#ffffff" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 14 - horizontalAlignment: Text.AlignHCenter - text: confirmButton.text - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - border.width: 0 - color: confirmButton.pressed - ? "#D95C1E" - : confirmButton.hovered || confirmButton.activeFocus - ? root.theme.colors.ctaHoverBg - : root.theme.colors.ctaBg - radius: 14 - } - } - } - } - } -} diff --git a/apps/amm/qml/components/swap/SwapConfirmationSummary.qml b/apps/amm/qml/components/swap/SwapConfirmationSummary.qml new file mode 100644 index 00000000..1d801b9a --- /dev/null +++ b/apps/amm/qml/components/swap/SwapConfirmationSummary.qml @@ -0,0 +1,103 @@ +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + property var theme + property var snapshot: ({}) + + spacing: 10 + + Rectangle { + Layout.fillWidth: true + color: root.theme.colors.inputBg + radius: 8 + implicitHeight: payColumn.implicitHeight + 24 + + ColumnLayout { + id: payColumn + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + Text { + objectName: "swapPayLabel" + Layout.fillWidth: true + text: root.snapshot.swapMode === "swap-exact-output" + ? qsTr("You pay at most") + : qsTr("You pay") + color: root.theme.colors.textSecondary + font.pixelSize: 12 + } + + Text { + objectName: "swapPayAmount" + Layout.fillWidth: true + text: qsTr("%1 %2") + .arg(root.snapshot.swapMode === "swap-exact-output" + ? root.snapshot.maxSent || "" + : root.snapshot.sellAmount || "") + .arg(root.snapshot.sellToken || "") + color: root.theme.colors.textPrimary + font.bold: true + font.pixelSize: 18 + elide: Text.ElideRight + } + } + } + + Rectangle { + Layout.fillWidth: true + color: root.theme.colors.inputBg + radius: 8 + implicitHeight: receiveColumn.implicitHeight + 24 + + ColumnLayout { + id: receiveColumn + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + Text { + objectName: "swapReceiveLabel" + Layout.fillWidth: true + text: root.snapshot.swapMode === "swap-exact-output" + ? qsTr("You receive") + : qsTr("You receive at least") + color: root.theme.colors.textSecondary + font.pixelSize: 12 + } + + Text { + objectName: "swapReceiveAmount" + Layout.fillWidth: true + text: qsTr("%1 %2") + .arg(root.snapshot.swapMode === "swap-exact-output" + ? root.snapshot.buyAmount || "" + : root.snapshot.minReceived || "") + .arg(root.snapshot.buyToken || "") + color: root.theme.colors.textPrimary + font.bold: true + font.pixelSize: 18 + elide: Text.ElideRight + } + } + } + + SwapSummary { + Layout.fillWidth: true + theme: root.theme + swapModeText: root.snapshot.swapModeText || "" + feeText: root.snapshot.feeAmount || "" + priceImpactText: root.snapshot.priceImpactPercent || "" + priceImpactPercent: Number(root.snapshot.priceImpactPercentValue) || 0 + slippageText: root.snapshot.slippageTolerance || "" + limitLabel: root.snapshot.swapMode === "swap-exact-output" + ? qsTr("Max sent") + : qsTr("Min received") + limitText: root.snapshot.swapMode === "swap-exact-output" + ? qsTr("%1 %2").arg(root.snapshot.maxSent || "").arg(root.snapshot.sellToken || "") + : qsTr("%1 %2").arg(root.snapshot.minReceived || "").arg(root.snapshot.buyToken || "") + } +} diff --git a/apps/amm/qml/components/swap/SwapSummary.qml b/apps/amm/qml/components/swap/SwapSummary.qml index 6ca2285a..584b1a59 100644 --- a/apps/amm/qml/components/swap/SwapSummary.qml +++ b/apps/amm/qml/components/swap/SwapSummary.qml @@ -10,7 +10,8 @@ Item { property string priceImpactText: "" property real priceImpactPercent: 0 property string slippageText: "" - property string minReceivedText: "" + property string limitLabel: qsTr("Min received") + property string limitText: "" readonly property color priceImpactColor: { if (root.priceImpactPercent > 5) return "#F08A76"; @@ -126,20 +127,22 @@ Item { Layout.fillWidth: true Text { + objectName: "swapLimitLabel" anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter color: root.theme.colors.textSecondary font.pixelSize: 12 - text: qsTr("Min received") + text: root.limitLabel } Text { + objectName: "swapLimitValue" anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter color: root.theme.colors.textPrimary font.bold: true font.pixelSize: 12 - text: root.minReceivedText + text: root.limitText } } } diff --git a/apps/amm/qml/components/wallet/AccountControl.qml b/apps/amm/qml/components/wallet/AccountControl.qml deleted file mode 100644 index 9956eebd..00000000 --- a/apps/amm/qml/components/wallet/AccountControl.qml +++ /dev/null @@ -1,490 +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 = "" - } - - 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) console.warn("openExisting failed") }, - function(error) { console.warn("openExisting error:", 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/settings.svg") - onClicked: viewStack.push(settingsView) - } - 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() - } - } - } - - // ── Settings view: back + editable network (sequencer) ──────────── - Component { - id: settingsView - - 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("Settings") - font.bold: true - color: Theme.palette.text - } - } - - LogosText { - text: qsTr("Network (sequencer URL)") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - - LogosTextField { - id: seqField - Layout.fillWidth: true - placeholderText: "http://127.0.0.1:3040" - // Initialize from the live value without binding, so typing - // isn't clobbered when sequencerAddr updates after a save. - Component.onCompleted: text = root.backend ? root.backend.sequencerAddr : "" - } - - LogosText { - id: seqStatus - Layout.fillWidth: true - visible: text.length > 0 - wrapMode: Text.WordWrap - font.pixelSize: Theme.typography.secondaryText - property bool ok: false - color: ok ? Theme.palette.success : Theme.palette.error - } - - LogosButton { - Layout.fillWidth: true - height: 40 - text: qsTr("Save") - onClicked: { - if (!root.backend) return - seqStatus.text = "" - logos.watch(root.backend.changeSequencerAddr(seqField.text), - function(ok) { - seqStatus.ok = ok - seqStatus.text = ok ? qsTr("Network updated.") - : qsTr("Failed to update network.") - }, - function(error) { - seqStatus.ok = false - seqStatus.text = qsTr("Error: %1").arg(error) - }) - } - } - } - } - } - - // ── 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) return - logos.watch(root.backend.createAccountPublic(), - function(_id) { /* model updates via NOTIFY after refresh */ }, - function(error) { console.warn("createAccountPublic failed:", error) }) - } - onCreatePrivateRequested: { - if (!root.backend) return - logos.watch(root.backend.createAccountPrivate(), - function(_id) { /* model updates via NOTIFY after refresh */ }, - function(error) { console.warn("createAccountPrivate failed:", error) }) - } - } -} diff --git a/apps/amm/qml/components/wallet/AccountDelegate.qml b/apps/amm/qml/components/wallet/AccountDelegate.qml deleted file mode 100644 index 14c03ee2..00000000 --- a/apps/amm/qml/components/wallet/AccountDelegate.qml +++ /dev/null @@ -1,84 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// One account row in the account dropdown. Ported from the LEZ wallet UI. -ItemDelegate { - id: root - - // Emitted when the user clicks the copy icon; the parent view connects this - // to its QML-side clipboard helper (AccountControl.copyToClipboard). - signal copyRequested(string text) - - leftPadding: Theme.spacing.medium - rightPadding: Theme.spacing.medium - topPadding: Theme.spacing.medium - bottomPadding: Theme.spacing.medium - - background: Rectangle { - color: root.highlighted || root.hovered ? - Theme.palette.backgroundMuted : - Theme.palette.backgroundTertiary - radius: Theme.spacing.radiusLarge - } - - contentItem: ColumnLayout { - spacing: Theme.spacing.small - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - LogosText { - text: model.name ?? "" - 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: model.isPublic ? qsTr("Public") : qsTr("Private") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - } - - Item { Layout.fillWidth: true } - - LogosText { - text: model.balance && model.balance.length > 0 ? model.balance : "—" - font.bold: true - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 0 - LogosText { - id: addressLabel - Layout.fillWidth: true - verticalAlignment: Text.AlignVCenter - text: model.address ?? "" - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textMuted - elide: Text.ElideMiddle - } - LogosCopyButton { - Layout.preferredHeight: 40 - Layout.preferredWidth: 40 - onCopyText: root.copyRequested(model.address) - visible: addressLabel.text - icon.color: Theme.palette.textMuted - } - } - } -} diff --git a/apps/amm/qml/components/wallet/CreateAccountDialog.qml b/apps/amm/qml/components/wallet/CreateAccountDialog.qml deleted file mode 100644 index 80707958..00000000 --- a/apps/amm/qml/components/wallet/CreateAccountDialog.qml +++ /dev/null @@ -1,102 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// Public/private account creation dialog. Ported from the LEZ wallet UI. -Popup { - id: root - - signal createPublicRequested() - signal createPrivateRequested() - - modal: true - dim: true - padding: Theme.spacing.large - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - - // Center on the full-window overlay rather than the small navbar control - // this popup is declared inside. - parent: Overlay.overlay - anchors.centerIn: parent - width: 360 - - background: Rectangle { - color: Theme.palette.backgroundSecondary - radius: Theme.spacing.radiusXlarge - border.color: Theme.palette.backgroundElevated - } - - contentItem: ColumnLayout { - id: contentLayout - // Pin to the popup's padded width so children stay within the modal. - width: root.availableWidth - spacing: Theme.spacing.large - - LogosText { - text: qsTr("Create account") - font.pixelSize: Theme.typography.titleText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - - LogosText { - text: qsTr("Choose account type.") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - Layout.topMargin: -Theme.spacing.small - } - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.medium - - ColumnLayout { - Layout.fillWidth: true - spacing: 0 - - LogosText { - text: qsTr("Private") - font.pixelSize: Theme.typography.primaryText - color: Theme.palette.text - } - LogosText { - text: qsTr("Private balance and activity.") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - wrapMode: Text.WordWrap - Layout.fillWidth: true - } - } - - LogosSwitch { - id: privateSwitch - checked: false - } - } - - RowLayout { - Layout.topMargin: Theme.spacing.medium - spacing: Theme.spacing.medium - Layout.fillWidth: true - LogosButton { - text: qsTr("Cancel") - Layout.fillWidth: true - onClicked: root.close() - } - LogosButton { - text: qsTr("Create") - Layout.fillWidth: true - onClicked: { - if (privateSwitch.checked) - root.createPrivateRequested() - else - root.createPublicRequested() - root.close() - } - } - } - } -} diff --git a/apps/amm/qml/components/wallet/CreateWalletDialog.qml b/apps/amm/qml/components/wallet/CreateWalletDialog.qml deleted file mode 100644 index 05c8d6e6..00000000 --- a/apps/amm/qml/components/wallet/CreateWalletDialog.qml +++ /dev/null @@ -1,201 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// Wallet creation modal. Two pages, driven by whether a mnemonic exists yet: -// 1. Password entry — emits createWallet(password); the parent creates the -// wallet and, on success, sets `mnemonic` to the returned seed phrase. -// 2. Seed-phrase backup — shows the mnemonic once and gates dismissal behind -// an explicit acknowledgement. This is the only time the phrase is shown, -// so the popup is not auto-dismissable while it is visible. -// Storage/config live at the per-app default (backend.walletHome) — no path -// picking. Opened from the navbar "Connect" button. -Popup { - id: root - - // Where the wallet will be stored, shown for transparency. - property string walletHome: "" - property string createError: "" - // Set by the parent to the BIP39 seed phrase once creation succeeds. A - // non-empty value flips the dialog to the backup page. - property string mnemonic: "" - - signal createWallet(string password) - signal copyRequested(string text) - - modal: true - dim: true - padding: Theme.spacing.large - // Once the wallet exists we must not let the user dismiss the modal (and - // lose the only view of their seed phrase) by clicking away or pressing Esc. - closePolicy: root.mnemonic.length > 0 - ? Popup.NoAutoClose - : (Popup.CloseOnEscape | Popup.CloseOnPressOutside) - // Center on the full-window overlay rather than the small navbar control - // this popup is declared inside. - parent: Overlay.overlay - anchors.centerIn: parent - width: 380 - - onOpened: { - passwordField.text = "" - confirmField.text = "" - root.createError = "" - root.mnemonic = "" - passwordField.forceActiveFocus() - } - - background: Rectangle { - color: Theme.palette.backgroundSecondary - radius: Theme.spacing.radiusXlarge - border.color: Theme.palette.backgroundElevated - } - - contentItem: ColumnLayout { - // Pin to the popup's padded width so long text wraps and fillWidth - // children don't push the layout wider than the modal. - width: root.availableWidth - spacing: 0 - - // ── Page 1: password entry ──────────────────────────────────────── - ColumnLayout { - id: passwordPage - visible: root.mnemonic.length === 0 - Layout.fillWidth: true - spacing: Theme.spacing.large - - LogosText { - text: qsTr("Create your wallet") - font.pixelSize: Theme.typography.titleText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - - LogosText { - text: qsTr("Secure your wallet with a password. It will be stored on this device at %1.") - .arg(root.walletHome || qsTr("the default location")) - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - wrapMode: Text.WordWrap - Layout.fillWidth: true - Layout.topMargin: -Theme.spacing.small - } - - LogosTextField { - id: passwordField - Layout.fillWidth: true - placeholderText: qsTr("Password") - echoMode: TextInput.Password - Keys.onReturnPressed: createButton.tryCreate() - } - LogosTextField { - id: confirmField - Layout.fillWidth: true - placeholderText: qsTr("Confirm password") - echoMode: TextInput.Password - Keys.onReturnPressed: createButton.tryCreate() - } - - LogosText { - Layout.fillWidth: true - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.error - wrapMode: Text.WordWrap - visible: text.length > 0 - text: root.createError - } - - RowLayout { - Layout.topMargin: Theme.spacing.small - Layout.fillWidth: true - spacing: Theme.spacing.medium - LogosButton { - text: qsTr("Cancel") - Layout.fillWidth: true - onClicked: root.close() - } - LogosButton { - id: createButton - Layout.fillWidth: true - text: qsTr("Create Wallet") - function tryCreate() { - if (passwordField.text.length === 0) { - root.createError = qsTr("Password cannot be empty.") - } else if (passwordField.text !== confirmField.text) { - root.createError = qsTr("Passwords do not match.") - } else { - root.createError = "" - root.createWallet(passwordField.text) - } - } - onClicked: tryCreate() - } - } - } - - // ── Page 2: seed-phrase backup ──────────────────────────────────── - ColumnLayout { - id: backupPage - visible: root.mnemonic.length > 0 - Layout.fillWidth: true - spacing: Theme.spacing.large - - LogosText { - text: qsTr("Back up your recovery phrase") - font.pixelSize: Theme.typography.titleText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - - LogosText { - text: qsTr("Write these words down in order and store them somewhere safe. Anyone with this phrase can control your wallet, and it will not be shown again — it is the only way to recover access.") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - wrapMode: Text.WordWrap - Layout.fillWidth: true - Layout.topMargin: -Theme.spacing.small - } - - Rectangle { - Layout.fillWidth: true - radius: Theme.spacing.radiusLarge - color: Theme.palette.backgroundElevated - implicitHeight: phraseText.implicitHeight + 2 * Theme.spacing.medium - - LogosText { - id: phraseText - anchors.fill: parent - anchors.margins: Theme.spacing.medium - text: root.mnemonic - wrapMode: Text.WordWrap - lineHeight: 1.4 - font.pixelSize: Theme.typography.primaryText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - } - - LogosButton { - Layout.fillWidth: true - text: qsTr("Copy to clipboard") - onClicked: root.copyRequested(root.mnemonic) - } - - LogosCheckbox { - id: ackCheck - Layout.fillWidth: true - text: qsTr("I have safely backed up my recovery phrase") - } - - LogosButton { - Layout.fillWidth: true - enabled: ackCheck.checked - text: qsTr("Continue") - onClicked: root.close() - } - } - } -} diff --git a/apps/amm/qml/components/wallet/LogosCopyButton.qml b/apps/amm/qml/components/wallet/LogosCopyButton.qml deleted file mode 100644 index 7c1d7bdc..00000000 --- a/apps/amm/qml/components/wallet/LogosCopyButton.qml +++ /dev/null @@ -1,39 +0,0 @@ -import QtQuick -import QtQuick.Controls - -import Logos.Theme - -Button { - id: root - - signal copyText() - - implicitWidth: 24 - implicitHeight: 24 - display: AbstractButton.IconOnly - flat: true - - property string iconSource: Qt.resolvedUrl("icons/copy.svg") - - icon.source: root.iconSource - icon.width: 24 - icon.height: 24 - icon.color: Theme.palette.textSecondary - - function reset() { - iconSource = Qt.resolvedUrl("icons/copy.svg") - } - - Timer { - id: resetTimer - interval: 1500 - repeat: false - onTriggered: root.reset() - } - - onClicked: { - root.copyText() - root.iconSource = Qt.resolvedUrl("icons/checkmark.svg") - resetTimer.restart() - } -} diff --git a/apps/amm/qml/components/wallet/WalletIconButton.qml b/apps/amm/qml/components/wallet/WalletIconButton.qml deleted file mode 100644 index 61545e0d..00000000 --- a/apps/amm/qml/components/wallet/WalletIconButton.qml +++ /dev/null @@ -1,25 +0,0 @@ -import QtQuick -import QtQuick.Controls - -import Logos.Theme - -// Small icon-only action button for the wallet menu. Uses the same Button + -// icon.source/icon.color path as LogosCopyButton, which renders reliably here -// (LogosIconButton's Image + MultiEffect shader path does not). -Button { - id: root - - property url iconSource - property color iconColor: Theme.palette.textSecondary - property int iconSize: 18 - - implicitWidth: 32 - implicitHeight: 32 - display: AbstractButton.IconOnly - flat: true - - icon.source: root.iconSource - icon.width: root.iconSize - icon.height: root.iconSize - icon.color: root.iconColor -} diff --git a/apps/amm/qml/components/wallet/icons/settings.svg b/apps/amm/qml/components/wallet/icons/settings.svg deleted file mode 100644 index 77f89ae5..00000000 --- a/apps/amm/qml/components/wallet/icons/settings.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index f4719521..511b0f29 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -1,30 +1,43 @@ -import QtQuick 2.15 -import QtQuick.Layouts 1.15 -import "../components/shared" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQml + +import Logos.Controls +import Logos.Icons +import Logos.Wallet + import "../components/liquidity" import "../state" Item { id: root - property int activeLiquidityTab: 0 - property real slippageTolerancePercent: 0.5 - readonly property int pageMargin: 16 - readonly property int preferredCardWidth: 492 - readonly property int pageCardY: pageCard.implicitHeight + root.pageMargin * 2 <= scroll.height ? Math.round((scroll.height - pageCard.implicitHeight) / 2) : root.pageMargin + property var backend: null + property var runtime: null + readonly property NewPositionFlow flow: newPositionFlow + + readonly property int pageMargin: width < 640 ? 16 : 24 + readonly property int contentMaxWidth: 1200 + readonly property bool wideLayout: width >= 760 + readonly property int stepRailWidth: Math.max(210, Math.min(330, + (width - pageMargin * 2) * 0.30)) + + AmmTheme { + id: theme + } - width: parent ? parent.width : implicitWidth - height: parent ? parent.height : implicitHeight - implicitWidth: root.preferredCardWidth + root.pageMargin * 2 - implicitHeight: pageCard.implicitHeight + root.pageMargin * 2 + NewPositionFlow { + id: newPositionFlow - DummyPoolState { - id: poolState + backend: root.backend + runtime: root.runtime + active: root.visible } Rectangle { anchors.fill: parent - color: "#151515" + color: theme.colors.background } Flickable { @@ -32,158 +45,305 @@ Item { anchors.fill: parent clip: true - contentHeight: Math.max(height, pageCard.y + pageCard.implicitHeight + root.pageMargin) contentWidth: width - enabled: !confirmationDialog.visible + contentHeight: Math.max(height, pageLayout.y + pageLayout.implicitHeight + root.pageMargin) + enabled: !confirmationDialog.opened flickableDirection: Flickable.VerticalFlick + boundsBehavior: Flickable.StopAtBounds - Rectangle { - id: pageCard - - color: "#1B1B1B" - implicitHeight: shellContent.implicitHeight + 24 - radius: 16 - border.color: "#303030" - border.width: 1 - width: Math.max(0, Math.min(scroll.width - root.pageMargin * 2, root.preferredCardWidth)) - x: Math.max(root.pageMargin, (scroll.width - width) / 2) - y: root.pageCardY + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } - ColumnLayout { - id: shellContent + ColumnLayout { + id: pageLayout - anchors.fill: parent - anchors.margins: 12 - spacing: 10 + x: Math.max(root.pageMargin, (scroll.width - width) / 2) + y: root.wideLayout ? 32 : 16 + width: Math.max(0, Math.min(root.contentMaxWidth, + scroll.width - root.pageMargin * 2)) + spacing: 24 - RowLayout { - spacing: 10 + RowLayout { + Layout.fillWidth: true + spacing: 16 + ColumnLayout { Layout.fillWidth: true + spacing: 4 Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 18 - text: qsTr("Liquidity") - - Layout.fillWidth: true + text: qsTr("New position") + color: theme.colors.textPrimary + font.pixelSize: 30 + font.weight: Font.Bold + font.letterSpacing: 0 } - Rectangle { - color: "#211914" - radius: 12 - border.color: "#49301F" - border.width: 1 - - Layout.preferredHeight: 26 - Layout.preferredWidth: pairText.implicitWidth + 20 - - Text { - id: pairText - - anchors.centerIn: parent - color: "#F2D8C7" - font.bold: true - font.pixelSize: 12 - text: qsTr("%1 / %2").arg(poolState.tokenA).arg(poolState.tokenB) - } + Text { + Layout.fillWidth: true + text: form.contextStatusText() + color: theme.colors.textSecondary + font.pixelSize: 12 + elide: Text.ElideRight } } - LiquidityActionTabs { - currentIndex: root.activeLiquidityTab - - Layout.fillWidth: true - Layout.preferredHeight: implicitHeight - - onTabRequested: function (index) { - root.activeLiquidityTab = index; - } + LogosIconButton { + objectName: "refreshPositionButton" + iconSource: LogosIcons.refresh + iconColor: theme.colors.textSecondary + iconSize: 18 + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + enabled: !newPositionFlow.contextLoading && !newPositionFlow.submitting + Accessible.name: qsTr("Refresh position data") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + onClicked: newPositionFlow.refreshContext(true) } + } - PoolPositionSummary { - poolState: poolState + Rectangle { + id: compactSteps - Layout.fillWidth: true - Layout.preferredHeight: implicitHeight - } + objectName: "compactPositionSteps" + Layout.fillWidth: true + implicitHeight: compactStepRow.implicitHeight + 32 + visible: !root.wideLayout + radius: 16 + color: theme.colors.cardBg + border.color: theme.colors.border + border.width: 1 - AddLiquidityForm { - id: addLiquidityForm + RowLayout { + id: compactStepRow - poolState: poolState - slippageTolerancePercent: root.slippageTolerancePercent - visible: root.activeLiquidityTab === 0 + anchors.fill: parent + anchors.margins: 16 + spacing: 12 - Layout.fillWidth: true - Layout.preferredHeight: visible ? implicitHeight : 0 + StepMarker { + Layout.fillWidth: true + colors: theme.colors + stepNumber: 1 + label: qsTr("Select pair and fees") + active: !form.hasPair + complete: form.hasPair + } - onSlippageToleranceChangeRequested: function (tolerancePercent) { - root.slippageTolerancePercent = poolState.clampSlippageTolerancePercent(tolerancePercent); + Rectangle { + Layout.preferredWidth: 20 + Layout.preferredHeight: 1 + color: form.hasPair ? theme.colors.ctaBg : theme.colors.divider } - onAddLiquidityRequested: function (snapshot) { - confirmationDialog.openWithSnapshot(snapshot); + StepMarker { + Layout.fillWidth: true + colors: theme.colors + stepNumber: 2 + label: form.missingPool + ? qsTr("Set price and deposit") + : qsTr("Enter deposit amounts") + active: form.hasPair } } + } + + RowLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: root.wideLayout ? 40 : 0 + + Rectangle { + id: positionStepRail + + objectName: "positionStepRail" + Layout.preferredWidth: root.stepRailWidth + Layout.alignment: Qt.AlignTop + implicitHeight: verticalSteps.implicitHeight + 40 + visible: root.wideLayout + radius: 16 + color: theme.colors.cardBg + border.color: theme.colors.border + border.width: 1 + + ColumnLayout { + id: verticalSteps + + anchors.fill: parent + anchors.margins: 20 + spacing: 0 + + StepMarker { + Layout.fillWidth: true + colors: theme.colors + stepNumber: 1 + label: qsTr("Select token pair and fees") + active: !form.hasPair + complete: form.hasPair + } - RemoveLiquidityForm { - id: removeLiquidityForm + Rectangle { + Layout.leftMargin: 17 + Layout.preferredWidth: 1 + Layout.preferredHeight: 28 + color: form.hasPair ? theme.colors.ctaBg : theme.colors.divider + } - poolState: poolState - slippageTolerancePercent: root.slippageTolerancePercent - visible: root.activeLiquidityTab === 1 + StepMarker { + Layout.fillWidth: true + colors: theme.colors + stepNumber: 2 + label: form.missingPool + ? qsTr("Set price and deposit amounts") + : qsTr("Enter deposit amounts") + active: form.hasPair + } + } + } + NewPositionForm { + id: form + + objectName: "newPositionForm" Layout.fillWidth: true - Layout.preferredHeight: visible ? implicitHeight : 0 + Layout.alignment: Qt.AlignTop + theme: theme + headingText: form.hasPair ? qsTr("Deposit tokens") : qsTr("Select pair") + headingDetail: form.hasPair + ? qsTr("Specify the token amounts for your liquidity contribution.") + : qsTr("Choose two tokens and a fee tier for this position.") + showRefreshAction: false + newPositionContext: newPositionFlow.newPositionContext + flowState: newPositionFlow.viewState + + onQuoteRequested: function(immediate, quoteRequest) { + newPositionFlow.scheduleQuote(immediate, quoteRequest) + } - onSlippageToleranceChangeRequested: function (tolerancePercent) { - root.slippageTolerancePercent = poolState.clampSlippageTolerancePercent(tolerancePercent); + onConfirmationRequested: function(snapshot) { + confirmationDialog.openWithSnapshot(snapshot) } - onRemoveLiquidityRequested: function (snapshot) { - confirmationDialog.openWithSnapshot(snapshot); + onTokenResolveRequested: function(tokenId) { + newPositionFlow.resolveToken(tokenId) } + + onDraftChanged: newPositionFlow.draftChanged() + onRefreshRequested: newPositionFlow.refreshContext(true) } } + } + } - SuccessToast { - id: successToast + Connections { + target: newPositionFlow - width: Math.max(0, Math.min(380, parent.width - 24)) + function onTokenResolutionFinished(finalResponse) { + form.finishTokenResolution(finalResponse) + } - anchors { - bottom: parent.bottom - bottomMargin: 14 - horizontalCenter: parent.horizontalCenter - } - } + function onTokenResolutionFailed(code) { + form.failTokenResolution(code) + } + + function onPoolActivated(quote) { + form.acceptPoolActivation(quote) + } + + function onQuoteRefreshRequested(immediate) { + form.requestQuote(immediate) } + + function onConfirmationQuoteReady(snapshot) { + confirmationDialog.updateSnapshot(snapshot) + } + } + + Component { + id: liquidityConfirmationSummary + + LiquidityConfirmationSummary { } } - LiquidityConfirmationDialog { + TransactionConfirmationDialog { id: confirmationDialog - anchors.fill: parent + objectName: "liquidityConfirmationDialog" + title: qsTr("Confirm new position") + confirmText: qsTr("Submit") + busy: newPositionFlow.submitting + || (snapshot.quoteReady === false && newPositionFlow.quoteLoading) + confirmEnabled: snapshot.quoteReady === true + && newPositionFlow.walletCanSubmit + summary: liquidityConfirmationSummary + + onSummaryEdited: function(snapshot) { + newPositionFlow.requoteConfirmation(snapshot) + } - onConfirmed: function (snapshot) { - root.confirmLiquidityAction(snapshot); + onConfirmed: function(snapshot) { + newPositionFlow.confirm(snapshot) } } - function confirmLiquidityAction(snapshot) { - if (snapshot.action === "add") { - poolState.applyAddLiquidity(snapshot.actualA, snapshot.actualB, snapshot.deltaLp); - addLiquidityForm.resetForm(); - successToast.show(qsTr("Liquidity added"), qsTr("Position updated")); - return; + component StepMarker: RowLayout { + id: marker + + required property var colors + required property int stepNumber + required property string label + property bool active: false + property bool complete: false + + spacing: 12 + + Rectangle { + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + radius: 18 + color: marker.active + ? marker.colors.ctaBg + : marker.complete ? marker.colors.selection : marker.colors.inputBg + border.color: marker.active || marker.complete + ? marker.colors.ctaBg : marker.colors.borderStrong + border.width: 1 + Accessible.ignored: true + + Text { + anchors.centerIn: parent + text: marker.stepNumber + color: marker.active + ? marker.colors.background + : marker.complete ? marker.colors.ctaBg : marker.colors.textPlaceholder + font.pixelSize: 13 + font.weight: Font.DemiBold + } } - if (snapshot.action === "remove") { - poolState.applyRemoveLiquidity(snapshot.withdrawA, snapshot.withdrawB, snapshot.burnAmount); - removeLiquidityForm.resetForm(); - successToast.show(qsTr("Liquidity removed"), qsTr("Position updated")); + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + Layout.fillWidth: true + text: qsTr("Step %1").arg(marker.stepNumber) + color: marker.active || marker.complete + ? marker.colors.textSecondary : marker.colors.textPlaceholder + font.pixelSize: 11 + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: marker.label + color: marker.active || marker.complete + ? marker.colors.textPrimary : marker.colors.textSecondary + font.pixelSize: 13 + font.weight: marker.active ? Font.DemiBold : Font.Normal + wrapMode: Text.Wrap + } } } } diff --git a/apps/amm/qml/pages/SwapPage.qml b/apps/amm/qml/pages/SwapPage.qml index 75b42d4e..99ea0480 100644 --- a/apps/amm/qml/pages/SwapPage.qml +++ b/apps/amm/qml/pages/SwapPage.qml @@ -1,13 +1,17 @@ +pragma ComponentBehavior: Bound + import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import Logos.Wallet import "../components/shared" import "../components/swap" -import "../state" Item { id: root + readonly property bool darkTheme: pageTheme.isDark + property var tokens: [ { symbol: "TOK1", name: "Token 1", color: "#627eea", letter: "E", address: "0x0000000000000000000000000000000000000000", usdPrice: 2392.70, balance: 4.25, reserve: 850 }, { symbol: "TOK2", name: "Token 2", color: "#2775ca", letter: "$", address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", usdPrice: 1.00, balance: 12480, reserve: 2400000 }, @@ -18,7 +22,7 @@ Item { ] QtObject { - id: theme + id: pageTheme property bool isDark: true property var colors: isDark ? dark : light @@ -61,67 +65,96 @@ Item { Rectangle { anchors.fill: parent - color: theme.colors.background + color: pageTheme.colors.background Behavior on color { ColorAnimation { duration: 300 } } // Theme toggle Rectangle { + objectName: "swapThemeToggle" anchors.top: parent.top anchors.right: parent.right anchors.margins: 16 width: 44; height: 24; radius: 12 - color: theme.colors.panelBg - border.color: theme.colors.border + z: 1 + color: pageTheme.colors.panelBg + border.color: pageTheme.colors.border border.width: 1 Text { anchors.centerIn: parent - text: theme.isDark ? "☀" : "☾" + text: pageTheme.isDark ? "☀" : "☾" font.pixelSize: 13 - color: theme.colors.textSecondary + color: pageTheme.colors.textSecondary } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: theme.isDark = !theme.isDark + onClicked: pageTheme.isDark = !pageTheme.isDark } } - ColumnLayout { - anchors.centerIn: parent - spacing: 28 + Flickable { + id: swapScroll - SwapCard { - id: swapCard - Layout.alignment: Qt.AlignHCenter - theme: theme - tokens: root.tokens - width: Math.min(480, root.width - 32) + objectName: "swapScroll" + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: Math.max(height, + swapContent.y + swapContent.implicitHeight + 16) + flickableDirection: Flickable.VerticalFlick + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } - onRequestTokenSelect: function(side) { - tokenModal.targetSide = side - tokenModal.open() + ColumnLayout { + id: swapContent + + objectName: "swapContent" + x: 16 + y: Math.max(56, Math.round((swapScroll.height - implicitHeight) / 2)) + width: Math.max(0, swapScroll.width - 32) + spacing: 28 + + SwapCard { + id: swapCard + objectName: "swapCard" + Layout.fillWidth: true + Layout.maximumWidth: 480 + Layout.alignment: Qt.AlignHCenter + theme: pageTheme + tokens: root.tokens + + onRequestTokenSelect: function(side) { + tokenModal.targetSide = side + tokenModal.open() + } + + onPreviewRequested: function(snapshot) { + swapConfirmationDialog.openWithSnapshot(snapshot) + } } - onSubmitRequested: function(snapshot) { - swapConfirmationDialog.openWithSnapshot(snapshot) + Text { + objectName: "swapPreviewNotice" + Layout.fillWidth: true + Layout.maximumWidth: 480 + Layout.alignment: Qt.AlignHCenter + text: qsTr("Preview only — sample data. No swap will be submitted.") + color: pageTheme.colors.textSecondary + font.pixelSize: 15 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap } } - - Text { - Layout.alignment: Qt.AlignHCenter - text: "Buy and sell crypto on LEZ." - textFormat: Text.RichText - color: theme.colors.textSecondary - font.pixelSize: 15 - horizontalAlignment: Text.AlignHCenter - } } TokenSelectorModal { id: tokenModal anchors.fill: parent z: 10 - theme: theme + theme: pageTheme tokens: root.tokens property string targetSide: "sell" @@ -132,32 +165,21 @@ Item { } } - SuccessToast { - id: swapToast - - width: Math.max(0, Math.min(380, parent.width - 32)) + Component { + id: swapConfirmationSummary - anchors { - bottom: parent.bottom - bottomMargin: 24 - horizontalCenter: parent.horizontalCenter + SwapConfirmationSummary { + theme: pageTheme } } - SwapConfirmationDialog { + TransactionConfirmationDialog { id: swapConfirmationDialog - anchors.fill: parent - theme: theme - - onConfirmed: function(snapshot) { - swapCard.resetAmounts() - swapToast.show(qsTr("Swap submitted"), - qsTr("%1 %2 → %3 %4") - .arg(snapshot.sellAmount) - .arg(snapshot.sellToken) - .arg(snapshot.minReceived) - .arg(snapshot.buyToken)) - } + objectName: "swapPreviewDialog" + title: qsTr("Swap preview") + cancelText: qsTr("Back") + confirmText: qsTr("Done") + summary: swapConfirmationSummary } } } diff --git a/apps/amm/qml/state/DummyPoolState.qml b/apps/amm/qml/state/DummyPoolState.qml deleted file mode 100644 index 7e4d573d..00000000 --- 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 00000000..95fb8afb --- /dev/null +++ b/apps/amm/qml/state/NewPositionFlow.qml @@ -0,0 +1,399 @@ +import QtQml + +QtObject { + id: root + + property var backend: null + property var runtime: null + property bool active: false + + readonly property bool backendReady: root.backend !== null + readonly property bool walletCanSubmit: root.backendReady + && root.backend.walletCanSubmit === true + readonly property var newPositionContext: root.backendReady + && root.backend.newPositionContext + && root.backend.newPositionContext.schema + ? root.backend.newPositionContext + : root.loadingContext() + readonly property var viewState: ({ + "quote": root.newPositionQuote, + "contextLoading": root.contextLoading + || root.newPositionContext.status === "loading", + "quoteLoading": root.quoteLoading, + "quoteStale": root.quoteStale, + "submitting": root.submitting, + "walletCanSubmit": root.walletCanSubmit, + "walletSyncStatus": root.backendReady + ? String(root.backend.walletSyncStatus || "closed") : "closed", + "poolCreationPending": root.selectedPoolCreationPending(), + "transactionId": root.transactionId, + "errorCode": root.flowErrorCode || root.contextErrorCode + || root.quoteErrorCode + }) + + property var newPositionQuote: ({}) + property var resolvedTokenIds: [] + property int quoteSerial: 0 + property int operationSerial: 0 + property int activeQuoteRequestId: 0 + property int submitRequestId: 0 + property int poolProbeRequestId: 0 + property var pendingSubmitSnapshot: ({}) + property var pendingConfirmationSnapshot: null + 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 pendingContextCompletion: null + 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 + signal confirmationQuoteReady(var snapshot) + + 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.active && root.pendingPoolProbes.length > 0 + onTriggered: root.pollPendingPool() + } + + property Connections backendConnections: Connections { + target: root.backend + ignoreUnknownSignals: true + + function onNewPositionQuoteResultChanged() { + root.acceptQuoteResult(root.backend.newPositionQuoteResult || ({})) + } + + function onNewPositionSubmitResultChanged() { + root.acceptSubmitResult(root.backend.newPositionSubmitResult || ({})) + } + } + + onNewPositionContextChanged: { + root.contextLoading = false + root.contextErrorCode = root.newPositionContext.status === "error" + ? String(root.newPositionContext.code || "backend_error") : "" + root.invalidateQuote() + const completed = root.pendingContextCompletion + root.pendingContextCompletion = null + root.tokenResolutionFinished(true) + if (completed) + completed() + } + + onActiveChanged: { + if (root.active && root.backendReady) + Qt.callLater(function() { root.quoteRefreshRequested(true) }) + } + + onBackendReadyChanged: { + if (root.backendReady || !root.submitting) + return + root.submitRequestId = 0 + root.submitting = false + root.pendingSubmitSnapshot = ({}) + root.flowErrorCode = "submission_status_unknown" + root.submitFailed() + } + + function contextHints(refreshPublicData) { + 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": refreshPublicData === true + } + } + + function refreshContext(refreshPublicData, completed) { + root.contextLoading = true + root.pendingContextCompletion = completed || null + if (!root.backendReady) { + root.contextLoading = false + return + } + try { + root.backend.refreshNewPositionContext(root.contextHints(refreshPublicData)) + } catch (_error) { + 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.backendReady && root.active + root.quoteDebounce.stop() + if (!root.backendReady || !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.backendReady || !root.active) { + root.quoteLoading = false + return + } + root.activeQuoteRequestId = ++root.operationSerial + root.backend.requestNewPositionQuote( + built.request, root.activeQuoteRequestId, true, false) + } + + function acceptQuoteResult(result) { + const requestId = Number(result.requestId || 0) + if (requestId === root.poolProbeRequestId) { + root.finishPoolProbe(root.pendingPoolProbes[0], result) + return + } + if (requestId !== root.activeQuoteRequestId) + return + root.quoteLoading = false + root.quoteStale = false + root.quoteErrorCode = "" + root.newPositionQuote = result.schema === "new-position.v2" + ? result : root.quoteError("unsupported_schema") + if (root.pendingConfirmationSnapshot) { + var snapshot = root.pendingConfirmationSnapshot + root.pendingConfirmationSnapshot = null + snapshot.quoteHash = String(root.newPositionQuote.quoteHash || "") + snapshot.expectedLpText = String(root.newPositionQuote.expectedLpRaw || "") + + " raw LP" + snapshot.instruction = String(root.newPositionQuote.instruction || "") + snapshot.lpHoldingOptions = root.newPositionQuote.lpHoldingOptions || [] + snapshot.selectedLpHoldingId = String( + root.newPositionQuote.selectedLpHoldingId || "") + snapshot.createFreshLp = root.newPositionQuote.requiresFreshLp === true + snapshot.lpDestinationRequired = + root.newPositionQuote.lpDestinationRequired === true + snapshot.quoteReady = root.newPositionQuote.canSubmit === true + root.confirmationQuoteReady(snapshot) + } + } + + function requoteConfirmation(snapshot) { + root.pendingConfirmationSnapshot = snapshot + root.scheduleQuote(true, { + "ok": true, + "errors": [], + "request": snapshot.request + }) + } + + function confirm(snapshot) { + if (root.submitting) + return + if (!root.walletCanSubmit) { + root.finishSubmitFailure(root.quoteError("wallet_syncing")) + return + } + root.submitting = true + root.flowErrorCode = "" + root.submitRequestId = ++root.operationSerial + root.pendingSubmitSnapshot = snapshot + root.backend.requestNewPositionSubmit( + snapshot.request, snapshot.quoteHash, root.submitRequestId) + } + + function acceptSubmitResult(result) { + if (Number(result.requestId || 0) !== root.submitRequestId) + return + if (result.schema === "new-position.v2" + && result.status === "submitted" + && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test( + String(result.transactionId || ""))) { + root.submitting = false + root.transactionId = result.transactionId + root.flowErrorCode = "" + root.contextErrorCode = "" + root.quoteErrorCode = "" + const poolProbe = root.pendingSubmitSnapshot.poolProbeRequest || null + if (String(root.pendingSubmitSnapshot.instruction || "") + === "NewDefinition" && poolProbe) + root.watchPoolCreation(poolProbe, result.deadlineMs) + root.invalidateQuote() + root.submitSucceeded() + return + } + root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed")) + } + + function finishSubmitFailure(result) { + root.submitting = false + const hasFreshQuote = result && result.quote + && result.quote.schema === "new-position.v2" + if (hasFreshQuote) { + root.newPositionQuote = result.quote + root.quoteLoading = false + root.quoteStale = false + } + root.flowErrorCode = result && result.code + ? result.code : "wallet_submission_failed" + root.submitFailed() + if (!hasFreshQuote) + 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.backendReady || !root.active) + return + const pending = root.pendingPoolProbes[0] + root.poolProbeInFlight = true + root.poolProbeRequestId = ++root.operationSerial + root.backend.requestNewPositionQuote( + pending.request, root.poolProbeRequestId, true, true) + } + + function finishPoolProbe(pending, quote) { + if (!pending) + return + root.poolProbeInFlight = false + root.poolProbeRequestId = 0 + if (quote && quote.schema === "new-position.v2" + && quote.poolStatus === "active_pool") { + root.removePendingPool(pending.key) + if (root.matchesSelectedPair(pending.request)) { + root.poolActivated(quote) + root.invalidateQuote() + root.refreshContext(false) + } + 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.activeQuoteRequestId = 0 + root.quoteDebounce.stop() + root.quoteLoading = false + root.quoteStale = true + } + + function loadingContext() { + return { + "schema": "new-position.v2", + "status": "loading", + "tokens": [], + "feeTiers": [] + } + } + + function quoteError(code) { + return { + "schema": "new-position.v2", + "status": "error", + "canSubmit": false, + "code": code, + "poolStatus": "unavailable_pool", + "errors": [{ + "code": code, + "blockingFields": [], + "details": ({}) + }], + "warnings": [], + "accountPreview": [] + } + } +} diff --git a/apps/amm/scripts/check-macos-prerequisites.sh b/apps/amm/scripts/check-macos-prerequisites.sh new file mode 100755 index 00000000..b2e48281 --- /dev/null +++ b/apps/amm/scripts/check-macos-prerequisites.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash + +# Verify the host requirements for a supported native macOS AMM build. + +set -euo pipefail + +failures=0 + +pass() { + printf 'ok: %s\n' "$1" +} + +fail() { + printf 'missing: %s\n' "$1" >&2 + failures=$((failures + 1)) +} + +if [[ "$(uname -s)" != "Darwin" ]]; then + printf 'missing: this script must run on macOS\n' >&2 + exit 1 +fi + +architecture="$(uname -m)" +case "$architecture" in + arm64|x86_64) + pass "supported architecture $architecture" + ;; + *) + fail "unsupported architecture $architecture" + ;; +esac + +developer_dir="$(xcode-select -p 2>/dev/null || true)" +if [[ "$developer_dir" == *.app/Contents/Developer ]]; then + pass "full Xcode selected at $developer_dir" + required_gib=40 +else + fail "full Xcode is not selected (current developer directory: ${developer_dir:-none})" + required_gib=60 +fi + +if xcode_version="$(xcodebuild -version 2>/dev/null)"; then + pass "$(printf '%s' "$xcode_version" | tr '\n' ' ')" +else + fail "xcodebuild is unavailable; install Xcode.app and select it with xcode-select" +fi + +if xcodebuild -checkFirstLaunchStatus >/dev/null 2>&1; then + pass "Xcode first-launch setup and licence are complete" +else + fail "Xcode setup is incomplete; run sudo xcodebuild -license accept and -runFirstLaunch" +fi + +for tool in metal metallib; do + if tool_path="$(xcrun --sdk macosx --find "$tool" 2>/dev/null)" && + [[ -x "$tool_path" ]]; then + pass "$tool at $tool_path" + else + fail "$tool is unavailable; run xcodebuild -downloadComponent MetalToolchain" + fi +done + +nix_bin="$(command -v nix 2>/dev/null || true)" +if [[ -z "$nix_bin" && -x /nix/var/nix/profiles/default/bin/nix ]]; then + nix_bin=/nix/var/nix/profiles/default/bin/nix +fi + +if [[ -n "$nix_bin" ]]; then + pass "Nix at $nix_bin ($("$nix_bin" --version))" + + configured_features="$( + "$nix_bin" config show experimental-features 2>/dev/null || + "$nix_bin" show-config 2>/dev/null | + awk -F ' = ' '$1 == "experimental-features" { print $2 }' + )" + if [[ " $configured_features " == *" nix-command "* && + " $configured_features " == *" flakes "* ]]; then + pass "Nix flakes and nix-command are enabled" + else + fail "enable experimental-features = nix-command flakes in nix.conf" + fi + + if "$nix_bin" --extra-experimental-features 'nix-command flakes' \ + store info >/dev/null 2>&1; then + pass "Nix daemon and store are available" + else + fail "Nix cannot contact its daemon or store" + fi + + sandbox_setting="$("$nix_bin" config show sandbox 2>/dev/null || true)" + if [[ "$sandbox_setting" == "false" ]]; then + pass "Nix macOS sandbox is disabled for the host Metal toolchain" + else + fail "Nix sandbox must be false for the host Metal toolchain (current: ${sandbox_setting:-unknown})" + fi +else + fail "Nix is unavailable; install multi-user Nix and start its daemon" +fi + +if [[ -d /nix ]]; then + available_kib="$(df -Pk /nix | awk 'NR == 2 { print $4 }')" + required_kib=$((required_gib * 1024 * 1024)) + if [[ "${available_kib:-0}" -ge "$required_kib" ]]; then + pass "at least ${required_gib} GiB free on the Nix volume" + else + fail "less than ${required_gib} GiB free on the Nix volume" + fi +else + fail "/nix is not mounted" +fi + +if (( failures > 0 )); then + printf '\nSee MACOS.md for installation and recovery instructions.\n' >&2 + exit 1 +fi + +printf '\nAll macOS AMM prerequisites are available.\n' diff --git a/apps/amm/src/AccountModel.cpp b/apps/amm/src/AccountModel.cpp deleted file mode 100644 index 63992f86..00000000 --- a/apps/amm/src/AccountModel.cpp +++ /dev/null @@ -1,79 +0,0 @@ -#include "AccountModel.h" - -#include - -AccountModel::AccountModel(QObject* parent) - : QAbstractListModel(parent) -{ -} - -int AccountModel::rowCount(const QModelIndex& parent) const -{ - if (parent.isValid()) - return 0; - return m_entries.size(); -} - -QVariant AccountModel::data(const QModelIndex& index, int role) const -{ - if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size()) - return QVariant(); - - const AccountEntry& e = m_entries.at(index.row()); - switch (role) { - case NameRole: return e.name; - case AddressRole: return e.address; - case BalanceRole: return e.balance; - case IsPublicRole: return e.isPublic; - default: return QVariant(); - } -} - -QHash AccountModel::roleNames() const -{ - return { - { NameRole, "name" }, - { AddressRole, "address" }, - { BalanceRole, "balance" }, - { IsPublicRole, "isPublic" } - }; -} - -void AccountModel::replaceFromJsonArray(const QJsonArray& arr) -{ - beginResetModel(); - const int oldCount = m_entries.size(); - m_entries.clear(); - int idx = 0; - for (const QJsonValue& v : arr) { - AccountEntry e; - e.name = QStringLiteral("Account %1").arg(++idx); - e.balance = QString(); - if (v.isObject()) { - const QJsonObject obj = v.toObject(); - e.address = obj.value(QStringLiteral("account_id")).toString(); - e.isPublic = obj.value(QStringLiteral("is_public")).toBool(true); - } else { - e.address = v.toString(); - e.isPublic = true; - } - m_entries.append(e); - } - endResetModel(); - if (oldCount != m_entries.size()) - emit countChanged(); -} - -void AccountModel::setBalanceByAddress(const QString& address, const QString& balance) -{ - for (int i = 0; i < m_entries.size(); ++i) { - if (m_entries.at(i).address == address) { - if (m_entries.at(i).balance != balance) { - m_entries[i].balance = balance; - const QModelIndex idx = index(i, 0); - emit dataChanged(idx, idx, { BalanceRole }); - } - return; - } - } -} diff --git a/apps/amm/src/AccountModel.h b/apps/amm/src/AccountModel.h deleted file mode 100644 index 918839c3..00000000 --- a/apps/amm/src/AccountModel.h +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -// One wallet account row. Mirrors the shape returned by the core wallet -// module's list_accounts() (account_id + is_public), with a display name and -// a lazily-fetched balance. -struct AccountEntry { - QString name; - QString address; - QString balance; - bool isPublic = true; -}; - -// QAbstractListModel of wallet accounts, exposed to QML via -// logos.model("amm_ui", "accountModel"). Ported from the LEZ wallet UI. -class AccountModel : public QAbstractListModel { - Q_OBJECT - Q_PROPERTY(int count READ count NOTIFY countChanged) -public: - enum Role { - NameRole = Qt::UserRole + 1, - AddressRole, - BalanceRole, - IsPublicRole - }; - Q_ENUM(Role) - - explicit AccountModel(QObject* parent = nullptr); - - int rowCount(const QModelIndex& parent = QModelIndex()) const override; - QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; - - void replaceFromJsonArray(const QJsonArray& arr); - void setBalanceByAddress(const QString& address, const QString& balance); - int count() const { return m_entries.size(); } - -signals: - void countChanged(); - -private: - QVector m_entries; -}; diff --git a/apps/amm/src/ActiveNetwork.cpp b/apps/amm/src/ActiveNetwork.cpp new file mode 100644 index 00000000..6397357d --- /dev/null +++ b/apps/amm/src/ActiveNetwork.cpp @@ -0,0 +1,170 @@ +#include "ActiveNetwork.h" + +#include +#include + +#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(); + m_failedIdentityProbes = 0; + + 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"); +} + +int ActiveNetwork::identityRetryDelayMs() const +{ + static constexpr int retryDelays[] = { + 1000, + 2000, + 4000, + 8000, + 16000, + 30000, + }; + if (m_failedIdentityProbes <= 0 + || m_network.status != QStringLiteral("network_unknown")) { + return 0; + } + const int index = (std::min)(m_failedIdentityProbes, + static_cast(std::size(retryDelays))) - 1; + return retryDelays[index]; +} + +void ActiveNetwork::sequencerChanged(bool available) +{ + m_failedIdentityProbes = 0; + if (isConfigured()) + clearIdentity(available ? QStringLiteral("loading") : QStringLiteral("network_unknown")); +} + +void ActiveNetwork::reachabilityChanged(bool reachable, bool wasReachable) +{ + if (!isConfigured()) + return; + if (!reachable || !wasReachable) + m_failedIdentityProbes = 0; + 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()) { + m_failedIdentityProbes = (std::min)(m_failedIdentityProbes + 1, 6); + clearIdentity(QStringLiteral("network_unknown")); + } else if (identity != m_expectedIdentity) { + m_failedIdentityProbes = 0; + clearIdentity(QStringLiteral("network_mismatch")); + } else { + m_failedIdentityProbes = 0; + 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 00000000..c0ed875c --- /dev/null +++ b/apps/amm/src/ActiveNetwork.h @@ -0,0 +1,38 @@ +#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; + int identityRetryDelayMs() 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; + int m_failedIdentityProbes = 0; +}; diff --git a/apps/amm/src/AmmClient.cpp b/apps/amm/src/AmmClient.cpp new file mode 100644 index 00000000..8ad23a4a --- /dev/null +++ b/apps/amm/src/AmmClient.cpp @@ -0,0 +1,76 @@ +#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); +} + +AmmClientResult BundledAmmClient::normalizeAccountRpc(const QJsonObject& request) const +{ + return call(amm_normalize_account_rpc, request); +} diff --git a/apps/amm/src/AmmClient.h b/apps/amm/src/AmmClient.h new file mode 100644 index 00000000..aca7efe3 --- /dev/null +++ b/apps/amm/src/AmmClient.h @@ -0,0 +1,32 @@ +#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; + virtual AmmClientResult normalizeAccountRpc(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; + AmmClientResult normalizeAccountRpc(const QJsonObject& request) const override; +}; diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index f937d039..a4573c4b 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -1,401 +1,380 @@ #include "AmmUiBackend.h" -#include -#include -#include -#include -#include -#include -#include #include #include #include +#include +#include #include #include #include -#include #include #include +#include "AmmClient.h" +#include "LogosWalletProvider.h" +#include "NewPositionRuntime.h" +#include "SequencerClient.h" +#include "WalletController.h" #include "logos_api.h" -#include "logos_sdk.h" namespace { - const char SETTINGS_ORG[] = "Logos"; - const char SETTINGS_APP[] = "AmmUI"; - // Sticky "user pressed Disconnect" flag so the wallet stays locked across - // relaunches until the user reconnects. - const char DISCONNECTED_KEY[] = "disconnected"; - const int WALLET_FFI_SUCCESS = 0; - - // Wallet home env override. Mirrors LEZ's own var so the app shares the - // canonical wallet (~/.lee/wallet) used by the wallet UI and other apps. - const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; - - // Normalise file:// URLs and OS paths to a plain local path. - QString toLocalPath(const QString& path) { - if (path.startsWith("file://") || path.contains("/")) - return QUrl::fromUserInput(path).toLocalFile(); - return path; + const int CHECKPOINT_BLOCK_ID = 10; + const int BLOCK_HASH_OFFSET = 40; + const int BLOCK_HASH_SIZE = 32; + + QByteArray jsonRpcBody(const QString& method, const QJsonArray& params) + { + return QJsonDocument(QJsonObject { + { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, + { QStringLiteral("id"), 1 }, + { QStringLiteral("method"), method }, + { QStringLiteral("params"), params }, + }).toJson(QJsonDocument::Compact); } -} -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 blockHashFromResponse(const QByteArray& payload) + { + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + return {}; + const QByteArray block = + QByteArray::fromBase64(document.object().value(QStringLiteral("result")).toString().toLatin1()); + if (block.size() < BLOCK_HASH_OFFSET + BLOCK_HASH_SIZE) + return {}; + return QString::fromLatin1(block.mid(BLOCK_HASH_OFFSET, BLOCK_HASH_SIZE).toHex()); + } -QString AmmUiBackend::defaultConfigPath() const -{ - return defaultWalletHome() + QStringLiteral("/wallet_config.json"); -} + QString channelIdFromResponse(const QByteArray& payload) + { + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + return {}; + const QString channel = document.object().value(QStringLiteral("result")).toString(); + return ActiveNetwork::isValidIdentity(channel) ? channel : QString(); + } -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_wallet(std::make_unique(m_logosAPI)), + m_walletController(std::make_unique( + *m_wallet, QStringLiteral("AmmUI"))), + m_ammClient(std::make_unique()), + m_sequencer(std::make_unique(m_ammClient.get(), this)), + m_newPosition(std::make_unique( + m_wallet.get(), m_ammClient.get(), m_sequencer.get())), m_net(new QNetworkAccessManager(this)), - m_reachabilityTimer(new QTimer(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); - - // 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); -} - -AmmUiBackend::~AmmUiBackend() + m_transactionTimer(new QTimer(this)), + m_identityRetryTimer(new QTimer(this)) { - saveWallet(); - delete m_logos; + setNewPositionQuoteResult({}); + setNewPositionSubmitResult({}); + m_transactionTimer->setInterval(5000); + connect(m_transactionTimer, &QTimer::timeout, + this, &AmmUiBackend::pollTransactions); + m_identityRetryTimer->setSingleShot(true); + connect(m_identityRetryTimer, &QTimer::timeout, + this, &AmmUiBackend::probeNetworkIdentity); + m_network.load(); + + connect(m_walletController.get(), &WalletController::stateChanged, + this, &AmmUiBackend::syncWalletState); + syncWalletState(); + m_walletController->start(); } -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; - } +AmmUiBackend::~AmmUiBackend() = default; - // 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() +WalletAccountModel* AmmUiBackend::accountModel() const { - // 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); + return m_walletController->createDefaultWallet(password); } 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(); - return mnemonic; + return m_walletController->createWallet(configPath, storagePath, password); } 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; + return m_walletController->open(); } 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(); } 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(); + 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); } -void AmmUiBackend::refreshBlockHeights() +void AmmUiBackend::refreshNewPositionContext(QVariantMap request) { - 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 bool refreshWalletAccounts = + request.take(QStringLiteral("refreshWalletAccounts")).toBool(); + if (request.contains(QStringLiteral("recentTokenIds")) + || request.contains(QStringLiteral("resolvedTokenIds"))) { + m_newPositionHints = request; + } + else { + request = m_newPositionHints; + } + const quint64 generation = ++m_contextGeneration; + m_newPosition->contextAsync( + request, m_network.snapshot(), isWalletOpen(), refreshWalletAccounts, + [this, generation](QVariantMap result) { + if (generation == m_contextGeneration) { + result.insert(QStringLiteral("requestId"), generation); + setNewPositionContext(std::move(result)); + } + }); } -void AmmUiBackend::refreshSequencerAddr() +void AmmUiBackend::requestNewPositionQuote(QVariantMap request, + int requestId, + bool forceRefresh, + bool isPoolProbe) { - const QString addr = m_logos->logos_execution_zone.get_sequencer_addr(); - if (sequencerAddr() != addr) - setSequencerAddr(addr); - // Probe right away so the banner reflects the (possibly new) endpoint - // without waiting for the next periodic tick. - checkReachability(); + m_newPosition->quoteAsync( + request, m_network.snapshot(), isWalletOpen(), forceRefresh, isPoolProbe, + [this, requestId](QVariantMap result) { + result.insert(QStringLiteral("requestId"), requestId); + setNewPositionQuoteResult(std::move(result)); + }); } -void AmmUiBackend::checkReachability() +void AmmUiBackend::requestNewPositionSubmit(QVariantMap request, + QString quoteHash, + int requestId) { - const QString addr = sequencerAddr(); - if (addr.isEmpty()) - return; - - QNetworkRequest req{QUrl(addr)}; - req.setTransferTimeout(4000); - QNetworkReply* reply = m_net->get(req); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - // Any HTTP response (even a 404) means the node is up; only a transport - // failure (connection refused, host not found, timeout) counts as down. - const bool gotHttpStatus = - reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid(); - const bool reachable = gotHttpStatus || reply->error() == QNetworkReply::NoError; - if (sequencerReachable() != reachable) - setSequencerReachable(reachable); - reply->deleteLater(); - }); + m_newPosition->submitAsync( + request, quoteHash, m_network.snapshot(), walletCanSubmit(), + [this, requestId](QVariantMap result) { + result.insert(QStringLiteral("requestId"), requestId); + setNewPositionSubmitResult(result); + watchTransaction(result); + }); } -void AmmUiBackend::saveWallet() +void AmmUiBackend::syncWalletState() { - if (isWalletOpen()) - m_logos->logos_execution_zone.save(); + const WalletUiState& state = m_walletController->state(); + const bool walletWasOpen = isWalletOpen(); + const bool walletCouldSubmit = walletCanSubmit(); + const bool wasReachable = sequencerReachable(); + const QString previousAddress = sequencerAddr(); + + setIsWalletOpen(state.isWalletOpen); + setWalletSyncStatus(state.syncStatus); + setWalletSyncError(state.syncError); + setWalletCanSubmit(state.canSubmit()); + setWalletStateReady(state.syncStatus != QStringLiteral("opening") + && state.syncStatus != QStringLiteral("syncing")); + setWalletExists(state.walletExists); + setConfigPath(state.configPath); + setStoragePath(state.storagePath); + setWalletHome(state.walletHome); + setLastSyncedBlock(state.lastSyncedBlock); + setCurrentBlockHeight(state.currentBlockHeight); + setSequencerAddr(state.sequencerAddress); + setSequencerReachable(state.sequencerReachable); + + m_sequencer->configure(state.configPath, state.sequencerAddress); + + const bool addressChanged = previousAddress != state.sequencerAddress; + if ((walletCouldSubmit && !state.canSubmit()) || addressChanged) + m_newPosition->cancelSubmit(); + if (addressChanged) { + m_identityRetryTimer->stop(); + m_pendingTransactions.clear(); + m_transactionPollsInFlight.clear(); + m_transactionTimer->stop(); + m_network.sequencerChanged(!state.sequencerAddress.isEmpty()); + } + if (addressChanged || wasReachable != state.sequencerReachable) { + m_identityRetryTimer->stop(); + m_network.reachabilityChanged(state.sequencerReachable, wasReachable); + } + if (walletWasOpen && !state.isWalletOpen) + m_newPosition->clearWalletAccounts(); + if (state.canSubmit()) { + const WalletSnapshot snapshot = m_wallet->snapshot(); + if (snapshot.ok()) + m_newPosition->setWalletAccounts(snapshot.accounts); + } + + publishNetworkContext(); + if (state.sequencerReachable && m_network.needsIdentityProbe()) + probeNetworkIdentity(); } -// 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) +void AmmUiBackend::probeNetworkIdentity() { - setConfigPath(toLocalPath(path)); + if (m_identityProbeInFlight + || m_identityRetryTimer->isActive() + || !m_network.isConfigured() + || sequencerAddr().isEmpty()) { + return; + } + m_identityProbeInFlight = true; + m_network.beginIdentityProbe(); + publishNetworkContext(); + const QString address = sequencerAddr(); + const bool devnet = m_network.isDevnet(); + const QString method = devnet + ? QStringLiteral("getChannelId") + : QStringLiteral("getBlock"); + const QJsonArray params = devnet + ? QJsonArray() + : QJsonArray { CHECKPOINT_BLOCK_ID }; + + QNetworkRequest request{QUrl(address)}; + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + request.setTransferTimeout(4000); + m_sequencer->applyAuthorization(request); + QNetworkReply* reply = m_net->post(request, jsonRpcBody(method, params)); + connect(reply, &QNetworkReply::finished, this, [this, reply, address, devnet]() { + m_identityProbeInFlight = false; + if (address != sequencerAddr()) { + reply->deleteLater(); + probeNetworkIdentity(); + return; + } + if (!sequencerReachable()) { + reply->deleteLater(); + return; + } + + const QVariant statusValue = reply->attribute( + QNetworkRequest::HttpStatusCodeAttribute); + const int status = statusValue.toInt(); + const bool successfulResponse = reply->error() == QNetworkReply::NoError + && statusValue.isValid() + && status >= 200 + && status < 300; + const QByteArray payload = successfulResponse ? reply->readAll() : QByteArray(); + const QString actual = successfulResponse + ? (devnet ? channelIdFromResponse(payload) : blockHashFromResponse(payload)) + : QString(); + m_network.finishIdentityProbe(actual); + const int retryDelay = m_network.identityRetryDelayMs(); + if (retryDelay > 0) + m_identityRetryTimer->start(retryDelay); + reply->deleteLater(); + publishNetworkContext(); + }); } -void AmmUiBackend::persistStoragePath(const QString& path) +void AmmUiBackend::publishNetworkContext() { - setStoragePath(toLocalPath(path)); + refreshNewPositionContext(m_newPositionHints); } -bool AmmUiBackend::changeSequencerAddr(QString url) +void AmmUiBackend::watchTransaction(const QVariantMap& result) { - const QString trimmed = url.trimmed(); - if (trimmed.isEmpty()) { - qWarning() << "AmmUiBackend: refusing to set empty sequencer_addr"; - return false; + if (result.value(QStringLiteral("status")).toString() + != QStringLiteral("submitted")) { + return; } - - 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(); + const QString nativeHash = result.value( + QStringLiteral("nativeTransactionHash")).toString(); + bool deadlineValid = false; + const qint64 deadline = result.value(QStringLiteral("deadlineMs")) + .toString().toLongLong(&deadlineValid); + QStringList affected; + for (const QVariant& value : result.value( + QStringLiteral("affectedAccountIds")).toList()) { + affected.append(value.toString()); } - obj.insert(QStringLiteral("sequencer_addr"), trimmed); + if (nativeHash.isEmpty() || !deadlineValid || affected.isEmpty()) + return; + m_pendingTransactions.insert(nativeHash, { + nativeHash, + affected, + deadline, + }); + if (!m_transactionTimer->isActive()) + m_transactionTimer->start(); + QTimer::singleShot(0, this, &AmmUiBackend::pollTransactions); +} - 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(); - - // Re-open so the live wallet uses the new endpoint right away. - if (isWalletOpen()) { - const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - const int err = m_logos->logos_execution_zone.open(cfg, stg); - if (err != WALLET_FFI_SUCCESS) { - qWarning() << "AmmUiBackend: reopen after sequencer change failed, code" << err; - return false; +void AmmUiBackend::pollTransactions() +{ + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + const QList pending = m_pendingTransactions.values(); + for (const PendingTransaction& transaction : pending) { + if (now >= transaction.deadlineMs) { + m_pendingTransactions.remove(transaction.nativeHash); + continue; } - refreshSequencerAddr(); - refreshAccounts(); + if (m_transactionPollsInFlight.contains(transaction.nativeHash)) + continue; + m_transactionPollsInFlight.insert(transaction.nativeHash); + m_sequencer->queryTransaction(transaction.nativeHash, + [this, transaction](bool ok, bool included) { + m_transactionPollsInFlight.remove(transaction.nativeHash); + if (!ok || !included + || !m_pendingTransactions.contains(transaction.nativeHash)) { + return; + } + m_pendingTransactions.remove(transaction.nativeHash); + refreshAffectedAccounts(transaction.affectedAccountIds); + if (m_pendingTransactions.isEmpty()) + m_transactionTimer->stop(); + }); } - return true; + if (m_pendingTransactions.isEmpty()) + m_transactionTimer->stop(); } -void AmmUiBackend::copyToClipboard(QString text) +void AmmUiBackend::refreshAffectedAccounts(const QStringList& accountIds, int attempt) { - if (QGuiApplication::clipboard()) - QGuiApplication::clipboard()->setText(text); + m_sequencer->readAccounts(accountIds, true, + [this, accountIds, attempt](QVector reads) { + QStringList failed; + for (qsizetype index = 0; index < reads.size(); ++index) { + if (!reads.at(index).ok()) + failed.append(accountIds.value(index)); + } + if (!failed.isEmpty() && attempt < 2) { + QTimer::singleShot(1000, this, + [this, failed, attempt]() { + refreshAffectedAccounts(failed, attempt + 1); + }); + return; + } + refreshNewPositionContext({}); + }); } diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 14d1e80e..1ab9d0fd 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -1,31 +1,41 @@ #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 SequencerClient; +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. @@ -34,44 +44,51 @@ public slots: void refreshAccounts() override; void refreshBalances() override; QString getBalance(QString accountIdHex, bool isPublic) override; + void refreshNewPositionContext(QVariantMap request) override; + void requestNewPositionQuote(QVariantMap request, + int requestId, + bool forceRefresh, + bool isPoolProbe) override; + void requestNewPositionSubmit(QVariantMap request, + QString quoteHash, + int requestId) override; // Return the new wallet's BIP39 mnemonic (empty string on failure) so the // UI can force a one-time seed-phrase backup step. QString createNewDefault(QString password) override; 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(); - - // Probe the configured sequencer over HTTP and update sequencerReachable. - void checkReachability(); - - AccountModel* m_accountModel; + void syncWalletState(); + void probeNetworkIdentity(); + void publishNetworkContext(); + void watchTransaction(const QVariantMap& result); + void pollTransactions(); + void refreshAffectedAccounts(const QStringList& accountIds, int attempt = 0); 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_sequencer; + std::unique_ptr m_newPosition; QNetworkAccessManager* m_net; - QTimer* m_reachabilityTimer; + QTimer* m_transactionTimer; + QTimer* m_identityRetryTimer; + + ActiveNetwork m_network; + QVariantMap m_newPositionHints; + bool m_identityProbeInFlight = false; + quint64 m_contextGeneration = 0; + struct PendingTransaction { + QString nativeHash; + QStringList affectedAccountIds; + qint64 deadlineMs = 0; + }; + QHash m_pendingTransactions; + QSet m_transactionPollsInFlight; }; #endif // AMM_UI_BACKEND_H diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index c9e2d50d..8ccb626d 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -1,10 +1,16 @@ // QtRO view contract for the AMM UI backend. PROPs auto-sync to every QML -// replica; SLOTs are the async surface QML calls via logos.watch(...). +// replica; SLOTs dispatch asynchronous backend work. // The account list is exposed separately as a Q_PROPERTY model on the backend // (reached from QML via logos.model("amm_ui", "accountModel")). 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(QString walletSyncStatus READONLY) + PROP(QString walletSyncError READONLY) + PROP(bool walletCanSubmit READONLY) PROP(bool walletExists READONLY) PROP(QString configPath READONLY) PROP(QString storagePath READONLY) @@ -23,8 +29,18 @@ class AmmUiBackend SLOT(void refreshBalances()) SLOT(QString getBalance(QString accountIdHex, bool isPublic)) + // New Position backend surface. QML dispatches request slots and observes + // request-tagged result properties. QVariant payloads are stable maps/lists + // so the UI never assembles AMM transactions or duplicates quote state. + PROP(QVariantMap newPositionContext READONLY) + PROP(QVariantMap newPositionQuoteResult READONLY) + PROP(QVariantMap newPositionSubmitResult READONLY) + SLOT(void refreshNewPositionContext(QVariantMap request)) + SLOT(void requestNewPositionQuote(QVariantMap request, int requestId, bool forceRefresh, bool isPoolProbe)) + SLOT(void requestNewPositionSubmit(QVariantMap request, QString quoteHash, int requestId)) + // 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. @@ -36,11 +52,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()) - - // Settings. Rewrites the wallet config's sequencer_addr and re-opens the - // wallet so the new network takes effect immediately. - SLOT(bool changeSequencerAddr(QString url)) - - // Misc - SLOT(void copyToClipboard(QString text)) } diff --git a/apps/amm/src/NewPositionRuntime.cpp b/apps/amm/src/NewPositionRuntime.cpp new file mode 100644 index 00000000..af159594 --- /dev/null +++ b/apps/amm/src/NewPositionRuntime.cpp @@ -0,0 +1,1176 @@ +#include "NewPositionRuntime.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "AmmClient.h" +#include "SequencerClient.h" +#include "WalletProvider.h" + +namespace { + const char SCHEMA[] = "new-position.v2"; + constexpr qsizetype HASH_BYTES = 32; + constexpr qsizetype ACCOUNT_ID_HEX_SIZE = HASH_BYTES * 2; + 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)); + } + + QString accountIdHex(const QString& accountId) + { + const QByteArray encoded = accountId.toLatin1(); + std::array bytes {}; + std::size_t size = bytes.size(); + if (!b58tobin(bytes.data(), &size, encoded.constData(), + static_cast(encoded.size())) + || size != bytes.size()) { + return {}; + } + return QString::fromLatin1( + QByteArray(reinterpret_cast(bytes.data()), + static_cast(bytes.size())).toHex()); + } + + bool isLowerHex(const QString& value, qsizetype size) + { + if (value.size() != size) + return false; + for (qsizetype index = 0; index < value.size(); ++index) { + const char16_t character = value.at(index).unicode(); + if (!((character >= u'0' && character <= u'9') + || (character >= u'a' && character <= u'f'))) { + return false; + } + } + return true; + } + + bool isDefaultAccountRead(const WalletAccountRead& read, + const QString& expectedAccountId) + { + return read.ok() + && read.accountId == expectedAccountId + && isLowerHex(read.accountId, ACCOUNT_ID_HEX_SIZE) + && read.programOwner == QString(ACCOUNT_ID_HEX_SIZE, QLatin1Char('0')) + && read.balanceHex == QString(32, QLatin1Char('0')) + && read.nonceHex == QString(32, QLatin1Char('0')) + && read.dataHex.isEmpty(); + } + + 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, + SequencerClient* sequencer) + : m_wallet(wallet), + m_client(client), + m_sequencer(sequencer) +{ +} + +void NewPositionRuntime::clearWalletAccounts() +{ + ++m_walletGeneration; + m_walletPublicAccountIds.clear(); + clearPendingFreshLp(); + m_wallet->clearSnapshot(); + cancelSubmit(); +} + +void NewPositionRuntime::setWalletAccounts(const QVector& accounts) +{ + QStringList publicAccountIds; + for (const WalletAccount& account : accounts) { + if (account.isPublic) + publicAccountIds.append(account.address); + } + publicAccountIds.sort(Qt::CaseSensitive); + if (publicAccountIds != m_walletPublicAccountIds) { + if (!m_pendingFreshLpAccountId.isEmpty() + && !publicAccountIds.contains(m_pendingFreshLpAccountId) + && m_freshLpState == FreshLpState::Ready) { + clearPendingFreshLp(); + } + m_walletPublicAccountIds = std::move(publicAccountIds); + cancelSubmit(); + return; + } +} + +void NewPositionRuntime::rememberPendingFreshLp(const QString& accountId) +{ + m_pendingFreshLpAccountId = accountId; + m_freshLpState = FreshLpState::Ready; + if (!m_walletPublicAccountIds.contains(accountId)) { + m_walletPublicAccountIds.append(accountId); + m_walletPublicAccountIds.sort(Qt::CaseSensitive); + } +} + +void NewPositionRuntime::clearPendingFreshLp() +{ + m_pendingFreshLpAccountId.clear(); + m_freshLpState = FreshLpState::None; +} + +void NewPositionRuntime::cancelSubmit() +{ + if (!m_submitInFlight) + return; + ++m_submitGeneration; + m_submitInFlight = false; + ResultCallback callback = std::move(m_submitCallback); + m_submitCallback = {}; + if (callback) + callback(publicError(QStringLiteral("wallet_unavailable")).toVariantMap()); +} + +void NewPositionRuntime::finishSubmit(quint64 submitGeneration, QVariantMap result) +{ + if (!m_submitInFlight || submitGeneration != m_submitGeneration) + return; + m_submitInFlight = false; + ResultCallback callback = std::move(m_submitCallback); + m_submitCallback = {}; + if (callback) + callback(std::move(result)); +} + +bool NewPositionRuntime::submitIsCurrent(quint64 submitGeneration, + quint64 walletGeneration) const +{ + return m_submitInFlight + && submitGeneration == m_submitGeneration + && walletGeneration == m_walletGeneration; +} + +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 }, + }; +} + +void NewPositionRuntime::contextAsync(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool refreshPublicData, + ResultCallback callback) +{ + const quint64 contextGeneration = ++m_contextGeneration; + if (network.status != QStringLiteral("ready")) { + callback(contextState(network.status, network).toVariantMap()); + return; + } + if (!m_sequencer || !m_sequencer->isConfigured()) { + callback(contextState(QStringLiteral("error"), network, + QStringLiteral("sequencer_config_required")).toVariantMap()); + return; + } + + const AmmClientResult configResult = m_client->configId( + QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } }); + if (!configResult.ok) { + callback(contextState(QStringLiteral("error"), network, + QStringLiteral("backend_error")).toVariantMap()); + return; + } + const QString configId = configResult.value.value( + QStringLiteral("configId")).toString(); + QPointer guard(this); + m_sequencer->readAccounts({ configId }, refreshPublicData, + [guard, request, network, walletOpen, refreshPublicData, contextGeneration, + callback = std::move(callback)](QVector configReads) mutable { + if (!guard || contextGeneration != guard->m_contextGeneration) + return; + const QJsonObject config = accountReadJson(configReads.value(0)); + guard->m_sequencer->readAccounts( + guard->m_walletPublicAccountIds, refreshPublicData, + [guard, request, network, walletOpen, refreshPublicData, config, + contextGeneration, + callback = std::move(callback)]( + QVector walletReads) mutable { + if (!guard || contextGeneration != guard->m_contextGeneration) + return; + const QJsonObject hints = QJsonObject::fromVariantMap(request); + 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 QJsonArray walletAccounts = accountReadsJson(walletReads); + const AmmClientResult tokenResult = guard->m_client->tokenIds(QJsonObject { + { QStringLiteral("ammProgramId"), network.ammProgramId }, + { QStringLiteral("config"), config }, + { QStringLiteral("walletAccounts"), walletAccounts }, + { QStringLiteral("configuredTokenIds"), configured }, + { QStringLiteral("recentTokenIds"), recent }, + { QStringLiteral("resolvedTokenIds"), resolved }, + }); + if (!tokenResult.ok + || tokenResult.value.value(QStringLiteral("status")).toString() + != QStringLiteral("ok")) { + const QString code = tokenResult.ok + ? tokenResult.value.value(QStringLiteral("code")).toString() + : QStringLiteral("backend_error"); + callback(contextState(QStringLiteral("error"), network, + code.isEmpty() ? QStringLiteral("backend_error") : code) + .toVariantMap()); + return; + } + + QStringList definitionIds; + for (const QJsonValue& id : tokenResult.value + .value(QStringLiteral("tokenIds")).toArray()) { + definitionIds.append(id.toString()); + } + guard->m_sequencer->readAccounts(definitionIds, refreshPublicData, + [guard, network, walletOpen, config, walletAccounts, + configured, recent, resolved, contextGeneration, + callback = std::move(callback)]( + QVector definitions) mutable { + if (!guard + || contextGeneration != guard->m_contextGeneration) { + return; + } + const AmmClientResult result = guard->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"), + accountReadsJson(definitions) }, + { QStringLiteral("configuredTokenIds"), configured }, + { QStringLiteral("recentTokenIds"), recent }, + { QStringLiteral("resolvedTokenIds"), resolved }, + }); + callback((result.ok + ? result.value + : contextState(QStringLiteral("error"), network, + QStringLiteral("backend_error"))).toVariantMap()); + }); + }); + }); +} + +void NewPositionRuntime::buildQuoteInputAsync( + const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool forceRefresh, + std::function shouldContinue, + std::function callback) +{ + if (!shouldContinue()) + return; + if (network.status != QStringLiteral("ready")) { + callback({}, publicError(network.status)); + return; + } + if (!m_sequencer || !m_sequencer->isConfigured()) { + callback({}, publicError(QStringLiteral("sequencer_config_required"))); + return; + } + const AmmClientResult configResult = m_client->configId( + QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } }); + if (!configResult.ok) { + callback({}, publicError(QStringLiteral("backend_error"))); + return; + } + const QString configId = configResult.value.value( + QStringLiteral("configId")).toString(); + QPointer guard(this); + m_sequencer->readAccounts({ configId }, forceRefresh, + [guard, request, network, walletOpen, forceRefresh, + shouldContinue, + callback = std::move(callback)](QVector configReads) mutable { + if (!guard || !shouldContinue()) + return; + const QJsonObject config = accountReadJson(configReads.value(0)); + const QJsonObject requestObject = QJsonObject::fromVariantMap(request); + const AmmClientResult pairResult = guard->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 + || pairResult.value.value(QStringLiteral("status")).toString() + != QStringLiteral("ok")) { + const QString code = pairResult.ok + ? pairResult.value.value(QStringLiteral("code")).toString() + : QStringLiteral("backend_error"); + callback({}, publicError(code.isEmpty() + ? QStringLiteral("backend_error") : code)); + return; + } + const QJsonObject pair = pairResult.value; + const QStringList fixedIds { + pair.value(QStringLiteral("tokenAId")).toString(), + pair.value(QStringLiteral("tokenBId")).toString(), + pair.value(QStringLiteral("poolId")).toString(), + pair.value(QStringLiteral("vaultAId")).toString(), + pair.value(QStringLiteral("vaultBId")).toString(), + pair.value(QStringLiteral("lpDefinitionId")).toString(), + pair.value(QStringLiteral("lpLockHoldingId")).toString(), + pair.value(QStringLiteral("currentTickId")).toString(), + pair.value(QStringLiteral("clockId")).toString(), + }; + guard->m_sequencer->readAccounts(fixedIds, forceRefresh, + [guard, requestObject, network, walletOpen, config, pair, + shouldContinue, + callback = std::move(callback)]( + QVector fixedReads) mutable { + if (!guard || !shouldContinue()) + return; + QStringList selectedIds; + for (const QString& key : { + QStringLiteral("holdingAId"), + QStringLiteral("holdingBId"), + QStringLiteral("lpHoldingId") }) { + const QString id = accountIdHex( + requestObject.value(key).toString()); + if (!id.isEmpty() && !selectedIds.contains(id)) + selectedIds.append(id); + } + QStringList walletIds = guard->m_walletPublicAccountIds; + for (const QString& id : selectedIds) + walletIds.removeAll(id); + guard->m_sequencer->readAccounts( + walletIds, false, + [guard, requestObject, network, walletOpen, config, pair, + selectedIds = std::move(selectedIds), + fixedReads = std::move(fixedReads), + shouldContinue, + callback = std::move(callback)]( + QVector walletReads) mutable { + if (!guard || !shouldContinue()) + return; + auto finish = [requestObject, network, walletOpen, config, + fixedReads = std::move(fixedReads), + walletReads = std::move(walletReads), + shouldContinue, + callback = std::move(callback)]( + QVector selectedReads) mutable { + if (!shouldContinue()) + return; + QHash walletById; + for (const WalletAccountRead& read : walletReads) + walletById.insert(read.accountId, read); + for (const WalletAccountRead& read : selectedReads) + walletById.insert(read.accountId, read); + const QJsonArray walletAccounts = accountReadsJson( + walletById.values()); + const QJsonObject snapshot { + { QStringLiteral("config"), config }, + { QStringLiteral("tokenA"), accountReadJson(fixedReads.value(0)) }, + { QStringLiteral("tokenB"), accountReadJson(fixedReads.value(1)) }, + { QStringLiteral("pool"), accountReadJson(fixedReads.value(2)) }, + { QStringLiteral("vaultA"), accountReadJson(fixedReads.value(3)) }, + { QStringLiteral("vaultB"), accountReadJson(fixedReads.value(4)) }, + { QStringLiteral("lpDefinition"), accountReadJson(fixedReads.value(5)) }, + { QStringLiteral("lpLockHolding"), accountReadJson(fixedReads.value(6)) }, + { QStringLiteral("currentTick"), accountReadJson(fixedReads.value(7)) }, + { QStringLiteral("clock"), accountReadJson(fixedReads.value(8)) }, + { QStringLiteral("walletAvailable"), walletOpen }, + { QStringLiteral("walletAccounts"), walletAccounts }, + }; + callback(QJsonObject { + { QStringLiteral("networkId"), network.id }, + { QStringLiteral("networkFingerprint"), network.fingerprint }, + { QStringLiteral("ammProgramId"), network.ammProgramId }, + { QStringLiteral("request"), requestObject }, + { QStringLiteral("snapshot"), snapshot }, + }, {}); + }; + if (selectedIds.isEmpty()) + finish({}); + else + guard->m_sequencer->readAccounts( + selectedIds, true, std::move(finish)); + }); + }); + }); +} + +void NewPositionRuntime::quoteFromAccountsAsync( + const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool forceRefresh, + std::function shouldContinue, + ResultCallback callback) +{ + QPointer guard(this); + buildQuoteInputAsync(request, network, walletOpen, forceRefresh, + shouldContinue, + [guard, shouldContinue, + callback = std::move(callback)]( + QJsonObject input, QJsonObject error) mutable { + if (!guard || !shouldContinue()) + return; + if (!error.isEmpty()) { + callback(error.toVariantMap()); + return; + } + const AmmClientResult result = guard->m_client->quote(input); + callback((result.ok ? result.value + : publicError(QStringLiteral("backend_error"))) + .toVariantMap()); + }); +} + +void NewPositionRuntime::quoteAsync(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool forceRefresh, + bool isPoolProbe, + ResultCallback callback) +{ + QPointer guard(this); + const quint64 quoteGeneration = isPoolProbe + ? 0 : ++m_userQuoteGeneration; + std::function shouldContinue = [guard, quoteGeneration]() { + return guard + && (quoteGeneration == 0 + || quoteGeneration == guard->m_userQuoteGeneration); + }; + const QString poolId = accountIdHex( + request.value(QStringLiteral("poolId")).toString()); + if (!isPoolProbe || poolId.isEmpty() + || network.status != QStringLiteral("ready") + || !m_sequencer || !m_sequencer->isConfigured()) { + quoteFromAccountsAsync( + request, network, walletOpen, forceRefresh, + std::move(shouldContinue), std::move(callback)); + return; + } + + m_sequencer->readAccounts( + { poolId }, true, + [guard, request, network, walletOpen, forceRefresh, poolId, + shouldContinue = std::move(shouldContinue), + callback = std::move(callback)]( + QVector reads) mutable { + if (!guard || !shouldContinue()) + return; + const WalletAccountRead read = reads.value(0); + if (!read.ok()) { + callback(publicError( + QStringLiteral("account_read_failed")).toVariantMap()); + return; + } + if (isDefaultAccountRead(read, poolId)) { + callback(QJsonObject { + { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, + { QStringLiteral("status"), QStringLiteral("ok") }, + { QStringLiteral("canSubmit"), false }, + { QStringLiteral("poolStatus"), QStringLiteral("missing_pool") }, + { QStringLiteral("poolId"), + request.value(QStringLiteral("poolId")).toString() }, + { QStringLiteral("tokenAId"), + request.value(QStringLiteral("tokenAId")).toString() }, + { QStringLiteral("tokenBId"), + request.value(QStringLiteral("tokenBId")).toString() }, + }.toVariantMap()); + return; + } + guard->quoteFromAccountsAsync( + request, network, walletOpen, forceRefresh, + std::move(shouldContinue), std::move(callback)); + }); +} + +void NewPositionRuntime::submitAsync(const QVariantMap& request, + const QString& quoteHash, + const ActiveNetworkSnapshot& network, + bool walletCanSubmit, + ResultCallback callback) +{ + if (m_submitInFlight) { + callback(publicError(QStringLiteral("submit_in_progress")).toVariantMap()); + return; + } + if (!walletCanSubmit) { + callback(publicError(QStringLiteral("wallet_syncing")).toVariantMap()); + return; + } + if (m_freshLpState == FreshLpState::Creating + || m_freshLpState == FreshLpState::Submitting) { + callback(publicError(QStringLiteral("submit_in_progress")).toVariantMap()); + return; + } + m_submitInFlight = true; + const quint64 submitGeneration = ++m_submitGeneration; + const quint64 walletGeneration = m_walletGeneration; + m_submitCallback = std::move(callback); + QPointer guard(this); + std::function shouldContinue = + [guard, submitGeneration, walletGeneration]() { + return guard + && guard->submitIsCurrent(submitGeneration, walletGeneration); + }; + buildQuoteInputAsync(request, network, true, true, + shouldContinue, + [guard, quoteHash, submitGeneration, walletGeneration, + shouldContinue]( + QJsonObject input, QJsonObject error) mutable { + if (!guard || !shouldContinue()) + return; + if (!error.isEmpty()) { + guard->finishSubmit(submitGeneration, error.toVariantMap()); + return; + } + const AmmClientResult quoteResult = guard->m_client->quote(input); + if (!quoteResult.ok) { + guard->finishSubmit( + submitGeneration, + publicError(QStringLiteral("backend_error")).toVariantMap()); + return; + } + const QJsonObject quote = quoteResult.value; + if (quote.value(QStringLiteral("quoteHash")).toString() != quoteHash) { + QJsonObject result = publicError(QStringLiteral("quote_changed")); + result.insert(QStringLiteral("quote"), quote); + guard->finishSubmit(submitGeneration, result.toVariantMap()); + return; + } + if (!quote.value(QStringLiteral("canSubmit")).toBool(false)) { + QJsonObject result = publicError(QStringLiteral("quote_not_submittable")); + result.insert(QStringLiteral("quote"), quote); + guard->finishSubmit(submitGeneration, result.toVariantMap()); + return; + } + + if (quote.value(QStringLiteral("requiresFreshLp")).toBool(false)) { + guard->prepareFreshLpAsync( + std::move(input), quoteHash, + submitGeneration, walletGeneration); + return; + } + guard->submitPlanAsync( + std::move(input), quoteHash, {}, {}, + submitGeneration, walletGeneration); + }); +} + +void NewPositionRuntime::prepareFreshLpAsync(QJsonObject input, + const QString& quoteHash, + quint64 submitGeneration, + quint64 walletGeneration) +{ + if (!submitIsCurrent(submitGeneration, walletGeneration)) + return; + if (m_freshLpState == FreshLpState::Ready + && !m_pendingFreshLpAccountId.isEmpty()) { + validatePendingFreshLpAsync( + std::move(input), quoteHash, + submitGeneration, walletGeneration); + return; + } + if (m_freshLpState != FreshLpState::None) { + finishSubmit( + submitGeneration, + publicError(QStringLiteral("submit_in_progress")).toVariantMap()); + return; + } + + m_freshLpState = FreshLpState::Creating; + QPointer guard(this); + m_wallet->createAccountAsync( + true, + [guard, input = std::move(input), quoteHash, + submitGeneration, walletGeneration]( + WalletAccountCreation creation) mutable { + if (!guard || walletGeneration != guard->m_walletGeneration) + return; + + const bool reusable = creation.ok() + && isLowerHex(creation.accountId, ACCOUNT_ID_HEX_SIZE); + if (reusable) + guard->rememberPendingFreshLp(creation.accountId); + else + guard->clearPendingFreshLp(); + if (!guard->submitIsCurrent(submitGeneration, walletGeneration)) + return; + if (!reusable) { + const QString code = creation.failure + == WalletFailure::WalletUnavailable + ? QStringLiteral("wallet_unavailable") + : QStringLiteral("wallet_submission_failed"); + guard->finishSubmit( + submitGeneration, publicError(code).toVariantMap()); + return; + } + + WalletAccountRead read = std::move(creation.publicAccount); + read.accountId = creation.accountId; + if (isDefaultAccountRead(read, creation.accountId)) { + guard->submitPlanAsync( + std::move(input), quoteHash, accountReadJson(read), + creation.accountId, submitGeneration, walletGeneration); + return; + } + guard->validatePendingFreshLpAsync( + std::move(input), quoteHash, + submitGeneration, walletGeneration); + }); +} + +void NewPositionRuntime::validatePendingFreshLpAsync( + QJsonObject input, + const QString& quoteHash, + quint64 submitGeneration, + quint64 walletGeneration) +{ + if (!submitIsCurrent(submitGeneration, walletGeneration)) + return; + const QString accountId = m_pendingFreshLpAccountId; + if (m_freshLpState != FreshLpState::Ready + || accountId.isEmpty() + || !m_sequencer + || !m_sequencer->isConfigured()) { + finishSubmit( + submitGeneration, + publicError(QStringLiteral("account_read_failed")).toVariantMap()); + return; + } + + QPointer guard(this); + m_sequencer->readAccounts( + { accountId }, true, + [guard, input = std::move(input), quoteHash, accountId, + submitGeneration, walletGeneration]( + QVector reads) mutable { + if (!guard + || !guard->submitIsCurrent( + submitGeneration, walletGeneration)) { + return; + } + if (guard->m_pendingFreshLpAccountId != accountId) { + guard->finishSubmit( + submitGeneration, + publicError(QStringLiteral("wallet_unavailable")).toVariantMap()); + return; + } + const WalletAccountRead read = reads.value(0); + if (!read.ok()) { + guard->finishSubmit( + submitGeneration, + publicError(QStringLiteral("account_read_failed")).toVariantMap()); + return; + } + if (!isDefaultAccountRead(read, accountId)) { + guard->clearPendingFreshLp(); + guard->finishSubmit( + submitGeneration, + publicError(QStringLiteral("submission_status_unknown")).toVariantMap()); + return; + } + guard->submitPlanAsync( + std::move(input), quoteHash, accountReadJson(read), accountId, + submitGeneration, walletGeneration); + }); +} + +void NewPositionRuntime::submitPlanAsync(QJsonObject input, + const QString& quoteHash, + QJsonValue freshLp, + QString freshLpAccountId, + quint64 submitGeneration, + quint64 walletGeneration) +{ + if (!submitIsCurrent(submitGeneration, walletGeneration)) + return; + + input.insert(QStringLiteral("quoteHash"), quoteHash); + input.insert(QStringLiteral("nowMs"), QDateTime::currentMSecsSinceEpoch()); + if (!freshLp.isUndefined() && !freshLp.isNull()) + input.insert(QStringLiteral("freshLp"), std::move(freshLp)); + const AmmClientResult planResult = m_client->plan(input); + if (!planResult.ok) { + finishSubmit( + submitGeneration, + publicError(QStringLiteral("backend_error")).toVariantMap()); + return; + } + const QJsonObject plan = planResult.value; + if (plan.value(QStringLiteral("status")).toString() + != QStringLiteral("ready")) { + const QString code = plan.value(QStringLiteral("code")).toString(); + finishSubmit( + submitGeneration, + publicError(code.isEmpty() + ? QStringLiteral("wallet_submission_failed") : code).toVariantMap()); + return; + } + + bool deadlineValid = false; + const qulonglong deadline = plan.value(QStringLiteral("deadlineMs")) + .toString().toULongLong(&deadlineValid); + if (!deadlineValid + || static_cast(QDateTime::currentMSecsSinceEpoch()) >= deadline) { + finishSubmit( + submitGeneration, + publicError(QStringLiteral("transaction_deadline_expired")).toVariantMap()); + return; + } + if (!submitIsCurrent(submitGeneration, walletGeneration)) + return; + + WalletTransaction transaction { + plan.value(QStringLiteral("programId")).toString(), + jsonStringList(plan.value(QStringLiteral("accountIds")).toArray()), + jsonBoolList(plan.value(QStringLiteral("signingRequirements")).toArray()), + jsonUIntList(plan.value(QStringLiteral("instruction")).toArray()), + }; + if (!freshLpAccountId.isEmpty() + && freshLpAccountId == m_pendingFreshLpAccountId) { + m_freshLpState = FreshLpState::Submitting; + } + QPointer guard(this); + m_wallet->submitPublicTransactionAsync( + transaction, + [guard, submitGeneration, walletGeneration, + freshLpAccountId = std::move(freshLpAccountId), deadlineMs = + plan.value(QStringLiteral("deadlineMs")), affectedAccountIds = + plan.value(QStringLiteral("affectedAccountIds"))]( + WalletSubmission submission) mutable { + if (!guard) + return; + const QString transactionId = submission.accepted() + ? base58TransactionId(submission.nativeHash) : QString(); + if (walletGeneration == guard->m_walletGeneration + && !freshLpAccountId.isEmpty() + && freshLpAccountId == guard->m_pendingFreshLpAccountId) { + if (submission.accepted()) + guard->clearPendingFreshLp(); + else + guard->m_freshLpState = FreshLpState::Ready; + } + if (!guard->submitIsCurrent(submitGeneration, walletGeneration)) + return; + if (transactionId.isEmpty()) { + const QString code = submission.failure + == WalletFailure::WalletUnavailable + ? QStringLiteral("wallet_unavailable") + : QStringLiteral("wallet_submission_failed"); + guard->finishSubmit( + submitGeneration, publicError(code).toVariantMap()); + return; + } + guard->finishSubmit(submitGeneration, QJsonObject { + { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, + { QStringLiteral("status"), QStringLiteral("submitted") }, + { QStringLiteral("transactionId"), transactionId }, + { QStringLiteral("nativeTransactionHash"), submission.nativeHash }, + { QStringLiteral("deadlineMs"), deadlineMs }, + { QStringLiteral("affectedAccountIds"), + affectedAccountIds }, + }.toVariantMap()); + }); +} + +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(); + if (m_freshLpState == FreshLpState::Creating + || m_freshLpState == FreshLpState::Submitting) { + return publicError(QStringLiteral("submit_in_progress")).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; + QString freshLpAccountId; + if (quote.value(QStringLiteral("requiresFreshLp")).toBool(false)) { + WalletAccountRead read; + if (m_freshLpState == FreshLpState::None) { + m_freshLpState = FreshLpState::Creating; + const WalletAccountCreation creation = m_wallet->createAccount(true); + if (!creation.ok() + || !isLowerHex(creation.accountId, ACCOUNT_ID_HEX_SIZE)) { + clearPendingFreshLp(); + return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); + } + rememberPendingFreshLp(creation.accountId); + read = creation.publicAccount; + read.accountId = creation.accountId; + } else if (m_freshLpState == FreshLpState::Ready + && !m_pendingFreshLpAccountId.isEmpty()) { + read = m_wallet->readPublicAccount(m_pendingFreshLpAccountId); + } else { + return publicError(QStringLiteral("submit_in_progress")).toVariantMap(); + } + freshLpAccountId = m_pendingFreshLpAccountId; + if (!read.ok()) + return publicError(QStringLiteral("account_read_failed")).toVariantMap(); + if (!isDefaultAccountRead(read, freshLpAccountId)) { + clearPendingFreshLp(); + return publicError(QStringLiteral("submission_status_unknown")).toVariantMap(); + } + freshLp = accountReadJson(read); + } + + 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(); + } + if (!freshLpAccountId.isEmpty() + && freshLpAccountId == m_pendingFreshLpAccountId) { + m_freshLpState = FreshLpState::Submitting; + } + const WalletSubmission submission = m_wallet->submitPublicTransaction({ + programId, + accountIds, + signingRequirements, + instruction, + }); + if (!submission.accepted()) { + if (!freshLpAccountId.isEmpty() + && freshLpAccountId == m_pendingFreshLpAccountId) { + m_freshLpState = FreshLpState::Ready; + } + return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); + } + if (!freshLpAccountId.isEmpty() + && freshLpAccountId == m_pendingFreshLpAccountId) { + clearPendingFreshLp(); + } + 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("nativeTransactionHash"), submission.nativeHash }, + { 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 00000000..75914c58 --- /dev/null +++ b/apps/amm/src/NewPositionRuntime.h @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include "ActiveNetwork.h" + +class AmmClient; +class WalletProvider; +class SequencerClient; +struct WalletAccount; + +class NewPositionRuntime : public QObject { +public: + using ResultCallback = std::function; + + NewPositionRuntime(WalletProvider* wallet, + AmmClient* client, + SequencerClient* sequencer = nullptr); + + void clearWalletAccounts(); + void setWalletAccounts(const QVector& accounts); + + void contextAsync(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool refreshPublicData, + ResultCallback callback); + void quoteAsync(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool forceRefresh, + bool isPoolProbe, + ResultCallback callback); + void submitAsync(const QVariantMap& request, + const QString& quoteHash, + const ActiveNetworkSnapshot& network, + bool walletCanSubmit, + ResultCallback callback); + void cancelSubmit(); + + 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: + enum class FreshLpState { + None, + Creating, + Ready, + Submitting, + }; + + QJsonArray walletAccountReads(bool walletOpen, bool refresh) const; + QJsonObject buildQuoteInput(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool freshWalletAccounts, + QJsonObject* error) const; + void buildQuoteInputAsync(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool forceRefresh, + std::function shouldContinue, + std::function callback); + void quoteFromAccountsAsync(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool forceRefresh, + std::function shouldContinue, + ResultCallback callback); + void submitPlanAsync(QJsonObject input, + const QString& quoteHash, + QJsonValue freshLp, + QString freshLpAccountId, + quint64 submitGeneration, + quint64 walletGeneration); + void prepareFreshLpAsync(QJsonObject input, + const QString& quoteHash, + quint64 submitGeneration, + quint64 walletGeneration); + void validatePendingFreshLpAsync(QJsonObject input, + const QString& quoteHash, + quint64 submitGeneration, + quint64 walletGeneration); + void rememberPendingFreshLp(const QString& accountId); + void clearPendingFreshLp(); + void finishSubmit(quint64 submitGeneration, QVariantMap result); + bool submitIsCurrent(quint64 submitGeneration, + quint64 walletGeneration) const; + + WalletProvider* m_wallet; + AmmClient* m_client; + SequencerClient* m_sequencer; + QStringList m_walletPublicAccountIds; + QString m_pendingFreshLpAccountId; + FreshLpState m_freshLpState = FreshLpState::None; + bool m_submitInFlight = false; + quint64 m_walletGeneration = 0; + quint64 m_contextGeneration = 0; + quint64 m_userQuoteGeneration = 0; + quint64 m_submitGeneration = 0; + ResultCallback m_submitCallback; +}; diff --git a/apps/amm/src/SequencerClient.cpp b/apps/amm/src/SequencerClient.cpp new file mode 100644 index 00000000..d1998950 --- /dev/null +++ b/apps/amm/src/SequencerClient.cpp @@ -0,0 +1,343 @@ +#include "SequencerClient.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "AmmClient.h" + +namespace { +constexpr int MAX_CONCURRENT_READS = 8; +constexpr qsizetype ACCOUNT_ID_HEX_SIZE = 64; +constexpr std::size_t BASE58_BUFFER_SIZE = 45; + +bool isLowerHex(const QString& value, qsizetype size) +{ + if (value.size() != size) + return false; + return std::all_of(value.cbegin(), value.cend(), [](QChar character) { + return character.isDigit() + || (character >= QLatin1Char('a') && character <= QLatin1Char('f')); + }); +} + +QString accountIdBase58(const QString& accountId) +{ + if (!isLowerHex(accountId, ACCOUNT_ID_HEX_SIZE)) + return {}; + const QByteArray bytes = QByteArray::fromHex(accountId.toLatin1()); + 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)); +} + +QByteArray rpcBody(const QString& method, const QJsonArray& params) +{ + return QJsonDocument(QJsonObject { + { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, + { QStringLiteral("id"), 1 }, + { QStringLiteral("method"), method }, + { QStringLiteral("params"), params }, + }).toJson(QJsonDocument::Compact); +} + +WalletAccountRead accountRead(const QJsonObject& value, const QString& fallbackId) +{ + WalletAccountRead read; + read.accountId = value.value(QStringLiteral("id")).toString(fallbackId); + read.status = value.value(QStringLiteral("status")).toString( + QStringLiteral("read_failed")); + const QJsonObject account = value.value(QStringLiteral("account")).toObject(); + if (read.ok()) { + read.programOwner = account.value(QStringLiteral("program_owner")).toString(); + read.balanceHex = account.value(QStringLiteral("balance")).toString(); + read.nonceHex = account.value(QStringLiteral("nonce")).toString(); + read.dataHex = account.value(QStringLiteral("data")).toString(); + } + return read; +} +} + +SequencerClient::SequencerClient(AmmClient* client, QObject* parent) + : QObject(parent), + m_client(client), + m_network(new QNetworkAccessManager(this)) +{ +} + +bool SequencerClient::configure(const QString& configPath, + const QString& effectiveEndpoint) +{ + QUrl configuredEndpoint; + QByteArray authorization; + QFile file(configPath); + bool configValid = file.open(QIODevice::ReadOnly); + if (configValid) { + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + const QJsonObject config = document.object(); + configuredEndpoint = QUrl( + config.value(QStringLiteral("sequencer_addr")).toString()); + const QString scheme = configuredEndpoint.scheme().toLower(); + configValid = document.isObject() + && configuredEndpoint.isValid() + && !configuredEndpoint.host().isEmpty() + && (scheme == QStringLiteral("http") || scheme == QStringLiteral("https")); + if (configValid) { + const QJsonObject basicAuth = config.value(QStringLiteral("basic_auth")).toObject(); + const QString username = basicAuth.value(QStringLiteral("username")).toString(); + const QString password = basicAuth.value(QStringLiteral("password")).toString(); + if (!username.isEmpty()) { + authorization = QByteArrayLiteral("Basic ") + + (username + QLatin1Char(':') + password).toUtf8().toBase64(); + } + } + } + + QUrl endpoint = effectiveEndpoint.isEmpty() + ? configuredEndpoint : QUrl(effectiveEndpoint); + const QString scheme = endpoint.scheme().toLower(); + const bool valid = endpoint.isValid() + && !endpoint.host().isEmpty() + && (scheme == QStringLiteral("http") || scheme == QStringLiteral("https")) + && (configValid || !effectiveEndpoint.isEmpty()); + if (!valid) + endpoint = QUrl(); + if (!configValid || configuredEndpoint != endpoint) + authorization.clear(); + if (endpoint == m_endpoint && authorization == m_authorization) + return valid; + + ++m_generation; + cancelPendingReads(); + clear(); + m_endpoint = endpoint; + m_authorization = authorization; + return valid; +} + +void SequencerClient::readAccounts(const QStringList& accountIds, + bool forceRefresh, + AccountsCallback callback) +{ + if (accountIds.isEmpty()) { + QTimer::singleShot(0, this, [callback = std::move(callback)]() mutable { + callback({}); + }); + return; + } + + struct Batch { + QVector reads; + qsizetype remaining = 0; + AccountsCallback callback; + }; + auto batch = std::make_shared(); + batch->reads.resize(accountIds.size()); + batch->remaining = accountIds.size(); + batch->callback = std::move(callback); + for (qsizetype index = 0; index < accountIds.size(); ++index) { + readAccount(accountIds.at(index), forceRefresh, + [batch, index](WalletAccountRead read) mutable { + batch->reads[index] = std::move(read); + if (--batch->remaining == 0) + batch->callback(std::move(batch->reads)); + }); + } +} + +void SequencerClient::readAccount(const QString& accountId, + bool forceRefresh, + AccountCallback callback) +{ + if (!isConfigured() || !isLowerHex(accountId, ACCOUNT_ID_HEX_SIZE)) { + QTimer::singleShot(0, this, + [callback = std::move(callback), accountId]() mutable { + callback(WalletAccountRead { accountId }); + }); + return; + } + if (forceRefresh) + m_cache.remove(accountId); + if (!forceRefresh && m_cache.contains(accountId)) { + const WalletAccountRead cached = m_cache.value(accountId); + const quint64 generation = m_generation; + QTimer::singleShot(0, this, + [this, callback = std::move(callback), cached, accountId, + generation]() mutable { + callback(generation == m_generation + ? cached : WalletAccountRead { accountId }); + }); + return; + } + if (forceRefresh && m_activeReadIds.contains(accountId)) { + m_forcedWaiters[accountId].append(std::move(callback)); + return; + } + + const bool alreadyPending = m_waiters.contains(accountId); + m_waiters[accountId].append(std::move(callback)); + if (!alreadyPending) + m_pending.enqueue({ accountId }); + startPendingReads(); +} + +void SequencerClient::startPendingReads() +{ + while (m_activeReads < MAX_CONCURRENT_READS && !m_pending.isEmpty()) { + const PendingRead pending = m_pending.dequeue(); + ++m_activeReads; + m_activeReadIds.insert(pending.accountId); + startRead(pending.accountId); + } +} + +void SequencerClient::startRead(const QString& accountId) +{ + const quint64 generation = m_generation; + const QString encodedId = accountIdBase58(accountId); + if (encodedId.isEmpty()) { + completeRead(accountId, WalletAccountRead { accountId }); + return; + } + + QNetworkReply* reply = m_network->post( + request(), rpcBody(QStringLiteral("getAccount"), QJsonArray { encodedId })); + connect(reply, &QNetworkReply::finished, this, + [this, reply, accountId, generation]() { + if (generation != m_generation) { + reply->deleteLater(); + return; + } + WalletAccountRead read { accountId }; + const int status = reply->attribute( + QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray payload = reply->readAll(); + if (reply->error() == QNetworkReply::NoError + && status >= 200 && status < 300) { + const AmmClientResult normalized = + m_client->normalizeAccountRpc(QJsonObject { + { QStringLiteral("accountId"), accountId }, + { QStringLiteral("response"), QString::fromUtf8(payload) }, + }); + if (normalized.ok) + read = accountRead(normalized.value, accountId); + } + reply->deleteLater(); + completeRead(accountId, read); + }); +} + +void SequencerClient::completeRead(const QString& accountId, + const WalletAccountRead& read) +{ + if (read.ok()) + m_cache.insert(accountId, read); + const QVector callbacks = m_waiters.take(accountId); + const QVector forcedCallbacks = m_forcedWaiters.take(accountId); + m_activeReadIds.remove(accountId); + --m_activeReads; + if (!forcedCallbacks.isEmpty()) { + m_cache.remove(accountId); + m_waiters.insert(accountId, forcedCallbacks); + m_pending.enqueue({ accountId }); + } + for (const AccountCallback& callback : callbacks) + callback(read); + startPendingReads(); +} + +void SequencerClient::queryTransaction(const QString& nativeHash, + TransactionCallback callback) +{ + if (!isConfigured() || !isLowerHex(nativeHash, ACCOUNT_ID_HEX_SIZE)) { + QTimer::singleShot(0, this, + [callback = std::move(callback)]() mutable { callback(false, false); }); + return; + } + const quint64 generation = m_generation; + QNetworkReply* reply = m_network->post( + request(), rpcBody(QStringLiteral("getTransaction"), QJsonArray { nativeHash })); + connect(reply, &QNetworkReply::finished, this, + [this, reply, generation, callback = std::move(callback)]() mutable { + if (generation != m_generation) { + reply->deleteLater(); + callback(false, false); + return; + } + const int status = reply->attribute( + QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray payload = reply->readAll(); + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(payload, &error); + const QJsonObject envelope = document.object(); + const bool ok = reply->error() == QNetworkReply::NoError + && status >= 200 && status < 300 + && error.error == QJsonParseError::NoError + && document.isObject() + && (!envelope.contains(QStringLiteral("error")) + || envelope.value(QStringLiteral("error")).isNull()) + && envelope.contains(QStringLiteral("result")); + const bool included = ok + && !envelope.value(QStringLiteral("result")).isNull(); + reply->deleteLater(); + callback(ok, included); + }); +} + +void SequencerClient::clear() +{ + m_cache.clear(); +} + +void SequencerClient::cancelPendingReads() +{ + decltype(m_waiters) waiters; + waiters.swap(m_waiters); + for (auto iterator = m_forcedWaiters.cbegin(); + iterator != m_forcedWaiters.cend(); ++iterator) { + waiters[iterator.key()].append(iterator.value()); + } + m_forcedWaiters.clear(); + m_pending.clear(); + m_activeReadIds.clear(); + m_activeReads = 0; + for (auto iterator = waiters.cbegin(); iterator != waiters.cend(); ++iterator) { + const WalletAccountRead failed { iterator.key() }; + for (const AccountCallback& callback : iterator.value()) { + QTimer::singleShot(0, this, [callback, failed]() { + callback(failed); + }); + } + } +} + +void SequencerClient::applyAuthorization(QNetworkRequest& request) const +{ + if (!m_authorization.isEmpty()) + request.setRawHeader(QByteArrayLiteral("Authorization"), m_authorization); +} + +QNetworkRequest SequencerClient::request() const +{ + QNetworkRequest request(m_endpoint); + request.setHeader(QNetworkRequest::ContentTypeHeader, + QStringLiteral("application/json")); + request.setTransferTimeout(4000); + applyAuthorization(request); + return request; +} diff --git a/apps/amm/src/SequencerClient.h b/apps/amm/src/SequencerClient.h new file mode 100644 index 00000000..342ad7b6 --- /dev/null +++ b/apps/amm/src/SequencerClient.h @@ -0,0 +1,65 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "WalletProvider.h" + +class AmmClient; +class QNetworkAccessManager; +class QNetworkRequest; + +class SequencerClient final : public QObject { + Q_OBJECT + +public: + using AccountsCallback = std::function)>; + using TransactionCallback = std::function; + + explicit SequencerClient(AmmClient* client, QObject* parent = nullptr); + + bool configure(const QString& configPath, + const QString& effectiveEndpoint = {}); + QString endpoint() const { return m_endpoint.toString(); } + bool isConfigured() const { return m_endpoint.isValid() && !m_endpoint.isEmpty(); } + void applyAuthorization(QNetworkRequest& request) const; + + void readAccounts(const QStringList& accountIds, + bool forceRefresh, + AccountsCallback callback); + void queryTransaction(const QString& nativeHash, TransactionCallback callback); + void clear(); + +private: + using AccountCallback = std::function; + + struct PendingRead { + QString accountId; + }; + + void readAccount(const QString& accountId, bool forceRefresh, AccountCallback callback); + void startPendingReads(); + void startRead(const QString& accountId); + void completeRead(const QString& accountId, const WalletAccountRead& read); + void cancelPendingReads(); + QNetworkRequest request() const; + + AmmClient* m_client; + QNetworkAccessManager* m_network; + QUrl m_endpoint; + QByteArray m_authorization; + QHash m_cache; + QHash> m_waiters; + QHash> m_forcedWaiters; + QQueue m_pending; + QSet m_activeReadIds; + int m_activeReads = 0; + quint64 m_generation = 0; +}; diff --git a/apps/amm/tests/cpp/ActiveNetworkTest.cpp b/apps/amm/tests/cpp/ActiveNetworkTest.cpp new file mode 100644 index 00000000..6374aae5 --- /dev/null +++ b/apps/amm/tests/cpp/ActiveNetworkTest.cpp @@ -0,0 +1,82 @@ +#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.finishIdentityProbe({}); + if (!expect(network.identityRetryDelayMs() == 1000, + "first failed identity probe should back off for one second")) + return 1; + network.finishIdentityProbe({}); + if (!expect(network.identityRetryDelayMs() == 2000, + "repeated identity failures should increase the delay")) + return 1; + for (int attempt = 0; attempt < 8; ++attempt) + network.finishIdentityProbe({}); + if (!expect(network.identityRetryDelayMs() == 30000, + "identity retry delay should cap at thirty seconds")) + return 1; + + network.sequencerChanged(true); + if (!expect(network.identityRetryDelayMs() == 0, + "sequencer changes should reset identity retry backoff")) + return 1; + 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 00000000..a4ae1581 --- /dev/null +++ b/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp @@ -0,0 +1,1482 @@ +#include "AmmClient.h" +#include "NewPositionRuntime.h" +#include "SequencerClient.h" +#include "WalletProvider.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + bool expect(bool condition, const char* message) + { + if (!condition) + qCritical("%s", message); + return condition; + } + + class LocalRpcServer final { + public: + LocalRpcServer() + { + QObject::connect(&m_server, &QTcpServer::newConnection, [&]() { + while (m_server.hasPendingConnections()) { + QTcpSocket* socket = m_server.nextPendingConnection(); + QObject::connect(socket, &QTcpSocket::readyRead, socket, + [this, socket]() { process(socket); }); + if (socket->bytesAvailable() > 0) + process(socket); + } + }); + } + + bool listen() + { + return m_server.listen(QHostAddress::LocalHost); + } + + QString endpoint() const + { + return QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort()); + } + + int requestCount() const { return m_requestCount; } + QByteArray lastRequest() const + { + return m_lastRequest; + } + void failNextRequest() { ++m_failuresRemaining; } + void holdResponses() { m_holdResponses = true; } + void releaseNextResponse() + { + if (m_heldResponses.isEmpty()) + return; + const HeldResponse held = m_heldResponses.takeFirst(); + respond(held.socket, held.fail); + } + + private: + struct HeldResponse { + QTcpSocket* socket = nullptr; + bool fail = false; + }; + + static void respond(QTcpSocket* socket, bool fail) + { + if (!socket) + return; + const QByteArray payload = QByteArrayLiteral( + R"({"jsonrpc":"2.0","id":1,"result":null})"); + QByteArray response = fail + ? QByteArrayLiteral( + "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: ") + : QByteArrayLiteral( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: "); + response += QByteArray::number(payload.size()); + response += QByteArrayLiteral("\r\nConnection: close\r\n\r\n"); + response += payload; + socket->write(response); + socket->disconnectFromHost(); + } + + void process(QTcpSocket* socket) + { + QByteArray& request = m_requests[socket]; + request.append(socket->readAll()); + const qsizetype headerEnd = request.indexOf("\r\n\r\n"); + if (headerEnd < 0) + return; + + qsizetype contentLength = 0; + for (QByteArray line : request.first(headerEnd).split('\n')) { + line = line.trimmed(); + if (line.toLower().startsWith("content-length:")) { + contentLength = line.mid(sizeof("content-length:") - 1) + .trimmed().toLongLong(); + } + } + const qsizetype bodyStart = headerEnd + 4; + if (request.size() - bodyStart < contentLength) + return; + + ++m_requestCount; + m_lastRequest = request; + const bool fail = m_failuresRemaining > 0; + if (fail) + --m_failuresRemaining; + m_requests.remove(socket); + if (m_holdResponses) { + m_heldResponses.append({ socket, fail }); + return; + } + respond(socket, fail); + } + + QTcpServer m_server; + QHash m_requests; + QByteArray m_lastRequest; + int m_requestCount = 0; + int m_failuresRemaining = 0; + bool m_holdResponses = false; + QVector m_heldResponses; + }; + + bool waitForRequestCount(const LocalRpcServer& server, int expected) + { + QElapsedTimer timer; + timer.start(); + while (server.requestCount() < expected && timer.elapsed() < 3000) + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + return server.requestCount() >= expected; + } + + bool waitForCallbacks(const bool& first, const bool& second) + { + QElapsedTimer timer; + timer.start(); + while ((!first || !second) && timer.elapsed() < 3000) + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + return first && second; + } + + bool waitForCondition(const std::function& condition) + { + QElapsedTimer timer; + timer.start(); + while (!condition() && timer.elapsed() < 3000) + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + return condition(); + } + + class FakeWallet final : public WalletProvider { + public: + WalletSession connect(const WalletPaths&) override + { + return {}; + } + + void connectAsync(const WalletPaths&, SessionCallback callback) override + { + callback({}); + } + + WalletCreation createWallet(const WalletPaths&, const QString&) override + { + return {}; + } + + WalletSnapshot snapshot(bool) override + { + return {}; + } + + void snapshotAsync(bool, SnapshotCallback callback) override + { + callback({}); + } + + void clearSnapshot() override {} + + WalletAccountCreation createAccount(bool isPublic) override + { + ++createdAccounts; + WalletAccountCreation creation; + const QLatin1Char accountDigit( + "abcdef"[(createdAccounts - 1) % 6]); + creation.accountId = QString(64, accountDigit); + if (isPublic && !failCreatedPublicRead) + creation.publicAccount = readPublicAccount(creation.accountId); + else + creation.publicAccount.accountId = creation.accountId; + return creation; + } + + void createAccountAsync(bool isPublic, AccountCreationCallback callback) override + { + WalletAccountCreation creation = createAccount(isPublic); + if (deferAccountCreation) { + pendingAccountCreation = std::move(callback); + pendingCreation = std::move(creation); + } else { + callback(std::move(creation)); + } + } + + WalletAccountRead readPublicAccount(const QString& accountId) const override + { + WalletAccountRead read; + read.accountId = accountId; + read.status = QStringLiteral("ok"); + read.programOwner = QString(64, QLatin1Char('0')); + read.balanceHex = readBalanceHex; + read.nonceHex = QString(32, QLatin1Char('0')); + return read; + } + + WalletSubmission submitPublicTransaction(const WalletTransaction& transaction) override + { + ++submissions; + submitted = transaction; + if (submissionFailuresRemaining > 0) { + --submissionFailuresRemaining; + return { WalletFailure::SubmissionFailed, {} }; + } + return { WalletFailure::None, transactionHash }; + } + + void submitPublicTransactionAsync( + const WalletTransaction& transaction, SubmissionCallback callback) override + { + WalletSubmission submission = submitPublicTransaction(transaction); + if (deferSubmission) { + pendingSubmissionCallback = std::move(callback); + pendingSubmission = std::move(submission); + } else { + callback(std::move(submission)); + } + } + + void finishAccountCreation() + { + AccountCreationCallback callback = std::move(pendingAccountCreation); + if (callback) + callback(std::move(pendingCreation)); + } + + void finishSubmission() + { + SubmissionCallback callback = std::move(pendingSubmissionCallback); + if (callback) + callback(std::move(pendingSubmission)); + } + + void disconnect() override {} + + QString transactionHash = QStringLiteral( + "000102030405060708090a0b0c0d0e0f" + "101112131415161718191a1b1c1d1e1f"); + int createdAccounts = 0; + int submissions = 0; + WalletTransaction submitted; + bool deferAccountCreation = false; + bool deferSubmission = false; + bool failCreatedPublicRead = false; + int submissionFailuresRemaining = 0; + QString readBalanceHex = QString(32, QLatin1Char('0')); + AccountCreationCallback pendingAccountCreation; + SubmissionCallback pendingSubmissionCallback; + WalletAccountCreation pendingCreation; + WalletSubmission pendingSubmission; + }; + + class FakeAmmClient final : public AmmClient { + public: + AmmClientResult configId(const QJsonObject&) const override + { + return success({ + { QStringLiteral("configId"), QString(64, QLatin1Char('1')) }, + }); + } + + AmmClientResult tokenIds(const QJsonObject&) const override + { + ++tokenIdsCalls; + return success({ { QStringLiteral("status"), QStringLiteral("ok") } }); + } + + AmmClientResult pairIds(const QJsonObject&) const override + { + ++pairIdsCalls; + 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 + { + ++contextCalls; + return success({}); + } + + AmmClientResult quote(const QJsonObject&) const override + { + ++quoteCalls; + return success({ + { QStringLiteral("schema"), QStringLiteral("new-position.v2") }, + { QStringLiteral("status"), QStringLiteral("ok") }, + { QStringLiteral("canSubmit"), true }, + { QStringLiteral("poolStatus"), quotePoolStatus }, + { QStringLiteral("quoteHash"), quoteHash }, + { QStringLiteral("requiresFreshLp"), requiresFreshLp }, + }); + } + + AmmClientResult plan(const QJsonObject& request) const override + { + sawFreshLp = request.contains(QStringLiteral("freshLp")); + if (sawFreshLp) { + freshLpAccountIds.append( + request.value(QStringLiteral("freshLp")).toObject() + .value(QStringLiteral("id")).toString()); + } + ++planCalls; + if (planFailuresRemaining > 0) { + --planFailuresRemaining; + return { false, {} }; + } + return success({ + { QStringLiteral("status"), QStringLiteral("ready") }, + { QStringLiteral("accountIds"), QJsonArray { QStringLiteral("account") } }, + { QStringLiteral("affectedAccountIds"), + QJsonArray { QStringLiteral("account") } }, + { QStringLiteral("signingRequirements"), QJsonArray { true } }, + { QStringLiteral("instruction"), QJsonArray { 1 } }, + { QStringLiteral("programId"), QStringLiteral("program") }, + { QStringLiteral("deadlineMs"), + QString::number(QDateTime::currentMSecsSinceEpoch() + 60'000) }, + }); + } + + AmmClientResult normalizeAccountRpc(const QJsonObject& request) const override + { + normalizedAccountIds.append( + request.value(QStringLiteral("accountId")).toString()); + return success({ + { QStringLiteral("id"), + request.value(QStringLiteral("accountId")).toString() }, + { QStringLiteral("status"), QStringLiteral("ok") }, + { QStringLiteral("account"), QJsonObject { + { QStringLiteral("program_owner"), + QString(64, QLatin1Char('0')) }, + { QStringLiteral("balance"), + normalizedBalanceHex }, + { QStringLiteral("nonce"), + QString(32, QLatin1Char('0')) }, + { QStringLiteral("data"), QString() }, + } }, + }); + } + + static AmmClientResult success(const QJsonObject& value) + { + return { true, value }; + } + + QString quoteHash = QStringLiteral("sha256:expected"); + QString quotePoolStatus = QStringLiteral("active_pool"); + bool requiresFreshLp = true; + mutable bool sawFreshLp = false; + mutable int tokenIdsCalls = 0; + mutable int contextCalls = 0; + mutable int pairIdsCalls = 0; + mutable int quoteCalls = 0; + mutable int planCalls = 0; + mutable int planFailuresRemaining = 0; + mutable QStringList freshLpAccountIds; + mutable QStringList normalizedAccountIds; + QString normalizedBalanceHex = QString(32, QLatin1Char('0')); + }; + + ActiveNetworkSnapshot readyNetwork() + { + return { + QStringLiteral("testnet"), + QStringLiteral("ready"), + QStringLiteral("block10:identity"), + QStringLiteral("program"), + {}, + }; + } + + bool waitForContext(NewPositionRuntime& runtime, bool forceRefresh) + { + bool completed = false; + QEventLoop loop; + QTimer timeout; + timeout.setSingleShot(true); + QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); + runtime.contextAsync({}, readyNetwork(), true, forceRefresh, + [&](QVariantMap) { + completed = true; + loop.quit(); + }); + timeout.start(3000); + if (!completed) + loop.exec(); + return completed; + } + + bool waitForAccounts(SequencerClient& sequencer, + const QStringList& accountIds, + bool forceRefresh, + QVector* reads) + { + bool completed = false; + QEventLoop loop; + QTimer timeout; + timeout.setSingleShot(true); + QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); + sequencer.readAccounts(accountIds, forceRefresh, + [&](QVector result) { + *reads = std::move(result); + completed = true; + loop.quit(); + }); + timeout.start(3000); + if (!completed) + loop.exec(); + return completed; + } +} + +int main(int argc, char** argv) +{ + QCoreApplication application(argc, argv); + const QVariantMap request { + { QStringLiteral("schema"), QStringLiteral("new-position.v2") }, + { 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(result.value(QStringLiteral("nativeTransactionHash")).toString() + == wallet.transactionHash, + "submitted result should preserve the native hash for polling")) + 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 planRetryWallet; + FakeAmmClient planRetryClient; + planRetryClient.planFailuresRemaining = 1; + NewPositionRuntime planRetryRuntime(&planRetryWallet, &planRetryClient); + const QVariantMap failedPlan = planRetryRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + const QVariantMap retriedPlan = planRetryRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(failedPlan.value(QStringLiteral("code")).toString() + == QStringLiteral("backend_error") + && retriedPlan.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted"), + "plan failure should remain retryable")) + return 1; + if (!expect(planRetryWallet.createdAccounts == 1 + && planRetryClient.freshLpAccountIds.size() == 2 + && planRetryClient.freshLpAccountIds.constFirst() + == planRetryClient.freshLpAccountIds.constLast(), + "plan retry should reuse the pending LP account")) + return 1; + + FakeWallet submissionRetryWallet; + submissionRetryWallet.submissionFailuresRemaining = 1; + FakeAmmClient submissionRetryClient; + NewPositionRuntime submissionRetryRuntime( + &submissionRetryWallet, &submissionRetryClient); + const QVariantMap failedSubmission = submissionRetryRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + const QVariantMap retriedSubmission = submissionRetryRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(failedSubmission.value(QStringLiteral("code")).toString() + == QStringLiteral("wallet_submission_failed") + && retriedSubmission.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted"), + "wallet rejection should remain retryable")) + return 1; + if (!expect(submissionRetryWallet.createdAccounts == 1 + && submissionRetryWallet.submissions == 2 + && submissionRetryClient.freshLpAccountIds.size() == 2 + && submissionRetryClient.freshLpAccountIds.constFirst() + == submissionRetryClient.freshLpAccountIds.constLast(), + "wallet retry should reuse the pending LP account")) + return 1; + + FakeWallet consumedWallet; + FakeAmmClient consumedClient; + NewPositionRuntime consumedRuntime(&consumedWallet, &consumedClient); + const QVariantMap firstConsumed = consumedRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + const QVariantMap secondConsumed = consumedRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(firstConsumed.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && secondConsumed.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && consumedWallet.createdAccounts == 2 + && consumedClient.freshLpAccountIds.size() == 2 + && consumedClient.freshLpAccountIds.constFirst() + != consumedClient.freshLpAccountIds.constLast(), + "accepted submission should consume the LP account reservation")) + return 1; + + FakeWallet changedWallet; + FakeAmmClient changedClient; + changedClient.planFailuresRemaining = 1; + NewPositionRuntime changedRuntime(&changedWallet, &changedClient); + const QVariantMap beforeWalletChange = changedRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + changedRuntime.clearWalletAccounts(); + const QVariantMap afterWalletChange = changedRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(beforeWalletChange.value(QStringLiteral("code")).toString() + == QStringLiteral("backend_error") + && afterWalletChange.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && changedWallet.createdAccounts == 2, + "wallet change should discard the previous LP reservation")) + return 1; + + FakeWallet readRetryWallet; + readRetryWallet.failCreatedPublicRead = true; + FakeAmmClient readRetryClient; + NewPositionRuntime readRetryRuntime(&readRetryWallet, &readRetryClient); + const QVariantMap failedCreatedRead = readRetryRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + readRetryWallet.failCreatedPublicRead = false; + const QVariantMap retriedCreatedRead = readRetryRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(failedCreatedRead.value(QStringLiteral("code")).toString() + == QStringLiteral("account_read_failed") + && retriedCreatedRead.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && readRetryWallet.createdAccounts == 1, + "created-account read failure should preserve the LP reservation")) + return 1; + + FakeWallet nonDefaultWallet; + FakeAmmClient nonDefaultClient; + nonDefaultClient.planFailuresRemaining = 1; + NewPositionRuntime nonDefaultRuntime(&nonDefaultWallet, &nonDefaultClient); + const QVariantMap reservedAccount = nonDefaultRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + nonDefaultWallet.readBalanceHex = QString(31, QLatin1Char('0')) + + QLatin1Char('1'); + const QVariantMap nonDefaultAccount = nonDefaultRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + nonDefaultWallet.readBalanceHex = QString(32, QLatin1Char('0')); + const QVariantMap replacementAccount = nonDefaultRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(reservedAccount.value(QStringLiteral("code")).toString() + == QStringLiteral("backend_error") + && nonDefaultAccount.value(QStringLiteral("code")).toString() + == QStringLiteral("submission_status_unknown") + && replacementAccount.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && nonDefaultWallet.createdAccounts == 2, + "non-default LP reservation should be replaced")) + 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; + + LocalRpcServer configuredEndpointServer; + LocalRpcServer adoptedEndpointServer; + if (!expect(configuredEndpointServer.listen() + && adoptedEndpointServer.listen(), + "endpoint-alignment sequencers should listen")) + return 1; + QTemporaryFile endpointConfig; + if (!expect(endpointConfig.open(), "endpoint-alignment config should open")) + return 1; + endpointConfig.write(QJsonDocument(QJsonObject { + { QStringLiteral("sequencer_addr"), configuredEndpointServer.endpoint() }, + { QStringLiteral("basic_auth"), QJsonObject { + { QStringLiteral("username"), QStringLiteral("user") }, + { QStringLiteral("password"), QStringLiteral("pass") }, + } }, + }).toJson(QJsonDocument::Compact)); + endpointConfig.flush(); + FakeAmmClient endpointClient; + SequencerClient alignedSequencer(&endpointClient); + if (!expect(alignedSequencer.configure( + endpointConfig.fileName(), adoptedEndpointServer.endpoint()), + "adopted wallet endpoint should configure")) + return 1; + if (!expect(alignedSequencer.endpoint() == adoptedEndpointServer.endpoint(), + "adopted wallet endpoint should override stale config")) + return 1; + QVector adoptedEndpointReads; + const QString endpointAccount(64, QLatin1Char('3')); + if (!expect(waitForAccounts( + alignedSequencer, { endpointAccount }, false, + &adoptedEndpointReads), + "adopted endpoint read should complete")) + return 1; + if (!expect(configuredEndpointServer.requestCount() == 0 + && adoptedEndpointServer.requestCount() == 1, + "direct reads should follow adopted wallet endpoint")) + return 1; + if (!expect(!adoptedEndpointServer.lastRequest().toLower().contains( + "authorization:"), + "stale config credentials must not reach adopted endpoint")) + return 1; + + if (!expect(alignedSequencer.configure( + endpointConfig.fileName(), configuredEndpointServer.endpoint()), + "matching wallet endpoint should configure")) + return 1; + QVector configuredEndpointReads; + if (!expect(waitForAccounts( + alignedSequencer, { endpointAccount }, false, + &configuredEndpointReads), + "matching endpoint read should complete")) + return 1; + if (!expect(configuredEndpointServer.requestCount() == 1 + && configuredEndpointServer.lastRequest().toLower().contains( + "authorization: basic dxnlcjpwyxnz"), + "matching endpoint should retain configured credentials")) + return 1; + + LocalRpcServer staleContextServer; + if (!expect(staleContextServer.listen(), + "stale-context sequencer should listen")) + return 1; + staleContextServer.holdResponses(); + QTemporaryFile staleContextConfig; + if (!expect(staleContextConfig.open(), + "stale-context config should open")) + return 1; + staleContextConfig.write(QJsonDocument(QJsonObject { + { QStringLiteral("sequencer_addr"), staleContextServer.endpoint() }, + }).toJson(QJsonDocument::Compact)); + staleContextConfig.flush(); + FakeWallet staleContextWallet; + FakeAmmClient staleContextClient; + SequencerClient staleContextSequencer(&staleContextClient); + if (!expect(staleContextSequencer.configure(staleContextConfig.fileName()), + "stale-context sequencer should configure")) + return 1; + NewPositionRuntime staleContextRuntime( + &staleContextWallet, &staleContextClient, &staleContextSequencer); + WalletAccount staleContextHolding; + staleContextHolding.address = QString(64, QLatin1Char('4')); + staleContextHolding.isPublic = true; + staleContextRuntime.setWalletAccounts({ staleContextHolding }); + int staleContextCallbacks = 0; + int latestContextCallbacks = 0; + staleContextRuntime.contextAsync( + {}, readyNetwork(), true, false, + [&](QVariantMap) { ++staleContextCallbacks; }); + if (!expect(waitForRequestCount(staleContextServer, 1), + "first context should begin the config read")) + return 1; + staleContextRuntime.contextAsync( + {}, readyNetwork(), true, false, + [&](QVariantMap) { ++latestContextCallbacks; }); + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + if (!expect(staleContextServer.requestCount() == 1, + "superseding context should share the active config read")) + return 1; + staleContextServer.releaseNextResponse(); + if (!expect(waitForRequestCount(staleContextServer, 2), + "latest context should continue with the wallet read")) + return 1; + if (!expect(staleContextCallbacks == 0 + && latestContextCallbacks == 0 + && staleContextClient.tokenIdsCalls == 0 + && staleContextClient.contextCalls == 0, + "context should wait for the latest wallet read")) + return 1; + staleContextServer.releaseNextResponse(); + if (!expect(waitForCondition([&]() { return latestContextCallbacks == 1; }), + "latest context should complete")) + return 1; + if (!expect(staleContextCallbacks == 0 + && staleContextClient.tokenIdsCalls == 1 + && staleContextClient.contextCalls == 1 + && staleContextServer.requestCount() == 2, + "superseded context should stop before downstream work")) + return 1; + + LocalRpcServer staleQuoteServer; + if (!expect(staleQuoteServer.listen(), + "stale-quote sequencer should listen")) + return 1; + staleQuoteServer.holdResponses(); + QTemporaryFile staleQuoteConfig; + if (!expect(staleQuoteConfig.open(), + "stale-quote config should open")) + return 1; + staleQuoteConfig.write(QJsonDocument(QJsonObject { + { QStringLiteral("sequencer_addr"), staleQuoteServer.endpoint() }, + }).toJson(QJsonDocument::Compact)); + staleQuoteConfig.flush(); + FakeWallet staleQuoteWallet; + FakeAmmClient staleQuoteClient; + SequencerClient staleQuoteSequencer(&staleQuoteClient); + if (!expect(staleQuoteSequencer.configure(staleQuoteConfig.fileName()), + "stale-quote sequencer should configure")) + return 1; + NewPositionRuntime staleQuoteRuntime( + &staleQuoteWallet, &staleQuoteClient, &staleQuoteSequencer); + int staleQuoteCallbacks = 0; + int latestQuoteCallbacks = 0; + staleQuoteRuntime.quoteAsync( + request, readyNetwork(), true, false, false, + [&](QVariantMap) { ++staleQuoteCallbacks; }); + if (!expect(waitForRequestCount(staleQuoteServer, 1), + "first quote should begin the config read")) + return 1; + staleQuoteRuntime.quoteAsync( + request, readyNetwork(), true, false, false, + [&](QVariantMap) { ++latestQuoteCallbacks; }); + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + if (!expect(staleQuoteServer.requestCount() == 1, + "superseding quote should share the active config read")) + return 1; + staleQuoteServer.releaseNextResponse(); + if (!expect(waitForCondition([&]() { return latestQuoteCallbacks == 1; }), + "latest quote should complete")) + return 1; + if (!expect(staleQuoteCallbacks == 0 + && staleQuoteClient.pairIdsCalls == 1 + && staleQuoteClient.quoteCalls == 1, + "superseded user quote should stop before pair work")) + return 1; + + int poolProbeCallbacks = 0; + int concurrentUserCallbacks = 0; + staleQuoteRuntime.quoteAsync( + request, readyNetwork(), true, false, true, + [&](QVariantMap) { ++poolProbeCallbacks; }); + staleQuoteRuntime.quoteAsync( + request, readyNetwork(), true, false, false, + [&](QVariantMap) { ++concurrentUserCallbacks; }); + if (!expect(waitForCondition([&]() { + return poolProbeCallbacks == 1 + && concurrentUserCallbacks == 1; + }), + "pool probe and user quote should complete independently")) + return 1; + if (!expect(staleQuoteClient.pairIdsCalls == 3 + && staleQuoteClient.quoteCalls == 3, + "user quote cancellation must not cancel the pool probe")) + return 1; + + LocalRpcServer poolProbeServer; + if (!expect(poolProbeServer.listen(), + "pool-probe sequencer should listen")) + return 1; + QTemporaryFile poolProbeConfig; + if (!expect(poolProbeConfig.open(), + "pool-probe config should open")) + return 1; + poolProbeConfig.write(QJsonDocument(QJsonObject { + { QStringLiteral("sequencer_addr"), poolProbeServer.endpoint() }, + }).toJson(QJsonDocument::Compact)); + poolProbeConfig.flush(); + FakeWallet poolProbeWallet; + FakeAmmClient poolProbeClient; + SequencerClient poolProbeSequencer(&poolProbeClient); + if (!expect(poolProbeSequencer.configure(poolProbeConfig.fileName()), + "pool-probe sequencer should configure")) + return 1; + NewPositionRuntime poolProbeRuntime( + &poolProbeWallet, &poolProbeClient, &poolProbeSequencer); + const QString poolIdBase58 = QStringLiteral( + "1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"); + const QString poolIdHex = QStringLiteral( + "000102030405060708090a0b0c0d0e0f" + "101112131415161718191a1b1c1d1e1f"); + QVariantMap poolProbeRequest = request; + poolProbeRequest.insert(QStringLiteral("poolId"), poolIdBase58); + QVariantMap poolProbeResult; + poolProbeRuntime.quoteAsync( + poolProbeRequest, readyNetwork(), true, true, true, + [&](QVariantMap result) { poolProbeResult = std::move(result); }); + if (!expect(waitForCondition([&]() { return !poolProbeResult.isEmpty(); }), + "default pool probe should complete")) + return 1; + if (!expect(poolProbeResult.value(QStringLiteral("poolStatus")).toString() + == QStringLiteral("missing_pool") + && poolProbeServer.requestCount() == 1 + && poolProbeClient.pairIdsCalls == 0 + && poolProbeClient.quoteCalls == 0 + && poolProbeClient.normalizedAccountIds.contains(poolIdHex), + "default pool probe should read only the pool account")) + return 1; + + poolProbeClient.normalizedBalanceHex = QString(32, QLatin1Char('1')); + poolProbeResult.clear(); + poolProbeRuntime.quoteAsync( + poolProbeRequest, readyNetwork(), true, true, true, + [&](QVariantMap result) { poolProbeResult = std::move(result); }); + if (!expect(waitForCondition([&]() { return !poolProbeResult.isEmpty(); }), + "nondefault pool probe should complete")) + return 1; + if (!expect(poolProbeResult.value(QStringLiteral("poolStatus")).toString() + == QStringLiteral("active_pool") + && poolProbeServer.requestCount() == 3 + && poolProbeClient.pairIdsCalls == 1 + && poolProbeClient.quoteCalls == 1, + "nondefault pool should run one validating full quote")) + return 1; + + poolProbeServer.failNextRequest(); + poolProbeResult.clear(); + poolProbeRuntime.quoteAsync( + poolProbeRequest, readyNetwork(), true, true, true, + [&](QVariantMap result) { poolProbeResult = std::move(result); }); + if (!expect(waitForCondition([&]() { return !poolProbeResult.isEmpty(); }), + "failed pool probe should complete")) + return 1; + if (!expect(poolProbeResult.value(QStringLiteral("code")).toString() + == QStringLiteral("account_read_failed") + && poolProbeServer.requestCount() == 4 + && poolProbeClient.pairIdsCalls == 1 + && poolProbeClient.quoteCalls == 1, + "failed pool probe should wait for the next one-account retry")) + return 1; + + LocalRpcServer server; + if (!expect(server.listen(), "local sequencer should listen")) + return 1; + QTemporaryFile sequencerConfig; + if (!expect(sequencerConfig.open(), "sequencer config should open")) + return 1; + sequencerConfig.write(QJsonDocument(QJsonObject { + { QStringLiteral("sequencer_addr"), server.endpoint() }, + }).toJson(QJsonDocument::Compact)); + sequencerConfig.flush(); + + FakeWallet refreshWallet; + FakeAmmClient refreshClient; + SequencerClient sequencer(&refreshClient); + if (!expect(sequencer.configure(sequencerConfig.fileName()), + "sequencer client should configure")) + return 1; + NewPositionRuntime refreshRuntime(&refreshWallet, &refreshClient, &sequencer); + WalletAccount holding; + holding.address = QString(64, QLatin1Char('2')); + holding.isPublic = true; + refreshRuntime.setWalletAccounts({ holding }); + + if (!expect(waitForContext(refreshRuntime, false), + "initial context should complete")) + return 1; + if (!expect(server.requestCount() == 2, + "initial context should read config and wallet holding")) + return 1; + if (!expect(waitForContext(refreshRuntime, false), + "cached context should complete")) + return 1; + if (!expect(server.requestCount() == 2, + "cached context should not reread accounts")) + return 1; + if (!expect(waitForContext(refreshRuntime, true), + "forced context should complete")) + return 1; + if (!expect(server.requestCount() == 4, + "forced context should reread config and wallet holding")) + return 1; + + FakeWallet selectedWallet; + NewPositionRuntime selectedRuntime( + &selectedWallet, &refreshClient, &sequencer); + const QString selectedAccountHex = QStringLiteral( + "000102030405060708090a0b0c0d0e0f" + "101112131415161718191a1b1c1d1e1f"); + WalletAccount selectedAccount; + selectedAccount.address = selectedAccountHex; + selectedAccount.isPublic = true; + selectedRuntime.setWalletAccounts({ selectedAccount }); + QVariantMap selectedRequest = request; + selectedRequest.insert( + QStringLiteral("holdingAId"), + QStringLiteral("1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE")); + const int requestsBeforeSelectedQuote = server.requestCount(); + const int normalizationsBeforeSelectedQuote = + refreshClient.normalizedAccountIds.count(selectedAccountHex); + QVariantMap selectedQuote; + selectedRuntime.quoteAsync( + selectedRequest, readyNetwork(), true, false, false, + [&](QVariantMap result) { selectedQuote = std::move(result); }); + if (!expect(waitForCondition([&]() { return !selectedQuote.isEmpty(); }), + "selected-holding quote should complete")) + return 1; + if (!expect(server.requestCount() == requestsBeforeSelectedQuote + 1 + && refreshClient.normalizedAccountIds.count(selectedAccountHex) + == normalizationsBeforeSelectedQuote + 1, + "selected holding should be read once per cold quote")) + return 1; + + server.failNextRequest(); + QVector failedRefresh; + if (!expect(waitForAccounts(sequencer, { holding.address }, true, + &failedRefresh), + "failed forced holding refresh should complete")) + return 1; + if (!expect(failedRefresh.size() == 1 && !failedRefresh.constFirst().ok(), + "forced holding refresh should surface the sequencer failure")) + return 1; + const int requestsAfterFailure = server.requestCount(); + QVector recoveredRead; + if (!expect(waitForAccounts(sequencer, { holding.address }, false, + &recoveredRead), + "holding read after failure should complete")) + return 1; + if (!expect(server.requestCount() == requestsAfterFailure + 1, + "failed forced refresh should evict the stale holding cache")) + return 1; + if (!expect(recoveredRead.size() == 1 && recoveredRead.constFirst().ok(), + "holding read should recover from the sequencer")) + return 1; + + const int requestsBeforeUnchangedConfig = server.requestCount(); + if (!expect(sequencer.configure(sequencerConfig.fileName()), + "unchanged sequencer config should remain valid")) + return 1; + QVector unchangedConfigRead; + if (!expect(waitForAccounts(sequencer, { holding.address }, false, + &unchangedConfigRead), + "cached read after unchanged config should complete")) + return 1; + if (!expect(server.requestCount() == requestsBeforeUnchangedConfig, + "unchanged config should preserve the account cache")) + return 1; + + LocalRpcServer replacementServer; + if (!expect(replacementServer.listen(), + "replacement sequencer should listen")) + return 1; + if (!expect(sequencerConfig.resize(0) && sequencerConfig.seek(0), + "sequencer config should rewind")) + return 1; + sequencerConfig.write(QJsonDocument(QJsonObject { + { QStringLiteral("sequencer_addr"), replacementServer.endpoint() }, + }).toJson(QJsonDocument::Compact)); + sequencerConfig.flush(); + if (!expect(sequencer.configure(sequencerConfig.fileName()), + "same-path endpoint update should configure")) + return 1; + QVector replacementRead; + if (!expect(waitForAccounts(sequencer, { holding.address }, false, + &replacementRead), + "same-path endpoint read should complete")) + return 1; + if (!expect(replacementServer.requestCount() == 1, + "same-path endpoint update should clear cache and use new sequencer")) + return 1; + + LocalRpcServer forcedRefreshServer; + if (!expect(forcedRefreshServer.listen(), + "forced-refresh sequencer should listen")) + return 1; + forcedRefreshServer.holdResponses(); + bool staleCachedCompleted = false; + QVector staleCachedRead; + sequencer.readAccounts({ holding.address }, false, + [&](QVector reads) { + staleCachedRead = std::move(reads); + staleCachedCompleted = true; + }); + if (!expect(sequencerConfig.resize(0) && sequencerConfig.seek(0), + "forced-refresh config should rewind")) + return 1; + sequencerConfig.write(QJsonDocument(QJsonObject { + { QStringLiteral("sequencer_addr"), forcedRefreshServer.endpoint() }, + }).toJson(QJsonDocument::Compact)); + sequencerConfig.flush(); + if (!expect(sequencer.configure(sequencerConfig.fileName()), + "forced-refresh sequencer should configure")) + return 1; + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + if (!expect(staleCachedCompleted + && staleCachedRead.size() == 1 + && !staleCachedRead.constFirst().ok(), + "cached read should fail after sequencer reconfiguration")) + return 1; + + bool ordinaryCompleted = false; + bool forcedCompleted = false; + sequencer.readAccounts({ holding.address }, false, + [&](QVector) { ordinaryCompleted = true; }); + if (!expect(waitForRequestCount(forcedRefreshServer, 1), + "ordinary read should reach sequencer")) + return 1; + sequencer.readAccounts({ holding.address }, true, + [&](QVector) { forcedCompleted = true; }); + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + if (!expect(forcedRefreshServer.requestCount() == 1, + "forced refresh should wait for active account read")) + return 1; + + forcedRefreshServer.releaseNextResponse(); + if (!expect(waitForRequestCount(forcedRefreshServer, 2), + "forced refresh should issue a follow-up account read")) + return 1; + if (!expect(ordinaryCompleted && !forcedCompleted, + "pre-refresh result must not complete forced callback")) + return 1; + forcedRefreshServer.releaseNextResponse(); + if (!expect(waitForCallbacks(ordinaryCompleted, forcedCompleted), + "forced follow-up read should complete")) + return 1; + + LocalRpcServer cancelledSubmitServer; + if (!expect(cancelledSubmitServer.listen(), + "cancelled-submit sequencer should listen")) + return 1; + cancelledSubmitServer.holdResponses(); + if (!expect(sequencerConfig.resize(0) && sequencerConfig.seek(0), + "cancelled-submit config should rewind")) + return 1; + sequencerConfig.write(QJsonDocument(QJsonObject { + { QStringLiteral("sequencer_addr"), cancelledSubmitServer.endpoint() }, + }).toJson(QJsonDocument::Compact)); + sequencerConfig.flush(); + if (!expect(sequencer.configure(sequencerConfig.fileName()), + "cancelled-submit sequencer should configure")) + return 1; + + FakeWallet cancelledWallet; + FakeAmmClient cancelledClient; + NewPositionRuntime cancelledRuntime( + &cancelledWallet, &cancelledClient, &sequencer); + bool cancelledCompleted = false; + QVariantMap cancelledResult; + cancelledRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + cancelledResult = std::move(result); + cancelledCompleted = true; + }); + if (!expect(waitForRequestCount(cancelledSubmitServer, 1), + "submit should begin account reads")) + return 1; + cancelledRuntime.clearWalletAccounts(); + cancelledRuntime.setWalletAccounts({}); + cancelledSubmitServer.releaseNextResponse(); + if (!expect(waitForCallbacks(cancelledCompleted, cancelledCompleted), + "cancelled submit should complete")) + return 1; + if (!expect(cancelledResult.value(QStringLiteral("code")).toString() + == QStringLiteral("wallet_unavailable") + && cancelledWallet.createdAccounts == 0 + && cancelledWallet.submissions == 0, + "wallet change should cancel submit before side effects")) + return 1; + + LocalRpcServer asyncSubmitServer; + if (!expect(asyncSubmitServer.listen(), "async-submit sequencer should listen")) + return 1; + if (!expect(sequencerConfig.resize(0) && sequencerConfig.seek(0), + "async-submit config should rewind")) + return 1; + sequencerConfig.write(QJsonDocument(QJsonObject { + { QStringLiteral("sequencer_addr"), asyncSubmitServer.endpoint() }, + }).toJson(QJsonDocument::Compact)); + sequencerConfig.flush(); + if (!expect(sequencer.configure(sequencerConfig.fileName()), + "async-submit sequencer should configure")) + return 1; + + FakeWallet asyncWallet; + asyncWallet.deferAccountCreation = true; + asyncWallet.deferSubmission = true; + FakeAmmClient asyncClient; + NewPositionRuntime asyncRuntime(&asyncWallet, &asyncClient, &sequencer); + int asyncCallbackCount = 0; + QVariantMap asyncResult; + asyncRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + ++asyncCallbackCount; + asyncResult = std::move(result); + }); + if (!expect(waitForCondition([&]() { return asyncWallet.createdAccounts == 1; }), + "submit should asynchronously request a fresh account")) + return 1; + if (!expect(asyncCallbackCount == 0 && asyncWallet.submissions == 0, + "account creation should not block or finish submission")) + return 1; + + QVariantMap concurrentResult; + asyncRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { concurrentResult = std::move(result); }); + if (!expect(concurrentResult.value(QStringLiteral("code")).toString() + == QStringLiteral("submit_in_progress"), + "concurrent submit should fail while async mutation is pending")) + return 1; + + asyncWallet.finishAccountCreation(); + if (!expect(waitForCondition([&]() { return asyncWallet.submissions == 1; }), + "fresh account completion should dispatch transaction")) + return 1; + if (!expect(asyncCallbackCount == 0, + "transaction submission should remain asynchronous")) + return 1; + asyncWallet.finishSubmission(); + if (!expect(asyncCallbackCount == 1 + && asyncResult.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted"), + "async wallet completion should finish exactly once")) + return 1; + + FakeWallet cancelledCreationWallet; + cancelledCreationWallet.deferAccountCreation = true; + FakeAmmClient cancelledCreationClient; + NewPositionRuntime cancelledCreationRuntime( + &cancelledCreationWallet, &cancelledCreationClient, &sequencer); + QVariantMap cancelledCreationResult; + cancelledCreationRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + cancelledCreationResult = std::move(result); + }); + if (!expect(waitForCondition( + [&]() { return cancelledCreationWallet.createdAccounts == 1; }), + "cancelled creation should reach the wallet")) + return 1; + WalletAccount creationDiscoveredAccount; + creationDiscoveredAccount.address = QString(64, QLatin1Char('8')); + creationDiscoveredAccount.isPublic = true; + cancelledCreationRuntime.setWalletAccounts({ creationDiscoveredAccount }); + QVariantMap blockedCreationRetry; + cancelledCreationRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + blockedCreationRetry = std::move(result); + }); + if (!expect(cancelledCreationResult.value(QStringLiteral("code")).toString() + == QStringLiteral("wallet_unavailable") + && blockedCreationRetry.value(QStringLiteral("code")).toString() + == QStringLiteral("submit_in_progress"), + "account refresh should wait for active creation to settle")) + return 1; + cancelledCreationWallet.finishAccountCreation(); + const int creationReadsBeforeRetry = refreshClient.normalizedAccountIds.count( + QString(64, QLatin1Char('a'))); + QVariantMap creationRetryResult; + cancelledCreationRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + creationRetryResult = std::move(result); + }); + if (!expect(waitForCondition([&]() { + return !creationRetryResult.isEmpty(); + }), + "creation retry should complete")) + return 1; + if (!expect(creationRetryResult.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && cancelledCreationWallet.createdAccounts == 1 + && cancelledCreationClient.freshLpAccountIds.size() == 1 + && refreshClient.normalizedAccountIds.count( + QString(64, QLatin1Char('a'))) + > creationReadsBeforeRetry, + "late creation should be reserved for the retry")) + return 1; + + FakeWallet failedReadWallet; + failedReadWallet.failCreatedPublicRead = true; + FakeAmmClient failedReadClient; + NewPositionRuntime failedReadRuntime( + &failedReadWallet, &failedReadClient, &sequencer); + const int failedReadNormalizations = refreshClient.normalizedAccountIds.count( + QString(64, QLatin1Char('a'))); + QVariantMap failedReadResult; + failedReadRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + failedReadResult = std::move(result); + }); + if (!expect(waitForCondition([&]() { return !failedReadResult.isEmpty(); }), + "sequencer fallback should complete the failed wallet read")) + return 1; + if (!expect(failedReadResult.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && failedReadWallet.createdAccounts == 1 + && failedReadWallet.submissions == 1 + && refreshClient.normalizedAccountIds.count( + QString(64, QLatin1Char('a'))) + > failedReadNormalizations, + "sequencer should validate a newly created LP account")) + return 1; + + FakeWallet cancelledSubmissionWallet; + cancelledSubmissionWallet.deferSubmission = true; + cancelledSubmissionWallet.submissionFailuresRemaining = 1; + FakeAmmClient cancelledSubmissionClient; + NewPositionRuntime cancelledSubmissionRuntime( + &cancelledSubmissionWallet, &cancelledSubmissionClient, &sequencer); + QVariantMap cancelledSubmissionResult; + cancelledSubmissionRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + cancelledSubmissionResult = std::move(result); + }); + if (!expect(waitForCondition( + [&]() { return cancelledSubmissionWallet.submissions == 1; }), + "cancelled submission should reach the wallet")) + return 1; + WalletAccount pendingFreshLp; + pendingFreshLp.address = QString(64, QLatin1Char('a')); + pendingFreshLp.isPublic = true; + WalletAccount discoveredAccount; + discoveredAccount.address = QString(64, QLatin1Char('9')); + discoveredAccount.isPublic = true; + cancelledSubmissionRuntime.setWalletAccounts( + { pendingFreshLp, discoveredAccount }); + QVariantMap blockedSubmissionRetry; + cancelledSubmissionRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + blockedSubmissionRetry = std::move(result); + }); + if (!expect(cancelledSubmissionResult.value(QStringLiteral("code")).toString() + == QStringLiteral("wallet_unavailable") + && blockedSubmissionRetry.value(QStringLiteral("code")).toString() + == QStringLiteral("submit_in_progress"), + "retry should wait for cancelled wallet submission to settle")) + return 1; + cancelledSubmissionWallet.finishSubmission(); + cancelledSubmissionWallet.deferSubmission = false; + QVariantMap settledSubmissionRetry; + cancelledSubmissionRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + settledSubmissionRetry = std::move(result); + }); + if (!expect(waitForCondition([&]() { + return !settledSubmissionRetry.isEmpty(); + }), + "settled submission retry should complete")) + return 1; + if (!expect(settledSubmissionRetry.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && cancelledSubmissionWallet.createdAccounts == 1 + && cancelledSubmissionWallet.submissions == 2 + && cancelledSubmissionClient.freshLpAccountIds.size() == 2 + && cancelledSubmissionClient.freshLpAccountIds.constFirst() + == cancelledSubmissionClient.freshLpAccountIds.constLast(), + "rejected late submission should restore the LP reservation")) + return 1; + + FakeWallet acceptedLateWallet; + acceptedLateWallet.deferSubmission = true; + FakeAmmClient acceptedLateClient; + NewPositionRuntime acceptedLateRuntime( + &acceptedLateWallet, &acceptedLateClient, &sequencer); + QVariantMap acceptedLateResult; + acceptedLateRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + acceptedLateResult = std::move(result); + }); + if (!expect(waitForCondition( + [&]() { return acceptedLateWallet.submissions == 1; }), + "late accepted submission should reach the wallet")) + return 1; + acceptedLateRuntime.cancelSubmit(); + QVariantMap acceptedLateBlockedRetry; + acceptedLateRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + acceptedLateBlockedRetry = std::move(result); + }); + if (!expect(acceptedLateResult.value(QStringLiteral("code")).toString() + == QStringLiteral("wallet_unavailable") + && acceptedLateBlockedRetry.value(QStringLiteral("code")).toString() + == QStringLiteral("submit_in_progress"), + "accepted late submission should block retry until settled")) + return 1; + acceptedLateWallet.finishSubmission(); + acceptedLateWallet.deferSubmission = false; + QVariantMap acceptedLateRetry; + acceptedLateRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + acceptedLateRetry = std::move(result); + }); + if (!expect(waitForCondition([&]() { return !acceptedLateRetry.isEmpty(); }), + "accepted late retry should complete")) + return 1; + if (!expect(acceptedLateRetry.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && acceptedLateWallet.createdAccounts == 2 + && acceptedLateWallet.submissions == 2 + && acceptedLateClient.freshLpAccountIds.size() == 2 + && acceptedLateClient.freshLpAccountIds.constFirst() + != acceptedLateClient.freshLpAccountIds.constLast(), + "accepted late submission should consume its LP reservation")) + return 1; + + FakeWallet changedDuringSubmissionWallet; + changedDuringSubmissionWallet.deferSubmission = true; + FakeAmmClient changedDuringSubmissionClient; + NewPositionRuntime changedDuringSubmissionRuntime( + &changedDuringSubmissionWallet, &changedDuringSubmissionClient, &sequencer); + QVariantMap changedDuringSubmissionResult; + changedDuringSubmissionRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + changedDuringSubmissionResult = std::move(result); + }); + if (!expect(waitForCondition([&]() { + return changedDuringSubmissionWallet.submissions == 1; + }), + "wallet-change submission should reach the wallet")) + return 1; + changedDuringSubmissionRuntime.clearWalletAccounts(); + if (!expect(changedDuringSubmissionResult.value( + QStringLiteral("code")).toString() + == QStringLiteral("wallet_unavailable"), + "wallet change should cancel deferred submission")) + return 1; + changedDuringSubmissionWallet.finishSubmission(); + changedDuringSubmissionWallet.deferSubmission = false; + QVariantMap changedWalletRetry; + changedDuringSubmissionRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + changedWalletRetry = std::move(result); + }); + if (!expect(waitForCondition([&]() { return !changedWalletRetry.isEmpty(); }), + "new wallet submission should complete")) + return 1; + if (!expect(changedWalletRetry.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted") + && changedDuringSubmissionWallet.createdAccounts == 2, + "old wallet callback should not mutate new reservation state")) + return 1; + + FakeWallet cancelledMutationWallet; + cancelledMutationWallet.deferAccountCreation = true; + FakeAmmClient cancelledMutationClient; + NewPositionRuntime cancelledMutationRuntime( + &cancelledMutationWallet, &cancelledMutationClient, &sequencer); + int cancelledMutationCallbacks = 0; + QVariantMap cancelledMutationResult; + cancelledMutationRuntime.submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap result) { + ++cancelledMutationCallbacks; + cancelledMutationResult = std::move(result); + }); + if (!expect(waitForCondition( + [&]() { return cancelledMutationWallet.createdAccounts == 1; }), + "cancellable submit should reach account creation")) + return 1; + cancelledMutationRuntime.clearWalletAccounts(); + if (!expect(cancelledMutationCallbacks == 1 + && cancelledMutationResult.value(QStringLiteral("code")).toString() + == QStringLiteral("wallet_unavailable"), + "wallet change should immediately cancel pending mutation")) + return 1; + cancelledMutationWallet.finishAccountCreation(); + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + if (!expect(cancelledMutationCallbacks == 1 + && cancelledMutationWallet.submissions == 0, + "stale account completion should have no transaction side effect")) + return 1; + + FakeWallet destroyedRuntimeWallet; + destroyedRuntimeWallet.deferSubmission = true; + FakeAmmClient destroyedRuntimeClient; + destroyedRuntimeClient.requiresFreshLp = false; + int destroyedRuntimeCallbacks = 0; + auto destroyedRuntime = std::make_unique( + &destroyedRuntimeWallet, &destroyedRuntimeClient, &sequencer); + destroyedRuntime->submitAsync( + request, QStringLiteral("sha256:expected"), readyNetwork(), true, + [&](QVariantMap) { ++destroyedRuntimeCallbacks; }); + if (!expect(waitForCondition( + [&]() { return destroyedRuntimeWallet.submissions == 1; }), + "lifetime test should reach async submission")) + return 1; + destroyedRuntime.reset(); + destroyedRuntimeWallet.finishSubmission(); + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + if (!expect(destroyedRuntimeCallbacks == 0, + "destroyed runtime should ignore late wallet completion")) + return 1; + + return 0; +} diff --git a/apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml b/apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml new file mode 100644 index 00000000..ff5fb0a3 --- /dev/null +++ b/apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml @@ -0,0 +1,62 @@ +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 + } + } + + SignalSpy { + id: editedSpy + signalName: "snapshotEdited" + } + + 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(""), "-") + } + + function test_lpDestinationOffersExistingHoldingsAndCreateNew() { + var summary = createTemporaryObject(summaryComponent, testCase, { + "snapshot": { + "instruction": "AddLiquidity", + "request": ({ "schema": "new-position.v2" }), + "lpHoldingOptions": [{ + "holdingId": "44444444444444444444444444444444", + "balanceRaw": "7" + }], + "lpDestinationRequired": true, + "quoteReady": false + } + }) + verify(summary) + editedSpy.target = summary + editedSpy.clear() + + var rows = summary.destinationRows() + compare(rows.length, 2) + compare(rows[1].createFresh, true) + summary.selectDestination(rows[0]) + compare(editedSpy.count, 1) + compare(editedSpy.signalArguments[0][0].request.lpHoldingId, + "44444444444444444444444444444444") + compare(editedSpy.signalArguments[0][0].quoteReady, false) + editedSpy.target = null + } +} diff --git a/apps/amm/tests/qml/tst_LiquidityPage.qml b/apps/amm/tests/qml/tst_LiquidityPage.qml new file mode 100644 index 00000000..60f3225b --- /dev/null +++ b/apps/amm/tests/qml/tst_LiquidityPage.qml @@ -0,0 +1,379 @@ +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 bool walletCanSubmit: true + property bool isWalletOpen: true + property string walletSyncStatus: "ready" + property var submitResult: ({}) + property var quoteResult: ({ + "schema": "new-position.v2", + "status": "ok", + "poolStatus": "missing_pool" + }) + property var newPositionQuoteResult: ({}) + property var newPositionSubmitResult: ({}) + property bool deferSubmitResult: false + property var newPositionContext: ({ + "schema": "new-position.v2", + "status": "ready", + "tokens": [], + "feeTiers": [] + }) + property int contextRefreshCalls: 0 + property int contextRequestId: 0 + property int quoteCalls: 0 + property var quotePoolProbeFlags: [] + property int submitCalls: 0 + property var lastContextRefreshRequest: ({}) + + function requestNewPositionSubmit(request, quoteHash, requestId) { + ++submitCalls + var result = JSON.parse(JSON.stringify(submitResult || ({}))) + result.requestId = requestId + if (!deferSubmitResult) + newPositionSubmitResult = result + } + + function requestNewPositionQuote(request, requestId, forceRefresh, + isPoolProbe) { + ++quoteCalls + quotePoolProbeFlags = quotePoolProbeFlags.concat([ + isPoolProbe === true + ]) + var result = JSON.parse(JSON.stringify(quoteResult || ({}))) + result.requestId = requestId + newPositionQuoteResult = result + } + + function refreshNewPositionContext(request) { + ++contextRefreshCalls + lastContextRefreshRequest = request + var result = JSON.parse(JSON.stringify(newPositionContext)) + result.requestId = ++contextRequestId + newPositionContext = result + } + } + } + + Component { + id: pageComponent + + Pages.LiquidityPage { + visible: false + width: 800 + height: 600 + } + } + + function test_positionLayoutUsesDesktopRailAndCompactProgress() { + var page = createTemporaryObject(pageComponent, testCase) + verify(page) + page.visible = true + wait(0) + + var rail = findChild(page, "positionStepRail") + var compactSteps = findChild(page, "compactPositionSteps") + var form = findChild(page, "newPositionForm") + verify(rail) + verify(compactSteps) + verify(form) + compare(page.wideLayout, true) + verify(form.width > rail.width) + + page.width = 600 + wait(0) + compare(page.wideLayout, false) + verify(compactSteps.implicitHeight > 0) + verify(form.width <= page.width - 32) + } + + function test_contextAvailableWhileWalletSyncs() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletCanSubmit": false, + "walletSyncStatus": "syncing" + }) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) + verify(page) + + compare(page.flow.newPositionContext.status, "ready") + compare(page.flow.viewState.walletSyncStatus, "syncing") + verify(!page.flow.walletCanSubmit) + } + + function test_contextRefreshControlsPublicRefresh() { + var backend = createTemporaryObject(backendComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) + verify(page) + + compare(page.flow.contextHints(false).refreshWalletAccounts, false) + compare(page.flow.contextHints(true).refreshWalletAccounts, true) + } + + function test_repeatedIdenticalContextCompletesRefresh() { + var backend = createTemporaryObject(backendComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) + verify(page) + + page.flow.refreshContext(false) + tryCompare(page.flow, "contextLoading", false) + compare(backend.contextRequestId, 1) + + page.flow.refreshContext(false) + tryCompare(page.flow, "contextLoading", false) + compare(backend.contextRequestId, 2) + compare(page.flow.newPositionContext.status, "ready") + } + + function test_submitFailureKeepsReturnedFreshQuoteWithoutRequery() { + var backend = createTemporaryObject(backendComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) + page.flow.quoteSerial = 7 + page.flow.finishSubmitFailure({ + "schema": "new-position.v2", + "status": "error", + "code": "quote_not_submittable", + "quote": { + "schema": "new-position.v2", + "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_submittedResultEntersSuccessState() { + var backend = createTemporaryObject(backendComponent, testCase, { + "submitResult": { + "schema": "new-position.v2", + "status": "submitted", + "transactionId": submittedTransactionId, + "deadlineMs": String(Date.now() + 60000), + "affectedAccountIds": [] + } + }) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) + page.flow.confirm({ "request": ({}), "quoteHash": "sha256:expected" }) + wait(0) + + compare(page.flow.transactionId, submittedTransactionId) + compare(page.flow.flowErrorCode, "") + compare(page.flow.submitting, false) + compare(backend.submitCalls, 1) + } + + function test_submissionCompletionClosesConfirmationWithoutBindingLoop() { + failOnWarning(/Binding loop detected for property "busy"/) + + var backend = createTemporaryObject(backendComponent, testCase, { + "deferSubmitResult": true + }) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend, + "visible": true + }) + verify(page) + + var dialog = findChild(page, "liquidityConfirmationDialog") + verify(dialog) + dialog.openWithSnapshot({ + "quoteReady": true, + "request": ({}), + "quoteHash": "sha256:expected" + }) + tryCompare(dialog, "opened", true) + + dialog.confirm() + tryCompare(page.flow, "submitting", true) + compare(dialog.busy, true) + + backend.newPositionSubmitResult = { + "schema": "new-position.v2", + "status": "submitted", + "transactionId": submittedTransactionId, + "deadlineMs": String(Date.now() + 60000), + "affectedAccountIds": [], + "requestId": page.flow.submitRequestId + } + + tryCompare(page.flow, "submitting", false) + tryCompare(dialog, "opened", false) + } + + function test_missingPoolSubmissionStartsPoolProbeWithoutWalletRefresh() { + var backend = createTemporaryObject(backendComponent, testCase, { + "submitResult": { + "schema": "new-position.v2", + "status": "submitted", + "transactionId": submittedTransactionId, + "deadlineMs": String(Date.now() + 60000) + } + }) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) + var probe = { + "tokenAId": "22222222222222222222222222222222", + "tokenBId": "33333333333333333333333333333333" + } + page.flow.pendingQuoteRequest = { + "ok": true, + "request": probe + } + page.flow.confirm({ + "instruction": "NewDefinition", + "request": { + "tokenAId": probe.tokenAId, + "tokenBId": probe.tokenBId, + "initialPriceRealRaw": "18446744073709551616" + }, + "poolProbeRequest": probe, + "quoteHash": "sha256:expected" + }) + wait(0) + + compare(page.flow.pendingPoolProbes.length, 1) + compare(backend.contextRefreshCalls, 0) + compare(backend.quoteCalls, 0) + verify(page.flow.selectedPoolCreationPending()) + + page.flow.pollPendingPool() + compare(backend.quoteCalls, 0) + + page.flow.active = true + wait(0) + page.flow.pollPendingPool() + compare(backend.quoteCalls, 1) + compare(backend.quotePoolProbeFlags.length, 1) + compare(backend.quotePoolProbeFlags[0], true) + } + + function test_userQuoteUsesInteractiveRequestLane() { + var backend = createTemporaryObject(backendComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) + page.flow.pendingQuoteRequest = { + "ok": true, + "request": ({}) + } + page.flow.active = true + page.flow.requestQuoteNow(page.flow.quoteSerial) + + verify(backend.quoteCalls > 0) + compare(backend.quotePoolProbeFlags[0], false) + } + + function test_activePoolSubmissionDoesNotStartPoolCreationProbe() { + var backend = createTemporaryObject(backendComponent, testCase, { + "submitResult": { + "schema": "new-position.v2", + "status": "submitted", + "transactionId": submittedTransactionId, + "deadlineMs": String(Date.now() + 60000) + } + }) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) + page.flow.confirm({ + "instruction": "AddLiquidity", + "request": ({}), + "poolProbeRequest": { + "tokenAId": "22222222222222222222222222222222", + "tokenBId": "33333333333333333333333333333333" + }, + "quoteHash": "sha256:expected" + }) + wait(0) + + compare(page.flow.pendingPoolProbes.length, 0) + } + + function test_walletSyncDisablesSubmissionOnly() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletCanSubmit": false, + "walletSyncStatus": "syncing" + }) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) + page.flow.confirm({ "request": ({}), "quoteHash": "sha256:expected" }) + + compare(backend.submitCalls, 0) + compare(page.flow.flowErrorCode, "wallet_syncing") + verify(!page.flow.submitting) + } + + function test_backendLossEndsSubmissionWithUnknownStatus() { + var backend = createTemporaryObject(backendComponent, testCase, { + "deferSubmitResult": true, + "submitResult": { + "schema": "new-position.v2", + "status": "submitted", + "transactionId": submittedTransactionId + } + }) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) + verify(page) + + page.flow.confirm({ "request": ({}), "quoteHash": "sha256:expected" }) + const staleRequestId = page.flow.submitRequestId + verify(page.flow.submitting) + verify(staleRequestId > 0) + + page.backend = null + tryCompare(page.flow, "submitting", false) + compare(page.flow.submitRequestId, 0) + compare(page.flow.flowErrorCode, "submission_status_unknown") + + page.backend = backend + backend.newPositionSubmitResult = { + "schema": "new-position.v2", + "status": "submitted", + "transactionId": submittedTransactionId, + "requestId": staleRequestId + } + wait(0) + compare(page.flow.transactionId, "") + compare(page.flow.flowErrorCode, "submission_status_unknown") + } + + function test_poolProbeDoesNotPublishProbeAsCurrentQuote() { + var backend = createTemporaryObject(backendComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) + 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.v2", + "status": "ok", + "poolStatus": "missing_pool" + } + page.flow.quoteStale = false + + page.flow.finishPoolProbe(pending, { + "schema": "new-position.v2", + "status": "ok", + "poolStatus": "active_pool" + }) + + compare(page.flow.newPositionQuote.poolStatus, "missing_pool") + verify(page.flow.quoteStale) + } +} diff --git a/apps/amm/tests/qml/tst_NavBar.qml b/apps/amm/tests/qml/tst_NavBar.qml new file mode 100644 index 00000000..385c7796 --- /dev/null +++ b/apps/amm/tests/qml/tst_NavBar.qml @@ -0,0 +1,49 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest + +import "../../qml" as Amm + +Item { + id: root + + width: 320 + height: 56 + + Component { + id: navComponent + + Amm.NavBar { + width: root.width + height: root.height + } + } + + TestCase { + name: "NavBar" + when: windowShown + + function test_compactControlsStayInsideViewport() { + const nav = createTemporaryObject(navComponent, root) + verify(nav) + const trade = findChild(nav, "navTab0") + const liquidity = findChild(nav, "navTab1") + const wallet = findChild(nav, "walletConnectButton") + verify(trade) + verify(liquidity) + verify(wallet) + + const controls = [trade, liquidity, wallet] + for (let index = 0; index < controls.length; ++index) { + const position = controls[index].mapToItem(nav, 0, 0) + verify(position.x >= 0) + verify(position.x + controls[index].width <= nav.width) + } + verify(trade.mapToItem(nav, trade.width, 0).x + <= liquidity.mapToItem(nav, 0, 0).x) + verify(liquidity.mapToItem(nav, liquidity.width, 0).x + <= wallet.mapToItem(nav, 0, 0).x) + } + } +} diff --git a/apps/amm/tests/qml/tst_NewPositionForm.qml b/apps/amm/tests/qml/tst_NewPositionForm.qml new file mode 100644 index 00000000..15b77fa9 --- /dev/null +++ b/apps/amm/tests/qml/tst_NewPositionForm.qml @@ -0,0 +1,729 @@ +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", + "holdings": [{ + "holdingId": "44444444444444444444444444444444", + "balanceRaw": "1000" + }], + "selectable": true + }, + { + "definitionId": tokenHigh, + "name": "High", + "totalSupplyRaw": "1000000000000", + "balanceRaw": "5000000000", + "holdings": [{ + "holdingId": "55555555555555555555555555555555", + "balanceRaw": "5000000000" + }], + "selectable": true + } + ] + } + } + + function rawAmountsContext() { + return { + "status": "ready", + "tokens": [ + { + "definitionId": tokenLow, + "name": "Sir Mints-a-Lot", + "totalSupplyRaw": "1000000000000", + "balanceRaw": "1000000000", + "holdings": [{ + "holdingId": "44444444444444444444444444444444", + "balanceRaw": "1000000000" + }], + "selectable": true + }, + { + "definitionId": tokenHigh, + "name": "Aurora", + "totalSupplyRaw": "1000000000000", + "balanceRaw": "1000000000", + "holdings": [{ + "holdingId": "55555555555555555555555555555555", + "balanceRaw": "1000000000" + }], + "selectable": true + } + ] + } + } + + function holdingContext() { + return { + "status": "ready", + "tokens": [ + { + "definitionId": tokenLow, + "name": "Low", + "balanceRaw": "10", + "selectable": true, + "holdings": [ + { "holdingId": "44444444444444444444444444444444", + "balanceRaw": "10" } + ] + }, + { + "definitionId": tokenHigh, + "name": "High", + "balanceRaw": "50", + "selectable": true, + "holdings": [ + { "holdingId": "55555555555555555555555555555555", + "balanceRaw": "20" }, + { "holdingId": "66666666666666666666666666666666", + "balanceRaw": "30" } + ] + } + ] + } + } + + function flowState(quote) { + return { + "quote": quote || ({}), + "contextLoading": false, + "quoteLoading": false, + "quoteStale": false, + "submitting": false, + "walletCanSubmit": true, + "walletSyncStatus": "ready" + } + } + + function createEmptyForm(context) { + var form = createTemporaryObject(formComponent, testCase, { + "flowState": flowState(({})), + "newPositionContext": context || readyContext() + }) + verify(form) + wait(0) + return form + } + + function createForm(context) { + var form = createEmptyForm(context) + form.selectToken("A", tokenLow) + form.selectToken("B", tokenHigh) + compare(form.selectedTokenAId, tokenLow) + compare(form.selectedTokenBId, tokenHigh) + return form + } + + function test_walletTokensDoNotPrefillPosition() { + var form = createEmptyForm() + compare(form.selectedTokenAId, "") + compare(form.selectedTokenBId, "") + compare(form.amountA, "") + compare(form.amountB, "") + } + + function test_holdingSelectionAutoSelectsOneAndRequiresExplicitMultiple() { + var form = createForm(holdingContext()) + wait(0) + compare(form.selectedHoldingAId, + "44444444444444444444444444444444") + compare(form.selectedHoldingBId, "") + verify(!form.buildQuoteRequest().ok) + + form.selectHolding("B", "66666666666666666666666666666666") + var built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.holdingAId, + "66666666666666666666666666666666") + compare(built.request.holdingBId, + "44444444444444444444444444444444") + } + + 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_tokenAmountsUseRawUnits() { + var form = createForm() + compare(form.decimalsA, 0) + compare(form.decimalsB, 0) + compare(form.balanceText(form.tokenA, form.decimalsA), "1000") + compare(form.balanceText(form.tokenB, form.decimalsB), "5000000000") + } + + function test_missingPoolKeepsMinimumRatioWhileEditingDeposit() { + var quote = { + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "2", + "minimumAmountBRaw": "3", + "actualAmountARaw": "2", + "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, "4") + compare(built.request.amountBRaw, "6") + } + + function test_missingPoolAcceptsLargeDirectAmountsFromEitherSide() { + var form = createForm(rawAmountsContext()) + 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, "100") + compare(built.request.amountBRaw, "150") + 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, "200") + compare(built.request.amountBRaw, "300") + } + + function test_missingPoolRoundsPairedRawAmounts() { + var form = createForm(rawAmountsContext()) + form.priceAmountA = "15" + form.priceAmountB = "10" + form.flowState = flowState({ + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "1", + "minimumAmountBRaw": "1", + "actualAmountARaw": "1", + "actualAmountBRaw": "1" + }) + wait(0) + + var cases = [ + { "input": "1", "paired": "1", "rawA": "1", "rawB": "1" }, + { "input": "2", "paired": "1", "rawA": "1", "rawB": "2" }, + { "input": "3", "paired": "2", "rawA": "2", "rawB": "3" }, + { "input": "4", "paired": "3", "rawA": "3", "rawB": "4" } + ] + 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) + } + + form.finishMissingAmount("A", "1.1234567") + compare(form.amountA, "1.1234567") + verify(!form.buildQuoteRequest().ok) + compare(form.fieldError("amountA"), form.issueText("invalid_amount_precision")) + } + + 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.v2", + "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_submissionSnapshotCarriesPoolAccountForProbe() { + var form = createForm() + form.flowState = flowState({ + "schema": "new-position.v2", + "status": "ok", + "canSubmit": true, + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "poolId": submittedTransactionId, + "instruction": "NewDefinition", + "quoteHash": "sha256:expected", + "actualAmountARaw": "2000000", + "actualAmountBRaw": "3", + "expectedLpRaw": "10" + }) + wait(0) + + var snapshot = form.submissionSnapshot() + compare(snapshot.poolProbeRequest.poolId, submittedTransactionId) + verify(snapshot.request.poolId === undefined) + } + + function test_staleQuoteErrorsDoNotMarkCurrentDraft() { + var quote = { + "schema": "new-position.v2", + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "active_pool", + "reserveARaw": "2", + "reserveBRaw": "10", + "maxAmountARaw": "4", + "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": "2", + "reserveBRaw": "10", + "maxAmountARaw": "4", + "maxAmountBRaw": "20" + } + var form = createForm() + form.flowState = flowState(quote) + wait(0) + compare(form.amountA, "") + compare(form.amountB, "") + + quoteRequestedSpy.target = form + quoteRequestedSpy.clear() + form.editActiveAmount("A", "5") + compare(form.amountA, "5") + compare(form.amountB, "") + 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, "1") + compare(built.request.maxAmountBRaw, "5") + + form.finishActiveAmount("B", "1.1234567") + compare(form.amountB, "1.1234567") + verify(!form.buildQuoteRequest().ok) + } + + function test_activePoolEditRecoversAfterInvalidQuote() { + var form = createForm() + form.flowState = flowState({ + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "active_pool", + "poolFeeBps": 30, + "reserveARaw": "2", + "reserveBRaw": "10", + "maxAmountARaw": "4", + "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, "1") + 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(form.amountA, "") + compare(form.amountB, "") + 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, "") + compare(form.selectedTokenBId, tokenHigh) + 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 00000000..0c56b7fe --- /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_SwapPage.qml b/apps/amm/tests/qml/tst_SwapPage.qml new file mode 100644 index 00000000..87ea120c --- /dev/null +++ b/apps/amm/tests/qml/tst_SwapPage.qml @@ -0,0 +1,230 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest + +import "../../qml/pages" as Pages + +Item { + id: root + + width: 800 + height: 700 + + Component { + id: pageComponent + + Pages.SwapPage { + width: root.width + height: root.height + } + } + + TestCase { + name: "SwapPage" + when: windowShown + + function test_tradeIsExplicitPreviewAndPreservesDraft() { + const page = createTemporaryObject(pageComponent, root) + verify(page) + + const notice = findChild(page, "swapPreviewNotice") + const card = findChild(page, "swapCard") + const dialog = findChild(page, "swapPreviewDialog") + verify(notice) + verify(card) + verify(dialog) + verify(notice.text.indexOf("Preview only") >= 0) + verify(notice.text.indexOf("No swap will be submitted") >= 0) + + card.setToken("sell", page.tokens[0]) + card.setToken("buy", page.tokens[1]) + card.sellInput = "1" + card.editingSide = "sell" + tryCompare(card, "canSubmit", true) + compare(card.submitButtonText, "Preview swap") + + card.previewRequested(card.buildSnapshot()) + tryCompare(dialog, "opened", true) + compare(dialog.title, "Swap preview") + compare(findChild(dialog, "transactionConfirmButton").text, "Done") + + dialog.confirm() + tryCompare(dialog, "opened", false) + compare(card.sellInput, "1") + } + + function test_themeToggleReceivesPointerInput() { + const page = createTemporaryObject(pageComponent, root) + verify(page) + + const toggle = findChild(page, "swapThemeToggle") + verify(toggle) + compare(page.darkTheme, true) + + const position = toggle.mapToItem(page, toggle.width / 2, toggle.height / 2) + mouseClick(page, position.x, position.y) + + tryCompare(page, "darkTheme", false) + } + + function test_exactOutputUsesMaximumInputLimit() { + const page = createTemporaryObject(pageComponent, root) + verify(page) + + const card = findChild(page, "swapCard") + const dialog = findChild(page, "swapPreviewDialog") + verify(card) + verify(dialog) + + card.setToken("sell", page.tokens[0]) + card.setToken("buy", page.tokens[1]) + card.slippageTolerancePercent = 10 + card.buyInput = "1000" + card.editingSide = "buy" + tryCompare(card, "canSubmit", true) + + const snapshot = card.buildSnapshot() + compare(snapshot.swapMode, "swap-exact-output") + compare(snapshot.buyAmount, "1000.00") + compare(snapshot.minReceived, "") + verify(snapshot.maxSent.length > 0) + verify(Number(snapshot.maxSent) > Number(snapshot.sellAmount)) + + dialog.openWithSnapshot(snapshot) + tryCompare(dialog, "opened", true) + tryCompare(findChild(dialog, "swapPayLabel"), "text", "You pay at most") + compare(findChild(dialog, "swapPayAmount").text, snapshot.maxSent + " " + snapshot.sellToken) + compare(findChild(dialog, "swapReceiveLabel").text, "You receive") + compare(findChild(dialog, "swapReceiveAmount").text, snapshot.buyAmount + " " + snapshot.buyToken) + compare(findChild(dialog, "swapLimitLabel").text, "Max sent") + compare(findChild(dialog, "swapLimitValue").text, snapshot.maxSent + " " + snapshot.sellToken) + } + + function test_exactInputUsesMinimumOutputLimit() { + const page = createTemporaryObject(pageComponent, root) + verify(page) + + const card = findChild(page, "swapCard") + verify(card) + card.setToken("sell", page.tokens[0]) + card.setToken("buy", page.tokens[1]) + card.slippageTolerancePercent = 10 + card.sellInput = "1" + card.editingSide = "sell" + tryCompare(card, "canSubmit", true) + + const snapshot = card.buildSnapshot() + compare(snapshot.swapMode, "swap-exact-input") + compare(snapshot.sellAmount, "1.00") + compare(snapshot.maxSent, "") + verify(snapshot.minReceived.length > 0) + verify(Number(snapshot.minReceived) < Number(snapshot.buyAmount)) + } + + function test_exactOutputRequiresBalanceForMaximumInput() { + const page = createTemporaryObject(pageComponent, root) + verify(page) + + const card = findChild(page, "swapCard") + verify(card) + card.setToken("sell", page.tokens[0]) + card.setToken("buy", page.tokens[1]) + card.slippageTolerancePercent = 10 + card.buyInput = "11500" + card.editingSide = "buy" + + verify(card.parsedSellAmount < card.sellToken.balance) + verify(card.maxSentAmount > card.sellToken.balance) + verify(card.insufficientBalance) + verify(!card.canSubmit) + compare(card.submitButtonText, "Insufficient balance") + } + + function test_exactOutputCannotConsumeEntireReserve() { + const page = createTemporaryObject(pageComponent, root) + verify(page) + + const card = findChild(page, "swapCard") + verify(card) + card.setToken("sell", page.tokens[0]) + card.setToken("buy", page.tokens[1]) + card.buyInput = String(card.buyToken.reserve) + card.editingSide = "buy" + + compare(card.parsedBuyAmount, card.buyToken.reserve) + verify(card.insufficientLiquidity) + verify(!card.canSubmit) + compare(card.submitButtonText, "Insufficient liquidity") + } + + function test_reselectingOppositeTokenSwapsPair() { + const page = createTemporaryObject(pageComponent, root) + verify(page) + + const card = findChild(page, "swapCard") + verify(card) + card.setToken("sell", page.tokens[0]) + card.setToken("buy", page.tokens[1]) + card.setToken("buy", page.tokens[0]) + + compare(card.sellToken.address, page.tokens[1].address) + compare(card.buyToken.address, page.tokens[0].address) + verify(!card.sameTokenSelected) + } + + function test_sameTokenPairCannotBePreviewed() { + const page = createTemporaryObject(pageComponent, root) + verify(page) + + const card = findChild(page, "swapCard") + verify(card) + card.sellToken = page.tokens[0] + card.buyToken = page.tokens[0] + card.sellInput = "1" + card.editingSide = "sell" + + verify(card.sameTokenSelected) + verify(!card.canSubmit) + compare(card.submitButtonText, "Select different tokens") + } + + function test_tradeContentStaysReachable_data() { + return [ + { "tag": "short", "width": 360, "height": 184 }, + { "tag": "expanded", "width": 400, "height": 544 } + ] + } + + function test_tradeContentStaysReachable(data) { + const page = createTemporaryObject(pageComponent, root, { + "width": data.width, + "height": data.height + }) + verify(page) + + const scroll = findChild(page, "swapScroll") + const card = findChild(page, "swapCard") + const notice = findChild(page, "swapPreviewNotice") + verify(scroll) + verify(card) + verify(notice) + card.setToken("sell", page.tokens[0]) + card.setToken("buy", page.tokens[1]) + card.sellInput = "1" + card.editingSide = "sell" + wait(0) + + const cardPosition = card.mapToItem(page, 0, 0) + verify(cardPosition.x >= 16) + verify(cardPosition.x + card.width <= page.width - 16) + + if (scroll.contentHeight > scroll.height) + scroll.contentY = scroll.contentHeight - scroll.height + wait(0) + const noticePosition = notice.mapToItem(scroll, 0, 0) + verify(noticePosition.y >= 0) + verify(noticePosition.y + notice.height <= scroll.height) + } + } +} diff --git a/apps/amm/tests/qml/tst_TokenAmountInput.qml b/apps/amm/tests/qml/tst_TokenAmountInput.qml new file mode 100644 index 00000000..7a10ae33 --- /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) + } +} diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt new file mode 100644 index 00000000..896716b8 --- /dev/null +++ b/apps/shared/wallet/CMakeLists.txt @@ -0,0 +1,175 @@ +cmake_minimum_required(VERSION 3.21) + +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + project(LogosWallet LANGUAGES CXX) + include(CTest) +endif() + +option(LOGOS_WALLET_BUILD_QML "Build the Logos.Wallet QML module" ON) +option(LOGOS_WALLET_BUILD_ACCESS "Build the generated-SDK wallet adapter" ON) +set(LOGOS_WALLET_GENERATED_DIR "" CACHE PATH "Path to generated Logos SDK sources") + +if(LOGOS_WALLET_BUILD_ACCESS + AND NOT EXISTS "${LOGOS_WALLET_GENERATED_DIR}/logos_sdk.h" + AND NOT EXISTS "${LOGOS_WALLET_GENERATED_DIR}/include/logos_sdk.h") + message(FATAL_ERROR + "logos_wallet_access requires logos_sdk.h; set LOGOS_WALLET_GENERATED_DIR" + ) +endif() + +find_package(Qt6 6.8 REQUIRED COMPONENTS Core) +if(LOGOS_WALLET_BUILD_ACCESS OR BUILD_TESTING) + find_package(Qt6 6.8 REQUIRED COMPONENTS Network) +endif() +set(CMAKE_AUTOMOC ON) + +if(LOGOS_WALLET_BUILD_ACCESS) + add_library(logos_wallet_access STATIC + src/WalletProvider.h + src/WalletProvider.cpp + src/LogosWalletProvider.h + src/LogosWalletProvider.cpp + src/WalletAccountModel.h + src/WalletAccountModel.cpp + src/WalletController.h + src/WalletController.cpp + ) + set_target_properties(logos_wallet_access PROPERTIES + AUTOMOC ON + POSITION_INDEPENDENT_CODE ON + ) + target_compile_features(logos_wallet_access PUBLIC cxx_std_17) + target_include_directories(logos_wallet_access + PUBLIC + "$" + PRIVATE + "${LOGOS_WALLET_GENERATED_DIR}" + "${LOGOS_WALLET_GENERATED_DIR}/include" + ) + target_link_libraries(logos_wallet_access + PUBLIC Qt6::Core + PRIVATE Qt6::Network + ) +endif() + +if(LOGOS_WALLET_BUILD_QML) + find_package(Qt6 6.8 REQUIRED COMPONENTS Qml Quick QuickControls2) + qt_policy(SET QTP0004 NEW) + + set(wallet_qml_output_dir "${CMAKE_CURRENT_BINARY_DIR}/qml/Logos/Wallet") + set(wallet_internal_qml + qml/internal/WalletIconButton.qml + qml/internal/CopyButton.qml + qml/internal/AccountDelegate.qml + qml/internal/CreateAccountDialog.qml + qml/internal/CreateWalletDialog.qml + qml/internal/WalletMessageDialog.qml + ) + set(wallet_public_qml + qml/WalletControl.qml + qml/TransactionConfirmationDialog.qml + qml/SubmittedTransaction.qml + ) + set(wallet_icons + qml/internal/icons/account.svg + qml/internal/icons/back.svg + qml/internal/icons/checkmark.svg + qml/internal/icons/copy.svg + qml/internal/icons/power.svg + ) + foreach(qml_file IN LISTS wallet_public_qml wallet_internal_qml) + get_filename_component(qml_name "${qml_file}" NAME) + set_source_files_properties("${qml_file}" PROPERTIES QT_RESOURCE_ALIAS "${qml_name}") + endforeach() + foreach(icon IN LISTS wallet_icons) + get_filename_component(icon_name "${icon}" NAME) + set_source_files_properties("${icon}" PROPERTIES QT_RESOURCE_ALIAS "icons/${icon_name}") + endforeach() + set_source_files_properties(${wallet_internal_qml} PROPERTIES QT_QML_INTERNAL_TYPE TRUE) + + qt_add_library(logos_wallet_qml SHARED) + qt_add_qml_module(logos_wallet_qml + URI Logos.Wallet + VERSION 1.0 + RESOURCE_PREFIX /qt/qml + OUTPUT_DIRECTORY "${wallet_qml_output_dir}" + TYPEINFO plugins.qmltypes + QML_FILES + ${wallet_public_qml} + ${wallet_internal_qml} + RESOURCES + ${wallet_icons} + ) + target_link_libraries(logos_wallet_qml PRIVATE + Qt6::Core + Qt6::Qml + Qt6::Quick + Qt6::QuickControls2 + ) + # Resolve the sibling liblogos_wallet_qml.dylib/.so next to the plugin. + # macOS dyld does not expand the ELF "$ORIGIN" token — it uses @loader_path. + if(APPLE) + set(wallet_qml_rpath "@loader_path") + else() + set(wallet_qml_rpath "$ORIGIN") + endif() + set_target_properties(logos_wallet_qml logos_wallet_qmlplugin PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${wallet_qml_output_dir}" + RUNTIME_OUTPUT_DIRECTORY "${wallet_qml_output_dir}" + BUILD_WITH_INSTALL_RPATH ON + INSTALL_RPATH "${wallet_qml_rpath}" + ) + + set(wallet_qml_install_dir "lib/qml/Logos/Wallet") + install(TARGETS logos_wallet_qml logos_wallet_qmlplugin + LIBRARY DESTINATION "${wallet_qml_install_dir}" + RUNTIME DESTINATION "${wallet_qml_install_dir}" + ) + install(FILES + "${wallet_qml_output_dir}/qmldir" + "${wallet_qml_output_dir}/plugins.qmltypes" + DESTINATION "${wallet_qml_install_dir}" + OPTIONAL + ) +endif() + +if(BUILD_TESTING) + find_package(Qt6 6.8 REQUIRED COMPONENTS Test) + + add_executable(logos_wallet_access_test + tests/cpp/LogosWalletProviderTest.cpp + src/WalletProvider.cpp + src/LogosWalletProvider.cpp + src/WalletAccountModel.cpp + src/WalletAccountModel.h + src/WalletController.cpp + src/WalletController.h + ) + set_target_properties(logos_wallet_access_test PROPERTIES AUTOMOC ON) + target_compile_features(logos_wallet_access_test PRIVATE cxx_std_17) + target_include_directories(logos_wallet_access_test PRIVATE + tests/cpp/fixtures + tests/support + src + ) + target_link_libraries(logos_wallet_access_test PRIVATE + Qt6::Core + Qt6::Network + Qt6::Test + ) + add_test(NAME logos_wallet_access COMMAND logos_wallet_access_test) + + if(LOGOS_WALLET_BUILD_QML) + find_package(Qt6 6.8 REQUIRED COMPONENTS QuickTest) + add_executable(logos_wallet_qml_test tests/qml/main.cpp) + target_compile_definitions(logos_wallet_qml_test PRIVATE + QUICK_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/qml" + ) + target_link_libraries(logos_wallet_qml_test PRIVATE Qt6::QuickTest) + add_dependencies(logos_wallet_qml_test logos_wallet_qmlplugin) + add_test(NAME logos_wallet_qml COMMAND logos_wallet_qml_test) + set_tests_properties(logos_wallet_qml PROPERTIES ENVIRONMENT + "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QML2_IMPORT_PATH=${CMAKE_CURRENT_BINARY_DIR}/qml;QML_IMPORT_PATH=${CMAKE_CURRENT_BINARY_DIR}/qml" + ) + endif() +endif() diff --git a/apps/shared/wallet/qml/SubmittedTransaction.qml b/apps/shared/wallet/qml/SubmittedTransaction.qml new file mode 100644 index 00000000..00cc3ba8 --- /dev/null +++ b/apps/shared/wallet/qml/SubmittedTransaction.qml @@ -0,0 +1,86 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Item { + id: root + + property string title: qsTr("Transaction submitted") + property string transactionId: "" + readonly property bool base58Shape: root.transactionId.length > 0 + && /^[1-9A-HJ-NP-Za-km-z]+$/.test(root.transactionId) + + implicitWidth: 420 + implicitHeight: resultCard.implicitHeight + + TextEdit { + id: clipboardProxy + visible: false + } + + function copyTransactionId() { + if (!root.transactionId) + return + clipboardProxy.text = root.transactionId + clipboardProxy.selectAll() + clipboardProxy.copy() + clipboardProxy.deselect() + clipboardProxy.text = "" + } + + Rectangle { + id: resultCard + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: resultContent.implicitHeight + 32 + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + + ColumnLayout { + id: resultContent + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Label { + Layout.fillWidth: true + text: root.title + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + wrapMode: Text.WordWrap + } + + Label { + Layout.fillWidth: true + text: qsTr("Transaction ID") + color: "#a1a1aa" + font.pixelSize: 12 + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + id: transactionLabel + objectName: "submittedTransactionId" + Layout.fillWidth: true + text: root.transactionId + color: "#f4f4f5" + font.family: "monospace" + wrapMode: Text.WrapAnywhere + Accessible.name: qsTr("Transaction ID %1").arg(root.transactionId) + } + + CopyButton { + objectName: "copySubmittedTransactionButton" + enabled: root.transactionId.length > 0 + onCopyRequested: root.copyTransactionId() + } + } + } + } +} diff --git a/apps/shared/wallet/qml/TransactionConfirmationDialog.qml b/apps/shared/wallet/qml/TransactionConfirmationDialog.qml new file mode 100644 index 00000000..189d3485 --- /dev/null +++ b/apps/shared/wallet/qml/TransactionConfirmationDialog.qml @@ -0,0 +1,185 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Popup { + id: root + + property string title: qsTr("Confirm transaction") + property string cancelText: qsTr("Cancel") + property string confirmText: qsTr("Confirm") + property bool busy: false + property var snapshot: ({}) + property Component summary: null + property bool confirmationPending: false + property bool confirmEnabled: true + + signal canceled + signal confirmed(var snapshot) + signal summaryEdited(var snapshot) + + modal: true + dim: true + padding: 20 + width: Math.max(0, Math.min(420, parent ? parent.width - 32 : 420)) + height: Math.max(0, Math.min(implicitHeight, parent ? parent.height - 32 : implicitHeight)) + x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 + y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 + closePolicy: root.busy ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside + focus: true + + function cloneSnapshot(value) { + if (value === undefined || value === null) + return ({}) + try { + return JSON.parse(JSON.stringify(value)) + } catch (_error) { + return value + } + } + + function openWithSnapshot(nextSnapshot) { + root.snapshot = root.cloneSnapshot(nextSnapshot) + root.confirmationPending = false + root.open() + cancelButton.forceActiveFocus() + } + + function updateSnapshot(nextSnapshot) { + root.snapshot = root.cloneSnapshot(nextSnapshot) + } + + function cancel() { + if (root.busy) + return + root.confirmationPending = false + root.close() + root.canceled() + } + + function confirm() { + if (root.busy || !root.confirmEnabled) + return + root.confirmationPending = true + root.confirmed(root.snapshot) + if (!root.busy) { + root.confirmationPending = false + root.close() + } + } + + Connections { + target: summaryLoader.item + ignoreUnknownSignals: true + + function onSnapshotEdited(snapshot) { + root.updateSnapshot(snapshot) + root.summaryEdited(root.snapshot) + } + } + + onBusyChanged: { + if (!root.busy && root.confirmationPending) { + root.confirmationPending = false + root.close() + } + } + + onSnapshotChanged: { + if (summaryLoader.item && summaryLoader.item.hasOwnProperty("snapshot")) + summaryLoader.item.snapshot = root.snapshot + } + + Overlay.modal: Rectangle { color: "#99000000" } + + background: Rectangle { + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + } + + contentItem: ColumnLayout { + spacing: 16 + + Label { + Layout.fillWidth: true + text: root.title + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + wrapMode: Text.WordWrap + } + + ScrollView { + id: summaryScroll + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 0 + Layout.preferredHeight: summaryLoader.implicitHeight + clip: true + contentWidth: availableWidth + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + Loader { + id: summaryLoader + objectName: "transactionSummaryLoader" + width: summaryScroll.availableWidth + sourceComponent: root.summary + onLoaded: { + if (item && item.hasOwnProperty("snapshot")) + item.snapshot = root.snapshot + } + } + } + + BusyIndicator { + Layout.alignment: Qt.AlignHCenter + visible: root.busy + running: root.busy + Accessible.name: qsTr("Submitting transaction") + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Button { + id: cancelButton + objectName: "transactionCancelButton" + Layout.fillWidth: true + implicitHeight: 44 + text: root.cancelText + enabled: !root.busy + Accessible.name: text + onClicked: root.cancel() + } + + Button { + id: confirmButton + objectName: "transactionConfirmButton" + Layout.fillWidth: true + implicitHeight: 44 + text: root.busy ? qsTr("Submitting...") : root.confirmText + enabled: !root.busy && root.confirmEnabled + Accessible.name: text + onClicked: root.confirm() + + background: Rectangle { + color: confirmButton.enabled + ? confirmButton.pressed ? "#d95c1e" : "#f26a21" + : "#52525b" + radius: 6 + } + + contentItem: Label { + text: confirmButton.text + color: "#ffffff" + font.bold: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + } +} diff --git a/apps/shared/wallet/qml/WalletControl.qml b/apps/shared/wallet/qml/WalletControl.qml new file mode 100644 index 00000000..54d72a2e --- /dev/null +++ b/apps/shared/wallet/qml/WalletControl.qml @@ -0,0 +1,533 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Item { + id: root + + property var wallet: null + property var accountModel: null + property var watchCall: null + property bool compact: false + property real viewportWidth: width + property int selectedIndex: 0 + property bool busy: false + property string handledSyncError: "" + + readonly property bool connected: root.wallet !== null && root.wallet.isWalletOpen + readonly property string syncStatus: root.wallet + ? String(root.wallet.walletSyncStatus || "closed") + : "closed" + readonly property bool syncing: root.syncStatus === "opening" + || root.syncStatus === "syncing" + readonly property bool compactLayout: root.compact || root.viewportWidth < 680 + readonly property string selectedAddress: root.accountAt(root.selectedIndex, "address") + readonly property string selectedName: root.accountAt(root.selectedIndex, "name") + readonly property string selectedBalance: root.accountAt(root.selectedIndex, "balance") + readonly property bool selectedIsPublic: root.accountAt(root.selectedIndex, "isPublic") === true + + implicitWidth: root.connected ? connectedButton.implicitWidth : connectButton.implicitWidth + implicitHeight: 40 + + Instantiator { + id: accounts + model: root.accountModel + delegate: QtObject { + required property string address + required property string name + required property string balance + required property bool isPublic + } + onCountChanged: root.clampSelection() + } + + function accountAt(index, field) { + const entry = index >= 0 && index < accounts.count ? accounts.objectAt(index) : null + return entry ? entry[field] : (field === "isPublic" ? false : "") + } + + function clampSelection() { + if (accounts.count === 0) { + root.selectedIndex = 0 + } else { + root.selectedIndex = Math.max(0, Math.min(root.selectedIndex, accounts.count - 1)) + } + } + + function shortAddress(address) { + return address && address.length > 13 + ? address.substring(0, 6) + "..." + address.substring(address.length - 4) + : address || "" + } + + function watchResult(result, success, failure) { + if (root.watchCall) { + root.watchCall(result, success, failure) + } else { + success(result) + } + } + + function showError(message) { + messageDialog.message = message + messageDialog.open() + } + + function handleSyncFailure() { + if (!root.wallet || root.syncStatus !== "error") { + root.handledSyncError = "" + return + } + const error = String(root.wallet.walletSyncError || "") + if (error.length === 0 || error === root.handledSyncError) + return + root.handledSyncError = error + root.busy = false + root.showError(qsTr("Wallet synchronization failed: %1").arg(error)) + } + + function openWallet() { + if (!root.wallet || root.busy) + return + root.busy = true + try { + root.watchResult(root.wallet.openExisting(), function(ok) { + root.busy = false + if (!ok) + root.showError(qsTr("Wallet could not be opened.")) + }, function(error) { + root.busy = false + root.showError(qsTr("Wallet could not be opened: %1").arg(error)) + }) + } catch (error) { + root.busy = false + root.showError(qsTr("Wallet could not be opened: %1").arg(error)) + } + } + + TextEdit { + id: clipboardProxy + visible: false + } + + function copyToClipboard(text) { + if (!text) + return + clipboardProxy.text = text + clipboardProxy.selectAll() + clipboardProxy.copy() + clipboardProxy.deselect() + clipboardProxy.text = "" + } + + Connections { + target: root.accountModel + ignoreUnknownSignals: true + function onModelReset() { root.clampSelection() } + function onRowsInserted() { root.clampSelection() } + function onRowsRemoved() { root.clampSelection() } + } + + Connections { + target: root.wallet + ignoreUnknownSignals: true + + function onWalletSyncStatusChanged() { root.handleSyncFailure() } + function onWalletSyncErrorChanged() { root.handleSyncFailure() } + } + + onConnectedChanged: { + if (!root.connected) { + root.selectedIndex = 0 + walletMenu.close() + } + } + + onViewportWidthChanged: { + if (walletMenu.opened) + Qt.callLater(walletMenu.updateAnchor) + } + + Button { + id: connectButton + objectName: "walletConnectButton" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: !root.connected + enabled: root.wallet !== null && !root.busy && !root.syncing + implicitHeight: 40 + implicitWidth: root.compactLayout ? 40 : 108 + text: root.compactLayout ? "" + : root.syncStatus === "opening" ? qsTr("Opening...") + : root.syncStatus === "syncing" ? qsTr("Syncing...") + : root.busy ? qsTr("Connecting...") : qsTr("Connect") + display: root.compactLayout ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + icon.source: Qt.resolvedUrl("icons/account.svg") + icon.color: "#ffffff" + icon.width: 18 + icon.height: 18 + Accessible.name: qsTr("Connect wallet") + ToolTip.text: Accessible.name + ToolTip.visible: hovered && root.compactLayout + + background: Rectangle { + color: connectButton.pressed ? "#d95c1e" : "#f26a21" + radius: 6 + } + + contentItem: RowLayout { + spacing: 6 + Image { + visible: root.compactLayout + source: connectButton.icon.source + sourceSize.width: 18 + sourceSize.height: 18 + } + Label { + Layout.fillWidth: true + visible: !root.compactLayout + text: connectButton.text + color: "#ffffff" + font.bold: true + horizontalAlignment: Text.AlignHCenter + } + } + + onClicked: { + if (root.wallet && root.wallet.walletExists) + root.openWallet() + else + createWalletDialog.open() + } + } + + Button { + id: connectedButton + objectName: "walletAccountButton" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: root.connected + enabled: !root.busy + implicitHeight: 40 + implicitWidth: root.compactLayout ? 44 : Math.max(140, accountButtonLabel.implicitWidth + 58) + Accessible.name: qsTr("Wallet account %1").arg(root.selectedAddress) + + background: Rectangle { + color: connectedButton.pressed ? "#3f3f46" : "#27272a" + border.width: walletMenu.opened || connectedButton.activeFocus ? 1 : 0 + border.color: "#f26a21" + radius: 6 + } + + contentItem: RowLayout { + spacing: 8 + + Rectangle { + Layout.preferredWidth: 8 + Layout.preferredHeight: 8 + radius: 4 + color: root.syncing ? "#f59e0b" : "#22c55e" + } + + Label { + id: accountButtonLabel + Layout.fillWidth: true + visible: !root.compactLayout + text: root.shortAddress(root.selectedAddress) || qsTr("Connected") + color: "#f4f4f5" + horizontalAlignment: Text.AlignHCenter + } + + Label { + visible: !root.compactLayout + text: walletMenu.opened ? "\u25b4" : "\u25be" + color: "#a1a1aa" + } + } + + onClicked: { + if (walletMenu.opened || Date.now() - walletMenu.lastClosedMs < 200) + walletMenu.close() + else + walletMenu.open() + } + } + + Popup { + id: walletMenu + objectName: "walletMenu" + property real lastClosedMs: 0 + property point anchorPosition: Qt.point(0, 0) + readonly property var viewport: Overlay.overlay + readonly property real spaceAbove: Math.max(0, anchorPosition.y - 20) + readonly property real spaceBelow: viewport + ? Math.max(0, viewport.height - anchorPosition.y - connectedButton.height - 20) + : implicitHeight + readonly property bool opensAbove: spaceBelow < implicitHeight && spaceAbove > spaceBelow + readonly property real availableMenuHeight: opensAbove ? spaceAbove : spaceBelow + parent: connectedButton + x: viewport + ? Math.max(12 - anchorPosition.x, + Math.min(connectedButton.width - width, + viewport.width - width - 12 - anchorPosition.x)) + : connectedButton.width - width + y: opensAbove + ? -height - 8 + : connectedButton.height + 8 + width: Math.min(360, Math.max(0, Math.min(root.viewportWidth, + viewport ? viewport.width : root.viewportWidth) - 24)) + height: Math.min(implicitHeight, availableMenuHeight) + margins: 12 + padding: 12 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + function updateAnchor() { + if (viewport) + anchorPosition = connectedButton.mapToItem(viewport, 0, 0) + } + + onAboutToShow: updateAnchor() + + onClosed: { + walletMenu.lastClosedMs = Date.now() + if (walletStack.depth > 1) + walletStack.pop(null, StackView.Immediate) + } + + Connections { + target: walletMenu.opened ? walletMenu.viewport : null + + function onWidthChanged() { Qt.callLater(walletMenu.updateAnchor) } + function onHeightChanged() { Qt.callLater(walletMenu.updateAnchor) } + } + + background: Rectangle { + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + } + + contentItem: StackView { + id: walletStack + width: walletMenu.availableWidth + height: walletMenu.availableHeight + implicitWidth: walletMenu.availableWidth + implicitHeight: currentItem ? currentItem.implicitHeight : 0 + initialItem: walletOverview + } + + Component { + id: walletOverview + + ColumnLayout { + spacing: 12 + + RowLayout { + Layout.fillWidth: true + + Item { Layout.fillWidth: true } + + WalletIconButton { + objectName: "walletAccountsButton" + iconSource: Qt.resolvedUrl("icons/account.svg") + accessibleName: qsTr("Accounts") + onClicked: walletStack.push(accountList) + } + + WalletIconButton { + objectName: "walletDisconnectButton" + iconSource: Qt.resolvedUrl("icons/power.svg") + accessibleName: qsTr("Disconnect") + onClicked: { + walletMenu.close() + if (root.wallet) + root.wallet.disconnectWallet() + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: accountCard.implicitHeight + 24 + color: "#27272a" + radius: 6 + + ColumnLayout { + id: accountCard + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + + Label { + text: root.selectedName || qsTr("Account") + color: "#f4f4f5" + font.bold: true + } + + Label { + text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private") + color: "#a1a1aa" + font.pixelSize: 11 + } + + Item { Layout.fillWidth: true } + + Label { + text: root.selectedBalance || "-" + color: "#f4f4f5" + font.bold: true + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 4 + + Label { + Layout.fillWidth: true + text: root.selectedAddress + color: "#a1a1aa" + font.family: "monospace" + font.pixelSize: 11 + elide: Text.ElideMiddle + } + + CopyButton { + visible: root.selectedAddress.length > 0 + onCopyRequested: root.copyToClipboard(root.selectedAddress) + } + } + } + } + } + } + + Component { + id: accountList + + ColumnLayout { + spacing: 12 + + RowLayout { + Layout.fillWidth: true + + WalletIconButton { + iconSource: Qt.resolvedUrl("icons/back.svg") + accessibleName: qsTr("Back") + onClicked: walletStack.pop() + } + + Label { + Layout.fillWidth: true + text: qsTr("Accounts") + color: "#f4f4f5" + font.bold: true + } + } + + ListView { + id: accountListView + objectName: "walletAccountList" + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 0 + Layout.preferredHeight: Math.min(contentHeight, 260) + clip: true + spacing: 6 + model: root.accountModel + ScrollIndicator.vertical: ScrollIndicator { } + + delegate: AccountDelegate { + width: ListView.view.width + highlighted: index === root.selectedIndex + onClicked: { + root.selectedIndex = index + walletStack.pop() + } + onCopyRequested: function(text) { root.copyToClipboard(text) } + } + } + + Button { + objectName: "walletAddAccountButton" + Layout.fillWidth: true + text: qsTr("Add account") + enabled: !root.busy + onClicked: createAccountDialog.open() + } + } + } + } + + CreateWalletDialog { + id: createWalletDialog + objectName: "createWalletDialog" + walletHome: root.wallet ? root.wallet.walletHome || "" : "" + busy: root.busy + + onCreateRequested: function(password) { + if (!root.wallet || root.busy) + return + root.busy = true + try { + root.watchResult(root.wallet.createNewDefault(password), function(mnemonic) { + root.busy = false + if (mnemonic && mnemonic.length > 0) + createWalletDialog.mnemonic = mnemonic + else + createWalletDialog.errorText = qsTr("Wallet could not be created.") + }, function(error) { + root.busy = false + createWalletDialog.errorText = qsTr("Wallet could not be created: %1").arg(error) + }) + } catch (error) { + root.busy = false + createWalletDialog.errorText = qsTr("Wallet could not be created: %1").arg(error) + } + } + + onCopyRequested: function(text) { root.copyToClipboard(text) } + } + + CreateAccountDialog { + id: createAccountDialog + objectName: "createAccountDialog" + busy: root.busy + + onCreateRequested: function(isPublic) { + if (!root.wallet || root.busy) + return + root.busy = true + try { + const request = isPublic + ? root.wallet.createAccountPublic() + : root.wallet.createAccountPrivate() + root.watchResult(request, function(accountId) { + root.busy = false + if (accountId && accountId.length > 0) { + createAccountDialog.close() + } else { + root.showError(qsTr("Account could not be created.")) + } + }, function(error) { + root.busy = false + root.showError(qsTr("Account could not be created: %1").arg(error)) + }) + } catch (error) { + root.busy = false + root.showError(qsTr("Account could not be created: %1").arg(error)) + } + } + } + + WalletMessageDialog { + id: messageDialog + objectName: "walletMessageDialog" + } +} diff --git a/apps/shared/wallet/qml/internal/AccountDelegate.qml b/apps/shared/wallet/qml/internal/AccountDelegate.qml new file mode 100644 index 00000000..b0ff529f --- /dev/null +++ b/apps/shared/wallet/qml/internal/AccountDelegate.qml @@ -0,0 +1,77 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +ItemDelegate { + id: root + + required property int index + required property string name + required property string address + required property string balance + required property bool isPublic + + signal copyRequested(string text) + + leftPadding: 12 + rightPadding: 8 + topPadding: 10 + bottomPadding: 10 + + Accessible.name: qsTr("%1, balance %2").arg(root.name).arg(root.balance || "0") + + background: Rectangle { + color: root.highlighted || root.hovered ? "#27272a" : "#18181b" + radius: 6 + border.width: root.activeFocus ? 1 : 0 + border.color: "#f26a21" + } + + contentItem: ColumnLayout { + spacing: 6 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + text: root.name + color: "#f4f4f5" + font.bold: true + } + + Label { + text: root.isPublic ? qsTr("Public") : qsTr("Private") + color: "#a1a1aa" + font.pixelSize: 11 + } + + Item { Layout.fillWidth: true } + + Label { + text: root.balance.length > 0 ? root.balance : "-" + color: "#f4f4f5" + font.bold: true + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 4 + + Label { + Layout.fillWidth: true + text: root.address + color: "#a1a1aa" + font.family: "monospace" + font.pixelSize: 11 + elide: Text.ElideMiddle + } + + CopyButton { + visible: root.address.length > 0 + onCopyRequested: root.copyRequested(root.address) + } + } + } +} diff --git a/apps/shared/wallet/qml/internal/CopyButton.qml b/apps/shared/wallet/qml/internal/CopyButton.qml new file mode 100644 index 00000000..1964da3c --- /dev/null +++ b/apps/shared/wallet/qml/internal/CopyButton.qml @@ -0,0 +1,26 @@ +import QtQuick + +WalletIconButton { + id: root + + signal copyRequested + + property bool copied: false + + accessibleName: root.copied ? qsTr("Copied") : qsTr("Copy") + iconSource: root.copied + ? Qt.resolvedUrl("icons/checkmark.svg") + : Qt.resolvedUrl("icons/copy.svg") + + Timer { + id: resetTimer + interval: 1500 + onTriggered: root.copied = false + } + + onClicked: { + root.copyRequested() + root.copied = true + resetTimer.restart() + } +} diff --git a/apps/shared/wallet/qml/internal/CreateAccountDialog.qml b/apps/shared/wallet/qml/internal/CreateAccountDialog.qml new file mode 100644 index 00000000..51d5f31f --- /dev/null +++ b/apps/shared/wallet/qml/internal/CreateAccountDialog.qml @@ -0,0 +1,94 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Popup { + id: root + + property bool busy: false + + signal createRequested(bool isPublic) + + modal: true + dim: true + parent: Overlay.overlay + width: Math.max(0, Math.min(380, parent ? parent.width - 32 : 380)) + x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 + y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 + padding: 20 + closePolicy: root.busy ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside + + onOpened: privateSwitch.checked = false + + background: Rectangle { + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + } + + contentItem: ColumnLayout { + spacing: 16 + + Label { + Layout.fillWidth: true + text: qsTr("Create account") + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Label { + text: privateSwitch.checked ? qsTr("Private") : qsTr("Public") + color: "#f4f4f5" + font.bold: true + } + + Label { + Layout.fillWidth: true + text: privateSwitch.checked + ? qsTr("Private balance and activity") + : qsTr("Public balance and activity") + color: "#a1a1aa" + wrapMode: Text.WordWrap + } + } + + Switch { + id: privateSwitch + objectName: "privateAccountSwitch" + Accessible.name: qsTr("Create private account") + enabled: !root.busy + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Button { + Layout.fillWidth: true + text: qsTr("Cancel") + enabled: !root.busy + onClicked: root.close() + } + + Button { + id: createButton + objectName: "createAccountButton" + Layout.fillWidth: true + text: root.busy ? qsTr("Creating...") : qsTr("Create") + enabled: !root.busy + onClicked: root.createRequested(!privateSwitch.checked) + } + } + } +} diff --git a/apps/shared/wallet/qml/internal/CreateWalletDialog.qml b/apps/shared/wallet/qml/internal/CreateWalletDialog.qml new file mode 100644 index 00000000..ac422c1f --- /dev/null +++ b/apps/shared/wallet/qml/internal/CreateWalletDialog.qml @@ -0,0 +1,190 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Popup { + id: root + + property string walletHome: "" + property string mnemonic: "" + property string errorText: "" + property bool busy: false + + signal createRequested(string password) + signal copyRequested(string text) + + modal: true + dim: true + parent: Overlay.overlay + width: Math.max(0, Math.min(420, parent ? parent.width - 32 : 420)) + x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 + y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 + padding: 20 + closePolicy: root.busy || root.mnemonic.length > 0 + ? Popup.NoAutoClose + : Popup.CloseOnEscape | Popup.CloseOnPressOutside + + onOpened: { + passwordField.text = "" + confirmField.text = "" + acknowledgement.checked = false + root.errorText = "" + root.mnemonic = "" + passwordField.forceActiveFocus() + } + + background: Rectangle { + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + } + + contentItem: ColumnLayout { + spacing: 16 + + ColumnLayout { + Layout.fillWidth: true + spacing: 14 + visible: root.mnemonic.length === 0 + + Label { + Layout.fillWidth: true + text: qsTr("Create wallet") + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + } + + Label { + Layout.fillWidth: true + text: root.walletHome.length > 0 + ? qsTr("Wallet will be stored at %1").arg(root.walletHome) + : qsTr("Wallet will use default storage") + color: "#a1a1aa" + wrapMode: Text.WordWrap + } + + TextField { + id: passwordField + objectName: "walletPasswordField" + Layout.fillWidth: true + placeholderText: qsTr("Password") + echoMode: TextInput.Password + enabled: !root.busy + Keys.onReturnPressed: createButton.tryCreate() + } + + TextField { + id: confirmField + objectName: "walletConfirmPasswordField" + Layout.fillWidth: true + placeholderText: qsTr("Confirm password") + echoMode: TextInput.Password + enabled: !root.busy + Keys.onReturnPressed: createButton.tryCreate() + } + + Label { + Layout.fillWidth: true + visible: root.errorText.length > 0 + text: root.errorText + color: "#f87171" + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Button { + Layout.fillWidth: true + text: qsTr("Cancel") + enabled: !root.busy + onClicked: root.close() + } + + Button { + id: createButton + objectName: "createWalletButton" + Layout.fillWidth: true + text: root.busy ? qsTr("Creating...") : qsTr("Create wallet") + enabled: !root.busy + + function tryCreate() { + if (passwordField.text.length === 0) { + root.errorText = qsTr("Password cannot be empty.") + } else if (passwordField.text !== confirmField.text) { + root.errorText = qsTr("Passwords do not match.") + } else { + root.errorText = "" + root.createRequested(passwordField.text) + } + } + + onClicked: tryCreate() + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 14 + visible: root.mnemonic.length > 0 + + Label { + Layout.fillWidth: true + text: qsTr("Back up recovery phrase") + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + } + + Label { + Layout.fillWidth: true + text: qsTr("Write these words down in order. Anyone with this phrase can control this wallet.") + color: "#d4d4d8" + wrapMode: Text.WordWrap + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: phraseLabel.implicitHeight + 24 + color: "#27272a" + radius: 6 + + Label { + id: phraseLabel + objectName: "walletMnemonic" + anchors.fill: parent + anchors.margins: 12 + text: root.mnemonic + color: "#f4f4f5" + font.bold: true + wrapMode: Text.WordWrap + } + } + + Button { + Layout.fillWidth: true + text: qsTr("Copy recovery phrase") + onClicked: root.copyRequested(root.mnemonic) + } + + CheckBox { + id: acknowledgement + objectName: "walletBackupAcknowledgement" + Layout.fillWidth: true + text: qsTr("I have safely backed up my recovery phrase") + } + + Button { + objectName: "walletContinueButton" + Layout.fillWidth: true + text: qsTr("Continue") + enabled: acknowledgement.checked + onClicked: root.close() + } + } + } +} diff --git a/apps/shared/wallet/qml/internal/WalletIconButton.qml b/apps/shared/wallet/qml/internal/WalletIconButton.qml new file mode 100644 index 00000000..159d5e10 --- /dev/null +++ b/apps/shared/wallet/qml/internal/WalletIconButton.qml @@ -0,0 +1,31 @@ +import QtQuick +import QtQuick.Controls.Basic + +Button { + id: root + + property url iconSource + property string accessibleName: "" + property color iconColor: "#d4d4d8" + + implicitWidth: 36 + implicitHeight: 36 + display: AbstractButton.IconOnly + hoverEnabled: true + + Accessible.name: root.accessibleName + ToolTip.text: root.accessibleName + ToolTip.visible: root.hovered && root.accessibleName.length > 0 + + icon.source: root.iconSource + icon.width: 18 + icon.height: 18 + icon.color: root.iconColor + + background: Rectangle { + color: root.pressed ? "#3f3f46" : root.hovered || root.activeFocus ? "#27272a" : "transparent" + radius: 6 + border.width: root.activeFocus ? 1 : 0 + border.color: "#f26a21" + } +} diff --git a/apps/shared/wallet/qml/internal/WalletMessageDialog.qml b/apps/shared/wallet/qml/internal/WalletMessageDialog.qml new file mode 100644 index 00000000..7f7a3467 --- /dev/null +++ b/apps/shared/wallet/qml/internal/WalletMessageDialog.qml @@ -0,0 +1,52 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Popup { + id: root + + property string title: qsTr("Wallet error") + property string message: "" + + modal: true + dim: true + parent: Overlay.overlay + width: Math.max(0, Math.min(380, parent ? parent.width - 32 : 380)) + x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 + y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 + padding: 20 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + } + + contentItem: ColumnLayout { + spacing: 16 + + Label { + Layout.fillWidth: true + text: root.title + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + wrapMode: Text.WordWrap + } + + Label { + Layout.fillWidth: true + text: root.message + color: "#d4d4d8" + wrapMode: Text.WordWrap + } + + Button { + Layout.alignment: Qt.AlignRight + text: qsTr("Close") + onClicked: root.close() + } + } +} diff --git a/apps/amm/qml/components/wallet/icons/account.svg b/apps/shared/wallet/qml/internal/icons/account.svg similarity index 100% rename from apps/amm/qml/components/wallet/icons/account.svg rename to apps/shared/wallet/qml/internal/icons/account.svg diff --git a/apps/amm/qml/components/wallet/icons/back.svg b/apps/shared/wallet/qml/internal/icons/back.svg similarity index 100% rename from apps/amm/qml/components/wallet/icons/back.svg rename to apps/shared/wallet/qml/internal/icons/back.svg diff --git a/apps/amm/qml/components/wallet/icons/checkmark.svg b/apps/shared/wallet/qml/internal/icons/checkmark.svg similarity index 100% rename from apps/amm/qml/components/wallet/icons/checkmark.svg rename to apps/shared/wallet/qml/internal/icons/checkmark.svg diff --git a/apps/amm/qml/components/wallet/icons/copy.svg b/apps/shared/wallet/qml/internal/icons/copy.svg similarity index 100% rename from apps/amm/qml/components/wallet/icons/copy.svg rename to apps/shared/wallet/qml/internal/icons/copy.svg diff --git a/apps/amm/qml/components/wallet/icons/power.svg b/apps/shared/wallet/qml/internal/icons/power.svg similarity index 100% rename from apps/amm/qml/components/wallet/icons/power.svg rename to apps/shared/wallet/qml/internal/icons/power.svg diff --git a/apps/shared/wallet/src/LogosWalletProvider.cpp b/apps/shared/wallet/src/LogosWalletProvider.cpp new file mode 100644 index 00000000..a95c3774 --- /dev/null +++ b/apps/shared/wallet/src/LogosWalletProvider.cpp @@ -0,0 +1,885 @@ +#include "LogosWalletProvider.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "logos_sdk.h" + +namespace { +constexpr int WALLET_FFI_SUCCESS = 0; + +bool isHex(const QString& value, qsizetype size, bool lowercaseOnly = true) +{ + if (value.size() != size) + return false; + + for (const QChar character : value) { + const bool digit = character >= QLatin1Char('0') + && character <= QLatin1Char('9'); + const bool lowercase = character >= QLatin1Char('a') + && character <= QLatin1Char('f'); + const bool uppercase = character >= QLatin1Char('A') + && character <= QLatin1Char('F'); + if (!digit && !lowercase && (!uppercase || lowercaseOnly)) + return false; + } + return true; +} + +QString littleEndianU128ToDecimal(const QString& value) +{ + if (!isHex(value, 32)) + return {}; + + QString decimal = QStringLiteral("0"); + for (int byteIndex = 15; byteIndex >= 0; --byteIndex) { + bool parsed = false; + int carry = value.mid(byteIndex * 2, 2).toInt(&parsed, 16); + if (!parsed) + return {}; + + for (qsizetype digit = decimal.size(); digit-- > 0;) { + const int next = (decimal.at(digit).unicode() - QLatin1Char('0').unicode()) + * 256 + carry; + decimal[digit] = QLatin1Char(static_cast('0' + next % 10)); + carry = next / 10; + } + while (carry > 0) { + decimal.prepend(QLatin1Char(static_cast('0' + carry % 10))); + carry /= 10; + } + } + + return decimal; +} + +WalletSession failedSession(WalletFailure failure) +{ + WalletSession session; + session.failure = failure; + session.snapshot.failure = failure; + return session; +} + +WalletCreation failedCreation(WalletFailure failure) +{ + WalletCreation creation; + creation.failure = failure; + creation.snapshot.failure = failure; + return creation; +} + +WalletAccountRead parsePublicAccount(const QString& accountId, const QString& payload) +{ + WalletAccountRead read; + read.accountId = accountId; + if (!isHex(accountId, 64)) + return read; + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(payload.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + return read; + + const QJsonObject account = document.object(); + const QString owner = account.value(QStringLiteral("program_owner")).toString(); + const QString balance = account.value(QStringLiteral("balance")).toString(); + const QString nonce = account.value(QStringLiteral("nonce")).toString(); + const QString data = account.value(QStringLiteral("data")).toString(); + if (!isHex(owner, 64) + || !isHex(balance, 32) + || !isHex(nonce, 32) + || data.size() % 2 != 0 + || !isHex(data, data.size())) { + return read; + } + + read.status = QStringLiteral("ok"); + read.programOwner = owner; + read.balanceHex = balance; + read.nonceHex = nonce; + read.dataHex = data; + return read; +} + +bool encodeTransaction(const WalletTransaction& transaction, + QVariantList* signingRequirements, + QVariantList* instruction) +{ + if (!isHex(transaction.programId, 64) + || transaction.accountIds.size() != transaction.signingRequirements.size()) { + return false; + } + for (const QString& accountId : transaction.accountIds) { + if (!isHex(accountId, 64)) + return false; + } + + signingRequirements->reserve(transaction.signingRequirements.size()); + for (bool required : transaction.signingRequirements) + signingRequirements->append(required); + + instruction->reserve(transaction.instruction.size()); + for (quint32 word : transaction.instruction) + instruction->append(word); + return true; +} + +WalletSubmission parseSubmission(const QString& response) +{ + WalletSubmission submission; + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(response.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + submission.failure = WalletFailure::SubmissionFailed; + return submission; + } + + const QJsonObject result = document.object(); + const QJsonValue success = result.value(QStringLiteral("success")); + const QJsonValue error = result.value(QStringLiteral("error")); + const QString hash = result.value(QStringLiteral("tx_hash")).toString(); + const bool emptyError = error.isUndefined() + || error.isNull() + || (error.isString() && error.toString().isEmpty()); + if (!success.isBool() + || !success.toBool() + || !emptyError + || !isHex(hash, 64, false)) { + submission.failure = WalletFailure::SubmissionFailed; + return submission; + } + + submission.nativeHash = hash.toLower(); + return submission; +} +} + +struct LogosWalletProvider::Impl { + explicit Impl(LogosAPI* api) + : ownedLogos(std::make_unique(api)), logos(ownedLogos.get()) + { + } + + explicit Impl(LogosModules* value) + : logos(value) + { + } + + std::unique_ptr ownedLogos; + LogosModules* logos = nullptr; +}; + +LogosWalletProvider::LogosWalletProvider(LogosAPI* api) + : m_impl(std::make_unique(api)) +{ +} + +LogosWalletProvider::LogosWalletProvider(LogosModules* logos) + : m_impl(std::make_unique(logos)) +{ +} + +LogosWalletProvider::~LogosWalletProvider() +{ + ++m_generation; + ++m_sessionGeneration; + if (m_connected) + save(); +} + +WalletSession LogosWalletProvider::connect(const WalletPaths& paths) +{ + ++m_generation; + ++m_sessionGeneration; + clearSnapshot(); + if (!m_impl->logos) + return failedSession(WalletFailure::WalletUnavailable); + + WalletSession session; + if (sharedWalletIsOpen()) { + session.adopted = true; + } else { + if (!QFileInfo::exists(paths.storage)) + return failedSession(WalletFailure::WalletMissing); + if (m_impl->logos->logos_execution_zone.open(paths.config, paths.storage) + != WALLET_FFI_SUCCESS) { + return failedSession(WalletFailure::OpenFailed); + } + } + + m_connected = true; + session.snapshot = snapshot(true); + session.failure = session.snapshot.failure; + return session; +} + +void LogosWalletProvider::connectAsync(const WalletPaths& paths, SessionCallback callback) +{ + ++m_sessionGeneration; + clearSnapshot(); + const quint64 generation = ++m_generation; + if (!m_impl->logos) { + QTimer::singleShot(0, [callback = std::move(callback)]() mutable { + callback(failedSession(WalletFailure::WalletUnavailable)); + }); + return; + } + + auto finishOpen = [this, generation, callback = std::move(callback)]( + bool adopted, WalletFailure failure) mutable { + if (generation != m_generation) + return; + if (failure != WalletFailure::None) { + callback(failedSession(failure)); + return; + } + m_connected = true; + loadSnapshotAsync(generation, + [this, generation, adopted, callback = std::move(callback)]( + WalletSnapshot snapshot) mutable { + if (generation != m_generation) + return; + WalletSession session; + session.adopted = adopted; + session.failure = snapshot.failure; + session.snapshot = std::move(snapshot); + callback(std::move(session)); + }); + }; + + auto openStored = [this, generation, paths, finishOpen]() mutable { + if (generation != m_generation) + return; + if (!QFileInfo::exists(paths.storage)) { + finishOpen(false, WalletFailure::WalletMissing); + return; + } + m_impl->logos->logos_execution_zone.openAsync( + paths.config, paths.storage, + [this, generation, finishOpen](int result) mutable { + if (generation != m_generation) + return; + finishOpen(false, result == WALLET_FFI_SUCCESS + ? WalletFailure::None : WalletFailure::OpenFailed); + }); + }; + + m_impl->logos->logos_execution_zone.get_sequencer_addrAsync( + [this, generation, finishOpen, openStored](QString address) mutable { + if (generation != m_generation) + return; + if (!address.isEmpty()) { + finishOpen(true, WalletFailure::None); + return; + } + m_impl->logos->logos_execution_zone.list_accountsAsync( + [this, generation, finishOpen, openStored](QVariantList accounts) mutable { + if (generation != m_generation) + return; + if (!accounts.isEmpty()) + finishOpen(true, WalletFailure::None); + else + openStored(); + }); + }); +} + +WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths, + const QString& password) +{ + ++m_generation; + ++m_sessionGeneration; + clearSnapshot(); + if (!m_impl->logos) + return failedCreation(WalletFailure::WalletUnavailable); + + const QFileInfo configInfo(paths.config); + const QFileInfo storageInfo(paths.storage); + if (!QDir().mkpath(configInfo.absolutePath()) + || !QDir().mkpath(storageInfo.absolutePath())) { + return failedCreation(WalletFailure::CreateFailed); + } + + WalletCreation creation; + creation.mnemonic = m_impl->logos->logos_execution_zone.create_new( + paths.config, paths.storage, password); + if (creation.mnemonic.isEmpty()) + return failedCreation(WalletFailure::CreateFailed); + + m_connected = true; + if (!save()) { + creation.failure = WalletFailure::SaveFailed; + creation.snapshot.failure = creation.failure; + return creation; + } + + return creation; +} + +WalletSnapshot LogosWalletProvider::snapshot(bool forceRefresh) +{ + if (m_snapshotReady && !forceRefresh) + return m_snapshot; + if (!m_connected) { + WalletSnapshot result; + result.failure = WalletFailure::WalletUnavailable; + return result; + } + + WalletSnapshot result = loadSnapshot(); + if (result.ok()) { + m_snapshot = result; + m_snapshotReady = true; + } + return result; +} + +void LogosWalletProvider::snapshotAsync(bool forceRefresh, SnapshotCallback callback) +{ + if (m_snapshotReady && !forceRefresh) { + const WalletSnapshot snapshot = m_snapshot; + QTimer::singleShot(0, [callback = std::move(callback), snapshot]() mutable { + callback(snapshot); + }); + return; + } + if (!m_connected) { + WalletSnapshot snapshot; + snapshot.failure = WalletFailure::WalletUnavailable; + QTimer::singleShot(0, [callback = std::move(callback), snapshot]() mutable { + callback(snapshot); + }); + return; + } + loadSnapshotAsync(++m_generation, std::move(callback)); +} + +void LogosWalletProvider::clearSnapshot() +{ + m_snapshot = {}; + m_snapshotReady = false; +} + +WalletAccountCreation LogosWalletProvider::createAccount(bool isPublic) +{ + WalletAccountCreation creation; + if (!m_connected || !m_impl->logos) { + creation.failure = WalletFailure::WalletUnavailable; + return creation; + } + + creation.accountId = isPublic + ? m_impl->logos->logos_execution_zone.create_account_public() + : m_impl->logos->logos_execution_zone.create_account_private(); + if (!isHex(creation.accountId, 64)) { + creation.failure = WalletFailure::CreateFailed; + return creation; + } + if (!save()) { + creation.failure = WalletFailure::SaveFailed; + return creation; + } + + if (isPublic) + creation.publicAccount = readPublicAccount(creation.accountId); + if (m_snapshotReady) { + WalletAccount account; + account.address = creation.accountId; + account.isPublic = isPublic; + if (isPublic && creation.publicAccount.ok()) { + account.balance = littleEndianU128ToDecimal(creation.publicAccount.balanceHex); + auto read = std::find_if( + m_snapshot.publicAccountReads.begin(), + m_snapshot.publicAccountReads.end(), + [&creation](const WalletAccountRead& existing) { + return existing.accountId == creation.accountId; + }); + if (read == m_snapshot.publicAccountReads.end()) + m_snapshot.publicAccountReads.append(creation.publicAccount); + else + *read = creation.publicAccount; + } else { + account.balance = m_impl->logos->logos_execution_zone.get_balance( + creation.accountId, isPublic); + } + auto existing = std::find_if( + m_snapshot.accounts.begin(), m_snapshot.accounts.end(), + [&creation](const WalletAccount& candidate) { + return candidate.address == creation.accountId; + }); + if (existing == m_snapshot.accounts.end()) + m_snapshot.accounts.append(account); + else + *existing = account; + creation.snapshot = m_snapshot; + } + return creation; +} + +void LogosWalletProvider::createAccountAsync(bool isPublic, + AccountCreationCallback callback) +{ + QPointer guard(this); + if (!m_connected || !m_impl->logos) { + QTimer::singleShot(0, [guard, callback = std::move(callback)]() mutable { + if (!guard) + return; + WalletAccountCreation creation; + creation.failure = WalletFailure::WalletUnavailable; + callback(std::move(creation)); + }); + return; + } + + const quint64 sessionGeneration = m_sessionGeneration; + auto finish = [guard, sessionGeneration, isPublic, + callback = std::move(callback)]( + WalletAccountCreation creation, QString fallbackBalance) mutable { + if (!guard) + return; + if (sessionGeneration != guard->m_sessionGeneration) { + WalletAccountCreation failed; + failed.failure = WalletFailure::WalletUnavailable; + callback(std::move(failed)); + return; + } + + if (guard->m_snapshotReady) { + WalletAccount account; + account.address = creation.accountId; + account.isPublic = isPublic; + if (isPublic && creation.publicAccount.ok()) { + account.balance = littleEndianU128ToDecimal( + creation.publicAccount.balanceHex); + auto read = std::find_if( + guard->m_snapshot.publicAccountReads.begin(), + guard->m_snapshot.publicAccountReads.end(), + [&creation](const WalletAccountRead& existing) { + return existing.accountId == creation.accountId; + }); + if (read == guard->m_snapshot.publicAccountReads.end()) + guard->m_snapshot.publicAccountReads.append(creation.publicAccount); + else + *read = creation.publicAccount; + } else { + account.balance = std::move(fallbackBalance); + } + auto existing = std::find_if( + guard->m_snapshot.accounts.begin(), + guard->m_snapshot.accounts.end(), + [&creation](const WalletAccount& candidate) { + return candidate.address == creation.accountId; + }); + if (existing == guard->m_snapshot.accounts.end()) + guard->m_snapshot.accounts.append(account); + else + *existing = account; + creation.snapshot = guard->m_snapshot; + } + callback(std::move(creation)); + }; + + auto created = [guard, sessionGeneration, isPublic, + finish = std::move(finish)](QString accountId) mutable { + if (!guard) + return; + if (sessionGeneration != guard->m_sessionGeneration) { + WalletAccountCreation failed; + failed.failure = WalletFailure::WalletUnavailable; + finish(std::move(failed), {}); + return; + } + + WalletAccountCreation creation; + creation.accountId = std::move(accountId); + if (!isHex(creation.accountId, 64)) { + creation.failure = WalletFailure::CreateFailed; + finish(std::move(creation), {}); + return; + } + + guard->m_impl->logos->logos_execution_zone.saveAsync( + [guard, sessionGeneration, isPublic, creation = std::move(creation), + finish = std::move(finish)](int result) mutable { + if (!guard) + return; + if (sessionGeneration != guard->m_sessionGeneration) { + WalletAccountCreation failed; + failed.failure = WalletFailure::WalletUnavailable; + finish(std::move(failed), {}); + return; + } + if (result != WALLET_FFI_SUCCESS) { + creation.failure = WalletFailure::SaveFailed; + finish(std::move(creation), {}); + return; + } + + if (!isPublic) { + guard->m_impl->logos->logos_execution_zone.get_balanceAsync( + creation.accountId, false, + [guard, sessionGeneration, creation = std::move(creation), + finish = std::move(finish)](QString balance) mutable { + if (!guard) + return; + if (sessionGeneration != guard->m_sessionGeneration) { + WalletAccountCreation failed; + failed.failure = WalletFailure::WalletUnavailable; + finish(std::move(failed), {}); + return; + } + finish(std::move(creation), std::move(balance)); + }); + return; + } + + const QString accountId = creation.accountId; + guard->m_impl->logos->logos_execution_zone.get_account_publicAsync( + accountId, + [guard, sessionGeneration, creation = std::move(creation), + finish = std::move(finish)](QString payload) mutable { + if (!guard) + return; + if (sessionGeneration != guard->m_sessionGeneration) { + WalletAccountCreation failed; + failed.failure = WalletFailure::WalletUnavailable; + finish(std::move(failed), {}); + return; + } + creation.publicAccount = parsePublicAccount( + creation.accountId, payload); + if (creation.publicAccount.ok()) { + finish(std::move(creation), {}); + return; + } + guard->m_impl->logos->logos_execution_zone.get_balanceAsync( + creation.accountId, true, + [guard, sessionGeneration, + creation = std::move(creation), + finish = std::move(finish)](QString balance) mutable { + if (!guard) + return; + if (sessionGeneration != guard->m_sessionGeneration) { + WalletAccountCreation failed; + failed.failure = WalletFailure::WalletUnavailable; + finish(std::move(failed), {}); + return; + } + finish(std::move(creation), std::move(balance)); + }); + }); + }); + }; + + if (isPublic) + m_impl->logos->logos_execution_zone.create_account_publicAsync(std::move(created)); + else + m_impl->logos->logos_execution_zone.create_account_privateAsync(std::move(created)); +} + +WalletAccountRead LogosWalletProvider::readPublicAccount(const QString& accountId) const +{ + if (!m_impl->logos || !isHex(accountId, 64)) + return WalletAccountRead { accountId }; + return parsePublicAccount( + accountId, + m_impl->logos->logos_execution_zone.get_account_public(accountId)); +} + +WalletSubmission LogosWalletProvider::submitPublicTransaction( + const WalletTransaction& transaction) +{ + WalletSubmission submission; + if (!m_connected || !m_impl->logos) { + submission.failure = WalletFailure::WalletUnavailable; + return submission; + } + QVariantList signingRequirements; + QVariantList instruction; + if (!encodeTransaction(transaction, &signingRequirements, &instruction)) { + submission.failure = WalletFailure::InvalidRequest; + return submission; + } + + const QString response = + m_impl->logos->logos_execution_zone.send_generic_public_transaction( + transaction.accountIds, + signingRequirements, + QVariant::fromValue(instruction), + transaction.programId); + return parseSubmission(response); +} + +void LogosWalletProvider::submitPublicTransactionAsync( + const WalletTransaction& transaction, SubmissionCallback callback) +{ + QPointer guard(this); + WalletSubmission submission; + if (!m_connected || !m_impl->logos) { + submission.failure = WalletFailure::WalletUnavailable; + QTimer::singleShot(0, [guard, callback = std::move(callback), + submission = std::move(submission)]() mutable { + if (guard) + callback(std::move(submission)); + }); + return; + } + + QVariantList signingRequirements; + QVariantList instruction; + if (!encodeTransaction(transaction, &signingRequirements, &instruction)) { + submission.failure = WalletFailure::InvalidRequest; + QTimer::singleShot(0, [guard, callback = std::move(callback), + submission = std::move(submission)]() mutable { + if (guard) + callback(std::move(submission)); + }); + return; + } + + const quint64 sessionGeneration = m_sessionGeneration; + m_impl->logos->logos_execution_zone.send_generic_public_transactionAsync( + transaction.accountIds, + signingRequirements, + QVariant::fromValue(instruction), + transaction.programId, + [guard, sessionGeneration, callback = std::move(callback)]( + QString response) mutable { + if (!guard) + return; + if (sessionGeneration != guard->m_sessionGeneration) { + WalletSubmission failed; + failed.failure = WalletFailure::WalletUnavailable; + callback(std::move(failed)); + return; + } + callback(parseSubmission(response)); + }); +} + +void LogosWalletProvider::disconnect() +{ + ++m_generation; + ++m_sessionGeneration; + if (m_connected) + save(); + clearSnapshot(); + m_connected = false; +} + +bool LogosWalletProvider::sharedWalletIsOpen() const +{ + if (!m_impl->logos) + return false; + if (!m_impl->logos->logos_execution_zone.get_sequencer_addr().isEmpty()) + return true; + return !m_impl->logos->logos_execution_zone.list_accounts().isEmpty(); +} + +WalletSnapshot LogosWalletProvider::loadSnapshot() +{ + WalletSnapshot result; + result.currentBlockHeight = static_cast( + qMax(0, m_impl->logos->logos_execution_zone.get_current_block_height())); + if (result.currentBlockHeight > 0 + && m_impl->logos->logos_execution_zone.sync_to_block(result.currentBlockHeight) + != WALLET_FFI_SUCCESS) { + result.failure = WalletFailure::ReadFailed; + return result; + } + result.lastSyncedBlock = static_cast( + qMax(0, m_impl->logos->logos_execution_zone.get_last_synced_block())); + result.sequencerAddress = m_impl->logos->logos_execution_zone.get_sequencer_addr(); + + const QVariantList entries = m_impl->logos->logos_execution_zone.list_accounts(); + result.accounts.reserve(entries.size()); + result.publicAccountReads.reserve(entries.size()); + for (const QVariant& value : entries) { + const QVariantMap entry = value.toMap(); + const QString address = entry.value(QStringLiteral("account_id")).toString(); + if (entry.isEmpty() || !isHex(address, 64)) { + result.failure = WalletFailure::ReadFailed; + return result; + } + + WalletAccount account; + account.address = address; + account.isPublic = entry.value(QStringLiteral("is_public"), true).toBool(); + if (account.isPublic) { + const WalletAccountRead read = readPublicAccount(address); + result.publicAccountReads.append(read); + account.balance = read.ok() + ? littleEndianU128ToDecimal(read.balanceHex) + : m_impl->logos->logos_execution_zone.get_balance(address, true); + } else { + account.balance = m_impl->logos->logos_execution_zone.get_balance(address, false); + } + result.accounts.append(account); + } + + return result; +} + +void LogosWalletProvider::loadSnapshotAsync(quint64 generation, SnapshotCallback callback) +{ + if (!m_impl->logos || generation != m_generation) + return; + + m_impl->logos->logos_execution_zone.get_current_block_heightAsync( + [this, generation, callback = std::move(callback)](int currentHeight) mutable { + if (generation != m_generation) + return; + + auto afterSync = [this, generation, currentHeight, + callback = std::move(callback)](int syncResult) mutable { + if (generation != m_generation) + return; + if (syncResult != WALLET_FFI_SUCCESS) { + WalletSnapshot failed; + failed.failure = WalletFailure::ReadFailed; + callback(std::move(failed)); + return; + } + + m_impl->logos->logos_execution_zone.get_last_synced_blockAsync( + [this, generation, currentHeight, + callback = std::move(callback)](int lastSynced) mutable { + if (generation != m_generation) + return; + m_impl->logos->logos_execution_zone.get_sequencer_addrAsync( + [this, generation, currentHeight, lastSynced, + callback = std::move(callback)](QString address) mutable { + if (generation != m_generation) + return; + m_impl->logos->logos_execution_zone.list_accountsAsync( + [this, generation, currentHeight, lastSynced, + address = std::move(address), + callback = std::move(callback)]( + QVariantList entries) mutable { + if (generation != m_generation) + return; + + struct SnapshotState { + WalletSnapshot snapshot; + QVector publicReads; + QVector publicFlags; + qsizetype remaining = 0; + SnapshotCallback callback; + }; + auto state = std::make_shared(); + state->snapshot.currentBlockHeight = static_cast( + qMax(0, currentHeight)); + state->snapshot.lastSyncedBlock = static_cast( + qMax(0, lastSynced)); + state->snapshot.sequencerAddress = std::move(address); + state->snapshot.accounts.resize(entries.size()); + state->publicReads.resize(entries.size()); + state->publicFlags.resize(entries.size()); + state->remaining = entries.size(); + state->callback = std::move(callback); + + for (qsizetype index = 0; index < entries.size(); ++index) { + const QVariantMap entry = entries.at(index).toMap(); + const QString accountId = entry + .value(QStringLiteral("account_id")).toString(); + if (entry.isEmpty() || !isHex(accountId, 64)) { + state->snapshot.failure = WalletFailure::ReadFailed; + state->callback(std::move(state->snapshot)); + return; + } + state->snapshot.accounts[index] = WalletAccount { + accountId, + {}, + entry.value(QStringLiteral("is_public"), true).toBool(), + }; + state->publicFlags[index] = + state->snapshot.accounts.at(index).isPublic; + } + + auto finishOne = std::make_shared>(); + *finishOne = [this, generation, state]() mutable { + if (generation != m_generation || --state->remaining > 0) + return; + for (qsizetype index = 0; + index < state->publicReads.size(); ++index) { + if (state->publicFlags.at(index)) + state->snapshot.publicAccountReads.append( + state->publicReads.at(index)); + } + if (state->snapshot.ok()) { + m_snapshot = state->snapshot; + m_snapshotReady = true; + } + state->callback(std::move(state->snapshot)); + }; + + if (entries.isEmpty()) { + state->remaining = 1; + (*finishOne)(); + return; + } + + for (qsizetype index = 0; index < entries.size(); ++index) { + const WalletAccount account = state->snapshot.accounts.at(index); + if (!account.isPublic) { + m_impl->logos->logos_execution_zone.get_balanceAsync( + account.address, false, + [state, finishOne, index](QString balance) { + state->snapshot.accounts[index].balance = + std::move(balance); + (*finishOne)(); + }); + continue; + } + + m_impl->logos->logos_execution_zone.get_account_publicAsync( + account.address, + [this, state, finishOne, index, + accountId = account.address](QString payload) { + const WalletAccountRead read = + parsePublicAccount(accountId, payload); + state->publicReads[index] = read; + if (read.ok()) { + state->snapshot.accounts[index].balance = + littleEndianU128ToDecimal(read.balanceHex); + (*finishOne)(); + return; + } + m_impl->logos->logos_execution_zone.get_balanceAsync( + accountId, true, + [state, finishOne, index](QString balance) { + state->snapshot.accounts[index].balance = + std::move(balance); + (*finishOne)(); + }); + }); + } + }); + }); + }); + }; + + if (currentHeight > 0) { + m_impl->logos->logos_execution_zone.sync_to_blockAsync( + currentHeight, std::move(afterSync)); + } else { + afterSync(WALLET_FFI_SUCCESS); + } + }); +} + +bool LogosWalletProvider::save() const +{ + return m_impl->logos + && m_impl->logos->logos_execution_zone.save() == WALLET_FFI_SUCCESS; +} diff --git a/apps/shared/wallet/src/LogosWalletProvider.h b/apps/shared/wallet/src/LogosWalletProvider.h new file mode 100644 index 00000000..db91702d --- /dev/null +++ b/apps/shared/wallet/src/LogosWalletProvider.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +#include + +#include "WalletProvider.h" + +class LogosAPI; +struct LogosModules; + +class LogosWalletProvider final : public QObject, public WalletProvider { +public: + explicit LogosWalletProvider(LogosAPI* api); + explicit LogosWalletProvider(LogosModules* logos); + ~LogosWalletProvider() override; + + WalletSession connect(const WalletPaths& paths) override; + void connectAsync(const WalletPaths& paths, SessionCallback callback) override; + WalletCreation createWallet(const WalletPaths& paths, + const QString& password) override; + WalletSnapshot snapshot(bool forceRefresh = false) override; + void snapshotAsync(bool forceRefresh, SnapshotCallback callback) override; + void clearSnapshot() override; + WalletAccountCreation createAccount(bool isPublic) override; + void createAccountAsync(bool isPublic, AccountCreationCallback callback) override; + WalletAccountRead readPublicAccount(const QString& accountId) const override; + WalletSubmission submitPublicTransaction( + const WalletTransaction& transaction) override; + void submitPublicTransactionAsync( + const WalletTransaction& transaction, SubmissionCallback callback) override; + void disconnect() override; + +private: + bool sharedWalletIsOpen() const; + WalletSnapshot loadSnapshot(); + void loadSnapshotAsync(quint64 generation, SnapshotCallback callback); + bool save() const; + + struct Impl; + std::unique_ptr m_impl; + WalletSnapshot m_snapshot; + bool m_snapshotReady = false; + bool m_connected = false; + quint64 m_generation = 0; + quint64 m_sessionGeneration = 0; +}; diff --git a/apps/shared/wallet/src/WalletAccountModel.cpp b/apps/shared/wallet/src/WalletAccountModel.cpp new file mode 100644 index 00000000..06e94bfd --- /dev/null +++ b/apps/shared/wallet/src/WalletAccountModel.cpp @@ -0,0 +1,61 @@ +#include "WalletAccountModel.h" + +WalletAccountModel::WalletAccountModel(QObject* parent) + : QAbstractListModel(parent) +{ +} + +int WalletAccountModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_accounts.size(); +} + +QVariant WalletAccountModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_accounts.size()) + return {}; + + const Entry& account = m_accounts.at(index.row()); + switch (role) { + case NameRole: + return account.name; + case AddressRole: + return account.address; + case BalanceRole: + return account.balance; + case IsPublicRole: + return account.isPublic; + default: + return {}; + } +} + +QHash WalletAccountModel::roleNames() const +{ + return { + { NameRole, "name" }, + { AddressRole, "address" }, + { BalanceRole, "balance" }, + { IsPublicRole, "isPublic" }, + }; +} + +void WalletAccountModel::replaceAccounts(const QVector& accounts) +{ + beginResetModel(); + const qsizetype oldCount = m_accounts.size(); + m_accounts.clear(); + m_accounts.reserve(accounts.size()); + for (qsizetype index = 0; index < accounts.size(); ++index) { + const WalletAccount& account = accounts.at(index); + m_accounts.append({ + QStringLiteral("Account %1").arg(index + 1), + account.address, + account.balance, + account.isPublic, + }); + } + endResetModel(); + if (oldCount != m_accounts.size()) + emit countChanged(); +} diff --git a/apps/shared/wallet/src/WalletAccountModel.h b/apps/shared/wallet/src/WalletAccountModel.h new file mode 100644 index 00000000..c00b0d65 --- /dev/null +++ b/apps/shared/wallet/src/WalletAccountModel.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +#include "WalletProvider.h" + +class WalletAccountModel final : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(int count READ count NOTIFY countChanged) + +public: + enum Role { + NameRole = Qt::UserRole + 1, + AddressRole, + BalanceRole, + IsPublicRole, + }; + Q_ENUM(Role) + + explicit WalletAccountModel(QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void replaceAccounts(const QVector& accounts); + int count() const { return m_accounts.size(); } + +signals: + void countChanged(); + +private: + struct Entry { + QString name; + QString address; + QString balance; + bool isPublic = true; + }; + + QVector m_accounts; +}; diff --git a/apps/shared/wallet/src/WalletController.cpp b/apps/shared/wallet/src/WalletController.cpp new file mode 100644 index 00000000..d7c34ee0 --- /dev/null +++ b/apps/shared/wallet/src/WalletController.cpp @@ -0,0 +1,364 @@ +#include "WalletController.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "WalletAccountModel.h" + +namespace { +const char SETTINGS_ORG[] = "Logos"; +const char DISCONNECTED_KEY[] = "disconnected"; +const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; + +QString toLocalPath(const QString& path) +{ + if (path.startsWith(QStringLiteral("file://")) || path.contains(QLatin1Char('/'))) + return QUrl::fromUserInput(path).toLocalFile(); + return path; +} + +QString configuredSequencer(const QString& path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return {}; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) + return {}; + return document.object().value(QStringLiteral("sequencer_addr")).toString(); +} +} + +WalletController::WalletController(WalletProvider& wallet, + QString settingsApplication, + QObject* parent) + : QObject(parent), + m_wallet(wallet), + m_settingsApplication(std::move(settingsApplication)), + m_accountModel(new WalletAccountModel(this)), + m_network(new QNetworkAccessManager(this)), + m_reachabilityTimer(new QTimer(this)) +{ + m_state.walletHome = defaultWalletHome(); + m_state.configPath = defaultConfigPath(); + m_state.storagePath = defaultStoragePath(); + m_state.walletExists = QFileInfo::exists(defaultStoragePath()); + m_state.sequencerAddress = configuredSequencer(defaultConfigPath()); + + m_reachabilityTimer->setInterval(10000); + connect(m_reachabilityTimer, &QTimer::timeout, + this, &WalletController::checkReachability); +} + +WalletController::~WalletController() = default; + +QString WalletController::defaultWalletHome() +{ + const QByteArray override = qgetenv(WALLET_HOME_ENV); + if (!override.isEmpty()) + return QString::fromLocal8Bit(override); + return QDir::homePath() + QStringLiteral("/.lee/wallet"); +} + +QString WalletController::defaultConfigPath() const +{ + return m_state.walletHome + QStringLiteral("/wallet_config.json"); +} + +QString WalletController::defaultStoragePath() const +{ + return m_state.walletHome + QStringLiteral("/storage.json"); +} + +void WalletController::start() +{ + if (m_started) + return; + m_started = true; + QTimer::singleShot(0, this, &WalletController::openOnStartup); +} + +void WalletController::openOnStartup() +{ + if (QSettings(SETTINGS_ORG, m_settingsApplication) + .value(DISCONNECTED_KEY, false).toBool()) { + return; + } + + const QString config = defaultConfigPath(); + const QString storage = defaultStoragePath(); + beginOpen(config, storage); +} + +bool WalletController::beginOpen(const QString& config, const QString& storage) +{ + if (m_state.syncStatus == QStringLiteral("opening") + || m_state.syncStatus == QStringLiteral("syncing")) { + return false; + } + + const quint64 generation = ++m_operationGeneration; + m_state.configPath = config; + m_state.storagePath = storage; + m_state.syncStatus = QStringLiteral("opening"); + m_state.syncError.clear(); + const QString endpoint = configuredSequencer(config); + if (!endpoint.isEmpty()) + m_state.sequencerAddress = endpoint; + emit stateChanged(); + + QTimer::singleShot(0, this, [this, generation]() { + if (generation == m_operationGeneration + && m_state.syncStatus == QStringLiteral("opening")) { + m_state.syncStatus = QStringLiteral("syncing"); + emit stateChanged(); + } + }); + const QPointer guard(this); + m_wallet.connectAsync({ config, storage }, + [guard, generation, config, storage](WalletSession session) { + if (!guard || generation != guard->m_operationGeneration) + return; + if (session.failure == WalletFailure::WalletMissing) { + guard->m_state.syncStatus = QStringLiteral("closed"); + guard->m_state.walletExists = false; + emit guard->stateChanged(); + return; + } + if (!session.ok()) { + qWarning() << "WalletController: wallet connection failed" + << walletFailureCode(session.failure); + guard->m_state.syncStatus = QStringLiteral("error"); + guard->m_state.syncError = walletFailureCode(session.failure); + emit guard->stateChanged(); + return; + } + + guard->m_state.configPath = config; + guard->m_state.storagePath = storage; + guard->m_state.walletExists = QFileInfo::exists(storage) || session.adopted; + guard->m_state.isWalletOpen = true; + guard->m_state.syncStatus = QStringLiteral("ready"); + guard->applySnapshot(session.snapshot); + }); + return true; +} + +QString WalletController::createDefaultWallet(const QString& password) +{ + return createWallet(defaultConfigPath(), defaultStoragePath(), password); +} + +QString WalletController::createWallet(const QString& configPath, + const QString& storagePath, + const QString& password) +{ + const quint64 generation = ++m_operationGeneration; + const QString config = toLocalPath(configPath); + const QString storage = toLocalPath(storagePath); + const WalletCreation creation = m_wallet.createWallet( + { config, storage }, password); + if (creation.mnemonic.isEmpty()) { + qWarning() << "WalletController: wallet creation failed" + << walletFailureCode(creation.failure); + return {}; + } + stopReachability(); + + m_state.configPath = config; + m_state.storagePath = storage; + QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false); + if (!creation.ok()) { + qWarning() << "WalletController: wallet creation failed" + << walletFailureCode(creation.failure); + m_state.walletExists = QFileInfo::exists(storage); + m_state.isWalletOpen = false; + m_state.syncStatus = QStringLiteral("error"); + m_state.syncError = walletFailureCode(creation.failure); + emit stateChanged(); + return creation.mnemonic; + } + + m_state.walletExists = true; + m_state.isWalletOpen = true; + m_state.syncStatus = QStringLiteral("syncing"); + m_state.syncError.clear(); + m_accountModel->replaceAccounts({}); + emit stateChanged(); + + const QPointer guard(this); + QTimer::singleShot(0, this, [guard, generation]() { + if (!guard || generation != guard->m_operationGeneration) + return; + guard->m_wallet.snapshotAsync(true, + [guard, generation](WalletSnapshot snapshot) { + if (!guard || generation != guard->m_operationGeneration) + return; + if (snapshot.ok()) { + guard->m_state.syncStatus = QStringLiteral("ready"); + guard->applySnapshot(snapshot); + return; + } + + qWarning() << "WalletController: initial wallet sync failed" + << walletFailureCode(snapshot.failure); + guard->m_state.syncStatus = QStringLiteral("error"); + guard->m_state.syncError = walletFailureCode(snapshot.failure); + emit guard->stateChanged(); + }); + }); + return creation.mnemonic; +} + +bool WalletController::open() +{ + const QString config = m_state.configPath.isEmpty() + ? defaultConfigPath() : m_state.configPath; + const QString storage = m_state.storagePath.isEmpty() + ? defaultStoragePath() : m_state.storagePath; + QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false); + return beginOpen(config, storage); +} + +void WalletController::disconnect() +{ + ++m_operationGeneration; + stopReachability(); + m_wallet.disconnect(); + m_state.isWalletOpen = false; + m_state.syncStatus = QStringLiteral("closed"); + m_state.syncError.clear(); + m_accountModel->replaceAccounts({}); + QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, true); + emit stateChanged(); +} + +QString WalletController::createAccount(bool isPublic) +{ + const WalletAccountCreation creation = m_wallet.createAccount(isPublic); + if (!creation.ok()) { + qWarning() << "WalletController: account creation failed" + << walletFailureCode(creation.failure); + return {}; + } + if (creation.snapshot.ok()) { + applySnapshot(creation.snapshot); + } else { + qWarning() << "WalletController: account refresh failed" + << walletFailureCode(creation.snapshot.failure); + } + return creation.accountId; +} + +void WalletController::refresh() +{ + if (!m_state.isWalletOpen || m_state.syncStatus == QStringLiteral("syncing")) + return; + const quint64 generation = ++m_operationGeneration; + m_state.syncStatus = QStringLiteral("syncing"); + m_state.syncError.clear(); + emit stateChanged(); + const QPointer guard(this); + m_wallet.snapshotAsync(true, [guard, generation](WalletSnapshot next) { + if (!guard || generation != guard->m_operationGeneration) + return; + if (next.ok()) { + guard->m_state.syncStatus = QStringLiteral("ready"); + guard->applySnapshot(next); + } else { + qWarning() << "WalletController: wallet refresh failed" + << walletFailureCode(next.failure); + guard->m_state.syncStatus = QStringLiteral("error"); + guard->m_state.syncError = walletFailureCode(next.failure); + emit guard->stateChanged(); + } + }); +} + +QString WalletController::balance(const QString& accountId, bool isPublic) +{ + const WalletSnapshot current = m_wallet.snapshot(); + for (const WalletAccount& account : current.accounts) { + if (account.address == accountId && account.isPublic == isPublic) + return account.balance; + } + return {}; +} + +void WalletController::applySnapshot(const WalletSnapshot& snapshot) +{ + m_accountModel->replaceAccounts(snapshot.accounts); + m_state.lastSyncedBlock = static_cast(snapshot.lastSyncedBlock); + m_state.currentBlockHeight = static_cast(snapshot.currentBlockHeight); + if (!snapshot.sequencerAddress.isEmpty()) + m_state.sequencerAddress = snapshot.sequencerAddress; + emit stateChanged(); + if (!m_reachabilityTimer->isActive()) + m_reachabilityTimer->start(); + checkReachability(); +} + +void WalletController::stopReachability() +{ + m_reachabilityTimer->stop(); + ++m_reachabilityGeneration; + if (m_reachabilityReply) { + QNetworkReply* reply = m_reachabilityReply; + m_reachabilityReply = nullptr; + m_reachabilityEndpoint.clear(); + reply->abort(); + } +} + +void WalletController::checkReachability() +{ + if (!m_state.isWalletOpen || m_state.sequencerAddress.isEmpty()) + return; + + const QString endpoint = m_state.sequencerAddress; + if (m_reachabilityReply && endpoint == m_reachabilityEndpoint) + return; + + const quint64 generation = ++m_reachabilityGeneration; + if (m_reachabilityReply) + m_reachabilityReply->abort(); + QNetworkRequest request{QUrl(endpoint)}; + request.setTransferTimeout(4000); + QNetworkReply* reply = m_network->get(request); + m_reachabilityReply = reply; + m_reachabilityEndpoint = endpoint; + connect(reply, &QNetworkReply::finished, this, + [this, reply, generation, endpoint]() { + if (m_reachabilityReply == reply) { + m_reachabilityReply = nullptr; + m_reachabilityEndpoint.clear(); + } + if (!m_state.isWalletOpen + || generation != m_reachabilityGeneration + || endpoint != m_state.sequencerAddress) { + reply->deleteLater(); + return; + } + const bool receivedHttp = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid(); + const bool reachable = receivedHttp || reply->error() == QNetworkReply::NoError; + if (m_state.sequencerReachable != reachable) { + m_state.sequencerReachable = reachable; + emit stateChanged(); + } + reply->deleteLater(); + }); +} diff --git a/apps/shared/wallet/src/WalletController.h b/apps/shared/wallet/src/WalletController.h new file mode 100644 index 00000000..b05bbae2 --- /dev/null +++ b/apps/shared/wallet/src/WalletController.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include + +#include "WalletProvider.h" + +class QNetworkAccessManager; +class QNetworkReply; +class QTimer; +class WalletAccountModel; + +struct WalletUiState { + bool isWalletOpen = false; + bool walletExists = false; + QString configPath; + QString storagePath; + QString walletHome; + int lastSyncedBlock = 0; + int currentBlockHeight = 0; + QString sequencerAddress; + bool sequencerReachable = true; + QString syncStatus = QStringLiteral("closed"); + QString syncError; + + bool canSubmit() const + { + return isWalletOpen && syncStatus == QStringLiteral("ready"); + } +}; + +class WalletController final : public QObject { + Q_OBJECT + +public: + // The provider must outlive the controller. + explicit WalletController(WalletProvider& wallet, + QString settingsApplication, + QObject* parent = nullptr); + ~WalletController() override; + + WalletAccountModel* accountModel() const { return m_accountModel; } + const WalletUiState& state() const { return m_state; } + + void start(); + QString createAccount(bool isPublic); + void refresh(); + QString balance(const QString& accountId, bool isPublic); + QString createDefaultWallet(const QString& password); + QString createWallet(const QString& configPath, + const QString& storagePath, + const QString& password); + bool open(); + void disconnect(); + +signals: + void stateChanged(); + +private: + static QString defaultWalletHome(); + QString defaultConfigPath() const; + QString defaultStoragePath() const; + + void openOnStartup(); + bool beginOpen(const QString& config, const QString& storage); + void applySnapshot(const WalletSnapshot& snapshot); + void checkReachability(); + void stopReachability(); + + WalletProvider& m_wallet; + QString m_settingsApplication; + WalletUiState m_state; + WalletAccountModel* m_accountModel; + QNetworkAccessManager* m_network; + QNetworkReply* m_reachabilityReply = nullptr; + QString m_reachabilityEndpoint; + QTimer* m_reachabilityTimer; + bool m_started = false; + quint64 m_operationGeneration = 0; + quint64 m_reachabilityGeneration = 0; +}; diff --git a/apps/shared/wallet/src/WalletProvider.cpp b/apps/shared/wallet/src/WalletProvider.cpp new file mode 100644 index 00000000..9cb259d4 --- /dev/null +++ b/apps/shared/wallet/src/WalletProvider.cpp @@ -0,0 +1,26 @@ +#include "WalletProvider.h" + +QString walletFailureCode(WalletFailure failure) +{ + switch (failure) { + case WalletFailure::None: + return {}; + case WalletFailure::WalletMissing: + return QStringLiteral("wallet_missing"); + case WalletFailure::WalletUnavailable: + return QStringLiteral("wallet_unavailable"); + case WalletFailure::OpenFailed: + return QStringLiteral("open_failed"); + case WalletFailure::CreateFailed: + return QStringLiteral("create_failed"); + case WalletFailure::SaveFailed: + return QStringLiteral("save_failed"); + case WalletFailure::ReadFailed: + return QStringLiteral("read_failed"); + case WalletFailure::InvalidRequest: + return QStringLiteral("invalid_request"); + case WalletFailure::SubmissionFailed: + return QStringLiteral("submission_failed"); + } + return QStringLiteral("wallet_unavailable"); +} diff --git a/apps/shared/wallet/src/WalletProvider.h b/apps/shared/wallet/src/WalletProvider.h new file mode 100644 index 00000000..3ecbe67e --- /dev/null +++ b/apps/shared/wallet/src/WalletProvider.h @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include +#include + +enum class WalletFailure { + None, + WalletMissing, + WalletUnavailable, + OpenFailed, + CreateFailed, + SaveFailed, + ReadFailed, + InvalidRequest, + SubmissionFailed, +}; + +QString walletFailureCode(WalletFailure failure); + +struct WalletPaths { + QString config; + QString storage; +}; + +struct WalletAccountRead { + QString accountId; + QString status = QStringLiteral("read_failed"); + QString programOwner; + QString balanceHex; + QString nonceHex; + QString dataHex; + + bool ok() const { return status == QStringLiteral("ok"); } +}; + +struct WalletAccount { + QString address; + QString balance; + bool isPublic = true; +}; + +struct WalletSnapshot { + WalletFailure failure = WalletFailure::None; + QVector accounts; + QVector publicAccountReads; + quint64 lastSyncedBlock = 0; + quint64 currentBlockHeight = 0; + QString sequencerAddress; + + bool ok() const { return failure == WalletFailure::None; } +}; + +struct WalletSession { + WalletFailure failure = WalletFailure::None; + WalletSnapshot snapshot; + bool adopted = false; + + bool ok() const { return failure == WalletFailure::None; } +}; + +struct WalletCreation { + WalletFailure failure = WalletFailure::None; + QString mnemonic; + WalletSnapshot snapshot; + + bool ok() const { return failure == WalletFailure::None; } +}; + +struct WalletAccountCreation { + WalletFailure failure = WalletFailure::None; + QString accountId; + WalletAccountRead publicAccount; + WalletSnapshot snapshot; + + bool ok() const { return failure == WalletFailure::None; } +}; + +struct WalletTransaction { + QString programId; + QStringList accountIds; + QVector signingRequirements; + QVector instruction; +}; + +struct WalletSubmission { + WalletFailure failure = WalletFailure::None; + QString nativeHash; + + bool accepted() const { return failure == WalletFailure::None && !nativeHash.isEmpty(); } +}; + +class WalletProvider { +public: + using SessionCallback = std::function; + using SnapshotCallback = std::function; + using AccountCreationCallback = std::function; + using SubmissionCallback = std::function; + + virtual ~WalletProvider() = default; + + virtual WalletSession connect(const WalletPaths& paths) = 0; + virtual void connectAsync(const WalletPaths& paths, SessionCallback callback) = 0; + virtual WalletCreation createWallet(const WalletPaths& paths, + const QString& password) = 0; + virtual WalletSnapshot snapshot(bool forceRefresh = false) = 0; + virtual void snapshotAsync(bool forceRefresh, SnapshotCallback callback) = 0; + virtual void clearSnapshot() = 0; + virtual WalletAccountCreation createAccount(bool isPublic) = 0; + virtual void createAccountAsync(bool isPublic, AccountCreationCallback callback) = 0; + virtual WalletAccountRead readPublicAccount(const QString& accountId) const = 0; + virtual WalletSubmission submitPublicTransaction( + const WalletTransaction& transaction) = 0; + virtual void submitPublicTransactionAsync( + const WalletTransaction& transaction, SubmissionCallback callback) = 0; + virtual void disconnect() = 0; +}; diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp new file mode 100644 index 00000000..55fed170 --- /dev/null +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -0,0 +1,786 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "FakeWalletProvider.h" +#include "LogosWalletProvider.h" +#include "WalletAccountModel.h" +#include "WalletController.h" +#include "logos_sdk.h" + +namespace { +const QString ACCOUNT_A(64, QLatin1Char('a')); +const QString ACCOUNT_B(64, QLatin1Char('b')); +const QString PROGRAM_ID(64, QLatin1Char('c')); + +QString publicAccountJson(const QString& owner = PROGRAM_ID, + const QString& balance = QStringLiteral("01000000000000000000000000000000"), + const QString& nonce = QString(32, QLatin1Char('0')), + const QString& data = QStringLiteral("00ff")) +{ + return QString::fromUtf8(QJsonDocument(QJsonObject { + { QStringLiteral("program_owner"), owner }, + { QStringLiteral("balance"), balance }, + { QStringLiteral("nonce"), nonce }, + { QStringLiteral("data"), data }, + }).toJson(QJsonDocument::Compact)); +} + +QVariantMap accountEntry(const QString& id, bool isPublic) +{ + return { + { QStringLiteral("account_id"), id }, + { QStringLiteral("is_public"), isPublic }, + }; +} + +} + +class LogosWalletProviderTest : public QObject { + Q_OBJECT + +private slots: + void adoptsOpenWalletAndCachesSnapshots(); + void opensConfiguredWalletWhenNoSharedSessionExists(); + void createsAndPersistsWallet(); + void validatesCompletePublicAccountPayloads(); + void fallsBackToBalanceWhenPublicReadFails(); + void createsAndPersistsAccounts(); + void preservesCreatedAccountWhenPublicReadFails(); + void createdAccountDoesNotRescanWallet(); + void dispatchesExactGenericTransaction(); + void rejectsInvalidSubmissionResponses(); + void walletMutationsUseAsyncSdk(); + void staleAsyncMutationCannotCrossSession(); + void destroyedProviderIgnoresLateMutation(); + void exposesStableAccountModelRoles(); + void fakeProviderImplementsConsumerContract(); + void controllerOwnsUiWalletFlow(); + void controllerOpenDoesNotWaitForWalletSync(); + void controllerCreationDoesNotWaitForWalletSync(); + void controllerStopsReachabilityChecksAfterDisconnect(); + void completedAsyncSnapshotReleasesCallback(); + void deferredCallbacksIgnoreDestroyedController(); + void newerReachabilityResultWins(); + void coalescesReachabilityChecksForSameEndpoint(); + void controllerReportsPartialWalletCreation(); +}; + +void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.currentBlockHeight = 12; + modules.logos_execution_zone.lastSyncedBlock = 11; + modules.logos_execution_zone.accounts = { + accountEntry(ACCOUNT_A, true), + accountEntry(ACCOUNT_B, false), + }; + modules.logos_execution_zone.publicAccounts.insert( + ACCOUNT_A, publicAccountJson()); + modules.logos_execution_zone.balances.insert(ACCOUNT_B, QStringLiteral("42")); + + LogosWalletProvider provider(&modules); + const WalletSession session = provider.connect({ QStringLiteral("unused"), QStringLiteral("unused") }); + + QVERIFY(session.ok()); + QVERIFY(session.adopted); + QCOMPARE(session.snapshot.accounts.size(), 2); + QCOMPARE(session.snapshot.accounts.at(0).balance, QStringLiteral("1")); + QCOMPARE(session.snapshot.accounts.at(1).balance, QStringLiteral("42")); + QCOMPARE(session.snapshot.publicAccountReads.size(), 1); + QCOMPARE(session.snapshot.currentBlockHeight, quint64(12)); + QCOMPARE(session.snapshot.lastSyncedBlock, quint64(11)); + + const int listCalls = modules.logos_execution_zone.listCalls; + const int readCalls = modules.logos_execution_zone.publicReadCalls; + const int saveCalls = modules.logos_execution_zone.saveCalls; + QVERIFY(provider.snapshot().ok()); + QCOMPARE(modules.logos_execution_zone.listCalls, listCalls); + QCOMPARE(modules.logos_execution_zone.publicReadCalls, readCalls); + + QVERIFY(provider.snapshot(true).ok()); + QVERIFY(modules.logos_execution_zone.listCalls > listCalls); + QVERIFY(modules.logos_execution_zone.publicReadCalls > readCalls); + QCOMPARE(modules.logos_execution_zone.saveCalls, saveCalls); + + modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson( + PROGRAM_ID, QString(32, QLatin1Char('f'))); + QCOMPARE(provider.snapshot(true).accounts.at(0).balance, + QStringLiteral("340282366920938463463374607431768211455")); + + provider.clearSnapshot(); + const int afterRefresh = modules.logos_execution_zone.listCalls; + QVERIFY(provider.snapshot().ok()); + QVERIFY(modules.logos_execution_zone.listCalls > afterRefresh); + + provider.disconnect(); + QCOMPARE(provider.snapshot().failure, WalletFailure::WalletUnavailable); +} + +void LogosWalletProviderTest::opensConfiguredWalletWhenNoSharedSessionExists() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString storage = directory.filePath(QStringLiteral("storage.json")); + QFile file(storage); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.close(); + + LogosModules modules; + LogosWalletProvider provider(&modules); + const WalletSession session = provider.connect({ + directory.filePath(QStringLiteral("wallet.json")), + storage, + }); + + QVERIFY(session.ok()); + QVERIFY(!session.adopted); + QCOMPARE(modules.logos_execution_zone.openCalls, 1); + QCOMPARE(modules.logos_execution_zone.openedStorage, storage); + + LogosModules missingModules; + LogosWalletProvider missingProvider(&missingModules); + QCOMPARE(missingProvider.connect({ QStringLiteral("config"), QStringLiteral("missing") }).failure, + WalletFailure::WalletMissing); +} + +void LogosWalletProviderTest::createsAndPersistsWallet() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + + LogosModules modules; + modules.logos_execution_zone.currentBlockHeight = 12; + modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) }; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + LogosWalletProvider provider(&modules); + const WalletPaths paths { + directory.filePath(QStringLiteral("config/wallet.json")), + directory.filePath(QStringLiteral("state/storage.json")), + }; + const WalletCreation creation = provider.createWallet(paths, QStringLiteral("secret")); + + QVERIFY(creation.ok()); + QCOMPARE(creation.mnemonic, modules.logos_execution_zone.mnemonic); + QCOMPARE(modules.logos_execution_zone.createdConfig, paths.config); + QCOMPARE(modules.logos_execution_zone.createdStorage, paths.storage); + QCOMPARE(modules.logos_execution_zone.createdPassword, QStringLiteral("secret")); + QVERIFY(modules.logos_execution_zone.saveCalls >= 1); + QCOMPARE(modules.logos_execution_zone.syncCalls, 0); + QCOMPARE(modules.logos_execution_zone.listCalls, 0); + QCOMPARE(modules.logos_execution_zone.publicReadCalls, 0); + + LogosModules rejectedModules; + rejectedModules.logos_execution_zone.mnemonic.clear(); + LogosWalletProvider rejectedProvider(&rejectedModules); + QCOMPARE(rejectedProvider.createWallet(paths, QStringLiteral("secret")).failure, + WalletFailure::CreateFailed); + + LogosModules unsavedModules; + unsavedModules.logos_execution_zone.saveResult = 1; + LogosWalletProvider unsavedProvider(&unsavedModules); + const WalletCreation unsaved = unsavedProvider.createWallet(paths, QStringLiteral("secret")); + QCOMPARE(unsaved.failure, WalletFailure::SaveFailed); + QCOMPARE(unsaved.mnemonic, unsavedModules.logos_execution_zone.mnemonic); +} + +void LogosWalletProviderTest::validatesCompletePublicAccountPayloads() +{ + LogosModules modules; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + LogosWalletProvider provider(&modules); + + const WalletAccountRead valid = provider.readPublicAccount(ACCOUNT_A); + QVERIFY(valid.ok()); + QCOMPARE(valid.accountId, ACCOUNT_A); + QCOMPARE(valid.programOwner, PROGRAM_ID); + QCOMPARE(valid.balanceHex, QStringLiteral("01000000000000000000000000000000")); + QCOMPARE(valid.dataHex, QStringLiteral("00ff")); + + modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson(PROGRAM_ID.toUpper()); + QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok()); + modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson( + PROGRAM_ID, QStringLiteral("01")); + QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok()); + modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson( + PROGRAM_ID, QStringLiteral("01000000000000000000000000000000"), + QString(32, QLatin1Char('0')), QStringLiteral("abc")); + QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok()); + modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = QStringLiteral("[]"); + QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok()); + QVERIFY(!provider.readPublicAccount(QStringLiteral("invalid")).ok()); +} + +void LogosWalletProviderTest::fallsBackToBalanceWhenPublicReadFails() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) }; + modules.logos_execution_zone.balances.insert(ACCOUNT_A, QStringLiteral("42")); + + LogosWalletProvider provider(&modules); + const WalletSession session = provider.connect({}); + + QVERIFY(session.ok()); + QCOMPARE(session.snapshot.accounts.size(), 1); + QCOMPARE(session.snapshot.accounts.at(0).balance, QStringLiteral("42")); + QCOMPARE(session.snapshot.publicAccountReads.size(), 1); + QVERIFY(!session.snapshot.publicAccountReads.at(0).ok()); +} + +void LogosWalletProviderTest::createsAndPersistsAccounts() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.publicAccountId = ACCOUNT_A; + modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) }; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + const int savesBeforeCreate = modules.logos_execution_zone.saveCalls; + const WalletAccountCreation creation = provider.createAccount(true); + QVERIFY(creation.ok()); + QCOMPARE(creation.accountId, ACCOUNT_A); + QVERIFY(creation.publicAccount.ok()); + QCOMPARE(creation.snapshot.accounts.size(), 1); + QVERIFY(modules.logos_execution_zone.saveCalls > savesBeforeCreate); + + modules.logos_execution_zone.saveResult = 1; + QCOMPARE(provider.createAccount(true).failure, WalletFailure::SaveFailed); +} + +void LogosWalletProviderTest::preservesCreatedAccountWhenPublicReadFails() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.publicAccountId = ACCOUNT_A; + modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) }; + modules.logos_execution_zone.balances.insert(ACCOUNT_A, QStringLiteral("7")); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + const WalletAccountCreation creation = provider.createAccount(true); + + QVERIFY(creation.ok()); + QCOMPARE(creation.accountId, ACCOUNT_A); + QVERIFY(!creation.publicAccount.ok()); + QCOMPARE(creation.snapshot.accounts.size(), 1); + QCOMPARE(creation.snapshot.accounts.at(0).balance, QStringLiteral("7")); +} + +void LogosWalletProviderTest::createdAccountDoesNotRescanWallet() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.publicAccountId = ACCOUNT_A; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + modules.logos_execution_zone.currentBlockHeight = 1; + modules.logos_execution_zone.syncResult = 1; + const int syncCalls = modules.logos_execution_zone.syncCalls; + const WalletAccountCreation creation = provider.createAccount(true); + + QVERIFY(creation.ok()); + QCOMPARE(creation.accountId, ACCOUNT_A); + QVERIFY(creation.snapshot.ok()); + QCOMPARE(modules.logos_execution_zone.syncCalls, syncCalls); +} + +void LogosWalletProviderTest::dispatchesExactGenericTransaction() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.transactionResponse = QStringLiteral( + R"({"success":true,"tx_hash":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"})"); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + WalletTransaction transaction; + transaction.programId = PROGRAM_ID; + transaction.accountIds = { ACCOUNT_A, ACCOUNT_B }; + transaction.signingRequirements = { true, false }; + transaction.instruction = { 7, 0, 4294967295U }; + + const WalletSubmission submission = provider.submitPublicTransaction(transaction); + QVERIFY(submission.accepted()); + QCOMPARE(submission.nativeHash, QString(64, QLatin1Char('a'))); + QCOMPARE(modules.logos_execution_zone.submitCalls, 1); + QCOMPARE(modules.logos_execution_zone.submittedProgramId, PROGRAM_ID); + QCOMPARE(modules.logos_execution_zone.submittedAccountIds, transaction.accountIds); + QCOMPARE(modules.logos_execution_zone.submittedSigningRequirements, + QVariantList({ true, false })); + QCOMPARE(modules.logos_execution_zone.submittedInstruction.toList(), + QVariantList({ 7U, 0U, 4294967295U })); +} + +void LogosWalletProviderTest::rejectsInvalidSubmissionResponses() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + WalletTransaction transaction { + PROGRAM_ID, + { ACCOUNT_A }, + { true }, + { 1 }, + }; + + const QStringList invalidResponses { + QStringLiteral("not-json"), + QStringLiteral(R"({"success":false,"tx_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})"), + QStringLiteral(R"({"success":true,"error":"rejected","tx_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})"), + QStringLiteral(R"({"success":true,"tx_hash":"short"})"), + }; + for (const QString& response : invalidResponses) { + modules.logos_execution_zone.transactionResponse = response; + QCOMPARE(provider.submitPublicTransaction(transaction).failure, + WalletFailure::SubmissionFailed); + } + + transaction.signingRequirements.clear(); + QCOMPARE(provider.submitPublicTransaction(transaction).failure, + WalletFailure::InvalidRequest); +} + +void LogosWalletProviderTest::walletMutationsUseAsyncSdk() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.publicAccountId = ACCOUNT_A; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + modules.logos_execution_zone.transactionResponse = QStringLiteral( + R"({"success":true,"tx_hash":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"})"); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + modules.logos_execution_zone.deferPublicAccountCreation = true; + bool creationFinished = false; + WalletAccountCreation creation; + const int listCalls = modules.logos_execution_zone.listCalls; + const int syncCalls = modules.logos_execution_zone.syncCalls; + provider.createAccountAsync(true, [&](WalletAccountCreation result) { + creation = std::move(result); + creationFinished = true; + }); + QVERIFY(!creationFinished); + QVERIFY(modules.logos_execution_zone.pendingPublicAccountCreation); + modules.logos_execution_zone.finishPublicAccountCreation(); + QVERIFY(creationFinished); + QVERIFY(creation.ok()); + QVERIFY(creation.publicAccount.ok()); + QCOMPARE(creation.accountId, ACCOUNT_A); + QCOMPARE(creation.snapshot.accounts.size(), 1); + QCOMPARE(modules.logos_execution_zone.listCalls, listCalls); + QCOMPARE(modules.logos_execution_zone.syncCalls, syncCalls); + + WalletTransaction transaction { + PROGRAM_ID, + { ACCOUNT_A, ACCOUNT_B }, + { true, false }, + { 7, 0, 4294967295U }, + }; + modules.logos_execution_zone.deferSubmission = true; + bool submissionFinished = false; + WalletSubmission submission; + provider.submitPublicTransactionAsync( + transaction, [&](WalletSubmission result) { + submission = std::move(result); + submissionFinished = true; + }); + QVERIFY(!submissionFinished); + QCOMPARE(modules.logos_execution_zone.submittedProgramId, PROGRAM_ID); + QCOMPARE(modules.logos_execution_zone.submittedAccountIds, transaction.accountIds); + QCOMPARE(modules.logos_execution_zone.submittedSigningRequirements, + QVariantList({ true, false })); + QCOMPARE(modules.logos_execution_zone.submittedInstruction.toList(), + QVariantList({ 7U, 0U, 4294967295U })); + modules.logos_execution_zone.finishSubmission(); + QVERIFY(submissionFinished); + QVERIFY(submission.accepted()); + QCOMPARE(submission.nativeHash, QString(64, QLatin1Char('a'))); +} + +void LogosWalletProviderTest::staleAsyncMutationCannotCrossSession() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.publicAccountId = ACCOUNT_A; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + modules.logos_execution_zone.deferPublicAccountCreation = true; + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + int callbackCount = 0; + WalletAccountCreation creation; + provider.createAccountAsync(true, [&](WalletAccountCreation result) { + ++callbackCount; + creation = std::move(result); + }); + provider.disconnect(); + modules.logos_execution_zone.finishPublicAccountCreation(); + + QCOMPARE(callbackCount, 1); + QCOMPARE(creation.failure, WalletFailure::WalletUnavailable); + QCOMPARE(modules.logos_execution_zone.publicReadCalls, 0); +} + +void LogosWalletProviderTest::destroyedProviderIgnoresLateMutation() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.transactionResponse = QStringLiteral( + R"({"success":true,"tx_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})"); + modules.logos_execution_zone.deferSubmission = true; + int callbackCount = 0; + { + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + provider.submitPublicTransactionAsync( + { PROGRAM_ID, { ACCOUNT_A }, { true }, { 1 } }, + [&](WalletSubmission) { ++callbackCount; }); + } + + modules.logos_execution_zone.finishSubmission(); + QCOMPARE(callbackCount, 0); +} + +void LogosWalletProviderTest::exposesStableAccountModelRoles() +{ + WalletAccountModel model; + QSignalSpy countChanged(&model, &WalletAccountModel::countChanged); + model.replaceAccounts({ + { ACCOUNT_A, QStringLiteral("10"), true }, + { ACCOUNT_B, QStringLiteral("20"), false }, + }); + + QCOMPARE(model.count(), 2); + QCOMPARE(countChanged.count(), 1); + QCOMPARE(model.roleNames().value(WalletAccountModel::NameRole), QByteArray("name")); + QCOMPARE(model.data(model.index(0), WalletAccountModel::NameRole).toString(), + QStringLiteral("Account 1")); + QCOMPARE(model.data(model.index(1), WalletAccountModel::AddressRole).toString(), ACCOUNT_B); + QCOMPARE(model.data(model.index(1), WalletAccountModel::BalanceRole).toString(), + QStringLiteral("20")); + QVERIFY(!model.data(model.index(1), WalletAccountModel::IsPublicRole).toBool()); +} + +void LogosWalletProviderTest::fakeProviderImplementsConsumerContract() +{ + FakeWalletProvider provider; + provider.snapshotResult.accounts = { { ACCOUNT_A, QStringLiteral("5"), true } }; + provider.submissionResult.nativeHash = QString(64, QLatin1Char('d')); + + QCOMPARE(provider.snapshot(true).accounts.size(), 1); + QVERIFY(provider.lastForceRefresh); + QCOMPARE(provider.readPublicAccount(ACCOUNT_B).accountId, ACCOUNT_B); + QCOMPARE(provider.readCalls, 1); + WalletTransaction transaction { PROGRAM_ID, { ACCOUNT_A }, { true }, { 9 } }; + QVERIFY(provider.submitPublicTransaction(transaction).accepted()); + QCOMPARE(provider.lastTransaction.instruction, transaction.instruction); + provider.disconnect(); + QCOMPARE(provider.disconnectCalls, 1); +} + +void LogosWalletProviderTest::controllerOwnsUiWalletFlow() +{ + const QString settingsApplication = QStringLiteral("WalletControllerTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + provider.connectResult.snapshot.accounts = { + { ACCOUNT_A, QStringLiteral("5"), true }, + }; + provider.connectResult.snapshot.lastSyncedBlock = 7; + provider.connectResult.snapshot.currentBlockHeight = 8; + + WalletController controller(provider, settingsApplication); + QSignalSpy stateChanged(&controller, &WalletController::stateChanged); + + QVERIFY(controller.open()); + QCOMPARE(provider.connectCalls, 1); + QVERIFY(controller.state().isWalletOpen); + QVERIFY(controller.state().walletExists); + QCOMPARE(controller.state().lastSyncedBlock, 7); + QCOMPARE(controller.state().currentBlockHeight, 8); + QCOMPARE(controller.accountModel()->count(), 1); + + provider.snapshotResult.accounts = { + { ACCOUNT_A, QStringLiteral("9"), true }, + }; + controller.refresh(); + QVERIFY(provider.lastForceRefresh); + QCOMPARE(controller.balance(ACCOUNT_A, true), QStringLiteral("9")); + + provider.createAccountResult.accountId = ACCOUNT_B; + provider.createAccountResult.snapshot.accounts = { + { ACCOUNT_A, QStringLiteral("9"), true }, + { ACCOUNT_B, QStringLiteral("3"), false }, + }; + QCOMPARE(controller.createAccount(false), ACCOUNT_B); + QVERIFY(!provider.lastAccountWasPublic); + QCOMPARE(controller.accountModel()->count(), 2); + + controller.disconnect(); + QCOMPARE(provider.disconnectCalls, 1); + QVERIFY(!controller.state().isWalletOpen); + QCOMPARE(controller.accountModel()->count(), 0); + QVERIFY(stateChanged.count() >= 4); + + settings.clear(); +} + +void LogosWalletProviderTest::controllerOpenDoesNotWaitForWalletSync() +{ + const QString settingsApplication = QStringLiteral("WalletAsyncOpenTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + provider.deferAsync = true; + provider.connectResult.snapshot.accounts = { + { ACCOUNT_A, QStringLiteral("5"), true }, + }; + WalletController controller(provider, settingsApplication); + + QVERIFY(controller.open()); + QCOMPARE(provider.connectCalls, 1); + QVERIFY(!controller.state().isWalletOpen); + QCOMPARE(controller.state().syncStatus, QStringLiteral("opening")); + QCOMPARE(controller.accountModel()->count(), 0); + + provider.finishConnect(); + QVERIFY(controller.state().isWalletOpen); + QVERIFY(controller.state().canSubmit()); + QCOMPARE(controller.state().syncStatus, QStringLiteral("ready")); + QCOMPARE(controller.accountModel()->count(), 1); + settings.clear(); +} + +void LogosWalletProviderTest::controllerCreationDoesNotWaitForWalletSync() +{ + const QString settingsApplication = QStringLiteral("WalletAsyncCreationTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + provider.deferAsync = true; + provider.createWalletResult.mnemonic = QStringLiteral("one two three"); + provider.snapshotResult.accounts = { + { ACCOUNT_A, QStringLiteral("5"), true }, + }; + WalletController controller(provider, settingsApplication); + + QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")), + provider.createWalletResult.mnemonic); + QCOMPARE(provider.createWalletCalls, 1); + QCOMPARE(provider.snapshotCalls, 0); + QVERIFY(controller.state().isWalletOpen); + QCOMPARE(controller.state().syncStatus, QStringLiteral("syncing")); + QVERIFY(!controller.state().canSubmit()); + QCOMPARE(controller.accountModel()->count(), 0); + + QTRY_COMPARE(provider.snapshotCalls, 1); + QVERIFY(provider.lastForceRefresh); + QCOMPARE(controller.state().syncStatus, QStringLiteral("syncing")); + provider.finishSnapshot(); + + QCOMPARE(controller.state().syncStatus, QStringLiteral("ready")); + QVERIFY(controller.state().canSubmit()); + QCOMPARE(controller.accountModel()->count(), 1); + settings.clear(); +} + +void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect() +{ + const QString settingsApplication = QStringLiteral("WalletReachabilityTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + provider.connectResult.snapshot.sequencerAddress = QStringLiteral("http://127.0.0.1:1"); + WalletController controller(provider, settingsApplication); + auto* network = controller.findChild(); + QVERIFY(network); + QSignalSpy finished(network, &QNetworkAccessManager::finished); + + QVERIFY(controller.open()); + QTRY_VERIFY_WITH_TIMEOUT(!finished.isEmpty(), 1000); + auto* timer = controller.findChild(); + QVERIFY(timer); + QVERIFY(timer->isActive()); + controller.disconnect(); + QVERIFY(!timer->isActive()); + finished.clear(); + + timer->setInterval(1); + controller.start(); + QTest::qWait(50); + QVERIFY(!timer->isActive()); + QCOMPARE(finished.count(), 0); + + settings.clear(); +} + +void LogosWalletProviderTest::completedAsyncSnapshotReleasesCallback() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + bool completed = false; + std::weak_ptr callbackLifetime; + { + auto lifetime = std::make_shared(1); + callbackLifetime = lifetime; + provider.snapshotAsync(true, + [lifetime = std::move(lifetime), &completed](WalletSnapshot snapshot) { + QVERIFY(snapshot.ok()); + completed = true; + }); + } + + QVERIFY(completed); + QVERIFY(callbackLifetime.expired()); + QCOMPARE(modules.logos_execution_zone.saveCalls, 0); +} + +void LogosWalletProviderTest::deferredCallbacksIgnoreDestroyedController() +{ + const QString settingsApplication = QStringLiteral("WalletDestroyedCallbackTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + provider.deferAsync = true; + { + auto controller = std::make_unique(provider, settingsApplication); + QVERIFY(controller->open()); + } + provider.finishConnect(); + + provider.deferAsync = false; + { + auto controller = std::make_unique(provider, settingsApplication); + QVERIFY(controller->open()); + provider.deferAsync = true; + controller->refresh(); + } + provider.finishSnapshot(); + settings.clear(); +} + +void LogosWalletProviderTest::newerReachabilityResultWins() +{ + const QString settingsApplication = QStringLiteral("WalletReachabilityOrderTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + QTcpServer firstServer; + QTcpServer secondServer; + QVERIFY(firstServer.listen(QHostAddress::LocalHost)); + QVERIFY(secondServer.listen(QHostAddress::LocalHost)); + const QString firstEndpoint = QStringLiteral("http://127.0.0.1:%1") + .arg(firstServer.serverPort()); + const QString secondEndpoint = QStringLiteral("http://127.0.0.1:%1") + .arg(secondServer.serverPort()); + + FakeWalletProvider provider; + provider.connectResult.snapshot.sequencerAddress = firstEndpoint; + WalletController controller(provider, settingsApplication); + auto* network = controller.findChild(); + QVERIFY(network); + QSignalSpy finished(network, &QNetworkAccessManager::finished); + + QVERIFY(controller.open()); + QTRY_VERIFY(firstServer.hasPendingConnections()); + QTcpSocket* first = firstServer.nextPendingConnection(); + QVERIFY(first); + + provider.createAccountResult.accountId = ACCOUNT_B; + provider.createAccountResult.snapshot.sequencerAddress = secondEndpoint; + QCOMPARE(controller.createAccount(true), ACCOUNT_B); + QTRY_VERIFY(secondServer.hasPendingConnections()); + QTcpSocket* second = secondServer.nextPendingConnection(); + QVERIFY(second); + + second->write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + second->disconnectFromHost(); + QTRY_COMPARE(finished.size(), 1); + QVERIFY(controller.state().sequencerReachable); + + first->disconnectFromHost(); + QTRY_COMPARE(finished.size(), 2); + QVERIFY(controller.state().sequencerReachable); + settings.clear(); +} + +void LogosWalletProviderTest::coalescesReachabilityChecksForSameEndpoint() +{ + const QString settingsApplication = QStringLiteral("WalletReachabilityCoalesceTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + QTcpServer server; + QVERIFY(server.listen(QHostAddress::LocalHost)); + const QString endpoint = QStringLiteral("http://127.0.0.1:%1").arg(server.serverPort()); + + FakeWalletProvider provider; + provider.connectResult.snapshot.sequencerAddress = endpoint; + WalletController controller(provider, settingsApplication); + QVERIFY(controller.open()); + QTRY_VERIFY(server.hasPendingConnections()); + QTcpSocket* request = server.nextPendingConnection(); + QVERIFY(request); + + provider.createAccountResult.accountId = ACCOUNT_B; + provider.createAccountResult.snapshot.sequencerAddress = endpoint; + QCOMPARE(controller.createAccount(true), ACCOUNT_B); + QTest::qWait(50); + QVERIFY(!server.hasPendingConnections()); + + request->write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + request->disconnectFromHost(); + settings.clear(); +} + +void LogosWalletProviderTest::controllerReportsPartialWalletCreation() +{ + const QString settingsApplication = QStringLiteral("WalletPartialCreationTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + provider.createWalletResult.mnemonic = QStringLiteral("one two three"); + provider.createWalletResult.failure = WalletFailure::SaveFailed; + WalletController controller(provider, settingsApplication); + + QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")), + provider.createWalletResult.mnemonic); + QVERIFY(!controller.state().isWalletOpen); + QCOMPARE(controller.state().syncStatus, QStringLiteral("error")); + QCOMPARE(controller.state().syncError, QStringLiteral("save_failed")); + settings.clear(); +} + +QTEST_GUILESS_MAIN(LogosWalletProviderTest) + +#include "LogosWalletProviderTest.moc" diff --git a/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h new file mode 100644 index 00000000..87cf30f4 --- /dev/null +++ b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h @@ -0,0 +1,208 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +class LogosAPI; + +class FakeExecutionZone { +public: + int openResult = 0; + int saveResult = 0; + int syncResult = 0; + int lastSyncedBlock = 0; + int currentBlockHeight = 0; + QString sequencerAddress; + QString mnemonic = QStringLiteral("one two three"); + QString publicAccountId; + QString privateAccountId; + QString transactionResponse; + QVariantList accounts; + QHash publicAccounts; + QHash balances; + + int openCalls = 0; + int saveCalls = 0; + int syncCalls = 0; + int listCalls = 0; + int publicReadCalls = 0; + int submitCalls = 0; + bool deferPublicAccountCreation = false; + bool deferSubmission = false; + std::function pendingPublicAccountCreation; + std::function pendingSubmission; + QString openedConfig; + QString openedStorage; + QString createdConfig; + QString createdStorage; + QString createdPassword; + QStringList submittedAccountIds; + QVariantList submittedSigningRequirements; + QVariant submittedInstruction; + QString submittedProgramId; + + int open(const QString& config, const QString& storage) + { + ++openCalls; + openedConfig = config; + openedStorage = storage; + return openResult; + } + + void openAsync(const QString& config, + const QString& storage, + std::function callback) + { + callback(open(config, storage)); + } + + QString create_new(const QString& config, + const QString& storage, + const QString& password) + { + createdConfig = config; + createdStorage = storage; + createdPassword = password; + return mnemonic; + } + + int save() + { + ++saveCalls; + return saveResult; + } + + void saveAsync(std::function callback) { callback(save()); } + + QString create_account_public() { return publicAccountId; } + QString create_account_private() { return privateAccountId; } + void create_account_publicAsync(std::function callback) + { + if (deferPublicAccountCreation) + pendingPublicAccountCreation = std::move(callback); + else + callback(create_account_public()); + } + void create_account_privateAsync(std::function callback) + { + callback(create_account_private()); + } + + int get_last_synced_block() const { return lastSyncedBlock; } + int get_current_block_height() const { return currentBlockHeight; } + void get_last_synced_blockAsync(std::function callback) + { + callback(get_last_synced_block()); + } + void get_current_block_heightAsync(std::function callback) + { + callback(get_current_block_height()); + } + + int sync_to_block(quint64) + { + ++syncCalls; + return syncResult; + } + void sync_to_blockAsync(int blockId, std::function callback) + { + callback(sync_to_block(static_cast(blockId))); + } + + QString get_sequencer_addr() const { return sequencerAddress; } + void get_sequencer_addrAsync(std::function callback) + { + callback(get_sequencer_addr()); + } + + QVariantList list_accounts() + { + ++listCalls; + return accounts; + } + void list_accountsAsync(std::function callback) + { + callback(list_accounts()); + } + + QString get_account_public(const QString& accountId) + { + ++publicReadCalls; + return publicAccounts.value(accountId); + } + void get_account_publicAsync(const QString& accountId, + std::function callback) + { + callback(get_account_public(accountId)); + } + + QString get_balance(const QString& accountId, bool) const + { + return balances.value(accountId); + } + void get_balanceAsync(const QString& accountId, + bool isPublic, + std::function callback) + { + callback(get_balance(accountId, isPublic)); + } + + QString send_generic_public_transaction( + const QStringList& accountIds, + const QVariantList& signingRequirements, + const QVariant& instruction, + const QString& programId) + { + ++submitCalls; + submittedAccountIds = accountIds; + submittedSigningRequirements = signingRequirements; + submittedInstruction = instruction; + submittedProgramId = programId; + return transactionResponse; + } + + void send_generic_public_transactionAsync( + const QStringList& accountIds, + const QVariantList& signingRequirements, + const QVariant& instruction, + const QString& programId, + std::function callback) + { + ++submitCalls; + submittedAccountIds = accountIds; + submittedSigningRequirements = signingRequirements; + submittedInstruction = instruction; + submittedProgramId = programId; + if (deferSubmission) + pendingSubmission = std::move(callback); + else + callback(transactionResponse); + } + + void finishPublicAccountCreation() + { + auto callback = std::move(pendingPublicAccountCreation); + if (callback) + callback(publicAccountId); + } + + void finishSubmission() + { + auto callback = std::move(pendingSubmission); + if (callback) + callback(transactionResponse); + } +}; + +struct LogosModules { + LogosModules() = default; + explicit LogosModules(LogosAPI*) { } + + FakeExecutionZone logos_execution_zone; +}; diff --git a/apps/shared/wallet/tests/qml/main.cpp b/apps/shared/wallet/tests/qml/main.cpp new file mode 100644 index 00000000..42c698d3 --- /dev/null +++ b/apps/shared/wallet/tests/qml/main.cpp @@ -0,0 +1,3 @@ +#include + +QUICK_TEST_MAIN(logos_wallet_qml) diff --git a/apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml b/apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml new file mode 100644 index 00000000..44e57c41 --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtTest +import Logos.Wallet as Wallet + +Item { + id: root + width: 360 + height: 400 + + Component { + id: resultComponent + + Wallet.SubmittedTransaction { + width: 280 + } + } + + TestCase { + name: "SubmittedTransaction" + when: windowShown + + function test_presentsBase58AndCopyFeedback() { + const result = createTemporaryObject(resultComponent, root, { + transactionId: "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + }) + verify(result, "Submitted result exists") + verify(result.base58Shape) + + const label = findChild(result, "submittedTransactionId") + verify(label, "Transaction label exists") + compare(label.text, result.transactionId) + verify(label.implicitHeight > 0) + + const copyButton = findChild(result, "copySubmittedTransactionButton") + verify(copyButton, "Copy button exists") + mouseClick(copyButton) + verify(copyButton.copied) + + result.transactionId = "0OIl" + verify(!result.base58Shape) + } + } +} diff --git a/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml b/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml new file mode 100644 index 00000000..898a2450 --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml @@ -0,0 +1,125 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtTest +import Logos.Wallet as Wallet + +Item { + id: root + width: 800 + height: 600 + + Component { + id: summaryComponent + + Item { + property var snapshot: ({}) + implicitHeight: summaryText.implicitHeight + + Label { + id: summaryText + objectName: "customSummaryText" + text: parent.snapshot.amount || "" + } + } + } + + Component { + id: dialogComponent + + Wallet.TransactionConfirmationDialog { + summary: summaryComponent + } + } + + Component { + id: viewportComponent + + Item { + width: 360 + height: 240 + } + } + + Component { + id: tallSummaryComponent + + Item { + property var snapshot: ({}) + implicitHeight: 360 + } + } + + Component { + id: constrainedDialogComponent + + Wallet.TransactionConfirmationDialog { + summary: tallSummaryComponent + } + } + + TestCase { + name: "TransactionConfirmationDialog" + when: windowShown + + function test_capturesSnapshotAndLoadsProgramSummary() { + const dialog = createTemporaryObject(dialogComponent, root) + verify(dialog, "Dialog exists") + const source = { amount: "10" } + dialog.openWithSnapshot(source) + tryCompare(dialog, "opened", true) + source.amount = "20" + compare(dialog.snapshot.amount, "10") + + const summary = findChild(dialog, "customSummaryText") + verify(summary, "Custom summary exists") + compare(summary.text, "10") + + confirmedSpy.target = dialog + confirmedSpy.signalName = "confirmed" + confirmedSpy.clear() + mouseClick(findChild(dialog, "transactionConfirmButton")) + compare(confirmedSpy.count, 1) + compare(confirmedSpy.signalArguments[0][0].amount, "10") + tryCompare(dialog, "opened", false) + } + + function test_busyStateCannotBeDismissed() { + const dialog = createTemporaryObject(dialogComponent, root) + verify(dialog, "Dialog exists") + dialog.openWithSnapshot({ amount: "5" }) + dialog.busy = true + const cancelButton = findChild(dialog, "transactionCancelButton") + const confirmButton = findChild(dialog, "transactionConfirmButton") + verify(!cancelButton.enabled) + verify(!confirmButton.enabled) + keyClick(Qt.Key_Escape) + verify(dialog.opened) + dialog.cancel() + verify(dialog.opened) + dialog.busy = false + dialog.cancel() + tryCompare(dialog, "opened", false) + } + + function test_keepsActionsInsideShortViewport() { + const viewport = createTemporaryObject(viewportComponent, root) + verify(viewport, "Short viewport exists") + const dialog = createTemporaryObject(constrainedDialogComponent, viewport) + verify(dialog, "Dialog exists") + + dialog.openWithSnapshot({ amount: "5" }) + tryCompare(dialog, "opened", true) + verify(dialog.height <= viewport.height - 32) + verify(dialog.y >= 0) + verify(dialog.y + dialog.height <= viewport.height) + + const confirmButton = findChild(dialog, "transactionConfirmButton") + verify(confirmButton, "Confirm button exists") + verify(confirmButton.visible) + } + } + + SignalSpy { id: confirmedSpy } +} diff --git a/apps/shared/wallet/tests/qml/tst_WalletControl.qml b/apps/shared/wallet/tests/qml/tst_WalletControl.qml new file mode 100644 index 00000000..021a072a --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_WalletControl.qml @@ -0,0 +1,360 @@ +import QtQuick +import QtTest +import Logos.Wallet as Wallet + +Item { + id: root + width: 800 + height: 600 + + Component { + id: backendComponent + + QtObject { + property bool isWalletOpen: false + property bool walletExists: true + property string walletHome: "/wallet" + property string walletSyncStatus: "closed" + property string walletSyncError: "" + property bool deferOpen: false + property int openCalls: 0 + property int createCalls: 0 + property int publicAccountCalls: 0 + property int privateAccountCalls: 0 + property int disconnectCalls: 0 + + function openExisting() { + openCalls++ + if (deferOpen) { + walletSyncStatus = "opening" + } else { + isWalletOpen = true + walletSyncStatus = "ready" + } + return true + } + + function createNewDefault(_password) { + createCalls++ + isWalletOpen = true + return "alpha beta gamma" + } + + function createAccountPublic() { + publicAccountCalls++ + return "a".repeat(64) + } + + function createAccountPrivate() { + privateAccountCalls++ + return "b".repeat(64) + } + + function disconnectWallet() { + disconnectCalls++ + isWalletOpen = false + } + } + } + + Component { + id: modelComponent + ListModel { } + } + + Component { + id: controlComponent + Wallet.WalletControl { + width: 320 + height: implicitHeight + viewportWidth: 800 + } + } + + Component { + id: compactWindowComponent + + Window { + width: 360 + height: 240 + visible: true + + property alias control: walletControl + + Wallet.WalletControl { + id: walletControl + x: 20 + y: 8 + width: 320 + height: implicitHeight + viewportWidth: parent.width + } + } + } + + Component { + id: compactDialogWindowComponent + + Window { + width: 360 + height: 600 + visible: true + + property alias control: walletControl + + Wallet.WalletControl { + id: walletControl + x: parent.width - width - 12 + y: 8 + width: 40 + height: implicitHeight + viewportWidth: parent.width + } + } + } + + TestCase { + name: "WalletControl" + when: windowShown + + function createControl(walletProperties, accounts) { + const backend = createTemporaryObject(backendComponent, root, walletProperties || {}) + verify(backend, "Backend exists") + const model = createTemporaryObject(modelComponent, root) + verify(model, "Account model exists") + for (const account of accounts || []) + model.append(account) + const control = createTemporaryObject(controlComponent, root, { + wallet: backend, + accountModel: model + }) + verify(control, "Wallet control exists") + return { backend, model, control } + } + + function test_opensExistingWallet() { + const fixture = createControl({ walletExists: true }, []) + const connectButton = findChild(fixture.control, "walletConnectButton") + verify(connectButton, "Connect button exists") + mouseClick(connectButton) + compare(fixture.backend.openCalls, 1) + tryCompare(fixture.control, "connected", true) + } + + function test_surfacesDeferredOpenFailure() { + const fixture = createControl({ walletExists: true, deferOpen: true }, []) + mouseClick(findChild(fixture.control, "walletConnectButton")) + compare(fixture.backend.openCalls, 1) + compare(fixture.control.syncStatus, "opening") + + fixture.backend.walletSyncStatus = "error" + fixture.backend.walletSyncError = "open_failed" + const dialog = findChild(fixture.control, "walletMessageDialog") + tryCompare(dialog, "opened", true) + verify(dialog.message.includes("open_failed")) + } + + function test_requiresSeedBackupAcknowledgement() { + const fixture = createControl({ walletExists: false }, []) + mouseClick(findChild(fixture.control, "walletConnectButton")) + + const dialog = findChild(fixture.control, "createWalletDialog") + tryCompare(dialog, "opened", true) + const password = findChild(dialog, "walletPasswordField") + const confirmation = findChild(dialog, "walletConfirmPasswordField") + const createButton = findChild(dialog, "createWalletButton") + verify(password && confirmation && createButton, "Wallet fields exist") + + password.text = "secret" + confirmation.text = "different" + mouseClick(createButton) + compare(fixture.backend.createCalls, 0) + verify(dialog.errorText.length > 0) + + confirmation.text = "secret" + mouseClick(createButton) + compare(fixture.backend.createCalls, 1) + compare(dialog.mnemonic, "alpha beta gamma") + + const acknowledgement = findChild(dialog, "walletBackupAcknowledgement") + const continueButton = findChild(dialog, "walletContinueButton") + verify(acknowledgement && continueButton, "Backup controls exist") + verify(!continueButton.enabled) + mouseClick(acknowledgement) + verify(continueButton.enabled) + mouseClick(continueButton) + tryCompare(dialog, "opened", false) + } + + function test_clampsSelectionAndDisconnectsLocally() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true }, + { name: "Two", address: "b".repeat(64), balance: "20", isPublic: false } + ]) + fixture.control.selectedIndex = 1 + compare(fixture.control.selectedAddress, "b".repeat(64)) + fixture.model.clear() + tryCompare(fixture.control, "selectedIndex", 0) + compare(fixture.control.selectedAddress, "") + + fixture.model.append({ + name: "One", address: "a".repeat(64), balance: "10", isPublic: true + }) + mouseClick(findChild(fixture.control, "walletAccountButton")) + const disconnectButton = findChild(fixture.control, "walletDisconnectButton") + tryVerify(function() { return disconnectButton.visible }) + mouseClick(disconnectButton) + compare(fixture.backend.disconnectCalls, 1) + tryCompare(fixture.control, "connected", false) + } + + function test_connectedButtonClosesOpenMenu() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + const accountButton = findChild(fixture.control, "walletAccountButton") + const menu = findChild(fixture.control, "walletMenu") + + mouseClick(accountButton) + tryCompare(menu, "opened", true) + mouseClick(accountButton) + tryCompare(menu, "opened", false) + } + + function test_openMenuTracksControlMovement() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + fixture.control.x = 20 + fixture.control.y = 20 + const accountButton = findChild(fixture.control, "walletAccountButton") + const menu = findChild(fixture.control, "walletMenu") + + mouseClick(accountButton) + tryCompare(menu, "opened", true) + const initialMenu = menu.contentItem.mapToItem(root, 0, 0) + const initialButton = accountButton.mapToItem(root, 0, 0) + + fixture.control.x += 300 + fixture.control.y += 30 + wait(0) + + const movedMenu = menu.contentItem.mapToItem(root, 0, 0) + const movedButton = accountButton.mapToItem(root, 0, 0) + compare(movedMenu.x - movedButton.x, initialMenu.x - initialButton.x) + compare(movedMenu.y - movedButton.y, initialMenu.y - initialButton.y) + } + + function test_selectsAccount() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true }, + { name: "Two", address: "b".repeat(64), balance: "20", isPublic: false } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + const accountsButton = findChild(fixture.control, "walletAccountsButton") + tryVerify(function() { return accountsButton.visible }) + mouseClick(accountsButton) + + const accountList = findChild(fixture.control, "walletAccountList") + tryCompare(accountList, "count", 2) + tryVerify(function() { return accountList.itemAtIndex(1) !== null }) + const secondAccount = accountList.itemAtIndex(1) + secondAccount.clicked() + tryCompare(fixture.control, "selectedIndex", 1) + compare(fixture.control.selectedAddress, "b".repeat(64)) + } + + function test_createsAccount() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + const accountsButton = findChild(fixture.control, "walletAccountsButton") + tryVerify(function() { return accountsButton.visible }) + mouseClick(accountsButton) + + const addButton = findChild(fixture.control, "walletAddAccountButton") + tryVerify(function() { return addButton.visible }) + addButton.clicked() + const dialog = findChild(fixture.control, "createAccountDialog") + tryCompare(dialog, "opened", true) + findChild(dialog, "createAccountButton").clicked() + compare(fixture.backend.publicAccountCalls, 1) + tryCompare(dialog, "opened", false) + } + + function test_compactLayoutHasStableWidth() { + const fixture = createControl({ isWalletOpen: false }, []) + fixture.control.viewportWidth = 480 + verify(fixture.control.compactLayout) + compare(fixture.control.implicitWidth, 40) + fixture.control.viewportWidth = 900 + verify(!fixture.control.compactLayout) + compare(fixture.control.implicitWidth, 108) + } + + function test_walletDialogsUseWindowViewport() { + const window = createTemporaryObject(compactDialogWindowComponent, root) + verify(window, "Compact window exists") + waitForRendering(window.contentItem) + + const dialogNames = [ + "createWalletDialog", + "createAccountDialog", + "walletMessageDialog" + ] + for (const name of dialogNames) { + const dialog = findChild(window.control, name) + verify(dialog, name + " exists") + dialog.open() + tryCompare(dialog, "opened", true) + compare(dialog.width, window.width - 32) + verify(dialog.x >= 0) + verify(dialog.x + dialog.width <= window.width) + dialog.close() + tryCompare(dialog, "opened", false) + } + } + + function test_accountsRemainReachableInShortWindow() { + const backend = createTemporaryObject(backendComponent, root, { isWalletOpen: true }) + const model = createTemporaryObject(modelComponent, root) + verify(backend && model, "Wallet fixture exists") + for (let index = 0; index < 10; ++index) { + model.append({ + name: "Account " + index, + address: String(index).repeat(64), + balance: String(index), + isPublic: true + }) + } + + const window = createTemporaryObject(compactWindowComponent, root) + verify(window, "Short window exists") + window.control.wallet = backend + window.control.accountModel = model + waitForRendering(window.contentItem) + + mouseClick(findChild(window.control, "walletAccountButton")) + const menu = findChild(window.control, "walletMenu") + tryCompare(menu, "opened", true) + mouseClick(findChild(window.control, "walletAccountsButton")) + + const accountList = findChild(window.control, "walletAccountList") + const addButton = findChild(window.control, "walletAddAccountButton") + tryCompare(accountList, "count", 10) + verify(menu.y >= 12, "Menu top: " + menu.y) + verify(menu.y + menu.height <= window.height - 12, + "Menu bottom: " + (menu.y + menu.height) + + ", window: " + window.height) + verify(accountList.height > 0) + verify(accountList.contentHeight > accountList.height) + verify(addButton && addButton.visible, "Add account button is visible") + const addPosition = addButton.mapToItem(window.contentItem, 0, 0) + verify(addPosition.y >= menu.y) + verify(addPosition.y + addButton.height <= menu.y + menu.height, + "Add account bottom: " + (addPosition.y + addButton.height) + + ", menu bottom: " + (menu.y + menu.height)) + } + } +} diff --git a/apps/shared/wallet/tests/support/FakeWalletProvider.h b/apps/shared/wallet/tests/support/FakeWalletProvider.h new file mode 100644 index 00000000..0c1327a9 --- /dev/null +++ b/apps/shared/wallet/tests/support/FakeWalletProvider.h @@ -0,0 +1,125 @@ +#pragma once + +#include + +#include "WalletProvider.h" + +class FakeWalletProvider final : public WalletProvider { +public: + WalletSession connectResult; + WalletCreation createWalletResult; + WalletSnapshot snapshotResult; + WalletAccountCreation createAccountResult; + WalletAccountRead readResult; + WalletSubmission submissionResult; + + int connectCalls = 0; + int createWalletCalls = 0; + int snapshotCalls = 0; + int clearCalls = 0; + int createAccountCalls = 0; + mutable int readCalls = 0; + int submitCalls = 0; + int disconnectCalls = 0; + bool lastForceRefresh = false; + bool lastAccountWasPublic = false; + WalletPaths lastPaths; + WalletTransaction lastTransaction; + bool deferAsync = false; + SessionCallback pendingConnectCallback; + SnapshotCallback pendingSnapshotCallback; + + WalletSession connect(const WalletPaths& paths) override + { + ++connectCalls; + lastPaths = paths; + return connectResult; + } + + void connectAsync(const WalletPaths& paths, SessionCallback callback) override + { + ++connectCalls; + lastPaths = paths; + if (deferAsync) + pendingConnectCallback = std::move(callback); + else + callback(connectResult); + } + + WalletCreation createWallet(const WalletPaths& paths, + const QString&) override + { + ++createWalletCalls; + lastPaths = paths; + return createWalletResult; + } + + WalletSnapshot snapshot(bool forceRefresh) override + { + ++snapshotCalls; + lastForceRefresh = forceRefresh; + return snapshotResult; + } + + void snapshotAsync(bool forceRefresh, SnapshotCallback callback) override + { + ++snapshotCalls; + lastForceRefresh = forceRefresh; + if (deferAsync) + pendingSnapshotCallback = std::move(callback); + else + callback(snapshotResult); + } + + void clearSnapshot() override { ++clearCalls; } + + WalletAccountCreation createAccount(bool isPublic) override + { + ++createAccountCalls; + lastAccountWasPublic = isPublic; + return createAccountResult; + } + + void createAccountAsync(bool isPublic, AccountCreationCallback callback) override + { + callback(createAccount(isPublic)); + } + + WalletAccountRead readPublicAccount(const QString& accountId) const override + { + ++readCalls; + WalletAccountRead result = readResult; + result.accountId = accountId; + return result; + } + + WalletSubmission submitPublicTransaction( + const WalletTransaction& transaction) override + { + ++submitCalls; + lastTransaction = transaction; + return submissionResult; + } + + void submitPublicTransactionAsync( + const WalletTransaction& transaction, SubmissionCallback callback) override + { + callback(submitPublicTransaction(transaction)); + } + + void disconnect() override { ++disconnectCalls; } + + void finishConnect() + { + SessionCallback callback = std::move(pendingConnectCallback); + if (callback) + callback(connectResult); + } + + void finishSnapshot() + { + SnapshotCallback callback = std::move(pendingSnapshotCallback); + if (callback) + callback(snapshotResult); + } +}; diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..abde0286 --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1783203018, + "narHash": "sha256-G6R9IT/xwFuu+CYBWDUAok6AdC4ERC4ZfPPFtEpxnZE=", + "owner": "ipetkov", + "repo": "crane", + "rev": "80db5bdc391be8a1794f6d8a2d56e3a84ebcede2", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1783776592, + "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..41ee9859 --- /dev/null +++ b/flake.nix @@ -0,0 +1,45 @@ +{ + description = "LEZ program client libraries"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + crane.url = "github:ipetkov/crane"; + }; + + outputs = { nixpkgs, crane, ... }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + in { + packages = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + craneLib = crane.mkLib pkgs; + src = craneLib.cleanCargoSource ./.; + commonArgs = { + inherit src; + pname = "amm_client"; + version = "0.1.0"; + strictDeps = true; + cargoExtraArgs = "-p amm_client"; + }; + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + ammClient = craneLib.buildPackage (commonArgs // { + inherit cargoArtifacts; + doCheck = false; + postInstall = '' + install -Dm644 ${./apps/amm/client/include/amm_client.h} \ + $out/include/amm_client.h + ''; + }); + in { + default = ammClient; + amm_client = ammClient; + }); + }; +}