feat: Introduce SequencerClient for enhanced account management and transaction querying

- Added SequencerClient class to handle asynchronous account reads and transaction queries.
- Updated NewPositionRuntime to integrate SequencerClient, allowing for improved wallet account management.
- Modified existing tests to accommodate changes in the new position schema from v1 to v2.
- Enhanced QML tests to validate new position form behavior with updated holding selections and context handling.
- Refactored code to ensure compatibility with the new SequencerClient functionalities.
This commit is contained in:
Ricardo Guilherme Schmidt 2026-07-17 14:14:50 -03:00
parent 90ba65f2d0
commit 0e01962bec
No known key found for this signature in database
GPG Key ID: 1396EA17DE132FFE
31 changed files with 1956 additions and 414 deletions

View File

@ -42,6 +42,8 @@ logos_module(
src/AmmClient.cpp src/AmmClient.cpp
src/NewPositionRuntime.h src/NewPositionRuntime.h
src/NewPositionRuntime.cpp src/NewPositionRuntime.cpp
src/SequencerClient.h
src/SequencerClient.cpp
FIND_PACKAGES FIND_PACKAGES
Qt6Gui Qt6Gui
Qt6Network Qt6Network
@ -73,10 +75,13 @@ if(BUILD_TESTING)
add_executable(amm_new_position_runtime_test add_executable(amm_new_position_runtime_test
tests/cpp/NewPositionRuntimeTest.cpp tests/cpp/NewPositionRuntimeTest.cpp
src/NewPositionRuntime.cpp src/NewPositionRuntime.cpp
src/SequencerClient.cpp
src/SequencerClient.h
) )
target_include_directories(amm_new_position_runtime_test PRIVATE src) target_include_directories(amm_new_position_runtime_test PRIVATE src)
target_link_libraries(amm_new_position_runtime_test PRIVATE target_link_libraries(amm_new_position_runtime_test PRIVATE
Qt6::Core Qt6::Core
Qt6::Network
PkgConfig::BASE58 PkgConfig::BASE58
logos_wallet_access logos_wallet_access
) )

View File

@ -11,6 +11,7 @@ char *amm_pair_ids(const char *request_json);
char *amm_context(const char *request_json); char *amm_context(const char *request_json);
char *amm_quote(const char *request_json); char *amm_quote(const char *request_json);
char *amm_plan(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); void amm_free(char *value);
#ifdef __cplusplus #ifdef __cplusplus

View File

@ -5,6 +5,9 @@ use nssa_core::{
program::ProgramId, program::ProgramId,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value};
use crate::api::NormalizeAccountRpcRequest;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")] #[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<RpcAccount>,
#[serde(default)]
error: Option<Value>,
}
#[derive(Deserialize)]
struct RpcAccount {
program_owner: ProgramId,
balance: u128,
data: Vec<u8>,
nonce: u128,
}
pub(crate) fn normalize_account_rpc(request: NormalizeAccountRpcRequest) -> Result<Value, String> {
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)] #[cfg(test)]
pub(crate) fn account_read(id: AccountId, account: &Account) -> AccountRead { pub(crate) fn account_read(id: AccountId, account: &Account) -> AccountRead {
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"
);
}
}

View File

