mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-16 20:09:36 +00:00
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:
parent
6c682dbadd
commit
8dd1189584
41
Cargo.lock
generated
41
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"apps/amm/client",
|
||||
"programs/token/core",
|
||||
"programs/token",
|
||||
"programs/token/methods",
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
24
apps/amm/client/Cargo.toml
Normal file
24
apps/amm/client/Cargo.toml
Normal 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"
|
||||
20
apps/amm/client/include/amm_client.h
Normal file
20
apps/amm/client/include/amm_client.h
Normal 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
|
||||
138
apps/amm/client/src/account.rs
Normal file
138
apps/amm/client/src/account.rs
Normal 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
122
apps/amm/client/src/lib.rs
Normal 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) };
|
||||
}
|
||||
}
|
||||
158
apps/amm/client/src/model.rs
Normal file
158
apps/amm/client/src/model.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
2445
apps/amm/client/src/protocol.rs
Normal file
2445
apps/amm/client/src/protocol.rs
Normal file
File diff suppressed because it is too large
Load Diff
7
apps/amm/config/networks.json
Normal file
7
apps/amm/config/networks.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"testnet": {
|
||||
"checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a",
|
||||
"ammProgramId": "d0c4c0ea9384928a97b65a63fcf9641639bb802e4e91819743360708ff12101e",
|
||||
"tokenDefinitionIds": []
|
||||
}
|
||||
}
|
||||
944
apps/amm/flake.lock
generated
944
apps/amm/flake.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||
|
||||
@ -14,7 +14,9 @@
|
||||
"build": [],
|
||||
"runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"]
|
||||
},
|
||||
"external_libraries": [],
|
||||
"external_libraries": [
|
||||
{ "name": "amm_client" }
|
||||
],
|
||||
"cmake": {
|
||||
"find_packages": [],
|
||||
"extra_sources": [],
|
||||
|
||||
271
apps/amm/qml/components/liquidity/AmountMath.js
Normal file
271
apps/amm/qml/components/liquidity/AmountMath.js
Normal 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))
|
||||
}
|
||||
@ -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
@ -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
|
||||
|
||||
461
apps/amm/qml/components/wallet/AccountControl.qml
Normal file
461
apps/amm/qml/components/wallet/AccountControl.qml
Normal 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() }
|
||||
}
|
||||
|
||||
// 0x123456…cdef style truncation for the connected button label.
|
||||
function truncated(addr) {
|
||||
if (!addr) return ""
|
||||
return addr.length > 13 ? (addr.substring(0, 6) + "…" + addr.substring(addr.length - 4)) : addr
|
||||
}
|
||||
|
||||
// Copy on the QML/view side. Routing this through the backend would call
|
||||
// QGuiApplication::clipboard() in the (headless) module host process, which
|
||||
// has no clipboard — that call tears the backend down, dropping the wallet
|
||||
// connection. A hidden TextEdit copies via the GUI process that owns it.
|
||||
TextEdit { id: clipboardProxy; visible: false }
|
||||
function copyToClipboard(text) {
|
||||
if (!text) return
|
||||
clipboardProxy.text = text
|
||||
clipboardProxy.selectAll()
|
||||
clipboardProxy.copy()
|
||||
clipboardProxy.deselect()
|
||||
clipboardProxy.text = ""
|
||||
}
|
||||
|
||||
function showWalletMessage(title, message) {
|
||||
walletMessageDialog.title = title
|
||||
walletMessageDialog.message = message
|
||||
walletMessageDialog.open()
|
||||
}
|
||||
|
||||
function finishAccountCreation(accountId, fallbackError) {
|
||||
createAccountDialog.busy = false
|
||||
if (accountId && accountId.length > 0)
|
||||
createAccountDialog.close()
|
||||
else
|
||||
createAccountDialog.createError = fallbackError
|
||||
}
|
||||
|
||||
implicitWidth: root.connected ? connectedButton.width : connectButton.width
|
||||
implicitHeight: 40
|
||||
|
||||
// ── Disconnected: Connect ────────────────────────────────────────────
|
||||
LogosButton {
|
||||
id: connectButton
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: 40
|
||||
visible: !root.connected
|
||||
enabled: root.backend !== null
|
||||
text: qsTr("Connect")
|
||||
onClicked: {
|
||||
// Re-open an existing wallet; only show the create modal on first run.
|
||||
if (root.backend && root.backend.walletExists)
|
||||
logos.watch(root.backend.openExisting(),
|
||||
function(ok) {
|
||||
if (!ok)
|
||||
root.showWalletMessage(
|
||||
qsTr("Unable to connect wallet"),
|
||||
qsTr("The existing wallet could not be opened. Check the wallet files and try again."))
|
||||
},
|
||||
function(error) {
|
||||
root.showWalletMessage(
|
||||
qsTr("Unable to connect wallet"),
|
||||
qsTr("Error opening wallet: %1").arg(error))
|
||||
})
|
||||
else
|
||||
createWalletDialog.open()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connected: address pill that toggles the wallet menu ─────────────
|
||||
Rectangle {
|
||||
id: connectedButton
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.connected
|
||||
implicitHeight: 40
|
||||
implicitWidth: connectedRow.implicitWidth + Theme.spacing.medium * 2
|
||||
radius: height / 2
|
||||
// Keep an opaque dark fill in both states: the navbar is white, and the
|
||||
// active "muted" fill is translucent gray, which renders light over white
|
||||
// and makes the white label unreadable. Signal "open" with an accent
|
||||
// border instead.
|
||||
color: Theme.palette.backgroundSecondary
|
||||
border.width: 1
|
||||
border.color: walletMenu.opened ? Theme.palette.overlayOrange : "transparent"
|
||||
|
||||
RowLayout {
|
||||
id: connectedRow
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacing.small
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 8
|
||||
Layout.preferredHeight: 8
|
||||
radius: 4
|
||||
color: "#39c06a"
|
||||
}
|
||||
LogosText {
|
||||
text: root.truncated(root.selectedAddress) || qsTr("Connected")
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
color: Theme.palette.text
|
||||
}
|
||||
LogosText {
|
||||
text: walletMenu.opened ? "▴" : "▾"
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
color: Theme.palette.textSecondary
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// CloseOnPressOutside already dismisses the popup on this same press
|
||||
// (the button is outside it), so `opened` is false by the time this
|
||||
// fires. Without the recency guard the dismissing click would just
|
||||
// reopen it. If it just closed, leave it closed.
|
||||
onClicked: {
|
||||
if (walletMenu.opened || (Date.now() - walletMenu.lastClosedMs) < 200)
|
||||
walletMenu.close()
|
||||
else
|
||||
walletMenu.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wallet menu popup (top-right, under the connected button) ─────────
|
||||
Popup {
|
||||
id: walletMenu
|
||||
parent: connectedButton
|
||||
y: connectedButton.height + Theme.spacing.small
|
||||
x: connectedButton.width - width // right-align under the button
|
||||
width: 360
|
||||
padding: Theme.spacing.medium
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
// Timestamp of the last dismissal, used by the toggle button to tell a
|
||||
// genuine "open" click from the press that just closed the popup.
|
||||
property real lastClosedMs: 0
|
||||
onClosed: {
|
||||
walletMenu.lastClosedMs = Date.now()
|
||||
// Always reopen on the main (selected-account) view.
|
||||
if (viewStack.depth > 1)
|
||||
viewStack.pop(null, StackView.Immediate)
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: Theme.palette.backgroundTertiary
|
||||
border.width: 1
|
||||
border.color: Theme.palette.backgroundElevated
|
||||
radius: Theme.spacing.radiusLarge
|
||||
}
|
||||
|
||||
// Two stacked views: the main view (active account + actions) and the
|
||||
// accounts view (full list + create). The popup height follows the
|
||||
// active view's natural height, animated so the resize isn't abrupt.
|
||||
contentItem: StackView {
|
||||
id: viewStack
|
||||
clip: true
|
||||
implicitWidth: walletMenu.availableWidth
|
||||
implicitHeight: currentItem ? currentItem.implicitHeight : 0
|
||||
initialItem: mainView
|
||||
|
||||
Behavior on implicitHeight {
|
||||
NumberAnimation { duration: 160; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
pushEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } }
|
||||
pushExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } }
|
||||
popEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } }
|
||||
popExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } }
|
||||
}
|
||||
|
||||
// ── Main view: account icon + power icon, then the active account ──
|
||||
Component {
|
||||
id: mainView
|
||||
|
||||
ColumnLayout {
|
||||
spacing: Theme.spacing.medium
|
||||
|
||||
// Top-right actions: open the account list / disconnect.
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.spacing.small
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
WalletIconButton {
|
||||
iconSource: Qt.resolvedUrl("icons/account.svg")
|
||||
onClicked: viewStack.push(accountsView)
|
||||
}
|
||||
WalletIconButton {
|
||||
iconSource: Qt.resolvedUrl("icons/power.svg")
|
||||
onClicked: {
|
||||
walletMenu.close()
|
||||
if (root.backend) root.backend.disconnectWallet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Active account card.
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: cardColumn.implicitHeight + Theme.spacing.medium * 2
|
||||
radius: Theme.spacing.radiusLarge
|
||||
color: Theme.palette.backgroundMuted
|
||||
|
||||
ColumnLayout {
|
||||
id: cardColumn
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacing.medium
|
||||
spacing: Theme.spacing.small
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.spacing.small
|
||||
|
||||
LogosText {
|
||||
text: root.selectedName
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
font.bold: true
|
||||
}
|
||||
Rectangle {
|
||||
Layout.preferredWidth: tagLabel.implicitWidth + Theme.spacing.small * 2
|
||||
Layout.preferredHeight: tagLabel.implicitHeight + 4
|
||||
radius: 4
|
||||
color: Theme.palette.backgroundSecondary
|
||||
LogosText {
|
||||
id: tagLabel
|
||||
anchors.centerIn: parent
|
||||
text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private")
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
color: Theme.palette.textSecondary
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
LogosText {
|
||||
text: root.selectedBalance.length > 0 ? root.selectedBalance : "—"
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
LogosText {
|
||||
Layout.fillWidth: true
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
text: root.selectedAddress
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
color: Theme.palette.textMuted
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
LogosCopyButton {
|
||||
Layout.preferredHeight: 40
|
||||
Layout.preferredWidth: 40
|
||||
visible: root.selectedAddress.length > 0
|
||||
onCopyText: root.copyToClipboard(root.selectedAddress)
|
||||
icon.color: Theme.palette.textMuted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Accounts view: back + full list + create ──────────────────────
|
||||
Component {
|
||||
id: accountsView
|
||||
|
||||
ColumnLayout {
|
||||
spacing: Theme.spacing.medium
|
||||
|
||||
// Header: back to the main view + title.
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.spacing.small
|
||||
|
||||
WalletIconButton {
|
||||
iconSource: Qt.resolvedUrl("icons/back.svg")
|
||||
onClicked: viewStack.pop()
|
||||
}
|
||||
LogosText {
|
||||
Layout.fillWidth: true
|
||||
text: qsTr("Accounts")
|
||||
font.bold: true
|
||||
color: Theme.palette.text
|
||||
}
|
||||
}
|
||||
|
||||
// Account list: tap a row to make it the active account, then
|
||||
// return to the main view so the selection is reflected.
|
||||
ListView {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(contentHeight, 260)
|
||||
clip: true
|
||||
model: root.accountModel
|
||||
spacing: Theme.spacing.small
|
||||
ScrollIndicator.vertical: ScrollIndicator { }
|
||||
|
||||
delegate: AccountDelegate {
|
||||
width: ListView.view.width
|
||||
highlighted: index === root.selectedIndex
|
||||
onClicked: {
|
||||
root.selectedIndex = index
|
||||
viewStack.pop()
|
||||
}
|
||||
onCopyRequested: (text) => root.copyToClipboard(text)
|
||||
}
|
||||
}
|
||||
|
||||
LogosButton {
|
||||
Layout.fillWidth: true
|
||||
height: 40
|
||||
text: qsTr("Add")
|
||||
// Leave the wallet menu open behind the (modal) dialog.
|
||||
onClicked: createAccountDialog.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── Dialogs ──────────────────────────────────────────────────────────
|
||||
CreateWalletDialog {
|
||||
id: createWalletDialog
|
||||
walletHome: root.backend ? root.backend.walletHome : ""
|
||||
onCreateWallet: function(password) {
|
||||
if (!root.backend) return
|
||||
// createNewDefault returns the new wallet's seed phrase (empty on
|
||||
// failure). On success we hand it to the dialog, which switches to
|
||||
// its backup page — we do NOT close here, so the user can't skip it.
|
||||
logos.watch(root.backend.createNewDefault(password),
|
||||
function(mnemonic) {
|
||||
if (mnemonic && mnemonic.length > 0)
|
||||
createWalletDialog.mnemonic = mnemonic
|
||||
else
|
||||
createWalletDialog.createError = qsTr("Failed to create wallet. Please try again.")
|
||||
},
|
||||
function(error) {
|
||||
createWalletDialog.createError = qsTr("Error creating wallet: %1").arg(error)
|
||||
})
|
||||
}
|
||||
onCopyRequested: function(text) {
|
||||
if (root.backend) root.backend.copyToClipboard(text)
|
||||
}
|
||||
}
|
||||
|
||||
CreateAccountDialog {
|
||||
id: createAccountDialog
|
||||
onCreatePublicRequested: {
|
||||
if (!root.backend) {
|
||||
root.finishAccountCreation("", qsTr("Wallet backend is unavailable. Reconnect and try again."))
|
||||
return
|
||||
}
|
||||
createAccountDialog.createError = ""
|
||||
logos.watch(root.backend.createAccountPublic(),
|
||||
function(id) {
|
||||
root.finishAccountCreation(id, qsTr("Failed to create account. Please try again."))
|
||||
},
|
||||
function(error) {
|
||||
createAccountDialog.busy = false
|
||||
createAccountDialog.createError = qsTr("Error creating account: %1").arg(error)
|
||||
})
|
||||
}
|
||||
onCreatePrivateRequested: {
|
||||
if (!root.backend) {
|
||||
root.finishAccountCreation("", qsTr("Wallet backend is unavailable. Reconnect and try again."))
|
||||
return
|
||||
}
|
||||
createAccountDialog.createError = ""
|
||||
logos.watch(root.backend.createAccountPrivate(),
|
||||
function(id) {
|
||||
root.finishAccountCreation(id, qsTr("Failed to create private account. Please try again."))
|
||||
},
|
||||
function(error) {
|
||||
createAccountDialog.busy = false
|
||||
createAccountDialog.createError = qsTr("Error creating private account: %1").arg(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
WalletMessageDialog {
|
||||
id: walletMessageDialog
|
||||
}
|
||||
}
|
||||
@ -1,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
@ -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
|
||||
|
||||
@ -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
43
flake.lock
generated
Normal 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
45
flake.nix
Normal 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;
|
||||
});
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user