feat: Introduce SequencerClient for enhanced account management and transaction querying

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

View File

@ -42,6 +42,8 @@ logos_module(
src/AmmClient.cpp
src/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
)

View File

@ -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

View File

@ -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"
);
}
}

View File

@ -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()

View File

@ -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
}

View File

@ -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)
}

View File

@ -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(),

View File

@ -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 {

View File

@ -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,

View File

@ -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>,

View File

@ -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(&quote_value, &plan_value, Some(fresh_lp));
}
@ -661,6 +717,15 @@ fn active_pool_quote_uses_ratio_and_existing_lp_holding() {
scenario.request.max_amount_b_raw = Some(String::from("3000"));
scenario.request.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");

View File

@ -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

View File

@ -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");
}

View File

@ -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
}
}

View File

@ -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))
}

View File

@ -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"))
}
}

View File

@ -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)
}

View File

@ -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,

View File

@ -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);
}

View File

@ -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;
};

View File

@ -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({});
});
}

View File

@ -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

View File

@ -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()

View File

@ -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();
}

View File

@ -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;
};

View File

@ -0,0 +1,302 @@
#include "SequencerClient.h"
#include <algorithm>
#include <array>
#include <cstddef>
#include <memory>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QTimer>
#include <libbase58.h>
#include "AmmClient.h"
namespace {
constexpr int MAX_CONCURRENT_READS = 8;
constexpr qsizetype ACCOUNT_ID_HEX_SIZE = 64;
constexpr std::size_t BASE58_BUFFER_SIZE = 45;
bool isLowerHex(const QString& value, qsizetype size)
{
if (value.size() != size)
return false;
return std::all_of(value.cbegin(), value.cend(), [](QChar character) {
return character.isDigit()
|| (character >= QLatin1Char('a') && character <= QLatin1Char('f'));
});
}
QString accountIdBase58(const QString& accountId)
{
if (!isLowerHex(accountId, ACCOUNT_ID_HEX_SIZE))
return {};
const QByteArray bytes = QByteArray::fromHex(accountId.toLatin1());
std::array<char, BASE58_BUFFER_SIZE> encoded {};
std::size_t size = encoded.size();
if (!b58enc(encoded.data(), &size, bytes.constData(),
static_cast<std::size_t>(bytes.size()))) {
return {};
}
return QString::fromLatin1(encoded.data(), static_cast<qsizetype>(size - 1));
}
QByteArray rpcBody(const QString& method, const QJsonArray& params)
{
return QJsonDocument(QJsonObject {
{ QStringLiteral("jsonrpc"), QStringLiteral("2.0") },
{ QStringLiteral("id"), 1 },
{ QStringLiteral("method"), method },
{ QStringLiteral("params"), params },
}).toJson(QJsonDocument::Compact);
}
WalletAccountRead accountRead(const QJsonObject& value, const QString& fallbackId)
{
WalletAccountRead read;
read.accountId = value.value(QStringLiteral("id")).toString(fallbackId);
read.status = value.value(QStringLiteral("status")).toString(
QStringLiteral("read_failed"));
const QJsonObject account = value.value(QStringLiteral("account")).toObject();
if (read.ok()) {
read.programOwner = account.value(QStringLiteral("program_owner")).toString();
read.balanceHex = account.value(QStringLiteral("balance")).toString();
read.nonceHex = account.value(QStringLiteral("nonce")).toString();
read.dataHex = account.value(QStringLiteral("data")).toString();
}
return read;
}
}
SequencerClient::SequencerClient(AmmClient* client, QObject* parent)
: QObject(parent),
m_client(client),
m_network(new QNetworkAccessManager(this))
{
}
bool SequencerClient::configure(const QString& configPath)
{
++m_generation;
cancelPendingReads();
clear();
QFile file(configPath);
if (!file.open(QIODevice::ReadOnly)) {
m_endpoint = QUrl();
m_authorization.clear();
return false;
}
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
const QJsonObject config = document.object();
const QUrl endpoint(config.value(QStringLiteral("sequencer_addr")).toString());
const QString scheme = endpoint.scheme().toLower();
if (!document.isObject() || !endpoint.isValid() || endpoint.host().isEmpty()
|| (scheme != QStringLiteral("http") && scheme != QStringLiteral("https"))) {
m_endpoint = QUrl();
m_authorization.clear();
return false;
}
m_endpoint = endpoint;
m_authorization.clear();
const QJsonObject basicAuth = config.value(QStringLiteral("basic_auth")).toObject();
const QString username = basicAuth.value(QStringLiteral("username")).toString();
const QString password = basicAuth.value(QStringLiteral("password")).toString();
if (!username.isEmpty()) {
m_authorization = QByteArrayLiteral("Basic ")
+ (username + QLatin1Char(':') + password).toUtf8().toBase64();
}
return true;
}
void SequencerClient::readAccounts(const QStringList& accountIds,
bool forceRefresh,
AccountsCallback callback)
{
if (accountIds.isEmpty()) {
QTimer::singleShot(0, this, [callback = std::move(callback)]() mutable {
callback({});
});
return;
}
struct Batch {
QVector<WalletAccountRead> reads;
qsizetype remaining = 0;
AccountsCallback callback;
};
auto batch = std::make_shared<Batch>();
batch->reads.resize(accountIds.size());
batch->remaining = accountIds.size();
batch->callback = std::move(callback);
for (qsizetype index = 0; index < accountIds.size(); ++index) {
readAccount(accountIds.at(index), forceRefresh,
[batch, index](WalletAccountRead read) mutable {
batch->reads[index] = std::move(read);
if (--batch->remaining == 0)
batch->callback(std::move(batch->reads));
});
}
}
void SequencerClient::readAccount(const QString& accountId,
bool forceRefresh,
AccountCallback callback)
{
if (!isConfigured() || !isLowerHex(accountId, ACCOUNT_ID_HEX_SIZE)) {
QTimer::singleShot(0, this,
[callback = std::move(callback), accountId]() mutable {
callback(WalletAccountRead { accountId });
});
return;
}
if (!forceRefresh && m_cache.contains(accountId)) {
const WalletAccountRead cached = m_cache.value(accountId);
QTimer::singleShot(0, this,
[callback = std::move(callback), cached]() mutable { callback(cached); });
return;
}
const bool alreadyPending = m_waiters.contains(accountId);
m_waiters[accountId].append(std::move(callback));
if (!alreadyPending)
m_pending.enqueue({ accountId });
startPendingReads();
}
void SequencerClient::startPendingReads()
{
while (m_activeReads < MAX_CONCURRENT_READS && !m_pending.isEmpty()) {
const PendingRead pending = m_pending.dequeue();
++m_activeReads;
startRead(pending.accountId);
}
}
void SequencerClient::startRead(const QString& accountId)
{
const quint64 generation = m_generation;
const QString encodedId = accountIdBase58(accountId);
if (encodedId.isEmpty()) {
completeRead(accountId, WalletAccountRead { accountId });
return;
}
QNetworkReply* reply = m_network->post(
request(), rpcBody(QStringLiteral("getAccount"), QJsonArray { encodedId }));
connect(reply, &QNetworkReply::finished, this,
[this, reply, accountId, generation]() {
if (generation != m_generation) {
reply->deleteLater();
return;
}
WalletAccountRead read { accountId };
const int status = reply->attribute(
QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray payload = reply->readAll();
if (reply->error() == QNetworkReply::NoError
&& status >= 200 && status < 300) {
const AmmClientResult normalized =
m_client->normalizeAccountRpc(QJsonObject {
{ QStringLiteral("accountId"), accountId },
{ QStringLiteral("response"), QString::fromUtf8(payload) },
});
if (normalized.ok)
read = accountRead(normalized.value, accountId);
}
reply->deleteLater();
completeRead(accountId, read);
});
}
void SequencerClient::completeRead(const QString& accountId,
const WalletAccountRead& read)
{
if (read.ok())
m_cache.insert(accountId, read);
const QVector<AccountCallback> callbacks = m_waiters.take(accountId);
--m_activeReads;
for (const AccountCallback& callback : callbacks)
callback(read);
startPendingReads();
}
void SequencerClient::queryTransaction(const QString& nativeHash,
TransactionCallback callback)
{
if (!isConfigured() || !isLowerHex(nativeHash, ACCOUNT_ID_HEX_SIZE)) {
QTimer::singleShot(0, this,
[callback = std::move(callback)]() mutable { callback(false, false); });
return;
}
const quint64 generation = m_generation;
QNetworkReply* reply = m_network->post(
request(), rpcBody(QStringLiteral("getTransaction"), QJsonArray { nativeHash }));
connect(reply, &QNetworkReply::finished, this,
[this, reply, generation, callback = std::move(callback)]() mutable {
if (generation != m_generation) {
reply->deleteLater();
callback(false, false);
return;
}
const int status = reply->attribute(
QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray payload = reply->readAll();
QJsonParseError error;
const QJsonDocument document = QJsonDocument::fromJson(payload, &error);
const QJsonObject envelope = document.object();
const bool ok = reply->error() == QNetworkReply::NoError
&& status >= 200 && status < 300
&& error.error == QJsonParseError::NoError
&& document.isObject()
&& (!envelope.contains(QStringLiteral("error"))
|| envelope.value(QStringLiteral("error")).isNull())
&& envelope.contains(QStringLiteral("result"));
const bool included = ok
&& !envelope.value(QStringLiteral("result")).isNull();
reply->deleteLater();
callback(ok, included);
});
}
void SequencerClient::clear()
{
m_cache.clear();
}
void SequencerClient::cancelPendingReads()
{
decltype(m_waiters) waiters;
waiters.swap(m_waiters);
m_pending.clear();
m_activeReads = 0;
for (auto iterator = waiters.cbegin(); iterator != waiters.cend(); ++iterator) {
const WalletAccountRead failed { iterator.key() };
for (const AccountCallback& callback : iterator.value()) {
QTimer::singleShot(0, this, [callback, failed]() {
callback(failed);
});
}
}
}
void SequencerClient::applyAuthorization(QNetworkRequest& request) const
{
if (!m_authorization.isEmpty())
request.setRawHeader(QByteArrayLiteral("Authorization"), m_authorization);
}
QNetworkRequest SequencerClient::request() const
{
QNetworkRequest request(m_endpoint);
request.setHeader(QNetworkRequest::ContentTypeHeader,
QStringLiteral("application/json"));
request.setTransferTimeout(4000);
applyAuthorization(request);
return request;
}

View File

@ -0,0 +1,61 @@
#pragma once
#include <functional>
#include <QHash>
#include <QObject>
#include <QQueue>
#include <QStringList>
#include <QUrl>
#include <QVector>
#include "WalletProvider.h"
class AmmClient;
class QNetworkAccessManager;
class QNetworkRequest;
class SequencerClient final : public QObject {
Q_OBJECT
public:
using AccountsCallback = std::function<void(QVector<WalletAccountRead>)>;
using TransactionCallback = std::function<void(bool ok, bool included)>;
explicit SequencerClient(AmmClient* client, QObject* parent = nullptr);
bool configure(const QString& configPath);
QString endpoint() const { return m_endpoint.toString(); }
bool isConfigured() const { return m_endpoint.isValid() && !m_endpoint.isEmpty(); }
void applyAuthorization(QNetworkRequest& request) const;
void readAccounts(const QStringList& accountIds,
bool forceRefresh,
AccountsCallback callback);
void queryTransaction(const QString& nativeHash, TransactionCallback callback);
void clear();
private:
using AccountCallback = std::function<void(WalletAccountRead)>;
struct PendingRead {
QString accountId;
};
void readAccount(const QString& accountId, bool forceRefresh, AccountCallback callback);
void startPendingReads();
void startRead(const QString& accountId);
void completeRead(const QString& accountId, const WalletAccountRead& read);
void cancelPendingReads();
QNetworkRequest request() const;
AmmClient* m_client;
QNetworkAccessManager* m_network;
QUrl m_endpoint;
QByteArray m_authorization;
QHash<QString, WalletAccountRead> m_cache;
QHash<QString, QVector<AccountCallback>> m_waiters;
QQueue<PendingRead> m_pending;
int m_activeReads = 0;
quint64 m_generation = 0;
};

View File

@ -19,6 +19,11 @@ namespace {
return {};
}
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;

View File

@ -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
}
}

View File

@ -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())
}
}

View File

@ -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,