feat(wallet): add reusable program account selector

This commit is contained in:
Ricardo Guilherme Schmidt
2026-08-10 11:23:44 -03:00
parent 4363f13912
commit a633adce20
24 changed files with 1562 additions and 169 deletions
+9 -4
View File
@@ -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",
+42 -9
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,
};
@@ -59,6 +59,26 @@ pub(super) fn context(request: ContextRequest) -> Result<Value, String> {
};
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::<Vec<_>>();
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<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(),
"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<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(),
"address": account_id_hex(holding.id),
"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()
@@ -128,6 +158,7 @@ pub(super) fn context(request: ContextRequest) -> Result<Value, String> {
"twapOracle": program_id_base58(config.twap_oracle_program_id),
},
"tokens": rows,
"programAccounts": program_accounts,
"feeTiers": fee_tiers(),
"warnings": warnings,
}))
@@ -141,6 +172,7 @@ fn context_error(request: &ContextRequest, code: &str) -> Value {
"networkFingerprint": request.network_fingerprint,
"walletAvailable": request.wallet_available,
"tokens": [],
"programAccounts": [],
"feeTiers": fee_tiers(),
"warnings": [],
})
@@ -193,6 +225,7 @@ fn token_sources(
fn unavailable_token_row(token_id: AccountId, sources: Vec<String>, code: &str) -> Value {
json!({
"definitionId": token_id.to_string(),
"definitionIdHex": account_id_hex(token_id),
"name": "",
"metadataId": Value::Null,
"totalSupplyRaw": "0",
+24 -8
View File
@@ -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,30 @@ pub(super) fn decode_fungible_holding(
pub(super) fn select_holding(
holdings: &[SelectedHolding],
definition_id: AccountId,
requested_id: Option<&str>,
) -> Option<SelectedHolding> {
holdings
let options = holding_options(holdings, definition_id);
let Some(requested_id) = requested_id else {
return (options.len() == 1).then(|| options[0].clone());
};
let requested_id = account_id_from_hex(requested_id, "holding id")
.or_else(|_| parse_base58_id(requested_id, "holding id"))
.ok()?;
options
.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.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
}
+197 -18
View File
@@ -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,
};
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;
@@ -206,9 +209,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,
@@ -216,7 +229,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,
@@ -225,7 +241,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();
@@ -245,7 +261,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(),
@@ -272,7 +288,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": [],
@@ -431,11 +452,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,
@@ -443,7 +472,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(
@@ -468,7 +500,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();
@@ -491,7 +523,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,
@@ -520,7 +552,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,
@@ -545,6 +582,148 @@ fn compute_active_quote(
}))
}
struct LpDestination {
options: Vec<SelectedHolding>,
selected: Option<SelectedHolding>,
requires_fresh: bool,
error: Option<Value>,
}
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,
};
}
match options.as_slice() {
[] => LpDestination {
options,
selected: None,
requires_fresh: true,
error: None,
},
[only] => LpDestination {
selected: Some(only.clone()),
options,
requires_fresh: false,
error: None,
},
_ => LpDestination {
error: Some(issue(
"lp_destination_required",
"Select an LP TokenHolding destination.",
&["lpHoldingId", "createFreshLp"],
json!({ "available": options.len() }),
)),
options,
selected: None,
requires_fresh: false,
},
}
}
fn holding_selection_issues(
input: &QuoteRequest,
pair: PairIds,
holdings: &[SelectedHolding],
holding_a: &Option<SelectedHolding>,
holding_b: &Option<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 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<Value> {
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,
+8
View File
@@ -148,6 +148,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>,
+190 -64
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: 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_requires_a_choice_when_multiple_exist() {
let definition = AccountId::new([9; 32]);
let holding = |id: u8, balance| SelectedHolding {
id: AccountId::new([id; 32]),
@@ -351,12 +418,15 @@ fn highest_balance_holding_wins_then_lowest_id() {
}),
),
};
let holdings = [holding(4, 10), holding(2, 20), holding(1, 20)];
assert!(select_holding(&holdings, definition, None).is_none());
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 +578,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 +686,31 @@ fn missing_pool_quote_accepts_large_direct_raw_amounts() {
assert!(quote_value.get("depositScaleBps").is_none());
}
#[test]
fn quote_requires_explicit_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, 1_000_000),
));
let ambiguous = scenario.quote();
assert_eq!(ambiguous["canSubmit"], false);
assert!(ambiguous["errors"].as_array().unwrap().iter().any(|error| {
error["code"] == "holding_selection_required"
&& error["blockingFields"] == json!(["holdingAId"])
}));
scenario.request.holding_a_id = Some(extra_holding.to_string());
let selected = scenario.quote();
assert_eq!(selected["canSubmit"], true);
assert_eq!(
selected["accountPreview"][6]["accountId"],
extra_holding.to_string()
);
}
#[test]
fn advancing_clock_does_not_stale_quote() {
let mut scenario = Scenario::testnet();
@@ -601,65 +740,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 +761,50 @@ fn active_pool_quote_uses_ratio_and_existing_lp_holding() {
assert_preview_matches_plan(&quote_value, &plan_value, None);
}
#[test]
fn active_pool_quote_requires_lp_destination_when_multiple_match() {
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 ambiguous = scenario.quote();
assert_eq!(ambiguous["canSubmit"], false);
assert_eq!(ambiguous["lpDestinationRequired"], true);
assert_eq!(ambiguous["lpHoldingOptions"].as_array().unwrap().len(), 2);
assert!(ambiguous["errors"]
.as_array()
.unwrap()
.iter()
.any(|error| error["code"] == "lp_destination_required"));
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();