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:
Ricardo Guilherme Schmidt 2026-07-14 02:11:15 -03:00 committed by r4bbit
parent 53a3d03f71
commit 6de8a5fe9a
No known key found for this signature in database
GPG Key ID: E95F1E9447DC91A9
52 changed files with 5623 additions and 5279 deletions

View File

@ -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()

View File

@ -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

View File

@ -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"),

View File

@ -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>,

View File

@ -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

View File

@ -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
View File

@ -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",

View File

@ -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" }

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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
};
}
}

View 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 }
}
}

View 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 }
}
}
}
}

View 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 }
}
}
}

View 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"
})
}

View 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()
}
}

View 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 }
}
}

View 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 }
}
}
}

View File

@ -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

View File

@ -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
}
}
}
}

View File

@ -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 || "-"
}
}

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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)
};
}
}

View File

@ -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 {

View File

@ -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
}
}

View 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
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -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() }
}
// 0x123456cdef 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
}
}

View File

@ -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
// 0x123456cdef 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)
}
}
}
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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(/[.]$/, "") + "%";
}
}

View 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": []
}
}
}

View 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();
}

View 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;
};

View 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
View 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

View File

@ -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

View File

@ -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())
}

View 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();
}

View 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;
};

View 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;
}

View 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;
}

View File

@ -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');

View 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(""), "-")
}
}

View 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())
}
}

View 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)
}
}

View 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)
}
}

View 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)
}
}