mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-25 00:03:11 +00:00
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:
parent
64b7e8a197
commit
b2753aa0c9
41
Cargo.lock
generated
41
Cargo.lock
generated
@ -76,6 +76,25 @@ dependencies = [
|
||||
"risc0-zkvm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "amm_client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"alloy-primitives",
|
||||
"amm_core",
|
||||
"borsh",
|
||||
"clock_core",
|
||||
"hex",
|
||||
"lee_core",
|
||||
"pretty_assertions",
|
||||
"risc0-zkvm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"token_core",
|
||||
"twap_oracle_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "amm_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"
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"apps/amm/client",
|
||||
"programs/token/core",
|
||||
"programs/token",
|
||||
"programs/token/methods",
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
52
apps/amm/VALIDATION.md
Normal 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.
|
||||
24
apps/amm/client/Cargo.toml
Normal file
24
apps/amm/client/Cargo.toml
Normal file
@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "amm_client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
alloy-primitives = { version = "1", default-features = false }
|
||||
amm_core = { workspace = true }
|
||||
borsh = { workspace = true }
|
||||
clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0" }
|
||||
hex = "0.4"
|
||||
nssa_core = { workspace = true }
|
||||
risc0-zkvm = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = "0.10"
|
||||
token_core = { workspace = true }
|
||||
twap_oracle_core = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1"
|
||||
20
apps/amm/client/include/amm_client.h
Normal file
20
apps/amm/client/include/amm_client.h
Normal file
@ -0,0 +1,20 @@
|
||||
#ifndef AMM_CLIENT_H
|
||||
#define AMM_CLIENT_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
char *amm_config_id(const char *request_json);
|
||||
char *amm_token_ids(const char *request_json);
|
||||
char *amm_pair_ids(const char *request_json);
|
||||
char *amm_context(const char *request_json);
|
||||
char *amm_quote(const char *request_json);
|
||||
char *amm_plan(const char *request_json);
|
||||
void amm_free(char *value);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
152
apps/amm/client/src/account.rs
Normal file
152
apps/amm/client/src/account.rs
Normal 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()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
226
apps/amm/client/src/api/accounts.rs
Normal file
226
apps/amm/client/src/api/accounts.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
12
apps/amm/client/src/api/clock.rs
Normal file
12
apps/amm/client/src/api/clock.rs
Normal 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))
|
||||
}
|
||||
51
apps/amm/client/src/api/commitment.rs
Normal file
51
apps/amm/client/src/api/commitment.rs
Normal 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>,
|
||||
}
|
||||
25
apps/amm/client/src/api/config.rs
Normal file
25
apps/amm/client/src/api/config.rs
Normal 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"))
|
||||
}
|
||||
254
apps/amm/client/src/api/context.rs
Normal file
254
apps/amm/client/src/api/context.rs
Normal 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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
106
apps/amm/client/src/api/funding.rs
Normal file
106
apps/amm/client/src/api/funding.rs
Normal 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))))
|
||||
}
|
||||
64
apps/amm/client/src/api/holding.rs
Normal file
64
apps/amm/client/src/api/holding.rs
Normal 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()
|
||||
}
|
||||
97
apps/amm/client/src/api/mod.rs
Normal file
97
apps/amm/client/src/api/mod.rs
Normal 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)
|
||||
}
|
||||
93
apps/amm/client/src/api/pair.rs
Normal file
93
apps/amm/client/src/api/pair.rs
Normal 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),
|
||||
})
|
||||
}
|
||||
136
apps/amm/client/src/api/plan.rs
Normal file
136
apps/amm/client/src/api/plan.rs
Normal 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("e_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("e_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("e_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("e_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)
|
||||
}
|
||||
229
apps/amm/client/src/api/position.rs
Normal file
229
apps/amm/client/src/api/position.rs
Normal 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("e.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,
|
||||
})
|
||||
}
|
||||
}
|
||||
726
apps/amm/client/src/api/quote.rs
Normal file
726
apps/amm/client/src/api/quote.rs
Normal 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)
|
||||
}
|
||||
25
apps/amm/client/src/api/quote_error.rs
Normal file
25
apps/amm/client/src/api/quote_error.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
116
apps/amm/client/src/api/request.rs
Normal file
116
apps/amm/client/src/api/request.rs
Normal 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>,
|
||||
}
|
||||
703
apps/amm/client/src/api/tests.rs
Normal file
703
apps/amm/client/src/api/tests.rs
Normal 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("e_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("e_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
140
apps/amm/client/src/ffi.rs
Normal 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) };
|
||||
}
|
||||
}
|
||||
12
apps/amm/client/src/lib.rs
Normal file
12
apps/amm/client/src/lib.rs
Normal 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,
|
||||
};
|
||||
13
apps/amm/client/tests/public_api.rs
Normal file
13
apps/amm/client/tests/public_api.rs
Normal 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");
|
||||
}
|
||||
18
apps/amm/config/networks.json
Normal file
18
apps/amm/config/networks.json
Normal 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
923
apps/amm/flake.lock
generated
File diff suppressed because it is too large
Load Diff
@ -26,6 +26,8 @@
|
||||
inputs.logos-execution-zone.url =
|
||||
"github:logos-blockchain/logos-execution-zone?rev=a7e06a660940a00093b1760560d37ff84aff5a05";
|
||||
};
|
||||
|
||||
amm_client.url = "path:../..";
|
||||
};
|
||||
|
||||
# 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.
|
||||
|
||||
@ -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": [],
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,222 +0,0 @@
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Controls 2.15
|
||||
import QtQuick.Layouts 1.15
|
||||
import "../shared"
|
||||
import "../../state"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
required property DummyPoolState poolState
|
||||
|
||||
property real slippageTolerancePercent: 0.5
|
||||
property string amountA: ""
|
||||
property string amountB: ""
|
||||
property string lastEditedToken: "A"
|
||||
readonly property real parsedA: root.poolState.parseAmount(root.amountA)
|
||||
readonly property real parsedB: root.poolState.parseAmount(root.amountB)
|
||||
readonly property var preview: root.poolState.addLiquidityPreview(root.parsedA, root.parsedB)
|
||||
readonly property int minLpReceived: root.poolState.minReceivedAmount(root.preview.deltaLp, root.slippageTolerancePercent)
|
||||
readonly property bool hasAnyAmount: root.parsedA > 0 || root.parsedB > 0
|
||||
readonly property bool amountAOverBalance: root.parsedA > root.poolState.walletBalanceA
|
||||
readonly property bool amountBOverBalance: root.parsedB > root.poolState.walletBalanceB
|
||||
readonly property bool minReceivedIsZero: root.hasAnyAmount && root.minLpReceived === 0
|
||||
readonly property bool zeroTokenDeposit: root.hasAnyAmount && (root.preview.actualA === 0 || root.preview.actualB === 0)
|
||||
readonly property bool zeroLpDeposit: root.preview.actualA > 0 && root.preview.actualB > 0 && root.preview.deltaLp === 0
|
||||
readonly property bool canSubmit: root.hasAnyAmount && !root.amountAOverBalance && !root.amountBOverBalance && !root.minReceivedIsZero && !root.zeroTokenDeposit && !root.zeroLpDeposit
|
||||
readonly property string submitButtonText: !root.hasAnyAmount ? qsTr("Enter an amount") : root.amountAOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenA) : root.amountBOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenB) : root.zeroTokenDeposit ? qsTr("Amount rounds to zero") : root.zeroLpDeposit ? qsTr("LP output is 0") : root.minReceivedIsZero ? qsTr("Minimum received is 0") : qsTr("Add Liquidity")
|
||||
readonly property string warningText: root.zeroTokenDeposit ? qsTr("Deposit would be rejected because one token amount rounds to zero") : root.zeroLpDeposit ? qsTr("Deposit would mint 0 LP tokens") : ""
|
||||
|
||||
signal slippageToleranceChangeRequested(real tolerancePercent)
|
||||
signal addLiquidityRequested(var snapshot)
|
||||
|
||||
color: "#00000000"
|
||||
implicitHeight: content.implicitHeight
|
||||
radius: 0
|
||||
border.width: 0
|
||||
|
||||
ColumnLayout {
|
||||
id: content
|
||||
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
|
||||
TokenAmountInput {
|
||||
balance: root.poolState.formatTokenAmount(root.poolState.walletBalanceA, root.poolState.tokenA)
|
||||
errorText: root.amountAOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenA) : ""
|
||||
helperText: root.lastEditedToken === "B" && root.amountA.length > 0 ? qsTr("Calculated from current pool ratio") : ""
|
||||
label: qsTr("Token A amount")
|
||||
token: root.poolState.tokenA
|
||||
text: root.amountA
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
onEditingChanged: function (value) {
|
||||
root.updateFromTokenA(value);
|
||||
}
|
||||
onMaxClicked: root.useMax("A")
|
||||
}
|
||||
|
||||
TokenAmountInput {
|
||||
balance: root.poolState.formatTokenAmount(root.poolState.walletBalanceB, root.poolState.tokenB)
|
||||
errorText: root.amountBOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenB) : ""
|
||||
helperText: root.lastEditedToken === "A" && root.amountB.length > 0 ? qsTr("Calculated from current pool ratio") : ""
|
||||
label: qsTr("Token B amount")
|
||||
token: root.poolState.tokenB
|
||||
text: root.amountB
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
onEditingChanged: function (value) {
|
||||
root.updateFromTokenB(value);
|
||||
}
|
||||
onMaxClicked: root.useMax("B")
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
label: qsTr("Current price")
|
||||
value: qsTr("1 %1 = %2 %3").arg(root.poolState.tokenB).arg(root.poolState.formatInteger(root.poolState.tokenAPerTokenB)).arg(root.poolState.tokenA)
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
estimated: true
|
||||
estimateHelp: qsTr("Estimated with the same integer floor math used by the add-liquidity contract path.")
|
||||
label: qsTr("Estimated LP tokens")
|
||||
value: root.poolState.formatLpAmount(root.preview.deltaLp)
|
||||
visible: root.hasAnyAmount
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
SlippageToleranceControl {
|
||||
tolerancePercent: root.slippageTolerancePercent
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
onToleranceChangeRequested: function (tolerancePercent) {
|
||||
root.slippageToleranceChangeRequested(tolerancePercent);
|
||||
}
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
label: qsTr("Min LP received")
|
||||
value: root.poolState.formatLpAmount(root.minLpReceived)
|
||||
visible: root.hasAnyAmount
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
color: "#F08A76"
|
||||
font.pixelSize: 12
|
||||
lineHeight: 1.25
|
||||
text: qsTr("Minimum received is 0. Increase amount or lower slippage.")
|
||||
visible: root.minReceivedIsZero
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
color: "#F08A76"
|
||||
font.pixelSize: 12
|
||||
lineHeight: 1.25
|
||||
text: root.warningText
|
||||
visible: root.warningText.length > 0
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Button {
|
||||
id: submitButton
|
||||
|
||||
activeFocusOnTab: true
|
||||
enabled: root.canSubmit
|
||||
focusPolicy: Qt.StrongFocus
|
||||
hoverEnabled: true
|
||||
text: root.submitButtonText
|
||||
|
||||
Accessible.name: submitButton.text
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: 44
|
||||
Layout.preferredHeight: 44
|
||||
|
||||
onClicked: root.addLiquidityRequested(root.submitSnapshot())
|
||||
|
||||
contentItem: Text {
|
||||
color: submitButton.enabled ? "#151515" : "#7D756E"
|
||||
elide: Text.ElideRight
|
||||
font.bold: true
|
||||
font.pixelSize: 13
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: submitButton.text
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
border.color: submitButton.enabled ? "#F26A21" : "#343434"
|
||||
border.width: 1
|
||||
color: submitButton.enabled ? submitButton.pressed ? "#D95C1E" : submitButton.hovered || submitButton.activeFocus ? "#FF8A3D" : "#F26A21" : "#181818"
|
||||
radius: 6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setAmounts(nextA, nextB, intentToken, showZero) {
|
||||
root.lastEditedToken = intentToken;
|
||||
root.amountA = nextA > 0 || showZero ? root.poolState.formatInputAmount(nextA) : "";
|
||||
root.amountB = nextB > 0 || showZero ? root.poolState.formatInputAmount(nextB) : "";
|
||||
}
|
||||
|
||||
function updateFromTokenA(value) {
|
||||
if (value.length === 0) {
|
||||
setAmounts(0, 0, "A", false);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextA = root.poolState.parseAmount(value);
|
||||
setAmounts(nextA, root.poolState.amountBForA(nextA), "A", true);
|
||||
}
|
||||
|
||||
function updateFromTokenB(value) {
|
||||
if (value.length === 0) {
|
||||
setAmounts(0, 0, "B", false);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextB = root.poolState.parseAmount(value);
|
||||
setAmounts(root.poolState.amountAForB(nextB), nextB, "B", true);
|
||||
}
|
||||
|
||||
function useMax(intentToken) {
|
||||
const capped = root.poolState.maxAddLiquidityForBalances();
|
||||
setAmounts(capped.actualA, capped.actualB, intentToken, false);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
root.amountA = "";
|
||||
root.amountB = "";
|
||||
root.lastEditedToken = "A";
|
||||
}
|
||||
|
||||
function submitSnapshot() {
|
||||
return {
|
||||
"action": "add",
|
||||
"actualA": root.preview.actualA,
|
||||
"actualB": root.preview.actualB,
|
||||
"currentRatio": qsTr("1 %1 = %2 %3").arg(root.poolState.tokenB).arg(root.poolState.formatInteger(root.poolState.tokenAPerTokenB)).arg(root.poolState.tokenA),
|
||||
"deltaLp": root.preview.deltaLp,
|
||||
"depositA": root.poolState.formatTokenAmount(root.preview.actualA, root.poolState.tokenA),
|
||||
"depositB": root.poolState.formatTokenAmount(root.preview.actualB, root.poolState.tokenB),
|
||||
"feeTier": root.poolState.feeTier,
|
||||
"minLpReceived": root.poolState.formatLpAmount(root.minLpReceived),
|
||||
"slippageTolerance": root.poolState.formatPercent(root.slippageTolerancePercent),
|
||||
"tokenA": root.poolState.tokenA,
|
||||
"tokenB": root.poolState.tokenB
|
||||
};
|
||||
}
|
||||
}
|
||||
15
apps/amm/qml/components/liquidity/AmmActionCard.qml
Normal file
15
apps/amm/qml/components/liquidity/AmmActionCard.qml
Normal file
@ -0,0 +1,15 @@
|
||||
import QtQuick
|
||||
|
||||
Rectangle {
|
||||
required property var theme
|
||||
|
||||
implicitWidth: 480
|
||||
radius: 24
|
||||
color: theme.colors.cardBg
|
||||
border.color: theme.colors.border
|
||||
border.width: 1
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 180 }
|
||||
}
|
||||
}
|
||||
61
apps/amm/qml/components/liquidity/AmmPairSeparator.qml
Normal file
61
apps/amm/qml/components/liquidity/AmmPairSeparator.qml
Normal file
@ -0,0 +1,61 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var theme
|
||||
property string symbol: "\u2193"
|
||||
property string accessibleName: qsTr("Swap token order")
|
||||
|
||||
signal clicked
|
||||
|
||||
implicitHeight: 40
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 1
|
||||
color: root.theme.colors.divider
|
||||
}
|
||||
|
||||
Button {
|
||||
id: button
|
||||
|
||||
anchors.centerIn: parent
|
||||
width: 36
|
||||
height: 36
|
||||
hoverEnabled: true
|
||||
enabled: root.enabled
|
||||
|
||||
Accessible.name: root.accessibleName
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: Accessible.name
|
||||
|
||||
onClicked: root.clicked()
|
||||
|
||||
contentItem: Text {
|
||||
text: root.symbol
|
||||
color: button.enabled
|
||||
? root.theme.colors.textPrimary
|
||||
: root.theme.colors.textPlaceholder
|
||||
font.pixelSize: 16
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: 18
|
||||
color: button.hovered || button.activeFocus
|
||||
? root.theme.colors.panelHoverBg
|
||||
: root.theme.colors.panelBg
|
||||
border.color: root.theme.colors.borderStrong
|
||||
border.width: 1
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 120 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
apps/amm/qml/components/liquidity/AmmPrimaryButton.qml
Normal file
43
apps/amm/qml/components/liquidity/AmmPrimaryButton.qml
Normal file
@ -0,0 +1,43 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
Button {
|
||||
id: root
|
||||
|
||||
required property var theme
|
||||
|
||||
activeFocusOnTab: true
|
||||
focusPolicy: Qt.StrongFocus
|
||||
hoverEnabled: true
|
||||
implicitHeight: 56
|
||||
|
||||
Accessible.name: text
|
||||
|
||||
contentItem: Text {
|
||||
text: root.text
|
||||
color: root.enabled ? "#FFFFFF" : root.theme.colors.textSecondary
|
||||
font.pixelSize: 17
|
||||
font.weight: Font.Medium
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: 20
|
||||
color: !root.enabled
|
||||
? root.theme.colors.panelBg
|
||||
: root.pressed
|
||||
? root.theme.colors.ctaPressedBg
|
||||
: root.hovered || root.activeFocus
|
||||
? root.theme.colors.ctaHoverBg
|
||||
: root.theme.colors.ctaBg
|
||||
border.color: root.activeFocus && root.enabled
|
||||
? root.theme.colors.textPrimary : "transparent"
|
||||
border.width: 1
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 120 }
|
||||
}
|
||||
}
|
||||
}
|
||||
50
apps/amm/qml/components/liquidity/AmmTheme.qml
Normal file
50
apps/amm/qml/components/liquidity/AmmTheme.qml
Normal file
@ -0,0 +1,50 @@
|
||||
import QtQml
|
||||
|
||||
QtObject {
|
||||
property bool isDark: true
|
||||
readonly property var colors: isDark ? dark : light
|
||||
|
||||
readonly property var light: ({
|
||||
"background": "#F4EDE3",
|
||||
"cardBg": "#FFFFFF",
|
||||
"inputBg": "#EFE7DB",
|
||||
"panelBg": "#E7E1D8",
|
||||
"panelHoverBg": "#D9D0C2",
|
||||
"textPrimary": "#151515",
|
||||
"textSecondary": "#7D756E",
|
||||
"textPlaceholder": "#A9A098",
|
||||
"border": Qt.rgba(0, 0, 0, 0.08),
|
||||
"borderStrong": Qt.rgba(0, 0, 0, 0.10),
|
||||
"divider": Qt.rgba(0, 0, 0, 0.06),
|
||||
"ctaBg": "#F26A21",
|
||||
"ctaHoverBg": "#D95C1E",
|
||||
"ctaPressedBg": "#C85018",
|
||||
"selection": "#F2D8C7",
|
||||
"noTokenCircle": "#A9A098",
|
||||
"success": "#4F9B64",
|
||||
"warning": "#B8732A",
|
||||
"error": "#D85F4B"
|
||||
})
|
||||
|
||||
readonly property var dark: ({
|
||||
"background": "#151515",
|
||||
"cardBg": "#1B1B1B",
|
||||
"inputBg": "#101010",
|
||||
"panelBg": "#181818",
|
||||
"panelHoverBg": "#202020",
|
||||
"textPrimary": "#E7E1D8",
|
||||
"textSecondary": "#A9A098",
|
||||
"textPlaceholder": "#8E8780",
|
||||
"border": Qt.rgba(1, 1, 1, 0.08),
|
||||
"borderStrong": Qt.rgba(1, 1, 1, 0.10),
|
||||
"divider": Qt.rgba(1, 1, 1, 0.06),
|
||||
"ctaBg": "#F26A21",
|
||||
"ctaHoverBg": "#FF8A3D",
|
||||
"ctaPressedBg": "#D95C1E",
|
||||
"selection": "#211914",
|
||||
"noTokenCircle": "#343434",
|
||||
"success": "#78C88D",
|
||||
"warning": "#F2B366",
|
||||
"error": "#F08A76"
|
||||
})
|
||||
}
|
||||
46
apps/amm/qml/components/liquidity/AmmTokenAccessory.qml
Normal file
46
apps/amm/qml/components/liquidity/AmmTokenAccessory.qml
Normal file
@ -0,0 +1,46 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
ColumnLayout {
|
||||
id: root
|
||||
|
||||
required property var theme
|
||||
property bool hasToken: false
|
||||
property string tokenColor: root.theme.colors.noTokenCircle
|
||||
property string tokenLetter: ""
|
||||
property string tokenText: qsTr("Select token")
|
||||
property string balance: ""
|
||||
property string accessibleName: qsTr("Select token")
|
||||
property bool invalid: false
|
||||
|
||||
signal clicked
|
||||
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: root.balance.length > 0
|
||||
text: qsTr("Balance %1").arg(root.balance)
|
||||
color: root.theme.colors.textSecondary
|
||||
font.pixelSize: 10
|
||||
horizontalAlignment: Text.AlignRight
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
AmmTokenSelectButton {
|
||||
objectName: "tokenSelectButton"
|
||||
Layout.fillWidth: true
|
||||
theme: root.theme
|
||||
enabled: root.enabled
|
||||
invalid: root.invalid
|
||||
hasToken: root.hasToken
|
||||
tokenColor: root.tokenColor
|
||||
tokenLetter: root.tokenLetter
|
||||
text: root.tokenText
|
||||
maximumTextWidth: 112
|
||||
Accessible.name: root.accessibleName
|
||||
onClicked: root.clicked()
|
||||
}
|
||||
}
|
||||
195
apps/amm/qml/components/liquidity/AmmTokenAmountSurface.qml
Normal file
195
apps/amm/qml/components/liquidity/AmmTokenAmountSurface.qml
Normal file
@ -0,0 +1,195 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
required property var theme
|
||||
property string label: ""
|
||||
property string amount: ""
|
||||
property string supportingText: ""
|
||||
property string errorText: ""
|
||||
property string supportingActionText: ""
|
||||
property bool readOnly: false
|
||||
property bool muted: false
|
||||
property bool invalid: root.errorText.length > 0
|
||||
property Component accessory
|
||||
property real accessoryWidth: 0
|
||||
property real accessoryHeight: 0
|
||||
property Component adjustment
|
||||
property real adjustmentWidth: 0
|
||||
property real adjustmentHeight: 0
|
||||
|
||||
signal amountEdited(string value)
|
||||
signal amountEditingFinished(string value)
|
||||
signal supportingActionClicked
|
||||
|
||||
implicitHeight: Math.max(110, contentRow.implicitHeight + 24)
|
||||
radius: 16
|
||||
color: root.muted ? root.theme.colors.panelBg : root.theme.colors.inputBg
|
||||
border.color: root.invalid
|
||||
? root.theme.colors.error
|
||||
: amountInput.activeFocus
|
||||
? root.theme.colors.ctaBg
|
||||
: "transparent"
|
||||
border.width: 1
|
||||
|
||||
Binding {
|
||||
target: amountInput
|
||||
property: "text"
|
||||
value: root.amount
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: contentRow
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 16
|
||||
anchors.topMargin: 12
|
||||
anchors.bottomMargin: 12
|
||||
spacing: 10
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
text: root.label
|
||||
color: root.theme.colors.textSecondary
|
||||
font.pixelSize: 13
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: root.errorText.length > 0
|
||||
text: root.errorText
|
||||
color: root.theme.colors.error
|
||||
font.pixelSize: 11
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 44
|
||||
|
||||
TextInput {
|
||||
id: amountInput
|
||||
|
||||
anchors.fill: parent
|
||||
readOnly: root.readOnly
|
||||
color: root.readOnly || root.muted
|
||||
? root.theme.colors.textSecondary
|
||||
: root.theme.colors.textPrimary
|
||||
font.pixelSize: {
|
||||
var length = Math.max(1, String(root.amount || "0").length)
|
||||
return Math.max(14, Math.min(34, Math.floor(width / (length * 0.68))))
|
||||
}
|
||||
font.weight: Font.Bold
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
selectionColor: root.theme.colors.selection
|
||||
selectedTextColor: root.theme.colors.textPrimary
|
||||
clip: true
|
||||
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
||||
maximumLength: 80
|
||||
validator: RegularExpressionValidator {
|
||||
regularExpression: /^[0-9]*\.?[0-9]*$/
|
||||
}
|
||||
|
||||
Accessible.name: root.label
|
||||
|
||||
onTextEdited: {
|
||||
if (!root.readOnly)
|
||||
root.amountEdited(text)
|
||||
}
|
||||
onEditingFinished: {
|
||||
if (!root.readOnly)
|
||||
root.amountEditingFinished(text)
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
text: "0"
|
||||
color: root.theme.colors.textPlaceholder
|
||||
font: amountInput.font
|
||||
visible: amountInput.text === "" && !amountInput.activeFocus
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
|
||||
Loader {
|
||||
active: root.adjustment !== null
|
||||
visible: active
|
||||
sourceComponent: root.adjustment
|
||||
Layout.preferredWidth: active ? root.adjustmentWidth : 0
|
||||
Layout.preferredHeight: active ? root.adjustmentHeight : 0
|
||||
}
|
||||
|
||||
Button {
|
||||
id: supportingAction
|
||||
|
||||
visible: root.supportingActionText.length > 0
|
||||
enabled: visible && !root.readOnly
|
||||
implicitWidth: 40
|
||||
implicitHeight: 24
|
||||
text: root.supportingActionText
|
||||
hoverEnabled: true
|
||||
Accessible.name: text
|
||||
onClicked: root.supportingActionClicked()
|
||||
|
||||
contentItem: Text {
|
||||
text: supportingAction.text
|
||||
color: supportingAction.enabled
|
||||
? root.theme.colors.ctaBg
|
||||
: root.theme.colors.textPlaceholder
|
||||
font.pixelSize: 11
|
||||
font.weight: Font.Bold
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: 6
|
||||
color: supportingAction.hovered || supportingAction.activeFocus
|
||||
? root.theme.colors.selection : "transparent"
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: root.supportingText
|
||||
color: root.theme.colors.textSecondary
|
||||
font.pixelSize: 11
|
||||
horizontalAlignment: root.adjustment !== null
|
||||
|| root.supportingActionText.length > 0
|
||||
? Text.AlignRight : Text.AlignLeft
|
||||
elide: Text.ElideRight
|
||||
visible: text.length > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
sourceComponent: root.accessory
|
||||
visible: status === Loader.Ready
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.topMargin: 17
|
||||
Layout.preferredWidth: root.accessoryWidth
|
||||
Layout.preferredHeight: root.accessoryHeight
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 180 }
|
||||
}
|
||||
}
|
||||
80
apps/amm/qml/components/liquidity/AmmTokenSelectButton.qml
Normal file
80
apps/amm/qml/components/liquidity/AmmTokenSelectButton.qml
Normal file
@ -0,0 +1,80 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
Button {
|
||||
id: root
|
||||
|
||||
required property var theme
|
||||
property bool hasToken: false
|
||||
property string tokenColor: root.theme.colors.noTokenCircle
|
||||
property string tokenLetter: ""
|
||||
property bool showIndicator: true
|
||||
property bool invalid: false
|
||||
property real maximumTextWidth: 112
|
||||
|
||||
implicitWidth: contentRow.implicitWidth + 24
|
||||
implicitHeight: 40
|
||||
leftPadding: 12
|
||||
rightPadding: 12
|
||||
hoverEnabled: true
|
||||
|
||||
contentItem: RowLayout {
|
||||
id: contentRow
|
||||
|
||||
spacing: 6
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 24
|
||||
Layout.preferredHeight: 24
|
||||
visible: root.hasToken
|
||||
radius: 12
|
||||
color: root.tokenColor
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: root.tokenLetter
|
||||
color: "#FFFFFF"
|
||||
font.pixelSize: 10
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.maximumWidth: root.maximumTextWidth
|
||||
text: root.text
|
||||
color: root.enabled
|
||||
? root.theme.colors.textPrimary
|
||||
: root.theme.colors.textPlaceholder
|
||||
font.pixelSize: 15
|
||||
font.weight: root.hasToken ? Font.Medium : Font.Normal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.showIndicator
|
||||
text: "\u25BE"
|
||||
color: root.enabled
|
||||
? root.theme.colors.textSecondary
|
||||
: root.theme.colors.textPlaceholder
|
||||
font.pixelSize: 10
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: 20
|
||||
color: root.pressed
|
||||
? root.theme.colors.selection
|
||||
: root.hovered || root.activeFocus
|
||||
? root.theme.colors.panelHoverBg
|
||||
: root.theme.colors.panelBg
|
||||
border.width: root.invalid || root.activeFocus ? 1 : 0
|
||||
border.color: root.invalid ? root.theme.colors.error : root.theme.colors.ctaBg
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 120 }
|
||||
}
|
||||
}
|
||||
}
|
||||
295
apps/amm/qml/components/liquidity/AmountMath.js
Normal file
295
apps/amm/qml/components/liquidity/AmountMath.js
Normal 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))
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Controls 2.15
|
||||
import QtQuick.Layouts 1.15
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property int currentIndex: 0
|
||||
|
||||
signal tabRequested(int index)
|
||||
|
||||
color: "#181818"
|
||||
implicitHeight: 42
|
||||
radius: 8
|
||||
border.color: "#303030"
|
||||
border.width: 1
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 4
|
||||
spacing: 4
|
||||
|
||||
Button {
|
||||
id: addTab
|
||||
|
||||
activeFocusOnTab: true
|
||||
focusPolicy: Qt.StrongFocus
|
||||
hoverEnabled: true
|
||||
text: qsTr("Add liquidity")
|
||||
|
||||
Accessible.name: addTab.text
|
||||
Accessible.role: Accessible.PageTab
|
||||
|
||||
Layout.fillHeight: true
|
||||
Layout.fillWidth: true
|
||||
|
||||
onClicked: root.tabRequested(0)
|
||||
|
||||
contentItem: Text {
|
||||
color: root.currentIndex === 0 ? "#F2D8C7" : addTab.hovered || addTab.activeFocus ? "#E7E1D8" : "#8E8780"
|
||||
elide: Text.ElideRight
|
||||
font.bold: true
|
||||
font.pixelSize: 12
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: addTab.text
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
border.color: addTab.activeFocus || root.currentIndex === 0 ? "#F26A21" : "#181818"
|
||||
border.width: 1
|
||||
color: addTab.pressed ? "#2A1D16" : root.currentIndex === 0 ? "#211914" : addTab.hovered || addTab.activeFocus ? "#202020" : "#121212"
|
||||
radius: 6
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: removeTab
|
||||
|
||||
activeFocusOnTab: true
|
||||
focusPolicy: Qt.StrongFocus
|
||||
hoverEnabled: true
|
||||
text: qsTr("Remove liquidity")
|
||||
|
||||
Accessible.name: removeTab.text
|
||||
Accessible.role: Accessible.PageTab
|
||||
|
||||
Layout.fillHeight: true
|
||||
Layout.fillWidth: true
|
||||
|
||||
onClicked: root.tabRequested(1)
|
||||
|
||||
contentItem: Text {
|
||||
color: root.currentIndex === 1 ? "#F2D8C7" : removeTab.hovered || removeTab.activeFocus ? "#E7E1D8" : "#8E8780"
|
||||
elide: Text.ElideRight
|
||||
font.bold: true
|
||||
font.pixelSize: 12
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: removeTab.text
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
border.color: removeTab.activeFocus || root.currentIndex === 1 ? "#F26A21" : "#181818"
|
||||
border.width: 1
|
||||
color: removeTab.pressed ? "#2A1D16" : root.currentIndex === 1 ? "#211914" : removeTab.hovered || removeTab.activeFocus ? "#202020" : "#121212"
|
||||
radius: 6
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,87 +5,47 @@ ColumnLayout {
|
||||
id: root
|
||||
|
||||
property var snapshot: ({})
|
||||
readonly property bool isAdd: root.snapshot.action === "add"
|
||||
|
||||
spacing: 8
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
visible: root.isAdd
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "")
|
||||
value: root.snapshot.depositA || ""
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "")
|
||||
value: root.snapshot.depositB || ""
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Receive at least")
|
||||
value: root.snapshot.minLpReceived || ""
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Current ratio")
|
||||
value: root.snapshot.currentRatio || ""
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Fee tier")
|
||||
value: root.snapshot.feeTier || ""
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Slippage tolerance")
|
||||
value: root.snapshot.slippageTolerance || ""
|
||||
}
|
||||
function actionText(instruction) {
|
||||
if (instruction === "NewDefinition")
|
||||
return qsTr("Create pool")
|
||||
if (instruction === "AddLiquidity")
|
||||
return qsTr("Add liquidity")
|
||||
return instruction || "-"
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
visible: !root.isAdd
|
||||
label: qsTr("Pair")
|
||||
value: root.snapshot.pairText || "-"
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Burn LP")
|
||||
value: qsTr("%1 (%2)")
|
||||
.arg(root.snapshot.burnText || "")
|
||||
.arg(root.snapshot.burnPercent || "")
|
||||
}
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Action")
|
||||
value: root.actionText(root.snapshot.instruction)
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "")
|
||||
value: root.snapshot.minTokenAReceived || ""
|
||||
}
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Fee")
|
||||
value: root.snapshot.feeText || "-"
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "")
|
||||
value: root.snapshot.minTokenBReceived || ""
|
||||
}
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Deposit")
|
||||
value: qsTr("%1 + %2")
|
||||
.arg(root.snapshot.depositAText || "-")
|
||||
.arg(root.snapshot.depositBText || "-")
|
||||
valueWrapAnywhere: true
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Slippage tolerance")
|
||||
value: root.snapshot.slippageTolerance || ""
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Post-removal share")
|
||||
value: root.snapshot.postRemovalShare || ""
|
||||
}
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Expected LP")
|
||||
value: root.snapshot.expectedLpText || "-"
|
||||
}
|
||||
}
|
||||
|
||||
1542
apps/amm/qml/components/liquidity/NewPositionForm.qml
Normal file
1542
apps/amm/qml/components/liquidity/NewPositionForm.qml
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,98 +0,0 @@
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Layouts 1.15
|
||||
import "../../state"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
required property DummyPoolState poolState
|
||||
readonly property string estimateHelp: qsTr("This value is an estimate from the current dummy reserves and your share of total LP supply.")
|
||||
|
||||
color: "#151515"
|
||||
implicitHeight: content.implicitHeight + 20
|
||||
radius: 8
|
||||
border.color: "#303030"
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
id: content
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 6
|
||||
|
||||
RowLayout {
|
||||
spacing: 10
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 2
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
Text {
|
||||
color: "#E7E1D8"
|
||||
font.bold: true
|
||||
font.pixelSize: 13
|
||||
text: root.poolState.userLpBalance > 0 ? qsTr("Your position") : qsTr("No position")
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
color: "#8E8780"
|
||||
font.pixelSize: 11
|
||||
text: qsTr("%1 LP tokens").arg(root.poolState.formatInteger(root.poolState.userLpBalance))
|
||||
visible: root.poolState.userLpBalance > 0
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
color: "#211914"
|
||||
radius: 10
|
||||
border.color: "#49301F"
|
||||
border.width: 1
|
||||
|
||||
Layout.preferredHeight: 24
|
||||
Layout.preferredWidth: shareText.implicitWidth + 18
|
||||
|
||||
Text {
|
||||
id: shareText
|
||||
|
||||
anchors.centerIn: parent
|
||||
color: "#F2D8C7"
|
||||
font.bold: true
|
||||
font.pixelSize: 11
|
||||
text: root.poolState.userLpBalance > 0 ? root.poolState.formatPoolShare(root.poolState.poolShare) : root.poolState.feeTier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
estimated: true
|
||||
estimateHelp: root.estimateHelp
|
||||
label: qsTr("Owned")
|
||||
value: qsTr("%1 + %2").arg(root.poolState.formatCompactTokenAmount(root.poolState.userOwnedA, root.poolState.tokenA)).arg(root.poolState.formatCompactTokenAmount(root.poolState.userOwnedB, root.poolState.tokenB))
|
||||
visible: root.poolState.userLpBalance > 0
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
label: qsTr("Pool")
|
||||
value: qsTr("%1 / %2").arg(root.poolState.formatCompactTokenAmount(root.poolState.reserveA, root.poolState.tokenA)).arg(root.poolState.formatCompactTokenAmount(root.poolState.reserveB, root.poolState.tokenB))
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
label: qsTr("Fee")
|
||||
value: root.poolState.feeTier
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,492 +0,0 @@
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Controls 2.15
|
||||
import QtQuick.Layouts 1.15
|
||||
import "../shared"
|
||||
import "../../state"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
required property DummyPoolState poolState
|
||||
|
||||
property real slippageTolerancePercent: 0.5
|
||||
property int burnAmount: 0
|
||||
readonly property int maxBurnAmount: root.poolState.clampBurnAmount(root.poolState.userLpBalance)
|
||||
readonly property bool hasLpTokens: root.maxBurnAmount > 0
|
||||
readonly property int preset25Amount: root.poolState.burnAmountForPercent(25)
|
||||
readonly property int preset50Amount: root.poolState.burnAmountForPercent(50)
|
||||
readonly property int preset75Amount: root.poolState.burnAmountForPercent(75)
|
||||
readonly property real removePercent: root.maxBurnAmount > 0 ? root.burnAmount * 100 / root.maxBurnAmount : 0
|
||||
readonly property var preview: root.poolState.removeLiquidityPreview(root.burnAmount)
|
||||
readonly property int minTokenAReceived: root.poolState.minReceivedAmount(root.preview.withdrawA, root.slippageTolerancePercent)
|
||||
readonly property int minTokenBReceived: root.poolState.minReceivedAmount(root.preview.withdrawB, root.slippageTolerancePercent)
|
||||
readonly property bool minReceivedIsZero: root.burnAmount > 0 && (root.minTokenAReceived === 0 || root.minTokenBReceived === 0)
|
||||
readonly property bool canSubmit: root.hasLpTokens && root.burnAmount > 0 && !root.minReceivedIsZero
|
||||
readonly property string estimateHelp: qsTr("Estimated with the same integer floor math used by the remove-liquidity contract path.")
|
||||
readonly property string submitButtonText: !root.hasLpTokens ? qsTr("No LP balance") : root.burnAmount === 0 ? qsTr("Enter an amount") : root.minReceivedIsZero ? qsTr("Minimum received is 0") : qsTr("Remove Liquidity")
|
||||
|
||||
signal slippageToleranceChangeRequested(real tolerancePercent)
|
||||
signal removeLiquidityRequested(var snapshot)
|
||||
|
||||
color: "#00000000"
|
||||
implicitHeight: content.implicitHeight
|
||||
radius: 0
|
||||
border.width: 0
|
||||
|
||||
onMaxBurnAmountChanged: {
|
||||
if (root.burnAmount > root.maxBurnAmount) {
|
||||
root.setBurnAmount(root.maxBurnAmount);
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: content
|
||||
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
|
||||
Text {
|
||||
color: "#F26A21"
|
||||
font.pixelSize: 12
|
||||
text: qsTr("No LP tokens")
|
||||
visible: !root.hasLpTokens
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
color: "#A9A098"
|
||||
font.pixelSize: 12
|
||||
lineHeight: 1.25
|
||||
text: qsTr("Add liquidity first to receive LP tokens before removing from this pool.")
|
||||
visible: !root.hasLpTokens
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
color: root.hasLpTokens ? "#151515" : "#121212"
|
||||
radius: 8
|
||||
border.color: burnField.activeFocus ? "#F26A21" : "#343434"
|
||||
border.width: 1
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: inputContent.implicitHeight + 20
|
||||
|
||||
ColumnLayout {
|
||||
id: inputContent
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 8
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
Text {
|
||||
color: "#A9A098"
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: 12
|
||||
text: qsTr("LP tokens to burn")
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
color: "#A9A098"
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: 11
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: qsTr("Available LP: %1").arg(root.poolState.formatInteger(root.poolState.userLpBalance))
|
||||
|
||||
Layout.maximumWidth: 170
|
||||
}
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: burnField
|
||||
|
||||
activeFocusOnTab: root.hasLpTokens
|
||||
color: "#E7E1D8"
|
||||
enabled: root.hasLpTokens
|
||||
font.bold: true
|
||||
font.pixelSize: 18
|
||||
inputMethodHints: Qt.ImhDigitsOnly
|
||||
placeholderText: qsTr("0")
|
||||
selectByMouse: true
|
||||
selectedTextColor: "#151515"
|
||||
selectionColor: "#F26A21"
|
||||
text: root.burnAmount > 0 ? String(root.burnAmount) : ""
|
||||
validator: RegularExpressionValidator {
|
||||
regularExpression: /[0-9]*/
|
||||
}
|
||||
|
||||
Accessible.name: qsTr("LP tokens to burn")
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: 44
|
||||
|
||||
onTextEdited: root.setBurnAmount(text)
|
||||
|
||||
background: Rectangle {
|
||||
border.color: burnField.activeFocus ? "#F26A21" : "#343434"
|
||||
border.width: 1
|
||||
color: burnField.activeFocus ? "#1F1B18" : "#101010"
|
||||
radius: 6
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
spacing: 6
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
Button {
|
||||
id: preset25
|
||||
|
||||
activeFocusOnTab: root.hasLpTokens
|
||||
enabled: root.hasLpTokens
|
||||
focusPolicy: Qt.StrongFocus
|
||||
hoverEnabled: true
|
||||
text: qsTr("25%")
|
||||
|
||||
Accessible.name: qsTr("Remove 25 percent")
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: 44
|
||||
|
||||
onClicked: root.setBurnPercent(25)
|
||||
|
||||
contentItem: Text {
|
||||
color: preset25.enabled && (preset25.hovered || preset25.activeFocus || root.preset25Amount === root.burnAmount) ? "#151515" : "#A9A098"
|
||||
font.bold: true
|
||||
font.pixelSize: 11
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: preset25.text
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
border.color: preset25.activeFocus || root.preset25Amount === root.burnAmount ? "#F26A21" : "#343434"
|
||||
border.width: 1
|
||||
color: preset25.pressed ? "#D95C1E" : root.preset25Amount === root.burnAmount ? "#F26A21" : preset25.hovered || preset25.activeFocus ? "#E7E1D8" : "#151515"
|
||||
radius: 6
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: preset50
|
||||
|
||||
activeFocusOnTab: root.hasLpTokens
|
||||
enabled: root.hasLpTokens
|
||||
focusPolicy: Qt.StrongFocus
|
||||
hoverEnabled: true
|
||||
text: qsTr("50%")
|
||||
|
||||
Accessible.name: qsTr("Remove 50 percent")
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: 44
|
||||
|
||||
onClicked: root.setBurnPercent(50)
|
||||
|
||||
contentItem: Text {
|
||||
color: preset50.enabled && (preset50.hovered || preset50.activeFocus || root.preset50Amount === root.burnAmount) ? "#151515" : "#A9A098"
|
||||
font.bold: true
|
||||
font.pixelSize: 11
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: preset50.text
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
border.color: preset50.activeFocus || root.preset50Amount === root.burnAmount ? "#F26A21" : "#343434"
|
||||
border.width: 1
|
||||
color: preset50.pressed ? "#D95C1E" : root.preset50Amount === root.burnAmount ? "#F26A21" : preset50.hovered || preset50.activeFocus ? "#E7E1D8" : "#151515"
|
||||
radius: 6
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: preset75
|
||||
|
||||
activeFocusOnTab: root.hasLpTokens
|
||||
enabled: root.hasLpTokens
|
||||
focusPolicy: Qt.StrongFocus
|
||||
hoverEnabled: true
|
||||
text: qsTr("75%")
|
||||
|
||||
Accessible.name: qsTr("Remove 75 percent")
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: 44
|
||||
|
||||
onClicked: root.setBurnPercent(75)
|
||||
|
||||
contentItem: Text {
|
||||
color: preset75.enabled && (preset75.hovered || preset75.activeFocus || root.preset75Amount === root.burnAmount) ? "#151515" : "#A9A098"
|
||||
font.bold: true
|
||||
font.pixelSize: 11
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: preset75.text
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
border.color: preset75.activeFocus || root.preset75Amount === root.burnAmount ? "#F26A21" : "#343434"
|
||||
border.width: 1
|
||||
color: preset75.pressed ? "#D95C1E" : root.preset75Amount === root.burnAmount ? "#F26A21" : preset75.hovered || preset75.activeFocus ? "#E7E1D8" : "#151515"
|
||||
radius: 6
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: presetMax
|
||||
|
||||
activeFocusOnTab: root.hasLpTokens
|
||||
enabled: root.hasLpTokens
|
||||
focusPolicy: Qt.StrongFocus
|
||||
hoverEnabled: true
|
||||
text: qsTr("MAX")
|
||||
|
||||
Accessible.name: qsTr("Remove maximum LP balance")
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: 44
|
||||
|
||||
onClicked: root.setBurnAmount(root.maxBurnAmount)
|
||||
|
||||
contentItem: Text {
|
||||
color: presetMax.enabled && (presetMax.hovered || presetMax.activeFocus || root.burnAmount === root.maxBurnAmount) ? "#151515" : "#A9A098"
|
||||
font.bold: true
|
||||
font.pixelSize: 11
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: presetMax.text
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
border.color: presetMax.activeFocus || root.burnAmount === root.maxBurnAmount ? "#F26A21" : "#343434"
|
||||
border.width: 1
|
||||
color: presetMax.pressed ? "#D95C1E" : root.burnAmount === root.maxBurnAmount ? "#F26A21" : presetMax.hovered || presetMax.activeFocus ? "#E7E1D8" : "#151515"
|
||||
radius: 6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 6
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
Text {
|
||||
color: "#A9A098"
|
||||
font.pixelSize: 12
|
||||
text: qsTr("Pool share to remove")
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
color: "#E7E1D8"
|
||||
font.bold: true
|
||||
font.pixelSize: 12
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: root.poolState.formatPercent(root.removePercent)
|
||||
|
||||
Layout.maximumWidth: 72
|
||||
}
|
||||
}
|
||||
|
||||
Slider {
|
||||
id: burnSlider
|
||||
|
||||
activeFocusOnTab: root.hasLpTokens
|
||||
enabled: root.hasLpTokens
|
||||
from: 0
|
||||
stepSize: 1
|
||||
to: 100
|
||||
value: root.removePercent
|
||||
|
||||
Accessible.name: qsTr("Pool share to remove")
|
||||
Accessible.role: Accessible.Slider
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: 44
|
||||
|
||||
onMoved: root.setBurnPercent(Math.round(value))
|
||||
|
||||
background: Rectangle {
|
||||
color: "#343434"
|
||||
implicitHeight: 4
|
||||
radius: 2
|
||||
x: burnSlider.leftPadding
|
||||
y: burnSlider.topPadding + burnSlider.availableHeight / 2 - height / 2
|
||||
|
||||
width: burnSlider.availableWidth
|
||||
|
||||
Rectangle {
|
||||
color: burnSlider.enabled ? "#F26A21" : "#56504A"
|
||||
height: parent.height
|
||||
radius: 2
|
||||
width: burnSlider.visualPosition * parent.width
|
||||
}
|
||||
}
|
||||
|
||||
handle: Rectangle {
|
||||
border.color: burnSlider.activeFocus ? "#E7E1D8" : "#F26A21"
|
||||
border.width: 1
|
||||
color: burnSlider.enabled ? "#F26A21" : "#56504A"
|
||||
height: 18
|
||||
radius: 9
|
||||
width: 18
|
||||
x: burnSlider.leftPadding + burnSlider.visualPosition * (burnSlider.availableWidth - width)
|
||||
y: burnSlider.topPadding + burnSlider.availableHeight / 2 - height / 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
estimated: true
|
||||
estimateHelp: root.estimateHelp
|
||||
label: qsTr("Withdraw %1").arg(root.poolState.tokenA)
|
||||
value: root.poolState.formatTokenAmount(root.preview.withdrawA, root.poolState.tokenA)
|
||||
visible: root.burnAmount > 0
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
estimated: true
|
||||
estimateHelp: root.estimateHelp
|
||||
label: qsTr("Withdraw %1").arg(root.poolState.tokenB)
|
||||
value: root.poolState.formatTokenAmount(root.preview.withdrawB, root.poolState.tokenB)
|
||||
visible: root.burnAmount > 0
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
SlippageToleranceControl {
|
||||
tolerancePercent: root.slippageTolerancePercent
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
onToleranceChangeRequested: function (tolerancePercent) {
|
||||
root.slippageToleranceChangeRequested(tolerancePercent);
|
||||
}
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
label: qsTr("Min %1 received").arg(root.poolState.tokenA)
|
||||
value: root.poolState.formatTokenAmount(root.minTokenAReceived, root.poolState.tokenA)
|
||||
visible: root.burnAmount > 0
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
label: qsTr("Min %1 received").arg(root.poolState.tokenB)
|
||||
value: root.poolState.formatTokenAmount(root.minTokenBReceived, root.poolState.tokenB)
|
||||
visible: root.burnAmount > 0
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
color: "#F08A76"
|
||||
font.pixelSize: 12
|
||||
lineHeight: 1.25
|
||||
text: qsTr("Minimum received is 0. Increase amount or lower slippage.")
|
||||
visible: root.minReceivedIsZero
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
estimated: true
|
||||
estimateHelp: root.estimateHelp
|
||||
label: qsTr("Position after")
|
||||
value: root.poolState.formatPoolShare(root.preview.newUserShare)
|
||||
visible: root.burnAmount > 0
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Button {
|
||||
id: submitButton
|
||||
|
||||
activeFocusOnTab: root.hasLpTokens
|
||||
enabled: root.canSubmit
|
||||
focusPolicy: Qt.StrongFocus
|
||||
hoverEnabled: true
|
||||
text: root.submitButtonText
|
||||
|
||||
Accessible.name: submitButton.text
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: 44
|
||||
Layout.preferredHeight: 44
|
||||
|
||||
onClicked: root.removeLiquidityRequested(root.submitSnapshot())
|
||||
|
||||
contentItem: Text {
|
||||
color: submitButton.enabled ? "#151515" : "#7D756E"
|
||||
elide: Text.ElideRight
|
||||
font.bold: true
|
||||
font.pixelSize: 13
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: submitButton.text
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
border.color: submitButton.enabled ? "#F26A21" : "#343434"
|
||||
border.width: 1
|
||||
color: submitButton.enabled ? submitButton.pressed ? "#D95C1E" : submitButton.hovered || submitButton.activeFocus ? "#FF8A3D" : "#F26A21" : "#181818"
|
||||
radius: 6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setBurnAmount(value) {
|
||||
root.burnAmount = root.poolState.clampBurnAmount(value);
|
||||
}
|
||||
|
||||
function setBurnPercent(percent) {
|
||||
root.setBurnAmount(root.poolState.burnAmountForPercent(percent));
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
root.setBurnAmount(0);
|
||||
}
|
||||
|
||||
function submitSnapshot() {
|
||||
return {
|
||||
"action": "remove",
|
||||
"burnAmount": root.preview.burnedLp,
|
||||
"burnPercent": root.poolState.formatPercent(root.removePercent),
|
||||
"burnText": root.poolState.formatLpAmount(root.preview.burnedLp),
|
||||
"minTokenAReceived": root.poolState.formatTokenAmount(root.minTokenAReceived, root.poolState.tokenA),
|
||||
"minTokenBReceived": root.poolState.formatTokenAmount(root.minTokenBReceived, root.poolState.tokenB),
|
||||
"postRemovalShare": root.poolState.formatPoolShare(root.preview.newUserShare),
|
||||
"slippageTolerance": root.poolState.formatPercent(root.slippageTolerancePercent),
|
||||
"tokenA": root.poolState.tokenA,
|
||||
"tokenB": root.poolState.tokenB,
|
||||
"withdrawA": root.preview.withdrawA,
|
||||
"withdrawB": root.preview.withdrawB,
|
||||
"withdrawAText": root.poolState.formatTokenAmount(root.preview.withdrawA, root.poolState.tokenA),
|
||||
"withdrawBText": root.poolState.formatTokenAmount(root.preview.withdrawB, root.poolState.tokenB)
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ Item {
|
||||
|
||||
property string label: ""
|
||||
property string value: ""
|
||||
property bool valueWrapAnywhere: false
|
||||
property bool estimated: false
|
||||
property string estimateHelp: qsTr("This value is derived from your LP token balance, total LP supply, and current pool reserves.")
|
||||
|
||||
@ -36,14 +37,16 @@ Item {
|
||||
|
||||
Text {
|
||||
color: "#E7E1D8"
|
||||
elide: Text.ElideRight
|
||||
elide: root.valueWrapAnywhere ? Text.ElideNone : Text.ElideRight
|
||||
font.bold: true
|
||||
font.pixelSize: 12
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: root.value
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
wrapMode: root.valueWrapAnywhere ? Text.WrapAtWordBoundaryOrAnywhere : Text.NoWrap
|
||||
|
||||
Layout.maximumWidth: Math.max(178, root.width * 0.55)
|
||||
Layout.preferredWidth: Math.min(implicitWidth, Math.max(178, root.width * 0.55))
|
||||
}
|
||||
|
||||
EstimateInfoButton {
|
||||
|
||||
@ -1,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
|
||||
}
|
||||
}
|
||||
|
||||
489
apps/amm/qml/components/liquidity/TokenSelectorModal.qml
Normal file
489
apps/amm/qml/components/liquidity/TokenSelectorModal.qml
Normal file
@ -0,0 +1,489 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
|
||||
required property var theme
|
||||
property var tokens: []
|
||||
property string searchText: ""
|
||||
property string title: qsTr("Select a token")
|
||||
property string searchPlaceholder: qsTr("Search tokens")
|
||||
property string popularTitle: qsTr("Popular tokens")
|
||||
property string listTitle: qsTr("Tokens by 24H volume")
|
||||
property bool allowCustomEntry: false
|
||||
property var disabledReasonForCode: function(code) {
|
||||
return qsTr("This token is unavailable (%1).").arg(code || "unknown")
|
||||
}
|
||||
property var detailForToken: function(token) {
|
||||
return token && token.balance !== undefined
|
||||
? qsTr("Balance %1").arg(token.balance) : ""
|
||||
}
|
||||
readonly property var rows: root.filteredTokens()
|
||||
readonly property var popularTokens: root.selectableTokens().slice(0, 5)
|
||||
readonly property bool compact: root.height < 360
|
||||
readonly property bool showPopular: root.popularTokens.length > 0 && root.height >= 440
|
||||
|
||||
signal tokenSelected(var token)
|
||||
signal tokenEntered(string value)
|
||||
|
||||
parent: Overlay.overlay
|
||||
x: parent ? Math.round((parent.width - width) / 2) : 0
|
||||
y: parent ? Math.round((parent.height - height) / 2) : 0
|
||||
width: parent && parent.width > 32
|
||||
? Math.max(0, Math.min(480, parent.width - 32)) : 280
|
||||
height: parent && parent.height > 32
|
||||
? Math.max(0, Math.min(600, parent.height - 32)) : 360
|
||||
padding: root.compact ? 8 : 20
|
||||
modal: true
|
||||
focus: true
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
onOpened: {
|
||||
root.searchText = ""
|
||||
searchField.text = ""
|
||||
Qt.callLater(searchField.forceActiveFocus)
|
||||
}
|
||||
|
||||
Overlay.modal: Rectangle {
|
||||
color: Qt.rgba(0, 0, 0, 0.4)
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: 24
|
||||
color: root.theme.colors.cardBg
|
||||
border.color: root.theme.colors.border
|
||||
border.width: 1
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 180 }
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
spacing: root.compact ? 8 : 16
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: root.title
|
||||
color: root.theme.colors.textPrimary
|
||||
font.pixelSize: 18
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
|
||||
Button {
|
||||
id: closeButton
|
||||
|
||||
Layout.preferredWidth: 32
|
||||
Layout.preferredHeight: 32
|
||||
text: "\u00D7"
|
||||
hoverEnabled: true
|
||||
Accessible.name: qsTr("Close token selector")
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: Accessible.name
|
||||
onClicked: root.close()
|
||||
|
||||
contentItem: Text {
|
||||
text: closeButton.text
|
||||
color: root.theme.colors.textSecondary
|
||||
font.pixelSize: 16
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: 16
|
||||
color: closeButton.hovered || closeButton.activeFocus
|
||||
? root.theme.colors.panelHoverBg
|
||||
: root.theme.colors.panelBg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: root.compact ? 40 : 48
|
||||
radius: 16
|
||||
color: root.theme.colors.inputBg
|
||||
border.color: searchField.activeFocus
|
||||
? root.theme.colors.ctaBg
|
||||
: root.theme.colors.border
|
||||
border.width: 1
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 14
|
||||
anchors.rightMargin: 14
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: "\u2315"
|
||||
color: root.theme.colors.textSecondary
|
||||
font.pixelSize: 20
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: searchField
|
||||
|
||||
objectName: "tokenSearchField"
|
||||
|
||||
Layout.fillWidth: true
|
||||
color: root.theme.colors.textPrimary
|
||||
placeholderText: root.searchPlaceholder
|
||||
placeholderTextColor: root.theme.colors.textPlaceholder
|
||||
selectionColor: root.theme.colors.selection
|
||||
selectedTextColor: root.theme.colors.textPrimary
|
||||
font.pixelSize: 15
|
||||
selectByMouse: true
|
||||
background: null
|
||||
|
||||
onTextEdited: root.searchText = text
|
||||
onAccepted: root.acceptInput(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: root.showPopular
|
||||
text: root.popularTitle
|
||||
color: root.theme.colors.textSecondary
|
||||
font.pixelSize: 13
|
||||
}
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
visible: root.showPopular
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: root.popularTokens
|
||||
|
||||
delegate: AmmTokenSelectButton {
|
||||
required property var modelData
|
||||
|
||||
theme: root.theme
|
||||
hasToken: true
|
||||
tokenColor: root.tokenColor(modelData)
|
||||
tokenLetter: root.tokenLetter(modelData)
|
||||
showIndicator: false
|
||||
text: root.tokenSymbol(modelData).length > 0
|
||||
? root.tokenSymbol(modelData) : root.tokenName(modelData)
|
||||
maximumTextWidth: 84
|
||||
width: Math.min(140, implicitWidth)
|
||||
onClicked: root.chooseToken(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: !root.compact
|
||||
text: root.listTitle
|
||||
color: root.theme.colors.textSecondary
|
||||
font.pixelSize: 13
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
ListView {
|
||||
id: tokenList
|
||||
|
||||
objectName: "tokenList"
|
||||
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
spacing: 2
|
||||
model: root.rows
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollIndicator.vertical: ScrollIndicator { }
|
||||
|
||||
delegate: Item {
|
||||
id: tokenRow
|
||||
|
||||
required property var modelData
|
||||
readonly property bool selectable: root.isSelectable(modelData)
|
||||
readonly property string disabledReason: root.disabledReasonForCode(
|
||||
modelData.code
|
||||
|| modelData.status)
|
||||
readonly property bool pointerHovered: rowHover.containsMouse
|
||||
readonly property bool disabledReasonVisible: (pointerHovered || activeFocus)
|
||||
&& !selectable
|
||||
|
||||
width: ListView.view.width
|
||||
height: 56
|
||||
activeFocusOnTab: true
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: root.tokenName(modelData)
|
||||
Accessible.description: selectable
|
||||
? root.detailForToken(modelData)
|
||||
: disabledReason
|
||||
Accessible.onPressAction: tokenRow.activate()
|
||||
Keys.onReturnPressed: function(event) {
|
||||
tokenRow.activate()
|
||||
event.accepted = true
|
||||
}
|
||||
Keys.onEnterPressed: function(event) {
|
||||
tokenRow.activate()
|
||||
event.accepted = true
|
||||
}
|
||||
Keys.onSpacePressed: function(event) {
|
||||
tokenRow.activate()
|
||||
event.accepted = true
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 12
|
||||
color: tokenRow.pointerHovered || tokenRow.activeFocus
|
||||
? root.theme.colors.panelBg : "transparent"
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
spacing: 12
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 36
|
||||
Layout.preferredHeight: 36
|
||||
radius: 18
|
||||
color: root.tokenColor(tokenRow.modelData)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: root.tokenLetter(tokenRow.modelData)
|
||||
color: "#FFFFFF"
|
||||
font.pixelSize: 14
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: root.tokenName(tokenRow.modelData)
|
||||
color: tokenRow.selectable
|
||||
? root.theme.colors.textPrimary
|
||||
: root.theme.colors.textPlaceholder
|
||||
font.pixelSize: 15
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
visible: text.length > 0
|
||||
text: root.tokenSymbol(tokenRow.modelData)
|
||||
color: root.theme.colors.textSecondary
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: root.shortAddress(root.tokenAddress(tokenRow.modelData))
|
||||
color: root.theme.colors.textPlaceholder
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.maximumWidth: 118
|
||||
text: root.detailForToken(tokenRow.modelData)
|
||||
color: root.theme.colors.textSecondary
|
||||
font.pixelSize: 11
|
||||
horizontalAlignment: Text.AlignRight
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: rowHover
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: tokenRow.selectable
|
||||
? Qt.PointingHandCursor : Qt.ForbiddenCursor
|
||||
onClicked: tokenRow.activate()
|
||||
}
|
||||
|
||||
ToolTip.visible: tokenRow.disabledReasonVisible
|
||||
ToolTip.text: tokenRow.disabledReason
|
||||
|
||||
function activate() {
|
||||
if (tokenRow.selectable)
|
||||
root.chooseToken(tokenRow.modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: customEntryButton
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
visible: root.rows.length === 0
|
||||
&& root.allowCustomEntry
|
||||
&& root.searchText.trim().length > 0
|
||||
implicitHeight: 56
|
||||
text: qsTr("Use TokenDefinition address")
|
||||
onClicked: root.acceptInput(root.searchText)
|
||||
|
||||
contentItem: Column {
|
||||
leftPadding: 8
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
width: parent.width - 16
|
||||
text: qsTr("Use TokenDefinition address")
|
||||
color: root.theme.colors.textPrimary
|
||||
font.pixelSize: 14
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width - 16
|
||||
text: root.searchText
|
||||
color: root.theme.colors.textSecondary
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: 12
|
||||
color: customEntryButton.hovered || customEntryButton.activeFocus
|
||||
? root.theme.colors.panelBg : "transparent"
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: 16
|
||||
visible: root.rows.length === 0
|
||||
&& (!root.allowCustomEntry
|
||||
|| root.searchText.trim().length === 0)
|
||||
text: qsTr("No tokens found")
|
||||
color: root.theme.colors.textSecondary
|
||||
font.pixelSize: 13
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filteredTokens() {
|
||||
var needle = root.searchText.trim().toLowerCase()
|
||||
if (needle.length === 0)
|
||||
return root.tokens
|
||||
var result = []
|
||||
for (var i = 0; i < root.tokens.length; ++i) {
|
||||
var token = root.tokens[i]
|
||||
if (root.tokenName(token).toLowerCase().indexOf(needle) >= 0
|
||||
|| root.tokenSymbol(token).toLowerCase().indexOf(needle) >= 0
|
||||
|| root.tokenAddress(token).toLowerCase().indexOf(needle) >= 0)
|
||||
result.push(token)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function selectableTokens() {
|
||||
var result = []
|
||||
for (var i = 0; i < root.tokens.length; ++i) {
|
||||
if (root.isSelectable(root.tokens[i]))
|
||||
result.push(root.tokens[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function acceptInput(value) {
|
||||
var entered = String(value || "").trim()
|
||||
if (entered.length === 0)
|
||||
return
|
||||
var lowered = entered.toLowerCase()
|
||||
for (var i = 0; i < root.tokens.length; ++i) {
|
||||
var token = root.tokens[i]
|
||||
if (root.tokenAddress(token).toLowerCase() === lowered
|
||||
|| root.tokenName(token).toLowerCase() === lowered
|
||||
|| root.tokenSymbol(token).toLowerCase() === lowered) {
|
||||
if (root.isSelectable(token))
|
||||
root.chooseToken(token)
|
||||
else {
|
||||
root.tokenEntered(root.tokenAddress(token))
|
||||
root.close()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if (root.rows.length === 1 && root.isSelectable(root.rows[0])) {
|
||||
root.chooseToken(root.rows[0])
|
||||
return
|
||||
}
|
||||
if (root.allowCustomEntry) {
|
||||
root.tokenEntered(entered)
|
||||
root.close()
|
||||
}
|
||||
}
|
||||
|
||||
function chooseToken(token) {
|
||||
if (!root.isSelectable(token))
|
||||
return
|
||||
root.tokenSelected(token)
|
||||
root.close()
|
||||
}
|
||||
|
||||
function isSelectable(token) {
|
||||
return token && token.selectable !== false
|
||||
}
|
||||
|
||||
function tokenName(token) {
|
||||
if (!token)
|
||||
return qsTr("Unknown token")
|
||||
return String(token.name || token.symbol || qsTr("Unknown token"))
|
||||
}
|
||||
|
||||
function tokenSymbol(token) {
|
||||
return token ? String(token.symbol || "") : ""
|
||||
}
|
||||
|
||||
function tokenAddress(token) {
|
||||
return token ? String(token.definitionId || token.address || "") : ""
|
||||
}
|
||||
|
||||
function tokenColor(token) {
|
||||
return token && token.color ? token.color : root.theme.colors.noTokenCircle
|
||||
}
|
||||
|
||||
function tokenLetter(token) {
|
||||
if (token && token.letter)
|
||||
return String(token.letter)
|
||||
var label = root.tokenSymbol(token) || root.tokenName(token)
|
||||
return label.length > 0 ? label.charAt(0).toUpperCase() : ""
|
||||
}
|
||||
|
||||
function shortAddress(value) {
|
||||
var text = String(value || "")
|
||||
return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text
|
||||
}
|
||||
}
|
||||
@ -1,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,205 +0,0 @@
|
||||
import QtQuick 2.15
|
||||
|
||||
QtObject {
|
||||
id: root
|
||||
|
||||
property string tokenA: "USDC"
|
||||
property string tokenB: "ETH"
|
||||
property string feeTier: "0.30%"
|
||||
property real userLpBalance: 1118033
|
||||
property real reserveA: 1000000
|
||||
property real reserveB: 500
|
||||
property real totalLpSupply: 22360679
|
||||
property real walletBalanceA: 60000
|
||||
property real walletBalanceB: 20
|
||||
readonly property real minimumLiquidity: 1000
|
||||
|
||||
readonly property real poolShare: totalLpSupply > 0 ? userLpBalance / totalLpSupply : 0
|
||||
readonly property real userOwnedA: reserveA * poolShare
|
||||
readonly property real userOwnedB: reserveB * poolShare
|
||||
readonly property real tokenAPerTokenB: reserveB > 0 ? Math.floor(reserveA / reserveB) : 0
|
||||
|
||||
function applyAddLiquidity(actualA, actualB, mintedLp) {
|
||||
const safeA = Math.max(0, Number(actualA) || 0);
|
||||
const safeB = Math.max(0, Number(actualB) || 0);
|
||||
const safeLp = Math.max(0, Number(mintedLp) || 0);
|
||||
|
||||
reserveA += safeA;
|
||||
reserveB += safeB;
|
||||
totalLpSupply += safeLp;
|
||||
userLpBalance += safeLp;
|
||||
}
|
||||
|
||||
function applyRemoveLiquidity(withdrawA, withdrawB, burnedLp) {
|
||||
const safeA = Math.max(0, Number(withdrawA) || 0);
|
||||
const safeB = Math.max(0, Number(withdrawB) || 0);
|
||||
const safeLp = Math.max(0, Number(burnedLp) || 0);
|
||||
|
||||
reserveA = Math.max(0, reserveA - safeA);
|
||||
reserveB = Math.max(0, reserveB - safeB);
|
||||
totalLpSupply = Math.max(0, totalLpSupply - safeLp);
|
||||
userLpBalance = Math.max(0, userLpBalance - safeLp);
|
||||
}
|
||||
|
||||
function resetDummyState() {
|
||||
tokenA = "USDC";
|
||||
tokenB = "ETH";
|
||||
feeTier = "0.30%";
|
||||
userLpBalance = 1118033;
|
||||
reserveA = 1000000;
|
||||
reserveB = 500;
|
||||
totalLpSupply = 22360679;
|
||||
walletBalanceA = 60000;
|
||||
walletBalanceB = 20;
|
||||
}
|
||||
|
||||
function parseAmount(value) {
|
||||
return Math.max(0, Number(value) || 0);
|
||||
}
|
||||
|
||||
function floorAmount(value) {
|
||||
return Math.floor(parseAmount(value));
|
||||
}
|
||||
|
||||
function amountBForA(amountA) {
|
||||
if (reserveA <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return reserveB * parseAmount(amountA) / reserveA;
|
||||
}
|
||||
|
||||
function amountAForB(amountB) {
|
||||
if (reserveB <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return reserveA * parseAmount(amountB) / reserveB;
|
||||
}
|
||||
|
||||
function addLiquidityPreview(maxA, maxB) {
|
||||
const safeMaxA = parseAmount(maxA);
|
||||
const safeMaxB = parseAmount(maxB);
|
||||
const idealA = reserveB > 0 ? reserveA * safeMaxB / reserveB : 0;
|
||||
const idealB = reserveA > 0 ? reserveB * safeMaxA / reserveA : 0;
|
||||
const actualA = Math.min(idealA, safeMaxA);
|
||||
const actualB = Math.min(idealB, safeMaxB);
|
||||
const lpFromA = reserveA > 0 ? Math.floor(totalLpSupply * actualA / reserveA) : 0;
|
||||
const lpFromB = reserveB > 0 ? Math.floor(totalLpSupply * actualB / reserveB) : 0;
|
||||
|
||||
return {
|
||||
"actualA": actualA,
|
||||
"actualB": actualB,
|
||||
"deltaLp": Math.min(lpFromA, lpFromB),
|
||||
"idealA": idealA,
|
||||
"idealB": idealB
|
||||
};
|
||||
}
|
||||
|
||||
function maxAddLiquidityForBalances() {
|
||||
return addLiquidityPreview(walletBalanceA, walletBalanceB);
|
||||
}
|
||||
|
||||
function clampBurnAmount(value) {
|
||||
return Math.min(floorAmount(value), Math.max(0, floorAmount(userLpBalance)));
|
||||
}
|
||||
|
||||
function clampSlippageTolerancePercent(value) {
|
||||
return Math.max(0.01, Math.min(50, Number(value) || 0));
|
||||
}
|
||||
|
||||
function minReceivedAmount(previewAmount, slippageTolerancePercent) {
|
||||
const safeAmount = floorAmount(previewAmount);
|
||||
const safeSlippage = clampSlippageTolerancePercent(slippageTolerancePercent);
|
||||
|
||||
return Math.floor(safeAmount * (1 - safeSlippage / 100));
|
||||
}
|
||||
|
||||
function burnAmountForPercent(percent) {
|
||||
const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
|
||||
|
||||
if (safePercent === 100) {
|
||||
return clampBurnAmount(userLpBalance);
|
||||
}
|
||||
|
||||
return clampBurnAmount(Math.floor(userLpBalance * safePercent / 100));
|
||||
}
|
||||
|
||||
function removeLiquidityPreview(burnedLp) {
|
||||
const safeBurnedLp = totalLpSupply > 0 ? Math.min(clampBurnAmount(burnedLp), floorAmount(totalLpSupply)) : 0;
|
||||
const withdrawA = totalLpSupply > 0 ? Math.floor(reserveA * safeBurnedLp / totalLpSupply) : 0;
|
||||
const withdrawB = totalLpSupply > 0 ? Math.floor(reserveB * safeBurnedLp / totalLpSupply) : 0;
|
||||
const newTotalLpSupply = Math.max(0, floorAmount(totalLpSupply) - safeBurnedLp);
|
||||
const newUserLpBalance = Math.max(0, floorAmount(userLpBalance) - safeBurnedLp);
|
||||
|
||||
return {
|
||||
"burnedLp": safeBurnedLp,
|
||||
"newReserveA": Math.max(0, reserveA - withdrawA),
|
||||
"newReserveB": Math.max(0, reserveB - withdrawB),
|
||||
"newTotalLpSupply": newTotalLpSupply,
|
||||
"newUserLpBalance": newUserLpBalance,
|
||||
"newUserShare": newTotalLpSupply > 0 ? newUserLpBalance / newTotalLpSupply : 0,
|
||||
"withdrawA": withdrawA,
|
||||
"withdrawB": withdrawB
|
||||
};
|
||||
}
|
||||
|
||||
function formatInteger(value) {
|
||||
const rounded = Math.round(Number(value) || 0);
|
||||
return rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
function formatDecimal(value) {
|
||||
const amount = Number(value) || 0;
|
||||
|
||||
if (Math.abs(amount - Math.round(amount)) < 0.000001) {
|
||||
return formatInteger(amount);
|
||||
}
|
||||
|
||||
return amount.toFixed(6).replace(/0+$/, "").replace(/[.]$/, "");
|
||||
}
|
||||
|
||||
function formatCompactDecimal(value) {
|
||||
const amount = Number(value) || 0;
|
||||
|
||||
if (Math.abs(amount) >= 1000 || Math.abs(amount - Math.round(amount)) < 0.000001) {
|
||||
return formatInteger(amount);
|
||||
}
|
||||
|
||||
if (Math.abs(amount) >= 1) {
|
||||
return amount.toFixed(2).replace(/0+$/, "").replace(/[.]$/, "");
|
||||
}
|
||||
|
||||
return amount.toFixed(6).replace(/0+$/, "").replace(/[.]$/, "");
|
||||
}
|
||||
|
||||
function formatInputAmount(value) {
|
||||
return formatDecimal(value);
|
||||
}
|
||||
|
||||
function formatTokenAmount(value, token) {
|
||||
return formatDecimal(value) + " " + token;
|
||||
}
|
||||
|
||||
function formatCompactTokenAmount(value, token) {
|
||||
return formatCompactDecimal(value) + " " + token;
|
||||
}
|
||||
|
||||
function formatLpAmount(value) {
|
||||
return formatInteger(value) + " LP";
|
||||
}
|
||||
|
||||
function formatPoolShare(value) {
|
||||
return "\u2248 " + (Math.max(0, Number(value) || 0) * 100).toFixed(2) + "%";
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const amount = Math.max(0, Number(value) || 0);
|
||||
|
||||
if (Math.abs(amount - Math.round(amount)) < 0.000001) {
|
||||
return Math.round(amount).toString() + "%";
|
||||
}
|
||||
|
||||
return amount.toFixed(2).replace(/0+$/, "").replace(/[.]$/, "") + "%";
|
||||
}
|
||||
}
|
||||
375
apps/amm/qml/state/NewPositionFlow.qml
Normal file
375
apps/amm/qml/state/NewPositionFlow.qml
Normal file
@ -0,0 +1,375 @@
|
||||
import QtQml
|
||||
|
||||
QtObject {
|
||||
id: root
|
||||
|
||||
property var backend: null
|
||||
property var runtime: null
|
||||
property bool active: false
|
||||
|
||||
readonly property bool walletStateReady: root.backend !== null
|
||||
&& root.backend.walletStateReady === true
|
||||
readonly property var newPositionContext: root.walletStateReady
|
||||
&& root.backend.newPositionContext
|
||||
? root.backend.newPositionContext
|
||||
: root.loadingContext()
|
||||
readonly property var viewState: ({
|
||||
"quote": root.newPositionQuote,
|
||||
"contextLoading": root.contextLoading || !root.walletStateReady
|
||||
|| root.newPositionContext.status === "loading",
|
||||
"quoteLoading": root.quoteLoading,
|
||||
"quoteStale": root.quoteStale,
|
||||
"submitting": root.submitting,
|
||||
"poolCreationPending": root.selectedPoolCreationPending(),
|
||||
"transactionId": root.transactionId,
|
||||
"errorCode": root.flowErrorCode || root.contextErrorCode
|
||||
|| root.quoteErrorCode
|
||||
})
|
||||
|
||||
property var newPositionQuote: ({})
|
||||
property var resolvedTokenIds: []
|
||||
property int contextSerial: 0
|
||||
property int quoteSerial: 0
|
||||
property bool contextLoading: false
|
||||
property bool quoteLoading: false
|
||||
property bool quoteStale: true
|
||||
property bool submitting: false
|
||||
property string transactionId: ""
|
||||
property string flowErrorCode: ""
|
||||
property string contextErrorCode: ""
|
||||
property string quoteErrorCode: ""
|
||||
property var pendingQuoteRequest: ({ "ok": false, "request": ({}) })
|
||||
property var pendingPoolProbes: []
|
||||
property bool poolProbeInFlight: false
|
||||
|
||||
signal tokenResolutionFinished(bool finalResponse)
|
||||
signal tokenResolutionFailed(string code)
|
||||
signal poolActivated(var quote)
|
||||
signal quoteRefreshRequested(bool immediate)
|
||||
signal submitSucceeded
|
||||
signal submitFailed
|
||||
|
||||
objectName: "newPositionFlow"
|
||||
|
||||
property Timer quoteDebounce: Timer {
|
||||
interval: 250
|
||||
repeat: false
|
||||
onTriggered: root.requestQuoteNow(root.quoteSerial)
|
||||
}
|
||||
|
||||
property Timer poolPoller: Timer {
|
||||
interval: 5000
|
||||
repeat: true
|
||||
running: root.pendingPoolProbes.length > 0
|
||||
onTriggered: root.pollPendingPool()
|
||||
}
|
||||
|
||||
onNewPositionContextChanged: root.invalidateQuote()
|
||||
|
||||
onWalletStateReadyChanged: {
|
||||
++root.contextSerial
|
||||
if (!root.walletStateReady)
|
||||
root.contextLoading = false
|
||||
root.invalidateQuote()
|
||||
}
|
||||
|
||||
onActiveChanged: {
|
||||
if (!root.active)
|
||||
return
|
||||
Qt.callLater(function() {
|
||||
if (root.active && root.walletStateReady)
|
||||
root.quoteRefreshRequested(true)
|
||||
})
|
||||
}
|
||||
|
||||
function contextHints(refreshWalletAccounts) {
|
||||
const request = root.pendingQuoteRequest.request || {}
|
||||
const recent = []
|
||||
if (request.tokenAId)
|
||||
recent.push(request.tokenAId)
|
||||
if (request.tokenBId && request.tokenBId !== request.tokenAId)
|
||||
recent.push(request.tokenBId)
|
||||
return {
|
||||
"recentTokenIds": recent,
|
||||
"resolvedTokenIds": root.resolvedTokenIds,
|
||||
"refreshWalletAccounts": refreshWalletAccounts === true
|
||||
}
|
||||
}
|
||||
|
||||
function refreshContext(refreshWalletAccounts, completed) {
|
||||
const serial = ++root.contextSerial
|
||||
root.contextLoading = true
|
||||
if (!root.walletStateReady || root.runtime === null) {
|
||||
root.contextLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
root.runtime.watch(root.backend.refreshNewPositionContext(
|
||||
root.contextHints(refreshWalletAccounts)),
|
||||
function() {
|
||||
root.finishContextRefresh(serial, completed)
|
||||
},
|
||||
function(error) {
|
||||
root.failContextRefresh(serial)
|
||||
})
|
||||
}
|
||||
|
||||
function finishContextRefresh(serial, completed) {
|
||||
if (serial !== root.contextSerial)
|
||||
return
|
||||
root.contextLoading = false
|
||||
root.contextErrorCode = ""
|
||||
Qt.callLater(function() {
|
||||
if (serial !== root.contextSerial)
|
||||
return
|
||||
root.tokenResolutionFinished(true)
|
||||
if (completed)
|
||||
completed()
|
||||
})
|
||||
}
|
||||
|
||||
function failContextRefresh(serial) {
|
||||
if (serial !== root.contextSerial)
|
||||
return
|
||||
root.contextLoading = false
|
||||
root.contextErrorCode = "backend_error"
|
||||
root.tokenResolutionFailed("backend_error")
|
||||
}
|
||||
|
||||
function resolveToken(tokenId) {
|
||||
const value = String(tokenId || "").trim()
|
||||
if (value.length === 0)
|
||||
return
|
||||
if (root.resolvedTokenIds.indexOf(value) < 0) {
|
||||
const next = root.resolvedTokenIds.slice(0)
|
||||
next.push(value)
|
||||
root.resolvedTokenIds = next
|
||||
}
|
||||
root.refreshContext(false)
|
||||
}
|
||||
|
||||
function scheduleQuote(immediate, quoteRequest) {
|
||||
++root.quoteSerial
|
||||
root.pendingQuoteRequest = quoteRequest
|
||||
root.quoteStale = true
|
||||
root.quoteLoading = root.walletStateReady && root.active
|
||||
root.quoteDebounce.stop()
|
||||
if (!root.walletStateReady || !root.active)
|
||||
return
|
||||
if (immediate)
|
||||
root.requestQuoteNow(root.quoteSerial)
|
||||
else
|
||||
root.quoteDebounce.restart()
|
||||
}
|
||||
|
||||
function requestQuoteNow(serial) {
|
||||
if (serial !== root.quoteSerial)
|
||||
return
|
||||
const built = root.pendingQuoteRequest
|
||||
if (!built.ok) {
|
||||
root.quoteLoading = false
|
||||
return
|
||||
}
|
||||
if (!root.walletStateReady || !root.active || root.runtime === null) {
|
||||
root.quoteLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
root.runtime.watch(root.backend.quoteNewPosition(built.request),
|
||||
function(quote) {
|
||||
if (serial !== root.quoteSerial)
|
||||
return
|
||||
root.quoteLoading = false
|
||||
root.quoteStale = false
|
||||
root.quoteErrorCode = ""
|
||||
if (!quote || quote.schema !== "new-position.v1")
|
||||
root.newPositionQuote = root.quoteError("unsupported_schema")
|
||||
else
|
||||
root.newPositionQuote = quote
|
||||
},
|
||||
function(error) {
|
||||
if (serial !== root.quoteSerial)
|
||||
return
|
||||
root.quoteLoading = false
|
||||
root.quoteStale = true
|
||||
root.quoteErrorCode = "backend_error"
|
||||
})
|
||||
}
|
||||
|
||||
function confirm(snapshot) {
|
||||
if (root.submitting)
|
||||
return
|
||||
root.submitting = true
|
||||
root.flowErrorCode = ""
|
||||
|
||||
if (!root.backend || root.runtime === null) {
|
||||
root.finishSubmitFailure(root.quoteError("wallet_unavailable"))
|
||||
return
|
||||
}
|
||||
|
||||
root.runtime.watch(root.backend.submitNewPosition(snapshot.request, snapshot.quoteHash),
|
||||
function(result) {
|
||||
if (result && result.schema === "new-position.v1"
|
||||
&& result.status === "submitted"
|
||||
&& /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(
|
||||
String(result.transactionId || ""))) {
|
||||
if (snapshot.request.initialPriceRealRaw !== undefined)
|
||||
root.watchPoolCreation(snapshot.poolProbeRequest, result.deadlineMs)
|
||||
root.submitting = false
|
||||
root.transactionId = result.transactionId
|
||||
root.flowErrorCode = ""
|
||||
root.contextErrorCode = ""
|
||||
root.quoteErrorCode = ""
|
||||
root.invalidateQuote()
|
||||
root.submitSucceeded()
|
||||
return
|
||||
}
|
||||
root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed"))
|
||||
},
|
||||
function(error) {
|
||||
root.finishSubmitFailure(root.quoteError("wallet_submission_failed"))
|
||||
})
|
||||
}
|
||||
|
||||
function finishSubmitFailure(result) {
|
||||
root.submitting = false
|
||||
const hasFreshQuote = result && result.quote
|
||||
&& result.quote.schema === "new-position.v1"
|
||||
if (hasFreshQuote) {
|
||||
root.newPositionQuote = result.quote
|
||||
root.quoteLoading = false
|
||||
root.quoteStale = false
|
||||
}
|
||||
const code = result && result.code ? result.code : "wallet_submission_failed"
|
||||
root.flowErrorCode = code
|
||||
root.submitFailed()
|
||||
if (hasFreshQuote)
|
||||
return
|
||||
root.scheduleQuote(true, root.pendingQuoteRequest)
|
||||
}
|
||||
|
||||
function watchPoolCreation(request, deadlineMs) {
|
||||
const key = root.pairKey(request)
|
||||
let deadline = Number(deadlineMs)
|
||||
if (!isFinite(deadline) || deadline <= 0)
|
||||
deadline = 0
|
||||
const pending = root.pendingPoolProbes.filter(function(item) {
|
||||
return item.key !== key
|
||||
})
|
||||
pending.push({
|
||||
"key": key,
|
||||
"request": request,
|
||||
"deadlineMs": deadline
|
||||
})
|
||||
root.pendingPoolProbes = pending
|
||||
Qt.callLater(root.pollPendingPool)
|
||||
}
|
||||
|
||||
function pollPendingPool() {
|
||||
if (root.poolProbeInFlight || root.pendingPoolProbes.length === 0
|
||||
|| !root.walletStateReady || root.runtime === null) {
|
||||
return
|
||||
}
|
||||
const pending = root.pendingPoolProbes[0]
|
||||
root.poolProbeInFlight = true
|
||||
root.runtime.watch(root.backend.quoteNewPosition(pending.request),
|
||||
function(quote) {
|
||||
root.finishPoolProbe(pending, quote)
|
||||
},
|
||||
function(error) {
|
||||
root.finishPoolProbe(pending, null)
|
||||
})
|
||||
}
|
||||
|
||||
function finishPoolProbe(pending, quote) {
|
||||
root.poolProbeInFlight = false
|
||||
if (quote && quote.schema === "new-position.v1"
|
||||
&& quote.poolStatus === "active_pool") {
|
||||
root.removePendingPool(pending.key)
|
||||
if (root.matchesSelectedPair(pending.request)) {
|
||||
root.poolActivated(quote)
|
||||
root.invalidateQuote()
|
||||
root.refreshContext(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (pending.deadlineMs > 0 && Date.now() >= pending.deadlineMs) {
|
||||
root.removePendingPool(pending.key)
|
||||
return
|
||||
}
|
||||
root.rotatePendingPool()
|
||||
}
|
||||
|
||||
function pairKey(request) {
|
||||
return String(request.tokenAId || "") + ":" + String(request.tokenBId || "")
|
||||
}
|
||||
|
||||
function matchesSelectedPair(request) {
|
||||
return root.pairKey(root.pendingQuoteRequest.request || {}) === root.pairKey(request)
|
||||
}
|
||||
|
||||
function selectedPoolCreationPending() {
|
||||
const request = root.pendingQuoteRequest.request || {}
|
||||
if (!request.tokenAId || !request.tokenBId)
|
||||
return false
|
||||
const selected = root.pairKey(request)
|
||||
return root.pendingPoolProbes.some(function(item) {
|
||||
return item.key === selected
|
||||
})
|
||||
}
|
||||
|
||||
function removePendingPool(key) {
|
||||
root.pendingPoolProbes = root.pendingPoolProbes.filter(function(item) {
|
||||
return item.key !== key
|
||||
})
|
||||
}
|
||||
|
||||
function rotatePendingPool() {
|
||||
if (root.pendingPoolProbes.length < 2)
|
||||
return
|
||||
const pending = root.pendingPoolProbes.slice(1)
|
||||
pending.push(root.pendingPoolProbes[0])
|
||||
root.pendingPoolProbes = pending
|
||||
}
|
||||
|
||||
function draftChanged() {
|
||||
root.invalidateQuote()
|
||||
root.transactionId = ""
|
||||
root.flowErrorCode = ""
|
||||
root.contextErrorCode = ""
|
||||
root.quoteErrorCode = ""
|
||||
}
|
||||
|
||||
function invalidateQuote() {
|
||||
++root.quoteSerial
|
||||
root.quoteDebounce.stop()
|
||||
root.quoteLoading = false
|
||||
root.quoteStale = true
|
||||
}
|
||||
|
||||
function loadingContext() {
|
||||
return {
|
||||
"schema": "new-position.v1",
|
||||
"status": "loading",
|
||||
"tokens": [],
|
||||
"feeTiers": []
|
||||
}
|
||||
}
|
||||
|
||||
function quoteError(code) {
|
||||
return {
|
||||
"schema": "new-position.v1",
|
||||
"status": "error",
|
||||
"canSubmit": false,
|
||||
"code": code,
|
||||
"poolStatus": "unavailable_pool",
|
||||
"errors": [{
|
||||
"code": code,
|
||||
"blockingFields": [],
|
||||
"details": ({})
|
||||
}],
|
||||
"warnings": [],
|
||||
"accountPreview": []
|
||||
}
|
||||
}
|
||||
}
|
||||
141
apps/amm/src/ActiveNetwork.cpp
Normal file
141
apps/amm/src/ActiveNetwork.cpp
Normal file
@ -0,0 +1,141 @@
|
||||
#include "ActiveNetwork.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace {
|
||||
const char NETWORK_ENV[] = "AMM_UI_NETWORK";
|
||||
const char DEVNET_FILE_ENV[] = "AMM_UI_DEVNET_FILE";
|
||||
|
||||
bool isLowerHex(const QString& value, int size)
|
||||
{
|
||||
if (value.size() != size)
|
||||
return false;
|
||||
for (const QChar character : value) {
|
||||
const bool isAsciiDigit = character >= QLatin1Char('0')
|
||||
&& character <= QLatin1Char('9');
|
||||
if (!isAsciiDigit
|
||||
&& (character < QLatin1Char('a') || character > QLatin1Char('f'))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool ActiveNetwork::load()
|
||||
{
|
||||
m_network = {};
|
||||
m_network.status = QStringLiteral("config_missing");
|
||||
m_expectedIdentity.clear();
|
||||
|
||||
const QByteArray selected = qgetenv(NETWORK_ENV);
|
||||
m_network.id = selected.isEmpty()
|
||||
? QStringLiteral("testnet")
|
||||
: QString::fromLocal8Bit(selected).trimmed();
|
||||
|
||||
QJsonObject entry;
|
||||
if (isDevnet()) {
|
||||
const QString path = QString::fromLocal8Bit(qgetenv(DEVNET_FILE_ENV));
|
||||
QFile file(path);
|
||||
if (path.isEmpty() || !file.open(QIODevice::ReadOnly))
|
||||
return false;
|
||||
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
|
||||
if (!document.isObject())
|
||||
return false;
|
||||
entry = document.object();
|
||||
m_expectedIdentity = entry.value(QStringLiteral("channelId")).toString();
|
||||
} else {
|
||||
QFile file(QStringLiteral(":/amm/config/networks.json"));
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return false;
|
||||
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
|
||||
if (!document.isObject())
|
||||
return false;
|
||||
const QJsonObject networks = document.object();
|
||||
entry = networks.value(m_network.id).toObject();
|
||||
m_expectedIdentity = entry.value(QStringLiteral("checkpointHash")).toString();
|
||||
}
|
||||
|
||||
m_network.ammProgramId = entry.value(QStringLiteral("ammProgramId")).toString();
|
||||
if (!isValidIdentity(m_expectedIdentity)
|
||||
|| !isLowerHex(m_network.ammProgramId, 64)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const QJsonValue& value : entry.value(QStringLiteral("tokenDefinitionIds")).toArray()) {
|
||||
const QString id = value.toString();
|
||||
if (!isLowerHex(id, 64)) {
|
||||
m_network.tokenIds.clear();
|
||||
return false;
|
||||
}
|
||||
m_network.tokenIds.append(id);
|
||||
}
|
||||
m_network.status = QStringLiteral("network_unknown");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ActiveNetwork::isConfigured() const
|
||||
{
|
||||
return m_network.status != QStringLiteral("config_missing");
|
||||
}
|
||||
|
||||
bool ActiveNetwork::isDevnet() const
|
||||
{
|
||||
return m_network.id == QStringLiteral("devnet");
|
||||
}
|
||||
|
||||
bool ActiveNetwork::needsIdentityProbe() const
|
||||
{
|
||||
return m_network.status == QStringLiteral("loading")
|
||||
|| m_network.status == QStringLiteral("network_unknown");
|
||||
}
|
||||
|
||||
void ActiveNetwork::sequencerChanged(bool available)
|
||||
{
|
||||
if (isConfigured())
|
||||
clearIdentity(available ? QStringLiteral("loading") : QStringLiteral("network_unknown"));
|
||||
}
|
||||
|
||||
void ActiveNetwork::reachabilityChanged(bool reachable, bool wasReachable)
|
||||
{
|
||||
if (!isConfigured())
|
||||
return;
|
||||
if (!reachable)
|
||||
clearIdentity(QStringLiteral("network_unknown"));
|
||||
else if (!wasReachable)
|
||||
clearIdentity(QStringLiteral("loading"));
|
||||
}
|
||||
|
||||
void ActiveNetwork::beginIdentityProbe()
|
||||
{
|
||||
if (isConfigured())
|
||||
clearIdentity(QStringLiteral("loading"));
|
||||
}
|
||||
|
||||
void ActiveNetwork::finishIdentityProbe(const QString& identity)
|
||||
{
|
||||
if (identity.isEmpty()) {
|
||||
clearIdentity(QStringLiteral("network_unknown"));
|
||||
} else if (identity != m_expectedIdentity) {
|
||||
clearIdentity(QStringLiteral("network_mismatch"));
|
||||
} else {
|
||||
m_network.status = QStringLiteral("ready");
|
||||
m_network.fingerprint = (isDevnet() ? QStringLiteral("channel:")
|
||||
: QStringLiteral("block10:"))
|
||||
+ identity;
|
||||
}
|
||||
}
|
||||
|
||||
bool ActiveNetwork::isValidIdentity(const QString& value)
|
||||
{
|
||||
return isLowerHex(value, 64);
|
||||
}
|
||||
|
||||
void ActiveNetwork::clearIdentity(const QString& status)
|
||||
{
|
||||
m_network.status = status;
|
||||
m_network.fingerprint.clear();
|
||||
}
|
||||
36
apps/amm/src/ActiveNetwork.h
Normal file
36
apps/amm/src/ActiveNetwork.h
Normal file
@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
struct ActiveNetworkSnapshot {
|
||||
QString id;
|
||||
QString status;
|
||||
QString fingerprint;
|
||||
QString ammProgramId;
|
||||
QStringList tokenIds;
|
||||
};
|
||||
|
||||
class ActiveNetwork final {
|
||||
public:
|
||||
bool load();
|
||||
|
||||
const QString& status() const { return m_network.status; }
|
||||
bool isConfigured() const;
|
||||
bool isDevnet() const;
|
||||
bool needsIdentityProbe() const;
|
||||
ActiveNetworkSnapshot snapshot() const { return m_network; }
|
||||
|
||||
void sequencerChanged(bool available);
|
||||
void reachabilityChanged(bool reachable, bool wasReachable);
|
||||
void beginIdentityProbe();
|
||||
void finishIdentityProbe(const QString& identity);
|
||||
|
||||
static bool isValidIdentity(const QString& value);
|
||||
|
||||
private:
|
||||
void clearIdentity(const QString& status);
|
||||
|
||||
ActiveNetworkSnapshot m_network;
|
||||
QString m_expectedIdentity;
|
||||
};
|
||||
71
apps/amm/src/AmmClient.cpp
Normal file
71
apps/amm/src/AmmClient.cpp
Normal file
@ -0,0 +1,71 @@
|
||||
#include "AmmClient.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonParseError>
|
||||
|
||||
#include "amm_client.h"
|
||||
|
||||
namespace {
|
||||
using Operation = char* (*)(const char*);
|
||||
|
||||
AmmClientResult call(Operation operation, const QJsonObject& request)
|
||||
{
|
||||
const QByteArray payload = QJsonDocument(request).toJson(QJsonDocument::Compact);
|
||||
char* raw = operation(payload.constData());
|
||||
if (!raw) {
|
||||
qWarning() << "AmmClient: bundled client returned a null response";
|
||||
return {};
|
||||
}
|
||||
const QByteArray response(raw);
|
||||
amm_free(raw);
|
||||
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument document = QJsonDocument::fromJson(response, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !document.isObject()) {
|
||||
qWarning() << "AmmClient: bundled client returned invalid JSON";
|
||||
return {};
|
||||
}
|
||||
const QJsonObject envelope = document.object();
|
||||
if (!envelope.value(QStringLiteral("ok")).toBool()) {
|
||||
qWarning() << "AmmClient: bundled client failure:"
|
||||
<< envelope.value(QStringLiteral("error")).toString();
|
||||
return {};
|
||||
}
|
||||
if (!envelope.value(QStringLiteral("value")).isObject()) {
|
||||
qWarning() << "AmmClient: bundled client value is not an object";
|
||||
return {};
|
||||
}
|
||||
return { true, envelope.value(QStringLiteral("value")).toObject() };
|
||||
}
|
||||
}
|
||||
|
||||
AmmClientResult BundledAmmClient::configId(const QJsonObject& request) const
|
||||
{
|
||||
return call(amm_config_id, request);
|
||||
}
|
||||
|
||||
AmmClientResult BundledAmmClient::tokenIds(const QJsonObject& request) const
|
||||
{
|
||||
return call(amm_token_ids, request);
|
||||
}
|
||||
|
||||
AmmClientResult BundledAmmClient::pairIds(const QJsonObject& request) const
|
||||
{
|
||||
return call(amm_pair_ids, request);
|
||||
}
|
||||
|
||||
AmmClientResult BundledAmmClient::context(const QJsonObject& request) const
|
||||
{
|
||||
return call(amm_context, request);
|
||||
}
|
||||
|
||||
AmmClientResult BundledAmmClient::quote(const QJsonObject& request) const
|
||||
{
|
||||
return call(amm_quote, request);
|
||||
}
|
||||
|
||||
AmmClientResult BundledAmmClient::plan(const QJsonObject& request) const
|
||||
{
|
||||
return call(amm_plan, request);
|
||||
}
|
||||
30
apps/amm/src/AmmClient.h
Normal file
30
apps/amm/src/AmmClient.h
Normal file
@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
|
||||
struct AmmClientResult {
|
||||
bool ok = false;
|
||||
QJsonObject value;
|
||||
};
|
||||
|
||||
class AmmClient {
|
||||
public:
|
||||
virtual ~AmmClient() = default;
|
||||
|
||||
virtual AmmClientResult configId(const QJsonObject& request) const = 0;
|
||||
virtual AmmClientResult tokenIds(const QJsonObject& request) const = 0;
|
||||
virtual AmmClientResult pairIds(const QJsonObject& request) const = 0;
|
||||
virtual AmmClientResult context(const QJsonObject& request) const = 0;
|
||||
virtual AmmClientResult quote(const QJsonObject& request) const = 0;
|
||||
virtual AmmClientResult plan(const QJsonObject& request) const = 0;
|
||||
};
|
||||
|
||||
class BundledAmmClient final : public AmmClient {
|
||||
public:
|
||||
AmmClientResult configId(const QJsonObject& request) const override;
|
||||
AmmClientResult tokenIds(const QJsonObject& request) const override;
|
||||
AmmClientResult pairIds(const QJsonObject& request) const override;
|
||||
AmmClientResult context(const QJsonObject& request) const override;
|
||||
AmmClientResult quote(const QJsonObject& request) const override;
|
||||
AmmClientResult plan(const QJsonObject& request) const override;
|
||||
};
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
390
apps/amm/src/NewPositionRuntime.cpp
Normal file
390
apps/amm/src/NewPositionRuntime.cpp
Normal file
@ -0,0 +1,390 @@
|
||||
#include "NewPositionRuntime.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDateTime>
|
||||
#include <QJsonObject>
|
||||
#include <QScopedValueRollback>
|
||||
#include <libbase58.h>
|
||||
|
||||
#include "AmmClient.h"
|
||||
#include "WalletProvider.h"
|
||||
|
||||
namespace {
|
||||
const char SCHEMA[] = "new-position.v1";
|
||||
constexpr qsizetype HASH_BYTES = 32;
|
||||
constexpr std::size_t BASE58_BUFFER_SIZE = 45;
|
||||
|
||||
QString base58TransactionId(const QString& transactionHash)
|
||||
{
|
||||
const QByteArray hex = transactionHash.toLatin1();
|
||||
const QByteArray bytes = QByteArray::fromHex(hex);
|
||||
if (bytes.size() != HASH_BYTES || bytes.toHex() != hex)
|
||||
return {};
|
||||
|
||||
std::array<char, BASE58_BUFFER_SIZE> encoded {};
|
||||
std::size_t size = encoded.size();
|
||||
if (!b58enc(encoded.data(), &size, bytes.constData(),
|
||||
static_cast<std::size_t>(bytes.size()))) {
|
||||
return {};
|
||||
}
|
||||
return QString::fromLatin1(
|
||||
encoded.data(), static_cast<qsizetype>(size - 1));
|
||||
}
|
||||
|
||||
QJsonObject issue(const QString& code,
|
||||
const QJsonArray& blockingFields = {})
|
||||
{
|
||||
return {
|
||||
{ QStringLiteral("code"), code },
|
||||
{ QStringLiteral("recoverable"), true },
|
||||
{ QStringLiteral("blockingFields"), blockingFields },
|
||||
{ QStringLiteral("details"), QJsonObject() },
|
||||
};
|
||||
}
|
||||
|
||||
QJsonObject publicError(const QString& code,
|
||||
const QJsonArray& blockingFields = {},
|
||||
const QJsonObject& details = {})
|
||||
{
|
||||
QJsonObject error = issue(code, blockingFields);
|
||||
error.insert(QStringLiteral("details"), details);
|
||||
return {
|
||||
{ QStringLiteral("schema"), QString::fromLatin1(SCHEMA) },
|
||||
{ QStringLiteral("status"), QStringLiteral("error") },
|
||||
{ QStringLiteral("canSubmit"), false },
|
||||
{ QStringLiteral("code"), code },
|
||||
{ QStringLiteral("errors"), QJsonArray { error } },
|
||||
{ QStringLiteral("warnings"), QJsonArray() },
|
||||
{ QStringLiteral("accountPreview"), QJsonArray() },
|
||||
};
|
||||
}
|
||||
|
||||
QJsonObject contextState(const QString& status,
|
||||
const ActiveNetworkSnapshot& network,
|
||||
const QString& code = {})
|
||||
{
|
||||
QJsonObject state {
|
||||
{ QStringLiteral("schema"), QString::fromLatin1(SCHEMA) },
|
||||
{ QStringLiteral("status"), status },
|
||||
{ QStringLiteral("networkId"), network.id },
|
||||
{ QStringLiteral("networkFingerprint"), network.fingerprint },
|
||||
{ QStringLiteral("tokens"), QJsonArray() },
|
||||
{ QStringLiteral("feeTiers"), QJsonArray() },
|
||||
{ QStringLiteral("warnings"), QJsonArray() },
|
||||
};
|
||||
if (!code.isEmpty())
|
||||
state.insert(QStringLiteral("code"), code);
|
||||
return state;
|
||||
}
|
||||
|
||||
QJsonArray variantStringArray(const QVariant& value)
|
||||
{
|
||||
QJsonArray result;
|
||||
for (const QVariant& item : value.toList())
|
||||
result.append(item.toString());
|
||||
return result;
|
||||
}
|
||||
|
||||
QStringList jsonStringList(const QJsonArray& values)
|
||||
{
|
||||
QStringList result;
|
||||
result.reserve(values.size());
|
||||
for (const QJsonValue& value : values)
|
||||
result.append(value.toString());
|
||||
return result;
|
||||
}
|
||||
|
||||
QVector<bool> jsonBoolList(const QJsonArray& values)
|
||||
{
|
||||
QVector<bool> result;
|
||||
result.reserve(values.size());
|
||||
for (const QJsonValue& value : values)
|
||||
result.append(value.toBool());
|
||||
return result;
|
||||
}
|
||||
|
||||
QVector<quint32> jsonUIntList(const QJsonArray& values)
|
||||
{
|
||||
QVector<quint32> result;
|
||||
result.reserve(values.size());
|
||||
for (const QJsonValue& value : values)
|
||||
result.append(static_cast<quint32>(value.toInteger()));
|
||||
return result;
|
||||
}
|
||||
|
||||
QJsonObject accountReadJson(const WalletAccountRead& read)
|
||||
{
|
||||
QJsonObject result {
|
||||
{ QStringLiteral("id"), read.accountId },
|
||||
{ QStringLiteral("status"), read.status },
|
||||
};
|
||||
if (read.ok()) {
|
||||
result.insert(QStringLiteral("account"), QJsonObject {
|
||||
{ QStringLiteral("program_owner"), read.programOwner },
|
||||
{ QStringLiteral("balance"), read.balanceHex },
|
||||
{ QStringLiteral("nonce"), read.nonceHex },
|
||||
{ QStringLiteral("data"), read.dataHex },
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QJsonArray accountReadsJson(const QVector<WalletAccountRead>& reads)
|
||||
{
|
||||
QJsonArray result;
|
||||
for (const WalletAccountRead& read : reads)
|
||||
result.append(accountReadJson(read));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
NewPositionRuntime::NewPositionRuntime(WalletProvider* wallet, AmmClient* client)
|
||||
: m_wallet(wallet),
|
||||
m_client(client)
|
||||
{
|
||||
}
|
||||
|
||||
void NewPositionRuntime::clearWalletAccounts()
|
||||
{
|
||||
m_wallet->clearSnapshot();
|
||||
}
|
||||
|
||||
QJsonArray NewPositionRuntime::walletAccountReads(bool walletOpen, bool refresh) const
|
||||
{
|
||||
if (!walletOpen)
|
||||
return {};
|
||||
return accountReadsJson(m_wallet->snapshot(refresh).publicAccountReads);
|
||||
}
|
||||
|
||||
QJsonObject NewPositionRuntime::buildQuoteInput(const QVariantMap& request,
|
||||
const ActiveNetworkSnapshot& network,
|
||||
bool walletOpen,
|
||||
bool freshWalletAccounts,
|
||||
QJsonObject* error) const
|
||||
{
|
||||
if (network.status != QStringLiteral("ready")) {
|
||||
*error = publicError(network.status);
|
||||
return {};
|
||||
}
|
||||
const AmmClientResult configResult = m_client->configId(
|
||||
QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } });
|
||||
if (!configResult.ok) {
|
||||
*error = publicError(QStringLiteral("backend_error"));
|
||||
return {};
|
||||
}
|
||||
const QJsonObject configManifest = configResult.value;
|
||||
const QJsonObject config = accountReadJson(m_wallet->readPublicAccount(
|
||||
configManifest.value(QStringLiteral("configId")).toString()));
|
||||
const QJsonObject requestObject = QJsonObject::fromVariantMap(request);
|
||||
const AmmClientResult pairResult = m_client->pairIds(
|
||||
QJsonObject {
|
||||
{ QStringLiteral("ammProgramId"), network.ammProgramId },
|
||||
{ QStringLiteral("config"), config },
|
||||
{ QStringLiteral("tokenAId"), requestObject.value(QStringLiteral("tokenAId")) },
|
||||
{ QStringLiteral("tokenBId"), requestObject.value(QStringLiteral("tokenBId")) },
|
||||
});
|
||||
if (!pairResult.ok) {
|
||||
*error = publicError(QStringLiteral("backend_error"));
|
||||
return {};
|
||||
}
|
||||
const QJsonObject pairManifest = pairResult.value;
|
||||
if (pairManifest.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) {
|
||||
*error = publicError(pairManifest.value(QStringLiteral("code")).toString());
|
||||
return {};
|
||||
}
|
||||
|
||||
const QJsonArray walletAccounts = walletAccountReads(walletOpen, freshWalletAccounts);
|
||||
const QJsonObject snapshot {
|
||||
{ QStringLiteral("config"), config },
|
||||
{ QStringLiteral("tokenA"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("tokenAId")).toString())) },
|
||||
{ QStringLiteral("tokenB"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("tokenBId")).toString())) },
|
||||
{ QStringLiteral("pool"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("poolId")).toString())) },
|
||||
{ QStringLiteral("vaultA"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("vaultAId")).toString())) },
|
||||
{ QStringLiteral("vaultB"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("vaultBId")).toString())) },
|
||||
{ QStringLiteral("lpDefinition"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("lpDefinitionId")).toString())) },
|
||||
{ QStringLiteral("lpLockHolding"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("lpLockHoldingId")).toString())) },
|
||||
{ QStringLiteral("currentTick"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("currentTickId")).toString())) },
|
||||
{ QStringLiteral("clock"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("clockId")).toString())) },
|
||||
{ QStringLiteral("walletAvailable"), walletOpen },
|
||||
{ QStringLiteral("walletAccounts"), walletAccounts },
|
||||
};
|
||||
return {
|
||||
{ QStringLiteral("networkId"), network.id },
|
||||
{ QStringLiteral("networkFingerprint"), network.fingerprint },
|
||||
{ QStringLiteral("ammProgramId"), network.ammProgramId },
|
||||
{ QStringLiteral("request"), requestObject },
|
||||
{ QStringLiteral("snapshot"), snapshot },
|
||||
};
|
||||
}
|
||||
|
||||
QVariantMap NewPositionRuntime::context(const QVariantMap& request,
|
||||
const ActiveNetworkSnapshot& network,
|
||||
bool walletOpen,
|
||||
bool refreshWalletAccounts)
|
||||
{
|
||||
if (network.status != QStringLiteral("ready"))
|
||||
return contextState(network.status, network).toVariantMap();
|
||||
|
||||
const QJsonArray walletAccounts = walletAccountReads(walletOpen, refreshWalletAccounts);
|
||||
|
||||
const QJsonObject hints = QJsonObject::fromVariantMap(request);
|
||||
const AmmClientResult configResult = m_client->configId(
|
||||
QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } });
|
||||
if (!configResult.ok)
|
||||
return contextState(
|
||||
QStringLiteral("error"), network, QStringLiteral("backend_error")).toVariantMap();
|
||||
|
||||
const QJsonObject configManifest = configResult.value;
|
||||
const QJsonObject config = accountReadJson(m_wallet->readPublicAccount(
|
||||
configManifest.value(QStringLiteral("configId")).toString()));
|
||||
QJsonArray configured;
|
||||
for (const QString& id : network.tokenIds)
|
||||
configured.append(id);
|
||||
const QJsonArray recent = variantStringArray(hints.value(QStringLiteral("recentTokenIds")).toVariant());
|
||||
const QJsonArray resolved = variantStringArray(hints.value(QStringLiteral("resolvedTokenIds")).toVariant());
|
||||
|
||||
const AmmClientResult tokenResult = m_client->tokenIds(
|
||||
QJsonObject {
|
||||
{ QStringLiteral("ammProgramId"), network.ammProgramId },
|
||||
{ QStringLiteral("config"), config },
|
||||
{ QStringLiteral("walletAccounts"), walletAccounts },
|
||||
{ QStringLiteral("configuredTokenIds"), configured },
|
||||
{ QStringLiteral("recentTokenIds"), recent },
|
||||
{ QStringLiteral("resolvedTokenIds"), resolved },
|
||||
});
|
||||
const QJsonObject tokenManifest = tokenResult.value;
|
||||
if (!tokenResult.ok
|
||||
|| tokenManifest.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) {
|
||||
const QString code = tokenResult.ok
|
||||
? tokenManifest.value(QStringLiteral("code")).toString()
|
||||
: QStringLiteral("backend_error");
|
||||
return contextState(
|
||||
QStringLiteral("error"),
|
||||
network,
|
||||
code.isEmpty() ? QStringLiteral("backend_error") : code).toVariantMap();
|
||||
}
|
||||
|
||||
QJsonArray definitions;
|
||||
for (const QJsonValue& id : tokenManifest.value(QStringLiteral("tokenIds")).toArray())
|
||||
definitions.append(accountReadJson(m_wallet->readPublicAccount(id.toString())));
|
||||
|
||||
const AmmClientResult contextResult = m_client->context(
|
||||
QJsonObject {
|
||||
{ QStringLiteral("networkId"), network.id },
|
||||
{ QStringLiteral("networkFingerprint"), network.fingerprint },
|
||||
{ QStringLiteral("ammProgramId"), network.ammProgramId },
|
||||
{ QStringLiteral("walletAvailable"), walletOpen },
|
||||
{ QStringLiteral("config"), config },
|
||||
{ QStringLiteral("walletAccounts"), walletAccounts },
|
||||
{ QStringLiteral("tokenDefinitions"), definitions },
|
||||
{ QStringLiteral("configuredTokenIds"), configured },
|
||||
{ QStringLiteral("recentTokenIds"), recent },
|
||||
{ QStringLiteral("resolvedTokenIds"), resolved },
|
||||
});
|
||||
return (contextResult.ok
|
||||
? contextResult.value
|
||||
: contextState(
|
||||
QStringLiteral("error"), network, QStringLiteral("backend_error"))).toVariantMap();
|
||||
}
|
||||
|
||||
QVariantMap NewPositionRuntime::quote(const QVariantMap& request,
|
||||
const ActiveNetworkSnapshot& network,
|
||||
bool walletOpen)
|
||||
{
|
||||
QJsonObject error;
|
||||
const QJsonObject input = buildQuoteInput(request, network, walletOpen, false, &error);
|
||||
if (!error.isEmpty())
|
||||
return error.toVariantMap();
|
||||
|
||||
const AmmClientResult result = m_client->quote(input);
|
||||
return (result.ok ? result.value : publicError(QStringLiteral("backend_error"))).toVariantMap();
|
||||
}
|
||||
|
||||
QVariantMap NewPositionRuntime::submit(const QVariantMap& request,
|
||||
const QString& quoteHash,
|
||||
const ActiveNetworkSnapshot& network,
|
||||
bool walletOpen)
|
||||
{
|
||||
if (m_submitInFlight)
|
||||
return publicError(QStringLiteral("submit_in_progress")).toVariantMap();
|
||||
if (!walletOpen)
|
||||
return publicError(QStringLiteral("wallet_unavailable")).toVariantMap();
|
||||
QScopedValueRollback<bool> submitGuard(m_submitInFlight, true);
|
||||
|
||||
QJsonObject error;
|
||||
const QJsonObject input = buildQuoteInput(request, network, walletOpen, true, &error);
|
||||
if (!error.isEmpty())
|
||||
return error.toVariantMap();
|
||||
|
||||
const AmmClientResult quoteResult = m_client->quote(input);
|
||||
if (!quoteResult.ok)
|
||||
return publicError(QStringLiteral("backend_error")).toVariantMap();
|
||||
const QJsonObject quote = quoteResult.value;
|
||||
if (quote.value(QStringLiteral("quoteHash")).toString() != quoteHash) {
|
||||
QJsonObject result = publicError(QStringLiteral("quote_changed"));
|
||||
result.insert(QStringLiteral("quote"), quote);
|
||||
return result.toVariantMap();
|
||||
}
|
||||
if (!quote.value(QStringLiteral("canSubmit")).toBool(false)) {
|
||||
QJsonObject result = publicError(QStringLiteral("quote_not_submittable"));
|
||||
result.insert(QStringLiteral("quote"), quote);
|
||||
return result.toVariantMap();
|
||||
}
|
||||
|
||||
QJsonValue freshLp;
|
||||
if (quote.value(QStringLiteral("requiresFreshLp")).toBool(false)) {
|
||||
const WalletAccountCreation creation = m_wallet->createAccount(true);
|
||||
if (!creation.ok() || !creation.publicAccount.ok())
|
||||
return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap();
|
||||
freshLp = accountReadJson(creation.publicAccount);
|
||||
}
|
||||
|
||||
QJsonObject planInput = input;
|
||||
planInput.insert(QStringLiteral("quoteHash"), quoteHash);
|
||||
planInput.insert(QStringLiteral("nowMs"), QDateTime::currentMSecsSinceEpoch());
|
||||
if (!freshLp.isUndefined())
|
||||
planInput.insert(QStringLiteral("freshLp"), freshLp);
|
||||
|
||||
const AmmClientResult planResult = m_client->plan(planInput);
|
||||
if (!planResult.ok)
|
||||
return publicError(QStringLiteral("backend_error")).toVariantMap();
|
||||
const QJsonObject plan = planResult.value;
|
||||
if (plan.value(QStringLiteral("status")).toString() != QStringLiteral("ready")) {
|
||||
const QString code = plan.value(QStringLiteral("code")).toString();
|
||||
return publicError(code.isEmpty() ? QStringLiteral("wallet_submission_failed") : code)
|
||||
.toVariantMap();
|
||||
}
|
||||
|
||||
const QStringList accountIds = jsonStringList(plan.value(QStringLiteral("accountIds")).toArray());
|
||||
const QVector<bool> signingRequirements = jsonBoolList(plan.value(QStringLiteral("signingRequirements")).toArray());
|
||||
const QVector<quint32> instruction = jsonUIntList(plan.value(QStringLiteral("instruction")).toArray());
|
||||
const QString programId = plan.value(QStringLiteral("programId")).toString();
|
||||
bool deadlineValid = false;
|
||||
const qulonglong deadline = plan.value(QStringLiteral("deadlineMs")).toString().toULongLong(&deadlineValid);
|
||||
if (!deadlineValid
|
||||
|| static_cast<qulonglong>(QDateTime::currentMSecsSinceEpoch()) >= deadline) {
|
||||
return publicError(QStringLiteral("transaction_deadline_expired")).toVariantMap();
|
||||
}
|
||||
const WalletSubmission submission = m_wallet->submitPublicTransaction({
|
||||
programId,
|
||||
accountIds,
|
||||
signingRequirements,
|
||||
instruction,
|
||||
});
|
||||
if (!submission.accepted())
|
||||
return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap();
|
||||
const QString transactionId = base58TransactionId(submission.nativeHash);
|
||||
if (transactionId.isEmpty())
|
||||
return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap();
|
||||
|
||||
return QJsonObject {
|
||||
{ QStringLiteral("schema"), QString::fromLatin1(SCHEMA) },
|
||||
{ QStringLiteral("status"), QStringLiteral("submitted") },
|
||||
{ QStringLiteral("transactionId"), transactionId },
|
||||
{ QStringLiteral("deadlineMs"), plan.value(QStringLiteral("deadlineMs")) },
|
||||
}.toVariantMap();
|
||||
}
|
||||
42
apps/amm/src/NewPositionRuntime.h
Normal file
42
apps/amm/src/NewPositionRuntime.h
Normal file
@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include "ActiveNetwork.h"
|
||||
|
||||
class AmmClient;
|
||||
class WalletProvider;
|
||||
|
||||
class NewPositionRuntime {
|
||||
public:
|
||||
NewPositionRuntime(WalletProvider* wallet, AmmClient* client);
|
||||
|
||||
void clearWalletAccounts();
|
||||
|
||||
QVariantMap context(const QVariantMap& request,
|
||||
const ActiveNetworkSnapshot& network,
|
||||
bool walletOpen,
|
||||
bool refreshWalletAccounts);
|
||||
QVariantMap quote(const QVariantMap& request,
|
||||
const ActiveNetworkSnapshot& network,
|
||||
bool walletOpen);
|
||||
QVariantMap submit(const QVariantMap& request,
|
||||
const QString& quoteHash,
|
||||
const ActiveNetworkSnapshot& network,
|
||||
bool walletOpen);
|
||||
|
||||
private:
|
||||
QJsonArray walletAccountReads(bool walletOpen, bool refresh) const;
|
||||
QJsonObject buildQuoteInput(const QVariantMap& request,
|
||||
const ActiveNetworkSnapshot& network,
|
||||
bool walletOpen,
|
||||
bool freshWalletAccounts,
|
||||
QJsonObject* error) const;
|
||||
|
||||
WalletProvider* m_wallet;
|
||||
AmmClient* m_client;
|
||||
bool m_submitInFlight = false;
|
||||
};
|
||||
65
apps/amm/tests/cpp/ActiveNetworkTest.cpp
Normal file
65
apps/amm/tests/cpp/ActiveNetworkTest.cpp
Normal file
@ -0,0 +1,65 @@
|
||||
#include "ActiveNetwork.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QTemporaryFile>
|
||||
|
||||
namespace {
|
||||
bool expect(bool condition, const char* message)
|
||||
{
|
||||
if (!condition)
|
||||
qCritical("%s", message);
|
||||
return condition;
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
const QString identity(64, QLatin1Char('a'));
|
||||
const QString programId(64, QLatin1Char('b'));
|
||||
const QString tokenId(64, QLatin1Char('c'));
|
||||
|
||||
QTemporaryFile config;
|
||||
if (!config.open())
|
||||
return 1;
|
||||
config.write(QJsonDocument(QJsonObject {
|
||||
{ QStringLiteral("channelId"), identity },
|
||||
{ QStringLiteral("ammProgramId"), programId },
|
||||
{ QStringLiteral("tokenDefinitionIds"), QJsonArray { tokenId } },
|
||||
}).toJson(QJsonDocument::Compact));
|
||||
config.flush();
|
||||
|
||||
qputenv("AMM_UI_NETWORK", "devnet");
|
||||
qputenv("AMM_UI_DEVNET_FILE", config.fileName().toLocal8Bit());
|
||||
|
||||
ActiveNetwork network;
|
||||
if (!expect(network.load(), "devnet config should load"))
|
||||
return 1;
|
||||
if (!expect(network.snapshot().status == QStringLiteral("network_unknown"),
|
||||
"loaded network should await identity"))
|
||||
return 1;
|
||||
|
||||
network.sequencerChanged(true);
|
||||
network.finishIdentityProbe(QString(64, QLatin1Char('d')));
|
||||
if (!expect(network.status() == QStringLiteral("network_mismatch"),
|
||||
"wrong identity should block the network"))
|
||||
return 1;
|
||||
|
||||
network.reachabilityChanged(false, true);
|
||||
network.reachabilityChanged(true, false);
|
||||
network.finishIdentityProbe(identity);
|
||||
const ActiveNetworkSnapshot snapshot = network.snapshot();
|
||||
if (!expect(snapshot.status == QStringLiteral("ready"),
|
||||
"matching identity should ready the network"))
|
||||
return 1;
|
||||
if (!expect(snapshot.fingerprint == QStringLiteral("channel:") + identity,
|
||||
"devnet fingerprint should use channel identity"))
|
||||
return 1;
|
||||
if (!expect(snapshot.ammProgramId == programId
|
||||
&& snapshot.tokenIds == QStringList { tokenId },
|
||||
"network snapshot should preserve configured program and tokens"))
|
||||
return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
230
apps/amm/tests/cpp/NewPositionRuntimeTest.cpp
Normal file
230
apps/amm/tests/cpp/NewPositionRuntimeTest.cpp
Normal file
@ -0,0 +1,230 @@
|
||||
#include "AmmClient.h"
|
||||
#include "NewPositionRuntime.h"
|
||||
#include "WalletProvider.h"
|
||||
|
||||
#include <QDateTime>
|
||||
|
||||
namespace {
|
||||
bool expect(bool condition, const char* message)
|
||||
{
|
||||
if (!condition)
|
||||
qCritical("%s", message);
|
||||
return condition;
|
||||
}
|
||||
|
||||
class FakeWallet final : public WalletProvider {
|
||||
public:
|
||||
WalletSession connect(const WalletPaths&) override
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
WalletCreation createWallet(const WalletPaths&, const QString&) override
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
WalletSnapshot snapshot(bool) override
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
void clearSnapshot() override {}
|
||||
|
||||
WalletAccountCreation createAccount(bool isPublic) override
|
||||
{
|
||||
++createdAccounts;
|
||||
WalletAccountCreation creation;
|
||||
creation.accountId = QStringLiteral("fresh-lp");
|
||||
if (isPublic)
|
||||
creation.publicAccount = readPublicAccount(creation.accountId);
|
||||
return creation;
|
||||
}
|
||||
|
||||
WalletAccountRead readPublicAccount(const QString& accountId) const override
|
||||
{
|
||||
WalletAccountRead read;
|
||||
read.accountId = accountId;
|
||||
read.status = QStringLiteral("ok");
|
||||
return read;
|
||||
}
|
||||
|
||||
WalletSubmission submitPublicTransaction(const WalletTransaction& transaction) override
|
||||
{
|
||||
++submissions;
|
||||
submitted = transaction;
|
||||
return { WalletFailure::None, transactionHash };
|
||||
}
|
||||
|
||||
void disconnect() override {}
|
||||
|
||||
QString transactionHash = QStringLiteral(
|
||||
"000102030405060708090a0b0c0d0e0f"
|
||||
"101112131415161718191a1b1c1d1e1f");
|
||||
int createdAccounts = 0;
|
||||
int submissions = 0;
|
||||
WalletTransaction submitted;
|
||||
};
|
||||
|
||||
class FakeAmmClient final : public AmmClient {
|
||||
public:
|
||||
AmmClientResult configId(const QJsonObject&) const override
|
||||
{
|
||||
return success({ { QStringLiteral("configId"), QStringLiteral("config") } });
|
||||
}
|
||||
|
||||
AmmClientResult tokenIds(const QJsonObject&) const override
|
||||
{
|
||||
return success({ { QStringLiteral("status"), QStringLiteral("ok") } });
|
||||
}
|
||||
|
||||
AmmClientResult pairIds(const QJsonObject&) const override
|
||||
{
|
||||
return success({
|
||||
{ QStringLiteral("status"), QStringLiteral("ok") },
|
||||
{ QStringLiteral("tokenAId"), QStringLiteral("token-a") },
|
||||
{ QStringLiteral("tokenBId"), QStringLiteral("token-b") },
|
||||
{ QStringLiteral("poolId"), QStringLiteral("pool") },
|
||||
{ QStringLiteral("vaultAId"), QStringLiteral("vault-a") },
|
||||
{ QStringLiteral("vaultBId"), QStringLiteral("vault-b") },
|
||||
{ QStringLiteral("lpDefinitionId"), QStringLiteral("lp") },
|
||||
{ QStringLiteral("lpLockHoldingId"), QStringLiteral("lp-lock") },
|
||||
{ QStringLiteral("currentTickId"), QStringLiteral("tick") },
|
||||
{ QStringLiteral("clockId"), QStringLiteral("clock") },
|
||||
});
|
||||
}
|
||||
|
||||
AmmClientResult context(const QJsonObject&) const override
|
||||
{
|
||||
return success({});
|
||||
}
|
||||
|
||||
AmmClientResult quote(const QJsonObject&) const override
|
||||
{
|
||||
return success({
|
||||
{ QStringLiteral("schema"), QStringLiteral("new-position.v1") },
|
||||
{ QStringLiteral("status"), QStringLiteral("ok") },
|
||||
{ QStringLiteral("canSubmit"), true },
|
||||
{ QStringLiteral("quoteHash"), quoteHash },
|
||||
{ QStringLiteral("requiresFreshLp"), requiresFreshLp },
|
||||
});
|
||||
}
|
||||
|
||||
AmmClientResult plan(const QJsonObject& request) const override
|
||||
{
|
||||
sawFreshLp = request.contains(QStringLiteral("freshLp"));
|
||||
return success({
|
||||
{ QStringLiteral("status"), QStringLiteral("ready") },
|
||||
{ QStringLiteral("accountIds"), QJsonArray { QStringLiteral("account") } },
|
||||
{ QStringLiteral("signingRequirements"), QJsonArray { true } },
|
||||
{ QStringLiteral("instruction"), QJsonArray { 1 } },
|
||||
{ QStringLiteral("programId"), QStringLiteral("program") },
|
||||
{ QStringLiteral("deadlineMs"),
|
||||
QString::number(QDateTime::currentMSecsSinceEpoch() + 60'000) },
|
||||
});
|
||||
}
|
||||
|
||||
static AmmClientResult success(const QJsonObject& value)
|
||||
{
|
||||
return { true, value };
|
||||
}
|
||||
|
||||
QString quoteHash = QStringLiteral("sha256:expected");
|
||||
bool requiresFreshLp = true;
|
||||
mutable bool sawFreshLp = false;
|
||||
};
|
||||
|
||||
ActiveNetworkSnapshot readyNetwork()
|
||||
{
|
||||
return {
|
||||
QStringLiteral("testnet"),
|
||||
QStringLiteral("ready"),
|
||||
QStringLiteral("block10:identity"),
|
||||
QStringLiteral("program"),
|
||||
{},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
const QVariantMap request {
|
||||
{ QStringLiteral("schema"), QStringLiteral("new-position.v1") },
|
||||
{ QStringLiteral("tokenAId"), QStringLiteral("token-a") },
|
||||
{ QStringLiteral("tokenBId"), QStringLiteral("token-b") },
|
||||
{ QStringLiteral("feeBps"), 30 },
|
||||
};
|
||||
|
||||
FakeWallet wallet;
|
||||
FakeAmmClient client;
|
||||
NewPositionRuntime runtime(&wallet, &client);
|
||||
const QVariantMap result = runtime.submit(
|
||||
request, QStringLiteral("sha256:expected"), readyNetwork(), true);
|
||||
if (!expect(result.value(QStringLiteral("status")).toString()
|
||||
== QStringLiteral("submitted"),
|
||||
"valid plan should submit"))
|
||||
return 1;
|
||||
if (!expect(result.value(QStringLiteral("transactionId")).toString()
|
||||
== QStringLiteral("1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"),
|
||||
"native hash should become the expected base58 transaction ID"))
|
||||
return 1;
|
||||
if (!expect(wallet.createdAccounts == 1 && client.sawFreshLp,
|
||||
"fresh LP account should enter the plan"))
|
||||
return 1;
|
||||
if (!expect(wallet.submissions == 1
|
||||
&& wallet.submitted.accountIds
|
||||
== QStringList { QStringLiteral("account") }
|
||||
&& wallet.submitted.signingRequirements.size() == 1
|
||||
&& wallet.submitted.signingRequirements.constFirst()
|
||||
&& wallet.submitted.instruction.size() == 1
|
||||
&& wallet.submitted.instruction.constFirst() == 1
|
||||
&& wallet.submitted.programId == QStringLiteral("program"),
|
||||
"runtime should dispatch the unchanged plan once"))
|
||||
return 1;
|
||||
|
||||
FakeWallet orderedBytesWallet;
|
||||
orderedBytesWallet.transactionHash = QStringLiteral(
|
||||
"0102030405060708090a0b0c0d0e0f10"
|
||||
"1112131415161718191a1b1c1d1e1f20");
|
||||
FakeAmmClient orderedBytesClient;
|
||||
orderedBytesClient.requiresFreshLp = false;
|
||||
NewPositionRuntime orderedBytesRuntime(&orderedBytesWallet, &orderedBytesClient);
|
||||
const QVariantMap orderedBytes = orderedBytesRuntime.submit(
|
||||
request, QStringLiteral("sha256:expected"), readyNetwork(), true);
|
||||
if (!expect(orderedBytes.value(QStringLiteral("transactionId")).toString()
|
||||
== QStringLiteral("4wBqpZM9xaSheZzJSMawUKKwhdpChKbZ5eu5ky4Vigw"),
|
||||
"ordered native hash bytes should preserve byte order"))
|
||||
return 1;
|
||||
|
||||
FakeWallet invalidHashWallet;
|
||||
invalidHashWallet.transactionHash = QStringLiteral("not-a-hash");
|
||||
FakeAmmClient invalidHashClient;
|
||||
invalidHashClient.requiresFreshLp = false;
|
||||
NewPositionRuntime invalidHashRuntime(&invalidHashWallet, &invalidHashClient);
|
||||
const QVariantMap invalidHash = invalidHashRuntime.submit(
|
||||
request, QStringLiteral("sha256:expected"), readyNetwork(), true);
|
||||
if (!expect(invalidHash.value(QStringLiteral("code")).toString()
|
||||
== QStringLiteral("wallet_submission_failed")
|
||||
&& !invalidHash.contains(QStringLiteral("transactionId")),
|
||||
"invalid native hash should fail without a hex fallback"))
|
||||
return 1;
|
||||
if (!expect(invalidHashWallet.submissions == 1,
|
||||
"hash conversion should happen after wallet submission"))
|
||||
return 1;
|
||||
|
||||
FakeWallet staleWallet;
|
||||
FakeAmmClient staleClient;
|
||||
staleClient.quoteHash = QStringLiteral("sha256:changed");
|
||||
NewPositionRuntime staleRuntime(&staleWallet, &staleClient);
|
||||
const QVariantMap stale = staleRuntime.submit(
|
||||
request, QStringLiteral("sha256:expected"), readyNetwork(), true);
|
||||
if (!expect(stale.value(QStringLiteral("code")).toString()
|
||||
== QStringLiteral("quote_changed"),
|
||||
"changed quote should stop submission"))
|
||||
return 1;
|
||||
if (!expect(staleWallet.createdAccounts == 0 && staleWallet.submissions == 0,
|
||||
"stale quote should have no wallet side effects"))
|
||||
return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
29
apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml
Normal file
29
apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml
Normal file
@ -0,0 +1,29 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtTest
|
||||
|
||||
import "../../qml/components/liquidity" as Liquidity
|
||||
|
||||
TestCase {
|
||||
id: testCase
|
||||
|
||||
name: "LiquidityConfirmationSummary"
|
||||
|
||||
Component {
|
||||
id: summaryComponent
|
||||
|
||||
Liquidity.LiquidityConfirmationSummary {
|
||||
width: 480
|
||||
}
|
||||
}
|
||||
|
||||
function test_protocolActionsUseDisplayLabels() {
|
||||
var summary = createTemporaryObject(summaryComponent, testCase)
|
||||
verify(summary)
|
||||
|
||||
compare(summary.actionText("NewDefinition"), "Create pool")
|
||||
compare(summary.actionText("AddLiquidity"), "Add liquidity")
|
||||
compare(summary.actionText(""), "-")
|
||||
}
|
||||
}
|
||||
335
apps/amm/tests/qml/tst_LiquidityPage.qml
Normal file
335
apps/amm/tests/qml/tst_LiquidityPage.qml
Normal 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())
|
||||
}
|
||||
}
|
||||
633
apps/amm/tests/qml/tst_NewPositionForm.qml
Normal file
633
apps/amm/tests/qml/tst_NewPositionForm.qml
Normal 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)
|
||||
}
|
||||
}
|
||||
47
apps/amm/tests/qml/tst_ResponsivePopups.qml
Normal file
47
apps/amm/tests/qml/tst_ResponsivePopups.qml
Normal file
@ -0,0 +1,47 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtTest
|
||||
|
||||
import "../../qml/components/liquidity" as Liquidity
|
||||
|
||||
TestCase {
|
||||
id: testCase
|
||||
|
||||
name: "ResponsivePopups"
|
||||
|
||||
Liquidity.AmmTheme {
|
||||
id: theme
|
||||
}
|
||||
|
||||
Component {
|
||||
id: viewportComponent
|
||||
|
||||
Item {
|
||||
width: 320
|
||||
height: 300
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: tokenSelectorComponent
|
||||
|
||||
Liquidity.TokenSelectorModal {
|
||||
theme: theme
|
||||
}
|
||||
}
|
||||
|
||||
function test_tokenSelectorStaysInsideShortViewport() {
|
||||
var viewport = createTemporaryObject(viewportComponent, testCase)
|
||||
var selector = createTemporaryObject(tokenSelectorComponent, viewport, {
|
||||
"parent": viewport
|
||||
})
|
||||
verify(viewport)
|
||||
verify(selector)
|
||||
|
||||
verify(selector.x >= 0)
|
||||
verify(selector.y >= 0)
|
||||
verify(selector.x + selector.width <= viewport.width)
|
||||
verify(selector.y + selector.height <= viewport.height)
|
||||
}
|
||||
}
|
||||
280
apps/amm/tests/qml/tst_TokenAmountInput.qml
Normal file
280
apps/amm/tests/qml/tst_TokenAmountInput.qml
Normal file
@ -0,0 +1,280 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtTest
|
||||
|
||||
import "../../qml/components/liquidity" as Liquidity
|
||||
|
||||
TestCase {
|
||||
id: testCase
|
||||
|
||||
name: "TokenAmountInput"
|
||||
readonly property string enabledId: "22222222222222222222222222222222"
|
||||
readonly property string disabledId: "33333333333333333333333333333333"
|
||||
|
||||
Component {
|
||||
id: inputComponent
|
||||
|
||||
Liquidity.TokenAmountInput {
|
||||
visible: false
|
||||
width: 400
|
||||
theme: inputTheme
|
||||
tokens: [
|
||||
{
|
||||
"definitionId": testCase.disabledId,
|
||||
"name": "Disabled",
|
||||
"selectable": false,
|
||||
"code": "token_not_fungible"
|
||||
},
|
||||
{
|
||||
"definitionId": testCase.enabledId,
|
||||
"name": "Enabled",
|
||||
"selectable": true
|
||||
}
|
||||
]
|
||||
|
||||
Liquidity.AmmTheme {
|
||||
id: inputTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SignalSpy {
|
||||
id: commitSpy
|
||||
signalName: "editingCommitted"
|
||||
}
|
||||
|
||||
SignalSpy {
|
||||
id: selectedSpy
|
||||
signalName: "tokenSelected"
|
||||
}
|
||||
|
||||
SignalSpy {
|
||||
id: enteredSpy
|
||||
signalName: "tokenEntered"
|
||||
}
|
||||
|
||||
function test_inactivityCommitsLatestEditOnce() {
|
||||
var input = createTemporaryObject(inputComponent, testCase)
|
||||
verify(input)
|
||||
commitSpy.target = input
|
||||
commitSpy.clear()
|
||||
|
||||
input.amountEdited("1")
|
||||
wait(150)
|
||||
compare(commitSpy.count, 0)
|
||||
|
||||
input.amountEdited("12")
|
||||
wait(200)
|
||||
compare(commitSpy.count, 0)
|
||||
tryCompare(commitSpy, "count", 1, 150)
|
||||
compare(commitSpy.signalArguments[0][0], "12")
|
||||
}
|
||||
|
||||
function test_editingFinishedCommitsWithoutDuplicate() {
|
||||
var input = createTemporaryObject(inputComponent, testCase)
|
||||
verify(input)
|
||||
commitSpy.target = input
|
||||
commitSpy.clear()
|
||||
|
||||
input.amountEdited("7")
|
||||
input.amountEditingFinished("7")
|
||||
compare(commitSpy.count, 1)
|
||||
compare(commitSpy.signalArguments[0][0], "7")
|
||||
|
||||
wait(300)
|
||||
compare(commitSpy.count, 1)
|
||||
}
|
||||
|
||||
function test_compactWidthShrinksTokenAccessory() {
|
||||
var input = createTemporaryObject(inputComponent, testCase, { "width": 296 })
|
||||
verify(input)
|
||||
compare(input.accessoryWidth, 132)
|
||||
|
||||
input.width = 416
|
||||
compare(input.accessoryWidth, 180)
|
||||
}
|
||||
|
||||
function test_disabledTokenIsRejectedByTypedInput() {
|
||||
var input = createTemporaryObject(inputComponent, testCase)
|
||||
verify(input)
|
||||
selectedSpy.target = input
|
||||
enteredSpy.target = input
|
||||
selectedSpy.clear()
|
||||
enteredSpy.clear()
|
||||
|
||||
input.acceptInput("Disabled")
|
||||
|
||||
compare(selectedSpy.count, 0)
|
||||
compare(enteredSpy.count, 1)
|
||||
compare(enteredSpy.signalArguments[0][0], disabledId)
|
||||
}
|
||||
|
||||
function test_enabledTokenIsSelectedByTypedInput() {
|
||||
var input = createTemporaryObject(inputComponent, testCase)
|
||||
verify(input)
|
||||
selectedSpy.target = input
|
||||
enteredSpy.target = input
|
||||
selectedSpy.clear()
|
||||
enteredSpy.clear()
|
||||
|
||||
input.acceptInput("Enabled")
|
||||
|
||||
compare(selectedSpy.count, 1)
|
||||
compare(selectedSpy.signalArguments[0][0], enabledId)
|
||||
compare(enteredSpy.count, 0)
|
||||
}
|
||||
|
||||
function test_partialNameSelectsSoleMatchInsteadOfCustomAddress() {
|
||||
var input = createTemporaryObject(inputComponent, testCase)
|
||||
verify(input)
|
||||
selectedSpy.target = input
|
||||
enteredSpy.target = input
|
||||
selectedSpy.clear()
|
||||
enteredSpy.clear()
|
||||
input.query = "Enabl"
|
||||
tryCompare(input.rows, "length", 1)
|
||||
|
||||
input.acceptInput("Enabl")
|
||||
|
||||
compare(selectedSpy.count, 1)
|
||||
compare(selectedSpy.signalArguments[0][0], enabledId)
|
||||
compare(enteredSpy.count, 0)
|
||||
}
|
||||
|
||||
function test_disabledTokenCanExplainWhyItIsUnavailable() {
|
||||
var input = createTemporaryObject(inputComponent, testCase, {
|
||||
"visible": true,
|
||||
"width": 320
|
||||
})
|
||||
verify(input)
|
||||
|
||||
input.popup.open()
|
||||
tryCompare(input.popup, "visible", true)
|
||||
var tokenList = findChild(input, "tokenList")
|
||||
verify(tokenList)
|
||||
tryCompare(tokenList, "count", 2)
|
||||
tryVerify(function() { return tokenList.itemAtIndex(0) !== null })
|
||||
var option = tokenList.itemAtIndex(0)
|
||||
|
||||
mouseMove(option, option.width / 2, option.height / 2)
|
||||
tryCompare(option, "pointerHovered", true)
|
||||
compare(option.disabledReasonVisible, true)
|
||||
|
||||
selectedSpy.target = input
|
||||
enteredSpy.target = input
|
||||
selectedSpy.clear()
|
||||
enteredSpy.clear()
|
||||
mouseClick(option, option.width / 2, option.height / 2)
|
||||
compare(selectedSpy.count, 0)
|
||||
compare(enteredSpy.count, 0)
|
||||
}
|
||||
|
||||
function test_tokenListSupportsKeyboardSelection() {
|
||||
var input = createTemporaryObject(inputComponent, testCase, {
|
||||
"visible": true,
|
||||
"width": 320
|
||||
})
|
||||
verify(input)
|
||||
selectedSpy.target = input
|
||||
selectedSpy.clear()
|
||||
|
||||
input.popup.open()
|
||||
tryCompare(input.popup, "visible", true)
|
||||
var tokenList = findChild(input, "tokenList")
|
||||
verify(tokenList)
|
||||
tryVerify(function() { return tokenList.itemAtIndex(1) !== null })
|
||||
var option = tokenList.itemAtIndex(1)
|
||||
option.forceActiveFocus()
|
||||
tryCompare(option, "activeFocus", true)
|
||||
|
||||
keyClick(Qt.Key_Space)
|
||||
|
||||
compare(selectedSpy.count, 1)
|
||||
compare(selectedSpy.signalArguments[0][0], enabledId)
|
||||
}
|
||||
|
||||
function test_disabledReasonAppearsOnKeyboardFocus() {
|
||||
var input = createTemporaryObject(inputComponent, testCase, {
|
||||
"visible": true,
|
||||
"width": 320
|
||||
})
|
||||
verify(input)
|
||||
|
||||
input.popup.open()
|
||||
tryCompare(input.popup, "visible", true)
|
||||
var tokenList = findChild(input, "tokenList")
|
||||
verify(tokenList)
|
||||
tryVerify(function() { return tokenList.itemAtIndex(0) !== null })
|
||||
var option = tokenList.itemAtIndex(0)
|
||||
option.forceActiveFocus()
|
||||
|
||||
tryCompare(option, "activeFocus", true)
|
||||
compare(option.disabledReasonVisible, true)
|
||||
}
|
||||
|
||||
function test_searchModelContainsOnlyMatchingRows() {
|
||||
var input = createTemporaryObject(inputComponent, testCase)
|
||||
verify(input)
|
||||
compare(input.rows.length, 2)
|
||||
|
||||
input.query = "Enabled"
|
||||
|
||||
tryVerify(function() { return input.rows.length === 1 })
|
||||
compare(input.rows[0].definitionId, enabledId)
|
||||
}
|
||||
|
||||
function test_typingKeepsSearchTextWhileModelFilters() {
|
||||
var input = createTemporaryObject(inputComponent, testCase, {
|
||||
"visible": true,
|
||||
"width": 320
|
||||
})
|
||||
verify(input)
|
||||
input.popup.open()
|
||||
tryCompare(input.popup, "visible", true)
|
||||
var editor = findChild(input, "tokenSearchField")
|
||||
verify(editor)
|
||||
|
||||
tryCompare(editor, "activeFocus", true)
|
||||
keyClick(Qt.Key_E)
|
||||
keyClick(Qt.Key_N)
|
||||
keyClick(Qt.Key_A)
|
||||
keyClick(Qt.Key_B)
|
||||
keyClick(Qt.Key_L)
|
||||
keyClick(Qt.Key_E)
|
||||
keyClick(Qt.Key_D)
|
||||
|
||||
compare(editor.text.toLowerCase(), "enabled")
|
||||
compare(input.rows.length, 1)
|
||||
compare(input.rows[0].definitionId, enabledId)
|
||||
}
|
||||
|
||||
function test_clickOpensSharedTokenModal() {
|
||||
var input = createTemporaryObject(inputComponent, testCase, {
|
||||
"visible": true,
|
||||
"width": 320
|
||||
})
|
||||
verify(input)
|
||||
var button = findChild(input, "tokenSelectButton")
|
||||
verify(button)
|
||||
|
||||
button.click()
|
||||
|
||||
tryCompare(input.popup, "visible", true)
|
||||
verify(findChild(input, "tokenSearchField"))
|
||||
verify(findChild(input, "tokenList"))
|
||||
}
|
||||
|
||||
function test_unlistedAddressCanBeEntered() {
|
||||
var input = createTemporaryObject(inputComponent, testCase)
|
||||
verify(input)
|
||||
enteredSpy.target = input
|
||||
enteredSpy.clear()
|
||||
var unlistedId = "44444444444444444444444444444444"
|
||||
|
||||
input.acceptInput(unlistedId)
|
||||
|
||||
compare(enteredSpy.count, 1)
|
||||
compare(enteredSpy.signalArguments[0][0], unlistedId)
|
||||
}
|
||||
}
|
||||
30
flake.nix
30
flake.nix
@ -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} "$@"
|
||||
''}";
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user