mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-28 17:53:23 +00:00
feat(amm-ui): complete new position liquidity flow
Implement one create-pool and add-liquidity workflow. Add searchable token resolution, direct opening-price and deposit editing, optimistic pool activation, and base58 transaction IDs. Isolate network, wallet, AMM client, and runtime boundaries. Stabilize quote, submission, refresh, and pool-probe state while consolidating liquidity tests and removing obsolete liquidity paths.
This commit is contained in:
parent
53a3d03f71
commit
6de8a5fe9a
@ -1,12 +1,30 @@
|
|||||||
cmake_minimum_required(VERSION 3.14)
|
cmake_minimum_required(VERSION 3.21)
|
||||||
project(AmmUiPlugin LANGUAGES CXX)
|
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})
|
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
|
||||||
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
|
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
|
||||||
else()
|
else()
|
||||||
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
|
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
|
||||||
endif()
|
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 +
|
# ui_qml module with a hand-written C++ backend (QtRO .rep view contract +
|
||||||
# generated *SimpleSource/*ViewPluginBase). Mirrors the LEZ wallet UI module.
|
# generated *SimpleSource/*ViewPluginBase). Mirrors the LEZ wallet UI module.
|
||||||
logos_module(
|
logos_module(
|
||||||
@ -18,14 +36,21 @@ logos_module(
|
|||||||
src/AmmUiPlugin.cpp
|
src/AmmUiPlugin.cpp
|
||||||
src/AmmUiBackend.h
|
src/AmmUiBackend.h
|
||||||
src/AmmUiBackend.cpp
|
src/AmmUiBackend.cpp
|
||||||
src/AccountModel.h
|
src/ActiveNetwork.h
|
||||||
src/AccountModel.cpp
|
src/ActiveNetwork.cpp
|
||||||
|
src/AmmClient.h
|
||||||
|
src/AmmClient.cpp
|
||||||
|
src/NewPositionRuntime.h
|
||||||
|
src/NewPositionRuntime.cpp
|
||||||
FIND_PACKAGES
|
FIND_PACKAGES
|
||||||
Qt6Gui
|
Qt6Gui
|
||||||
Qt6Network
|
Qt6Network
|
||||||
LINK_LIBRARIES
|
LINK_LIBRARIES
|
||||||
Qt6::Gui
|
Qt6::Gui
|
||||||
Qt6::Network
|
Qt6::Network
|
||||||
|
PkgConfig::BASE58
|
||||||
|
LINK_TARGETS
|
||||||
|
logos_wallet_access
|
||||||
EXTERNAL_LIBS
|
EXTERNAL_LIBS
|
||||||
amm_client
|
amm_client
|
||||||
)
|
)
|
||||||
@ -35,3 +60,25 @@ qt_add_resources(amm_ui_module_plugin amm_ui_config
|
|||||||
FILES
|
FILES
|
||||||
config/networks.json
|
config/networks.json
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if(BUILD_TESTING)
|
||||||
|
add_executable(amm_active_network_test
|
||||||
|
tests/cpp/ActiveNetworkTest.cpp
|
||||||
|
src/ActiveNetwork.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(amm_active_network_test PRIVATE src)
|
||||||
|
target_link_libraries(amm_active_network_test PRIVATE Qt6::Core)
|
||||||
|
add_test(NAME amm_active_network COMMAND amm_active_network_test)
|
||||||
|
|
||||||
|
add_executable(amm_new_position_runtime_test
|
||||||
|
tests/cpp/NewPositionRuntimeTest.cpp
|
||||||
|
src/NewPositionRuntime.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(amm_new_position_runtime_test PRIVATE src)
|
||||||
|
target_link_libraries(amm_new_position_runtime_test PRIVATE
|
||||||
|
Qt6::Core
|
||||||
|
PkgConfig::BASE58
|
||||||
|
logos_wallet_access
|
||||||
|
)
|
||||||
|
add_test(NAME amm_new_position_runtime COMMAND amm_new_position_runtime_test)
|
||||||
|
endif()
|
||||||
|
|||||||
@ -1,16 +1,25 @@
|
|||||||
# AMM UI Validation
|
# AMM UI Validation
|
||||||
|
|
||||||
Validation for the New Position flow covers deterministic quote math, account
|
Validation for the New Position flow covers the shipped Rust client, QML
|
||||||
construction, QML checks, and the module build.
|
syntax, and the complete module build.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
Run from the repository root:
|
Run from the repository root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node apps/amm/tests/new-position-validation.mjs
|
cargo +1.94.0 test -p amm_client
|
||||||
qmllint apps/amm/qml/pages/LiquidityPage.qml apps/amm/qml/components/liquidity/NewPositionForm.qml apps/amm/qml/components/liquidity/NewPositionConfirmationDialog.qml
|
cargo +1.94.0 clippy -p amm_client --all-targets -- -D warnings
|
||||||
nix build ./apps/amm#packages.x86_64-linux.default --no-link
|
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
|
When shared AMM program types or instruction signatures change, also run the
|
||||||
@ -23,24 +32,23 @@ cargo +1.94.0 build -p idl-gen --release
|
|||||||
diff artifacts/amm-idl.json /tmp/amm-idl.json
|
diff artifacts/amm-idl.json /tmp/amm-idl.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Live wallet/sequencer validation is manual until transaction dispatch has real
|
Live wallet/sequencer validation uses the configured Network and an External
|
||||||
token holding IDs. Use a local devnet wallet/sequencer when available, open the
|
Wallet Provider. Open the AMM UI, select two TokenProgram-scoped fungible token
|
||||||
AMM UI, create or adopt one active account, preview both the active LOGOS/USDC
|
definitions, preview an active or missing Pool, submit, and verify the returned
|
||||||
pool and the missing USDC/WETH pool, then submit and verify either a successful
|
transaction ID is displayed.
|
||||||
transaction or the deterministic `submit_unavailable` backend response.
|
|
||||||
|
|
||||||
## Acceptance Checklist
|
## Acceptance Checklist
|
||||||
|
|
||||||
- Active wallet context exposes only holdings for the active account.
|
- Context exposes Wallet-Scoped Holdings and configured token definitions.
|
||||||
- Unsupported fee tiers are disabled and explain why.
|
- Unsupported fee tiers are disabled and explain why.
|
||||||
- Active pool fee tier is fixed to the stored pool fee.
|
- Active pool fee tier is fixed to the stored pool fee.
|
||||||
- Missing pool flow locks the user-selected price and scales from the minimum
|
- Missing pool flow accepts an editable `X Token A = Y Token B` ratio and
|
||||||
deposit that mints more than `MINIMUM_LIQUIDITY`.
|
scales either deposit from the minimum that mints more than
|
||||||
- Active pool flow keeps the reserve ratio and previews LP with
|
`MINIMUM_LIQUIDITY`.
|
||||||
`floor(min(supply * amount_a / reserve_a, supply * amount_b / reserve_b))`.
|
- Active Pool flow keeps the reserve ratio and previews expected LP output.
|
||||||
- Decimal inference keeps small supplies at 0 decimals, medium supplies at 6,
|
- Decimal inference keeps small supplies at 0 decimals, medium supplies at 6,
|
||||||
large supplies at 9, and massive supplies at 18.
|
large supplies at 9, and massive supplies at 18.
|
||||||
- Human amount parse/format is reversible for 0, 6, 9, and 18 decimals.
|
- Human amount parse/format preserves 0, 6, 9, and 18 decimal values.
|
||||||
- `new_definition` and `add_liquidity` account lists match the committed AMM IDL
|
- `new_definition` and `add_liquidity` account lists match the committed AMM IDL
|
||||||
order.
|
order.
|
||||||
- Submit re-quotes and surfaces `quote_changed` when the request no longer
|
- Submit re-quotes and surfaces `quote_changed` when the request no longer
|
||||||
|
|||||||
@ -4,8 +4,24 @@ use nssa_core::{
|
|||||||
account::{Account, AccountId, Data, Nonce},
|
account::{Account, AccountId, Data, Nonce},
|
||||||
program::ProgramId,
|
program::ProgramId,
|
||||||
};
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::model::AccountRead;
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct AccountRead {
|
||||||
|
pub(crate) id: String,
|
||||||
|
pub(crate) status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) account: Option<WalletAccount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub(crate) struct WalletAccount {
|
||||||
|
pub(crate) program_owner: String,
|
||||||
|
pub(crate) balance: String,
|
||||||
|
pub(crate) nonce: String,
|
||||||
|
pub(crate) data: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> {
|
pub fn parse_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> {
|
||||||
if value.len() != 64
|
if value.len() != 64
|
||||||
@ -123,8 +139,6 @@ pub fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), String
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn account_read(id: AccountId, account: &Account) -> AccountRead {
|
pub fn account_read(id: AccountId, account: &Account) -> AccountRead {
|
||||||
use crate::model::WalletAccount;
|
|
||||||
|
|
||||||
AccountRead {
|
AccountRead {
|
||||||
id: account_id_hex(id),
|
id: account_id_hex(id),
|
||||||
status: String::from("ok"),
|
status: String::from("ok"),
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
#![deny(unsafe_op_in_unsafe_fn)]
|
#![deny(unsafe_op_in_unsafe_fn)]
|
||||||
|
|
||||||
mod account;
|
mod account;
|
||||||
mod model;
|
|
||||||
mod protocol;
|
mod protocol;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
@ -9,13 +8,39 @@ use std::{
|
|||||||
panic::{catch_unwind, AssertUnwindSafe},
|
panic::{catch_unwind, AssertUnwindSafe},
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::de::DeserializeOwned;
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
|
||||||
use crate::model::{
|
use crate::protocol::{
|
||||||
ConfigIdRequest, ContextRequest, Envelope, PairIdsRequest, PlanRequest, QuoteRequest,
|
ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest, QuoteRequest, TokenIdsRequest,
|
||||||
TokenIdsRequest,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Envelope {
|
||||||
|
ok: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
value: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Envelope {
|
||||||
|
fn success(value: serde_json::Value) -> Self {
|
||||||
|
Self {
|
||||||
|
ok: true,
|
||||||
|
value: Some(value),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure(error: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
ok: false,
|
||||||
|
value: None,
|
||||||
|
error: Some(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn call<T: DeserializeOwned>(
|
fn call<T: DeserializeOwned>(
|
||||||
request: *const c_char,
|
request: *const c_char,
|
||||||
operation: fn(T) -> Result<serde_json::Value, String>,
|
operation: fn(T) -> Result<serde_json::Value, String>,
|
||||||
|
|||||||
@ -1,158 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
pub const SCHEMA: &str = "new-position.v1";
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct AccountRead {
|
|
||||||
pub id: String,
|
|
||||||
pub status: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub account: Option<WalletAccount>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
pub struct WalletAccount {
|
|
||||||
pub program_owner: String,
|
|
||||||
pub balance: String,
|
|
||||||
pub nonce: String,
|
|
||||||
pub data: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct ConfigIdRequest {
|
|
||||||
pub amm_program_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct TokenIdsRequest {
|
|
||||||
pub amm_program_id: String,
|
|
||||||
pub config: AccountRead,
|
|
||||||
#[serde(default)]
|
|
||||||
pub wallet_accounts: Vec<AccountRead>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub configured_token_ids: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub recent_token_ids: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub resolved_token_ids: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct ContextRequest {
|
|
||||||
pub network_id: String,
|
|
||||||
pub network_fingerprint: String,
|
|
||||||
pub amm_program_id: String,
|
|
||||||
pub wallet_available: bool,
|
|
||||||
pub config: AccountRead,
|
|
||||||
#[serde(default)]
|
|
||||||
pub wallet_accounts: Vec<AccountRead>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub token_definitions: Vec<AccountRead>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub configured_token_ids: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub recent_token_ids: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub resolved_token_ids: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PairIdsRequest {
|
|
||||||
pub amm_program_id: String,
|
|
||||||
pub config: AccountRead,
|
|
||||||
pub token_a_id: String,
|
|
||||||
pub token_b_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PositionRequest {
|
|
||||||
pub schema: String,
|
|
||||||
pub token_a_id: String,
|
|
||||||
pub token_b_id: String,
|
|
||||||
pub fee_bps: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub max_amount_a_raw: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub max_amount_b_raw: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub slippage_bps: Option<u32>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub initial_price_real_raw: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub deposit_scale_bps: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PairSnapshot {
|
|
||||||
pub config: AccountRead,
|
|
||||||
pub token_a: AccountRead,
|
|
||||||
pub token_b: AccountRead,
|
|
||||||
pub pool: AccountRead,
|
|
||||||
pub vault_a: AccountRead,
|
|
||||||
pub vault_b: AccountRead,
|
|
||||||
pub lp_definition: AccountRead,
|
|
||||||
pub lp_lock_holding: AccountRead,
|
|
||||||
pub current_tick: AccountRead,
|
|
||||||
pub clock: AccountRead,
|
|
||||||
pub wallet_available: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub wallet_accounts: Vec<AccountRead>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct QuoteRequest {
|
|
||||||
pub network_id: String,
|
|
||||||
pub network_fingerprint: String,
|
|
||||||
pub amm_program_id: String,
|
|
||||||
pub request: PositionRequest,
|
|
||||||
pub snapshot: PairSnapshot,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PlanRequest {
|
|
||||||
pub network_id: String,
|
|
||||||
pub network_fingerprint: String,
|
|
||||||
pub amm_program_id: String,
|
|
||||||
pub request: PositionRequest,
|
|
||||||
pub snapshot: PairSnapshot,
|
|
||||||
pub quote_hash: String,
|
|
||||||
pub now_ms: u64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub fresh_lp: Option<AccountRead>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct Envelope {
|
|
||||||
pub ok: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub value: Option<serde_json::Value>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Envelope {
|
|
||||||
pub fn success(value: serde_json::Value) -> Self {
|
|
||||||
Self {
|
|
||||||
ok: true,
|
|
||||||
value: Some(value),
|
|
||||||
error: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn failure(error: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
ok: false,
|
|
||||||
value: None,
|
|
||||||
error: Some(error.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,18 @@
|
|||||||
{
|
{
|
||||||
"testnet": {
|
"testnet": {
|
||||||
"checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a",
|
"checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a",
|
||||||
"ammProgramId": "d0c4c0ea9384928a97b65a63fcf9641639bb802e4e91819743360708ff12101e",
|
"ammProgramId": "77eeaa23668ad2675fb768cd7ecb1893387be464b9a51f16756006c1d307db07",
|
||||||
"tokenDefinitionIds": []
|
"tokenDefinitionIds": [
|
||||||
|
"7b464ff9dd0d3bc07f7e2e0b0667ccd066d85ad12be4c79fc55687a863910aa6",
|
||||||
|
"48c81cf032e601ca367fc9816b957dbf5c0e4c11cf7008e8f4581ec1a67aab42",
|
||||||
|
"159caef810ea545951b3bd913efe625ee45008c80865c330e72a72ed48b61649",
|
||||||
|
"75f33110b185717209e3955f228d4a4448801d0ce8ba438a4a268050eeff3f44",
|
||||||
|
"fbd107ca4bb66bc58f59ac2d32a759be3ee0fb453f8fecd1991c11837d9660c7",
|
||||||
|
"5547fcb72644d95a385d313b887a96be41ff263bce6150b49fd87276839822bf",
|
||||||
|
"fa43e74a97d79c5f907ff3edabda5ad89bfbd3b0922572e675d4ad3c7b6029c7",
|
||||||
|
"4f3231d8a01e1d79f163bc27fce0c860a4a2f6890280e9d135eafbde0d68ed79",
|
||||||
|
"fa32f354408857006f8ea396b0419823bd04436eadb2d273d2618a46b4793ed8",
|
||||||
|
"00fe99e4fbd4c71f92e47c384c6235244c8cce39b6d6367e1e338eca0ffe01cb"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
apps/amm/flake.lock
generated
21
apps/amm/flake.lock
generated
@ -14468,11 +14468,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782082589,
|
"lastModified": 1782920676,
|
||||||
"narHash": "sha256-Qeqxp0HYb3oTpmfD5YlFPJzpAJa7Ilb9o4sMeVvmHRI=",
|
"narHash": "sha256-Vb81kiYbi8yYDZbUSW6v7QhV6uO/pj7F+lCAW1coAsY=",
|
||||||
"owner": "logos-co",
|
"owner": "logos-co",
|
||||||
"repo": "logos-protocol",
|
"repo": "logos-protocol",
|
||||||
"rev": "315a3a2e0af61bc47aad5601ee44cd7689975820",
|
"rev": "d7ad26d369c4e464a99f2a357f10c5947c7174e1",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@ -26810,7 +26810,8 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"amm_client": "amm_client",
|
"amm_client": "amm_client",
|
||||||
"logos-module-builder": "logos-module-builder",
|
"logos-module-builder": "logos-module-builder",
|
||||||
"logos_execution_zone": "logos_execution_zone"
|
"logos_execution_zone": "logos_execution_zone",
|
||||||
|
"shared_wallet": "shared_wallet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rust-overlay": {
|
"rust-overlay": {
|
||||||
@ -26896,6 +26897,18 @@
|
|||||||
"rev": "e91187f8ccb5bbfc7bb00dac88169112428da78f",
|
"rev": "e91187f8ccb5bbfc7bb00dac88169112428da78f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"shared_wallet": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"path": "../shared/wallet",
|
||||||
|
"type": "path"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"path": "../shared/wallet",
|
||||||
|
"type": "path"
|
||||||
|
},
|
||||||
|
"parent": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
@ -11,8 +11,8 @@
|
|||||||
|
|
||||||
"nix": {
|
"nix": {
|
||||||
"packages": {
|
"packages": {
|
||||||
"build": [],
|
"build": ["pkg-config"],
|
||||||
"runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"]
|
"runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp", "libbase58"]
|
||||||
},
|
},
|
||||||
"external_libraries": [
|
"external_libraries": [
|
||||||
{ "name": "amm_client" }
|
{ "name": "amm_client" }
|
||||||
|
|||||||
@ -45,7 +45,7 @@ Item {
|
|||||||
height: show ? 32 : 0
|
height: show ? 32 : 0
|
||||||
visible: height > 0
|
visible: height > 0
|
||||||
clip: true
|
clip: true
|
||||||
color: Theme.palette.error
|
color: Theme.palette.warning
|
||||||
|
|
||||||
Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
|
Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
|
||||||
|
|
||||||
@ -56,7 +56,7 @@ Item {
|
|||||||
elide: Text.ElideMiddle
|
elide: Text.ElideMiddle
|
||||||
font.pixelSize: 12
|
font.pixelSize: 12
|
||||||
font.weight: Font.Medium
|
font.weight: Font.Medium
|
||||||
color: Theme.palette.text
|
color: Theme.palette.background
|
||||||
text: qsTr("Unable to connect to network")
|
text: qsTr("Unable to connect to network")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -88,12 +88,8 @@ Item {
|
|||||||
LiquidityPage {
|
LiquidityPage {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
backend: root.ready ? root.backend : null
|
backend: root.ready ? root.backend : null
|
||||||
|
runtime: logos
|
||||||
visible: navbar.currentIndex === 1
|
visible: navbar.currentIndex === 1
|
||||||
}
|
}
|
||||||
|
|
||||||
CreatePoolPage {
|
|
||||||
anchors.fill: parent
|
|
||||||
visible: navbar.currentIndex === 2
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ Item {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
property int currentIndex: 0
|
property int currentIndex: 0
|
||||||
readonly property var tabs: ["Trade", "Liquidity", "Create Pool"]
|
readonly property var tabs: ["Trade", "Liquidity"]
|
||||||
|
|
||||||
// Wallet wiring, passed down from Main.qml.
|
// Wallet wiring, passed down from Main.qml.
|
||||||
property var backend: null
|
property var backend: 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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
apps/amm/qml/components/liquidity/AmmActionCard.qml
Normal file
15
apps/amm/qml/components/liquidity/AmmActionCard.qml
Normal file
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
61
apps/amm/qml/components/liquidity/AmmPairSeparator.qml
Normal file
61
apps/amm/qml/components/liquidity/AmmPairSeparator.qml
Normal file
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/amm/qml/components/liquidity/AmmPrimaryButton.qml
Normal file
43
apps/amm/qml/components/liquidity/AmmPrimaryButton.qml
Normal file
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apps/amm/qml/components/liquidity/AmmTheme.qml
Normal file
50
apps/amm/qml/components/liquidity/AmmTheme.qml
Normal file
@ -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"
|
||||||
|
})
|
||||||
|
}
|
||||||
46
apps/amm/qml/components/liquidity/AmmTokenAccessory.qml
Normal file
46
apps/amm/qml/components/liquidity/AmmTokenAccessory.qml
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
195
apps/amm/qml/components/liquidity/AmmTokenAmountSurface.qml
Normal file
195
apps/amm/qml/components/liquidity/AmmTokenAmountSurface.qml
Normal file
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
80
apps/amm/qml/components/liquidity/AmmTokenSelectButton.qml
Normal file
80
apps/amm/qml/components/liquidity/AmmTokenSelectButton.qml
Normal file
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -113,26 +113,17 @@ function divide(left, right) {
|
|||||||
return { "quotient": normalize(quotient), "remainder": remainder, "valid": true }
|
return { "quotient": normalize(quotient), "remainder": remainder, "valid": true }
|
||||||
}
|
}
|
||||||
|
|
||||||
function divideCeil(left, right) {
|
function increment(value) {
|
||||||
var result = divide(left, right)
|
|
||||||
if (!result.valid || result.remainder === "0")
|
|
||||||
return result.quotient
|
|
||||||
return addSmall(result.quotient, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSmall(value, increment) {
|
|
||||||
var digits = normalize(value).split("")
|
var digits = normalize(value).split("")
|
||||||
var carry = increment
|
for (var i = digits.length - 1; i >= 0; --i) {
|
||||||
for (var i = digits.length - 1; i >= 0 && carry > 0; --i) {
|
if (digits[i] !== "9") {
|
||||||
var sum = Number(digits[i]) + carry
|
digits[i] = String(Number(digits[i]) + 1)
|
||||||
digits[i] = String(sum % 10)
|
return digits.join("")
|
||||||
carry = Math.floor(sum / 10)
|
}
|
||||||
|
digits[i] = "0"
|
||||||
}
|
}
|
||||||
while (carry > 0) {
|
digits.unshift("1")
|
||||||
digits.unshift(String(carry % 10))
|
return digits.join("")
|
||||||
carry = Math.floor(carry / 10)
|
|
||||||
}
|
|
||||||
return normalize(digits.join(""))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pow10(exponent) {
|
function pow10(exponent) {
|
||||||
@ -172,6 +163,17 @@ function parseHuman(text, decimals) {
|
|||||||
return { "ok": true, "code": "", "raw": raw }
|
return { "ok": true, "code": "", "raw": raw }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trimHumanPrecision(text, decimals) {
|
||||||
|
var value = String(text)
|
||||||
|
var match = /^(0|[1-9][0-9]*)(?:\.([0-9]*))?$/.exec(value)
|
||||||
|
if (!match)
|
||||||
|
return value
|
||||||
|
var fraction = match[2] || ""
|
||||||
|
if (fraction.length <= decimals)
|
||||||
|
return value
|
||||||
|
return decimals > 0 ? match[1] + "." + fraction.slice(0, decimals) : match[1]
|
||||||
|
}
|
||||||
|
|
||||||
function formatRaw(rawValue, decimals) {
|
function formatRaw(rawValue, decimals) {
|
||||||
if (!isUnsigned(rawValue))
|
if (!isUnsigned(rawValue))
|
||||||
return ""
|
return ""
|
||||||
@ -204,19 +206,36 @@ function parsePrice(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function priceToQ64(text, canonicalDecimalsA, canonicalDecimalsB, displayIsCanonical) {
|
function parseRatio(amountA, amountB) {
|
||||||
var parsed = parsePrice(text)
|
var parsedA = parsePrice(amountA)
|
||||||
if (!parsed.ok)
|
if (!parsedA.ok)
|
||||||
return { "ok": false, "code": parsed.code, "raw": "" }
|
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 numerator
|
||||||
var denominator
|
var denominator
|
||||||
if (displayIsCanonical) {
|
if (displayIsCanonical) {
|
||||||
numerator = multiply(multiply(parsed.numerator, Q64), pow10(canonicalDecimalsB))
|
numerator = multiply(multiply(ratio.numerator, Q64), pow10(canonicalDecimalsB))
|
||||||
denominator = pow10(parsed.scale + canonicalDecimalsA)
|
denominator = multiply(ratio.denominator, pow10(canonicalDecimalsA))
|
||||||
} else {
|
} else {
|
||||||
numerator = multiply(multiply(pow10(parsed.scale), Q64), pow10(canonicalDecimalsB))
|
numerator = multiply(multiply(ratio.denominator, Q64), pow10(canonicalDecimalsB))
|
||||||
denominator = multiply(parsed.numerator, pow10(canonicalDecimalsA))
|
denominator = multiply(ratio.numerator, pow10(canonicalDecimalsA))
|
||||||
}
|
}
|
||||||
var raw = divide(numerator, denominator).quotient
|
var raw = divide(numerator, denominator).quotient
|
||||||
if (raw === "0")
|
if (raw === "0")
|
||||||
@ -226,6 +245,39 @@ function priceToQ64(text, canonicalDecimalsA, canonicalDecimalsB, displayIsCanon
|
|||||||
return { "ok": true, "code": "", "raw": 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) {
|
function formatRatio(numerator, denominator, precision) {
|
||||||
var result = divide(numerator, denominator)
|
var result = divide(numerator, denominator)
|
||||||
if (!result.valid)
|
if (!result.valid)
|
||||||
@ -260,10 +312,6 @@ function mulDivFloor(left, right, denominator) {
|
|||||||
return divide(multiply(left, right), denominator).quotient
|
return divide(multiply(left, right), denominator).quotient
|
||||||
}
|
}
|
||||||
|
|
||||||
function mulDivCeil(left, right, denominator) {
|
|
||||||
return divideCeil(multiply(left, right), denominator)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toU32(value) {
|
function toU32(value) {
|
||||||
if (!isUnsigned(value) || compare(value, U32_MAX) > 0)
|
if (!isUnsigned(value) || compare(value, U32_MAX) > 0)
|
||||||
return -1
|
return -1
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,87 +5,47 @@ ColumnLayout {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
property var snapshot: ({})
|
property var snapshot: ({})
|
||||||
readonly property bool isAdd: root.snapshot.action === "add"
|
|
||||||
|
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
ColumnLayout {
|
function actionText(instruction) {
|
||||||
Layout.fillWidth: true
|
if (instruction === "NewDefinition")
|
||||||
spacing: 8
|
return qsTr("Create pool")
|
||||||
visible: root.isAdd
|
if (instruction === "AddLiquidity")
|
||||||
|
return qsTr("Add liquidity")
|
||||||
SummaryRow {
|
return instruction || "-"
|
||||||
Layout.fillWidth: true
|
|
||||||
label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "")
|
|
||||||
value: root.snapshot.depositA || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
SummaryRow {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "")
|
|
||||||
value: root.snapshot.depositB || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
SummaryRow {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
label: qsTr("Receive at least")
|
|
||||||
value: root.snapshot.minLpReceived || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
SummaryRow {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
label: qsTr("Current ratio")
|
|
||||||
value: root.snapshot.currentRatio || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
SummaryRow {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
label: qsTr("Fee tier")
|
|
||||||
value: root.snapshot.feeTier || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
SummaryRow {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
label: qsTr("Slippage tolerance")
|
|
||||||
value: root.snapshot.slippageTolerance || ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
SummaryRow {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 8
|
label: qsTr("Pair")
|
||||||
visible: !root.isAdd
|
value: root.snapshot.pairText || "-"
|
||||||
|
}
|
||||||
|
|
||||||
SummaryRow {
|
SummaryRow {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
label: qsTr("Burn LP")
|
label: qsTr("Action")
|
||||||
value: qsTr("%1 (%2)")
|
value: root.actionText(root.snapshot.instruction)
|
||||||
.arg(root.snapshot.burnText || "")
|
}
|
||||||
.arg(root.snapshot.burnPercent || "")
|
|
||||||
}
|
|
||||||
|
|
||||||
SummaryRow {
|
SummaryRow {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "")
|
label: qsTr("Fee")
|
||||||
value: root.snapshot.minTokenAReceived || ""
|
value: root.snapshot.feeText || "-"
|
||||||
}
|
}
|
||||||
|
|
||||||
SummaryRow {
|
SummaryRow {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "")
|
label: qsTr("Deposit")
|
||||||
value: root.snapshot.minTokenBReceived || ""
|
value: qsTr("%1 + %2")
|
||||||
}
|
.arg(root.snapshot.depositAText || "-")
|
||||||
|
.arg(root.snapshot.depositBText || "-")
|
||||||
|
valueWrapAnywhere: true
|
||||||
|
}
|
||||||
|
|
||||||
SummaryRow {
|
SummaryRow {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
label: qsTr("Slippage tolerance")
|
label: qsTr("Expected LP")
|
||||||
value: root.snapshot.slippageTolerance || ""
|
value: root.snapshot.expectedLpText || "-"
|
||||||
}
|
|
||||||
|
|
||||||
SummaryRow {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
label: qsTr("Post-removal share")
|
|
||||||
value: root.snapshot.postRemovalShare || ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,201 +0,0 @@
|
|||||||
pragma ComponentBehavior: Bound
|
|
||||||
|
|
||||||
import QtQuick
|
|
||||||
import QtQuick.Controls
|
|
||||||
import QtQuick.Layouts
|
|
||||||
|
|
||||||
import Logos.Controls
|
|
||||||
import Logos.Theme
|
|
||||||
|
|
||||||
FocusScope {
|
|
||||||
id: root
|
|
||||||
|
|
||||||
property var snapshot: ({})
|
|
||||||
property bool open: false
|
|
||||||
property bool busy: false
|
|
||||||
|
|
||||||
signal canceled
|
|
||||||
signal confirmed(var snapshot)
|
|
||||||
|
|
||||||
visible: open
|
|
||||||
focus: open
|
|
||||||
z: 200
|
|
||||||
|
|
||||||
Keys.onEscapePressed: function(event) {
|
|
||||||
event.accepted = true
|
|
||||||
if (!root.busy)
|
|
||||||
root.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
function openWithSnapshot(nextSnapshot) {
|
|
||||||
root.snapshot = nextSnapshot || ({})
|
|
||||||
root.open = true
|
|
||||||
root.forceActiveFocus()
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
if (root.busy)
|
|
||||||
return
|
|
||||||
root.open = false
|
|
||||||
root.canceled()
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirm() {
|
|
||||||
if (!root.busy)
|
|
||||||
root.confirmed(root.snapshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAfterSuccess() {
|
|
||||||
root.open = false
|
|
||||||
root.snapshot = ({})
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAfterFailure() {
|
|
||||||
root.open = false
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.fill: parent
|
|
||||||
color: "#B0000000"
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
anchors.fill: parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
width: Math.min(520, parent.width - 32)
|
|
||||||
implicitHeight: dialogContent.implicitHeight + 40
|
|
||||||
radius: 8
|
|
||||||
color: Theme.palette.backgroundElevated
|
|
||||||
border.color: Theme.palette.borderSecondary
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
id: dialogContent
|
|
||||||
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 20
|
|
||||||
spacing: 14
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: qsTr("Confirm new position")
|
|
||||||
color: Theme.palette.text
|
|
||||||
font.pixelSize: 19
|
|
||||||
font.weight: Font.DemiBold
|
|
||||||
font.letterSpacing: 0
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
|
|
||||||
BusyIndicator {
|
|
||||||
running: root.busy
|
|
||||||
visible: running
|
|
||||||
implicitWidth: 24
|
|
||||||
implicitHeight: 24
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
implicitHeight: summary.implicitHeight + 24
|
|
||||||
radius: 6
|
|
||||||
color: Theme.palette.backgroundTertiary
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
id: summary
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 12
|
|
||||||
spacing: 9
|
|
||||||
|
|
||||||
SummaryLine {
|
|
||||||
label: qsTr("Pair")
|
|
||||||
value: root.snapshot.pairText || "—"
|
|
||||||
}
|
|
||||||
|
|
||||||
SummaryLine {
|
|
||||||
label: qsTr("Action")
|
|
||||||
value: root.snapshot.instruction || "—"
|
|
||||||
}
|
|
||||||
|
|
||||||
SummaryLine {
|
|
||||||
label: qsTr("Fee")
|
|
||||||
value: root.snapshot.feeText || "—"
|
|
||||||
}
|
|
||||||
|
|
||||||
SummaryLine {
|
|
||||||
label: qsTr("Deposit")
|
|
||||||
value: qsTr("%1 + %2")
|
|
||||||
.arg(root.snapshot.depositAText || "—")
|
|
||||||
.arg(root.snapshot.depositBText || "—")
|
|
||||||
}
|
|
||||||
|
|
||||||
SummaryLine {
|
|
||||||
label: qsTr("Expected LP")
|
|
||||||
value: root.snapshot.expectedLpText || "—"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.busy
|
|
||||||
? qsTr("Waiting for wallet submission")
|
|
||||||
: qsTr("Your wallet will review and submit this transaction.")
|
|
||||||
color: Theme.palette.textSecondary
|
|
||||||
font.pixelSize: 12
|
|
||||||
wrapMode: Text.Wrap
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 10
|
|
||||||
|
|
||||||
LogosButton {
|
|
||||||
text: qsTr("Cancel")
|
|
||||||
enabled: !root.busy
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.minimumHeight: 44
|
|
||||||
radius: 6
|
|
||||||
onClicked: root.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
LogosButton {
|
|
||||||
text: root.busy ? qsTr("Submitting…") : qsTr("Submit")
|
|
||||||
enabled: !root.busy
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.minimumHeight: 44
|
|
||||||
radius: 6
|
|
||||||
onClicked: root.confirm()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
component SummaryLine: RowLayout {
|
|
||||||
required property string label
|
|
||||||
required property string value
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 12
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: parent.label
|
|
||||||
color: Theme.palette.textSecondary
|
|
||||||
font.pixelSize: 12
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: parent.value
|
|
||||||
color: Theme.palette.text
|
|
||||||
font.pixelSize: 12
|
|
||||||
font.weight: Font.Medium
|
|
||||||
horizontalAlignment: Text.AlignRight
|
|
||||||
wrapMode: Text.WrapAnywhere
|
|
||||||
Layout.maximumWidth: 320
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,6 +6,7 @@ Item {
|
|||||||
|
|
||||||
property string label: ""
|
property string label: ""
|
||||||
property string value: ""
|
property string value: ""
|
||||||
|
property bool valueWrapAnywhere: false
|
||||||
property bool estimated: 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.")
|
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 {
|
Text {
|
||||||
color: "#E7E1D8"
|
color: "#E7E1D8"
|
||||||
elide: Text.ElideRight
|
elide: root.valueWrapAnywhere ? Text.ElideNone : Text.ElideRight
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.pixelSize: 12
|
font.pixelSize: 12
|
||||||
horizontalAlignment: Text.AlignRight
|
horizontalAlignment: Text.AlignRight
|
||||||
text: root.value
|
text: root.value
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
wrapMode: root.valueWrapAnywhere ? Text.WrapAtWordBoundaryOrAnywhere : Text.NoWrap
|
||||||
|
|
||||||
Layout.maximumWidth: Math.max(178, root.width * 0.55)
|
Layout.maximumWidth: Math.max(178, root.width * 0.55)
|
||||||
|
Layout.preferredWidth: Math.min(implicitWidth, Math.max(178, root.width * 0.55))
|
||||||
}
|
}
|
||||||
|
|
||||||
EstimateInfoButton {
|
EstimateInfoButton {
|
||||||
|
|||||||
@ -1,165 +1,135 @@
|
|||||||
import QtQuick
|
pragma ComponentBehavior: Bound
|
||||||
import QtQuick.Controls
|
|
||||||
import QtQuick.Layouts
|
|
||||||
|
|
||||||
Rectangle {
|
import QtQuick
|
||||||
|
|
||||||
|
import "../shared"
|
||||||
|
|
||||||
|
AmmTokenAmountSurface {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property alias text: amountField.text
|
property string text: ""
|
||||||
property string balance: ""
|
property string balance: ""
|
||||||
property string errorText: ""
|
|
||||||
property string helperText: ""
|
property string helperText: ""
|
||||||
property string label: ""
|
|
||||||
property bool readOnly: false
|
|
||||||
property bool showMaxButton: true
|
property bool showMaxButton: true
|
||||||
property string token: ""
|
property var tokenData: null
|
||||||
|
property var tokens: []
|
||||||
|
property string selectedTokenId: ""
|
||||||
|
property bool tokenInvalid: false
|
||||||
|
property bool tokenSelectionEnabled: true
|
||||||
|
property bool editPending: false
|
||||||
|
property string pendingValue: ""
|
||||||
|
property var disabledReasonForCode: function(code) {
|
||||||
|
return qsTr("This token is unavailable (%1).").arg(code || "unknown")
|
||||||
|
}
|
||||||
|
property var detailForToken: function(token) { return "" }
|
||||||
|
property alias popup: tokenModal
|
||||||
|
property alias query: tokenModal.searchText
|
||||||
|
readonly property var rows: tokenModal.rows
|
||||||
|
|
||||||
signal editingChanged(string value)
|
signal editingChanged(string value)
|
||||||
|
signal editingCommitted(string value)
|
||||||
signal maxClicked
|
signal maxClicked
|
||||||
|
signal tokenSelected(string tokenId)
|
||||||
|
signal tokenEntered(string value)
|
||||||
|
|
||||||
color: "#151515"
|
amount: root.text
|
||||||
implicitHeight: content.implicitHeight + 20
|
supportingText: root.helperText
|
||||||
radius: 8
|
supportingActionText: root.showMaxButton ? qsTr("MAX") : ""
|
||||||
border.color: root.errorText.length > 0 ? "#D85F4B" : amountField.activeFocus ? "#F26A21" : "#343434"
|
accessory: tokenActions
|
||||||
border.width: 1
|
accessoryWidth: width < 360 ? 132 : 180
|
||||||
|
accessoryHeight: root.balance.length > 0 ? 58 : 40
|
||||||
|
|
||||||
Accessible.name: root.label
|
onAmountEdited: function(value) {
|
||||||
Accessible.role: Accessible.EditableText
|
root.pendingValue = value
|
||||||
|
root.editPending = true
|
||||||
ColumnLayout {
|
root.editingChanged(value)
|
||||||
id: content
|
commitTimer.restart()
|
||||||
|
}
|
||||||
anchors.fill: parent
|
onAmountEditingFinished: function(value) {
|
||||||
anchors.margins: 10
|
root.pendingValue = value
|
||||||
spacing: 8
|
root.commitPendingEdit()
|
||||||
|
}
|
||||||
RowLayout {
|
onSupportingActionClicked: root.maxClicked()
|
||||||
spacing: 8
|
onTextChanged: {
|
||||||
|
if (root.editPending && root.text !== root.pendingValue) {
|
||||||
Layout.fillWidth: true
|
commitTimer.stop()
|
||||||
|
root.editPending = false
|
||||||
Text {
|
|
||||||
color: "#A9A098"
|
|
||||||
elide: Text.ElideRight
|
|
||||||
font.pixelSize: 12
|
|
||||||
text: root.label
|
|
||||||
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
color: "#E7E1D8"
|
|
||||||
elide: Text.ElideRight
|
|
||||||
font.bold: true
|
|
||||||
font.pixelSize: 12
|
|
||||||
horizontalAlignment: Text.AlignRight
|
|
||||||
text: root.token
|
|
||||||
|
|
||||||
Layout.maximumWidth: 76
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Layout.fillWidth: true
|
|
||||||
|
|
||||||
TextField {
|
|
||||||
id: amountField
|
|
||||||
|
|
||||||
activeFocusOnTab: true
|
|
||||||
color: "#E7E1D8"
|
|
||||||
font.bold: true
|
|
||||||
font.pixelSize: 18
|
|
||||||
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
|
||||||
maximumLength: 80
|
|
||||||
placeholderText: qsTr("0")
|
|
||||||
readOnly: root.readOnly
|
|
||||||
selectByMouse: true
|
|
||||||
selectedTextColor: "#151515"
|
|
||||||
selectionColor: "#F26A21"
|
|
||||||
validator: RegularExpressionValidator {
|
|
||||||
regularExpression: /[0-9]*([.][0-9]*)?/
|
|
||||||
}
|
|
||||||
|
|
||||||
Accessible.name: root.label
|
|
||||||
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.minimumHeight: 44
|
|
||||||
|
|
||||||
onTextEdited: {
|
|
||||||
if (!root.readOnly)
|
|
||||||
root.editingChanged(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
border.color: amountField.activeFocus ? "#F26A21" : "#343434"
|
|
||||||
border.width: 1
|
|
||||||
color: amountField.activeFocus ? "#1F1B18" : "#101010"
|
|
||||||
radius: 6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
id: maxButton
|
|
||||||
|
|
||||||
activeFocusOnTab: true
|
|
||||||
enabled: root.showMaxButton && !root.readOnly
|
|
||||||
focusPolicy: Qt.StrongFocus
|
|
||||||
hoverEnabled: true
|
|
||||||
text: qsTr("MAX")
|
|
||||||
visible: root.showMaxButton
|
|
||||||
|
|
||||||
Accessible.name: qsTr("Use maximum %1 balance").arg(root.token)
|
|
||||||
|
|
||||||
Layout.minimumHeight: 44
|
|
||||||
Layout.preferredWidth: visible ? 58 : 0
|
|
||||||
|
|
||||||
onClicked: root.maxClicked()
|
|
||||||
|
|
||||||
contentItem: Text {
|
|
||||||
color: maxButton.activeFocus || maxButton.hovered ? "#151515" : "#F26A21"
|
|
||||||
font.bold: true
|
|
||||||
font.pixelSize: 11
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
text: maxButton.text
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
border.color: "#F26A21"
|
|
||||||
border.width: 1
|
|
||||||
color: maxButton.pressed ? "#D95C1E" : maxButton.hovered || maxButton.activeFocus ? "#F26A21" : "#201712"
|
|
||||||
radius: 6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Layout.fillWidth: true
|
|
||||||
|
|
||||||
Text {
|
|
||||||
color: root.errorText.length > 0 ? "#F08A76" : root.helperText.length > 0 ? "#F26A21" : "#A9A098"
|
|
||||||
elide: Text.ElideRight
|
|
||||||
font.pixelSize: 11
|
|
||||||
text: root.errorText.length > 0 ? root.errorText : root.helperText
|
|
||||||
visible: text.length > 0
|
|
||||||
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
color: "#A9A098"
|
|
||||||
elide: Text.ElideRight
|
|
||||||
font.pixelSize: 11
|
|
||||||
horizontalAlignment: Text.AlignRight
|
|
||||||
text: qsTr("Balance %1").arg(root.balance)
|
|
||||||
|
|
||||||
Layout.alignment: Qt.AlignRight
|
|
||||||
Layout.maximumWidth: 150
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: commitTimer
|
||||||
|
|
||||||
|
interval: 250
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.commitPendingEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: tokenActions
|
||||||
|
|
||||||
|
AmmTokenAccessory {
|
||||||
|
theme: root.theme
|
||||||
|
enabled: root.tokenSelectionEnabled
|
||||||
|
invalid: root.tokenInvalid
|
||||||
|
hasToken: root.tokenData !== null
|
||||||
|
tokenColor: root.tokenColor(root.tokenData)
|
||||||
|
tokenLetter: root.tokenLetter(root.tokenData)
|
||||||
|
tokenText: root.tokenText(root.tokenData)
|
||||||
|
balance: root.balance
|
||||||
|
accessibleName: qsTr("Select %1").arg(root.label)
|
||||||
|
onClicked: tokenModal.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TokenSelectorModal {
|
||||||
|
id: tokenModal
|
||||||
|
|
||||||
|
theme: root.theme
|
||||||
|
tokens: root.tokens
|
||||||
|
title: qsTr("Select a token")
|
||||||
|
searchPlaceholder: qsTr("Search name or address")
|
||||||
|
popularTitle: qsTr("Quick select")
|
||||||
|
listTitle: qsTr("All tokens")
|
||||||
|
allowCustomEntry: true
|
||||||
|
disabledReasonForCode: root.disabledReasonForCode
|
||||||
|
detailForToken: root.detailForToken
|
||||||
|
|
||||||
|
onTokenSelected: function(token) {
|
||||||
|
root.tokenSelected(String(token.definitionId || token.address || ""))
|
||||||
|
}
|
||||||
|
onTokenEntered: function(value) { root.tokenEntered(value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptInput(value) {
|
||||||
|
tokenModal.acceptInput(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitPendingEdit() {
|
||||||
|
if (!root.editPending)
|
||||||
|
return
|
||||||
|
commitTimer.stop()
|
||||||
|
root.editPending = false
|
||||||
|
root.editingCommitted(root.pendingValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenText(token) {
|
||||||
|
if (!token)
|
||||||
|
return qsTr("Select token")
|
||||||
|
return String(token.symbol || token.name || root.shortId(root.selectedTokenId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenLetter(token) {
|
||||||
|
var text = root.tokenText(token)
|
||||||
|
return token ? String(token.letter || text.charAt(0).toUpperCase()) : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenColor(token) {
|
||||||
|
return token && token.color ? token.color : root.theme.colors.noTokenCircle
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortId(value) {
|
||||||
|
var text = String(value || "")
|
||||||
|
return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
489
apps/amm/qml/components/liquidity/TokenSelectorModal.qml
Normal file
489
apps/amm/qml/components/liquidity/TokenSelectorModal.qml
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,116 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import QtQuick.Layouts
|
|
||||||
|
|
||||||
import Logos.Theme
|
|
||||||
import Logos.Controls
|
|
||||||
|
|
||||||
// Vertical progress rail for the pool-creation flow (Uniswap-style): numbered
|
|
||||||
// steps connected by a line, with the active step highlighted and completed
|
|
||||||
// steps marked done. Read currentStep to drive which step is active. Clicking an
|
|
||||||
// already-reached step (index <= currentStep) emits stepClicked so the page can
|
|
||||||
// navigate back to it.
|
|
||||||
Item {
|
|
||||||
id: root
|
|
||||||
|
|
||||||
property int currentStep: 0
|
|
||||||
readonly property var steps: [
|
|
||||||
{ title: qsTr("Select token pair"), subtitle: qsTr("Pick the two tokens for the pool.") },
|
|
||||||
{ title: qsTr("Deposit amounts"), subtitle: qsTr("Set the initial liquidity.") }
|
|
||||||
]
|
|
||||||
|
|
||||||
signal stepClicked(int index)
|
|
||||||
|
|
||||||
implicitWidth: 240
|
|
||||||
implicitHeight: column.implicitHeight
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
id: column
|
|
||||||
anchors.fill: parent
|
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
Repeater {
|
|
||||||
model: root.steps
|
|
||||||
|
|
||||||
delegate: Item {
|
|
||||||
id: stepItem
|
|
||||||
|
|
||||||
readonly property bool active: index === root.currentStep
|
|
||||||
readonly property bool done: index < root.currentStep
|
|
||||||
readonly property bool last: index === root.steps.length - 1
|
|
||||||
// Only steps already reached can be clicked (no jumping ahead).
|
|
||||||
readonly property bool reachable: index <= root.currentStep
|
|
||||||
|
|
||||||
Layout.fillWidth: true
|
|
||||||
implicitHeight: stepRow.implicitHeight
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
id: stepRow
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.top: parent.top
|
|
||||||
spacing: Theme.spacing.medium
|
|
||||||
|
|
||||||
// Indicator: numbered dot + connector line down to the next dot.
|
|
||||||
Item {
|
|
||||||
Layout.preferredWidth: 28
|
|
||||||
Layout.fillHeight: true
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
id: dot
|
|
||||||
width: 28
|
|
||||||
height: 28
|
|
||||||
radius: 14
|
|
||||||
color: (stepItem.active || stepItem.done) ? Theme.palette.primary : Theme.palette.backgroundSecondary
|
|
||||||
border.width: 1
|
|
||||||
border.color: (stepItem.active || stepItem.done) ? Theme.palette.primary : Theme.palette.border
|
|
||||||
|
|
||||||
LogosText {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: stepItem.done ? "✓" : (index + 1)
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
font.bold: true
|
|
||||||
color: (stepItem.active || stepItem.done) ? Theme.palette.background : Theme.palette.textSecondary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Rectangle {
|
|
||||||
visible: !stepItem.last
|
|
||||||
width: 2
|
|
||||||
anchors.top: dot.bottom
|
|
||||||
anchors.bottom: parent.bottom
|
|
||||||
anchors.horizontalCenter: dot.horizontalCenter
|
|
||||||
color: stepItem.done ? Theme.palette.primary : Theme.palette.border
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step text.
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.bottomMargin: stepItem.last ? 0 : Theme.spacing.xlarge
|
|
||||||
spacing: 2
|
|
||||||
|
|
||||||
LogosText {
|
|
||||||
text: modelData.title
|
|
||||||
font.pixelSize: Theme.typography.primaryText
|
|
||||||
font.bold: stepItem.active
|
|
||||||
color: (stepItem.active || stepItem.done) ? Theme.palette.text : Theme.palette.textSecondary
|
|
||||||
}
|
|
||||||
LogosText {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
text: modelData.subtitle
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.textSecondary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
anchors.fill: parent
|
|
||||||
enabled: stepItem.reachable
|
|
||||||
cursorShape: stepItem.reachable ? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
||||||
onClicked: root.stepClicked(index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,461 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import QtQml
|
|
||||||
import QtQuick.Controls
|
|
||||||
import QtQuick.Layouts
|
|
||||||
|
|
||||||
import Logos.Theme
|
|
||||||
import Logos.Controls
|
|
||||||
|
|
||||||
// Header wallet control (Uniswap-style), with two states:
|
|
||||||
// - not connected → a "Connect" button that opens the create-wallet modal
|
|
||||||
// - connected → a single button showing the active account address;
|
|
||||||
// clicking it opens a popup (top-right, just under the
|
|
||||||
// button) holding the account selector, create-account and
|
|
||||||
// disconnect actions.
|
|
||||||
// The selected account address is exposed via selectedAddress for the
|
|
||||||
// trade/liquidity flows to use as the "from" account.
|
|
||||||
Item {
|
|
||||||
id: root
|
|
||||||
|
|
||||||
// Backend replica (logos.module("amm_ui")) and its account model.
|
|
||||||
property var backend: null
|
|
||||||
property var accountModel: null
|
|
||||||
|
|
||||||
readonly property bool connected: backend !== null && backend.isWalletOpen
|
|
||||||
|
|
||||||
// Index of the active account. selectedAddress/selectedName are derived from
|
|
||||||
// the model mirror below so they stay valid while the popup (and its list)
|
|
||||||
// is closed.
|
|
||||||
property int selectedIndex: 0
|
|
||||||
|
|
||||||
// Non-visual mirror of the account model: realizes every row regardless of
|
|
||||||
// popup visibility, so the active account is addressable by index at all
|
|
||||||
// times (a ListView only realizes rows while it is shown).
|
|
||||||
Instantiator {
|
|
||||||
id: accounts
|
|
||||||
model: root.accountModel
|
|
||||||
delegate: QtObject {
|
|
||||||
readonly property string address: model.address ?? ""
|
|
||||||
readonly property string name: model.name ?? ""
|
|
||||||
readonly property string balance: model.balance ?? ""
|
|
||||||
readonly property bool isPublic: model.isPublic ?? false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function entryAt(i) {
|
|
||||||
return (i >= 0 && i < accounts.count) ? accounts.objectAt(i) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly property string selectedAddress: {
|
|
||||||
const e = root.entryAt(root.selectedIndex)
|
|
||||||
return e ? e.address : ""
|
|
||||||
}
|
|
||||||
readonly property string selectedName: {
|
|
||||||
const e = root.entryAt(root.selectedIndex)
|
|
||||||
return e ? e.name : ""
|
|
||||||
}
|
|
||||||
readonly property string selectedBalance: {
|
|
||||||
const e = root.entryAt(root.selectedIndex)
|
|
||||||
return e ? e.balance : ""
|
|
||||||
}
|
|
||||||
readonly property bool selectedIsPublic: {
|
|
||||||
const e = root.entryAt(root.selectedIndex)
|
|
||||||
return e ? e.isPublic : false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the selection within bounds as accounts are added/removed.
|
|
||||||
function clampSelection() {
|
|
||||||
if (accounts.count === 0) { root.selectedIndex = 0; return }
|
|
||||||
if (root.selectedIndex < 0) root.selectedIndex = 0
|
|
||||||
else if (root.selectedIndex >= accounts.count) root.selectedIndex = accounts.count - 1
|
|
||||||
}
|
|
||||||
Connections {
|
|
||||||
target: root.accountModel
|
|
||||||
ignoreUnknownSignals: true
|
|
||||||
function onModelReset() { root.clampSelection() }
|
|
||||||
function onRowsInserted() { root.clampSelection() }
|
|
||||||
function onRowsRemoved() { root.clampSelection() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 0x123456…cdef style truncation for the connected button label.
|
|
||||||
function truncated(addr) {
|
|
||||||
if (!addr) return ""
|
|
||||||
return addr.length > 13 ? (addr.substring(0, 6) + "…" + addr.substring(addr.length - 4)) : addr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy on the QML/view side. Routing this through the backend would call
|
|
||||||
// QGuiApplication::clipboard() in the (headless) module host process, which
|
|
||||||
// has no clipboard — that call tears the backend down, dropping the wallet
|
|
||||||
// connection. A hidden TextEdit copies via the GUI process that owns it.
|
|
||||||
TextEdit { id: clipboardProxy; visible: false }
|
|
||||||
function copyToClipboard(text) {
|
|
||||||
if (!text) return
|
|
||||||
clipboardProxy.text = text
|
|
||||||
clipboardProxy.selectAll()
|
|
||||||
clipboardProxy.copy()
|
|
||||||
clipboardProxy.deselect()
|
|
||||||
clipboardProxy.text = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function showWalletMessage(title, message) {
|
|
||||||
walletMessageDialog.title = title
|
|
||||||
walletMessageDialog.message = message
|
|
||||||
walletMessageDialog.open()
|
|
||||||
}
|
|
||||||
|
|
||||||
function finishAccountCreation(accountId, fallbackError) {
|
|
||||||
createAccountDialog.busy = false
|
|
||||||
if (accountId && accountId.length > 0)
|
|
||||||
createAccountDialog.close()
|
|
||||||
else
|
|
||||||
createAccountDialog.createError = fallbackError
|
|
||||||
}
|
|
||||||
|
|
||||||
implicitWidth: root.connected ? connectedButton.width : connectButton.width
|
|
||||||
implicitHeight: 40
|
|
||||||
|
|
||||||
// ── Disconnected: Connect ────────────────────────────────────────────
|
|
||||||
LogosButton {
|
|
||||||
id: connectButton
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
height: 40
|
|
||||||
visible: !root.connected
|
|
||||||
enabled: root.backend !== null
|
|
||||||
text: qsTr("Connect")
|
|
||||||
onClicked: {
|
|
||||||
// Re-open an existing wallet; only show the create modal on first run.
|
|
||||||
if (root.backend && root.backend.walletExists)
|
|
||||||
logos.watch(root.backend.openExisting(),
|
|
||||||
function(ok) {
|
|
||||||
if (!ok)
|
|
||||||
root.showWalletMessage(
|
|
||||||
qsTr("Unable to connect wallet"),
|
|
||||||
qsTr("The existing wallet could not be opened. Check the wallet files and try again."))
|
|
||||||
},
|
|
||||||
function(error) {
|
|
||||||
root.showWalletMessage(
|
|
||||||
qsTr("Unable to connect wallet"),
|
|
||||||
qsTr("Error opening wallet: %1").arg(error))
|
|
||||||
})
|
|
||||||
else
|
|
||||||
createWalletDialog.open()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Connected: address pill that toggles the wallet menu ─────────────
|
|
||||||
Rectangle {
|
|
||||||
id: connectedButton
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
visible: root.connected
|
|
||||||
implicitHeight: 40
|
|
||||||
implicitWidth: connectedRow.implicitWidth + Theme.spacing.medium * 2
|
|
||||||
radius: height / 2
|
|
||||||
// Keep an opaque dark fill in both states: the navbar is white, and the
|
|
||||||
// active "muted" fill is translucent gray, which renders light over white
|
|
||||||
// and makes the white label unreadable. Signal "open" with an accent
|
|
||||||
// border instead.
|
|
||||||
color: Theme.palette.backgroundSecondary
|
|
||||||
border.width: 1
|
|
||||||
border.color: walletMenu.opened ? Theme.palette.overlayOrange : "transparent"
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
id: connectedRow
|
|
||||||
anchors.centerIn: parent
|
|
||||||
spacing: Theme.spacing.small
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.preferredWidth: 8
|
|
||||||
Layout.preferredHeight: 8
|
|
||||||
radius: 4
|
|
||||||
color: "#39c06a"
|
|
||||||
}
|
|
||||||
LogosText {
|
|
||||||
text: root.truncated(root.selectedAddress) || qsTr("Connected")
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.text
|
|
||||||
}
|
|
||||||
LogosText {
|
|
||||||
text: walletMenu.opened ? "▴" : "▾"
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.textSecondary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
anchors.fill: parent
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
// CloseOnPressOutside already dismisses the popup on this same press
|
|
||||||
// (the button is outside it), so `opened` is false by the time this
|
|
||||||
// fires. Without the recency guard the dismissing click would just
|
|
||||||
// reopen it. If it just closed, leave it closed.
|
|
||||||
onClicked: {
|
|
||||||
if (walletMenu.opened || (Date.now() - walletMenu.lastClosedMs) < 200)
|
|
||||||
walletMenu.close()
|
|
||||||
else
|
|
||||||
walletMenu.open()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Wallet menu popup (top-right, under the connected button) ─────────
|
|
||||||
Popup {
|
|
||||||
id: walletMenu
|
|
||||||
parent: connectedButton
|
|
||||||
y: connectedButton.height + Theme.spacing.small
|
|
||||||
x: connectedButton.width - width // right-align under the button
|
|
||||||
width: 360
|
|
||||||
padding: Theme.spacing.medium
|
|
||||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
|
||||||
|
|
||||||
// Timestamp of the last dismissal, used by the toggle button to tell a
|
|
||||||
// genuine "open" click from the press that just closed the popup.
|
|
||||||
property real lastClosedMs: 0
|
|
||||||
onClosed: {
|
|
||||||
walletMenu.lastClosedMs = Date.now()
|
|
||||||
// Always reopen on the main (selected-account) view.
|
|
||||||
if (viewStack.depth > 1)
|
|
||||||
viewStack.pop(null, StackView.Immediate)
|
|
||||||
}
|
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
color: Theme.palette.backgroundTertiary
|
|
||||||
border.width: 1
|
|
||||||
border.color: Theme.palette.backgroundElevated
|
|
||||||
radius: Theme.spacing.radiusLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two stacked views: the main view (active account + actions) and the
|
|
||||||
// accounts view (full list + create). The popup height follows the
|
|
||||||
// active view's natural height, animated so the resize isn't abrupt.
|
|
||||||
contentItem: StackView {
|
|
||||||
id: viewStack
|
|
||||||
clip: true
|
|
||||||
implicitWidth: walletMenu.availableWidth
|
|
||||||
implicitHeight: currentItem ? currentItem.implicitHeight : 0
|
|
||||||
initialItem: mainView
|
|
||||||
|
|
||||||
Behavior on implicitHeight {
|
|
||||||
NumberAnimation { duration: 160; easing.type: Easing.OutCubic }
|
|
||||||
}
|
|
||||||
|
|
||||||
pushEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } }
|
|
||||||
pushExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } }
|
|
||||||
popEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } }
|
|
||||||
popExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main view: account icon + power icon, then the active account ──
|
|
||||||
Component {
|
|
||||||
id: mainView
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
spacing: Theme.spacing.medium
|
|
||||||
|
|
||||||
// Top-right actions: open the account list / disconnect.
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: Theme.spacing.small
|
|
||||||
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
|
|
||||||
WalletIconButton {
|
|
||||||
iconSource: Qt.resolvedUrl("icons/account.svg")
|
|
||||||
onClicked: viewStack.push(accountsView)
|
|
||||||
}
|
|
||||||
WalletIconButton {
|
|
||||||
iconSource: Qt.resolvedUrl("icons/power.svg")
|
|
||||||
onClicked: {
|
|
||||||
walletMenu.close()
|
|
||||||
if (root.backend) root.backend.disconnectWallet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active account card.
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.preferredHeight: cardColumn.implicitHeight + Theme.spacing.medium * 2
|
|
||||||
radius: Theme.spacing.radiusLarge
|
|
||||||
color: Theme.palette.backgroundMuted
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
id: cardColumn
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.spacing.medium
|
|
||||||
spacing: Theme.spacing.small
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: Theme.spacing.small
|
|
||||||
|
|
||||||
LogosText {
|
|
||||||
text: root.selectedName
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Rectangle {
|
|
||||||
Layout.preferredWidth: tagLabel.implicitWidth + Theme.spacing.small * 2
|
|
||||||
Layout.preferredHeight: tagLabel.implicitHeight + 4
|
|
||||||
radius: 4
|
|
||||||
color: Theme.palette.backgroundSecondary
|
|
||||||
LogosText {
|
|
||||||
id: tagLabel
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private")
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.textSecondary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
LogosText {
|
|
||||||
text: root.selectedBalance.length > 0 ? root.selectedBalance : "—"
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 0
|
|
||||||
LogosText {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
text: root.selectedAddress
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.textMuted
|
|
||||||
elide: Text.ElideMiddle
|
|
||||||
}
|
|
||||||
LogosCopyButton {
|
|
||||||
Layout.preferredHeight: 40
|
|
||||||
Layout.preferredWidth: 40
|
|
||||||
visible: root.selectedAddress.length > 0
|
|
||||||
onCopyText: root.copyToClipboard(root.selectedAddress)
|
|
||||||
icon.color: Theme.palette.textMuted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Accounts view: back + full list + create ──────────────────────
|
|
||||||
Component {
|
|
||||||
id: accountsView
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
spacing: Theme.spacing.medium
|
|
||||||
|
|
||||||
// Header: back to the main view + title.
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: Theme.spacing.small
|
|
||||||
|
|
||||||
WalletIconButton {
|
|
||||||
iconSource: Qt.resolvedUrl("icons/back.svg")
|
|
||||||
onClicked: viewStack.pop()
|
|
||||||
}
|
|
||||||
LogosText {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
text: qsTr("Accounts")
|
|
||||||
font.bold: true
|
|
||||||
color: Theme.palette.text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Account list: tap a row to make it the active account, then
|
|
||||||
// return to the main view so the selection is reflected.
|
|
||||||
ListView {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.preferredHeight: Math.min(contentHeight, 260)
|
|
||||||
clip: true
|
|
||||||
model: root.accountModel
|
|
||||||
spacing: Theme.spacing.small
|
|
||||||
ScrollIndicator.vertical: ScrollIndicator { }
|
|
||||||
|
|
||||||
delegate: AccountDelegate {
|
|
||||||
width: ListView.view.width
|
|
||||||
highlighted: index === root.selectedIndex
|
|
||||||
onClicked: {
|
|
||||||
root.selectedIndex = index
|
|
||||||
viewStack.pop()
|
|
||||||
}
|
|
||||||
onCopyRequested: (text) => root.copyToClipboard(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LogosButton {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
height: 40
|
|
||||||
text: qsTr("Add")
|
|
||||||
// Leave the wallet menu open behind the (modal) dialog.
|
|
||||||
onClicked: createAccountDialog.open()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dialogs ──────────────────────────────────────────────────────────
|
|
||||||
CreateWalletDialog {
|
|
||||||
id: createWalletDialog
|
|
||||||
walletHome: root.backend ? root.backend.walletHome : ""
|
|
||||||
onCreateWallet: function(password) {
|
|
||||||
if (!root.backend) return
|
|
||||||
// createNewDefault returns the new wallet's seed phrase (empty on
|
|
||||||
// failure). On success we hand it to the dialog, which switches to
|
|
||||||
// its backup page — we do NOT close here, so the user can't skip it.
|
|
||||||
logos.watch(root.backend.createNewDefault(password),
|
|
||||||
function(mnemonic) {
|
|
||||||
if (mnemonic && mnemonic.length > 0)
|
|
||||||
createWalletDialog.mnemonic = mnemonic
|
|
||||||
else
|
|
||||||
createWalletDialog.createError = qsTr("Failed to create wallet. Please try again.")
|
|
||||||
},
|
|
||||||
function(error) {
|
|
||||||
createWalletDialog.createError = qsTr("Error creating wallet: %1").arg(error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onCopyRequested: function(text) {
|
|
||||||
if (root.backend) root.backend.copyToClipboard(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CreateAccountDialog {
|
|
||||||
id: createAccountDialog
|
|
||||||
onCreatePublicRequested: {
|
|
||||||
if (!root.backend) {
|
|
||||||
root.finishAccountCreation("", qsTr("Wallet backend is unavailable. Reconnect and try again."))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
createAccountDialog.createError = ""
|
|
||||||
logos.watch(root.backend.createAccountPublic(),
|
|
||||||
function(id) {
|
|
||||||
root.finishAccountCreation(id, qsTr("Failed to create account. Please try again."))
|
|
||||||
},
|
|
||||||
function(error) {
|
|
||||||
createAccountDialog.busy = false
|
|
||||||
createAccountDialog.createError = qsTr("Error creating account: %1").arg(error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onCreatePrivateRequested: {
|
|
||||||
if (!root.backend) {
|
|
||||||
root.finishAccountCreation("", qsTr("Wallet backend is unavailable. Reconnect and try again."))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
createAccountDialog.createError = ""
|
|
||||||
logos.watch(root.backend.createAccountPrivate(),
|
|
||||||
function(id) {
|
|
||||||
root.finishAccountCreation(id, qsTr("Failed to create private account. Please try again."))
|
|
||||||
},
|
|
||||||
function(error) {
|
|
||||||
createAccountDialog.busy = false
|
|
||||||
createAccountDialog.createError = qsTr("Error creating private account: %1").arg(error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
WalletMessageDialog {
|
|
||||||
id: walletMessageDialog
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,270 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import QtQuick.Controls
|
|
||||||
import QtQuick.Layouts
|
|
||||||
|
|
||||||
import Logos.Theme
|
|
||||||
import Logos.Controls
|
|
||||||
|
|
||||||
import "../components/pool"
|
|
||||||
|
|
||||||
// Two-column pool-creation flow (Uniswap-style): a vertical step rail on the
|
|
||||||
// left and the active step's panel on the right. Step 1 selects the token pair;
|
|
||||||
// step 2 enters deposit amounts. Both panels stay alive so the entered values
|
|
||||||
// persist when navigating between steps via the rail.
|
|
||||||
Item {
|
|
||||||
id: root
|
|
||||||
|
|
||||||
readonly property int pageMargin: 24
|
|
||||||
// Breathing room below the navbar. The Trade page fully centers its card
|
|
||||||
// (gap = leftover space / 2); this uses a quarter of the leftover so it sits
|
|
||||||
// roughly half as far down, scaling with the window, with a sensible floor.
|
|
||||||
readonly property int topMargin: Math.max(48, Math.round((scroll.height - content.implicitHeight) / 4))
|
|
||||||
readonly property int contentWidth: 760
|
|
||||||
|
|
||||||
// 0x123456…cdef style truncation for showing token addresses compactly.
|
|
||||||
function truncated(addr) {
|
|
||||||
const a = (addr || "").trim()
|
|
||||||
return a.length > 13 ? (a.substring(0, 6) + "…" + a.substring(a.length - 4)) : a
|
|
||||||
}
|
|
||||||
|
|
||||||
// Numbers only (digits + a single decimal point) for the deposit amounts.
|
|
||||||
RegularExpressionValidator {
|
|
||||||
id: amountValidator
|
|
||||||
regularExpression: /^[0-9]*\.?[0-9]*$/
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.fill: parent
|
|
||||||
color: Theme.palette.background
|
|
||||||
}
|
|
||||||
|
|
||||||
Flickable {
|
|
||||||
id: scroll
|
|
||||||
anchors.fill: parent
|
|
||||||
clip: true
|
|
||||||
contentWidth: width
|
|
||||||
contentHeight: Math.max(height, content.implicitHeight + root.topMargin + root.pageMargin)
|
|
||||||
flickableDirection: Flickable.VerticalFlick
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
id: content
|
|
||||||
x: Math.max(root.pageMargin, (scroll.width - width) / 2)
|
|
||||||
y: root.topMargin
|
|
||||||
width: Math.min(scroll.width - root.pageMargin * 2, root.contentWidth)
|
|
||||||
spacing: Theme.spacing.xxlarge
|
|
||||||
|
|
||||||
PoolStepRail {
|
|
||||||
id: rail
|
|
||||||
currentStep: 0
|
|
||||||
Layout.preferredWidth: 240
|
|
||||||
Layout.alignment: Qt.AlignTop
|
|
||||||
// Jump back to an already-reached step (e.g. step 1 to re-pick
|
|
||||||
// tokens). Selections persist because both panels stay alive.
|
|
||||||
onStepClicked: (index) => { rail.currentStep = index }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Right: active step's panel (both kept alive to preserve state) ──
|
|
||||||
Item {
|
|
||||||
id: rightPane
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.alignment: Qt.AlignTop
|
|
||||||
Layout.preferredHeight: rail.currentStep === 0
|
|
||||||
? selectCard.implicitHeight
|
|
||||||
: depositCard.implicitHeight
|
|
||||||
|
|
||||||
// ── Step 1: select pair ──────────────────────────────────
|
|
||||||
Rectangle {
|
|
||||||
id: selectCard
|
|
||||||
width: parent.width
|
|
||||||
visible: rail.currentStep === 0
|
|
||||||
implicitHeight: selectCol.implicitHeight + Theme.spacing.large * 2
|
|
||||||
radius: Theme.spacing.radiusLarge
|
|
||||||
color: Theme.palette.backgroundSecondary
|
|
||||||
border.width: 1
|
|
||||||
border.color: Theme.palette.borderSecondary
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
id: selectCol
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.spacing.large
|
|
||||||
spacing: Theme.spacing.medium
|
|
||||||
|
|
||||||
LogosText {
|
|
||||||
text: qsTr("Select pair")
|
|
||||||
font.pixelSize: Theme.typography.panelTitleText
|
|
||||||
font.weight: Theme.typography.weightBold
|
|
||||||
color: Theme.palette.text
|
|
||||||
}
|
|
||||||
LogosText {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
text: qsTr("Choose the two tokens for your pool by entering each token's address.")
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.textSecondary
|
|
||||||
}
|
|
||||||
|
|
||||||
LogosText {
|
|
||||||
Layout.topMargin: Theme.spacing.small
|
|
||||||
text: qsTr("Token A address")
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.textSecondary
|
|
||||||
}
|
|
||||||
LogosTextField {
|
|
||||||
id: tokenAField
|
|
||||||
Layout.fillWidth: true
|
|
||||||
placeholderText: "0x…"
|
|
||||||
}
|
|
||||||
|
|
||||||
LogosText {
|
|
||||||
Layout.topMargin: Theme.spacing.small
|
|
||||||
text: qsTr("Token B address")
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.textSecondary
|
|
||||||
}
|
|
||||||
LogosTextField {
|
|
||||||
id: tokenBField
|
|
||||||
Layout.fillWidth: true
|
|
||||||
placeholderText: "0x…"
|
|
||||||
}
|
|
||||||
|
|
||||||
LogosButton {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.topMargin: Theme.spacing.medium
|
|
||||||
height: 44
|
|
||||||
text: qsTr("Continue")
|
|
||||||
enabled: tokenAField.text.trim().length > 0
|
|
||||||
&& tokenBField.text.trim().length > 0
|
|
||||||
&& tokenAField.text.trim() !== tokenBField.text.trim()
|
|
||||||
onClicked: rail.currentStep = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 2: deposit amounts ──────────────────────────────
|
|
||||||
Rectangle {
|
|
||||||
id: depositCard
|
|
||||||
width: parent.width
|
|
||||||
visible: rail.currentStep === 1
|
|
||||||
implicitHeight: depositCol.implicitHeight + Theme.spacing.large * 2
|
|
||||||
radius: Theme.spacing.radiusLarge
|
|
||||||
color: Theme.palette.backgroundSecondary
|
|
||||||
border.width: 1
|
|
||||||
border.color: Theme.palette.borderSecondary
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
id: depositCol
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.spacing.large
|
|
||||||
spacing: Theme.spacing.medium
|
|
||||||
|
|
||||||
LogosText {
|
|
||||||
text: qsTr("Deposit amounts")
|
|
||||||
font.pixelSize: Theme.typography.panelTitleText
|
|
||||||
font.weight: Theme.typography.weightBold
|
|
||||||
color: Theme.palette.text
|
|
||||||
}
|
|
||||||
LogosText {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
text: qsTr("Set the initial liquidity for the pool.")
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.textSecondary
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token pair carried over from step 1.
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.topMargin: Theme.spacing.small
|
|
||||||
implicitHeight: pairCol.implicitHeight + Theme.spacing.medium * 2
|
|
||||||
radius: Theme.spacing.radiusLarge
|
|
||||||
color: Theme.palette.backgroundTertiary
|
|
||||||
border.width: 1
|
|
||||||
border.color: Theme.palette.borderSecondary
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
id: pairCol
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.spacing.medium
|
|
||||||
spacing: Theme.spacing.small
|
|
||||||
|
|
||||||
LogosText {
|
|
||||||
text: qsTr("Selected pair")
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.textSecondary
|
|
||||||
}
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
LogosText {
|
|
||||||
text: qsTr("Token A")
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.textSecondary
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
LogosText {
|
|
||||||
text: root.truncated(tokenAField.text)
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
LogosText {
|
|
||||||
text: qsTr("Token B")
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.textSecondary
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
LogosText {
|
|
||||||
text: root.truncated(tokenBField.text)
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Amount inputs, one per token.
|
|
||||||
LogosText {
|
|
||||||
Layout.topMargin: Theme.spacing.small
|
|
||||||
text: qsTr("Token A amount")
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.textSecondary
|
|
||||||
}
|
|
||||||
LogosTextField {
|
|
||||||
id: amountAField
|
|
||||||
Layout.fillWidth: true
|
|
||||||
placeholderText: "0.0"
|
|
||||||
Component.onCompleted: textInput.validator = amountValidator
|
|
||||||
}
|
|
||||||
|
|
||||||
LogosText {
|
|
||||||
Layout.topMargin: Theme.spacing.small
|
|
||||||
text: qsTr("Token B amount")
|
|
||||||
font.pixelSize: Theme.typography.secondaryText
|
|
||||||
color: Theme.palette.textSecondary
|
|
||||||
}
|
|
||||||
LogosTextField {
|
|
||||||
id: amountBField
|
|
||||||
Layout.fillWidth: true
|
|
||||||
placeholderText: "0.0"
|
|
||||||
Component.onCompleted: textInput.validator = amountValidator
|
|
||||||
}
|
|
||||||
|
|
||||||
LogosButton {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.topMargin: Theme.spacing.medium
|
|
||||||
height: 44
|
|
||||||
text: qsTr("Create pool")
|
|
||||||
enabled: parseFloat(amountAField.text) > 0
|
|
||||||
&& parseFloat(amountBField.text) > 0
|
|
||||||
// Wiring to the AMM new_definition instruction is a follow-up.
|
|
||||||
onClicked: console.log("create pool",
|
|
||||||
tokenAField.text, amountAField.text,
|
|
||||||
tokenBField.text, amountBField.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,56 +1,35 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQml
|
import QtQml
|
||||||
|
import Logos.Wallet
|
||||||
|
|
||||||
import "../components/liquidity"
|
import "../components/liquidity"
|
||||||
|
import "../state"
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property var backend: null
|
property var backend: null
|
||||||
property var newPositionContext: ({
|
property var runtime: null
|
||||||
"schema": "new-position.v1",
|
readonly property NewPositionFlow flow: newPositionFlow
|
||||||
"status": "loading",
|
|
||||||
"tokens": [],
|
|
||||||
"feeTiers": []
|
|
||||||
})
|
|
||||||
property var newPositionQuote: ({})
|
|
||||||
property var resolvedTokenIds: []
|
|
||||||
property int quoteSerial: 0
|
|
||||||
property bool componentReady: false
|
|
||||||
property bool contextLoading: false
|
|
||||||
property bool quoteLoading: false
|
|
||||||
property bool quoteStale: true
|
|
||||||
property bool submitting: false
|
|
||||||
property bool refreshingAfterSuccess: false
|
|
||||||
property string transactionId: ""
|
|
||||||
property string refreshWarning: ""
|
|
||||||
property string lastContextJson: ""
|
|
||||||
|
|
||||||
readonly property int pageMargin: 16
|
readonly property int pageMargin: 16
|
||||||
readonly property int preferredWidth: 800
|
readonly property int preferredWidth: 480
|
||||||
|
|
||||||
Component.onCompleted: {
|
AmmTheme {
|
||||||
root.componentReady = true
|
id: theme
|
||||||
root.refreshNewPositionContext()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onBackendChanged: {
|
NewPositionFlow {
|
||||||
if (root.componentReady)
|
id: newPositionFlow
|
||||||
root.refreshNewPositionContext()
|
|
||||||
}
|
|
||||||
|
|
||||||
Connections {
|
backend: root.backend
|
||||||
target: root.backend
|
runtime: root.runtime
|
||||||
ignoreUnknownSignals: true
|
active: root.visible
|
||||||
|
|
||||||
function onNewPositionContextChanged(value) {
|
|
||||||
root.adoptContext(value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
color: "#151515"
|
color: theme.colors.background
|
||||||
}
|
}
|
||||||
|
|
||||||
Flickable {
|
Flickable {
|
||||||
@ -60,7 +39,7 @@ Item {
|
|||||||
clip: true
|
clip: true
|
||||||
contentWidth: width
|
contentWidth: width
|
||||||
contentHeight: Math.max(height, form.y + form.implicitHeight + root.pageMargin)
|
contentHeight: Math.max(height, form.y + form.implicitHeight + root.pageMargin)
|
||||||
enabled: !confirmationDialog.open
|
enabled: !confirmationDialog.opened
|
||||||
flickableDirection: Flickable.VerticalFlick
|
flickableDirection: Flickable.VerticalFlick
|
||||||
|
|
||||||
NewPositionForm {
|
NewPositionForm {
|
||||||
@ -69,18 +48,12 @@ Item {
|
|||||||
x: Math.max(root.pageMargin, (scroll.width - width) / 2)
|
x: Math.max(root.pageMargin, (scroll.width - width) / 2)
|
||||||
y: root.pageMargin
|
y: root.pageMargin
|
||||||
width: Math.max(0, Math.min(root.preferredWidth, scroll.width - root.pageMargin * 2))
|
width: Math.max(0, Math.min(root.preferredWidth, scroll.width - root.pageMargin * 2))
|
||||||
|
theme: theme
|
||||||
|
newPositionContext: newPositionFlow.newPositionContext
|
||||||
|
flowState: newPositionFlow.viewState
|
||||||
|
|
||||||
newPositionContext: root.newPositionContext
|
onQuoteRequested: function(immediate, quoteRequest) {
|
||||||
quotePayload: root.newPositionQuote
|
newPositionFlow.scheduleQuote(immediate, quoteRequest)
|
||||||
contextLoading: root.contextLoading
|
|
||||||
quoteLoading: root.quoteLoading
|
|
||||||
quoteStale: root.quoteStale
|
|
||||||
submitting: root.submitting
|
|
||||||
transactionId: root.transactionId
|
|
||||||
refreshWarning: root.refreshWarning
|
|
||||||
|
|
||||||
onQuoteRequested: function(immediate) {
|
|
||||||
root.scheduleQuote(immediate)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onConfirmationRequested: function(snapshot) {
|
onConfirmationRequested: function(snapshot) {
|
||||||
@ -88,217 +61,51 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onTokenResolveRequested: function(tokenId) {
|
onTokenResolveRequested: function(tokenId) {
|
||||||
root.resolveToken(tokenId)
|
newPositionFlow.resolveToken(tokenId)
|
||||||
}
|
}
|
||||||
|
|
||||||
onDraftChanged: {
|
onDraftChanged: newPositionFlow.draftChanged()
|
||||||
root.transactionId = ""
|
onRefreshRequested: newPositionFlow.refreshContext(true)
|
||||||
root.refreshWarning = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
onRefreshRequested: root.refreshNewPositionContext()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
Connections {
|
||||||
id: quoteDebounce
|
target: newPositionFlow
|
||||||
interval: 250
|
|
||||||
repeat: false
|
function onTokenResolutionFinished(finalResponse) {
|
||||||
onTriggered: root.requestQuoteNow(root.quoteSerial)
|
form.finishTokenResolution(finalResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTokenResolutionFailed(code) {
|
||||||
|
form.failTokenResolution(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPoolActivated(quote) {
|
||||||
|
form.acceptPoolActivation(quote)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQuoteRefreshRequested(immediate) {
|
||||||
|
form.requestQuote(immediate)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
NewPositionConfirmationDialog {
|
Component {
|
||||||
|
id: liquidityConfirmationSummary
|
||||||
|
|
||||||
|
LiquidityConfirmationSummary { }
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionConfirmationDialog {
|
||||||
id: confirmationDialog
|
id: confirmationDialog
|
||||||
|
|
||||||
anchors.fill: parent
|
title: qsTr("Confirm new position")
|
||||||
busy: root.submitting
|
confirmText: qsTr("Submit")
|
||||||
|
busy: newPositionFlow.submitting
|
||||||
|
summary: liquidityConfirmationSummary
|
||||||
|
|
||||||
onConfirmed: function(snapshot) {
|
onConfirmed: function(snapshot) {
|
||||||
root.confirmNewPosition(snapshot)
|
newPositionFlow.confirm(snapshot)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function contextHints() {
|
|
||||||
var recent = []
|
|
||||||
if (form.selectedTokenAId.length > 0)
|
|
||||||
recent.push(form.selectedTokenAId)
|
|
||||||
if (form.selectedTokenBId.length > 0
|
|
||||||
&& form.selectedTokenBId !== form.selectedTokenAId) {
|
|
||||||
recent.push(form.selectedTokenBId)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"recentTokenIds": recent,
|
|
||||||
"resolvedTokenIds": root.resolvedTokenIds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshNewPositionContext() {
|
|
||||||
root.contextLoading = true
|
|
||||||
if (!root.backend || typeof logos === "undefined") {
|
|
||||||
root.contextLoading = false
|
|
||||||
root.newPositionContext = root.contextError("wallet_unavailable")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logos.watch(root.backend.refreshNewPositionContext(root.contextHints()),
|
|
||||||
function(context) {
|
|
||||||
root.adoptContext(context)
|
|
||||||
if (root.refreshingAfterSuccess) {
|
|
||||||
root.refreshingAfterSuccess = false
|
|
||||||
root.refreshWarning = context.status === "ready" || context.status === "no_wallet"
|
|
||||||
? ""
|
|
||||||
: qsTr("Balances could not be refreshed.")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
function(error) {
|
|
||||||
root.contextLoading = false
|
|
||||||
if (root.refreshingAfterSuccess) {
|
|
||||||
root.refreshingAfterSuccess = false
|
|
||||||
root.refreshWarning = qsTr("Balances could not be refreshed.")
|
|
||||||
} else {
|
|
||||||
root.newPositionContext = root.contextError("backend_error")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function adoptContext(context) {
|
|
||||||
if (!context || context.schema !== "new-position.v1")
|
|
||||||
context = root.contextError("unsupported_schema")
|
|
||||||
var serialized = JSON.stringify(context)
|
|
||||||
root.contextLoading = false
|
|
||||||
if (serialized === root.lastContextJson)
|
|
||||||
return
|
|
||||||
root.lastContextJson = serialized
|
|
||||||
root.newPositionContext = context
|
|
||||||
root.quoteStale = true
|
|
||||||
++root.quoteSerial
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveToken(tokenId) {
|
|
||||||
var value = String(tokenId || "").trim()
|
|
||||||
if (value.length === 0)
|
|
||||||
return
|
|
||||||
if (root.resolvedTokenIds.indexOf(value) < 0) {
|
|
||||||
var next = root.resolvedTokenIds.slice(0)
|
|
||||||
next.push(value)
|
|
||||||
root.resolvedTokenIds = next
|
|
||||||
}
|
|
||||||
root.refreshNewPositionContext()
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleQuote(immediate) {
|
|
||||||
++root.quoteSerial
|
|
||||||
root.quoteStale = true
|
|
||||||
root.quoteLoading = true
|
|
||||||
quoteDebounce.stop()
|
|
||||||
if (immediate)
|
|
||||||
root.requestQuoteNow(root.quoteSerial)
|
|
||||||
else
|
|
||||||
quoteDebounce.restart()
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestQuoteNow(serial) {
|
|
||||||
if (serial !== root.quoteSerial)
|
|
||||||
return
|
|
||||||
var built = form.buildQuoteRequest()
|
|
||||||
if (!built.ok) {
|
|
||||||
root.quoteLoading = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!root.backend || typeof logos === "undefined") {
|
|
||||||
root.quoteLoading = false
|
|
||||||
root.newPositionQuote = root.quoteError("wallet_unavailable")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logos.watch(root.backend.quoteNewPosition(built.request),
|
|
||||||
function(quote) {
|
|
||||||
if (serial !== root.quoteSerial)
|
|
||||||
return
|
|
||||||
root.quoteLoading = false
|
|
||||||
root.quoteStale = false
|
|
||||||
if (!quote || quote.schema !== "new-position.v1")
|
|
||||||
root.newPositionQuote = root.quoteError("unsupported_schema")
|
|
||||||
else
|
|
||||||
root.newPositionQuote = quote
|
|
||||||
},
|
|
||||||
function(error) {
|
|
||||||
if (serial !== root.quoteSerial)
|
|
||||||
return
|
|
||||||
root.quoteLoading = false
|
|
||||||
root.quoteStale = true
|
|
||||||
form.submitError = qsTr("Quote request failed. Refresh and retry.")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmNewPosition(snapshot) {
|
|
||||||
if (root.submitting)
|
|
||||||
return
|
|
||||||
root.submitting = true
|
|
||||||
form.submitError = ""
|
|
||||||
|
|
||||||
if (!root.backend || typeof logos === "undefined") {
|
|
||||||
root.finishSubmitFailure(root.quoteError("wallet_unavailable"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logos.watch(root.backend.submitNewPosition(snapshot.request, snapshot.quoteHash),
|
|
||||||
function(result) {
|
|
||||||
if (result && result.schema === "new-position.v1"
|
|
||||||
&& result.status === "submitted"
|
|
||||||
&& /^[0-9a-f]{64}$/.test(String(result.transactionId || ""))) {
|
|
||||||
root.submitting = false
|
|
||||||
confirmationDialog.closeAfterSuccess()
|
|
||||||
root.transactionId = result.transactionId
|
|
||||||
root.refreshWarning = ""
|
|
||||||
form.resetAfterSubmit()
|
|
||||||
root.newPositionQuote = ({})
|
|
||||||
root.quoteStale = true
|
|
||||||
root.refreshingAfterSuccess = true
|
|
||||||
root.refreshNewPositionContext()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed"))
|
|
||||||
},
|
|
||||||
function(error) {
|
|
||||||
root.finishSubmitFailure(root.quoteError("wallet_submission_failed"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function finishSubmitFailure(result) {
|
|
||||||
root.submitting = false
|
|
||||||
confirmationDialog.closeAfterFailure()
|
|
||||||
if (result && result.quote && result.quote.schema === "new-position.v1")
|
|
||||||
root.newPositionQuote = result.quote
|
|
||||||
var code = result && result.code ? result.code : "wallet_submission_failed"
|
|
||||||
form.submitError = form.issueText(code)
|
|
||||||
root.scheduleQuote(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function contextError(code) {
|
|
||||||
return {
|
|
||||||
"schema": "new-position.v1",
|
|
||||||
"status": "error",
|
|
||||||
"code": code,
|
|
||||||
"tokens": [],
|
|
||||||
"feeTiers": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function quoteError(code) {
|
|
||||||
return {
|
|
||||||
"schema": "new-position.v1",
|
|
||||||
"status": "error",
|
|
||||||
"canSubmit": false,
|
|
||||||
"code": code,
|
|
||||||
"poolStatus": "unavailable_pool",
|
|
||||||
"errors": [{
|
|
||||||
"code": code,
|
|
||||||
"blockingFields": [],
|
|
||||||
"details": ({})
|
|
||||||
}],
|
|
||||||
"warnings": [],
|
|
||||||
"accountPreview": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(/[.]$/, "") + "%";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
375
apps/amm/qml/state/NewPositionFlow.qml
Normal file
375
apps/amm/qml/state/NewPositionFlow.qml
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
import QtQml
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var backend: null
|
||||||
|
property var runtime: null
|
||||||
|
property bool active: false
|
||||||
|
|
||||||
|
readonly property bool walletStateReady: root.backend !== null
|
||||||
|
&& root.backend.walletStateReady === true
|
||||||
|
readonly property var newPositionContext: root.walletStateReady
|
||||||
|
&& root.backend.newPositionContext
|
||||||
|
? root.backend.newPositionContext
|
||||||
|
: root.loadingContext()
|
||||||
|
readonly property var viewState: ({
|
||||||
|
"quote": root.newPositionQuote,
|
||||||
|
"contextLoading": root.contextLoading || !root.walletStateReady
|
||||||
|
|| root.newPositionContext.status === "loading",
|
||||||
|
"quoteLoading": root.quoteLoading,
|
||||||
|
"quoteStale": root.quoteStale,
|
||||||
|
"submitting": root.submitting,
|
||||||
|
"poolCreationPending": root.selectedPoolCreationPending(),
|
||||||
|
"transactionId": root.transactionId,
|
||||||
|
"errorCode": root.flowErrorCode || root.contextErrorCode
|
||||||
|
|| root.quoteErrorCode
|
||||||
|
})
|
||||||
|
|
||||||
|
property var newPositionQuote: ({})
|
||||||
|
property var resolvedTokenIds: []
|
||||||
|
property int contextSerial: 0
|
||||||
|
property int quoteSerial: 0
|
||||||
|
property bool contextLoading: false
|
||||||
|
property bool quoteLoading: false
|
||||||
|
property bool quoteStale: true
|
||||||
|
property bool submitting: false
|
||||||
|
property string transactionId: ""
|
||||||
|
property string flowErrorCode: ""
|
||||||
|
property string contextErrorCode: ""
|
||||||
|
property string quoteErrorCode: ""
|
||||||
|
property var pendingQuoteRequest: ({ "ok": false, "request": ({}) })
|
||||||
|
property var pendingPoolProbes: []
|
||||||
|
property bool poolProbeInFlight: false
|
||||||
|
|
||||||
|
signal tokenResolutionFinished(bool finalResponse)
|
||||||
|
signal tokenResolutionFailed(string code)
|
||||||
|
signal poolActivated(var quote)
|
||||||
|
signal quoteRefreshRequested(bool immediate)
|
||||||
|
signal submitSucceeded
|
||||||
|
signal submitFailed
|
||||||
|
|
||||||
|
objectName: "newPositionFlow"
|
||||||
|
|
||||||
|
property Timer quoteDebounce: Timer {
|
||||||
|
interval: 250
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.requestQuoteNow(root.quoteSerial)
|
||||||
|
}
|
||||||
|
|
||||||
|
property Timer poolPoller: Timer {
|
||||||
|
interval: 5000
|
||||||
|
repeat: true
|
||||||
|
running: root.pendingPoolProbes.length > 0
|
||||||
|
onTriggered: root.pollPendingPool()
|
||||||
|
}
|
||||||
|
|
||||||
|
onNewPositionContextChanged: root.invalidateQuote()
|
||||||
|
|
||||||
|
onWalletStateReadyChanged: {
|
||||||
|
++root.contextSerial
|
||||||
|
if (!root.walletStateReady)
|
||||||
|
root.contextLoading = false
|
||||||
|
root.invalidateQuote()
|
||||||
|
}
|
||||||
|
|
||||||
|
onActiveChanged: {
|
||||||
|
if (!root.active)
|
||||||
|
return
|
||||||
|
Qt.callLater(function() {
|
||||||
|
if (root.active && root.walletStateReady)
|
||||||
|
root.quoteRefreshRequested(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextHints(refreshWalletAccounts) {
|
||||||
|
const request = root.pendingQuoteRequest.request || {}
|
||||||
|
const recent = []
|
||||||
|
if (request.tokenAId)
|
||||||
|
recent.push(request.tokenAId)
|
||||||
|
if (request.tokenBId && request.tokenBId !== request.tokenAId)
|
||||||
|
recent.push(request.tokenBId)
|
||||||
|
return {
|
||||||
|
"recentTokenIds": recent,
|
||||||
|
"resolvedTokenIds": root.resolvedTokenIds,
|
||||||
|
"refreshWalletAccounts": refreshWalletAccounts === true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshContext(refreshWalletAccounts, completed) {
|
||||||
|
const serial = ++root.contextSerial
|
||||||
|
root.contextLoading = true
|
||||||
|
if (!root.walletStateReady || root.runtime === null) {
|
||||||
|
root.contextLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
root.runtime.watch(root.backend.refreshNewPositionContext(
|
||||||
|
root.contextHints(refreshWalletAccounts)),
|
||||||
|
function() {
|
||||||
|
root.finishContextRefresh(serial, completed)
|
||||||
|
},
|
||||||
|
function(error) {
|
||||||
|
root.failContextRefresh(serial)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishContextRefresh(serial, completed) {
|
||||||
|
if (serial !== root.contextSerial)
|
||||||
|
return
|
||||||
|
root.contextLoading = false
|
||||||
|
root.contextErrorCode = ""
|
||||||
|
Qt.callLater(function() {
|
||||||
|
if (serial !== root.contextSerial)
|
||||||
|
return
|
||||||
|
root.tokenResolutionFinished(true)
|
||||||
|
if (completed)
|
||||||
|
completed()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function failContextRefresh(serial) {
|
||||||
|
if (serial !== root.contextSerial)
|
||||||
|
return
|
||||||
|
root.contextLoading = false
|
||||||
|
root.contextErrorCode = "backend_error"
|
||||||
|
root.tokenResolutionFailed("backend_error")
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveToken(tokenId) {
|
||||||
|
const value = String(tokenId || "").trim()
|
||||||
|
if (value.length === 0)
|
||||||
|
return
|
||||||
|
if (root.resolvedTokenIds.indexOf(value) < 0) {
|
||||||
|
const next = root.resolvedTokenIds.slice(0)
|
||||||
|
next.push(value)
|
||||||
|
root.resolvedTokenIds = next
|
||||||
|
}
|
||||||
|
root.refreshContext(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleQuote(immediate, quoteRequest) {
|
||||||
|
++root.quoteSerial
|
||||||
|
root.pendingQuoteRequest = quoteRequest
|
||||||
|
root.quoteStale = true
|
||||||
|
root.quoteLoading = root.walletStateReady && root.active
|
||||||
|
root.quoteDebounce.stop()
|
||||||
|
if (!root.walletStateReady || !root.active)
|
||||||
|
return
|
||||||
|
if (immediate)
|
||||||
|
root.requestQuoteNow(root.quoteSerial)
|
||||||
|
else
|
||||||
|
root.quoteDebounce.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestQuoteNow(serial) {
|
||||||
|
if (serial !== root.quoteSerial)
|
||||||
|
return
|
||||||
|
const built = root.pendingQuoteRequest
|
||||||
|
if (!built.ok) {
|
||||||
|
root.quoteLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!root.walletStateReady || !root.active || root.runtime === null) {
|
||||||
|
root.quoteLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
root.runtime.watch(root.backend.quoteNewPosition(built.request),
|
||||||
|
function(quote) {
|
||||||
|
if (serial !== root.quoteSerial)
|
||||||
|
return
|
||||||
|
root.quoteLoading = false
|
||||||
|
root.quoteStale = false
|
||||||
|
root.quoteErrorCode = ""
|
||||||
|
if (!quote || quote.schema !== "new-position.v1")
|
||||||
|
root.newPositionQuote = root.quoteError("unsupported_schema")
|
||||||
|
else
|
||||||
|
root.newPositionQuote = quote
|
||||||
|
},
|
||||||
|
function(error) {
|
||||||
|
if (serial !== root.quoteSerial)
|
||||||
|
return
|
||||||
|
root.quoteLoading = false
|
||||||
|
root.quoteStale = true
|
||||||
|
root.quoteErrorCode = "backend_error"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm(snapshot) {
|
||||||
|
if (root.submitting)
|
||||||
|
return
|
||||||
|
root.submitting = true
|
||||||
|
root.flowErrorCode = ""
|
||||||
|
|
||||||
|
if (!root.backend || root.runtime === null) {
|
||||||
|
root.finishSubmitFailure(root.quoteError("wallet_unavailable"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
root.runtime.watch(root.backend.submitNewPosition(snapshot.request, snapshot.quoteHash),
|
||||||
|
function(result) {
|
||||||
|
if (result && result.schema === "new-position.v1"
|
||||||
|
&& result.status === "submitted"
|
||||||
|
&& /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(
|
||||||
|
String(result.transactionId || ""))) {
|
||||||
|
if (snapshot.request.initialPriceRealRaw !== undefined)
|
||||||
|
root.watchPoolCreation(snapshot.poolProbeRequest, result.deadlineMs)
|
||||||
|
root.submitting = false
|
||||||
|
root.transactionId = result.transactionId
|
||||||
|
root.flowErrorCode = ""
|
||||||
|
root.contextErrorCode = ""
|
||||||
|
root.quoteErrorCode = ""
|
||||||
|
root.invalidateQuote()
|
||||||
|
root.submitSucceeded()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed"))
|
||||||
|
},
|
||||||
|
function(error) {
|
||||||
|
root.finishSubmitFailure(root.quoteError("wallet_submission_failed"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishSubmitFailure(result) {
|
||||||
|
root.submitting = false
|
||||||
|
const hasFreshQuote = result && result.quote
|
||||||
|
&& result.quote.schema === "new-position.v1"
|
||||||
|
if (hasFreshQuote) {
|
||||||
|
root.newPositionQuote = result.quote
|
||||||
|
root.quoteLoading = false
|
||||||
|
root.quoteStale = false
|
||||||
|
}
|
||||||
|
const code = result && result.code ? result.code : "wallet_submission_failed"
|
||||||
|
root.flowErrorCode = code
|
||||||
|
root.submitFailed()
|
||||||
|
if (hasFreshQuote)
|
||||||
|
return
|
||||||
|
root.scheduleQuote(true, root.pendingQuoteRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchPoolCreation(request, deadlineMs) {
|
||||||
|
const key = root.pairKey(request)
|
||||||
|
let deadline = Number(deadlineMs)
|
||||||
|
if (!isFinite(deadline) || deadline <= 0)
|
||||||
|
deadline = 0
|
||||||
|
const pending = root.pendingPoolProbes.filter(function(item) {
|
||||||
|
return item.key !== key
|
||||||
|
})
|
||||||
|
pending.push({
|
||||||
|
"key": key,
|
||||||
|
"request": request,
|
||||||
|
"deadlineMs": deadline
|
||||||
|
})
|
||||||
|
root.pendingPoolProbes = pending
|
||||||
|
Qt.callLater(root.pollPendingPool)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollPendingPool() {
|
||||||
|
if (root.poolProbeInFlight || root.pendingPoolProbes.length === 0
|
||||||
|
|| !root.walletStateReady || root.runtime === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pending = root.pendingPoolProbes[0]
|
||||||
|
root.poolProbeInFlight = true
|
||||||
|
root.runtime.watch(root.backend.quoteNewPosition(pending.request),
|
||||||
|
function(quote) {
|
||||||
|
root.finishPoolProbe(pending, quote)
|
||||||
|
},
|
||||||
|
function(error) {
|
||||||
|
root.finishPoolProbe(pending, null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishPoolProbe(pending, quote) {
|
||||||
|
root.poolProbeInFlight = false
|
||||||
|
if (quote && quote.schema === "new-position.v1"
|
||||||
|
&& quote.poolStatus === "active_pool") {
|
||||||
|
root.removePendingPool(pending.key)
|
||||||
|
if (root.matchesSelectedPair(pending.request)) {
|
||||||
|
root.poolActivated(quote)
|
||||||
|
root.invalidateQuote()
|
||||||
|
root.refreshContext(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pending.deadlineMs > 0 && Date.now() >= pending.deadlineMs) {
|
||||||
|
root.removePendingPool(pending.key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.rotatePendingPool()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pairKey(request) {
|
||||||
|
return String(request.tokenAId || "") + ":" + String(request.tokenBId || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesSelectedPair(request) {
|
||||||
|
return root.pairKey(root.pendingQuoteRequest.request || {}) === root.pairKey(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedPoolCreationPending() {
|
||||||
|
const request = root.pendingQuoteRequest.request || {}
|
||||||
|
if (!request.tokenAId || !request.tokenBId)
|
||||||
|
return false
|
||||||
|
const selected = root.pairKey(request)
|
||||||
|
return root.pendingPoolProbes.some(function(item) {
|
||||||
|
return item.key === selected
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePendingPool(key) {
|
||||||
|
root.pendingPoolProbes = root.pendingPoolProbes.filter(function(item) {
|
||||||
|
return item.key !== key
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotatePendingPool() {
|
||||||
|
if (root.pendingPoolProbes.length < 2)
|
||||||
|
return
|
||||||
|
const pending = root.pendingPoolProbes.slice(1)
|
||||||
|
pending.push(root.pendingPoolProbes[0])
|
||||||
|
root.pendingPoolProbes = pending
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftChanged() {
|
||||||
|
root.invalidateQuote()
|
||||||
|
root.transactionId = ""
|
||||||
|
root.flowErrorCode = ""
|
||||||
|
root.contextErrorCode = ""
|
||||||
|
root.quoteErrorCode = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateQuote() {
|
||||||
|
++root.quoteSerial
|
||||||
|
root.quoteDebounce.stop()
|
||||||
|
root.quoteLoading = false
|
||||||
|
root.quoteStale = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadingContext() {
|
||||||
|
return {
|
||||||
|
"schema": "new-position.v1",
|
||||||
|
"status": "loading",
|
||||||
|
"tokens": [],
|
||||||
|
"feeTiers": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteError(code) {
|
||||||
|
return {
|
||||||
|
"schema": "new-position.v1",
|
||||||
|
"status": "error",
|
||||||
|
"canSubmit": false,
|
||||||
|
"code": code,
|
||||||
|
"poolStatus": "unavailable_pool",
|
||||||
|
"errors": [{
|
||||||
|
"code": code,
|
||||||
|
"blockingFields": [],
|
||||||
|
"details": ({})
|
||||||
|
}],
|
||||||
|
"warnings": [],
|
||||||
|
"accountPreview": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
apps/amm/src/ActiveNetwork.cpp
Normal file
141
apps/amm/src/ActiveNetwork.cpp
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
#include "ActiveNetwork.h"
|
||||||
|
|
||||||
|
#include <QFile>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
const char NETWORK_ENV[] = "AMM_UI_NETWORK";
|
||||||
|
const char DEVNET_FILE_ENV[] = "AMM_UI_DEVNET_FILE";
|
||||||
|
|
||||||
|
bool isLowerHex(const QString& value, int size)
|
||||||
|
{
|
||||||
|
if (value.size() != size)
|
||||||
|
return false;
|
||||||
|
for (const QChar character : value) {
|
||||||
|
const bool isAsciiDigit = character >= QLatin1Char('0')
|
||||||
|
&& character <= QLatin1Char('9');
|
||||||
|
if (!isAsciiDigit
|
||||||
|
&& (character < QLatin1Char('a') || character > QLatin1Char('f'))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ActiveNetwork::load()
|
||||||
|
{
|
||||||
|
m_network = {};
|
||||||
|
m_network.status = QStringLiteral("config_missing");
|
||||||
|
m_expectedIdentity.clear();
|
||||||
|
|
||||||
|
const QByteArray selected = qgetenv(NETWORK_ENV);
|
||||||
|
m_network.id = selected.isEmpty()
|
||||||
|
? QStringLiteral("testnet")
|
||||||
|
: QString::fromLocal8Bit(selected).trimmed();
|
||||||
|
|
||||||
|
QJsonObject entry;
|
||||||
|
if (isDevnet()) {
|
||||||
|
const QString path = QString::fromLocal8Bit(qgetenv(DEVNET_FILE_ENV));
|
||||||
|
QFile file(path);
|
||||||
|
if (path.isEmpty() || !file.open(QIODevice::ReadOnly))
|
||||||
|
return false;
|
||||||
|
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
|
||||||
|
if (!document.isObject())
|
||||||
|
return false;
|
||||||
|
entry = document.object();
|
||||||
|
m_expectedIdentity = entry.value(QStringLiteral("channelId")).toString();
|
||||||
|
} else {
|
||||||
|
QFile file(QStringLiteral(":/amm/config/networks.json"));
|
||||||
|
if (!file.open(QIODevice::ReadOnly))
|
||||||
|
return false;
|
||||||
|
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
|
||||||
|
if (!document.isObject())
|
||||||
|
return false;
|
||||||
|
const QJsonObject networks = document.object();
|
||||||
|
entry = networks.value(m_network.id).toObject();
|
||||||
|
m_expectedIdentity = entry.value(QStringLiteral("checkpointHash")).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
m_network.ammProgramId = entry.value(QStringLiteral("ammProgramId")).toString();
|
||||||
|
if (!isValidIdentity(m_expectedIdentity)
|
||||||
|
|| !isLowerHex(m_network.ammProgramId, 64)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const QJsonValue& value : entry.value(QStringLiteral("tokenDefinitionIds")).toArray()) {
|
||||||
|
const QString id = value.toString();
|
||||||
|
if (!isLowerHex(id, 64)) {
|
||||||
|
m_network.tokenIds.clear();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
m_network.tokenIds.append(id);
|
||||||
|
}
|
||||||
|
m_network.status = QStringLiteral("network_unknown");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ActiveNetwork::isConfigured() const
|
||||||
|
{
|
||||||
|
return m_network.status != QStringLiteral("config_missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ActiveNetwork::isDevnet() const
|
||||||
|
{
|
||||||
|
return m_network.id == QStringLiteral("devnet");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ActiveNetwork::needsIdentityProbe() const
|
||||||
|
{
|
||||||
|
return m_network.status == QStringLiteral("loading")
|
||||||
|
|| m_network.status == QStringLiteral("network_unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
void ActiveNetwork::sequencerChanged(bool available)
|
||||||
|
{
|
||||||
|
if (isConfigured())
|
||||||
|
clearIdentity(available ? QStringLiteral("loading") : QStringLiteral("network_unknown"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ActiveNetwork::reachabilityChanged(bool reachable, bool wasReachable)
|
||||||
|
{
|
||||||
|
if (!isConfigured())
|
||||||
|
return;
|
||||||
|
if (!reachable)
|
||||||
|
clearIdentity(QStringLiteral("network_unknown"));
|
||||||
|
else if (!wasReachable)
|
||||||
|
clearIdentity(QStringLiteral("loading"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ActiveNetwork::beginIdentityProbe()
|
||||||
|
{
|
||||||
|
if (isConfigured())
|
||||||
|
clearIdentity(QStringLiteral("loading"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ActiveNetwork::finishIdentityProbe(const QString& identity)
|
||||||
|
{
|
||||||
|
if (identity.isEmpty()) {
|
||||||
|
clearIdentity(QStringLiteral("network_unknown"));
|
||||||
|
} else if (identity != m_expectedIdentity) {
|
||||||
|
clearIdentity(QStringLiteral("network_mismatch"));
|
||||||
|
} else {
|
||||||
|
m_network.status = QStringLiteral("ready");
|
||||||
|
m_network.fingerprint = (isDevnet() ? QStringLiteral("channel:")
|
||||||
|
: QStringLiteral("block10:"))
|
||||||
|
+ identity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ActiveNetwork::isValidIdentity(const QString& value)
|
||||||
|
{
|
||||||
|
return isLowerHex(value, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ActiveNetwork::clearIdentity(const QString& status)
|
||||||
|
{
|
||||||
|
m_network.status = status;
|
||||||
|
m_network.fingerprint.clear();
|
||||||
|
}
|
||||||
36
apps/amm/src/ActiveNetwork.h
Normal file
36
apps/amm/src/ActiveNetwork.h
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
struct ActiveNetworkSnapshot {
|
||||||
|
QString id;
|
||||||
|
QString status;
|
||||||
|
QString fingerprint;
|
||||||
|
QString ammProgramId;
|
||||||
|
QStringList tokenIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ActiveNetwork final {
|
||||||
|
public:
|
||||||
|
bool load();
|
||||||
|
|
||||||
|
const QString& status() const { return m_network.status; }
|
||||||
|
bool isConfigured() const;
|
||||||
|
bool isDevnet() const;
|
||||||
|
bool needsIdentityProbe() const;
|
||||||
|
ActiveNetworkSnapshot snapshot() const { return m_network; }
|
||||||
|
|
||||||
|
void sequencerChanged(bool available);
|
||||||
|
void reachabilityChanged(bool reachable, bool wasReachable);
|
||||||
|
void beginIdentityProbe();
|
||||||
|
void finishIdentityProbe(const QString& identity);
|
||||||
|
|
||||||
|
static bool isValidIdentity(const QString& value);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void clearIdentity(const QString& status);
|
||||||
|
|
||||||
|
ActiveNetworkSnapshot m_network;
|
||||||
|
QString m_expectedIdentity;
|
||||||
|
};
|
||||||
71
apps/amm/src/AmmClient.cpp
Normal file
71
apps/amm/src/AmmClient.cpp
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
#include "AmmClient.h"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonParseError>
|
||||||
|
|
||||||
|
#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);
|
||||||
|
}
|
||||||
30
apps/amm/src/AmmClient.h
Normal file
30
apps/amm/src/AmmClient.h
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
struct AmmClientResult {
|
||||||
|
bool ok = false;
|
||||||
|
QJsonObject value;
|
||||||
|
};
|
||||||
|
|
||||||
|
class AmmClient {
|
||||||
|
public:
|
||||||
|
virtual ~AmmClient() = default;
|
||||||
|
|
||||||
|
virtual AmmClientResult configId(const QJsonObject& request) const = 0;
|
||||||
|
virtual AmmClientResult tokenIds(const QJsonObject& request) const = 0;
|
||||||
|
virtual AmmClientResult pairIds(const QJsonObject& request) const = 0;
|
||||||
|
virtual AmmClientResult context(const QJsonObject& request) const = 0;
|
||||||
|
virtual AmmClientResult quote(const QJsonObject& request) const = 0;
|
||||||
|
virtual AmmClientResult plan(const QJsonObject& request) const = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
class BundledAmmClient final : public AmmClient {
|
||||||
|
public:
|
||||||
|
AmmClientResult configId(const QJsonObject& request) const override;
|
||||||
|
AmmClientResult tokenIds(const QJsonObject& request) const override;
|
||||||
|
AmmClientResult pairIds(const QJsonObject& request) const override;
|
||||||
|
AmmClientResult context(const QJsonObject& request) const override;
|
||||||
|
AmmClientResult quote(const QJsonObject& request) const override;
|
||||||
|
AmmClientResult plan(const QJsonObject& request) const override;
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,35 +1,36 @@
|
|||||||
#ifndef AMM_UI_BACKEND_H
|
#ifndef AMM_UI_BACKEND_H
|
||||||
#define AMM_UI_BACKEND_H
|
#define AMM_UI_BACKEND_H
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QJsonArray>
|
|
||||||
#include <QJsonObject>
|
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QStringList>
|
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
|
|
||||||
#include "rep_AmmUiBackend_source.h"
|
#include "rep_AmmUiBackend_source.h"
|
||||||
|
|
||||||
#include "AccountModel.h"
|
#include "ActiveNetwork.h"
|
||||||
|
#include "WalletAccountModel.h"
|
||||||
|
|
||||||
class LogosAPI;
|
class LogosAPI;
|
||||||
struct LogosModules;
|
class AmmClient;
|
||||||
|
class LogosWalletProvider;
|
||||||
|
class NewPositionRuntime;
|
||||||
class QNetworkAccessManager;
|
class QNetworkAccessManager;
|
||||||
class QTimer;
|
class WalletController;
|
||||||
|
|
||||||
// Source-side implementation of the AmmUiBackend .rep interface.
|
// Source-side implementation of the AmmUiBackend .rep interface.
|
||||||
// Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and
|
// Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and
|
||||||
// SLOTs from AmmUiBackend.rep — all the simple ones flow over QtRO. Talks to
|
// SLOTs from AmmUiBackend.rep — all the simple ones flow over QtRO.
|
||||||
// the core logos_execution_zone wallet module via LogosModules.
|
|
||||||
class AmmUiBackend : public AmmUiBackendSimpleSource {
|
class AmmUiBackend : public AmmUiBackendSimpleSource {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
Q_PROPERTY(AccountModel* accountModel READ accountModel CONSTANT)
|
Q_PROPERTY(WalletAccountModel* accountModel READ accountModel CONSTANT)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr);
|
explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr);
|
||||||
~AmmUiBackend() override;
|
~AmmUiBackend() override;
|
||||||
|
|
||||||
AccountModel* accountModel() const { return m_accountModel; }
|
WalletAccountModel* accountModel() const;
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
// Overrides of the pure-virtual slots generated from the .rep.
|
// Overrides of the pure-virtual slots generated from the .rep.
|
||||||
@ -38,7 +39,7 @@ public slots:
|
|||||||
void refreshAccounts() override;
|
void refreshAccounts() override;
|
||||||
void refreshBalances() override;
|
void refreshBalances() override;
|
||||||
QString getBalance(QString accountIdHex, bool isPublic) override;
|
QString getBalance(QString accountIdHex, bool isPublic) override;
|
||||||
QVariantMap refreshNewPositionContext(QVariantMap request) override;
|
void refreshNewPositionContext(QVariantMap request) override;
|
||||||
QVariantMap quoteNewPosition(QVariantMap request) override;
|
QVariantMap quoteNewPosition(QVariantMap request) override;
|
||||||
QVariantMap submitNewPosition(QVariantMap request, QString quoteHash) override;
|
QVariantMap submitNewPosition(QVariantMap request, QString quoteHash) override;
|
||||||
// Return the new wallet's BIP39 mnemonic (empty string on failure) so the
|
// Return the new wallet's BIP39 mnemonic (empty string on failure) so the
|
||||||
@ -47,52 +48,23 @@ public slots:
|
|||||||
QString createNew(QString configPath, QString storagePath, QString password) override;
|
QString createNew(QString configPath, QString storagePath, QString password) override;
|
||||||
bool openExisting() override;
|
bool openExisting() override;
|
||||||
void disconnectWallet() override;
|
void disconnectWallet() override;
|
||||||
bool changeSequencerAddr(QString url) override;
|
|
||||||
void copyToClipboard(QString text) override;
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Per-app wallet home (kept distinct from the wallet's canonical
|
void syncWalletState();
|
||||||
// ~/.lee/wallet so standalone instances stay isolated; Basecamp sharing
|
|
||||||
// is handled by adopting an already-open shared wallet on startup).
|
|
||||||
static QString defaultWalletHome();
|
|
||||||
QString defaultConfigPath() const;
|
|
||||||
QString defaultStoragePath() const;
|
|
||||||
|
|
||||||
void persistConfigPath(const QString& path);
|
|
||||||
void persistStoragePath(const QString& path);
|
|
||||||
void openOrAdoptWallet();
|
|
||||||
// True when the shared core already has a wallet open — including a freshly
|
|
||||||
// created one with zero accounts. See the definition for why list_accounts()
|
|
||||||
// alone is insufficient.
|
|
||||||
bool sharedWalletIsOpen();
|
|
||||||
void refreshBlockHeights();
|
|
||||||
void refreshSequencerAddr();
|
|
||||||
void saveWallet();
|
|
||||||
bool loadNetworkConfig();
|
|
||||||
void probeNetworkIdentity();
|
void probeNetworkIdentity();
|
||||||
QJsonObject readAccount(const QString& accountId) const;
|
void publishNetworkContext();
|
||||||
QJsonArray walletAccountReads() const;
|
|
||||||
QJsonObject buildQuoteInput(const QVariantMap& request, QJsonObject* error) const;
|
|
||||||
|
|
||||||
// Probe the configured sequencer over HTTP and update sequencerReachable.
|
|
||||||
void checkReachability();
|
|
||||||
|
|
||||||
AccountModel* m_accountModel;
|
|
||||||
|
|
||||||
LogosAPI* m_logosAPI;
|
LogosAPI* m_logosAPI;
|
||||||
LogosModules* m_logos;
|
std::unique_ptr<LogosWalletProvider> m_wallet;
|
||||||
|
std::unique_ptr<WalletController> m_walletController;
|
||||||
|
std::unique_ptr<AmmClient> m_ammClient;
|
||||||
|
std::unique_ptr<NewPositionRuntime> m_newPosition;
|
||||||
|
|
||||||
QNetworkAccessManager* m_net;
|
QNetworkAccessManager* m_net;
|
||||||
QTimer* m_reachabilityTimer;
|
|
||||||
|
|
||||||
QString m_networkId;
|
ActiveNetwork m_network;
|
||||||
QString m_networkStatus = QStringLiteral("config_missing");
|
QVariantMap m_newPositionHints;
|
||||||
QString m_networkFingerprint;
|
|
||||||
QString m_expectedNetworkIdentity;
|
|
||||||
QString m_ammProgramId;
|
|
||||||
QStringList m_configuredTokenIds;
|
|
||||||
bool m_identityProbeInFlight = false;
|
bool m_identityProbeInFlight = false;
|
||||||
bool m_submitInFlight = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // AMM_UI_BACKEND_H
|
#endif // AMM_UI_BACKEND_H
|
||||||
|
|||||||
@ -5,6 +5,9 @@
|
|||||||
class AmmUiBackend
|
class AmmUiBackend
|
||||||
{
|
{
|
||||||
PROP(bool isWalletOpen READONLY)
|
PROP(bool isWalletOpen READONLY)
|
||||||
|
// False while startup or reconnect is still resolving wallet state. This
|
||||||
|
// stays distinct from isWalletOpen because a disconnected wallet is ready.
|
||||||
|
PROP(bool walletStateReady READONLY)
|
||||||
PROP(bool walletExists READONLY)
|
PROP(bool walletExists READONLY)
|
||||||
PROP(QString configPath READONLY)
|
PROP(QString configPath READONLY)
|
||||||
PROP(QString storagePath READONLY)
|
PROP(QString storagePath READONLY)
|
||||||
@ -27,12 +30,12 @@ class AmmUiBackend
|
|||||||
// The QVariant payloads are stable maps/lists so the UI never assembles AMM
|
// The QVariant payloads are stable maps/lists so the UI never assembles AMM
|
||||||
// transactions or duplicates quote state.
|
// transactions or duplicates quote state.
|
||||||
PROP(QVariantMap newPositionContext READONLY)
|
PROP(QVariantMap newPositionContext READONLY)
|
||||||
SLOT(QVariantMap refreshNewPositionContext(QVariantMap request))
|
SLOT(void refreshNewPositionContext(QVariantMap request))
|
||||||
SLOT(QVariantMap quoteNewPosition(QVariantMap request))
|
SLOT(QVariantMap quoteNewPosition(QVariantMap request))
|
||||||
SLOT(QVariantMap submitNewPosition(QVariantMap request, QString quoteHash))
|
SLOT(QVariantMap submitNewPosition(QVariantMap request, QString quoteHash))
|
||||||
|
|
||||||
// Wallet lifecycle. createNewDefault() is the happy path: it creates a
|
// 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
|
// 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
|
// 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.
|
// before the wallet is usable — this is the only chance to record it.
|
||||||
@ -44,5 +47,4 @@ class AmmUiBackend
|
|||||||
// Close this app's wallet view (lock); does not delete the wallet and, in
|
// Close this app's wallet view (lock); does not delete the wallet and, in
|
||||||
// Basecamp, does not close the wallet other apps share.
|
// Basecamp, does not close the wallet other apps share.
|
||||||
SLOT(void disconnectWallet())
|
SLOT(void disconnectWallet())
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
390
apps/amm/src/NewPositionRuntime.cpp
Normal file
390
apps/amm/src/NewPositionRuntime.cpp
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
#include "NewPositionRuntime.h"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QScopedValueRollback>
|
||||||
|
#include <libbase58.h>
|
||||||
|
|
||||||
|
#include "AmmClient.h"
|
||||||
|
#include "WalletProvider.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
const char SCHEMA[] = "new-position.v1";
|
||||||
|
constexpr qsizetype HASH_BYTES = 32;
|
||||||
|
constexpr std::size_t BASE58_BUFFER_SIZE = 45;
|
||||||
|
|
||||||
|
QString base58TransactionId(const QString& transactionHash)
|
||||||
|
{
|
||||||
|
const QByteArray hex = transactionHash.toLatin1();
|
||||||
|
const QByteArray bytes = QByteArray::fromHex(hex);
|
||||||
|
if (bytes.size() != HASH_BYTES || bytes.toHex() != hex)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
std::array<char, BASE58_BUFFER_SIZE> encoded {};
|
||||||
|
std::size_t size = encoded.size();
|
||||||
|
if (!b58enc(encoded.data(), &size, bytes.constData(),
|
||||||
|
static_cast<std::size_t>(bytes.size()))) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return QString::fromLatin1(
|
||||||
|
encoded.data(), static_cast<qsizetype>(size - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject issue(const QString& code,
|
||||||
|
const QJsonArray& blockingFields = {})
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
{ QStringLiteral("code"), code },
|
||||||
|
{ QStringLiteral("recoverable"), true },
|
||||||
|
{ QStringLiteral("blockingFields"), blockingFields },
|
||||||
|
{ QStringLiteral("details"), QJsonObject() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject publicError(const QString& code,
|
||||||
|
const QJsonArray& blockingFields = {},
|
||||||
|
const QJsonObject& details = {})
|
||||||
|
{
|
||||||
|
QJsonObject error = issue(code, blockingFields);
|
||||||
|
error.insert(QStringLiteral("details"), details);
|
||||||
|
return {
|
||||||
|
{ QStringLiteral("schema"), QString::fromLatin1(SCHEMA) },
|
||||||
|
{ QStringLiteral("status"), QStringLiteral("error") },
|
||||||
|
{ QStringLiteral("canSubmit"), false },
|
||||||
|
{ QStringLiteral("code"), code },
|
||||||
|
{ QStringLiteral("errors"), QJsonArray { error } },
|
||||||
|
{ QStringLiteral("warnings"), QJsonArray() },
|
||||||
|
{ QStringLiteral("accountPreview"), QJsonArray() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject contextState(const QString& status,
|
||||||
|
const ActiveNetworkSnapshot& network,
|
||||||
|
const QString& code = {})
|
||||||
|
{
|
||||||
|
QJsonObject state {
|
||||||
|
{ QStringLiteral("schema"), QString::fromLatin1(SCHEMA) },
|
||||||
|
{ QStringLiteral("status"), status },
|
||||||
|
{ QStringLiteral("networkId"), network.id },
|
||||||
|
{ QStringLiteral("networkFingerprint"), network.fingerprint },
|
||||||
|
{ QStringLiteral("tokens"), QJsonArray() },
|
||||||
|
{ QStringLiteral("feeTiers"), QJsonArray() },
|
||||||
|
{ QStringLiteral("warnings"), QJsonArray() },
|
||||||
|
};
|
||||||
|
if (!code.isEmpty())
|
||||||
|
state.insert(QStringLiteral("code"), code);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray variantStringArray(const QVariant& value)
|
||||||
|
{
|
||||||
|
QJsonArray result;
|
||||||
|
for (const QVariant& item : value.toList())
|
||||||
|
result.append(item.toString());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList jsonStringList(const QJsonArray& values)
|
||||||
|
{
|
||||||
|
QStringList result;
|
||||||
|
result.reserve(values.size());
|
||||||
|
for (const QJsonValue& value : values)
|
||||||
|
result.append(value.toString());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<bool> jsonBoolList(const QJsonArray& values)
|
||||||
|
{
|
||||||
|
QVector<bool> result;
|
||||||
|
result.reserve(values.size());
|
||||||
|
for (const QJsonValue& value : values)
|
||||||
|
result.append(value.toBool());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<quint32> jsonUIntList(const QJsonArray& values)
|
||||||
|
{
|
||||||
|
QVector<quint32> result;
|
||||||
|
result.reserve(values.size());
|
||||||
|
for (const QJsonValue& value : values)
|
||||||
|
result.append(static_cast<quint32>(value.toInteger()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<WalletAccountRead>& reads)
|
||||||
|
{
|
||||||
|
QJsonArray result;
|
||||||
|
for (const WalletAccountRead& read : reads)
|
||||||
|
result.append(accountReadJson(read));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NewPositionRuntime::NewPositionRuntime(WalletProvider* wallet, AmmClient* client)
|
||||||
|
: m_wallet(wallet),
|
||||||
|
m_client(client)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void NewPositionRuntime::clearWalletAccounts()
|
||||||
|
{
|
||||||
|
m_wallet->clearSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray NewPositionRuntime::walletAccountReads(bool walletOpen, bool refresh) const
|
||||||
|
{
|
||||||
|
if (!walletOpen)
|
||||||
|
return {};
|
||||||
|
return accountReadsJson(m_wallet->snapshot(refresh).publicAccountReads);
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject NewPositionRuntime::buildQuoteInput(const QVariantMap& request,
|
||||||
|
const ActiveNetworkSnapshot& network,
|
||||||
|
bool walletOpen,
|
||||||
|
bool freshWalletAccounts,
|
||||||
|
QJsonObject* error) const
|
||||||
|
{
|
||||||
|
if (network.status != QStringLiteral("ready")) {
|
||||||
|
*error = publicError(network.status);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const AmmClientResult configResult = m_client->configId(
|
||||||
|
QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } });
|
||||||
|
if (!configResult.ok) {
|
||||||
|
*error = publicError(QStringLiteral("backend_error"));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const QJsonObject configManifest = configResult.value;
|
||||||
|
const QJsonObject config = accountReadJson(m_wallet->readPublicAccount(
|
||||||
|
configManifest.value(QStringLiteral("configId")).toString()));
|
||||||
|
const QJsonObject requestObject = QJsonObject::fromVariantMap(request);
|
||||||
|
const AmmClientResult pairResult = m_client->pairIds(
|
||||||
|
QJsonObject {
|
||||||
|
{ QStringLiteral("ammProgramId"), network.ammProgramId },
|
||||||
|
{ QStringLiteral("config"), config },
|
||||||
|
{ QStringLiteral("tokenAId"), requestObject.value(QStringLiteral("tokenAId")) },
|
||||||
|
{ QStringLiteral("tokenBId"), requestObject.value(QStringLiteral("tokenBId")) },
|
||||||
|
});
|
||||||
|
if (!pairResult.ok) {
|
||||||
|
*error = publicError(QStringLiteral("backend_error"));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const QJsonObject pairManifest = pairResult.value;
|
||||||
|
if (pairManifest.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) {
|
||||||
|
*error = publicError(pairManifest.value(QStringLiteral("code")).toString());
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonArray walletAccounts = walletAccountReads(walletOpen, freshWalletAccounts);
|
||||||
|
const QJsonObject snapshot {
|
||||||
|
{ QStringLiteral("config"), config },
|
||||||
|
{ QStringLiteral("tokenA"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("tokenAId")).toString())) },
|
||||||
|
{ QStringLiteral("tokenB"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("tokenBId")).toString())) },
|
||||||
|
{ QStringLiteral("pool"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("poolId")).toString())) },
|
||||||
|
{ QStringLiteral("vaultA"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("vaultAId")).toString())) },
|
||||||
|
{ QStringLiteral("vaultB"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("vaultBId")).toString())) },
|
||||||
|
{ QStringLiteral("lpDefinition"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("lpDefinitionId")).toString())) },
|
||||||
|
{ QStringLiteral("lpLockHolding"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("lpLockHoldingId")).toString())) },
|
||||||
|
{ QStringLiteral("currentTick"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("currentTickId")).toString())) },
|
||||||
|
{ QStringLiteral("clock"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("clockId")).toString())) },
|
||||||
|
{ QStringLiteral("walletAvailable"), walletOpen },
|
||||||
|
{ QStringLiteral("walletAccounts"), walletAccounts },
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
{ QStringLiteral("networkId"), network.id },
|
||||||
|
{ QStringLiteral("networkFingerprint"), network.fingerprint },
|
||||||
|
{ QStringLiteral("ammProgramId"), network.ammProgramId },
|
||||||
|
{ QStringLiteral("request"), requestObject },
|
||||||
|
{ QStringLiteral("snapshot"), snapshot },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantMap NewPositionRuntime::context(const QVariantMap& request,
|
||||||
|
const ActiveNetworkSnapshot& network,
|
||||||
|
bool walletOpen,
|
||||||
|
bool refreshWalletAccounts)
|
||||||
|
{
|
||||||
|
if (network.status != QStringLiteral("ready"))
|
||||||
|
return contextState(network.status, network).toVariantMap();
|
||||||
|
|
||||||
|
const QJsonArray walletAccounts = walletAccountReads(walletOpen, refreshWalletAccounts);
|
||||||
|
|
||||||
|
const QJsonObject hints = QJsonObject::fromVariantMap(request);
|
||||||
|
const AmmClientResult configResult = m_client->configId(
|
||||||
|
QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } });
|
||||||
|
if (!configResult.ok)
|
||||||
|
return contextState(
|
||||||
|
QStringLiteral("error"), network, QStringLiteral("backend_error")).toVariantMap();
|
||||||
|
|
||||||
|
const QJsonObject configManifest = configResult.value;
|
||||||
|
const QJsonObject config = accountReadJson(m_wallet->readPublicAccount(
|
||||||
|
configManifest.value(QStringLiteral("configId")).toString()));
|
||||||
|
QJsonArray configured;
|
||||||
|
for (const QString& id : network.tokenIds)
|
||||||
|
configured.append(id);
|
||||||
|
const QJsonArray recent = variantStringArray(hints.value(QStringLiteral("recentTokenIds")).toVariant());
|
||||||
|
const QJsonArray resolved = variantStringArray(hints.value(QStringLiteral("resolvedTokenIds")).toVariant());
|
||||||
|
|
||||||
|
const AmmClientResult tokenResult = m_client->tokenIds(
|
||||||
|
QJsonObject {
|
||||||
|
{ QStringLiteral("ammProgramId"), network.ammProgramId },
|
||||||
|
{ QStringLiteral("config"), config },
|
||||||
|
{ QStringLiteral("walletAccounts"), walletAccounts },
|
||||||
|
{ QStringLiteral("configuredTokenIds"), configured },
|
||||||
|
{ QStringLiteral("recentTokenIds"), recent },
|
||||||
|
{ QStringLiteral("resolvedTokenIds"), resolved },
|
||||||
|
});
|
||||||
|
const QJsonObject tokenManifest = tokenResult.value;
|
||||||
|
if (!tokenResult.ok
|
||||||
|
|| tokenManifest.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) {
|
||||||
|
const QString code = tokenResult.ok
|
||||||
|
? tokenManifest.value(QStringLiteral("code")).toString()
|
||||||
|
: QStringLiteral("backend_error");
|
||||||
|
return contextState(
|
||||||
|
QStringLiteral("error"),
|
||||||
|
network,
|
||||||
|
code.isEmpty() ? QStringLiteral("backend_error") : code).toVariantMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray definitions;
|
||||||
|
for (const QJsonValue& id : tokenManifest.value(QStringLiteral("tokenIds")).toArray())
|
||||||
|
definitions.append(accountReadJson(m_wallet->readPublicAccount(id.toString())));
|
||||||
|
|
||||||
|
const AmmClientResult contextResult = m_client->context(
|
||||||
|
QJsonObject {
|
||||||
|
{ QStringLiteral("networkId"), network.id },
|
||||||
|
{ QStringLiteral("networkFingerprint"), network.fingerprint },
|
||||||
|
{ QStringLiteral("ammProgramId"), network.ammProgramId },
|
||||||
|
{ QStringLiteral("walletAvailable"), walletOpen },
|
||||||
|
{ QStringLiteral("config"), config },
|
||||||
|
{ QStringLiteral("walletAccounts"), walletAccounts },
|
||||||
|
{ QStringLiteral("tokenDefinitions"), definitions },
|
||||||
|
{ QStringLiteral("configuredTokenIds"), configured },
|
||||||
|
{ QStringLiteral("recentTokenIds"), recent },
|
||||||
|
{ QStringLiteral("resolvedTokenIds"), resolved },
|
||||||
|
});
|
||||||
|
return (contextResult.ok
|
||||||
|
? contextResult.value
|
||||||
|
: contextState(
|
||||||
|
QStringLiteral("error"), network, QStringLiteral("backend_error"))).toVariantMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantMap NewPositionRuntime::quote(const QVariantMap& request,
|
||||||
|
const ActiveNetworkSnapshot& network,
|
||||||
|
bool walletOpen)
|
||||||
|
{
|
||||||
|
QJsonObject error;
|
||||||
|
const QJsonObject input = buildQuoteInput(request, network, walletOpen, false, &error);
|
||||||
|
if (!error.isEmpty())
|
||||||
|
return error.toVariantMap();
|
||||||
|
|
||||||
|
const AmmClientResult result = m_client->quote(input);
|
||||||
|
return (result.ok ? result.value : publicError(QStringLiteral("backend_error"))).toVariantMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantMap NewPositionRuntime::submit(const QVariantMap& request,
|
||||||
|
const QString& quoteHash,
|
||||||
|
const ActiveNetworkSnapshot& network,
|
||||||
|
bool walletOpen)
|
||||||
|
{
|
||||||
|
if (m_submitInFlight)
|
||||||
|
return publicError(QStringLiteral("submit_in_progress")).toVariantMap();
|
||||||
|
if (!walletOpen)
|
||||||
|
return publicError(QStringLiteral("wallet_unavailable")).toVariantMap();
|
||||||
|
QScopedValueRollback<bool> submitGuard(m_submitInFlight, true);
|
||||||
|
|
||||||
|
QJsonObject error;
|
||||||
|
const QJsonObject input = buildQuoteInput(request, network, walletOpen, true, &error);
|
||||||
|
if (!error.isEmpty())
|
||||||
|
return error.toVariantMap();
|
||||||
|
|
||||||
|
const AmmClientResult quoteResult = m_client->quote(input);
|
||||||
|
if (!quoteResult.ok)
|
||||||
|
return publicError(QStringLiteral("backend_error")).toVariantMap();
|
||||||
|
const QJsonObject quote = quoteResult.value;
|
||||||
|
if (quote.value(QStringLiteral("quoteHash")).toString() != quoteHash) {
|
||||||
|
QJsonObject result = publicError(QStringLiteral("quote_changed"));
|
||||||
|
result.insert(QStringLiteral("quote"), quote);
|
||||||
|
return result.toVariantMap();
|
||||||
|
}
|
||||||
|
if (!quote.value(QStringLiteral("canSubmit")).toBool(false)) {
|
||||||
|
QJsonObject result = publicError(QStringLiteral("quote_not_submittable"));
|
||||||
|
result.insert(QStringLiteral("quote"), quote);
|
||||||
|
return result.toVariantMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonValue freshLp;
|
||||||
|
if (quote.value(QStringLiteral("requiresFreshLp")).toBool(false)) {
|
||||||
|
const WalletAccountCreation creation = m_wallet->createAccount(true);
|
||||||
|
if (!creation.ok() || !creation.publicAccount.ok())
|
||||||
|
return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap();
|
||||||
|
freshLp = accountReadJson(creation.publicAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject planInput = input;
|
||||||
|
planInput.insert(QStringLiteral("quoteHash"), quoteHash);
|
||||||
|
planInput.insert(QStringLiteral("nowMs"), QDateTime::currentMSecsSinceEpoch());
|
||||||
|
if (!freshLp.isUndefined())
|
||||||
|
planInput.insert(QStringLiteral("freshLp"), freshLp);
|
||||||
|
|
||||||
|
const AmmClientResult planResult = m_client->plan(planInput);
|
||||||
|
if (!planResult.ok)
|
||||||
|
return publicError(QStringLiteral("backend_error")).toVariantMap();
|
||||||
|
const QJsonObject plan = planResult.value;
|
||||||
|
if (plan.value(QStringLiteral("status")).toString() != QStringLiteral("ready")) {
|
||||||
|
const QString code = plan.value(QStringLiteral("code")).toString();
|
||||||
|
return publicError(code.isEmpty() ? QStringLiteral("wallet_submission_failed") : code)
|
||||||
|
.toVariantMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
const QStringList accountIds = jsonStringList(plan.value(QStringLiteral("accountIds")).toArray());
|
||||||
|
const QVector<bool> signingRequirements = jsonBoolList(plan.value(QStringLiteral("signingRequirements")).toArray());
|
||||||
|
const QVector<quint32> 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<qulonglong>(QDateTime::currentMSecsSinceEpoch()) >= deadline) {
|
||||||
|
return publicError(QStringLiteral("transaction_deadline_expired")).toVariantMap();
|
||||||
|
}
|
||||||
|
const WalletSubmission submission = m_wallet->submitPublicTransaction({
|
||||||
|
programId,
|
||||||
|
accountIds,
|
||||||
|
signingRequirements,
|
||||||
|
instruction,
|
||||||
|
});
|
||||||
|
if (!submission.accepted())
|
||||||
|
return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap();
|
||||||
|
const QString transactionId = base58TransactionId(submission.nativeHash);
|
||||||
|
if (transactionId.isEmpty())
|
||||||
|
return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap();
|
||||||
|
|
||||||
|
return QJsonObject {
|
||||||
|
{ QStringLiteral("schema"), QString::fromLatin1(SCHEMA) },
|
||||||
|
{ QStringLiteral("status"), QStringLiteral("submitted") },
|
||||||
|
{ QStringLiteral("transactionId"), transactionId },
|
||||||
|
{ QStringLiteral("deadlineMs"), plan.value(QStringLiteral("deadlineMs")) },
|
||||||
|
}.toVariantMap();
|
||||||
|
}
|
||||||
42
apps/amm/src/NewPositionRuntime.h
Normal file
42
apps/amm/src/NewPositionRuntime.h
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QVariantMap>
|
||||||
|
|
||||||
|
#include "ActiveNetwork.h"
|
||||||
|
|
||||||
|
class AmmClient;
|
||||||
|
class WalletProvider;
|
||||||
|
|
||||||
|
class NewPositionRuntime {
|
||||||
|
public:
|
||||||
|
NewPositionRuntime(WalletProvider* wallet, AmmClient* client);
|
||||||
|
|
||||||
|
void clearWalletAccounts();
|
||||||
|
|
||||||
|
QVariantMap context(const QVariantMap& request,
|
||||||
|
const ActiveNetworkSnapshot& network,
|
||||||
|
bool walletOpen,
|
||||||
|
bool refreshWalletAccounts);
|
||||||
|
QVariantMap quote(const QVariantMap& request,
|
||||||
|
const ActiveNetworkSnapshot& network,
|
||||||
|
bool walletOpen);
|
||||||
|
QVariantMap submit(const QVariantMap& request,
|
||||||
|
const QString& quoteHash,
|
||||||
|
const ActiveNetworkSnapshot& network,
|
||||||
|
bool walletOpen);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QJsonArray walletAccountReads(bool walletOpen, bool refresh) const;
|
||||||
|
QJsonObject buildQuoteInput(const QVariantMap& request,
|
||||||
|
const ActiveNetworkSnapshot& network,
|
||||||
|
bool walletOpen,
|
||||||
|
bool freshWalletAccounts,
|
||||||
|
QJsonObject* error) const;
|
||||||
|
|
||||||
|
WalletProvider* m_wallet;
|
||||||
|
AmmClient* m_client;
|
||||||
|
bool m_submitInFlight = false;
|
||||||
|
};
|
||||||
65
apps/amm/tests/cpp/ActiveNetworkTest.cpp
Normal file
65
apps/amm/tests/cpp/ActiveNetworkTest.cpp
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
#include "ActiveNetwork.h"
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QTemporaryFile>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
bool expect(bool condition, const char* message)
|
||||||
|
{
|
||||||
|
if (!condition)
|
||||||
|
qCritical("%s", message);
|
||||||
|
return condition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
const QString identity(64, QLatin1Char('a'));
|
||||||
|
const QString programId(64, QLatin1Char('b'));
|
||||||
|
const QString tokenId(64, QLatin1Char('c'));
|
||||||
|
|
||||||
|
QTemporaryFile config;
|
||||||
|
if (!config.open())
|
||||||
|
return 1;
|
||||||
|
config.write(QJsonDocument(QJsonObject {
|
||||||
|
{ QStringLiteral("channelId"), identity },
|
||||||
|
{ QStringLiteral("ammProgramId"), programId },
|
||||||
|
{ QStringLiteral("tokenDefinitionIds"), QJsonArray { tokenId } },
|
||||||
|
}).toJson(QJsonDocument::Compact));
|
||||||
|
config.flush();
|
||||||
|
|
||||||
|
qputenv("AMM_UI_NETWORK", "devnet");
|
||||||
|
qputenv("AMM_UI_DEVNET_FILE", config.fileName().toLocal8Bit());
|
||||||
|
|
||||||
|
ActiveNetwork network;
|
||||||
|
if (!expect(network.load(), "devnet config should load"))
|
||||||
|
return 1;
|
||||||
|
if (!expect(network.snapshot().status == QStringLiteral("network_unknown"),
|
||||||
|
"loaded network should await identity"))
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
network.sequencerChanged(true);
|
||||||
|
network.finishIdentityProbe(QString(64, QLatin1Char('d')));
|
||||||
|
if (!expect(network.status() == QStringLiteral("network_mismatch"),
|
||||||
|
"wrong identity should block the network"))
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
network.reachabilityChanged(false, true);
|
||||||
|
network.reachabilityChanged(true, false);
|
||||||
|
network.finishIdentityProbe(identity);
|
||||||
|
const ActiveNetworkSnapshot snapshot = network.snapshot();
|
||||||
|
if (!expect(snapshot.status == QStringLiteral("ready"),
|
||||||
|
"matching identity should ready the network"))
|
||||||
|
return 1;
|
||||||
|
if (!expect(snapshot.fingerprint == QStringLiteral("channel:") + identity,
|
||||||
|
"devnet fingerprint should use channel identity"))
|
||||||
|
return 1;
|
||||||
|
if (!expect(snapshot.ammProgramId == programId
|
||||||
|
&& snapshot.tokenIds == QStringList { tokenId },
|
||||||
|
"network snapshot should preserve configured program and tokens"))
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
230
apps/amm/tests/cpp/NewPositionRuntimeTest.cpp
Normal file
230
apps/amm/tests/cpp/NewPositionRuntimeTest.cpp
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
#include "AmmClient.h"
|
||||||
|
#include "NewPositionRuntime.h"
|
||||||
|
#include "WalletProvider.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
bool expect(bool condition, const char* message)
|
||||||
|
{
|
||||||
|
if (!condition)
|
||||||
|
qCritical("%s", message);
|
||||||
|
return condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeWallet final : public WalletProvider {
|
||||||
|
public:
|
||||||
|
WalletSession connect(const WalletPaths&) override
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
WalletCreation createWallet(const WalletPaths&, const QString&) override
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
WalletSnapshot snapshot(bool) override
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSnapshot() override {}
|
||||||
|
|
||||||
|
WalletAccountCreation createAccount(bool isPublic) override
|
||||||
|
{
|
||||||
|
++createdAccounts;
|
||||||
|
WalletAccountCreation creation;
|
||||||
|
creation.accountId = QStringLiteral("fresh-lp");
|
||||||
|
if (isPublic)
|
||||||
|
creation.publicAccount = readPublicAccount(creation.accountId);
|
||||||
|
return creation;
|
||||||
|
}
|
||||||
|
|
||||||
|
WalletAccountRead readPublicAccount(const QString& accountId) const override
|
||||||
|
{
|
||||||
|
WalletAccountRead read;
|
||||||
|
read.accountId = accountId;
|
||||||
|
read.status = QStringLiteral("ok");
|
||||||
|
return read;
|
||||||
|
}
|
||||||
|
|
||||||
|
WalletSubmission submitPublicTransaction(const WalletTransaction& transaction) override
|
||||||
|
{
|
||||||
|
++submissions;
|
||||||
|
submitted = transaction;
|
||||||
|
return { WalletFailure::None, transactionHash };
|
||||||
|
}
|
||||||
|
|
||||||
|
void disconnect() override {}
|
||||||
|
|
||||||
|
QString transactionHash = QStringLiteral(
|
||||||
|
"000102030405060708090a0b0c0d0e0f"
|
||||||
|
"101112131415161718191a1b1c1d1e1f");
|
||||||
|
int createdAccounts = 0;
|
||||||
|
int submissions = 0;
|
||||||
|
WalletTransaction submitted;
|
||||||
|
};
|
||||||
|
|
||||||
|
class FakeAmmClient final : public AmmClient {
|
||||||
|
public:
|
||||||
|
AmmClientResult configId(const QJsonObject&) const override
|
||||||
|
{
|
||||||
|
return success({ { QStringLiteral("configId"), QStringLiteral("config") } });
|
||||||
|
}
|
||||||
|
|
||||||
|
AmmClientResult tokenIds(const QJsonObject&) const override
|
||||||
|
{
|
||||||
|
return success({ { QStringLiteral("status"), QStringLiteral("ok") } });
|
||||||
|
}
|
||||||
|
|
||||||
|
AmmClientResult pairIds(const QJsonObject&) const override
|
||||||
|
{
|
||||||
|
return success({
|
||||||
|
{ QStringLiteral("status"), QStringLiteral("ok") },
|
||||||
|
{ QStringLiteral("tokenAId"), QStringLiteral("token-a") },
|
||||||
|
{ QStringLiteral("tokenBId"), QStringLiteral("token-b") },
|
||||||
|
{ QStringLiteral("poolId"), QStringLiteral("pool") },
|
||||||
|
{ QStringLiteral("vaultAId"), QStringLiteral("vault-a") },
|
||||||
|
{ QStringLiteral("vaultBId"), QStringLiteral("vault-b") },
|
||||||
|
{ QStringLiteral("lpDefinitionId"), QStringLiteral("lp") },
|
||||||
|
{ QStringLiteral("lpLockHoldingId"), QStringLiteral("lp-lock") },
|
||||||
|
{ QStringLiteral("currentTickId"), QStringLiteral("tick") },
|
||||||
|
{ QStringLiteral("clockId"), QStringLiteral("clock") },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
AmmClientResult context(const QJsonObject&) const override
|
||||||
|
{
|
||||||
|
return success({});
|
||||||
|
}
|
||||||
|
|
||||||
|
AmmClientResult quote(const QJsonObject&) const override
|
||||||
|
{
|
||||||
|
return success({
|
||||||
|
{ QStringLiteral("schema"), QStringLiteral("new-position.v1") },
|
||||||
|
{ QStringLiteral("status"), QStringLiteral("ok") },
|
||||||
|
{ QStringLiteral("canSubmit"), true },
|
||||||
|
{ QStringLiteral("quoteHash"), quoteHash },
|
||||||
|
{ QStringLiteral("requiresFreshLp"), requiresFreshLp },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
AmmClientResult plan(const QJsonObject& request) const override
|
||||||
|
{
|
||||||
|
sawFreshLp = request.contains(QStringLiteral("freshLp"));
|
||||||
|
return success({
|
||||||
|
{ QStringLiteral("status"), QStringLiteral("ready") },
|
||||||
|
{ QStringLiteral("accountIds"), QJsonArray { QStringLiteral("account") } },
|
||||||
|
{ QStringLiteral("signingRequirements"), QJsonArray { true } },
|
||||||
|
{ QStringLiteral("instruction"), QJsonArray { 1 } },
|
||||||
|
{ QStringLiteral("programId"), QStringLiteral("program") },
|
||||||
|
{ QStringLiteral("deadlineMs"),
|
||||||
|
QString::number(QDateTime::currentMSecsSinceEpoch() + 60'000) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static AmmClientResult success(const QJsonObject& value)
|
||||||
|
{
|
||||||
|
return { true, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
QString quoteHash = QStringLiteral("sha256:expected");
|
||||||
|
bool requiresFreshLp = true;
|
||||||
|
mutable bool sawFreshLp = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
ActiveNetworkSnapshot readyNetwork()
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
QStringLiteral("testnet"),
|
||||||
|
QStringLiteral("ready"),
|
||||||
|
QStringLiteral("block10:identity"),
|
||||||
|
QStringLiteral("program"),
|
||||||
|
{},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
const QVariantMap request {
|
||||||
|
{ QStringLiteral("schema"), QStringLiteral("new-position.v1") },
|
||||||
|
{ QStringLiteral("tokenAId"), QStringLiteral("token-a") },
|
||||||
|
{ QStringLiteral("tokenBId"), QStringLiteral("token-b") },
|
||||||
|
{ QStringLiteral("feeBps"), 30 },
|
||||||
|
};
|
||||||
|
|
||||||
|
FakeWallet wallet;
|
||||||
|
FakeAmmClient client;
|
||||||
|
NewPositionRuntime runtime(&wallet, &client);
|
||||||
|
const QVariantMap result = runtime.submit(
|
||||||
|
request, QStringLiteral("sha256:expected"), readyNetwork(), true);
|
||||||
|
if (!expect(result.value(QStringLiteral("status")).toString()
|
||||||
|
== QStringLiteral("submitted"),
|
||||||
|
"valid plan should submit"))
|
||||||
|
return 1;
|
||||||
|
if (!expect(result.value(QStringLiteral("transactionId")).toString()
|
||||||
|
== QStringLiteral("1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"),
|
||||||
|
"native hash should become the expected base58 transaction ID"))
|
||||||
|
return 1;
|
||||||
|
if (!expect(wallet.createdAccounts == 1 && client.sawFreshLp,
|
||||||
|
"fresh LP account should enter the plan"))
|
||||||
|
return 1;
|
||||||
|
if (!expect(wallet.submissions == 1
|
||||||
|
&& wallet.submitted.accountIds
|
||||||
|
== QStringList { QStringLiteral("account") }
|
||||||
|
&& wallet.submitted.signingRequirements.size() == 1
|
||||||
|
&& wallet.submitted.signingRequirements.constFirst()
|
||||||
|
&& wallet.submitted.instruction.size() == 1
|
||||||
|
&& wallet.submitted.instruction.constFirst() == 1
|
||||||
|
&& wallet.submitted.programId == QStringLiteral("program"),
|
||||||
|
"runtime should dispatch the unchanged plan once"))
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
FakeWallet orderedBytesWallet;
|
||||||
|
orderedBytesWallet.transactionHash = QStringLiteral(
|
||||||
|
"0102030405060708090a0b0c0d0e0f10"
|
||||||
|
"1112131415161718191a1b1c1d1e1f20");
|
||||||
|
FakeAmmClient orderedBytesClient;
|
||||||
|
orderedBytesClient.requiresFreshLp = false;
|
||||||
|
NewPositionRuntime orderedBytesRuntime(&orderedBytesWallet, &orderedBytesClient);
|
||||||
|
const QVariantMap orderedBytes = orderedBytesRuntime.submit(
|
||||||
|
request, QStringLiteral("sha256:expected"), readyNetwork(), true);
|
||||||
|
if (!expect(orderedBytes.value(QStringLiteral("transactionId")).toString()
|
||||||
|
== QStringLiteral("4wBqpZM9xaSheZzJSMawUKKwhdpChKbZ5eu5ky4Vigw"),
|
||||||
|
"ordered native hash bytes should preserve byte order"))
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
FakeWallet invalidHashWallet;
|
||||||
|
invalidHashWallet.transactionHash = QStringLiteral("not-a-hash");
|
||||||
|
FakeAmmClient invalidHashClient;
|
||||||
|
invalidHashClient.requiresFreshLp = false;
|
||||||
|
NewPositionRuntime invalidHashRuntime(&invalidHashWallet, &invalidHashClient);
|
||||||
|
const QVariantMap invalidHash = invalidHashRuntime.submit(
|
||||||
|
request, QStringLiteral("sha256:expected"), readyNetwork(), true);
|
||||||
|
if (!expect(invalidHash.value(QStringLiteral("code")).toString()
|
||||||
|
== QStringLiteral("wallet_submission_failed")
|
||||||
|
&& !invalidHash.contains(QStringLiteral("transactionId")),
|
||||||
|
"invalid native hash should fail without a hex fallback"))
|
||||||
|
return 1;
|
||||||
|
if (!expect(invalidHashWallet.submissions == 1,
|
||||||
|
"hash conversion should happen after wallet submission"))
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
FakeWallet staleWallet;
|
||||||
|
FakeAmmClient staleClient;
|
||||||
|
staleClient.quoteHash = QStringLiteral("sha256:changed");
|
||||||
|
NewPositionRuntime staleRuntime(&staleWallet, &staleClient);
|
||||||
|
const QVariantMap stale = staleRuntime.submit(
|
||||||
|
request, QStringLiteral("sha256:expected"), readyNetwork(), true);
|
||||||
|
if (!expect(stale.value(QStringLiteral("code")).toString()
|
||||||
|
== QStringLiteral("quote_changed"),
|
||||||
|
"changed quote should stop submission"))
|
||||||
|
return 1;
|
||||||
|
if (!expect(staleWallet.createdAccounts == 0 && staleWallet.submissions == 0,
|
||||||
|
"stale quote should have no wallet side effects"))
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@ -1,312 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
|
|
||||||
const MINIMUM_LIQUIDITY = 1000;
|
|
||||||
const ACTIVE_POOL_RESERVES = {
|
|
||||||
LOGOS: 10_000_000,
|
|
||||||
USDC: 1_250_000,
|
|
||||||
};
|
|
||||||
const ACTIVE_POOL_LP_SUPPLY = 6_875_000;
|
|
||||||
const SUPPORTED_FEE_TIERS = new Set([1, 5, 30, 100]);
|
|
||||||
const ROLE_TO_IDL_NAME = {
|
|
||||||
Config: 'config',
|
|
||||||
Pool: 'pool',
|
|
||||||
'Vault A': 'vault_a',
|
|
||||||
'Vault B': 'vault_b',
|
|
||||||
'LP definition': 'pool_definition_lp',
|
|
||||||
'LP lock holding': 'lp_lock_holding',
|
|
||||||
'User holding A': 'user_holding_a',
|
|
||||||
'User holding B': 'user_holding_b',
|
|
||||||
'User LP holding': 'user_holding_lp',
|
|
||||||
'Current tick': 'current_tick_account',
|
|
||||||
Clock: 'clock',
|
|
||||||
};
|
|
||||||
|
|
||||||
function stableId(key) {
|
|
||||||
return createHash('sha256').update(key).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferDecimals(totalSupply) {
|
|
||||||
const supply = BigInt(totalSupply);
|
|
||||||
const digits = supply.toString().length;
|
|
||||||
|
|
||||||
if (supply < 10_000_000n)
|
|
||||||
return 0;
|
|
||||||
if (digits <= 14)
|
|
||||||
return 6;
|
|
||||||
if (digits <= 21)
|
|
||||||
return 9;
|
|
||||||
return 18;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseHumanAmount(input, decimals) {
|
|
||||||
const text = String(input).replaceAll(',', '').trim();
|
|
||||||
assert.match(text, /^\d+(\.\d+)?$/, `invalid amount: ${input}`);
|
|
||||||
|
|
||||||
const [whole, fraction = ''] = text.split('.');
|
|
||||||
assert.ok(fraction.length <= decimals, `too many decimal places: ${input}`);
|
|
||||||
|
|
||||||
const scale = 10n ** BigInt(decimals);
|
|
||||||
const paddedFraction = fraction.padEnd(decimals, '0') || '0';
|
|
||||||
return BigInt(whole) * scale + BigInt(paddedFraction);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBaseUnits(units, decimals) {
|
|
||||||
const value = BigInt(units);
|
|
||||||
if (decimals === 0)
|
|
||||||
return value.toString();
|
|
||||||
|
|
||||||
const scale = 10n ** BigInt(decimals);
|
|
||||||
const whole = value / scale;
|
|
||||||
const fraction = (value % scale).toString().padStart(decimals, '0').replace(/0+$/, '');
|
|
||||||
return fraction.length > 0 ? `${whole}.${fraction}` : whole.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePositiveAmount(value) {
|
|
||||||
const parsed = Number(String(value).replaceAll(',', '').trim());
|
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unorderedPairKey(symbolA, symbolB) {
|
|
||||||
return symbolA < symbolB ? `${symbolA}/${symbolB}` : `${symbolB}/${symbolA}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeRatio(symbolA, symbolB) {
|
|
||||||
if (symbolA === 'USDC' && symbolB === 'LOGOS')
|
|
||||||
return ACTIVE_POOL_RESERVES.LOGOS / ACTIVE_POOL_RESERVES.USDC;
|
|
||||||
if (symbolA === 'LOGOS' && symbolB === 'USDC')
|
|
||||||
return ACTIVE_POOL_RESERVES.USDC / ACTIVE_POOL_RESERVES.LOGOS;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultInitialPrice(symbolA, symbolB) {
|
|
||||||
if (symbolA === 'USDC' && symbolB === 'WETH')
|
|
||||||
return 2500;
|
|
||||||
if (symbolA === 'WETH' && symbolB === 'USDC')
|
|
||||||
return 0.0004;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function poolContext(symbolA, symbolB) {
|
|
||||||
const pairKey = unorderedPairKey(symbolA, symbolB);
|
|
||||||
const poolId = stableId(`devnet:amm-pool:${pairKey}`);
|
|
||||||
|
|
||||||
if (pairKey === 'LOGOS/USDC')
|
|
||||||
return { poolId, poolStatus: 'active_pool', instruction: 'add_liquidity', storedFeeBps: 30 };
|
|
||||||
if (pairKey === 'USDC/WETH')
|
|
||||||
return { poolId, poolStatus: 'missing_pool', instruction: 'new_definition', storedFeeBps: 0 };
|
|
||||||
return { poolId, poolStatus: 'unavailable_pool', instruction: '', storedFeeBps: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function accountChange(role, id, action) {
|
|
||||||
return { role, id, action };
|
|
||||||
}
|
|
||||||
|
|
||||||
function accountChanges(request, context) {
|
|
||||||
const { tokenA, tokenB } = request;
|
|
||||||
const { poolId } = context;
|
|
||||||
const missingPool = context.poolStatus === 'missing_pool';
|
|
||||||
const changes = [
|
|
||||||
accountChange('Config', stableId('devnet:amm-config'), 'Read'),
|
|
||||||
accountChange('Pool', poolId, missingPool ? 'Create' : 'Update'),
|
|
||||||
accountChange('Vault A', stableId(`devnet:vault:${poolId}:${tokenA}`), missingPool ? 'Update or create' : 'Update'),
|
|
||||||
accountChange('Vault B', stableId(`devnet:vault:${poolId}:${tokenB}`), missingPool ? 'Update or create' : 'Update'),
|
|
||||||
accountChange('LP definition', stableId(`devnet:lp-definition:${poolId}`), missingPool ? 'Create' : 'Update'),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (missingPool)
|
|
||||||
changes.push(accountChange('LP lock holding', stableId(`devnet:lp-lock:${poolId}`), 'Create'));
|
|
||||||
|
|
||||||
changes.push(
|
|
||||||
accountChange('User holding A', stableId(`devnet:user-holding:${tokenA}`), 'Update'),
|
|
||||||
accountChange('User holding B', stableId(`devnet:user-holding:${tokenB}`), 'Update'),
|
|
||||||
accountChange('User LP holding', stableId(`devnet:user-lp:${poolId}`), 'Update or create'),
|
|
||||||
accountChange('Current tick', stableId(`devnet:current-tick:${poolId}`), missingPool ? 'Create' : 'Update'),
|
|
||||||
accountChange('Clock', stableId('devnet:clock:canonical'), 'Read'),
|
|
||||||
);
|
|
||||||
|
|
||||||
return changes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function quoteNewPosition(request) {
|
|
||||||
assert.ok(SUPPORTED_FEE_TIERS.has(request.feeBps), 'unsupported fee tier');
|
|
||||||
|
|
||||||
const context = poolContext(request.tokenA, request.tokenB);
|
|
||||||
const slippageBps = Math.max(1, Math.min(5000, request.slippageBps));
|
|
||||||
|
|
||||||
if (context.poolStatus === 'active_pool') {
|
|
||||||
assert.equal(request.feeBps, context.storedFeeBps, 'active pool fee tier mismatch');
|
|
||||||
|
|
||||||
const inputA = parsePositiveAmount(request.amountA);
|
|
||||||
const inputB = parsePositiveAmount(request.amountB);
|
|
||||||
const editA = request.editedSide !== 'B';
|
|
||||||
const ratio = activeRatio(request.tokenA, request.tokenB);
|
|
||||||
const amountA = editA ? inputA : inputB / ratio;
|
|
||||||
const amountB = editA ? inputA * ratio : inputB;
|
|
||||||
const reserveA = ACTIVE_POOL_RESERVES[request.tokenA];
|
|
||||||
const reserveB = ACTIVE_POOL_RESERVES[request.tokenB];
|
|
||||||
const expectedLp = Math.floor(Math.min(
|
|
||||||
ACTIVE_POOL_LP_SUPPLY * amountA / reserveA,
|
|
||||||
ACTIVE_POOL_LP_SUPPLY * amountB / reserveB,
|
|
||||||
));
|
|
||||||
|
|
||||||
return {
|
|
||||||
accountChanges: accountChanges(request, context),
|
|
||||||
deposit: { maxA: amountA, maxB: amountB, actualA: amountA, actualB: amountB },
|
|
||||||
instruction: context.instruction,
|
|
||||||
lp: {
|
|
||||||
expected: expectedLp,
|
|
||||||
locked: 0,
|
|
||||||
minimum: Math.floor(expectedLp * (10000 - slippageBps) / 10000),
|
|
||||||
},
|
|
||||||
poolStatus: context.poolStatus,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.poolStatus === 'missing_pool') {
|
|
||||||
const parsedPrice = parsePositiveAmount(request.initialPrice);
|
|
||||||
const price = parsedPrice > 0 ? parsedPrice : defaultInitialPrice(request.tokenA, request.tokenB);
|
|
||||||
const baseA = price >= 1 ? price : 1;
|
|
||||||
const baseB = price >= 1 ? 1 : 1 / price;
|
|
||||||
const minimumScale = Math.floor(MINIMUM_LIQUIDITY / Math.sqrt(baseA * baseB)) + 1;
|
|
||||||
const scale = minimumScale * Math.max(1, request.depositScale);
|
|
||||||
const amountA = baseA * scale;
|
|
||||||
const amountB = baseB * scale;
|
|
||||||
const initialLp = Math.floor(Math.sqrt(amountA * amountB));
|
|
||||||
const userLp = Math.max(0, initialLp - MINIMUM_LIQUIDITY);
|
|
||||||
|
|
||||||
return {
|
|
||||||
accountChanges: accountChanges(request, context),
|
|
||||||
deposit: { maxA: amountA, maxB: amountB, actualA: amountA, actualB: amountB },
|
|
||||||
instruction: context.instruction,
|
|
||||||
lp: {
|
|
||||||
expected: userLp,
|
|
||||||
locked: MINIMUM_LIQUIDITY,
|
|
||||||
minimum: Math.floor(userLp * (10000 - slippageBps) / 10000),
|
|
||||||
},
|
|
||||||
poolStatus: context.poolStatus,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`unsupported pool: ${request.tokenA}/${request.tokenB}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function idlAccounts(instructionName) {
|
|
||||||
const idl = JSON.parse(readFileSync(new URL('../../../artifacts/amm-idl.json', import.meta.url), 'utf8'));
|
|
||||||
const instruction = idl.instructions.find((item) => item.name === instructionName);
|
|
||||||
assert.ok(instruction, `missing IDL instruction: ${instructionName}`);
|
|
||||||
return instruction.accounts.map((account) => account.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertAccountOrderMatchesIdl(quote, instructionName) {
|
|
||||||
const actual = quote.accountChanges.map((change) => {
|
|
||||||
assert.ok(ROLE_TO_IDL_NAME[change.role], `unmapped account role: ${change.role}`);
|
|
||||||
return ROLE_TO_IDL_NAME[change.role];
|
|
||||||
});
|
|
||||||
assert.deepEqual(actual, idlAccounts(instructionName));
|
|
||||||
}
|
|
||||||
|
|
||||||
function testDecimals() {
|
|
||||||
assert.equal(inferDecimals(9_999_999n), 0);
|
|
||||||
assert.equal(inferDecimals(1_000_000_000_000n), 6);
|
|
||||||
assert.equal(inferDecimals(1_000_000_000_000_000_000n), 9);
|
|
||||||
assert.equal(inferDecimals(1_000_000_000_000_000_000_000_000n), 18);
|
|
||||||
assert.ok([9_999_999n, 1_000_000_000_000n, 1_000_000_000_000_000_000n]
|
|
||||||
.every((supply) => inferDecimals(supply) !== 8));
|
|
||||||
|
|
||||||
for (const [amount, decimals] of [
|
|
||||||
['12345', 0],
|
|
||||||
['12.3456', 6],
|
|
||||||
['0.000000001', 9],
|
|
||||||
['1.000000000000000001', 18],
|
|
||||||
]) {
|
|
||||||
assert.equal(formatBaseUnits(parseHumanAmount(amount, decimals), decimals), amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.throws(() => parseHumanAmount('1.1', 0), /too many decimal places/);
|
|
||||||
assert.throws(() => parseHumanAmount('1.0000000001', 9), /too many decimal places/);
|
|
||||||
}
|
|
||||||
|
|
||||||
function testMissingPool() {
|
|
||||||
const quote1x = quoteNewPosition({
|
|
||||||
amountA: '',
|
|
||||||
amountB: '',
|
|
||||||
depositScale: 1,
|
|
||||||
editedSide: 'A',
|
|
||||||
feeBps: 30,
|
|
||||||
initialPrice: '2500',
|
|
||||||
slippageBps: 50,
|
|
||||||
tokenA: 'USDC',
|
|
||||||
tokenB: 'WETH',
|
|
||||||
});
|
|
||||||
assert.equal(quote1x.poolStatus, 'missing_pool');
|
|
||||||
assert.equal(quote1x.instruction, 'new_definition');
|
|
||||||
assert.equal(quote1x.deposit.maxA, 52_500);
|
|
||||||
assert.equal(quote1x.deposit.maxB, 21);
|
|
||||||
assert.equal(quote1x.lp.expected, 50);
|
|
||||||
assert.equal(quote1x.lp.locked, 1000);
|
|
||||||
assert.equal(quote1x.lp.minimum, 49);
|
|
||||||
assertAccountOrderMatchesIdl(quote1x, 'new_definition');
|
|
||||||
|
|
||||||
const quote2x = quoteNewPosition({
|
|
||||||
amountA: '',
|
|
||||||
amountB: '',
|
|
||||||
depositScale: 2,
|
|
||||||
editedSide: 'A',
|
|
||||||
feeBps: 30,
|
|
||||||
initialPrice: '2500',
|
|
||||||
slippageBps: 50,
|
|
||||||
tokenA: 'USDC',
|
|
||||||
tokenB: 'WETH',
|
|
||||||
});
|
|
||||||
assert.equal(quote2x.deposit.maxA / quote2x.deposit.maxB, 2500);
|
|
||||||
assert.equal(quote2x.deposit.maxA, 105_000);
|
|
||||||
assert.equal(quote2x.deposit.maxB, 42);
|
|
||||||
assert.equal(quote2x.lp.expected, 1100);
|
|
||||||
assert.equal(quote2x.lp.minimum, 1094);
|
|
||||||
}
|
|
||||||
|
|
||||||
function testActivePool() {
|
|
||||||
const quoteByA = quoteNewPosition({
|
|
||||||
amountA: '100',
|
|
||||||
amountB: '',
|
|
||||||
depositScale: 1,
|
|
||||||
editedSide: 'A',
|
|
||||||
feeBps: 30,
|
|
||||||
initialPrice: '',
|
|
||||||
slippageBps: 50,
|
|
||||||
tokenA: 'USDC',
|
|
||||||
tokenB: 'LOGOS',
|
|
||||||
});
|
|
||||||
assert.equal(quoteByA.poolStatus, 'active_pool');
|
|
||||||
assert.equal(quoteByA.instruction, 'add_liquidity');
|
|
||||||
assert.equal(quoteByA.deposit.maxA, 100);
|
|
||||||
assert.equal(quoteByA.deposit.maxB, 800);
|
|
||||||
assert.equal(quoteByA.lp.expected, 550);
|
|
||||||
assert.equal(quoteByA.lp.minimum, 547);
|
|
||||||
assertAccountOrderMatchesIdl(quoteByA, 'add_liquidity');
|
|
||||||
|
|
||||||
const quoteByB = quoteNewPosition({
|
|
||||||
amountA: '',
|
|
||||||
amountB: '100',
|
|
||||||
depositScale: 1,
|
|
||||||
editedSide: 'B',
|
|
||||||
feeBps: 30,
|
|
||||||
initialPrice: '',
|
|
||||||
slippageBps: 50,
|
|
||||||
tokenA: 'LOGOS',
|
|
||||||
tokenB: 'USDC',
|
|
||||||
});
|
|
||||||
assert.equal(quoteByB.deposit.maxA, 800);
|
|
||||||
assert.equal(quoteByB.deposit.maxB, 100);
|
|
||||||
assert.equal(quoteByB.lp.expected, 550);
|
|
||||||
}
|
|
||||||
|
|
||||||
testDecimals();
|
|
||||||
testMissingPool();
|
|
||||||
testActivePool();
|
|
||||||
|
|
||||||
console.log('New Position validation harness passed');
|
|
||||||
29
apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml
Normal file
29
apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtTest
|
||||||
|
|
||||||
|
import "../../qml/components/liquidity" as Liquidity
|
||||||
|
|
||||||
|
TestCase {
|
||||||
|
id: testCase
|
||||||
|
|
||||||
|
name: "LiquidityConfirmationSummary"
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: summaryComponent
|
||||||
|
|
||||||
|
Liquidity.LiquidityConfirmationSummary {
|
||||||
|
width: 480
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_protocolActionsUseDisplayLabels() {
|
||||||
|
var summary = createTemporaryObject(summaryComponent, testCase)
|
||||||
|
verify(summary)
|
||||||
|
|
||||||
|
compare(summary.actionText("NewDefinition"), "Create pool")
|
||||||
|
compare(summary.actionText("AddLiquidity"), "Add liquidity")
|
||||||
|
compare(summary.actionText(""), "-")
|
||||||
|
}
|
||||||
|
}
|
||||||
309
apps/amm/tests/qml/tst_LiquidityPage.qml
Normal file
309
apps/amm/tests/qml/tst_LiquidityPage.qml
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtTest
|
||||||
|
|
||||||
|
import "../../qml/pages" as Pages
|
||||||
|
|
||||||
|
TestCase {
|
||||||
|
id: testCase
|
||||||
|
|
||||||
|
name: "LiquidityPage"
|
||||||
|
readonly property string submittedTransactionId:
|
||||||
|
"1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: backendComponent
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
property bool walletStateReady: false
|
||||||
|
property var submitResult: ({})
|
||||||
|
property var quoteResult: ({
|
||||||
|
"schema": "new-position.v1",
|
||||||
|
"status": "ok",
|
||||||
|
"poolStatus": "missing_pool"
|
||||||
|
})
|
||||||
|
property var newPositionContext: ({
|
||||||
|
"schema": "new-position.v1",
|
||||||
|
"status": "ready",
|
||||||
|
"tokens": [],
|
||||||
|
"feeTiers": []
|
||||||
|
})
|
||||||
|
|
||||||
|
function submitNewPosition(request, quoteHash) {
|
||||||
|
return submitResult
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteNewPosition(request) {
|
||||||
|
return quoteResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: runtimeComponent
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
function watch(value, succeeded, failed) {
|
||||||
|
succeeded(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: pageComponent
|
||||||
|
|
||||||
|
Pages.LiquidityPage {
|
||||||
|
visible: false
|
||||||
|
width: 800
|
||||||
|
height: 600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_contextWaitsForWalletState() {
|
||||||
|
var backend = createTemporaryObject(backendComponent, testCase)
|
||||||
|
var page = createTemporaryObject(pageComponent, testCase, {
|
||||||
|
"backend": backend
|
||||||
|
})
|
||||||
|
verify(backend)
|
||||||
|
verify(page)
|
||||||
|
|
||||||
|
compare(page.flow.newPositionContext.status, "loading")
|
||||||
|
|
||||||
|
backend.walletStateReady = true
|
||||||
|
tryCompare(page.flow.newPositionContext, "status", "ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_contextRefreshControlsWalletScan() {
|
||||||
|
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||||
|
"walletStateReady": true
|
||||||
|
})
|
||||||
|
var page = createTemporaryObject(pageComponent, testCase, {
|
||||||
|
"backend": backend
|
||||||
|
})
|
||||||
|
verify(backend)
|
||||||
|
verify(page)
|
||||||
|
|
||||||
|
compare(page.flow.contextHints(false).refreshWalletAccounts, false)
|
||||||
|
compare(page.flow.contextHints(true).refreshWalletAccounts, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_staleContextCompletionCannotFinishNewerRefresh() {
|
||||||
|
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||||
|
"walletStateReady": true
|
||||||
|
})
|
||||||
|
var page = createTemporaryObject(pageComponent, testCase, {
|
||||||
|
"backend": backend
|
||||||
|
})
|
||||||
|
verify(backend)
|
||||||
|
verify(page)
|
||||||
|
|
||||||
|
page.flow.contextSerial = 2
|
||||||
|
page.flow.contextLoading = true
|
||||||
|
page.flow.contextErrorCode = "newer_request_pending"
|
||||||
|
|
||||||
|
page.flow.finishContextRefresh(1, null)
|
||||||
|
page.flow.failContextRefresh(1)
|
||||||
|
|
||||||
|
compare(page.flow.contextLoading, true)
|
||||||
|
compare(page.flow.contextErrorCode, "newer_request_pending")
|
||||||
|
|
||||||
|
page.flow.finishContextRefresh(2, null)
|
||||||
|
compare(page.flow.contextLoading, false)
|
||||||
|
compare(page.flow.contextErrorCode, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_submitFailureKeepsReturnedFreshQuoteWithoutRequery() {
|
||||||
|
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||||
|
"walletStateReady": true
|
||||||
|
})
|
||||||
|
var page = createTemporaryObject(pageComponent, testCase, {
|
||||||
|
"backend": backend
|
||||||
|
})
|
||||||
|
verify(backend)
|
||||||
|
verify(page)
|
||||||
|
|
||||||
|
page.flow.quoteSerial = 7
|
||||||
|
page.flow.finishSubmitFailure({
|
||||||
|
"schema": "new-position.v1",
|
||||||
|
"status": "error",
|
||||||
|
"code": "quote_not_submittable",
|
||||||
|
"quote": {
|
||||||
|
"schema": "new-position.v1",
|
||||||
|
"status": "ok",
|
||||||
|
"canSubmit": false,
|
||||||
|
"quoteHash": "sha256:fresh"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
compare(page.flow.quoteSerial, 7)
|
||||||
|
compare(page.flow.newPositionQuote.quoteHash, "sha256:fresh")
|
||||||
|
compare(page.flow.quoteStale, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_base58SubmittedResultEntersSuccessState() {
|
||||||
|
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||||
|
"walletStateReady": true,
|
||||||
|
"submitResult": {
|
||||||
|
"schema": "new-position.v1",
|
||||||
|
"status": "submitted",
|
||||||
|
"transactionId": submittedTransactionId,
|
||||||
|
"deadlineMs": String(Date.now() + 60000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
var runtime = createTemporaryObject(runtimeComponent, testCase)
|
||||||
|
var page = createTemporaryObject(pageComponent, testCase, {
|
||||||
|
"backend": backend,
|
||||||
|
"runtime": runtime
|
||||||
|
})
|
||||||
|
verify(backend)
|
||||||
|
verify(runtime)
|
||||||
|
verify(page)
|
||||||
|
|
||||||
|
page.flow.confirm({
|
||||||
|
"request": ({}),
|
||||||
|
"quoteHash": "sha256:expected"
|
||||||
|
})
|
||||||
|
|
||||||
|
compare(page.flow.transactionId, submittedTransactionId)
|
||||||
|
compare(page.flow.flowErrorCode, "")
|
||||||
|
compare(page.flow.submitting, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_base58MissingPoolSubmissionStartsPoolWatch() {
|
||||||
|
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||||
|
"walletStateReady": true,
|
||||||
|
"submitResult": {
|
||||||
|
"schema": "new-position.v1",
|
||||||
|
"status": "submitted",
|
||||||
|
"transactionId": submittedTransactionId,
|
||||||
|
"deadlineMs": String(Date.now() + 60000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
var runtime = createTemporaryObject(runtimeComponent, testCase)
|
||||||
|
var page = createTemporaryObject(pageComponent, testCase, {
|
||||||
|
"backend": backend,
|
||||||
|
"runtime": runtime
|
||||||
|
})
|
||||||
|
verify(backend)
|
||||||
|
verify(runtime)
|
||||||
|
verify(page)
|
||||||
|
|
||||||
|
var probe = {
|
||||||
|
"tokenAId": "22222222222222222222222222222222",
|
||||||
|
"tokenBId": "33333333333333333333333333333333"
|
||||||
|
}
|
||||||
|
page.flow.pendingQuoteRequest = { "ok": true, "request": probe }
|
||||||
|
page.flow.confirm({
|
||||||
|
"request": {
|
||||||
|
"initialPriceRealRaw": "18446744073709551616"
|
||||||
|
},
|
||||||
|
"poolProbeRequest": probe,
|
||||||
|
"quoteHash": "sha256:expected"
|
||||||
|
})
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
compare(page.flow.transactionId, submittedTransactionId)
|
||||||
|
compare(page.flow.pendingPoolProbes.length, 1)
|
||||||
|
compare(page.flow.selectedPoolCreationPending(), true)
|
||||||
|
compare(page.flow.poolProbeInFlight, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_nativeHexSubmittedResultDoesNotEnterSuccessState() {
|
||||||
|
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||||
|
"walletStateReady": true,
|
||||||
|
"submitResult": {
|
||||||
|
"schema": "new-position.v1",
|
||||||
|
"status": "submitted",
|
||||||
|
"transactionId": "000102030405060708090a0b0c0d0e0f"
|
||||||
|
+ "101112131415161718191a1b1c1d1e1f"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
var runtime = createTemporaryObject(runtimeComponent, testCase)
|
||||||
|
var page = createTemporaryObject(pageComponent, testCase, {
|
||||||
|
"backend": backend,
|
||||||
|
"runtime": runtime
|
||||||
|
})
|
||||||
|
verify(backend)
|
||||||
|
verify(runtime)
|
||||||
|
verify(page)
|
||||||
|
|
||||||
|
page.flow.confirm({
|
||||||
|
"request": ({}),
|
||||||
|
"quoteHash": "sha256:expected"
|
||||||
|
})
|
||||||
|
|
||||||
|
compare(page.flow.transactionId, "")
|
||||||
|
compare(page.flow.flowErrorCode, "wallet_submission_failed")
|
||||||
|
compare(page.flow.submitting, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_poolProbeDoesNotPublishProbeAmountsAsCurrentQuote() {
|
||||||
|
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||||
|
"walletStateReady": true
|
||||||
|
})
|
||||||
|
var page = createTemporaryObject(pageComponent, testCase, {
|
||||||
|
"backend": backend
|
||||||
|
})
|
||||||
|
verify(backend)
|
||||||
|
verify(page)
|
||||||
|
|
||||||
|
var request = {
|
||||||
|
"tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
|
||||||
|
"tokenBId": "22222222222222222222222222222222"
|
||||||
|
}
|
||||||
|
var pending = { "key": page.flow.pairKey(request), "request": request }
|
||||||
|
page.flow.pendingQuoteRequest = { "ok": true, "request": request }
|
||||||
|
page.flow.pendingPoolProbes = [pending]
|
||||||
|
page.flow.newPositionQuote = {
|
||||||
|
"schema": "new-position.v1",
|
||||||
|
"status": "ok",
|
||||||
|
"poolStatus": "missing_pool",
|
||||||
|
"tokenAId": request.tokenAId,
|
||||||
|
"tokenBId": request.tokenBId
|
||||||
|
}
|
||||||
|
page.flow.quoteStale = false
|
||||||
|
|
||||||
|
page.flow.finishPoolProbe(pending, {
|
||||||
|
"schema": "new-position.v1",
|
||||||
|
"status": "ok",
|
||||||
|
"poolStatus": "active_pool",
|
||||||
|
"tokenAId": request.tokenAId,
|
||||||
|
"tokenBId": request.tokenBId
|
||||||
|
})
|
||||||
|
|
||||||
|
compare(page.flow.pendingPoolProbes.length, 0)
|
||||||
|
compare(page.flow.newPositionQuote.poolStatus, "missing_pool")
|
||||||
|
verify(page.flow.quoteStale)
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_poolProbeStopsBlockingAfterTransactionDeadline() {
|
||||||
|
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||||
|
"walletStateReady": true
|
||||||
|
})
|
||||||
|
var page = createTemporaryObject(pageComponent, testCase, {
|
||||||
|
"backend": backend
|
||||||
|
})
|
||||||
|
verify(backend)
|
||||||
|
verify(page)
|
||||||
|
|
||||||
|
var request = {
|
||||||
|
"tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
|
||||||
|
"tokenBId": "22222222222222222222222222222222"
|
||||||
|
}
|
||||||
|
var pending = {
|
||||||
|
"key": page.flow.pairKey(request),
|
||||||
|
"request": request,
|
||||||
|
"deadlineMs": Date.now() - 1
|
||||||
|
}
|
||||||
|
page.flow.pendingQuoteRequest = { "ok": true, "request": request }
|
||||||
|
page.flow.pendingPoolProbes = [pending]
|
||||||
|
page.flow.poolProbeInFlight = true
|
||||||
|
|
||||||
|
page.flow.finishPoolProbe(pending, null)
|
||||||
|
|
||||||
|
compare(page.flow.pendingPoolProbes.length, 0)
|
||||||
|
compare(page.flow.poolProbeInFlight, false)
|
||||||
|
verify(!page.flow.selectedPoolCreationPending())
|
||||||
|
}
|
||||||
|
}
|
||||||
629
apps/amm/tests/qml/tst_NewPositionForm.qml
Normal file
629
apps/amm/tests/qml/tst_NewPositionForm.qml
Normal file
@ -0,0 +1,629 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtTest
|
||||||
|
|
||||||
|
import "../../qml/components/liquidity" as Liquidity
|
||||||
|
|
||||||
|
TestCase {
|
||||||
|
id: testCase
|
||||||
|
|
||||||
|
name: "NewPositionForm"
|
||||||
|
|
||||||
|
readonly property string tokenLow: "22222222222222222222222222222222"
|
||||||
|
readonly property string tokenHigh: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
|
||||||
|
readonly property string tokenThird: "33333333333333333333333333333333"
|
||||||
|
readonly property string submittedTransactionId:
|
||||||
|
"1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: formComponent
|
||||||
|
|
||||||
|
Liquidity.NewPositionForm {
|
||||||
|
visible: false
|
||||||
|
width: 760
|
||||||
|
newPositionContext: testCase.readyContext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: clipboardSinkComponent
|
||||||
|
|
||||||
|
TextEdit {}
|
||||||
|
}
|
||||||
|
|
||||||
|
SignalSpy {
|
||||||
|
id: quoteRequestedSpy
|
||||||
|
signalName: "quoteRequested"
|
||||||
|
}
|
||||||
|
|
||||||
|
function readyContext() {
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"tokens": [
|
||||||
|
{
|
||||||
|
"definitionId": tokenLow,
|
||||||
|
"name": "Low",
|
||||||
|
"totalSupplyRaw": "1000000",
|
||||||
|
"balanceRaw": "1000",
|
||||||
|
"selectable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definitionId": tokenHigh,
|
||||||
|
"name": "High",
|
||||||
|
"totalSupplyRaw": "1000000000000",
|
||||||
|
"balanceRaw": "5000000000",
|
||||||
|
"selectable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function equalDecimalsContext() {
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"tokens": [
|
||||||
|
{
|
||||||
|
"definitionId": tokenLow,
|
||||||
|
"name": "Sir Mints-a-Lot",
|
||||||
|
"totalSupplyRaw": "1000000000000",
|
||||||
|
"balanceRaw": "1000000000",
|
||||||
|
"selectable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definitionId": tokenHigh,
|
||||||
|
"name": "Aurora",
|
||||||
|
"totalSupplyRaw": "1000000000000",
|
||||||
|
"balanceRaw": "1000000000",
|
||||||
|
"selectable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowState(quote) {
|
||||||
|
return {
|
||||||
|
"quote": quote || ({}),
|
||||||
|
"contextLoading": false,
|
||||||
|
"quoteLoading": false,
|
||||||
|
"quoteStale": false,
|
||||||
|
"submitting": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createForm() {
|
||||||
|
var form = createTemporaryObject(formComponent, testCase, {
|
||||||
|
"flowState": flowState(({}))
|
||||||
|
})
|
||||||
|
verify(form)
|
||||||
|
wait(0)
|
||||||
|
compare(form.selectedTokenAId, tokenLow)
|
||||||
|
compare(form.selectedTokenBId, tokenHigh)
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_displayOrderMapsToCanonicalRequestAfterSwap() {
|
||||||
|
var form = createForm()
|
||||||
|
var built = form.buildQuoteRequest()
|
||||||
|
verify(built.ok)
|
||||||
|
compare(built.request.tokenAId, tokenHigh)
|
||||||
|
compare(built.request.tokenBId, tokenLow)
|
||||||
|
|
||||||
|
form.swapTokens()
|
||||||
|
compare(form.selectedTokenAId, tokenHigh)
|
||||||
|
compare(form.selectedTokenBId, tokenLow)
|
||||||
|
built = form.buildQuoteRequest()
|
||||||
|
verify(built.ok)
|
||||||
|
compare(built.request.tokenAId, tokenHigh)
|
||||||
|
compare(built.request.tokenBId, tokenLow)
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_missingPoolKeepsMinimumRatioWhileEditingDeposit() {
|
||||||
|
var quote = {
|
||||||
|
"status": "ok",
|
||||||
|
"tokenAId": tokenHigh,
|
||||||
|
"tokenBId": tokenLow,
|
||||||
|
"poolStatus": "missing_pool",
|
||||||
|
"minimumAmountARaw": "2000000",
|
||||||
|
"minimumAmountBRaw": "3",
|
||||||
|
"actualAmountARaw": "2000000",
|
||||||
|
"actualAmountBRaw": "3"
|
||||||
|
}
|
||||||
|
var form = createForm()
|
||||||
|
form.priceAmountA = "3"
|
||||||
|
form.priceAmountB = "2"
|
||||||
|
form.flowState = flowState(quote)
|
||||||
|
wait(0)
|
||||||
|
compare(form.amountA, "3")
|
||||||
|
compare(form.amountB, "2")
|
||||||
|
|
||||||
|
form.editMissingAmount("A", "6")
|
||||||
|
compare(form.amountA, "6")
|
||||||
|
compare(form.amountB, "2")
|
||||||
|
|
||||||
|
form.finishMissingAmount("A", "6")
|
||||||
|
compare(form.amountB, "4")
|
||||||
|
|
||||||
|
var built = form.buildQuoteRequest()
|
||||||
|
verify(built.ok)
|
||||||
|
compare(built.request.amountARaw, "4000000")
|
||||||
|
compare(built.request.amountBRaw, "6")
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_missingPoolAcceptsLargeDirectAmountsFromEitherSide() {
|
||||||
|
var form = createTemporaryObject(formComponent, testCase, {
|
||||||
|
"flowState": flowState(({})),
|
||||||
|
"newPositionContext": equalDecimalsContext()
|
||||||
|
})
|
||||||
|
verify(form)
|
||||||
|
wait(0)
|
||||||
|
form.priceAmountA = "15"
|
||||||
|
form.priceAmountB = "10"
|
||||||
|
form.flowState = flowState({
|
||||||
|
"status": "ok",
|
||||||
|
"tokenAId": tokenHigh,
|
||||||
|
"tokenBId": tokenLow,
|
||||||
|
"poolStatus": "missing_pool",
|
||||||
|
"minimumAmountARaw": "26",
|
||||||
|
"minimumAmountBRaw": "39",
|
||||||
|
"actualAmountARaw": "26",
|
||||||
|
"actualAmountBRaw": "39"
|
||||||
|
})
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
form.finishMissingAmount("A", "150")
|
||||||
|
compare(form.amountA, "150")
|
||||||
|
compare(form.amountB, "100")
|
||||||
|
var built = form.buildQuoteRequest()
|
||||||
|
verify(built.ok)
|
||||||
|
compare(built.request.amountARaw, "100000000")
|
||||||
|
compare(built.request.amountBRaw, "150000000")
|
||||||
|
compare(built.request.initialPriceRealRaw, "27670116110564327424")
|
||||||
|
verify(!built.request.hasOwnProperty("depositScaleBps"))
|
||||||
|
|
||||||
|
form.finishMissingAmount("B", "200")
|
||||||
|
compare(form.amountA, "300")
|
||||||
|
compare(form.amountB, "200")
|
||||||
|
built = form.buildQuoteRequest()
|
||||||
|
verify(built.ok)
|
||||||
|
compare(built.request.amountARaw, "200000000")
|
||||||
|
compare(built.request.amountBRaw, "300000000")
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_missingPoolRoundsPairedAmountAndTrimsInputPrecision() {
|
||||||
|
var form = createTemporaryObject(formComponent, testCase, {
|
||||||
|
"flowState": flowState(({})),
|
||||||
|
"newPositionContext": equalDecimalsContext()
|
||||||
|
})
|
||||||
|
verify(form)
|
||||||
|
wait(0)
|
||||||
|
form.priceAmountA = "15"
|
||||||
|
form.priceAmountB = "10"
|
||||||
|
form.flowState = flowState({
|
||||||
|
"status": "ok",
|
||||||
|
"tokenAId": tokenHigh,
|
||||||
|
"tokenBId": tokenLow,
|
||||||
|
"poolStatus": "missing_pool",
|
||||||
|
"minimumAmountARaw": "26",
|
||||||
|
"minimumAmountBRaw": "39",
|
||||||
|
"actualAmountARaw": "26",
|
||||||
|
"actualAmountBRaw": "39"
|
||||||
|
})
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
var cases = [
|
||||||
|
{ "input": "1", "paired": "0.666667", "rawA": "666667", "rawB": "1000000",
|
||||||
|
"price": "27670102275513189667" },
|
||||||
|
{ "input": "2", "paired": "1.333333", "rawA": "1333333", "rawB": "2000000",
|
||||||
|
"price": "27670123028095084447" },
|
||||||
|
{ "input": "3", "paired": "2", "rawA": "2000000", "rawB": "3000000",
|
||||||
|
"price": "27670116110564327424" },
|
||||||
|
{ "input": "4", "paired": "2.666667", "rawA": "2666667", "rawB": "4000000",
|
||||||
|
"price": "27670112651800245948" }
|
||||||
|
]
|
||||||
|
for (var i = 0; i < cases.length; ++i) {
|
||||||
|
form.finishMissingAmount("A", cases[i].input)
|
||||||
|
compare(form.amountB, cases[i].paired)
|
||||||
|
var built = form.buildQuoteRequest()
|
||||||
|
verify(built.ok)
|
||||||
|
compare(built.request.amountARaw, cases[i].rawA)
|
||||||
|
compare(built.request.amountBRaw, cases[i].rawB)
|
||||||
|
compare(built.request.initialPriceRealRaw, cases[i].price)
|
||||||
|
}
|
||||||
|
|
||||||
|
form.finishMissingAmount("A", "1.1234567")
|
||||||
|
compare(form.amountA, "1.123456")
|
||||||
|
compare(form.amountB, "0.748971")
|
||||||
|
verify(form.buildQuoteRequest().ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_missingPoolUsesTradeStyleInputsWithInlinePrice() {
|
||||||
|
var form = createForm()
|
||||||
|
form.flowState = flowState({
|
||||||
|
"status": "ok",
|
||||||
|
"tokenAId": tokenHigh,
|
||||||
|
"tokenBId": tokenLow,
|
||||||
|
"poolStatus": "missing_pool",
|
||||||
|
"minimumAmountARaw": "2000000",
|
||||||
|
"minimumAmountBRaw": "3",
|
||||||
|
"actualAmountARaw": "2000000",
|
||||||
|
"actualAmountBRaw": "3"
|
||||||
|
})
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
var amountAInput = findChild(form, "tokenAAmountInput")
|
||||||
|
var amountBInput = findChild(form, "tokenBAmountInput")
|
||||||
|
verify(amountAInput)
|
||||||
|
verify(amountBInput)
|
||||||
|
compare(amountAInput.selectedTokenId, tokenLow)
|
||||||
|
compare(amountBInput.selectedTokenId, tokenHigh)
|
||||||
|
verify(amountAInput.height <= 114)
|
||||||
|
verify(amountBInput.height <= 114)
|
||||||
|
verify(findChild(form, "priceAmountAField"))
|
||||||
|
verify(findChild(form, "priceAmountBField"))
|
||||||
|
|
||||||
|
form.width = 328
|
||||||
|
wait(0)
|
||||||
|
compare(amountAInput.adjustmentWidth, 100)
|
||||||
|
compare(amountBInput.adjustmentWidth, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_errorMessageStaysAbovePairAndMarksCausingControl() {
|
||||||
|
var form = createForm()
|
||||||
|
form.flowState = flowState({
|
||||||
|
"status": "ok",
|
||||||
|
"tokenAId": tokenHigh,
|
||||||
|
"tokenBId": tokenLow,
|
||||||
|
"poolStatus": "missing_pool",
|
||||||
|
"minimumAmountARaw": "2000000",
|
||||||
|
"minimumAmountBRaw": "3",
|
||||||
|
"actualAmountARaw": "2000000",
|
||||||
|
"actualAmountBRaw": "3"
|
||||||
|
})
|
||||||
|
wait(0)
|
||||||
|
var amountAInput = findChild(form, "tokenAAmountInput")
|
||||||
|
var amountBInput = findChild(form, "tokenBAmountInput")
|
||||||
|
|
||||||
|
form.localErrors = [form.localIssue("amount_exceeds_balance", ["amountB"])]
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
compare(amountAInput.errorText, form.issueText("amount_exceeds_balance"))
|
||||||
|
compare(amountAInput.invalid, false)
|
||||||
|
compare(amountBInput.invalid, true)
|
||||||
|
|
||||||
|
form.resolveToken("B", "invalid")
|
||||||
|
wait(0)
|
||||||
|
compare(amountAInput.errorText, form.issueText("invalid_token_id"))
|
||||||
|
compare(amountAInput.tokenInvalid, false)
|
||||||
|
compare(amountBInput.tokenInvalid, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_staleMissingPoolQuoteDoesNotReplaceDraft() {
|
||||||
|
var quote = {
|
||||||
|
"status": "ok",
|
||||||
|
"tokenAId": tokenHigh,
|
||||||
|
"tokenBId": tokenLow,
|
||||||
|
"poolStatus": "missing_pool",
|
||||||
|
"minimumAmountARaw": "2000000",
|
||||||
|
"minimumAmountBRaw": "3",
|
||||||
|
"actualAmountARaw": "2000000",
|
||||||
|
"actualAmountBRaw": "3"
|
||||||
|
}
|
||||||
|
var form = createForm()
|
||||||
|
form.flowState = flowState(quote)
|
||||||
|
wait(0)
|
||||||
|
compare(form.amountA, "3")
|
||||||
|
|
||||||
|
form.editMissingAmount("A", "2")
|
||||||
|
form.flowState = {
|
||||||
|
"quote": {
|
||||||
|
"status": "ok",
|
||||||
|
"tokenAId": tokenHigh,
|
||||||
|
"tokenBId": tokenLow,
|
||||||
|
"poolStatus": "missing_pool",
|
||||||
|
"minimumAmountARaw": "2000000",
|
||||||
|
"minimumAmountBRaw": "3",
|
||||||
|
"actualAmountARaw": "2000000",
|
||||||
|
"actualAmountBRaw": "3"
|
||||||
|
},
|
||||||
|
"contextLoading": false,
|
||||||
|
"quoteLoading": false,
|
||||||
|
"quoteStale": true,
|
||||||
|
"submitting": false
|
||||||
|
}
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
compare(form.amountA, "2")
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_poolActivationClearsCreationDraftWithoutPublishingProbeAmounts() {
|
||||||
|
var form = createForm()
|
||||||
|
form.confirmedPoolStatus = "missing_pool"
|
||||||
|
form.amountA = "3"
|
||||||
|
form.amountB = "2"
|
||||||
|
form.minimumAmountARaw = "3"
|
||||||
|
form.minimumAmountBRaw = "2000000"
|
||||||
|
|
||||||
|
verify(form.acceptPoolActivation({
|
||||||
|
"schema": "new-position.v1",
|
||||||
|
"status": "ok",
|
||||||
|
"tokenAId": tokenHigh,
|
||||||
|
"tokenBId": tokenLow,
|
||||||
|
"poolStatus": "active_pool",
|
||||||
|
"reserveARaw": "3000000",
|
||||||
|
"reserveBRaw": "2"
|
||||||
|
}))
|
||||||
|
|
||||||
|
verify(form.activePool)
|
||||||
|
compare(form.activePoolQuote.poolStatus, "active_pool")
|
||||||
|
compare(form.amountA, "")
|
||||||
|
compare(form.amountB, "")
|
||||||
|
compare(form.minimumAmountARaw, "")
|
||||||
|
compare(form.minimumAmountBRaw, "")
|
||||||
|
quoteRequestedSpy.target = form
|
||||||
|
quoteRequestedSpy.clear()
|
||||||
|
form.requestQuote(true)
|
||||||
|
compare(quoteRequestedSpy.count, 0)
|
||||||
|
compare(form.localErrors.length, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_staleQuoteErrorsDoNotMarkCurrentDraft() {
|
||||||
|
var quote = {
|
||||||
|
"schema": "new-position.v1",
|
||||||
|
"status": "ok",
|
||||||
|
"tokenAId": tokenHigh,
|
||||||
|
"tokenBId": tokenLow,
|
||||||
|
"poolStatus": "active_pool",
|
||||||
|
"reserveARaw": "2000000",
|
||||||
|
"reserveBRaw": "10",
|
||||||
|
"maxAmountARaw": "4000000",
|
||||||
|
"maxAmountBRaw": "20",
|
||||||
|
"errors": [{
|
||||||
|
"code": "amount_exceeds_balance",
|
||||||
|
"blockingFields": ["maxAmountARaw"]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
var form = createForm()
|
||||||
|
form.flowState = flowState(quote)
|
||||||
|
wait(0)
|
||||||
|
compare(form.fieldError("amountB"), form.issueText("amount_exceeds_balance"))
|
||||||
|
|
||||||
|
var staleState = flowState(quote)
|
||||||
|
staleState.quoteStale = true
|
||||||
|
form.flowState = staleState
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
compare(form.fieldError("amountB"), "")
|
||||||
|
compare(form.formErrorText(), "")
|
||||||
|
compare(form.accountPreview().length, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_activePoolEditUsesDisplayReserveRatio() {
|
||||||
|
var quote = {
|
||||||
|
"status": "ok",
|
||||||
|
"tokenAId": tokenHigh,
|
||||||
|
"tokenBId": tokenLow,
|
||||||
|
"poolStatus": "active_pool",
|
||||||
|
"reserveARaw": "2000000",
|
||||||
|
"reserveBRaw": "10",
|
||||||
|
"maxAmountARaw": "4000000",
|
||||||
|
"maxAmountBRaw": "20"
|
||||||
|
}
|
||||||
|
var form = createForm()
|
||||||
|
form.flowState = flowState(quote)
|
||||||
|
wait(0)
|
||||||
|
compare(form.amountA, "20")
|
||||||
|
compare(form.amountB, "4")
|
||||||
|
|
||||||
|
quoteRequestedSpy.target = form
|
||||||
|
quoteRequestedSpy.clear()
|
||||||
|
form.editActiveAmount("A", "5")
|
||||||
|
compare(form.amountA, "5")
|
||||||
|
compare(form.amountB, "4")
|
||||||
|
compare(quoteRequestedSpy.count, 0)
|
||||||
|
|
||||||
|
form.finishActiveAmount("A", "5")
|
||||||
|
compare(form.amountB, "1")
|
||||||
|
compare(quoteRequestedSpy.count, 1)
|
||||||
|
|
||||||
|
var built = form.buildQuoteRequest()
|
||||||
|
verify(built.ok)
|
||||||
|
compare(built.request.maxAmountARaw, "1000000")
|
||||||
|
compare(built.request.maxAmountBRaw, "5")
|
||||||
|
|
||||||
|
form.finishActiveAmount("B", "1.1234567")
|
||||||
|
compare(form.amountB, "1.123456")
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_activePoolEditRecoversAfterInvalidQuote() {
|
||||||
|
var form = createForm()
|
||||||
|
form.flowState = flowState({
|
||||||
|
"status": "ok",
|
||||||
|
"tokenAId": tokenHigh,
|
||||||
|
"tokenBId": tokenLow,
|
||||||
|
"poolStatus": "active_pool",
|
||||||
|
"poolFeeBps": 30,
|
||||||
|
"reserveARaw": "2000000",
|
||||||
|
"reserveBRaw": "10",
|
||||||
|
"maxAmountARaw": "4000000",
|
||||||
|
"maxAmountBRaw": "20"
|
||||||
|
})
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
form.amountA = "0"
|
||||||
|
form.amountB = "0"
|
||||||
|
form.flowState = flowState({
|
||||||
|
"status": "error",
|
||||||
|
"code": "value_must_be_positive",
|
||||||
|
"tokenAId": tokenHigh,
|
||||||
|
"tokenBId": tokenLow
|
||||||
|
})
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
compare(form.poolFeeBps, 30)
|
||||||
|
form.finishActiveAmount("B", "1")
|
||||||
|
compare(form.amountA, "5")
|
||||||
|
|
||||||
|
var built = form.buildQuoteRequest()
|
||||||
|
verify(built.ok)
|
||||||
|
compare(built.request.maxAmountARaw, "1000000")
|
||||||
|
compare(built.request.maxAmountBRaw, "5")
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_quoteStateChangeDoesNotRequestAnotherQuote() {
|
||||||
|
var form = createForm()
|
||||||
|
quoteRequestedSpy.target = form
|
||||||
|
quoteRequestedSpy.clear()
|
||||||
|
|
||||||
|
form.flowState = {
|
||||||
|
"quote": ({}),
|
||||||
|
"contextLoading": false,
|
||||||
|
"quoteLoading": true,
|
||||||
|
"quoteStale": true,
|
||||||
|
"submitting": false
|
||||||
|
}
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
compare(quoteRequestedSpy.count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_existingPoolFeeCorrectionKeepsQuoteRequestValid() {
|
||||||
|
var form = createForm()
|
||||||
|
quoteRequestedSpy.target = form
|
||||||
|
quoteRequestedSpy.clear()
|
||||||
|
|
||||||
|
form.flowState = flowState({
|
||||||
|
"status": "error",
|
||||||
|
"code": "fee_tier_mismatch",
|
||||||
|
"tokenAId": tokenHigh,
|
||||||
|
"tokenBId": tokenLow,
|
||||||
|
"errors": [{
|
||||||
|
"code": "fee_tier_mismatch",
|
||||||
|
"details": { "poolFeeBps": "5" }
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
compare(form.selectedFeeBps, 5)
|
||||||
|
compare(quoteRequestedSpy.count, 1)
|
||||||
|
verify(quoteRequestedSpy.signalArguments[0][1].ok)
|
||||||
|
compare(quoteRequestedSpy.signalArguments[0][1].request.maxAmountARaw, "5000000000")
|
||||||
|
compare(quoteRequestedSpy.signalArguments[0][1].request.maxAmountBRaw, "1000")
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_contextFailureFinishesTokenResolution() {
|
||||||
|
var form = createForm()
|
||||||
|
form.resolvingTokenId = tokenThird
|
||||||
|
form.resolvingTokenSide = "A"
|
||||||
|
|
||||||
|
form.newPositionContext = {
|
||||||
|
"status": "error",
|
||||||
|
"code": "config_unavailable",
|
||||||
|
"tokens": []
|
||||||
|
}
|
||||||
|
form.finishTokenResolution(true)
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
compare(form.resolvingTokenId, "")
|
||||||
|
compare(form.resolvingTokenSide, "")
|
||||||
|
compare(form.tokenResolutionError, form.issueText("config_unavailable"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_staleContextDoesNotFinishNewerTokenResolution() {
|
||||||
|
var form = createForm()
|
||||||
|
form.resolvingTokenId = tokenThird
|
||||||
|
form.resolvingTokenSide = "B"
|
||||||
|
|
||||||
|
form.newPositionContext = {
|
||||||
|
"status": "ready",
|
||||||
|
"tokens": [{
|
||||||
|
"definitionId": tokenLow,
|
||||||
|
"name": "Earlier token",
|
||||||
|
"selectable": true
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
compare(form.resolvingTokenId, tokenThird)
|
||||||
|
compare(form.resolvingTokenSide, "B")
|
||||||
|
compare(form.tokenResolutionError, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_replacingSelectedTokensClearsPairDraft() {
|
||||||
|
var form = createForm()
|
||||||
|
form.amountA = "12"
|
||||||
|
form.amountB = "34"
|
||||||
|
form.minimumAmountARaw = "12"
|
||||||
|
form.minimumAmountBRaw = "34"
|
||||||
|
form.confirmedPoolStatus = "active_pool"
|
||||||
|
|
||||||
|
form.newPositionContext = {
|
||||||
|
"status": "ready",
|
||||||
|
"tokens": [
|
||||||
|
{
|
||||||
|
"definitionId": tokenHigh,
|
||||||
|
"name": "High",
|
||||||
|
"totalSupplyRaw": "1000000000000",
|
||||||
|
"balanceRaw": "5000000000",
|
||||||
|
"selectable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"definitionId": tokenThird,
|
||||||
|
"name": "Third",
|
||||||
|
"totalSupplyRaw": "1000000",
|
||||||
|
"balanceRaw": "100",
|
||||||
|
"selectable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
compare(form.selectedTokenAId, tokenHigh)
|
||||||
|
compare(form.selectedTokenBId, tokenThird)
|
||||||
|
compare(form.amountA, "")
|
||||||
|
compare(form.amountB, "")
|
||||||
|
compare(form.minimumAmountARaw, "")
|
||||||
|
compare(form.minimumAmountBRaw, "")
|
||||||
|
compare(form.confirmedPoolStatus, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_networkFailurePreservesPairDraft() {
|
||||||
|
var form = createForm()
|
||||||
|
form.amountA = "12"
|
||||||
|
form.amountB = "34"
|
||||||
|
form.confirmedPoolStatus = "active_pool"
|
||||||
|
|
||||||
|
form.newPositionContext = {
|
||||||
|
"status": "network_mismatch",
|
||||||
|
"tokens": []
|
||||||
|
}
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
compare(form.selectedTokenAId, tokenLow)
|
||||||
|
compare(form.selectedTokenBId, tokenHigh)
|
||||||
|
compare(form.amountA, "12")
|
||||||
|
compare(form.amountB, "34")
|
||||||
|
compare(form.confirmedPoolStatus, "active_pool")
|
||||||
|
verify(form.contextBlocksForm())
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_submittedBase58TransactionIdIsCopied() {
|
||||||
|
var state = flowState(({}))
|
||||||
|
state.transactionId = submittedTransactionId
|
||||||
|
var form = createTemporaryObject(formComponent, testCase, {
|
||||||
|
"flowState": state
|
||||||
|
})
|
||||||
|
var sink = createTemporaryObject(clipboardSinkComponent, testCase)
|
||||||
|
verify(form)
|
||||||
|
verify(sink)
|
||||||
|
wait(0)
|
||||||
|
|
||||||
|
compare(form.transactionId, submittedTransactionId)
|
||||||
|
var copyButton = findChild(form, "copySubmittedTransactionButton")
|
||||||
|
verify(copyButton)
|
||||||
|
copyButton.clicked()
|
||||||
|
verify(copyButton.copied)
|
||||||
|
sink.paste()
|
||||||
|
tryCompare(sink, "text", submittedTransactionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/amm/tests/qml/tst_ResponsivePopups.qml
Normal file
47
apps/amm/tests/qml/tst_ResponsivePopups.qml
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
280
apps/amm/tests/qml/tst_TokenAmountInput.qml
Normal file
280
apps/amm/tests/qml/tst_TokenAmountInput.qml
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user