diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 285a898..3e10204 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -42,6 +42,8 @@ logos_module( src/AmmClient.cpp src/NewPositionRuntime.h src/NewPositionRuntime.cpp + src/SequencerClient.h + src/SequencerClient.cpp FIND_PACKAGES Qt6Gui Qt6Network @@ -73,10 +75,13 @@ if(BUILD_TESTING) add_executable(amm_new_position_runtime_test tests/cpp/NewPositionRuntimeTest.cpp src/NewPositionRuntime.cpp + src/SequencerClient.cpp + src/SequencerClient.h ) target_include_directories(amm_new_position_runtime_test PRIVATE src) target_link_libraries(amm_new_position_runtime_test PRIVATE Qt6::Core + Qt6::Network PkgConfig::BASE58 logos_wallet_access ) diff --git a/apps/amm/client/include/amm_client.h b/apps/amm/client/include/amm_client.h index 83c56a6..79bde2e 100644 --- a/apps/amm/client/include/amm_client.h +++ b/apps/amm/client/include/amm_client.h @@ -11,6 +11,7 @@ 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); +char *amm_normalize_account_rpc(const char *request_json); void amm_free(char *value); #ifdef __cplusplus diff --git a/apps/amm/client/src/account.rs b/apps/amm/client/src/account.rs index 2a8d020..a70feb1 100644 --- a/apps/amm/client/src/account.rs +++ b/apps/amm/client/src/account.rs @@ -5,6 +5,9 @@ use nssa_core::{ program::ProgramId, }; use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::api::NormalizeAccountRpcRequest; #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] @@ -137,6 +140,47 @@ pub(crate) fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), )) } +#[derive(Deserialize)] +struct RpcEnvelope { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(Deserialize)] +struct RpcAccount { + program_owner: ProgramId, + balance: u128, + data: Vec, + nonce: u128, +} + +pub(crate) fn normalize_account_rpc(request: NormalizeAccountRpcRequest) -> Result { + parse_hex_32(&request.account_id, "account id")?; + let envelope: RpcEnvelope = serde_json::from_str(&request.response) + .map_err(|error| format!("invalid sequencer response: {error}"))?; + if envelope.error.is_some() { + return Err(String::from("sequencer returned an account-read error")); + } + let account = envelope + .result + .ok_or_else(|| String::from("sequencer returned no account"))?; + let data = Data::try_from(account.data) + .map_err(|error| format!("account data is too large: {error}"))?; + + Ok(json!({ + "id": request.account_id, + "status": "ok", + "account": { + "program_owner": hex::encode(program_id_bytes(account.program_owner)), + "balance": hex::encode(account.balance.to_le_bytes()), + "nonce": hex::encode(account.nonce.to_le_bytes()), + "data": hex::encode(data.as_ref()), + }, + })) +} + #[cfg(test)] pub(crate) fn account_read(id: AccountId, account: &Account) -> AccountRead { AccountRead { @@ -150,3 +194,35 @@ pub(crate) fn account_read(id: AccountId, account: &Account) -> AccountRead { }), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_rpc_u128_without_precision_loss() { + let account_id = hex::encode([7_u8; 32]); + let value = normalize_account_rpc(NormalizeAccountRpcRequest { + account_id: account_id.clone(), + response: String::from( + r#"{"jsonrpc":"2.0","id":1,"result":{"program_owner":[1,2,3,4,5,6,7,8],"balance":340282366920938463463374607431768211455,"data":[0,255],"nonce":18446744073709551617}}"#, + ), + }) + .unwrap(); + + assert_eq!(value["id"], account_id); + assert_eq!( + value["account"]["balance"], + "ffffffffffffffffffffffffffffffff" + ); + assert_eq!( + value["account"]["nonce"], + "01000000000000000100000000000000" + ); + assert_eq!(value["account"]["data"], "00ff"); + assert_eq!( + value["account"]["program_owner"], + "0100000002000000030000000400000005000000060000000700000008000000" + ); + } +} diff --git a/apps/amm/client/src/api/context.rs b/apps/amm/client/src/api/context.rs index 601bdc4..896c155 100644 --- a/apps/amm/client/src/api/context.rs +++ b/apps/amm/client/src/api/context.rs @@ -9,7 +9,7 @@ use token_core::TokenDefinition; use super::{ config::load_config, - holding::{select_holding, wallet_holdings, SelectedHolding}, + holding::{holding_options, wallet_holdings, SelectedHolding}, quote_error::issue, ContextRequest, TokenIdsRequest, SCHEMA, }; @@ -85,8 +85,11 @@ pub(super) fn context(request: ContextRequest) -> Result { } }; - let selected = select_holding(&holdings, token_id); - let mut row = json!({ + let options = holding_options(&holdings, token_id); + let total_balance = options.iter().fold(0_u128, |total, holding| { + total.saturating_add(holding.balance) + }); + let row = json!({ "definitionId": token_id.to_string(), "name": name, "metadataId": metadata_id.map(|id| id.to_string()), @@ -98,17 +101,22 @@ pub(super) fn context(request: ContextRequest) -> Result { "status": "available", "code": "available", "sources": sources, + "balanceRaw": total_balance.to_string(), + "holdings": options.into_iter().map(|holding| json!({ + "holdingId": holding.id.to_string(), + "balanceRaw": holding.balance.to_string(), + })).collect::>(), }); - 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(); + let left_holding = left["holdings"] + .as_array() + .is_some_and(|rows| !rows.is_empty()); + let right_holding = right["holdings"] + .as_array() + .is_some_and(|rows| !rows.is_empty()); right_holding.cmp(&left_holding).then_with(|| { left["definitionId"] .as_str() diff --git a/apps/amm/client/src/api/holding.rs b/apps/amm/client/src/api/holding.rs index 8481040..3219e7b 100644 --- a/apps/amm/client/src/api/holding.rs +++ b/apps/amm/client/src/api/holding.rs @@ -4,7 +4,7 @@ use nssa_core::{ }; use token_core::TokenHolding; -use crate::account::{decode_account, AccountRead}; +use crate::account::{decode_account, parse_base58_id, AccountRead}; #[derive(Clone)] pub(super) struct SelectedHolding { @@ -51,14 +51,24 @@ pub(super) fn decode_fungible_holding( pub(super) fn select_holding( holdings: &[SelectedHolding], definition_id: AccountId, + requested_id: Option<&str>, ) -> Option { + let requested_id = parse_base58_id(requested_id?, "holding id").ok()?; 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)) - }) + .find(|holding| holding.definition_id == definition_id && holding.id == requested_id) .cloned() } + +pub(super) fn holding_options( + holdings: &[SelectedHolding], + definition_id: AccountId, +) -> Vec { + let mut options = holdings + .iter() + .filter(|holding| holding.definition_id == definition_id) + .cloned() + .collect::>(); + options.sort_by_key(|holding| holding.id); + options +} diff --git a/apps/amm/client/src/api/mod.rs b/apps/amm/client/src/api/mod.rs index fdae243..b30e994 100644 --- a/apps/amm/client/src/api/mod.rs +++ b/apps/amm/client/src/api/mod.rs @@ -20,15 +20,15 @@ mod tests; use std::{error::Error, fmt}; pub use request::{ - ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest, - QuoteRequest, TokenIdsRequest, + ConfigIdRequest, ContextRequest, NormalizeAccountRpcRequest, 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 const NEW_POSITION_SCHEMA: &str = "new-position.v2"; pub(crate) const SCHEMA: &str = NEW_POSITION_SCHEMA; @@ -95,3 +95,8 @@ pub fn quote(request: QuoteRequest) -> AmmResult { pub fn plan(request: PlanRequest) -> AmmResult { plan::plan(request).map_err(Into::into) } + +/// Converts one raw sequencer `getAccount` response into precision-safe account-read JSON. +pub fn normalize_account_rpc(request: NormalizeAccountRpcRequest) -> AmmResult { + crate::account::normalize_account_rpc(request).map_err(Into::into) +} diff --git a/apps/amm/client/src/api/plan.rs b/apps/amm/client/src/api/plan.rs index b87ce0b..97edcf2 100644 --- a/apps/amm/client/src/api/plan.rs +++ b/apps/amm/client/src/api/plan.rs @@ -77,6 +77,7 @@ pub(super) fn plan(input: PlanRequest) -> Result { return Ok(plan_error("transaction_deadline_expired")); } let NewPositionPlan { accounts, branch } = plan; + let affected_account_ids = accounts.writable_account_ids(fresh_lp)?; let (account_ids, signing_requirements) = accounts.wallet_args(fresh_lp)?; let instruction = match branch { QuoteBranch::Missing { amount_a, amount_b } => { @@ -116,6 +117,10 @@ pub(super) fn plan(input: PlanRequest) -> Result { "status": "ready", "programId": input.amm_program_id, "accountIds": account_ids.into_iter().map(account_id_hex).collect::>(), + "affectedAccountIds": affected_account_ids + .into_iter() + .map(account_id_hex) + .collect::>(), "signingRequirements": signing_requirements, "instruction": instruction, "deadlineMs": deadline.to_string(), diff --git a/apps/amm/client/src/api/position.rs b/apps/amm/client/src/api/position.rs index c173faa..d36ad05 100644 --- a/apps/amm/client/src/api/position.rs +++ b/apps/amm/client/src/api/position.rs @@ -193,6 +193,23 @@ impl AccountPlan { } Ok((account_ids, signing_requirements)) } + + pub(super) fn writable_account_ids( + &self, + fresh_lp: Option, + ) -> Result, String> { + self.rows + .iter() + .filter(|row| row.action != "read") + .map(|row| match row.account_id { + Some(account_id) => Ok(account_id), + None if row.role == "user_holding_lp" => { + fresh_lp.ok_or_else(|| String::from("transaction plan has no LP holding")) + } + None => Err(String::from("transaction plan has an unresolved account")), + }) + .collect() + } } impl AccountPlanRow { diff --git a/apps/amm/client/src/api/quote.rs b/apps/amm/client/src/api/quote.rs index 9d4e322..739b942 100644 --- a/apps/amm/client/src/api/quote.rs +++ b/apps/amm/client/src/api/quote.rs @@ -17,7 +17,7 @@ use super::{ commitment::{QuoteCommitment, RequestCommitment}, context::fungible_definition, funding::{funding_commitments, funding_issues, hash_quote}, - holding::{decode_fungible_holding, select_holding, wallet_holdings}, + holding::{decode_fungible_holding, holding_options, select_holding, wallet_holdings}, pair::{derive_pair, is_canonical_pair, PairIds}, position::{ AccountPlan, AccountPlanHoldings, EvaluatedQuote, NewPositionPlan, QuoteBranch, @@ -213,9 +213,18 @@ fn compute_missing_quote( 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( + let holding_a = select_holding( + &holdings, + pair.token_a, + input.request.holding_a_id.as_deref(), + ); + let holding_b = select_holding( + &holdings, + pair.token_b, + input.request.holding_b_id.as_deref(), + ); + let mut funding = holding_selection_issues(input, pair, &holdings, &holding_a, &holding_b); + funding.extend(funding_issues( input.snapshot.wallet_available, pair, &holding_a, @@ -223,7 +232,7 @@ fn compute_missing_quote( &holding_b, amount_b, ["amountARaw", "amountBRaw"], - ); + )); let can_submit = funding.is_empty(); let mut account_plan = missing_account_plan( input, @@ -440,11 +449,31 @@ fn compute_active_quote( 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( + let holding_a = select_holding( + &holdings, + pair.token_a, + input.request.holding_a_id.as_deref(), + ); + let holding_b = select_holding( + &holdings, + pair.token_b, + input.request.holding_b_id.as_deref(), + ); + let lp_options = holding_options(&holdings, pair.lp_definition); + let lp_holding = if input.request.create_fresh_lp { + None + } else { + select_holding( + &holdings, + pair.lp_definition, + input.request.lp_holding_id.as_deref(), + ) + }; + let lp_destination_selected = + input.request.create_fresh_lp || lp_holding.is_some() || lp_options.is_empty(); + let requires_fresh_lp = input.request.create_fresh_lp || lp_options.is_empty(); + let mut funding = holding_selection_issues(input, pair, &holdings, &holding_a, &holding_b); + funding.extend(funding_issues( input.snapshot.wallet_available, pair, &holding_a, @@ -452,7 +481,15 @@ fn compute_active_quote( &holding_b, actual_b, ["maxAmountARaw", "maxAmountBRaw"], - ); + )); + if !lp_destination_selected { + funding.push(issue( + "lp_destination_required", + "Select an LP token destination.", + &["lpHoldingId", "createFreshLp"], + json!({ "available": lp_options.len() }), + )); + } let can_submit = funding.is_empty(); let warnings = if slippage_bps >= HIGH_SLIPPAGE_BPS { vec![issue( @@ -532,6 +569,12 @@ fn compute_active_quote( "minimumLpRaw": minimum_lp.to_string(), "initialPriceRealRaw": spot_price_q64_64(reserve_a, reserve_b).to_string(), "requiresFreshLp": requires_fresh_lp, + "lpDestinationRequired": !lp_destination_selected, + "lpHoldingOptions": lp_options.iter().map(|holding| json!({ + "holdingId": holding.id.to_string(), + "balanceRaw": holding.balance.to_string(), + })).collect::>(), + "selectedLpHoldingId": lp_holding.as_ref().map(|holding| holding.id.to_string()), "accountPreview": preview, "errors": funding, "warnings": warnings, @@ -556,6 +599,52 @@ fn compute_active_quote( })) } +fn holding_selection_issues( + input: &QuoteRequest, + pair: PairIds, + holdings: &[super::holding::SelectedHolding], + holding_a: &Option, + holding_b: &Option, +) -> Vec { + if !input.snapshot.wallet_available { + return Vec::new(); + } + + let mut errors = Vec::new(); + for (definition_id, requested, selected, field) in [ + ( + pair.token_a, + input.request.holding_a_id.as_deref(), + holding_a, + "holdingAId", + ), + ( + pair.token_b, + input.request.holding_b_id.as_deref(), + holding_b, + "holdingBId", + ), + ] { + let options = holding_options(holdings, definition_id); + if !options.is_empty() && selected.is_none() { + errors.push(issue( + if requested.is_some() { + "invalid_holding_selection" + } else { + "holding_selection_required" + }, + "Select a wallet holding for this token.", + &[field], + json!({ + "tokenId": definition_id.to_string(), + "available": options.len(), + }), + )); + } + } + errors +} + fn validate_active_accounts( input: &QuoteRequest, pair: PairIds, diff --git a/apps/amm/client/src/api/request.rs b/apps/amm/client/src/api/request.rs index 082b143..3fd112f 100644 --- a/apps/amm/client/src/api/request.rs +++ b/apps/amm/client/src/api/request.rs @@ -2,6 +2,13 @@ use serde::Deserialize; use crate::account::AccountRead; +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct NormalizeAccountRpcRequest { + pub account_id: String, + pub response: String, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ConfigIdRequest { @@ -60,6 +67,14 @@ pub struct PositionRequest { pub token_b_id: String, pub fee_bps: u32, #[serde(default)] + pub holding_a_id: Option, + #[serde(default)] + pub holding_b_id: Option, + #[serde(default)] + pub lp_holding_id: Option, + #[serde(default)] + pub create_fresh_lp: bool, + #[serde(default)] pub amount_a_raw: Option, #[serde(default)] pub amount_b_raw: Option, diff --git a/apps/amm/client/src/api/tests.rs b/apps/amm/client/src/api/tests.rs index 520899d..eaa2eff 100644 --- a/apps/amm/client/src/api/tests.rs +++ b/apps/amm/client/src/api/tests.rs @@ -144,6 +144,10 @@ fn request(pair: PairIds) -> PositionRequest { token_a_id: pair.token_a.to_string(), token_b_id: pair.token_b.to_string(), fee_bps: 30, + holding_a_id: Some(AccountId::new([61; 32]).to_string()), + holding_b_id: Some(AccountId::new([62; 32]).to_string()), + lp_holding_id: None, + create_fresh_lp: false, amount_a_raw: None, amount_b_raw: None, max_amount_a_raw: None, @@ -244,8 +248,16 @@ fn account_plan_sources_follow_pool_branch() { 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 holding_a = select_holding( + &holdings, + pair.token_a, + input.request.holding_a_id.as_deref(), + ); + let holding_b = select_holding( + &holdings, + pair.token_b, + input.request.holding_b_id.as_deref(), + ); let missing = missing_account_plan( &input, @@ -337,7 +349,7 @@ fn minimum_pair_is_minimal_on_price_base_side() { } #[test] -fn highest_balance_holding_wins_then_lowest_id() { +fn holding_selection_uses_requested_id() { let definition = AccountId::new([9; 32]); let holding = |id: u8, balance| SelectedHolding { id: AccountId::new([id; 32]), @@ -354,9 +366,10 @@ fn highest_balance_holding_wins_then_lowest_id() { let selected = select_holding( &[holding(4, 10), holding(2, 20), holding(1, 20)], definition, + Some(&AccountId::new([4; 32]).to_string()), ) .unwrap(); - assert_eq!(selected.id, AccountId::new([1; 32])); + assert_eq!(selected.id, AccountId::new([4; 32])); } #[test] @@ -511,6 +524,43 @@ fn context_selects_tokens_without_holdings() { assert!(value["tokens"][0].get("holdingId").is_none()); } +#[test] +fn context_lists_all_holdings_without_preselecting_one() { + let token_id = AccountId::new([3; 32]); + let first = AccountId::new([4; 32]); + let second = AccountId::new([5; 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![ + account_read(second, &token_holding(token_id, 20)), + account_read(first, &token_holding(token_id, 10)), + ], + 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!(value["tokens"][0].get("holdingId").is_none()); + assert_eq!(value["tokens"][0]["balanceRaw"], "30"); + assert_eq!( + value["tokens"][0]["holdings"], + json!([ + { "holdingId": first.to_string(), "balanceRaw": "10" }, + { "holdingId": second.to_string(), "balanceRaw": "20" }, + ]) + ); +} + #[test] fn missing_pool_snapshot_defaults_remain_real_accounts() { let id = AccountId::new([5; 32]); @@ -529,6 +579,8 @@ fn missing_pool_quote_and_plan_use_current_account_order() { 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 config_id = scenario.pair.config; + let clock_id = scenario.pair.clock; let fresh_lp = AccountId::new([63; 32]); let plan_value = scenario.plan(quote_hash, Some(default_read(fresh_lp))); @@ -538,6 +590,10 @@ fn missing_pool_quote_and_plan_use_current_account_order() { assert_eq!(plan_value["signingRequirements"][6], true); assert_eq!(plan_value["signingRequirements"][7], true); assert_eq!(plan_value["signingRequirements"][8], true); + let affected = plan_value["affectedAccountIds"].as_array().unwrap(); + assert_eq!(affected.len(), 9); + assert!(!affected.contains(&json!(account_id_hex(config_id)))); + assert!(!affected.contains(&json!(account_id_hex(clock_id)))); assert_preview_matches_plan("e_value, &plan_value, Some(fresh_lp)); } @@ -661,6 +717,15 @@ fn active_pool_quote_uses_ratio_and_existing_lp_holding() { scenario.request.max_amount_b_raw = Some(String::from("3000")); scenario.request.slippage_bps = Some(50); + let awaiting_destination = scenario.quote(); + assert_eq!(awaiting_destination["lpDestinationRequired"], true); + assert_eq!(awaiting_destination["canSubmit"], false); + assert_eq!( + awaiting_destination["errors"][0]["code"], + "lp_destination_required" + ); + scenario.request.lp_holding_id = Some(lp_holding.to_string()); + let quote_value = scenario.quote(); assert_eq!(quote_value["poolStatus"], "active_pool"); assert_eq!(quote_value["actualAmountARaw"], "1000"); diff --git a/apps/amm/client/src/ffi.rs b/apps/amm/client/src/ffi.rs index 552e950..13bbf03 100644 --- a/apps/amm/client/src/ffi.rs +++ b/apps/amm/client/src/ffi.rs @@ -6,8 +6,8 @@ use std::{ use serde::{de::DeserializeOwned, Serialize}; use crate::api::{ - self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest, - QuoteRequest, TokenIdsRequest, + self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, NormalizeAccountRpcRequest, + PairIdsRequest, PlanRequest, QuoteRequest, TokenIdsRequest, }; #[derive(Serialize)] @@ -106,6 +106,11 @@ pub extern "C" fn amm_plan(request_json: *const c_char) -> *mut c_char { call::(request_json, api::plan) } +#[unsafe(no_mangle)] +pub extern "C" fn amm_normalize_account_rpc(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::normalize_account_rpc) +} + /// Releases a string returned by an `amm_*` operation. /// /// # Safety diff --git a/apps/amm/client/tests/public_api.rs b/apps/amm/client/tests/public_api.rs index 7d38289..878f6f6 100644 --- a/apps/amm/client/tests/public_api.rs +++ b/apps/amm/client/tests/public_api.rs @@ -9,5 +9,5 @@ fn direct_rust_api_does_not_require_ffi() { assert_eq!(response["status"], "ok"); assert!(response["configId"].is_string()); - assert_eq!(NEW_POSITION_SCHEMA, "new-position.v1"); + assert_eq!(NEW_POSITION_SCHEMA, "new-position.v2"); } diff --git a/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml index cbcc3b6..7d6287f 100644 --- a/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml +++ b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml @@ -1,4 +1,7 @@ +pragma ComponentBehavior: Bound + import QtQuick +import QtQuick.Controls import QtQuick.Layouts ColumnLayout { @@ -6,6 +9,8 @@ ColumnLayout { property var snapshot: ({}) + signal snapshotEdited(var snapshot) + spacing: 8 function actionText(instruction) { @@ -22,6 +27,40 @@ ColumnLayout { value: root.snapshot.pairText || "-" } + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + visible: root.snapshot.instruction === "AddLiquidity" + + Text { + text: qsTr("LP destination") + color: "#a1a1aa" + font.pixelSize: 12 + } + + ComboBox { + id: lpDestinationPicker + + objectName: "lpDestinationPicker" + Layout.fillWidth: true + model: root.destinationRows() + enabled: model.length > 1 + currentIndex: root.destinationIndex() + displayText: currentIndex >= 0 + ? model[currentIndex].label : qsTr("Select destination") + Accessible.name: qsTr("LP token destination") + onActivated: function(index) { + root.selectDestination(model[index]) + } + + delegate: ItemDelegate { + required property var modelData + width: lpDestinationPicker.width + text: modelData.label + } + } + } + SummaryRow { Layout.fillWidth: true label: qsTr("Action") @@ -48,4 +87,55 @@ ColumnLayout { label: qsTr("Expected LP") value: root.snapshot.expectedLpText || "-" } + + function destinationRows() { + var rows = [] + var options = root.snapshot.lpHoldingOptions || [] + for (var i = 0; i < options.length; ++i) { + var id = String(options[i].holdingId || "") + rows.push({ + "holdingId": id, + "createFresh": false, + "label": qsTr("%1 · balance %2") + .arg(root.shortId(id)) + .arg(String(options[i].balanceRaw || "0")) + }) + } + rows.push({ + "holdingId": "", + "createFresh": true, + "label": qsTr("Create new LP holding") + }) + return rows + } + + function destinationIndex() { + var rows = root.destinationRows() + for (var i = 0; i < rows.length; ++i) { + if (rows[i].createFresh === (root.snapshot.createFreshLp === true) + && (rows[i].createFresh + || rows[i].holdingId === root.snapshot.selectedLpHoldingId)) { + return i + } + } + return -1 + } + + function selectDestination(destination) { + var next = JSON.parse(JSON.stringify(root.snapshot || ({}))) + next.request = next.request || ({}) + delete next.request.lpHoldingId + next.request.createFreshLp = destination.createFresh === true + if (!destination.createFresh) + next.request.lpHoldingId = destination.holdingId + next.selectedLpHoldingId = destination.holdingId + next.createFreshLp = destination.createFresh === true + next.lpDestinationRequired = false + next.quoteReady = false + root.snapshotEdited(next) + } + + function shortId(value) { + return value.length > 14 ? value.slice(0, 7) + "…" + value.slice(-5) : value + } } diff --git a/apps/amm/qml/components/liquidity/NewPositionForm.qml b/apps/amm/qml/components/liquidity/NewPositionForm.qml index 9cd9896..72e563d 100644 --- a/apps/amm/qml/components/liquidity/NewPositionForm.qml +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -23,6 +23,8 @@ AmmActionCard { property var flowState: ({}) property string selectedTokenAId: "" property string selectedTokenBId: "" + property string selectedHoldingAId: "" + property string selectedHoldingBId: "" property int selectedFeeBps: 30 property int slippageBps: 50 property string amountA: "" @@ -47,6 +49,9 @@ AmmActionCard { readonly property bool contextLoading: root.flowState.contextLoading === true readonly property bool quoteLoading: root.flowState.quoteLoading === true readonly property bool submitting: root.flowState.submitting === true + readonly property bool walletCanSubmit: root.flowState.walletCanSubmit === true + readonly property string walletSyncStatus: String( + root.flowState.walletSyncStatus || "closed") readonly property bool quoteStale: root.flowState.quoteStale === true readonly property bool poolCreationPending: root.flowState.poolCreationPending === true readonly property string submitError: root.flowState.errorCode @@ -65,6 +70,12 @@ AmmActionCard { ? root.newPositionContext.feeTiers : [] readonly property var tokenA: root.tokenById(root.selectedTokenAId) readonly property var tokenB: root.tokenById(root.selectedTokenBId) + readonly property var holdingsA: root.tokenHoldings(root.tokenA) + readonly property var holdingsB: root.tokenHoldings(root.tokenB) + readonly property var holdingA: root.holdingById(root.holdingsA, + root.selectedHoldingAId) + readonly property var holdingB: root.holdingById(root.holdingsB, + root.selectedHoldingBId) readonly property int decimalsA: 0 readonly property int decimalsB: 0 readonly property bool displayIsCanonical: root.selectedTokenAId.length > 0 @@ -87,9 +98,13 @@ AmmActionCard { && root.selectedTokenBId.length > 0 && root.selectedTokenAId !== root.selectedTokenBId readonly property bool resolvingToken: root.resolvingTokenId.length > 0 - readonly property bool canConfirm: root.quotePayload.schema === "new-position.v1" + readonly property bool lpDestinationRequired: root.quotePayload.status === "ok" + && root.quotePayload.lpDestinationRequired === true + && root.onlyLpDestinationBlocks() + readonly property bool canConfirm: root.quotePayload.schema === "new-position.v2" && root.quotePayload.status === "ok" - && root.quotePayload.canSubmit === true + && (root.quotePayload.canSubmit === true + || root.lpDestinationRequired) && root.quoteMatchesPair() && String(root.quotePayload.quoteHash || "").length > 0 && !root.contextLoading @@ -97,6 +112,7 @@ AmmActionCard { && !root.quoteStale && !root.submitting && !root.poolCreationPending + && root.walletCanSubmit signal quoteRequested(bool immediate, var quoteRequest) signal confirmationRequested(var snapshot) @@ -116,6 +132,7 @@ AmmActionCard { root.finishTokenResolution() else root.reconcileSelection() + root.reconcileHoldings() } onQuotePayloadChanged: { if (root.quoteStale) @@ -236,7 +253,8 @@ AmmActionCard { theme: root.theme text: root.amountA label: qsTr("Token A amount") - balance: root.contextLoading ? "" : root.balanceText(root.tokenA, root.decimalsA) + balance: root.contextLoading ? "" : root.holdingBalanceText(root.holdingA, + root.decimalsA) helperText: root.missingPool && !root.compact ? root.minimumAmountText("A") : "" errorText: root.formErrorText() @@ -246,6 +264,9 @@ AmmActionCard { tokenData: root.tokenA.definitionId ? root.tokenA : null tokens: root.tokens selectedTokenId: root.selectedTokenAId + holdings: root.holdingsA + selectedHoldingId: root.selectedHoldingAId + holdingSelectionEnabled: !root.submitting tokenInvalid: root.tokenHasError("A") tokenSelectionEnabled: !root.contextLoading && !root.submitting adjustment: root.missingPool ? priceAmountAAdjustment : null @@ -268,6 +289,9 @@ AmmActionCard { onMaxClicked: root.useMaximum() onTokenSelected: function(tokenId) { root.resolveToken("A", tokenId) } onTokenEntered: function(value) { root.resolveToken("A", value) } + onHoldingSelected: function(holdingId) { + root.selectHolding("A", holdingId) + } } AmmPairSeparator { @@ -285,7 +309,8 @@ AmmActionCard { theme: root.theme text: root.amountB label: qsTr("Token B amount") - balance: root.contextLoading ? "" : root.balanceText(root.tokenB, root.decimalsB) + balance: root.contextLoading ? "" : root.holdingBalanceText(root.holdingB, + root.decimalsB) helperText: root.missingPool && !root.compact ? root.minimumAmountText("B") : "" invalid: root.fieldHasError("amountB") @@ -294,6 +319,9 @@ AmmActionCard { tokenData: root.tokenB.definitionId ? root.tokenB : null tokens: root.tokens selectedTokenId: root.selectedTokenBId + holdings: root.holdingsB + selectedHoldingId: root.selectedHoldingBId + holdingSelectionEnabled: !root.submitting tokenInvalid: root.tokenHasError("B") tokenSelectionEnabled: !root.contextLoading && !root.submitting adjustment: root.missingPool ? priceAmountBAdjustment : null @@ -316,6 +344,9 @@ AmmActionCard { onMaxClicked: root.useMaximum() onTokenSelected: function(tokenId) { root.resolveToken("B", tokenId) } onTokenEntered: function(value) { root.resolveToken("B", value) } + onHoldingSelected: function(holdingId) { + root.selectHolding("B", holdingId) + } } } @@ -601,6 +632,10 @@ AmmActionCard { text: root.submitting ? qsTr("Submitting…") : root.contextLoading ? qsTr("Loading…") + : !root.walletCanSubmit && root.walletSyncStatus === "syncing" + ? qsTr("Syncing wallet…") + : !root.walletCanSubmit && root.walletSyncStatus === "opening" + ? qsTr("Opening wallet…") : root.poolCreationPending ? qsTr("Waiting for pool") : root.missingPool ? qsTr("Create pool") : qsTr("Add liquidity") enabled: root.canConfirm @@ -700,6 +735,40 @@ AmmActionCard { return root.emptyToken } + function tokenHoldings(token) { + return token && token.holdings ? token.holdings : [] + } + + function holdingById(holdings, holdingId) { + for (var i = 0; i < holdings.length; ++i) { + if (String(holdings[i].holdingId || "") === holdingId) + return holdings[i] + } + return ({}) + } + + function reconcileHoldings() { + root.selectedHoldingAId = root.reconciledHoldingId( + root.holdingsA, root.selectedHoldingAId) + root.selectedHoldingBId = root.reconciledHoldingId( + root.holdingsB, root.selectedHoldingBId) + } + + function reconciledHoldingId(holdings, selectedId) { + if (holdings.length === 1) + return String(holdings[0].holdingId || "") + return root.holdingById(holdings, selectedId).holdingId ? selectedId : "" + } + + function selectHolding(side, holdingId) { + if (side === "A") + root.selectedHoldingAId = holdingId + else + root.selectedHoldingBId = holdingId + root.noteDraftChanged() + root.requestQuote(true) + } + function selectableTokenIds() { var result = [] for (var i = 0; i < root.tokens.length; ++i) { @@ -823,6 +892,9 @@ AmmActionCard { root.selectedTokenAId = root.selectedTokenBId root.selectedTokenBId = tokenId } + root.selectedHoldingAId = "" + root.selectedHoldingBId = "" + root.reconcileHoldings() root.resetPairDraft() } @@ -830,6 +902,9 @@ AmmActionCard { var tokenId = root.selectedTokenAId root.selectedTokenAId = root.selectedTokenBId root.selectedTokenBId = tokenId + var holdingId = root.selectedHoldingAId + root.selectedHoldingAId = root.selectedHoldingBId + root.selectedHoldingBId = holdingId var amount = root.amountA root.amountA = root.amountB root.amountB = amount @@ -858,7 +933,7 @@ AmmActionCard { } function acceptPoolActivation(quote) { - if (!quote || quote.schema !== "new-position.v1" + if (!quote || quote.schema !== "new-position.v2" || quote.status !== "ok" || quote.poolStatus !== "active_pool" || !root.quoteMatchesSelectedPair(quote)) { @@ -985,6 +1060,10 @@ AmmActionCard { } var request = root.pairRequest() + if (root.holdingsA.length > 0 && root.selectedHoldingAId.length === 0) + errors.push(root.localIssue("holding_selection_required", ["holdingAId"])) + if (root.holdingsB.length > 0 && root.selectedHoldingBId.length === 0) + errors.push(root.localIssue("holding_selection_required", ["holdingBId"])) if (root.activePool) { var parsedA = AmountMath.parseHuman(root.amountA, root.decimalsA) @@ -1076,14 +1155,23 @@ AmmActionCard { } function pairRequest() { - return { - "schema": "new-position.v1", + var request = { + "schema": "new-position.v2", "tokenAId": root.displayIsCanonical ? root.selectedTokenAId : root.selectedTokenBId, "tokenBId": root.displayIsCanonical ? root.selectedTokenBId : root.selectedTokenAId, "feeBps": root.selectedFeeBps } + var holdingAId = root.displayIsCanonical + ? root.selectedHoldingAId : root.selectedHoldingBId + var holdingBId = root.displayIsCanonical + ? root.selectedHoldingBId : root.selectedHoldingAId + if (holdingAId.length > 0) + request.holdingAId = holdingAId + if (holdingBId.length > 0) + request.holdingBId = holdingBId + return request } function requestQuote(immediate) { @@ -1095,7 +1183,11 @@ AmmActionCard { } function probeRaw(token, decimals) { - var balance = String(token.balanceRaw || "0") + var holdings = root.tokenHoldings(token) + var selectedId = token.definitionId === root.tokenA.definitionId + ? root.selectedHoldingAId : root.selectedHoldingBId + var selected = root.holdingById(holdings, selectedId) + var balance = String(selected.balanceRaw || "0") var simulated = AmountMath.multiply(AmountMath.pow10(decimals), "1000") if (AmountMath.isUnsigned(balance) && AmountMath.compare(balance, simulated) > 0) return balance @@ -1195,6 +1287,9 @@ AmmActionCard { "invalid_amount_precision": qsTr("Token amounts must use whole raw units."), "invalid_raw_amount": qsTr("Value is outside the supported range."), "amount_exceeds_balance": qsTr("Amount exceeds the selected holding balance."), + "holding_selection_required": qsTr("Select a wallet holding for this token."), + "invalid_holding_selection": qsTr("Selected wallet holding is unavailable."), + "lp_destination_required": qsTr("Select where LP tokens should be deposited."), "amount_too_low": qsTr("Value is too low for this pool."), "invalid_token_id": qsTr("Enter a valid base58 TokenDefinition ID."), "deposit_ratio_mismatch": qsTr("Deposit amounts must match the initial price."), @@ -1203,6 +1298,7 @@ AmmActionCard { "fee_tier_mismatch": qsTr("Select the existing pool fee tier."), "no_wallet": qsTr("Connect a wallet to submit this position."), "wallet_unavailable": qsTr("Wallet is unavailable."), + "wallet_syncing": qsTr("Wallet is still syncing. Review the quote while it finishes."), "wallet_submission_failed": qsTr("Wallet submission failed. Review and retry manually."), "signature_rejected": qsTr("Wallet approval was rejected."), "quote_changed": qsTr("Pool or wallet state changed. Review the refreshed quote."), @@ -1217,6 +1313,7 @@ AmmActionCard { "network_unknown": qsTr("Network identity could not be verified. Refresh and retry."), "network_mismatch": qsTr("Connected wallet uses a different network."), "config_missing": qsTr("Network configuration is missing."), + "sequencer_config_required": qsTr("Sequencer endpoint is missing from wallet_config.json."), "account_read_failed": qsTr("Required on-chain state could not be read."), "pool_unavailable": qsTr("Pool state is unavailable."), "config_unavailable": qsTr("AMM configuration is unavailable."), @@ -1264,8 +1361,8 @@ AmmActionCard { var reserveB = root.poolReserve("B") if (!reserveA || !reserveB || reserveA === "0" || reserveB === "0") return - var balanceA = String(root.tokenA.balanceRaw || "0") - var balanceB = String(root.tokenB.balanceRaw || "0") + var balanceA = String(root.holdingA.balanceRaw || "0") + var balanceB = String(root.holdingB.balanceRaw || "0") var fitA = AmountMath.mulDivFloor(balanceB, reserveA, reserveB) var rawA = AmountMath.compare(balanceA, fitA) < 0 ? balanceA : fitA var rawB = AmountMath.mulDivFloor(rawA, reserveB, reserveA) @@ -1459,10 +1556,27 @@ AmmActionCard { "depositAText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A"), "depositBText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "B"), "expectedLpText": root.rawLpText(root.quotePayload.expectedLpRaw), - "instruction": String(root.quotePayload.instruction || "") + "instruction": String(root.quotePayload.instruction || ""), + "lpHoldingOptions": root.quotePayload.lpHoldingOptions || [], + "selectedLpHoldingId": String(root.quotePayload.selectedLpHoldingId || ""), + "createFreshLp": root.quotePayload.requiresFreshLp === true + && root.quotePayload.lpDestinationRequired !== true, + "lpDestinationRequired": root.quotePayload.lpDestinationRequired === true, + "quoteReady": root.quotePayload.canSubmit === true } } + function onlyLpDestinationBlocks() { + var errors = root.quotePayload.errors || [] + if (errors.length === 0) + return false + for (var i = 0; i < errors.length; ++i) { + if (errors[i].code !== "lp_destination_required") + return false + } + return true + } + function shortTokenName(token) { if (token && token.name && token.name.length > 0) return token.name @@ -1473,6 +1587,12 @@ AmmActionCard { return AmountMath.formatRaw(String(token.balanceRaw || "0"), decimals) } + function holdingBalanceText(holding, decimals) { + if (!holding || holding.balanceRaw === undefined) + return "" + return AmountMath.formatRaw(String(holding.balanceRaw || "0"), decimals) + } + function tokenBalanceDetail(token) { return qsTr("Available %1").arg(root.balanceText(token, 0)) } diff --git a/apps/amm/qml/components/liquidity/TokenAmountInput.qml b/apps/amm/qml/components/liquidity/TokenAmountInput.qml index 311276c..1e47268 100644 --- a/apps/amm/qml/components/liquidity/TokenAmountInput.qml +++ b/apps/amm/qml/components/liquidity/TokenAmountInput.qml @@ -1,6 +1,8 @@ pragma ComponentBehavior: Bound import QtQuick +import QtQuick.Controls +import QtQuick.Layouts import "../shared" @@ -16,6 +18,9 @@ AmmTokenAmountSurface { property string selectedTokenId: "" property bool tokenInvalid: false property bool tokenSelectionEnabled: true + property var holdings: [] + property string selectedHoldingId: "" + property bool holdingSelectionEnabled: true property bool editPending: false property string pendingValue: "" property var disabledReasonForCode: function(code) { @@ -31,13 +36,15 @@ AmmTokenAmountSurface { signal maxClicked signal tokenSelected(string tokenId) signal tokenEntered(string value) + signal holdingSelected(string holdingId) amount: root.text supportingText: root.helperText supportingActionText: root.showMaxButton ? qsTr("MAX") : "" accessory: tokenActions accessoryWidth: width < 360 ? 132 : 180 - accessoryHeight: root.balance.length > 0 ? 58 : 40 + accessoryHeight: root.holdings.length > 1 ? 88 + : root.balance.length > 0 ? 58 : 40 onAmountEdited: function(value) { root.pendingValue = value @@ -68,17 +75,46 @@ AmmTokenAmountSurface { 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() + ColumnLayout { + spacing: 4 + + AmmTokenAccessory { + Layout.fillWidth: true + 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() + } + + ComboBox { + id: holdingPicker + + objectName: "holdingPicker" + Layout.fillWidth: true + visible: root.holdings.length > 1 + enabled: root.holdingSelectionEnabled && root.holdings.length > 1 + model: root.holdings + currentIndex: root.holdingIndex() + displayText: currentIndex >= 0 + ? root.holdingLabel(root.holdings[currentIndex]) + : qsTr("Select holding") + Accessible.name: qsTr("Wallet holding for %1").arg(root.label) + onActivated: function(index) { + root.holdingSelected(String(root.holdings[index].holdingId || "")) + } + + delegate: ItemDelegate { + required property var modelData + width: holdingPicker.width + text: root.holdingLabel(modelData) + } + } } } @@ -132,4 +168,20 @@ AmmTokenAmountSurface { var text = String(value || "") return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text } + + function holdingIndex() { + for (var i = 0; i < root.holdings.length; ++i) { + if (String(root.holdings[i].holdingId || "") === root.selectedHoldingId) + return i + } + return -1 + } + + function holdingLabel(holding) { + if (!holding) + return qsTr("Select holding") + return qsTr("%1 · %2") + .arg(root.shortId(String(holding.holdingId || ""))) + .arg(String(holding.balanceRaw || "0")) + } } diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index 297b6d9..979c1ed 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -255,6 +255,10 @@ Item { function onQuoteRefreshRequested(immediate) { form.requestQuote(immediate) } + + function onConfirmationQuoteReady(snapshot) { + confirmationDialog.updateSnapshot(snapshot) + } } Component { @@ -269,8 +273,16 @@ Item { title: qsTr("Confirm new position") confirmText: qsTr("Submit") busy: newPositionFlow.submitting + || (opened && snapshot.quoteReady === false + && newPositionFlow.quoteLoading) + confirmEnabled: snapshot.quoteReady === true + && newPositionFlow.walletCanSubmit summary: liquidityConfirmationSummary + onSummaryEdited: function(snapshot) { + newPositionFlow.requoteConfirmation(snapshot) + } + onConfirmed: function(snapshot) { newPositionFlow.confirm(snapshot) } diff --git a/apps/amm/qml/state/NewPositionFlow.qml b/apps/amm/qml/state/NewPositionFlow.qml index 6edba5c..e3227e5 100644 --- a/apps/amm/qml/state/NewPositionFlow.qml +++ b/apps/amm/qml/state/NewPositionFlow.qml @@ -7,19 +7,24 @@ QtObject { 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 + readonly property bool backendReady: root.backend !== null + readonly property bool walletCanSubmit: root.backendReady + && root.backend.walletCanSubmit === true + readonly property var newPositionContext: root.backendReady && root.backend.newPositionContext + && root.backend.newPositionContext.schema ? root.backend.newPositionContext : root.loadingContext() readonly property var viewState: ({ "quote": root.newPositionQuote, - "contextLoading": root.contextLoading || !root.walletStateReady + "contextLoading": root.contextLoading || root.newPositionContext.status === "loading", "quoteLoading": root.quoteLoading, "quoteStale": root.quoteStale, "submitting": root.submitting, + "walletCanSubmit": root.walletCanSubmit, + "walletSyncStatus": root.backendReady + ? String(root.backend.walletSyncStatus || "closed") : "closed", "poolCreationPending": root.selectedPoolCreationPending(), "transactionId": root.transactionId, "errorCode": root.flowErrorCode || root.contextErrorCode @@ -28,8 +33,13 @@ QtObject { property var newPositionQuote: ({}) property var resolvedTokenIds: [] - property int contextSerial: 0 property int quoteSerial: 0 + property int operationSerial: 0 + property int activeQuoteRequestId: 0 + property int submitRequestId: 0 + property int poolProbeRequestId: 0 + property var pendingSubmitSnapshot: ({}) + property var pendingConfirmationSnapshot: null property bool contextLoading: false property bool quoteLoading: false property bool quoteStale: true @@ -38,6 +48,7 @@ QtObject { property string flowErrorCode: "" property string contextErrorCode: "" property string quoteErrorCode: "" + property var pendingContextCompletion: null property var pendingQuoteRequest: ({ "ok": false, "request": ({}) }) property var pendingPoolProbes: [] property bool poolProbeInFlight: false @@ -48,6 +59,7 @@ QtObject { signal quoteRefreshRequested(bool immediate) signal submitSucceeded signal submitFailed + signal confirmationQuoteReady(var snapshot) objectName: "newPositionFlow" @@ -64,25 +76,37 @@ QtObject { onTriggered: root.pollPendingPool() } - onNewPositionContextChanged: root.invalidateQuote() + property Connections backendConnections: Connections { + target: root.backend + ignoreUnknownSignals: true - onWalletStateReadyChanged: { - ++root.contextSerial - if (!root.walletStateReady) - root.contextLoading = false + function onNewPositionQuoteResultChanged() { + root.acceptQuoteResult(root.backend.newPositionQuoteResult || ({})) + } + + function onNewPositionSubmitResultChanged() { + root.acceptSubmitResult(root.backend.newPositionSubmitResult || ({})) + } + } + + onNewPositionContextChanged: { + root.contextLoading = false + root.contextErrorCode = root.newPositionContext.status === "error" + ? String(root.newPositionContext.code || "backend_error") : "" root.invalidateQuote() + const completed = root.pendingContextCompletion + root.pendingContextCompletion = null + root.tokenResolutionFinished(true) + if (completed) + completed() } onActiveChanged: { - if (!root.active) - return - Qt.callLater(function() { - if (root.active && root.walletStateReady) - root.quoteRefreshRequested(true) - }) + if (root.active && root.backendReady) + Qt.callLater(function() { root.quoteRefreshRequested(true) }) } - function contextHints(refreshWalletAccounts) { + function contextHints(refreshPublicData) { const request = root.pendingQuoteRequest.request || {} const recent = [] if (request.tokenAId) @@ -92,48 +116,24 @@ QtObject { return { "recentTokenIds": recent, "resolvedTokenIds": root.resolvedTokenIds, - "refreshWalletAccounts": refreshWalletAccounts === true + "refreshWalletAccounts": refreshPublicData === true } } - function refreshContext(refreshWalletAccounts, completed) { - const serial = ++root.contextSerial + function refreshContext(refreshPublicData, completed) { root.contextLoading = true - if (!root.walletStateReady || root.runtime === null) { + root.pendingContextCompletion = completed || null + if (!root.backendReady) { 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") + try { + root.backend.refreshNewPositionContext(root.contextHints(refreshPublicData)) + } catch (_error) { + root.contextLoading = false + root.contextErrorCode = "backend_error" + root.tokenResolutionFailed("backend_error") + } } function resolveToken(tokenId) { @@ -152,9 +152,9 @@ QtObject { ++root.quoteSerial root.pendingQuoteRequest = quoteRequest root.quoteStale = true - root.quoteLoading = root.walletStateReady && root.active + root.quoteLoading = root.backendReady && root.active root.quoteDebounce.stop() - if (!root.walletStateReady || !root.active) + if (!root.backendReady || !root.active) return if (immediate) root.requestQuoteNow(root.quoteSerial) @@ -166,86 +166,107 @@ QtObject { if (serial !== root.quoteSerial) return const built = root.pendingQuoteRequest - if (!built.ok) { - root.quoteLoading = false - return - } - if (!root.walletStateReady || !root.active || root.runtime === null) { + if (!built.ok || !root.backendReady || !root.active) { root.quoteLoading = false return } + root.activeQuoteRequestId = ++root.operationSerial + root.backend.requestNewPositionQuote( + built.request, root.activeQuoteRequestId, true) + } - 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 acceptQuoteResult(result) { + const requestId = Number(result.requestId || 0) + if (requestId === root.poolProbeRequestId) { + root.finishPoolProbe(root.pendingPoolProbes[0], result) + return + } + if (requestId !== root.activeQuoteRequestId) + return + root.quoteLoading = false + root.quoteStale = false + root.quoteErrorCode = "" + root.newPositionQuote = result.schema === "new-position.v2" + ? result : root.quoteError("unsupported_schema") + if (root.pendingConfirmationSnapshot) { + var snapshot = root.pendingConfirmationSnapshot + root.pendingConfirmationSnapshot = null + snapshot.quoteHash = String(root.newPositionQuote.quoteHash || "") + snapshot.expectedLpText = String(root.newPositionQuote.expectedLpRaw || "") + + " raw LP" + snapshot.instruction = String(root.newPositionQuote.instruction || "") + snapshot.lpHoldingOptions = root.newPositionQuote.lpHoldingOptions || [] + snapshot.selectedLpHoldingId = String( + root.newPositionQuote.selectedLpHoldingId || "") + snapshot.createFreshLp = root.newPositionQuote.requiresFreshLp === true + snapshot.lpDestinationRequired = + root.newPositionQuote.lpDestinationRequired === true + snapshot.quoteReady = root.newPositionQuote.canSubmit === true + root.confirmationQuoteReady(snapshot) + } + } + + function requoteConfirmation(snapshot) { + root.pendingConfirmationSnapshot = snapshot + root.scheduleQuote(true, { + "ok": true, + "errors": [], + "request": snapshot.request + }) } function confirm(snapshot) { if (root.submitting) return - root.submitting = true - root.flowErrorCode = "" - - if (!root.backend || root.runtime === null) { - root.finishSubmitFailure(root.quoteError("wallet_unavailable")) + if (!root.walletCanSubmit) { + root.finishSubmitFailure(root.quoteError("wallet_syncing")) return } + root.submitting = true + root.flowErrorCode = "" + root.submitRequestId = ++root.operationSerial + root.pendingSubmitSnapshot = snapshot + root.backend.requestNewPositionSubmit( + snapshot.request, snapshot.quoteHash, root.submitRequestId) + } - 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 acceptSubmitResult(result) { + if (Number(result.requestId || 0) !== root.submitRequestId) + return + if (result.schema === "new-position.v2" + && result.status === "submitted" + && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test( + String(result.transactionId || ""))) { + root.submitting = false + root.transactionId = result.transactionId + root.flowErrorCode = "" + root.contextErrorCode = "" + root.quoteErrorCode = "" + const poolProbe = root.pendingSubmitSnapshot.poolProbeRequest || null + if (String(root.pendingSubmitSnapshot.instruction || "") + === "NewDefinition" && poolProbe) + root.watchPoolCreation(poolProbe, result.deadlineMs) + root.invalidateQuote() + root.submitSucceeded() + return + } + root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed")) } function finishSubmitFailure(result) { root.submitting = false const hasFreshQuote = result && result.quote - && result.quote.schema === "new-position.v1" + && result.quote.schema === "new-position.v2" 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.flowErrorCode = result && result.code + ? result.code : "wallet_submission_failed" root.submitFailed() - if (hasFreshQuote) - return - root.scheduleQuote(true, root.pendingQuoteRequest) + if (!hasFreshQuote) + root.scheduleQuote(true, root.pendingQuoteRequest) } function watchPoolCreation(request, deadlineMs) { @@ -256,40 +277,33 @@ QtObject { const pending = root.pendingPoolProbes.filter(function(item) { return item.key !== key }) - pending.push({ - "key": key, - "request": request, - "deadlineMs": deadline - }) + 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) { + || !root.backendReady) 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) - }) + root.poolProbeRequestId = ++root.operationSerial + root.backend.requestNewPositionQuote(pending.request, root.poolProbeRequestId, true) } function finishPoolProbe(pending, quote) { + if (!pending) + return root.poolProbeInFlight = false - if (quote && quote.schema === "new-position.v1" + root.poolProbeRequestId = 0 + if (quote && quote.schema === "new-position.v2" && quote.poolStatus === "active_pool") { root.removePendingPool(pending.key) if (root.matchesSelectedPair(pending.request)) { root.poolActivated(quote) root.invalidateQuote() - root.refreshContext(true) + root.refreshContext(false) } return } @@ -313,9 +327,7 @@ QtObject { if (!request.tokenAId || !request.tokenBId) return false const selected = root.pairKey(request) - return root.pendingPoolProbes.some(function(item) { - return item.key === selected - }) + return root.pendingPoolProbes.some(function(item) { return item.key === selected }) } function removePendingPool(key) { @@ -342,6 +354,7 @@ QtObject { function invalidateQuote() { ++root.quoteSerial + root.activeQuoteRequestId = 0 root.quoteDebounce.stop() root.quoteLoading = false root.quoteStale = true @@ -349,7 +362,7 @@ QtObject { function loadingContext() { return { - "schema": "new-position.v1", + "schema": "new-position.v2", "status": "loading", "tokens": [], "feeTiers": [] @@ -358,7 +371,7 @@ QtObject { function quoteError(code) { return { - "schema": "new-position.v1", + "schema": "new-position.v2", "status": "error", "canSubmit": false, "code": code, diff --git a/apps/amm/src/AmmClient.cpp b/apps/amm/src/AmmClient.cpp index 17d6d61..8ad23a4 100644 --- a/apps/amm/src/AmmClient.cpp +++ b/apps/amm/src/AmmClient.cpp @@ -69,3 +69,8 @@ AmmClientResult BundledAmmClient::plan(const QJsonObject& request) const { return call(amm_plan, request); } + +AmmClientResult BundledAmmClient::normalizeAccountRpc(const QJsonObject& request) const +{ + return call(amm_normalize_account_rpc, request); +} diff --git a/apps/amm/src/AmmClient.h b/apps/amm/src/AmmClient.h index 6c69f48..aca7efe 100644 --- a/apps/amm/src/AmmClient.h +++ b/apps/amm/src/AmmClient.h @@ -17,6 +17,7 @@ public: virtual AmmClientResult context(const QJsonObject& request) const = 0; virtual AmmClientResult quote(const QJsonObject& request) const = 0; virtual AmmClientResult plan(const QJsonObject& request) const = 0; + virtual AmmClientResult normalizeAccountRpc(const QJsonObject& request) const = 0; }; class BundledAmmClient final : public AmmClient { @@ -27,4 +28,5 @@ public: AmmClientResult context(const QJsonObject& request) const override; AmmClientResult quote(const QJsonObject& request) const override; AmmClientResult plan(const QJsonObject& request) const override; + AmmClientResult normalizeAccountRpc(const QJsonObject& request) const override; }; diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 65cf790..67b8fd5 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -13,6 +14,7 @@ #include "AmmClient.h" #include "LogosWalletProvider.h" #include "NewPositionRuntime.h" +#include "SequencerClient.h" #include "WalletController.h" #include "logos_api.h" @@ -63,22 +65,24 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) m_walletController(std::make_unique( *m_wallet, QStringLiteral("AmmUI"))), m_ammClient(std::make_unique()), - m_newPosition(std::make_unique(m_wallet.get(), m_ammClient.get())), - m_net(new QNetworkAccessManager(this)) + m_sequencer(std::make_unique(m_ammClient.get(), this)), + m_newPosition(std::make_unique( + m_wallet.get(), m_ammClient.get(), m_sequencer.get())), + m_net(new QNetworkAccessManager(this)), + m_transactionTimer(new QTimer(this)) { - setWalletStateReady(false); + setNewPositionQuoteResult({}); + setNewPositionSubmitResult({}); + m_transactionTimer->setInterval(5000); + connect(m_transactionTimer, &QTimer::timeout, + this, &AmmUiBackend::pollTransactions); m_network.load(); - setNewPositionContext(m_newPosition->context( - QVariantMap(), m_network.snapshot(), false, false)); connect(m_walletController.get(), &WalletController::stateChanged, this, &AmmUiBackend::syncWalletState); syncWalletState(); + refreshNewPositionContext({}); m_walletController->start(); - QTimer::singleShot(0, this, [this]() { - setWalletStateReady(true); - syncWalletState(); - }); } AmmUiBackend::~AmmUiBackend() = default; @@ -90,28 +94,22 @@ WalletAccountModel* AmmUiBackend::accountModel() const 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; } @@ -119,7 +117,6 @@ bool AmmUiBackend::openExisting() void AmmUiBackend::disconnectWallet() { m_walletController->disconnect(); - setWalletStateReady(true); m_newPosition->clearWalletAccounts(); refreshNewPositionContext(QVariantMap()); } @@ -162,19 +159,38 @@ void AmmUiBackend::refreshNewPositionContext(QVariantMap request) } if (m_network.status() == QStringLiteral("network_unknown")) probeNetworkIdentity(); - setNewPositionContext(m_newPosition->context( - request, m_network.snapshot(), isWalletOpen(), refreshWalletAccounts)); + const quint64 generation = ++m_contextGeneration; + m_newPosition->contextAsync( + request, m_network.snapshot(), isWalletOpen(), refreshWalletAccounts, + [this, generation](QVariantMap result) { + if (generation == m_contextGeneration) + setNewPositionContext(std::move(result)); + }); } -QVariantMap AmmUiBackend::quoteNewPosition(QVariantMap request) +void AmmUiBackend::requestNewPositionQuote(QVariantMap request, + int requestId, + bool forceRefresh) { - return m_newPosition->quote(request, m_network.snapshot(), isWalletOpen()); + m_newPosition->quoteAsync( + request, m_network.snapshot(), isWalletOpen(), forceRefresh, + [this, requestId](QVariantMap result) { + result.insert(QStringLiteral("requestId"), requestId); + setNewPositionQuoteResult(std::move(result)); + }); } -QVariantMap AmmUiBackend::submitNewPosition(QVariantMap request, QString quoteHash) +void AmmUiBackend::requestNewPositionSubmit(QVariantMap request, + QString quoteHash, + int requestId) { - return m_newPosition->submit( - request, quoteHash, m_network.snapshot(), isWalletOpen()); + m_newPosition->submitAsync( + request, quoteHash, m_network.snapshot(), walletCanSubmit(), + [this, requestId](QVariantMap result) { + result.insert(QStringLiteral("requestId"), requestId); + setNewPositionSubmitResult(result); + watchTransaction(result); + }); } void AmmUiBackend::syncWalletState() @@ -185,6 +201,11 @@ void AmmUiBackend::syncWalletState() const QString previousAddress = sequencerAddr(); setIsWalletOpen(state.isWalletOpen); + setWalletSyncStatus(state.syncStatus); + setWalletSyncError(state.syncError); + setWalletCanSubmit(state.canSubmit()); + setWalletStateReady(state.syncStatus != QStringLiteral("opening") + && state.syncStatus != QStringLiteral("syncing")); setWalletExists(state.walletExists); setConfigPath(state.configPath); setStoragePath(state.storagePath); @@ -194,14 +215,27 @@ void AmmUiBackend::syncWalletState() setSequencerAddr(state.sequencerAddress); setSequencerReachable(state.sequencerReachable); + if (m_sequencerConfigPath != state.configPath || !m_sequencer->isConfigured()) { + m_sequencerConfigPath = state.configPath; + m_sequencer->configure(state.configPath); + } + const bool addressChanged = previousAddress != state.sequencerAddress; - if (addressChanged) + if (addressChanged) { + m_pendingTransactions.clear(); + m_transactionTimer->stop(); m_network.sequencerChanged(!state.sequencerAddress.isEmpty()); + } if (addressChanged || wasReachable != state.sequencerReachable) { m_network.reachabilityChanged(state.sequencerReachable, wasReachable); } if (walletWasOpen && !state.isWalletOpen) m_newPosition->clearWalletAccounts(); + if (state.canSubmit()) { + const WalletSnapshot snapshot = m_wallet->snapshot(); + if (snapshot.ok()) + m_newPosition->setWalletAccounts(snapshot.accounts); + } publishNetworkContext(); if (state.sequencerReachable && m_network.needsIdentityProbe()) @@ -230,6 +264,7 @@ void AmmUiBackend::probeNetworkIdentity() QNetworkRequest request{QUrl(address)}; request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); request.setTransferTimeout(4000); + m_sequencer->applyAuthorization(request); QNetworkReply* reply = m_net->post(request, jsonRpcBody(method, params)); connect(reply, &QNetworkReply::finished, this, [this, reply, address, devnet]() { m_identityProbeInFlight = false; @@ -255,6 +290,78 @@ void AmmUiBackend::probeNetworkIdentity() void AmmUiBackend::publishNetworkContext() { - setNewPositionContext(m_newPosition->context( - m_newPositionHints, m_network.snapshot(), isWalletOpen(), false)); + refreshNewPositionContext(m_newPositionHints); +} + +void AmmUiBackend::watchTransaction(const QVariantMap& result) +{ + if (result.value(QStringLiteral("status")).toString() + != QStringLiteral("submitted")) { + return; + } + const QString nativeHash = result.value( + QStringLiteral("nativeTransactionHash")).toString(); + bool deadlineValid = false; + const qint64 deadline = result.value(QStringLiteral("deadlineMs")) + .toString().toLongLong(&deadlineValid); + QStringList affected; + for (const QVariant& value : result.value( + QStringLiteral("affectedAccountIds")).toList()) { + affected.append(value.toString()); + } + if (nativeHash.isEmpty() || !deadlineValid || affected.isEmpty()) + return; + m_pendingTransactions.insert(nativeHash, { + nativeHash, + affected, + deadline, + }); + if (!m_transactionTimer->isActive()) + m_transactionTimer->start(); + QTimer::singleShot(0, this, &AmmUiBackend::pollTransactions); +} + +void AmmUiBackend::pollTransactions() +{ + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + const QList pending = m_pendingTransactions.values(); + for (const PendingTransaction& transaction : pending) { + if (now >= transaction.deadlineMs) { + m_pendingTransactions.remove(transaction.nativeHash); + continue; + } + m_sequencer->queryTransaction(transaction.nativeHash, + [this, transaction](bool ok, bool included) { + if (!ok || !included + || !m_pendingTransactions.contains(transaction.nativeHash)) { + return; + } + m_pendingTransactions.remove(transaction.nativeHash); + refreshAffectedAccounts(transaction.affectedAccountIds); + if (m_pendingTransactions.isEmpty()) + m_transactionTimer->stop(); + }); + } + if (m_pendingTransactions.isEmpty()) + m_transactionTimer->stop(); +} + +void AmmUiBackend::refreshAffectedAccounts(const QStringList& accountIds, int attempt) +{ + m_sequencer->readAccounts(accountIds, true, + [this, accountIds, attempt](QVector reads) { + QStringList failed; + for (qsizetype index = 0; index < reads.size(); ++index) { + if (!reads.at(index).ok()) + failed.append(accountIds.value(index)); + } + if (!failed.isEmpty() && attempt < 2) { + QTimer::singleShot(1000, this, + [this, failed, attempt]() { + refreshAffectedAccounts(failed, attempt + 1); + }); + return; + } + refreshNewPositionContext({}); + }); } diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 725652e..6355b83 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -4,7 +4,9 @@ #include #include +#include #include +#include #include #include "rep_AmmUiBackend_source.h" @@ -17,6 +19,8 @@ class AmmClient; class LogosWalletProvider; class NewPositionRuntime; class QNetworkAccessManager; +class QTimer; +class SequencerClient; class WalletController; // Source-side implementation of the AmmUiBackend .rep interface. @@ -40,8 +44,12 @@ public slots: void refreshBalances() override; QString getBalance(QString accountIdHex, bool isPublic) override; void refreshNewPositionContext(QVariantMap request) override; - QVariantMap quoteNewPosition(QVariantMap request) override; - QVariantMap submitNewPosition(QVariantMap request, QString quoteHash) override; + void requestNewPositionQuote(QVariantMap request, + int requestId, + bool forceRefresh) override; + void requestNewPositionSubmit(QVariantMap request, + QString quoteHash, + int requestId) 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; @@ -53,18 +61,31 @@ private: void syncWalletState(); void probeNetworkIdentity(); void publishNetworkContext(); + void watchTransaction(const QVariantMap& result); + void pollTransactions(); + void refreshAffectedAccounts(const QStringList& accountIds, int attempt = 0); LogosAPI* m_logosAPI; std::unique_ptr m_wallet; std::unique_ptr m_walletController; std::unique_ptr m_ammClient; + std::unique_ptr m_sequencer; std::unique_ptr m_newPosition; QNetworkAccessManager* m_net; + QTimer* m_transactionTimer; ActiveNetwork m_network; QVariantMap m_newPositionHints; + QString m_sequencerConfigPath; bool m_identityProbeInFlight = false; + quint64 m_contextGeneration = 0; + struct PendingTransaction { + QString nativeHash; + QStringList affectedAccountIds; + qint64 deadlineMs = 0; + }; + QHash m_pendingTransactions; }; #endif // AMM_UI_BACKEND_H diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index f0d0b69..78c7b10 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -1,5 +1,5 @@ // QtRO view contract for the AMM UI backend. PROPs auto-sync to every QML -// replica; SLOTs are the async surface QML calls via logos.watch(...). +// replica; SLOTs dispatch asynchronous backend work. // The account list is exposed separately as a Q_PROPERTY model on the backend // (reached from QML via logos.model("amm_ui", "accountModel")). class AmmUiBackend @@ -8,6 +8,9 @@ class AmmUiBackend // 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(QString walletSyncStatus READONLY) + PROP(QString walletSyncError READONLY) + PROP(bool walletCanSubmit READONLY) PROP(bool walletExists READONLY) PROP(QString configPath READONLY) PROP(QString storagePath READONLY) @@ -26,13 +29,15 @@ class AmmUiBackend SLOT(void refreshBalances()) SLOT(QString getBalance(QString accountIdHex, bool isPublic)) - // New Position backend surface. QML calls these through logos.watch(...). - // The QVariant payloads are stable maps/lists so the UI never assembles AMM - // transactions or duplicates quote state. + // New Position backend surface. QML dispatches request slots and observes + // request-tagged result properties. QVariant payloads are stable maps/lists + // so the UI never assembles AMM transactions or duplicates quote state. PROP(QVariantMap newPositionContext READONLY) + PROP(QVariantMap newPositionQuoteResult READONLY) + PROP(QVariantMap newPositionSubmitResult READONLY) SLOT(void refreshNewPositionContext(QVariantMap request)) - SLOT(QVariantMap quoteNewPosition(QVariantMap request)) - SLOT(QVariantMap submitNewPosition(QVariantMap request, QString quoteHash)) + SLOT(void requestNewPositionQuote(QVariantMap request, int requestId, bool forceRefresh)) + SLOT(void requestNewPositionSubmit(QVariantMap request, QString quoteHash, int requestId)) // Wallet lifecycle. createNewDefault() is the happy path: it creates a // fresh wallet at the canonical walletHome with no path picking. createNew() diff --git a/apps/amm/src/NewPositionRuntime.cpp b/apps/amm/src/NewPositionRuntime.cpp index 3f016ef..2ac246c 100644 --- a/apps/amm/src/NewPositionRuntime.cpp +++ b/apps/amm/src/NewPositionRuntime.cpp @@ -11,10 +11,11 @@ #include #include "AmmClient.h" +#include "SequencerClient.h" #include "WalletProvider.h" namespace { - const char SCHEMA[] = "new-position.v1"; + const char SCHEMA[] = "new-position.v2"; constexpr qsizetype HASH_BYTES = 32; constexpr std::size_t BASE58_BUFFER_SIZE = 45; @@ -35,6 +36,21 @@ namespace { encoded.data(), static_cast(size - 1)); } + QString accountIdHex(const QString& accountId) + { + const QByteArray encoded = accountId.toLatin1(); + std::array bytes {}; + std::size_t size = bytes.size(); + if (!b58tobin(bytes.data(), &size, encoded.constData(), + static_cast(encoded.size())) + || size != bytes.size()) { + return {}; + } + return QString::fromLatin1( + QByteArray(reinterpret_cast(bytes.data()), + static_cast(bytes.size())).toHex()); + } + QJsonObject issue(const QString& code, const QJsonArray& blockingFields = {}) { @@ -142,17 +158,30 @@ namespace { } } -NewPositionRuntime::NewPositionRuntime(WalletProvider* wallet, AmmClient* client) +NewPositionRuntime::NewPositionRuntime(WalletProvider* wallet, + AmmClient* client, + SequencerClient* sequencer) : m_wallet(wallet), - m_client(client) + m_client(client), + m_sequencer(sequencer) { } void NewPositionRuntime::clearWalletAccounts() { + m_walletPublicAccountIds.clear(); m_wallet->clearSnapshot(); } +void NewPositionRuntime::setWalletAccounts(const QVector& accounts) +{ + m_walletPublicAccountIds.clear(); + for (const WalletAccount& account : accounts) { + if (account.isPublic) + m_walletPublicAccountIds.append(account.address); + } +} + QJsonArray NewPositionRuntime::walletAccountReads(bool walletOpen, bool refresh) const { if (!walletOpen) @@ -221,6 +250,356 @@ QJsonObject NewPositionRuntime::buildQuoteInput(const QVariantMap& request, }; } +void NewPositionRuntime::contextAsync(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool refreshPublicData, + ResultCallback callback) +{ + if (network.status != QStringLiteral("ready")) { + callback(contextState(network.status, network).toVariantMap()); + return; + } + if (!m_sequencer || !m_sequencer->isConfigured()) { + callback(contextState(QStringLiteral("error"), network, + QStringLiteral("sequencer_config_required")).toVariantMap()); + return; + } + + const AmmClientResult configResult = m_client->configId( + QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } }); + if (!configResult.ok) { + callback(contextState(QStringLiteral("error"), network, + QStringLiteral("backend_error")).toVariantMap()); + return; + } + const QString configId = configResult.value.value( + QStringLiteral("configId")).toString(); + m_sequencer->readAccounts({ configId }, refreshPublicData, + [this, request, network, walletOpen, refreshPublicData, + callback = std::move(callback)](QVector configReads) mutable { + const QJsonObject config = accountReadJson(configReads.value(0)); + m_sequencer->readAccounts(m_walletPublicAccountIds, false, + [this, request, network, walletOpen, refreshPublicData, config, + callback = std::move(callback)]( + QVector walletReads) mutable { + const QJsonObject hints = QJsonObject::fromVariantMap(request); + 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 QJsonArray walletAccounts = accountReadsJson(walletReads); + 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 }, + }); + if (!tokenResult.ok + || tokenResult.value.value(QStringLiteral("status")).toString() + != QStringLiteral("ok")) { + const QString code = tokenResult.ok + ? tokenResult.value.value(QStringLiteral("code")).toString() + : QStringLiteral("backend_error"); + callback(contextState(QStringLiteral("error"), network, + code.isEmpty() ? QStringLiteral("backend_error") : code) + .toVariantMap()); + return; + } + + QStringList definitionIds; + for (const QJsonValue& id : tokenResult.value + .value(QStringLiteral("tokenIds")).toArray()) { + definitionIds.append(id.toString()); + } + m_sequencer->readAccounts(definitionIds, refreshPublicData, + [this, network, walletOpen, config, walletAccounts, + configured, recent, resolved, + callback = std::move(callback)]( + QVector definitions) mutable { + const AmmClientResult result = 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"), + accountReadsJson(definitions) }, + { QStringLiteral("configuredTokenIds"), configured }, + { QStringLiteral("recentTokenIds"), recent }, + { QStringLiteral("resolvedTokenIds"), resolved }, + }); + callback((result.ok + ? result.value + : contextState(QStringLiteral("error"), network, + QStringLiteral("backend_error"))).toVariantMap()); + }); + }); + }); +} + +void NewPositionRuntime::buildQuoteInputAsync( + const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool forceRefresh, + std::function callback) const +{ + if (network.status != QStringLiteral("ready")) { + callback({}, publicError(network.status)); + return; + } + if (!m_sequencer || !m_sequencer->isConfigured()) { + callback({}, publicError(QStringLiteral("sequencer_config_required"))); + return; + } + const AmmClientResult configResult = m_client->configId( + QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } }); + if (!configResult.ok) { + callback({}, publicError(QStringLiteral("backend_error"))); + return; + } + const QString configId = configResult.value.value( + QStringLiteral("configId")).toString(); + m_sequencer->readAccounts({ configId }, forceRefresh, + [this, request, network, walletOpen, forceRefresh, + callback = std::move(callback)](QVector configReads) mutable { + const QJsonObject config = accountReadJson(configReads.value(0)); + 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 + || pairResult.value.value(QStringLiteral("status")).toString() + != QStringLiteral("ok")) { + const QString code = pairResult.ok + ? pairResult.value.value(QStringLiteral("code")).toString() + : QStringLiteral("backend_error"); + callback({}, publicError(code.isEmpty() + ? QStringLiteral("backend_error") : code)); + return; + } + const QJsonObject pair = pairResult.value; + const QStringList fixedIds { + pair.value(QStringLiteral("tokenAId")).toString(), + pair.value(QStringLiteral("tokenBId")).toString(), + pair.value(QStringLiteral("poolId")).toString(), + pair.value(QStringLiteral("vaultAId")).toString(), + pair.value(QStringLiteral("vaultBId")).toString(), + pair.value(QStringLiteral("lpDefinitionId")).toString(), + pair.value(QStringLiteral("lpLockHoldingId")).toString(), + pair.value(QStringLiteral("currentTickId")).toString(), + pair.value(QStringLiteral("clockId")).toString(), + }; + m_sequencer->readAccounts(fixedIds, forceRefresh, + [this, requestObject, network, walletOpen, config, pair, + callback = std::move(callback)]( + QVector fixedReads) mutable { + m_sequencer->readAccounts(m_walletPublicAccountIds, false, + [this, requestObject, network, walletOpen, config, pair, + fixedReads = std::move(fixedReads), + callback = std::move(callback)]( + QVector walletReads) mutable { + QStringList selectedIds; + for (const QString& key : { + QStringLiteral("holdingAId"), + QStringLiteral("holdingBId"), + QStringLiteral("lpHoldingId") }) { + const QString id = accountIdHex( + requestObject.value(key).toString()); + if (!id.isEmpty() && !selectedIds.contains(id)) + selectedIds.append(id); + } + auto finish = [this, requestObject, network, walletOpen, + config, pair, + fixedReads = std::move(fixedReads), + walletReads = std::move(walletReads), + callback = std::move(callback)]( + QVector selectedReads) mutable { + QHash walletById; + for (const WalletAccountRead& read : walletReads) + walletById.insert(read.accountId, read); + for (const WalletAccountRead& read : selectedReads) + walletById.insert(read.accountId, read); + const QJsonArray walletAccounts = accountReadsJson( + walletById.values()); + const QJsonObject snapshot { + { QStringLiteral("config"), config }, + { QStringLiteral("tokenA"), accountReadJson(fixedReads.value(0)) }, + { QStringLiteral("tokenB"), accountReadJson(fixedReads.value(1)) }, + { QStringLiteral("pool"), accountReadJson(fixedReads.value(2)) }, + { QStringLiteral("vaultA"), accountReadJson(fixedReads.value(3)) }, + { QStringLiteral("vaultB"), accountReadJson(fixedReads.value(4)) }, + { QStringLiteral("lpDefinition"), accountReadJson(fixedReads.value(5)) }, + { QStringLiteral("lpLockHolding"), accountReadJson(fixedReads.value(6)) }, + { QStringLiteral("currentTick"), accountReadJson(fixedReads.value(7)) }, + { QStringLiteral("clock"), accountReadJson(fixedReads.value(8)) }, + { QStringLiteral("walletAvailable"), walletOpen }, + { QStringLiteral("walletAccounts"), walletAccounts }, + }; + callback(QJsonObject { + { QStringLiteral("networkId"), network.id }, + { QStringLiteral("networkFingerprint"), network.fingerprint }, + { QStringLiteral("ammProgramId"), network.ammProgramId }, + { QStringLiteral("request"), requestObject }, + { QStringLiteral("snapshot"), snapshot }, + }, {}); + }; + if (selectedIds.isEmpty()) + finish({}); + else + m_sequencer->readAccounts( + selectedIds, true, std::move(finish)); + }); + }); + }); +} + +void NewPositionRuntime::quoteAsync(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool forceRefresh, + ResultCallback callback) +{ + buildQuoteInputAsync(request, network, walletOpen, forceRefresh, + [this, callback = std::move(callback)]( + QJsonObject input, QJsonObject error) mutable { + if (!error.isEmpty()) { + callback(error.toVariantMap()); + return; + } + const AmmClientResult result = m_client->quote(input); + callback((result.ok ? result.value + : publicError(QStringLiteral("backend_error"))) + .toVariantMap()); + }); +} + +void NewPositionRuntime::submitAsync(const QVariantMap& request, + const QString& quoteHash, + const ActiveNetworkSnapshot& network, + bool walletCanSubmit, + ResultCallback callback) +{ + if (m_submitInFlight) { + callback(publicError(QStringLiteral("submit_in_progress")).toVariantMap()); + return; + } + if (!walletCanSubmit) { + callback(publicError(QStringLiteral("wallet_syncing")).toVariantMap()); + return; + } + m_submitInFlight = true; + auto finish = [this, callback = std::move(callback)](QVariantMap result) mutable { + m_submitInFlight = false; + callback(std::move(result)); + }; + buildQuoteInputAsync(request, network, true, true, + [this, quoteHash, finish = std::move(finish)]( + QJsonObject input, QJsonObject error) mutable { + if (!error.isEmpty()) { + finish(error.toVariantMap()); + return; + } + const AmmClientResult quoteResult = m_client->quote(input); + if (!quoteResult.ok) { + finish(publicError(QStringLiteral("backend_error")).toVariantMap()); + return; + } + const QJsonObject quote = quoteResult.value; + if (quote.value(QStringLiteral("quoteHash")).toString() != quoteHash) { + QJsonObject result = publicError(QStringLiteral("quote_changed")); + result.insert(QStringLiteral("quote"), quote); + finish(result.toVariantMap()); + return; + } + if (!quote.value(QStringLiteral("canSubmit")).toBool(false)) { + QJsonObject result = publicError(QStringLiteral("quote_not_submittable")); + result.insert(QStringLiteral("quote"), quote); + finish(result.toVariantMap()); + return; + } + + QJsonValue freshLp; + if (quote.value(QStringLiteral("requiresFreshLp")).toBool(false)) { + const WalletAccountCreation creation = m_wallet->createAccount(true); + if (!creation.ok() || !creation.publicAccount.ok()) { + finish(publicError(QStringLiteral("wallet_submission_failed")).toVariantMap()); + return; + } + freshLp = accountReadJson(creation.publicAccount); + if (!m_walletPublicAccountIds.contains(creation.accountId)) + m_walletPublicAccountIds.append(creation.accountId); + } + + 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) { + finish(publicError(QStringLiteral("backend_error")).toVariantMap()); + return; + } + const QJsonObject plan = planResult.value; + if (plan.value(QStringLiteral("status")).toString() + != QStringLiteral("ready")) { + const QString code = plan.value(QStringLiteral("code")).toString(); + finish(publicError(code.isEmpty() + ? QStringLiteral("wallet_submission_failed") : code).toVariantMap()); + return; + } + + const QStringList accountIds = jsonStringList( + plan.value(QStringLiteral("accountIds")).toArray()); + const QVector signingRequirements = jsonBoolList( + plan.value(QStringLiteral("signingRequirements")).toArray()); + const QVector instruction = jsonUIntList( + plan.value(QStringLiteral("instruction")).toArray()); + bool deadlineValid = false; + const qulonglong deadline = plan.value(QStringLiteral("deadlineMs")) + .toString().toULongLong(&deadlineValid); + if (!deadlineValid + || static_cast(QDateTime::currentMSecsSinceEpoch()) >= deadline) { + finish(publicError(QStringLiteral("transaction_deadline_expired")).toVariantMap()); + return; + } + const WalletSubmission submission = m_wallet->submitPublicTransaction({ + plan.value(QStringLiteral("programId")).toString(), + accountIds, + signingRequirements, + instruction, + }); + const QString transactionId = submission.accepted() + ? base58TransactionId(submission.nativeHash) : QString(); + if (transactionId.isEmpty()) { + finish(publicError(QStringLiteral("wallet_submission_failed")).toVariantMap()); + return; + } + finish(QJsonObject { + { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, + { QStringLiteral("status"), QStringLiteral("submitted") }, + { QStringLiteral("transactionId"), transactionId }, + { QStringLiteral("nativeTransactionHash"), submission.nativeHash }, + { QStringLiteral("deadlineMs"), plan.value(QStringLiteral("deadlineMs")) }, + { QStringLiteral("affectedAccountIds"), + plan.value(QStringLiteral("affectedAccountIds")) }, + }.toVariantMap()); + }); +} + QVariantMap NewPositionRuntime::context(const QVariantMap& request, const ActiveNetworkSnapshot& network, bool walletOpen, @@ -385,6 +764,7 @@ QVariantMap NewPositionRuntime::submit(const QVariantMap& request, { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, { QStringLiteral("status"), QStringLiteral("submitted") }, { QStringLiteral("transactionId"), transactionId }, + { QStringLiteral("nativeTransactionHash"), submission.nativeHash }, { QStringLiteral("deadlineMs"), plan.value(QStringLiteral("deadlineMs")) }, }.toVariantMap(); } diff --git a/apps/amm/src/NewPositionRuntime.h b/apps/amm/src/NewPositionRuntime.h index 99e190e..7180975 100644 --- a/apps/amm/src/NewPositionRuntime.h +++ b/apps/amm/src/NewPositionRuntime.h @@ -4,17 +4,43 @@ #include #include #include +#include + +#include #include "ActiveNetwork.h" class AmmClient; class WalletProvider; +class SequencerClient; +struct WalletAccount; class NewPositionRuntime { public: - NewPositionRuntime(WalletProvider* wallet, AmmClient* client); + using ResultCallback = std::function; + + NewPositionRuntime(WalletProvider* wallet, + AmmClient* client, + SequencerClient* sequencer = nullptr); void clearWalletAccounts(); + void setWalletAccounts(const QVector& accounts); + + void contextAsync(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool refreshPublicData, + ResultCallback callback); + void quoteAsync(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool forceRefresh, + ResultCallback callback); + void submitAsync(const QVariantMap& request, + const QString& quoteHash, + const ActiveNetworkSnapshot& network, + bool walletCanSubmit, + ResultCallback callback); QVariantMap context(const QVariantMap& request, const ActiveNetworkSnapshot& network, @@ -35,8 +61,15 @@ private: bool walletOpen, bool freshWalletAccounts, QJsonObject* error) const; + void buildQuoteInputAsync(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool forceRefresh, + std::function callback) const; WalletProvider* m_wallet; AmmClient* m_client; + SequencerClient* m_sequencer; + QStringList m_walletPublicAccountIds; bool m_submitInFlight = false; }; diff --git a/apps/amm/src/SequencerClient.cpp b/apps/amm/src/SequencerClient.cpp new file mode 100644 index 0000000..afe14ad --- /dev/null +++ b/apps/amm/src/SequencerClient.cpp @@ -0,0 +1,302 @@ +#include "SequencerClient.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "AmmClient.h" + +namespace { +constexpr int MAX_CONCURRENT_READS = 8; +constexpr qsizetype ACCOUNT_ID_HEX_SIZE = 64; +constexpr std::size_t BASE58_BUFFER_SIZE = 45; + +bool isLowerHex(const QString& value, qsizetype size) +{ + if (value.size() != size) + return false; + return std::all_of(value.cbegin(), value.cend(), [](QChar character) { + return character.isDigit() + || (character >= QLatin1Char('a') && character <= QLatin1Char('f')); + }); +} + +QString accountIdBase58(const QString& accountId) +{ + if (!isLowerHex(accountId, ACCOUNT_ID_HEX_SIZE)) + return {}; + const QByteArray bytes = QByteArray::fromHex(accountId.toLatin1()); + std::array encoded {}; + std::size_t size = encoded.size(); + if (!b58enc(encoded.data(), &size, bytes.constData(), + static_cast(bytes.size()))) { + return {}; + } + return QString::fromLatin1(encoded.data(), static_cast(size - 1)); +} + +QByteArray rpcBody(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); +} + +WalletAccountRead accountRead(const QJsonObject& value, const QString& fallbackId) +{ + WalletAccountRead read; + read.accountId = value.value(QStringLiteral("id")).toString(fallbackId); + read.status = value.value(QStringLiteral("status")).toString( + QStringLiteral("read_failed")); + const QJsonObject account = value.value(QStringLiteral("account")).toObject(); + if (read.ok()) { + read.programOwner = account.value(QStringLiteral("program_owner")).toString(); + read.balanceHex = account.value(QStringLiteral("balance")).toString(); + read.nonceHex = account.value(QStringLiteral("nonce")).toString(); + read.dataHex = account.value(QStringLiteral("data")).toString(); + } + return read; +} +} + +SequencerClient::SequencerClient(AmmClient* client, QObject* parent) + : QObject(parent), + m_client(client), + m_network(new QNetworkAccessManager(this)) +{ +} + +bool SequencerClient::configure(const QString& configPath) +{ + ++m_generation; + cancelPendingReads(); + clear(); + QFile file(configPath); + if (!file.open(QIODevice::ReadOnly)) { + m_endpoint = QUrl(); + m_authorization.clear(); + return false; + } + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + const QJsonObject config = document.object(); + const QUrl endpoint(config.value(QStringLiteral("sequencer_addr")).toString()); + const QString scheme = endpoint.scheme().toLower(); + if (!document.isObject() || !endpoint.isValid() || endpoint.host().isEmpty() + || (scheme != QStringLiteral("http") && scheme != QStringLiteral("https"))) { + m_endpoint = QUrl(); + m_authorization.clear(); + return false; + } + + m_endpoint = endpoint; + m_authorization.clear(); + const QJsonObject basicAuth = config.value(QStringLiteral("basic_auth")).toObject(); + const QString username = basicAuth.value(QStringLiteral("username")).toString(); + const QString password = basicAuth.value(QStringLiteral("password")).toString(); + if (!username.isEmpty()) { + m_authorization = QByteArrayLiteral("Basic ") + + (username + QLatin1Char(':') + password).toUtf8().toBase64(); + } + return true; +} + +void SequencerClient::readAccounts(const QStringList& accountIds, + bool forceRefresh, + AccountsCallback callback) +{ + if (accountIds.isEmpty()) { + QTimer::singleShot(0, this, [callback = std::move(callback)]() mutable { + callback({}); + }); + return; + } + + struct Batch { + QVector reads; + qsizetype remaining = 0; + AccountsCallback callback; + }; + auto batch = std::make_shared(); + batch->reads.resize(accountIds.size()); + batch->remaining = accountIds.size(); + batch->callback = std::move(callback); + for (qsizetype index = 0; index < accountIds.size(); ++index) { + readAccount(accountIds.at(index), forceRefresh, + [batch, index](WalletAccountRead read) mutable { + batch->reads[index] = std::move(read); + if (--batch->remaining == 0) + batch->callback(std::move(batch->reads)); + }); + } +} + +void SequencerClient::readAccount(const QString& accountId, + bool forceRefresh, + AccountCallback callback) +{ + if (!isConfigured() || !isLowerHex(accountId, ACCOUNT_ID_HEX_SIZE)) { + QTimer::singleShot(0, this, + [callback = std::move(callback), accountId]() mutable { + callback(WalletAccountRead { accountId }); + }); + return; + } + if (!forceRefresh && m_cache.contains(accountId)) { + const WalletAccountRead cached = m_cache.value(accountId); + QTimer::singleShot(0, this, + [callback = std::move(callback), cached]() mutable { callback(cached); }); + return; + } + + const bool alreadyPending = m_waiters.contains(accountId); + m_waiters[accountId].append(std::move(callback)); + if (!alreadyPending) + m_pending.enqueue({ accountId }); + startPendingReads(); +} + +void SequencerClient::startPendingReads() +{ + while (m_activeReads < MAX_CONCURRENT_READS && !m_pending.isEmpty()) { + const PendingRead pending = m_pending.dequeue(); + ++m_activeReads; + startRead(pending.accountId); + } +} + +void SequencerClient::startRead(const QString& accountId) +{ + const quint64 generation = m_generation; + const QString encodedId = accountIdBase58(accountId); + if (encodedId.isEmpty()) { + completeRead(accountId, WalletAccountRead { accountId }); + return; + } + + QNetworkReply* reply = m_network->post( + request(), rpcBody(QStringLiteral("getAccount"), QJsonArray { encodedId })); + connect(reply, &QNetworkReply::finished, this, + [this, reply, accountId, generation]() { + if (generation != m_generation) { + reply->deleteLater(); + return; + } + WalletAccountRead read { accountId }; + const int status = reply->attribute( + QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray payload = reply->readAll(); + if (reply->error() == QNetworkReply::NoError + && status >= 200 && status < 300) { + const AmmClientResult normalized = + m_client->normalizeAccountRpc(QJsonObject { + { QStringLiteral("accountId"), accountId }, + { QStringLiteral("response"), QString::fromUtf8(payload) }, + }); + if (normalized.ok) + read = accountRead(normalized.value, accountId); + } + reply->deleteLater(); + completeRead(accountId, read); + }); +} + +void SequencerClient::completeRead(const QString& accountId, + const WalletAccountRead& read) +{ + if (read.ok()) + m_cache.insert(accountId, read); + const QVector callbacks = m_waiters.take(accountId); + --m_activeReads; + for (const AccountCallback& callback : callbacks) + callback(read); + startPendingReads(); +} + +void SequencerClient::queryTransaction(const QString& nativeHash, + TransactionCallback callback) +{ + if (!isConfigured() || !isLowerHex(nativeHash, ACCOUNT_ID_HEX_SIZE)) { + QTimer::singleShot(0, this, + [callback = std::move(callback)]() mutable { callback(false, false); }); + return; + } + const quint64 generation = m_generation; + QNetworkReply* reply = m_network->post( + request(), rpcBody(QStringLiteral("getTransaction"), QJsonArray { nativeHash })); + connect(reply, &QNetworkReply::finished, this, + [this, reply, generation, callback = std::move(callback)]() mutable { + if (generation != m_generation) { + reply->deleteLater(); + callback(false, false); + return; + } + const int status = reply->attribute( + QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray payload = reply->readAll(); + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(payload, &error); + const QJsonObject envelope = document.object(); + const bool ok = reply->error() == QNetworkReply::NoError + && status >= 200 && status < 300 + && error.error == QJsonParseError::NoError + && document.isObject() + && (!envelope.contains(QStringLiteral("error")) + || envelope.value(QStringLiteral("error")).isNull()) + && envelope.contains(QStringLiteral("result")); + const bool included = ok + && !envelope.value(QStringLiteral("result")).isNull(); + reply->deleteLater(); + callback(ok, included); + }); +} + +void SequencerClient::clear() +{ + m_cache.clear(); +} + +void SequencerClient::cancelPendingReads() +{ + decltype(m_waiters) waiters; + waiters.swap(m_waiters); + m_pending.clear(); + m_activeReads = 0; + for (auto iterator = waiters.cbegin(); iterator != waiters.cend(); ++iterator) { + const WalletAccountRead failed { iterator.key() }; + for (const AccountCallback& callback : iterator.value()) { + QTimer::singleShot(0, this, [callback, failed]() { + callback(failed); + }); + } + } +} + +void SequencerClient::applyAuthorization(QNetworkRequest& request) const +{ + if (!m_authorization.isEmpty()) + request.setRawHeader(QByteArrayLiteral("Authorization"), m_authorization); +} + +QNetworkRequest SequencerClient::request() const +{ + QNetworkRequest request(m_endpoint); + request.setHeader(QNetworkRequest::ContentTypeHeader, + QStringLiteral("application/json")); + request.setTransferTimeout(4000); + applyAuthorization(request); + return request; +} diff --git a/apps/amm/src/SequencerClient.h b/apps/amm/src/SequencerClient.h new file mode 100644 index 0000000..5062999 --- /dev/null +++ b/apps/amm/src/SequencerClient.h @@ -0,0 +1,61 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#include "WalletProvider.h" + +class AmmClient; +class QNetworkAccessManager; +class QNetworkRequest; + +class SequencerClient final : public QObject { + Q_OBJECT + +public: + using AccountsCallback = std::function)>; + using TransactionCallback = std::function; + + explicit SequencerClient(AmmClient* client, QObject* parent = nullptr); + + bool configure(const QString& configPath); + QString endpoint() const { return m_endpoint.toString(); } + bool isConfigured() const { return m_endpoint.isValid() && !m_endpoint.isEmpty(); } + void applyAuthorization(QNetworkRequest& request) const; + + void readAccounts(const QStringList& accountIds, + bool forceRefresh, + AccountsCallback callback); + void queryTransaction(const QString& nativeHash, TransactionCallback callback); + void clear(); + +private: + using AccountCallback = std::function; + + struct PendingRead { + QString accountId; + }; + + void readAccount(const QString& accountId, bool forceRefresh, AccountCallback callback); + void startPendingReads(); + void startRead(const QString& accountId); + void completeRead(const QString& accountId, const WalletAccountRead& read); + void cancelPendingReads(); + QNetworkRequest request() const; + + AmmClient* m_client; + QNetworkAccessManager* m_network; + QUrl m_endpoint; + QByteArray m_authorization; + QHash m_cache; + QHash> m_waiters; + QQueue m_pending; + int m_activeReads = 0; + quint64 m_generation = 0; +}; diff --git a/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp b/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp index 38f174f..b87261e 100644 --- a/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp +++ b/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp @@ -19,6 +19,11 @@ namespace { return {}; } + void connectAsync(const WalletPaths&, SessionCallback callback) override + { + callback({}); + } + WalletCreation createWallet(const WalletPaths&, const QString&) override { return {}; @@ -29,6 +34,11 @@ namespace { return {}; } + void snapshotAsync(bool, SnapshotCallback callback) override + { + callback({}); + } + void clearSnapshot() override {} WalletAccountCreation createAccount(bool isPublic) override @@ -102,7 +112,7 @@ namespace { AmmClientResult quote(const QJsonObject&) const override { return success({ - { QStringLiteral("schema"), QStringLiteral("new-position.v1") }, + { QStringLiteral("schema"), QStringLiteral("new-position.v2") }, { QStringLiteral("status"), QStringLiteral("ok") }, { QStringLiteral("canSubmit"), true }, { QStringLiteral("quoteHash"), quoteHash }, @@ -116,6 +126,8 @@ namespace { return success({ { QStringLiteral("status"), QStringLiteral("ready") }, { QStringLiteral("accountIds"), QJsonArray { QStringLiteral("account") } }, + { QStringLiteral("affectedAccountIds"), + QJsonArray { QStringLiteral("account") } }, { QStringLiteral("signingRequirements"), QJsonArray { true } }, { QStringLiteral("instruction"), QJsonArray { 1 } }, { QStringLiteral("programId"), QStringLiteral("program") }, @@ -124,6 +136,11 @@ namespace { }); } + AmmClientResult normalizeAccountRpc(const QJsonObject&) const override + { + return {}; + } + static AmmClientResult success(const QJsonObject& value) { return { true, value }; @@ -149,7 +166,7 @@ namespace { int main() { const QVariantMap request { - { QStringLiteral("schema"), QStringLiteral("new-position.v1") }, + { QStringLiteral("schema"), QStringLiteral("new-position.v2") }, { QStringLiteral("tokenAId"), QStringLiteral("token-a") }, { QStringLiteral("tokenBId"), QStringLiteral("token-b") }, { QStringLiteral("feeBps"), 30 }, @@ -168,6 +185,10 @@ int main() == QStringLiteral("1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"), "native hash should become the expected base58 transaction ID")) return 1; + if (!expect(result.value(QStringLiteral("nativeTransactionHash")).toString() + == wallet.transactionHash, + "submitted result should preserve the native hash for polling")) + return 1; if (!expect(wallet.createdAccounts == 1 && client.sawFreshLp, "fresh LP account should enter the plan")) return 1; diff --git a/apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml b/apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml index 11ca026..ff5fb0a 100644 --- a/apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml +++ b/apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml @@ -18,6 +18,11 @@ TestCase { } } + SignalSpy { + id: editedSpy + signalName: "snapshotEdited" + } + function test_protocolActionsUseDisplayLabels() { var summary = createTemporaryObject(summaryComponent, testCase) verify(summary) @@ -26,4 +31,32 @@ TestCase { compare(summary.actionText("AddLiquidity"), "Add liquidity") compare(summary.actionText(""), "-") } + + function test_lpDestinationOffersExistingHoldingsAndCreateNew() { + var summary = createTemporaryObject(summaryComponent, testCase, { + "snapshot": { + "instruction": "AddLiquidity", + "request": ({ "schema": "new-position.v2" }), + "lpHoldingOptions": [{ + "holdingId": "44444444444444444444444444444444", + "balanceRaw": "7" + }], + "lpDestinationRequired": true, + "quoteReady": false + } + }) + verify(summary) + editedSpy.target = summary + editedSpy.clear() + + var rows = summary.destinationRows() + compare(rows.length, 2) + compare(rows[1].createFresh, true) + summary.selectDestination(rows[0]) + compare(editedSpy.count, 1) + compare(editedSpy.signalArguments[0][0].request.lpHoldingId, + "44444444444444444444444444444444") + compare(editedSpy.signalArguments[0][0].quoteReady, false) + editedSpy.target = null + } } diff --git a/apps/amm/tests/qml/tst_LiquidityPage.qml b/apps/amm/tests/qml/tst_LiquidityPage.qml index afc9b4a..bb0399c 100644 --- a/apps/amm/tests/qml/tst_LiquidityPage.qml +++ b/apps/amm/tests/qml/tst_LiquidityPage.qml @@ -17,26 +17,54 @@ TestCase { QtObject { property bool walletStateReady: false + property bool walletCanSubmit: true + property bool isWalletOpen: true + property string walletSyncStatus: "ready" property var submitResult: ({}) property var quoteResult: ({ - "schema": "new-position.v1", + "schema": "new-position.v2", "status": "ok", "poolStatus": "missing_pool" }) + property var newPositionQuoteResult: ({}) + property var newPositionSubmitResult: ({}) property var newPositionContext: ({ - "schema": "new-position.v1", + "schema": "new-position.v2", "status": "ready", "tokens": [], "feeTiers": [] }) + property int contextRefreshCalls: 0 + property int submitCalls: 0 + property var lastContextRefreshRequest: ({}) - function submitNewPosition(request, quoteHash) { - return submitResult + function requestNewPositionSubmit(request, quoteHash, requestId) { + ++submitCalls + var result = JSON.parse(JSON.stringify(submitResult || ({}))) + result.requestId = requestId + newPositionSubmitResult = result } - function quoteNewPosition(request) { - return quoteResult + function requestNewPositionQuote(request, requestId, forceRefresh) { + var result = JSON.parse(JSON.stringify(quoteResult || ({}))) + result.requestId = requestId + newPositionQuoteResult = result } + + function refreshNewPositionContext(request) { + ++contextRefreshCalls + lastContextRefreshRequest = request + } + } + } + + Component { + id: pageComponent + + Pages.LiquidityPage { + visible: false + width: 800 + height: 600 } } @@ -52,110 +80,48 @@ TestCase { 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 + function test_contextAvailableWhileWalletSyncs() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletCanSubmit": false, + "walletSyncStatus": "syncing" }) - verify(backend) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) verify(page) - compare(page.flow.newPositionContext.status, "loading") - - backend.walletStateReady = true - tryCompare(page.flow.newPositionContext, "status", "ready") + compare(page.flow.newPositionContext.status, "ready") + compare(page.flow.viewState.walletSyncStatus, "syncing") + verify(!page.flow.walletCanSubmit) } - function test_contextRefreshControlsWalletScan() { - var backend = createTemporaryObject(backendComponent, testCase, { - "walletStateReady": true - }) - var page = createTemporaryObject(pageComponent, testCase, { - "backend": backend - }) - verify(backend) + function test_contextRefreshControlsPublicRefresh() { + var backend = createTemporaryObject(backendComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { "backend": 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) - + var backend = createTemporaryObject(backendComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) page.flow.quoteSerial = 7 page.flow.finishSubmitFailure({ - "schema": "new-position.v1", + "schema": "new-position.v2", "status": "error", "code": "quote_not_submittable", "quote": { - "schema": "new-position.v1", + "schema": "new-position.v2", "status": "ok", "canSubmit": false, "quoteHash": "sha256:fresh" @@ -167,61 +133,49 @@ TestCase { compare(page.flow.quoteStale, false) } - function test_base58SubmittedResultEntersSuccessState() { + function test_submittedResultEntersSuccessState() { var backend = createTemporaryObject(backendComponent, testCase, { - "walletStateReady": true, "submitResult": { - "schema": "new-position.v1", + "schema": "new-position.v2", "status": "submitted", "transactionId": submittedTransactionId, - "deadlineMs": String(Date.now() + 60000) + "deadlineMs": String(Date.now() + 60000), + "affectedAccountIds": [] } }) - 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" - }) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) + page.flow.confirm({ "request": ({}), "quoteHash": "sha256:expected" }) + wait(0) compare(page.flow.transactionId, submittedTransactionId) compare(page.flow.flowErrorCode, "") compare(page.flow.submitting, false) + compare(backend.submitCalls, 1) } - function test_base58MissingPoolSubmissionStartsPoolWatch() { + function test_missingPoolSubmissionStartsPoolProbeWithoutWalletRefresh() { var backend = createTemporaryObject(backendComponent, testCase, { - "walletStateReady": true, "submitResult": { - "schema": "new-position.v1", + "schema": "new-position.v2", "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 page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) var probe = { "tokenAId": "22222222222222222222222222222222", "tokenBId": "33333333333333333333333333333333" } - page.flow.pendingQuoteRequest = { "ok": true, "request": probe } + page.flow.pendingQuoteRequest = { + "ok": true, + "request": probe + } page.flow.confirm({ + "instruction": "NewDefinition", "request": { + "tokenAId": probe.tokenAId, + "tokenBId": probe.tokenBId, "initialPriceRealRaw": "18446744073709551616" }, "poolProbeRequest": probe, @@ -229,51 +183,51 @@ TestCase { }) wait(0) - compare(page.flow.transactionId, submittedTransactionId) compare(page.flow.pendingPoolProbes.length, 1) - compare(page.flow.selectedPoolCreationPending(), true) - compare(page.flow.poolProbeInFlight, false) + compare(backend.contextRefreshCalls, 0) + verify(page.flow.selectedPoolCreationPending()) } - function test_nativeHexSubmittedResultDoesNotEnterSuccessState() { + function test_activePoolSubmissionDoesNotStartPoolCreationProbe() { var backend = createTemporaryObject(backendComponent, testCase, { - "walletStateReady": true, "submitResult": { - "schema": "new-position.v1", + "schema": "new-position.v2", "status": "submitted", - "transactionId": "000102030405060708090a0b0c0d0e0f" - + "101112131415161718191a1b1c1d1e1f" + "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 page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) page.flow.confirm({ + "instruction": "AddLiquidity", "request": ({}), + "poolProbeRequest": { + "tokenAId": "22222222222222222222222222222222", + "tokenBId": "33333333333333333333333333333333" + }, "quoteHash": "sha256:expected" }) + wait(0) - compare(page.flow.transactionId, "") - compare(page.flow.flowErrorCode, "wallet_submission_failed") - compare(page.flow.submitting, false) + compare(page.flow.pendingPoolProbes.length, 0) } - function test_poolProbeDoesNotPublishProbeAmountsAsCurrentQuote() { + function test_walletSyncDisablesSubmissionOnly() { var backend = createTemporaryObject(backendComponent, testCase, { - "walletStateReady": true + "walletCanSubmit": false, + "walletSyncStatus": "syncing" }) - var page = createTemporaryObject(pageComponent, testCase, { - "backend": backend - }) - verify(backend) - verify(page) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) + page.flow.confirm({ "request": ({}), "quoteHash": "sha256:expected" }) + compare(backend.submitCalls, 0) + compare(page.flow.flowErrorCode, "wallet_syncing") + verify(!page.flow.submitting) + } + + function test_poolProbeDoesNotPublishProbeAsCurrentQuote() { + var backend = createTemporaryObject(backendComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { "backend": backend }) var request = { "tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "tokenBId": "22222222222222222222222222222222" @@ -282,54 +236,19 @@ TestCase { page.flow.pendingQuoteRequest = { "ok": true, "request": request } page.flow.pendingPoolProbes = [pending] page.flow.newPositionQuote = { - "schema": "new-position.v1", + "schema": "new-position.v2", "status": "ok", - "poolStatus": "missing_pool", - "tokenAId": request.tokenAId, - "tokenBId": request.tokenBId + "poolStatus": "missing_pool" } page.flow.quoteStale = false page.flow.finishPoolProbe(pending, { - "schema": "new-position.v1", + "schema": "new-position.v2", "status": "ok", - "poolStatus": "active_pool", - "tokenAId": request.tokenAId, - "tokenBId": request.tokenBId + "poolStatus": "active_pool" }) - 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()) - } } diff --git a/apps/amm/tests/qml/tst_NewPositionForm.qml b/apps/amm/tests/qml/tst_NewPositionForm.qml index d2a8192..16de739 100644 --- a/apps/amm/tests/qml/tst_NewPositionForm.qml +++ b/apps/amm/tests/qml/tst_NewPositionForm.qml @@ -46,6 +46,10 @@ TestCase { "name": "Low", "totalSupplyRaw": "1000000", "balanceRaw": "1000", + "holdings": [{ + "holdingId": "44444444444444444444444444444444", + "balanceRaw": "1000" + }], "selectable": true }, { @@ -53,6 +57,10 @@ TestCase { "name": "High", "totalSupplyRaw": "1000000000000", "balanceRaw": "5000000000", + "holdings": [{ + "holdingId": "55555555555555555555555555555555", + "balanceRaw": "5000000000" + }], "selectable": true } ] @@ -68,6 +76,10 @@ TestCase { "name": "Sir Mints-a-Lot", "totalSupplyRaw": "1000000000000", "balanceRaw": "1000000000", + "holdings": [{ + "holdingId": "44444444444444444444444444444444", + "balanceRaw": "1000000000" + }], "selectable": true }, { @@ -75,19 +87,55 @@ TestCase { "name": "Aurora", "totalSupplyRaw": "1000000000000", "balanceRaw": "1000000000", + "holdings": [{ + "holdingId": "55555555555555555555555555555555", + "balanceRaw": "1000000000" + }], "selectable": true } ] } } + function holdingContext() { + return { + "status": "ready", + "tokens": [ + { + "definitionId": tokenLow, + "name": "Low", + "balanceRaw": "10", + "selectable": true, + "holdings": [ + { "holdingId": "44444444444444444444444444444444", + "balanceRaw": "10" } + ] + }, + { + "definitionId": tokenHigh, + "name": "High", + "balanceRaw": "50", + "selectable": true, + "holdings": [ + { "holdingId": "55555555555555555555555555555555", + "balanceRaw": "20" }, + { "holdingId": "66666666666666666666666666666666", + "balanceRaw": "30" } + ] + } + ] + } + } + function flowState(quote) { return { "quote": quote || ({}), "contextLoading": false, "quoteLoading": false, "quoteStale": false, - "submitting": false + "submitting": false, + "walletCanSubmit": true, + "walletSyncStatus": "ready" } } @@ -118,6 +166,23 @@ TestCase { compare(form.amountB, "") } + function test_holdingSelectionAutoSelectsOneAndRequiresExplicitMultiple() { + var form = createForm(holdingContext()) + wait(0) + compare(form.selectedHoldingAId, + "44444444444444444444444444444444") + compare(form.selectedHoldingBId, "") + verify(!form.buildQuoteRequest().ok) + + form.selectHolding("B", "66666666666666666666666666666666") + var built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.holdingAId, + "66666666666666666666666666666666") + compare(built.request.holdingBId, + "44444444444444444444444444444444") + } + function test_displayOrderMapsToCanonicalRequestAfterSwap() { var form = createForm() var built = form.buildQuoteRequest() @@ -354,7 +419,7 @@ TestCase { form.minimumAmountBRaw = "2000000" verify(form.acceptPoolActivation({ - "schema": "new-position.v1", + "schema": "new-position.v2", "status": "ok", "tokenAId": tokenHigh, "tokenBId": tokenLow, @@ -378,7 +443,7 @@ TestCase { function test_staleQuoteErrorsDoNotMarkCurrentDraft() { var quote = { - "schema": "new-position.v1", + "schema": "new-position.v2", "status": "ok", "tokenAId": tokenHigh, "tokenBId": tokenLow,