feat(apps/amm): add create-pool / new liquidity position flow

Add a "Create Pool" flow to the AMM app that lets a user open a new
liquidity position — seeding a pool's initial liquidity — from token
selection and amount entry, through a confirmation dialog, to on-chain
submission.

- client crate (apps/amm/client): pure, testable protocol logic — account
  decoding, pair/position modelling, and quote/plan computation — exposed to
  the app over a C ABI (config/networks.json drives network selection).
- C++ runtime + backend: AmmClient, ActiveNetwork, and NewPositionRuntime,
  wired into AmmUiBackend (new resolve/quote/submit slots).
- QML flow: NewPositionForm, NewPositionFlow state, NewPositionConfirmation-
  Dialog, TokenSelectorModal, and reusable Amm* presentational components
  (theme, surfaces, buttons) + AmountMath.js.
- tests: C++ (NewPositionRuntimeTest, ActiveNetworkTest) and QML
  (tst_NewPositionForm, tst_LiquidityPage, tst_TokenAmountInput, …).
This commit is contained in:
r4bbit 2026-06-26 09:22:42 +02:00
parent 64b7e8a197
commit b2753aa0c9
No known key found for this signature in database
GPG Key ID: E95F1E9447DC91A9
68 changed files with 10075 additions and 1911 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_client_ffi"
version = "0.1.0"
@ -1309,6 +1328,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"
@ -2761,6 +2786,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"
@ -4878,6 +4913,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

@ -4,6 +4,11 @@ project(AmmUiPlugin LANGUAGES CXX)
find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2)
qt_standard_project_setup(REQUIRES 6.8)
find_package(PkgConfig REQUIRED)
pkg_check_modules(BASE58 REQUIRED IMPORTED_TARGET libbase58)
include(CTest)
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
else()
@ -31,14 +36,50 @@ logos_module(
src/AmmUiPlugin.cpp
src/AmmUiBackend.h
src/AmmUiBackend.cpp
src/ActiveNetwork.h
src/ActiveNetwork.cpp
src/AmmClient.h
src/AmmClient.cpp
src/NewPositionRuntime.h
src/NewPositionRuntime.cpp
FIND_PACKAGES
Qt6Gui
Qt6Network
LINK_LIBRARIES
Qt6::Gui
Qt6::Network
EXTERNAL_LIBS
amm_client_ffi
PkgConfig::BASE58
LINK_TARGETS
logos_wallet_access
EXTERNAL_LIBS
amm_client_ffi
amm_client
)
qt_add_resources(amm_ui_module_plugin amm_ui_config
PREFIX "/amm"
FILES
config/networks.json
)
if(BUILD_TESTING)
add_executable(amm_active_network_test
tests/cpp/ActiveNetworkTest.cpp
src/ActiveNetwork.cpp
)
target_include_directories(amm_active_network_test PRIVATE src)
target_link_libraries(amm_active_network_test PRIVATE Qt6::Core)
add_test(NAME amm_active_network COMMAND amm_active_network_test)
add_executable(amm_new_position_runtime_test
tests/cpp/NewPositionRuntimeTest.cpp
src/NewPositionRuntime.cpp
)
target_include_directories(amm_new_position_runtime_test PRIVATE src)
target_link_libraries(amm_new_position_runtime_test PRIVATE
Qt6::Core
PkgConfig::BASE58
logos_wallet_access
)
add_test(NAME amm_new_position_runtime COMMAND amm_new_position_runtime_test)
endif()

View File

