mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-30 18:53: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
266c20a90e
commit
01829a280c
41
Cargo.lock
generated
41
Cargo.lock
generated
@ -76,6 +76,25 @@ dependencies = [
|
|||||||
"risc0-zkvm",
|
"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]]
|
[[package]]
|
||||||
name = "amm_client_ffi"
|
name = "amm_client_ffi"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@ -1309,6 +1328,12 @@ dependencies = [
|
|||||||
"unicode-xid",
|
"unicode-xid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "diff"
|
||||||
|
version = "0.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@ -2761,6 +2786,16 @@ dependencies = [
|
|||||||
"zerocopy",
|
"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]]
|
[[package]]
|
||||||
name = "primitive-types"
|
name = "primitive-types"
|
||||||
version = "0.12.2"
|
version = "0.12.2"
|
||||||
@ -4878,6 +4913,12 @@ dependencies = [
|
|||||||
"hashlink",
|
"hashlink",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yansi"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
|
"apps/amm/client",
|
||||||
"programs/token/core",
|
"programs/token/core",
|
||||||
"programs/token",
|
"programs/token",
|
||||||
"programs/token/methods",
|
"programs/token/methods",
|
||||||
|
|||||||
@ -4,6 +4,11 @@ project(AmmUiPlugin LANGUAGES CXX)
|
|||||||
find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2)
|
find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2)
|
||||||
qt_standard_project_setup(REQUIRES 6.8)
|
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})
|
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
|
||||||
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
|
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
|
||||||
else()
|
else()
|
||||||
@ -31,14 +36,50 @@ logos_module(
|
|||||||
src/AmmUiPlugin.cpp
|
src/AmmUiPlugin.cpp
|
||||||
src/AmmUiBackend.h
|
src/AmmUiBackend.h
|
||||||
src/AmmUiBackend.cpp
|
src/AmmUiBackend.cpp
|
||||||
|
src/ActiveNetwork.h
|
||||||
|
src/ActiveNetwork.cpp
|
||||||
|
src/AmmClient.h
|
||||||
|
src/AmmClient.cpp
|
||||||
|
src/NewPositionRuntime.h
|
||||||
|
src/NewPositionRuntime.cpp
|
||||||
FIND_PACKAGES
|
FIND_PACKAGES
|
||||||
Qt6Gui
|
Qt6Gui
|
||||||
Qt6Network
|
Qt6Network
|
||||||
LINK_LIBRARIES
|
LINK_LIBRARIES
|
||||||
Qt6::Gui
|
Qt6::Gui
|
||||||
Qt6::Network
|
Qt6::Network
|
||||||
EXTERNAL_LIBS
|
PkgConfig::BASE58
|
||||||
amm_client_ffi
|
|
||||||
LINK_TARGETS
|
LINK_TARGETS
|
||||||
logos_wallet_access
|
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
|
startup the backend **adopts** the already-open wallet (see
|
||||||
`openOrAdoptWallet()`), surfacing **shared** accounts across apps.
|
`openOrAdoptWallet()`), surfacing **shared** accounts across apps.
|
||||||
|
|
||||||
> Follow-up: the wallet FFI requires explicit `config_path`/`storage_path` even
|
> Follow-up: the app reconstructs the wallet paths itself because the
|
||||||
> though the wallet crate already defines defaults (`~/.lee/wallet`,
|
> `logos_execution_zone` module only exposes path-taking `create_new`/`open`.
|
||||||
> `from_path_or_initialize_default`). A `wallet_ffi_create_new_default()` /
|
> LEZ's wallet FFI now provides path-free variants (`wallet_ffi_create_new_default`,
|
||||||
> `_open_default()` upstream would let the app drop its path handling entirely.
|
> `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
|
## Setup
|
||||||
|
|
||||||
@ -204,6 +206,11 @@ TOKENS_CONFIG=$(pwd)/amm-tokens.json \
|
|||||||
nix run .#amm-ui
|
nix run .#amm-ui
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
New Position validation commands and acceptance criteria live in
|
||||||
|
[VALIDATION.md](VALIDATION.md).
|
||||||
|
|
||||||
## Updating Dependencies
|
## Updating Dependencies
|
||||||
|
|
||||||
To update the pinned versions of dependencies in `flake.lock`:
|
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 =
|
inputs.logos-execution-zone.url =
|
||||||
"github:logos-blockchain/logos-execution-zone?rev=a7e06a660940a00093b1760560d37ff84aff5a05";
|
"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
|
# NOTE: this flake is no longer built standalone. The amm_client_ffi crate
|
||||||
@ -47,6 +49,12 @@
|
|||||||
preConfigure = ''
|
preConfigure = ''
|
||||||
cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${shared_wallet}")
|
cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${shared_wallet}")
|
||||||
'';
|
'';
|
||||||
|
externalLibInputs = {
|
||||||
|
amm_client = {
|
||||||
|
input = inputs.amm_client;
|
||||||
|
packages.default = "amm_client";
|
||||||
|
};
|
||||||
|
};
|
||||||
postInstall = ''
|
postInstall = ''
|
||||||
# The builder installs the view under lib/qml after this hook. Its
|
# The builder installs the view under lib/qml after this hook. Its
|
||||||
# import descriptor points back to this compiled shared QML module.
|
# import descriptor points back to this compiled shared QML module.
|
||||||
|
|||||||
@ -11,11 +11,12 @@
|
|||||||
|
|
||||||
"nix": {
|
"nix": {
|
||||||
"packages": {
|
"packages": {
|
||||||
"build": [],
|
"build": ["pkg-config"],
|
||||||
"runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"]
|
"runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp", "libbase58"]
|
||||||
},
|
},
|
||||||
"external_libraries": [
|
"external_libraries": [
|
||||||
{ "name": "amm_client_ffi" }
|
{ "name": "amm_client_ffi" },
|
||||||
|
{ "name": "amm_client" }
|
||||||
],
|
],
|
||||||
"cmake": {
|
"cmake": {
|
||||||
"find_packages": [],
|
"find_packages": [],
|
||||||
|
|||||||
@ -45,7 +45,7 @@ Item {
|
|||||||
height: show ? 32 : 0
|
height: show ? 32 : 0
|
||||||
visible: height > 0
|
visible: height > 0
|
||||||
clip: true
|
clip: true
|
||||||
color: Theme.palette.error
|
color: Theme.palette.warning
|
||||||
|
|
||||||
Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
|
Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
|
||||||
|
|
||||||
@ -56,7 +56,7 @@ Item {
|
|||||||
elide: Text.ElideMiddle
|
elide: Text.ElideMiddle
|
||||||
font.pixelSize: 12
|
font.pixelSize: 12
|
||||||
font.weight: Font.Medium
|
font.weight: Font.Medium
|
||||||
color: Theme.palette.text
|
color: Theme.palette.background
|
||||||
text: qsTr("Unable to connect to network")
|
text: qsTr("Unable to connect to network")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -88,6 +88,8 @@ Item {
|
|||||||
|
|
||||||
LiquidityPage {
|
LiquidityPage {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
backend: root.ready ? root.backend : null
|
||||||
|
runtime: logos
|
||||||
visible: navbar.currentIndex === 1
|
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
|
id: root
|
||||||
|
|
||||||
property var snapshot: ({})
|
property var snapshot: ({})
|
||||||
readonly property bool isAdd: root.snapshot.action === "add"
|
|
||||||
|
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
ColumnLayout {
|
function actionText(instruction) {
|
||||||
Layout.fillWidth: true
|
if (instruction === "NewDefinition")
|
||||||
spacing: 8
|
return qsTr("Create pool")
|
||||||
visible: root.isAdd
|
if (instruction === "AddLiquidity")
|
||||||
|
return qsTr("Add liquidity")
|
||||||
SummaryRow {
|
return instruction || "-"
|
||||||
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 || ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
SummaryRow {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 8
|
label: qsTr("Pair")
|
||||||
visible: !root.isAdd
|
value: root.snapshot.pairText || "-"
|
||||||
|
}
|
||||||
|
|
||||||
SummaryRow {
|
SummaryRow {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
label: qsTr("Burn LP")
|
label: qsTr("Action")
|
||||||
value: qsTr("%1 (%2)")
|
value: root.actionText(root.snapshot.instruction)
|
||||||
.arg(root.snapshot.burnText || "")
|
}
|
||||||
.arg(root.snapshot.burnPercent || "")
|
|
||||||
}
|
|
||||||
|
|
||||||
SummaryRow {
|
SummaryRow {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "")
|
label: qsTr("Fee")
|
||||||
value: root.snapshot.minTokenAReceived || ""
|
value: root.snapshot.feeText || "-"
|
||||||
}
|
}
|
||||||
|
|
||||||
SummaryRow {
|
SummaryRow {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "")
|
label: qsTr("Deposit")
|
||||||
value: root.snapshot.minTokenBReceived || ""
|
value: qsTr("%1 + %2")
|
||||||
}
|
.arg(root.snapshot.depositAText || "-")
|
||||||
|
.arg(root.snapshot.depositBText || "-")
|
||||||
|
valueWrapAnywhere: true
|
||||||
|
}
|
||||||
|
|
||||||
SummaryRow {
|
SummaryRow {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
label: qsTr("Slippage tolerance")
|
label: qsTr("Expected LP")
|
||||||
value: root.snapshot.slippageTolerance || ""
|
value: root.snapshot.expectedLpText || "-"
|
||||||
}
|
|
||||||
|
|
||||||
SummaryRow {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
label: qsTr("Post-removal share")
|
|
||||||
value: root.snapshot.postRemovalShare || ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 label: ""
|
||||||
property string value: ""
|
property string value: ""
|
||||||
|
property bool valueWrapAnywhere: false
|
||||||
property bool estimated: 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.")
|
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 {
|
Text {
|
||||||
color: "#E7E1D8"
|
color: "#E7E1D8"
|
||||||
elide: Text.ElideRight
|
elide: root.valueWrapAnywhere ? Text.ElideNone : Text.ElideRight
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.pixelSize: 12
|
font.pixelSize: 12
|
||||||
horizontalAlignment: Text.AlignRight
|
horizontalAlignment: Text.AlignRight
|
||||||
text: root.value
|
text: root.value
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
wrapMode: root.valueWrapAnywhere ? Text.WrapAtWordBoundaryOrAnywhere : Text.NoWrap
|
||||||
|
|
||||||
Layout.maximumWidth: Math.max(178, root.width * 0.55)
|
Layout.maximumWidth: Math.max(178, root.width * 0.55)
|
||||||
|
Layout.preferredWidth: Math.min(implicitWidth, Math.max(178, root.width * 0.55))
|
||||||
}
|
}
|
||||||
|
|
||||||
EstimateInfoButton {
|
EstimateInfoButton {
|
||||||
|
|||||||
@ -1,156 +1,135 @@
|
|||||||
import QtQuick 2.15
|
pragma ComponentBehavior: Bound
|
||||||
import QtQuick.Controls 2.15
|
|
||||||
import QtQuick.Layouts 1.15
|
|
||||||
|
|
||||||
Rectangle {
|
import QtQuick
|
||||||
|
|
||||||
|
import "../shared"
|
||||||
|
|
||||||
|
AmmTokenAmountSurface {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property alias text: amountField.text
|
property string text: ""
|
||||||
property string balance: ""
|
property string balance: ""
|
||||||
property string errorText: ""
|
|
||||||
property string helperText: ""
|
property string helperText: ""
|
||||||
property string label: ""
|
property bool showMaxButton: true
|
||||||
property string token: ""
|
property var tokenData: null
|
||||||
|
property var tokens: []
|
||||||
|
property string selectedTokenId: ""
|
||||||
|
property bool tokenInvalid: false
|
||||||
|
property bool tokenSelectionEnabled: true
|
||||||
|
property bool editPending: false
|
||||||
|
property string pendingValue: ""
|
||||||
|
property var disabledReasonForCode: function(code) {
|
||||||
|
return qsTr("This token is unavailable (%1).").arg(code || "unknown")
|
||||||
|
}
|
||||||
|
property var detailForToken: function(token) { return "" }
|
||||||
|
property alias popup: tokenModal
|
||||||
|
property alias query: tokenModal.searchText
|
||||||
|
readonly property var rows: tokenModal.rows
|
||||||
|
|
||||||
signal editingChanged(string value)
|
signal editingChanged(string value)
|
||||||
|
signal editingCommitted(string value)
|
||||||
signal maxClicked
|
signal maxClicked
|
||||||
|
signal tokenSelected(string tokenId)
|
||||||
|
signal tokenEntered(string value)
|
||||||
|
|
||||||
color: "#151515"
|
amount: root.text
|
||||||
implicitHeight: content.implicitHeight + 20
|
supportingText: root.helperText
|
||||||
radius: 8
|
supportingActionText: root.showMaxButton ? qsTr("MAX") : ""
|
||||||
border.color: root.errorText.length > 0 ? "#D85F4B" : amountField.activeFocus ? "#F26A21" : "#343434"
|
accessory: tokenActions
|
||||||
border.width: 1
|
accessoryWidth: width < 360 ? 132 : 180
|
||||||
|
accessoryHeight: root.balance.length > 0 ? 58 : 40
|
||||||
|
|
||||||
Accessible.name: root.label
|
onAmountEdited: function(value) {
|
||||||
Accessible.role: Accessible.EditableText
|
root.pendingValue = value
|
||||||
|
root.editPending = true
|
||||||
ColumnLayout {
|
root.editingChanged(value)
|
||||||
id: content
|
commitTimer.restart()
|
||||||
|
}
|
||||||
anchors.fill: parent
|
onAmountEditingFinished: function(value) {
|
||||||
anchors.margins: 10
|
root.pendingValue = value
|
||||||
spacing: 8
|
root.commitPendingEdit()
|
||||||
|
}
|
||||||
RowLayout {
|
onSupportingActionClicked: root.maxClicked()
|
||||||
spacing: 8
|
onTextChanged: {
|
||||||
|
if (root.editPending && root.text !== root.pendingValue) {
|
||||||
Layout.fillWidth: true
|
commitTimer.stop()
|
||||||
|
root.editPending = false
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
import QtQuick.Layouts 1.15
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import QtQml
|
||||||
|
|
||||||
|
import Logos.Controls
|
||||||
|
import Logos.Icons
|
||||||
import Logos.Wallet
|
import Logos.Wallet
|
||||||
import "../components/shared"
|
|
||||||
import "../components/liquidity"
|
import "../components/liquidity"
|
||||||
import "../state"
|
import "../state"
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property int activeLiquidityTab: 0
|
property var backend: null
|
||||||
property real slippageTolerancePercent: 0.5
|
property var runtime: null
|
||||||
readonly property int pageMargin: 16
|
readonly property NewPositionFlow flow: newPositionFlow
|
||||||
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
|
|
||||||
|
|
||||||
width: parent ? parent.width : implicitWidth
|
readonly property int pageMargin: width < 640 ? 16 : 24
|
||||||
height: parent ? parent.height : implicitHeight
|
readonly property int contentMaxWidth: 1200
|
||||||
implicitWidth: root.preferredCardWidth + root.pageMargin * 2
|
readonly property bool wideLayout: width >= 760
|
||||||
implicitHeight: pageCard.implicitHeight + root.pageMargin * 2
|
readonly property int stepRailWidth: Math.max(210, Math.min(330,
|
||||||
|
(width - pageMargin * 2) * 0.30))
|
||||||
|
|
||||||
DummyPoolState {
|
AmmTheme {
|
||||||
id: poolState
|
id: theme
|
||||||
|
}
|
||||||
|
|
||||||
|
NewPositionFlow {
|
||||||
|
id: newPositionFlow
|
||||||
|
|
||||||
|
backend: root.backend
|
||||||
|
runtime: root.runtime
|
||||||
|
active: root.visible
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
color: "#151515"
|
color: theme.colors.background
|
||||||
}
|
}
|
||||||
|
|
||||||
Flickable {
|
Flickable {
|
||||||
@ -33,133 +45,215 @@ Item {
|
|||||||
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
clip: true
|
clip: true
|
||||||
contentHeight: Math.max(height, pageCard.y + pageCard.implicitHeight + root.pageMargin)
|
|
||||||
contentWidth: width
|
contentWidth: width
|
||||||
enabled: !confirmationDialog.visible
|
contentHeight: Math.max(height, pageLayout.y + pageLayout.implicitHeight + root.pageMargin)
|
||||||
|
enabled: !confirmationDialog.opened
|
||||||
flickableDirection: Flickable.VerticalFlick
|
flickableDirection: Flickable.VerticalFlick
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
|
||||||
Rectangle {
|
ScrollBar.vertical: ScrollBar {
|
||||||
id: pageCard
|
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)
|
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 {
|
RowLayout {
|
||||||
id: shellContent
|
Layout.fillWidth: true
|
||||||
|
spacing: 16
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 12
|
|
||||||
spacing: 10
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
spacing: 10
|
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
color: "#E7E1D8"
|
text: qsTr("New position")
|
||||||
font.bold: true
|
color: theme.colors.textPrimary
|
||||||
font.pixelSize: 18
|
font.pixelSize: 30
|
||||||
text: qsTr("Liquidity")
|
font.weight: Font.Bold
|
||||||
|
font.letterSpacing: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
Layout.fillWidth: true
|
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 {
|
Rectangle {
|
||||||
color: "#211914"
|
Layout.preferredWidth: 20
|
||||||
radius: 12
|
Layout.preferredHeight: 1
|
||||||
border.color: "#49301F"
|
color: form.hasPair ? theme.colors.ctaBg : theme.colors.divider
|
||||||
border.width: 1
|
}
|
||||||
|
|
||||||
Layout.preferredHeight: 26
|
StepMarker {
|
||||||
Layout.preferredWidth: pairText.implicitWidth + 20
|
Layout.fillWidth: true
|
||||||
|
colors: theme.colors
|
||||||
|
stepNumber: 2
|
||||||
|
label: form.missingPool
|
||||||
|
? qsTr("Set price and deposit")
|
||||||
|
: qsTr("Enter deposit amounts")
|
||||||
|
active: form.hasPair
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text {
|
RowLayout {
|
||||||
id: pairText
|
Layout.fillWidth: true
|
||||||
|
Layout.alignment: Qt.AlignTop
|
||||||
|
spacing: root.wideLayout ? 40 : 0
|
||||||
|
|
||||||
anchors.centerIn: parent
|
Rectangle {
|
||||||
color: "#F2D8C7"
|
id: positionStepRail
|
||||||
font.bold: true
|
|
||||||
font.pixelSize: 12
|
objectName: "positionStepRail"
|
||||||
text: qsTr("%1 / %2").arg(poolState.tokenA).arg(poolState.tokenB)
|
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 {
|
NewPositionForm {
|
||||||
currentIndex: root.activeLiquidityTab
|
id: form
|
||||||
|
|
||||||
|
objectName: "newPositionForm"
|
||||||
Layout.fillWidth: true
|
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) {
|
onQuoteRequested: function(immediate, quoteRequest) {
|
||||||
root.activeLiquidityTab = index;
|
newPositionFlow.scheduleQuote(immediate, quoteRequest)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onAddLiquidityRequested: function (snapshot) {
|
onConfirmationRequested: function(snapshot) {
|
||||||
confirmationDialog.openWithSnapshot(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onRemoveLiquidityRequested: function (snapshot) {
|
onTokenResolveRequested: function(tokenId) {
|
||||||
confirmationDialog.openWithSnapshot(snapshot);
|
newPositionFlow.resolveToken(tokenId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onDraftChanged: newPositionFlow.draftChanged()
|
||||||
|
onRefreshRequested: newPositionFlow.refreshContext(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SuccessToast {
|
Connections {
|
||||||
id: successToast
|
target: newPositionFlow
|
||||||
|
|
||||||
width: Math.max(0, Math.min(380, parent.width - 24))
|
function onTokenResolutionFinished(finalResponse) {
|
||||||
|
form.finishTokenResolution(finalResponse)
|
||||||
|
}
|
||||||
|
|
||||||
anchors {
|
function onTokenResolutionFailed(code) {
|
||||||
bottom: parent.bottom
|
form.failTokenResolution(code)
|
||||||
bottomMargin: 14
|
}
|
||||||
horizontalCenter: parent.horizontalCenter
|
|
||||||
}
|
function onPoolActivated(quote) {
|
||||||
}
|
form.acceptPoolActivation(quote)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQuoteRefreshRequested(immediate) {
|
||||||
|
form.requestQuote(immediate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -171,28 +265,73 @@ Item {
|
|||||||
|
|
||||||
TransactionConfirmationDialog {
|
TransactionConfirmationDialog {
|
||||||
id: confirmationDialog
|
id: confirmationDialog
|
||||||
title: snapshot.action === "add"
|
|
||||||
? qsTr("Confirm add liquidity")
|
title: qsTr("Confirm new position")
|
||||||
: qsTr("Confirm remove liquidity")
|
confirmText: qsTr("Submit")
|
||||||
|
busy: newPositionFlow.submitting
|
||||||
summary: liquidityConfirmationSummary
|
summary: liquidityConfirmationSummary
|
||||||
|
|
||||||
onConfirmed: function (snapshot) {
|
onConfirmed: function(snapshot) {
|
||||||
root.confirmLiquidityAction(snapshot);
|
newPositionFlow.confirm(snapshot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmLiquidityAction(snapshot) {
|
component StepMarker: RowLayout {
|
||||||
if (snapshot.action === "add") {
|
id: marker
|
||||||
poolState.applyAddLiquidity(snapshot.actualA, snapshot.actualB, snapshot.deltaLp);
|
|
||||||
addLiquidityForm.resetForm();
|
required property var colors
|
||||||
successToast.show(qsTr("Liquidity added"), qsTr("Position updated"));
|
required property int stepNumber
|
||||||
return;
|
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") {
|
ColumnLayout {
|
||||||
poolState.applyRemoveLiquidity(snapshot.withdrawA, snapshot.withdrawB, snapshot.burnAmount);
|
Layout.fillWidth: true
|
||||||
removeLiquidityForm.resetForm();
|
spacing: 2
|
||||||
successToast.show(qsTr("Liquidity removed"), qsTr("Position updated"));
|
|
||||||
|
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 <QJsonArray>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
#include <QJsonParseError>
|
||||||
#include <QNetworkAccessManager>
|
#include <QNetworkAccessManager>
|
||||||
#include <QNetworkReply>
|
#include <QNetworkReply>
|
||||||
#include <QNetworkRequest>
|
#include <QNetworkRequest>
|
||||||
@ -20,7 +21,9 @@
|
|||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
|
|
||||||
|
#include "AmmClient.h"
|
||||||
#include "LogosWalletProvider.h"
|
#include "LogosWalletProvider.h"
|
||||||
|
#include "NewPositionRuntime.h"
|
||||||
#include "WalletController.h"
|
#include "WalletController.h"
|
||||||
#include "logos_api.h"
|
#include "logos_api.h"
|
||||||
#include "logos_sdk.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)
|
AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent)
|
||||||
: AmmUiBackendSimpleSource(parent),
|
: AmmUiBackendSimpleSource(parent),
|
||||||
m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)),
|
m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)),
|
||||||
m_logos(std::make_unique<LogosModules>(m_logosAPI)),
|
m_logos(std::make_unique<LogosModules>(m_logosAPI)),
|
||||||
m_wallet(std::make_unique<LogosWalletProvider>(m_logosAPI)),
|
m_wallet(std::make_unique<LogosWalletProvider>(m_logosAPI)),
|
||||||
m_walletController(std::make_unique<WalletController>(
|
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,
|
connect(m_walletController.get(), &WalletController::stateChanged,
|
||||||
this, &AmmUiBackend::syncWalletState);
|
this, &AmmUiBackend::syncWalletState);
|
||||||
syncWalletState();
|
syncWalletState();
|
||||||
m_walletController->start();
|
m_walletController->start();
|
||||||
|
QTimer::singleShot(0, this, [this]() {
|
||||||
|
setWalletStateReady(true);
|
||||||
|
syncWalletState();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
AmmUiBackend::~AmmUiBackend() = default;
|
AmmUiBackend::~AmmUiBackend() = default;
|
||||||
@ -162,6 +217,42 @@ WalletAccountModel* AmmUiBackend::accountModel() const
|
|||||||
return m_walletController->accountModel();
|
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()
|
QString AmmUiBackend::createAccountPublic()
|
||||||
{
|
{
|
||||||
return m_walletController->createAccount(true);
|
return m_walletController->createAccount(true);
|
||||||
@ -187,31 +278,41 @@ QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic)
|
|||||||
return m_walletController->balance(accountIdHex, 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,
|
QVariantMap AmmUiBackend::quoteNewPosition(QVariantMap request)
|
||||||
QString storagePath,
|
|
||||||
QString password)
|
|
||||||
{
|
{
|
||||||
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();
|
return m_newPosition->submit(
|
||||||
}
|
request, quoteHash, m_network.snapshot(), isWalletOpen());
|
||||||
|
|
||||||
void AmmUiBackend::disconnectWallet()
|
|
||||||
{
|
|
||||||
m_walletController->disconnect();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void AmmUiBackend::syncWalletState()
|
void AmmUiBackend::syncWalletState()
|
||||||
{
|
{
|
||||||
const WalletUiState& state = m_walletController->state();
|
const WalletUiState& state = m_walletController->state();
|
||||||
|
const bool walletWasOpen = isWalletOpen();
|
||||||
|
const bool wasReachable = sequencerReachable();
|
||||||
|
const QString previousAddress = sequencerAddr();
|
||||||
|
|
||||||
setIsWalletOpen(state.isWalletOpen);
|
setIsWalletOpen(state.isWalletOpen);
|
||||||
setWalletExists(state.walletExists);
|
setWalletExists(state.walletExists);
|
||||||
setConfigPath(state.configPath);
|
setConfigPath(state.configPath);
|
||||||
@ -221,6 +322,70 @@ void AmmUiBackend::syncWalletState()
|
|||||||
setCurrentBlockHeight(state.currentBlockHeight);
|
setCurrentBlockHeight(state.currentBlockHeight);
|
||||||
setSequencerAddr(state.sequencerAddress);
|
setSequencerAddr(state.sequencerAddress);
|
||||||
setSequencerReachable(state.sequencerReachable);
|
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)
|
QString AmmUiBackend::normalizeAccountId(const QString& id)
|
||||||
|
|||||||
@ -3,12 +3,15 @@
|
|||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QVariant>
|
||||||
#include <QVariantList>
|
#include <QVariantList>
|
||||||
#include <QVariantMap>
|
#include <QVariantMap>
|
||||||
|
|
||||||
#include "rep_AmmUiBackend_source.h"
|
#include "rep_AmmUiBackend_source.h"
|
||||||
|
|
||||||
|
#include "ActiveNetwork.h"
|
||||||
#include "WalletAccountModel.h"
|
#include "WalletAccountModel.h"
|
||||||
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
@ -17,9 +20,15 @@ extern "C" {
|
|||||||
|
|
||||||
class LogosAPI;
|
class LogosAPI;
|
||||||
struct LogosModules;
|
struct LogosModules;
|
||||||
|
class AmmClient;
|
||||||
class LogosWalletProvider;
|
class LogosWalletProvider;
|
||||||
|
class NewPositionRuntime;
|
||||||
|
class QNetworkAccessManager;
|
||||||
class WalletController;
|
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 {
|
class AmmUiBackend : public AmmUiBackendSimpleSource {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
Q_PROPERTY(WalletAccountModel* accountModel READ accountModel CONSTANT)
|
Q_PROPERTY(WalletAccountModel* accountModel READ accountModel CONSTANT)
|
||||||
@ -31,11 +40,17 @@ public:
|
|||||||
WalletAccountModel* accountModel() const;
|
WalletAccountModel* accountModel() const;
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
|
// Overrides of the pure-virtual slots generated from the .rep.
|
||||||
QString createAccountPublic() override;
|
QString createAccountPublic() override;
|
||||||
QString createAccountPrivate() override;
|
QString createAccountPrivate() override;
|
||||||
void refreshAccounts() override;
|
void refreshAccounts() override;
|
||||||
void refreshBalances() override;
|
void refreshBalances() override;
|
||||||
QString getBalance(QString accountIdHex, bool isPublic) 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 createNewDefault(QString password) override;
|
||||||
QString createNew(QString configPath, QString storagePath, QString password) override;
|
QString createNew(QString configPath, QString storagePath, QString password) override;
|
||||||
bool openExisting() override;
|
bool openExisting() override;
|
||||||
@ -52,6 +67,8 @@ public slots:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
void syncWalletState();
|
void syncWalletState();
|
||||||
|
void probeNetworkIdentity();
|
||||||
|
void publishNetworkContext();
|
||||||
|
|
||||||
// Normalizes an account id given as either 64-char lowercase/uppercase hex
|
// 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
|
// 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<LogosModules> m_logos;
|
||||||
std::unique_ptr<LogosWalletProvider> m_wallet;
|
std::unique_ptr<LogosWalletProvider> m_wallet;
|
||||||
std::unique_ptr<WalletController> m_walletController;
|
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
|
#endif // AMM_UI_BACKEND_H
|
||||||
|
|||||||
@ -5,6 +5,9 @@
|
|||||||
class AmmUiBackend
|
class AmmUiBackend
|
||||||
{
|
{
|
||||||
PROP(bool isWalletOpen READONLY)
|
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(bool walletExists READONLY)
|
||||||
PROP(QString configPath READONLY)
|
PROP(QString configPath READONLY)
|
||||||
PROP(QString storagePath READONLY)
|
PROP(QString storagePath READONLY)
|
||||||
@ -23,8 +26,16 @@ class AmmUiBackend
|
|||||||
SLOT(void refreshBalances())
|
SLOT(void refreshBalances())
|
||||||
SLOT(QString getBalance(QString accountIdHex, bool isPublic))
|
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
|
// 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
|
// 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
|
// 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.
|
// 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
|
in
|
||||||
{
|
{
|
||||||
packages.default = ammClientFfi;
|
packages.default = ammClientFfi;
|
||||||
packages.amm_client_ffi = ammClientFfi;
|
packages.amm_client_ffi = ammClientFfi;
|
||||||
|
packages.amm_client = ammClient;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -117,6 +143,7 @@
|
|||||||
flakeInputs = inputs;
|
flakeInputs = inputs;
|
||||||
externalLibInputs = {
|
externalLibInputs = {
|
||||||
amm_client_ffi = { input = self; packages.default = "amm_client_ffi"; };
|
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
|
# 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
|
# Logos.Wallet QML module (apps/shared/wallet). apps/amm/flake.nix wires
|
||||||
@ -160,10 +187,11 @@
|
|||||||
let
|
let
|
||||||
pkgs = import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; };
|
pkgs = import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; };
|
||||||
ammFfi = crateOutputs.packages.${system}.amm_client_ffi;
|
ammFfi = crateOutputs.packages.${system}.amm_client_ffi;
|
||||||
|
ammClient = crateOutputs.packages.${system}.amm_client;
|
||||||
in
|
in
|
||||||
app // {
|
app // {
|
||||||
program = "${pkgs.writeShellScript "run-amm-ui" ''
|
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} "$@"
|
exec ${app.program} "$@"
|
||||||
''}";
|
''}";
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user