feat(amm-ui): implement new position flow #216

Replace prototype data and floating-point quotes with live wallet and
chain reads, exact Rust quote planning, and optimistic external-wallet
submission.

Add canonical display-order mapping, responsive QML states,
network-scoped program configuration, and packaged Rust client support.
This commit is contained in:
Ricardo Guilherme Schmidt 2026-07-12 02:15:53 -03:00 committed by r4bbit
parent 6c682dbadd
commit 8dd1189584
No known key found for this signature in database
GPG Key ID: E95F1E9447DC91A9
24 changed files with 6417 additions and 2591 deletions

41
Cargo.lock generated
View File

@ -76,6 +76,25 @@ dependencies = [
"risc0-zkvm",
]
[[package]]
name = "amm_client"
version = "0.1.0"
dependencies = [
"alloy-primitives",
"amm_core",
"borsh",
"clock_core",
"hex",
"lee_core",
"pretty_assertions",
"risc0-zkvm",
"serde",
"serde_json",
"sha2",
"token_core",
"twap_oracle_core",
]
[[package]]
name = "amm_core"
version = "0.1.0"
@ -1193,6 +1212,12 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "digest"
version = "0.9.0"
@ -2627,6 +2652,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
dependencies = [
"diff",
"yansi",
]
[[package]]
name = "primitive-types"
version = "0.12.2"
@ -4738,6 +4773,12 @@ dependencies = [
"hashlink",
]
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yoke"
version = "0.8.3"

View File

@ -1,5 +1,6 @@
[workspace]
members = [
"apps/amm/client",
"programs/token/core",
"programs/token",
"programs/token/methods",

View File

@ -1,25 +1,12 @@
cmake_minimum_required(VERSION 3.21)
cmake_minimum_required(VERSION 3.14)
project(AmmUiPlugin LANGUAGES CXX)
find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2)
qt_standard_project_setup(REQUIRES 6.8)
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(
@ -31,12 +18,20 @@ logos_module(
src/AmmUiPlugin.cpp
src/AmmUiBackend.h
src/AmmUiBackend.cpp
src/AccountModel.h
src/AccountModel.cpp
FIND_PACKAGES
Qt6Gui
Qt6Network
LINK_LIBRARIES
Qt6::Gui
Qt6::Network
LINK_TARGETS
logos_wallet_access
EXTERNAL_LIBS
amm_client
)
qt_add_resources(amm_ui_module_plugin amm_ui_config
PREFIX "/amm"
FILES
config/networks.json
)

View File

@ -0,0 +1,24 @@
[package]
name = "amm_client"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
alloy-primitives = { version = "1", default-features = false }
amm_core = { workspace = true }
borsh = { workspace = true }
clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc6" }
hex = "0.4"
nssa_core = { workspace = true }
risc0-zkvm = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = "0.10"
token_core = { workspace = true }
twap_oracle_core = { workspace = true }
[dev-dependencies]
pretty_assertions = "1"

View File

@ -0,0 +1,20 @@
#ifndef AMM_CLIENT_H
#define AMM_CLIENT_H
#ifdef __cplusplus
extern "C" {
#endif
char *amm_config_id(const char *request_json);
char *amm_token_ids(const char *request_json);
char *amm_pair_ids(const char *request_json);
char *amm_context(const char *request_json);
char *amm_quote(const char *request_json);
char *amm_plan(const char *request_json);
void amm_free(char *value);
#ifdef __cplusplus
}
#endif
#endif

View File

@ -0,0 +1,138 @@
use std::str::FromStr;
use nssa_core::{
account::{Account, AccountId, Data, Nonce},
program::ProgramId,
};
use crate::model::AccountRead;
pub fn parse_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> {
if value.len() != 64
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(format!(
"{label} must be 64 lowercase hexadecimal characters"
));
}
let mut bytes = [0_u8; 32];
hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?;
Ok(bytes)
}
pub fn parse_program_id(value: &str) -> Result<ProgramId, String> {
let bytes = parse_hex_32(value, "program id")?;
let mut program_id = [0_u32; 8];
for (word, chunk) in program_id.iter_mut().zip(bytes.chunks_exact(4)) {
let chunk: [u8; 4] = chunk
.try_into()
.map_err(|_| String::from("program id word has invalid length"))?;
*word = u32::from_le_bytes(chunk);
}
Ok(program_id)
}
#[cfg(test)]
pub fn program_id_hex(program_id: ProgramId) -> String {
let bytes = program_id
.iter()
.flat_map(|word| word.to_le_bytes())
.collect::<Vec<_>>();
hex::encode(bytes)
}
pub fn program_id_base58(program_id: ProgramId) -> String {
AccountId::new(program_id_bytes(program_id)).to_string()
}
pub fn program_id_bytes(program_id: ProgramId) -> [u8; 32] {
let mut bytes = [0_u8; 32];
for (chunk, word) in bytes.chunks_exact_mut(4).zip(program_id) {
chunk.copy_from_slice(&word.to_le_bytes());
}
bytes
}
pub fn parse_base58_id(value: &str, label: &str) -> Result<AccountId, String> {
AccountId::from_str(value).map_err(|error| format!("invalid {label}: {error}"))
}
pub fn account_id_from_hex(value: &str, label: &str) -> Result<AccountId, String> {
Ok(AccountId::new(parse_hex_32(value, label)?))
}
pub fn account_id_hex(account_id: AccountId) -> String {
hex::encode(account_id.into_value())
}
fn parse_le_u128(value: &str, label: &str) -> Result<u128, String> {
if value.len() != 32
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(format!(
"{label} must be 32 lowercase hexadecimal characters"
));
}
let mut bytes = [0_u8; 16];
hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?;
Ok(u128::from_le_bytes(bytes))
}
pub fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), String> {
if read.status != "ok" {
return Err(String::from("account read failed"));
}
let account_id = account_id_from_hex(&read.id, "account id")?;
let source = read
.account
.as_ref()
.ok_or_else(|| String::from("successful account read has no account"))?;
let program_owner = parse_program_id(&source.program_owner)?;
let balance = parse_le_u128(&source.balance, "account balance")?;
let nonce = parse_le_u128(&source.nonce, "account nonce")?;
if source.data.len() % 2 != 0
|| !source
.data
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(String::from(
"account data must be lowercase even-length hexadecimal",
));
}
let data =
hex::decode(&source.data).map_err(|error| format!("invalid account data: {error}"))?;
let data =
Data::try_from(data).map_err(|error| format!("account data is too large: {error}"))?;
Ok((
account_id,
Account {
program_owner,
balance,
data,
nonce: Nonce(nonce),
},
))
}
#[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"),
account: Some(WalletAccount {
program_owner: program_id_hex(account.program_owner),
balance: hex::encode(account.balance.to_le_bytes()),
nonce: hex::encode(account.nonce.0.to_le_bytes()),
data: hex::encode(account.data.as_ref()),
}),
}
}

