mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-17 04:19:35 +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
e1685e423a
commit
21f6d818a7
@ -1,12 +1,30 @@
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
project(AmmUiPlugin LANGUAGES CXX)
|
||||
|
||||
find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2)
|
||||
qt_standard_project_setup(REQUIRES 6.8)
|
||||
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(BASE58 REQUIRED IMPORTED_TARGET libbase58)
|
||||
|
||||
include(CTest)
|
||||
|
||||
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
|
||||
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
|
||||
else()
|
||||
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
|
||||
endif()
|
||||
|
||||
set(LOGOS_WALLET_SOURCE_DIR
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../shared/wallet"
|
||||
CACHE PATH "Path to the shared Logos wallet module"
|
||||
)
|
||||
set(LOGOS_WALLET_GENERATED_DIR
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/generated_code"
|
||||
CACHE PATH "Path to generated Logos SDK sources"
|
||||
)
|
||||
add_subdirectory("${LOGOS_WALLET_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/shared-wallet")
|
||||
|
||||
# ui_qml module with a hand-written C++ backend (QtRO .rep view contract +
|
||||
# generated *SimpleSource/*ViewPluginBase). Mirrors the LEZ wallet UI module.
|
||||
logos_module(
|
||||
@ -18,14 +36,21 @@ logos_module(
|
||||
src/AmmUiPlugin.cpp
|
||||
src/AmmUiBackend.h
|
||||
src/AmmUiBackend.cpp
|
||||
src/AccountModel.h
|
||||
src/AccountModel.cpp
|
||||
src/ActiveNetwork.h
|
||||
src/ActiveNetwork.cpp
|
||||
src/AmmClient.h
|
||||
src/AmmClient.cpp
|
||||
src/NewPositionRuntime.h
|
||||
src/NewPositionRuntime.cpp
|
||||
FIND_PACKAGES
|
||||
Qt6Gui
|
||||
Qt6Network
|
||||
LINK_LIBRARIES
|
||||
Qt6::Gui
|
||||
Qt6::Network
|
||||
PkgConfig::BASE58
|
||||
LINK_TARGETS
|
||||
logos_wallet_access
|
||||
EXTERNAL_LIBS
|
||||
amm_client
|
||||
)
|
||||
@ -35,3 +60,25 @@ qt_add_resources(amm_ui_module_plugin amm_ui_config
|
||||
FILES
|
||||
config/networks.json
|
||||
)
|
||||
|
||||
if(BUILD_TESTING)
|
||||
add_executable(amm_active_network_test
|
||||
tests/cpp/ActiveNetworkTest.cpp
|
||||
src/ActiveNetwork.cpp
|
||||
)
|
||||
target_include_directories(amm_active_network_test PRIVATE src)
|
||||
target_link_libraries(amm_active_network_test PRIVATE Qt6::Core)
|
||||
add_test(NAME amm_active_network COMMAND amm_active_network_test)
|
||||
|
||||
add_executable(amm_new_position_runtime_test
|
||||
tests/cpp/NewPositionRuntimeTest.cpp
|
||||
src/NewPositionRuntime.cpp
|
||||
)
|
||||
target_include_directories(amm_new_position_runtime_test PRIVATE src)
|
||||
target_link_libraries(amm_new_position_runtime_test PRIVATE
|
||||
Qt6::Core
|
||||
PkgConfig::BASE58
|
||||
logos_wallet_access
|
||||
)
|
||||
add_test(NAME amm_new_position_runtime COMMAND amm_new_position_runtime_test)
|
||||
endif()
|
||||
|
||||
@ -1,16 +1,25 @@
|
||||
# AMM UI Validation
|
||||
|
||||
Validation for the New Position flow covers deterministic quote math, account
|
||||
construction, QML checks, and the module build.
|
||||
Validation for the New Position flow covers the shipped Rust client, QML
|
||||
syntax, and the complete module build.
|
||||
|
||||
## Commands
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
node apps/amm/tests/new-position-validation.mjs
|
||||
qmllint apps/amm/qml/pages/LiquidityPage.qml apps/amm/qml/components/liquidity/NewPositionForm.qml apps/amm/qml/components/liquidity/NewPositionConfirmationDialog.qml
|
||||
nix build ./apps/amm#packages.x86_64-linux.default --no-link
|
||||
cargo +1.94.0 test -p amm_client
|
||||
cargo +1.94.0 clippy -p amm_client --all-targets -- -D warnings
|
||||
logos_qml=$(nix build github:logos-co/logos-design-system/6176f0d7a5dfeb64a7f0f98e7ca2bf71a4804772 --no-link --print-out-paths)
|
||||
amm_qml=$(nix build ./apps/amm#packages.x86_64-linux.default --no-link --print-out-paths)
|
||||
qt_qml=$(nix-store --query --requisites "$amm_qml" | rg -m1 -- '-qtdeclarative-[0-9]')
|
||||
qt_svg=$(nix-store --query --requisites "$amm_qml" | rg -m1 -- '-qtsvg-[0-9]')
|
||||
export QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software QT_PLUGIN_PATH="$qt_svg/lib/qt-6/plugins"
|
||||
amm_import="$amm_qml/lib/qml"
|
||||
test -f "$amm_import/Logos/Wallet/qmldir"
|
||||
"$qt_qml/bin/qmltestrunner" -import "$qt_qml/lib/qt-6/qml" -import "$logos_qml/lib" -import "$amm_import" -input apps/shared/wallet/tests/qml
|
||||
"$qt_qml/bin/qmltestrunner" -import "$qt_qml/lib/qt-6/qml" -import "$logos_qml/lib" -import "$amm_import" -input apps/amm/tests/qml
|
||||
"$qt_qml/bin/qmllint" -I "$qt_qml/lib/qt-6/qml" -I "$logos_qml/lib" -I "$amm_qml/lib" -I "$amm_import" apps/amm/qml/pages/LiquidityPage.qml apps/amm/qml/components/liquidity/NewPositionForm.qml apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml
|
||||
```
|
||||
|
||||
When shared AMM program types or instruction signatures change, also run the
|
||||
@ -23,24 +32,23 @@ cargo +1.94.0 build -p idl-gen --release
|
||||
diff artifacts/amm-idl.json /tmp/amm-idl.json
|
||||
```
|
||||
|
||||
Live wallet/sequencer validation is manual until transaction dispatch has real
|
||||
token holding IDs. Use a local devnet wallet/sequencer when available, open the
|
||||
AMM UI, create or adopt one active account, preview both the active LOGOS/USDC
|
||||
pool and the missing USDC/WETH pool, then submit and verify either a successful
|
||||
transaction or the deterministic `submit_unavailable` backend response.
|
||||
Live wallet/sequencer validation uses the configured Network and an External
|
||||
Wallet Provider. Open the AMM UI, select two TokenProgram-scoped fungible token
|
||||
definitions, preview an active or missing Pool, submit, and verify the returned
|
||||
transaction ID is displayed.
|
||||
|
||||
## Acceptance Checklist
|
||||
|
||||
- Active wallet context exposes only holdings for the active account.
|
||||
- Context exposes Wallet-Scoped Holdings and configured token definitions.
|
||||
- Unsupported fee tiers are disabled and explain why.
|
||||
- Active pool fee tier is fixed to the stored pool fee.
|
||||
- Missing pool flow locks the user-selected price and scales from the minimum
|
||||
deposit that mints more than `MINIMUM_LIQUIDITY`.
|
||||
- Active pool flow keeps the reserve ratio and previews LP with
|
||||
`floor(min(supply * amount_a / reserve_a, supply * amount_b / reserve_b))`.
|
||||
- Missing pool flow accepts an editable `X Token A = Y Token B` ratio and
|
||||
scales either deposit from the minimum that mints more than
|
||||
`MINIMUM_LIQUIDITY`.
|
||||
- Active Pool flow keeps the reserve ratio and previews expected LP output.
|
||||
- Decimal inference keeps small supplies at 0 decimals, medium supplies at 6,
|
||||
large supplies at 9, and massive supplies at 18.
|
||||
- Human amount parse/format is reversible for 0, 6, 9, and 18 decimals.
|
||||
- Human amount parse/format preserves 0, 6, 9, and 18 decimal values.
|
||||
- `new_definition` and `add_liquidity` account lists match the committed AMM IDL
|
||||
order.
|
||||
- Submit re-quotes and surfaces `quote_changed` when the request no longer
|
||||
|
||||
@ -4,8 +4,24 @@ use nssa_core::{
|
||||
account::{Account, AccountId, Data, Nonce},
|
||||
program::ProgramId,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::model::AccountRead;
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct AccountRead {
|
||||
pub(crate) id: String,
|
||||
pub(crate) status: String,
|
||||
#[serde(default)]
|
||||
pub(crate) account: Option<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> {
|
||||
if value.len() != 64
|
||||
@ -123,8 +139,6 @@ pub fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), String
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn account_read(id: AccountId, account: &Account) -> AccountRead {
|
||||
use crate::model::WalletAccount;
|
||||
|
||||
AccountRead {
|
||||
id: account_id_hex(id),
|
||||
status: String::from("ok"),
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
mod account;
|
||||
mod model;
|
||||
mod protocol;
|
||||
|
||||
use std::{
|
||||
@ -9,13 +8,39 @@ use std::{
|
||||
panic::{catch_unwind, AssertUnwindSafe},
|
||||
};
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
|
||||
use crate::model::{
|
||||
ConfigIdRequest, ContextRequest, Envelope, PairIdsRequest, PlanRequest, QuoteRequest,
|
||||
TokenIdsRequest,
|
||||
use crate::protocol::{
|
||||
ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest, QuoteRequest, TokenIdsRequest,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Envelope {
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
value: Option<serde_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>(
|
||||
request: *const c_char,
|
||||
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": {
|
||||
"checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a",
|
||||
"ammProgramId": "d0c4c0ea9384928a97b65a63fcf9641639bb802e4e91819743360708ff12101e",
|
||||
"tokenDefinitionIds": []
|
||||
"ammProgramId": "77eeaa23668ad2675fb768cd7ecb1893387be464b9a51f16756006c1d307db07",
|
||||
"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": {
|
||||
"lastModified": 1782082589,
|
||||
"narHash": "sha256-Qeqxp0HYb3oTpmfD5YlFPJzpAJa7Ilb9o4sMeVvmHRI=",
|
||||
"lastModified": 1782920676,
|
||||
"narHash": "sha256-Vb81kiYbi8yYDZbUSW6v7QhV6uO/pj7F+lCAW1coAsY=",
|
||||
"owner": "logos-co",
|
||||
"repo": "logos-protocol",
|
||||
"rev": "315a3a2e0af61bc47aad5601ee44cd7689975820",
|
||||
"rev": "d7ad26d369c4e464a99f2a357f10c5947c7174e1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -26810,7 +26810,8 @@
|
||||
"inputs": {
|
||||
"amm_client": "amm_client",
|
||||
"logos-module-builder": "logos-module-builder",
|
||||
"logos_execution_zone": "logos_execution_zone"
|
||||
"logos_execution_zone": "logos_execution_zone",
|
||||
"shared_wallet": "shared_wallet"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
@ -26896,6 +26897,18 @@
|
||||
"rev": "e91187f8ccb5bbfc7bb00dac88169112428da78f",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"shared_wallet": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"path": "../shared/wallet",
|
||||
"type": "path"
|
||||
},
|
||||
"original": {
|
||||
"path": "../shared/wallet",
|
||||
"type": "path"
|
||||
},
|
||||
"parent": []
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
@ -11,8 +11,8 @@
|
||||
|
||||
"nix": {
|
||||
"packages": {
|
||||
"build": [],
|
||||
"runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"]
|
||||
"build": ["pkg-config"],
|
||||
"runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp", "libbase58"]
|
||||
},
|
||||
"external_libraries": [
|
||||
{ "name": "amm_client" }
|
||||
|
||||
@ -45,7 +45,7 @@ Item {
|
||||
height: show ? 32 : 0
|
||||
visible: height > 0
|
||||
clip: true
|
||||
color: Theme.palette.error
|
||||
color: Theme.palette.warning
|
||||
|
||||
Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
|
||||
|
||||
@ -56,7 +56,7 @@ Item {
|
||||
elide: Text.ElideMiddle
|
||||
font.pixelSize: 12
|
||||
font.weight: Font.Medium
|
||||
color: Theme.palette.text
|
||||
color: Theme.palette.background
|
||||
text: qsTr("Unable to connect to network")
|
||||
}
|
||||
}
|
||||
@ -88,12 +88,8 @@ Item {
|
||||
LiquidityPage {
|
||||
anchors.fill: parent
|
||||
backend: root.ready ? root.backend : null
|
||||
runtime: logos
|
||||
visible: navbar.currentIndex === 1
|
||||
}
|
||||
|
||||
CreatePoolPage {
|
||||
anchors.fill: parent
|
||||
visible: navbar.currentIndex === 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ Item {
|
||||
id: root
|
||||
|
||||
property int currentIndex: 0
|
||||
readonly property var tabs: ["Trade", "Liquidity", "Create Pool"]
|
||||
readonly property var tabs: ["Trade", "Liquidity"]
|
||||
|
||||
// Wallet wiring, passed down from Main.qml.
|
||||
property var backend: null
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
function divideCeil(left, right) {
|
||||
var result = divide(left, right)
|
||||
if (!result.valid || result.remainder === "0")
|
||||
return result.quotient
|
||||
return addSmall(result.quotient, 1)
|
||||
}
|
||||
|
||||
function addSmall(value, increment) {
|
||||
function increment(value) {
|
||||
var digits = normalize(value).split("")
|
||||
var carry = increment
|
||||
for (var i = digits.length - 1; i >= 0 && carry > 0; --i) {
|
||||
var sum = Number(digits[i]) + carry
|
||||
digits[i] = String(sum % 10)
|
||||
carry = Math.floor(sum / 10)
|
||||
for (var i = digits.length - 1; i >= 0; --i) {
|
||||
if (digits[i] !== "9") {
|
||||
digits[i] = String(Number(digits[i]) + 1)
|
||||
return digits.join("")
|
||||
}
|
||||
digits[i] = "0"
|
||||
}
|
||||
while (carry > 0) {
|
||||
digits.unshift(String(carry % 10))
|
||||
carry = Math.floor(carry / 10)
|
||||
}
|
||||
return normalize(digits.join(""))
|
||||
digits.unshift("1")
|
||||
return digits.join("")
|
||||
}
|
||||
|
||||
function pow10(exponent) {
|
||||
@ -172,6 +163,17 @@ function parseHuman(text, decimals) {
|
||||
return { "ok": true, "code": "", "raw": raw }
|
||||
}
|
||||
|
||||
function trimHumanPrecision(text, decimals) {
|
||||
var value = String(text)
|
||||
var match = /^(0|[1-9][0-9]*)(?:\.([0-9]*))?$/.exec(value)
|
||||
if (!match)
|
||||
return value
|
||||
var fraction = match[2] || ""
|
||||
if (fraction.length <= decimals)
|
||||
return value
|
||||
return decimals > 0 ? match[1] + "." + fraction.slice(0, decimals) : match[1]
|
||||
}
|
||||
|
||||
function formatRaw(rawValue, decimals) {
|
||||
if (!isUnsigned(rawValue))
|
||||
return ""
|
||||
@ -204,19 +206,36 @@ function parsePrice(text) {
|
||||
}
|
||||
}
|
||||
|
||||
function priceToQ64(text, canonicalDecimalsA, canonicalDecimalsB, displayIsCanonical) {
|
||||
var parsed = parsePrice(text)
|
||||
if (!parsed.ok)
|
||||
return { "ok": false, "code": parsed.code, "raw": "" }
|
||||
function parseRatio(amountA, amountB) {
|
||||
var parsedA = parsePrice(amountA)
|
||||
if (!parsedA.ok)
|
||||
return { "ok": false, "code": parsedA.code }
|
||||
var parsedB = parsePrice(amountB)
|
||||
if (!parsedB.ok)
|
||||
return { "ok": false, "code": parsedB.code }
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"code": "",
|
||||
"numerator": multiply(parsedB.numerator, pow10(parsedA.scale)),
|
||||
"denominator": multiply(parsedA.numerator, pow10(parsedB.scale))
|
||||
}
|
||||
}
|
||||
|
||||
function ratioToQ64(amountA, amountB, canonicalDecimalsA, canonicalDecimalsB,
|
||||
displayIsCanonical) {
|
||||
var ratio = parseRatio(amountA, amountB)
|
||||
if (!ratio.ok)
|
||||
return { "ok": false, "code": ratio.code, "raw": "" }
|
||||
|
||||
var numerator
|
||||
var denominator
|
||||
if (displayIsCanonical) {
|
||||
numerator = multiply(multiply(parsed.numerator, Q64), pow10(canonicalDecimalsB))
|
||||
denominator = pow10(parsed.scale + canonicalDecimalsA)
|
||||
numerator = multiply(multiply(ratio.numerator, Q64), pow10(canonicalDecimalsB))
|
||||
denominator = multiply(ratio.denominator, pow10(canonicalDecimalsA))
|
||||
} else {
|
||||
numerator = multiply(multiply(pow10(parsed.scale), Q64), pow10(canonicalDecimalsB))
|
||||
denominator = multiply(parsed.numerator, pow10(canonicalDecimalsA))
|
||||
numerator = multiply(multiply(ratio.denominator, Q64), pow10(canonicalDecimalsB))
|
||||
denominator = multiply(ratio.numerator, pow10(canonicalDecimalsA))
|
||||
}
|
||||
var raw = divide(numerator, denominator).quotient
|
||||
if (raw === "0")
|
||||
@ -226,6 +245,39 @@ function priceToQ64(text, canonicalDecimalsA, canonicalDecimalsB, displayIsCanon
|
||||
return { "ok": true, "code": "", "raw": raw }
|
||||
}
|
||||
|
||||
function pairAmount(raw, fromA, decimalsA, decimalsB, priceAmountA, priceAmountB) {
|
||||
if (!isUnsigned(raw))
|
||||
return { "ok": false, "code": "invalid_raw_amount", "raw": "" }
|
||||
var ratio = parseRatio(priceAmountA, priceAmountB)
|
||||
if (!ratio.ok)
|
||||
return { "ok": false, "code": ratio.code, "raw": "" }
|
||||
|
||||
var numerator
|
||||
var denominator
|
||||
if (fromA) {
|
||||
numerator = multiply(multiply(raw, ratio.numerator), pow10(decimalsB))
|
||||
denominator = multiply(ratio.denominator, pow10(decimalsA))
|
||||
} else {
|
||||
numerator = multiply(multiply(raw, ratio.denominator), pow10(decimalsA))
|
||||
denominator = multiply(ratio.numerator, pow10(decimalsB))
|
||||
}
|
||||
var result = divide(numerator, denominator)
|
||||
if (!result.valid)
|
||||
return { "ok": false, "code": "invalid_raw_amount", "raw": "" }
|
||||
var rounded = compare(multiply(result.remainder, "2"), denominator) >= 0
|
||||
? increment(result.quotient) : result.quotient
|
||||
if (rounded === "0")
|
||||
return { "ok": false, "code": "amount_too_low", "raw": "" }
|
||||
if (compare(rounded, U128_MAX) > 0)
|
||||
return { "ok": false, "code": "invalid_raw_amount", "raw": "" }
|
||||
return { "ok": true, "code": "", "raw": rounded }
|
||||
}
|
||||
|
||||
function ratioValue(amountA, amountB, precision) {
|
||||
var ratio = parseRatio(amountA, amountB)
|
||||
return ratio.ok ? formatRatio(ratio.numerator, ratio.denominator, precision) : ""
|
||||
}
|
||||
|
||||
function formatRatio(numerator, denominator, precision) {
|
||||
var result = divide(numerator, denominator)
|
||||
if (!result.valid)
|
||||
@ -260,10 +312,6 @@ function mulDivFloor(left, right, denominator) {
|
||||
return divide(multiply(left, right), denominator).quotient
|
||||
}
|
||||
|
||||
function mulDivCeil(left, right, denominator) {
|
||||
return divideCeil(multiply(left, right), denominator)
|
||||
}
|
||||
|
||||
function toU32(value) {
|
||||
if (!isUnsigned(value) || compare(value, U32_MAX) > 0)
|
||||
return -1
|
||||
|
||||
@ -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
|
||||
|
||||
property var snapshot: ({})
|
||||
readonly property bool isAdd: root.snapshot.action === "add"
|
||||
|
||||
spacing: 8
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
visible: root.isAdd
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "")
|
||||
value: root.snapshot.depositA || ""
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "")
|
||||
value: root.snapshot.depositB || ""
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Receive at least")
|
||||
value: root.snapshot.minLpReceived || ""
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Current ratio")
|
||||
value: root.snapshot.currentRatio || ""
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Fee tier")
|
||||
value: root.snapshot.feeTier || ""
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Slippage tolerance")
|
||||
value: root.snapshot.slippageTolerance || ""
|
||||
}
|
||||
function actionText(instruction) {
|
||||
if (instruction === "NewDefinition")
|
||||
return qsTr("Create pool")
|
||||
if (instruction === "AddLiquidity")
|
||||
return qsTr("Add liquidity")
|
||||
return instruction || "-"
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
visible: !root.isAdd
|
||||
label: qsTr("Pair")
|
||||
value: root.snapshot.pairText || "-"
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Burn LP")
|
||||
value: qsTr("%1 (%2)")
|
||||
.arg(root.snapshot.burnText || "")
|
||||
.arg(root.snapshot.burnPercent || "")
|
||||
}
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Action")
|
||||
value: root.actionText(root.snapshot.instruction)
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "")
|
||||
value: root.snapshot.minTokenAReceived || ""
|
||||
}
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Fee")
|
||||
value: root.snapshot.feeText || "-"
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "")
|
||||
value: root.snapshot.minTokenBReceived || ""
|
||||
}
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Deposit")
|
||||
value: qsTr("%1 + %2")
|
||||
.arg(root.snapshot.depositAText || "-")
|
||||
.arg(root.snapshot.depositBText || "-")
|
||||
valueWrapAnywhere: true
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Slippage tolerance")
|
||||
value: root.snapshot.slippageTolerance || ""
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Post-removal share")
|
||||
value: root.snapshot.postRemovalShare || ""
|
||||
}
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Expected LP")
|
||||
value: root.snapshot.expectedLpText || "-"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 value: ""
|
||||
property bool valueWrapAnywhere: false
|
||||
property bool estimated: false
|
||||
property string estimateHelp: qsTr("This value is derived from your LP token balance, total LP supply, and current pool reserves.")
|
||||
|
||||
@ -36,14 +37,16 @@ Item {
|
||||
|
||||
Text {
|
||||
color: "#E7E1D8"
|
||||
elide: Text.ElideRight
|
||||
elide: root.valueWrapAnywhere ? Text.ElideNone : Text.ElideRight
|
||||
font.bold: true
|
||||
font.pixelSize: 12
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: root.value
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
wrapMode: root.valueWrapAnywhere ? Text.WrapAtWordBoundaryOrAnywhere : Text.NoWrap
|
||||
|
||||
Layout.maximumWidth: Math.max(178, root.width * 0.55)
|
||||
Layout.preferredWidth: Math.min(implicitWidth, Math.max(178, root.width * 0.55))
|
||||
}
|
||||
|
||||
EstimateInfoButton {
|
||||
|
||||
@ -1,165 +1,135 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
Rectangle {
|
||||
import QtQuick
|
||||
|
||||
import "../shared"
|
||||
|
||||
AmmTokenAmountSurface {
|
||||
id: root
|
||||
|
||||
property alias text: amountField.text
|
||||
property string text: ""
|
||||
property string balance: ""
|
||||
property string errorText: ""
|
||||
property string helperText: ""
|
||||
property string label: ""
|
||||
property bool readOnly: false
|
||||
property bool showMaxButton: true
|
||||
property string token: ""
|
||||
property var tokenData: null
|
||||
property var tokens: []
|
||||
property string selectedTokenId: ""
|
||||
property bool tokenInvalid: false
|
||||
property bool tokenSelectionEnabled: true
|
||||
property bool editPending: false
|
||||
property string pendingValue: ""
|
||||
property var disabledReasonForCode: function(code) {
|
||||
return qsTr("This token is unavailable (%1).").arg(code || "unknown")
|
||||
}
|
||||
property var detailForToken: function(token) { return "" }
|
||||
property alias popup: tokenModal
|
||||
property alias query: tokenModal.searchText
|
||||
readonly property var rows: tokenModal.rows
|
||||
|
||||
signal editingChanged(string value)
|
||||
signal editingCommitted(string value)
|
||||
signal maxClicked
|
||||
signal tokenSelected(string tokenId)
|
||||
signal tokenEntered(string value)
|
||||
|
||||
color: "#151515"
|
||||
implicitHeight: content.implicitHeight + 20
|
||||
radius: 8
|
||||
border.color: root.errorText.length > 0 ? "#D85F4B" : amountField.activeFocus ? "#F26A21" : "#343434"
|
||||
border.width: 1
|
||||
amount: root.text
|
||||
supportingText: root.helperText
|
||||
supportingActionText: root.showMaxButton ? qsTr("MAX") : ""
|
||||
accessory: tokenActions
|
||||
accessoryWidth: width < 360 ? 132 : 180
|
||||
accessoryHeight: root.balance.length > 0 ? 58 : 40
|
||||
|
||||
Accessible.name: root.label
|
||||
Accessible.role: Accessible.EditableText
|
||||
|
||||
ColumnLayout {
|
||||
id: content
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 8
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
Text {
|
||||
color: "#A9A098"
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: 12
|
||||
text: root.label
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
color: "#E7E1D8"
|
||||
elide: Text.ElideRight
|
||||
font.bold: true
|
||||
font.pixelSize: 12
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: root.token
|
||||
|
||||
Layout.maximumWidth: 76
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
TextField {
|
||||
id: amountField
|
||||
|
||||
activeFocusOnTab: true
|
||||
color: "#E7E1D8"
|
||||
font.bold: true
|
||||
font.pixelSize: 18
|
||||
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
||||
maximumLength: 80
|
||||
placeholderText: qsTr("0")
|
||||
readOnly: root.readOnly
|
||||
selectByMouse: true
|
||||
selectedTextColor: "#151515"
|
||||
selectionColor: "#F26A21"
|
||||
validator: RegularExpressionValidator {
|
||||
regularExpression: /[0-9]*([.][0-9]*)?/
|
||||
}
|
||||
|
||||
Accessible.name: root.label
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: 44
|
||||
|
||||
onTextEdited: {
|
||||
if (!root.readOnly)
|
||||
root.editingChanged(text);
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
border.color: amountField.activeFocus ? "#F26A21" : "#343434"
|
||||
border.width: 1
|
||||
color: amountField.activeFocus ? "#1F1B18" : "#101010"
|
||||
radius: 6
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: maxButton
|
||||
|
||||
activeFocusOnTab: true
|
||||
enabled: root.showMaxButton && !root.readOnly
|
||||
focusPolicy: Qt.StrongFocus
|
||||
hoverEnabled: true
|
||||
text: qsTr("MAX")
|
||||
visible: root.showMaxButton
|
||||
|
||||
Accessible.name: qsTr("Use maximum %1 balance").arg(root.token)
|
||||
|
||||
Layout.minimumHeight: 44
|
||||
Layout.preferredWidth: visible ? 58 : 0
|
||||
|
||||
onClicked: root.maxClicked()
|
||||
|
||||
contentItem: Text {
|
||||
color: maxButton.activeFocus || maxButton.hovered ? "#151515" : "#F26A21"
|
||||
font.bold: true
|
||||
font.pixelSize: 11
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: maxButton.text
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
border.color: "#F26A21"
|
||||
border.width: 1
|
||||
color: maxButton.pressed ? "#D95C1E" : maxButton.hovered || maxButton.activeFocus ? "#F26A21" : "#201712"
|
||||
radius: 6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
Text {
|
||||
color: root.errorText.length > 0 ? "#F08A76" : root.helperText.length > 0 ? "#F26A21" : "#A9A098"
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: 11
|
||||
text: root.errorText.length > 0 ? root.errorText : root.helperText
|
||||
visible: text.length > 0
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
color: "#A9A098"
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: 11
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: qsTr("Balance %1").arg(root.balance)
|
||||
|
||||
Layout.alignment: Qt.AlignRight
|
||||
Layout.maximumWidth: 150
|
||||
}
|
||||
onAmountEdited: function(value) {
|
||||
root.pendingValue = value
|
||||
root.editPending = true
|
||||
root.editingChanged(value)
|
||||
commitTimer.restart()
|
||||
}
|
||||
onAmountEditingFinished: function(value) {
|
||||
root.pendingValue = value
|
||||
root.commitPendingEdit()
|
||||
}
|
||||
onSupportingActionClicked: root.maxClicked()
|
||||
onTextChanged: {
|
||||
if (root.editPending && root.text !== root.pendingValue) {
|
||||
commitTimer.stop()
|
||||
root.editPending = false
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: commitTimer
|
||||
|
||||
interval: 250
|
||||
repeat: false
|
||||
onTriggered: root.commitPendingEdit()
|
||||
}
|
||||
|
||||
Component {
|
||||
id: tokenActions
|
||||
|
||||
AmmTokenAccessory {
|
||||
theme: root.theme
|
||||
enabled: root.tokenSelectionEnabled
|
||||
invalid: root.tokenInvalid
|
||||
hasToken: root.tokenData !== null
|
||||
tokenColor: root.tokenColor(root.tokenData)
|
||||
tokenLetter: root.tokenLetter(root.tokenData)
|
||||
tokenText: root.tokenText(root.tokenData)
|
||||
balance: root.balance
|
||||
accessibleName: qsTr("Select %1").arg(root.label)
|
||||
onClicked: tokenModal.open()
|
||||
}
|
||||
}
|
||||
|
||||
TokenSelectorModal {
|
||||
id: tokenModal
|
||||
|
||||
theme: root.theme
|
||||
tokens: root.tokens
|
||||
title: qsTr("Select a token")
|
||||
searchPlaceholder: qsTr("Search name or address")
|
||||
popularTitle: qsTr("Quick select")
|
||||
listTitle: qsTr("All tokens")
|
||||
allowCustomEntry: true
|
||||
disabledReasonForCode: root.disabledReasonForCode
|
||||
detailForToken: root.detailForToken
|
||||
|
||||
onTokenSelected: function(token) {
|
||||
root.tokenSelected(String(token.definitionId || token.address || ""))
|
||||
}
|
||||
onTokenEntered: function(value) { root.tokenEntered(value) }
|
||||
}
|
||||
|
||||
function acceptInput(value) {
|
||||
tokenModal.acceptInput(value)
|
||||
}
|
||||
|
||||
function commitPendingEdit() {
|
||||
if (!root.editPending)
|
||||
return
|
||||
commitTimer.stop()
|
||||
root.editPending = false
|
||||
root.editingCommitted(root.pendingValue)
|
||||
}
|
||||
|
||||
function tokenText(token) {
|
||||
if (!token)
|
||||
return qsTr("Select token")
|
||||
return String(token.symbol || token.name || root.shortId(root.selectedTokenId))
|
||||
}
|
||||
|
||||
function tokenLetter(token) {
|
||||
var text = root.tokenText(token)
|
||||
return token ? String(token.letter || text.charAt(0).toUpperCase()) : ""
|
||||
}
|
||||
|
||||
function tokenColor(token) {
|
||||
return token && token.color ? token.color : root.theme.colors.noTokenCircle
|
||||
}
|
||||
|
||||
function shortId(value) {
|
||||
var text = String(value || "")
|
||||
return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text
|
||||
}
|
||||
}
|
||||
|
||||
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 QtQml
|
||||
import Logos.Wallet
|
||||
|
||||
import "../components/liquidity"
|
||||
import "../state"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var backend: null
|
||||
property var newPositionContext: ({
|
||||
"schema": "new-position.v1",
|
||||
"status": "loading",
|
||||
"tokens": [],
|
||||
"feeTiers": []
|
||||
})
|
||||
property var newPositionQuote: ({})
|
||||
property var resolvedTokenIds: []
|
||||
property int quoteSerial: 0
|
||||
property bool componentReady: false
|
||||
property bool contextLoading: false
|
||||
property bool quoteLoading: false
|
||||
property bool quoteStale: true
|
||||
property bool submitting: false
|
||||
property bool refreshingAfterSuccess: false
|
||||
property string transactionId: ""
|
||||
property string refreshWarning: ""
|
||||
property string lastContextJson: ""
|
||||
property var runtime: null
|
||||
readonly property NewPositionFlow flow: newPositionFlow
|
||||
|
||||
readonly property int pageMargin: 16
|
||||
readonly property int preferredWidth: 800
|
||||
readonly property int preferredWidth: 480
|
||||
|
||||
Component.onCompleted: {
|
||||
root.componentReady = true
|
||||
root.refreshNewPositionContext()
|
||||
AmmTheme {
|
||||
id: theme
|
||||
}
|
||||
|
||||
onBackendChanged: {
|
||||
if (root.componentReady)
|
||||
root.refreshNewPositionContext()
|
||||
}
|
||||
NewPositionFlow {
|
||||
id: newPositionFlow
|
||||
|
||||
Connections {
|
||||
target: root.backend
|
||||
ignoreUnknownSignals: true
|
||||
|
||||
function onNewPositionContextChanged(value) {
|
||||
root.adoptContext(value)
|
||||
}
|
||||
backend: root.backend
|
||||
runtime: root.runtime
|
||||
active: root.visible
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "#151515"
|
||||
color: theme.colors.background
|
||||
}
|
||||
|
||||
Flickable {
|
||||
@ -60,7 +39,7 @@ Item {
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: Math.max(height, form.y + form.implicitHeight + root.pageMargin)
|
||||
enabled: !confirmationDialog.open
|
||||
enabled: !confirmationDialog.opened
|
||||
flickableDirection: Flickable.VerticalFlick
|
||||
|
||||
NewPositionForm {
|
||||
@ -69,18 +48,12 @@ Item {
|
||||
x: Math.max(root.pageMargin, (scroll.width - width) / 2)
|
||||
y: root.pageMargin
|
||||
width: Math.max(0, Math.min(root.preferredWidth, scroll.width - root.pageMargin * 2))
|
||||
theme: theme
|
||||
newPositionContext: newPositionFlow.newPositionContext
|
||||
flowState: newPositionFlow.viewState
|
||||
|
||||
newPositionContext: root.newPositionContext
|
||||
quotePayload: root.newPositionQuote
|
||||
contextLoading: root.contextLoading
|
||||
quoteLoading: root.quoteLoading
|
||||
quoteStale: root.quoteStale
|
||||
submitting: root.submitting
|
||||
transactionId: root.transactionId
|
||||
refreshWarning: root.refreshWarning
|
||||
|
||||
onQuoteRequested: function(immediate) {
|
||||
root.scheduleQuote(immediate)
|
||||
onQuoteRequested: function(immediate, quoteRequest) {
|
||||
newPositionFlow.scheduleQuote(immediate, quoteRequest)
|
||||
}
|
||||
|
||||
onConfirmationRequested: function(snapshot) {
|
||||
@ -88,217 +61,51 @@ Item {
|
||||
}
|
||||
|
||||
onTokenResolveRequested: function(tokenId) {
|
||||
root.resolveToken(tokenId)
|
||||
newPositionFlow.resolveToken(tokenId)
|
||||
}
|
||||
|
||||
onDraftChanged: {
|
||||
root.transactionId = ""
|
||||
root.refreshWarning = ""
|
||||
}
|
||||
|
||||
onRefreshRequested: root.refreshNewPositionContext()
|
||||
onDraftChanged: newPositionFlow.draftChanged()
|
||||
onRefreshRequested: newPositionFlow.refreshContext(true)
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: quoteDebounce
|
||||
interval: 250
|
||||
repeat: false
|
||||
onTriggered: root.requestQuoteNow(root.quoteSerial)
|
||||
Connections {
|
||||
target: newPositionFlow
|
||||
|
||||
function onTokenResolutionFinished(finalResponse) {
|
||||
form.finishTokenResolution(finalResponse)
|
||||
}
|
||||
|
||||
function onTokenResolutionFailed(code) {
|
||||
form.failTokenResolution(code)
|
||||
}
|
||||
|
||||
function onPoolActivated(quote) {
|
||||
form.acceptPoolActivation(quote)
|
||||
}
|
||||
|
||||
function onQuoteRefreshRequested(immediate) {
|
||||
form.requestQuote(immediate)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
NewPositionConfirmationDialog {
|
||||
Component {
|
||||
id: liquidityConfirmationSummary
|
||||
|
||||
LiquidityConfirmationSummary { }
|
||||
}
|
||||
|
||||
TransactionConfirmationDialog {
|
||||
id: confirmationDialog
|
||||
|
||||
anchors.fill: parent
|
||||
busy: root.submitting
|
||||
title: qsTr("Confirm new position")
|
||||
confirmText: qsTr("Submit")
|
||||
busy: newPositionFlow.submitting
|
||||
summary: liquidityConfirmationSummary
|
||||
|
||||
onConfirmed: function(snapshot) {
|
||||
root.confirmNewPosition(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
function contextHints() {
|
||||
var recent = []
|
||||
if (form.selectedTokenAId.length > 0)
|
||||
recent.push(form.selectedTokenAId)
|
||||
if (form.selectedTokenBId.length > 0
|
||||
&& form.selectedTokenBId !== form.selectedTokenAId) {
|
||||
recent.push(form.selectedTokenBId)
|
||||
}
|
||||
return {
|
||||
"recentTokenIds": recent,
|
||||
"resolvedTokenIds": root.resolvedTokenIds
|
||||
}
|
||||
}
|
||||
|
||||
function refreshNewPositionContext() {
|
||||
root.contextLoading = true
|
||||
if (!root.backend || typeof logos === "undefined") {
|
||||
root.contextLoading = false
|
||||
root.newPositionContext = root.contextError("wallet_unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
logos.watch(root.backend.refreshNewPositionContext(root.contextHints()),
|
||||
function(context) {
|
||||
root.adoptContext(context)
|
||||
if (root.refreshingAfterSuccess) {
|
||||
root.refreshingAfterSuccess = false
|
||||
root.refreshWarning = context.status === "ready" || context.status === "no_wallet"
|
||||
? ""
|
||||
: qsTr("Balances could not be refreshed.")
|
||||
}
|
||||
},
|
||||
function(error) {
|
||||
root.contextLoading = false
|
||||
if (root.refreshingAfterSuccess) {
|
||||
root.refreshingAfterSuccess = false
|
||||
root.refreshWarning = qsTr("Balances could not be refreshed.")
|
||||
} else {
|
||||
root.newPositionContext = root.contextError("backend_error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function adoptContext(context) {
|
||||
if (!context || context.schema !== "new-position.v1")
|
||||
context = root.contextError("unsupported_schema")
|
||||
var serialized = JSON.stringify(context)
|
||||
root.contextLoading = false
|
||||
if (serialized === root.lastContextJson)
|
||||
return
|
||||
root.lastContextJson = serialized
|
||||
root.newPositionContext = context
|
||||
root.quoteStale = true
|
||||
++root.quoteSerial
|
||||
}
|
||||
|
||||
function resolveToken(tokenId) {
|
||||
var value = String(tokenId || "").trim()
|
||||
if (value.length === 0)
|
||||
return
|
||||
if (root.resolvedTokenIds.indexOf(value) < 0) {
|
||||
var next = root.resolvedTokenIds.slice(0)
|
||||
next.push(value)
|
||||
root.resolvedTokenIds = next
|
||||
}
|
||||
root.refreshNewPositionContext()
|
||||
}
|
||||
|
||||
function scheduleQuote(immediate) {
|
||||
++root.quoteSerial
|
||||
root.quoteStale = true
|
||||
root.quoteLoading = true
|
||||
quoteDebounce.stop()
|
||||
if (immediate)
|
||||
root.requestQuoteNow(root.quoteSerial)
|
||||
else
|
||||
quoteDebounce.restart()
|
||||
}
|
||||
|
||||
function requestQuoteNow(serial) {
|
||||
if (serial !== root.quoteSerial)
|
||||
return
|
||||
var built = form.buildQuoteRequest()
|
||||
if (!built.ok) {
|
||||
root.quoteLoading = false
|
||||
return
|
||||
}
|
||||
if (!root.backend || typeof logos === "undefined") {
|
||||
root.quoteLoading = false
|
||||
root.newPositionQuote = root.quoteError("wallet_unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
logos.watch(root.backend.quoteNewPosition(built.request),
|
||||
function(quote) {
|
||||
if (serial !== root.quoteSerial)
|
||||
return
|
||||
root.quoteLoading = false
|
||||
root.quoteStale = false
|
||||
if (!quote || quote.schema !== "new-position.v1")
|
||||
root.newPositionQuote = root.quoteError("unsupported_schema")
|
||||
else
|
||||
root.newPositionQuote = quote
|
||||
},
|
||||
function(error) {
|
||||
if (serial !== root.quoteSerial)
|
||||
return
|
||||
root.quoteLoading = false
|
||||
root.quoteStale = true
|
||||
form.submitError = qsTr("Quote request failed. Refresh and retry.")
|
||||
})
|
||||
}
|
||||
|
||||
function confirmNewPosition(snapshot) {
|
||||
if (root.submitting)
|
||||
return
|
||||
root.submitting = true
|
||||
form.submitError = ""
|
||||
|
||||
if (!root.backend || typeof logos === "undefined") {
|
||||
root.finishSubmitFailure(root.quoteError("wallet_unavailable"))
|
||||
return
|
||||
}
|
||||
|
||||
logos.watch(root.backend.submitNewPosition(snapshot.request, snapshot.quoteHash),
|
||||
function(result) {
|
||||
if (result && result.schema === "new-position.v1"
|
||||
&& result.status === "submitted"
|
||||
&& /^[0-9a-f]{64}$/.test(String(result.transactionId || ""))) {
|
||||
root.submitting = false
|
||||
confirmationDialog.closeAfterSuccess()
|
||||
root.transactionId = result.transactionId
|
||||
root.refreshWarning = ""
|
||||
form.resetAfterSubmit()
|
||||
root.newPositionQuote = ({})
|
||||
root.quoteStale = true
|
||||
root.refreshingAfterSuccess = true
|
||||
root.refreshNewPositionContext()
|
||||
return
|
||||
}
|
||||
root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed"))
|
||||
},
|
||||
function(error) {
|
||||
root.finishSubmitFailure(root.quoteError("wallet_submission_failed"))
|
||||
})
|
||||
}
|
||||
|
||||
function finishSubmitFailure(result) {
|
||||
root.submitting = false
|
||||
confirmationDialog.closeAfterFailure()
|
||||
if (result && result.quote && result.quote.schema === "new-position.v1")
|
||||
root.newPositionQuote = result.quote
|
||||
var code = result && result.code ? result.code : "wallet_submission_failed"
|
||||
form.submitError = form.issueText(code)
|
||||
root.scheduleQuote(true)
|
||||
}
|
||||
|
||||
function contextError(code) {
|
||||
return {
|
||||
"schema": "new-position.v1",
|
||||
"status": "error",
|
||||
"code": code,
|
||||
"tokens": [],
|
||||
"feeTiers": []
|
||||
}
|
||||
}
|
||||
|
||||
function quoteError(code) {
|
||||
return {
|
||||
"schema": "new-position.v1",
|
||||
"status": "error",
|
||||
"canSubmit": false,
|
||||
"code": code,
|
||||
"poolStatus": "unavailable_pool",
|
||||
"errors": [{
|
||||
"code": code,
|
||||
"blockingFields": [],
|
||||
"details": ({})
|
||||
}],
|
||||
"warnings": [],
|
||||
"accountPreview": []
|
||||
newPositionFlow.confirm(snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
#define AMM_UI_BACKEND_H
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include <QObject>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVariant>
|
||||
|
||||
#include "rep_AmmUiBackend_source.h"
|
||||
|
||||
#include "AccountModel.h"
|
||||
#include "ActiveNetwork.h"
|
||||
#include "WalletAccountModel.h"
|
||||
|
||||
class LogosAPI;
|
||||
struct LogosModules;
|
||||
class AmmClient;
|
||||
class LogosWalletProvider;
|
||||
class NewPositionRuntime;
|
||||
class QNetworkAccessManager;
|
||||
class QTimer;
|
||||
class WalletController;
|
||||
|
||||
// Source-side implementation of the AmmUiBackend .rep interface.
|
||||
// Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and
|
||||
// SLOTs from AmmUiBackend.rep — all the simple ones flow over QtRO. Talks to
|
||||
// the core logos_execution_zone wallet module via LogosModules.
|
||||
// SLOTs from AmmUiBackend.rep — all the simple ones flow over QtRO.
|
||||
class AmmUiBackend : public AmmUiBackendSimpleSource {
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(AccountModel* accountModel READ accountModel CONSTANT)
|
||||
Q_PROPERTY(WalletAccountModel* accountModel READ accountModel CONSTANT)
|
||||
|
||||
public:
|
||||
explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr);
|
||||
~AmmUiBackend() override;
|
||||
|
||||
AccountModel* accountModel() const { return m_accountModel; }
|
||||
WalletAccountModel* accountModel() const;
|
||||
|
||||
public slots:
|
||||
// Overrides of the pure-virtual slots generated from the .rep.
|
||||
@ -38,7 +39,7 @@ public slots:
|
||||
void refreshAccounts() override;
|
||||
void refreshBalances() override;
|
||||
QString getBalance(QString accountIdHex, bool isPublic) override;
|
||||
QVariantMap refreshNewPositionContext(QVariantMap request) override;
|
||||
void refreshNewPositionContext(QVariantMap request) override;
|
||||
QVariantMap quoteNewPosition(QVariantMap request) override;
|
||||
QVariantMap submitNewPosition(QVariantMap request, QString quoteHash) override;
|
||||
// Return the new wallet's BIP39 mnemonic (empty string on failure) so the
|
||||
@ -47,52 +48,23 @@ public slots:
|
||||
QString createNew(QString configPath, QString storagePath, QString password) override;
|
||||
bool openExisting() override;
|
||||
void disconnectWallet() override;
|
||||
bool changeSequencerAddr(QString url) override;
|
||||
void copyToClipboard(QString text) override;
|
||||
|
||||
private:
|
||||
// Per-app wallet home (kept distinct from the wallet's canonical
|
||||
// ~/.lee/wallet so standalone instances stay isolated; Basecamp sharing
|
||||
// is handled by adopting an already-open shared wallet on startup).
|
||||
static QString defaultWalletHome();
|
||||
QString defaultConfigPath() const;
|
||||
QString defaultStoragePath() const;
|
||||
|
||||
void persistConfigPath(const QString& path);
|
||||
void persistStoragePath(const QString& path);
|
||||
void openOrAdoptWallet();
|
||||
// True when the shared core already has a wallet open — including a freshly
|
||||
// created one with zero accounts. See the definition for why list_accounts()
|
||||
// alone is insufficient.
|
||||
bool sharedWalletIsOpen();
|
||||
void refreshBlockHeights();
|
||||
void refreshSequencerAddr();
|
||||
void saveWallet();
|
||||
bool loadNetworkConfig();
|
||||
void syncWalletState();
|
||||
void probeNetworkIdentity();
|
||||
QJsonObject readAccount(const QString& accountId) const;
|
||||
QJsonArray walletAccountReads() const;
|
||||
QJsonObject buildQuoteInput(const QVariantMap& request, QJsonObject* error) const;
|
||||
|
||||
// Probe the configured sequencer over HTTP and update sequencerReachable.
|
||||
void checkReachability();
|
||||
|
||||
AccountModel* m_accountModel;
|
||||
void publishNetworkContext();
|
||||
|
||||
LogosAPI* m_logosAPI;
|
||||
LogosModules* m_logos;
|
||||
std::unique_ptr<LogosWalletProvider> m_wallet;
|
||||
std::unique_ptr<WalletController> m_walletController;
|
||||
std::unique_ptr<AmmClient> m_ammClient;
|
||||
std::unique_ptr<NewPositionRuntime> m_newPosition;
|
||||
|
||||
QNetworkAccessManager* m_net;
|
||||
QTimer* m_reachabilityTimer;
|
||||
|
||||
QString m_networkId;
|
||||
QString m_networkStatus = QStringLiteral("config_missing");
|
||||
QString m_networkFingerprint;
|
||||
QString m_expectedNetworkIdentity;
|
||||
QString m_ammProgramId;
|
||||
QStringList m_configuredTokenIds;
|
||||
ActiveNetwork m_network;
|
||||
QVariantMap m_newPositionHints;
|
||||
bool m_identityProbeInFlight = false;
|
||||
bool m_submitInFlight = false;
|
||||
};
|
||||
|
||||
#endif // AMM_UI_BACKEND_H
|
||||
|
||||
@ -5,6 +5,9 @@
|
||||
class AmmUiBackend
|
||||
{
|
||||
PROP(bool isWalletOpen READONLY)
|
||||
// False while startup or reconnect is still resolving wallet state. This
|
||||
// stays distinct from isWalletOpen because a disconnected wallet is ready.
|
||||
PROP(bool walletStateReady READONLY)
|
||||
PROP(bool walletExists READONLY)
|
||||
PROP(QString configPath READONLY)
|
||||
PROP(QString storagePath READONLY)
|
||||
@ -27,12 +30,12 @@ class AmmUiBackend
|
||||
// The QVariant payloads are stable maps/lists so the UI never assembles AMM
|
||||
// transactions or duplicates quote state.
|
||||
PROP(QVariantMap newPositionContext READONLY)
|
||||
SLOT(QVariantMap refreshNewPositionContext(QVariantMap request))
|
||||
SLOT(void refreshNewPositionContext(QVariantMap request))
|
||||
SLOT(QVariantMap quoteNewPosition(QVariantMap request))
|
||||
SLOT(QVariantMap submitNewPosition(QVariantMap request, QString quoteHash))
|
||||
|
||||
// Wallet lifecycle. createNewDefault() is the happy path: it creates a
|
||||
// fresh per-app wallet at walletHome with no path picking. createNew()
|
||||
// fresh wallet at the canonical walletHome with no path picking. createNew()
|
||||
// keeps explicit paths for an "advanced" flow. Both return the new wallet's
|
||||
// BIP39 mnemonic (empty on failure) so the UI can force a seed-phrase backup
|
||||
// before the wallet is usable — this is the only chance to record it.
|
||||
@ -44,5 +47,4 @@ class AmmUiBackend
|
||||
// Close this app's wallet view (lock); does not delete the wallet and, in
|
||||
// Basecamp, does not close the wallet other apps share.
|
||||
SLOT(void disconnectWallet())
|
||||
|
||||
}
|
||||
|
||||
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