@ -9,7 +9,7 @@ use token_core::TokenDefinition;
use super::{ use super::{
config::load_config, config::load_config,
holding::{select_holding, wallet_holdings, SelectedHolding}, holding::{holding_options, wallet_holdings, SelectedHolding},
quote_error::issue, quote_error::issue,
ContextRequest, TokenIdsRequest, SCHEMA, ContextRequest, TokenIdsRequest, SCHEMA,
}; };
@ -85,8 +85,11 @@ pub(super) fn context(request: ContextRequest) -> Result<Value, String> {
} }
}; };
let selected = select_holding(&holdings, token_id); let options = holding_options(&holdings, token_id);
let mut row = json!({ let total_balance = options.iter().fold(0_u128, |total, holding| {
total.saturating_add(holding.balance)
});
let row = json!({
"definitionId": token_id.to_string(), "definitionId": token_id.to_string(),
"name": name, "name": name,
"metadataId": metadata_id.map(|id| id.to_string()), "metadataId": metadata_id.map(|id| id.to_string()),
@ -98,17 +101,22 @@ pub(super) fn context(request: ContextRequest) -> Result<Value, String> {
"status": "available", "status": "available",
"code": "available", "code": "available",
"sources": sources, "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::<Vec<_>>(),
}); });
if let Some(selected) = selected {
row["holdingId"] = json!(selected.id.to_string());
row["balanceRaw"] = json!(selected.balance.to_string());
}
rows.push(row); rows.push(row);
} }
rows.sort_by(|left, right| { rows.sort_by(|left, right| {
let left_holding = left.get("holdingId").is_some(); let left_holding = left["holdings"]
let right_holding = right.get("holdingId").is_some(); .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(|| { right_holding.cmp(&left_holding).then_with(|| {
left["definitionId"] left["definitionId"]
.as_str() .as_str()

View File

@ -4,7 +4,7 @@ use nssa_core::{
}; };
use token_core::TokenHolding; use token_core::TokenHolding;
use crate::account::{decode_account, AccountRead}; use crate::account::{decode_account, parse_base58_id, AccountRead};
#[derive(Clone)] #[derive(Clone)]
pub(super) struct SelectedHolding { pub(super) struct SelectedHolding {
@ -51,14 +51,24 @@ pub(super) fn decode_fungible_holding(
pub(super) fn select_holding( pub(super) fn select_holding(
holdings: &[SelectedHolding], holdings: &[SelectedHolding],
definition_id: AccountId, definition_id: AccountId,
requested_id: Option<&str>,
) -> Option<SelectedHolding> { ) -> Option<SelectedHolding> {
let requested_id = parse_base58_id(requested_id?, "holding id").ok()?;
holdings holdings
.iter() .iter()
.filter(|holding| holding.definition_id == definition_id) .find(|holding| holding.definition_id == definition_id && holding.id == requested_id)
.max_by(|left, right| {
left.balance
.cmp(&right.balance)
.then_with(|| right.id.cmp(&left.id))
})
.cloned() .cloned()
} }
pub(super) fn holding_options(
holdings: &[SelectedHolding],
definition_id: AccountId,
) -> Vec<SelectedHolding> {
let mut options = holdings
.iter()
.filter(|holding| holding.definition_id == definition_id)
.cloned()
.collect::<Vec<_>>();
options.sort_by_key(|holding| holding.id);
options
}

View File

@ -20,15 +20,15 @@ mod tests;
use std::{error::Error, fmt}; use std::{error::Error, fmt};
pub use request::{ pub use request::{
ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest, ConfigIdRequest, ContextRequest, NormalizeAccountRpcRequest, PairIdsRequest, PairSnapshot,
QuoteRequest, TokenIdsRequest, PlanRequest, PositionRequest, QuoteRequest, TokenIdsRequest,
}; };
use serde_json::Value; use serde_json::Value;
pub use crate::account::{AccountRead, WalletAccount}; pub use crate::account::{AccountRead, WalletAccount};
/// Schema identifier expected by position quote and plan requests. /// 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; pub(crate) const SCHEMA: &str = NEW_POSITION_SCHEMA;
@ -95,3 +95,8 @@ pub fn quote(request: QuoteRequest) -> AmmResult {
pub fn plan(request: PlanRequest) -> AmmResult { pub fn plan(request: PlanRequest) -> AmmResult {
plan::plan(request).map_err(Into::into) 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)
}

View File

@ -77,6 +77,7 @@ pub(super) fn plan(input: PlanRequest) -> Result<Value, String> {
return Ok(plan_error("transaction_deadline_expired")); return Ok(plan_error("transaction_deadline_expired"));
} }
let NewPositionPlan { accounts, branch } = plan; 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 (account_ids, signing_requirements) = accounts.wallet_args(fresh_lp)?;
let instruction = match branch { let instruction = match branch {
QuoteBranch::Missing { amount_a, amount_b } => { QuoteBranch::Missing { amount_a, amount_b } => {
@ -116,6 +117,10 @@ pub(super) fn plan(input: PlanRequest) -> Result<Value, String> {
"status": "ready", "status": "ready",
"programId": input.amm_program_id, "programId": input.amm_program_id,
"accountIds": account_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(), "accountIds": account_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(),
"affectedAccountIds": affected_account_ids
.into_iter()
.map(account_id_hex)
.collect::<Vec<_>>(),
"signingRequirements": signing_requirements, "signingRequirements": signing_requirements,
"instruction": instruction, "instruction": instruction,
"deadlineMs": deadline.to_string(), "deadlineMs": deadline.to_string(),

View File

@ -193,6 +193,23 @@ impl AccountPlan {
} }
Ok((account_ids, signing_requirements)) Ok((account_ids, signing_requirements))
} }
pub(super) fn writable_account_ids(
&self,
fresh_lp: Option<AccountId>,
) -> Result<Vec<AccountId>, 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 { impl AccountPlanRow {

View File

@ -17,7 +17,7 @@ use super::{
commitment::{QuoteCommitment, RequestCommitment}, commitment::{QuoteCommitment, RequestCommitment},
context::fungible_definition, context::fungible_definition,
funding::{funding_commitments, funding_issues, hash_quote}, 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}, pair::{derive_pair, is_canonical_pair, PairIds},
position::{ position::{
AccountPlan, AccountPlanHoldings, EvaluatedQuote, NewPositionPlan, QuoteBranch, AccountPlan, AccountPlanHoldings, EvaluatedQuote, NewPositionPlan, QuoteBranch,
@ -213,9 +213,18 @@ fn compute_missing_quote(
let expected_lp = initial_lp - MINIMUM_LIQUIDITY; let expected_lp = initial_lp - MINIMUM_LIQUIDITY;
let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program); let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program);
let holding_a = select_holding(&holdings, pair.token_a); let holding_a = select_holding(
let holding_b = select_holding(&holdings, pair.token_b); &holdings,
let funding = funding_issues( 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, input.snapshot.wallet_available,
pair, pair,
&holding_a, &holding_a,
@ -223,7 +232,7 @@ fn compute_missing_quote(
&holding_b, &holding_b,
amount_b, amount_b,
["amountARaw", "amountBRaw"], ["amountARaw", "amountBRaw"],
); ));
let can_submit = funding.is_empty(); let can_submit = funding.is_empty();
let mut account_plan = missing_account_plan( let mut account_plan = missing_account_plan(
input, input,
@ -440,11 +449,31 @@ fn compute_active_quote(
return Ok(error); return Ok(error);
} }
let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program); let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program);
let holding_a = select_holding(&holdings, pair.token_a); let holding_a = select_holding(
let holding_b = select_holding(&holdings, pair.token_b); &holdings,
let lp_holding = select_holding(&holdings, pair.lp_definition); pair.token_a,
let requires_fresh_lp = lp_holding.is_none(); input.request.holding_a_id.as_deref(),
let funding = funding_issues( );
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, input.snapshot.wallet_available,
pair, pair,
&holding_a, &holding_a,
@ -452,7 +481,15 @@ fn compute_active_quote(
&holding_b, &holding_b,
actual_b, actual_b,
["maxAmountARaw", "maxAmountBRaw"], ["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 can_submit = funding.is_empty();
let warnings = if slippage_bps >= HIGH_SLIPPAGE_BPS { let warnings = if slippage_bps >= HIGH_SLIPPAGE_BPS {
vec![issue( vec![issue(
@ -532,6 +569,12 @@ fn compute_active_quote(
"minimumLpRaw": minimum_lp.to_string(), "minimumLpRaw": minimum_lp.to_string(),
"initialPriceRealRaw": spot_price_q64_64(reserve_a, reserve_b).to_string(), "initialPriceRealRaw": spot_price_q64_64(reserve_a, reserve_b).to_string(),
"requiresFreshLp": requires_fresh_lp, "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::<Vec<_>>(),
"selectedLpHoldingId": lp_holding.as_ref().map(|holding| holding.id.to_string()),
"accountPreview": preview, "accountPreview": preview,
"errors": funding, "errors": funding,
"warnings": warnings, "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<super::holding::SelectedHolding>,
holding_b: &Option<super::holding::SelectedHolding>,
) -> Vec<Value> {
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( fn validate_active_accounts(
input: &QuoteRequest, input: &QuoteRequest,
pair: PairIds, pair: PairIds,

View File

@ -2,6 +2,13 @@ use serde::Deserialize;
use crate::account::AccountRead; 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)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ConfigIdRequest { pub struct ConfigIdRequest {
@ -60,6 +67,14 @@ pub struct PositionRequest {
pub token_b_id: String, pub token_b_id: String,
pub fee_bps: u32, pub fee_bps: u32,
#[serde(default)] #[serde(default)]
pub holding_a_id: Option<String>,
#[serde(default)]
pub holding_b_id: Option<String>,
#[serde(default)]
pub lp_holding_id: Option<String>,
#[serde(default)]
pub create_fresh_lp: bool,
#[serde(default)]
pub amount_a_raw: Option<String>, pub amount_a_raw: Option<String>,
#[serde(default)] #[serde(default)]
pub amount_b_raw: Option<String>, pub amount_b_raw: Option<String>,

View File

@ -144,6 +144,10 @@ fn request(pair: PairIds) -> PositionRequest {
token_a_id: pair.token_a.to_string(), token_a_id: pair.token_a.to_string(),
token_b_id: pair.token_b.to_string(), token_b_id: pair.token_b.to_string(),
fee_bps: 30, 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_a_raw: None,
amount_b_raw: None, amount_b_raw: None,
max_amount_a_raw: None, max_amount_a_raw: None,
@ -244,8 +248,16 @@ fn account_plan_sources_follow_pool_branch() {
let pair = scenario.pair; let pair = scenario.pair;
let input = scenario.quote_request(); let input = scenario.quote_request();
let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program); let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program);
let holding_a = select_holding(&holdings, pair.token_a); let holding_a = select_holding(
let holding_b = select_holding(&holdings, pair.token_b); &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( let missing = missing_account_plan(
&input, &input,
@ -337,7 +349,7 @@ fn minimum_pair_is_minimal_on_price_base_side() {
} }
#[test] #[test]
fn highest_balance_holding_wins_then_lowest_id() { fn holding_selection_uses_requested_id() {
let definition = AccountId::new([9; 32]); let definition = AccountId::new([9; 32]);
let holding = |id: u8, balance| SelectedHolding { let holding = |id: u8, balance| SelectedHolding {
id: AccountId::new([id; 32]), id: AccountId::new([id; 32]),
@ -354,9 +366,10 @@ fn highest_balance_holding_wins_then_lowest_id() {
let selected = select_holding( let selected = select_holding(
&[holding(4, 10), holding(2, 20), holding(1, 20)], &[holding(4, 10), holding(2, 20), holding(1, 20)],
definition, definition,
Some(&AccountId::new([4; 32]).to_string()),
) )
.unwrap(); .unwrap();
assert_eq!(selected.id, AccountId::new([1; 32])); assert_eq!(selected.id, AccountId::new([4; 32]));
} }
#[test] #[test]
@ -511,6 +524,43 @@ fn context_selects_tokens_without_holdings() {
assert!(value["tokens"][0].get("holdingId").is_none()); 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] #[test]
fn missing_pool_snapshot_defaults_remain_real_accounts() { fn missing_pool_snapshot_defaults_remain_real_accounts() {
let id = AccountId::new([5; 32]); 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["canSubmit"], true);
assert_eq!(quote_value["accountPreview"].as_array().unwrap().len(), 11); assert_eq!(quote_value["accountPreview"].as_array().unwrap().len(), 11);
let quote_hash = quote_value["quoteHash"].as_str().unwrap().to_owned(); 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 fresh_lp = AccountId::new([63; 32]);
let plan_value = scenario.plan(quote_hash, Some(default_read(fresh_lp))); 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"][6], true);
assert_eq!(plan_value["signingRequirements"][7], true); assert_eq!(plan_value["signingRequirements"][7], true);
assert_eq!(plan_value["signingRequirements"][8], 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(&quote_value, &plan_value, Some(fresh_lp)); assert_preview_matches_plan(&quote_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.max_amount_b_raw = Some(String::from("3000"));
scenario.request.slippage_bps = Some(50); 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(); let quote_value = scenario.quote();
assert_eq!(quote_value["poolStatus"], "active_pool"); assert_eq!(quote_value["poolStatus"], "active_pool");
assert_eq!(quote_value["actualAmountARaw"], "1000"); assert_eq!(quote_value["actualAmountARaw"], "1000");

View File

@ -6,8 +6,8 @@ use std::{
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
use crate::api::{ use crate::api::{
self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest, self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, NormalizeAccountRpcRequest,
QuoteRequest, TokenIdsRequest, PairIdsRequest, PlanRequest, QuoteRequest, TokenIdsRequest,
}; };
#[derive(Serialize)] #[derive(Serialize)]
@ -106,6 +106,11 @@ pub extern "C" fn amm_plan(request_json: *const c_char) -> *mut c_char {
call::<PlanRequest>(request_json, api::plan) call::<PlanRequest>(request_json, api::plan)
} }
#[unsafe(no_mangle)]
pub extern "C" fn amm_normalize_account_rpc(request_json: *const c_char) -> *mut c_char {
call::<NormalizeAccountRpcRequest>(request_json, api::normalize_account_rpc)
}
/// Releases a string returned by an `amm_*` operation. /// Releases a string returned by an `amm_*` operation.
/// ///
/// # Safety /// # Safety

View File

@ -9,5 +9,5 @@ fn direct_rust_api_does_not_require_ffi() {
assert_eq!(response["status"], "ok"); assert_eq!(response["status"], "ok");
assert!(response["configId"].is_string()); assert!(response["configId"].is_string());
assert_eq!(NEW_POSITION_SCHEMA, "new-position.v1"); assert_eq!(NEW_POSITION_SCHEMA, "new-position.v2");
} }

View File

@ -1,4 +1,7 @@
pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
ColumnLayout { ColumnLayout {
@ -6,6 +9,8 @@ ColumnLayout {
property var snapshot: ({}) property var snapshot: ({})
signal snapshotEdited(var snapshot)
spacing: 8 spacing: 8
function actionText(instruction) { function actionText(instruction) {
@ -22,6 +27,40 @@ ColumnLayout {
value: root.snapshot.pairText || "-" 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 { SummaryRow {
Layout.fillWidth: true Layout.fillWidth: true
label: qsTr("Action") label: qsTr("Action")
@ -48,4 +87,55 @@ ColumnLayout {
label: qsTr("Expected LP") label: qsTr("Expected LP")
value: root.snapshot.expectedLpText || "-" 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
}
} }

View File

@ -23,6 +23,8 @@ AmmActionCard {
property var flowState: ({}) property var flowState: ({})
property string selectedTokenAId: "" property string selectedTokenAId: ""
property string selectedTokenBId: "" property string selectedTokenBId: ""
property string selectedHoldingAId: ""
property string selectedHoldingBId: ""
property int selectedFeeBps: 30 property int selectedFeeBps: 30
property int slippageBps: 50 property int slippageBps: 50
property string amountA: "" property string amountA: ""
@ -47,6 +49,9 @@ AmmActionCard {
readonly property bool contextLoading: root.flowState.contextLoading === true readonly property bool contextLoading: root.flowState.contextLoading === true
readonly property bool quoteLoading: root.flowState.quoteLoading === true readonly property bool quoteLoading: root.flowState.quoteLoading === true
readonly property bool submitting: root.flowState.submitting === 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 quoteStale: root.flowState.quoteStale === true
readonly property bool poolCreationPending: root.flowState.poolCreationPending === true readonly property bool poolCreationPending: root.flowState.poolCreationPending === true
readonly property string submitError: root.flowState.errorCode readonly property string submitError: root.flowState.errorCode
@ -65,6 +70,12 @@ AmmActionCard {
? root.newPositionContext.feeTiers : [] ? root.newPositionContext.feeTiers : []
readonly property var tokenA: root.tokenById(root.selectedTokenAId) readonly property var tokenA: root.tokenById(root.selectedTokenAId)
readonly property var tokenB: root.tokenById(root.selectedTokenBId) 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 decimalsA: 0
readonly property int decimalsB: 0 readonly property int decimalsB: 0
readonly property bool displayIsCanonical: root.selectedTokenAId.length > 0 readonly property bool displayIsCanonical: root.selectedTokenAId.length > 0
@ -87,9 +98,13 @@ AmmActionCard {
&& root.selectedTokenBId.length > 0 && root.selectedTokenBId.length > 0
&& root.selectedTokenAId !== root.selectedTokenBId && root.selectedTokenAId !== root.selectedTokenBId
readonly property bool resolvingToken: root.resolvingTokenId.length > 0 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.status === "ok"
&& root.quotePayload.canSubmit === true && (root.quotePayload.canSubmit === true
|| root.lpDestinationRequired)
&& root.quoteMatchesPair() && root.quoteMatchesPair()
&& String(root.quotePayload.quoteHash || "").length > 0 && String(root.quotePayload.quoteHash || "").length > 0
&& !root.contextLoading && !root.contextLoading
@ -97,6 +112,7 @@ AmmActionCard {
&& !root.quoteStale && !root.quoteStale
&& !root.submitting && !root.submitting
&& !root.poolCreationPending && !root.poolCreationPending
&& root.walletCanSubmit
signal quoteRequested(bool immediate, var quoteRequest) signal quoteRequested(bool immediate, var quoteRequest)
signal confirmationRequested(var snapshot) signal confirmationRequested(var snapshot)
@ -116,6 +132,7 @@ AmmActionCard {
root.finishTokenResolution() root.finishTokenResolution()
else else
root.reconcileSelection() root.reconcileSelection()
root.reconcileHoldings()
} }
onQuotePayloadChanged: { onQuotePayloadChanged: {
if (root.quoteStale) if (root.quoteStale)
@ -236,7 +253,8 @@ AmmActionCard {
theme: root.theme theme: root.theme
text: root.amountA text: root.amountA
label: qsTr("Token A amount") 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 helperText: root.missingPool && !root.compact
? root.minimumAmountText("A") : "" ? root.minimumAmountText("A") : ""
errorText: root.formErrorText() errorText: root.formErrorText()
@ -246,6 +264,9 @@ AmmActionCard {
tokenData: root.tokenA.definitionId ? root.tokenA : null tokenData: root.tokenA.definitionId ? root.tokenA : null
tokens: root.tokens tokens: root.tokens
selectedTokenId: root.selectedTokenAId selectedTokenId: root.selectedTokenAId
holdings: root.holdingsA
selectedHoldingId: root.selectedHoldingAId
holdingSelectionEnabled: !root.submitting
tokenInvalid: root.tokenHasError("A") tokenInvalid: root.tokenHasError("A")
tokenSelectionEnabled: !root.contextLoading && !root.submitting tokenSelectionEnabled: !root.contextLoading && !root.submitting
adjustment: root.missingPool ? priceAmountAAdjustment : null adjustment: root.missingPool ? priceAmountAAdjustment : null
@ -268,6 +289,9 @@ AmmActionCard {
onMaxClicked: root.useMaximum() onMaxClicked: root.useMaximum()
onTokenSelected: function(tokenId) { root.resolveToken("A", tokenId) } onTokenSelected: function(tokenId) { root.resolveToken("A", tokenId) }
onTokenEntered: function(value) { root.resolveToken("A", value) } onTokenEntered: function(value) { root.resolveToken("A", value) }
onHoldingSelected: function(holdingId) {
root.selectHolding("A", holdingId)
}
} }
AmmPairSeparator { AmmPairSeparator {
@ -285,7 +309,8 @@ AmmActionCard {
theme: root.theme theme: root.theme
text: root.amountB text: root.amountB
label: qsTr("Token B amount") 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 helperText: root.missingPool && !root.compact
? root.minimumAmountText("B") : "" ? root.minimumAmountText("B") : ""
invalid: root.fieldHasError("amountB") invalid: root.fieldHasError("amountB")
@ -294,6 +319,9 @@ AmmActionCard {
tokenData: root.tokenB.definitionId ? root.tokenB : null tokenData: root.tokenB.definitionId ? root.tokenB : null
tokens: root.tokens tokens: root.tokens
selectedTokenId: root.selectedTokenBId selectedTokenId: root.selectedTokenBId
holdings: root.holdingsB
selectedHoldingId: root.selectedHoldingBId
holdingSelectionEnabled: !root.submitting
tokenInvalid: root.tokenHasError("B") tokenInvalid: root.tokenHasError("B")
tokenSelectionEnabled: !root.contextLoading && !root.submitting tokenSelectionEnabled: !root.contextLoading && !root.submitting
adjustment: root.missingPool ? priceAmountBAdjustment : null adjustment: root.missingPool ? priceAmountBAdjustment : null
@ -316,6 +344,9 @@ AmmActionCard {
onMaxClicked: root.useMaximum() onMaxClicked: root.useMaximum()
onTokenSelected: function(tokenId) { root.resolveToken("B", tokenId) } onTokenSelected: function(tokenId) { root.resolveToken("B", tokenId) }
onTokenEntered: function(value) { root.resolveToken("B", value) } onTokenEntered: function(value) { root.resolveToken("B", value) }
onHoldingSelected: function(holdingId) {
root.selectHolding("B", holdingId)
}
} }
} }
@ -601,6 +632,10 @@ AmmActionCard {
text: root.submitting text: root.submitting
? qsTr("Submitting…") ? qsTr("Submitting…")
: root.contextLoading ? qsTr("Loading…") : 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.poolCreationPending ? qsTr("Waiting for pool")
: root.missingPool ? qsTr("Create pool") : qsTr("Add liquidity") : root.missingPool ? qsTr("Create pool") : qsTr("Add liquidity")
enabled: root.canConfirm enabled: root.canConfirm
@ -700,6 +735,40 @@ AmmActionCard {
return root.emptyToken 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() { function selectableTokenIds() {
var result = [] var result = []
for (var i = 0; i < root.tokens.length; ++i) { for (var i = 0; i < root.tokens.length; ++i) {
@ -823,6 +892,9 @@ AmmActionCard {
root.selectedTokenAId = root.selectedTokenBId root.selectedTokenAId = root.selectedTokenBId
root.selectedTokenBId = tokenId root.selectedTokenBId = tokenId
} }
root.selectedHoldingAId = ""
root.selectedHoldingBId = ""
root.reconcileHoldings()
root.resetPairDraft() root.resetPairDraft()
} }
@ -830,6 +902,9 @@ AmmActionCard {
var tokenId = root.selectedTokenAId var tokenId = root.selectedTokenAId
root.selectedTokenAId = root.selectedTokenBId root.selectedTokenAId = root.selectedTokenBId
root.selectedTokenBId = tokenId root.selectedTokenBId = tokenId
var holdingId = root.selectedHoldingAId
root.selectedHoldingAId = root.selectedHoldingBId
root.selectedHoldingBId = holdingId
var amount = root.amountA var amount = root.amountA
root.amountA = root.amountB root.amountA = root.amountB
root.amountB = amount root.amountB = amount
@ -858,7 +933,7 @@ AmmActionCard {
} }
function acceptPoolActivation(quote) { function acceptPoolActivation(quote) {
if (!quote || quote.schema !== "new-position.v1" if (!quote || quote.schema !== "new-position.v2"
|| quote.status !== "ok" || quote.status !== "ok"
|| quote.poolStatus !== "active_pool" || quote.poolStatus !== "active_pool"
|| !root.quoteMatchesSelectedPair(quote)) { || !root.quoteMatchesSelectedPair(quote)) {
@ -985,6 +1060,10 @@ AmmActionCard {
} }
var request = root.pairRequest() 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) { if (root.activePool) {
var parsedA = AmountMath.parseHuman(root.amountA, root.decimalsA) var parsedA = AmountMath.parseHuman(root.amountA, root.decimalsA)
@ -1076,14 +1155,23 @@ AmmActionCard {
} }
function pairRequest() { function pairRequest() {
return { var request = {
"schema": "new-position.v1", "schema": "new-position.v2",
"tokenAId": root.displayIsCanonical "tokenAId": root.displayIsCanonical
? root.selectedTokenAId : root.selectedTokenBId, ? root.selectedTokenAId : root.selectedTokenBId,
"tokenBId": root.displayIsCanonical "tokenBId": root.displayIsCanonical
? root.selectedTokenBId : root.selectedTokenAId, ? root.selectedTokenBId : root.selectedTokenAId,
"feeBps": root.selectedFeeBps "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) { function requestQuote(immediate) {
@ -1095,7 +1183,11 @@ AmmActionCard {
} }
function probeRaw(token, decimals) { 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") var simulated = AmountMath.multiply(AmountMath.pow10(decimals), "1000")
if (AmountMath.isUnsigned(balance) && AmountMath.compare(balance, simulated) > 0) if (AmountMath.isUnsigned(balance) && AmountMath.compare(balance, simulated) > 0)
return balance return balance
@ -1195,6 +1287,9 @@ AmmActionCard {
"invalid_amount_precision": qsTr("Token amounts must use whole raw units."), "invalid_amount_precision": qsTr("Token amounts must use whole raw units."),
"invalid_raw_amount": qsTr("Value is outside the supported range."), "invalid_raw_amount": qsTr("Value is outside the supported range."),
"amount_exceeds_balance": qsTr("Amount exceeds the selected holding balance."), "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."), "amount_too_low": qsTr("Value is too low for this pool."),
"invalid_token_id": qsTr("Enter a valid base58 TokenDefinition ID."), "invalid_token_id": qsTr("Enter a valid base58 TokenDefinition ID."),
"deposit_ratio_mismatch": qsTr("Deposit amounts must match the initial price."), "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."), "fee_tier_mismatch": qsTr("Select the existing pool fee tier."),
"no_wallet": qsTr("Connect a wallet to submit this position."), "no_wallet": qsTr("Connect a wallet to submit this position."),
"wallet_unavailable": qsTr("Wallet is unavailable."), "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."), "wallet_submission_failed": qsTr("Wallet submission failed. Review and retry manually."),
"signature_rejected": qsTr("Wallet approval was rejected."), "signature_rejected": qsTr("Wallet approval was rejected."),
"quote_changed": qsTr("Pool or wallet state changed. Review the refreshed quote."), "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_unknown": qsTr("Network identity could not be verified. Refresh and retry."),
"network_mismatch": qsTr("Connected wallet uses a different network."), "network_mismatch": qsTr("Connected wallet uses a different network."),
"config_missing": qsTr("Network configuration is missing."), "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."), "account_read_failed": qsTr("Required on-chain state could not be read."),
"pool_unavailable": qsTr("Pool state is unavailable."), "pool_unavailable": qsTr("Pool state is unavailable."),
"config_unavailable": qsTr("AMM configuration is unavailable."), "config_unavailable": qsTr("AMM configuration is unavailable."),
@ -1264,8 +1361,8 @@ AmmActionCard {
var reserveB = root.poolReserve("B") var reserveB = root.poolReserve("B")
if (!reserveA || !reserveB || reserveA === "0" || reserveB === "0") if (!reserveA || !reserveB || reserveA === "0" || reserveB === "0")
return return
var balanceA = String(root.tokenA.balanceRaw || "0") var balanceA = String(root.holdingA.balanceRaw || "0")
var balanceB = String(root.tokenB.balanceRaw || "0") var balanceB = String(root.holdingB.balanceRaw || "0")
var fitA = AmountMath.mulDivFloor(balanceB, reserveA, reserveB) var fitA = AmountMath.mulDivFloor(balanceB, reserveA, reserveB)
var rawA = AmountMath.compare(balanceA, fitA) < 0 ? balanceA : fitA var rawA = AmountMath.compare(balanceA, fitA) < 0 ? balanceA : fitA
var rawB = AmountMath.mulDivFloor(rawA, reserveB, reserveA) var rawB = AmountMath.mulDivFloor(rawA, reserveB, reserveA)
@ -1459,10 +1556,27 @@ AmmActionCard {
"depositAText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A"), "depositAText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A"),
"depositBText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "B"), "depositBText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "B"),
"expectedLpText": root.rawLpText(root.quotePayload.expectedLpRaw), "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) { function shortTokenName(token) {
if (token && token.name && token.name.length > 0) if (token && token.name && token.name.length > 0)
return token.name return token.name
@ -1473,6 +1587,12 @@ AmmActionCard {
return AmountMath.formatRaw(String(token.balanceRaw || "0"), decimals) 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) { function tokenBalanceDetail(token) {
return qsTr("Available %1").arg(root.balanceText(token, 0)) return qsTr("Available %1").arg(root.balanceText(token, 0))
} }

View File

@ -1,6 +1,8 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "../shared" import "../shared"
@ -16,6 +18,9 @@ AmmTokenAmountSurface {
property string selectedTokenId: "" property string selectedTokenId: ""
property bool tokenInvalid: false property bool tokenInvalid: false
property bool tokenSelectionEnabled: true property bool tokenSelectionEnabled: true
property var holdings: []
property string selectedHoldingId: ""
property bool holdingSelectionEnabled: true
property bool editPending: false property bool editPending: false
property string pendingValue: "" property string pendingValue: ""
property var disabledReasonForCode: function(code) { property var disabledReasonForCode: function(code) {
@ -31,13 +36,15 @@ AmmTokenAmountSurface {
signal maxClicked signal maxClicked
signal tokenSelected(string tokenId) signal tokenSelected(string tokenId)
signal tokenEntered(string value) signal tokenEntered(string value)
signal holdingSelected(string holdingId)
amount: root.text amount: root.text
supportingText: root.helperText supportingText: root.helperText
supportingActionText: root.showMaxButton ? qsTr("MAX") : "" supportingActionText: root.showMaxButton ? qsTr("MAX") : ""
accessory: tokenActions accessory: tokenActions
accessoryWidth: width < 360 ? 132 : 180 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) { onAmountEdited: function(value) {
root.pendingValue = value root.pendingValue = value
@ -68,17 +75,46 @@ AmmTokenAmountSurface {
Component { Component {
id: tokenActions id: tokenActions
AmmTokenAccessory { ColumnLayout {
theme: root.theme spacing: 4
enabled: root.tokenSelectionEnabled
invalid: root.tokenInvalid AmmTokenAccessory {
hasToken: root.tokenData !== null Layout.fillWidth: true
tokenColor: root.tokenColor(root.tokenData) theme: root.theme
tokenLetter: root.tokenLetter(root.tokenData) enabled: root.tokenSelectionEnabled
tokenText: root.tokenText(root.tokenData) invalid: root.tokenInvalid
balance: root.balance hasToken: root.tokenData !== null
accessibleName: qsTr("Select %1").arg(root.label) tokenColor: root.tokenColor(root.tokenData)
onClicked: tokenModal.open() 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 || "") var text = String(value || "")
return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text 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"))
}
} }

View File

@ -255,6 +255,10 @@ Item {
function onQuoteRefreshRequested(immediate) { function onQuoteRefreshRequested(immediate) {
form.requestQuote(immediate) form.requestQuote(immediate)
} }
function onConfirmationQuoteReady(snapshot) {
confirmationDialog.updateSnapshot(snapshot)
}
} }
Component { Component {
@ -269,8 +273,16 @@ Item {
title: qsTr("Confirm new position") title: qsTr("Confirm new position")
confirmText: qsTr("Submit") confirmText: qsTr("Submit")
busy: newPositionFlow.submitting busy: newPositionFlow.submitting
|| (opened && snapshot.quoteReady === false
&& newPositionFlow.quoteLoading)
confirmEnabled: snapshot.quoteReady === true
&& newPositionFlow.walletCanSubmit
summary: liquidityConfirmationSummary summary: liquidityConfirmationSummary
onSummaryEdited: function(snapshot) {
newPositionFlow.requoteConfirmation(snapshot)
}
onConfirmed: function(snapshot) { onConfirmed: function(snapshot) {
newPositionFlow.confirm(snapshot) newPositionFlow.confirm(snapshot)
} }

View File

@ -7,19 +7,24 @@ QtObject {
property var runtime: null property var runtime: null
property bool active: false property bool active: false
readonly property bool walletStateReady: root.backend !== null readonly property bool backendReady: root.backend !== null
&& root.backend.walletStateReady === true readonly property bool walletCanSubmit: root.backendReady
readonly property var newPositionContext: root.walletStateReady && root.backend.walletCanSubmit === true
readonly property var newPositionContext: root.backendReady
&& root.backend.newPositionContext && root.backend.newPositionContext
&& root.backend.newPositionContext.schema
? root.backend.newPositionContext ? root.backend.newPositionContext
: root.loadingContext() : root.loadingContext()
readonly property var viewState: ({ readonly property var viewState: ({
"quote": root.newPositionQuote, "quote": root.newPositionQuote,
"contextLoading": root.contextLoading || !root.walletStateReady "contextLoading": root.contextLoading
|| root.newPositionContext.status === "loading", || root.newPositionContext.status === "loading",
"quoteLoading": root.quoteLoading, "quoteLoading": root.quoteLoading,
"quoteStale": root.quoteStale, "quoteStale": root.quoteStale,
"submitting": root.submitting, "submitting": root.submitting,
"walletCanSubmit": root.walletCanSubmit,
"walletSyncStatus": root.backendReady
? String(root.backend.walletSyncStatus || "closed") : "closed",
"poolCreationPending": root.selectedPoolCreationPending(), "poolCreationPending": root.selectedPoolCreationPending(),
"transactionId": root.transactionId, "transactionId": root.transactionId,
"errorCode": root.flowErrorCode || root.contextErrorCode "errorCode": root.flowErrorCode || root.contextErrorCode
@ -28,8 +33,13 @@ QtObject {
property var newPositionQuote: ({}) property var newPositionQuote: ({})
property var resolvedTokenIds: [] property var resolvedTokenIds: []
property int contextSerial: 0
property int quoteSerial: 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 contextLoading: false
property bool quoteLoading: false property bool quoteLoading: false
property bool quoteStale: true property bool quoteStale: true
@ -38,6 +48,7 @@ QtObject {
property string flowErrorCode: "" property string flowErrorCode: ""
property string contextErrorCode: "" property string contextErrorCode: ""
property string quoteErrorCode: "" property string quoteErrorCode: ""
property var pendingContextCompletion: null
property var pendingQuoteRequest: ({ "ok": false, "request": ({}) }) property var pendingQuoteRequest: ({ "ok": false, "request": ({}) })
property var pendingPoolProbes: [] property var pendingPoolProbes: []
property bool poolProbeInFlight: false property bool poolProbeInFlight: false
@ -48,6 +59,7 @@ QtObject {
signal quoteRefreshRequested(bool immediate) signal quoteRefreshRequested(bool immediate)
signal submitSucceeded signal submitSucceeded
signal submitFailed signal submitFailed
signal confirmationQuoteReady(var snapshot)
objectName: "newPositionFlow" objectName: "newPositionFlow"
@ -64,25 +76,37 @@ QtObject {
onTriggered: root.pollPendingPool() onTriggered: root.pollPendingPool()
} }
onNewPositionContextChanged: root.invalidateQuote() property Connections backendConnections: Connections {
target: root.backend
ignoreUnknownSignals: true
onWalletStateReadyChanged: { function onNewPositionQuoteResultChanged() {
++root.contextSerial root.acceptQuoteResult(root.backend.newPositionQuoteResult || ({}))
if (!root.walletStateReady) }
root.contextLoading = false
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() root.invalidateQuote()
const completed = root.pendingContextCompletion
root.pendingContextCompletion = null
root.tokenResolutionFinished(true)
if (completed)
completed()
} }
onActiveChanged: { onActiveChanged: {
if (!root.active) if (root.active && root.backendReady)
return Qt.callLater(function() { root.quoteRefreshRequested(true) })
Qt.callLater(function() {
if (root.active && root.walletStateReady)
root.quoteRefreshRequested(true)
})
} }
function contextHints(refreshWalletAccounts) { function contextHints(refreshPublicData) {
const request = root.pendingQuoteRequest.request || {} const request = root.pendingQuoteRequest.request || {}
const recent = [] const recent = []
if (request.tokenAId) if (request.tokenAId)
@ -92,48 +116,24 @@ QtObject {
return { return {
"recentTokenIds": recent, "recentTokenIds": recent,
"resolvedTokenIds": root.resolvedTokenIds, "resolvedTokenIds": root.resolvedTokenIds,
"refreshWalletAccounts": refreshWalletAccounts === true "refreshWalletAccounts": refreshPublicData === true
} }
} }
function refreshContext(refreshWalletAccounts, completed) { function refreshContext(refreshPublicData, completed) {
const serial = ++root.contextSerial
root.contextLoading = true root.contextLoading = true
if (!root.walletStateReady || root.runtime === null) { root.pendingContextCompletion = completed || null
if (!root.backendReady) {
root.contextLoading = false root.contextLoading = false
return return
} }
try {
root.runtime.watch(root.backend.refreshNewPositionContext( root.backend.refreshNewPositionContext(root.contextHints(refreshPublicData))
root.contextHints(refreshWalletAccounts)), } catch (_error) {
function() { root.contextLoading = false
root.finishContextRefresh(serial, completed) root.contextErrorCode = "backend_error"
}, root.tokenResolutionFailed("backend_error")
function(error) { }
root.failContextRefresh(serial)
})
}
function finishContextRefresh(serial, completed) {
if (serial !== root.contextSerial)
return
root.contextLoading = false
root.contextErrorCode = ""
Qt.callLater(function() {
if (serial !== root.contextSerial)
return
root.tokenResolutionFinished(true)
if (completed)
completed()
})
}
function failContextRefresh(serial) {
if (serial !== root.contextSerial)
return
root.contextLoading = false
root.contextErrorCode = "backend_error"
root.tokenResolutionFailed("backend_error")
} }
function resolveToken(tokenId) { function resolveToken(tokenId) {
@ -152,9 +152,9 @@ QtObject {
++root.quoteSerial ++root.quoteSerial
root.pendingQuoteRequest = quoteRequest root.pendingQuoteRequest = quoteRequest
root.quoteStale = true root.quoteStale = true
root.quoteLoading = root.walletStateReady && root.active root.quoteLoading = root.backendReady && root.active
root.quoteDebounce.stop() root.quoteDebounce.stop()
if (!root.walletStateReady || !root.active) if (!root.backendReady || !root.active)
return return
if (immediate) if (immediate)
root.requestQuoteNow(root.quoteSerial) root.requestQuoteNow(root.quoteSerial)
@ -166,86 +166,107 @@ QtObject {
if (serial !== root.quoteSerial) if (serial !== root.quoteSerial)
return return
const built = root.pendingQuoteRequest const built = root.pendingQuoteRequest
if (!built.ok) { if (!built.ok || !root.backendReady || !root.active) {
root.quoteLoading = false
return
}
if (!root.walletStateReady || !root.active || root.runtime === null) {
root.quoteLoading = false root.quoteLoading = false
return return
} }
root.activeQuoteRequestId = ++root.operationSerial
root.backend.requestNewPositionQuote(
built.request, root.activeQuoteRequestId, true)
}
root.runtime.watch(root.backend.quoteNewPosition(built.request), function acceptQuoteResult(result) {
function(quote) { const requestId = Number(result.requestId || 0)
if (serial !== root.quoteSerial) if (requestId === root.poolProbeRequestId) {
return root.finishPoolProbe(root.pendingPoolProbes[0], result)
root.quoteLoading = false return
root.quoteStale = false }
root.quoteErrorCode = "" if (requestId !== root.activeQuoteRequestId)
if (!quote || quote.schema !== "new-position.v1") return
root.newPositionQuote = root.quoteError("unsupported_schema") root.quoteLoading = false
else root.quoteStale = false
root.newPositionQuote = quote root.quoteErrorCode = ""
}, root.newPositionQuote = result.schema === "new-position.v2"
function(error) { ? result : root.quoteError("unsupported_schema")
if (serial !== root.quoteSerial) if (root.pendingConfirmationSnapshot) {
return var snapshot = root.pendingConfirmationSnapshot
root.quoteLoading = false root.pendingConfirmationSnapshot = null
root.quoteStale = true snapshot.quoteHash = String(root.newPositionQuote.quoteHash || "")
root.quoteErrorCode = "backend_error" 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) { function confirm(snapshot) {
if (root.submitting) if (root.submitting)
return return
root.submitting = true if (!root.walletCanSubmit) {
root.flowErrorCode = "" root.finishSubmitFailure(root.quoteError("wallet_syncing"))
if (!root.backend || root.runtime === null) {
root.finishSubmitFailure(root.quoteError("wallet_unavailable"))
return 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 acceptSubmitResult(result) {
function(result) { if (Number(result.requestId || 0) !== root.submitRequestId)
if (result && result.schema === "new-position.v1" return
&& result.status === "submitted" if (result.schema === "new-position.v2"
&& /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test( && result.status === "submitted"
String(result.transactionId || ""))) { && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(
if (snapshot.request.initialPriceRealRaw !== undefined) String(result.transactionId || ""))) {
root.watchPoolCreation(snapshot.poolProbeRequest, result.deadlineMs) root.submitting = false
root.submitting = false root.transactionId = result.transactionId
root.transactionId = result.transactionId root.flowErrorCode = ""
root.flowErrorCode = "" root.contextErrorCode = ""
root.contextErrorCode = "" root.quoteErrorCode = ""
root.quoteErrorCode = "" const poolProbe = root.pendingSubmitSnapshot.poolProbeRequest || null
root.invalidateQuote() if (String(root.pendingSubmitSnapshot.instruction || "")
root.submitSucceeded() === "NewDefinition" && poolProbe)
return root.watchPoolCreation(poolProbe, result.deadlineMs)
} root.invalidateQuote()
root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed")) root.submitSucceeded()
}, return
function(error) { }
root.finishSubmitFailure(root.quoteError("wallet_submission_failed")) root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed"))
})
} }
function finishSubmitFailure(result) { function finishSubmitFailure(result) {
root.submitting = false root.submitting = false
const hasFreshQuote = result && result.quote const hasFreshQuote = result && result.quote
&& result.quote.schema === "new-position.v1" && result.quote.schema === "new-position.v2"
if (hasFreshQuote) { if (hasFreshQuote) {
root.newPositionQuote = result.quote root.newPositionQuote = result.quote
root.quoteLoading = false root.quoteLoading = false
root.quoteStale = false root.quoteStale = false
} }
const code = result && result.code ? result.code : "wallet_submission_failed" root.flowErrorCode = result && result.code
root.flowErrorCode = code ? result.code : "wallet_submission_failed"
root.submitFailed() root.submitFailed()
if (hasFreshQuote) if (!hasFreshQuote)
return root.scheduleQuote(true, root.pendingQuoteRequest)
root.scheduleQuote(true, root.pendingQuoteRequest)
} }
function watchPoolCreation(request, deadlineMs) { function watchPoolCreation(request, deadlineMs) {
@ -256,40 +277,33 @@ QtObject {
const pending = root.pendingPoolProbes.filter(function(item) { const pending = root.pendingPoolProbes.filter(function(item) {
return item.key !== key return item.key !== key
}) })
pending.push({ pending.push({ "key": key, "request": request, "deadlineMs": deadline })
"key": key,
"request": request,
"deadlineMs": deadline
})
root.pendingPoolProbes = pending root.pendingPoolProbes = pending
Qt.callLater(root.pollPendingPool) Qt.callLater(root.pollPendingPool)
} }
function pollPendingPool() { function pollPendingPool() {
if (root.poolProbeInFlight || root.pendingPoolProbes.length === 0 if (root.poolProbeInFlight || root.pendingPoolProbes.length === 0
|| !root.walletStateReady || root.runtime === null) { || !root.backendReady)
return return
}
const pending = root.pendingPoolProbes[0] const pending = root.pendingPoolProbes[0]
root.poolProbeInFlight = true root.poolProbeInFlight = true
root.runtime.watch(root.backend.quoteNewPosition(pending.request), root.poolProbeRequestId = ++root.operationSerial
function(quote) { root.backend.requestNewPositionQuote(pending.request, root.poolProbeRequestId, true)
root.finishPoolProbe(pending, quote)
},
function(error) {
root.finishPoolProbe(pending, null)
})
} }
function finishPoolProbe(pending, quote) { function finishPoolProbe(pending, quote) {
if (!pending)
return
root.poolProbeInFlight = false root.poolProbeInFlight = false
if (quote && quote.schema === "new-position.v1" root.poolProbeRequestId = 0
if (quote && quote.schema === "new-position.v2"
&& quote.poolStatus === "active_pool") { && quote.poolStatus === "active_pool") {
root.removePendingPool(pending.key) root.removePendingPool(pending.key)
if (root.matchesSelectedPair(pending.request)) { if (root.matchesSelectedPair(pending.request)) {
root.poolActivated(quote) root.poolActivated(quote)
root.invalidateQuote() root.invalidateQuote()
root.refreshContext(true) root.refreshContext(false)
} }
return return
} }
@ -313,9 +327,7 @@ QtObject {
if (!request.tokenAId || !request.tokenBId) if (!request.tokenAId || !request.tokenBId)
return false return false
const selected = root.pairKey(request) const selected = root.pairKey(request)
return root.pendingPoolProbes.some(function(item) { return root.pendingPoolProbes.some(function(item) { return item.key === selected })
return item.key === selected
})
} }
function removePendingPool(key) { function removePendingPool(key) {
@ -342,6 +354,7 @@ QtObject {
function invalidateQuote() { function invalidateQuote() {
++root.quoteSerial ++root.quoteSerial
root.activeQuoteRequestId = 0
root.quoteDebounce.stop() root.quoteDebounce.stop()
root.quoteLoading = false root.quoteLoading = false
root.quoteStale = true root.quoteStale = true
@ -349,7 +362,7 @@ QtObject {
function loadingContext() { function loadingContext() {
return { return {
"schema": "new-position.v1", "schema": "new-position.v2",
"status": "loading", "status": "loading",
"tokens": [], "tokens": [],
"feeTiers": [] "feeTiers": []
@ -358,7 +371,7 @@ QtObject {
function quoteError(code) { function quoteError(code) {
return { return {
"schema": "new-position.v1", "schema": "new-position.v2",
"status": "error", "status": "error",
"canSubmit": false, "canSubmit": false,
"code": code, "code": code,

View File

@ -69,3 +69,8 @@ AmmClientResult BundledAmmClient::plan(const QJsonObject& request) const
{ {
return call(amm_plan, request); return call(amm_plan, request);
} }
AmmClientResult BundledAmmClient::normalizeAccountRpc(const QJsonObject& request) const
{
return call(amm_normalize_account_rpc, request);
}

View File

@ -17,6 +17,7 @@ public:
virtual AmmClientResult context(const QJsonObject& request) const = 0; virtual AmmClientResult context(const QJsonObject& request) const = 0;
virtual AmmClientResult quote(const QJsonObject& request) const = 0; virtual AmmClientResult quote(const QJsonObject& request) const = 0;
virtual AmmClientResult plan(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 { class BundledAmmClient final : public AmmClient {
@ -27,4 +28,5 @@ public:
AmmClientResult context(const QJsonObject& request) const override; AmmClientResult context(const QJsonObject& request) const override;
AmmClientResult quote(const QJsonObject& request) const override; AmmClientResult quote(const QJsonObject& request) const override;
AmmClientResult plan(const QJsonObject& request) const override; AmmClientResult plan(const QJsonObject& request) const override;
AmmClientResult normalizeAccountRpc(const QJsonObject& request) const override;
}; };

View File

@ -4,6 +4,7 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonParseError> #include <QJsonParseError>
#include <QDateTime>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkReply> #include <QNetworkReply>
#include <QNetworkRequest> #include <QNetworkRequest>
@ -13,6 +14,7 @@
#include "AmmClient.h" #include "AmmClient.h"
#include "LogosWalletProvider.h" #include "LogosWalletProvider.h"
#include "NewPositionRuntime.h" #include "NewPositionRuntime.h"
#include "SequencerClient.h"
#include "WalletController.h" #include "WalletController.h"
#include "logos_api.h" #include "logos_api.h"
@ -63,22 +65,24 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent)
m_walletController(std::make_unique<WalletController>( m_walletController(std::make_unique<WalletController>(
*m_wallet, QStringLiteral("AmmUI"))), *m_wallet, QStringLiteral("AmmUI"))),
m_ammClient(std::make_unique<BundledAmmClient>()), m_ammClient(std::make_unique<BundledAmmClient>()),
m_newPosition(std::make_unique<NewPositionRuntime>(m_wallet.get(), m_ammClient.get())), m_sequencer(std::make_unique<SequencerClient>(m_ammClient.get(), this)),
m_net(new QNetworkAccessManager(this)) m_newPosition(std::make_unique<NewPositionRuntime>(
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(); m_network.load();
setNewPositionContext(m_newPosition->context(
QVariantMap(), m_network.snapshot(), false, false));
connect(m_walletController.get(), &WalletController::stateChanged, connect(m_walletController.get(), &WalletController::stateChanged,
this, &AmmUiBackend::syncWalletState); this, &AmmUiBackend::syncWalletState);
syncWalletState(); syncWalletState();
refreshNewPositionContext({});
m_walletController->start(); m_walletController->start();
QTimer::singleShot(0, this, [this]() {
setWalletStateReady(true);
syncWalletState();
});
} }
AmmUiBackend::~AmmUiBackend() = default; AmmUiBackend::~AmmUiBackend() = default;
@ -90,28 +94,22 @@ WalletAccountModel* AmmUiBackend::accountModel() const
QString AmmUiBackend::createNewDefault(QString password) QString AmmUiBackend::createNewDefault(QString password)
{ {
setWalletStateReady(false);
const QString mnemonic = m_walletController->createDefaultWallet(password); const QString mnemonic = m_walletController->createDefaultWallet(password);
setWalletStateReady(true);
syncWalletState(); syncWalletState();
return mnemonic; return mnemonic;
} }
QString AmmUiBackend::createNew(QString configPath, QString storagePath, QString password) QString AmmUiBackend::createNew(QString configPath, QString storagePath, QString password)
{ {
setWalletStateReady(false);
const QString mnemonic = const QString mnemonic =
m_walletController->createWallet(configPath, storagePath, password); m_walletController->createWallet(configPath, storagePath, password);
setWalletStateReady(true);
syncWalletState(); syncWalletState();
return mnemonic; return mnemonic;
} }
bool AmmUiBackend::openExisting() bool AmmUiBackend::openExisting()
{ {
setWalletStateReady(false);
const bool opened = m_walletController->open(); const bool opened = m_walletController->open();
setWalletStateReady(true);
syncWalletState(); syncWalletState();
return opened; return opened;
} }
@ -119,7 +117,6 @@ bool AmmUiBackend::openExisting()
void AmmUiBackend::disconnectWallet() void AmmUiBackend::disconnectWallet()
{ {
m_walletController->disconnect(); m_walletController->disconnect();
setWalletStateReady(true);
m_newPosition->clearWalletAccounts(); m_newPosition->clearWalletAccounts();
refreshNewPositionContext(QVariantMap()); refreshNewPositionContext(QVariantMap());
} }
@ -162,19 +159,38 @@ void AmmUiBackend::refreshNewPositionContext(QVariantMap request)
} }
if (m_network.status() == QStringLiteral("network_unknown")) if (m_network.status() == QStringLiteral("network_unknown"))
probeNetworkIdentity(); probeNetworkIdentity();
setNewPositionContext(m_newPosition->context( const quint64 generation = ++m_contextGeneration;
request, m_network.snapshot(), isWalletOpen(), refreshWalletAccounts)); 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( m_newPosition->submitAsync(
request, quoteHash, m_network.snapshot(), isWalletOpen()); request, quoteHash, m_network.snapshot(), walletCanSubmit(),
[this, requestId](QVariantMap result) {
result.insert(QStringLiteral("requestId"), requestId);
setNewPositionSubmitResult(result);
watchTransaction(result);
});
} }
void AmmUiBackend::syncWalletState() void AmmUiBackend::syncWalletState()
@ -185,6 +201,11 @@ void AmmUiBackend::syncWalletState()
const QString previousAddress = sequencerAddr(); const QString previousAddress = sequencerAddr();
setIsWalletOpen(state.isWalletOpen); setIsWalletOpen(state.isWalletOpen);
setWalletSyncStatus(state.syncStatus);
setWalletSyncError(state.syncError);
setWalletCanSubmit(state.canSubmit());
setWalletStateReady(state.syncStatus != QStringLiteral("opening")
&& state.syncStatus != QStringLiteral("syncing"));
setWalletExists(state.walletExists); setWalletExists(state.walletExists);
setConfigPath(state.configPath); setConfigPath(state.configPath);
setStoragePath(state.storagePath); setStoragePath(state.storagePath);
@ -194,14 +215,27 @@ void AmmUiBackend::syncWalletState()
setSequencerAddr(state.sequencerAddress); setSequencerAddr(state.sequencerAddress);
setSequencerReachable(state.sequencerReachable); 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; const bool addressChanged = previousAddress != state.sequencerAddress;
if (addressChanged) if (addressChanged) {
m_pendingTransactions.clear();
m_transactionTimer->stop();
m_network.sequencerChanged(!state.sequencerAddress.isEmpty()); m_network.sequencerChanged(!state.sequencerAddress.isEmpty());
}
if (addressChanged || wasReachable != state.sequencerReachable) { if (addressChanged || wasReachable != state.sequencerReachable) {
m_network.reachabilityChanged(state.sequencerReachable, wasReachable); m_network.reachabilityChanged(state.sequencerReachable, wasReachable);
} }
if (walletWasOpen && !state.isWalletOpen) if (walletWasOpen && !state.isWalletOpen)
m_newPosition->clearWalletAccounts(); m_newPosition->clearWalletAccounts();
if (state.canSubmit()) {
const WalletSnapshot snapshot = m_wallet->snapshot();
if (snapshot.ok())
m_newPosition->setWalletAccounts(snapshot.accounts);
}
publishNetworkContext(); publishNetworkContext();
if (state.sequencerReachable && m_network.needsIdentityProbe()) if (state.sequencerReachable && m_network.needsIdentityProbe())
@ -230,6 +264,7 @@ void AmmUiBackend::probeNetworkIdentity()
QNetworkRequest request{QUrl(address)}; QNetworkRequest request{QUrl(address)};
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
request.setTransferTimeout(4000); request.setTransferTimeout(4000);
m_sequencer->applyAuthorization(request);
QNetworkReply* reply = m_net->post(request, jsonRpcBody(method, params)); QNetworkReply* reply = m_net->post(request, jsonRpcBody(method, params));
connect(reply, &QNetworkReply::finished, this, [this, reply, address, devnet]() { connect(reply, &QNetworkReply::finished, this, [this, reply, address, devnet]() {
m_identityProbeInFlight = false; m_identityProbeInFlight = false;
@ -255,6 +290,78 @@ void AmmUiBackend::probeNetworkIdentity()
void AmmUiBackend::publishNetworkContext() void AmmUiBackend::publishNetworkContext()
{ {
setNewPositionContext(m_newPosition->context( refreshNewPositionContext(m_newPositionHints);
m_newPositionHints, m_network.snapshot(), isWalletOpen(), false)); }
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<PendingTransaction> 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<WalletAccountRead> 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({});
});
} }

View File

@ -4,7 +4,9 @@
#include <memory> #include <memory>
#include <QObject> #include <QObject>
#include <QHash>
#include <QString> #include <QString>
#include <QStringList>
#include <QVariant> #include <QVariant>
#include "rep_AmmUiBackend_source.h" #include "rep_AmmUiBackend_source.h"
@ -17,6 +19,8 @@ class AmmClient;
class LogosWalletProvider; class LogosWalletProvider;
class NewPositionRuntime; class NewPositionRuntime;
class QNetworkAccessManager; class QNetworkAccessManager;
class QTimer;
class SequencerClient;
class WalletController; class WalletController;
// Source-side implementation of the AmmUiBackend .rep interface. // Source-side implementation of the AmmUiBackend .rep interface.
@ -40,8 +44,12 @@ public slots:
void refreshBalances() override; void refreshBalances() override;
QString getBalance(QString accountIdHex, bool isPublic) override; QString getBalance(QString accountIdHex, bool isPublic) override;
void refreshNewPositionContext(QVariantMap request) override; void refreshNewPositionContext(QVariantMap request) override;
QVariantMap quoteNewPosition(QVariantMap request) override; void requestNewPositionQuote(QVariantMap request,
QVariantMap submitNewPosition(QVariantMap request, QString quoteHash) override; 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 // Return the new wallet's BIP39 mnemonic (empty string on failure) so the
// UI can force a one-time seed-phrase backup step. // UI can force a one-time seed-phrase backup step.
QString createNewDefault(QString password) override; QString createNewDefault(QString password) override;
@ -53,18 +61,31 @@ private:
void syncWalletState(); void syncWalletState();
void probeNetworkIdentity(); void probeNetworkIdentity();
void publishNetworkContext(); void publishNetworkContext();
void watchTransaction(const QVariantMap& result);
void pollTransactions();
void refreshAffectedAccounts(const QStringList& accountIds, int attempt = 0);
LogosAPI* m_logosAPI; LogosAPI* m_logosAPI;
std::unique_ptr<LogosWalletProvider> m_wallet; std::unique_ptr<LogosWalletProvider> m_wallet;
std::unique_ptr<WalletController> m_walletController; std::unique_ptr<WalletController> m_walletController;
std::unique_ptr<AmmClient> m_ammClient; std::unique_ptr<AmmClient> m_ammClient;
std::unique_ptr<SequencerClient> m_sequencer;
std::unique_ptr<NewPositionRuntime> m_newPosition; std::unique_ptr<NewPositionRuntime> m_newPosition;
QNetworkAccessManager* m_net; QNetworkAccessManager* m_net;
QTimer* m_transactionTimer;
ActiveNetwork m_network; ActiveNetwork m_network;
QVariantMap m_newPositionHints; QVariantMap m_newPositionHints;
QString m_sequencerConfigPath;
bool m_identityProbeInFlight = false; bool m_identityProbeInFlight = false;
quint64 m_contextGeneration = 0;
struct PendingTransaction {
QString nativeHash;
QStringList affectedAccountIds;
qint64 deadlineMs = 0;
};
QHash<QString, PendingTransaction> m_pendingTransactions;
}; };
#endif // AMM_UI_BACKEND_H #endif // AMM_UI_BACKEND_H

View File

@ -1,5 +1,5 @@
// QtRO view contract for the AMM UI backend. PROPs auto-sync to every QML // 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 // The account list is exposed separately as a Q_PROPERTY model on the backend
// (reached from QML via logos.model("amm_ui", "accountModel")). // (reached from QML via logos.model("amm_ui", "accountModel")).
class AmmUiBackend class AmmUiBackend
@ -8,6 +8,9 @@ class AmmUiBackend
// False while startup or reconnect is still resolving wallet state. This // False while startup or reconnect is still resolving wallet state. This
// stays distinct from isWalletOpen because a disconnected wallet is ready. // stays distinct from isWalletOpen because a disconnected wallet is ready.
PROP(bool walletStateReady READONLY) PROP(bool walletStateReady READONLY)
PROP(QString walletSyncStatus READONLY)
PROP(QString walletSyncError READONLY)
PROP(bool walletCanSubmit READONLY)
PROP(bool walletExists READONLY) PROP(bool walletExists READONLY)
PROP(QString configPath READONLY) PROP(QString configPath READONLY)
PROP(QString storagePath READONLY) PROP(QString storagePath READONLY)
@ -26,13 +29,15 @@ class AmmUiBackend
SLOT(void refreshBalances()) SLOT(void refreshBalances())
SLOT(QString getBalance(QString accountIdHex, bool isPublic)) SLOT(QString getBalance(QString accountIdHex, bool isPublic))
// New Position backend surface. QML calls these through logos.watch(...). // New Position backend surface. QML dispatches request slots and observes
// The QVariant payloads are stable maps/lists so the UI never assembles AMM // request-tagged result properties. QVariant payloads are stable maps/lists
// transactions or duplicates quote state. // so the UI never assembles AMM transactions or duplicates quote state.
PROP(QVariantMap newPositionContext READONLY) PROP(QVariantMap newPositionContext READONLY)
PROP(QVariantMap newPositionQuoteResult READONLY)
PROP(QVariantMap newPositionSubmitResult READONLY)
SLOT(void refreshNewPositionContext(QVariantMap request)) SLOT(void refreshNewPositionContext(QVariantMap request))
SLOT(QVariantMap quoteNewPosition(QVariantMap request)) SLOT(void requestNewPositionQuote(QVariantMap request, int requestId, bool forceRefresh))
SLOT(QVariantMap submitNewPosition(QVariantMap request, QString quoteHash)) SLOT(void requestNewPositionSubmit(QVariantMap request, QString quoteHash, int requestId))
// Wallet lifecycle. createNewDefault() is the happy path: it creates a // Wallet lifecycle. createNewDefault() is the happy path: it creates a
// fresh wallet at the canonical walletHome with no path picking. createNew() // fresh wallet at the canonical walletHome with no path picking. createNew()

View File

@ -11,10 +11,11 @@
#include <libbase58.h> #include <libbase58.h>
#include "AmmClient.h" #include "AmmClient.h"
#include "SequencerClient.h"
#include "WalletProvider.h" #include "WalletProvider.h"
namespace { namespace {
const char SCHEMA[] = "new-position.v1"; const char SCHEMA[] = "new-position.v2";
constexpr qsizetype HASH_BYTES = 32; constexpr qsizetype HASH_BYTES = 32;
constexpr std::size_t BASE58_BUFFER_SIZE = 45; constexpr std::size_t BASE58_BUFFER_SIZE = 45;
@ -35,6 +36,21 @@ namespace {
encoded.data(), static_cast<qsizetype>(size - 1)); encoded.data(), static_cast<qsizetype>(size - 1));
} }
QString accountIdHex(const QString& accountId)
{
const QByteArray encoded = accountId.toLatin1();
std::array<unsigned char, HASH_BYTES> bytes {};
std::size_t size = bytes.size();
if (!b58tobin(bytes.data(), &size, encoded.constData(),
static_cast<std::size_t>(encoded.size()))
|| size != bytes.size()) {
return {};
}
return QString::fromLatin1(
QByteArray(reinterpret_cast<const char*>(bytes.data()),
static_cast<qsizetype>(bytes.size())).toHex());
}
QJsonObject issue(const QString& code, QJsonObject issue(const QString& code,
const QJsonArray& blockingFields = {}) 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_wallet(wallet),
m_client(client) m_client(client),
m_sequencer(sequencer)
{ {
} }
void NewPositionRuntime::clearWalletAccounts() void NewPositionRuntime::clearWalletAccounts()
{ {
m_walletPublicAccountIds.clear();
m_wallet->clearSnapshot(); m_wallet->clearSnapshot();
} }
void NewPositionRuntime::setWalletAccounts(const QVector<WalletAccount>& 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 QJsonArray NewPositionRuntime::walletAccountReads(bool walletOpen, bool refresh) const
{ {
if (!walletOpen) 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<WalletAccountRead> 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<WalletAccountRead> 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<WalletAccountRead> 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<void(QJsonObject, QJsonObject)> 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<WalletAccountRead> 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<WalletAccountRead> fixedReads) mutable {
m_sequencer->readAccounts(m_walletPublicAccountIds, false,
[this, requestObject, network, walletOpen, config, pair,
fixedReads = std::move(fixedReads),
callback = std::move(callback)](
QVector<WalletAccountRead> 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<WalletAccountRead> selectedReads) mutable {
QHash<QString, WalletAccountRead> 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<bool> signingRequirements = jsonBoolList(
plan.value(QStringLiteral("signingRequirements")).toArray());
const QVector<quint32> instruction = jsonUIntList(
plan.value(QStringLiteral("instruction")).toArray());
bool deadlineValid = false;
const qulonglong deadline = plan.value(QStringLiteral("deadlineMs"))
.toString().toULongLong(&deadlineValid);
if (!deadlineValid
|| static_cast<qulonglong>(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, QVariantMap NewPositionRuntime::context(const QVariantMap& request,
const ActiveNetworkSnapshot& network, const ActiveNetworkSnapshot& network,
bool walletOpen, bool walletOpen,
@ -385,6 +764,7 @@ QVariantMap NewPositionRuntime::submit(const QVariantMap& request,
{ QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) },
{ QStringLiteral("status"), QStringLiteral("submitted") }, { QStringLiteral("status"), QStringLiteral("submitted") },
{ QStringLiteral("transactionId"), transactionId }, { QStringLiteral("transactionId"), transactionId },
{ QStringLiteral("nativeTransactionHash"), submission.nativeHash },
{ QStringLiteral("deadlineMs"), plan.value(QStringLiteral("deadlineMs")) }, { QStringLiteral("deadlineMs"), plan.value(QStringLiteral("deadlineMs")) },
}.toVariantMap(); }.toVariantMap();
} }

View File

@ -4,17 +4,43 @@
#include <QJsonObject> #include <QJsonObject>
#include <QString> #include <QString>
#include <QVariantMap> #include <QVariantMap>
#include <QVector>
#include <functional>
#include "ActiveNetwork.h" #include "ActiveNetwork.h"
class AmmClient; class AmmClient;
class WalletProvider; class WalletProvider;
class SequencerClient;
struct WalletAccount;
class NewPositionRuntime { class NewPositionRuntime {
public: public:
NewPositionRuntime(WalletProvider* wallet, AmmClient* client); using ResultCallback = std::function<void(QVariantMap)>;
NewPositionRuntime(WalletProvider* wallet,
AmmClient* client,
SequencerClient* sequencer = nullptr);
void clearWalletAccounts(); void clearWalletAccounts();
void setWalletAccounts(const QVector<WalletAccount>& 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, QVariantMap context(const QVariantMap& request,
const ActiveNetworkSnapshot& network, const ActiveNetworkSnapshot& network,
@ -35,8 +61,15 @@ private:
bool walletOpen, bool walletOpen,
bool freshWalletAccounts, bool freshWalletAccounts,
QJsonObject* error) const; QJsonObject* error) const;
void buildQuoteInputAsync(const QVariantMap& request,
const ActiveNetworkSnapshot& network,
bool walletOpen,
bool forceRefresh,
std::function<void(QJsonObject, QJsonObject)> callback) const;
WalletProvider* m_wallet; WalletProvider* m_wallet;
AmmClient* m_client; AmmClient* m_client;
SequencerClient* m_sequencer;
QStringList m_walletPublicAccountIds;
bool m_submitInFlight = false; bool m_submitInFlight = false;
}; };

View File

@ -0,0 +1,302 @@
#include "SequencerClient.h"
#include <algorithm>
#include <array>
#include <cstddef>
#include <memory>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QTimer>
#include <libbase58.h>
#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<char, BASE58_BUFFER_SIZE> encoded {};
std::size_t size = encoded.size();
if (!b58enc(encoded.data(), &size, bytes.constData(),
static_cast<std::size_t>(bytes.size()))) {
return {};
}
return QString::fromLatin1(encoded.data(), static_cast<qsizetype>(size - 1));
}
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<WalletAccountRead> reads;
qsizetype remaining = 0;
AccountsCallback callback;
};
auto batch = std::make_shared<Batch>();
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<AccountCallback> 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;
}

View File

@ -0,0 +1,61 @@
#pragma once
#include <functional>
#include <QHash>
#include <QObject>
#include <QQueue>
#include <QStringList>
#include <QUrl>
#include <QVector>
#include "WalletProvider.h"
class AmmClient;
class QNetworkAccessManager;
class QNetworkRequest;
class SequencerClient final : public QObject {
Q_OBJECT
public:
using AccountsCallback = std::function<void(QVector<WalletAccountRead>)>;
using TransactionCallback = std::function<void(bool ok, bool included)>;
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<void(WalletAccountRead)>;
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<QString, WalletAccountRead> m_cache;
QHash<QString, QVector<AccountCallback>> m_waiters;
QQueue<PendingRead> m_pending;
int m_activeReads = 0;
quint64 m_generation = 0;
};

View File

@ -19,6 +19,11 @@ namespace {
return {}; return {};
} }
void connectAsync(const WalletPaths&, SessionCallback callback) override
{
callback({});
}
WalletCreation createWallet(const WalletPaths&, const QString&) override WalletCreation createWallet(const WalletPaths&, const QString&) override
{ {
return {}; return {};
@ -29,6 +34,11 @@ namespace {
return {}; return {};
} }
void snapshotAsync(bool, SnapshotCallback callback) override
{
callback({});
}
void clearSnapshot() override {} void clearSnapshot() override {}
WalletAccountCreation createAccount(bool isPublic) override WalletAccountCreation createAccount(bool isPublic) override
@ -102,7 +112,7 @@ namespace {
AmmClientResult quote(const QJsonObject&) const override AmmClientResult quote(const QJsonObject&) const override
{ {
return success({ return success({
{ QStringLiteral("schema"), QStringLiteral("new-position.v1") }, { QStringLiteral("schema"), QStringLiteral("new-position.v2") },
{ QStringLiteral("status"), QStringLiteral("ok") }, { QStringLiteral("status"), QStringLiteral("ok") },
{ QStringLiteral("canSubmit"), true }, { QStringLiteral("canSubmit"), true },
{ QStringLiteral("quoteHash"), quoteHash }, { QStringLiteral("quoteHash"), quoteHash },
@ -116,6 +126,8 @@ namespace {
return success({ return success({
{ QStringLiteral("status"), QStringLiteral("ready") }, { QStringLiteral("status"), QStringLiteral("ready") },
{ QStringLiteral("accountIds"), QJsonArray { QStringLiteral("account") } }, { QStringLiteral("accountIds"), QJsonArray { QStringLiteral("account") } },
{ QStringLiteral("affectedAccountIds"),
QJsonArray { QStringLiteral("account") } },
{ QStringLiteral("signingRequirements"), QJsonArray { true } }, { QStringLiteral("signingRequirements"), QJsonArray { true } },
{ QStringLiteral("instruction"), QJsonArray { 1 } }, { QStringLiteral("instruction"), QJsonArray { 1 } },
{ QStringLiteral("programId"), QStringLiteral("program") }, { QStringLiteral("programId"), QStringLiteral("program") },
@ -124,6 +136,11 @@ namespace {
}); });
} }
AmmClientResult normalizeAccountRpc(const QJsonObject&) const override
{
return {};
}
static AmmClientResult success(const QJsonObject& value) static AmmClientResult success(const QJsonObject& value)
{ {
return { true, value }; return { true, value };
@ -149,7 +166,7 @@ namespace {
int main() int main()
{ {
const QVariantMap request { const QVariantMap request {
{ QStringLiteral("schema"), QStringLiteral("new-position.v1") }, { QStringLiteral("schema"), QStringLiteral("new-position.v2") },
{ QStringLiteral("tokenAId"), QStringLiteral("token-a") }, { QStringLiteral("tokenAId"), QStringLiteral("token-a") },
{ QStringLiteral("tokenBId"), QStringLiteral("token-b") }, { QStringLiteral("tokenBId"), QStringLiteral("token-b") },
{ QStringLiteral("feeBps"), 30 }, { QStringLiteral("feeBps"), 30 },
@ -168,6 +185,10 @@ int main()
== QStringLiteral("1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"), == QStringLiteral("1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"),
"native hash should become the expected base58 transaction ID")) "native hash should become the expected base58 transaction ID"))
return 1; 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, if (!expect(wallet.createdAccounts == 1 && client.sawFreshLp,
"fresh LP account should enter the plan")) "fresh LP account should enter the plan"))
return 1; return 1;

View File

@ -18,6 +18,11 @@ TestCase {
} }
} }
SignalSpy {
id: editedSpy
signalName: "snapshotEdited"
}
function test_protocolActionsUseDisplayLabels() { function test_protocolActionsUseDisplayLabels() {
var summary = createTemporaryObject(summaryComponent, testCase) var summary = createTemporaryObject(summaryComponent, testCase)
verify(summary) verify(summary)
@ -26,4 +31,32 @@ TestCase {
compare(summary.actionText("AddLiquidity"), "Add liquidity") compare(summary.actionText("AddLiquidity"), "Add liquidity")
compare(summary.actionText(""), "-") 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
}
} }

View File

@ -17,26 +17,54 @@ TestCase {
QtObject { QtObject {
property bool walletStateReady: false property bool walletStateReady: false
property bool walletCanSubmit: true
property bool isWalletOpen: true
property string walletSyncStatus: "ready"
property var submitResult: ({}) property var submitResult: ({})
property var quoteResult: ({ property var quoteResult: ({
"schema": "new-position.v1", "schema": "new-position.v2",
"status": "ok", "status": "ok",
"poolStatus": "missing_pool" "poolStatus": "missing_pool"
}) })
property var newPositionQuoteResult: ({})
property var newPositionSubmitResult: ({})
property var newPositionContext: ({ property var newPositionContext: ({
"schema": "new-position.v1", "schema": "new-position.v2",
"status": "ready", "status": "ready",
"tokens": [], "tokens": [],
"feeTiers": [] "feeTiers": []
}) })
property int contextRefreshCalls: 0
property int submitCalls: 0
property var lastContextRefreshRequest: ({})
function submitNewPosition(request, quoteHash) { function requestNewPositionSubmit(request, quoteHash, requestId) {
return submitResult ++submitCalls
var result = JSON.parse(JSON.stringify(submitResult || ({})))
result.requestId = requestId
newPositionSubmitResult = result
} }
function quoteNewPosition(request) { function requestNewPositionQuote(request, requestId, forceRefresh) {
return quoteResult 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(rail)
verify(compactSteps) verify(compactSteps)
verify(form) verify(form)
compare(page.wideLayout, true) compare(page.wideLayout, true)
verify(rail.width > 0)
verify(form.width > rail.width) verify(form.width > rail.width)
page.width = 600 page.width = 600
wait(0) wait(0)
compare(page.wideLayout, false) compare(page.wideLayout, false)
verify(compactSteps.implicitHeight > 0) verify(compactSteps.implicitHeight > 0)
verify(form.width > 0)
verify(form.width <= page.width - 32) verify(form.width <= page.width - 32)
} }
Component { function test_contextAvailableWhileWalletSyncs() {
id: runtimeComponent var backend = createTemporaryObject(backendComponent, testCase, {
"walletCanSubmit": false,
QtObject { "walletSyncStatus": "syncing"
function watch(value, succeeded, failed) {
succeeded(value)
}
}
}
Component {
id: pageComponent
Pages.LiquidityPage {
visible: false
width: 800
height: 600
}
}
function test_contextWaitsForWalletState() {
var backend = createTemporaryObject(backendComponent, testCase)
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend
}) })
verify(backend) var page = createTemporaryObject(pageComponent, testCase, { "backend": backend })
verify(page) verify(page)
compare(page.flow.newPositionContext.status, "loading") compare(page.flow.newPositionContext.status, "ready")
compare(page.flow.viewState.walletSyncStatus, "syncing")
backend.walletStateReady = true verify(!page.flow.walletCanSubmit)
tryCompare(page.flow.newPositionContext, "status", "ready")
} }
function test_contextRefreshControlsWalletScan() { function test_contextRefreshControlsPublicRefresh() {
var backend = createTemporaryObject(backendComponent, testCase, { var backend = createTemporaryObject(backendComponent, testCase)
"walletStateReady": true var page = createTemporaryObject(pageComponent, testCase, { "backend": backend })
})
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend
})
verify(backend)
verify(page) verify(page)
compare(page.flow.contextHints(false).refreshWalletAccounts, false) compare(page.flow.contextHints(false).refreshWalletAccounts, false)
compare(page.flow.contextHints(true).refreshWalletAccounts, true) 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() { function test_submitFailureKeepsReturnedFreshQuoteWithoutRequery() {
var backend = createTemporaryObject(backendComponent, testCase, { var backend = createTemporaryObject(backendComponent, testCase)
"walletStateReady": true var page = createTemporaryObject(pageComponent, testCase, { "backend": backend })
})
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend
})
verify(backend)
verify(page)
page.flow.quoteSerial = 7 page.flow.quoteSerial = 7
page.flow.finishSubmitFailure({ page.flow.finishSubmitFailure({
"schema": "new-position.v1", "schema": "new-position.v2",
"status": "error", "status": "error",
"code": "quote_not_submittable", "code": "quote_not_submittable",
"quote": { "quote": {
"schema": "new-position.v1", "schema": "new-position.v2",
"status": "ok", "status": "ok",
"canSubmit": false, "canSubmit": false,
"quoteHash": "sha256:fresh" "quoteHash": "sha256:fresh"
@ -167,61 +133,49 @@ TestCase {
compare(page.flow.quoteStale, false) compare(page.flow.quoteStale, false)
} }
function test_base58SubmittedResultEntersSuccessState() { function test_submittedResultEntersSuccessState() {
var backend = createTemporaryObject(backendComponent, testCase, { var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true,
"submitResult": { "submitResult": {
"schema": "new-position.v1", "schema": "new-position.v2",
"status": "submitted", "status": "submitted",
"transactionId": submittedTransactionId, "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 })
var page = createTemporaryObject(pageComponent, testCase, { page.flow.confirm({ "request": ({}), "quoteHash": "sha256:expected" })
"backend": backend, wait(0)
"runtime": runtime
})
verify(backend)
verify(runtime)
verify(page)
page.flow.confirm({
"request": ({}),
"quoteHash": "sha256:expected"
})
compare(page.flow.transactionId, submittedTransactionId) compare(page.flow.transactionId, submittedTransactionId)
compare(page.flow.flowErrorCode, "") compare(page.flow.flowErrorCode, "")
compare(page.flow.submitting, false) compare(page.flow.submitting, false)
compare(backend.submitCalls, 1)
} }
function test_base58MissingPoolSubmissionStartsPoolWatch() { function test_missingPoolSubmissionStartsPoolProbeWithoutWalletRefresh() {
var backend = createTemporaryObject(backendComponent, testCase, { var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true,
"submitResult": { "submitResult": {
"schema": "new-position.v1", "schema": "new-position.v2",
"status": "submitted", "status": "submitted",
"transactionId": submittedTransactionId, "transactionId": submittedTransactionId,
"deadlineMs": String(Date.now() + 60000) "deadlineMs": String(Date.now() + 60000)
} }
}) })
var runtime = createTemporaryObject(runtimeComponent, testCase) var page = createTemporaryObject(pageComponent, testCase, { "backend": backend })
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend,
"runtime": runtime
})
verify(backend)
verify(runtime)
verify(page)
var probe = { var probe = {
"tokenAId": "22222222222222222222222222222222", "tokenAId": "22222222222222222222222222222222",
"tokenBId": "33333333333333333333333333333333" "tokenBId": "33333333333333333333333333333333"
} }
page.flow.pendingQuoteRequest = { "ok": true, "request": probe } page.flow.pendingQuoteRequest = {
"ok": true,
"request": probe
}
page.flow.confirm({ page.flow.confirm({
"instruction": "NewDefinition",
"request": { "request": {
"tokenAId": probe.tokenAId,
"tokenBId": probe.tokenBId,
"initialPriceRealRaw": "18446744073709551616" "initialPriceRealRaw": "18446744073709551616"
}, },
"poolProbeRequest": probe, "poolProbeRequest": probe,
@ -229,51 +183,51 @@ TestCase {
}) })
wait(0) wait(0)
compare(page.flow.transactionId, submittedTransactionId)
compare(page.flow.pendingPoolProbes.length, 1) compare(page.flow.pendingPoolProbes.length, 1)
compare(page.flow.selectedPoolCreationPending(), true) compare(backend.contextRefreshCalls, 0)
compare(page.flow.poolProbeInFlight, false) verify(page.flow.selectedPoolCreationPending())
} }
function test_nativeHexSubmittedResultDoesNotEnterSuccessState() { function test_activePoolSubmissionDoesNotStartPoolCreationProbe() {
var backend = createTemporaryObject(backendComponent, testCase, { var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true,
"submitResult": { "submitResult": {
"schema": "new-position.v1", "schema": "new-position.v2",
"status": "submitted", "status": "submitted",
"transactionId": "000102030405060708090a0b0c0d0e0f" "transactionId": submittedTransactionId,
+ "101112131415161718191a1b1c1d1e1f" "deadlineMs": String(Date.now() + 60000)
} }
}) })
var runtime = createTemporaryObject(runtimeComponent, testCase) var page = createTemporaryObject(pageComponent, testCase, { "backend": backend })
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend,
"runtime": runtime
})
verify(backend)
verify(runtime)
verify(page)
page.flow.confirm({ page.flow.confirm({
"instruction": "AddLiquidity",
"request": ({}), "request": ({}),
"poolProbeRequest": {
"tokenAId": "22222222222222222222222222222222",
"tokenBId": "33333333333333333333333333333333"
},
"quoteHash": "sha256:expected" "quoteHash": "sha256:expected"
}) })
wait(0)
compare(page.flow.transactionId, "") compare(page.flow.pendingPoolProbes.length, 0)
compare(page.flow.flowErrorCode, "wallet_submission_failed")
compare(page.flow.submitting, false)
} }
function test_poolProbeDoesNotPublishProbeAmountsAsCurrentQuote() { function test_walletSyncDisablesSubmissionOnly() {
var backend = createTemporaryObject(backendComponent, testCase, { var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true "walletCanSubmit": false,
"walletSyncStatus": "syncing"
}) })
var page = createTemporaryObject(pageComponent, testCase, { var page = createTemporaryObject(pageComponent, testCase, { "backend": backend })
"backend": backend page.flow.confirm({ "request": ({}), "quoteHash": "sha256:expected" })
})
verify(backend)
verify(page)
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 = { var request = {
"tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
"tokenBId": "22222222222222222222222222222222" "tokenBId": "22222222222222222222222222222222"
@ -282,54 +236,19 @@ TestCase {
page.flow.pendingQuoteRequest = { "ok": true, "request": request } page.flow.pendingQuoteRequest = { "ok": true, "request": request }
page.flow.pendingPoolProbes = [pending] page.flow.pendingPoolProbes = [pending]
page.flow.newPositionQuote = { page.flow.newPositionQuote = {
"schema": "new-position.v1", "schema": "new-position.v2",
"status": "ok", "status": "ok",
"poolStatus": "missing_pool", "poolStatus": "missing_pool"
"tokenAId": request.tokenAId,
"tokenBId": request.tokenBId
} }
page.flow.quoteStale = false page.flow.quoteStale = false
page.flow.finishPoolProbe(pending, { page.flow.finishPoolProbe(pending, {
"schema": "new-position.v1", "schema": "new-position.v2",
"status": "ok", "status": "ok",
"poolStatus": "active_pool", "poolStatus": "active_pool"
"tokenAId": request.tokenAId,
"tokenBId": request.tokenBId
}) })
compare(page.flow.pendingPoolProbes.length, 0)
compare(page.flow.newPositionQuote.poolStatus, "missing_pool") compare(page.flow.newPositionQuote.poolStatus, "missing_pool")
verify(page.flow.quoteStale) verify(page.flow.quoteStale)
} }
function test_poolProbeStopsBlockingAfterTransactionDeadline() {
var backend = createTemporaryObject(backendComponent, testCase, {
"walletStateReady": true
})
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend
})
verify(backend)
verify(page)
var request = {
"tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
"tokenBId": "22222222222222222222222222222222"
}
var pending = {
"key": page.flow.pairKey(request),
"request": request,
"deadlineMs": Date.now() - 1
}
page.flow.pendingQuoteRequest = { "ok": true, "request": request }
page.flow.pendingPoolProbes = [pending]
page.flow.poolProbeInFlight = true
page.flow.finishPoolProbe(pending, null)
compare(page.flow.pendingPoolProbes.length, 0)
compare(page.flow.poolProbeInFlight, false)
verify(!page.flow.selectedPoolCreationPending())
}
} }

View File

@ -46,6 +46,10 @@ TestCase {
"name": "Low", "name": "Low",
"totalSupplyRaw": "1000000", "totalSupplyRaw": "1000000",
"balanceRaw": "1000", "balanceRaw": "1000",
"holdings": [{
"holdingId": "44444444444444444444444444444444",
"balanceRaw": "1000"
}],
"selectable": true "selectable": true
}, },
{ {
@ -53,6 +57,10 @@ TestCase {
"name": "High", "name": "High",
"totalSupplyRaw": "1000000000000", "totalSupplyRaw": "1000000000000",
"balanceRaw": "5000000000", "balanceRaw": "5000000000",
"holdings": [{
"holdingId": "55555555555555555555555555555555",
"balanceRaw": "5000000000"
}],
"selectable": true "selectable": true
} }
] ]
@ -68,6 +76,10 @@ TestCase {
"name": "Sir Mints-a-Lot", "name": "Sir Mints-a-Lot",
"totalSupplyRaw": "1000000000000", "totalSupplyRaw": "1000000000000",
"balanceRaw": "1000000000", "balanceRaw": "1000000000",
"holdings": [{
"holdingId": "44444444444444444444444444444444",
"balanceRaw": "1000000000"
}],
"selectable": true "selectable": true
}, },
{ {
@ -75,19 +87,55 @@ TestCase {
"name": "Aurora", "name": "Aurora",
"totalSupplyRaw": "1000000000000", "totalSupplyRaw": "1000000000000",
"balanceRaw": "1000000000", "balanceRaw": "1000000000",
"holdings": [{
"holdingId": "55555555555555555555555555555555",
"balanceRaw": "1000000000"
}],
"selectable": true "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) { function flowState(quote) {
return { return {
"quote": quote || ({}), "quote": quote || ({}),
"contextLoading": false, "contextLoading": false,
"quoteLoading": false, "quoteLoading": false,
"quoteStale": false, "quoteStale": false,
"submitting": false "submitting": false,
"walletCanSubmit": true,
"walletSyncStatus": "ready"
} }
} }
@ -118,6 +166,23 @@ TestCase {
compare(form.amountB, "") 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() { function test_displayOrderMapsToCanonicalRequestAfterSwap() {
var form = createForm() var form = createForm()
var built = form.buildQuoteRequest() var built = form.buildQuoteRequest()
@ -354,7 +419,7 @@ TestCase {
form.minimumAmountBRaw = "2000000" form.minimumAmountBRaw = "2000000"
verify(form.acceptPoolActivation({ verify(form.acceptPoolActivation({
"schema": "new-position.v1", "schema": "new-position.v2",
"status": "ok", "status": "ok",
"tokenAId": tokenHigh, "tokenAId": tokenHigh,
"tokenBId": tokenLow, "tokenBId": tokenLow,
@ -378,7 +443,7 @@ TestCase {
function test_staleQuoteErrorsDoNotMarkCurrentDraft() { function test_staleQuoteErrorsDoNotMarkCurrentDraft() {
var quote = { var quote = {
"schema": "new-position.v1", "schema": "new-position.v2",
"status": "ok", "status": "ok",
"tokenAId": tokenHigh, "tokenAId": tokenHigh,
"tokenBId": tokenLow, "tokenBId": tokenLow,