mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 14:11:09 +00:00
feat(wallet): add reusable program account selector
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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("e_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();
|
||||
|
||||
Reference in New Issue
Block a user