@ -27,10 +27,12 @@ Account/keystore sharing follows the runtime:
startup the backend **adopts** the already-open wallet (see
`openOrAdoptWallet()`), surfacing **shared** accounts across apps.
> Follow-up: the wallet FFI requires explicit `config_path`/`storage_path` even
> though the wallet crate already defines defaults (`~/.lee/wallet`,
> `from_path_or_initialize_default`). A `wallet_ffi_create_new_default()` /
> `_open_default()` upstream would let the app drop its path handling entirely.
> Follow-up: the app reconstructs the wallet paths itself because the
> `logos_execution_zone` module only exposes path-taking `create_new`/`open`.
> LEZ's wallet FFI now provides path-free variants (`wallet_ffi_create_new_default`,
> `wallet_ffi_open_default`, plus `wallet_ffi_default_config_path` /
> `_storage_path` / `wallet_ffi_wallet_exists_default`). Once the module surfaces
> those over QtRO, the app can drop its `defaultWalletHome/Config/Storage` logic.
## Setup
@ -204,6 +206,11 @@ TOKENS_CONFIG=$(pwd)/amm-tokens.json \
nix run .#amm-ui
```
## Validation
New Position validation commands and acceptance criteria live in
[VALIDATION.md](VALIDATION.md).
## Updating Dependencies
To update the pinned versions of dependencies in `flake.lock`:

52
apps/amm/VALIDATION.md Normal file
View File

@ -0,0 +1,52 @@
# AMM UI Validation
Validation for the New Position flow covers the shipped Rust client, QML
syntax, and the complete module build.
## Commands
Run from the repository root:
```bash
cargo +1.94.0 test -p amm_client
cargo +1.94.0 clippy -p amm_client --all-targets -- -D warnings
logos_qml=$(nix build github:logos-co/logos-design-system/6176f0d7a5dfeb64a7f0f98e7ca2bf71a4804772 --no-link --print-out-paths)
amm_qml=$(nix build ./apps/amm#packages.x86_64-linux.default --no-link --print-out-paths)
qt_qml=$(nix-store --query --requisites "$amm_qml" | rg -m1 -- '-qtdeclarative-[0-9]')
qt_svg=$(nix-store --query --requisites "$amm_qml" | rg -m1 -- '-qtsvg-[0-9]')
export QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software QT_PLUGIN_PATH="$qt_svg/lib/qt-6/plugins"
amm_import="$amm_qml/lib/qml"
test -f "$amm_import/Logos/Wallet/qmldir"
"$qt_qml/bin/qmltestrunner" -import "$qt_qml/lib/qt-6/qml" -import "$logos_qml/lib" -import "$amm_import" -input apps/shared/wallet/tests/qml
"$qt_qml/bin/qmltestrunner" -import "$qt_qml/lib/qt-6/qml" -import "$logos_qml/lib" -import "$amm_import" -input apps/amm/tests/qml
"$qt_qml/bin/qmllint" -I "$qt_qml/lib/qt-6/qml" -I "$logos_qml/lib" -I "$amm_qml/lib" -I "$amm_import" apps/amm/qml/pages/LiquidityPage.qml apps/amm/qml/components/liquidity/NewPositionForm.qml apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml
```
When shared AMM program types or instruction signatures change, also run the
affected Rust checks:
```bash
RISC0_DEV_MODE=1 cargo +1.94.0 test -p amm_program
cargo +1.94.0 build -p idl-gen --release
./target/release/idl-gen programs/amm/methods/guest/src/bin/amm.rs > /tmp/amm-idl.json
diff artifacts/amm-idl.json /tmp/amm-idl.json
```
Live wallet/sequencer validation uses the configured Network and an External
Wallet Provider. Open the AMM UI, select two TokenProgram-scoped fungible token
definitions, preview an active or missing Pool, submit, and verify the returned
transaction ID is displayed.
## Acceptance Checklist
- Context exposes Wallet-Scoped Holdings and configured token definitions.
- Unsupported fee tiers are disabled and explain why.
- Active pool fee tier is fixed to the stored pool fee.
- Missing pool flow accepts an editable `X Token A = Y Token B` ratio and
scales either deposit from the minimum that mints more than
`MINIMUM_LIQUIDITY`.
- Active Pool flow keeps the reserve ratio and previews expected LP output.
- `new_definition` and `add_liquidity` account lists match the committed AMM IDL
order.
- Submit re-quotes and surfaces `quote_changed` when the request no longer
matches the preview hash.

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" }
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,152 @@
use std::str::FromStr;
use nssa_core::{
account::{Account, AccountId, Data, Nonce},
program::ProgramId,
};
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AccountRead {
pub id: String,
pub status: String,
#[serde(default)]
pub account: Option<WalletAccount>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct WalletAccount {
pub program_owner: String,
pub balance: String,
pub nonce: String,
pub data: String,
}
pub(crate) 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(crate) 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(crate) 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(crate) fn program_id_base58(program_id: ProgramId) -> String {
AccountId::new(program_id_bytes(program_id)).to_string()
}
pub(crate) 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(crate) fn parse_base58_id(value: &str, label: &str) -> Result<AccountId, String> {
AccountId::from_str(value).map_err(|error| format!("invalid {label}: {error}"))
}
pub(crate) fn account_id_from_hex(value: &str, label: &str) -> Result<AccountId, String> {
Ok(AccountId::new(parse_hex_32(value, label)?))
}
pub(crate) 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(crate) 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(crate) fn account_read(id: AccountId, account: &Account) -> AccountRead {
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()),
}),
}
}

View File

@ -0,0 +1,226 @@
use amm_core::PoolDefinition;
use nssa_core::program::ProgramId;
use super::{
funding::{append_holding_source, sources_from_reads},
pair::PairIds,
position::{AccountPlan, AccountPlanHoldings, AccountPlanRow},
QuoteRequest,
};
pub(super) fn missing_account_plan(
input: &QuoteRequest,
pair: PairIds,
amm_program: ProgramId,
holdings: AccountPlanHoldings<'_>,
) -> Result<AccountPlan, String> {
let mut sources = sources_from_reads(&[
("config", &input.snapshot.config),
("token_a", &input.snapshot.token_a),
("token_b", &input.snapshot.token_b),
("pool", &input.snapshot.pool),
("vault_a", &input.snapshot.vault_a),
("vault_b", &input.snapshot.vault_b),
("lp_definition", &input.snapshot.lp_definition),
("lp_lock_holding", &input.snapshot.lp_lock_holding),
("current_tick", &input.snapshot.current_tick),
])?;
append_holding_source(&mut sources, "holding_a", holdings.token_a);
append_holding_source(&mut sources, "holding_b", holdings.token_b);
Ok(AccountPlan {
rows: vec![
AccountPlanRow::new(
"config",
Some(pair.config),
Some(amm_program),
"read",
false,
false,
),
AccountPlanRow::new(
"pool",
Some(pair.pool),
Some(amm_program),
"create",
false,
true,
),
AccountPlanRow::new(
"vault_a",
Some(pair.vault_a),
Some(pair.token_program),
"create",
false,
true,
),
AccountPlanRow::new(
"vault_b",
Some(pair.vault_b),
Some(pair.token_program),
"create",
false,
true,
),
AccountPlanRow::new(
"lp_definition",
Some(pair.lp_definition),
Some(pair.token_program),
"create",
false,
true,
),
AccountPlanRow::new(
"lp_lock_holding",
Some(pair.lp_lock_holding),
Some(pair.token_program),
"create",
false,
true,
),
AccountPlanRow::new(
"user_holding_a",
holdings.token_a.map(|value| value.id),
Some(pair.token_program),
"update",
true,
false,
),
AccountPlanRow::new(
"user_holding_b",
holdings.token_b.map(|value| value.id),
Some(pair.token_program),
"update",
true,
false,
),
AccountPlanRow::new(
"user_holding_lp",
None,
Some(pair.token_program),
"create",
true,
true,
),
AccountPlanRow::new(
"current_tick",
Some(pair.current_tick),
Some(pair.twap_program),
"create",
false,
true,
),
AccountPlanRow::new("clock", Some(pair.clock), None, "read", false, false),
],
sources,
})
}
pub(super) fn active_account_plan(
input: &QuoteRequest,
pair: PairIds,
amm_program: ProgramId,
pool: &PoolDefinition,
stored_reversed: bool,
holdings: AccountPlanHoldings<'_>,
) -> Result<AccountPlan, String> {
let (stored_holding_a, stored_holding_b) = if stored_reversed {
(holdings.token_b, holdings.token_a)
} else {
(holdings.token_a, holdings.token_b)
};
let mut sources = sources_from_reads(&[
("config", &input.snapshot.config),
("token_a", &input.snapshot.token_a),
("token_b", &input.snapshot.token_b),
("pool", &input.snapshot.pool),
("vault_a", &input.snapshot.vault_a),
("vault_b", &input.snapshot.vault_b),
("lp_definition", &input.snapshot.lp_definition),
("current_tick", &input.snapshot.current_tick),
])?;
append_holding_source(&mut sources, "holding_a", holdings.token_a);
append_holding_source(&mut sources, "holding_b", holdings.token_b);
append_holding_source(&mut sources, "holding_lp", holdings.lp);
Ok(AccountPlan {
rows: vec![
AccountPlanRow::new(
"config",
Some(pair.config),
Some(amm_program),
"read",
false,
false,
),
AccountPlanRow::new(
"pool",
Some(pair.pool),
Some(amm_program),
"update",
false,
false,
),
AccountPlanRow::new(
"vault_a",
Some(pool.vault_a_id),
Some(pair.token_program),
"update",
false,
false,
),
AccountPlanRow::new(
"vault_b",
Some(pool.vault_b_id),
Some(pair.token_program),
"update",
false,
false,
),
AccountPlanRow::new(
"lp_definition",
Some(pair.lp_definition),
Some(pair.token_program),
"update",
false,
false,
),
AccountPlanRow::new(
"user_holding_a",
stored_holding_a.map(|value| value.id),
Some(pair.token_program),
"update",
true,
false,
),
AccountPlanRow::new(
"user_holding_b",
stored_holding_b.map(|value| value.id),
Some(pair.token_program),
"update",
true,
false,
),
AccountPlanRow::new(
"user_holding_lp",
holdings.lp.map(|value| value.id),
Some(pair.token_program),
if holdings.lp.is_some() {
"update"
} else {
"create"
},
holdings.lp.is_none(),
holdings.lp.is_none(),
),
AccountPlanRow::new(
"current_tick",
Some(pair.current_tick),
Some(pair.twap_program),
"update",
false,
false,
),
AccountPlanRow::new("clock", Some(pair.clock), None, "read", false, false),
],
sources,
})
}

View File

@ -0,0 +1,12 @@
use borsh::from_slice;
use clock_core::ClockAccountData;
use nssa_core::account::AccountId;
use crate::account::{decode_account, AccountRead};
pub(super) fn decode_clock(read: &AccountRead) -> Result<(AccountId, ClockAccountData), String> {
let (id, account) = decode_account(read)?;
let clock = from_slice(account.data.as_ref())
.map_err(|error| format!("invalid clock account: {error}"))?;
Ok((id, clock))
}

View File

@ -0,0 +1,51 @@
use borsh::BorshSerialize;
#[derive(BorshSerialize)]
pub(super) enum RequestCommitment {
Missing {
amount_a: u128,
amount_b: u128,
},
Active {
max_a: u128,
max_b: u128,
slippage_bps: u32,
},
}
#[derive(BorshSerialize)]
pub(super) struct SourceCommitment {
pub(super) role: String,
pub(super) commitment: [u8; 32],
}
#[derive(BorshSerialize)]
pub(super) struct FundingCommitment {
pub(super) token_id: [u8; 32],
pub(super) holding_id: Option<[u8; 32]>,
pub(super) available: u128,
pub(super) requested: u128,
}
#[derive(BorshSerialize)]
pub(super) struct QuoteCommitment {
pub(super) schema: String,
pub(super) network_id: String,
pub(super) network_fingerprint: String,
pub(super) amm_program_id: [u8; 32],
pub(super) token_a_id: [u8; 32],
pub(super) token_b_id: [u8; 32],
pub(super) fee_bps: u32,
pub(super) pool_status: u8,
pub(super) request: RequestCommitment,
pub(super) max_a: u128,
pub(super) max_b: u128,
pub(super) actual_a: u128,
pub(super) actual_b: u128,
pub(super) expected_lp: u128,
pub(super) lp_guard: u128,
pub(super) requires_fresh_lp: bool,
pub(super) sources: Vec<SourceCommitment>,
pub(super) funding: Vec<FundingCommitment>,
pub(super) warnings: Vec<String>,
}

View File

@ -0,0 +1,25 @@
use amm_core::{compute_config_pda, AmmConfig};
use nssa_core::{account::Account, program::ProgramId};
use serde_json::{json, Value};
use super::ConfigIdRequest;
use crate::account::{account_id_hex, decode_account, parse_program_id, AccountRead};
pub(super) fn config_id(request: ConfigIdRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
Ok(json!({
"status": "ok",
"configId": account_id_hex(compute_config_pda(amm_program)),
}))
}
pub(super) fn load_config(amm_program: ProgramId, read: &AccountRead) -> Result<AmmConfig, String> {
let (id, account) = decode_account(read)?;
if id != compute_config_pda(amm_program)
|| account.program_owner != amm_program
|| account == Account::default()
{
return Err(String::from("AMM config is unavailable"));
}
AmmConfig::try_from(&account.data).map_err(|_| String::from("AMM config is invalid"))
}

View File

@ -0,0 +1,254 @@
use std::collections::{BTreeMap, BTreeSet};
use amm_core::{
FEE_TIER_BPS_1, FEE_TIER_BPS_100, FEE_TIER_BPS_30, FEE_TIER_BPS_5, MINIMUM_LIQUIDITY,
};
use nssa_core::{account::AccountId, program::ProgramId};
use serde_json::{json, Value};
use token_core::TokenDefinition;
use super::{
config::load_config,
holding::{select_holding, wallet_holdings, SelectedHolding},
quote_error::issue,
ContextRequest, TokenIdsRequest, SCHEMA,
};
use crate::account::{
account_id_from_hex, account_id_hex, decode_account, parse_base58_id, parse_program_id,
program_id_base58, AccountRead,
};
pub(super) fn token_ids(request: TokenIdsRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
let Ok(config) = load_config(amm_program, &request.config) else {
return Ok(manifest_error("config_unavailable"));
};
let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id);
let mut token_ids = BTreeSet::new();
for id in &request.configured_token_ids {
if let Ok(id) = account_id_from_hex(id, "configured token id") {
token_ids.insert(id);
}
}
for id in request
.recent_token_ids
.iter()
.chain(&request.resolved_token_ids)
{
if let Ok(id) = parse_base58_id(id, "token id") {
token_ids.insert(id);
}
}
token_ids.extend(holdings.into_iter().map(|holding| holding.definition_id));
Ok(json!({
"status": "ok",
"tokenIds": token_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(),
}))
}
fn manifest_error(code: &str) -> Value {
json!({ "status": "error", "code": code, "tokenIds": [] })
}
pub(super) fn context(request: ContextRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
let Ok(config) = load_config(amm_program, &request.config) else {
return Ok(context_error(&request, "config_unavailable"));
};
let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id);
let source_map = token_sources(&request, &holdings);
let mut rows = Vec::new();
let mut warnings = Vec::new();
for (token_id, sources) in source_map {
let read = request
.token_definitions
.iter()
.find(|read| account_id_from_hex(&read.id, "token definition id") == Ok(token_id));
let (name, total_supply, metadata_id) =
match fungible_definition(read, token_id, config.token_program_id) {
Ok(definition) => definition,
Err(error) => {
rows.push(unavailable_token_row(token_id, sources, error.code));
if error.warn {
warnings.push(issue(
error.code,
"Token definition could not be read.",
&[],
json!({ "tokenId": token_id.to_string() }),
));
}
continue;
}
};
let selected = select_holding(&holdings, token_id);
let mut row = json!({
"definitionId": token_id.to_string(),
"name": name,
"metadataId": metadata_id.map(|id| id.to_string()),
"totalSupplyRaw": total_supply.to_string(),
"ownerProgramId": program_id_base58(config.token_program_id),
"public": true,
"fungible": true,
"selectable": true,
"status": "available",
"code": "available",
"sources": sources,
});
if let Some(selected) = selected {
row["holdingId"] = json!(selected.id.to_string());
row["balanceRaw"] = json!(selected.balance.to_string());
}
rows.push(row);
}
rows.sort_by(|left, right| {
let left_holding = left.get("holdingId").is_some();
let right_holding = right.get("holdingId").is_some();
right_holding.cmp(&left_holding).then_with(|| {
left["definitionId"]
.as_str()
.cmp(&right["definitionId"].as_str())
})
});
Ok(json!({
"schema": SCHEMA,
"status": if request.wallet_available { "ready" } else { "no_wallet" },
"networkId": request.network_id,
"networkFingerprint": request.network_fingerprint,
"walletAvailable": request.wallet_available,
"minimumLiquidityRaw": MINIMUM_LIQUIDITY.to_string(),
"programIds": {
"amm": program_id_base58(amm_program),
"token": program_id_base58(config.token_program_id),
"twapOracle": program_id_base58(config.twap_oracle_program_id),
},
"tokens": rows,
"feeTiers": fee_tiers(),
"warnings": warnings,
}))
}
fn context_error(request: &ContextRequest, code: &str) -> Value {
json!({
"schema": SCHEMA,
"status": "error",
"code": code,
"networkId": request.network_id,
"networkFingerprint": request.network_fingerprint,
"walletAvailable": request.wallet_available,
"tokens": [],
"feeTiers": fee_tiers(),
"warnings": [],
})
}
fn fee_tiers() -> Value {
json!([
{ "feeBps": FEE_TIER_BPS_1, "label": "0.01%", "enabled": true },
{ "feeBps": FEE_TIER_BPS_5, "label": "0.05%", "enabled": true },
{ "feeBps": FEE_TIER_BPS_30, "label": "0.30%", "enabled": true },
{ "feeBps": FEE_TIER_BPS_100, "label": "1.00%", "enabled": true },
])
}
fn token_sources(
request: &ContextRequest,
holdings: &[SelectedHolding],
) -> BTreeMap<AccountId, Vec<String>> {
let mut sources: BTreeMap<AccountId, BTreeSet<String>> = BTreeMap::new();
for id in &request.configured_token_ids {
if let Ok(id) = account_id_from_hex(id, "configured token id") {
sources
.entry(id)
.or_default()
.insert(String::from("config"));
}
}
for (ids, source) in [
(&request.recent_token_ids, "recent"),
(&request.resolved_token_ids, "resolved"),
] {
for id in ids {
if let Ok(id) = parse_base58_id(id, "token id") {
sources.entry(id).or_default().insert(String::from(source));
}
}
}
for holding in holdings {
sources
.entry(holding.definition_id)
.or_default()
.insert(String::from("holding"));
}
sources
.into_iter()
.map(|(id, values)| (id, values.into_iter().collect()))
.collect()
}
fn unavailable_token_row(token_id: AccountId, sources: Vec<String>, code: &str) -> Value {
json!({
"definitionId": token_id.to_string(),
"name": "",
"metadataId": Value::Null,
"totalSupplyRaw": "0",
"selectable": false,
"status": code,
"code": code,
"sources": sources,
})
}
pub(super) struct DefinitionError {
pub(super) code: &'static str,
pub(super) warn: bool,
}
pub(super) fn fungible_definition(
read: Option<&AccountRead>,
token_id: AccountId,
token_program: ProgramId,
) -> Result<(String, u128, Option<AccountId>), DefinitionError> {
let Some(read) = read else {
return Err(DefinitionError {
code: "token_definition_unreadable",
warn: true,
});
};
let Ok((id, account)) = decode_account(read) else {
return Err(DefinitionError {
code: "token_definition_unreadable",
warn: true,
});
};
if id != token_id {
return Err(DefinitionError {
code: "token_definition_unreadable",
warn: false,
});
}
if account.program_owner != token_program {
return Err(DefinitionError {
code: "token_program_mismatch",
warn: false,
});
}
match TokenDefinition::try_from(&account.data) {
Ok(TokenDefinition::Fungible {
name,
total_supply,
metadata_id,
..
}) => Ok((name, total_supply, metadata_id)),
_ => Err(DefinitionError {
code: "token_not_fungible",
warn: false,
}),
}
}

View File

@ -0,0 +1,106 @@
use nssa_core::Commitment;
use serde_json::{json, Value};
use sha2::{Digest as _, Sha256};
use super::{
commitment::{FundingCommitment, QuoteCommitment, SourceCommitment},
holding::SelectedHolding,
pair::PairIds,
quote_error::issue,
};
use crate::account::{decode_account, AccountRead};
pub(super) fn funding_issues(
wallet_available: bool,
pair: PairIds,
holding_a: &Option<SelectedHolding>,
requested_a: u128,
holding_b: &Option<SelectedHolding>,
requested_b: u128,
fields: [&str; 2],
) -> Vec<Value> {
if !wallet_available {
return vec![issue(
"no_wallet",
"Connect a wallet to submit.",
&[],
json!({}),
)];
}
let mut errors = Vec::new();
for (token_id, holding, requested, field) in [
(pair.token_a, holding_a, requested_a, fields[0]),
(pair.token_b, holding_b, requested_b, fields[1]),
] {
let available = holding.as_ref().map_or(0, |value| value.balance);
if available < requested {
errors.push(issue(
"amount_exceeds_balance",
"Amount exceeds the selected wallet holding balance.",
&[field],
json!({
"requestedRaw": requested.to_string(),
"availableRaw": available.to_string(),
"holdingFound": holding.is_some(),
"tokenId": token_id.to_string(),
}),
));
}
}
errors
}
pub(super) fn funding_commitments(
pair: PairIds,
holding_a: &Option<SelectedHolding>,
requested_a: u128,
holding_b: &Option<SelectedHolding>,
requested_b: u128,
) -> Vec<FundingCommitment> {
[
(pair.token_a, holding_a, requested_a),
(pair.token_b, holding_b, requested_b),
]
.into_iter()
.map(|(token_id, holding, requested)| FundingCommitment {
token_id: token_id.into_value(),
holding_id: holding.as_ref().map(|value| value.id.into_value()),
available: holding.as_ref().map_or(0, |value| value.balance),
requested,
})
.collect()
}
pub(super) fn sources_from_reads(
reads: &[(&str, &AccountRead)],
) -> Result<Vec<SourceCommitment>, String> {
reads
.iter()
.map(|(role, read)| {
let (id, account) = decode_account(read)?;
Ok(SourceCommitment {
role: String::from(*role),
commitment: Commitment::new(&id, &account).to_byte_array(),
})
})
.collect()
}
pub(super) fn append_holding_source(
sources: &mut Vec<SourceCommitment>,
role: &str,
holding: Option<&SelectedHolding>,
) {
if let Some(holding) = holding {
sources.push(SourceCommitment {
role: String::from(role),
commitment: Commitment::new(&holding.id, &holding.account).to_byte_array(),
});
}
}
pub(super) fn hash_quote(commitment: &QuoteCommitment) -> Result<String, String> {
let bytes = borsh::to_vec(commitment)
.map_err(|error| format!("quote commitment serialization failed: {error}"))?;
Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes))))
}

View File

@ -0,0 +1,64 @@
use nssa_core::{
account::{Account, AccountId},
program::ProgramId,
};
use token_core::TokenHolding;
use crate::account::{decode_account, AccountRead};
#[derive(Clone)]
pub(super) struct SelectedHolding {
pub(super) id: AccountId,
pub(super) definition_id: AccountId,
pub(super) balance: u128,
pub(super) account: Account,
}
pub(super) fn wallet_holdings(
reads: &[AccountRead],
token_program: ProgramId,
) -> Vec<SelectedHolding> {
reads
.iter()
.filter_map(|read| decode_fungible_holding(read, token_program).ok())
.collect()
}
pub(super) fn decode_fungible_holding(
read: &AccountRead,
token_program: ProgramId,
) -> Result<SelectedHolding, String> {
let (id, account) = decode_account(read)?;
if account.program_owner != token_program {
return Err(String::from("holding owner mismatch"));
}
let TokenHolding::Fungible {
definition_id,
balance,
} = TokenHolding::try_from(&account.data)
.map_err(|_| String::from("invalid fungible holding"))?
else {
return Err(String::from("invalid fungible holding"));
};
Ok(SelectedHolding {
id,
definition_id,
balance,
account,
})
}
pub(super) fn select_holding(
holdings: &[SelectedHolding],
definition_id: AccountId,
) -> Option<SelectedHolding> {
holdings
.iter()
.filter(|holding| holding.definition_id == definition_id)
.max_by(|left, right| {
left.balance
.cmp(&right.balance)
.then_with(|| right.id.cmp(&left.id))
})
.cloned()
}

View File

@ -0,0 +1,97 @@
//! Transport-independent AMM client operations.
mod accounts;
mod clock;
mod commitment;
mod config;
mod context;
mod funding;
mod holding;
mod pair;
mod plan;
mod position;
mod quote;
mod quote_error;
mod request;
#[cfg(test)]
mod tests;
use std::{error::Error, fmt};
pub use request::{
ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest,
QuoteRequest, TokenIdsRequest,
};
use serde_json::Value;
pub use crate::account::{AccountRead, WalletAccount};
/// Schema identifier expected by position quote and plan requests.
pub const NEW_POSITION_SCHEMA: &str = "new-position.v1";
pub(crate) const SCHEMA: &str = NEW_POSITION_SCHEMA;
/// JSON response shared by direct Rust callers and transport adapters.
pub type AmmResponse = Value;
/// Result returned by AMM client operations.
pub type AmmResult = Result<AmmResponse, AmmApiError>;
/// Failure produced before an AMM response can be constructed.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AmmApiError {
message: String,
}
impl AmmApiError {
/// Returns the stable human-readable failure detail.
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
impl fmt::Display for AmmApiError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl Error for AmmApiError {}
impl From<String> for AmmApiError {
fn from(message: String) -> Self {
Self { message }
}
}
/// Derives the AMM configuration account ID.
pub fn config_id(request: ConfigIdRequest) -> AmmResult {
config::config_id(request).map_err(Into::into)
}
/// Discovers token definition IDs available to the active wallet and app.
pub fn token_ids(request: TokenIdsRequest) -> AmmResult {
context::token_ids(request).map_err(Into::into)
}
/// Derives canonical accounts for one token pair.
pub fn pair_ids(request: PairIdsRequest) -> AmmResult {
pair::pair_ids(request).map_err(Into::into)
}
/// Builds network, token, holding, and fee-tier context.
pub fn context(request: ContextRequest) -> AmmResult {
context::context(request).map_err(Into::into)
}
/// Evaluates a pool-creation or add-liquidity request.
pub fn quote(request: QuoteRequest) -> AmmResult {
quote::quote(request).map_err(Into::into)
}
/// Materializes a previously quoted request into wallet submission arguments.
pub fn plan(request: PlanRequest) -> AmmResult {
plan::plan(request).map_err(Into::into)
}

View File

@ -0,0 +1,93 @@
use amm_core::{
compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda,
compute_vault_pda,
};
use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID;
use nssa_core::{account::AccountId, program::ProgramId};
use serde_json::{json, Value};
use twap_oracle_core::compute_current_tick_account_pda;
use super::{config::load_config, PairIdsRequest};
use crate::account::{account_id_hex, parse_base58_id, parse_program_id, AccountRead};
#[derive(Clone, Copy)]
pub(super) struct PairIds {
pub(super) token_a: AccountId,
pub(super) token_b: AccountId,
pub(super) config: AccountId,
pub(super) pool: AccountId,
pub(super) vault_a: AccountId,
pub(super) vault_b: AccountId,
pub(super) lp_definition: AccountId,
pub(super) lp_lock_holding: AccountId,
pub(super) current_tick: AccountId,
pub(super) clock: AccountId,
pub(super) token_program: ProgramId,
pub(super) twap_program: ProgramId,
}
pub(super) fn pair_ids(request: PairIdsRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
let Ok(token_a) = parse_base58_id(&request.token_a_id, "token A id") else {
return Ok(json!({ "status": "error", "code": "invalid_token_id" }));
};
let Ok(token_b) = parse_base58_id(&request.token_b_id, "token B id") else {
return Ok(json!({ "status": "error", "code": "invalid_token_id" }));
};
if token_a == token_b {
return Ok(json!({ "status": "error", "code": "same_token_pair" }));
}
if !is_canonical_pair(token_a, token_b) {
return Ok(json!({ "status": "error", "code": "non_canonical_pair" }));
}
let Ok(pair) = derive_pair(amm_program, token_a, token_b, &request.config) else {
return Ok(json!({ "status": "error", "code": "config_unavailable" }));
};
Ok(pair_json(pair))
}
pub(super) fn is_canonical_pair(token_a: AccountId, token_b: AccountId) -> bool {
token_a.value() > token_b.value()
}
pub(super) fn derive_pair(
amm_program: ProgramId,
token_a: AccountId,
token_b: AccountId,
config_read: &AccountRead,
) -> Result<PairIds, String> {
let config_id = compute_config_pda(amm_program);
let config = load_config(amm_program, config_read)?;
let pool = compute_pool_pda(amm_program, token_a, token_b);
Ok(PairIds {
token_a,
token_b,
config: config_id,
pool,
vault_a: compute_vault_pda(amm_program, pool, token_a),
vault_b: compute_vault_pda(amm_program, pool, token_b),
lp_definition: compute_liquidity_token_pda(amm_program, pool),
lp_lock_holding: compute_lp_lock_holding_pda(amm_program, pool),
current_tick: compute_current_tick_account_pda(config.twap_oracle_program_id, pool),
clock: CLOCK_01_PROGRAM_ACCOUNT_ID,
token_program: config.token_program_id,
twap_program: config.twap_oracle_program_id,
})
}
fn pair_json(pair: PairIds) -> Value {
json!({
"status": "ok",
"tokenAId": account_id_hex(pair.token_a),
"tokenBId": account_id_hex(pair.token_b),
"configId": account_id_hex(pair.config),
"poolId": account_id_hex(pair.pool),
"vaultAId": account_id_hex(pair.vault_a),
"vaultBId": account_id_hex(pair.vault_b),
"lpDefinitionId": account_id_hex(pair.lp_definition),
"lpLockHoldingId": account_id_hex(pair.lp_lock_holding),
"currentTickId": account_id_hex(pair.current_tick),
"clockId": account_id_hex(pair.clock),
})
}

View File

@ -0,0 +1,136 @@
use nssa_core::account::Account;
use serde_json::{json, Value};
use super::{
clock::decode_clock,
position::{NewPositionPlan, QuoteBranch, QuoteComputation},
quote::compute_quote,
PlanRequest, QuoteRequest, SCHEMA,
};
use crate::account::{account_id_hex, decode_account, AccountRead};
const DEADLINE_WINDOW_MS: u64 = 1_200_000;
pub(super) fn plan(input: PlanRequest) -> Result<Value, String> {
let quote_input = QuoteRequest {
network_id: input.network_id,
network_fingerprint: input.network_fingerprint,
amm_program_id: input.amm_program_id.clone(),
request: input.request,
snapshot: input.snapshot,
};
let quote = compute_quote(&quote_input)?;
if quote.quote_hash() != Some(input.quote_hash.as_str()) {
return Ok(json!({
"schema": SCHEMA,
"status": "error",
"code": "quote_changed",
"recoverable": true,
"quote": quote.into_value(&quote_input.request),
}));
}
let evaluated = match quote {
QuoteComputation::Evaluated(evaluated) => evaluated,
QuoteComputation::Failed(failure) => {
return Ok(json!({
"schema": SCHEMA,
"status": "error",
"code": "quote_not_submittable",
"recoverable": true,
"quote": failure.into_value(&quote_input.request),
}))
}
};
let Some(plan) = evaluated.plan else {
return Ok(json!({
"schema": SCHEMA,
"status": "error",
"code": "quote_not_submittable",
"recoverable": true,
"quote": evaluated.value,
}));
};
let fresh_lp = if plan.requires_fresh_lp() {
let Some(read) = input.fresh_lp.as_ref() else {
return Ok(json!({
"schema": SCHEMA,
"status": "needs_fresh_lp",
"code": "fresh_lp_required",
}));
};
let Ok((id, account)) = decode_account(read) else {
return Ok(plan_error("wallet_submission_failed"));
};
if account != Account::default() || plan.accounts.contains(id) {
return Ok(plan_error("wallet_submission_failed"));
}
Some(id)
} else {
None
};
let deadline = input
.now_ms
.checked_add(DEADLINE_WINDOW_MS)
.ok_or_else(|| String::from("transaction deadline overflow"))?;
let clock_timestamp = clock_timestamp(&quote_input.snapshot.clock)?;
if clock_timestamp >= deadline {
return Ok(plan_error("transaction_deadline_expired"));
}
let NewPositionPlan { accounts, branch } = plan;
let (account_ids, signing_requirements) = accounts.wallet_args(fresh_lp)?;
let instruction = match branch {
QuoteBranch::Missing { amount_a, amount_b } => {
let instruction = amm_core::Instruction::NewDefinition {
token_a_amount: amount_a,
token_b_amount: amount_b,
fees: u128::from(quote_input.request.fee_bps),
deadline,
};
risc0_zkvm::serde::to_vec(&instruction)
.map_err(|error| format!("instruction serialization failed: {error}"))?
}
QuoteBranch::Active {
max_a,
max_b,
minimum_lp,
stored_reversed,
} => {
let (stored_max_a, stored_max_b) = if stored_reversed {
(max_b, max_a)
} else {
(max_a, max_b)
};
let instruction = amm_core::Instruction::AddLiquidity {
min_amount_liquidity: minimum_lp,
max_amount_to_add_token_a: stored_max_a,
max_amount_to_add_token_b: stored_max_b,
deadline,
};
risc0_zkvm::serde::to_vec(&instruction)
.map_err(|error| format!("instruction serialization failed: {error}"))?
}
};
Ok(json!({
"schema": SCHEMA,
"status": "ready",
"programId": input.amm_program_id,
"accountIds": account_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(),
"signingRequirements": signing_requirements,
"instruction": instruction,
"deadlineMs": deadline.to_string(),
}))
}
fn plan_error(code: &str) -> Value {
json!({
"schema": SCHEMA,
"status": "error",
"code": code,
"recoverable": true,
})
}
fn clock_timestamp(read: &AccountRead) -> Result<u64, String> {
decode_clock(read).map(|(_, clock)| clock.timestamp)
}

View File

@ -0,0 +1,229 @@
use nssa_core::{account::AccountId, program::ProgramId};
use serde_json::{json, Value};
use super::{
commitment::SourceCommitment, holding::SelectedHolding, pair::PairIds, quote_error::issue,
PairSnapshot, PositionRequest, SCHEMA,
};
use crate::account::{account_id_from_hex, program_id_base58};
#[derive(Clone)]
pub(super) enum QuoteBranch {
Missing {
amount_a: u128,
amount_b: u128,
},
Active {
max_a: u128,
max_b: u128,
minimum_lp: u128,
stored_reversed: bool,
},
}
pub(super) struct EvaluatedQuote {
pub(super) value: Value,
pub(super) quote_hash: String,
pub(super) plan: Option<NewPositionPlan>,
}
pub(super) enum QuoteComputation {
Failed(QuoteFailure),
Evaluated(EvaluatedQuote),
}
pub(super) struct QuoteFailure {
pub(super) code: &'static str,
pub(super) fields: Vec<&'static str>,
pub(super) details: Value,
}
pub(super) struct NewPositionPlan {
pub(super) accounts: AccountPlan,
pub(super) branch: QuoteBranch,
}
pub(super) struct AccountPlan {
pub(super) rows: Vec<AccountPlanRow>,
pub(super) sources: Vec<SourceCommitment>,
}
pub(super) struct AccountPlanRow {
pub(super) role: &'static str,
pub(super) account_id: Option<AccountId>,
pub(super) expected_program: Option<ProgramId>,
pub(super) action: &'static str,
pub(super) signer: bool,
pub(super) init: bool,
}
pub(super) struct AccountPlanHoldings<'a> {
pub(super) token_a: Option<&'a SelectedHolding>,
pub(super) token_b: Option<&'a SelectedHolding>,
pub(super) lp: Option<&'a SelectedHolding>,
}
impl QuoteComputation {
pub(super) fn into_value(self, request: &PositionRequest) -> Value {
match self {
Self::Failed(failure) => failure.into_value(request),
Self::Evaluated(EvaluatedQuote { value, .. }) => value,
}
}
pub(super) fn quote_hash(&self) -> Option<&str> {
match self {
Self::Failed(_) => None,
Self::Evaluated(quote) => Some(&quote.quote_hash),
}
}
}
impl QuoteFailure {
pub(super) fn into_value(self, request: &PositionRequest) -> Value {
json!({
"schema": SCHEMA,
"status": "error",
"canSubmit": false,
"code": self.code,
"poolStatus": "unavailable_pool",
"tokenAId": request.token_a_id,
"tokenBId": request.token_b_id,
"accountPreview": [],
"errors": [issue(
self.code,
"Position quote is unavailable.",
&self.fields,
self.details,
)],
"warnings": [],
})
}
}
impl NewPositionPlan {
pub(super) fn new(accounts: AccountPlan, branch: QuoteBranch) -> Result<Self, String> {
accounts.validate_ready()?;
Ok(Self { accounts, branch })
}
pub(super) fn requires_fresh_lp(&self) -> bool {
self.accounts.requires_fresh_lp()
}
}
impl AccountPlan {
pub(super) fn validate_snapshot_ids(
pair: &PairIds,
snapshot: &PairSnapshot,
) -> Option<&'static str> {
let expected = [
(&snapshot.config, pair.config, "config"),
(&snapshot.token_a, pair.token_a, "token_a"),
(&snapshot.token_b, pair.token_b, "token_b"),
(&snapshot.pool, pair.pool, "pool"),
(&snapshot.vault_a, pair.vault_a, "vault_a"),
(&snapshot.vault_b, pair.vault_b, "vault_b"),
(&snapshot.lp_definition, pair.lp_definition, "lp_definition"),
(
&snapshot.lp_lock_holding,
pair.lp_lock_holding,
"lp_lock_holding",
),
(&snapshot.current_tick, pair.current_tick, "current_tick"),
(&snapshot.clock, pair.clock, "clock"),
];
expected.into_iter().find_map(|(read, id, role)| {
match account_id_from_hex(&read.id, "account id") {
Ok(actual) if actual == id => None,
_ => Some(role),
}
})
}
pub(super) fn preview(&self) -> Vec<Value> {
self.rows
.iter()
.enumerate()
.map(|(order, row)| row.preview(order))
.collect()
}
pub(super) fn take_sources(&mut self) -> Vec<SourceCommitment> {
std::mem::take(&mut self.sources)
}
pub(super) fn requires_fresh_lp(&self) -> bool {
self.rows
.iter()
.any(|row| row.role == "user_holding_lp" && row.account_id.is_none())
}
pub(super) fn contains(&self, account_id: AccountId) -> bool {
self.rows
.iter()
.any(|row| row.account_id == Some(account_id))
}
pub(super) fn validate_ready(&self) -> Result<(), String> {
if self.rows.iter().any(|row| {
row.account_id.is_none() && !(row.role == "user_holding_lp" && row.signer && row.init)
}) {
return Err(String::from("submittable quote has an unresolved account"));
}
Ok(())
}
pub(super) fn wallet_args(
&self,
fresh_lp: Option<AccountId>,
) -> Result<(Vec<AccountId>, Vec<bool>), String> {
let mut account_ids = Vec::with_capacity(self.rows.len());
let mut signing_requirements = Vec::with_capacity(self.rows.len());
for row in &self.rows {
let account_id = match row.account_id {
Some(account_id) => account_id,
None if row.role == "user_holding_lp" => {
fresh_lp.ok_or_else(|| String::from("transaction plan has no LP holding"))?
}
None => return Err(String::from("transaction plan has an unresolved account")),
};
account_ids.push(account_id);
signing_requirements.push(row.signer);
}
Ok((account_ids, signing_requirements))
}
}
impl AccountPlanRow {
pub(super) fn new(
role: &'static str,
account_id: Option<AccountId>,
expected_program: Option<ProgramId>,
action: &'static str,
signer: bool,
init: bool,
) -> Self {
Self {
role,
account_id,
expected_program,
action,
signer,
init,
}
}
fn preview(&self, order: usize) -> Value {
json!({
"order": order,
"role": self.role,
"accountId": self.account_id.map(|id| id.to_string()),
"expectedProgramId": self.expected_program.map(program_id_base58),
"action": self.action,
"writable": self.action != "read",
"signer": self.signer,
"init": self.init,
})
}
}

View File

@ -0,0 +1,726 @@
use alloy_primitives::U256;
use amm_core::{
is_supported_fee_tier, isqrt_product, mul_div_floor, spot_price_q64_64, PoolDefinition,
FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY,
};
use nssa_core::{
account::{Account, AccountId},
program::ProgramId,
};
use serde_json::{json, Value};
use token_core::TokenDefinition;
use twap_oracle_core::CurrentTickAccount;
use super::{
accounts::{active_account_plan, missing_account_plan},
clock::decode_clock,
commitment::{QuoteCommitment, RequestCommitment},
context::fungible_definition,
funding::{funding_commitments, funding_issues, hash_quote},
holding::{decode_fungible_holding, select_holding, wallet_holdings},
pair::{derive_pair, is_canonical_pair, PairIds},
position::{
AccountPlan, AccountPlanHoldings, EvaluatedQuote, NewPositionPlan, QuoteBranch,
QuoteComputation,
},
quote_error::{fatal_quote, issue},
QuoteRequest, SCHEMA,
};
use crate::account::{
decode_account, parse_base58_id, parse_program_id, program_id_bytes, AccountRead,
};
const DEFAULT_SLIPPAGE_BPS: u32 = 50;
const MAX_SLIPPAGE_BPS: u32 = 5_000;
const HIGH_SLIPPAGE_BPS: u32 = 2_000;
pub(super) const Q64: u128 = 1_u128 << 64;
pub(super) fn quote(request: QuoteRequest) -> Result<Value, String> {
Ok(compute_quote(&request)?.into_value(&request.request))
}
pub(super) fn compute_quote(input: &QuoteRequest) -> Result<QuoteComputation, String> {
if input.request.schema != SCHEMA {
return Ok(fatal_quote(
"unsupported_schema",
&["schema"],
json!({ "received": input.request.schema }),
));
}
let amm_program = parse_program_id(&input.amm_program_id)?;
let token_a = match parse_base58_id(&input.request.token_a_id, "token A id") {
Ok(id) => id,
Err(_) => return Ok(fatal_quote("invalid_token_id", &["tokenAId"], json!({}))),
};
let token_b = match parse_base58_id(&input.request.token_b_id, "token B id") {
Ok(id) => id,
Err(_) => return Ok(fatal_quote("invalid_token_id", &["tokenBId"], json!({}))),
};
if token_a == token_b {
return Ok(fatal_quote(
"same_token_pair",
&["tokenAId", "tokenBId"],
json!({}),
));
}
if !is_canonical_pair(token_a, token_b) {
return Ok(fatal_quote(
"non_canonical_pair",
&["tokenAId", "tokenBId"],
json!({}),
));
}
if !is_supported_fee_tier(u128::from(input.request.fee_bps)) {
return Ok(fatal_quote(
"invalid_fee_tier",
&["feeBps"],
json!({ "feeBps": input.request.fee_bps }),
));
}
let pair = match derive_pair(amm_program, token_a, token_b, &input.snapshot.config) {
Ok(pair) => pair,
Err(_) => return Ok(fatal_quote("config_unavailable", &[], json!({}))),
};
if let Some(error) = AccountPlan::validate_snapshot_ids(&pair, &input.snapshot) {
return Ok(fatal_quote(
"account_read_failed",
&[],
json!({ "role": error }),
));
}
for (read, token_id, field) in [
(&input.snapshot.token_a, token_a, "tokenAId"),
(&input.snapshot.token_b, token_b, "tokenBId"),
] {
if let Err(error) = fungible_definition(Some(read), token_id, pair.token_program) {
return Ok(fatal_quote(
error.code,
&[field],
json!({ "tokenId": token_id.to_string() }),
));
}
}
let (_, pool_account) = match decode_account(&input.snapshot.pool) {
Ok(value) => value,
Err(_) => {
return Ok(fatal_quote(
"account_read_failed",
&[],
json!({ "role": "pool", "accountId": pair.pool.to_string() }),
))
}
};
if pool_account == Account::default() {
compute_missing_quote(input, amm_program, pair)
} else {
compute_active_quote(input, amm_program, pair, pool_account)
}
}
fn compute_missing_quote(
input: &QuoteRequest,
amm_program: ProgramId,
pair: PairIds,
) -> Result<QuoteComputation, String> {
for (read, role) in [
(&input.snapshot.vault_a, "vault_a"),
(&input.snapshot.vault_b, "vault_b"),
(&input.snapshot.lp_definition, "lp_definition"),
(&input.snapshot.lp_lock_holding, "lp_lock_holding"),
(&input.snapshot.current_tick, "current_tick"),
] {
let Ok((_, account)) = decode_account(read) else {
return Ok(fatal_quote(
"account_read_failed",
&[],
json!({ "role": role }),
));
};
if account != Account::default() {
return Ok(fatal_quote(
"pool_unavailable",
&[],
json!({ "role": role }),
));
}
}
if !valid_clock(&input.snapshot.clock, pair.clock) {
return Ok(fatal_quote(
"account_read_failed",
&[],
json!({ "role": "clock" }),
));
}
let requested_price = match raw_value(input.request.initial_price_real_raw.as_deref()) {
Ok(value) if value > 0 => value,
Ok(_) => {
return Ok(fatal_quote(
"amount_must_be_positive",
&["initialPriceRealRaw"],
json!({}),
))
}
Err(code) => return Ok(fatal_quote(code, &["initialPriceRealRaw"], json!({}))),
};
let (minimum_a, minimum_b) = minimum_opening_pair(requested_price)?;
let direct_amounts =
input.request.amount_a_raw.is_some() || input.request.amount_b_raw.is_some();
let (amount_a, amount_b) = if direct_amounts {
let amount_a = match raw_value(input.request.amount_a_raw.as_deref()) {
Ok(value) if value > 0 => value,
Ok(_) => {
return Ok(fatal_quote(
"amount_must_be_positive",
&["amountARaw"],
json!({}),
))
}
Err(code) => return Ok(fatal_quote(code, &["amountARaw"], json!({}))),
};
let amount_b = match raw_value(input.request.amount_b_raw.as_deref()) {
Ok(value) if value > 0 => value,
Ok(_) => {
return Ok(fatal_quote(
"amount_must_be_positive",
&["amountBRaw"],
json!({}),
))
}
Err(code) => return Ok(fatal_quote(code, &["amountBRaw"], json!({}))),
};
if spot_price_q64_64(amount_a, amount_b) != requested_price {
return Ok(fatal_quote(
"deposit_ratio_mismatch",
&["amountARaw", "amountBRaw"],
json!({}),
));
}
(amount_a, amount_b)
} else {
(minimum_a, minimum_b)
};
let initial_lp = isqrt_product(amount_a, amount_b);
if initial_lp <= MINIMUM_LIQUIDITY {
return Ok(fatal_quote(
"amount_too_low",
&["amountARaw", "amountBRaw"],
json!({ "minimumLiquidityRaw": MINIMUM_LIQUIDITY.to_string() }),
));
}
let expected_lp = initial_lp - MINIMUM_LIQUIDITY;
let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program);
let holding_a = select_holding(&holdings, pair.token_a);
let holding_b = select_holding(&holdings, pair.token_b);
let funding = funding_issues(
input.snapshot.wallet_available,
pair,
&holding_a,
amount_a,
&holding_b,
amount_b,
["amountARaw", "amountBRaw"],
);
let can_submit = funding.is_empty();
let mut account_plan = missing_account_plan(
input,
pair,
amm_program,
AccountPlanHoldings {
token_a: holding_a.as_ref(),
token_b: holding_b.as_ref(),
lp: None,
},
)?;
let sources = account_plan.take_sources();
let funding_commitment = funding_commitments(pair, &holding_a, amount_a, &holding_b, amount_b);
let commitment = QuoteCommitment {
schema: String::from(SCHEMA),
network_id: input.network_id.clone(),
network_fingerprint: input.network_fingerprint.clone(),
amm_program_id: program_id_bytes(amm_program),
token_a_id: pair.token_a.into_value(),
token_b_id: pair.token_b.into_value(),
fee_bps: input.request.fee_bps,
pool_status: 0,
request: RequestCommitment::Missing { amount_a, amount_b },
max_a: amount_a,
max_b: amount_b,
actual_a: amount_a,
actual_b: amount_b,
expected_lp,
lp_guard: MINIMUM_LIQUIDITY,
requires_fresh_lp: true,
sources,
funding: funding_commitment,
warnings: Vec::new(),
};
let quote_hash = hash_quote(&commitment)?;
let preview = account_plan.preview();
let value = json!({
"schema": SCHEMA,
"status": "ok",
"canSubmit": can_submit,
"code": if can_submit { "ready" } else { "funding_required" },
"poolStatus": "missing_pool",
"instruction": "NewDefinition",
"quoteHash": quote_hash,
"feeBps": input.request.fee_bps,
"poolId": pair.pool.to_string(),
"tokenAId": pair.token_a.to_string(),
"tokenBId": pair.token_b.to_string(),
"maxAmountARaw": amount_a.to_string(),
"maxAmountBRaw": amount_b.to_string(),
"actualAmountARaw": amount_a.to_string(),
"actualAmountBRaw": amount_b.to_string(),
"expectedLpRaw": expected_lp.to_string(),
"lockedLpRaw": MINIMUM_LIQUIDITY.to_string(),
"initialPriceRealRaw": spot_price_q64_64(amount_a, amount_b).to_string(),
"minimumAmountARaw": minimum_a.to_string(),
"minimumAmountBRaw": minimum_b.to_string(),
"requiresFreshLp": true,
"accountPreview": preview,
"errors": funding,
"warnings": [],
});
let plan = if can_submit {
Some(NewPositionPlan::new(
account_plan,
QuoteBranch::Missing { amount_a, amount_b },
)?)
} else {
None
};
Ok(QuoteComputation::Evaluated(EvaluatedQuote {
value,
quote_hash,
plan,
}))
}
fn compute_active_quote(
input: &QuoteRequest,
amm_program: ProgramId,
pair: PairIds,
pool_account: Account,
) -> Result<QuoteComputation, String> {
if pool_account.program_owner != amm_program {
return Ok(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "owner_mismatch" }),
));
}
let Ok(pool) = PoolDefinition::try_from(&pool_account.data) else {
return Ok(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "invalid_pool_data" }),
));
};
let stored_reversed = if pool.definition_token_a_id == pair.token_a
&& pool.definition_token_b_id == pair.token_b
{
false
} else if pool.definition_token_a_id == pair.token_b
&& pool.definition_token_b_id == pair.token_a
{
true
} else {
return Ok(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "pair_mismatch" }),
));
};
if pool.reserve_a == 0 || pool.reserve_b == 0 || pool.liquidity_pool_supply == 0 {
return Ok(fatal_quote("pool_inactive", &[], json!({})));
}
if pool.fees != u128::from(input.request.fee_bps) {
return Ok(fatal_quote(
"fee_tier_mismatch",
&["feeBps"],
json!({ "poolFeeBps": pool.fees.to_string() }),
));
}
if !is_supported_fee_tier(pool.fees) {
return Ok(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "unsupported_pool_fee" }),
));
}
let max_a = match raw_value(input.request.max_amount_a_raw.as_deref()) {
Ok(value) if value > 0 => value,
Ok(_) => {
return Ok(fatal_quote(
"amount_must_be_positive",
&["maxAmountARaw"],
json!({}),
))
}
Err(code) => return Ok(fatal_quote(code, &["maxAmountARaw"], json!({}))),
};
let max_b = match raw_value(input.request.max_amount_b_raw.as_deref()) {
Ok(value) if value > 0 => value,
Ok(_) => {
return Ok(fatal_quote(
"amount_must_be_positive",
&["maxAmountBRaw"],
json!({}),
))
}
Err(code) => return Ok(fatal_quote(code, &["maxAmountBRaw"], json!({}))),
};
let slippage_bps = input.request.slippage_bps.unwrap_or(DEFAULT_SLIPPAGE_BPS);
if slippage_bps > MAX_SLIPPAGE_BPS {
return Ok(fatal_quote(
"invalid_slippage",
&["slippageBps"],
json!({ "maximum": MAX_SLIPPAGE_BPS }),
));
}
let (stored_max_a, stored_max_b) = if stored_reversed {
(max_b, max_a)
} else {
(max_a, max_b)
};
let ideal_a = mul_div_floor(pool.reserve_a, stored_max_b, pool.reserve_b);
let ideal_b = mul_div_floor(pool.reserve_b, stored_max_a, pool.reserve_a);
let stored_actual_a = stored_max_a.min(ideal_a);
let stored_actual_b = stored_max_b.min(ideal_b);
if stored_actual_a == 0 || stored_actual_b == 0 {
return Ok(fatal_quote(
"amount_too_low",
&["maxAmountARaw", "maxAmountBRaw"],
json!({}),
));
}
let expected_lp =
mul_div_floor(pool.liquidity_pool_supply, stored_actual_a, pool.reserve_a).min(
mul_div_floor(pool.liquidity_pool_supply, stored_actual_b, pool.reserve_b),
);
if expected_lp == 0 {
return Ok(fatal_quote(
"amount_too_low",
&["maxAmountARaw", "maxAmountBRaw"],
json!({}),
));
}
let minimum_lp = mul_div_floor(
expected_lp,
FEE_BPS_DENOMINATOR - u128::from(slippage_bps),
FEE_BPS_DENOMINATOR,
);
if minimum_lp == 0 {
return Ok(fatal_quote("minimum_lp_zero", &["slippageBps"], json!({})));
}
let (actual_a, actual_b, reserve_a, reserve_b) = if stored_reversed {
(
stored_actual_b,
stored_actual_a,
pool.reserve_b,
pool.reserve_a,
)
} else {
(
stored_actual_a,
stored_actual_b,
pool.reserve_a,
pool.reserve_b,
)
};
if let Some(error) = validate_active_accounts(input, pair, &pool, stored_reversed) {
return Ok(error);
}
let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program);
let holding_a = select_holding(&holdings, pair.token_a);
let holding_b = select_holding(&holdings, pair.token_b);
let lp_holding = select_holding(&holdings, pair.lp_definition);
let requires_fresh_lp = lp_holding.is_none();
let funding = funding_issues(
input.snapshot.wallet_available,
pair,
&holding_a,
actual_a,
&holding_b,
actual_b,
["maxAmountARaw", "maxAmountBRaw"],
);
let can_submit = funding.is_empty();
let warnings = if slippage_bps >= HIGH_SLIPPAGE_BPS {
vec![issue(
"high_slippage",
"High slippage tolerance.",
&["slippageBps"],
json!({ "slippageBps": slippage_bps }),
)]
} else {
Vec::new()
};
let warning_codes = warnings
.iter()
.filter_map(|warning| warning["code"].as_str().map(String::from))
.collect();
let mut account_plan = active_account_plan(
input,
pair,
amm_program,
&pool,
stored_reversed,
AccountPlanHoldings {
token_a: holding_a.as_ref(),
token_b: holding_b.as_ref(),
lp: lp_holding.as_ref(),
},
)?;
let sources = account_plan.take_sources();
let commitment = QuoteCommitment {
schema: String::from(SCHEMA),
network_id: input.network_id.clone(),
network_fingerprint: input.network_fingerprint.clone(),
amm_program_id: program_id_bytes(amm_program),
token_a_id: pair.token_a.into_value(),
token_b_id: pair.token_b.into_value(),
fee_bps: input.request.fee_bps,
pool_status: 1,
request: RequestCommitment::Active {
max_a,
max_b,
slippage_bps,
},
max_a,
max_b,
actual_a,
actual_b,
expected_lp,
lp_guard: minimum_lp,
requires_fresh_lp,
sources,
funding: funding_commitments(pair, &holding_a, actual_a, &holding_b, actual_b),
warnings: warning_codes,
};
let quote_hash = hash_quote(&commitment)?;
let preview = account_plan.preview();
let value = json!({
"schema": SCHEMA,
"status": "ok",
"canSubmit": can_submit,
"code": if can_submit { "ready" } else { "funding_required" },
"poolStatus": "active_pool",
"instruction": "AddLiquidity",
"quoteHash": quote_hash,
"feeBps": input.request.fee_bps,
"poolFeeBps": pool.fees.to_string(),
"poolId": pair.pool.to_string(),
"tokenAId": pair.token_a.to_string(),
"tokenBId": pair.token_b.to_string(),
"maxAmountARaw": max_a.to_string(),
"maxAmountBRaw": max_b.to_string(),
"actualAmountARaw": actual_a.to_string(),
"actualAmountBRaw": actual_b.to_string(),
"reserveARaw": reserve_a.to_string(),
"reserveBRaw": reserve_b.to_string(),
"liquiditySupplyRaw": pool.liquidity_pool_supply.to_string(),
"expectedLpRaw": expected_lp.to_string(),
"minimumLpRaw": minimum_lp.to_string(),
"initialPriceRealRaw": spot_price_q64_64(reserve_a, reserve_b).to_string(),
"requiresFreshLp": requires_fresh_lp,
"accountPreview": preview,
"errors": funding,
"warnings": warnings,
});
let plan = if can_submit {
Some(NewPositionPlan::new(
account_plan,
QuoteBranch::Active {
max_a,
max_b,
minimum_lp,
stored_reversed,
},
)?)
} else {
None
};
Ok(QuoteComputation::Evaluated(EvaluatedQuote {
value,
quote_hash,
plan,
}))
}
fn validate_active_accounts(
input: &QuoteRequest,
pair: PairIds,
pool: &PoolDefinition,
stored_reversed: bool,
) -> Option<QuoteComputation> {
let expected_vault_a = if stored_reversed {
pair.vault_b
} else {
pair.vault_a
};
let expected_vault_b = if stored_reversed {
pair.vault_a
} else {
pair.vault_b
};
if pool.vault_a_id != expected_vault_a
|| pool.vault_b_id != expected_vault_b
|| pool.liquidity_pool_id != pair.lp_definition
{
return Some(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "pool_account_mismatch" }),
));
}
let (canonical_vault_a, canonical_vault_b) = (
decode_holding(&input.snapshot.vault_a, pair.token_program, pair.token_a),
decode_holding(&input.snapshot.vault_b, pair.token_program, pair.token_b),
);
let (Ok(vault_a_balance), Ok(vault_b_balance)) = (canonical_vault_a, canonical_vault_b) else {
return Some(fatal_quote(
"account_read_failed",
&[],
json!({ "role": "vault" }),
));
};
let (reserve_a, reserve_b) = if stored_reversed {
(pool.reserve_b, pool.reserve_a)
} else {
(pool.reserve_a, pool.reserve_b)
};
if vault_a_balance < reserve_a || vault_b_balance < reserve_b {
return Some(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "vault_below_reserve" }),
));
}
let Ok((lp_id, lp_account)) = decode_account(&input.snapshot.lp_definition) else {
return Some(fatal_quote(
"account_read_failed",
&[],
json!({ "role": "lp_definition" }),
));
};
if lp_id != pair.lp_definition
|| lp_account.program_owner != pair.token_program
|| !matches!(
TokenDefinition::try_from(&lp_account.data),
Ok(TokenDefinition::Fungible { .. })
)
{
return Some(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "invalid_lp_definition" }),
));
}
let Ok((tick_id, tick_account)) = decode_account(&input.snapshot.current_tick) else {
return Some(fatal_quote(
"account_read_failed",
&[],
json!({ "role": "current_tick" }),
));
};
if tick_id != pair.current_tick
|| tick_account.program_owner != pair.twap_program
|| CurrentTickAccount::try_from(&tick_account.data).is_err()
{
return Some(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "invalid_current_tick" }),
));
}
if !valid_clock(&input.snapshot.clock, pair.clock) {
return Some(fatal_quote(
"account_read_failed",
&[],
json!({ "role": "clock" }),
));
}
None
}
fn decode_holding(
read: &AccountRead,
token_program: ProgramId,
definition_id: AccountId,
) -> Result<u128, String> {
let holding = decode_fungible_holding(read, token_program)?;
if holding.definition_id != definition_id {
return Err(String::from("invalid fungible holding"));
}
Ok(holding.balance)
}
fn valid_clock(read: &AccountRead, expected_id: AccountId) -> bool {
matches!(decode_clock(read), Ok((id, _)) if id == expected_id)
}
fn raw_value(value: Option<&str>) -> Result<u128, &'static str> {
let Some(value) = value else {
return Err("amount_required");
};
if value.is_empty() {
return Err("amount_required");
}
if !value.bytes().all(|byte| byte.is_ascii_digit()) {
return Err("invalid_raw_amount");
}
value.parse().map_err(|_| "invalid_raw_amount")
}
pub(super) fn minimum_opening_pair(price: u128) -> Result<(u128, u128), String> {
let minimum_initial_lp = U256::from(MINIMUM_LIQUIDITY + 1);
let target_product = minimum_initial_lp
.checked_mul(minimum_initial_lp)
.ok_or_else(|| String::from("minimum liquidity product overflow"))?;
if price >= Q64 {
let amount_a = binary_search_min(1, MINIMUM_LIQUIDITY + 1, |amount_a| {
let amount_b = div_ceil_u256(U256::from(amount_a) * U256::from(price), U256::from(Q64));
U256::from(amount_a) * amount_b >= target_product
});
let amount_b = div_ceil_u256(U256::from(amount_a) * U256::from(price), U256::from(Q64));
Ok((
amount_a,
u128::try_from(amount_b).map_err(|_| String::from("opening amount overflow"))?,
))
} else {
let amount_b = binary_search_min(1, MINIMUM_LIQUIDITY + 1, |amount_b| {
let amount_a = div_ceil_u256(U256::from(amount_b) * U256::from(Q64), U256::from(price));
amount_a * U256::from(amount_b) >= target_product
});
let amount_a = div_ceil_u256(U256::from(amount_b) * U256::from(Q64), U256::from(price));
Ok((
u128::try_from(amount_a).map_err(|_| String::from("opening amount overflow"))?,
amount_b,
))
}
}
fn binary_search_min(mut low: u128, mut high: u128, predicate: impl Fn(u128) -> bool) -> u128 {
while low < high {
let mid = low + (high - low) / 2;
if predicate(mid) {
high = mid;
} else {
low = mid + 1;
}
}
low
}
pub(super) fn div_ceil_u256(numerator: U256, denominator: U256) -> U256 {
numerator.div_ceil(denominator)
}

View File

@ -0,0 +1,25 @@
use serde_json::{json, Value};
use super::position::{QuoteComputation, QuoteFailure};
pub(super) fn issue(code: &str, message: &str, fields: &[&str], details: Value) -> Value {
json!({
"code": code,
"message": message,
"details": details,
"recoverable": true,
"blockingFields": fields,
})
}
pub(super) fn fatal_quote(
code: &'static str,
fields: &[&'static str],
details: Value,
) -> QuoteComputation {
QuoteComputation::Failed(QuoteFailure {
code,
fields: fields.to_vec(),
details,
})
}

View File

@ -0,0 +1,116 @@
use serde::Deserialize;
use crate::account::AccountRead;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ConfigIdRequest {
pub amm_program_id: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[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(Clone, Debug, Deserialize, Eq, PartialEq)]
#[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(Clone, Debug, Deserialize, Eq, PartialEq)]
#[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, Eq, PartialEq)]
#[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 amount_a_raw: Option<String>,
#[serde(default)]
pub amount_b_raw: Option<String>,
#[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>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[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(Clone, Debug, Deserialize, Eq, PartialEq)]
#[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(Clone, Debug, Deserialize, Eq, PartialEq)]
#[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>,
}

View File

@ -0,0 +1,703 @@
use alloy_primitives::U256;
use amm_core::{
compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda,
compute_vault_pda, isqrt_product, spot_price_q64_64, AmmConfig, PoolDefinition,
MINIMUM_LIQUIDITY,
};
use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID};
use nssa_core::{
account::{Account, AccountId, Data, Nonce},
program::ProgramId,
};
use pretty_assertions::assert_eq;
use serde_json::{json, Value};
use token_core::{TokenDefinition, TokenHolding};
use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount};
use super::{
accounts::{active_account_plan, missing_account_plan},
context::{context, token_ids},
holding::{select_holding, wallet_holdings, SelectedHolding},
pair::{is_canonical_pair, pair_ids, PairIds},
plan::plan,
position::AccountPlanHoldings,
quote::{div_ceil_u256, minimum_opening_pair, quote, Q64},
ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest, QuoteRequest,
TokenIdsRequest, SCHEMA,
};
use crate::{
account::{account_id_hex, account_read, decode_account, parse_base58_id, program_id_bytes},
AccountRead,
};
const AMM_PROGRAM: ProgramId = [11; 8];
const TOKEN_PROGRAM: ProgramId = [22; 8];
const TWAP_PROGRAM: ProgramId = [33; 8];
fn account(owner: ProgramId, data: Data) -> Account {
Account {
program_owner: owner,
balance: 0,
data,
nonce: Nonce(0),
}
}
fn default_read(id: AccountId) -> AccountRead {
account_read(id, &Account::default())
}
fn config_account() -> Account {
account(
AMM_PROGRAM,
Data::from(&AmmConfig {
token_program_id: TOKEN_PROGRAM,
twap_oracle_program_id: TWAP_PROGRAM,
authority: AccountId::new([7; 32]),
}),
)
}
fn token_definition(name: &str, supply: u128) -> Account {
account(
TOKEN_PROGRAM,
Data::from(&TokenDefinition::Fungible {
name: String::from(name),
total_supply: supply,
metadata_id: None,
authority: None,
}),
)
}
fn token_holding(definition_id: AccountId, balance: u128) -> Account {
account(
TOKEN_PROGRAM,
Data::from(&TokenHolding::Fungible {
definition_id,
balance,
}),
)
}
fn clock_account() -> Account {
account(
[44; 8],
Data::try_from(
ClockAccountData {
block_id: 10,
timestamp: 1_000,
}
.to_bytes(),
)
.unwrap(),
)
}
fn ids() -> PairIds {
let token_a = AccountId::new([2; 32]);
let token_b = AccountId::new([1; 32]);
let config = compute_config_pda(AMM_PROGRAM);
let pool = compute_pool_pda(AMM_PROGRAM, token_a, token_b);
PairIds {
token_a,
token_b,
config,
pool,
vault_a: compute_vault_pda(AMM_PROGRAM, pool, token_a),
vault_b: compute_vault_pda(AMM_PROGRAM, pool, token_b),
lp_definition: compute_liquidity_token_pda(AMM_PROGRAM, pool),
lp_lock_holding: compute_lp_lock_holding_pda(AMM_PROGRAM, pool),
current_tick: compute_current_tick_account_pda(TWAP_PROGRAM, pool),
clock: CLOCK_01_PROGRAM_ACCOUNT_ID,
token_program: TOKEN_PROGRAM,
twap_program: TWAP_PROGRAM,
}
}
fn base_snapshot(pair: PairIds) -> PairSnapshot {
let holding_a_id = AccountId::new([61; 32]);
let holding_b_id = AccountId::new([62; 32]);
PairSnapshot {
config: account_read(pair.config, &config_account()),
token_a: account_read(pair.token_a, &token_definition("A", 1_000_000)),
token_b: account_read(pair.token_b, &token_definition("B", 2_000_000)),
pool: default_read(pair.pool),
vault_a: default_read(pair.vault_a),
vault_b: default_read(pair.vault_b),
lp_definition: default_read(pair.lp_definition),
lp_lock_holding: default_read(pair.lp_lock_holding),
current_tick: default_read(pair.current_tick),
clock: account_read(pair.clock, &clock_account()),
wallet_available: true,
wallet_accounts: vec![
account_read(holding_a_id, &token_holding(pair.token_a, 1_000_000)),
account_read(holding_b_id, &token_holding(pair.token_b, 1_000_000)),
],
}
}
fn request(pair: PairIds) -> PositionRequest {
assert!(is_canonical_pair(pair.token_a, pair.token_b));
PositionRequest {
schema: String::from(SCHEMA),
token_a_id: pair.token_a.to_string(),
token_b_id: pair.token_b.to_string(),
fee_bps: 30,
amount_a_raw: None,
amount_b_raw: None,
max_amount_a_raw: None,
max_amount_b_raw: None,
slippage_bps: None,
initial_price_real_raw: Some(Q64.to_string()),
}
}
fn amm_program_id() -> String {
hex::encode(program_id_bytes(AMM_PROGRAM))
}
struct Scenario {
pair: PairIds,
request: PositionRequest,
snapshot: PairSnapshot,
network_id: &'static str,
network_fingerprint: &'static str,
}
impl Scenario {
fn devnet() -> Self {
Self::new("devnet", "channel:test")
}
fn testnet() -> Self {
Self::new("testnet", "block10:test")
}
fn new(network_id: &'static str, network_fingerprint: &'static str) -> Self {
let pair = ids();
Self {
pair,
request: request(pair),
snapshot: base_snapshot(pair),
network_id,
network_fingerprint,
}
}
fn quote_request(&self) -> QuoteRequest {
QuoteRequest {
network_id: String::from(self.network_id),
network_fingerprint: String::from(self.network_fingerprint),
amm_program_id: amm_program_id(),
request: self.request.clone(),
snapshot: self.snapshot.clone(),
}
}
fn quote(&self) -> Value {
quote(self.quote_request()).unwrap()
}
fn plan(self, quote_hash: impl Into<String>, fresh_lp: Option<AccountRead>) -> Value {
plan(PlanRequest {
network_id: String::from(self.network_id),
network_fingerprint: String::from(self.network_fingerprint),
amm_program_id: amm_program_id(),
request: self.request,
snapshot: self.snapshot,
quote_hash: quote_hash.into(),
now_ms: 2_000,
fresh_lp,
})
.unwrap()
}
}
fn assert_preview_matches_plan(
quote_value: &Value,
plan_value: &Value,
fresh_lp: Option<AccountId>,
) {
let preview = quote_value["accountPreview"].as_array().unwrap();
let account_ids = plan_value["accountIds"].as_array().unwrap();
let signing_requirements = plan_value["signingRequirements"].as_array().unwrap();
assert_eq!(preview.len(), account_ids.len());
assert_eq!(preview.len(), signing_requirements.len());
for (order, row) in preview.iter().enumerate() {
assert_eq!(row["order"], order);
assert_eq!(row["signer"], signing_requirements[order]);
if let Some(account_id) = row["accountId"].as_str() {
let account_id = parse_base58_id(account_id, "preview account id").unwrap();
assert_eq!(account_ids[order], account_id_hex(account_id));
} else {
assert_eq!(row["role"], "user_holding_lp");
assert_eq!(account_ids[order], account_id_hex(fresh_lp.unwrap()));
}
}
}
#[test]
fn account_plan_sources_follow_pool_branch() {
let scenario = Scenario::devnet();
let pair = scenario.pair;
let input = scenario.quote_request();
let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program);
let holding_a = select_holding(&holdings, pair.token_a);
let holding_b = select_holding(&holdings, pair.token_b);
let missing = missing_account_plan(
&input,
pair,
AMM_PROGRAM,
AccountPlanHoldings {
token_a: holding_a.as_ref(),
token_b: holding_b.as_ref(),
lp: None,
},
)
.unwrap();
assert_eq!(
missing
.sources
.iter()
.map(|source| source.role.as_str())
.collect::<Vec<_>>(),
vec![
"config",
"token_a",
"token_b",
"pool",
"vault_a",
"vault_b",
"lp_definition",
"lp_lock_holding",
"current_tick",
"holding_a",
"holding_b",
]
);
let active = active_account_plan(
&input,
pair,
AMM_PROGRAM,
&PoolDefinition::default(),
false,
AccountPlanHoldings {
token_a: holding_a.as_ref(),
token_b: holding_b.as_ref(),
lp: None,
},
)
.unwrap();
assert_eq!(
active
.sources
.iter()
.map(|source| source.role.as_str())
.collect::<Vec<_>>(),
vec![
"config",
"token_a",
"token_b",
"pool",
"vault_a",
"vault_b",
"lp_definition",
"current_tick",
"holding_a",
"holding_b",
]
);
}
#[test]
fn minimum_pair_exceeds_protocol_lock() {
for price in [1, Q64 / 2_500, Q64 / 10, Q64, Q64 * 2, u128::MAX] {
let (amount_a, amount_b) = minimum_opening_pair(price).unwrap();
assert!(amount_a > 0);
assert!(amount_b > 0);
assert!(isqrt_product(amount_a, amount_b) > MINIMUM_LIQUIDITY);
}
}
#[test]
fn minimum_pair_is_minimal_on_price_base_side() {
let (amount_a, amount_b) = minimum_opening_pair(Q64 * 2).unwrap();
assert!(isqrt_product(amount_a, amount_b) > MINIMUM_LIQUIDITY);
let previous_b = div_ceil_u256(
U256::from(amount_a - 1) * U256::from(Q64 * 2),
U256::from(Q64),
);
assert!(
U256::from(amount_a - 1) * previous_b <= U256::from(MINIMUM_LIQUIDITY * MINIMUM_LIQUIDITY)
);
}
#[test]
fn highest_balance_holding_wins_then_lowest_id() {
let definition = AccountId::new([9; 32]);
let holding = |id: u8, balance| SelectedHolding {
id: AccountId::new([id; 32]),
definition_id: definition,
balance,
account: account(
TOKEN_PROGRAM,
Data::from(&TokenHolding::Fungible {
definition_id: definition,
balance,
}),
),
};
let selected = select_holding(
&[holding(4, 10), holding(2, 20), holding(1, 20)],
definition,
)
.unwrap();
assert_eq!(selected.id, AccountId::new([1; 32]));
}
#[test]
fn pair_manifest_uses_canonical_ids_and_current_program_types() {
let token_a = AccountId::new([2; 32]);
let token_b = AccountId::new([1; 32]);
let config_id = compute_config_pda(AMM_PROGRAM);
let result = pair_ids(PairIdsRequest {
amm_program_id: amm_program_id(),
config: account_read(config_id, &config_account()),
token_a_id: token_a.to_string(),
token_b_id: token_b.to_string(),
})
.unwrap();
assert_eq!(result["status"], "ok");
assert_eq!(result["tokenAId"], account_id_hex(token_a));
assert_eq!(result["tokenBId"], account_id_hex(token_b));
assert_eq!(
result["poolId"],
account_id_hex(compute_pool_pda(AMM_PROGRAM, token_a, token_b))
);
}
#[test]
fn pair_manifest_reports_invalid_token_as_domain_error() {
let pair = ids();
let result = pair_ids(PairIdsRequest {
amm_program_id: amm_program_id(),
config: account_read(pair.config, &config_account()),
token_a_id: String::from("not-a-token-id"),
token_b_id: pair.token_b.to_string(),
})
.expect("invalid user input is a domain result");
assert_eq!(result["status"], "error");
assert_eq!(result["code"], "invalid_token_id");
}
#[test]
fn pair_manifest_reports_unavailable_config_as_domain_error() {
let pair = ids();
let result = pair_ids(PairIdsRequest {
amm_program_id: amm_program_id(),
config: default_read(pair.config),
token_a_id: pair.token_a.to_string(),
token_b_id: pair.token_b.to_string(),
})
.expect("unavailable chain state is a domain result");
assert_eq!(result["status"], "error");
assert_eq!(result["code"], "config_unavailable");
}
#[test]
fn token_manifest_includes_compatible_wallet_holdings() {
let config_id = compute_config_pda(AMM_PROGRAM);
let configured = AccountId::new([1; 32]);
let held = AccountId::new([2; 32]);
let recent = AccountId::new([3; 32]);
let resolved = AccountId::new([4; 32]);
let value = token_ids(TokenIdsRequest {
amm_program_id: amm_program_id(),
config: account_read(config_id, &config_account()),
wallet_accounts: vec![account_read(
AccountId::new([5; 32]),
&token_holding(held, 9),
)],
configured_token_ids: vec![account_id_hex(configured)],
recent_token_ids: vec![recent.to_string()],
resolved_token_ids: vec![resolved.to_string()],
})
.unwrap();
assert_eq!(
value["tokenIds"],
json!([
account_id_hex(configured),
account_id_hex(held),
account_id_hex(recent),
account_id_hex(resolved),
])
);
}
#[test]
fn wrong_program_holdings_do_not_contribute_token_candidates() {
let config_id = compute_config_pda(AMM_PROGRAM);
let config = config_account();
let definition = AccountId::new([2; 32]);
let wrong_owner_holding = account(
[99; 8],
Data::from(&TokenHolding::Fungible {
definition_id: definition,
balance: 9,
}),
);
let wallet_accounts = vec![account_read(AccountId::new([3; 32]), &wrong_owner_holding)];
let manifest = token_ids(TokenIdsRequest {
amm_program_id: amm_program_id(),
config: account_read(config_id, &config),
wallet_accounts: wallet_accounts.clone(),
configured_token_ids: Vec::new(),
recent_token_ids: Vec::new(),
resolved_token_ids: Vec::new(),
})
.unwrap();
assert_eq!(manifest["tokenIds"], json!([]));
let value = context(ContextRequest {
network_id: String::from("testnet"),
network_fingerprint: String::from("block10:abc"),
amm_program_id: amm_program_id(),
wallet_available: true,
config: account_read(config_id, &config),
wallet_accounts,
token_definitions: vec![account_read(
definition,
&token_definition("Token", 1_000_000),
)],
configured_token_ids: Vec::new(),
recent_token_ids: Vec::new(),
resolved_token_ids: Vec::new(),
})
.unwrap();
assert_eq!(value["tokens"], json!([]));
}
#[test]
fn context_selects_tokens_without_holdings() {
let token_id = AccountId::new([3; 32]);
let config_id = compute_config_pda(AMM_PROGRAM);
let value = context(ContextRequest {
network_id: String::from("testnet"),
network_fingerprint: String::from("block10:abc"),
amm_program_id: amm_program_id(),
wallet_available: true,
config: account_read(config_id, &config_account()),
wallet_accounts: Vec::new(),
token_definitions: vec![account_read(
token_id,
&token_definition("Token", 1_000_000),
)],
configured_token_ids: vec![account_id_hex(token_id)],
recent_token_ids: Vec::new(),
resolved_token_ids: Vec::new(),
})
.unwrap();
assert_eq!(value["tokens"][0]["selectable"], true);
assert_eq!(value["tokens"][0]["sources"], json!(["config"]));
assert!(value["tokens"][0].get("holdingId").is_none());
}
#[test]
fn missing_pool_snapshot_defaults_remain_real_accounts() {
let id = AccountId::new([5; 32]);
let read = default_read(id);
let (decoded_id, decoded) = decode_account(&read).unwrap();
assert_eq!(decoded_id, id);
assert_eq!(decoded, Account::default());
}
#[test]
fn missing_pool_quote_and_plan_use_current_account_order() {
let scenario = Scenario::devnet();
let quote_value = scenario.quote();
assert_eq!(quote_value["status"], "ok");
assert_eq!(quote_value["poolStatus"], "missing_pool");
assert_eq!(quote_value["canSubmit"], true);
assert_eq!(quote_value["accountPreview"].as_array().unwrap().len(), 11);
let quote_hash = quote_value["quoteHash"].as_str().unwrap().to_owned();
let fresh_lp = AccountId::new([63; 32]);
let plan_value = scenario.plan(quote_hash, Some(default_read(fresh_lp)));
assert_eq!(plan_value["status"], "ready");
assert_eq!(plan_value["accountIds"].as_array().unwrap().len(), 11);
assert_eq!(plan_value["accountIds"][8], account_id_hex(fresh_lp));
assert_eq!(plan_value["signingRequirements"][6], true);
assert_eq!(plan_value["signingRequirements"][7], true);
assert_eq!(plan_value["signingRequirements"][8], true);
assert_preview_matches_plan(&quote_value, &plan_value, Some(fresh_lp));
}
#[test]
fn missing_pool_plan_rejects_fresh_lp_account_collision() {
let scenario = Scenario::devnet();
let pool = scenario.pair.pool;
let quote_value = scenario.quote();
let quote_hash = quote_value["quoteHash"].as_str().unwrap().to_owned();
let plan_value = scenario.plan(quote_hash, Some(default_read(pool)));
assert_eq!(plan_value["status"], "error");
assert_eq!(plan_value["code"], "wallet_submission_failed");
}
#[test]
fn missing_pool_quote_accepts_large_direct_raw_amounts() {
let mut scenario = Scenario::devnet();
let amount_a = 100_000_000;
let amount_b = 150_000_000;
scenario.request.amount_a_raw = Some(amount_a.to_string());
scenario.request.amount_b_raw = Some(amount_b.to_string());
scenario.request.initial_price_real_raw =
Some(spot_price_q64_64(amount_a, amount_b).to_string());
let quote_value = scenario.quote();
assert_eq!(quote_value["status"], "ok");
assert_eq!(quote_value["actualAmountARaw"], amount_a.to_string());
assert_eq!(quote_value["actualAmountBRaw"], amount_b.to_string());
assert!(quote_value.get("depositScaleBps").is_none());
}
#[test]
fn advancing_clock_does_not_stale_quote() {
let mut scenario = Scenario::testnet();
let quote_value = scenario.quote();
scenario.snapshot.clock = account_read(
scenario.pair.clock,
&account(
[44; 8],
Data::try_from(
ClockAccountData {
block_id: 11,
timestamp: 1_500,
}
.to_bytes(),
)
.unwrap(),
),
);
let plan_value = scenario.plan(
quote_value["quoteHash"].as_str().unwrap(),
Some(default_read(AccountId::new([63; 32]))),
);
assert_eq!(plan_value["status"], "ready");
}
#[test]
fn active_pool_quote_uses_ratio_and_existing_lp_holding() {
let mut scenario = Scenario::testnet();
let pair = scenario.pair;
let pool = PoolDefinition {
definition_token_a_id: pair.token_a,
definition_token_b_id: pair.token_b,
vault_a_id: pair.vault_a,
vault_b_id: pair.vault_b,
liquidity_pool_id: pair.lp_definition,
liquidity_pool_supply: 10_000,
reserve_a: 10_000,
reserve_b: 20_000,
fees: 30,
};
scenario.snapshot.pool = account_read(pair.pool, &account(AMM_PROGRAM, Data::from(&pool)));
scenario.snapshot.vault_a =
account_read(pair.vault_a, &token_holding(pair.token_a, pool.reserve_a));
scenario.snapshot.vault_b =
account_read(pair.vault_b, &token_holding(pair.token_b, pool.reserve_b));
scenario.snapshot.lp_definition = account_read(
pair.lp_definition,
&account(
TOKEN_PROGRAM,
Data::from(&TokenDefinition::Fungible {
name: String::from("LP"),
total_supply: pool.liquidity_pool_supply,
metadata_id: None,
authority: Some(pair.lp_definition),
}),
),
);
scenario.snapshot.current_tick = account_read(
pair.current_tick,
&account(
TWAP_PROGRAM,
Data::from(&CurrentTickAccount {
tick: 0,
last_updated: 1_000,
}),
),
);
scenario.snapshot.wallet_accounts = vec![
account_read(
AccountId::new([61; 32]),
&token_holding(pair.token_a, 1_000),
),
account_read(
AccountId::new([62; 32]),
&token_holding(pair.token_b, 2_000),
),
];
let lp_holding = AccountId::new([64; 32]);
scenario.snapshot.wallet_accounts.push(account_read(
lp_holding,
&token_holding(pair.lp_definition, 500),
));
scenario.request.initial_price_real_raw = None;
scenario.request.max_amount_a_raw = Some(String::from("1000"));
scenario.request.max_amount_b_raw = Some(String::from("3000"));
scenario.request.slippage_bps = Some(50);
let quote_value = scenario.quote();
assert_eq!(quote_value["poolStatus"], "active_pool");
assert_eq!(quote_value["actualAmountARaw"], "1000");
assert_eq!(quote_value["actualAmountBRaw"], "2000");
assert_eq!(quote_value["expectedLpRaw"], "1000");
assert_eq!(quote_value["minimumLpRaw"], "995");
assert_eq!(quote_value["requiresFreshLp"], false);
assert_eq!(quote_value["canSubmit"], true);
assert_eq!(quote_value["errors"], json!([]));
let plan_value = scenario.plan(quote_value["quoteHash"].as_str().unwrap(), None);
assert_eq!(plan_value["status"], "ready");
assert_eq!(plan_value["accountIds"].as_array().unwrap().len(), 10);
assert_eq!(plan_value["accountIds"][7], account_id_hex(lp_holding));
assert_eq!(plan_value["signingRequirements"][7], false);
assert_preview_matches_plan(&quote_value, &plan_value, None);
}
#[test]
fn matching_unfunded_quote_has_no_transaction_plan() {
let mut scenario = Scenario::devnet();
scenario.snapshot.wallet_available = false;
scenario.snapshot.wallet_accounts.clear();
let quote_value = scenario.quote();
assert_eq!(quote_value["canSubmit"], false);
let plan_value = scenario.plan(quote_value["quoteHash"].as_str().unwrap(), None);
assert_eq!(plan_value["status"], "error");
assert_eq!(plan_value["code"], "quote_not_submittable");
assert_eq!(plan_value["quote"], quote_value);
}
#[test]
fn stale_hash_returns_recomputed_quote_without_plan() {
let value = Scenario::devnet().plan("sha256:deadbeef", None);
assert_eq!(value["status"], "error");
assert_eq!(value["code"], "quote_changed");
assert_eq!(value["quote"]["status"], "ok");
}

140
apps/amm/client/src/ffi.rs Normal file
View File

@ -0,0 +1,140 @@
use std::{
ffi::{c_char, CStr, CString},
panic::{catch_unwind, AssertUnwindSafe},
};
use serde::{de::DeserializeOwned, Serialize};
use crate::api::{
self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest,
QuoteRequest, TokenIdsRequest,
};
#[derive(Serialize)]
struct Envelope {
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
impl Envelope {
fn success(value: serde_json::Value) -> Self {
Self {
ok: true,
value: Some(value),
error: None,
}
}
fn failure(error: impl Into<String>) -> Self {
Self {
ok: false,
value: None,
error: Some(error.into()),
}
}
}
fn call<T: DeserializeOwned>(request: *const c_char, operation: fn(T) -> AmmResult) -> *mut c_char {
let result = catch_unwind(AssertUnwindSafe(|| {
let request = request_text(request).map_err(AmmApiError::from)?;
let request = serde_json::from_str::<T>(&request)
.map_err(|error| AmmApiError::from(format!("invalid request JSON: {error}")))?;
operation(request)
}));
let envelope = match result {
Ok(Ok(value)) => Envelope::success(value),
Ok(Err(error)) => Envelope::failure(error.to_string()),
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, api::config_id)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_token_ids(request_json: *const c_char) -> *mut c_char {
call::<TokenIdsRequest>(request_json, api::token_ids)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_pair_ids(request_json: *const c_char) -> *mut c_char {
call::<PairIdsRequest>(request_json, api::pair_ids)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_context(request_json: *const c_char) -> *mut c_char {
call::<ContextRequest>(request_json, api::context)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_quote(request_json: *const c_char) -> *mut c_char {
call::<QuoteRequest>(request_json, api::quote)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_plan(request_json: *const c_char) -> *mut c_char {
call::<PlanRequest>(request_json, api::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,12 @@
#![deny(unsafe_op_in_unsafe_fn)]
mod account;
mod ffi;
pub mod api;
pub use api::{
config_id, context, pair_ids, plan, quote, token_ids, AccountRead, AmmApiError, AmmResponse,
AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest,
PositionRequest, QuoteRequest, TokenIdsRequest, WalletAccount, NEW_POSITION_SCHEMA,
};

View File

@ -0,0 +1,13 @@
use amm_client::{config_id, ConfigIdRequest, NEW_POSITION_SCHEMA};
#[test]
fn direct_rust_api_does_not_require_ffi() {
let response = config_id(ConfigIdRequest {
amm_program_id: "0000000000000000000000000000000000000000000000000000000000000000".into(),
})
.expect("valid program ID should produce a response");
assert_eq!(response["status"], "ok");
assert!(response["configId"].is_string());
assert_eq!(NEW_POSITION_SCHEMA, "new-position.v1");
}

View File

@ -0,0 +1,18 @@
{
"testnet": {
"checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a",
"ammProgramId": "77eeaa23668ad2675fb768cd7ecb1893387be464b9a51f16756006c1d307db07",
"tokenDefinitionIds": [
"7b464ff9dd0d3bc07f7e2e0b0667ccd066d85ad12be4c79fc55687a863910aa6",
"48c81cf032e601ca367fc9816b957dbf5c0e4c11cf7008e8f4581ec1a67aab42",
"159caef810ea545951b3bd913efe625ee45008c80865c330e72a72ed48b61649",
"75f33110b185717209e3955f228d4a4448801d0ce8ba438a4a268050eeff3f44",
"fbd107ca4bb66bc58f59ac2d32a759be3ee0fb453f8fecd1991c11837d9660c7",
"5547fcb72644d95a385d313b887a96be41ff263bce6150b49fd87276839822bf",
"fa43e74a97d79c5f907ff3edabda5ad89bfbd3b0922572e675d4ad3c7b6029c7",
"4f3231d8a01e1d79f163bc27fce0c860a4a2f6890280e9d135eafbde0d68ed79",
"fa32f354408857006f8ea396b0419823bd04436eadb2d273d2618a46b4793ed8",
"00fe99e4fbd4c71f92e47c384c6235244c8cce39b6d6367e1e338eca0ffe01cb"
]
}
}

923
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:../..";
};
# NOTE: this flake is no longer built standalone. The amm_client_ffi crate
@ -47,6 +49,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

@ -11,11 +11,12 @@
"nix": {
"packages": {
"build": [],
"runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"]
"build": ["pkg-config"],
"runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp", "libbase58"]
},
"external_libraries": [
{ "name": "amm_client_ffi" }
{ "name": "amm_client_ffi" },
{ "name": "amm_client" }
],
"cmake": {
"find_packages": [],

View File

@ -45,7 +45,7 @@ Item {
height: show ? 32 : 0
visible: height > 0
clip: true
color: Theme.palette.error
color: Theme.palette.warning
Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
@ -56,7 +56,7 @@ Item {
elide: Text.ElideMiddle
font.pixelSize: 12
font.weight: Font.Medium
color: Theme.palette.text
color: Theme.palette.background
text: qsTr("Unable to connect to network")
}
}
@ -88,6 +88,8 @@ Item {
LiquidityPage {
anchors.fill: parent
backend: root.ready ? root.backend : null
runtime: logos
visible: navbar.currentIndex === 1
}
}

View File

@ -1,222 +0,0 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import "../shared"
import "../../state"
Rectangle {
id: root
required property DummyPoolState poolState
property real slippageTolerancePercent: 0.5
property string amountA: ""
property string amountB: ""
property string lastEditedToken: "A"
readonly property real parsedA: root.poolState.parseAmount(root.amountA)
readonly property real parsedB: root.poolState.parseAmount(root.amountB)
readonly property var preview: root.poolState.addLiquidityPreview(root.parsedA, root.parsedB)
readonly property int minLpReceived: root.poolState.minReceivedAmount(root.preview.deltaLp, root.slippageTolerancePercent)
readonly property bool hasAnyAmount: root.parsedA > 0 || root.parsedB > 0
readonly property bool amountAOverBalance: root.parsedA > root.poolState.walletBalanceA
readonly property bool amountBOverBalance: root.parsedB > root.poolState.walletBalanceB
readonly property bool minReceivedIsZero: root.hasAnyAmount && root.minLpReceived === 0
readonly property bool zeroTokenDeposit: root.hasAnyAmount && (root.preview.actualA === 0 || root.preview.actualB === 0)
readonly property bool zeroLpDeposit: root.preview.actualA > 0 && root.preview.actualB > 0 && root.preview.deltaLp === 0
readonly property bool canSubmit: root.hasAnyAmount && !root.amountAOverBalance && !root.amountBOverBalance && !root.minReceivedIsZero && !root.zeroTokenDeposit && !root.zeroLpDeposit
readonly property string submitButtonText: !root.hasAnyAmount ? qsTr("Enter an amount") : root.amountAOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenA) : root.amountBOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenB) : root.zeroTokenDeposit ? qsTr("Amount rounds to zero") : root.zeroLpDeposit ? qsTr("LP output is 0") : root.minReceivedIsZero ? qsTr("Minimum received is 0") : qsTr("Add Liquidity")
readonly property string warningText: root.zeroTokenDeposit ? qsTr("Deposit would be rejected because one token amount rounds to zero") : root.zeroLpDeposit ? qsTr("Deposit would mint 0 LP tokens") : ""
signal slippageToleranceChangeRequested(real tolerancePercent)
signal addLiquidityRequested(var snapshot)
color: "#00000000"
implicitHeight: content.implicitHeight
radius: 0
border.width: 0
ColumnLayout {
id: content
anchors.fill: parent
spacing: 10
TokenAmountInput {
balance: root.poolState.formatTokenAmount(root.poolState.walletBalanceA, root.poolState.tokenA)
errorText: root.amountAOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenA) : ""
helperText: root.lastEditedToken === "B" && root.amountA.length > 0 ? qsTr("Calculated from current pool ratio") : ""
label: qsTr("Token A amount")
token: root.poolState.tokenA
text: root.amountA
Layout.fillWidth: true
onEditingChanged: function (value) {
root.updateFromTokenA(value);
}
onMaxClicked: root.useMax("A")
}
TokenAmountInput {
balance: root.poolState.formatTokenAmount(root.poolState.walletBalanceB, root.poolState.tokenB)
errorText: root.amountBOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenB) : ""
helperText: root.lastEditedToken === "A" && root.amountB.length > 0 ? qsTr("Calculated from current pool ratio") : ""
label: qsTr("Token B amount")
token: root.poolState.tokenB
text: root.amountB
Layout.fillWidth: true
onEditingChanged: function (value) {
root.updateFromTokenB(value);
}
onMaxClicked: root.useMax("B")
}
SummaryRow {
label: qsTr("Current price")
value: qsTr("1 %1 = %2 %3").arg(root.poolState.tokenB).arg(root.poolState.formatInteger(root.poolState.tokenAPerTokenB)).arg(root.poolState.tokenA)
Layout.fillWidth: true
}
SummaryRow {
estimated: true
estimateHelp: qsTr("Estimated with the same integer floor math used by the add-liquidity contract path.")
label: qsTr("Estimated LP tokens")
value: root.poolState.formatLpAmount(root.preview.deltaLp)
visible: root.hasAnyAmount
Layout.fillWidth: true
}
SlippageToleranceControl {
tolerancePercent: root.slippageTolerancePercent
Layout.fillWidth: true
onToleranceChangeRequested: function (tolerancePercent) {
root.slippageToleranceChangeRequested(tolerancePercent);
}
}
SummaryRow {
label: qsTr("Min LP received")
value: root.poolState.formatLpAmount(root.minLpReceived)
visible: root.hasAnyAmount
Layout.fillWidth: true
}
Text {
color: "#F08A76"
font.pixelSize: 12
lineHeight: 1.25
text: qsTr("Minimum received is 0. Increase amount or lower slippage.")
visible: root.minReceivedIsZero
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
Text {
color: "#F08A76"
font.pixelSize: 12
lineHeight: 1.25
text: root.warningText
visible: root.warningText.length > 0
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
Button {
id: submitButton
activeFocusOnTab: true
enabled: root.canSubmit
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: root.submitButtonText
Accessible.name: submitButton.text
Layout.fillWidth: true
Layout.minimumHeight: 44
Layout.preferredHeight: 44
onClicked: root.addLiquidityRequested(root.submitSnapshot())
contentItem: Text {
color: submitButton.enabled ? "#151515" : "#7D756E"
elide: Text.ElideRight
font.bold: true
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
text: submitButton.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: submitButton.enabled ? "#F26A21" : "#343434"
border.width: 1
color: submitButton.enabled ? submitButton.pressed ? "#D95C1E" : submitButton.hovered || submitButton.activeFocus ? "#FF8A3D" : "#F26A21" : "#181818"
radius: 6
}
}
}
function setAmounts(nextA, nextB, intentToken, showZero) {
root.lastEditedToken = intentToken;
root.amountA = nextA > 0 || showZero ? root.poolState.formatInputAmount(nextA) : "";
root.amountB = nextB > 0 || showZero ? root.poolState.formatInputAmount(nextB) : "";
}
function updateFromTokenA(value) {
if (value.length === 0) {
setAmounts(0, 0, "A", false);
return;
}
const nextA = root.poolState.parseAmount(value);
setAmounts(nextA, root.poolState.amountBForA(nextA), "A", true);
}
function updateFromTokenB(value) {
if (value.length === 0) {
setAmounts(0, 0, "B", false);
return;
}
const nextB = root.poolState.parseAmount(value);
setAmounts(root.poolState.amountAForB(nextB), nextB, "B", true);
}
function useMax(intentToken) {
const capped = root.poolState.maxAddLiquidityForBalances();
setAmounts(capped.actualA, capped.actualB, intentToken, false);
}
function resetForm() {
root.amountA = "";
root.amountB = "";
root.lastEditedToken = "A";
}
function submitSnapshot() {
return {
"action": "add",
"actualA": root.preview.actualA,
"actualB": root.preview.actualB,
"currentRatio": qsTr("1 %1 = %2 %3").arg(root.poolState.tokenB).arg(root.poolState.formatInteger(root.poolState.tokenAPerTokenB)).arg(root.poolState.tokenA),
"deltaLp": root.preview.deltaLp,
"depositA": root.poolState.formatTokenAmount(root.preview.actualA, root.poolState.tokenA),
"depositB": root.poolState.formatTokenAmount(root.preview.actualB, root.poolState.tokenB),
"feeTier": root.poolState.feeTier,
"minLpReceived": root.poolState.formatLpAmount(root.minLpReceived),
"slippageTolerance": root.poolState.formatPercent(root.slippageTolerancePercent),
"tokenA": root.poolState.tokenA,
"tokenB": root.poolState.tokenB
};
}
}

View File

@ -0,0 +1,15 @@
import QtQuick
Rectangle {
required property var theme
implicitWidth: 480
radius: 24
color: theme.colors.cardBg
border.color: theme.colors.border
border.width: 1
Behavior on color {
ColorAnimation { duration: 180 }
}
}

View File

@ -0,0 +1,61 @@
import QtQuick
import QtQuick.Controls
Item {
id: root
required property var theme
property string symbol: "\u2193"
property string accessibleName: qsTr("Swap token order")
signal clicked
implicitHeight: 40
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.right: parent.right
height: 1
color: root.theme.colors.divider
}
Button {
id: button
anchors.centerIn: parent
width: 36
height: 36
hoverEnabled: true
enabled: root.enabled
Accessible.name: root.accessibleName
ToolTip.visible: hovered
ToolTip.text: Accessible.name
onClicked: root.clicked()
contentItem: Text {
text: root.symbol
color: button.enabled
? root.theme.colors.textPrimary
: root.theme.colors.textPlaceholder
font.pixelSize: 16
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: 18
color: button.hovered || button.activeFocus
? root.theme.colors.panelHoverBg
: root.theme.colors.panelBg
border.color: root.theme.colors.borderStrong
border.width: 1
Behavior on color {
ColorAnimation { duration: 120 }
}
}
}
}

View File

@ -0,0 +1,43 @@
import QtQuick
import QtQuick.Controls
Button {
id: root
required property var theme
activeFocusOnTab: true
focusPolicy: Qt.StrongFocus
hoverEnabled: true
implicitHeight: 56
Accessible.name: text
contentItem: Text {
text: root.text
color: root.enabled ? "#FFFFFF" : root.theme.colors.textSecondary
font.pixelSize: 17
font.weight: Font.Medium
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
background: Rectangle {
radius: 20
color: !root.enabled
? root.theme.colors.panelBg
: root.pressed
? root.theme.colors.ctaPressedBg
: root.hovered || root.activeFocus
? root.theme.colors.ctaHoverBg
: root.theme.colors.ctaBg
border.color: root.activeFocus && root.enabled
? root.theme.colors.textPrimary : "transparent"
border.width: 1
Behavior on color {
ColorAnimation { duration: 120 }
}
}
}

View File

@ -0,0 +1,50 @@
import QtQml
QtObject {
property bool isDark: true
readonly property var colors: isDark ? dark : light
readonly property var light: ({
"background": "#F4EDE3",
"cardBg": "#FFFFFF",
"inputBg": "#EFE7DB",
"panelBg": "#E7E1D8",
"panelHoverBg": "#D9D0C2",
"textPrimary": "#151515",
"textSecondary": "#7D756E",
"textPlaceholder": "#A9A098",
"border": Qt.rgba(0, 0, 0, 0.08),
"borderStrong": Qt.rgba(0, 0, 0, 0.10),
"divider": Qt.rgba(0, 0, 0, 0.06),
"ctaBg": "#F26A21",
"ctaHoverBg": "#D95C1E",
"ctaPressedBg": "#C85018",
"selection": "#F2D8C7",
"noTokenCircle": "#A9A098",
"success": "#4F9B64",
"warning": "#B8732A",
"error": "#D85F4B"
})
readonly property var dark: ({
"background": "#151515",
"cardBg": "#1B1B1B",
"inputBg": "#101010",
"panelBg": "#181818",
"panelHoverBg": "#202020",
"textPrimary": "#E7E1D8",
"textSecondary": "#A9A098",
"textPlaceholder": "#8E8780",
"border": Qt.rgba(1, 1, 1, 0.08),
"borderStrong": Qt.rgba(1, 1, 1, 0.10),
"divider": Qt.rgba(1, 1, 1, 0.06),
"ctaBg": "#F26A21",
"ctaHoverBg": "#FF8A3D",
"ctaPressedBg": "#D95C1E",
"selection": "#211914",
"noTokenCircle": "#343434",
"success": "#78C88D",
"warning": "#F2B366",
"error": "#F08A76"
})
}

View File

@ -0,0 +1,46 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
ColumnLayout {
id: root
required property var theme
property bool hasToken: false
property string tokenColor: root.theme.colors.noTokenCircle
property string tokenLetter: ""
property string tokenText: qsTr("Select token")
property string balance: ""
property string accessibleName: qsTr("Select token")
property bool invalid: false
signal clicked
spacing: 2
Text {
Layout.fillWidth: true
visible: root.balance.length > 0
text: qsTr("Balance %1").arg(root.balance)
color: root.theme.colors.textSecondary
font.pixelSize: 10
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
}
AmmTokenSelectButton {
objectName: "tokenSelectButton"
Layout.fillWidth: true
theme: root.theme
enabled: root.enabled
invalid: root.invalid
hasToken: root.hasToken
tokenColor: root.tokenColor
tokenLetter: root.tokenLetter
text: root.tokenText
maximumTextWidth: 112
Accessible.name: root.accessibleName
onClicked: root.clicked()
}
}

View File

@ -0,0 +1,195 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Rectangle {
id: root
required property var theme
property string label: ""
property string amount: ""
property string supportingText: ""
property string errorText: ""
property string supportingActionText: ""
property bool readOnly: false
property bool muted: false
property bool invalid: root.errorText.length > 0
property Component accessory
property real accessoryWidth: 0
property real accessoryHeight: 0
property Component adjustment
property real adjustmentWidth: 0
property real adjustmentHeight: 0
signal amountEdited(string value)
signal amountEditingFinished(string value)
signal supportingActionClicked
implicitHeight: Math.max(110, contentRow.implicitHeight + 24)
radius: 16
color: root.muted ? root.theme.colors.panelBg : root.theme.colors.inputBg
border.color: root.invalid
? root.theme.colors.error
: amountInput.activeFocus
? root.theme.colors.ctaBg
: "transparent"
border.width: 1
Binding {
target: amountInput
property: "text"
value: root.amount
}
RowLayout {
id: contentRow
anchors.fill: parent
anchors.leftMargin: 16
anchors.rightMargin: 16
anchors.topMargin: 12
anchors.bottomMargin: 12
spacing: 10
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
text: root.label
color: root.theme.colors.textSecondary
font.pixelSize: 13
elide: Text.ElideRight
Layout.fillWidth: true
}
Text {
Layout.fillWidth: true
visible: root.errorText.length > 0
text: root.errorText
color: root.theme.colors.error
font.pixelSize: 11
wrapMode: Text.Wrap
}
Item {
Layout.fillWidth: true
Layout.preferredHeight: 44
TextInput {
id: amountInput
anchors.fill: parent
readOnly: root.readOnly
color: root.readOnly || root.muted
? root.theme.colors.textSecondary
: root.theme.colors.textPrimary
font.pixelSize: {
var length = Math.max(1, String(root.amount || "0").length)
return Math.max(14, Math.min(34, Math.floor(width / (length * 0.68))))
}
font.weight: Font.Bold
horizontalAlignment: Text.AlignLeft
selectionColor: root.theme.colors.selection
selectedTextColor: root.theme.colors.textPrimary
clip: true
inputMethodHints: Qt.ImhFormattedNumbersOnly
maximumLength: 80
validator: RegularExpressionValidator {
regularExpression: /^[0-9]*\.?[0-9]*$/
}
Accessible.name: root.label
onTextEdited: {
if (!root.readOnly)
root.amountEdited(text)
}
onEditingFinished: {
if (!root.readOnly)
root.amountEditingFinished(text)
}
}
Text {
anchors.fill: parent
text: "0"
color: root.theme.colors.textPlaceholder
font: amountInput.font
visible: amountInput.text === "" && !amountInput.activeFocus
verticalAlignment: Text.AlignVCenter
}
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Loader {
active: root.adjustment !== null
visible: active
sourceComponent: root.adjustment
Layout.preferredWidth: active ? root.adjustmentWidth : 0
Layout.preferredHeight: active ? root.adjustmentHeight : 0
}
Button {
id: supportingAction
visible: root.supportingActionText.length > 0
enabled: visible && !root.readOnly
implicitWidth: 40
implicitHeight: 24
text: root.supportingActionText
hoverEnabled: true
Accessible.name: text
onClicked: root.supportingActionClicked()
contentItem: Text {
text: supportingAction.text
color: supportingAction.enabled
? root.theme.colors.ctaBg
: root.theme.colors.textPlaceholder
font.pixelSize: 11
font.weight: Font.Bold
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: 6
color: supportingAction.hovered || supportingAction.activeFocus
? root.theme.colors.selection : "transparent"
}
}
Text {
Layout.fillWidth: true
text: root.supportingText
color: root.theme.colors.textSecondary
font.pixelSize: 11
horizontalAlignment: root.adjustment !== null
|| root.supportingActionText.length > 0
? Text.AlignRight : Text.AlignLeft
elide: Text.ElideRight
visible: text.length > 0
}
}
}
Loader {
sourceComponent: root.accessory
visible: status === Loader.Ready
Layout.alignment: Qt.AlignTop
Layout.topMargin: 17
Layout.preferredWidth: root.accessoryWidth
Layout.preferredHeight: root.accessoryHeight
}
}
Behavior on color {
ColorAnimation { duration: 180 }
}
}

View File

@ -0,0 +1,80 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Button {
id: root
required property var theme
property bool hasToken: false
property string tokenColor: root.theme.colors.noTokenCircle
property string tokenLetter: ""
property bool showIndicator: true
property bool invalid: false
property real maximumTextWidth: 112
implicitWidth: contentRow.implicitWidth + 24
implicitHeight: 40
leftPadding: 12
rightPadding: 12
hoverEnabled: true
contentItem: RowLayout {
id: contentRow
spacing: 6
Rectangle {
Layout.preferredWidth: 24
Layout.preferredHeight: 24
visible: root.hasToken
radius: 12
color: root.tokenColor
Text {
anchors.centerIn: parent
text: root.tokenLetter
color: "#FFFFFF"
font.pixelSize: 10
font.weight: Font.Bold
}
}
Text {
Layout.maximumWidth: root.maximumTextWidth
text: root.text
color: root.enabled
? root.theme.colors.textPrimary
: root.theme.colors.textPlaceholder
font.pixelSize: 15
font.weight: root.hasToken ? Font.Medium : Font.Normal
elide: Text.ElideRight
}
Text {
visible: root.showIndicator
text: "\u25BE"
color: root.enabled
? root.theme.colors.textSecondary
: root.theme.colors.textPlaceholder
font.pixelSize: 10
}
}
background: Rectangle {
radius: 20
color: root.pressed
? root.theme.colors.selection
: root.hovered || root.activeFocus
? root.theme.colors.panelHoverBg
: root.theme.colors.panelBg
border.width: root.invalid || root.activeFocus ? 1 : 0
border.color: root.invalid ? root.theme.colors.error : root.theme.colors.ctaBg
Behavior on color {
ColorAnimation { duration: 120 }
}
}
}

View File

@ -0,0 +1,295 @@
.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 increment(value) {
var digits = normalize(value).split("")
for (var i = digits.length - 1; i >= 0; --i) {
if (digits[i] !== "9") {
digits[i] = String(Number(digits[i]) + 1)
return digits.join("")
}
digits[i] = "0"
}
digits.unshift("1")
return digits.join("")
}
function pow10(exponent) {
var value = "1"
for (var i = 0; i < exponent; ++i)
value += "0"
return value
}
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 parseRatio(amountA, amountB) {
var parsedA = parsePrice(amountA)
if (!parsedA.ok)
return { "ok": false, "code": parsedA.code }
var parsedB = parsePrice(amountB)
if (!parsedB.ok)
return { "ok": false, "code": parsedB.code }
return {
"ok": true,
"code": "",
"numerator": multiply(parsedB.numerator, pow10(parsedA.scale)),
"denominator": multiply(parsedA.numerator, pow10(parsedB.scale))
}
}
function ratioToQ64(amountA, amountB, canonicalDecimalsA, canonicalDecimalsB,
displayIsCanonical) {
var ratio = parseRatio(amountA, amountB)
if (!ratio.ok)
return { "ok": false, "code": ratio.code, "raw": "" }
var numerator
var denominator
if (displayIsCanonical) {
numerator = multiply(multiply(ratio.numerator, Q64), pow10(canonicalDecimalsB))
denominator = multiply(ratio.denominator, pow10(canonicalDecimalsA))
} else {
numerator = multiply(multiply(ratio.denominator, Q64), pow10(canonicalDecimalsB))
denominator = multiply(ratio.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 pairAmount(raw, fromA, decimalsA, decimalsB, priceAmountA, priceAmountB) {
if (!isUnsigned(raw))
return { "ok": false, "code": "invalid_raw_amount", "raw": "" }
var ratio = parseRatio(priceAmountA, priceAmountB)
if (!ratio.ok)
return { "ok": false, "code": ratio.code, "raw": "" }
var numerator
var denominator
if (fromA) {
numerator = multiply(multiply(raw, ratio.numerator), pow10(decimalsB))
denominator = multiply(ratio.denominator, pow10(decimalsA))
} else {
numerator = multiply(multiply(raw, ratio.denominator), pow10(decimalsA))
denominator = multiply(ratio.numerator, pow10(decimalsB))
}
var result = divide(numerator, denominator)
if (!result.valid)
return { "ok": false, "code": "invalid_raw_amount", "raw": "" }
var rounded = compare(multiply(result.remainder, "2"), denominator) >= 0
? increment(result.quotient) : result.quotient
if (rounded === "0")
return { "ok": false, "code": "amount_too_low", "raw": "" }
if (compare(rounded, U128_MAX) > 0)
return { "ok": false, "code": "invalid_raw_amount", "raw": "" }
return { "ok": true, "code": "", "raw": rounded }
}
function ratioValue(amountA, amountB, precision) {
var ratio = parseRatio(amountA, amountB)
return ratio.ok ? formatRatio(ratio.numerator, ratio.denominator, precision) : ""
}
function formatRatio(numerator, denominator, precision) {
var result = divide(numerator, denominator)
if (!result.valid)
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 toU32(value) {
if (!isUnsigned(value) || compare(value, U32_MAX) > 0)
return -1
return Number(normalize(value))
}

View File

@ -1,91 +0,0 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Rectangle {
id: root
property int currentIndex: 0
signal tabRequested(int index)
color: "#181818"
implicitHeight: 42
radius: 8
border.color: "#303030"
border.width: 1
RowLayout {
anchors.fill: parent
anchors.margins: 4
spacing: 4
Button {
id: addTab
activeFocusOnTab: true
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: qsTr("Add liquidity")
Accessible.name: addTab.text
Accessible.role: Accessible.PageTab
Layout.fillHeight: true
Layout.fillWidth: true
onClicked: root.tabRequested(0)
contentItem: Text {
color: root.currentIndex === 0 ? "#F2D8C7" : addTab.hovered || addTab.activeFocus ? "#E7E1D8" : "#8E8780"
elide: Text.ElideRight
font.bold: true
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
text: addTab.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: addTab.activeFocus || root.currentIndex === 0 ? "#F26A21" : "#181818"
border.width: 1
color: addTab.pressed ? "#2A1D16" : root.currentIndex === 0 ? "#211914" : addTab.hovered || addTab.activeFocus ? "#202020" : "#121212"
radius: 6
}
}
Button {
id: removeTab
activeFocusOnTab: true
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: qsTr("Remove liquidity")
Accessible.name: removeTab.text
Accessible.role: Accessible.PageTab
Layout.fillHeight: true
Layout.fillWidth: true
onClicked: root.tabRequested(1)
contentItem: Text {
color: root.currentIndex === 1 ? "#F2D8C7" : removeTab.hovered || removeTab.activeFocus ? "#E7E1D8" : "#8E8780"
elide: Text.ElideRight
font.bold: true
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
text: removeTab.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: removeTab.activeFocus || root.currentIndex === 1 ? "#F26A21" : "#181818"
border.width: 1
color: removeTab.pressed ? "#2A1D16" : root.currentIndex === 1 ? "#211914" : removeTab.hovered || removeTab.activeFocus ? "#202020" : "#121212"
radius: 6
}
}
}
}

View File

@ -5,87 +5,47 @@ ColumnLayout {
id: root
property var snapshot: ({})
readonly property bool isAdd: root.snapshot.action === "add"
spacing: 8
ColumnLayout {
Layout.fillWidth: true
spacing: 8
visible: root.isAdd
SummaryRow {
Layout.fillWidth: true
label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "")
value: root.snapshot.depositA || ""
}
SummaryRow {
Layout.fillWidth: true
label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "")
value: root.snapshot.depositB || ""
}
SummaryRow {
Layout.fillWidth: true
label: qsTr("Receive at least")
value: root.snapshot.minLpReceived || ""
}
SummaryRow {
Layout.fillWidth: true
label: qsTr("Current ratio")
value: root.snapshot.currentRatio || ""
}
SummaryRow {
Layout.fillWidth: true
label: qsTr("Fee tier")
value: root.snapshot.feeTier || ""
}
SummaryRow {
Layout.fillWidth: true
label: qsTr("Slippage tolerance")
value: root.snapshot.slippageTolerance || ""
}
function actionText(instruction) {
if (instruction === "NewDefinition")
return qsTr("Create pool")
if (instruction === "AddLiquidity")
return qsTr("Add liquidity")
return instruction || "-"
}
ColumnLayout {
SummaryRow {
Layout.fillWidth: true
spacing: 8
visible: !root.isAdd
label: qsTr("Pair")
value: root.snapshot.pairText || "-"
}
SummaryRow {
Layout.fillWidth: true
label: qsTr("Burn LP")
value: qsTr("%1 (%2)")
.arg(root.snapshot.burnText || "")
.arg(root.snapshot.burnPercent || "")
}
SummaryRow {
Layout.fillWidth: true
label: qsTr("Action")
value: root.actionText(root.snapshot.instruction)
}
SummaryRow {
Layout.fillWidth: true
label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "")
value: root.snapshot.minTokenAReceived || ""
}
SummaryRow {
Layout.fillWidth: true
label: qsTr("Fee")
value: root.snapshot.feeText || "-"
}
SummaryRow {
Layout.fillWidth: true
label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "")
value: root.snapshot.minTokenBReceived || ""
}
SummaryRow {
Layout.fillWidth: true
label: qsTr("Deposit")
value: qsTr("%1 + %2")
.arg(root.snapshot.depositAText || "-")
.arg(root.snapshot.depositBText || "-")
valueWrapAnywhere: true
}
SummaryRow {
Layout.fillWidth: true
label: qsTr("Slippage tolerance")
value: root.snapshot.slippageTolerance || ""
}
SummaryRow {
Layout.fillWidth: true
label: qsTr("Post-removal share")
value: root.snapshot.postRemovalShare || ""
}
SummaryRow {
Layout.fillWidth: true
label: qsTr("Expected LP")
value: root.snapshot.expectedLpText || "-"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,98 +0,0 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import "../../state"
Rectangle {
id: root
required property DummyPoolState poolState
readonly property string estimateHelp: qsTr("This value is an estimate from the current dummy reserves and your share of total LP supply.")
color: "#151515"
implicitHeight: content.implicitHeight + 20
radius: 8
border.color: "#303030"
border.width: 1
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: 10
spacing: 6
RowLayout {
spacing: 10
Layout.fillWidth: true
ColumnLayout {
spacing: 2
Layout.fillWidth: true
Text {
color: "#E7E1D8"
font.bold: true
font.pixelSize: 13
text: root.poolState.userLpBalance > 0 ? qsTr("Your position") : qsTr("No position")
Layout.fillWidth: true
}
Text {
color: "#8E8780"
font.pixelSize: 11
text: qsTr("%1 LP tokens").arg(root.poolState.formatInteger(root.poolState.userLpBalance))
visible: root.poolState.userLpBalance > 0
Layout.fillWidth: true
}
}
Rectangle {
color: "#211914"
radius: 10
border.color: "#49301F"
border.width: 1
Layout.preferredHeight: 24
Layout.preferredWidth: shareText.implicitWidth + 18
Text {
id: shareText
anchors.centerIn: parent
color: "#F2D8C7"
font.bold: true
font.pixelSize: 11
text: root.poolState.userLpBalance > 0 ? root.poolState.formatPoolShare(root.poolState.poolShare) : root.poolState.feeTier
}
}
}
SummaryRow {
estimated: true
estimateHelp: root.estimateHelp
label: qsTr("Owned")
value: qsTr("%1 + %2").arg(root.poolState.formatCompactTokenAmount(root.poolState.userOwnedA, root.poolState.tokenA)).arg(root.poolState.formatCompactTokenAmount(root.poolState.userOwnedB, root.poolState.tokenB))
visible: root.poolState.userLpBalance > 0
Layout.fillWidth: true
}
SummaryRow {
label: qsTr("Pool")
value: qsTr("%1 / %2").arg(root.poolState.formatCompactTokenAmount(root.poolState.reserveA, root.poolState.tokenA)).arg(root.poolState.formatCompactTokenAmount(root.poolState.reserveB, root.poolState.tokenB))
Layout.fillWidth: true
}
SummaryRow {
label: qsTr("Fee")
value: root.poolState.feeTier
Layout.fillWidth: true
}
}
}

View File

@ -1,492 +0,0 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import "../shared"
import "../../state"
Rectangle {
id: root
required property DummyPoolState poolState
property real slippageTolerancePercent: 0.5
property int burnAmount: 0
readonly property int maxBurnAmount: root.poolState.clampBurnAmount(root.poolState.userLpBalance)
readonly property bool hasLpTokens: root.maxBurnAmount > 0
readonly property int preset25Amount: root.poolState.burnAmountForPercent(25)
readonly property int preset50Amount: root.poolState.burnAmountForPercent(50)
readonly property int preset75Amount: root.poolState.burnAmountForPercent(75)
readonly property real removePercent: root.maxBurnAmount > 0 ? root.burnAmount * 100 / root.maxBurnAmount : 0
readonly property var preview: root.poolState.removeLiquidityPreview(root.burnAmount)
readonly property int minTokenAReceived: root.poolState.minReceivedAmount(root.preview.withdrawA, root.slippageTolerancePercent)
readonly property int minTokenBReceived: root.poolState.minReceivedAmount(root.preview.withdrawB, root.slippageTolerancePercent)
readonly property bool minReceivedIsZero: root.burnAmount > 0 && (root.minTokenAReceived === 0 || root.minTokenBReceived === 0)
readonly property bool canSubmit: root.hasLpTokens && root.burnAmount > 0 && !root.minReceivedIsZero
readonly property string estimateHelp: qsTr("Estimated with the same integer floor math used by the remove-liquidity contract path.")
readonly property string submitButtonText: !root.hasLpTokens ? qsTr("No LP balance") : root.burnAmount === 0 ? qsTr("Enter an amount") : root.minReceivedIsZero ? qsTr("Minimum received is 0") : qsTr("Remove Liquidity")
signal slippageToleranceChangeRequested(real tolerancePercent)
signal removeLiquidityRequested(var snapshot)
color: "#00000000"
implicitHeight: content.implicitHeight
radius: 0
border.width: 0
onMaxBurnAmountChanged: {
if (root.burnAmount > root.maxBurnAmount) {
root.setBurnAmount(root.maxBurnAmount);
}
}
ColumnLayout {
id: content
anchors.fill: parent
spacing: 10
Text {
color: "#F26A21"
font.pixelSize: 12
text: qsTr("No LP tokens")
visible: !root.hasLpTokens
Layout.fillWidth: true
}
Text {
color: "#A9A098"
font.pixelSize: 12
lineHeight: 1.25
text: qsTr("Add liquidity first to receive LP tokens before removing from this pool.")
visible: !root.hasLpTokens
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
Rectangle {
color: root.hasLpTokens ? "#151515" : "#121212"
radius: 8
border.color: burnField.activeFocus ? "#F26A21" : "#343434"
border.width: 1
Layout.fillWidth: true
Layout.preferredHeight: inputContent.implicitHeight + 20
ColumnLayout {
id: inputContent
anchors.fill: parent
anchors.margins: 10
spacing: 8
RowLayout {
spacing: 8
Layout.fillWidth: true
Text {
color: "#A9A098"
elide: Text.ElideRight
font.pixelSize: 12
text: qsTr("LP tokens to burn")
Layout.fillWidth: true
}
Text {
color: "#A9A098"
elide: Text.ElideRight
font.pixelSize: 11
horizontalAlignment: Text.AlignRight
text: qsTr("Available LP: %1").arg(root.poolState.formatInteger(root.poolState.userLpBalance))
Layout.maximumWidth: 170
}
}
TextField {
id: burnField
activeFocusOnTab: root.hasLpTokens
color: "#E7E1D8"
enabled: root.hasLpTokens
font.bold: true
font.pixelSize: 18
inputMethodHints: Qt.ImhDigitsOnly
placeholderText: qsTr("0")
selectByMouse: true
selectedTextColor: "#151515"
selectionColor: "#F26A21"
text: root.burnAmount > 0 ? String(root.burnAmount) : ""
validator: RegularExpressionValidator {
regularExpression: /[0-9]*/
}
Accessible.name: qsTr("LP tokens to burn")
Layout.fillWidth: true
Layout.minimumHeight: 44
onTextEdited: root.setBurnAmount(text)
background: Rectangle {
border.color: burnField.activeFocus ? "#F26A21" : "#343434"
border.width: 1
color: burnField.activeFocus ? "#1F1B18" : "#101010"
radius: 6
}
}
}
}
RowLayout {
spacing: 6
Layout.fillWidth: true
Button {
id: preset25
activeFocusOnTab: root.hasLpTokens
enabled: root.hasLpTokens
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: qsTr("25%")
Accessible.name: qsTr("Remove 25 percent")
Layout.fillWidth: true
Layout.minimumHeight: 44
onClicked: root.setBurnPercent(25)
contentItem: Text {
color: preset25.enabled && (preset25.hovered || preset25.activeFocus || root.preset25Amount === root.burnAmount) ? "#151515" : "#A9A098"
font.bold: true
font.pixelSize: 11
horizontalAlignment: Text.AlignHCenter
text: preset25.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: preset25.activeFocus || root.preset25Amount === root.burnAmount ? "#F26A21" : "#343434"
border.width: 1
color: preset25.pressed ? "#D95C1E" : root.preset25Amount === root.burnAmount ? "#F26A21" : preset25.hovered || preset25.activeFocus ? "#E7E1D8" : "#151515"
radius: 6
}
}
Button {
id: preset50
activeFocusOnTab: root.hasLpTokens
enabled: root.hasLpTokens
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: qsTr("50%")
Accessible.name: qsTr("Remove 50 percent")
Layout.fillWidth: true
Layout.minimumHeight: 44
onClicked: root.setBurnPercent(50)
contentItem: Text {
color: preset50.enabled && (preset50.hovered || preset50.activeFocus || root.preset50Amount === root.burnAmount) ? "#151515" : "#A9A098"
font.bold: true
font.pixelSize: 11
horizontalAlignment: Text.AlignHCenter
text: preset50.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: preset50.activeFocus || root.preset50Amount === root.burnAmount ? "#F26A21" : "#343434"
border.width: 1
color: preset50.pressed ? "#D95C1E" : root.preset50Amount === root.burnAmount ? "#F26A21" : preset50.hovered || preset50.activeFocus ? "#E7E1D8" : "#151515"
radius: 6
}
}
Button {
id: preset75
activeFocusOnTab: root.hasLpTokens
enabled: root.hasLpTokens
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: qsTr("75%")
Accessible.name: qsTr("Remove 75 percent")
Layout.fillWidth: true
Layout.minimumHeight: 44
onClicked: root.setBurnPercent(75)
contentItem: Text {
color: preset75.enabled && (preset75.hovered || preset75.activeFocus || root.preset75Amount === root.burnAmount) ? "#151515" : "#A9A098"
font.bold: true
font.pixelSize: 11
horizontalAlignment: Text.AlignHCenter
text: preset75.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: preset75.activeFocus || root.preset75Amount === root.burnAmount ? "#F26A21" : "#343434"
border.width: 1
color: preset75.pressed ? "#D95C1E" : root.preset75Amount === root.burnAmount ? "#F26A21" : preset75.hovered || preset75.activeFocus ? "#E7E1D8" : "#151515"
radius: 6
}
}
Button {
id: presetMax
activeFocusOnTab: root.hasLpTokens
enabled: root.hasLpTokens
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: qsTr("MAX")
Accessible.name: qsTr("Remove maximum LP balance")
Layout.fillWidth: true
Layout.minimumHeight: 44
onClicked: root.setBurnAmount(root.maxBurnAmount)
contentItem: Text {
color: presetMax.enabled && (presetMax.hovered || presetMax.activeFocus || root.burnAmount === root.maxBurnAmount) ? "#151515" : "#A9A098"
font.bold: true
font.pixelSize: 11
horizontalAlignment: Text.AlignHCenter
text: presetMax.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: presetMax.activeFocus || root.burnAmount === root.maxBurnAmount ? "#F26A21" : "#343434"
border.width: 1
color: presetMax.pressed ? "#D95C1E" : root.burnAmount === root.maxBurnAmount ? "#F26A21" : presetMax.hovered || presetMax.activeFocus ? "#E7E1D8" : "#151515"
radius: 6
}
}
}
ColumnLayout {
spacing: 6
Layout.fillWidth: true
RowLayout {
spacing: 8
Layout.fillWidth: true
Text {
color: "#A9A098"
font.pixelSize: 12
text: qsTr("Pool share to remove")
Layout.fillWidth: true
}
Text {
color: "#E7E1D8"
font.bold: true
font.pixelSize: 12
horizontalAlignment: Text.AlignRight
text: root.poolState.formatPercent(root.removePercent)
Layout.maximumWidth: 72
}
}
Slider {
id: burnSlider
activeFocusOnTab: root.hasLpTokens
enabled: root.hasLpTokens
from: 0
stepSize: 1
to: 100
value: root.removePercent
Accessible.name: qsTr("Pool share to remove")
Accessible.role: Accessible.Slider
Layout.fillWidth: true
Layout.minimumHeight: 44
onMoved: root.setBurnPercent(Math.round(value))
background: Rectangle {
color: "#343434"
implicitHeight: 4
radius: 2
x: burnSlider.leftPadding
y: burnSlider.topPadding + burnSlider.availableHeight / 2 - height / 2
width: burnSlider.availableWidth
Rectangle {
color: burnSlider.enabled ? "#F26A21" : "#56504A"
height: parent.height
radius: 2
width: burnSlider.visualPosition * parent.width
}
}
handle: Rectangle {
border.color: burnSlider.activeFocus ? "#E7E1D8" : "#F26A21"
border.width: 1
color: burnSlider.enabled ? "#F26A21" : "#56504A"
height: 18
radius: 9
width: 18
x: burnSlider.leftPadding + burnSlider.visualPosition * (burnSlider.availableWidth - width)
y: burnSlider.topPadding + burnSlider.availableHeight / 2 - height / 2
}
}
}
SummaryRow {
estimated: true
estimateHelp: root.estimateHelp
label: qsTr("Withdraw %1").arg(root.poolState.tokenA)
value: root.poolState.formatTokenAmount(root.preview.withdrawA, root.poolState.tokenA)
visible: root.burnAmount > 0
Layout.fillWidth: true
}
SummaryRow {
estimated: true
estimateHelp: root.estimateHelp
label: qsTr("Withdraw %1").arg(root.poolState.tokenB)
value: root.poolState.formatTokenAmount(root.preview.withdrawB, root.poolState.tokenB)
visible: root.burnAmount > 0
Layout.fillWidth: true
}
SlippageToleranceControl {
tolerancePercent: root.slippageTolerancePercent
Layout.fillWidth: true
onToleranceChangeRequested: function (tolerancePercent) {
root.slippageToleranceChangeRequested(tolerancePercent);
}
}
SummaryRow {
label: qsTr("Min %1 received").arg(root.poolState.tokenA)
value: root.poolState.formatTokenAmount(root.minTokenAReceived, root.poolState.tokenA)
visible: root.burnAmount > 0
Layout.fillWidth: true
}
SummaryRow {
label: qsTr("Min %1 received").arg(root.poolState.tokenB)
value: root.poolState.formatTokenAmount(root.minTokenBReceived, root.poolState.tokenB)
visible: root.burnAmount > 0
Layout.fillWidth: true
}
Text {
color: "#F08A76"
font.pixelSize: 12
lineHeight: 1.25
text: qsTr("Minimum received is 0. Increase amount or lower slippage.")
visible: root.minReceivedIsZero
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
SummaryRow {
estimated: true
estimateHelp: root.estimateHelp
label: qsTr("Position after")
value: root.poolState.formatPoolShare(root.preview.newUserShare)
visible: root.burnAmount > 0
Layout.fillWidth: true
}
Button {
id: submitButton
activeFocusOnTab: root.hasLpTokens
enabled: root.canSubmit
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: root.submitButtonText
Accessible.name: submitButton.text
Layout.fillWidth: true
Layout.minimumHeight: 44
Layout.preferredHeight: 44
onClicked: root.removeLiquidityRequested(root.submitSnapshot())
contentItem: Text {
color: submitButton.enabled ? "#151515" : "#7D756E"
elide: Text.ElideRight
font.bold: true
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
text: submitButton.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: submitButton.enabled ? "#F26A21" : "#343434"
border.width: 1
color: submitButton.enabled ? submitButton.pressed ? "#D95C1E" : submitButton.hovered || submitButton.activeFocus ? "#FF8A3D" : "#F26A21" : "#181818"
radius: 6
}
}
}
function setBurnAmount(value) {
root.burnAmount = root.poolState.clampBurnAmount(value);
}
function setBurnPercent(percent) {
root.setBurnAmount(root.poolState.burnAmountForPercent(percent));
}
function resetForm() {
root.setBurnAmount(0);
}
function submitSnapshot() {
return {
"action": "remove",
"burnAmount": root.preview.burnedLp,
"burnPercent": root.poolState.formatPercent(root.removePercent),
"burnText": root.poolState.formatLpAmount(root.preview.burnedLp),
"minTokenAReceived": root.poolState.formatTokenAmount(root.minTokenAReceived, root.poolState.tokenA),
"minTokenBReceived": root.poolState.formatTokenAmount(root.minTokenBReceived, root.poolState.tokenB),
"postRemovalShare": root.poolState.formatPoolShare(root.preview.newUserShare),
"slippageTolerance": root.poolState.formatPercent(root.slippageTolerancePercent),
"tokenA": root.poolState.tokenA,
"tokenB": root.poolState.tokenB,
"withdrawA": root.preview.withdrawA,
"withdrawB": root.preview.withdrawB,
"withdrawAText": root.poolState.formatTokenAmount(root.preview.withdrawA, root.poolState.tokenA),
"withdrawBText": root.poolState.formatTokenAmount(root.preview.withdrawB, root.poolState.tokenB)
};
}
}

View File

@ -6,6 +6,7 @@ Item {
property string label: ""
property string value: ""
property bool valueWrapAnywhere: false
property bool estimated: false
property string estimateHelp: qsTr("This value is derived from your LP token balance, total LP supply, and current pool reserves.")
@ -36,14 +37,16 @@ Item {
Text {
color: "#E7E1D8"
elide: Text.ElideRight
elide: root.valueWrapAnywhere ? Text.ElideNone : Text.ElideRight
font.bold: true
font.pixelSize: 12
horizontalAlignment: Text.AlignRight
text: root.value
verticalAlignment: Text.AlignVCenter
wrapMode: root.valueWrapAnywhere ? Text.WrapAtWordBoundaryOrAnywhere : Text.NoWrap
Layout.maximumWidth: Math.max(178, root.width * 0.55)
Layout.preferredWidth: Math.min(implicitWidth, Math.max(178, root.width * 0.55))
}
EstimateInfoButton {

View File

@ -1,156 +1,135 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
pragma ComponentBehavior: Bound
Rectangle {
import QtQuick
import "../shared"
AmmTokenAmountSurface {
id: root
property alias text: amountField.text
property string text: ""
property string balance: ""
property string errorText: ""
property string helperText: ""
property string label: ""
property string token: ""
property bool showMaxButton: true
property var tokenData: null
property var tokens: []
property string selectedTokenId: ""
property bool tokenInvalid: false
property bool tokenSelectionEnabled: true
property bool editPending: false
property string pendingValue: ""
property var disabledReasonForCode: function(code) {
return qsTr("This token is unavailable (%1).").arg(code || "unknown")
}
property var detailForToken: function(token) { return "" }
property alias popup: tokenModal
property alias query: tokenModal.searchText
readonly property var rows: tokenModal.rows
signal editingChanged(string value)
signal editingCommitted(string value)
signal maxClicked
signal tokenSelected(string tokenId)
signal tokenEntered(string value)
color: "#151515"
implicitHeight: content.implicitHeight + 20
radius: 8
border.color: root.errorText.length > 0 ? "#D85F4B" : amountField.activeFocus ? "#F26A21" : "#343434"
border.width: 1
amount: root.text
supportingText: root.helperText
supportingActionText: root.showMaxButton ? qsTr("MAX") : ""
accessory: tokenActions
accessoryWidth: width < 360 ? 132 : 180
accessoryHeight: root.balance.length > 0 ? 58 : 40
Accessible.name: root.label
Accessible.role: Accessible.EditableText
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: 10
spacing: 8
RowLayout {
spacing: 8
Layout.fillWidth: true
Text {
color: "#A9A098"
elide: Text.ElideRight
font.pixelSize: 12
text: root.label
Layout.fillWidth: true
}
Text {
color: "#E7E1D8"
elide: Text.ElideRight
font.bold: true
font.pixelSize: 12
horizontalAlignment: Text.AlignRight
text: root.token
Layout.maximumWidth: 76
}
}
RowLayout {
spacing: 8
Layout.fillWidth: true
TextField {
id: amountField
activeFocusOnTab: true
color: "#E7E1D8"
font.bold: true
font.pixelSize: 18
inputMethodHints: Qt.ImhFormattedNumbersOnly
placeholderText: qsTr("0")
selectByMouse: true
selectedTextColor: "#151515"
selectionColor: "#F26A21"
validator: RegularExpressionValidator {
regularExpression: /[0-9]*([.][0-9]*)?/
}
Accessible.name: root.label
Layout.fillWidth: true
Layout.minimumHeight: 44
onTextEdited: root.editingChanged(text)
background: Rectangle {
border.color: amountField.activeFocus ? "#F26A21" : "#343434"
border.width: 1
color: amountField.activeFocus ? "#1F1B18" : "#101010"
radius: 6
}
}
Button {
id: maxButton
activeFocusOnTab: true
focusPolicy: Qt.StrongFocus
hoverEnabled: true
text: qsTr("MAX")
Accessible.name: qsTr("Use maximum %1 balance").arg(root.token)
Layout.minimumHeight: 44
Layout.preferredWidth: 58
onClicked: root.maxClicked()
contentItem: Text {
color: maxButton.activeFocus || maxButton.hovered ? "#151515" : "#F26A21"
font.bold: true
font.pixelSize: 11
horizontalAlignment: Text.AlignHCenter
text: maxButton.text
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
border.color: "#F26A21"
border.width: 1
color: maxButton.pressed ? "#D95C1E" : maxButton.hovered || maxButton.activeFocus ? "#F26A21" : "#201712"
radius: 6
}
}
}
RowLayout {
spacing: 8
Layout.fillWidth: true
Text {
color: root.errorText.length > 0 ? "#F08A76" : root.helperText.length > 0 ? "#F26A21" : "#A9A098"
elide: Text.ElideRight
font.pixelSize: 11
text: root.errorText.length > 0 ? root.errorText : root.helperText
visible: text.length > 0
Layout.fillWidth: true
}
Text {
color: "#A9A098"
elide: Text.ElideRight
font.pixelSize: 11
horizontalAlignment: Text.AlignRight
text: qsTr("Balance %1").arg(root.balance)
Layout.alignment: Qt.AlignRight
Layout.maximumWidth: 150
}
onAmountEdited: function(value) {
root.pendingValue = value
root.editPending = true
root.editingChanged(value)
commitTimer.restart()
}
onAmountEditingFinished: function(value) {
root.pendingValue = value
root.commitPendingEdit()
}
onSupportingActionClicked: root.maxClicked()
onTextChanged: {
if (root.editPending && root.text !== root.pendingValue) {
commitTimer.stop()
root.editPending = false
}
}
Timer {
id: commitTimer
interval: 250
repeat: false
onTriggered: root.commitPendingEdit()
}
Component {
id: tokenActions
AmmTokenAccessory {
theme: root.theme
enabled: root.tokenSelectionEnabled
invalid: root.tokenInvalid
hasToken: root.tokenData !== null
tokenColor: root.tokenColor(root.tokenData)
tokenLetter: root.tokenLetter(root.tokenData)
tokenText: root.tokenText(root.tokenData)
balance: root.balance
accessibleName: qsTr("Select %1").arg(root.label)
onClicked: tokenModal.open()
}
}
TokenSelectorModal {
id: tokenModal
theme: root.theme
tokens: root.tokens
title: qsTr("Select a token")
searchPlaceholder: qsTr("Search name or address")
popularTitle: qsTr("Quick select")
listTitle: qsTr("All tokens")
allowCustomEntry: true
disabledReasonForCode: root.disabledReasonForCode
detailForToken: root.detailForToken
onTokenSelected: function(token) {
root.tokenSelected(String(token.definitionId || token.address || ""))
}
onTokenEntered: function(value) { root.tokenEntered(value) }
}
function acceptInput(value) {
tokenModal.acceptInput(value)
}
function commitPendingEdit() {
if (!root.editPending)
return
commitTimer.stop()
root.editPending = false
root.editingCommitted(root.pendingValue)
}
function tokenText(token) {
if (!token)
return qsTr("Select token")
return String(token.symbol || token.name || root.shortId(root.selectedTokenId))
}
function tokenLetter(token) {
var text = root.tokenText(token)
return token ? String(token.letter || text.charAt(0).toUpperCase()) : ""
}
function tokenColor(token) {
return token && token.color ? token.color : root.theme.colors.noTokenCircle
}
function shortId(value) {
var text = String(value || "")
return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text
}
}

View File

@ -0,0 +1,489 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Popup {
id: root
required property var theme
property var tokens: []
property string searchText: ""
property string title: qsTr("Select a token")
property string searchPlaceholder: qsTr("Search tokens")
property string popularTitle: qsTr("Popular tokens")
property string listTitle: qsTr("Tokens by 24H volume")
property bool allowCustomEntry: false
property var disabledReasonForCode: function(code) {
return qsTr("This token is unavailable (%1).").arg(code || "unknown")
}
property var detailForToken: function(token) {
return token && token.balance !== undefined
? qsTr("Balance %1").arg(token.balance) : ""
}
readonly property var rows: root.filteredTokens()
readonly property var popularTokens: root.selectableTokens().slice(0, 5)
readonly property bool compact: root.height < 360
readonly property bool showPopular: root.popularTokens.length > 0 && root.height >= 440
signal tokenSelected(var token)
signal tokenEntered(string value)
parent: Overlay.overlay
x: parent ? Math.round((parent.width - width) / 2) : 0
y: parent ? Math.round((parent.height - height) / 2) : 0
width: parent && parent.width > 32
? Math.max(0, Math.min(480, parent.width - 32)) : 280
height: parent && parent.height > 32
? Math.max(0, Math.min(600, parent.height - 32)) : 360
padding: root.compact ? 8 : 20
modal: true
focus: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
onOpened: {
root.searchText = ""
searchField.text = ""
Qt.callLater(searchField.forceActiveFocus)
}
Overlay.modal: Rectangle {
color: Qt.rgba(0, 0, 0, 0.4)
}
background: Rectangle {
radius: 24
color: root.theme.colors.cardBg
border.color: root.theme.colors.border
border.width: 1
Behavior on color {
ColorAnimation { duration: 180 }
}
}
contentItem: ColumnLayout {
spacing: root.compact ? 8 : 16
RowLayout {
Layout.fillWidth: true
Text {
Layout.fillWidth: true
text: root.title
color: root.theme.colors.textPrimary
font.pixelSize: 18
font.weight: Font.Bold
}
Button {
id: closeButton
Layout.preferredWidth: 32
Layout.preferredHeight: 32
text: "\u00D7"
hoverEnabled: true
Accessible.name: qsTr("Close token selector")
ToolTip.visible: hovered
ToolTip.text: Accessible.name
onClicked: root.close()
contentItem: Text {
text: closeButton.text
color: root.theme.colors.textSecondary
font.pixelSize: 16
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: 16
color: closeButton.hovered || closeButton.activeFocus
? root.theme.colors.panelHoverBg
: root.theme.colors.panelBg
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: root.compact ? 40 : 48
radius: 16
color: root.theme.colors.inputBg
border.color: searchField.activeFocus
? root.theme.colors.ctaBg
: root.theme.colors.border
border.width: 1
RowLayout {
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 14
spacing: 8
Text {
text: "\u2315"
color: root.theme.colors.textSecondary
font.pixelSize: 20
}
TextField {
id: searchField
objectName: "tokenSearchField"
Layout.fillWidth: true
color: root.theme.colors.textPrimary
placeholderText: root.searchPlaceholder
placeholderTextColor: root.theme.colors.textPlaceholder
selectionColor: root.theme.colors.selection
selectedTextColor: root.theme.colors.textPrimary
font.pixelSize: 15
selectByMouse: true
background: null
onTextEdited: root.searchText = text
onAccepted: root.acceptInput(text)
}
}
}
Text {
Layout.fillWidth: true
visible: root.showPopular
text: root.popularTitle
color: root.theme.colors.textSecondary
font.pixelSize: 13
}
Flow {
Layout.fillWidth: true
visible: root.showPopular
spacing: 8
Repeater {
model: root.popularTokens
delegate: AmmTokenSelectButton {
required property var modelData
theme: root.theme
hasToken: true
tokenColor: root.tokenColor(modelData)
tokenLetter: root.tokenLetter(modelData)
showIndicator: false
text: root.tokenSymbol(modelData).length > 0
? root.tokenSymbol(modelData) : root.tokenName(modelData)
maximumTextWidth: 84
width: Math.min(140, implicitWidth)
onClicked: root.chooseToken(modelData)
}
}
}
Text {
Layout.fillWidth: true
visible: !root.compact
text: root.listTitle
color: root.theme.colors.textSecondary
font.pixelSize: 13
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
ListView {
id: tokenList
objectName: "tokenList"
anchors.fill: parent
clip: true
spacing: 2
model: root.rows
boundsBehavior: Flickable.StopAtBounds
ScrollIndicator.vertical: ScrollIndicator { }
delegate: Item {
id: tokenRow
required property var modelData
readonly property bool selectable: root.isSelectable(modelData)
readonly property string disabledReason: root.disabledReasonForCode(
modelData.code
|| modelData.status)
readonly property bool pointerHovered: rowHover.containsMouse
readonly property bool disabledReasonVisible: (pointerHovered || activeFocus)
&& !selectable
width: ListView.view.width
height: 56
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: root.tokenName(modelData)
Accessible.description: selectable
? root.detailForToken(modelData)
: disabledReason
Accessible.onPressAction: tokenRow.activate()
Keys.onReturnPressed: function(event) {
tokenRow.activate()
event.accepted = true
}
Keys.onEnterPressed: function(event) {
tokenRow.activate()
event.accepted = true
}
Keys.onSpacePressed: function(event) {
tokenRow.activate()
event.accepted = true
}
Rectangle {
anchors.fill: parent
radius: 12
color: tokenRow.pointerHovered || tokenRow.activeFocus
? root.theme.colors.panelBg : "transparent"
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 12
Rectangle {
Layout.preferredWidth: 36
Layout.preferredHeight: 36
radius: 18
color: root.tokenColor(tokenRow.modelData)
Text {
anchors.centerIn: parent
text: root.tokenLetter(tokenRow.modelData)
color: "#FFFFFF"
font.pixelSize: 14
font.weight: Font.Bold
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
Layout.fillWidth: true
text: root.tokenName(tokenRow.modelData)
color: tokenRow.selectable
? root.theme.colors.textPrimary
: root.theme.colors.textPlaceholder
font.pixelSize: 15
elide: Text.ElideRight
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Text {
visible: text.length > 0
text: root.tokenSymbol(tokenRow.modelData)
color: root.theme.colors.textSecondary
font.pixelSize: 12
}
Text {
Layout.fillWidth: true
text: root.shortAddress(root.tokenAddress(tokenRow.modelData))
color: root.theme.colors.textPlaceholder
font.family: "monospace"
font.pixelSize: 11
elide: Text.ElideMiddle
}
}
}
Text {
Layout.maximumWidth: 118
text: root.detailForToken(tokenRow.modelData)
color: root.theme.colors.textSecondary
font.pixelSize: 11
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
}
}
}
MouseArea {
id: rowHover
anchors.fill: parent
hoverEnabled: true
cursorShape: tokenRow.selectable
? Qt.PointingHandCursor : Qt.ForbiddenCursor
onClicked: tokenRow.activate()
}
ToolTip.visible: tokenRow.disabledReasonVisible
ToolTip.text: tokenRow.disabledReason
function activate() {
if (tokenRow.selectable)
root.chooseToken(tokenRow.modelData)
}
}
}
Button {
id: customEntryButton
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
visible: root.rows.length === 0
&& root.allowCustomEntry
&& root.searchText.trim().length > 0
implicitHeight: 56
text: qsTr("Use TokenDefinition address")
onClicked: root.acceptInput(root.searchText)
contentItem: Column {
leftPadding: 8
spacing: 2
Text {
width: parent.width - 16
text: qsTr("Use TokenDefinition address")
color: root.theme.colors.textPrimary
font.pixelSize: 14
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width - 16
text: root.searchText
color: root.theme.colors.textSecondary
font.family: "monospace"
font.pixelSize: 11
elide: Text.ElideMiddle
}
}
background: Rectangle {
radius: 12
color: customEntryButton.hovered || customEntryButton.activeFocus
? root.theme.colors.panelBg : "transparent"
}
}
Text {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: 16
visible: root.rows.length === 0
&& (!root.allowCustomEntry
|| root.searchText.trim().length === 0)
text: qsTr("No tokens found")
color: root.theme.colors.textSecondary
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
}
}
}
function filteredTokens() {
var needle = root.searchText.trim().toLowerCase()
if (needle.length === 0)
return root.tokens
var result = []
for (var i = 0; i < root.tokens.length; ++i) {
var token = root.tokens[i]
if (root.tokenName(token).toLowerCase().indexOf(needle) >= 0
|| root.tokenSymbol(token).toLowerCase().indexOf(needle) >= 0
|| root.tokenAddress(token).toLowerCase().indexOf(needle) >= 0)
result.push(token)
}
return result
}
function selectableTokens() {
var result = []
for (var i = 0; i < root.tokens.length; ++i) {
if (root.isSelectable(root.tokens[i]))
result.push(root.tokens[i])
}
return result
}
function acceptInput(value) {
var entered = String(value || "").trim()
if (entered.length === 0)
return
var lowered = entered.toLowerCase()
for (var i = 0; i < root.tokens.length; ++i) {
var token = root.tokens[i]
if (root.tokenAddress(token).toLowerCase() === lowered
|| root.tokenName(token).toLowerCase() === lowered
|| root.tokenSymbol(token).toLowerCase() === lowered) {
if (root.isSelectable(token))
root.chooseToken(token)
else {
root.tokenEntered(root.tokenAddress(token))
root.close()
}
return
}
}
if (root.rows.length === 1 && root.isSelectable(root.rows[0])) {
root.chooseToken(root.rows[0])
return
}
if (root.allowCustomEntry) {
root.tokenEntered(entered)
root.close()
}
}
function chooseToken(token) {
if (!root.isSelectable(token))
return
root.tokenSelected(token)
root.close()
}
function isSelectable(token) {
return token && token.selectable !== false
}
function tokenName(token) {
if (!token)
return qsTr("Unknown token")
return String(token.name || token.symbol || qsTr("Unknown token"))
}
function tokenSymbol(token) {
return token ? String(token.symbol || "") : ""
}
function tokenAddress(token) {
return token ? String(token.definitionId || token.address || "") : ""
}
function tokenColor(token) {
return token && token.color ? token.color : root.theme.colors.noTokenCircle
}
function tokenLetter(token) {
if (token && token.letter)
return String(token.letter)
var label = root.tokenSymbol(token) || root.tokenName(token)
return label.length > 0 ? label.charAt(0).toUpperCase() : ""
}
function shortAddress(value) {
var text = String(value || "")
return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text
}
}

View File

@ -1,31 +1,43 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQml
import Logos.Controls
import Logos.Icons
import Logos.Wallet
import "../components/shared"
import "../components/liquidity"
import "../state"
Item {
id: root
property int activeLiquidityTab: 0
property real slippageTolerancePercent: 0.5
readonly property int pageMargin: 16
readonly property int preferredCardWidth: 492
readonly property int pageCardY: pageCard.implicitHeight + root.pageMargin * 2 <= scroll.height ? Math.round((scroll.height - pageCard.implicitHeight) / 2) : root.pageMargin
property var backend: null
property var runtime: null
readonly property NewPositionFlow flow: newPositionFlow
width: parent ? parent.width : implicitWidth
height: parent ? parent.height : implicitHeight
implicitWidth: root.preferredCardWidth + root.pageMargin * 2
implicitHeight: pageCard.implicitHeight + root.pageMargin * 2
readonly property int pageMargin: width < 640 ? 16 : 24
readonly property int contentMaxWidth: 1200
readonly property bool wideLayout: width >= 760
readonly property int stepRailWidth: Math.max(210, Math.min(330,
(width - pageMargin * 2) * 0.30))
DummyPoolState {
id: poolState
AmmTheme {
id: theme
}
NewPositionFlow {
id: newPositionFlow
backend: root.backend
runtime: root.runtime
active: root.visible
}
Rectangle {
anchors.fill: parent
color: "#151515"
color: theme.colors.background
}
Flickable {
@ -33,133 +45,215 @@ Item {
anchors.fill: parent
clip: true
contentHeight: Math.max(height, pageCard.y + pageCard.implicitHeight + root.pageMargin)
contentWidth: width
enabled: !confirmationDialog.visible
contentHeight: Math.max(height, pageLayout.y + pageLayout.implicitHeight + root.pageMargin)
enabled: !confirmationDialog.opened
flickableDirection: Flickable.VerticalFlick
boundsBehavior: Flickable.StopAtBounds
Rectangle {
id: pageCard
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
}
ColumnLayout {
id: pageLayout
color: "#1B1B1B"
implicitHeight: shellContent.implicitHeight + 24
radius: 16
border.color: "#303030"
border.width: 1
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
y: root.wideLayout ? 32 : 16
width: Math.max(0, Math.min(root.contentMaxWidth,
scroll.width - root.pageMargin * 2))
spacing: 24
ColumnLayout {
id: shellContent
anchors.fill: parent
anchors.margins: 12
spacing: 10
RowLayout {
spacing: 10
RowLayout {
Layout.fillWidth: true
spacing: 16
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text {
color: "#E7E1D8"
font.bold: true
font.pixelSize: 18
text: qsTr("Liquidity")
text: qsTr("New position")
color: theme.colors.textPrimary
font.pixelSize: 30
font.weight: Font.Bold
font.letterSpacing: 0
}
Text {
Layout.fillWidth: true
text: form.contextStatusText()
color: theme.colors.textSecondary
font.pixelSize: 12
elide: Text.ElideRight
}
}
LogosIconButton {
objectName: "refreshPositionButton"
iconSource: LogosIcons.refresh
iconColor: theme.colors.textSecondary
iconSize: 18
Layout.preferredWidth: 40
Layout.preferredHeight: 40
enabled: !newPositionFlow.contextLoading && !newPositionFlow.submitting
Accessible.name: qsTr("Refresh position data")
ToolTip.visible: hovered
ToolTip.text: Accessible.name
onClicked: newPositionFlow.refreshContext(true)
}
}
Rectangle {
id: compactSteps
objectName: "compactPositionSteps"
Layout.fillWidth: true
implicitHeight: compactStepRow.implicitHeight + 32
visible: !root.wideLayout
radius: 16
color: theme.colors.cardBg
border.color: theme.colors.border
border.width: 1
RowLayout {
id: compactStepRow
anchors.fill: parent
anchors.margins: 16
spacing: 12
StepMarker {
Layout.fillWidth: true
colors: theme.colors
stepNumber: 1
label: qsTr("Select pair and fees")
active: !form.hasPair
complete: form.hasPair
}
Rectangle {
color: "#211914"
radius: 12
border.color: "#49301F"
border.width: 1
Layout.preferredWidth: 20
Layout.preferredHeight: 1
color: form.hasPair ? theme.colors.ctaBg : theme.colors.divider
}
Layout.preferredHeight: 26
Layout.preferredWidth: pairText.implicitWidth + 20
StepMarker {
Layout.fillWidth: true
colors: theme.colors
stepNumber: 2
label: form.missingPool
? qsTr("Set price and deposit")
: qsTr("Enter deposit amounts")
active: form.hasPair
}
}
}
Text {
id: pairText
RowLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignTop
spacing: root.wideLayout ? 40 : 0
anchors.centerIn: parent
color: "#F2D8C7"
font.bold: true
font.pixelSize: 12
text: qsTr("%1 / %2").arg(poolState.tokenA).arg(poolState.tokenB)
Rectangle {
id: positionStepRail
objectName: "positionStepRail"
Layout.preferredWidth: root.stepRailWidth
Layout.alignment: Qt.AlignTop
implicitHeight: verticalSteps.implicitHeight + 40
visible: root.wideLayout
radius: 16
color: theme.colors.cardBg
border.color: theme.colors.border
border.width: 1
ColumnLayout {
id: verticalSteps
anchors.fill: parent
anchors.margins: 20
spacing: 0
StepMarker {
Layout.fillWidth: true
colors: theme.colors
stepNumber: 1
label: qsTr("Select token pair and fees")
active: !form.hasPair
complete: form.hasPair
}
Rectangle {
Layout.leftMargin: 17
Layout.preferredWidth: 1
Layout.preferredHeight: 28
color: form.hasPair ? theme.colors.ctaBg : theme.colors.divider
}
StepMarker {
Layout.fillWidth: true
colors: theme.colors
stepNumber: 2
label: form.missingPool
? qsTr("Set price and deposit amounts")
: qsTr("Enter deposit amounts")
active: form.hasPair
}
}
}
LiquidityActionTabs {
currentIndex: root.activeLiquidityTab
NewPositionForm {
id: form
objectName: "newPositionForm"
Layout.fillWidth: true
Layout.preferredHeight: implicitHeight
Layout.alignment: Qt.AlignTop
theme: theme
headingText: form.hasPair ? qsTr("Deposit tokens") : qsTr("Select pair")
headingDetail: form.hasPair
? qsTr("Specify the token amounts for your liquidity contribution.")
: qsTr("Choose two tokens and a fee tier for this position.")
showRefreshAction: false
newPositionContext: newPositionFlow.newPositionContext
flowState: newPositionFlow.viewState
onTabRequested: function (index) {
root.activeLiquidityTab = index;
}
}
PoolPositionSummary {
poolState: poolState
Layout.fillWidth: true
Layout.preferredHeight: implicitHeight
}
AddLiquidityForm {
id: addLiquidityForm
poolState: poolState
slippageTolerancePercent: root.slippageTolerancePercent
visible: root.activeLiquidityTab === 0
Layout.fillWidth: true
Layout.preferredHeight: visible ? implicitHeight : 0
onSlippageToleranceChangeRequested: function (tolerancePercent) {
root.slippageTolerancePercent = poolState.clampSlippageTolerancePercent(tolerancePercent);
onQuoteRequested: function(immediate, quoteRequest) {
newPositionFlow.scheduleQuote(immediate, quoteRequest)
}
onAddLiquidityRequested: function (snapshot) {
confirmationDialog.openWithSnapshot(snapshot);
}
}
RemoveLiquidityForm {
id: removeLiquidityForm
poolState: poolState
slippageTolerancePercent: root.slippageTolerancePercent
visible: root.activeLiquidityTab === 1
Layout.fillWidth: true
Layout.preferredHeight: visible ? implicitHeight : 0
onSlippageToleranceChangeRequested: function (tolerancePercent) {
root.slippageTolerancePercent = poolState.clampSlippageTolerancePercent(tolerancePercent);
onConfirmationRequested: function(snapshot) {
confirmationDialog.openWithSnapshot(snapshot)
}
onRemoveLiquidityRequested: function (snapshot) {
confirmationDialog.openWithSnapshot(snapshot);
onTokenResolveRequested: function(tokenId) {
newPositionFlow.resolveToken(tokenId)
}
onDraftChanged: newPositionFlow.draftChanged()
onRefreshRequested: newPositionFlow.refreshContext(true)
}
}
}
}
SuccessToast {
id: successToast
Connections {
target: newPositionFlow
width: Math.max(0, Math.min(380, parent.width - 24))
function onTokenResolutionFinished(finalResponse) {
form.finishTokenResolution(finalResponse)
}
anchors {
bottom: parent.bottom
bottomMargin: 14
horizontalCenter: parent.horizontalCenter
}
}
function onTokenResolutionFailed(code) {
form.failTokenResolution(code)
}
function onPoolActivated(quote) {
form.acceptPoolActivation(quote)
}
function onQuoteRefreshRequested(immediate) {
form.requestQuote(immediate)
}
}
@ -171,28 +265,73 @@ Item {
TransactionConfirmationDialog {
id: confirmationDialog
title: snapshot.action === "add"
? qsTr("Confirm add liquidity")
: qsTr("Confirm remove liquidity")
title: qsTr("Confirm new position")
confirmText: qsTr("Submit")
busy: newPositionFlow.submitting
summary: liquidityConfirmationSummary
onConfirmed: function (snapshot) {
root.confirmLiquidityAction(snapshot);
onConfirmed: function(snapshot) {
newPositionFlow.confirm(snapshot)
}
}
function confirmLiquidityAction(snapshot) {
if (snapshot.action === "add") {
poolState.applyAddLiquidity(snapshot.actualA, snapshot.actualB, snapshot.deltaLp);
addLiquidityForm.resetForm();
successToast.show(qsTr("Liquidity added"), qsTr("Position updated"));
return;
component StepMarker: RowLayout {
id: marker
required property var colors
required property int stepNumber
required property string label
property bool active: false
property bool complete: false
spacing: 12
Rectangle {
Layout.preferredWidth: 36
Layout.preferredHeight: 36
radius: 18
color: marker.active
? marker.colors.ctaBg
: marker.complete ? marker.colors.selection : marker.colors.inputBg
border.color: marker.active || marker.complete
? marker.colors.ctaBg : marker.colors.borderStrong
border.width: 1
Accessible.ignored: true
Text {
anchors.centerIn: parent
text: marker.stepNumber
color: marker.active
? marker.colors.background
: marker.complete ? marker.colors.ctaBg : marker.colors.textPlaceholder
font.pixelSize: 13
font.weight: Font.DemiBold
}
}
if (snapshot.action === "remove") {
poolState.applyRemoveLiquidity(snapshot.withdrawA, snapshot.withdrawB, snapshot.burnAmount);
removeLiquidityForm.resetForm();
successToast.show(qsTr("Liquidity removed"), qsTr("Position updated"));
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
Layout.fillWidth: true
text: qsTr("Step %1").arg(marker.stepNumber)
color: marker.active || marker.complete
? marker.colors.textSecondary : marker.colors.textPlaceholder
font.pixelSize: 11
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: marker.label
color: marker.active || marker.complete
? marker.colors.textPrimary : marker.colors.textSecondary
font.pixelSize: 13
font.weight: marker.active ? Font.DemiBold : Font.Normal
wrapMode: Text.Wrap
}
}
}
}

View File

@ -1,205 +0,0 @@
import QtQuick 2.15
QtObject {
id: root
property string tokenA: "USDC"
property string tokenB: "ETH"
property string feeTier: "0.30%"
property real userLpBalance: 1118033
property real reserveA: 1000000
property real reserveB: 500
property real totalLpSupply: 22360679
property real walletBalanceA: 60000
property real walletBalanceB: 20
readonly property real minimumLiquidity: 1000
readonly property real poolShare: totalLpSupply > 0 ? userLpBalance / totalLpSupply : 0
readonly property real userOwnedA: reserveA * poolShare
readonly property real userOwnedB: reserveB * poolShare
readonly property real tokenAPerTokenB: reserveB > 0 ? Math.floor(reserveA / reserveB) : 0
function applyAddLiquidity(actualA, actualB, mintedLp) {
const safeA = Math.max(0, Number(actualA) || 0);
const safeB = Math.max(0, Number(actualB) || 0);
const safeLp = Math.max(0, Number(mintedLp) || 0);
reserveA += safeA;
reserveB += safeB;
totalLpSupply += safeLp;
userLpBalance += safeLp;
}
function applyRemoveLiquidity(withdrawA, withdrawB, burnedLp) {
const safeA = Math.max(0, Number(withdrawA) || 0);
const safeB = Math.max(0, Number(withdrawB) || 0);
const safeLp = Math.max(0, Number(burnedLp) || 0);
reserveA = Math.max(0, reserveA - safeA);
reserveB = Math.max(0, reserveB - safeB);
totalLpSupply = Math.max(0, totalLpSupply - safeLp);
userLpBalance = Math.max(0, userLpBalance - safeLp);
}
function resetDummyState() {
tokenA = "USDC";
tokenB = "ETH";
feeTier = "0.30%";
userLpBalance = 1118033;
reserveA = 1000000;
reserveB = 500;
totalLpSupply = 22360679;
walletBalanceA = 60000;
walletBalanceB = 20;
}
function parseAmount(value) {
return Math.max(0, Number(value) || 0);
}
function floorAmount(value) {
return Math.floor(parseAmount(value));
}
function amountBForA(amountA) {
if (reserveA <= 0) {
return 0;
}
return reserveB * parseAmount(amountA) / reserveA;
}
function amountAForB(amountB) {
if (reserveB <= 0) {
return 0;
}
return reserveA * parseAmount(amountB) / reserveB;
}
function addLiquidityPreview(maxA, maxB) {
const safeMaxA = parseAmount(maxA);
const safeMaxB = parseAmount(maxB);
const idealA = reserveB > 0 ? reserveA * safeMaxB / reserveB : 0;
const idealB = reserveA > 0 ? reserveB * safeMaxA / reserveA : 0;
const actualA = Math.min(idealA, safeMaxA);
const actualB = Math.min(idealB, safeMaxB);
const lpFromA = reserveA > 0 ? Math.floor(totalLpSupply * actualA / reserveA) : 0;
const lpFromB = reserveB > 0 ? Math.floor(totalLpSupply * actualB / reserveB) : 0;
return {
"actualA": actualA,
"actualB": actualB,
"deltaLp": Math.min(lpFromA, lpFromB),
"idealA": idealA,
"idealB": idealB
};
}
function maxAddLiquidityForBalances() {
return addLiquidityPreview(walletBalanceA, walletBalanceB);
}
function clampBurnAmount(value) {
return Math.min(floorAmount(value), Math.max(0, floorAmount(userLpBalance)));
}
function clampSlippageTolerancePercent(value) {
return Math.max(0.01, Math.min(50, Number(value) || 0));
}
function minReceivedAmount(previewAmount, slippageTolerancePercent) {
const safeAmount = floorAmount(previewAmount);
const safeSlippage = clampSlippageTolerancePercent(slippageTolerancePercent);
return Math.floor(safeAmount * (1 - safeSlippage / 100));
}
function burnAmountForPercent(percent) {
const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
if (safePercent === 100) {
return clampBurnAmount(userLpBalance);
}
return clampBurnAmount(Math.floor(userLpBalance * safePercent / 100));
}
function removeLiquidityPreview(burnedLp) {
const safeBurnedLp = totalLpSupply > 0 ? Math.min(clampBurnAmount(burnedLp), floorAmount(totalLpSupply)) : 0;
const withdrawA = totalLpSupply > 0 ? Math.floor(reserveA * safeBurnedLp / totalLpSupply) : 0;
const withdrawB = totalLpSupply > 0 ? Math.floor(reserveB * safeBurnedLp / totalLpSupply) : 0;
const newTotalLpSupply = Math.max(0, floorAmount(totalLpSupply) - safeBurnedLp);
const newUserLpBalance = Math.max(0, floorAmount(userLpBalance) - safeBurnedLp);
return {
"burnedLp": safeBurnedLp,
"newReserveA": Math.max(0, reserveA - withdrawA),
"newReserveB": Math.max(0, reserveB - withdrawB),
"newTotalLpSupply": newTotalLpSupply,
"newUserLpBalance": newUserLpBalance,
"newUserShare": newTotalLpSupply > 0 ? newUserLpBalance / newTotalLpSupply : 0,
"withdrawA": withdrawA,
"withdrawB": withdrawB
};
}
function formatInteger(value) {
const rounded = Math.round(Number(value) || 0);
return rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function formatDecimal(value) {
const amount = Number(value) || 0;
if (Math.abs(amount - Math.round(amount)) < 0.000001) {
return formatInteger(amount);
}
return amount.toFixed(6).replace(/0+$/, "").replace(/[.]$/, "");
}
function formatCompactDecimal(value) {
const amount = Number(value) || 0;
if (Math.abs(amount) >= 1000 || Math.abs(amount - Math.round(amount)) < 0.000001) {
return formatInteger(amount);
}
if (Math.abs(amount) >= 1) {
return amount.toFixed(2).replace(/0+$/, "").replace(/[.]$/, "");
}
return amount.toFixed(6).replace(/0+$/, "").replace(/[.]$/, "");
}
function formatInputAmount(value) {
return formatDecimal(value);
}
function formatTokenAmount(value, token) {
return formatDecimal(value) + " " + token;
}
function formatCompactTokenAmount(value, token) {
return formatCompactDecimal(value) + " " + token;
}
function formatLpAmount(value) {
return formatInteger(value) + " LP";
}
function formatPoolShare(value) {
return "\u2248 " + (Math.max(0, Number(value) || 0) * 100).toFixed(2) + "%";
}
function formatPercent(value) {
const amount = Math.max(0, Number(value) || 0);
if (Math.abs(amount - Math.round(amount)) < 0.000001) {
return Math.round(amount).toString() + "%";
}
return amount.toFixed(2).replace(/0+$/, "").replace(/[.]$/, "") + "%";
}
}

View File

@ -0,0 +1,375 @@
import QtQml
QtObject {
id: root
property var backend: null
property var runtime: null
property bool active: false
readonly property bool walletStateReady: root.backend !== null
&& root.backend.walletStateReady === true
readonly property var newPositionContext: root.walletStateReady
&& root.backend.newPositionContext
? root.backend.newPositionContext
: root.loadingContext()
readonly property var viewState: ({
"quote": root.newPositionQuote,
"contextLoading": root.contextLoading || !root.walletStateReady
|| root.newPositionContext.status === "loading",
"quoteLoading": root.quoteLoading,
"quoteStale": root.quoteStale,
"submitting": root.submitting,
"poolCreationPending": root.selectedPoolCreationPending(),
"transactionId": root.transactionId,
"errorCode": root.flowErrorCode || root.contextErrorCode
|| root.quoteErrorCode
})
property var newPositionQuote: ({})
property var resolvedTokenIds: []
property int contextSerial: 0
property int quoteSerial: 0
property bool contextLoading: false
property bool quoteLoading: false
property bool quoteStale: true
property bool submitting: false
property string transactionId: ""
property string flowErrorCode: ""
property string contextErrorCode: ""
property string quoteErrorCode: ""
property var pendingQuoteRequest: ({ "ok": false, "request": ({}) })
property var pendingPoolProbes: []
property bool poolProbeInFlight: false
signal tokenResolutionFinished(bool finalResponse)
signal tokenResolutionFailed(string code)
signal poolActivated(var quote)
signal quoteRefreshRequested(bool immediate)
signal submitSucceeded
signal submitFailed
objectName: "newPositionFlow"
property Timer quoteDebounce: Timer {
interval: 250
repeat: false
onTriggered: root.requestQuoteNow(root.quoteSerial)
}
property Timer poolPoller: Timer {
interval: 5000
repeat: true
running: root.pendingPoolProbes.length > 0
onTriggered: root.pollPendingPool()
}
onNewPositionContextChanged: root.invalidateQuote()
onWalletStateReadyChanged: {
++root.contextSerial
if (!root.walletStateReady)
root.contextLoading = false
root.invalidateQuote()
}
onActiveChanged: {
if (!root.active)
return
Qt.callLater(function() {
if (root.active && root.walletStateReady)
root.quoteRefreshRequested(true)
})
}
function contextHints(refreshWalletAccounts) {
const request = root.pendingQuoteRequest.request || {}
const recent = []
if (request.tokenAId)
recent.push(request.tokenAId)
if (request.tokenBId && request.tokenBId !== request.tokenAId)
recent.push(request.tokenBId)
return {
"recentTokenIds": recent,
"resolvedTokenIds": root.resolvedTokenIds,
"refreshWalletAccounts": refreshWalletAccounts === true
}
}
function refreshContext(refreshWalletAccounts, completed) {
const serial = ++root.contextSerial
root.contextLoading = true
if (!root.walletStateReady || root.runtime === null) {
root.contextLoading = false
return
}
root.runtime.watch(root.backend.refreshNewPositionContext(
root.contextHints(refreshWalletAccounts)),
function() {
root.finishContextRefresh(serial, completed)
},
function(error) {
root.failContextRefresh(serial)
})
}
function finishContextRefresh(serial, completed) {
if (serial !== root.contextSerial)
return
root.contextLoading = false
root.contextErrorCode = ""
Qt.callLater(function() {
if (serial !== root.contextSerial)
return
root.tokenResolutionFinished(true)
if (completed)
completed()
})
}
function failContextRefresh(serial) {
if (serial !== root.contextSerial)
return
root.contextLoading = false
root.contextErrorCode = "backend_error"
root.tokenResolutionFailed("backend_error")
}
function resolveToken(tokenId) {
const value = String(tokenId || "").trim()
if (value.length === 0)
return
if (root.resolvedTokenIds.indexOf(value) < 0) {
const next = root.resolvedTokenIds.slice(0)
next.push(value)
root.resolvedTokenIds = next
}
root.refreshContext(false)
}
function scheduleQuote(immediate, quoteRequest) {
++root.quoteSerial
root.pendingQuoteRequest = quoteRequest
root.quoteStale = true
root.quoteLoading = root.walletStateReady && root.active
root.quoteDebounce.stop()
if (!root.walletStateReady || !root.active)
return
if (immediate)
root.requestQuoteNow(root.quoteSerial)
else
root.quoteDebounce.restart()
}
function requestQuoteNow(serial) {
if (serial !== root.quoteSerial)
return
const built = root.pendingQuoteRequest
if (!built.ok) {
root.quoteLoading = false
return
}
if (!root.walletStateReady || !root.active || root.runtime === null) {
root.quoteLoading = false
return
}
root.runtime.watch(root.backend.quoteNewPosition(built.request),
function(quote) {
if (serial !== root.quoteSerial)
return
root.quoteLoading = false
root.quoteStale = false
root.quoteErrorCode = ""
if (!quote || quote.schema !== "new-position.v1")
root.newPositionQuote = root.quoteError("unsupported_schema")
else
root.newPositionQuote = quote
},
function(error) {
if (serial !== root.quoteSerial)
return
root.quoteLoading = false
root.quoteStale = true
root.quoteErrorCode = "backend_error"
})
}
function confirm(snapshot) {
if (root.submitting)
return
root.submitting = true
root.flowErrorCode = ""
if (!root.backend || root.runtime === null) {
root.finishSubmitFailure(root.quoteError("wallet_unavailable"))
return
}
root.runtime.watch(root.backend.submitNewPosition(snapshot.request, snapshot.quoteHash),
function(result) {
if (result && result.schema === "new-position.v1"
&& result.status === "submitted"
&& /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(
String(result.transactionId || ""))) {
if (snapshot.request.initialPriceRealRaw !== undefined)
root.watchPoolCreation(snapshot.poolProbeRequest, result.deadlineMs)
root.submitting = false
root.transactionId = result.transactionId
root.flowErrorCode = ""
root.contextErrorCode = ""
root.quoteErrorCode = ""
root.invalidateQuote()
root.submitSucceeded()
return
}
root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed"))
},
function(error) {
root.finishSubmitFailure(root.quoteError("wallet_submission_failed"))
})
}
function finishSubmitFailure(result) {
root.submitting = false
const hasFreshQuote = result && result.quote
&& result.quote.schema === "new-position.v1"
if (hasFreshQuote) {
root.newPositionQuote = result.quote
root.quoteLoading = false
root.quoteStale = false
}
const code = result && result.code ? result.code : "wallet_submission_failed"
root.flowErrorCode = code
root.submitFailed()
if (hasFreshQuote)
return
root.scheduleQuote(true, root.pendingQuoteRequest)
}
function watchPoolCreation(request, deadlineMs) {
const key = root.pairKey(request)
let deadline = Number(deadlineMs)
if (!isFinite(deadline) || deadline <= 0)
deadline = 0
const pending = root.pendingPoolProbes.filter(function(item) {
return item.key !== key
})
pending.push({
"key": key,
"request": request,
"deadlineMs": deadline
})
root.pendingPoolProbes = pending
Qt.callLater(root.pollPendingPool)
}
function pollPendingPool() {
if (root.poolProbeInFlight || root.pendingPoolProbes.length === 0
|| !root.walletStateReady || root.runtime === null) {
return
}
const pending = root.pendingPoolProbes[0]
root.poolProbeInFlight = true
root.runtime.watch(root.backend.quoteNewPosition(pending.request),
function(quote) {
root.finishPoolProbe(pending, quote)
},
function(error) {
root.finishPoolProbe(pending, null)
})
}
function finishPoolProbe(pending, quote) {
root.poolProbeInFlight = false
if (quote && quote.schema === "new-position.v1"
&& quote.poolStatus === "active_pool") {
root.removePendingPool(pending.key)
if (root.matchesSelectedPair(pending.request)) {
root.poolActivated(quote)
root.invalidateQuote()
root.refreshContext(true)
}
return
}
if (pending.deadlineMs > 0 && Date.now() >= pending.deadlineMs) {
root.removePendingPool(pending.key)
return
}
root.rotatePendingPool()
}
function pairKey(request) {
return String(request.tokenAId || "") + ":" + String(request.tokenBId || "")
}
function matchesSelectedPair(request) {
return root.pairKey(root.pendingQuoteRequest.request || {}) === root.pairKey(request)
}
function selectedPoolCreationPending() {
const request = root.pendingQuoteRequest.request || {}
if (!request.tokenAId || !request.tokenBId)
return false
const selected = root.pairKey(request)
return root.pendingPoolProbes.some(function(item) {
return item.key === selected
})
}
function removePendingPool(key) {
root.pendingPoolProbes = root.pendingPoolProbes.filter(function(item) {
return item.key !== key
})
}
function rotatePendingPool() {
if (root.pendingPoolProbes.length < 2)
return
const pending = root.pendingPoolProbes.slice(1)
pending.push(root.pendingPoolProbes[0])
root.pendingPoolProbes = pending
}
function draftChanged() {
root.invalidateQuote()
root.transactionId = ""
root.flowErrorCode = ""
root.contextErrorCode = ""
root.quoteErrorCode = ""
}
function invalidateQuote() {
++root.quoteSerial
root.quoteDebounce.stop()
root.quoteLoading = false
root.quoteStale = true
}
function loadingContext() {
return {
"schema": "new-position.v1",
"status": "loading",
"tokens": [],
"feeTiers": []
}
}
function quoteError(code) {
return {
"schema": "new-position.v1",
"status": "error",
"canSubmit": false,
"code": code,
"poolStatus": "unavailable_pool",
"errors": [{
"code": code,
"blockingFields": [],
"details": ({})
}],
"warnings": [],
"accountPreview": []
}
}
}

View File

@ -0,0 +1,141 @@
#include "ActiveNetwork.h"
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
namespace {
const char NETWORK_ENV[] = "AMM_UI_NETWORK";
const char DEVNET_FILE_ENV[] = "AMM_UI_DEVNET_FILE";
bool isLowerHex(const QString& value, int size)
{
if (value.size() != size)
return false;
for (const QChar character : value) {
const bool isAsciiDigit = character >= QLatin1Char('0')
&& character <= QLatin1Char('9');
if (!isAsciiDigit
&& (character < QLatin1Char('a') || character > QLatin1Char('f'))) {
return false;
}
}
return true;
}
}
bool ActiveNetwork::load()
{
m_network = {};
m_network.status = QStringLiteral("config_missing");
m_expectedIdentity.clear();
const QByteArray selected = qgetenv(NETWORK_ENV);
m_network.id = selected.isEmpty()
? QStringLiteral("testnet")
: QString::fromLocal8Bit(selected).trimmed();
QJsonObject entry;
if (isDevnet()) {
const QString path = QString::fromLocal8Bit(qgetenv(DEVNET_FILE_ENV));
QFile file(path);
if (path.isEmpty() || !file.open(QIODevice::ReadOnly))
return false;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
if (!document.isObject())
return false;
entry = document.object();
m_expectedIdentity = entry.value(QStringLiteral("channelId")).toString();
} else {
QFile file(QStringLiteral(":/amm/config/networks.json"));
if (!file.open(QIODevice::ReadOnly))
return false;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
if (!document.isObject())
return false;
const QJsonObject networks = document.object();
entry = networks.value(m_network.id).toObject();
m_expectedIdentity = entry.value(QStringLiteral("checkpointHash")).toString();
}
m_network.ammProgramId = entry.value(QStringLiteral("ammProgramId")).toString();
if (!isValidIdentity(m_expectedIdentity)
|| !isLowerHex(m_network.ammProgramId, 64)) {
return false;
}
for (const QJsonValue& value : entry.value(QStringLiteral("tokenDefinitionIds")).toArray()) {
const QString id = value.toString();
if (!isLowerHex(id, 64)) {
m_network.tokenIds.clear();
return false;
}
m_network.tokenIds.append(id);
}
m_network.status = QStringLiteral("network_unknown");
return true;
}
bool ActiveNetwork::isConfigured() const
{
return m_network.status != QStringLiteral("config_missing");
}
bool ActiveNetwork::isDevnet() const
{
return m_network.id == QStringLiteral("devnet");
}
bool ActiveNetwork::needsIdentityProbe() const
{
return m_network.status == QStringLiteral("loading")
|| m_network.status == QStringLiteral("network_unknown");
}
void ActiveNetwork::sequencerChanged(bool available)
{
if (isConfigured())
clearIdentity(available ? QStringLiteral("loading") : QStringLiteral("network_unknown"));
}
void ActiveNetwork::reachabilityChanged(bool reachable, bool wasReachable)
{
if (!isConfigured())
return;
if (!reachable)
clearIdentity(QStringLiteral("network_unknown"));
else if (!wasReachable)
clearIdentity(QStringLiteral("loading"));
}
void ActiveNetwork::beginIdentityProbe()
{
if (isConfigured())
clearIdentity(QStringLiteral("loading"));
}
void ActiveNetwork::finishIdentityProbe(const QString& identity)
{
if (identity.isEmpty()) {
clearIdentity(QStringLiteral("network_unknown"));
} else if (identity != m_expectedIdentity) {
clearIdentity(QStringLiteral("network_mismatch"));
} else {
m_network.status = QStringLiteral("ready");
m_network.fingerprint = (isDevnet() ? QStringLiteral("channel:")
: QStringLiteral("block10:"))
+ identity;
}
}
bool ActiveNetwork::isValidIdentity(const QString& value)
{
return isLowerHex(value, 64);
}
void ActiveNetwork::clearIdentity(const QString& status)
{
m_network.status = status;
m_network.fingerprint.clear();
}

View File

@ -0,0 +1,36 @@
#pragma once
#include <QString>
#include <QStringList>
struct ActiveNetworkSnapshot {
QString id;
QString status;
QString fingerprint;
QString ammProgramId;
QStringList tokenIds;
};
class ActiveNetwork final {
public:
bool load();
const QString& status() const { return m_network.status; }
bool isConfigured() const;
bool isDevnet() const;
bool needsIdentityProbe() const;
ActiveNetworkSnapshot snapshot() const { return m_network; }
void sequencerChanged(bool available);
void reachabilityChanged(bool reachable, bool wasReachable);
void beginIdentityProbe();
void finishIdentityProbe(const QString& identity);
static bool isValidIdentity(const QString& value);
private:
void clearIdentity(const QString& status);
ActiveNetworkSnapshot m_network;
QString m_expectedIdentity;
};

View File

@ -0,0 +1,71 @@
#include "AmmClient.h"
#include <QDebug>
#include <QJsonDocument>
#include <QJsonParseError>
#include "amm_client.h"
namespace {
using Operation = char* (*)(const char*);
AmmClientResult call(Operation operation, const QJsonObject& request)
{
const QByteArray payload = QJsonDocument(request).toJson(QJsonDocument::Compact);
char* raw = operation(payload.constData());
if (!raw) {
qWarning() << "AmmClient: bundled client returned a null response";
return {};
}
const QByteArray response(raw);
amm_free(raw);
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(response, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject()) {
qWarning() << "AmmClient: bundled client returned invalid JSON";
return {};
}
const QJsonObject envelope = document.object();
if (!envelope.value(QStringLiteral("ok")).toBool()) {
qWarning() << "AmmClient: bundled client failure:"
<< envelope.value(QStringLiteral("error")).toString();
return {};
}
if (!envelope.value(QStringLiteral("value")).isObject()) {
qWarning() << "AmmClient: bundled client value is not an object";
return {};
}
return { true, envelope.value(QStringLiteral("value")).toObject() };
}
}
AmmClientResult BundledAmmClient::configId(const QJsonObject& request) const
{
return call(amm_config_id, request);
}
AmmClientResult BundledAmmClient::tokenIds(const QJsonObject& request) const
{
return call(amm_token_ids, request);
}
AmmClientResult BundledAmmClient::pairIds(const QJsonObject& request) const
{
return call(amm_pair_ids, request);
}
AmmClientResult BundledAmmClient::context(const QJsonObject& request) const
{
return call(amm_context, request);
}
AmmClientResult BundledAmmClient::quote(const QJsonObject& request) const
{
return call(amm_quote, request);
}
AmmClientResult BundledAmmClient::plan(const QJsonObject& request) const
{
return call(amm_plan, request);
}

30
apps/amm/src/AmmClient.h Normal file
View File

@ -0,0 +1,30 @@
#pragma once
#include <QJsonObject>
struct AmmClientResult {
bool ok = false;
QJsonObject value;
};
class AmmClient {
public:
virtual ~AmmClient() = default;
virtual AmmClientResult configId(const QJsonObject& request) const = 0;
virtual AmmClientResult tokenIds(const QJsonObject& request) const = 0;
virtual AmmClientResult pairIds(const QJsonObject& request) const = 0;
virtual AmmClientResult context(const QJsonObject& request) const = 0;
virtual AmmClientResult quote(const QJsonObject& request) const = 0;
virtual AmmClientResult plan(const QJsonObject& request) const = 0;
};
class BundledAmmClient final : public AmmClient {
public:
AmmClientResult configId(const QJsonObject& request) const override;
AmmClientResult tokenIds(const QJsonObject& request) const override;
AmmClientResult pairIds(const QJsonObject& request) const override;
AmmClientResult context(const QJsonObject& request) const override;
AmmClientResult quote(const QJsonObject& request) const override;
AmmClientResult plan(const QJsonObject& request) const override;
};

View File

@ -13,6 +13,7 @@
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
@ -20,7 +21,9 @@
#include <QTimer>
#include <QUrl>
#include "AmmClient.h"
#include "LogosWalletProvider.h"
#include "NewPositionRuntime.h"
#include "WalletController.h"
#include "logos_api.h"
#include "logos_sdk.h"
@ -141,18 +144,70 @@ namespace {
}
}
namespace {
const int CHECKPOINT_BLOCK_ID = 10;
const int BLOCK_HASH_OFFSET = 40;
const int BLOCK_HASH_SIZE = 32;
QByteArray jsonRpcBody(const QString& method, const QJsonArray& params)
{
return QJsonDocument(QJsonObject {
{ QStringLiteral("jsonrpc"), QStringLiteral("2.0") },
{ QStringLiteral("id"), 1 },
{ QStringLiteral("method"), method },
{ QStringLiteral("params"), params },
}).toJson(QJsonDocument::Compact);
}
QString blockHashFromResponse(const QByteArray& payload)
{
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
return {};
const QByteArray block =
QByteArray::fromBase64(document.object().value(QStringLiteral("result")).toString().toLatin1());
if (block.size() < BLOCK_HASH_OFFSET + BLOCK_HASH_SIZE)
return {};
return QString::fromLatin1(block.mid(BLOCK_HASH_OFFSET, BLOCK_HASH_SIZE).toHex());
}
QString channelIdFromResponse(const QByteArray& payload)
{
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
return {};
const QString channel = document.object().value(QStringLiteral("result")).toString();
return ActiveNetwork::isValidIdentity(channel) ? channel : QString();
}
}
AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent)
: AmmUiBackendSimpleSource(parent),
m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)),
m_logos(std::make_unique<LogosModules>(m_logosAPI)),
m_wallet(std::make_unique<LogosWalletProvider>(m_logosAPI)),
m_walletController(std::make_unique<WalletController>(
*m_wallet, QStringLiteral("AmmUI")))
*m_wallet, QStringLiteral("AmmUI"))),
m_ammClient(std::make_unique<BundledAmmClient>()),
m_newPosition(std::make_unique<NewPositionRuntime>(m_wallet.get(), m_ammClient.get())),
m_net(new QNetworkAccessManager(this))
{
setWalletStateReady(false);
m_network.load();
setNewPositionContext(m_newPosition->context(
QVariantMap(), m_network.snapshot(), false, false));
connect(m_walletController.get(), &WalletController::stateChanged,
this, &AmmUiBackend::syncWalletState);
syncWalletState();
m_walletController->start();
QTimer::singleShot(0, this, [this]() {
setWalletStateReady(true);
syncWalletState();
});
}
AmmUiBackend::~AmmUiBackend() = default;
@ -162,6 +217,42 @@ WalletAccountModel* AmmUiBackend::accountModel() const
return m_walletController->accountModel();
}
QString AmmUiBackend::createNewDefault(QString password)
{
setWalletStateReady(false);
const QString mnemonic = m_walletController->createDefaultWallet(password);
setWalletStateReady(true);
syncWalletState();
return mnemonic;
}
QString AmmUiBackend::createNew(QString configPath, QString storagePath, QString password)
{
setWalletStateReady(false);
const QString mnemonic =
m_walletController->createWallet(configPath, storagePath, password);
setWalletStateReady(true);
syncWalletState();
return mnemonic;
}
bool AmmUiBackend::openExisting()
{
setWalletStateReady(false);
const bool opened = m_walletController->open();
setWalletStateReady(true);
syncWalletState();
return opened;
}
void AmmUiBackend::disconnectWallet()
{
m_walletController->disconnect();
setWalletStateReady(true);
m_newPosition->clearWalletAccounts();
refreshNewPositionContext(QVariantMap());
}
QString AmmUiBackend::createAccountPublic()
{
return m_walletController->createAccount(true);
@ -187,31 +278,41 @@ QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic)
return m_walletController->balance(accountIdHex, isPublic);
}
QString AmmUiBackend::createNewDefault(QString password)
void AmmUiBackend::refreshNewPositionContext(QVariantMap request)
{
return m_walletController->createDefaultWallet(password);
const bool refreshWalletAccounts =
request.take(QStringLiteral("refreshWalletAccounts")).toBool();
if (request.contains(QStringLiteral("recentTokenIds"))
|| request.contains(QStringLiteral("resolvedTokenIds"))) {
m_newPositionHints = request;
}
else {
request = m_newPositionHints;
}
if (m_network.status() == QStringLiteral("network_unknown"))
probeNetworkIdentity();
setNewPositionContext(m_newPosition->context(
request, m_network.snapshot(), isWalletOpen(), refreshWalletAccounts));
}
QString AmmUiBackend::createNew(QString configPath,
QString storagePath,
QString password)
QVariantMap AmmUiBackend::quoteNewPosition(QVariantMap request)
{
return m_walletController->createWallet(configPath, storagePath, password);
return m_newPosition->quote(request, m_network.snapshot(), isWalletOpen());
}
bool AmmUiBackend::openExisting()
QVariantMap AmmUiBackend::submitNewPosition(QVariantMap request, QString quoteHash)
{
return m_walletController->open();
}
void AmmUiBackend::disconnectWallet()
{
m_walletController->disconnect();
return m_newPosition->submit(
request, quoteHash, m_network.snapshot(), isWalletOpen());
}
void AmmUiBackend::syncWalletState()
{
const WalletUiState& state = m_walletController->state();
const bool walletWasOpen = isWalletOpen();
const bool wasReachable = sequencerReachable();
const QString previousAddress = sequencerAddr();
setIsWalletOpen(state.isWalletOpen);
setWalletExists(state.walletExists);
setConfigPath(state.configPath);
@ -221,6 +322,70 @@ void AmmUiBackend::syncWalletState()
setCurrentBlockHeight(state.currentBlockHeight);
setSequencerAddr(state.sequencerAddress);
setSequencerReachable(state.sequencerReachable);
const bool addressChanged = previousAddress != state.sequencerAddress;
if (addressChanged)
m_network.sequencerChanged(!state.sequencerAddress.isEmpty());
if (addressChanged || wasReachable != state.sequencerReachable) {
m_network.reachabilityChanged(state.sequencerReachable, wasReachable);
}
if (walletWasOpen && !state.isWalletOpen)
m_newPosition->clearWalletAccounts();
publishNetworkContext();
if (state.sequencerReachable && m_network.needsIdentityProbe())
probeNetworkIdentity();
}
void AmmUiBackend::probeNetworkIdentity()
{
if (m_identityProbeInFlight
|| !m_network.isConfigured()
|| sequencerAddr().isEmpty()) {
return;
}
m_identityProbeInFlight = true;
m_network.beginIdentityProbe();
publishNetworkContext();
const QString address = sequencerAddr();
const bool devnet = m_network.isDevnet();
const QString method = devnet
? QStringLiteral("getChannelId")
: QStringLiteral("getBlock");
const QJsonArray params = devnet
? QJsonArray()
: QJsonArray { CHECKPOINT_BLOCK_ID };
QNetworkRequest request{QUrl(address)};
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
request.setTransferTimeout(4000);
QNetworkReply* reply = m_net->post(request, jsonRpcBody(method, params));
connect(reply, &QNetworkReply::finished, this, [this, reply, address, devnet]() {
m_identityProbeInFlight = false;
if (address != sequencerAddr()) {
reply->deleteLater();
probeNetworkIdentity();
return;
}
if (!sequencerReachable()) {
reply->deleteLater();
return;
}
const QByteArray payload = reply->readAll();
const QString actual = devnet
? channelIdFromResponse(payload)
: blockHashFromResponse(payload);
m_network.finishIdentityProbe(actual);
reply->deleteLater();
publishNetworkContext();
});
}
void AmmUiBackend::publishNetworkContext()
{
setNewPositionContext(m_newPosition->context(
m_newPositionHints, m_network.snapshot(), isWalletOpen(), false));
}
QString AmmUiBackend::normalizeAccountId(const QString& id)

View File

@ -3,12 +3,15 @@
#include <memory>
#include <QObject>
#include <QString>
#include <QVariant>
#include <QVariantList>
#include <QVariantMap>
#include "rep_AmmUiBackend_source.h"
#include "ActiveNetwork.h"
#include "WalletAccountModel.h"
extern "C" {
@ -17,9 +20,15 @@ extern "C" {
class LogosAPI;
struct LogosModules;
class AmmClient;
class LogosWalletProvider;
class NewPositionRuntime;
class QNetworkAccessManager;
class WalletController;
// Source-side implementation of the AmmUiBackend .rep interface.
// Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and
// SLOTs from AmmUiBackend.rep — all the simple ones flow over QtRO.
class AmmUiBackend : public AmmUiBackendSimpleSource {
Q_OBJECT
Q_PROPERTY(WalletAccountModel* accountModel READ accountModel CONSTANT)
@ -31,11 +40,17 @@ public:
WalletAccountModel* accountModel() const;
public slots:
// Overrides of the pure-virtual slots generated from the .rep.
QString createAccountPublic() override;
QString createAccountPrivate() override;
void refreshAccounts() override;
void refreshBalances() override;
QString getBalance(QString accountIdHex, bool isPublic) override;
void refreshNewPositionContext(QVariantMap request) override;
QVariantMap quoteNewPosition(QVariantMap request) override;
QVariantMap submitNewPosition(QVariantMap request, QString quoteHash) override;
// Return the new wallet's BIP39 mnemonic (empty string on failure) so the
// UI can force a one-time seed-phrase backup step.
QString createNewDefault(QString password) override;
QString createNew(QString configPath, QString storagePath, QString password) override;
bool openExisting() override;
@ -52,6 +67,8 @@ public slots:
private:
void syncWalletState();
void probeNetworkIdentity();
void publishNetworkContext();
// Normalizes an account id given as either 64-char lowercase/uppercase hex
// or base58 to lowercase hex. Returns an empty QString if `id` is neither
@ -72,6 +89,14 @@ private:
std::unique_ptr<LogosModules> m_logos;
std::unique_ptr<LogosWalletProvider> m_wallet;
std::unique_ptr<WalletController> m_walletController;
std::unique_ptr<AmmClient> m_ammClient;
std::unique_ptr<NewPositionRuntime> m_newPosition;
QNetworkAccessManager* m_net;
ActiveNetwork m_network;
QVariantMap m_newPositionHints;
bool m_identityProbeInFlight = false;
};
#endif // AMM_UI_BACKEND_H

View File

@ -5,6 +5,9 @@
class AmmUiBackend
{
PROP(bool isWalletOpen READONLY)
// False while startup or reconnect is still resolving wallet state. This
// stays distinct from isWalletOpen because a disconnected wallet is ready.
PROP(bool walletStateReady READONLY)
PROP(bool walletExists READONLY)
PROP(QString configPath READONLY)
PROP(QString storagePath READONLY)
@ -23,8 +26,16 @@ class AmmUiBackend
SLOT(void refreshBalances())
SLOT(QString getBalance(QString accountIdHex, bool isPublic))
// 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(QVariantMap newPositionContext READONLY)
SLOT(void refreshNewPositionContext(QVariantMap request))
SLOT(QVariantMap quoteNewPosition(QVariantMap request))
SLOT(QVariantMap submitNewPosition(QVariantMap request, QString quoteHash))
// Wallet lifecycle. createNewDefault() is the happy path: it creates a
// fresh per-app wallet at walletHome with no path picking. createNew()
// fresh wallet at the canonical walletHome with no path picking. createNew()
// keeps explicit paths for an "advanced" flow. Both return the new wallet's
// BIP39 mnemonic (empty on failure) so the UI can force a seed-phrase backup
// before the wallet is usable — this is the only chance to record it.

View File

@ -0,0 +1,390 @@
#include "NewPositionRuntime.h"
#include <array>
#include <cstddef>
#include <cstdint>
#include <QByteArray>
#include <QDateTime>
#include <QJsonObject>
#include <QScopedValueRollback>
#include <libbase58.h>
#include "AmmClient.h"
#include "WalletProvider.h"
namespace {
const char SCHEMA[] = "new-position.v1";
constexpr qsizetype HASH_BYTES = 32;
constexpr std::size_t BASE58_BUFFER_SIZE = 45;
QString base58TransactionId(const QString& transactionHash)
{
const QByteArray hex = transactionHash.toLatin1();
const QByteArray bytes = QByteArray::fromHex(hex);
if (bytes.size() != HASH_BYTES || bytes.toHex() != hex)
return {};
std::array<char, BASE58_BUFFER_SIZE> encoded {};
std::size_t size = encoded.size();
if (!b58enc(encoded.data(), &size, bytes.constData(),
static_cast<std::size_t>(bytes.size()))) {
return {};
}
return QString::fromLatin1(
encoded.data(), static_cast<qsizetype>(size - 1));
}
QJsonObject issue(const QString& code,
const QJsonArray& blockingFields = {})
{
return {
{ QStringLiteral("code"), code },
{ QStringLiteral("recoverable"), true },
{ QStringLiteral("blockingFields"), blockingFields },
{ QStringLiteral("details"), QJsonObject() },
};
}
QJsonObject publicError(const QString& code,
const QJsonArray& blockingFields = {},
const QJsonObject& details = {})
{
QJsonObject error = issue(code, blockingFields);
error.insert(QStringLiteral("details"), details);
return {
{ QStringLiteral("schema"), QString::fromLatin1(SCHEMA) },
{ QStringLiteral("status"), QStringLiteral("error") },
{ QStringLiteral("canSubmit"), false },
{ QStringLiteral("code"), code },
{ QStringLiteral("errors"), QJsonArray { error } },
{ QStringLiteral("warnings"), QJsonArray() },
{ QStringLiteral("accountPreview"), QJsonArray() },
};
}
QJsonObject contextState(const QString& status,
const ActiveNetworkSnapshot& network,
const QString& code = {})
{
QJsonObject state {
{ QStringLiteral("schema"), QString::fromLatin1(SCHEMA) },
{ QStringLiteral("status"), status },
{ QStringLiteral("networkId"), network.id },
{ QStringLiteral("networkFingerprint"), network.fingerprint },
{ QStringLiteral("tokens"), QJsonArray() },
{ QStringLiteral("feeTiers"), QJsonArray() },
{ QStringLiteral("warnings"), QJsonArray() },
};
if (!code.isEmpty())
state.insert(QStringLiteral("code"), code);
return state;
}
QJsonArray variantStringArray(const QVariant& value)
{
QJsonArray result;
for (const QVariant& item : value.toList())
result.append(item.toString());
return result;
}
QStringList jsonStringList(const QJsonArray& values)
{
QStringList result;
result.reserve(values.size());
for (const QJsonValue& value : values)
result.append(value.toString());
return result;
}
QVector<bool> jsonBoolList(const QJsonArray& values)
{
QVector<bool> result;
result.reserve(values.size());
for (const QJsonValue& value : values)
result.append(value.toBool());
return result;
}
QVector<quint32> jsonUIntList(const QJsonArray& values)
{
QVector<quint32> result;
result.reserve(values.size());
for (const QJsonValue& value : values)
result.append(static_cast<quint32>(value.toInteger()));
return result;
}
QJsonObject accountReadJson(const WalletAccountRead& read)
{
QJsonObject result {
{ QStringLiteral("id"), read.accountId },
{ QStringLiteral("status"), read.status },
};
if (read.ok()) {
result.insert(QStringLiteral("account"), QJsonObject {
{ QStringLiteral("program_owner"), read.programOwner },
{ QStringLiteral("balance"), read.balanceHex },
{ QStringLiteral("nonce"), read.nonceHex },
{ QStringLiteral("data"), read.dataHex },
});
}
return result;
}
QJsonArray accountReadsJson(const QVector<WalletAccountRead>& reads)
{
QJsonArray result;
for (const WalletAccountRead& read : reads)
result.append(accountReadJson(read));
return result;
}
}
NewPositionRuntime::NewPositionRuntime(WalletProvider* wallet, AmmClient* client)
: m_wallet(wallet),
m_client(client)
{
}
void NewPositionRuntime::clearWalletAccounts()
{
m_wallet->clearSnapshot();
}
QJsonArray NewPositionRuntime::walletAccountReads(bool walletOpen, bool refresh) const
{
if (!walletOpen)
return {};
return accountReadsJson(m_wallet->snapshot(refresh).publicAccountReads);
}
QJsonObject NewPositionRuntime::buildQuoteInput(const QVariantMap& request,
const ActiveNetworkSnapshot& network,
bool walletOpen,
bool freshWalletAccounts,
QJsonObject* error) const
{
if (network.status != QStringLiteral("ready")) {
*error = publicError(network.status);
return {};
}
const AmmClientResult configResult = m_client->configId(
QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } });
if (!configResult.ok) {
*error = publicError(QStringLiteral("backend_error"));
return {};
}
const QJsonObject configManifest = configResult.value;
const QJsonObject config = accountReadJson(m_wallet->readPublicAccount(
configManifest.value(QStringLiteral("configId")).toString()));
const QJsonObject requestObject = QJsonObject::fromVariantMap(request);
const AmmClientResult pairResult = m_client->pairIds(
QJsonObject {
{ QStringLiteral("ammProgramId"), network.ammProgramId },
{ QStringLiteral("config"), config },
{ QStringLiteral("tokenAId"), requestObject.value(QStringLiteral("tokenAId")) },
{ QStringLiteral("tokenBId"), requestObject.value(QStringLiteral("tokenBId")) },
});
if (!pairResult.ok) {
*error = publicError(QStringLiteral("backend_error"));
return {};
}
const QJsonObject pairManifest = pairResult.value;
if (pairManifest.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) {
*error = publicError(pairManifest.value(QStringLiteral("code")).toString());
return {};
}
const QJsonArray walletAccounts = walletAccountReads(walletOpen, freshWalletAccounts);
const QJsonObject snapshot {
{ QStringLiteral("config"), config },
{ QStringLiteral("tokenA"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("tokenAId")).toString())) },
{ QStringLiteral("tokenB"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("tokenBId")).toString())) },
{ QStringLiteral("pool"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("poolId")).toString())) },
{ QStringLiteral("vaultA"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("vaultAId")).toString())) },
{ QStringLiteral("vaultB"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("vaultBId")).toString())) },
{ QStringLiteral("lpDefinition"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("lpDefinitionId")).toString())) },
{ QStringLiteral("lpLockHolding"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("lpLockHoldingId")).toString())) },
{ QStringLiteral("currentTick"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("currentTickId")).toString())) },
{ QStringLiteral("clock"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("clockId")).toString())) },
{ QStringLiteral("walletAvailable"), walletOpen },
{ QStringLiteral("walletAccounts"), walletAccounts },
};
return {
{ QStringLiteral("networkId"), network.id },
{ QStringLiteral("networkFingerprint"), network.fingerprint },
{ QStringLiteral("ammProgramId"), network.ammProgramId },
{ QStringLiteral("request"), requestObject },
{ QStringLiteral("snapshot"), snapshot },
};
}
QVariantMap NewPositionRuntime::context(const QVariantMap& request,
const ActiveNetworkSnapshot& network,
bool walletOpen,
bool refreshWalletAccounts)
{
if (network.status != QStringLiteral("ready"))
return contextState(network.status, network).toVariantMap();
const QJsonArray walletAccounts = walletAccountReads(walletOpen, refreshWalletAccounts);
const QJsonObject hints = QJsonObject::fromVariantMap(request);
const AmmClientResult configResult = m_client->configId(
QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } });
if (!configResult.ok)
return contextState(
QStringLiteral("error"), network, QStringLiteral("backend_error")).toVariantMap();
const QJsonObject configManifest = configResult.value;
const QJsonObject config = accountReadJson(m_wallet->readPublicAccount(
configManifest.value(QStringLiteral("configId")).toString()));
QJsonArray configured;
for (const QString& id : network.tokenIds)
configured.append(id);
const QJsonArray recent = variantStringArray(hints.value(QStringLiteral("recentTokenIds")).toVariant());
const QJsonArray resolved = variantStringArray(hints.value(QStringLiteral("resolvedTokenIds")).toVariant());
const AmmClientResult tokenResult = m_client->tokenIds(
QJsonObject {
{ QStringLiteral("ammProgramId"), network.ammProgramId },
{ QStringLiteral("config"), config },
{ QStringLiteral("walletAccounts"), walletAccounts },
{ QStringLiteral("configuredTokenIds"), configured },
{ QStringLiteral("recentTokenIds"), recent },
{ QStringLiteral("resolvedTokenIds"), resolved },
});
const QJsonObject tokenManifest = tokenResult.value;
if (!tokenResult.ok
|| tokenManifest.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) {
const QString code = tokenResult.ok
? tokenManifest.value(QStringLiteral("code")).toString()
: QStringLiteral("backend_error");
return contextState(
QStringLiteral("error"),
network,
code.isEmpty() ? QStringLiteral("backend_error") : code).toVariantMap();
}
QJsonArray definitions;
for (const QJsonValue& id : tokenManifest.value(QStringLiteral("tokenIds")).toArray())
definitions.append(accountReadJson(m_wallet->readPublicAccount(id.toString())));
const AmmClientResult contextResult = m_client->context(
QJsonObject {
{ QStringLiteral("networkId"), network.id },
{ QStringLiteral("networkFingerprint"), network.fingerprint },
{ QStringLiteral("ammProgramId"), network.ammProgramId },
{ QStringLiteral("walletAvailable"), walletOpen },
{ QStringLiteral("config"), config },
{ QStringLiteral("walletAccounts"), walletAccounts },
{ QStringLiteral("tokenDefinitions"), definitions },
{ QStringLiteral("configuredTokenIds"), configured },
{ QStringLiteral("recentTokenIds"), recent },
{ QStringLiteral("resolvedTokenIds"), resolved },
});
return (contextResult.ok
? contextResult.value
: contextState(
QStringLiteral("error"), network, QStringLiteral("backend_error"))).toVariantMap();
}
QVariantMap NewPositionRuntime::quote(const QVariantMap& request,
const ActiveNetworkSnapshot& network,
bool walletOpen)
{
QJsonObject error;
const QJsonObject input = buildQuoteInput(request, network, walletOpen, false, &error);
if (!error.isEmpty())
return error.toVariantMap();
const AmmClientResult result = m_client->quote(input);
return (result.ok ? result.value : publicError(QStringLiteral("backend_error"))).toVariantMap();
}
QVariantMap NewPositionRuntime::submit(const QVariantMap& request,
const QString& quoteHash,
const ActiveNetworkSnapshot& network,
bool walletOpen)
{
if (m_submitInFlight)
return publicError(QStringLiteral("submit_in_progress")).toVariantMap();
if (!walletOpen)
return publicError(QStringLiteral("wallet_unavailable")).toVariantMap();
QScopedValueRollback<bool> submitGuard(m_submitInFlight, true);
QJsonObject error;
const QJsonObject input = buildQuoteInput(request, network, walletOpen, true, &error);
if (!error.isEmpty())
return error.toVariantMap();
const AmmClientResult quoteResult = m_client->quote(input);
if (!quoteResult.ok)
return publicError(QStringLiteral("backend_error")).toVariantMap();
const QJsonObject quote = quoteResult.value;
if (quote.value(QStringLiteral("quoteHash")).toString() != quoteHash) {
QJsonObject result = publicError(QStringLiteral("quote_changed"));
result.insert(QStringLiteral("quote"), quote);
return result.toVariantMap();
}
if (!quote.value(QStringLiteral("canSubmit")).toBool(false)) {
QJsonObject result = publicError(QStringLiteral("quote_not_submittable"));
result.insert(QStringLiteral("quote"), quote);
return result.toVariantMap();
}
QJsonValue freshLp;
if (quote.value(QStringLiteral("requiresFreshLp")).toBool(false)) {
const WalletAccountCreation creation = m_wallet->createAccount(true);
if (!creation.ok() || !creation.publicAccount.ok())
return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap();
freshLp = accountReadJson(creation.publicAccount);
}
QJsonObject planInput = input;
planInput.insert(QStringLiteral("quoteHash"), quoteHash);
planInput.insert(QStringLiteral("nowMs"), QDateTime::currentMSecsSinceEpoch());
if (!freshLp.isUndefined())
planInput.insert(QStringLiteral("freshLp"), freshLp);
const AmmClientResult planResult = m_client->plan(planInput);
if (!planResult.ok)
return publicError(QStringLiteral("backend_error")).toVariantMap();
const QJsonObject plan = planResult.value;
if (plan.value(QStringLiteral("status")).toString() != QStringLiteral("ready")) {
const QString code = plan.value(QStringLiteral("code")).toString();
return publicError(code.isEmpty() ? QStringLiteral("wallet_submission_failed") : code)
.toVariantMap();
}
const QStringList accountIds = jsonStringList(plan.value(QStringLiteral("accountIds")).toArray());
const QVector<bool> signingRequirements = jsonBoolList(plan.value(QStringLiteral("signingRequirements")).toArray());
const QVector<quint32> instruction = jsonUIntList(plan.value(QStringLiteral("instruction")).toArray());
const QString programId = plan.value(QStringLiteral("programId")).toString();
bool deadlineValid = false;
const qulonglong deadline = plan.value(QStringLiteral("deadlineMs")).toString().toULongLong(&deadlineValid);
if (!deadlineValid
|| static_cast<qulonglong>(QDateTime::currentMSecsSinceEpoch()) >= deadline) {
return publicError(QStringLiteral("transaction_deadline_expired")).toVariantMap();
}
const WalletSubmission submission = m_wallet->submitPublicTransaction({
programId,
accountIds,
signingRequirements,
instruction,
});
if (!submission.accepted())
return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap();
const QString transactionId = base58TransactionId(submission.nativeHash);
if (transactionId.isEmpty())
return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap();
return QJsonObject {
{ QStringLiteral("schema"), QString::fromLatin1(SCHEMA) },
{ QStringLiteral("status"), QStringLiteral("submitted") },
{ QStringLiteral("transactionId"), transactionId },
{ QStringLiteral("deadlineMs"), plan.value(QStringLiteral("deadlineMs")) },
}.toVariantMap();
}

View File

@ -0,0 +1,42 @@
#pragma once
#include <QJsonArray>
#include <QJsonObject>
#include <QString>
#include <QVariantMap>
#include "ActiveNetwork.h"
class AmmClient;
class WalletProvider;
class NewPositionRuntime {
public:
NewPositionRuntime(WalletProvider* wallet, AmmClient* client);
void clearWalletAccounts();
QVariantMap context(const QVariantMap& request,
const ActiveNetworkSnapshot& network,
bool walletOpen,
bool refreshWalletAccounts);
QVariantMap quote(const QVariantMap& request,
const ActiveNetworkSnapshot& network,
bool walletOpen);
QVariantMap submit(const QVariantMap& request,
const QString& quoteHash,
const ActiveNetworkSnapshot& network,
bool walletOpen);
private:
QJsonArray walletAccountReads(bool walletOpen, bool refresh) const;
QJsonObject buildQuoteInput(const QVariantMap& request,
const ActiveNetworkSnapshot& network,
bool walletOpen,
bool freshWalletAccounts,
QJsonObject* error) const;
WalletProvider* m_wallet;
AmmClient* m_client;
bool m_submitInFlight = false;
};

View File

@ -0,0 +1,65 @@
#include "ActiveNetwork.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTemporaryFile>
namespace {
bool expect(bool condition, const char* message)
{
if (!condition)
qCritical("%s", message);
return condition;
}
}
int main()
{
const QString identity(64, QLatin1Char('a'));
const QString programId(64, QLatin1Char('b'));
const QString tokenId(64, QLatin1Char('c'));
QTemporaryFile config;
if (!config.open())
return 1;
config.write(QJsonDocument(QJsonObject {
{ QStringLiteral("channelId"), identity },
{ QStringLiteral("ammProgramId"), programId },
{ QStringLiteral("tokenDefinitionIds"), QJsonArray { tokenId } },
}).toJson(QJsonDocument::Compact));
config.flush();
qputenv("AMM_UI_NETWORK", "devnet");
qputenv("AMM_UI_DEVNET_FILE", config.fileName().toLocal8Bit());
ActiveNetwork network;
if (!expect(network.load(), "devnet config should load"))
return 1;
if (!expect(network.snapshot().status == QStringLiteral("network_unknown"),
"loaded network should await identity"))
return 1;
network.sequencerChanged(true);
network.finishIdentityProbe(QString(64, QLatin1Char('d')));
if (!expect(network.status() == QStringLiteral("network_mismatch"),
"wrong identity should block the network"))
return 1;
network.reachabilityChanged(false, true);
network.reachabilityChanged(true, false);
network.finishIdentityProbe(identity);
const ActiveNetworkSnapshot snapshot = network.snapshot();
if (!expect(snapshot.status == QStringLiteral("ready"),
"matching identity should ready the network"))
return 1;
if (!expect(snapshot.fingerprint == QStringLiteral("channel:") + identity,
"devnet fingerprint should use channel identity"))
return 1;
if (!expect(snapshot.ammProgramId == programId
&& snapshot.tokenIds == QStringList { tokenId },
"network snapshot should preserve configured program and tokens"))
return 1;
return 0;
}

View File

@ -0,0 +1,230 @@
#include "AmmClient.h"
#include "NewPositionRuntime.h"
#include "WalletProvider.h"
#include <QDateTime>
namespace {
bool expect(bool condition, const char* message)
{
if (!condition)
qCritical("%s", message);
return condition;
}
class FakeWallet final : public WalletProvider {
public:
WalletSession connect(const WalletPaths&) override
{
return {};
}
WalletCreation createWallet(const WalletPaths&, const QString&) override
{
return {};
}
WalletSnapshot snapshot(bool) override
{
return {};
}
void clearSnapshot() override {}
WalletAccountCreation createAccount(bool isPublic) override
{
++createdAccounts;
WalletAccountCreation creation;
creation.accountId = QStringLiteral("fresh-lp");
if (isPublic)
creation.publicAccount = readPublicAccount(creation.accountId);
return creation;
}
WalletAccountRead readPublicAccount(const QString& accountId) const override
{
WalletAccountRead read;
read.accountId = accountId;
read.status = QStringLiteral("ok");
return read;
}
WalletSubmission submitPublicTransaction(const WalletTransaction& transaction) override
{
++submissions;
submitted = transaction;
return { WalletFailure::None, transactionHash };
}
void disconnect() override {}
QString transactionHash = QStringLiteral(
"000102030405060708090a0b0c0d0e0f"
"101112131415161718191a1b1c1d1e1f");
int createdAccounts = 0;
int submissions = 0;
WalletTransaction submitted;
};
class FakeAmmClient final : public AmmClient {
public:
AmmClientResult configId(const QJsonObject&) const override
{
return success({ { QStringLiteral("configId"), QStringLiteral("config") } });
}
AmmClientResult tokenIds(const QJsonObject&) const override
{
return success({ { QStringLiteral("status"), QStringLiteral("ok") } });
}
AmmClientResult pairIds(const QJsonObject&) const override
{
return success({
{ QStringLiteral("status"), QStringLiteral("ok") },
{ QStringLiteral("tokenAId"), QStringLiteral("token-a") },
{ QStringLiteral("tokenBId"), QStringLiteral("token-b") },
{ QStringLiteral("poolId"), QStringLiteral("pool") },
{ QStringLiteral("vaultAId"), QStringLiteral("vault-a") },
{ QStringLiteral("vaultBId"), QStringLiteral("vault-b") },
{ QStringLiteral("lpDefinitionId"), QStringLiteral("lp") },
{ QStringLiteral("lpLockHoldingId"), QStringLiteral("lp-lock") },
{ QStringLiteral("currentTickId"), QStringLiteral("tick") },
{ QStringLiteral("clockId"), QStringLiteral("clock") },
});
}
AmmClientResult context(const QJsonObject&) const override
{
return success({});
}
AmmClientResult quote(const QJsonObject&) const override
{
return success({
{ QStringLiteral("schema"), QStringLiteral("new-position.v1") },
{ QStringLiteral("status"), QStringLiteral("ok") },
{ QStringLiteral("canSubmit"), true },
{ QStringLiteral("quoteHash"), quoteHash },
{ QStringLiteral("requiresFreshLp"), requiresFreshLp },
});
}
AmmClientResult plan(const QJsonObject& request) const override
{
sawFreshLp = request.contains(QStringLiteral("freshLp"));
return success({
{ QStringLiteral("status"), QStringLiteral("ready") },
{ QStringLiteral("accountIds"), QJsonArray { QStringLiteral("account") } },
{ QStringLiteral("signingRequirements"), QJsonArray { true } },
{ QStringLiteral("instruction"), QJsonArray { 1 } },
{ QStringLiteral("programId"), QStringLiteral("program") },
{ QStringLiteral("deadlineMs"),
QString::number(QDateTime::currentMSecsSinceEpoch() + 60'000) },
});
}
static AmmClientResult success(const QJsonObject& value)
{
return { true, value };
}
QString quoteHash = QStringLiteral("sha256:expected");
bool requiresFreshLp = true;
mutable bool sawFreshLp = false;
};
ActiveNetworkSnapshot readyNetwork()
{
return {
QStringLiteral("testnet"),
QStringLiteral("ready"),
QStringLiteral("block10:identity"),
QStringLiteral("program"),
{},
};
}
}
int main()
{
const QVariantMap request {
{ QStringLiteral("schema"), QStringLiteral("new-position.v1") },
{ QStringLiteral("tokenAId"), QStringLiteral("token-a") },
{ QStringLiteral("tokenBId"), QStringLiteral("token-b") },
{ QStringLiteral("feeBps"), 30 },
};
FakeWallet wallet;
FakeAmmClient client;
NewPositionRuntime runtime(&wallet, &client);
const QVariantMap result = runtime.submit(
request, QStringLiteral("sha256:expected"), readyNetwork(), true);
if (!expect(result.value(QStringLiteral("status")).toString()
== QStringLiteral("submitted"),
"valid plan should submit"))
return 1;
if (!expect(result.value(QStringLiteral("transactionId")).toString()
== QStringLiteral("1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"),
"native hash should become the expected base58 transaction ID"))
return 1;
if (!expect(wallet.createdAccounts == 1 && client.sawFreshLp,
"fresh LP account should enter the plan"))
return 1;
if (!expect(wallet.submissions == 1
&& wallet.submitted.accountIds
== QStringList { QStringLiteral("account") }
&& wallet.submitted.signingRequirements.size() == 1
&& wallet.submitted.signingRequirements.constFirst()
&& wallet.submitted.instruction.size() == 1
&& wallet.submitted.instruction.constFirst() == 1
&& wallet.submitted.programId == QStringLiteral("program"),
"runtime should dispatch the unchanged plan once"))
return 1;
FakeWallet orderedBytesWallet;
orderedBytesWallet.transactionHash = QStringLiteral(
"0102030405060708090a0b0c0d0e0f10"
"1112131415161718191a1b1c1d1e1f20");
FakeAmmClient orderedBytesClient;
orderedBytesClient.requiresFreshLp = false;
NewPositionRuntime orderedBytesRuntime(&orderedBytesWallet, &orderedBytesClient);
const QVariantMap orderedBytes = orderedBytesRuntime.submit(
request, QStringLiteral("sha256:expected"), readyNetwork(), true);
if (!expect(orderedBytes.value(QStringLiteral("transactionId")).toString()
== QStringLiteral("4wBqpZM9xaSheZzJSMawUKKwhdpChKbZ5eu5ky4Vigw"),
"ordered native hash bytes should preserve byte order"))
return 1;
FakeWallet invalidHashWallet;
invalidHashWallet.transactionHash = QStringLiteral("not-a-hash");
FakeAmmClient invalidHashClient;
invalidHashClient.requiresFreshLp = false;
NewPositionRuntime invalidHashRuntime(&invalidHashWallet, &invalidHashClient);
const QVariantMap invalidHash = invalidHashRuntime.submit(
request, QStringLiteral("sha256:expected"), readyNetwork(), true);
if (!expect(invalidHash.value(QStringLiteral("code")).toString()
== QStringLiteral("wallet_submission_failed")
&& !invalidHash.contains(QStringLiteral("transactionId")),
"invalid native hash should fail without a hex fallback"))
return 1;
if (!expect(invalidHashWallet.submissions == 1,
"hash conversion should happen after wallet submission"))
return 1;
FakeWallet staleWallet;
FakeAmmClient staleClient;
staleClient.quoteHash = QStringLiteral("sha256:changed");
NewPositionRuntime staleRuntime(&staleWallet, &staleClient);
const QVariantMap stale = staleRuntime.submit(
request, QStringLiteral("sha256:expected"), readyNetwork(), true);
if (!expect(stale.value(QStringLiteral("code")).toString()
== QStringLiteral("quote_changed"),
"changed quote should stop submission"))
return 1;
if (!expect(staleWallet.createdAccounts == 0 && staleWallet.submissions == 0,
"stale quote should have no wallet side effects"))
return 1;
return 0;
}

View File

@ -0,0 +1,29 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtTest
import "../../qml/components/liquidity" as Liquidity
TestCase {
id: testCase
name: "LiquidityConfirmationSummary"
Component {
id: summaryComponent
Liquidity.LiquidityConfirmationSummary {
width: 480
}
}
function test_protocolActionsUseDisplayLabels() {
var summary = createTemporaryObject(summaryComponent, testCase)
verify(summary)
compare(summary.actionText("NewDefinition"), "Create pool")
compare(summary.actionText("AddLiquidity"), "Add liquidity")
compare(summary.actionText(""), "-")
}
}

View File

@ -0,0 +1,335 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtTest
import "../../qml/pages" as Pages
TestCase {
id: testCase
name: "LiquidityPage"
readonly property string submittedTransactionId:
"1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"
Component {
id: backendComponent
QtObject {
property bool walletStateReady: false
property var submitResult: ({})
property var quoteResult: ({
"schema": "new-position.v1",
"status": "ok",
"poolStatus": "missing_pool"
})
property var newPositionContext: ({
"schema": "new-position.v1",
"status": "ready",
"tokens": [],
"feeTiers": []
})
function submitNewPosition(request, quoteHash) {
return submitResult
}
function quoteNewPosition(request) {
return quoteResult
}
}
}
function test_positionLayoutUsesDesktopRailAndCompactProgress() {
var page = createTemporaryObject(pageComponent, testCase)
verify(page)
page.visible = true
wait(0)
var rail = findChild(page, "positionStepRail")
var compactSteps = findChild(page, "compactPositionSteps")
var form = findChild(page, "newPositionForm")
verify(rail)
verify(compactSteps)
verify(form)
compare(page.wideLayout, true)
verify(rail.width > 0)
verify(form.width > rail.width)
page.width = 600
wait(0)
compare(page.wideLayout, false)
verify(compactSteps.implicitHeight > 0)
verify(form.width > 0)
verify(form.width <= page.width - 32)
}
Component {
id: runtimeComponent
QtObject {
function watch(value, succeeded, failed) {
succeeded(value)
}
}
}
Component {
id: pageComponent
Pages.LiquidityPage {
visible: false
width: 800
height: 600
}
}
function test_contextWaitsForWalletState() {
var backend = createTemporaryObject(backendComponent, testCase)
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend
})
verify(backend)
verify(page)
compare(page.flow.newPositionContext.status, "loading")
backend.walletStateReady = true
tryCompare(page.flow.newPositionContext, "status", "ready")
}
function test_contextRefreshControlsWalletScan() {
var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true
})
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend
})
verify(backend)
verify(page)
compare(page.flow.contextHints(false).refreshWalletAccounts, false)
compare(page.flow.contextHints(true).refreshWalletAccounts, true)
}
function test_staleContextCompletionCannotFinishNewerRefresh() {
var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true
})
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend
})
verify(backend)
verify(page)
page.flow.contextSerial = 2
page.flow.contextLoading = true
page.flow.contextErrorCode = "newer_request_pending"
page.flow.finishContextRefresh(1, null)
page.flow.failContextRefresh(1)
compare(page.flow.contextLoading, true)
compare(page.flow.contextErrorCode, "newer_request_pending")
page.flow.finishContextRefresh(2, null)
compare(page.flow.contextLoading, false)
compare(page.flow.contextErrorCode, "")
}
function test_submitFailureKeepsReturnedFreshQuoteWithoutRequery() {
var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true
})
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend
})
verify(backend)
verify(page)
page.flow.quoteSerial = 7
page.flow.finishSubmitFailure({
"schema": "new-position.v1",
"status": "error",
"code": "quote_not_submittable",
"quote": {
"schema": "new-position.v1",
"status": "ok",
"canSubmit": false,
"quoteHash": "sha256:fresh"
}
})
compare(page.flow.quoteSerial, 7)
compare(page.flow.newPositionQuote.quoteHash, "sha256:fresh")
compare(page.flow.quoteStale, false)
}
function test_base58SubmittedResultEntersSuccessState() {
var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true,
"submitResult": {
"schema": "new-position.v1",
"status": "submitted",
"transactionId": submittedTransactionId,
"deadlineMs": String(Date.now() + 60000)
}
})
var runtime = createTemporaryObject(runtimeComponent, testCase)
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend,
"runtime": runtime
})
verify(backend)
verify(runtime)
verify(page)
page.flow.confirm({
"request": ({}),
"quoteHash": "sha256:expected"
})
compare(page.flow.transactionId, submittedTransactionId)
compare(page.flow.flowErrorCode, "")
compare(page.flow.submitting, false)
}
function test_base58MissingPoolSubmissionStartsPoolWatch() {
var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true,
"submitResult": {
"schema": "new-position.v1",
"status": "submitted",
"transactionId": submittedTransactionId,
"deadlineMs": String(Date.now() + 60000)
}
})
var runtime = createTemporaryObject(runtimeComponent, testCase)
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend,
"runtime": runtime
})
verify(backend)
verify(runtime)
verify(page)
var probe = {
"tokenAId": "22222222222222222222222222222222",
"tokenBId": "33333333333333333333333333333333"
}
page.flow.pendingQuoteRequest = { "ok": true, "request": probe }
page.flow.confirm({
"request": {
"initialPriceRealRaw": "18446744073709551616"
},
"poolProbeRequest": probe,
"quoteHash": "sha256:expected"
})
wait(0)
compare(page.flow.transactionId, submittedTransactionId)
compare(page.flow.pendingPoolProbes.length, 1)
compare(page.flow.selectedPoolCreationPending(), true)
compare(page.flow.poolProbeInFlight, false)
}
function test_nativeHexSubmittedResultDoesNotEnterSuccessState() {
var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true,
"submitResult": {
"schema": "new-position.v1",
"status": "submitted",
"transactionId": "000102030405060708090a0b0c0d0e0f"
+ "101112131415161718191a1b1c1d1e1f"
}
})
var runtime = createTemporaryObject(runtimeComponent, testCase)
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend,
"runtime": runtime
})
verify(backend)
verify(runtime)
verify(page)
page.flow.confirm({
"request": ({}),
"quoteHash": "sha256:expected"
})
compare(page.flow.transactionId, "")
compare(page.flow.flowErrorCode, "wallet_submission_failed")
compare(page.flow.submitting, false)
}
function test_poolProbeDoesNotPublishProbeAmountsAsCurrentQuote() {
var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true
})
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend
})
verify(backend)
verify(page)
var request = {
"tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
"tokenBId": "22222222222222222222222222222222"
}
var pending = { "key": page.flow.pairKey(request), "request": request }
page.flow.pendingQuoteRequest = { "ok": true, "request": request }
page.flow.pendingPoolProbes = [pending]
page.flow.newPositionQuote = {
"schema": "new-position.v1",
"status": "ok",
"poolStatus": "missing_pool",
"tokenAId": request.tokenAId,
"tokenBId": request.tokenBId
}
page.flow.quoteStale = false
page.flow.finishPoolProbe(pending, {
"schema": "new-position.v1",
"status": "ok",
"poolStatus": "active_pool",
"tokenAId": request.tokenAId,
"tokenBId": request.tokenBId
})
compare(page.flow.pendingPoolProbes.length, 0)
compare(page.flow.newPositionQuote.poolStatus, "missing_pool")
verify(page.flow.quoteStale)
}
function test_poolProbeStopsBlockingAfterTransactionDeadline() {
var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true
})
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend
})
verify(backend)
verify(page)
var request = {
"tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
"tokenBId": "22222222222222222222222222222222"
}
var pending = {
"key": page.flow.pairKey(request),
"request": request,
"deadlineMs": Date.now() - 1
}
page.flow.pendingQuoteRequest = { "ok": true, "request": request }
page.flow.pendingPoolProbes = [pending]
page.flow.poolProbeInFlight = true
page.flow.finishPoolProbe(pending, null)
compare(page.flow.pendingPoolProbes.length, 0)
compare(page.flow.poolProbeInFlight, false)
verify(!page.flow.selectedPoolCreationPending())
}
}

View File

@ -0,0 +1,633 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtTest
import "../../qml/components/liquidity" as Liquidity
TestCase {
id: testCase
name: "NewPositionForm"
readonly property string tokenLow: "22222222222222222222222222222222"
readonly property string tokenHigh: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
readonly property string tokenThird: "33333333333333333333333333333333"
readonly property string submittedTransactionId:
"1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"
Component {
id: formComponent
Liquidity.NewPositionForm {
visible: false
width: 760
newPositionContext: testCase.readyContext()
}
}
Component {
id: clipboardSinkComponent
TextEdit {}
}
SignalSpy {
id: quoteRequestedSpy
signalName: "quoteRequested"
}
function readyContext() {
return {
"status": "ready",
"tokens": [
{
"definitionId": tokenLow,
"name": "Low",
"totalSupplyRaw": "1000000",
"balanceRaw": "1000",
"selectable": true
},
{
"definitionId": tokenHigh,
"name": "High",
"totalSupplyRaw": "1000000000000",
"balanceRaw": "5000000000",
"selectable": true
}
]
}
}
function rawAmountsContext() {
return {
"status": "ready",
"tokens": [
{
"definitionId": tokenLow,
"name": "Sir Mints-a-Lot",
"totalSupplyRaw": "1000000000000",
"balanceRaw": "1000000000",
"selectable": true
},
{
"definitionId": tokenHigh,
"name": "Aurora",
"totalSupplyRaw": "1000000000000",
"balanceRaw": "1000000000",
"selectable": true
}
]
}
}
function flowState(quote) {
return {
"quote": quote || ({}),
"contextLoading": false,
"quoteLoading": false,
"quoteStale": false,
"submitting": false
}
}
function createForm() {
var form = createTemporaryObject(formComponent, testCase, {
"flowState": flowState(({}))
})
verify(form)
wait(0)
compare(form.selectedTokenAId, tokenLow)
compare(form.selectedTokenBId, tokenHigh)
return form
}
function test_displayOrderMapsToCanonicalRequestAfterSwap() {
var form = createForm()
var built = form.buildQuoteRequest()
verify(built.ok)
compare(built.request.tokenAId, tokenHigh)
compare(built.request.tokenBId, tokenLow)
form.swapTokens()
compare(form.selectedTokenAId, tokenHigh)
compare(form.selectedTokenBId, tokenLow)
built = form.buildQuoteRequest()
verify(built.ok)
compare(built.request.tokenAId, tokenHigh)
compare(built.request.tokenBId, tokenLow)
}
function test_tokenAmountsUseRawUnits() {
var form = createForm()
compare(form.decimalsA, 0)
compare(form.decimalsB, 0)
compare(form.balanceText(form.tokenA, form.decimalsA), "1000")
compare(form.balanceText(form.tokenB, form.decimalsB), "5000000000")
}
function test_missingPoolKeepsMinimumRatioWhileEditingDeposit() {
var quote = {
"status": "ok",
"tokenAId": tokenHigh,
"tokenBId": tokenLow,
"poolStatus": "missing_pool",
"minimumAmountARaw": "2",
"minimumAmountBRaw": "3",
"actualAmountARaw": "2",
"actualAmountBRaw": "3"
}
var form = createForm()
form.priceAmountA = "3"
form.priceAmountB = "2"
form.flowState = flowState(quote)
wait(0)
compare(form.amountA, "3")
compare(form.amountB, "2")
form.editMissingAmount("A", "6")
compare(form.amountA, "6")
compare(form.amountB, "2")
form.finishMissingAmount("A", "6")
compare(form.amountB, "4")
var built = form.buildQuoteRequest()
verify(built.ok)
compare(built.request.amountARaw, "4")
compare(built.request.amountBRaw, "6")
}
function test_missingPoolAcceptsLargeDirectAmountsFromEitherSide() {
var form = createTemporaryObject(formComponent, testCase, {
"flowState": flowState(({})),
"newPositionContext": rawAmountsContext()
})
verify(form)
wait(0)
form.priceAmountA = "15"
form.priceAmountB = "10"
form.flowState = flowState({
"status": "ok",
"tokenAId": tokenHigh,
"tokenBId": tokenLow,
"poolStatus": "missing_pool",
"minimumAmountARaw": "26",
"minimumAmountBRaw": "39",
"actualAmountARaw": "26",
"actualAmountBRaw": "39"
})
wait(0)
form.finishMissingAmount("A", "150")
compare(form.amountA, "150")
compare(form.amountB, "100")
var built = form.buildQuoteRequest()
verify(built.ok)
compare(built.request.amountARaw, "100")
compare(built.request.amountBRaw, "150")
compare(built.request.initialPriceRealRaw, "27670116110564327424")
verify(!built.request.hasOwnProperty("depositScaleBps"))
form.finishMissingAmount("B", "200")
compare(form.amountA, "300")
compare(form.amountB, "200")
built = form.buildQuoteRequest()
verify(built.ok)
compare(built.request.amountARaw, "200")
compare(built.request.amountBRaw, "300")
}
function test_missingPoolRoundsPairedRawAmounts() {
var form = createTemporaryObject(formComponent, testCase, {
"flowState": flowState(({})),
"newPositionContext": rawAmountsContext()
})
verify(form)
wait(0)
form.priceAmountA = "15"
form.priceAmountB = "10"
form.flowState = flowState({
"status": "ok",
"tokenAId": tokenHigh,
"tokenBId": tokenLow,
"poolStatus": "missing_pool",
"minimumAmountARaw": "1",
"minimumAmountBRaw": "1",
"actualAmountARaw": "1",
"actualAmountBRaw": "1"
})
wait(0)
var cases = [
{ "input": "1", "paired": "1", "rawA": "1", "rawB": "1" },
{ "input": "2", "paired": "1", "rawA": "1", "rawB": "2" },
{ "input": "3", "paired": "2", "rawA": "2", "rawB": "3" },
{ "input": "4", "paired": "3", "rawA": "3", "rawB": "4" }
]
for (var i = 0; i < cases.length; ++i) {
form.finishMissingAmount("A", cases[i].input)
compare(form.amountB, cases[i].paired)
var built = form.buildQuoteRequest()
verify(built.ok)
compare(built.request.amountARaw, cases[i].rawA)
compare(built.request.amountBRaw, cases[i].rawB)
}
form.finishMissingAmount("A", "1.1234567")
compare(form.amountA, "1.1234567")
verify(!form.buildQuoteRequest().ok)
compare(form.fieldError("amountA"), form.issueText("invalid_amount_precision"))
}
function test_missingPoolUsesTradeStyleInputsWithInlinePrice() {
var form = createForm()
form.flowState = flowState({
"status": "ok",
"tokenAId": tokenHigh,
"tokenBId": tokenLow,
"poolStatus": "missing_pool",
"minimumAmountARaw": "2000000",
"minimumAmountBRaw": "3",
"actualAmountARaw": "2000000",
"actualAmountBRaw": "3"
})
wait(0)
var amountAInput = findChild(form, "tokenAAmountInput")
var amountBInput = findChild(form, "tokenBAmountInput")
verify(amountAInput)
verify(amountBInput)
compare(amountAInput.selectedTokenId, tokenLow)
compare(amountBInput.selectedTokenId, tokenHigh)
verify(amountAInput.height <= 114)
verify(amountBInput.height <= 114)
verify(findChild(form, "priceAmountAField"))
verify(findChild(form, "priceAmountBField"))
form.width = 328
wait(0)
compare(amountAInput.adjustmentWidth, 100)
compare(amountBInput.adjustmentWidth, 100)
}
function test_errorMessageStaysAbovePairAndMarksCausingControl() {
var form = createForm()
form.flowState = flowState({
"status": "ok",
"tokenAId": tokenHigh,
"tokenBId": tokenLow,
"poolStatus": "missing_pool",
"minimumAmountARaw": "2000000",
"minimumAmountBRaw": "3",
"actualAmountARaw": "2000000",
"actualAmountBRaw": "3"
})
wait(0)
var amountAInput = findChild(form, "tokenAAmountInput")
var amountBInput = findChild(form, "tokenBAmountInput")
form.localErrors = [form.localIssue("amount_exceeds_balance", ["amountB"])]
wait(0)
compare(amountAInput.errorText, form.issueText("amount_exceeds_balance"))
compare(amountAInput.invalid, false)
compare(amountBInput.invalid, true)
form.resolveToken("B", "invalid")
wait(0)
compare(amountAInput.errorText, form.issueText("invalid_token_id"))
compare(amountAInput.tokenInvalid, false)
compare(amountBInput.tokenInvalid, true)
}
function test_staleMissingPoolQuoteDoesNotReplaceDraft() {
var quote = {
"status": "ok",
"tokenAId": tokenHigh,
"tokenBId": tokenLow,
"poolStatus": "missing_pool",
"minimumAmountARaw": "2000000",
"minimumAmountBRaw": "3",
"actualAmountARaw": "2000000",
"actualAmountBRaw": "3"
}
var form = createForm()
form.flowState = flowState(quote)
wait(0)
compare(form.amountA, "3")
form.editMissingAmount("A", "2")
form.flowState = {
"quote": {
"status": "ok",
"tokenAId": tokenHigh,
"tokenBId": tokenLow,
"poolStatus": "missing_pool",
"minimumAmountARaw": "2000000",
"minimumAmountBRaw": "3",
"actualAmountARaw": "2000000",
"actualAmountBRaw": "3"
},
"contextLoading": false,
"quoteLoading": false,
"quoteStale": true,
"submitting": false
}
wait(0)
compare(form.amountA, "2")
}
function test_poolActivationClearsCreationDraftWithoutPublishingProbeAmounts() {
var form = createForm()
form.confirmedPoolStatus = "missing_pool"
form.amountA = "3"
form.amountB = "2"
form.minimumAmountARaw = "3"
form.minimumAmountBRaw = "2000000"
verify(form.acceptPoolActivation({
"schema": "new-position.v1",
"status": "ok",
"tokenAId": tokenHigh,
"tokenBId": tokenLow,
"poolStatus": "active_pool",
"reserveARaw": "3000000",
"reserveBRaw": "2"
}))
verify(form.activePool)
compare(form.activePoolQuote.poolStatus, "active_pool")
compare(form.amountA, "")
compare(form.amountB, "")
compare(form.minimumAmountARaw, "")
compare(form.minimumAmountBRaw, "")
quoteRequestedSpy.target = form
quoteRequestedSpy.clear()
form.requestQuote(true)
compare(quoteRequestedSpy.count, 0)
compare(form.localErrors.length, 0)
}
function test_staleQuoteErrorsDoNotMarkCurrentDraft() {
var quote = {
"schema": "new-position.v1",
"status": "ok",
"tokenAId": tokenHigh,
"tokenBId": tokenLow,
"poolStatus": "active_pool",
"reserveARaw": "2",
"reserveBRaw": "10",
"maxAmountARaw": "4",
"maxAmountBRaw": "20",
"errors": [{
"code": "amount_exceeds_balance",
"blockingFields": ["maxAmountARaw"]
}]
}
var form = createForm()
form.flowState = flowState(quote)
wait(0)
compare(form.fieldError("amountB"), form.issueText("amount_exceeds_balance"))
var staleState = flowState(quote)
staleState.quoteStale = true
form.flowState = staleState
wait(0)
compare(form.fieldError("amountB"), "")
compare(form.formErrorText(), "")
compare(form.accountPreview().length, 0)
}
function test_activePoolEditUsesDisplayReserveRatio() {
var quote = {
"status": "ok",
"tokenAId": tokenHigh,
"tokenBId": tokenLow,
"poolStatus": "active_pool",
"reserveARaw": "2",
"reserveBRaw": "10",
"maxAmountARaw": "4",
"maxAmountBRaw": "20"
}
var form = createForm()
form.flowState = flowState(quote)
wait(0)
compare(form.amountA, "20")
compare(form.amountB, "4")
quoteRequestedSpy.target = form
quoteRequestedSpy.clear()
form.editActiveAmount("A", "5")
compare(form.amountA, "5")
compare(form.amountB, "4")
compare(quoteRequestedSpy.count, 0)
form.finishActiveAmount("A", "5")
compare(form.amountB, "1")
compare(quoteRequestedSpy.count, 1)
var built = form.buildQuoteRequest()
verify(built.ok)
compare(built.request.maxAmountARaw, "1")
compare(built.request.maxAmountBRaw, "5")
form.finishActiveAmount("B", "1.1234567")
compare(form.amountB, "1.1234567")
verify(!form.buildQuoteRequest().ok)
}
function test_activePoolEditRecoversAfterInvalidQuote() {
var form = createForm()
form.flowState = flowState({
"status": "ok",
"tokenAId": tokenHigh,
"tokenBId": tokenLow,
"poolStatus": "active_pool",
"poolFeeBps": 30,
"reserveARaw": "2",
"reserveBRaw": "10",
"maxAmountARaw": "4",
"maxAmountBRaw": "20"
})
wait(0)
form.amountA = "0"
form.amountB = "0"
form.flowState = flowState({
"status": "error",
"code": "value_must_be_positive",
"tokenAId": tokenHigh,
"tokenBId": tokenLow
})
wait(0)
compare(form.poolFeeBps, 30)
form.finishActiveAmount("B", "1")
compare(form.amountA, "5")
var built = form.buildQuoteRequest()
verify(built.ok)
compare(built.request.maxAmountARaw, "1")
compare(built.request.maxAmountBRaw, "5")
}
function test_quoteStateChangeDoesNotRequestAnotherQuote() {
var form = createForm()
quoteRequestedSpy.target = form
quoteRequestedSpy.clear()
form.flowState = {
"quote": ({}),
"contextLoading": false,
"quoteLoading": true,
"quoteStale": true,
"submitting": false
}
wait(0)
compare(quoteRequestedSpy.count, 0)
}
function test_existingPoolFeeCorrectionKeepsQuoteRequestValid() {
var form = createForm()
quoteRequestedSpy.target = form
quoteRequestedSpy.clear()
form.flowState = flowState({
"status": "error",
"code": "fee_tier_mismatch",
"tokenAId": tokenHigh,
"tokenBId": tokenLow,
"errors": [{
"code": "fee_tier_mismatch",
"details": { "poolFeeBps": "5" }
}]
})
wait(0)
compare(form.selectedFeeBps, 5)
compare(quoteRequestedSpy.count, 1)
verify(quoteRequestedSpy.signalArguments[0][1].ok)
compare(quoteRequestedSpy.signalArguments[0][1].request.maxAmountARaw, "5000000000")
compare(quoteRequestedSpy.signalArguments[0][1].request.maxAmountBRaw, "1000")
}
function test_contextFailureFinishesTokenResolution() {
var form = createForm()
form.resolvingTokenId = tokenThird
form.resolvingTokenSide = "A"
form.newPositionContext = {
"status": "error",
"code": "config_unavailable",
"tokens": []
}
form.finishTokenResolution(true)
wait(0)
compare(form.resolvingTokenId, "")
compare(form.resolvingTokenSide, "")
compare(form.tokenResolutionError, form.issueText("config_unavailable"))
}
function test_staleContextDoesNotFinishNewerTokenResolution() {
var form = createForm()
form.resolvingTokenId = tokenThird
form.resolvingTokenSide = "B"
form.newPositionContext = {
"status": "ready",
"tokens": [{
"definitionId": tokenLow,
"name": "Earlier token",
"selectable": true
}]
}
wait(0)
compare(form.resolvingTokenId, tokenThird)
compare(form.resolvingTokenSide, "B")
compare(form.tokenResolutionError, "")
}
function test_replacingSelectedTokensClearsPairDraft() {
var form = createForm()
form.amountA = "12"
form.amountB = "34"
form.minimumAmountARaw = "12"
form.minimumAmountBRaw = "34"
form.confirmedPoolStatus = "active_pool"
form.newPositionContext = {
"status": "ready",
"tokens": [
{
"definitionId": tokenHigh,
"name": "High",
"totalSupplyRaw": "1000000000000",
"balanceRaw": "5000000000",
"selectable": true
},
{
"definitionId": tokenThird,
"name": "Third",
"totalSupplyRaw": "1000000",
"balanceRaw": "100",
"selectable": true
}
]
}
wait(0)
compare(form.selectedTokenAId, tokenHigh)
compare(form.selectedTokenBId, tokenThird)
compare(form.amountA, "")
compare(form.amountB, "")
compare(form.minimumAmountARaw, "")
compare(form.minimumAmountBRaw, "")
compare(form.confirmedPoolStatus, "")
}
function test_networkFailurePreservesPairDraft() {
var form = createForm()
form.amountA = "12"
form.amountB = "34"
form.confirmedPoolStatus = "active_pool"
form.newPositionContext = {
"status": "network_mismatch",
"tokens": []
}
wait(0)
compare(form.selectedTokenAId, tokenLow)
compare(form.selectedTokenBId, tokenHigh)
compare(form.amountA, "12")
compare(form.amountB, "34")
compare(form.confirmedPoolStatus, "active_pool")
verify(form.contextBlocksForm())
}
function test_submittedBase58TransactionIdIsCopied() {
var state = flowState(({}))
state.transactionId = submittedTransactionId
var form = createTemporaryObject(formComponent, testCase, {
"flowState": state
})
var sink = createTemporaryObject(clipboardSinkComponent, testCase)
verify(form)
verify(sink)
wait(0)
compare(form.transactionId, submittedTransactionId)
var copyButton = findChild(form, "copySubmittedTransactionButton")
verify(copyButton)
copyButton.clicked()
verify(copyButton.copied)
sink.paste()
tryCompare(sink, "text", submittedTransactionId)
}
}

View File

@ -0,0 +1,47 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtTest
import "../../qml/components/liquidity" as Liquidity
TestCase {
id: testCase
name: "ResponsivePopups"
Liquidity.AmmTheme {
id: theme
}
Component {
id: viewportComponent
Item {
width: 320
height: 300
}
}
Component {
id: tokenSelectorComponent
Liquidity.TokenSelectorModal {
theme: theme
}
}
function test_tokenSelectorStaysInsideShortViewport() {
var viewport = createTemporaryObject(viewportComponent, testCase)
var selector = createTemporaryObject(tokenSelectorComponent, viewport, {
"parent": viewport
})
verify(viewport)
verify(selector)
verify(selector.x >= 0)
verify(selector.y >= 0)
verify(selector.x + selector.width <= viewport.width)
verify(selector.y + selector.height <= viewport.height)
}
}

View File

@ -0,0 +1,280 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtTest
import "../../qml/components/liquidity" as Liquidity
TestCase {
id: testCase
name: "TokenAmountInput"
readonly property string enabledId: "22222222222222222222222222222222"
readonly property string disabledId: "33333333333333333333333333333333"
Component {
id: inputComponent
Liquidity.TokenAmountInput {
visible: false
width: 400
theme: inputTheme
tokens: [
{
"definitionId": testCase.disabledId,
"name": "Disabled",
"selectable": false,
"code": "token_not_fungible"
},
{
"definitionId": testCase.enabledId,
"name": "Enabled",
"selectable": true
}
]
Liquidity.AmmTheme {
id: inputTheme
}
}
}
SignalSpy {
id: commitSpy
signalName: "editingCommitted"
}
SignalSpy {
id: selectedSpy
signalName: "tokenSelected"
}
SignalSpy {
id: enteredSpy
signalName: "tokenEntered"
}
function test_inactivityCommitsLatestEditOnce() {
var input = createTemporaryObject(inputComponent, testCase)
verify(input)
commitSpy.target = input
commitSpy.clear()
input.amountEdited("1")
wait(150)
compare(commitSpy.count, 0)
input.amountEdited("12")
wait(200)
compare(commitSpy.count, 0)
tryCompare(commitSpy, "count", 1, 150)
compare(commitSpy.signalArguments[0][0], "12")
}
function test_editingFinishedCommitsWithoutDuplicate() {
var input = createTemporaryObject(inputComponent, testCase)
verify(input)
commitSpy.target = input
commitSpy.clear()
input.amountEdited("7")
input.amountEditingFinished("7")
compare(commitSpy.count, 1)
compare(commitSpy.signalArguments[0][0], "7")
wait(300)
compare(commitSpy.count, 1)
}
function test_compactWidthShrinksTokenAccessory() {
var input = createTemporaryObject(inputComponent, testCase, { "width": 296 })
verify(input)
compare(input.accessoryWidth, 132)
input.width = 416
compare(input.accessoryWidth, 180)
}
function test_disabledTokenIsRejectedByTypedInput() {
var input = createTemporaryObject(inputComponent, testCase)
verify(input)
selectedSpy.target = input
enteredSpy.target = input
selectedSpy.clear()
enteredSpy.clear()
input.acceptInput("Disabled")
compare(selectedSpy.count, 0)
compare(enteredSpy.count, 1)
compare(enteredSpy.signalArguments[0][0], disabledId)
}
function test_enabledTokenIsSelectedByTypedInput() {
var input = createTemporaryObject(inputComponent, testCase)
verify(input)
selectedSpy.target = input
enteredSpy.target = input
selectedSpy.clear()
enteredSpy.clear()
input.acceptInput("Enabled")
compare(selectedSpy.count, 1)
compare(selectedSpy.signalArguments[0][0], enabledId)
compare(enteredSpy.count, 0)
}
function test_partialNameSelectsSoleMatchInsteadOfCustomAddress() {
var input = createTemporaryObject(inputComponent, testCase)
verify(input)
selectedSpy.target = input
enteredSpy.target = input
selectedSpy.clear()
enteredSpy.clear()
input.query = "Enabl"
tryCompare(input.rows, "length", 1)
input.acceptInput("Enabl")
compare(selectedSpy.count, 1)
compare(selectedSpy.signalArguments[0][0], enabledId)
compare(enteredSpy.count, 0)
}
function test_disabledTokenCanExplainWhyItIsUnavailable() {
var input = createTemporaryObject(inputComponent, testCase, {
"visible": true,
"width": 320
})
verify(input)
input.popup.open()
tryCompare(input.popup, "visible", true)
var tokenList = findChild(input, "tokenList")
verify(tokenList)
tryCompare(tokenList, "count", 2)
tryVerify(function() { return tokenList.itemAtIndex(0) !== null })
var option = tokenList.itemAtIndex(0)
mouseMove(option, option.width / 2, option.height / 2)
tryCompare(option, "pointerHovered", true)
compare(option.disabledReasonVisible, true)
selectedSpy.target = input
enteredSpy.target = input
selectedSpy.clear()
enteredSpy.clear()
mouseClick(option, option.width / 2, option.height / 2)
compare(selectedSpy.count, 0)
compare(enteredSpy.count, 0)
}
function test_tokenListSupportsKeyboardSelection() {
var input = createTemporaryObject(inputComponent, testCase, {
"visible": true,
"width": 320
})
verify(input)
selectedSpy.target = input
selectedSpy.clear()
input.popup.open()
tryCompare(input.popup, "visible", true)
var tokenList = findChild(input, "tokenList")
verify(tokenList)
tryVerify(function() { return tokenList.itemAtIndex(1) !== null })
var option = tokenList.itemAtIndex(1)
option.forceActiveFocus()
tryCompare(option, "activeFocus", true)
keyClick(Qt.Key_Space)
compare(selectedSpy.count, 1)
compare(selectedSpy.signalArguments[0][0], enabledId)
}
function test_disabledReasonAppearsOnKeyboardFocus() {
var input = createTemporaryObject(inputComponent, testCase, {
"visible": true,
"width": 320
})
verify(input)
input.popup.open()
tryCompare(input.popup, "visible", true)
var tokenList = findChild(input, "tokenList")
verify(tokenList)
tryVerify(function() { return tokenList.itemAtIndex(0) !== null })
var option = tokenList.itemAtIndex(0)
option.forceActiveFocus()
tryCompare(option, "activeFocus", true)
compare(option.disabledReasonVisible, true)
}
function test_searchModelContainsOnlyMatchingRows() {
var input = createTemporaryObject(inputComponent, testCase)
verify(input)
compare(input.rows.length, 2)
input.query = "Enabled"
tryVerify(function() { return input.rows.length === 1 })
compare(input.rows[0].definitionId, enabledId)
}
function test_typingKeepsSearchTextWhileModelFilters() {
var input = createTemporaryObject(inputComponent, testCase, {
"visible": true,
"width": 320
})
verify(input)
input.popup.open()
tryCompare(input.popup, "visible", true)
var editor = findChild(input, "tokenSearchField")
verify(editor)
tryCompare(editor, "activeFocus", true)
keyClick(Qt.Key_E)
keyClick(Qt.Key_N)
keyClick(Qt.Key_A)
keyClick(Qt.Key_B)
keyClick(Qt.Key_L)
keyClick(Qt.Key_E)
keyClick(Qt.Key_D)
compare(editor.text.toLowerCase(), "enabled")
compare(input.rows.length, 1)
compare(input.rows[0].definitionId, enabledId)
}
function test_clickOpensSharedTokenModal() {
var input = createTemporaryObject(inputComponent, testCase, {
"visible": true,
"width": 320
})
verify(input)
var button = findChild(input, "tokenSelectButton")
verify(button)
button.click()
tryCompare(input.popup, "visible", true)
verify(findChild(input, "tokenSearchField"))
verify(findChild(input, "tokenList"))
}
function test_unlistedAddressCanBeEntered() {
var input = createTemporaryObject(inputComponent, testCase)
verify(input)
enteredSpy.target = input
enteredSpy.clear()
var unlistedId = "44444444444444444444444444444444"
input.acceptInput(unlistedId)
compare(enteredSpy.count, 1)
compare(enteredSpy.signalArguments[0][0], unlistedId)
}
}

View File

@ -98,10 +98,36 @@
'';
}
);
# Second AMM client crate (apps/amm/client) — the new-position/pool
# flow's protocol lib. Built alongside amm_client_ffi (they coexist:
# symbols are prefixed amm_* vs amm_client_*). TODO: consolidate the two
# into a single AMM client FFI.
ammClientArgs = commonArgs // {
pname = "amm_client";
cargoExtraArgs = "-p amm_client";
};
ammClient = craneLib.buildPackage (
ammClientArgs
// {
cargoArtifacts = craneLib.buildDepsOnly ammClientArgs;
postInstall =
''
mkdir -p $out/include
cp apps/amm/client/include/amm_client.h $out/include/
''
+ pkgs.lib.optionalString pkgs.stdenv.isDarwin ''
if [ -f $out/lib/libamm_client.dylib ]; then
install_name_tool -id "$out/lib/libamm_client.dylib" $out/lib/libamm_client.dylib
fi
'';
}
);
in
{
packages.default = ammClientFfi;
packages.amm_client_ffi = ammClientFfi;
packages.amm_client = ammClient;
}
);
@ -117,6 +143,7 @@
flakeInputs = inputs;
externalLibInputs = {
amm_client_ffi = { input = self; packages.default = "amm_client_ffi"; };
amm_client = { input = self; packages.default = "amm_client"; };
};
# The AMM UI links the shared C++ wallet access lib and bundles the
# Logos.Wallet QML module (apps/shared/wallet). apps/amm/flake.nix wires
@ -160,10 +187,11 @@
let
pkgs = import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; };
ammFfi = crateOutputs.packages.${system}.amm_client_ffi;
ammClient = crateOutputs.packages.${system}.amm_client;
in
app // {
program = "${pkgs.writeShellScript "run-amm-ui" ''
export DYLD_FALLBACK_LIBRARY_PATH="${ammFfi}/lib''${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}"
export DYLD_FALLBACK_LIBRARY_PATH="${ammFfi}/lib:${ammClient}/lib''${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}"
exec ${app.program} "$@"
''}";
};