mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-19 13:29:56 +00:00
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:
parent
90ba65f2d0
commit
0e01962bec
@ -42,6 +42,8 @@ logos_module(
|
||||
src/AmmClient.cpp
|
||||
src/NewPositionRuntime.h
|
||||
src/NewPositionRuntime.cpp
|
||||
src/SequencerClient.h
|
||||
src/SequencerClient.cpp
|
||||
FIND_PACKAGES
|
||||
Qt6Gui
|
||||
Qt6Network
|
||||
@ -73,10 +75,13 @@ if(BUILD_TESTING)
|
||||
add_executable(amm_new_position_runtime_test
|
||||
tests/cpp/NewPositionRuntimeTest.cpp
|
||||
src/NewPositionRuntime.cpp
|
||||
src/SequencerClient.cpp
|
||||
src/SequencerClient.h
|
||||
)
|
||||
target_include_directories(amm_new_position_runtime_test PRIVATE src)
|
||||
target_link_libraries(amm_new_position_runtime_test PRIVATE
|
||||
Qt6::Core
|
||||
Qt6::Network
|
||||
PkgConfig::BASE58
|
||||
logos_wallet_access
|
||||
)
|
||||
|
||||
@ -11,6 +11,7 @@ char *amm_pair_ids(const char *request_json);
|
||||
char *amm_context(const char *request_json);
|
||||
char *amm_quote(const char *request_json);
|
||||
char *amm_plan(const char *request_json);
|
||||
char *amm_normalize_account_rpc(const char *request_json);
|
||||
void amm_free(char *value);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@ -5,6 +5,9 @@ use nssa_core::{
|
||||
program::ProgramId,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::api::NormalizeAccountRpcRequest;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -137,6 +140,47 @@ pub(crate) fn decode_account(read: &AccountRead) -> Result<(AccountId, Account),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RpcEnvelope {
|
||||
#[serde(default)]
|
||||
result: Option<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)]
|
||||
pub(crate) fn account_read(id: AccountId, account: &Account) -> AccountRead {
|
||||
AccountRead {
|
||||
@ -150,3 +194,35 @@ pub(crate) fn account_read(id: AccountId, account: &Account) -> AccountRead {
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_rpc_u128_without_precision_loss() {
|
||||
let account_id = hex::encode([7_u8; 32]);
|
||||
let value = normalize_account_rpc(NormalizeAccountRpcRequest {
|
||||
account_id: account_id.clone(),
|
||||
response: String::from(
|
||||
r#"{"jsonrpc":"2.0","id":1,"result":{"program_owner":[1,2,3,4,5,6,7,8],"balance":340282366920938463463374607431768211455,"data":[0,255],"nonce":18446744073709551617}}"#,
|
||||
),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(value["id"], account_id);
|
||||
assert_eq!(
|
||||
value["account"]["balance"],
|
||||
"ffffffffffffffffffffffffffffffff"
|
||||
);
|
||||
assert_eq!(
|
||||
value["account"]["nonce"],
|
||||
"01000000000000000100000000000000"
|
||||
);
|
||||
assert_eq!(value["account"]["data"], "00ff");
|
||||
assert_eq!(
|
||||
value["account"]["program_owner"],
|
||||
"0100000002000000030000000400000005000000060000000700000008000000"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ use token_core::TokenDefinition;
|
||||
|
||||
use super::{
|
||||
config::load_config,
|
||||
holding::{select_holding, wallet_holdings, SelectedHolding},
|
||||
holding::{holding_options, wallet_holdings, SelectedHolding},
|
||||
quote_error::issue,
|
||||
ContextRequest, TokenIdsRequest, SCHEMA,
|
||||
};
|
||||
@ -85,8 +85,11 @@ pub(super) fn context(request: ContextRequest) -> Result<Value, String> {
|
||||
}
|
||||
};
|
||||
|
||||
let selected = select_holding(&holdings, token_id);
|
||||
let mut row = json!({
|
||||
let options = holding_options(&holdings, token_id);
|
||||
let total_balance = options.iter().fold(0_u128, |total, holding| {
|
||||
total.saturating_add(holding.balance)
|
||||
});
|
||||
let row = json!({
|
||||
"definitionId": token_id.to_string(),
|
||||
"name": name,
|
||||
"metadataId": metadata_id.map(|id| id.to_string()),
|
||||
@ -98,17 +101,22 @@ pub(super) fn context(request: ContextRequest) -> Result<Value, String> {
|
||||
"status": "available",
|
||||
"code": "available",
|
||||
"sources": sources,
|
||||
"balanceRaw": total_balance.to_string(),
|
||||
"holdings": options.into_iter().map(|holding| json!({
|
||||
"holdingId": holding.id.to_string(),
|
||||
"balanceRaw": holding.balance.to_string(),
|
||||
})).collect::<Vec<_>>(),
|
||||
});
|
||||
if let Some(selected) = selected {
|
||||
row["holdingId"] = json!(selected.id.to_string());
|
||||
row["balanceRaw"] = json!(selected.balance.to_string());
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
rows.sort_by(|left, right| {
|
||||
let left_holding = left.get("holdingId").is_some();
|
||||
let right_holding = right.get("holdingId").is_some();
|
||||
let left_holding = left["holdings"]
|
||||
.as_array()
|
||||
.is_some_and(|rows| !rows.is_empty());
|
||||
let right_holding = right["holdings"]
|
||||
.as_array()
|
||||
.is_some_and(|rows| !rows.is_empty());
|
||||
right_holding.cmp(&left_holding).then_with(|| {
|
||||
left["definitionId"]
|
||||
.as_str()
|
||||
|
||||
@ -4,7 +4,7 @@ use nssa_core::{
|
||||
};
|
||||
use token_core::TokenHolding;
|
||||
|
||||
use crate::account::{decode_account, AccountRead};
|
||||
use crate::account::{decode_account, parse_base58_id, AccountRead};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct SelectedHolding {
|
||||
@ -51,14 +51,24 @@ pub(super) fn decode_fungible_holding(
|
||||
pub(super) fn select_holding(
|
||||
holdings: &[SelectedHolding],
|
||||
definition_id: AccountId,
|
||||
requested_id: Option<&str>,
|
||||
) -> Option<SelectedHolding> {
|
||||
let requested_id = parse_base58_id(requested_id?, "holding id").ok()?;
|
||||
holdings
|
||||
.iter()
|
||||
.filter(|holding| holding.definition_id == definition_id)
|
||||
.max_by(|left, right| {
|
||||
left.balance
|
||||
.cmp(&right.balance)
|
||||
.then_with(|| right.id.cmp(&left.id))
|
||||
})
|
||||
.find(|holding| holding.definition_id == definition_id && holding.id == requested_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(super) fn holding_options(
|
||||
holdings: &[SelectedHolding],
|
||||
definition_id: AccountId,
|
||||
) -> Vec<SelectedHolding> {
|
||||
let mut options = holdings
|
||||
.iter()
|
||||
.filter(|holding| holding.definition_id == definition_id)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
options.sort_by_key(|holding| holding.id);
|
||||
options
|
||||
}
|
||||
|
||||
@ -20,15 +20,15 @@ mod tests;
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
pub use request::{
|
||||
ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest,
|
||||
QuoteRequest, TokenIdsRequest,
|
||||
ConfigIdRequest, ContextRequest, NormalizeAccountRpcRequest, PairIdsRequest, PairSnapshot,
|
||||
PlanRequest, PositionRequest, QuoteRequest, TokenIdsRequest,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
pub use crate::account::{AccountRead, WalletAccount};
|
||||
|
||||
/// Schema identifier expected by position quote and plan requests.
|
||||
pub const NEW_POSITION_SCHEMA: &str = "new-position.v1";
|
||||
pub const NEW_POSITION_SCHEMA: &str = "new-position.v2";
|
||||
|
||||
pub(crate) const SCHEMA: &str = NEW_POSITION_SCHEMA;
|
||||
|
||||
@ -95,3 +95,8 @@ pub fn quote(request: QuoteRequest) -> AmmResult {
|
||||
pub fn plan(request: PlanRequest) -> AmmResult {
|
||||
plan::plan(request).map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Converts one raw sequencer `getAccount` response into precision-safe account-read JSON.
|
||||
pub fn normalize_account_rpc(request: NormalizeAccountRpcRequest) -> AmmResult {
|
||||
crate::account::normalize_account_rpc(request).map_err(Into::into)
|
||||
}
|
||||
|
||||
@ -77,6 +77,7 @@ pub(super) fn plan(input: PlanRequest) -> Result<Value, String> {
|
||||
return Ok(plan_error("transaction_deadline_expired"));
|
||||
}
|
||||
let NewPositionPlan { accounts, branch } = plan;
|
||||
let affected_account_ids = accounts.writable_account_ids(fresh_lp)?;
|
||||
let (account_ids, signing_requirements) = accounts.wallet_args(fresh_lp)?;
|
||||
let instruction = match branch {
|
||||
QuoteBranch::Missing { amount_a, amount_b } => {
|
||||
@ -116,6 +117,10 @@ pub(super) fn plan(input: PlanRequest) -> Result<Value, String> {
|
||||
"status": "ready",
|
||||
"programId": input.amm_program_id,
|
||||
"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,
|
||||
"instruction": instruction,
|
||||
"deadlineMs": deadline.to_string(),
|
||||
|
||||
@ -193,6 +193,23 @@ impl AccountPlan {
|
||||
}
|
||||
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 {
|
||||
|
||||
@ -17,7 +17,7 @@ use super::{
|
||||
commitment::{QuoteCommitment, RequestCommitment},
|
||||
context::fungible_definition,
|
||||
funding::{funding_commitments, funding_issues, hash_quote},
|
||||
holding::{decode_fungible_holding, select_holding, wallet_holdings},
|
||||
holding::{decode_fungible_holding, holding_options, select_holding, wallet_holdings},
|
||||
pair::{derive_pair, is_canonical_pair, PairIds},
|
||||
position::{
|
||||
AccountPlan, AccountPlanHoldings, EvaluatedQuote, NewPositionPlan, QuoteBranch,
|
||||
@ -213,9 +213,18 @@ fn compute_missing_quote(
|
||||
let expected_lp = initial_lp - MINIMUM_LIQUIDITY;
|
||||
|
||||
let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program);
|
||||
let holding_a = select_holding(&holdings, pair.token_a);
|
||||
let holding_b = select_holding(&holdings, pair.token_b);
|
||||
let funding = funding_issues(
|
||||
let holding_a = select_holding(
|
||||
&holdings,
|
||||
pair.token_a,
|
||||
input.request.holding_a_id.as_deref(),
|
||||
);
|
||||
let holding_b = select_holding(
|
||||
&holdings,
|
||||
pair.token_b,
|
||||
input.request.holding_b_id.as_deref(),
|
||||
);
|
||||
let mut funding = holding_selection_issues(input, pair, &holdings, &holding_a, &holding_b);
|
||||
funding.extend(funding_issues(
|
||||
input.snapshot.wallet_available,
|
||||
pair,
|
||||
&holding_a,
|
||||
@ -223,7 +232,7 @@ fn compute_missing_quote(
|
||||
&holding_b,
|
||||
amount_b,
|
||||
["amountARaw", "amountBRaw"],
|
||||
);
|
||||
));
|
||||
let can_submit = funding.is_empty();
|
||||
let mut account_plan = missing_account_plan(
|
||||
input,
|
||||
@ -440,11 +449,31 @@ fn compute_active_quote(
|
||||
return Ok(error);
|
||||
}
|
||||
let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program);
|
||||
let holding_a = select_holding(&holdings, pair.token_a);
|
||||
let holding_b = select_holding(&holdings, pair.token_b);
|
||||
let lp_holding = select_holding(&holdings, pair.lp_definition);
|
||||
let requires_fresh_lp = lp_holding.is_none();
|
||||
let funding = funding_issues(
|
||||
let holding_a = select_holding(
|
||||
&holdings,
|
||||
pair.token_a,
|
||||
input.request.holding_a_id.as_deref(),
|
||||
);
|
||||
let holding_b = select_holding(
|
||||
&holdings,
|
||||
pair.token_b,
|
||||
input.request.holding_b_id.as_deref(),
|
||||
);
|
||||
let lp_options = holding_options(&holdings, pair.lp_definition);
|
||||
let lp_holding = if input.request.create_fresh_lp {
|
||||
None
|
||||
} else {
|
||||
select_holding(
|
||||
&holdings,
|
||||
pair.lp_definition,
|
||||
input.request.lp_holding_id.as_deref(),
|
||||
)
|
||||
};
|
||||
let lp_destination_selected =
|
||||
input.request.create_fresh_lp || lp_holding.is_some() || lp_options.is_empty();
|
||||
let requires_fresh_lp = input.request.create_fresh_lp || lp_options.is_empty();
|
||||
let mut funding = holding_selection_issues(input, pair, &holdings, &holding_a, &holding_b);
|
||||
funding.extend(funding_issues(
|
||||
input.snapshot.wallet_available,
|
||||
pair,
|
||||
&holding_a,
|
||||
@ -452,7 +481,15 @@ fn compute_active_quote(
|
||||
&holding_b,
|
||||
actual_b,
|
||||
["maxAmountARaw", "maxAmountBRaw"],
|
||||
);
|
||||
));
|
||||
if !lp_destination_selected {
|
||||
funding.push(issue(
|
||||
"lp_destination_required",
|
||||
"Select an LP token destination.",
|
||||
&["lpHoldingId", "createFreshLp"],
|
||||
json!({ "available": lp_options.len() }),
|
||||
));
|
||||
}
|
||||
let can_submit = funding.is_empty();
|
||||
let warnings = if slippage_bps >= HIGH_SLIPPAGE_BPS {
|
||||
vec![issue(
|
||||
@ -532,6 +569,12 @@ fn compute_active_quote(
|
||||
"minimumLpRaw": minimum_lp.to_string(),
|
||||
"initialPriceRealRaw": spot_price_q64_64(reserve_a, reserve_b).to_string(),
|
||||
"requiresFreshLp": requires_fresh_lp,
|
||||
"lpDestinationRequired": !lp_destination_selected,
|
||||
"lpHoldingOptions": lp_options.iter().map(|holding| json!({
|
||||
"holdingId": holding.id.to_string(),
|
||||
"balanceRaw": holding.balance.to_string(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"selectedLpHoldingId": lp_holding.as_ref().map(|holding| holding.id.to_string()),
|
||||
"accountPreview": preview,
|
||||
"errors": funding,
|
||||
"warnings": warnings,
|
||||
@ -556,6 +599,52 @@ fn compute_active_quote(
|
||||
}))
|
||||
}
|
||||
|
||||
fn holding_selection_issues(
|
||||
input: &QuoteRequest,
|
||||
pair: PairIds,
|
||||
holdings: &[super::holding::SelectedHolding],
|
||||
holding_a: &Option<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(
|
||||
input: &QuoteRequest,
|
||||
pair: PairIds,
|
||||
|
||||
@ -2,6 +2,13 @@ use serde::Deserialize;
|
||||
|
||||
use crate::account::AccountRead;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NormalizeAccountRpcRequest {
|
||||
pub account_id: String,
|
||||
pub response: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigIdRequest {
|
||||
@ -60,6 +67,14 @@ pub struct PositionRequest {
|
||||
pub token_b_id: String,
|
||||
pub fee_bps: u32,
|
||||
#[serde(default)]
|
||||
pub holding_a_id: Option<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>,
|
||||
#[serde(default)]
|
||||
pub amount_b_raw: Option<String>,
|
||||
|
||||
@ -144,6 +144,10 @@ fn request(pair: PairIds) -> PositionRequest {
|
||||
token_a_id: pair.token_a.to_string(),
|
||||
token_b_id: pair.token_b.to_string(),
|
||||
fee_bps: 30,
|
||||
holding_a_id: Some(AccountId::new([61; 32]).to_string()),
|
||||
holding_b_id: Some(AccountId::new([62; 32]).to_string()),
|
||||
lp_holding_id: None,
|
||||
create_fresh_lp: false,
|
||||
amount_a_raw: None,
|
||||
amount_b_raw: None,
|
||||
max_amount_a_raw: None,
|
||||
@ -244,8 +248,16 @@ fn account_plan_sources_follow_pool_branch() {
|
||||
let pair = scenario.pair;
|
||||
let input = scenario.quote_request();
|
||||
let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program);
|
||||
let holding_a = select_holding(&holdings, pair.token_a);
|
||||
let holding_b = select_holding(&holdings, pair.token_b);
|
||||
let holding_a = select_holding(
|
||||
&holdings,
|
||||
pair.token_a,
|
||||
input.request.holding_a_id.as_deref(),
|
||||
);
|
||||
let holding_b = select_holding(
|
||||
&holdings,
|
||||
pair.token_b,
|
||||
input.request.holding_b_id.as_deref(),
|
||||
);
|
||||
|
||||
let missing = missing_account_plan(
|
||||
&input,
|
||||
@ -337,7 +349,7 @@ fn minimum_pair_is_minimal_on_price_base_side() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highest_balance_holding_wins_then_lowest_id() {
|
||||
fn holding_selection_uses_requested_id() {
|
||||
let definition = AccountId::new([9; 32]);
|
||||
let holding = |id: u8, balance| SelectedHolding {
|
||||
id: AccountId::new([id; 32]),
|
||||
@ -354,9 +366,10 @@ fn highest_balance_holding_wins_then_lowest_id() {
|
||||
let selected = select_holding(
|
||||
&[holding(4, 10), holding(2, 20), holding(1, 20)],
|
||||
definition,
|
||||
Some(&AccountId::new([4; 32]).to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(selected.id, AccountId::new([1; 32]));
|
||||
assert_eq!(selected.id, AccountId::new([4; 32]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -511,6 +524,43 @@ fn context_selects_tokens_without_holdings() {
|
||||
assert!(value["tokens"][0].get("holdingId").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_lists_all_holdings_without_preselecting_one() {
|
||||
let token_id = AccountId::new([3; 32]);
|
||||
let first = AccountId::new([4; 32]);
|
||||
let second = AccountId::new([5; 32]);
|
||||
let config_id = compute_config_pda(AMM_PROGRAM);
|
||||
let value = context(ContextRequest {
|
||||
network_id: String::from("testnet"),
|
||||
network_fingerprint: String::from("block10:abc"),
|
||||
amm_program_id: amm_program_id(),
|
||||
wallet_available: true,
|
||||
config: account_read(config_id, &config_account()),
|
||||
wallet_accounts: vec![
|
||||
account_read(second, &token_holding(token_id, 20)),
|
||||
account_read(first, &token_holding(token_id, 10)),
|
||||
],
|
||||
token_definitions: vec![account_read(
|
||||
token_id,
|
||||
&token_definition("Token", 1_000_000),
|
||||
)],
|
||||
configured_token_ids: vec![account_id_hex(token_id)],
|
||||
recent_token_ids: Vec::new(),
|
||||
resolved_token_ids: Vec::new(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(value["tokens"][0].get("holdingId").is_none());
|
||||
assert_eq!(value["tokens"][0]["balanceRaw"], "30");
|
||||
assert_eq!(
|
||||
value["tokens"][0]["holdings"],
|
||||
json!([
|
||||
{ "holdingId": first.to_string(), "balanceRaw": "10" },
|
||||
{ "holdingId": second.to_string(), "balanceRaw": "20" },
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_pool_snapshot_defaults_remain_real_accounts() {
|
||||
let id = AccountId::new([5; 32]);
|
||||
@ -529,6 +579,8 @@ fn missing_pool_quote_and_plan_use_current_account_order() {
|
||||
assert_eq!(quote_value["canSubmit"], true);
|
||||
assert_eq!(quote_value["accountPreview"].as_array().unwrap().len(), 11);
|
||||
let quote_hash = quote_value["quoteHash"].as_str().unwrap().to_owned();
|
||||
let config_id = scenario.pair.config;
|
||||
let clock_id = scenario.pair.clock;
|
||||
|
||||
let fresh_lp = AccountId::new([63; 32]);
|
||||
let plan_value = scenario.plan(quote_hash, Some(default_read(fresh_lp)));
|
||||
@ -538,6 +590,10 @@ fn missing_pool_quote_and_plan_use_current_account_order() {
|
||||
assert_eq!(plan_value["signingRequirements"][6], true);
|
||||
assert_eq!(plan_value["signingRequirements"][7], true);
|
||||
assert_eq!(plan_value["signingRequirements"][8], true);
|
||||
let affected = plan_value["affectedAccountIds"].as_array().unwrap();
|
||||
assert_eq!(affected.len(), 9);
|
||||
assert!(!affected.contains(&json!(account_id_hex(config_id))));
|
||||
assert!(!affected.contains(&json!(account_id_hex(clock_id))));
|
||||
assert_preview_matches_plan("e_value, &plan_value, Some(fresh_lp));
|
||||
}
|
||||
|
||||
@ -661,6 +717,15 @@ fn active_pool_quote_uses_ratio_and_existing_lp_holding() {
|
||||
scenario.request.max_amount_b_raw = Some(String::from("3000"));
|
||||
scenario.request.slippage_bps = Some(50);
|
||||
|
||||
let awaiting_destination = scenario.quote();
|
||||
assert_eq!(awaiting_destination["lpDestinationRequired"], true);
|
||||
assert_eq!(awaiting_destination["canSubmit"], false);
|
||||
assert_eq!(
|
||||
awaiting_destination["errors"][0]["code"],
|
||||
"lp_destination_required"
|
||||
);
|
||||
scenario.request.lp_holding_id = Some(lp_holding.to_string());
|
||||
|
||||
let quote_value = scenario.quote();
|
||||
assert_eq!(quote_value["poolStatus"], "active_pool");
|
||||
assert_eq!(quote_value["actualAmountARaw"], "1000");
|
||||
|
||||
@ -6,8 +6,8 @@ use std::{
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
|
||||
use crate::api::{
|
||||
self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest,
|
||||
QuoteRequest, TokenIdsRequest,
|
||||
self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, NormalizeAccountRpcRequest,
|
||||
PairIdsRequest, PlanRequest, QuoteRequest, TokenIdsRequest,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
@ -106,6 +106,11 @@ pub extern "C" fn amm_plan(request_json: *const c_char) -> *mut c_char {
|
||||
call::<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.
|
||||
///
|
||||
/// # Safety
|
||||
|
||||
@ -9,5 +9,5 @@ fn direct_rust_api_does_not_require_ffi() {
|
||||
|
||||
assert_eq!(response["status"], "ok");
|
||||
assert!(response["configId"].is_string());
|
||||
assert_eq!(NEW_POSITION_SCHEMA, "new-position.v1");
|
||||
assert_eq!(NEW_POSITION_SCHEMA, "new-position.v2");
|
||||
}
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
ColumnLayout {
|
||||
@ -6,6 +9,8 @@ ColumnLayout {
|
||||
|
||||
property var snapshot: ({})
|
||||
|
||||
signal snapshotEdited(var snapshot)
|
||||
|
||||
spacing: 8
|
||||
|
||||
function actionText(instruction) {
|
||||
@ -22,6 +27,40 @@ ColumnLayout {
|
||||
value: root.snapshot.pairText || "-"
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 5
|
||||
visible: root.snapshot.instruction === "AddLiquidity"
|
||||
|
||||
Text {
|
||||
text: qsTr("LP destination")
|
||||
color: "#a1a1aa"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
ComboBox {
|
||||
id: lpDestinationPicker
|
||||
|
||||
objectName: "lpDestinationPicker"
|
||||
Layout.fillWidth: true
|
||||
model: root.destinationRows()
|
||||
enabled: model.length > 1
|
||||
currentIndex: root.destinationIndex()
|
||||
displayText: currentIndex >= 0
|
||||
? model[currentIndex].label : qsTr("Select destination")
|
||||
Accessible.name: qsTr("LP token destination")
|
||||
onActivated: function(index) {
|
||||
root.selectDestination(model[index])
|
||||
}
|
||||
|
||||
delegate: ItemDelegate {
|
||||
required property var modelData
|
||||
width: lpDestinationPicker.width
|
||||
text: modelData.label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Action")
|
||||
@ -48,4 +87,55 @@ ColumnLayout {
|
||||
label: qsTr("Expected LP")
|
||||
value: root.snapshot.expectedLpText || "-"
|
||||
}
|
||||
|
||||
function destinationRows() {
|
||||
var rows = []
|
||||
var options = root.snapshot.lpHoldingOptions || []
|
||||
for (var i = 0; i < options.length; ++i) {
|
||||
var id = String(options[i].holdingId || "")
|
||||
rows.push({
|
||||
"holdingId": id,
|
||||
"createFresh": false,
|
||||
"label": qsTr("%1 · balance %2")
|
||||
.arg(root.shortId(id))
|
||||
.arg(String(options[i].balanceRaw || "0"))
|
||||
})
|
||||
}
|
||||
rows.push({
|
||||
"holdingId": "",
|
||||
"createFresh": true,
|
||||
"label": qsTr("Create new LP holding")
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
function destinationIndex() {
|
||||
var rows = root.destinationRows()
|
||||
for (var i = 0; i < rows.length; ++i) {
|
||||
if (rows[i].createFresh === (root.snapshot.createFreshLp === true)
|
||||
&& (rows[i].createFresh
|
||||
|| rows[i].holdingId === root.snapshot.selectedLpHoldingId)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function selectDestination(destination) {
|
||||
var next = JSON.parse(JSON.stringify(root.snapshot || ({})))
|
||||
next.request = next.request || ({})
|
||||
delete next.request.lpHoldingId
|
||||
next.request.createFreshLp = destination.createFresh === true
|
||||
if (!destination.createFresh)
|
||||
next.request.lpHoldingId = destination.holdingId
|
||||
next.selectedLpHoldingId = destination.holdingId
|
||||
next.createFreshLp = destination.createFresh === true
|
||||
next.lpDestinationRequired = false
|
||||
next.quoteReady = false
|
||||
root.snapshotEdited(next)
|
||||
}
|
||||
|
||||
function shortId(value) {
|
||||
return value.length > 14 ? value.slice(0, 7) + "…" + value.slice(-5) : value
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,6 +23,8 @@ AmmActionCard {
|
||||
property var flowState: ({})
|
||||
property string selectedTokenAId: ""
|
||||
property string selectedTokenBId: ""
|
||||
property string selectedHoldingAId: ""
|
||||
property string selectedHoldingBId: ""
|
||||
property int selectedFeeBps: 30
|
||||
property int slippageBps: 50
|
||||
property string amountA: ""
|
||||
@ -47,6 +49,9 @@ AmmActionCard {
|
||||
readonly property bool contextLoading: root.flowState.contextLoading === true
|
||||
readonly property bool quoteLoading: root.flowState.quoteLoading === true
|
||||
readonly property bool submitting: root.flowState.submitting === true
|
||||
readonly property bool walletCanSubmit: root.flowState.walletCanSubmit === true
|
||||
readonly property string walletSyncStatus: String(
|
||||
root.flowState.walletSyncStatus || "closed")
|
||||
readonly property bool quoteStale: root.flowState.quoteStale === true
|
||||
readonly property bool poolCreationPending: root.flowState.poolCreationPending === true
|
||||
readonly property string submitError: root.flowState.errorCode
|
||||
@ -65,6 +70,12 @@ AmmActionCard {
|
||||
? root.newPositionContext.feeTiers : []
|
||||
readonly property var tokenA: root.tokenById(root.selectedTokenAId)
|
||||
readonly property var tokenB: root.tokenById(root.selectedTokenBId)
|
||||
readonly property var holdingsA: root.tokenHoldings(root.tokenA)
|
||||
readonly property var holdingsB: root.tokenHoldings(root.tokenB)
|
||||
readonly property var holdingA: root.holdingById(root.holdingsA,
|
||||
root.selectedHoldingAId)
|
||||
readonly property var holdingB: root.holdingById(root.holdingsB,
|
||||
root.selectedHoldingBId)
|
||||
readonly property int decimalsA: 0
|
||||
readonly property int decimalsB: 0
|
||||
readonly property bool displayIsCanonical: root.selectedTokenAId.length > 0
|
||||
@ -87,9 +98,13 @@ AmmActionCard {
|
||||
&& root.selectedTokenBId.length > 0
|
||||
&& root.selectedTokenAId !== root.selectedTokenBId
|
||||
readonly property bool resolvingToken: root.resolvingTokenId.length > 0
|
||||
readonly property bool canConfirm: root.quotePayload.schema === "new-position.v1"
|
||||
readonly property bool lpDestinationRequired: root.quotePayload.status === "ok"
|
||||
&& root.quotePayload.lpDestinationRequired === true
|
||||
&& root.onlyLpDestinationBlocks()
|
||||
readonly property bool canConfirm: root.quotePayload.schema === "new-position.v2"
|
||||
&& root.quotePayload.status === "ok"
|
||||
&& root.quotePayload.canSubmit === true
|
||||
&& (root.quotePayload.canSubmit === true
|
||||
|| root.lpDestinationRequired)
|
||||
&& root.quoteMatchesPair()
|
||||
&& String(root.quotePayload.quoteHash || "").length > 0
|
||||
&& !root.contextLoading
|
||||
@ -97,6 +112,7 @@ AmmActionCard {
|
||||
&& !root.quoteStale
|
||||
&& !root.submitting
|
||||
&& !root.poolCreationPending
|
||||
&& root.walletCanSubmit
|
||||
|
||||
signal quoteRequested(bool immediate, var quoteRequest)
|
||||
signal confirmationRequested(var snapshot)
|
||||
@ -116,6 +132,7 @@ AmmActionCard {
|
||||
root.finishTokenResolution()
|
||||
else
|
||||
root.reconcileSelection()
|
||||
root.reconcileHoldings()
|
||||
}
|
||||
onQuotePayloadChanged: {
|
||||
if (root.quoteStale)
|
||||
@ -236,7 +253,8 @@ AmmActionCard {
|
||||
theme: root.theme
|
||||
text: root.amountA
|
||||
label: qsTr("Token A amount")
|
||||
balance: root.contextLoading ? "" : root.balanceText(root.tokenA, root.decimalsA)
|
||||
balance: root.contextLoading ? "" : root.holdingBalanceText(root.holdingA,
|
||||
root.decimalsA)
|
||||
helperText: root.missingPool && !root.compact
|
||||
? root.minimumAmountText("A") : ""
|
||||
errorText: root.formErrorText()
|
||||
@ -246,6 +264,9 @@ AmmActionCard {
|
||||
tokenData: root.tokenA.definitionId ? root.tokenA : null
|
||||
tokens: root.tokens
|
||||
selectedTokenId: root.selectedTokenAId
|
||||
holdings: root.holdingsA
|
||||
selectedHoldingId: root.selectedHoldingAId
|
||||
holdingSelectionEnabled: !root.submitting
|
||||
tokenInvalid: root.tokenHasError("A")
|
||||
tokenSelectionEnabled: !root.contextLoading && !root.submitting
|
||||
adjustment: root.missingPool ? priceAmountAAdjustment : null
|
||||
@ -268,6 +289,9 @@ AmmActionCard {
|
||||
onMaxClicked: root.useMaximum()
|
||||
onTokenSelected: function(tokenId) { root.resolveToken("A", tokenId) }
|
||||
onTokenEntered: function(value) { root.resolveToken("A", value) }
|
||||
onHoldingSelected: function(holdingId) {
|
||||
root.selectHolding("A", holdingId)
|
||||
}
|
||||
}
|
||||
|
||||
AmmPairSeparator {
|
||||
@ -285,7 +309,8 @@ AmmActionCard {
|
||||
theme: root.theme
|
||||
text: root.amountB
|
||||
label: qsTr("Token B amount")
|
||||
balance: root.contextLoading ? "" : root.balanceText(root.tokenB, root.decimalsB)
|
||||
balance: root.contextLoading ? "" : root.holdingBalanceText(root.holdingB,
|
||||
root.decimalsB)
|
||||
helperText: root.missingPool && !root.compact
|
||||
? root.minimumAmountText("B") : ""
|
||||
invalid: root.fieldHasError("amountB")
|
||||
@ -294,6 +319,9 @@ AmmActionCard {
|
||||
tokenData: root.tokenB.definitionId ? root.tokenB : null
|
||||
tokens: root.tokens
|
||||
selectedTokenId: root.selectedTokenBId
|
||||
holdings: root.holdingsB
|
||||
selectedHoldingId: root.selectedHoldingBId
|
||||
holdingSelectionEnabled: !root.submitting
|
||||
tokenInvalid: root.tokenHasError("B")
|
||||
tokenSelectionEnabled: !root.contextLoading && !root.submitting
|
||||
adjustment: root.missingPool ? priceAmountBAdjustment : null
|
||||
@ -316,6 +344,9 @@ AmmActionCard {
|
||||
onMaxClicked: root.useMaximum()
|
||||
onTokenSelected: function(tokenId) { root.resolveToken("B", tokenId) }
|
||||
onTokenEntered: function(value) { root.resolveToken("B", value) }
|
||||
onHoldingSelected: function(holdingId) {
|
||||
root.selectHolding("B", holdingId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -601,6 +632,10 @@ AmmActionCard {
|
||||
text: root.submitting
|
||||
? qsTr("Submitting…")
|
||||
: root.contextLoading ? qsTr("Loading…")
|
||||
: !root.walletCanSubmit && root.walletSyncStatus === "syncing"
|
||||
? qsTr("Syncing wallet…")
|
||||
: !root.walletCanSubmit && root.walletSyncStatus === "opening"
|
||||
? qsTr("Opening wallet…")
|
||||
: root.poolCreationPending ? qsTr("Waiting for pool")
|
||||
: root.missingPool ? qsTr("Create pool") : qsTr("Add liquidity")
|
||||
enabled: root.canConfirm
|
||||
@ -700,6 +735,40 @@ AmmActionCard {
|
||||
return root.emptyToken
|
||||
}
|
||||
|
||||
function tokenHoldings(token) {
|
||||
return token && token.holdings ? token.holdings : []
|
||||
}
|
||||
|
||||
function holdingById(holdings, holdingId) {
|
||||
for (var i = 0; i < holdings.length; ++i) {
|
||||
if (String(holdings[i].holdingId || "") === holdingId)
|
||||
return holdings[i]
|
||||
}
|
||||
return ({})
|
||||
}
|
||||
|
||||
function reconcileHoldings() {
|
||||
root.selectedHoldingAId = root.reconciledHoldingId(
|
||||
root.holdingsA, root.selectedHoldingAId)
|
||||
root.selectedHoldingBId = root.reconciledHoldingId(
|
||||
root.holdingsB, root.selectedHoldingBId)
|
||||
}
|
||||
|
||||
function reconciledHoldingId(holdings, selectedId) {
|
||||
if (holdings.length === 1)
|
||||
return String(holdings[0].holdingId || "")
|
||||
return root.holdingById(holdings, selectedId).holdingId ? selectedId : ""
|
||||
}
|
||||
|
||||
function selectHolding(side, holdingId) {
|
||||
if (side === "A")
|
||||
root.selectedHoldingAId = holdingId
|
||||
else
|
||||
root.selectedHoldingBId = holdingId
|
||||
root.noteDraftChanged()
|
||||
root.requestQuote(true)
|
||||
}
|
||||
|
||||
function selectableTokenIds() {
|
||||
var result = []
|
||||
for (var i = 0; i < root.tokens.length; ++i) {
|
||||
@ -823,6 +892,9 @@ AmmActionCard {
|
||||
root.selectedTokenAId = root.selectedTokenBId
|
||||
root.selectedTokenBId = tokenId
|
||||
}
|
||||
root.selectedHoldingAId = ""
|
||||
root.selectedHoldingBId = ""
|
||||
root.reconcileHoldings()
|
||||
root.resetPairDraft()
|
||||
}
|
||||
|
||||
@ -830,6 +902,9 @@ AmmActionCard {
|
||||
var tokenId = root.selectedTokenAId
|
||||
root.selectedTokenAId = root.selectedTokenBId
|
||||
root.selectedTokenBId = tokenId
|
||||
var holdingId = root.selectedHoldingAId
|
||||
root.selectedHoldingAId = root.selectedHoldingBId
|
||||
root.selectedHoldingBId = holdingId
|
||||
var amount = root.amountA
|
||||
root.amountA = root.amountB
|
||||
root.amountB = amount
|
||||
@ -858,7 +933,7 @@ AmmActionCard {
|
||||
}
|
||||
|
||||
function acceptPoolActivation(quote) {
|
||||
if (!quote || quote.schema !== "new-position.v1"
|
||||
if (!quote || quote.schema !== "new-position.v2"
|
||||
|| quote.status !== "ok"
|
||||
|| quote.poolStatus !== "active_pool"
|
||||
|| !root.quoteMatchesSelectedPair(quote)) {
|
||||
@ -985,6 +1060,10 @@ AmmActionCard {
|
||||
}
|
||||
|
||||
var request = root.pairRequest()
|
||||
if (root.holdingsA.length > 0 && root.selectedHoldingAId.length === 0)
|
||||
errors.push(root.localIssue("holding_selection_required", ["holdingAId"]))
|
||||
if (root.holdingsB.length > 0 && root.selectedHoldingBId.length === 0)
|
||||
errors.push(root.localIssue("holding_selection_required", ["holdingBId"]))
|
||||
|
||||
if (root.activePool) {
|
||||
var parsedA = AmountMath.parseHuman(root.amountA, root.decimalsA)
|
||||
@ -1076,14 +1155,23 @@ AmmActionCard {
|
||||
}
|
||||
|
||||
function pairRequest() {
|
||||
return {
|
||||
"schema": "new-position.v1",
|
||||
var request = {
|
||||
"schema": "new-position.v2",
|
||||
"tokenAId": root.displayIsCanonical
|
||||
? root.selectedTokenAId : root.selectedTokenBId,
|
||||
"tokenBId": root.displayIsCanonical
|
||||
? root.selectedTokenBId : root.selectedTokenAId,
|
||||
"feeBps": root.selectedFeeBps
|
||||
}
|
||||
var holdingAId = root.displayIsCanonical
|
||||
? root.selectedHoldingAId : root.selectedHoldingBId
|
||||
var holdingBId = root.displayIsCanonical
|
||||
? root.selectedHoldingBId : root.selectedHoldingAId
|
||||
if (holdingAId.length > 0)
|
||||
request.holdingAId = holdingAId
|
||||
if (holdingBId.length > 0)
|
||||
request.holdingBId = holdingBId
|
||||
return request
|
||||
}
|
||||
|
||||
function requestQuote(immediate) {
|
||||
@ -1095,7 +1183,11 @@ AmmActionCard {
|
||||
}
|
||||
|
||||
function probeRaw(token, decimals) {
|
||||
var balance = String(token.balanceRaw || "0")
|
||||
var holdings = root.tokenHoldings(token)
|
||||
var selectedId = token.definitionId === root.tokenA.definitionId
|
||||
? root.selectedHoldingAId : root.selectedHoldingBId
|
||||
var selected = root.holdingById(holdings, selectedId)
|
||||
var balance = String(selected.balanceRaw || "0")
|
||||
var simulated = AmountMath.multiply(AmountMath.pow10(decimals), "1000")
|
||||
if (AmountMath.isUnsigned(balance) && AmountMath.compare(balance, simulated) > 0)
|
||||
return balance
|
||||
@ -1195,6 +1287,9 @@ AmmActionCard {
|
||||
"invalid_amount_precision": qsTr("Token amounts must use whole raw units."),
|
||||
"invalid_raw_amount": qsTr("Value is outside the supported range."),
|
||||
"amount_exceeds_balance": qsTr("Amount exceeds the selected holding balance."),
|
||||
"holding_selection_required": qsTr("Select a wallet holding for this token."),
|
||||
"invalid_holding_selection": qsTr("Selected wallet holding is unavailable."),
|
||||
"lp_destination_required": qsTr("Select where LP tokens should be deposited."),
|
||||
"amount_too_low": qsTr("Value is too low for this pool."),
|
||||
"invalid_token_id": qsTr("Enter a valid base58 TokenDefinition ID."),
|
||||
"deposit_ratio_mismatch": qsTr("Deposit amounts must match the initial price."),
|
||||
@ -1203,6 +1298,7 @@ AmmActionCard {
|
||||
"fee_tier_mismatch": qsTr("Select the existing pool fee tier."),
|
||||
"no_wallet": qsTr("Connect a wallet to submit this position."),
|
||||
"wallet_unavailable": qsTr("Wallet is unavailable."),
|
||||
"wallet_syncing": qsTr("Wallet is still syncing. Review the quote while it finishes."),
|
||||
"wallet_submission_failed": qsTr("Wallet submission failed. Review and retry manually."),
|
||||
"signature_rejected": qsTr("Wallet approval was rejected."),
|
||||
"quote_changed": qsTr("Pool or wallet state changed. Review the refreshed quote."),
|
||||
@ -1217,6 +1313,7 @@ AmmActionCard {
|
||||
"network_unknown": qsTr("Network identity could not be verified. Refresh and retry."),
|
||||
"network_mismatch": qsTr("Connected wallet uses a different network."),
|
||||
"config_missing": qsTr("Network configuration is missing."),
|
||||
"sequencer_config_required": qsTr("Sequencer endpoint is missing from wallet_config.json."),
|
||||
"account_read_failed": qsTr("Required on-chain state could not be read."),
|
||||
"pool_unavailable": qsTr("Pool state is unavailable."),
|
||||
"config_unavailable": qsTr("AMM configuration is unavailable."),
|
||||
@ -1264,8 +1361,8 @@ AmmActionCard {
|
||||
var reserveB = root.poolReserve("B")
|
||||
if (!reserveA || !reserveB || reserveA === "0" || reserveB === "0")
|
||||
return
|
||||
var balanceA = String(root.tokenA.balanceRaw || "0")
|
||||
var balanceB = String(root.tokenB.balanceRaw || "0")
|
||||
var balanceA = String(root.holdingA.balanceRaw || "0")
|
||||
var balanceB = String(root.holdingB.balanceRaw || "0")
|
||||
var fitA = AmountMath.mulDivFloor(balanceB, reserveA, reserveB)
|
||||
var rawA = AmountMath.compare(balanceA, fitA) < 0 ? balanceA : fitA
|
||||
var rawB = AmountMath.mulDivFloor(rawA, reserveB, reserveA)
|
||||
@ -1459,10 +1556,27 @@ AmmActionCard {
|
||||
"depositAText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A"),
|
||||
"depositBText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "B"),
|
||||
"expectedLpText": root.rawLpText(root.quotePayload.expectedLpRaw),
|
||||
"instruction": String(root.quotePayload.instruction || "")
|
||||
"instruction": String(root.quotePayload.instruction || ""),
|
||||
"lpHoldingOptions": root.quotePayload.lpHoldingOptions || [],
|
||||
"selectedLpHoldingId": String(root.quotePayload.selectedLpHoldingId || ""),
|
||||
"createFreshLp": root.quotePayload.requiresFreshLp === true
|
||||
&& root.quotePayload.lpDestinationRequired !== true,
|
||||
"lpDestinationRequired": root.quotePayload.lpDestinationRequired === true,
|
||||
"quoteReady": root.quotePayload.canSubmit === true
|
||||
}
|
||||
}
|
||||
|
||||
function onlyLpDestinationBlocks() {
|
||||
var errors = root.quotePayload.errors || []
|
||||
if (errors.length === 0)
|
||||
return false
|
||||
for (var i = 0; i < errors.length; ++i) {
|
||||
if (errors[i].code !== "lp_destination_required")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function shortTokenName(token) {
|
||||
if (token && token.name && token.name.length > 0)
|
||||
return token.name
|
||||
@ -1473,6 +1587,12 @@ AmmActionCard {
|
||||
return AmountMath.formatRaw(String(token.balanceRaw || "0"), decimals)
|
||||
}
|
||||
|
||||
function holdingBalanceText(holding, decimals) {
|
||||
if (!holding || holding.balanceRaw === undefined)
|
||||
return ""
|
||||
return AmountMath.formatRaw(String(holding.balanceRaw || "0"), decimals)
|
||||
}
|
||||
|
||||
function tokenBalanceDetail(token) {
|
||||
return qsTr("Available %1").arg(root.balanceText(token, 0))
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import "../shared"
|
||||
|
||||
@ -16,6 +18,9 @@ AmmTokenAmountSurface {
|
||||
property string selectedTokenId: ""
|
||||
property bool tokenInvalid: false
|
||||
property bool tokenSelectionEnabled: true
|
||||
property var holdings: []
|
||||
property string selectedHoldingId: ""
|
||||
property bool holdingSelectionEnabled: true
|
||||
property bool editPending: false
|
||||
property string pendingValue: ""
|
||||
property var disabledReasonForCode: function(code) {
|
||||
@ -31,13 +36,15 @@ AmmTokenAmountSurface {
|
||||
signal maxClicked
|
||||
signal tokenSelected(string tokenId)
|
||||
signal tokenEntered(string value)
|
||||
signal holdingSelected(string holdingId)
|
||||
|
||||
amount: root.text
|
||||
supportingText: root.helperText
|
||||
supportingActionText: root.showMaxButton ? qsTr("MAX") : ""
|
||||
accessory: tokenActions
|
||||
accessoryWidth: width < 360 ? 132 : 180
|
||||
accessoryHeight: root.balance.length > 0 ? 58 : 40
|
||||
accessoryHeight: root.holdings.length > 1 ? 88
|
||||
: root.balance.length > 0 ? 58 : 40
|
||||
|
||||
onAmountEdited: function(value) {
|
||||
root.pendingValue = value
|
||||
@ -68,17 +75,46 @@ AmmTokenAmountSurface {
|
||||
Component {
|
||||
id: tokenActions
|
||||
|
||||
AmmTokenAccessory {
|
||||
theme: root.theme
|
||||
enabled: root.tokenSelectionEnabled
|
||||
invalid: root.tokenInvalid
|
||||
hasToken: root.tokenData !== null
|
||||
tokenColor: root.tokenColor(root.tokenData)
|
||||
tokenLetter: root.tokenLetter(root.tokenData)
|
||||
tokenText: root.tokenText(root.tokenData)
|
||||
balance: root.balance
|
||||
accessibleName: qsTr("Select %1").arg(root.label)
|
||||
onClicked: tokenModal.open()
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
|
||||
AmmTokenAccessory {
|
||||
Layout.fillWidth: true
|
||||
theme: root.theme
|
||||
enabled: root.tokenSelectionEnabled
|
||||
invalid: root.tokenInvalid
|
||||
hasToken: root.tokenData !== null
|
||||
tokenColor: root.tokenColor(root.tokenData)
|
||||
tokenLetter: root.tokenLetter(root.tokenData)
|
||||
tokenText: root.tokenText(root.tokenData)
|
||||
balance: root.balance
|
||||
accessibleName: qsTr("Select %1").arg(root.label)
|
||||
onClicked: tokenModal.open()
|
||||
}
|
||||
|
||||
ComboBox {
|
||||
id: holdingPicker
|
||||
|
||||
objectName: "holdingPicker"
|
||||
Layout.fillWidth: true
|
||||
visible: root.holdings.length > 1
|
||||
enabled: root.holdingSelectionEnabled && root.holdings.length > 1
|
||||
model: root.holdings
|
||||
currentIndex: root.holdingIndex()
|
||||
displayText: currentIndex >= 0
|
||||
? root.holdingLabel(root.holdings[currentIndex])
|
||||
: qsTr("Select holding")
|
||||
Accessible.name: qsTr("Wallet holding for %1").arg(root.label)
|
||||
onActivated: function(index) {
|
||||
root.holdingSelected(String(root.holdings[index].holdingId || ""))
|
||||
}
|
||||
|
||||
delegate: ItemDelegate {
|
||||
required property var modelData
|
||||
width: holdingPicker.width
|
||||
text: root.holdingLabel(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -132,4 +168,20 @@ AmmTokenAmountSurface {
|
||||
var text = String(value || "")
|
||||
return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text
|
||||
}
|
||||
|
||||
function holdingIndex() {
|
||||
for (var i = 0; i < root.holdings.length; ++i) {
|
||||
if (String(root.holdings[i].holdingId || "") === root.selectedHoldingId)
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function holdingLabel(holding) {
|
||||
if (!holding)
|
||||
return qsTr("Select holding")
|
||||
return qsTr("%1 · %2")
|
||||
.arg(root.shortId(String(holding.holdingId || "")))
|
||||
.arg(String(holding.balanceRaw || "0"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -255,6 +255,10 @@ Item {
|
||||
function onQuoteRefreshRequested(immediate) {
|
||||
form.requestQuote(immediate)
|
||||
}
|
||||
|
||||
function onConfirmationQuoteReady(snapshot) {
|
||||
confirmationDialog.updateSnapshot(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
@ -269,8 +273,16 @@ Item {
|
||||
title: qsTr("Confirm new position")
|
||||
confirmText: qsTr("Submit")
|
||||
busy: newPositionFlow.submitting
|
||||
|| (opened && snapshot.quoteReady === false
|
||||
&& newPositionFlow.quoteLoading)
|
||||
confirmEnabled: snapshot.quoteReady === true
|
||||
&& newPositionFlow.walletCanSubmit
|
||||
summary: liquidityConfirmationSummary
|
||||
|
||||
onSummaryEdited: function(snapshot) {
|
||||
newPositionFlow.requoteConfirmation(snapshot)
|
||||
}
|
||||
|
||||
onConfirmed: function(snapshot) {
|
||||
newPositionFlow.confirm(snapshot)
|
||||
}
|
||||
|
||||
@ -7,19 +7,24 @@ QtObject {
|
||||
property var runtime: null
|
||||
property bool active: false
|
||||
|
||||
readonly property bool walletStateReady: root.backend !== null
|
||||
&& root.backend.walletStateReady === true
|
||||
readonly property var newPositionContext: root.walletStateReady
|
||||
readonly property bool backendReady: root.backend !== null
|
||||
readonly property bool walletCanSubmit: root.backendReady
|
||||
&& root.backend.walletCanSubmit === true
|
||||
readonly property var newPositionContext: root.backendReady
|
||||
&& root.backend.newPositionContext
|
||||
&& root.backend.newPositionContext.schema
|
||||
? root.backend.newPositionContext
|
||||
: root.loadingContext()
|
||||
readonly property var viewState: ({
|
||||
"quote": root.newPositionQuote,
|
||||
"contextLoading": root.contextLoading || !root.walletStateReady
|
||||
"contextLoading": root.contextLoading
|
||||
|| root.newPositionContext.status === "loading",
|
||||
"quoteLoading": root.quoteLoading,
|
||||
"quoteStale": root.quoteStale,
|
||||
"submitting": root.submitting,
|
||||
"walletCanSubmit": root.walletCanSubmit,
|
||||
"walletSyncStatus": root.backendReady
|
||||
? String(root.backend.walletSyncStatus || "closed") : "closed",
|
||||
"poolCreationPending": root.selectedPoolCreationPending(),
|
||||
"transactionId": root.transactionId,
|
||||
"errorCode": root.flowErrorCode || root.contextErrorCode
|
||||
@ -28,8 +33,13 @@ QtObject {
|
||||
|
||||
property var newPositionQuote: ({})
|
||||
property var resolvedTokenIds: []
|
||||
property int contextSerial: 0
|
||||
property int quoteSerial: 0
|
||||
property int operationSerial: 0
|
||||
property int activeQuoteRequestId: 0
|
||||
property int submitRequestId: 0
|
||||
property int poolProbeRequestId: 0
|
||||
property var pendingSubmitSnapshot: ({})
|
||||
property var pendingConfirmationSnapshot: null
|
||||
property bool contextLoading: false
|
||||
property bool quoteLoading: false
|
||||
property bool quoteStale: true
|
||||
@ -38,6 +48,7 @@ QtObject {
|
||||
property string flowErrorCode: ""
|
||||
property string contextErrorCode: ""
|
||||
property string quoteErrorCode: ""
|
||||
property var pendingContextCompletion: null
|
||||
property var pendingQuoteRequest: ({ "ok": false, "request": ({}) })
|
||||
property var pendingPoolProbes: []
|
||||
property bool poolProbeInFlight: false
|
||||
@ -48,6 +59,7 @@ QtObject {
|
||||
signal quoteRefreshRequested(bool immediate)
|
||||
signal submitSucceeded
|
||||
signal submitFailed
|
||||
signal confirmationQuoteReady(var snapshot)
|
||||
|
||||
objectName: "newPositionFlow"
|
||||
|
||||
@ -64,25 +76,37 @@ QtObject {
|
||||
onTriggered: root.pollPendingPool()
|
||||
}
|
||||
|
||||
onNewPositionContextChanged: root.invalidateQuote()
|
||||
property Connections backendConnections: Connections {
|
||||
target: root.backend
|
||||
ignoreUnknownSignals: true
|
||||
|
||||
onWalletStateReadyChanged: {
|
||||
++root.contextSerial
|
||||
if (!root.walletStateReady)
|
||||
root.contextLoading = false
|
||||
function onNewPositionQuoteResultChanged() {
|
||||
root.acceptQuoteResult(root.backend.newPositionQuoteResult || ({}))
|
||||
}
|
||||
|
||||
function onNewPositionSubmitResultChanged() {
|
||||
root.acceptSubmitResult(root.backend.newPositionSubmitResult || ({}))
|
||||
}
|
||||
}
|
||||
|
||||
onNewPositionContextChanged: {
|
||||
root.contextLoading = false
|
||||
root.contextErrorCode = root.newPositionContext.status === "error"
|
||||
? String(root.newPositionContext.code || "backend_error") : ""
|
||||
root.invalidateQuote()
|
||||
const completed = root.pendingContextCompletion
|
||||
root.pendingContextCompletion = null
|
||||
root.tokenResolutionFinished(true)
|
||||
if (completed)
|
||||
completed()
|
||||
}
|
||||
|
||||
onActiveChanged: {
|
||||
if (!root.active)
|
||||
return
|
||||
Qt.callLater(function() {
|
||||
if (root.active && root.walletStateReady)
|
||||
root.quoteRefreshRequested(true)
|
||||
})
|
||||
if (root.active && root.backendReady)
|
||||
Qt.callLater(function() { root.quoteRefreshRequested(true) })
|
||||
}
|
||||
|
||||
function contextHints(refreshWalletAccounts) {
|
||||
function contextHints(refreshPublicData) {
|
||||
const request = root.pendingQuoteRequest.request || {}
|
||||
const recent = []
|
||||
if (request.tokenAId)
|
||||
@ -92,48 +116,24 @@ QtObject {
|
||||
return {
|
||||
"recentTokenIds": recent,
|
||||
"resolvedTokenIds": root.resolvedTokenIds,
|
||||
"refreshWalletAccounts": refreshWalletAccounts === true
|
||||
"refreshWalletAccounts": refreshPublicData === true
|
||||
}
|
||||
}
|
||||
|
||||
function refreshContext(refreshWalletAccounts, completed) {
|
||||
const serial = ++root.contextSerial
|
||||
function refreshContext(refreshPublicData, completed) {
|
||||
root.contextLoading = true
|
||||
if (!root.walletStateReady || root.runtime === null) {
|
||||
root.pendingContextCompletion = completed || null
|
||||
if (!root.backendReady) {
|
||||
root.contextLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
root.runtime.watch(root.backend.refreshNewPositionContext(
|
||||
root.contextHints(refreshWalletAccounts)),
|
||||
function() {
|
||||
root.finishContextRefresh(serial, completed)
|
||||
},
|
||||
function(error) {
|
||||
root.failContextRefresh(serial)
|
||||
})
|
||||
}
|
||||
|
||||
function finishContextRefresh(serial, completed) {
|
||||
if (serial !== root.contextSerial)
|
||||
return
|
||||
root.contextLoading = false
|
||||
root.contextErrorCode = ""
|
||||
Qt.callLater(function() {
|
||||
if (serial !== root.contextSerial)
|
||||
return
|
||||
root.tokenResolutionFinished(true)
|
||||
if (completed)
|
||||
completed()
|
||||
})
|
||||
}
|
||||
|
||||
function failContextRefresh(serial) {
|
||||
if (serial !== root.contextSerial)
|
||||
return
|
||||
root.contextLoading = false
|
||||
root.contextErrorCode = "backend_error"
|
||||
root.tokenResolutionFailed("backend_error")
|
||||
try {
|
||||
root.backend.refreshNewPositionContext(root.contextHints(refreshPublicData))
|
||||
} catch (_error) {
|
||||
root.contextLoading = false
|
||||
root.contextErrorCode = "backend_error"
|
||||
root.tokenResolutionFailed("backend_error")
|
||||
}
|
||||
}
|
||||
|
||||
function resolveToken(tokenId) {
|
||||
@ -152,9 +152,9 @@ QtObject {
|
||||
++root.quoteSerial
|
||||
root.pendingQuoteRequest = quoteRequest
|
||||
root.quoteStale = true
|
||||
root.quoteLoading = root.walletStateReady && root.active
|
||||
root.quoteLoading = root.backendReady && root.active
|
||||
root.quoteDebounce.stop()
|
||||
if (!root.walletStateReady || !root.active)
|
||||
if (!root.backendReady || !root.active)
|
||||
return
|
||||
if (immediate)
|
||||
root.requestQuoteNow(root.quoteSerial)
|
||||
@ -166,86 +166,107 @@ QtObject {
|
||||
if (serial !== root.quoteSerial)
|
||||
return
|
||||
const built = root.pendingQuoteRequest
|
||||
if (!built.ok) {
|
||||
root.quoteLoading = false
|
||||
return
|
||||
}
|
||||
if (!root.walletStateReady || !root.active || root.runtime === null) {
|
||||
if (!built.ok || !root.backendReady || !root.active) {
|
||||
root.quoteLoading = false
|
||||
return
|
||||
}
|
||||
root.activeQuoteRequestId = ++root.operationSerial
|
||||
root.backend.requestNewPositionQuote(
|
||||
built.request, root.activeQuoteRequestId, true)
|
||||
}
|
||||
|
||||
root.runtime.watch(root.backend.quoteNewPosition(built.request),
|
||||
function(quote) {
|
||||
if (serial !== root.quoteSerial)
|
||||
return
|
||||
root.quoteLoading = false
|
||||
root.quoteStale = false
|
||||
root.quoteErrorCode = ""
|
||||
if (!quote || quote.schema !== "new-position.v1")
|
||||
root.newPositionQuote = root.quoteError("unsupported_schema")
|
||||
else
|
||||
root.newPositionQuote = quote
|
||||
},
|
||||
function(error) {
|
||||
if (serial !== root.quoteSerial)
|
||||
return
|
||||
root.quoteLoading = false
|
||||
root.quoteStale = true
|
||||
root.quoteErrorCode = "backend_error"
|
||||
})
|
||||
function acceptQuoteResult(result) {
|
||||
const requestId = Number(result.requestId || 0)
|
||||
if (requestId === root.poolProbeRequestId) {
|
||||
root.finishPoolProbe(root.pendingPoolProbes[0], result)
|
||||
return
|
||||
}
|
||||
if (requestId !== root.activeQuoteRequestId)
|
||||
return
|
||||
root.quoteLoading = false
|
||||
root.quoteStale = false
|
||||
root.quoteErrorCode = ""
|
||||
root.newPositionQuote = result.schema === "new-position.v2"
|
||||
? result : root.quoteError("unsupported_schema")
|
||||
if (root.pendingConfirmationSnapshot) {
|
||||
var snapshot = root.pendingConfirmationSnapshot
|
||||
root.pendingConfirmationSnapshot = null
|
||||
snapshot.quoteHash = String(root.newPositionQuote.quoteHash || "")
|
||||
snapshot.expectedLpText = String(root.newPositionQuote.expectedLpRaw || "")
|
||||
+ " raw LP"
|
||||
snapshot.instruction = String(root.newPositionQuote.instruction || "")
|
||||
snapshot.lpHoldingOptions = root.newPositionQuote.lpHoldingOptions || []
|
||||
snapshot.selectedLpHoldingId = String(
|
||||
root.newPositionQuote.selectedLpHoldingId || "")
|
||||
snapshot.createFreshLp = root.newPositionQuote.requiresFreshLp === true
|
||||
snapshot.lpDestinationRequired =
|
||||
root.newPositionQuote.lpDestinationRequired === true
|
||||
snapshot.quoteReady = root.newPositionQuote.canSubmit === true
|
||||
root.confirmationQuoteReady(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
function requoteConfirmation(snapshot) {
|
||||
root.pendingConfirmationSnapshot = snapshot
|
||||
root.scheduleQuote(true, {
|
||||
"ok": true,
|
||||
"errors": [],
|
||||
"request": snapshot.request
|
||||
})
|
||||
}
|
||||
|
||||
function confirm(snapshot) {
|
||||
if (root.submitting)
|
||||
return
|
||||
root.submitting = true
|
||||
root.flowErrorCode = ""
|
||||
|
||||
if (!root.backend || root.runtime === null) {
|
||||
root.finishSubmitFailure(root.quoteError("wallet_unavailable"))
|
||||
if (!root.walletCanSubmit) {
|
||||
root.finishSubmitFailure(root.quoteError("wallet_syncing"))
|
||||
return
|
||||
}
|
||||
root.submitting = true
|
||||
root.flowErrorCode = ""
|
||||
root.submitRequestId = ++root.operationSerial
|
||||
root.pendingSubmitSnapshot = snapshot
|
||||
root.backend.requestNewPositionSubmit(
|
||||
snapshot.request, snapshot.quoteHash, root.submitRequestId)
|
||||
}
|
||||
|
||||
root.runtime.watch(root.backend.submitNewPosition(snapshot.request, snapshot.quoteHash),
|
||||
function(result) {
|
||||
if (result && result.schema === "new-position.v1"
|
||||
&& result.status === "submitted"
|
||||
&& /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(
|
||||
String(result.transactionId || ""))) {
|
||||
if (snapshot.request.initialPriceRealRaw !== undefined)
|
||||
root.watchPoolCreation(snapshot.poolProbeRequest, result.deadlineMs)
|
||||
root.submitting = false
|
||||
root.transactionId = result.transactionId
|
||||
root.flowErrorCode = ""
|
||||
root.contextErrorCode = ""
|
||||
root.quoteErrorCode = ""
|
||||
root.invalidateQuote()
|
||||
root.submitSucceeded()
|
||||
return
|
||||
}
|
||||
root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed"))
|
||||
},
|
||||
function(error) {
|
||||
root.finishSubmitFailure(root.quoteError("wallet_submission_failed"))
|
||||
})
|
||||
function acceptSubmitResult(result) {
|
||||
if (Number(result.requestId || 0) !== root.submitRequestId)
|
||||
return
|
||||
if (result.schema === "new-position.v2"
|
||||
&& result.status === "submitted"
|
||||
&& /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(
|
||||
String(result.transactionId || ""))) {
|
||||
root.submitting = false
|
||||
root.transactionId = result.transactionId
|
||||
root.flowErrorCode = ""
|
||||
root.contextErrorCode = ""
|
||||
root.quoteErrorCode = ""
|
||||
const poolProbe = root.pendingSubmitSnapshot.poolProbeRequest || null
|
||||
if (String(root.pendingSubmitSnapshot.instruction || "")
|
||||
=== "NewDefinition" && poolProbe)
|
||||
root.watchPoolCreation(poolProbe, result.deadlineMs)
|
||||
root.invalidateQuote()
|
||||
root.submitSucceeded()
|
||||
return
|
||||
}
|
||||
root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed"))
|
||||
}
|
||||
|
||||
function finishSubmitFailure(result) {
|
||||
root.submitting = false
|
||||
const hasFreshQuote = result && result.quote
|
||||
&& result.quote.schema === "new-position.v1"
|
||||
&& result.quote.schema === "new-position.v2"
|
||||
if (hasFreshQuote) {
|
||||
root.newPositionQuote = result.quote
|
||||
root.quoteLoading = false
|
||||
root.quoteStale = false
|
||||
}
|
||||
const code = result && result.code ? result.code : "wallet_submission_failed"
|
||||
root.flowErrorCode = code
|
||||
root.flowErrorCode = result && result.code
|
||||
? result.code : "wallet_submission_failed"
|
||||
root.submitFailed()
|
||||
if (hasFreshQuote)
|
||||
return
|
||||
root.scheduleQuote(true, root.pendingQuoteRequest)
|
||||
if (!hasFreshQuote)
|
||||
root.scheduleQuote(true, root.pendingQuoteRequest)
|
||||
}
|
||||
|
||||
function watchPoolCreation(request, deadlineMs) {
|
||||
@ -256,40 +277,33 @@ QtObject {
|
||||
const pending = root.pendingPoolProbes.filter(function(item) {
|
||||
return item.key !== key
|
||||
})
|
||||
pending.push({
|
||||
"key": key,
|
||||
"request": request,
|
||||
"deadlineMs": deadline
|
||||
})
|
||||
pending.push({ "key": key, "request": request, "deadlineMs": deadline })
|
||||
root.pendingPoolProbes = pending
|
||||
Qt.callLater(root.pollPendingPool)
|
||||
}
|
||||
|
||||
function pollPendingPool() {
|
||||
if (root.poolProbeInFlight || root.pendingPoolProbes.length === 0
|
||||
|| !root.walletStateReady || root.runtime === null) {
|
||||
|| !root.backendReady)
|
||||
return
|
||||
}
|
||||
const pending = root.pendingPoolProbes[0]
|
||||
root.poolProbeInFlight = true
|
||||
root.runtime.watch(root.backend.quoteNewPosition(pending.request),
|
||||
function(quote) {
|
||||
root.finishPoolProbe(pending, quote)
|
||||
},
|
||||
function(error) {
|
||||
root.finishPoolProbe(pending, null)
|
||||
})
|
||||
root.poolProbeRequestId = ++root.operationSerial
|
||||
root.backend.requestNewPositionQuote(pending.request, root.poolProbeRequestId, true)
|
||||
}
|
||||
|
||||
function finishPoolProbe(pending, quote) {
|
||||
if (!pending)
|
||||
return
|
||||
root.poolProbeInFlight = false
|
||||
if (quote && quote.schema === "new-position.v1"
|
||||
root.poolProbeRequestId = 0
|
||||
if (quote && quote.schema === "new-position.v2"
|
||||
&& quote.poolStatus === "active_pool") {
|
||||
root.removePendingPool(pending.key)
|
||||
if (root.matchesSelectedPair(pending.request)) {
|
||||
root.poolActivated(quote)
|
||||
root.invalidateQuote()
|
||||
root.refreshContext(true)
|
||||
root.refreshContext(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -313,9 +327,7 @@ QtObject {
|
||||
if (!request.tokenAId || !request.tokenBId)
|
||||
return false
|
||||
const selected = root.pairKey(request)
|
||||
return root.pendingPoolProbes.some(function(item) {
|
||||
return item.key === selected
|
||||
})
|
||||
return root.pendingPoolProbes.some(function(item) { return item.key === selected })
|
||||
}
|
||||
|
||||
function removePendingPool(key) {
|
||||
@ -342,6 +354,7 @@ QtObject {
|
||||
|
||||
function invalidateQuote() {
|
||||
++root.quoteSerial
|
||||
root.activeQuoteRequestId = 0
|
||||
root.quoteDebounce.stop()
|
||||
root.quoteLoading = false
|
||||
root.quoteStale = true
|
||||
@ -349,7 +362,7 @@ QtObject {
|
||||
|
||||
function loadingContext() {
|
||||
return {
|
||||
"schema": "new-position.v1",
|
||||
"schema": "new-position.v2",
|
||||
"status": "loading",
|
||||
"tokens": [],
|
||||
"feeTiers": []
|
||||
@ -358,7 +371,7 @@ QtObject {
|
||||
|
||||
function quoteError(code) {
|
||||
return {
|
||||
"schema": "new-position.v1",
|
||||
"schema": "new-position.v2",
|
||||
"status": "error",
|
||||
"canSubmit": false,
|
||||
"code": code,
|
||||
|
||||
@ -69,3 +69,8 @@ AmmClientResult BundledAmmClient::plan(const QJsonObject& request) const
|
||||
{
|
||||
return call(amm_plan, request);
|
||||
}
|
||||
|
||||
AmmClientResult BundledAmmClient::normalizeAccountRpc(const QJsonObject& request) const
|
||||
{
|
||||
return call(amm_normalize_account_rpc, request);
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ public:
|
||||
virtual AmmClientResult context(const QJsonObject& request) const = 0;
|
||||
virtual AmmClientResult quote(const QJsonObject& request) const = 0;
|
||||
virtual AmmClientResult plan(const QJsonObject& request) const = 0;
|
||||
virtual AmmClientResult normalizeAccountRpc(const QJsonObject& request) const = 0;
|
||||
};
|
||||
|
||||
class BundledAmmClient final : public AmmClient {
|
||||
@ -27,4 +28,5 @@ public:
|
||||
AmmClientResult context(const QJsonObject& request) const override;
|
||||
AmmClientResult quote(const QJsonObject& request) const override;
|
||||
AmmClientResult plan(const QJsonObject& request) const override;
|
||||
AmmClientResult normalizeAccountRpc(const QJsonObject& request) const override;
|
||||
};
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QDateTime>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
@ -13,6 +14,7 @@
|
||||
#include "AmmClient.h"
|
||||
#include "LogosWalletProvider.h"
|
||||
#include "NewPositionRuntime.h"
|
||||
#include "SequencerClient.h"
|
||||
#include "WalletController.h"
|
||||
#include "logos_api.h"
|
||||
|
||||
@ -63,22 +65,24 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent)
|
||||
m_walletController(std::make_unique<WalletController>(
|
||||
*m_wallet, QStringLiteral("AmmUI"))),
|
||||
m_ammClient(std::make_unique<BundledAmmClient>()),
|
||||
m_newPosition(std::make_unique<NewPositionRuntime>(m_wallet.get(), m_ammClient.get())),
|
||||
m_net(new QNetworkAccessManager(this))
|
||||
m_sequencer(std::make_unique<SequencerClient>(m_ammClient.get(), 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();
|
||||
setNewPositionContext(m_newPosition->context(
|
||||
QVariantMap(), m_network.snapshot(), false, false));
|
||||
|
||||
connect(m_walletController.get(), &WalletController::stateChanged,
|
||||
this, &AmmUiBackend::syncWalletState);
|
||||
syncWalletState();
|
||||
refreshNewPositionContext({});
|
||||
m_walletController->start();
|
||||
QTimer::singleShot(0, this, [this]() {
|
||||
setWalletStateReady(true);
|
||||
syncWalletState();
|
||||
});
|
||||
}
|
||||
|
||||
AmmUiBackend::~AmmUiBackend() = default;
|
||||
@ -90,28 +94,22 @@ WalletAccountModel* AmmUiBackend::accountModel() const
|
||||
|
||||
QString AmmUiBackend::createNewDefault(QString password)
|
||||
{
|
||||
setWalletStateReady(false);
|
||||
const QString mnemonic = m_walletController->createDefaultWallet(password);
|
||||
setWalletStateReady(true);
|
||||
syncWalletState();
|
||||
return mnemonic;
|
||||
}
|
||||
|
||||
QString AmmUiBackend::createNew(QString configPath, QString storagePath, QString password)
|
||||
{
|
||||
setWalletStateReady(false);
|
||||
const QString mnemonic =
|
||||
m_walletController->createWallet(configPath, storagePath, password);
|
||||
setWalletStateReady(true);
|
||||
syncWalletState();
|
||||
return mnemonic;
|
||||
}
|
||||
|
||||
bool AmmUiBackend::openExisting()
|
||||
{
|
||||
setWalletStateReady(false);
|
||||
const bool opened = m_walletController->open();
|
||||
setWalletStateReady(true);
|
||||
syncWalletState();
|
||||
return opened;
|
||||
}
|
||||
@ -119,7 +117,6 @@ bool AmmUiBackend::openExisting()
|
||||
void AmmUiBackend::disconnectWallet()
|
||||
{
|
||||
m_walletController->disconnect();
|
||||
setWalletStateReady(true);
|
||||
m_newPosition->clearWalletAccounts();
|
||||
refreshNewPositionContext(QVariantMap());
|
||||
}
|
||||
@ -162,19 +159,38 @@ void AmmUiBackend::refreshNewPositionContext(QVariantMap request)
|
||||
}
|
||||
if (m_network.status() == QStringLiteral("network_unknown"))
|
||||
probeNetworkIdentity();
|
||||
setNewPositionContext(m_newPosition->context(
|
||||
request, m_network.snapshot(), isWalletOpen(), refreshWalletAccounts));
|
||||
const quint64 generation = ++m_contextGeneration;
|
||||
m_newPosition->contextAsync(
|
||||
request, m_network.snapshot(), isWalletOpen(), refreshWalletAccounts,
|
||||
[this, generation](QVariantMap result) {
|
||||
if (generation == m_contextGeneration)
|
||||
setNewPositionContext(std::move(result));
|
||||
});
|
||||
}
|
||||
|
||||
QVariantMap AmmUiBackend::quoteNewPosition(QVariantMap request)
|
||||
void AmmUiBackend::requestNewPositionQuote(QVariantMap request,
|
||||
int requestId,
|
||||
bool forceRefresh)
|
||||
{
|
||||
return m_newPosition->quote(request, m_network.snapshot(), isWalletOpen());
|
||||
m_newPosition->quoteAsync(
|
||||
request, m_network.snapshot(), isWalletOpen(), forceRefresh,
|
||||
[this, requestId](QVariantMap result) {
|
||||
result.insert(QStringLiteral("requestId"), requestId);
|
||||
setNewPositionQuoteResult(std::move(result));
|
||||
});
|
||||
}
|
||||
|
||||
QVariantMap AmmUiBackend::submitNewPosition(QVariantMap request, QString quoteHash)
|
||||
void AmmUiBackend::requestNewPositionSubmit(QVariantMap request,
|
||||
QString quoteHash,
|
||||
int requestId)
|
||||
{
|
||||
return m_newPosition->submit(
|
||||
request, quoteHash, m_network.snapshot(), isWalletOpen());
|
||||
m_newPosition->submitAsync(
|
||||
request, quoteHash, m_network.snapshot(), walletCanSubmit(),
|
||||
[this, requestId](QVariantMap result) {
|
||||
result.insert(QStringLiteral("requestId"), requestId);
|
||||
setNewPositionSubmitResult(result);
|
||||
watchTransaction(result);
|
||||
});
|
||||
}
|
||||
|
||||
void AmmUiBackend::syncWalletState()
|
||||
@ -185,6 +201,11 @@ void AmmUiBackend::syncWalletState()
|
||||
const QString previousAddress = sequencerAddr();
|
||||
|
||||
setIsWalletOpen(state.isWalletOpen);
|
||||
setWalletSyncStatus(state.syncStatus);
|
||||
setWalletSyncError(state.syncError);
|
||||
setWalletCanSubmit(state.canSubmit());
|
||||
setWalletStateReady(state.syncStatus != QStringLiteral("opening")
|
||||
&& state.syncStatus != QStringLiteral("syncing"));
|
||||
setWalletExists(state.walletExists);
|
||||
setConfigPath(state.configPath);
|
||||
setStoragePath(state.storagePath);
|
||||
@ -194,14 +215,27 @@ void AmmUiBackend::syncWalletState()
|
||||
setSequencerAddr(state.sequencerAddress);
|
||||
setSequencerReachable(state.sequencerReachable);
|
||||
|
||||
if (m_sequencerConfigPath != state.configPath || !m_sequencer->isConfigured()) {
|
||||
m_sequencerConfigPath = state.configPath;
|
||||
m_sequencer->configure(state.configPath);
|
||||
}
|
||||
|
||||
const bool addressChanged = previousAddress != state.sequencerAddress;
|
||||
if (addressChanged)
|
||||
if (addressChanged) {
|
||||
m_pendingTransactions.clear();
|
||||
m_transactionTimer->stop();
|
||||
m_network.sequencerChanged(!state.sequencerAddress.isEmpty());
|
||||
}
|
||||
if (addressChanged || wasReachable != state.sequencerReachable) {
|
||||
m_network.reachabilityChanged(state.sequencerReachable, wasReachable);
|
||||
}
|
||||
if (walletWasOpen && !state.isWalletOpen)
|
||||
m_newPosition->clearWalletAccounts();
|
||||
if (state.canSubmit()) {
|
||||
const WalletSnapshot snapshot = m_wallet->snapshot();
|
||||
if (snapshot.ok())
|
||||
m_newPosition->setWalletAccounts(snapshot.accounts);
|
||||
}
|
||||
|
||||
publishNetworkContext();
|
||||
if (state.sequencerReachable && m_network.needsIdentityProbe())
|
||||
@ -230,6 +264,7 @@ void AmmUiBackend::probeNetworkIdentity()
|
||||
QNetworkRequest request{QUrl(address)};
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||
request.setTransferTimeout(4000);
|
||||
m_sequencer->applyAuthorization(request);
|
||||
QNetworkReply* reply = m_net->post(request, jsonRpcBody(method, params));
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, address, devnet]() {
|
||||
m_identityProbeInFlight = false;
|
||||
@ -255,6 +290,78 @@ void AmmUiBackend::probeNetworkIdentity()
|
||||
|
||||
void AmmUiBackend::publishNetworkContext()
|
||||
{
|
||||
setNewPositionContext(m_newPosition->context(
|
||||
m_newPositionHints, m_network.snapshot(), isWalletOpen(), false));
|
||||
refreshNewPositionContext(m_newPositionHints);
|
||||
}
|
||||
|
||||
void AmmUiBackend::watchTransaction(const QVariantMap& result)
|
||||
{
|
||||
if (result.value(QStringLiteral("status")).toString()
|
||||
!= QStringLiteral("submitted")) {
|
||||
return;
|
||||
}
|
||||
const QString nativeHash = result.value(
|
||||
QStringLiteral("nativeTransactionHash")).toString();
|
||||
bool deadlineValid = false;
|
||||
const qint64 deadline = result.value(QStringLiteral("deadlineMs"))
|
||||
.toString().toLongLong(&deadlineValid);
|
||||
QStringList affected;
|
||||
for (const QVariant& value : result.value(
|
||||
QStringLiteral("affectedAccountIds")).toList()) {
|
||||
affected.append(value.toString());
|
||||
}
|
||||
if (nativeHash.isEmpty() || !deadlineValid || affected.isEmpty())
|
||||
return;
|
||||
m_pendingTransactions.insert(nativeHash, {
|
||||
nativeHash,
|
||||
affected,
|
||||
deadline,
|
||||
});
|
||||
if (!m_transactionTimer->isActive())
|
||||
m_transactionTimer->start();
|
||||
QTimer::singleShot(0, this, &AmmUiBackend::pollTransactions);
|
||||
}
|
||||
|
||||
void AmmUiBackend::pollTransactions()
|
||||
{
|
||||
const qint64 now = QDateTime::currentMSecsSinceEpoch();
|
||||
const QList<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({});
|
||||
});
|
||||
}
|
||||
|
||||
@ -4,7 +4,9 @@
|
||||
#include <memory>
|
||||
|
||||
#include <QObject>
|
||||
#include <QHash>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVariant>
|
||||
|
||||
#include "rep_AmmUiBackend_source.h"
|
||||
@ -17,6 +19,8 @@ class AmmClient;
|
||||
class LogosWalletProvider;
|
||||
class NewPositionRuntime;
|
||||
class QNetworkAccessManager;
|
||||
class QTimer;
|
||||
class SequencerClient;
|
||||
class WalletController;
|
||||
|
||||
// Source-side implementation of the AmmUiBackend .rep interface.
|
||||
@ -40,8 +44,12 @@ public slots:
|
||||
void refreshBalances() override;
|
||||
QString getBalance(QString accountIdHex, bool isPublic) override;
|
||||
void refreshNewPositionContext(QVariantMap request) override;
|
||||
QVariantMap quoteNewPosition(QVariantMap request) override;
|
||||
QVariantMap submitNewPosition(QVariantMap request, QString quoteHash) override;
|
||||
void requestNewPositionQuote(QVariantMap request,
|
||||
int requestId,
|
||||
bool forceRefresh) override;
|
||||
void requestNewPositionSubmit(QVariantMap request,
|
||||
QString quoteHash,
|
||||
int requestId) override;
|
||||
// Return the new wallet's BIP39 mnemonic (empty string on failure) so the
|
||||
// UI can force a one-time seed-phrase backup step.
|
||||
QString createNewDefault(QString password) override;
|
||||
@ -53,18 +61,31 @@ private:
|
||||
void syncWalletState();
|
||||
void probeNetworkIdentity();
|
||||
void publishNetworkContext();
|
||||
void watchTransaction(const QVariantMap& result);
|
||||
void pollTransactions();
|
||||
void refreshAffectedAccounts(const QStringList& accountIds, int attempt = 0);
|
||||
|
||||
LogosAPI* m_logosAPI;
|
||||
std::unique_ptr<LogosWalletProvider> m_wallet;
|
||||
std::unique_ptr<WalletController> m_walletController;
|
||||
std::unique_ptr<AmmClient> m_ammClient;
|
||||
std::unique_ptr<SequencerClient> m_sequencer;
|
||||
std::unique_ptr<NewPositionRuntime> m_newPosition;
|
||||
|
||||
QNetworkAccessManager* m_net;
|
||||
QTimer* m_transactionTimer;
|
||||
|
||||
ActiveNetwork m_network;
|
||||
QVariantMap m_newPositionHints;
|
||||
QString m_sequencerConfigPath;
|
||||
bool m_identityProbeInFlight = false;
|
||||
quint64 m_contextGeneration = 0;
|
||||
struct PendingTransaction {
|
||||
QString nativeHash;
|
||||
QStringList affectedAccountIds;
|
||||
qint64 deadlineMs = 0;
|
||||
};
|
||||
QHash<QString, PendingTransaction> m_pendingTransactions;
|
||||
};
|
||||
|
||||
#endif // AMM_UI_BACKEND_H
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// QtRO view contract for the AMM UI backend. PROPs auto-sync to every QML
|
||||
// replica; SLOTs are the async surface QML calls via logos.watch(...).
|
||||
// replica; SLOTs dispatch asynchronous backend work.
|
||||
// The account list is exposed separately as a Q_PROPERTY model on the backend
|
||||
// (reached from QML via logos.model("amm_ui", "accountModel")).
|
||||
class AmmUiBackend
|
||||
@ -8,6 +8,9 @@ class AmmUiBackend
|
||||
// False while startup or reconnect is still resolving wallet state. This
|
||||
// stays distinct from isWalletOpen because a disconnected wallet is ready.
|
||||
PROP(bool walletStateReady READONLY)
|
||||
PROP(QString walletSyncStatus READONLY)
|
||||
PROP(QString walletSyncError READONLY)
|
||||
PROP(bool walletCanSubmit READONLY)
|
||||
PROP(bool walletExists READONLY)
|
||||
PROP(QString configPath READONLY)
|
||||
PROP(QString storagePath READONLY)
|
||||
@ -26,13 +29,15 @@ class AmmUiBackend
|
||||
SLOT(void refreshBalances())
|
||||
SLOT(QString getBalance(QString accountIdHex, bool isPublic))
|
||||
|
||||
// New Position backend surface. QML calls these through logos.watch(...).
|
||||
// The QVariant payloads are stable maps/lists so the UI never assembles AMM
|
||||
// transactions or duplicates quote state.
|
||||
// New Position backend surface. QML dispatches request slots and observes
|
||||
// request-tagged result properties. QVariant payloads are stable maps/lists
|
||||
// so the UI never assembles AMM transactions or duplicates quote state.
|
||||
PROP(QVariantMap newPositionContext READONLY)
|
||||
PROP(QVariantMap newPositionQuoteResult READONLY)
|
||||
PROP(QVariantMap newPositionSubmitResult READONLY)
|
||||
SLOT(void refreshNewPositionContext(QVariantMap request))
|
||||
SLOT(QVariantMap quoteNewPosition(QVariantMap request))
|
||||
SLOT(QVariantMap submitNewPosition(QVariantMap request, QString quoteHash))
|
||||
SLOT(void requestNewPositionQuote(QVariantMap request, int requestId, bool forceRefresh))
|
||||
SLOT(void requestNewPositionSubmit(QVariantMap request, QString quoteHash, int requestId))
|
||||
|
||||
// Wallet lifecycle. createNewDefault() is the happy path: it creates a
|
||||
// fresh wallet at the canonical walletHome with no path picking. createNew()
|
||||
|
||||
@ -11,10 +11,11 @@
|
||||
#include <libbase58.h>
|
||||
|
||||
#include "AmmClient.h"
|
||||
#include "SequencerClient.h"
|
||||
#include "WalletProvider.h"
|
||||
|
||||
namespace {
|
||||
const char SCHEMA[] = "new-position.v1";
|
||||
const char SCHEMA[] = "new-position.v2";
|
||||
constexpr qsizetype HASH_BYTES = 32;
|
||||
constexpr std::size_t BASE58_BUFFER_SIZE = 45;
|
||||
|
||||
@ -35,6 +36,21 @@ namespace {
|
||||
encoded.data(), static_cast<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,
|
||||
const QJsonArray& blockingFields = {})
|
||||
{
|
||||
@ -142,17 +158,30 @@ namespace {
|
||||
}
|
||||
}
|
||||
|
||||
NewPositionRuntime::NewPositionRuntime(WalletProvider* wallet, AmmClient* client)
|
||||
NewPositionRuntime::NewPositionRuntime(WalletProvider* wallet,
|
||||
AmmClient* client,
|
||||
SequencerClient* sequencer)
|
||||
: m_wallet(wallet),
|
||||
m_client(client)
|
||||
m_client(client),
|
||||
m_sequencer(sequencer)
|
||||
{
|
||||
}
|
||||
|
||||
void NewPositionRuntime::clearWalletAccounts()
|
||||
{
|
||||
m_walletPublicAccountIds.clear();
|
||||
m_wallet->clearSnapshot();
|
||||
}
|
||||
|
||||
void NewPositionRuntime::setWalletAccounts(const QVector<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
|
||||
{
|
||||
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,
|
||||
const ActiveNetworkSnapshot& network,
|
||||
bool walletOpen,
|
||||
@ -385,6 +764,7 @@ QVariantMap NewPositionRuntime::submit(const QVariantMap& request,
|
||||
{ QStringLiteral("schema"), QString::fromLatin1(SCHEMA) },
|
||||
{ QStringLiteral("status"), QStringLiteral("submitted") },
|
||||
{ QStringLiteral("transactionId"), transactionId },
|
||||
{ QStringLiteral("nativeTransactionHash"), submission.nativeHash },
|
||||
{ QStringLiteral("deadlineMs"), plan.value(QStringLiteral("deadlineMs")) },
|
||||
}.toVariantMap();
|
||||
}
|
||||
|
||||
@ -4,17 +4,43 @@
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QVariantMap>
|
||||
#include <QVector>
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "ActiveNetwork.h"
|
||||
|
||||
class AmmClient;
|
||||
class WalletProvider;
|
||||
class SequencerClient;
|
||||
struct WalletAccount;
|
||||
|
||||
class NewPositionRuntime {
|
||||
public:
|
||||
NewPositionRuntime(WalletProvider* wallet, AmmClient* client);
|
||||
using ResultCallback = std::function<void(QVariantMap)>;
|
||||
|
||||
NewPositionRuntime(WalletProvider* wallet,
|
||||
AmmClient* client,
|
||||
SequencerClient* sequencer = nullptr);
|
||||
|
||||
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,
|
||||
const ActiveNetworkSnapshot& network,
|
||||
@ -35,8 +61,15 @@ private:
|
||||
bool walletOpen,
|
||||
bool freshWalletAccounts,
|
||||
QJsonObject* error) const;
|
||||
void buildQuoteInputAsync(const QVariantMap& request,
|
||||
const ActiveNetworkSnapshot& network,
|
||||
bool walletOpen,
|
||||
bool forceRefresh,
|
||||
std::function<void(QJsonObject, QJsonObject)> callback) const;
|
||||
|
||||
WalletProvider* m_wallet;
|
||||
AmmClient* m_client;
|
||||
SequencerClient* m_sequencer;
|
||||
QStringList m_walletPublicAccountIds;
|
||||
bool m_submitInFlight = false;
|
||||
};
|
||||
|
||||
302
apps/amm/src/SequencerClient.cpp
Normal file
302
apps/amm/src/SequencerClient.cpp
Normal 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;
|
||||
}
|
||||
61
apps/amm/src/SequencerClient.h
Normal file
61
apps/amm/src/SequencerClient.h
Normal 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;
|
||||
};
|
||||
@ -19,6 +19,11 @@ namespace {
|
||||
return {};
|
||||
}
|
||||
|
||||
void connectAsync(const WalletPaths&, SessionCallback callback) override
|
||||
{
|
||||
callback({});
|
||||
}
|
||||
|
||||
WalletCreation createWallet(const WalletPaths&, const QString&) override
|
||||
{
|
||||
return {};
|
||||
@ -29,6 +34,11 @@ namespace {
|
||||
return {};
|
||||
}
|
||||
|
||||
void snapshotAsync(bool, SnapshotCallback callback) override
|
||||
{
|
||||
callback({});
|
||||
}
|
||||
|
||||
void clearSnapshot() override {}
|
||||
|
||||
WalletAccountCreation createAccount(bool isPublic) override
|
||||
@ -102,7 +112,7 @@ namespace {
|
||||
AmmClientResult quote(const QJsonObject&) const override
|
||||
{
|
||||
return success({
|
||||
{ QStringLiteral("schema"), QStringLiteral("new-position.v1") },
|
||||
{ QStringLiteral("schema"), QStringLiteral("new-position.v2") },
|
||||
{ QStringLiteral("status"), QStringLiteral("ok") },
|
||||
{ QStringLiteral("canSubmit"), true },
|
||||
{ QStringLiteral("quoteHash"), quoteHash },
|
||||
@ -116,6 +126,8 @@ namespace {
|
||||
return success({
|
||||
{ QStringLiteral("status"), QStringLiteral("ready") },
|
||||
{ QStringLiteral("accountIds"), QJsonArray { QStringLiteral("account") } },
|
||||
{ QStringLiteral("affectedAccountIds"),
|
||||
QJsonArray { QStringLiteral("account") } },
|
||||
{ QStringLiteral("signingRequirements"), QJsonArray { true } },
|
||||
{ QStringLiteral("instruction"), QJsonArray { 1 } },
|
||||
{ QStringLiteral("programId"), QStringLiteral("program") },
|
||||
@ -124,6 +136,11 @@ namespace {
|
||||
});
|
||||
}
|
||||
|
||||
AmmClientResult normalizeAccountRpc(const QJsonObject&) const override
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
static AmmClientResult success(const QJsonObject& value)
|
||||
{
|
||||
return { true, value };
|
||||
@ -149,7 +166,7 @@ namespace {
|
||||
int main()
|
||||
{
|
||||
const QVariantMap request {
|
||||
{ QStringLiteral("schema"), QStringLiteral("new-position.v1") },
|
||||
{ QStringLiteral("schema"), QStringLiteral("new-position.v2") },
|
||||
{ QStringLiteral("tokenAId"), QStringLiteral("token-a") },
|
||||
{ QStringLiteral("tokenBId"), QStringLiteral("token-b") },
|
||||
{ QStringLiteral("feeBps"), 30 },
|
||||
@ -168,6 +185,10 @@ int main()
|
||||
== QStringLiteral("1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"),
|
||||
"native hash should become the expected base58 transaction ID"))
|
||||
return 1;
|
||||
if (!expect(result.value(QStringLiteral("nativeTransactionHash")).toString()
|
||||
== wallet.transactionHash,
|
||||
"submitted result should preserve the native hash for polling"))
|
||||
return 1;
|
||||
if (!expect(wallet.createdAccounts == 1 && client.sawFreshLp,
|
||||
"fresh LP account should enter the plan"))
|
||||
return 1;
|
||||
|
||||
@ -18,6 +18,11 @@ TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
SignalSpy {
|
||||
id: editedSpy
|
||||
signalName: "snapshotEdited"
|
||||
}
|
||||
|
||||
function test_protocolActionsUseDisplayLabels() {
|
||||
var summary = createTemporaryObject(summaryComponent, testCase)
|
||||
verify(summary)
|
||||
@ -26,4 +31,32 @@ TestCase {
|
||||
compare(summary.actionText("AddLiquidity"), "Add liquidity")
|
||||
compare(summary.actionText(""), "-")
|
||||
}
|
||||
|
||||
function test_lpDestinationOffersExistingHoldingsAndCreateNew() {
|
||||
var summary = createTemporaryObject(summaryComponent, testCase, {
|
||||
"snapshot": {
|
||||
"instruction": "AddLiquidity",
|
||||
"request": ({ "schema": "new-position.v2" }),
|
||||
"lpHoldingOptions": [{
|
||||
"holdingId": "44444444444444444444444444444444",
|
||||
"balanceRaw": "7"
|
||||
}],
|
||||
"lpDestinationRequired": true,
|
||||
"quoteReady": false
|
||||
}
|
||||
})
|
||||
verify(summary)
|
||||
editedSpy.target = summary
|
||||
editedSpy.clear()
|
||||
|
||||
var rows = summary.destinationRows()
|
||||
compare(rows.length, 2)
|
||||
compare(rows[1].createFresh, true)
|
||||
summary.selectDestination(rows[0])
|
||||
compare(editedSpy.count, 1)
|
||||
compare(editedSpy.signalArguments[0][0].request.lpHoldingId,
|
||||
"44444444444444444444444444444444")
|
||||
compare(editedSpy.signalArguments[0][0].quoteReady, false)
|
||||
editedSpy.target = null
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,26 +17,54 @@ TestCase {
|
||||
|
||||
QtObject {
|
||||
property bool walletStateReady: false
|
||||
property bool walletCanSubmit: true
|
||||
property bool isWalletOpen: true
|
||||
property string walletSyncStatus: "ready"
|
||||
property var submitResult: ({})
|
||||
property var quoteResult: ({
|
||||
"schema": "new-position.v1",
|
||||
"schema": "new-position.v2",
|
||||
"status": "ok",
|
||||
"poolStatus": "missing_pool"
|
||||
})
|
||||
property var newPositionQuoteResult: ({})
|
||||
property var newPositionSubmitResult: ({})
|
||||
property var newPositionContext: ({
|
||||
"schema": "new-position.v1",
|
||||
"schema": "new-position.v2",
|
||||
"status": "ready",
|
||||
"tokens": [],
|
||||
"feeTiers": []
|
||||
})
|
||||
property int contextRefreshCalls: 0
|
||||
property int submitCalls: 0
|
||||
property var lastContextRefreshRequest: ({})
|
||||
|
||||
function submitNewPosition(request, quoteHash) {
|
||||
return submitResult
|
||||
function requestNewPositionSubmit(request, quoteHash, requestId) {
|
||||
++submitCalls
|
||||
var result = JSON.parse(JSON.stringify(submitResult || ({})))
|
||||
result.requestId = requestId
|
||||
newPositionSubmitResult = result
|
||||
}
|
||||
|
||||
function quoteNewPosition(request) {
|
||||
return quoteResult
|
||||
function requestNewPositionQuote(request, requestId, forceRefresh) {
|
||||
var result = JSON.parse(JSON.stringify(quoteResult || ({})))
|
||||
result.requestId = requestId
|
||||
newPositionQuoteResult = result
|
||||
}
|
||||
|
||||
function refreshNewPositionContext(request) {
|
||||
++contextRefreshCalls
|
||||
lastContextRefreshRequest = request
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: pageComponent
|
||||
|
||||
Pages.LiquidityPage {
|
||||
visible: false
|
||||
width: 800
|
||||
height: 600
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,110 +80,48 @@ TestCase {
|
||||
verify(rail)
|
||||
verify(compactSteps)
|
||||
verify(form)
|
||||
|
||||
compare(page.wideLayout, true)
|
||||
verify(rail.width > 0)
|
||||
verify(form.width > rail.width)
|
||||
|
||||
page.width = 600
|
||||
wait(0)
|
||||
|
||||
compare(page.wideLayout, false)
|
||||
verify(compactSteps.implicitHeight > 0)
|
||||
verify(form.width > 0)
|
||||
verify(form.width <= page.width - 32)
|
||||
}
|
||||
|
||||
Component {
|
||||
id: runtimeComponent
|
||||
|
||||
QtObject {
|
||||
function watch(value, succeeded, failed) {
|
||||
succeeded(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: pageComponent
|
||||
|
||||
Pages.LiquidityPage {
|
||||
visible: false
|
||||
width: 800
|
||||
height: 600
|
||||
}
|
||||
}
|
||||
|
||||
function test_contextWaitsForWalletState() {
|
||||
var backend = createTemporaryObject(backendComponent, testCase)
|
||||
var page = createTemporaryObject(pageComponent, testCase, {
|
||||
"backend": backend
|
||||
function test_contextAvailableWhileWalletSyncs() {
|
||||
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||
"walletCanSubmit": false,
|
||||
"walletSyncStatus": "syncing"
|
||||
})
|
||||
verify(backend)
|
||||
var page = createTemporaryObject(pageComponent, testCase, { "backend": backend })
|
||||
verify(page)
|
||||
|
||||
compare(page.flow.newPositionContext.status, "loading")
|
||||
|
||||
backend.walletStateReady = true
|
||||
tryCompare(page.flow.newPositionContext, "status", "ready")
|
||||
compare(page.flow.newPositionContext.status, "ready")
|
||||
compare(page.flow.viewState.walletSyncStatus, "syncing")
|
||||
verify(!page.flow.walletCanSubmit)
|
||||
}
|
||||
|
||||
function test_contextRefreshControlsWalletScan() {
|
||||
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||
"walletStateReady": true
|
||||
})
|
||||
var page = createTemporaryObject(pageComponent, testCase, {
|
||||
"backend": backend
|
||||
})
|
||||
verify(backend)
|
||||
function test_contextRefreshControlsPublicRefresh() {
|
||||
var backend = createTemporaryObject(backendComponent, testCase)
|
||||
var page = createTemporaryObject(pageComponent, testCase, { "backend": backend })
|
||||
verify(page)
|
||||
|
||||
compare(page.flow.contextHints(false).refreshWalletAccounts, false)
|
||||
compare(page.flow.contextHints(true).refreshWalletAccounts, true)
|
||||
}
|
||||
|
||||
function test_staleContextCompletionCannotFinishNewerRefresh() {
|
||||
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||
"walletStateReady": true
|
||||
})
|
||||
var page = createTemporaryObject(pageComponent, testCase, {
|
||||
"backend": backend
|
||||
})
|
||||
verify(backend)
|
||||
verify(page)
|
||||
|
||||
page.flow.contextSerial = 2
|
||||
page.flow.contextLoading = true
|
||||
page.flow.contextErrorCode = "newer_request_pending"
|
||||
|
||||
page.flow.finishContextRefresh(1, null)
|
||||
page.flow.failContextRefresh(1)
|
||||
|
||||
compare(page.flow.contextLoading, true)
|
||||
compare(page.flow.contextErrorCode, "newer_request_pending")
|
||||
|
||||
page.flow.finishContextRefresh(2, null)
|
||||
compare(page.flow.contextLoading, false)
|
||||
compare(page.flow.contextErrorCode, "")
|
||||
}
|
||||
|
||||
function test_submitFailureKeepsReturnedFreshQuoteWithoutRequery() {
|
||||
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||
"walletStateReady": true
|
||||
})
|
||||
var page = createTemporaryObject(pageComponent, testCase, {
|
||||
"backend": backend
|
||||
})
|
||||
verify(backend)
|
||||
verify(page)
|
||||
|
||||
var backend = createTemporaryObject(backendComponent, testCase)
|
||||
var page = createTemporaryObject(pageComponent, testCase, { "backend": backend })
|
||||
page.flow.quoteSerial = 7
|
||||
page.flow.finishSubmitFailure({
|
||||
"schema": "new-position.v1",
|
||||
"schema": "new-position.v2",
|
||||
"status": "error",
|
||||
"code": "quote_not_submittable",
|
||||
"quote": {
|
||||
"schema": "new-position.v1",
|
||||
"schema": "new-position.v2",
|
||||
"status": "ok",
|
||||
"canSubmit": false,
|
||||
"quoteHash": "sha256:fresh"
|
||||
@ -167,61 +133,49 @@ TestCase {
|
||||
compare(page.flow.quoteStale, false)
|
||||
}
|
||||
|
||||
function test_base58SubmittedResultEntersSuccessState() {
|
||||
function test_submittedResultEntersSuccessState() {
|
||||
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||
"walletStateReady": true,
|
||||
"submitResult": {
|
||||
"schema": "new-position.v1",
|
||||
"schema": "new-position.v2",
|
||||
"status": "submitted",
|
||||
"transactionId": submittedTransactionId,
|
||||
"deadlineMs": String(Date.now() + 60000)
|
||||
"deadlineMs": String(Date.now() + 60000),
|
||||
"affectedAccountIds": []
|
||||
}
|
||||
})
|
||||
var runtime = createTemporaryObject(runtimeComponent, testCase)
|
||||
var page = createTemporaryObject(pageComponent, testCase, {
|
||||
"backend": backend,
|
||||
"runtime": runtime
|
||||
})
|
||||
verify(backend)
|
||||
verify(runtime)
|
||||
verify(page)
|
||||
|
||||
page.flow.confirm({
|
||||
"request": ({}),
|
||||
"quoteHash": "sha256:expected"
|
||||
})
|
||||
var page = createTemporaryObject(pageComponent, testCase, { "backend": backend })
|
||||
page.flow.confirm({ "request": ({}), "quoteHash": "sha256:expected" })
|
||||
wait(0)
|
||||
|
||||
compare(page.flow.transactionId, submittedTransactionId)
|
||||
compare(page.flow.flowErrorCode, "")
|
||||
compare(page.flow.submitting, false)
|
||||
compare(backend.submitCalls, 1)
|
||||
}
|
||||
|
||||
function test_base58MissingPoolSubmissionStartsPoolWatch() {
|
||||
function test_missingPoolSubmissionStartsPoolProbeWithoutWalletRefresh() {
|
||||
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||
"walletStateReady": true,
|
||||
"submitResult": {
|
||||
"schema": "new-position.v1",
|
||||
"schema": "new-position.v2",
|
||||
"status": "submitted",
|
||||
"transactionId": submittedTransactionId,
|
||||
"deadlineMs": String(Date.now() + 60000)
|
||||
}
|
||||
})
|
||||
var runtime = createTemporaryObject(runtimeComponent, testCase)
|
||||
var page = createTemporaryObject(pageComponent, testCase, {
|
||||
"backend": backend,
|
||||
"runtime": runtime
|
||||
})
|
||||
verify(backend)
|
||||
verify(runtime)
|
||||
verify(page)
|
||||
|
||||
var page = createTemporaryObject(pageComponent, testCase, { "backend": backend })
|
||||
var probe = {
|
||||
"tokenAId": "22222222222222222222222222222222",
|
||||
"tokenBId": "33333333333333333333333333333333"
|
||||
}
|
||||
page.flow.pendingQuoteRequest = { "ok": true, "request": probe }
|
||||
page.flow.pendingQuoteRequest = {
|
||||
"ok": true,
|
||||
"request": probe
|
||||
}
|
||||
page.flow.confirm({
|
||||
"instruction": "NewDefinition",
|
||||
"request": {
|
||||
"tokenAId": probe.tokenAId,
|
||||
"tokenBId": probe.tokenBId,
|
||||
"initialPriceRealRaw": "18446744073709551616"
|
||||
},
|
||||
"poolProbeRequest": probe,
|
||||
@ -229,51 +183,51 @@ TestCase {
|
||||
})
|
||||
wait(0)
|
||||
|
||||
compare(page.flow.transactionId, submittedTransactionId)
|
||||
compare(page.flow.pendingPoolProbes.length, 1)
|
||||
compare(page.flow.selectedPoolCreationPending(), true)
|
||||
compare(page.flow.poolProbeInFlight, false)
|
||||
compare(backend.contextRefreshCalls, 0)
|
||||
verify(page.flow.selectedPoolCreationPending())
|
||||
}
|
||||
|
||||
function test_nativeHexSubmittedResultDoesNotEnterSuccessState() {
|
||||
function test_activePoolSubmissionDoesNotStartPoolCreationProbe() {
|
||||
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||
"walletStateReady": true,
|
||||
"submitResult": {
|
||||
"schema": "new-position.v1",
|
||||
"schema": "new-position.v2",
|
||||
"status": "submitted",
|
||||
"transactionId": "000102030405060708090a0b0c0d0e0f"
|
||||
+ "101112131415161718191a1b1c1d1e1f"
|
||||
"transactionId": submittedTransactionId,
|
||||
"deadlineMs": String(Date.now() + 60000)
|
||||
}
|
||||
})
|
||||
var runtime = createTemporaryObject(runtimeComponent, testCase)
|
||||
var page = createTemporaryObject(pageComponent, testCase, {
|
||||
"backend": backend,
|
||||
"runtime": runtime
|
||||
})
|
||||
verify(backend)
|
||||
verify(runtime)
|
||||
verify(page)
|
||||
|
||||
var page = createTemporaryObject(pageComponent, testCase, { "backend": backend })
|
||||
page.flow.confirm({
|
||||
"instruction": "AddLiquidity",
|
||||
"request": ({}),
|
||||
"poolProbeRequest": {
|
||||
"tokenAId": "22222222222222222222222222222222",
|
||||
"tokenBId": "33333333333333333333333333333333"
|
||||
},
|
||||
"quoteHash": "sha256:expected"
|
||||
})
|
||||
wait(0)
|
||||
|
||||
compare(page.flow.transactionId, "")
|
||||
compare(page.flow.flowErrorCode, "wallet_submission_failed")
|
||||
compare(page.flow.submitting, false)
|
||||
compare(page.flow.pendingPoolProbes.length, 0)
|
||||
}
|
||||
|
||||
function test_poolProbeDoesNotPublishProbeAmountsAsCurrentQuote() {
|
||||
function test_walletSyncDisablesSubmissionOnly() {
|
||||
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||
"walletStateReady": true
|
||||
"walletCanSubmit": false,
|
||||
"walletSyncStatus": "syncing"
|
||||
})
|
||||
var page = createTemporaryObject(pageComponent, testCase, {
|
||||
"backend": backend
|
||||
})
|
||||
verify(backend)
|
||||
verify(page)
|
||||
var page = createTemporaryObject(pageComponent, testCase, { "backend": backend })
|
||||
page.flow.confirm({ "request": ({}), "quoteHash": "sha256:expected" })
|
||||
|
||||
compare(backend.submitCalls, 0)
|
||||
compare(page.flow.flowErrorCode, "wallet_syncing")
|
||||
verify(!page.flow.submitting)
|
||||
}
|
||||
|
||||
function test_poolProbeDoesNotPublishProbeAsCurrentQuote() {
|
||||
var backend = createTemporaryObject(backendComponent, testCase)
|
||||
var page = createTemporaryObject(pageComponent, testCase, { "backend": backend })
|
||||
var request = {
|
||||
"tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
|
||||
"tokenBId": "22222222222222222222222222222222"
|
||||
@ -282,54 +236,19 @@ TestCase {
|
||||
page.flow.pendingQuoteRequest = { "ok": true, "request": request }
|
||||
page.flow.pendingPoolProbes = [pending]
|
||||
page.flow.newPositionQuote = {
|
||||
"schema": "new-position.v1",
|
||||
"schema": "new-position.v2",
|
||||
"status": "ok",
|
||||
"poolStatus": "missing_pool",
|
||||
"tokenAId": request.tokenAId,
|
||||
"tokenBId": request.tokenBId
|
||||
"poolStatus": "missing_pool"
|
||||
}
|
||||
page.flow.quoteStale = false
|
||||
|
||||
page.flow.finishPoolProbe(pending, {
|
||||
"schema": "new-position.v1",
|
||||
"schema": "new-position.v2",
|
||||
"status": "ok",
|
||||
"poolStatus": "active_pool",
|
||||
"tokenAId": request.tokenAId,
|
||||
"tokenBId": request.tokenBId
|
||||
"poolStatus": "active_pool"
|
||||
})
|
||||
|
||||
compare(page.flow.pendingPoolProbes.length, 0)
|
||||
compare(page.flow.newPositionQuote.poolStatus, "missing_pool")
|
||||
verify(page.flow.quoteStale)
|
||||
}
|
||||
|
||||
function test_poolProbeStopsBlockingAfterTransactionDeadline() {
|
||||
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||
"walletStateReady": true
|
||||
})
|
||||
var page = createTemporaryObject(pageComponent, testCase, {
|
||||
"backend": backend
|
||||
})
|
||||
verify(backend)
|
||||
verify(page)
|
||||
|
||||
var request = {
|
||||
"tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
|
||||
"tokenBId": "22222222222222222222222222222222"
|
||||
}
|
||||
var pending = {
|
||||
"key": page.flow.pairKey(request),
|
||||
"request": request,
|
||||
"deadlineMs": Date.now() - 1
|
||||
}
|
||||
page.flow.pendingQuoteRequest = { "ok": true, "request": request }
|
||||
page.flow.pendingPoolProbes = [pending]
|
||||
page.flow.poolProbeInFlight = true
|
||||
|
||||
page.flow.finishPoolProbe(pending, null)
|
||||
|
||||
compare(page.flow.pendingPoolProbes.length, 0)
|
||||
compare(page.flow.poolProbeInFlight, false)
|
||||
verify(!page.flow.selectedPoolCreationPending())
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,6 +46,10 @@ TestCase {
|
||||
"name": "Low",
|
||||
"totalSupplyRaw": "1000000",
|
||||
"balanceRaw": "1000",
|
||||
"holdings": [{
|
||||
"holdingId": "44444444444444444444444444444444",
|
||||
"balanceRaw": "1000"
|
||||
}],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
@ -53,6 +57,10 @@ TestCase {
|
||||
"name": "High",
|
||||
"totalSupplyRaw": "1000000000000",
|
||||
"balanceRaw": "5000000000",
|
||||
"holdings": [{
|
||||
"holdingId": "55555555555555555555555555555555",
|
||||
"balanceRaw": "5000000000"
|
||||
}],
|
||||
"selectable": true
|
||||
}
|
||||
]
|
||||
@ -68,6 +76,10 @@ TestCase {
|
||||
"name": "Sir Mints-a-Lot",
|
||||
"totalSupplyRaw": "1000000000000",
|
||||
"balanceRaw": "1000000000",
|
||||
"holdings": [{
|
||||
"holdingId": "44444444444444444444444444444444",
|
||||
"balanceRaw": "1000000000"
|
||||
}],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
@ -75,19 +87,55 @@ TestCase {
|
||||
"name": "Aurora",
|
||||
"totalSupplyRaw": "1000000000000",
|
||||
"balanceRaw": "1000000000",
|
||||
"holdings": [{
|
||||
"holdingId": "55555555555555555555555555555555",
|
||||
"balanceRaw": "1000000000"
|
||||
}],
|
||||
"selectable": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function holdingContext() {
|
||||
return {
|
||||
"status": "ready",
|
||||
"tokens": [
|
||||
{
|
||||
"definitionId": tokenLow,
|
||||
"name": "Low",
|
||||
"balanceRaw": "10",
|
||||
"selectable": true,
|
||||
"holdings": [
|
||||
{ "holdingId": "44444444444444444444444444444444",
|
||||
"balanceRaw": "10" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"definitionId": tokenHigh,
|
||||
"name": "High",
|
||||
"balanceRaw": "50",
|
||||
"selectable": true,
|
||||
"holdings": [
|
||||
{ "holdingId": "55555555555555555555555555555555",
|
||||
"balanceRaw": "20" },
|
||||
{ "holdingId": "66666666666666666666666666666666",
|
||||
"balanceRaw": "30" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function flowState(quote) {
|
||||
return {
|
||||
"quote": quote || ({}),
|
||||
"contextLoading": false,
|
||||
"quoteLoading": false,
|
||||
"quoteStale": false,
|
||||
"submitting": false
|
||||
"submitting": false,
|
||||
"walletCanSubmit": true,
|
||||
"walletSyncStatus": "ready"
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,6 +166,23 @@ TestCase {
|
||||
compare(form.amountB, "")
|
||||
}
|
||||
|
||||
function test_holdingSelectionAutoSelectsOneAndRequiresExplicitMultiple() {
|
||||
var form = createForm(holdingContext())
|
||||
wait(0)
|
||||
compare(form.selectedHoldingAId,
|
||||
"44444444444444444444444444444444")
|
||||
compare(form.selectedHoldingBId, "")
|
||||
verify(!form.buildQuoteRequest().ok)
|
||||
|
||||
form.selectHolding("B", "66666666666666666666666666666666")
|
||||
var built = form.buildQuoteRequest()
|
||||
verify(built.ok)
|
||||
compare(built.request.holdingAId,
|
||||
"66666666666666666666666666666666")
|
||||
compare(built.request.holdingBId,
|
||||
"44444444444444444444444444444444")
|
||||
}
|
||||
|
||||
function test_displayOrderMapsToCanonicalRequestAfterSwap() {
|
||||
var form = createForm()
|
||||
var built = form.buildQuoteRequest()
|
||||
@ -354,7 +419,7 @@ TestCase {
|
||||
form.minimumAmountBRaw = "2000000"
|
||||
|
||||
verify(form.acceptPoolActivation({
|
||||
"schema": "new-position.v1",
|
||||
"schema": "new-position.v2",
|
||||
"status": "ok",
|
||||
"tokenAId": tokenHigh,
|
||||
"tokenBId": tokenLow,
|
||||
@ -378,7 +443,7 @@ TestCase {
|
||||
|
||||
function test_staleQuoteErrorsDoNotMarkCurrentDraft() {
|
||||
var quote = {
|
||||
"schema": "new-position.v1",
|
||||
"schema": "new-position.v2",
|
||||
"status": "ok",
|
||||
"tokenAId": tokenHigh,
|
||||
"tokenBId": tokenLow,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user