diff --git a/apps/amm/README.md b/apps/amm/README.md index b33841a0..39ce175a 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -179,11 +179,11 @@ Swap view stays disabled (no pool can be resolved). ### Token list config (required for the Swap token picker) -The Swap view's token picker is config-driven: it doesn't derive tokens from -chain state, it reads a flat JSON list from the `TOKENS_CONFIG` environment -variable (absolute path). Each entry needs, at minimum, the token's -`definitionId` and **your own** `holding` account address for that token (the -account the wallet will sign transfers from/to for that token): +The Swap view's token picker is config-driven: it doesn't derive token +definitions from chain state, it reads a flat JSON list from the +`TOKENS_CONFIG` environment variable (absolute path). Each entry needs, at +minimum, a `definitionId`. Source and destination TokenHoldings come from the +connected wallet: ```json [ @@ -191,7 +191,6 @@ account the wallet will sign transfers from/to for that token): "symbol": "TKA", "name": "Token A", "definitionId": "9qbX…", - "holding": "4T69…", "decimals": 18 } ] @@ -199,8 +198,9 @@ account the wallet will sign transfers from/to for that token): If `TOKENS_CONFIG` is unset, unreadable, or not a valid JSON array, the token picker stays empty (a `qWarning` naming the exact cause is logged to stderr; no -swap can be started). `definitionId`/`holding` may be given as base58 (as the -wallet/runbook display them) or hex — the app normalizes both to hex. +swap can be started). `definitionId` may be given as base58 (as the +wallet/runbook displays it) or hex. A legacy `holding` value is accepted but is +not used for transaction account selection. Full command with both variables set (absolute paths, from the repo root): diff --git a/apps/amm/client/src/api/accounts.rs b/apps/amm/client/src/api/accounts.rs index a1c3c881..36207569 100644 --- a/apps/amm/client/src/api/accounts.rs +++ b/apps/amm/client/src/api/accounts.rs @@ -27,6 +27,7 @@ pub(super) fn missing_account_plan( ])?; append_holding_source(&mut sources, "holding_a", holdings.token_a); append_holding_source(&mut sources, "holding_b", holdings.token_b); + append_holding_source(&mut sources, "holding_lp", holdings.lp); Ok(AccountPlan { rows: vec![ AccountPlanRow::new( @@ -95,11 +96,15 @@ pub(super) fn missing_account_plan( ), AccountPlanRow::new( "user_holding_lp", - None, + holdings.lp.map(|value| value.id), Some(pair.token_program), - "create", - true, - true, + if holdings.lp.is_some() { + "update" + } else { + "create" + }, + holdings.lp.is_none(), + holdings.lp.is_none(), ), AccountPlanRow::new( "current_tick", diff --git a/apps/amm/client/src/api/context.rs b/apps/amm/client/src/api/context.rs index 601bdc44..0f564402 100644 --- a/apps/amm/client/src/api/context.rs +++ b/apps/amm/client/src/api/context.rs @@ -9,7 +9,7 @@ use token_core::TokenDefinition; use super::{ config::load_config, - holding::{select_holding, wallet_holdings, SelectedHolding}, + holding::{holding_options, wallet_holdings, SelectedHolding}, quote_error::issue, ContextRequest, TokenIdsRequest, SCHEMA, }; @@ -59,6 +59,26 @@ pub(super) fn context(request: ContextRequest) -> Result { }; let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id); + let mut program_accounts = holdings.clone(); + program_accounts.sort_by_key(|holding| holding.id); + let program_accounts = program_accounts + .into_iter() + .map(|holding| { + json!({ + "accountId": holding.id.to_string(), + "address": account_id_hex(holding.id), + "displayAddress": holding.id.to_string(), + "accountType": "TokenHolding", + "definitionId": account_id_hex(holding.definition_id), + "definitionDisplayId": holding.definition_id.to_string(), + "balanceRaw": holding.balance.to_string(), + "state": { + "definitionId": account_id_hex(holding.definition_id), + "balanceRaw": holding.balance.to_string(), + }, + }) + }) + .collect::>(); let source_map = token_sources(&request, &holdings); let mut rows = Vec::new(); let mut warnings = Vec::new(); @@ -85,9 +105,13 @@ pub(super) fn context(request: ContextRequest) -> Result { } }; - let selected = select_holding(&holdings, token_id); - let mut row = json!({ + let options = holding_options(&holdings, token_id); + let total_balance = options.iter().fold(0_u128, |total, holding| { + total.saturating_add(holding.balance) + }); + let row = json!({ "definitionId": token_id.to_string(), + "definitionIdHex": account_id_hex(token_id), "name": name, "metadataId": metadata_id.map(|id| id.to_string()), "totalSupplyRaw": total_supply.to_string(), @@ -98,17 +122,23 @@ pub(super) fn context(request: ContextRequest) -> Result { "status": "available", "code": "available", "sources": sources, + "balanceRaw": total_balance.to_string(), + "holdings": options.into_iter().map(|holding| json!({ + "holdingId": holding.id.to_string(), + "address": account_id_hex(holding.id), + "balanceRaw": holding.balance.to_string(), + })).collect::>(), }); - if let Some(selected) = selected { - row["holdingId"] = json!(selected.id.to_string()); - row["balanceRaw"] = json!(selected.balance.to_string()); - } rows.push(row); } rows.sort_by(|left, right| { - let left_holding = left.get("holdingId").is_some(); - let right_holding = right.get("holdingId").is_some(); + let left_holding = left["holdings"] + .as_array() + .is_some_and(|rows| !rows.is_empty()); + let right_holding = right["holdings"] + .as_array() + .is_some_and(|rows| !rows.is_empty()); right_holding.cmp(&left_holding).then_with(|| { left["definitionId"] .as_str() @@ -129,6 +159,7 @@ pub(super) fn context(request: ContextRequest) -> Result { "twapOracle": program_id_base58(config.twap_oracle_program_id), }, "tokens": rows, + "programAccounts": program_accounts, "feeTiers": fee_tiers(), "warnings": warnings, })) @@ -143,6 +174,7 @@ fn context_error(request: &ContextRequest, code: &str) -> Value { "networkFingerprint": request.network_fingerprint, "walletAvailable": request.wallet_available, "tokens": [], + "programAccounts": [], "feeTiers": fee_tiers(), "warnings": [], }) @@ -195,6 +227,7 @@ fn token_sources( fn unavailable_token_row(token_id: AccountId, sources: Vec, code: &str) -> Value { json!({ "definitionId": token_id.to_string(), + "definitionIdHex": account_id_hex(token_id), "name": "", "metadataId": Value::Null, "totalSupplyRaw": "0", diff --git a/apps/amm/client/src/api/holding.rs b/apps/amm/client/src/api/holding.rs index 84810406..53cdb37e 100644 --- a/apps/amm/client/src/api/holding.rs +++ b/apps/amm/client/src/api/holding.rs @@ -4,7 +4,7 @@ use nssa_core::{ }; use token_core::TokenHolding; -use crate::account::{decode_account, AccountRead}; +use crate::account::{account_id_from_hex, decode_account, parse_base58_id, AccountRead}; #[derive(Clone)] pub(super) struct SelectedHolding { @@ -51,14 +51,35 @@ pub(super) fn decode_fungible_holding( pub(super) fn select_holding( holdings: &[SelectedHolding], definition_id: AccountId, + requested_id: Option<&str>, ) -> Option { - holdings + let options = holding_options(holdings, definition_id); + let Some(requested_id) = requested_id else { + return options.first().cloned(); + }; + let requested_id = account_id_from_hex(requested_id, "holding id") + .or_else(|_| parse_base58_id(requested_id, "holding id")) + .ok()?; + options + .iter() + .find(|holding| holding.id == requested_id) + .cloned() +} + +pub(super) fn holding_options( + holdings: &[SelectedHolding], + definition_id: AccountId, +) -> Vec { + let mut options = holdings .iter() .filter(|holding| holding.definition_id == definition_id) - .max_by(|left, right| { - left.balance - .cmp(&right.balance) - .then_with(|| right.id.cmp(&left.id)) - }) .cloned() + .collect::>(); + options.sort_by(|left, right| { + right + .balance + .cmp(&left.balance) + .then_with(|| left.id.cmp(&right.id)) + }); + options } diff --git a/apps/amm/client/src/api/quote.rs b/apps/amm/client/src/api/quote.rs index 9d4e3226..5677fc4b 100644 --- a/apps/amm/client/src/api/quote.rs +++ b/apps/amm/client/src/api/quote.rs @@ -17,7 +17,9 @@ 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, SelectedHolding, + }, pair::{derive_pair, is_canonical_pair, PairIds}, position::{ AccountPlan, AccountPlanHoldings, EvaluatedQuote, NewPositionPlan, QuoteBranch, @@ -27,7 +29,8 @@ use super::{ QuoteRequest, SCHEMA, }; use crate::account::{ - decode_account, parse_base58_id, parse_program_id, program_id_bytes, AccountRead, + account_id_hex, decode_account, parse_base58_id, parse_program_id, program_id_bytes, + AccountRead, }; const DEFAULT_SLIPPAGE_BPS: u32 = 50; @@ -213,9 +216,19 @@ 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 lp_destination = select_lp_destination(input, &holdings, pair.lp_definition); + 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 +236,10 @@ fn compute_missing_quote( &holding_b, amount_b, ["amountARaw", "amountBRaw"], - ); + )); + if let Some(error) = lp_destination.error.clone() { + funding.push(error); + } let can_submit = funding.is_empty(); let mut account_plan = missing_account_plan( input, @@ -232,7 +248,7 @@ fn compute_missing_quote( AccountPlanHoldings { token_a: holding_a.as_ref(), token_b: holding_b.as_ref(), - lp: None, + lp: lp_destination.selected.as_ref(), }, )?; let sources = account_plan.take_sources(); @@ -253,7 +269,7 @@ fn compute_missing_quote( actual_b: amount_b, expected_lp, lp_guard: MINIMUM_LIQUIDITY, - requires_fresh_lp: true, + requires_fresh_lp: lp_destination.requires_fresh, sources, funding: funding_commitment, warnings: Vec::new(), @@ -281,7 +297,12 @@ fn compute_missing_quote( "initialPriceRealRaw": spot_price_q64_64(amount_a, amount_b).to_string(), "minimumAmountARaw": minimum_a.to_string(), "minimumAmountBRaw": minimum_b.to_string(), - "requiresFreshLp": true, + "requiresFreshLp": lp_destination.requires_fresh, + "lpDefinitionId": pair.lp_definition.to_string(), + "lpDefinitionIdHex": account_id_hex(pair.lp_definition), + "lpDestinationRequired": lp_destination.error.is_some(), + "lpHoldingOptions": holding_rows(&lp_destination.options), + "selectedLpHoldingId": lp_destination.selected.as_ref().map(|holding| holding.id.to_string()), "accountPreview": preview, "errors": funding, "warnings": [], @@ -440,11 +461,19 @@ 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_destination = select_lp_destination(input, &holdings, pair.lp_definition); + 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,10 @@ fn compute_active_quote( &holding_b, actual_b, ["maxAmountARaw", "maxAmountBRaw"], - ); + )); + if let Some(error) = lp_destination.error.clone() { + funding.push(error); + } let can_submit = funding.is_empty(); let warnings = if slippage_bps >= HIGH_SLIPPAGE_BPS { vec![issue( @@ -477,7 +509,7 @@ fn compute_active_quote( AccountPlanHoldings { token_a: holding_a.as_ref(), token_b: holding_b.as_ref(), - lp: lp_holding.as_ref(), + lp: lp_destination.selected.as_ref(), }, )?; let sources = account_plan.take_sources(); @@ -501,7 +533,7 @@ fn compute_active_quote( actual_b, expected_lp, lp_guard: minimum_lp, - requires_fresh_lp, + requires_fresh_lp: lp_destination.requires_fresh, sources, funding: funding_commitments(pair, &holding_a, actual_a, &holding_b, actual_b), warnings: warning_codes, @@ -531,7 +563,12 @@ fn compute_active_quote( "expectedLpRaw": expected_lp.to_string(), "minimumLpRaw": minimum_lp.to_string(), "initialPriceRealRaw": spot_price_q64_64(reserve_a, reserve_b).to_string(), - "requiresFreshLp": requires_fresh_lp, + "requiresFreshLp": lp_destination.requires_fresh, + "lpDefinitionId": pair.lp_definition.to_string(), + "lpDefinitionIdHex": account_id_hex(pair.lp_definition), + "lpDestinationRequired": lp_destination.error.is_some(), + "lpHoldingOptions": holding_rows(&lp_destination.options), + "selectedLpHoldingId": lp_destination.selected.as_ref().map(|holding| holding.id.to_string()), "accountPreview": preview, "errors": funding, "warnings": warnings, @@ -556,6 +593,138 @@ fn compute_active_quote( })) } +struct LpDestination { + options: Vec, + selected: Option, + requires_fresh: bool, + error: Option, +} + +fn select_lp_destination( + input: &QuoteRequest, + holdings: &[SelectedHolding], + definition_id: AccountId, +) -> LpDestination { + let options = holding_options(holdings, definition_id); + if input.request.create_fresh_lp && input.request.lp_holding_id.is_some() { + return LpDestination { + options, + selected: None, + requires_fresh: false, + error: Some(issue( + "invalid_lp_destination", + "Choose either a wallet holding or a new TokenHolding.", + &["lpHoldingId", "createFreshLp"], + json!({}), + )), + }; + } + if input.request.create_fresh_lp { + return LpDestination { + options, + selected: None, + requires_fresh: true, + error: None, + }; + } + if input.request.lp_holding_id.is_some() { + let selected = select_holding( + holdings, + definition_id, + input.request.lp_holding_id.as_deref(), + ); + let error = selected.is_none().then(|| { + issue( + "invalid_lp_destination", + "Selected LP TokenHolding is unavailable.", + &["lpHoldingId"], + json!({ "available": options.len() }), + ) + }); + return LpDestination { + options, + selected, + requires_fresh: false, + error, + }; + } + if options.is_empty() { + LpDestination { + options, + selected: None, + requires_fresh: true, + error: None, + } + } else { + LpDestination { + selected: options.first().cloned(), + options, + requires_fresh: false, + error: None, + } + } +} + +fn holding_selection_issues( + input: &QuoteRequest, + pair: PairIds, + holdings: &[SelectedHolding], + holding_a: &Option, + holding_b: &Option, +) -> Vec { + if !input.snapshot.wallet_available { + return Vec::new(); + } + + let mut errors = Vec::new(); + for (definition_id, requested, selected, field) in [ + ( + pair.token_a, + input.request.holding_a_id.as_deref(), + holding_a, + "holdingAId", + ), + ( + pair.token_b, + input.request.holding_b_id.as_deref(), + holding_b, + "holdingBId", + ), + ] { + let options = holding_options(holdings, definition_id); + if selected.is_some() || (requested.is_none() && options.len() <= 1) { + continue; + } + 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 holding_rows(holdings: &[SelectedHolding]) -> Vec { + holdings + .iter() + .map(|holding| { + json!({ + "holdingId": holding.id.to_string(), + "address": account_id_hex(holding.id), + "balanceRaw": holding.balance.to_string(), + }) + }) + .collect() +} + fn validate_active_accounts( input: &QuoteRequest, pair: PairIds, diff --git a/apps/amm/client/src/api/request.rs b/apps/amm/client/src/api/request.rs index 082b143d..ca83c950 100644 --- a/apps/amm/client/src/api/request.rs +++ b/apps/amm/client/src/api/request.rs @@ -60,6 +60,14 @@ pub struct PositionRequest { pub token_b_id: String, pub fee_bps: u32, #[serde(default)] + pub holding_a_id: Option, + #[serde(default)] + pub holding_b_id: Option, + #[serde(default)] + pub lp_holding_id: Option, + #[serde(default)] + pub create_fresh_lp: bool, + #[serde(default)] pub amount_a_raw: Option, #[serde(default)] pub amount_b_raw: Option, diff --git a/apps/amm/client/src/api/tests.rs b/apps/amm/client/src/api/tests.rs index 520899da..96478a15 100644 --- a/apps/amm/client/src/api/tests.rs +++ b/apps/amm/client/src/api/tests.rs @@ -144,6 +144,10 @@ fn request(pair: PairIds) -> PositionRequest { token_a_id: pair.token_a.to_string(), token_b_id: pair.token_b.to_string(), fee_bps: 30, + holding_a_id: None, + holding_b_id: None, + lp_holding_id: None, + create_fresh_lp: false, amount_a_raw: None, amount_b_raw: None, max_amount_a_raw: None, @@ -214,6 +218,69 @@ impl Scenario { } } +fn active_scenario(lp_holdings: &[(AccountId, u128)]) -> Scenario { + let mut scenario = Scenario::testnet(); + let pair = scenario.pair; + let pool = PoolDefinition { + definition_token_a_id: pair.token_a, + definition_token_b_id: pair.token_b, + vault_a_id: pair.vault_a, + vault_b_id: pair.vault_b, + liquidity_pool_id: pair.lp_definition, + liquidity_pool_supply: 10_000, + reserve_a: 10_000, + reserve_b: 20_000, + fees: 30, + }; + scenario.snapshot.pool = account_read(pair.pool, &account(AMM_PROGRAM, Data::from(&pool))); + scenario.snapshot.vault_a = + account_read(pair.vault_a, &token_holding(pair.token_a, pool.reserve_a)); + scenario.snapshot.vault_b = + account_read(pair.vault_b, &token_holding(pair.token_b, pool.reserve_b)); + scenario.snapshot.lp_definition = account_read( + pair.lp_definition, + &account( + TOKEN_PROGRAM, + Data::from(&TokenDefinition::Fungible { + name: String::from("LP"), + total_supply: pool.liquidity_pool_supply, + metadata_id: None, + authority: Some(pair.lp_definition), + }), + ), + ); + scenario.snapshot.current_tick = account_read( + pair.current_tick, + &account( + TWAP_PROGRAM, + Data::from(&CurrentTickAccount { + tick: 0, + last_updated: 1_000, + }), + ), + ); + scenario.snapshot.wallet_accounts = vec![ + account_read( + AccountId::new([61; 32]), + &token_holding(pair.token_a, 1_000), + ), + account_read( + AccountId::new([62; 32]), + &token_holding(pair.token_b, 2_000), + ), + ]; + scenario.snapshot.wallet_accounts.extend( + lp_holdings + .iter() + .map(|(id, balance)| account_read(*id, &token_holding(pair.lp_definition, *balance))), + ); + scenario.request.initial_price_real_raw = None; + scenario.request.max_amount_a_raw = Some(String::from("1000")); + scenario.request.max_amount_b_raw = Some(String::from("3000")); + scenario.request.slippage_bps = Some(50); + scenario +} + fn assert_preview_matches_plan( quote_value: &Value, plan_value: &Value, @@ -244,8 +311,8 @@ 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, None); + let holding_b = select_holding(&holdings, pair.token_b, None); let missing = missing_account_plan( &input, @@ -337,7 +404,7 @@ fn minimum_pair_is_minimal_on_price_base_side() { } #[test] -fn highest_balance_holding_wins_then_lowest_id() { +fn holding_selection_defaults_to_highest_balance_then_lowest_id() { let definition = AccountId::new([9; 32]); let holding = |id: u8, balance| SelectedHolding { id: AccountId::new([id; 32]), @@ -351,12 +418,18 @@ fn highest_balance_holding_wins_then_lowest_id() { }), ), }; + let holdings = [holding(4, 10), holding(2, 20), holding(1, 20)]; + assert_eq!( + select_holding(&holdings, definition, None).unwrap().id, + AccountId::new([1; 32]) + ); let selected = select_holding( - &[holding(4, 10), holding(2, 20), holding(1, 20)], + &holdings, 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] @@ -508,7 +581,51 @@ fn context_selects_tokens_without_holdings() { .unwrap(); assert_eq!(value["tokens"][0]["selectable"], true); assert_eq!(value["tokens"][0]["sources"], json!(["config"])); - assert!(value["tokens"][0].get("holdingId").is_none()); + assert_eq!(value["tokens"][0]["holdings"], json!([])); + assert_eq!(value["programAccounts"], json!([])); +} + +#[test] +fn context_exposes_all_compatible_holdings_as_program_accounts() { + let token_id = AccountId::new([3; 32]); + let holding_a = AccountId::new([4; 32]); + let holding_b = 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(holding_b, &token_holding(token_id, 80)), + account_read(holding_a, &token_holding(token_id, 120)), + ], + token_definitions: vec![account_read( + token_id, + &token_definition("Token", 1_000_000), + )], + configured_token_ids: vec![account_id_hex(token_id)], + recent_token_ids: Vec::new(), + resolved_token_ids: Vec::new(), + }) + .unwrap(); + + assert_eq!(value["tokens"][0]["balanceRaw"], "200"); + assert_eq!(value["tokens"][0]["holdings"].as_array().unwrap().len(), 2); + assert_eq!( + value["programAccounts"][0]["accountId"], + holding_a.to_string() + ); + assert_eq!(value["programAccounts"][0]["accountType"], "TokenHolding"); + assert_eq!( + value["programAccounts"][0]["state"]["definitionId"], + account_id_hex(token_id) + ); + assert_eq!( + value["programAccounts"][1]["accountId"], + holding_b.to_string() + ); } #[test] @@ -572,6 +689,32 @@ fn missing_pool_quote_accepts_large_direct_raw_amounts() { assert!(quote_value.get("depositScaleBps").is_none()); } +#[test] +fn quote_defaults_to_highest_balance_input_holding_when_multiple_match() { + let mut scenario = Scenario::devnet(); + let extra_holding = AccountId::new([63; 32]); + scenario.snapshot.wallet_accounts.push(account_read( + extra_holding, + &token_holding(scenario.pair.token_a, 2_000_000), + )); + + let defaulted = scenario.quote(); + assert_eq!(defaulted["canSubmit"], true); + assert_eq!( + defaulted["accountPreview"][6]["accountId"], + extra_holding.to_string() + ); + + let original_holding = AccountId::new([61; 32]); + scenario.request.holding_a_id = Some(original_holding.to_string()); + let selected = scenario.quote(); + assert_eq!(selected["canSubmit"], true); + assert_eq!( + selected["accountPreview"][6]["accountId"], + original_holding.to_string() + ); +} + #[test] fn advancing_clock_does_not_stale_quote() { let mut scenario = Scenario::testnet(); @@ -601,65 +744,8 @@ fn advancing_clock_does_not_stale_quote() { #[test] fn active_pool_quote_uses_ratio_and_existing_lp_holding() { - let mut scenario = Scenario::testnet(); - let pair = scenario.pair; - let pool = PoolDefinition { - definition_token_a_id: pair.token_a, - definition_token_b_id: pair.token_b, - vault_a_id: pair.vault_a, - vault_b_id: pair.vault_b, - liquidity_pool_id: pair.lp_definition, - liquidity_pool_supply: 10_000, - reserve_a: 10_000, - reserve_b: 20_000, - fees: 30, - }; - scenario.snapshot.pool = account_read(pair.pool, &account(AMM_PROGRAM, Data::from(&pool))); - scenario.snapshot.vault_a = - account_read(pair.vault_a, &token_holding(pair.token_a, pool.reserve_a)); - scenario.snapshot.vault_b = - account_read(pair.vault_b, &token_holding(pair.token_b, pool.reserve_b)); - scenario.snapshot.lp_definition = account_read( - pair.lp_definition, - &account( - TOKEN_PROGRAM, - Data::from(&TokenDefinition::Fungible { - name: String::from("LP"), - total_supply: pool.liquidity_pool_supply, - metadata_id: None, - authority: Some(pair.lp_definition), - }), - ), - ); - scenario.snapshot.current_tick = account_read( - pair.current_tick, - &account( - TWAP_PROGRAM, - Data::from(&CurrentTickAccount { - tick: 0, - last_updated: 1_000, - }), - ), - ); - scenario.snapshot.wallet_accounts = vec![ - account_read( - AccountId::new([61; 32]), - &token_holding(pair.token_a, 1_000), - ), - account_read( - AccountId::new([62; 32]), - &token_holding(pair.token_b, 2_000), - ), - ]; let lp_holding = AccountId::new([64; 32]); - scenario.snapshot.wallet_accounts.push(account_read( - lp_holding, - &token_holding(pair.lp_definition, 500), - )); - scenario.request.initial_price_real_raw = None; - scenario.request.max_amount_a_raw = Some(String::from("1000")); - scenario.request.max_amount_b_raw = Some(String::from("3000")); - scenario.request.slippage_bps = Some(50); + let scenario = active_scenario(&[(lp_holding, 500)]); let quote_value = scenario.quote(); assert_eq!(quote_value["poolStatus"], "active_pool"); @@ -679,6 +765,46 @@ fn active_pool_quote_uses_ratio_and_existing_lp_holding() { assert_preview_matches_plan("e_value, &plan_value, None); } +#[test] +fn active_pool_quote_defaults_to_highest_balance_lp_destination() { + let lp_a = AccountId::new([64; 32]); + let lp_b = AccountId::new([65; 32]); + let mut scenario = active_scenario(&[(lp_a, 500), (lp_b, 200)]); + + let defaulted = scenario.quote(); + assert_eq!(defaulted["canSubmit"], true); + assert_eq!(defaulted["lpDestinationRequired"], false); + assert_eq!(defaulted["lpHoldingOptions"].as_array().unwrap().len(), 2); + assert_eq!(defaulted["selectedLpHoldingId"], lp_a.to_string()); + + scenario.request.lp_holding_id = Some(lp_b.to_string()); + let selected = scenario.quote(); + assert_eq!(selected["canSubmit"], true); + assert_eq!(selected["selectedLpHoldingId"], lp_b.to_string()); + assert_eq!(selected["requiresFreshLp"], false); +} + +#[test] +fn active_pool_quote_can_force_fresh_lp_destination() { + let lp_holding = AccountId::new([64; 32]); + let mut scenario = active_scenario(&[(lp_holding, 500)]); + scenario.request.create_fresh_lp = true; + + let quote_value = scenario.quote(); + assert_eq!(quote_value["canSubmit"], true); + assert_eq!(quote_value["selectedLpHoldingId"], Value::Null); + assert_eq!(quote_value["requiresFreshLp"], true); + + let fresh_lp = AccountId::new([66; 32]); + let plan_value = scenario.plan( + quote_value["quoteHash"].as_str().unwrap(), + Some(default_read(fresh_lp)), + ); + assert_eq!(plan_value["status"], "ready"); + assert_eq!(plan_value["accountIds"][7], account_id_hex(fresh_lp)); + assert_eq!(plan_value["signingRequirements"][7], true); +} + #[test] fn matching_unfunded_quote_has_no_transaction_plan() { let mut scenario = Scenario::devnet(); diff --git a/apps/amm/qml/components/liquidity/NewPositionForm.qml b/apps/amm/qml/components/liquidity/NewPositionForm.qml index 9cd98968..da22d3fd 100644 --- a/apps/amm/qml/components/liquidity/NewPositionForm.qml +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -61,10 +61,17 @@ AmmActionCard { }) readonly property var tokens: root.newPositionContext && root.newPositionContext.tokens ? root.newPositionContext.tokens : [] + readonly property var programAccounts: root.newPositionContext + && root.newPositionContext.programAccounts + ? root.newPositionContext.programAccounts : [] readonly property var feeTiers: root.newPositionContext && root.newPositionContext.feeTiers ? root.newPositionContext.feeTiers : [] readonly property var tokenA: root.tokenById(root.selectedTokenAId) readonly property var tokenB: root.tokenById(root.selectedTokenBId) + readonly property string selectedHoldingAId: tokenAInput.selectedHoldingId + readonly property string selectedHoldingBId: tokenBInput.selectedHoldingId + readonly property var holdingA: tokenAInput.selectedHolding + readonly property var holdingB: tokenBInput.selectedHolding readonly property int decimalsA: 0 readonly property int decimalsB: 0 readonly property bool displayIsCanonical: root.selectedTokenAId.length > 0 @@ -97,6 +104,9 @@ AmmActionCard { && !root.quoteStale && !root.submitting && !root.poolCreationPending + && tokenAInput.holdingReady + && tokenBInput.holdingReady + && lpDestinationSelector.ready signal quoteRequested(bool immediate, var quoteRequest) signal confirmationRequested(var snapshot) @@ -236,15 +246,18 @@ 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() invalid: root.fieldHasError("amountA") + || root.fieldHasError("holdingAId") readOnly: root.submitting || (!root.activePool && !root.missingPool) showMaxButton: root.activePool tokenData: root.tokenA.definitionId ? root.tokenA : null tokens: root.tokens + programAccounts: root.programAccounts selectedTokenId: root.selectedTokenAId tokenInvalid: root.tokenHasError("A") tokenSelectionEnabled: !root.contextLoading && !root.submitting @@ -268,6 +281,10 @@ AmmActionCard { onMaxClicked: root.useMaximum() onTokenSelected: function(tokenId) { root.resolveToken("A", tokenId) } onTokenEntered: function(value) { root.resolveToken("A", value) } + onHoldingSelectionChanged: function(holdingId) { + root.noteDraftChanged() + root.requestQuote(true) + } } AmmPairSeparator { @@ -285,14 +302,17 @@ 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") + || root.fieldHasError("holdingBId") readOnly: root.submitting || (!root.activePool && !root.missingPool) showMaxButton: root.activePool tokenData: root.tokenB.definitionId ? root.tokenB : null tokens: root.tokens + programAccounts: root.programAccounts selectedTokenId: root.selectedTokenBId tokenInvalid: root.tokenHasError("B") tokenSelectionEnabled: !root.contextLoading && !root.submitting @@ -316,6 +336,10 @@ AmmActionCard { onMaxClicked: root.useMaximum() onTokenSelected: function(tokenId) { root.resolveToken("B", tokenId) } onTokenEntered: function(value) { root.resolveToken("B", value) } + onHoldingSelectionChanged: function(holdingId) { + root.noteDraftChanged() + root.requestQuote(true) + } } } @@ -477,6 +501,44 @@ AmmActionCard { } } + ColumnLayout { + Layout.fillWidth: true + spacing: 6 + visible: root.hasPair + + Text { + text: qsTr("LP TokenHolding output") + color: root.theme.colors.textSecondary + font.pixelSize: 12 + } + + ProgramAccountSelector { + id: lpDestinationSelector + + objectName: "lpTokenHoldingSelector" + Layout.fillWidth: true + sourceModel: root.programAccounts + accountType: "TokenHolding" + stateField: "definitionId" + stateValue: root.lpDefinitionIdHex() + selectionMode: ProgramAccountSelector.Output + createNewText: qsTr("Create new TokenHolding") + placeholderText: qsTr("Select LP destination") + criteriaPendingText: qsTr("Resolving LP token") + accessibleName: qsTr("LP TokenHolding destination") + backgroundColor: root.theme.colors.panelBg + hoverColor: root.theme.colors.panelHoverBg + textColor: root.theme.colors.textPrimary + secondaryTextColor: root.theme.colors.textSecondary + borderColor: root.theme.colors.borderStrong + focusColor: root.theme.colors.ctaBg + onSelectionChanged: function(accountId, createNew) { + root.noteDraftChanged() + root.requestQuote(true) + } + } + } + ColumnLayout { Layout.fillWidth: true spacing: 9 @@ -827,6 +889,8 @@ AmmActionCard { } function swapTokens() { + var holdingAId = root.selectedHoldingAId + var holdingBId = root.selectedHoldingBId var tokenId = root.selectedTokenAId root.selectedTokenAId = root.selectedTokenBId root.selectedTokenBId = tokenId @@ -839,8 +903,12 @@ AmmActionCard { var priceAmount = root.priceAmountA root.priceAmountA = root.priceAmountB root.priceAmountB = priceAmount - root.noteDraftChanged() - root.requestQuote(true) + Qt.callLater(function() { + tokenAInput.setHoldingSelection(holdingBId) + tokenBInput.setHoldingSelection(holdingAId) + root.noteDraftChanged() + root.requestQuote(true) + }) } function resetPairDraft() { @@ -1076,7 +1144,7 @@ AmmActionCard { } function pairRequest() { - return { + var request = { "schema": "new-position.v1", "tokenAId": root.displayIsCanonical ? root.selectedTokenAId : root.selectedTokenBId, @@ -1084,6 +1152,20 @@ AmmActionCard { ? 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 + if (lpDestinationSelector.createNewSelected) { + request.createFreshLp = true + } else if (lpDestinationSelector.selectedAccountId.length > 0) { + request.lpHoldingId = lpDestinationSelector.selectedAccountId + } + return request } function requestQuote(immediate) { @@ -1095,7 +1177,10 @@ AmmActionCard { } function probeRaw(token, decimals) { - var balance = String(token.balanceRaw || "0") + var holding = token.definitionId === root.tokenA.definitionId + ? root.holdingA : root.holdingB + var balance = String(holding && holding.balanceRaw + ? holding.balanceRaw : "0") var simulated = AmountMath.multiply(AmountMath.pow10(decimals), "1000") if (AmountMath.isUnsigned(balance) && AmountMath.compare(balance, simulated) > 0) return balance @@ -1135,6 +1220,12 @@ AmmActionCard { return root.displayIsCanonical ? "amountA" : "amountB" if (field === "amountBRaw") return root.displayIsCanonical ? "amountB" : "amountA" + if (field === "holdingAId") + return root.displayIsCanonical ? "holdingAId" : "holdingBId" + if (field === "holdingBId") + return root.displayIsCanonical ? "holdingBId" : "holdingAId" + if (field === "lpHoldingId" || field === "createFreshLp") + return "lpHoldingId" if (field === "initialPriceRealRaw") return "initialPrice" return field @@ -1195,6 +1286,10 @@ 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 TokenHolding for this token."), + "invalid_holding_selection": qsTr("Selected TokenHolding is unavailable."), + "lp_destination_required": qsTr("Select where LP tokens should be deposited."), + "invalid_lp_destination": qsTr("Selected LP TokenHolding is unavailable."), "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."), @@ -1264,8 +1359,10 @@ 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 && root.holdingA.balanceRaw + ? root.holdingA.balanceRaw : "0") + var balanceB = String(root.holdingB && root.holdingB.balanceRaw + ? 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) @@ -1473,10 +1570,23 @@ AmmActionCard { return AmountMath.formatRaw(String(token.balanceRaw || "0"), decimals) } + function holdingBalanceText(holding, decimals) { + return holding ? AmountMath.formatRaw( + String(holding.balanceRaw || "0"), decimals) : "" + } + function tokenBalanceDetail(token) { return qsTr("Available %1").arg(root.balanceText(token, 0)) } + function lpDefinitionIdHex() { + if (root.quoteMatchesPair()) + return String(root.quotePayload.lpDefinitionIdHex || "") + if (root.quoteMatchesSelectedPair(root.activePoolQuote)) + return String(root.activePoolQuote.lpDefinitionIdHex || "") + return "" + } + function shortId(value) { var text = String(value || "") return text.length > 14 ? text.slice(0, 7) + "…" + text.slice(-5) : text diff --git a/apps/amm/qml/components/liquidity/TokenAmountInput.qml b/apps/amm/qml/components/liquidity/TokenAmountInput.qml index 311276cf..0d787282 100644 --- a/apps/amm/qml/components/liquidity/TokenAmountInput.qml +++ b/apps/amm/qml/components/liquidity/TokenAmountInput.qml @@ -1,6 +1,9 @@ pragma ComponentBehavior: Bound import QtQuick +import QtQuick.Layouts + +import Logos.Wallet import "../shared" @@ -13,6 +16,7 @@ AmmTokenAmountSurface { property bool showMaxButton: true property var tokenData: null property var tokens: [] + property var programAccounts: [] property string selectedTokenId: "" property bool tokenInvalid: false property bool tokenSelectionEnabled: true @@ -25,19 +29,26 @@ AmmTokenAmountSurface { property alias popup: tokenModal property alias query: tokenModal.searchText readonly property var rows: tokenModal.rows + property string selectedHoldingId: "" + property bool holdingReady: false + property bool hasHoldingFunds: false + property var selectedHolding: null + property real accessoryContentHeight: 40 signal editingChanged(string value) signal editingCommitted(string value) signal maxClicked signal tokenSelected(string tokenId) signal tokenEntered(string value) + signal holdingSelectionChanged(string holdingId) + signal holdingSelectionRequested(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.accessoryContentHeight onAmountEdited: function(value) { root.pendingValue = value @@ -68,17 +79,87 @@ 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 { + id: tokenActionLayout + + spacing: 4 + + Binding { + target: root + property: "accessoryContentHeight" + value: tokenActionLayout.implicitHeight + } + + Binding { + target: root + property: "selectedHoldingId" + value: holdingPicker.selectedAccountId + } + + Binding { + target: root + property: "holdingReady" + value: holdingPicker.ready + } + + Binding { + target: root + property: "hasHoldingFunds" + value: holdingPicker.hasFunds + } + + Binding { + target: root + property: "selectedHolding" + value: holdingPicker.selectedAccount + } + + Connections { + target: root + + function onHoldingSelectionRequested(accountId) { + holdingPicker.setSelection(accountId, false) + holdingPicker.reconcileSelection() + } + } + + 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() + } + + ProgramAccountSelector { + id: holdingPicker + + Layout.fillWidth: true + sourceModel: root.programAccounts + accountType: "TokenHolding" + stateField: "definitionId" + stateValue: root.tokenData + ? String(root.tokenData.definitionIdHex + || root.tokenData.definitionId || "") : "" + selectionMode: ProgramAccountSelector.Input + placeholderText: qsTr("Select source") + accessibleName: qsTr("Source TokenHolding for %1").arg(root.label) + backgroundColor: root.theme.colors.panelBg + hoverColor: root.theme.colors.panelHoverBg + textColor: root.theme.colors.textPrimary + secondaryTextColor: root.theme.colors.textSecondary + borderColor: root.theme.colors.borderStrong + focusColor: root.theme.colors.ctaBg + onSelectionChanged: function(accountId, createNew) { + root.holdingSelectionChanged(accountId) + } + } } } @@ -105,6 +186,10 @@ AmmTokenAmountSurface { tokenModal.acceptInput(value) } + function setHoldingSelection(accountId) { + root.holdingSelectionRequested(accountId) + } + function commitPendingEdit() { if (!root.editPending) return diff --git a/apps/amm/qml/components/swap/SwapCard.qml b/apps/amm/qml/components/swap/SwapCard.qml index 9dbe0235..c47c4db3 100644 --- a/apps/amm/qml/components/swap/SwapCard.qml +++ b/apps/amm/qml/components/swap/SwapCard.qml @@ -1,6 +1,7 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import Logos.Wallet import "../shared" import "../../state" @@ -13,6 +14,7 @@ Rectangle { property var theme property var tokens: [] + property var programAccounts: [] // Real backend replica (logos.module("amm_ui")), wired from SwapPage. property var backend: null @@ -175,10 +177,17 @@ Rectangle { && parsedSellAmount > 0 && parsedBuyAmount > 0 && root.poolResolved && root.poolExists && !insufficientLiquidity && !root.swapInProgress + && sellAmountInput.holdingReady + && buyAmountInput.holdingReady + && root.backend && root.backend.isWalletOpen readonly property string submitButtonText: { if (!tokensSelected) return qsTr("Select tokens") if (root.swapInProgress) return qsTr("Submitting…") + if (root.sellToken && !sellAmountInput.hasHoldingFunds) return qsTr("No funds") + if (root.sellToken && !sellAmountInput.holdingReady) return qsTr("Select source holding") + if (root.buyToken && !buyAmountInput.holdingReady) return qsTr("Select destination") + if (root.backend && !root.backend.isWalletOpen) return qsTr("Connect wallet") if (!hasAmount) return qsTr("Enter an amount") if (editingSide === "buy") return qsTr("Enter a sell amount to swap") if (root.poolLoading || !root.poolResolved) return qsTr("Resolving pool…") @@ -243,7 +252,10 @@ Rectangle { "priceImpactPercentValue": priceImpactPercent, "slippageTolerance": swapState.formatSlippagePercent(slippageTolerancePercent), "swapMode": "swap-exact-input", - "swapModeText": swapModeText + "swapModeText": swapModeText, + "inputHoldingId": sellAmountInput.selectedHoldingId, + "outputHoldingId": buyAmountInput.selectedHoldingId, + "createOutputHolding": buyAmountInput.createNewHolding } } @@ -254,9 +266,6 @@ Rectangle { if (!root.backend || !root.canSubmit) return - root.swapInProgress = true - root.swapError = "" - // Compute the submitted slippage floor with exact integer (BigInt) math // rather than the double-based preview: base-unit values for 18-decimal // tokens exceed 2^53, where doubles would understate min_out and weaken @@ -268,10 +277,33 @@ Rectangle { root.slippageTolerancePercent) // Max u64 sentinel: "ignore deadline", per AmmUiBackend.rep. var deadline = "18446744073709551615" + var inputHolding = sellAmountInput.selectedHoldingId + if (buyAmountInput.createNewHolding) { + root.swapInProgress = true + root.swapError = "" + logos.watch(root.backend.createAccountPublic(), + function(accountId) { + if (!accountId) { + root.failSwap(qsTr("Could not create a destination TokenHolding.")) + return + } + root.submitSwap(inputHolding, String(accountId), minOutStr, deadline) + }, + function(error) { + root.failSwap(qsTr("Could not create a destination TokenHolding: %1").arg(error)) + }) + return + } + root.submitSwap(inputHolding, buyAmountInput.selectedHoldingId, minOutStr, deadline) + } + + function submitSwap(inputHolding, outputHolding, minOutStr, deadline) { + root.swapInProgress = true + root.swapError = "" logos.watch(root.backend.swapExactInput( root.sellToken.definitionId, root.buyToken.definitionId, - root.sellToken.holding, root.buyToken.holding, + inputHolding, outputHolding, root.sellInput, minOutStr, deadline), function (txHash) { root.swapInProgress = false @@ -285,19 +317,27 @@ Rectangle { }) root.resetAmounts() resolveDebounce.restart() + logos.watch(root.backend.refreshNewPositionContext({ + "refreshWalletAccounts": true + }), function() {}, function(error) { + console.warn("wallet holding refresh error:", error) + }) } else { - root.swapError = qsTr("Swap failed (empty response from sequencer).") - root.swapFailed(root.swapError) + root.failSwap(qsTr("Swap failed (empty response from sequencer).")) } }, function (error) { console.warn("swapExactInput error:", error) - root.swapInProgress = false - root.swapError = qsTr("Swap error: %1").arg(error) - root.swapFailed(root.swapError) + root.failSwap(qsTr("Swap error: %1").arg(error)) }) } + function failSwap(message) { + root.swapInProgress = false + root.swapError = message + root.swapFailed(message) + } + radius: 24 color: theme.colors.cardBg border.color: theme.colors.border @@ -316,11 +356,15 @@ Rectangle { spacing: 0 TokenInput { + id: sellAmountInput + Layout.fillWidth: true theme: root.theme label: "Sell" amount: root.sellDisplay token: root.sellToken + programAccounts: root.programAccounts + holdingSelectionMode: ProgramAccountSelector.Input active: root.editingSide === "sell" // Sell amount is sent to the backend as a raw base-units integer // string; reject fractional entry rather than fail opaquely. @@ -374,11 +418,15 @@ Rectangle { } TokenInput { + id: buyAmountInput + Layout.fillWidth: true theme: root.theme label: "Buy" amount: root.buyDisplay token: root.buyToken + programAccounts: root.programAccounts + holdingSelectionMode: ProgramAccountSelector.Output active: root.editingSide === "buy" onInputEdited: function(v) { root.buyInput = v diff --git a/apps/amm/qml/components/swap/TokenInput.qml b/apps/amm/qml/components/swap/TokenInput.qml index 4b582f7f..6b8f88f2 100644 --- a/apps/amm/qml/components/swap/TokenInput.qml +++ b/apps/amm/qml/components/swap/TokenInput.qml @@ -1,5 +1,6 @@ import QtQuick 2.15 import QtQuick.Layouts 1.15 +import Logos.Wallet import "TokenVisuals.js" as TokenVisuals Rectangle { @@ -10,6 +11,8 @@ Rectangle { property string amount: "" property string usdValue: "" property var token: null + property var programAccounts: [] + property int holdingSelectionMode: ProgramAccountSelector.Input property bool active: true // When true, restrict input to digits only — used for the sell-amount // field, whose value is sent to the backend as a raw base-units integer @@ -19,6 +22,13 @@ Rectangle { signal tokenClicked() signal inputEdited(string newValue) + signal holdingSelectionChanged(string accountId, bool createNew) + + property alias selectedHoldingId: holdingSelector.selectedAccountId + property alias createNewHolding: holdingSelector.createNewSelected + readonly property bool holdingReady: holdingSelector.ready + readonly property bool hasHoldingFunds: holdingSelector.hasFunds + readonly property var selectedHolding: holdingSelector.selectedAccount Binding { target: tiInput @@ -98,51 +108,85 @@ Rectangle { } } - Rectangle { - height: 40 - radius: 20 - color: tokenBtnHover.containsMouse ? theme.colors.panelHoverBg : theme.colors.panelBg - implicitWidth: tokenBtnRow.implicitWidth + 24 - Behavior on color { ColorAnimation { duration: 120 } } - - RowLayout { - id: tokenBtnRow - anchors.centerIn: parent - spacing: 6 - - Rectangle { - width: 24; height: 24; radius: 12 - color: root.token ? TokenVisuals.colorFor(root.token.symbol) : theme.colors.noTokenCircle - visible: root.token !== null + ColumnLayout { + spacing: 6 + + Rectangle { + Layout.alignment: Qt.AlignRight + Layout.preferredHeight: 40 + radius: 20 + color: tokenBtnHover.containsMouse ? theme.colors.panelHoverBg : theme.colors.panelBg + implicitWidth: tokenBtnRow.implicitWidth + 24 + Behavior on color { ColorAnimation { duration: 120 } } + + RowLayout { + id: tokenBtnRow + anchors.centerIn: parent + spacing: 6 + + Rectangle { + width: 24; height: 24; radius: 12 + color: root.token ? TokenVisuals.colorFor(root.token.symbol) : theme.colors.noTokenCircle + visible: root.token !== null + Text { + anchors.centerIn: parent + text: root.token ? TokenVisuals.letterFor(root.token.symbol) : "" + color: "#ffffff" + font.pixelSize: 10 + font.weight: Font.Bold + } + } + Text { - anchors.centerIn: parent - text: root.token ? TokenVisuals.letterFor(root.token.symbol) : "" - color: "#ffffff" - font.pixelSize: 10 - font.weight: Font.Bold + text: root.token ? root.token.symbol : "Select token" + color: theme.colors.textPrimary + font.pixelSize: 15 + font.weight: root.token ? Font.Medium : Font.Normal } - } - Text { - text: root.token ? root.token.symbol : "Select token" - color: theme.colors.textPrimary - font.pixelSize: 15 - font.weight: root.token ? Font.Medium : Font.Normal + Text { + text: "▼" + color: theme.colors.textSecondary + font.pixelSize: 10 + } } - Text { - text: "▼" - color: theme.colors.textSecondary - font.pixelSize: 10 + MouseArea { + id: tokenBtnHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.tokenClicked() } } - MouseArea { - id: tokenBtnHover - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.tokenClicked() + ProgramAccountSelector { + id: holdingSelector + + Layout.alignment: Qt.AlignRight + Layout.preferredWidth: 190 + sourceModel: root.programAccounts + accountType: "TokenHolding" + stateField: "definitionId" + stateValue: root.token + ? String(root.token.definitionIdHex + || root.token.definitionId || "") : "" + selectionMode: root.holdingSelectionMode + createNewText: qsTr("Create new TokenHolding") + placeholderText: root.holdingSelectionMode === ProgramAccountSelector.Input + ? qsTr("Select source") : qsTr("Select destination") + accessibleName: root.holdingSelectionMode === ProgramAccountSelector.Input + ? qsTr("Source TokenHolding for %1").arg(root.label) + : qsTr("Destination TokenHolding for %1").arg(root.label) + backgroundColor: theme.colors.panelBg + hoverColor: theme.colors.panelHoverBg + textColor: theme.colors.textPrimary + secondaryTextColor: theme.colors.textSecondary + borderColor: theme.colors.borderStrong + focusColor: theme.colors.ctaBg + onSelectionChanged: function(accountId, createNew) { + root.holdingSelectionChanged(accountId, createNew) + } } } } diff --git a/apps/amm/qml/components/swap/TokenVisuals.js b/apps/amm/qml/components/swap/TokenVisuals.js index 43e363c2..dc5a2068 100644 --- a/apps/amm/qml/components/swap/TokenVisuals.js +++ b/apps/amm/qml/components/swap/TokenVisuals.js @@ -2,7 +2,7 @@ // Shared derivation helpers for a token's display avatar (color + letter). // The real token config (see AmmUiBackend::tokenList / TOKENS_CONFIG) only -// carries symbol/name/definitionId/holding/decimals — no color/letter — so +// carries symbol/name/definitionId/decimals — no color/letter — so // every place that used to read token.color/token.letter derives them here // instead, deterministically from the token's symbol. diff --git a/apps/amm/qml/pages/SwapPage.qml b/apps/amm/qml/pages/SwapPage.qml index d524e527..1cf3c4b0 100644 --- a/apps/amm/qml/pages/SwapPage.qml +++ b/apps/amm/qml/pages/SwapPage.qml @@ -16,6 +16,10 @@ Item { // reads the TOKENS_CONFIG JSON file — see apps/amm/README.md). Empty // until the backend is ready and the call resolves. property var tokens: [] + readonly property var programAccounts: root.backend + && root.backend.newPositionContext + && root.backend.newPositionContext.programAccounts + ? root.backend.newPositionContext.programAccounts : [] onBackendChanged: { if (root.backend) { @@ -103,6 +107,7 @@ Item { Layout.alignment: Qt.AlignHCenter theme: pageTheme tokens: root.tokens + programAccounts: root.programAccounts backend: root.backend width: Math.min(480, root.width - 32) diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index acb5396e..5e810068 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -522,6 +522,16 @@ QString AmmUiBackend::swapExactInput(QString defAHex, QString defBHex, QString u QString userOutputHoldingHex, QString amountInDecimal, QString minOutDecimal, QString deadlineDecimal) { + defAHex = normalizeAccountId(defAHex); + defBHex = normalizeAccountId(defBHex); + userInputHoldingHex = normalizeAccountId(userInputHoldingHex); + userOutputHoldingHex = normalizeAccountId(userOutputHoldingHex); + if (defAHex.isEmpty() || defBHex.isEmpty() + || userInputHoldingHex.isEmpty() || userOutputHoldingHex.isEmpty()) { + qWarning() << "AmmUiBackend::swapExactInput: invalid token or holding account id"; + return {}; + } + AMM_DBG() << "[amm-debug] swapExactInput: ARGS" << "defA=" << defAHex << "defB=" << defBHex << "userInputHolding=" << userInputHoldingHex @@ -573,8 +583,9 @@ QString AmmUiBackend::swapExactInput(QString defAHex, QString defBHex, QString u // flags. The swap's direction is derived from the input holding's own // token, so the two user holdings occupy fixed role slots: user_input_holding // (the token being sold) then user_output_holding (received). Only the input - // holding signs — the guest debits it via the downstream token transfer; the - // output holding only receives and needs no signature. + // holding signs because the guest debits it. The output also signs: existing + // wallet holdings may authorize harmlessly, while a fresh wallet account must + // authorize the downstream token transfer so it can be claimed and initialized. const QString clockHex = m_logos->logos_execution_zone.account_id_from_base58(QString::fromLatin1(CLOCK_ACCOUNT_BASE58)); if (clockHex.isEmpty()) { @@ -592,7 +603,7 @@ QString AmmUiBackend::swapExactInput(QString defAHex, QString defBHex, QString u pool.value(QStringLiteral("currentTickHex")).toString(), clockHex, }; - const QVariantList signers = { false, false, false, false, true, false, false, false }; + const QVariantList signers = { false, false, false, false, true, true, false, false }; // Debug: dump the exact accounts/signers we submit (base58 for `spel` // comparison), plus the instruction/elf sizes. @@ -710,18 +721,25 @@ QVariantList AmmUiBackend::tokenList() // swapExactInput, and the QML's hex comparisons) can assume hex. const QString definitionId = normalizeAccountId(obj.value(QStringLiteral("definitionId")).toString()); - const QString holding = normalizeAccountId(obj.value(QStringLiteral("holding")).toString()); - if (definitionId.isEmpty() || holding.isEmpty()) { + if (definitionId.isEmpty()) { qWarning() << "AmmUiBackend::tokenList: skipping token" << symbol - << "— cannot normalize definitionId/holding to hex (not valid hex or base58)"; + << "— cannot normalize definitionId to hex (not valid hex or base58)"; continue; } + const QString configuredHolding = + obj.value(QStringLiteral("holding")).toString().trimmed(); + const QString holding = normalizeAccountId(configuredHolding); + if (!configuredHolding.isEmpty() && holding.isEmpty()) { + qWarning() << "AmmUiBackend::tokenList: ignoring invalid legacy holding for" + << symbol; + } QVariantMap token; token[QStringLiteral("symbol")] = symbol; token[QStringLiteral("name")] = obj.value(QStringLiteral("name")).toString(); token[QStringLiteral("definitionId")] = definitionId; - token[QStringLiteral("holding")] = holding; + if (!holding.isEmpty()) + token[QStringLiteral("holding")] = holding; token[QStringLiteral("decimals")] = obj.value(QStringLiteral("decimals")).toInt(); out.append(token); } diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index 447ca286..b581e5f9 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -65,8 +65,8 @@ class AmmUiBackend // (no pool, unreadable AMM_PROGRAM_BIN, bad inputs, or a failed tx). SLOT(QString swapExactInput(QString defAHex, QString defBHex, QString userInputHoldingHex, QString userOutputHoldingHex, QString amountInDecimal, QString minOutDecimal, QString deadlineDecimal)) // Reads the token list config at TOKENS_CONFIG (absolute path, JSON array - // of { symbol, name, definitionId, holding, decimals }) and returns it as - // a QVariantList of QVariantMap entries. Returns an empty list if - // TOKENS_CONFIG is unset/unreadable/invalid. + // of { symbol, name, definitionId, decimals }). A legacy holding field is + // accepted but transaction inputs come from wallet-owned TokenHoldings. + // Returns an empty list if TOKENS_CONFIG is unset/unreadable/invalid. SLOT(QVariantList tokenList()) } diff --git a/apps/amm/tests/qml/tst_NewPositionForm.qml b/apps/amm/tests/qml/tst_NewPositionForm.qml index d2a81928..2910b3d0 100644 --- a/apps/amm/tests/qml/tst_NewPositionForm.qml +++ b/apps/amm/tests/qml/tst_NewPositionForm.qml @@ -13,6 +13,8 @@ TestCase { readonly property string tokenLow: "22222222222222222222222222222222" readonly property string tokenHigh: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" readonly property string tokenThird: "33333333333333333333333333333333" + readonly property string holdingLow: "holding-low" + readonly property string holdingHigh: "holding-high" readonly property string submittedTransactionId: "1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE" @@ -43,6 +45,7 @@ TestCase { "tokens": [ { "definitionId": tokenLow, + "definitionIdHex": tokenLow, "name": "Low", "totalSupplyRaw": "1000000", "balanceRaw": "1000", @@ -50,11 +53,16 @@ TestCase { }, { "definitionId": tokenHigh, + "definitionIdHex": tokenHigh, "name": "High", "totalSupplyRaw": "1000000000000", "balanceRaw": "5000000000", "selectable": true } + ], + "programAccounts": [ + programAccount(holdingLow, tokenLow, "1000"), + programAccount(holdingHigh, tokenHigh, "5000000000") ] } } @@ -65,6 +73,7 @@ TestCase { "tokens": [ { "definitionId": tokenLow, + "definitionIdHex": tokenLow, "name": "Sir Mints-a-Lot", "totalSupplyRaw": "1000000000000", "balanceRaw": "1000000000", @@ -72,15 +81,29 @@ TestCase { }, { "definitionId": tokenHigh, + "definitionIdHex": tokenHigh, "name": "Aurora", "totalSupplyRaw": "1000000000000", "balanceRaw": "1000000000", "selectable": true } + ], + "programAccounts": [ + programAccount(holdingLow, tokenLow, "1000000000"), + programAccount(holdingHigh, tokenHigh, "1000000000") ] } } + function programAccount(accountId, definitionId, balanceRaw) { + return { + "accountId": accountId, + "accountType": "TokenHolding", + "definitionId": definitionId, + "balanceRaw": balanceRaw + } + } + function flowState(quote) { return { "quote": quote || ({}), @@ -107,6 +130,8 @@ TestCase { form.selectToken("B", tokenHigh) compare(form.selectedTokenAId, tokenLow) compare(form.selectedTokenBId, tokenHigh) + tryCompare(form, "selectedHoldingAId", holdingLow) + tryCompare(form, "selectedHoldingBId", holdingHigh) return form } @@ -124,14 +149,20 @@ TestCase { verify(built.ok) compare(built.request.tokenAId, tokenHigh) compare(built.request.tokenBId, tokenLow) + compare(built.request.holdingAId, holdingHigh) + compare(built.request.holdingBId, holdingLow) form.swapTokens() + tryCompare(form, "selectedHoldingAId", holdingHigh) + tryCompare(form, "selectedHoldingBId", holdingLow) compare(form.selectedTokenAId, tokenHigh) compare(form.selectedTokenBId, tokenLow) built = form.buildQuoteRequest() verify(built.ok) compare(built.request.tokenAId, tokenHigh) compare(built.request.tokenBId, tokenLow) + compare(built.request.holdingAId, holdingHigh) + compare(built.request.holdingBId, holdingLow) } function test_tokenAmountsUseRawUnits() { diff --git a/apps/amm/tests/qml/tst_TokenAmountInput.qml b/apps/amm/tests/qml/tst_TokenAmountInput.qml index 7a10ae33..0a7dea76 100644 --- a/apps/amm/tests/qml/tst_TokenAmountInput.qml +++ b/apps/amm/tests/qml/tst_TokenAmountInput.qml @@ -95,6 +95,61 @@ TestCase { compare(input.accessoryWidth, 180) } + function test_singleMatchingHoldingAutoSelects() { + var input = createTemporaryObject(inputComponent, testCase, { + "tokenData": { + "definitionId": enabledId, + "definitionIdHex": enabledId, + "name": "Enabled" + }, + "programAccounts": [{ + "accountId": "holding-enabled", + "accountType": "TokenHolding", + "definitionId": enabledId, + "balanceRaw": "42" + }] + }) + verify(input) + + tryCompare(input, "selectedHoldingId", "holding-enabled") + compare(input.holdingReady, true) + compare(input.hasHoldingFunds, true) + compare(input.selectedHolding.balanceRaw, "42") + } + + function test_multipleMatchingHoldingsSelectHighestBalance() { + var input = createTemporaryObject(inputComponent, testCase, { + "tokenData": { + "definitionId": enabledId, + "definitionIdHex": enabledId, + "name": "Enabled" + }, + "programAccounts": [ + { + "accountId": "holding-a", + "accountType": "TokenHolding", + "definitionId": enabledId, + "balanceRaw": "42" + }, + { + "accountId": "holding-b", + "accountType": "TokenHolding", + "definitionId": enabledId, + "balanceRaw": "21" + } + ] + }) + verify(input) + + tryCompare(input, "hasHoldingFunds", true) + tryCompare(input, "selectedHoldingId", "holding-a") + compare(input.holdingReady, true) + + input.setHoldingSelection("holding-b") + compare(input.selectedHoldingId, "holding-b") + compare(input.holdingReady, true) + } + function test_disabledTokenIsRejectedByTypedInput() { var input = createTemporaryObject(inputComponent, testCase) verify(input) diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt index 896716b8..b06fdc4c 100644 --- a/apps/shared/wallet/CMakeLists.txt +++ b/apps/shared/wallet/CMakeLists.txt @@ -67,6 +67,7 @@ if(LOGOS_WALLET_BUILD_QML) ) set(wallet_public_qml qml/WalletControl.qml + qml/ProgramAccountSelector.qml qml/TransactionConfirmationDialog.qml qml/SubmittedTransaction.qml ) diff --git a/apps/shared/wallet/qml/ProgramAccountSelector.qml b/apps/shared/wallet/qml/ProgramAccountSelector.qml new file mode 100644 index 00000000..797f10ff --- /dev/null +++ b/apps/shared/wallet/qml/ProgramAccountSelector.qml @@ -0,0 +1,406 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls.Basic + +Item { + id: root + + enum SelectionMode { + Input, + Output + } + + property var sourceModel: [] + property string accountType: "" + property string stateField: "" + property var stateValue: "" + property int selectionMode: ProgramAccountSelector.Input + property string selectedAccountId: "" + property bool createNewSelected: false + property string createNewText: qsTr("Create new account") + property string emptyInputText: qsTr("No funds") + property string placeholderText: qsTr("Select account") + property string criteriaPendingText: qsTr("Select token first") + property string accessibleName: qsTr("Program account") + property color backgroundColor: "#27272a" + property color hoverColor: "#3f3f46" + property color textColor: "#f4f4f5" + property color secondaryTextColor: "#a1a1aa" + property color borderColor: "#52525b" + property color focusColor: "#f26a21" + property int modelRevision: 0 + property bool selectionWasAutomatic: false + property string reconciledCriteriaKey: "" + + readonly property bool criteriaReady: root.accountType.length > 0 + && (root.stateField.length === 0 + || root.scalarText(root.stateValue).length > 0) + readonly property var matchingAccounts: root.filteredAccounts() + readonly property var choices: root.choiceRows() + readonly property bool hasFunds: root.matchingAccounts.length > 0 + readonly property bool selectionValid: root.accountById(root.selectedAccountId) !== null + readonly property bool ready: root.criteriaReady + && (root.selectionValid + || (root.selectionMode === ProgramAccountSelector.Output + && root.createNewSelected)) + readonly property var selectedAccount: root.accountById(root.selectedAccountId) + readonly property string selectedBalanceRaw: root.selectedAccount + ? String(root.valueFor( + root.selectedAccount, + "balanceRaw") || "0") + : "0" + readonly property bool showCombo: root.selectionMode === ProgramAccountSelector.Output + || (root.criteriaReady + && root.matchingAccounts.length > 1) + readonly property bool showEmptyInput: root.selectionMode === ProgramAccountSelector.Input + && root.criteriaReady + && root.matchingAccounts.length === 0 + + signal selectionChanged(string accountId, bool createNew) + + implicitWidth: 220 + implicitHeight: root.showCombo ? 34 : root.showEmptyInput ? 20 : 0 + visible: implicitHeight > 0 + + Instantiator { + id: rows + + model: root.sourceModel + delegate: QtObject { + required property var model + required property var modelData + + readonly property var accountRow: { + if (modelData !== null && typeof modelData === "object") { + return modelData + } + if (model === null || typeof model !== "object") + return ({}) + return { + "accountId": model.accountId, + "address": model.address, + "displayAddress": model.displayAddress, + "accountType": model.accountType, + "definitionId": model.definitionId, + "balanceRaw": model.balanceRaw, + "state": model.state + } + } + } + onObjectAdded: function(index, object) { + ++root.modelRevision + Qt.callLater(root.reconcileSelection) + } + onObjectRemoved: function(index, object) { + ++root.modelRevision + Qt.callLater(root.reconcileSelection) + } + } + + onSourceModelChanged: Qt.callLater(root.reconcileSelection) + onAccountTypeChanged: Qt.callLater(root.reconcileSelection) + onStateFieldChanged: Qt.callLater(root.reconcileSelection) + onStateValueChanged: Qt.callLater(root.reconcileSelection) + onSelectionModeChanged: Qt.callLater(root.reconcileSelection) + Component.onCompleted: Qt.callLater(root.reconcileSelection) + + Text { + anchors.fill: parent + visible: root.showEmptyInput + text: root.emptyInputText + color: root.secondaryTextColor + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + Accessible.role: Accessible.StaticText + Accessible.name: text + } + + ComboBox { + id: accountCombo + + objectName: "programAccountComboBox" + anchors.fill: parent + visible: root.showCombo + enabled: root.enabled && root.criteriaReady && root.choices.length > 0 + model: root.choices + currentIndex: root.choiceIndex() + displayText: root.displayLabel() + leftPadding: 10 + rightPadding: 28 + topPadding: 0 + bottomPadding: 0 + hoverEnabled: true + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + Accessible.name: root.accessibleName + + contentItem: Text { + leftPadding: accountCombo.leftPadding + rightPadding: accountCombo.rightPadding + text: accountCombo.displayText + color: accountCombo.enabled ? root.textColor : root.secondaryTextColor + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideMiddle + } + + indicator: Text { + x: accountCombo.width - width - 10 + y: Math.round((accountCombo.height - height) / 2) + text: "\u25BE" + color: accountCombo.enabled ? root.secondaryTextColor : root.borderColor + font.pixelSize: 10 + } + + background: Rectangle { + radius: 7 + color: !accountCombo.enabled + ? root.backgroundColor + : accountCombo.down || accountCombo.hovered + ? root.hoverColor : root.backgroundColor + border.color: accountCombo.activeFocus ? root.focusColor : root.borderColor + border.width: 1 + } + + delegate: ItemDelegate { + id: optionDelegate + + required property int index + required property var modelData + + width: ListView.view ? ListView.view.width : accountCombo.width + height: 34 + hoverEnabled: true + highlighted: accountCombo.highlightedIndex === optionDelegate.index + + contentItem: Text { + leftPadding: 8 + rightPadding: 8 + text: root.labelFor(optionDelegate.modelData) + color: root.textColor + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideMiddle + } + + background: Rectangle { + radius: 5 + color: optionDelegate.highlighted || optionDelegate.hovered + ? root.hoverColor : "transparent" + } + } + + popup: Popup { + y: accountCombo.height + 4 + width: accountCombo.width + implicitHeight: Math.min(contentItem.implicitHeight + topPadding + bottomPadding, + 204) + padding: 4 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: accountCombo.delegateModel + currentIndex: accountCombo.highlightedIndex + highlightMoveDuration: 0 + ScrollIndicator.vertical: ScrollIndicator { } + } + + background: Rectangle { + radius: 7 + color: root.backgroundColor + border.color: root.borderColor + border.width: 1 + } + } + + onActivated: function(index) { + const choice = root.choices[index] + if (!choice) + return + root.setSelection(choice.createNew === true + ? "" : root.accountIdFor(choice), + choice.createNew === true) + } + } + + function filteredAccounts() { + root.modelRevision + if (!root.criteriaReady) + return [] + const result = [] + for (let index = 0; index < rows.count; ++index) { + const object = rows.objectAt(index) + const row = object ? root.valueFor(object, "accountRow") : null + if (!row) + continue + const type = String(root.valueFor(row, "accountType") + || root.valueFor(row, "typeName") + || root.valueFor(row, "programType") || "") + if (type !== root.accountType) + continue + if (root.stateField.length > 0 + && root.scalarText(root.valueFor(row, root.stateField)) + !== root.scalarText(root.stateValue)) { + continue + } + if (root.accountIdFor(row).length > 0) + result.push(row) + } + result.sort(function(left, right) { + const balanceOrder = root.compareUnsignedDecimals( + root.valueFor(left, "balanceRaw"), + root.valueFor(right, "balanceRaw")) + if (balanceOrder !== 0) + return -balanceOrder + const leftId = root.accountIdFor(left) + const rightId = root.accountIdFor(right) + return leftId < rightId ? -1 : leftId > rightId ? 1 : 0 + }) + return result + } + + function valueFor(row, field) { + if (!row) + return undefined + if (row[field] !== undefined) + return row[field] + if (row.state && row.state[field] !== undefined) + return row.state[field] + if (row.fields && row.fields[field] !== undefined) + return row.fields[field] + return undefined + } + + function scalarText(value) { + return value === undefined || value === null ? "" : String(value) + } + + function normalizedUnsignedDecimal(value) { + const text = root.scalarText(value).trim() + if (!/^[0-9]+$/.test(text)) + return "0" + const normalized = text.replace(/^0+/, "") + return normalized.length > 0 ? normalized : "0" + } + + function compareUnsignedDecimals(left, right) { + const leftText = root.normalizedUnsignedDecimal(left) + const rightText = root.normalizedUnsignedDecimal(right) + if (leftText.length !== rightText.length) + return leftText.length < rightText.length ? -1 : 1 + return leftText < rightText ? -1 : leftText > rightText ? 1 : 0 + } + + function accountIdFor(row) { + return String(root.valueFor(row, "accountId") + || root.valueFor(row, "displayAddress") + || root.valueFor(row, "address") + || root.valueFor(row, "holdingId") || "") + } + + function accountById(accountId) { + const value = String(accountId || "") + if (value.length === 0) + return null + for (let index = 0; index < root.matchingAccounts.length; ++index) { + if (root.accountIdFor(root.matchingAccounts[index]) === value) + return root.matchingAccounts[index] + } + return null + } + + function choiceRows() { + const result = root.matchingAccounts.slice(0) + if (root.selectionMode === ProgramAccountSelector.Output) + result.push({ "createNew": true }) + return result + } + + function choiceIndex() { + if (root.createNewSelected) + return root.choices.length - 1 + for (let index = 0; index < root.choices.length; ++index) { + if (root.accountIdFor(root.choices[index]) === root.selectedAccountId) + return index + } + return -1 + } + + function displayLabel() { + const index = root.choiceIndex() + if (index >= 0) + return root.labelFor(root.choices[index]) + if (!root.criteriaReady) + return root.criteriaPendingText + return root.placeholderText + } + + function labelFor(row) { + if (row && row.createNew === true) + return root.createNewText + const id = root.accountIdFor(row) + const balance = String(root.valueFor(row, "balanceRaw") || "") + return balance.length > 0 + ? qsTr("%1 · %2").arg(root.shortId(id)).arg(balance) + : root.shortId(id) + } + + function shortId(value) { + const text = String(value || "") + return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text + } + + function setSelection(accountId, createNew, automatic) { + const nextId = String(accountId || "") + const nextCreate = createNew === true + const nextAutomatic = automatic === true + if (root.selectedAccountId === nextId && root.createNewSelected === nextCreate) { + root.selectionWasAutomatic = nextAutomatic + return + } + root.selectionWasAutomatic = nextAutomatic + root.selectedAccountId = nextId + root.createNewSelected = nextCreate + root.selectionChanged(nextId, nextCreate) + } + + function criteriaKey() { + return root.accountType + "\u0000" + + root.stateField + "\u0000" + + root.scalarText(root.stateValue) + "\u0000" + + String(root.selectionMode) + } + + function reconcileSelection() { + const nextCriteriaKey = root.criteriaKey() + const criteriaChanged = root.reconciledCriteriaKey !== nextCriteriaKey + root.reconciledCriteriaKey = nextCriteriaKey + if (!root.criteriaReady) { + root.setSelection("", false, true) + return + } + if (!criteriaChanged && !root.selectionWasAutomatic) { + if (root.selectionValid) + return + if (root.selectionMode === ProgramAccountSelector.Output + && root.createNewSelected) { + return + } + } + if (root.selectionMode === ProgramAccountSelector.Input) { + root.setSelection(root.matchingAccounts.length > 0 + ? root.accountIdFor(root.matchingAccounts[0]) : "", + false, + true) + return + } + if (root.matchingAccounts.length === 0) { + root.setSelection("", true, true) + } else { + root.setSelection(root.accountIdFor(root.matchingAccounts[0]), false, true) + } + } +} diff --git a/apps/shared/wallet/tests/qml/tst_ProgramAccountSelector.qml b/apps/shared/wallet/tests/qml/tst_ProgramAccountSelector.qml new file mode 100644 index 00000000..e27ed366 --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_ProgramAccountSelector.qml @@ -0,0 +1,197 @@ +import QtQuick +import QtTest +import Logos.Wallet as Wallet + +Item { + id: root + + width: 480 + height: 320 + + readonly property var holdingA: ({ + "accountId": "holding-a", + "accountType": "TokenHolding", + "definitionId": "token-a", + "balanceRaw": "120" + }) + readonly property var holdingB: ({ + "accountId": "holding-b", + "accountType": "TokenHolding", + "state": { + "definitionId": "token-a", + "balanceRaw": "80" + } + }) + readonly property var otherToken: ({ + "accountId": "holding-c", + "accountType": "TokenHolding", + "definitionId": "token-b", + "balanceRaw": "50" + }) + readonly property var otherType: ({ + "accountId": "pool-a", + "accountType": "Pool", + "definitionId": "token-a" + }) + + Component { + id: selectorComponent + + Wallet.ProgramAccountSelector { + width: 260 + accountType: "TokenHolding" + stateField: "definitionId" + stateValue: "token-a" + } + } + + TestCase { + name: "ProgramAccountSelector" + when: windowShown + + function test_inputNoFunds() { + const selector = createTemporaryObject(selectorComponent, root, { + "sourceModel": [root.otherToken, root.otherType], + "selectionMode": Wallet.ProgramAccountSelector.Input + }) + verify(!!selector, "Component exists") + tryCompare(selector, "showEmptyInput", true) + compare(selector.showCombo, false) + compare(selector.hasFunds, false) + compare(selector.ready, false) + compare(selector.selectedAccountId, "") + } + + function test_inputSingleHoldingAutoSelectsWithoutCombo() { + const selector = createTemporaryObject(selectorComponent, root, { + "sourceModel": [root.holdingA, root.otherToken], + "selectionMode": Wallet.ProgramAccountSelector.Input + }) + verify(!!selector, "Component exists") + tryCompare(selector, "selectedAccountId", "holding-a") + compare(selector.showCombo, false) + compare(selector.hasFunds, true) + compare(selector.ready, true) + compare(selector.selectedBalanceRaw, "120") + } + + function test_inputMultipleHoldingsSelectsHighestBalance() { + const selector = createTemporaryObject(selectorComponent, root, { + "sourceModel": [root.holdingA, root.holdingB], + "selectionMode": Wallet.ProgramAccountSelector.Input + }) + verify(!!selector, "Component exists") + tryCompare(selector, "showCombo", true) + compare(selector.matchingAccounts.length, 2) + tryCompare(selector, "selectedAccountId", "holding-a") + compare(selector.selectedBalanceRaw, "120") + compare(selector.ready, true) + + selector.setSelection("holding-b", false) + compare(selector.selectedAccountId, "holding-b") + compare(selector.selectedBalanceRaw, "80") + compare(selector.ready, true) + selector.reconcileSelection() + compare(selector.selectedAccountId, "holding-b") + } + + function test_outputNoHoldingSelectsCreateNew() { + const selector = createTemporaryObject(selectorComponent, root, { + "sourceModel": [], + "selectionMode": Wallet.ProgramAccountSelector.Output + }) + verify(!!selector, "Component exists") + tryCompare(selector, "createNewSelected", true) + compare(selector.showCombo, true) + compare(selector.choices.length, 1) + compare(selector.ready, true) + } + + function test_outputSingleHoldingOffersExistingAndCreateNew() { + const selector = createTemporaryObject(selectorComponent, root, { + "sourceModel": [root.holdingA], + "selectionMode": Wallet.ProgramAccountSelector.Output + }) + verify(!!selector, "Component exists") + tryCompare(selector, "selectedAccountId", "holding-a") + compare(selector.choices.length, 2) + compare(selector.createNewSelected, false) + compare(selector.ready, true) + + selector.setSelection("", true) + compare(selector.selectedAccountId, "") + compare(selector.createNewSelected, true) + compare(selector.ready, true) + } + + function test_outputMultipleHoldingsSelectsHighestAndOffersCreateNew() { + const selector = createTemporaryObject(selectorComponent, root, { + "sourceModel": [root.holdingA, root.holdingB], + "selectionMode": Wallet.ProgramAccountSelector.Output + }) + verify(!!selector, "Component exists") + tryCompare(selector, "showCombo", true) + compare(selector.choices.length, 3) + tryCompare(selector, "selectedAccountId", "holding-a") + compare(selector.createNewSelected, false) + compare(selector.ready, true) + + selector.setSelection("", true) + compare(selector.selectedAccountId, "") + compare(selector.createNewSelected, true) + compare(selector.ready, true) + } + + function test_highestBalanceComparisonPreservesU128Precision() { + const selector = createTemporaryObject(selectorComponent, root, { + "sourceModel": [ + { + "accountId": "holding-lower", + "accountType": "TokenHolding", + "definitionId": "token-a", + "balanceRaw": "900719925474099299999" + }, + { + "accountId": "holding-higher", + "accountType": "TokenHolding", + "definitionId": "token-a", + "balanceRaw": "900719925474099300000" + } + ], + "selectionMode": Wallet.ProgramAccountSelector.Input + }) + verify(!!selector, "Component exists") + tryCompare(selector, "selectedAccountId", "holding-higher") + compare(selector.matchingAccounts[0].accountId, "holding-higher") + } + + function test_automaticCreateNewChangesToHoldingAfterWalletLoads() { + const selector = createTemporaryObject(selectorComponent, root, { + "sourceModel": [], + "selectionMode": Wallet.ProgramAccountSelector.Output + }) + verify(!!selector, "Component exists") + tryCompare(selector, "createNewSelected", true) + + selector.sourceModel = [root.holdingB, root.holdingA] + tryCompare(selector, "selectedAccountId", "holding-a") + compare(selector.createNewSelected, false) + } + + function test_matchesNumericZeroState() { + const selector = createTemporaryObject(selectorComponent, root, { + "sourceModel": [{ + "accountId": "zero-state", + "accountType": "TokenHolding", + "state": { "version": 0 } + }], + "stateField": "version", + "stateValue": 0, + "selectionMode": Wallet.ProgramAccountSelector.Input + }) + verify(!!selector, "Component exists") + tryCompare(selector, "selectedAccountId", "zero-state") + compare(selector.ready, true) + } + } +} diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index 4ca27656..5ada284f 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -176,14 +176,16 @@ pub enum Instruction { /// /// Swap direction is determined by the input holding: `user_input_holding`'s token definition /// selects which pool token is sold. That holding must be signed so the downstream token - /// transfer can debit it; `user_output_holding` only receives and needs no signature. + /// transfer can debit it. `user_output_holding` may be initialized, or fresh and signed so the + /// downstream token transfer can claim and initialize it. /// /// Required accounts: /// - AMM Pool (initialized) /// - Vault Holding Account for Token A (initialized) /// - Vault Holding Account for Token B (initialized) /// - User Input Holding Account (initialized, signed) — the token being sold - /// - User Output Holding Account (initialized) — receives the token being bought + /// - User Output Holding Account (initialized, or uninitialized and signed) — receives the + /// token being bought /// - Current Tick Account, the pool's TWAP PDA derived as /// `compute_current_tick_account_pda(twap_oracle_program_id, pool.account_id)`; refreshed /// with the new spot price diff --git a/programs/amm/methods/guest/src/bin/amm.rs b/programs/amm/methods/guest/src/bin/amm.rs index a05db609..5b873a82 100644 --- a/programs/amm/methods/guest/src/bin/amm.rs +++ b/programs/amm/methods/guest/src/bin/amm.rs @@ -300,7 +300,8 @@ mod amm { /// Swap some quantity of tokens while maintaining the pool constant product. /// /// The swap direction is the input holding's own token; `user_input_holding` must be signed so - /// the downstream token transfer can debit it. `user_output_holding` only receives. + /// the downstream token transfer can debit it. `user_output_holding` may be initialized, or + /// fresh and authorized so the downstream transfer can claim and initialize it. #[expect( clippy::too_many_arguments, reason = "instruction interface requires explicit pool, vault, user accounts, and bounds" diff --git a/programs/amm/src/swap.rs b/programs/amm/src/swap.rs index 1f102529..a7b466d2 100644 --- a/programs/amm/src/swap.rs +++ b/programs/amm/src/swap.rs @@ -6,7 +6,7 @@ use amm_core::{ pub use amm_core::{compute_liquidity_token_pda_seed, compute_vault_pda_seed, PoolDefinition}; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ - account::{AccountId, AccountWithMetadata, Data}, + account::{Account, AccountId, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; use twap_oracle_core::compute_current_tick_account_pda; @@ -49,6 +49,18 @@ fn validate_swap_setup( pool_def_data } +fn assert_user_holding_owner_or_fresh( + holding: &AccountWithMetadata, + token_program_id: ProgramId, + message: &str, +) { + assert!( + holding.account.program_owner == token_program_id + || (holding.account == Account::default() && holding.is_authorized), + "{message}" + ); +} + /// Assembles the swap post-states (including the echoed current-tick and clock accounts) and the /// chained call that refreshes the pool's TWAP current tick from the post-swap spot price. #[expect( @@ -188,13 +200,15 @@ pub fn swap_exact_input( } else { panic!("Swap exact input: input holding token is not part of the pool"); }; - assert_eq!( - user_holding_a.account.program_owner, token_program_id, - "User Token A holding must be owned by the configured Token Program" + assert_user_holding_owner_or_fresh( + &user_holding_a, + token_program_id, + "User Token A holding must be owned by the configured Token Program", ); - assert_eq!( - user_holding_b.account.program_owner, token_program_id, - "User Token B holding must be owned by the configured Token Program" + assert_user_holding_owner_or_fresh( + &user_holding_b, + token_program_id, + "User Token B holding must be owned by the configured Token Program", ); // The current tick is refreshed by a chained call to the oracle; validate its PDA and the // clock here so the swap is rejected early with an AMM-level error. diff --git a/programs/amm/src/tests.rs b/programs/amm/src/tests.rs index 5852983f..01db2d76 100644 --- a/programs/amm/src/tests.rs +++ b/programs/amm/src/tests.rs @@ -736,6 +736,14 @@ impl AccountWithMetadataForTests { } } + fn fresh_user_output_holding() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: AccountId::new([48; 32]), + } + } + fn vault_a_init() -> AccountWithMetadata { AccountWithMetadata { account: Account { @@ -2734,6 +2742,60 @@ fn test_call_swap_chained_call_successful_1() { assert_update_tick_call(&chained_calls, pool_post.account()); } +#[test] +fn test_call_swap_exact_input_accepts_fresh_authorized_output() { + let fresh_output = AccountWithMetadataForTests::fresh_user_output_holding(); + let (_, chained_calls) = swap_exact_input( + AccountWithMetadataForTests::config_init(), + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::user_holding_a(), + fresh_output.clone(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), + BalanceForTests::add_max_amount_a(), + BalanceForTests::add_max_amount_a_low(), + AMM_PROGRAM_ID, + ); + + let mut vault_b = AccountWithMetadataForTests::vault_b_init(); + vault_b.is_authorized = true; + let expected = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![vault_b, fresh_output], + &token_core::Instruction::Transfer { + amount_to_transfer: BalanceForTests::swap_amount_out_b(), + }, + ) + .with_pda_seeds(vec![compute_vault_pda_seed( + IdForTests::pool_definition_id(), + IdForTests::token_b_definition_id(), + )]); + + assert_eq!(chained_calls[1], expected); +} + +#[test] +#[should_panic(expected = "User Token B holding must be owned by the configured Token Program")] +fn test_call_swap_exact_input_rejects_fresh_unauthorized_output() { + let mut fresh_output = AccountWithMetadataForTests::fresh_user_output_holding(); + fresh_output.is_authorized = false; + let _ = swap_exact_input( + AccountWithMetadataForTests::config_init(), + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::user_holding_a(), + fresh_output, + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), + BalanceForTests::add_max_amount_a(), + BalanceForTests::add_max_amount_a_low(), + AMM_PROGRAM_ID, + ); +} + #[test] fn test_call_swap_chained_call_successful_2() { let (post_states, chained_calls) = swap_exact_input( diff --git a/programs/integration_tests/tests/amm.rs b/programs/integration_tests/tests/amm.rs index 6d6896aa..5fbae75c 100644 --- a/programs/integration_tests/tests/amm.rs +++ b/programs/integration_tests/tests/amm.rs @@ -34,6 +34,10 @@ impl Keys { PrivateKey::try_new([33; 32]).expect("valid private key") } + fn fresh_output() -> PrivateKey { + PrivateKey::try_new([35; 32]).expect("valid private key") + } + fn admin() -> PrivateKey { PrivateKey::try_new([34; 32]).expect("valid private key") } @@ -131,6 +135,10 @@ impl Ids { AccountId::from(&PublicKey::new_from_private_key(&Keys::user_lp())) } + fn fresh_output() -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(&Keys::fresh_output())) + } + fn admin() -> AccountId { AccountId::from(&PublicKey::new_from_private_key(&Keys::admin())) } @@ -2837,6 +2845,53 @@ fn amm_swap_a_to_b() { assert_eq!(tick_account.tick, expected_tick); } +#[test] +fn amm_swap_exact_input_creates_fresh_output_holding() { + let mut state = state_for_amm_tests(); + let instruction = amm_core::Instruction::SwapExactInput { + swap_amount_in: Balances::swap_amount_in(), + min_amount_out: Balances::swap_min_out(), + deadline: u64::MAX, + }; + let message = public_transaction::Message::try_new( + Ids::amm_program(), + vec![ + Ids::config(), + Ids::pool_definition(), + Ids::vault_a(), + Ids::vault_b(), + Ids::user_a(), + Ids::fresh_output(), + Ids::current_tick_account(), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + vec![current_nonce(&state, Ids::user_a()), Nonce(0)], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message( + &message, + &[&Keys::user_a(), &Keys::fresh_output()], + ); + + state + .transition_from_public_transaction(&PublicTransaction::new(message, witness_set), 0, 0) + .unwrap(); + + assert_eq!( + state.get_account_by_id(Ids::fresh_output()), + Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_b_definition(), + balance: Balances::user_b_swap_2() - Balances::user_b_init(), + }), + nonce: Nonce(1), + } + ); +} + #[test] fn amm_swap_exact_output_refreshes_current_tick() { let mut state = state_for_amm_tests();