122
apps/amm/client/src/lib.rs Normal file
View File

@ -0,0 +1,122 @@
#![deny(unsafe_op_in_unsafe_fn)]
mod account;
mod model;
mod protocol;
use std::{
ffi::{c_char, CStr, CString},
panic::{catch_unwind, AssertUnwindSafe},
};
use serde::de::DeserializeOwned;
use crate::model::{
ConfigIdRequest, ContextRequest, Envelope, PairIdsRequest, PlanRequest, QuoteRequest,
TokenIdsRequest,
};
fn call<T: DeserializeOwned>(
request: *const c_char,
operation: fn(T) -> Result<serde_json::Value, String>,
) -> *mut c_char {
let result = catch_unwind(AssertUnwindSafe(|| {
let request = request_text(request)?;
let request = serde_json::from_str::<T>(&request)
.map_err(|error| format!("invalid request JSON: {error}"))?;
operation(request)
}));
let envelope = match result {
Ok(Ok(value)) => Envelope::success(value),
Ok(Err(error)) => Envelope::failure(error),
Err(_) => Envelope::failure("internal panic"),
};
encode_envelope(&envelope)
}
fn request_text(request: *const c_char) -> Result<String, String> {
if request.is_null() {
return Err(String::from("request pointer is null"));
}
// SAFETY: The C++ caller passes a live NUL-terminated UTF-8 buffer for this call.
let request = unsafe { CStr::from_ptr(request) };
request
.to_str()
.map(String::from)
.map_err(|error| format!("request is not UTF-8: {error}"))
}
fn encode_envelope(envelope: &Envelope) -> *mut c_char {
let json = serde_json::to_string(envelope).unwrap_or_else(|_| {
String::from(r#"{"ok":false,"error":"response serialization failed"}"#)
});
match CString::new(json) {
Ok(value) => value.into_raw(),
Err(_) => CString::new(r#"{"ok":false,"error":"response contains NUL"}"#)
.map_or(std::ptr::null_mut(), CString::into_raw),
}
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_config_id(request_json: *const c_char) -> *mut c_char {
call::<ConfigIdRequest>(request_json, protocol::config_id)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_token_ids(request_json: *const c_char) -> *mut c_char {
call::<TokenIdsRequest>(request_json, protocol::token_ids)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_pair_ids(request_json: *const c_char) -> *mut c_char {
call::<PairIdsRequest>(request_json, protocol::pair_ids)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_context(request_json: *const c_char) -> *mut c_char {
call::<ContextRequest>(request_json, protocol::context)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_quote(request_json: *const c_char) -> *mut c_char {
call::<QuoteRequest>(request_json, protocol::quote)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_plan(request_json: *const c_char) -> *mut c_char {
call::<PlanRequest>(request_json, protocol::plan)
}
/// Releases a string returned by an `amm_*` operation.
///
/// # Safety
/// `value` must be null or a pointer returned by this library that has not been freed.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn amm_free(value: *mut c_char) {
if value.is_null() {
return;
}
// SAFETY: The caller contract requires a pointer produced by CString::into_raw above.
drop(unsafe { CString::from_raw(value) });
}
#[cfg(test)]
mod tests {
use std::ffi::CString;
use super::*;
#[test]
fn malformed_json_uses_boundary_failure_envelope() {
let request = CString::new("{").unwrap();
let response = amm_config_id(request.as_ptr());
assert!(!response.is_null());
// SAFETY: response was returned by amm_config_id and remains live until amm_free.
let text = unsafe { CStr::from_ptr(response) }.to_str().unwrap();
let value: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(value["ok"], false);
// SAFETY: response was allocated by this library and has not been freed.
unsafe { amm_free(response) };
}
}

View File

@ -0,0 +1,158 @@
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

@ -0,0 +1,7 @@
{
"testnet": {
"checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a",
"ammProgramId": "d0c4c0ea9384928a97b65a63fcf9641639bb802e4e91819743360708ff12101e",
"tokenDefinitionIds": []
}
}

944
apps/amm/flake.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -26,6 +26,8 @@
inputs.logos-execution-zone.url =
"github:logos-blockchain/logos-execution-zone?rev=a7e06a660940a00093b1760560d37ff84aff5a05";
};
amm_client.url = "path:../..";
};
outputs = inputs@{ logos-module-builder, shared_wallet, ... }:
@ -36,6 +38,12 @@
preConfigure = ''
cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${shared_wallet}")
'';
externalLibInputs = {
amm_client = {
input = inputs.amm_client;
packages.default = "amm_client";
};
};
postInstall = ''
# The builder installs the view under lib/qml after this hook. Its
# import descriptor points back to this compiled shared QML module.

View File

@ -14,7 +14,9 @@
"build": [],
"runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"]
},
"external_libraries": [],
"external_libraries": [
{ "name": "amm_client" }
],
"cmake": {
"find_packages": [],
"extra_sources": [],

View File

@ -0,0 +1,271 @@
.pragma library
var base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
var U128_MAX = "340282366920938463463374607431768211455"
var U32_MAX = "4294967295"
var Q64 = "18446744073709551616"
function normalize(value) {
var text = String(value === undefined || value === null ? "" : value)
var index = 0
while (index + 1 < text.length && text.charAt(index) === "0")
++index
return text.length > 0 ? text.slice(index) : "0"
}
function isUnsigned(value) {
return /^[0-9]+$/.test(String(value))
}
function compare(left, right) {
var a = normalize(left)
var b = normalize(right)
if (a.length !== b.length)
return a.length < b.length ? -1 : 1
if (a === b)
return 0
return a < b ? -1 : 1
}
function compareBase58Ids(left, right) {
var leftStart = 0
var rightStart = 0
while (leftStart < left.length && left[leftStart] === "1")
++leftStart
while (rightStart < right.length && right[rightStart] === "1")
++rightStart
var leftLength = left.length - leftStart
var rightLength = right.length - rightStart
if (leftLength !== rightLength)
return leftLength < rightLength ? -1 : 1
for (var i = 0; i < leftLength; ++i) {
var leftDigit = base58Alphabet.indexOf(left[leftStart + i])
var rightDigit = base58Alphabet.indexOf(right[rightStart + i])
if (leftDigit < 0 || rightDigit < 0)
return 0
if (leftDigit !== rightDigit)
return leftDigit < rightDigit ? -1 : 1
}
return 0
}
function subtract(left, right) {
var a = normalize(left)
var b = normalize(right)
var result = []
var borrow = 0
var j = b.length - 1
for (var i = a.length - 1; i >= 0; --i) {
var digit = Number(a.charAt(i)) - borrow - (j >= 0 ? Number(b.charAt(j)) : 0)
if (digit < 0) {
digit += 10
borrow = 1
} else {
borrow = 0
}
result.unshift(String(digit))
--j
}
return normalize(result.join(""))
}
function multiply(left, right) {
var a = normalize(left)
var b = normalize(right)
if (a === "0" || b === "0")
return "0"
var result = []
for (var z = 0; z < a.length + b.length; ++z)
result.push(0)
for (var i = a.length - 1; i >= 0; --i) {
for (var j = b.length - 1; j >= 0; --j) {
var low = i + j + 1
var high = i + j
var product = Number(a.charAt(i)) * Number(b.charAt(j)) + result[low]
result[low] = product % 10
result[high] += Math.floor(product / 10)
}
}
return normalize(result.join(""))
}
function divide(left, right) {
var numerator = normalize(left)
var denominator = normalize(right)
if (denominator === "0")
return { "quotient": "0", "remainder": numerator, "valid": false }
var quotient = ""
var remainder = "0"
for (var i = 0; i < numerator.length; ++i) {
remainder = normalize((remainder === "0" ? "" : remainder) + numerator.charAt(i))
var digit = 0
while (compare(remainder, denominator) >= 0) {
remainder = subtract(remainder, denominator)
++digit
}
quotient += String(digit)
}
return { "quotient": normalize(quotient), "remainder": remainder, "valid": true }
}
function 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) {
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)
}
while (carry > 0) {
digits.unshift(String(carry % 10))
carry = Math.floor(carry / 10)
}
return normalize(digits.join(""))
}
function pow10(exponent) {
var value = "1"
for (var i = 0; i < exponent; ++i)
value += "0"
return value
}
function implyDecimals(totalSupplyRaw) {
if (!isUnsigned(totalSupplyRaw))
return 0
var digits = normalize(totalSupplyRaw).length
if (digits <= 9)
return 0
if (digits <= 14)
return 6
if (digits <= 21)
return 9
return 18
}
function parseHuman(text, decimals) {
var value = String(text)
if (value.length === 0)
return { "ok": false, "code": "amount_required", "raw": "" }
var match = /^(0|[1-9][0-9]*)(?:\.([0-9]*))?$/.exec(value)
if (!match)
return { "ok": false, "code": "invalid_amount_format", "raw": "" }
var fraction = match[2] || ""
if (fraction.length > decimals)
return { "ok": false, "code": "invalid_amount_precision", "raw": "" }
var raw = normalize(match[1] + fraction + pow10(decimals - fraction.length).slice(1))
if (compare(raw, U128_MAX) > 0)
return { "ok": false, "code": "invalid_raw_amount", "raw": "" }
return { "ok": true, "code": "", "raw": raw }
}
function formatRaw(rawValue, decimals) {
if (!isUnsigned(rawValue))
return ""
var raw = normalize(rawValue)
if (decimals === 0)
return raw
while (raw.length <= decimals)
raw = "0" + raw
var whole = raw.slice(0, raw.length - decimals)
var fraction = raw.slice(raw.length - decimals).replace(/0+$/, "")
return fraction.length > 0 ? whole + "." + fraction : whole
}
function parsePrice(text) {
var value = String(text)
if (value.length === 0)
return { "ok": false, "code": "amount_required" }
var match = /^(0|[1-9][0-9]*)(?:\.([0-9]*))?$/.exec(value)
if (!match)
return { "ok": false, "code": "invalid_amount_format" }
var fraction = match[2] || ""
var numerator = normalize(match[1] + fraction)
if (numerator === "0")
return { "ok": false, "code": "amount_must_be_positive" }
return {
"ok": true,
"code": "",
"numerator": numerator,
"scale": fraction.length
}
}
function priceToQ64(text, canonicalDecimalsA, canonicalDecimalsB, displayIsCanonical) {
var parsed = parsePrice(text)
if (!parsed.ok)
return { "ok": false, "code": parsed.code, "raw": "" }
var numerator
var denominator
if (displayIsCanonical) {
numerator = multiply(multiply(parsed.numerator, Q64), pow10(canonicalDecimalsB))
denominator = pow10(parsed.scale + canonicalDecimalsA)
} else {
numerator = multiply(multiply(pow10(parsed.scale), Q64), pow10(canonicalDecimalsB))
denominator = multiply(parsed.numerator, pow10(canonicalDecimalsA))
}
var raw = divide(numerator, denominator).quotient
if (raw === "0")
return { "ok": false, "code": "amount_too_low", "raw": "" }
if (compare(raw, U128_MAX) > 0)
return { "ok": false, "code": "invalid_raw_amount", "raw": "" }
return { "ok": true, "code": "", "raw": raw }
}
function formatRatio(numerator, denominator, precision) {
var result = divide(numerator, denominator)
if (!result.valid)
return ""
var fraction = ""
var remainder = result.remainder
for (var i = 0; i < precision && remainder !== "0"; ++i) {
var digit = divide(multiply(remainder, "10"), denominator)
fraction += digit.quotient
remainder = digit.remainder
}
fraction = fraction.replace(/0+$/, "")
return fraction.length > 0 ? result.quotient + "." + fraction : result.quotient
}
function priceFromQ64(rawValue, canonicalDecimalsA, canonicalDecimalsB, displayIsCanonical) {
if (!isUnsigned(rawValue) || normalize(rawValue) === "0")
return ""
var numerator
var denominator
if (displayIsCanonical) {
numerator = multiply(rawValue, pow10(canonicalDecimalsA))
denominator = multiply(Q64, pow10(canonicalDecimalsB))
} else {
numerator = multiply(Q64, pow10(canonicalDecimalsB))
denominator = multiply(rawValue, pow10(canonicalDecimalsA))
}
return formatRatio(numerator, denominator, 12)
}
function mulDivFloor(left, right, denominator) {
return divide(multiply(left, right), denominator).quotient
}
function mulDivCeil(left, right, denominator) {
return divideCeil(multiply(left, right), denominator)
}
function toU32(value) {
if (!isUnsigned(value) || compare(value, U32_MAX) > 0)
return -1
return Number(normalize(value))
}

View File

@ -1,6 +1,11 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Controls
import Logos.Theme
FocusScope {
id: root
@ -8,45 +13,50 @@ FocusScope {
property var snapshot: ({})
property bool open: false
property bool busy: false
property string errorText: ""
signal canceled
signal confirmed(var snapshot)
visible: root.open
z: 20
visible: open
focus: open
z: 200
Keys.onEscapePressed: root.cancel()
Keys.onEscapePressed: function(event) {
event.accepted = true
if (!root.busy)
root.cancel()
}
function openWithSnapshot(nextSnapshot) {
root.snapshot = nextSnapshot;
root.errorText = "";
root.open = true;
root.forceActiveFocus();
cancelButton.forceActiveFocus();
root.snapshot = nextSnapshot || ({})
root.open = true
root.forceActiveFocus()
}
function cancel() {
if (root.busy)
return;
root.open = false;
root.canceled();
return
root.open = false
root.canceled()
}
function confirm() {
if (root.busy)
return;
root.errorText = "";
root.confirmed(root.snapshot);
if (!root.busy)
root.confirmed(root.snapshot)
}
function closeAfterSuccess() {
root.open = false;
root.open = false
root.snapshot = ({})
}
function closeAfterFailure() {
root.open = false
}
Rectangle {
anchors.fill: parent
color: "#99000000"
color: "#B0000000"
MouseArea {
anchors.fill: parent
@ -54,212 +64,138 @@ FocusScope {
}
Rectangle {
id: panel
anchors.centerIn: parent
color: "#1D1D1D"
implicitHeight: dialogContent.implicitHeight + 24
width: Math.min(520, parent.width - 32)
implicitHeight: dialogContent.implicitHeight + 40
radius: 8
width: Math.max(0, Math.min(420, root.width - 32))
border.color: "#343434"
color: Theme.palette.backgroundElevated
border.color: Theme.palette.borderSecondary
border.width: 1
ColumnLayout {
id: dialogContent
anchors.fill: parent
anchors.margins: 12
spacing: 12
Text {
color: "#E7E1D8"
font.bold: true
font.pixelSize: 16
text: qsTr("Confirm new position")
anchors.margins: 20
spacing: 14
RowLayout {
Layout.fillWidth: true
}
Rectangle {
color: "#151515"
radius: 8
border.color: "#343434"
border.width: 1
Layout.fillWidth: true
Layout.preferredHeight: summaryLayout.implicitHeight + 20
ColumnLayout {
id: summaryLayout
anchors.fill: parent
anchors.margins: 10
spacing: 8
SummaryRow {
label: qsTr("Pair")
value: root.snapshot.pairText || ""
Layout.fillWidth: true
}
SummaryRow {
label: qsTr("Instruction")
value: root.snapshot.instructionText || ""
Layout.fillWidth: true
}
SummaryRow {
label: qsTr("Fee tier")
value: root.snapshot.feeLabel || ""
Layout.fillWidth: true
}
SummaryRow {
label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "")
value: root.snapshot.depositA || ""
Layout.fillWidth: true
}
SummaryRow {
label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "")
value: root.snapshot.depositB || ""
Layout.fillWidth: true
}
SummaryRow {
label: qsTr("Expected LP")
value: root.snapshot.expectedLp || ""
Layout.fillWidth: true
}
SummaryRow {
label: qsTr("Minimum LP")
value: root.snapshot.minimumLp || ""
Layout.fillWidth: true
}
SummaryRow {
label: qsTr("Quote hash")
value: root.snapshot.shortQuoteHash || ""
Layout.fillWidth: true
}
}
}
Text {
color: "#A9A098"
font.pixelSize: 12
lineHeight: 1.25
text: qsTr("Submit will re-quote against current wallet and chain state before dispatch.")
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
Rectangle {
color: "#211914"
radius: 8
border.color: "#49301F"
border.width: 1
visible: root.errorText.length > 0
Layout.fillWidth: true
Layout.preferredHeight: visible ? submitError.implicitHeight + 20 : 0
Text {
id: submitError
anchors.fill: parent
anchors.margins: 10
color: "#F08A76"
font.pixelSize: 12
lineHeight: 1.2
text: root.errorText
wrapMode: Text.WordWrap
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 {
spacing: 8
Layout.fillWidth: true
spacing: 10
Button {
id: cancelButton
activeFocusOnTab: true
enabled: !root.busy
focusPolicy: Qt.StrongFocus
hoverEnabled: true
LogosButton {
text: qsTr("Cancel")
Accessible.name: cancelButton.text
enabled: !root.busy
Layout.fillWidth: true
Layout.minimumHeight: 44
radius: 6
onClicked: root.cancel()
contentItem: Text {
color: cancelButton.hovered || cancelButton.activeFocus ? "#151515" : "#E7E1D8"
elide: Text.ElideRight
font.bold: true
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
text: cancelButton.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: cancelButton.activeFocus ? "#F26A21" : "#343434"
border.width: 1
color: cancelButton.pressed ? "#343434" : cancelButton.hovered || cancelButton.activeFocus ? "#E7E1D8" : "#151515"
radius: 6
}
}
Button {
id: confirmButton
activeFocusOnTab: true
LogosButton {
text: root.busy ? qsTr("Submitting…") : qsTr("Submit")
enabled: !root.busy
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: root.busy ? qsTr("Submitting") : qsTr("Submit")
Accessible.name: confirmButton.text
Layout.fillWidth: true
Layout.minimumHeight: 44
radius: 6
onClicked: root.confirm()
contentItem: Text {
color: confirmButton.enabled ? "#151515" : "#7D756E"
elide: Text.ElideRight
font.bold: true
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
text: confirmButton.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: confirmButton.enabled ? "#F26A21" : "#343434"
border.width: 1
color: confirmButton.enabled ? confirmButton.pressed ? "#D95C1E" : confirmButton.hovered || confirmButton.activeFocus ? "#FF8A3D" : "#F26A21" : "#181818"
radius: 6
}
}
}
}
}
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,6 +1,6 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Rectangle {
id: root
@ -72,6 +72,7 @@ Rectangle {
font.bold: true
font.pixelSize: 18
inputMethodHints: Qt.ImhFormattedNumbersOnly
maximumLength: 80
placeholderText: qsTr("0")
readOnly: root.readOnly
selectByMouse: true

View File

@ -0,0 +1,461 @@
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,39 +1,50 @@
import QtQuick 2.15
import QtQml 2.15
import "../components/shared"
import QtQuick
import QtQml
import "../components/liquidity"
Item {
id: root
property var backend: null
property var newPositionContext: ({})
property var newPositionQuote: root.errorQuote(qsTr("Wallet backend is unavailable."), root.currentRequest())
property var newPositionContext: ({
"schema": "new-position.v1",
"status": "loading",
"tokens": [],
"feeTiers": []
})
property var newPositionQuote: ({})
property var resolvedTokenIds: []
property int quoteSerial: 0
property bool formReady: false
property bool componentReady: false
property bool contextLoading: false
property bool quoteLoading: false
property bool quoteStale: true
property bool submitting: false
property bool refreshingAfterSuccess: false
property string transactionId: ""
property string refreshWarning: ""
property string lastContextJson: ""
readonly property int pageMargin: 16
readonly property int preferredCardWidth: 960
readonly property int pageCardY: newPositionForm.implicitHeight + root.pageMargin * 2 <= scroll.height ? Math.max(root.pageMargin, Math.round((scroll.height - newPositionForm.implicitHeight) / 4)) : root.pageMargin
readonly property int preferredWidth: 800
width: parent ? parent.width : implicitWidth
height: parent ? parent.height : implicitHeight
implicitWidth: root.preferredCardWidth + root.pageMargin * 2
implicitHeight: newPositionForm.implicitHeight + root.pageMargin * 2
Component.onCompleted: {
root.componentReady = true
root.refreshNewPositionContext()
}
Component.onCompleted: root.refreshNewPositionContext()
onBackendChanged: root.refreshNewPositionContext()
onBackendChanged: {
if (root.componentReady)
root.refreshNewPositionContext()
}
Connections {
target: root.backend
ignoreUnknownSignals: true
function onNewPositionContextChanged(value) {
root.newPositionContext = value;
root.requestQuote(root.currentRequest());
root.adoptContext(value)
}
}
@ -47,49 +58,53 @@ Item {
anchors.fill: parent
clip: true
contentHeight: Math.max(height, newPositionForm.y + newPositionForm.implicitHeight + root.pageMargin)
contentWidth: width
contentHeight: Math.max(height, form.y + form.implicitHeight + root.pageMargin)
enabled: !confirmationDialog.open
flickableDirection: Flickable.VerticalFlick
NewPositionForm {
id: newPositionForm
id: form
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))
newPositionContext: root.newPositionContext
quotePayload: root.newPositionQuote
contextLoading: root.contextLoading
quoteLoading: root.quoteLoading
quoteStale: root.quoteStale
submitting: root.submitting
width: Math.max(0, Math.min(scroll.width - root.pageMargin * 2, root.preferredCardWidth))
x: Math.max(root.pageMargin, (scroll.width - width) / 2)
y: root.pageCardY
transactionId: root.transactionId
refreshWarning: root.refreshWarning
Component.onCompleted: {
root.formReady = true;
root.refreshNewPositionContext();
onQuoteRequested: function(immediate) {
root.scheduleQuote(immediate)
}
onQuoteRequested: function (request) {
root.requestQuote(request);
onConfirmationRequested: function(snapshot) {
confirmationDialog.openWithSnapshot(snapshot)
}
onConfirmationRequested: function (snapshot) {
confirmationDialog.openWithSnapshot(snapshot);
onTokenResolveRequested: function(tokenId) {
root.resolveToken(tokenId)
}
onDraftChanged: {
root.transactionId = ""
root.refreshWarning = ""
}
onRefreshRequested: root.refreshNewPositionContext()
}
}
SuccessToast {
id: successToast
width: Math.max(0, Math.min(380, root.width - 32))
z: 30
anchors {
bottom: parent.bottom
bottomMargin: 18
horizontalCenter: parent.horizontalCenter
}
Timer {
id: quoteDebounce
interval: 250
repeat: false
onTriggered: root.requestQuoteNow(root.quoteSerial)
}
NewPositionConfirmationDialog {
@ -98,163 +113,192 @@ Item {
anchors.fill: parent
busy: root.submitting
onConfirmed: function (snapshot) {
root.confirmNewPosition(snapshot);
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;
confirmationDialog.errorText = "";
newPositionForm.setSubmitError("");
return
root.submitting = true
form.submitError = ""
if (!root.backend || typeof logos === "undefined") {
root.submitting = false;
root.showSubmitError(qsTr("Wallet backend is unavailable."));
return;
root.finishSubmitFailure(root.quoteError("wallet_unavailable"))
return
}
logos.watch(root.backend.submitNewPosition(snapshot.request, snapshot.quoteHash),
function(result) {
root.submitting = false;
if (result.status !== "ok") {
root.showSubmitError(result.error);
return;
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
}
confirmationDialog.closeAfterSuccess();
newPositionForm.resetAfterSubmit();
successToast.show(result.message, result.detail);
root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed"))
},
function(error) {
root.submitting = false;
root.showSubmitError(qsTr("Error submitting position: %1").arg(error));
});
root.finishSubmitFailure(root.quoteError("wallet_submission_failed"))
})
}
function refreshNewPositionContext() {
root.contextLoading = true;
if (!root.backend || typeof logos === "undefined") {
root.contextLoading = false;
root.newPositionContext = {
"activeAccountDisplay": qsTr("Not connected"),
"holdings": [],
"feeTiers": []
};
root.newPositionQuote = root.errorQuote(qsTr("Wallet backend is unavailable."), root.currentRequest());
return;
}
logos.watch(root.backend.refreshNewPositionContext(),
function(context) {
root.contextLoading = false;
root.newPositionContext = context;
root.requestQuote(root.currentRequest());
},
function(error) {
root.contextLoading = false;
root.newPositionQuote = root.errorQuote(qsTr("Error loading position context: %1").arg(error), root.currentRequest());
});
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 requestQuote(request) {
root.quoteLoading = true;
if (!root.backend || typeof logos === "undefined") {
root.quoteLoading = false;
root.newPositionQuote = root.errorQuote(qsTr("Wallet backend is unavailable."), request);
return;
}
const serial = ++root.quoteSerial;
logos.watch(root.backend.quoteNewPosition(request),
function(quote) {
if (serial === root.quoteSerial) {
root.quoteLoading = false;
root.newPositionQuote = quote;
}
},
function(error) {
if (serial === root.quoteSerial) {
root.quoteLoading = false;
root.newPositionQuote = root.errorQuote(qsTr("Error loading quote: %1").arg(error), request);
}
});
}
function showSubmitError(message) {
const text = message && message.length > 0 ? message : qsTr("Position submission failed.");
confirmationDialog.errorText = text;
newPositionForm.setSubmitError(text);
}
function amountValue(symbol) {
return {
"value": 0,
"input": "0",
"display": qsTr("0 %1").arg(symbol),
"symbol": symbol
};
}
function currentRequest() {
if (root.formReady)
return newPositionForm.buildRequest();
return {
"amountA": "",
"amountB": "",
"depositScale": 1,
"editedSide": "A",
"feeBps": 30,
"initialPrice": "1",
"slippageBps": 50,
"tokenA": "",
"tokenB": ""
};
}
function errorQuote(message, request) {
const tokenA = request ? request.tokenA : "";
const tokenB = request ? request.tokenB : "";
function contextError(code) {
return {
"schema": "new-position.v1",
"status": "error",
"error": message,
"code": code,
"tokens": [],
"feeTiers": []
}
}
function quoteError(code) {
return {
"schema": "new-position.v1",
"status": "error",
"canSubmit": false,
"code": code,
"poolStatus": "unavailable_pool",
"statusLabel": qsTr("Unavailable"),
"statusDetail": message,
"instruction": "",
"storedFeeBps": 0,
"feeBps": request ? request.feeBps : 0,
"feeLabel": "",
"quoteHash": "",
"pool": {
"id": "",
"priceText": "",
"reserveText": ""
},
"deposit": {
"maxA": root.amountValue(tokenA),
"maxB": root.amountValue(tokenB),
"actualA": root.amountValue(tokenA),
"actualB": root.amountValue(tokenB)
},
"lp": {
"expected": root.amountValue("LP"),
"minimum": root.amountValue("LP"),
"locked": root.amountValue("LP")
},
"position": {
"userLp": "0 LP",
"share": "-",
"ownedA": qsTr("0 %1").arg(tokenA),
"ownedB": qsTr("0 %1").arg(tokenB)
},
"accountChanges": []
};
"errors": [{
"code": code,
"blockingFields": [],
"details": ({})
}],
"warnings": [],
"accountPreview": []
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,10 @@
#define AMM_UI_BACKEND_H
#include <QObject>
#include <QJsonArray>
#include <QJsonObject>
#include <QString>
#include <QStringList>
#include <QVariant>
#include "rep_AmmUiBackend_source.h"
@ -35,9 +38,9 @@ public slots:
void refreshAccounts() override;
void refreshBalances() override;
QString getBalance(QString accountIdHex, bool isPublic) override;
QVariant refreshNewPositionContext() override;
QVariant quoteNewPosition(QVariant request) override;
QVariant submitNewPosition(QVariant request, QString quoteHash) override;
QVariantMap 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
// UI can force a one-time seed-phrase backup step.
QString createNewDefault(QString password) override;
@ -65,9 +68,11 @@ private:
void refreshBlockHeights();
void refreshSequencerAddr();
void saveWallet();
QString activeAccountAddress() const;
QVariantMap buildNewPositionContext() const;
QVariantMap quoteNewPositionMap(const QVariantMap& request) const;
bool loadNetworkConfig();
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();
@ -79,6 +84,15 @@ private:
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;
bool m_identityProbeInFlight = false;
bool m_submitInFlight = false;
};
#endif // AMM_UI_BACKEND_H

View File

@ -26,10 +26,10 @@ class AmmUiBackend
// New Position backend surface. QML calls these through logos.watch(...).
// The QVariant payloads are stable maps/lists so the UI never assembles AMM
// transactions or duplicates quote state.
PROP(QVariant newPositionContext READONLY)
SLOT(QVariant refreshNewPositionContext())
SLOT(QVariant quoteNewPosition(QVariant request))
SLOT(QVariant submitNewPosition(QVariant request, QString quoteHash))
PROP(QVariantMap newPositionContext READONLY)
SLOT(QVariantMap 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()

43
flake.lock generated Normal file
View File

@ -0,0 +1,43 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1783203018,
"narHash": "sha256-G6R9IT/xwFuu+CYBWDUAok6AdC4ERC4ZfPPFtEpxnZE=",
"owner": "ipetkov",
"repo": "crane",
"rev": "80db5bdc391be8a1794f6d8a2d56e3a84ebcede2",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1783776592,
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

45
flake.nix Normal file
View File

@ -0,0 +1,45 @@
{
description = "LEZ program client libraries";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
crane.url = "github:ipetkov/crane";
};
outputs = { nixpkgs, crane, ... }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
in {
packages = forAllSystems (system:
let
pkgs = import nixpkgs { inherit system; };
craneLib = crane.mkLib pkgs;
src = craneLib.cleanCargoSource ./.;
commonArgs = {
inherit src;
pname = "amm_client";
version = "0.1.0";
strictDeps = true;
cargoExtraArgs = "-p amm_client";
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
ammClient = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
doCheck = false;
postInstall = ''
install -Dm644 ${./apps/amm/client/include/amm_client.h} \
$out/include/amm_client.h
'';
});
in {
default = ammClient;
amm_client = ammClient;
});
};
}