mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-23 23:33:10 +00:00
refactor(amm): split client protocol into reusable API
Expose typed Rust request contracts and transport-independent operations for application clients. Keep the existing C ABI as a thin adapter so the AMM UI continues to use the same symbols.
This commit is contained in:
parent
21f6d818a7
commit
2b97012956
@ -6,24 +6,24 @@ use nssa_core::{
|
|||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct AccountRead {
|
pub struct AccountRead {
|
||||||
pub(crate) id: String,
|
pub id: String,
|
||||||
pub(crate) status: String,
|
pub status: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) account: Option<WalletAccount>,
|
pub account: Option<WalletAccount>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||||
pub(crate) struct WalletAccount {
|
pub struct WalletAccount {
|
||||||
pub(crate) program_owner: String,
|
pub program_owner: String,
|
||||||
pub(crate) balance: String,
|
pub balance: String,
|
||||||
pub(crate) nonce: String,
|
pub nonce: String,
|
||||||
pub(crate) data: String,
|
pub data: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> {
|
pub(crate) fn parse_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> {
|
||||||
if value.len() != 64
|
if value.len() != 64
|
||||||
|| !value
|
|| !value
|
||||||
.bytes()
|
.bytes()
|
||||||
@ -39,7 +39,7 @@ pub fn parse_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> {
|
|||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_program_id(value: &str) -> Result<ProgramId, String> {
|
pub(crate) fn parse_program_id(value: &str) -> Result<ProgramId, String> {
|
||||||
let bytes = parse_hex_32(value, "program id")?;
|
let bytes = parse_hex_32(value, "program id")?;
|
||||||
let mut program_id = [0_u32; 8];
|
let mut program_id = [0_u32; 8];
|
||||||
for (word, chunk) in program_id.iter_mut().zip(bytes.chunks_exact(4)) {
|
for (word, chunk) in program_id.iter_mut().zip(bytes.chunks_exact(4)) {
|
||||||
@ -52,7 +52,7 @@ pub fn parse_program_id(value: &str) -> Result<ProgramId, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn program_id_hex(program_id: ProgramId) -> String {
|
pub(crate) fn program_id_hex(program_id: ProgramId) -> String {
|
||||||
let bytes = program_id
|
let bytes = program_id
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|word| word.to_le_bytes())
|
.flat_map(|word| word.to_le_bytes())
|
||||||
@ -60,11 +60,11 @@ pub fn program_id_hex(program_id: ProgramId) -> String {
|
|||||||
hex::encode(bytes)
|
hex::encode(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn program_id_base58(program_id: ProgramId) -> String {
|
pub(crate) fn program_id_base58(program_id: ProgramId) -> String {
|
||||||
AccountId::new(program_id_bytes(program_id)).to_string()
|
AccountId::new(program_id_bytes(program_id)).to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn program_id_bytes(program_id: ProgramId) -> [u8; 32] {
|
pub(crate) fn program_id_bytes(program_id: ProgramId) -> [u8; 32] {
|
||||||
let mut bytes = [0_u8; 32];
|
let mut bytes = [0_u8; 32];
|
||||||
for (chunk, word) in bytes.chunks_exact_mut(4).zip(program_id) {
|
for (chunk, word) in bytes.chunks_exact_mut(4).zip(program_id) {
|
||||||
chunk.copy_from_slice(&word.to_le_bytes());
|
chunk.copy_from_slice(&word.to_le_bytes());
|
||||||
@ -72,15 +72,15 @@ pub fn program_id_bytes(program_id: ProgramId) -> [u8; 32] {
|
|||||||
bytes
|
bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_base58_id(value: &str, label: &str) -> Result<AccountId, String> {
|
pub(crate) fn parse_base58_id(value: &str, label: &str) -> Result<AccountId, String> {
|
||||||
AccountId::from_str(value).map_err(|error| format!("invalid {label}: {error}"))
|
AccountId::from_str(value).map_err(|error| format!("invalid {label}: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn account_id_from_hex(value: &str, label: &str) -> Result<AccountId, String> {
|
pub(crate) fn account_id_from_hex(value: &str, label: &str) -> Result<AccountId, String> {
|
||||||
Ok(AccountId::new(parse_hex_32(value, label)?))
|
Ok(AccountId::new(parse_hex_32(value, label)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn account_id_hex(account_id: AccountId) -> String {
|
pub(crate) fn account_id_hex(account_id: AccountId) -> String {
|
||||||
hex::encode(account_id.into_value())
|
hex::encode(account_id.into_value())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,7 +99,7 @@ fn parse_le_u128(value: &str, label: &str) -> Result<u128, String> {
|
|||||||
Ok(u128::from_le_bytes(bytes))
|
Ok(u128::from_le_bytes(bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), String> {
|
pub(crate) fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), String> {
|
||||||
if read.status != "ok" {
|
if read.status != "ok" {
|
||||||
return Err(String::from("account read failed"));
|
return Err(String::from("account read failed"));
|
||||||
}
|
}
|
||||||
@ -138,7 +138,7 @@ pub fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), String
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn account_read(id: AccountId, account: &Account) -> AccountRead {
|
pub(crate) fn account_read(id: AccountId, account: &Account) -> AccountRead {
|
||||||
AccountRead {
|
AccountRead {
|
||||||
id: account_id_hex(id),
|
id: account_id_hex(id),
|
||||||
status: String::from("ok"),
|
status: String::from("ok"),
|
||||||
|
|||||||
226
apps/amm/client/src/api/accounts.rs
Normal file
226
apps/amm/client/src/api/accounts.rs
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
use amm_core::PoolDefinition;
|
||||||
|
use nssa_core::program::ProgramId;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
funding::{append_holding_source, sources_from_reads},
|
||||||
|
pair::PairIds,
|
||||||
|
position::{AccountPlan, AccountPlanHoldings, AccountPlanRow},
|
||||||
|
QuoteRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) fn missing_account_plan(
|
||||||
|
input: &QuoteRequest,
|
||||||
|
pair: PairIds,
|
||||||
|
amm_program: ProgramId,
|
||||||
|
holdings: AccountPlanHoldings<'_>,
|
||||||
|
) -> Result<AccountPlan, String> {
|
||||||
|
let mut sources = sources_from_reads(&[
|
||||||
|
("config", &input.snapshot.config),
|
||||||
|
("token_a", &input.snapshot.token_a),
|
||||||
|
("token_b", &input.snapshot.token_b),
|
||||||
|
("pool", &input.snapshot.pool),
|
||||||
|
("vault_a", &input.snapshot.vault_a),
|
||||||
|
("vault_b", &input.snapshot.vault_b),
|
||||||
|
("lp_definition", &input.snapshot.lp_definition),
|
||||||
|
("lp_lock_holding", &input.snapshot.lp_lock_holding),
|
||||||
|
("current_tick", &input.snapshot.current_tick),
|
||||||
|
])?;
|
||||||
|
append_holding_source(&mut sources, "holding_a", holdings.token_a);
|
||||||
|
append_holding_source(&mut sources, "holding_b", holdings.token_b);
|
||||||
|
Ok(AccountPlan {
|
||||||
|
rows: vec![
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"config",
|
||||||
|
Some(pair.config),
|
||||||
|
Some(amm_program),
|
||||||
|
"read",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"pool",
|
||||||
|
Some(pair.pool),
|
||||||
|
Some(amm_program),
|
||||||
|
"create",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"vault_a",
|
||||||
|
Some(pair.vault_a),
|
||||||
|
Some(pair.token_program),
|
||||||
|
"create",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"vault_b",
|
||||||
|
Some(pair.vault_b),
|
||||||
|
Some(pair.token_program),
|
||||||
|
"create",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"lp_definition",
|
||||||
|
Some(pair.lp_definition),
|
||||||
|
Some(pair.token_program),
|
||||||
|
"create",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"lp_lock_holding",
|
||||||
|
Some(pair.lp_lock_holding),
|
||||||
|
Some(pair.token_program),
|
||||||
|
"create",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"user_holding_a",
|
||||||
|
holdings.token_a.map(|value| value.id),
|
||||||
|
Some(pair.token_program),
|
||||||
|
"update",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"user_holding_b",
|
||||||
|
holdings.token_b.map(|value| value.id),
|
||||||
|
Some(pair.token_program),
|
||||||
|
"update",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"user_holding_lp",
|
||||||
|
None,
|
||||||
|
Some(pair.token_program),
|
||||||
|
"create",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"current_tick",
|
||||||
|
Some(pair.current_tick),
|
||||||
|
Some(pair.twap_program),
|
||||||
|
"create",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new("clock", Some(pair.clock), None, "read", false, false),
|
||||||
|
],
|
||||||
|
sources,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn active_account_plan(
|
||||||
|
input: &QuoteRequest,
|
||||||
|
pair: PairIds,
|
||||||
|
amm_program: ProgramId,
|
||||||
|
pool: &PoolDefinition,
|
||||||
|
stored_reversed: bool,
|
||||||
|
holdings: AccountPlanHoldings<'_>,
|
||||||
|
) -> Result<AccountPlan, String> {
|
||||||
|
let (stored_holding_a, stored_holding_b) = if stored_reversed {
|
||||||
|
(holdings.token_b, holdings.token_a)
|
||||||
|
} else {
|
||||||
|
(holdings.token_a, holdings.token_b)
|
||||||
|
};
|
||||||
|
let mut sources = sources_from_reads(&[
|
||||||
|
("config", &input.snapshot.config),
|
||||||
|
("token_a", &input.snapshot.token_a),
|
||||||
|
("token_b", &input.snapshot.token_b),
|
||||||
|
("pool", &input.snapshot.pool),
|
||||||
|
("vault_a", &input.snapshot.vault_a),
|
||||||
|
("vault_b", &input.snapshot.vault_b),
|
||||||
|
("lp_definition", &input.snapshot.lp_definition),
|
||||||
|
("current_tick", &input.snapshot.current_tick),
|
||||||
|
])?;
|
||||||
|
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(
|
||||||
|
"config",
|
||||||
|
Some(pair.config),
|
||||||
|
Some(amm_program),
|
||||||
|
"read",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"pool",
|
||||||
|
Some(pair.pool),
|
||||||
|
Some(amm_program),
|
||||||
|
"update",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"vault_a",
|
||||||
|
Some(pool.vault_a_id),
|
||||||
|
Some(pair.token_program),
|
||||||
|
"update",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"vault_b",
|
||||||
|
Some(pool.vault_b_id),
|
||||||
|
Some(pair.token_program),
|
||||||
|
"update",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"lp_definition",
|
||||||
|
Some(pair.lp_definition),
|
||||||
|
Some(pair.token_program),
|
||||||
|
"update",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"user_holding_a",
|
||||||
|
stored_holding_a.map(|value| value.id),
|
||||||
|
Some(pair.token_program),
|
||||||
|
"update",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"user_holding_b",
|
||||||
|
stored_holding_b.map(|value| value.id),
|
||||||
|
Some(pair.token_program),
|
||||||
|
"update",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"user_holding_lp",
|
||||||
|
holdings.lp.map(|value| value.id),
|
||||||
|
Some(pair.token_program),
|
||||||
|
if holdings.lp.is_some() {
|
||||||
|
"update"
|
||||||
|
} else {
|
||||||
|
"create"
|
||||||
|
},
|
||||||
|
holdings.lp.is_none(),
|
||||||
|
holdings.lp.is_none(),
|
||||||
|
),
|
||||||
|
AccountPlanRow::new(
|
||||||
|
"current_tick",
|
||||||
|
Some(pair.current_tick),
|
||||||
|
Some(pair.twap_program),
|
||||||
|
"update",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
AccountPlanRow::new("clock", Some(pair.clock), None, "read", false, false),
|
||||||
|
],
|
||||||
|
sources,
|
||||||
|
})
|
||||||
|
}
|
||||||
12
apps/amm/client/src/api/clock.rs
Normal file
12
apps/amm/client/src/api/clock.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
use borsh::from_slice;
|
||||||
|
use clock_core::ClockAccountData;
|
||||||
|
use nssa_core::account::AccountId;
|
||||||
|
|
||||||
|
use crate::account::{decode_account, AccountRead};
|
||||||
|
|
||||||
|
pub(super) fn decode_clock(read: &AccountRead) -> Result<(AccountId, ClockAccountData), String> {
|
||||||
|
let (id, account) = decode_account(read)?;
|
||||||
|
let clock = from_slice(account.data.as_ref())
|
||||||
|
.map_err(|error| format!("invalid clock account: {error}"))?;
|
||||||
|
Ok((id, clock))
|
||||||
|
}
|
||||||
51
apps/amm/client/src/api/commitment.rs
Normal file
51
apps/amm/client/src/api/commitment.rs
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
use borsh::BorshSerialize;
|
||||||
|
|
||||||
|
#[derive(BorshSerialize)]
|
||||||
|
pub(super) enum RequestCommitment {
|
||||||
|
Missing {
|
||||||
|
amount_a: u128,
|
||||||
|
amount_b: u128,
|
||||||
|
},
|
||||||
|
Active {
|
||||||
|
max_a: u128,
|
||||||
|
max_b: u128,
|
||||||
|
slippage_bps: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(BorshSerialize)]
|
||||||
|
pub(super) struct SourceCommitment {
|
||||||
|
pub(super) role: String,
|
||||||
|
pub(super) commitment: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(BorshSerialize)]
|
||||||
|
pub(super) struct FundingCommitment {
|
||||||
|
pub(super) token_id: [u8; 32],
|
||||||
|
pub(super) holding_id: Option<[u8; 32]>,
|
||||||
|
pub(super) available: u128,
|
||||||
|
pub(super) requested: u128,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(BorshSerialize)]
|
||||||
|
pub(super) struct QuoteCommitment {
|
||||||
|
pub(super) schema: String,
|
||||||
|
pub(super) network_id: String,
|
||||||
|
pub(super) network_fingerprint: String,
|
||||||
|
pub(super) amm_program_id: [u8; 32],
|
||||||
|
pub(super) token_a_id: [u8; 32],
|
||||||
|
pub(super) token_b_id: [u8; 32],
|
||||||
|
pub(super) fee_bps: u32,
|
||||||
|
pub(super) pool_status: u8,
|
||||||
|
pub(super) request: RequestCommitment,
|
||||||
|
pub(super) max_a: u128,
|
||||||
|
pub(super) max_b: u128,
|
||||||
|
pub(super) actual_a: u128,
|
||||||
|
pub(super) actual_b: u128,
|
||||||
|
pub(super) expected_lp: u128,
|
||||||
|
pub(super) lp_guard: u128,
|
||||||
|
pub(super) requires_fresh_lp: bool,
|
||||||
|
pub(super) sources: Vec<SourceCommitment>,
|
||||||
|
pub(super) funding: Vec<FundingCommitment>,
|
||||||
|
pub(super) warnings: Vec<String>,
|
||||||
|
}
|
||||||
25
apps/amm/client/src/api/config.rs
Normal file
25
apps/amm/client/src/api/config.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
use amm_core::{compute_config_pda, AmmConfig};
|
||||||
|
use nssa_core::{account::Account, program::ProgramId};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use super::ConfigIdRequest;
|
||||||
|
use crate::account::{account_id_hex, decode_account, parse_program_id, AccountRead};
|
||||||
|
|
||||||
|
pub(super) fn config_id(request: ConfigIdRequest) -> Result<Value, String> {
|
||||||
|
let amm_program = parse_program_id(&request.amm_program_id)?;
|
||||||
|
Ok(json!({
|
||||||
|
"status": "ok",
|
||||||
|
"configId": account_id_hex(compute_config_pda(amm_program)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn load_config(amm_program: ProgramId, read: &AccountRead) -> Result<AmmConfig, String> {
|
||||||
|
let (id, account) = decode_account(read)?;
|
||||||
|
if id != compute_config_pda(amm_program)
|
||||||
|
|| account.program_owner != amm_program
|
||||||
|
|| account == Account::default()
|
||||||
|
{
|
||||||
|
return Err(String::from("AMM config is unavailable"));
|
||||||
|
}
|
||||||
|
AmmConfig::try_from(&account.data).map_err(|_| String::from("AMM config is invalid"))
|
||||||
|
}
|
||||||
254
apps/amm/client/src/api/context.rs
Normal file
254
apps/amm/client/src/api/context.rs
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use amm_core::{
|
||||||
|
FEE_TIER_BPS_1, FEE_TIER_BPS_100, FEE_TIER_BPS_30, FEE_TIER_BPS_5, MINIMUM_LIQUIDITY,
|
||||||
|
};
|
||||||
|
use nssa_core::{account::AccountId, program::ProgramId};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use token_core::TokenDefinition;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
config::load_config,
|
||||||
|
holding::{select_holding, wallet_holdings, SelectedHolding},
|
||||||
|
quote_error::issue,
|
||||||
|
ContextRequest, TokenIdsRequest, SCHEMA,
|
||||||
|
};
|
||||||
|
use crate::account::{
|
||||||
|
account_id_from_hex, account_id_hex, decode_account, parse_base58_id, parse_program_id,
|
||||||
|
program_id_base58, AccountRead,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) fn token_ids(request: TokenIdsRequest) -> Result<Value, String> {
|
||||||
|
let amm_program = parse_program_id(&request.amm_program_id)?;
|
||||||
|
let Ok(config) = load_config(amm_program, &request.config) else {
|
||||||
|
return Ok(manifest_error("config_unavailable"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id);
|
||||||
|
let mut token_ids = BTreeSet::new();
|
||||||
|
for id in &request.configured_token_ids {
|
||||||
|
if let Ok(id) = account_id_from_hex(id, "configured token id") {
|
||||||
|
token_ids.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in request
|
||||||
|
.recent_token_ids
|
||||||
|
.iter()
|
||||||
|
.chain(&request.resolved_token_ids)
|
||||||
|
{
|
||||||
|
if let Ok(id) = parse_base58_id(id, "token id") {
|
||||||
|
token_ids.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token_ids.extend(holdings.into_iter().map(|holding| holding.definition_id));
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"status": "ok",
|
||||||
|
"tokenIds": token_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_error(code: &str) -> Value {
|
||||||
|
json!({ "status": "error", "code": code, "tokenIds": [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn context(request: ContextRequest) -> Result<Value, String> {
|
||||||
|
let amm_program = parse_program_id(&request.amm_program_id)?;
|
||||||
|
let Ok(config) = load_config(amm_program, &request.config) else {
|
||||||
|
return Ok(context_error(&request, "config_unavailable"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id);
|
||||||
|
let source_map = token_sources(&request, &holdings);
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
|
||||||
|
for (token_id, sources) in source_map {
|
||||||
|
let read = request
|
||||||
|
.token_definitions
|
||||||
|
.iter()
|
||||||
|
.find(|read| account_id_from_hex(&read.id, "token definition id") == Ok(token_id));
|
||||||
|
let (name, total_supply, metadata_id) =
|
||||||
|
match fungible_definition(read, token_id, config.token_program_id) {
|
||||||
|
Ok(definition) => definition,
|
||||||
|
Err(error) => {
|
||||||
|
rows.push(unavailable_token_row(token_id, sources, error.code));
|
||||||
|
if error.warn {
|
||||||
|
warnings.push(issue(
|
||||||
|
error.code,
|
||||||
|
"Token definition could not be read.",
|
||||||
|
&[],
|
||||||
|
json!({ "tokenId": token_id.to_string() }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let selected = select_holding(&holdings, token_id);
|
||||||
|
let mut row = json!({
|
||||||
|
"definitionId": token_id.to_string(),
|
||||||
|
"name": name,
|
||||||
|
"metadataId": metadata_id.map(|id| id.to_string()),
|
||||||
|
"totalSupplyRaw": total_supply.to_string(),
|
||||||
|
"ownerProgramId": program_id_base58(config.token_program_id),
|
||||||
|
"public": true,
|
||||||
|
"fungible": true,
|
||||||
|
"selectable": true,
|
||||||
|
"status": "available",
|
||||||
|
"code": "available",
|
||||||
|
"sources": sources,
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
right_holding.cmp(&left_holding).then_with(|| {
|
||||||
|
left["definitionId"]
|
||||||
|
.as_str()
|
||||||
|
.cmp(&right["definitionId"].as_str())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"status": if request.wallet_available { "ready" } else { "no_wallet" },
|
||||||
|
"networkId": request.network_id,
|
||||||
|
"networkFingerprint": request.network_fingerprint,
|
||||||
|
"walletAvailable": request.wallet_available,
|
||||||
|
"minimumLiquidityRaw": MINIMUM_LIQUIDITY.to_string(),
|
||||||
|
"programIds": {
|
||||||
|
"amm": program_id_base58(amm_program),
|
||||||
|
"token": program_id_base58(config.token_program_id),
|
||||||
|
"twapOracle": program_id_base58(config.twap_oracle_program_id),
|
||||||
|
},
|
||||||
|
"tokens": rows,
|
||||||
|
"feeTiers": fee_tiers(),
|
||||||
|
"warnings": warnings,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn context_error(request: &ContextRequest, code: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"status": "error",
|
||||||
|
"code": code,
|
||||||
|
"networkId": request.network_id,
|
||||||
|
"networkFingerprint": request.network_fingerprint,
|
||||||
|
"walletAvailable": request.wallet_available,
|
||||||
|
"tokens": [],
|
||||||
|
"feeTiers": fee_tiers(),
|
||||||
|
"warnings": [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fee_tiers() -> Value {
|
||||||
|
json!([
|
||||||
|
{ "feeBps": FEE_TIER_BPS_1, "label": "0.01%", "enabled": true },
|
||||||
|
{ "feeBps": FEE_TIER_BPS_5, "label": "0.05%", "enabled": true },
|
||||||
|
{ "feeBps": FEE_TIER_BPS_30, "label": "0.30%", "enabled": true },
|
||||||
|
{ "feeBps": FEE_TIER_BPS_100, "label": "1.00%", "enabled": true },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_sources(
|
||||||
|
request: &ContextRequest,
|
||||||
|
holdings: &[SelectedHolding],
|
||||||
|
) -> BTreeMap<AccountId, Vec<String>> {
|
||||||
|
let mut sources: BTreeMap<AccountId, BTreeSet<String>> = BTreeMap::new();
|
||||||
|
for id in &request.configured_token_ids {
|
||||||
|
if let Ok(id) = account_id_from_hex(id, "configured token id") {
|
||||||
|
sources
|
||||||
|
.entry(id)
|
||||||
|
.or_default()
|
||||||
|
.insert(String::from("config"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (ids, source) in [
|
||||||
|
(&request.recent_token_ids, "recent"),
|
||||||
|
(&request.resolved_token_ids, "resolved"),
|
||||||
|
] {
|
||||||
|
for id in ids {
|
||||||
|
if let Ok(id) = parse_base58_id(id, "token id") {
|
||||||
|
sources.entry(id).or_default().insert(String::from(source));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for holding in holdings {
|
||||||
|
sources
|
||||||
|
.entry(holding.definition_id)
|
||||||
|
.or_default()
|
||||||
|
.insert(String::from("holding"));
|
||||||
|
}
|
||||||
|
sources
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, values)| (id, values.into_iter().collect()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unavailable_token_row(token_id: AccountId, sources: Vec<String>, code: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"definitionId": token_id.to_string(),
|
||||||
|
"name": "",
|
||||||
|
"metadataId": Value::Null,
|
||||||
|
"totalSupplyRaw": "0",
|
||||||
|
"selectable": false,
|
||||||
|
"status": code,
|
||||||
|
"code": code,
|
||||||
|
"sources": sources,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct DefinitionError {
|
||||||
|
pub(super) code: &'static str,
|
||||||
|
pub(super) warn: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn fungible_definition(
|
||||||
|
read: Option<&AccountRead>,
|
||||||
|
token_id: AccountId,
|
||||||
|
token_program: ProgramId,
|
||||||
|
) -> Result<(String, u128, Option<AccountId>), DefinitionError> {
|
||||||
|
let Some(read) = read else {
|
||||||
|
return Err(DefinitionError {
|
||||||
|
code: "token_definition_unreadable",
|
||||||
|
warn: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let Ok((id, account)) = decode_account(read) else {
|
||||||
|
return Err(DefinitionError {
|
||||||
|
code: "token_definition_unreadable",
|
||||||
|
warn: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if id != token_id {
|
||||||
|
return Err(DefinitionError {
|
||||||
|
code: "token_definition_unreadable",
|
||||||
|
warn: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if account.program_owner != token_program {
|
||||||
|
return Err(DefinitionError {
|
||||||
|
code: "token_program_mismatch",
|
||||||
|
warn: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
match TokenDefinition::try_from(&account.data) {
|
||||||
|
Ok(TokenDefinition::Fungible {
|
||||||
|
name,
|
||||||
|
total_supply,
|
||||||
|
metadata_id,
|
||||||
|
..
|
||||||
|
}) => Ok((name, total_supply, metadata_id)),
|
||||||
|
_ => Err(DefinitionError {
|
||||||
|
code: "token_not_fungible",
|
||||||
|
warn: false,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
106
apps/amm/client/src/api/funding.rs
Normal file
106
apps/amm/client/src/api/funding.rs
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
use nssa_core::Commitment;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use sha2::{Digest as _, Sha256};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
commitment::{FundingCommitment, QuoteCommitment, SourceCommitment},
|
||||||
|
holding::SelectedHolding,
|
||||||
|
pair::PairIds,
|
||||||
|
quote_error::issue,
|
||||||
|
};
|
||||||
|
use crate::account::{decode_account, AccountRead};
|
||||||
|
|
||||||
|
pub(super) fn funding_issues(
|
||||||
|
wallet_available: bool,
|
||||||
|
pair: PairIds,
|
||||||
|
holding_a: &Option<SelectedHolding>,
|
||||||
|
requested_a: u128,
|
||||||
|
holding_b: &Option<SelectedHolding>,
|
||||||
|
requested_b: u128,
|
||||||
|
fields: [&str; 2],
|
||||||
|
) -> Vec<Value> {
|
||||||
|
if !wallet_available {
|
||||||
|
return vec![issue(
|
||||||
|
"no_wallet",
|
||||||
|
"Connect a wallet to submit.",
|
||||||
|
&[],
|
||||||
|
json!({}),
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for (token_id, holding, requested, field) in [
|
||||||
|
(pair.token_a, holding_a, requested_a, fields[0]),
|
||||||
|
(pair.token_b, holding_b, requested_b, fields[1]),
|
||||||
|
] {
|
||||||
|
let available = holding.as_ref().map_or(0, |value| value.balance);
|
||||||
|
if available < requested {
|
||||||
|
errors.push(issue(
|
||||||
|
"amount_exceeds_balance",
|
||||||
|
"Amount exceeds the selected wallet holding balance.",
|
||||||
|
&[field],
|
||||||
|
json!({
|
||||||
|
"requestedRaw": requested.to_string(),
|
||||||
|
"availableRaw": available.to_string(),
|
||||||
|
"holdingFound": holding.is_some(),
|
||||||
|
"tokenId": token_id.to_string(),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn funding_commitments(
|
||||||
|
pair: PairIds,
|
||||||
|
holding_a: &Option<SelectedHolding>,
|
||||||
|
requested_a: u128,
|
||||||
|
holding_b: &Option<SelectedHolding>,
|
||||||
|
requested_b: u128,
|
||||||
|
) -> Vec<FundingCommitment> {
|
||||||
|
[
|
||||||
|
(pair.token_a, holding_a, requested_a),
|
||||||
|
(pair.token_b, holding_b, requested_b),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(token_id, holding, requested)| FundingCommitment {
|
||||||
|
token_id: token_id.into_value(),
|
||||||
|
holding_id: holding.as_ref().map(|value| value.id.into_value()),
|
||||||
|
available: holding.as_ref().map_or(0, |value| value.balance),
|
||||||
|
requested,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sources_from_reads(
|
||||||
|
reads: &[(&str, &AccountRead)],
|
||||||
|
) -> Result<Vec<SourceCommitment>, String> {
|
||||||
|
reads
|
||||||
|
.iter()
|
||||||
|
.map(|(role, read)| {
|
||||||
|
let (id, account) = decode_account(read)?;
|
||||||
|
Ok(SourceCommitment {
|
||||||
|
role: String::from(*role),
|
||||||
|
commitment: Commitment::new(&id, &account).to_byte_array(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn append_holding_source(
|
||||||
|
sources: &mut Vec<SourceCommitment>,
|
||||||
|
role: &str,
|
||||||
|
holding: Option<&SelectedHolding>,
|
||||||
|
) {
|
||||||
|
if let Some(holding) = holding {
|
||||||
|
sources.push(SourceCommitment {
|
||||||
|
role: String::from(role),
|
||||||
|
commitment: Commitment::new(&holding.id, &holding.account).to_byte_array(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn hash_quote(commitment: &QuoteCommitment) -> Result<String, String> {
|
||||||
|
let bytes = borsh::to_vec(commitment)
|
||||||
|
.map_err(|error| format!("quote commitment serialization failed: {error}"))?;
|
||||||
|
Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes))))
|
||||||
|
}
|
||||||
64
apps/amm/client/src/api/holding.rs
Normal file
64
apps/amm/client/src/api/holding.rs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
use nssa_core::{
|
||||||
|
account::{Account, AccountId},
|
||||||
|
program::ProgramId,
|
||||||
|
};
|
||||||
|
use token_core::TokenHolding;
|
||||||
|
|
||||||
|
use crate::account::{decode_account, AccountRead};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(super) struct SelectedHolding {
|
||||||
|
pub(super) id: AccountId,
|
||||||
|
pub(super) definition_id: AccountId,
|
||||||
|
pub(super) balance: u128,
|
||||||
|
pub(super) account: Account,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn wallet_holdings(
|
||||||
|
reads: &[AccountRead],
|
||||||
|
token_program: ProgramId,
|
||||||
|
) -> Vec<SelectedHolding> {
|
||||||
|
reads
|
||||||
|
.iter()
|
||||||
|
.filter_map(|read| decode_fungible_holding(read, token_program).ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn decode_fungible_holding(
|
||||||
|
read: &AccountRead,
|
||||||
|
token_program: ProgramId,
|
||||||
|
) -> Result<SelectedHolding, String> {
|
||||||
|
let (id, account) = decode_account(read)?;
|
||||||
|
if account.program_owner != token_program {
|
||||||
|
return Err(String::from("holding owner mismatch"));
|
||||||
|
}
|
||||||
|
let TokenHolding::Fungible {
|
||||||
|
definition_id,
|
||||||
|
balance,
|
||||||
|
} = TokenHolding::try_from(&account.data)
|
||||||
|
.map_err(|_| String::from("invalid fungible holding"))?
|
||||||
|
else {
|
||||||
|
return Err(String::from("invalid fungible holding"));
|
||||||
|
};
|
||||||
|
Ok(SelectedHolding {
|
||||||
|
id,
|
||||||
|
definition_id,
|
||||||
|
balance,
|
||||||
|
account,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn select_holding(
|
||||||
|
holdings: &[SelectedHolding],
|
||||||
|
definition_id: AccountId,
|
||||||
|
) -> Option<SelectedHolding> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
97
apps/amm/client/src/api/mod.rs
Normal file
97
apps/amm/client/src/api/mod.rs
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
//! Transport-independent AMM client operations.
|
||||||
|
|
||||||
|
mod accounts;
|
||||||
|
mod clock;
|
||||||
|
mod commitment;
|
||||||
|
mod config;
|
||||||
|
mod context;
|
||||||
|
mod funding;
|
||||||
|
mod holding;
|
||||||
|
mod pair;
|
||||||
|
mod plan;
|
||||||
|
mod position;
|
||||||
|
mod quote;
|
||||||
|
mod quote_error;
|
||||||
|
mod request;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
use std::{error::Error, fmt};
|
||||||
|
|
||||||
|
pub use request::{
|
||||||
|
ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest,
|
||||||
|
QuoteRequest, TokenIdsRequest,
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub use crate::account::{AccountRead, WalletAccount};
|
||||||
|
|
||||||
|
/// Schema identifier expected by position quote and plan requests.
|
||||||
|
pub const NEW_POSITION_SCHEMA: &str = "new-position.v1";
|
||||||
|
|
||||||
|
pub(crate) const SCHEMA: &str = NEW_POSITION_SCHEMA;
|
||||||
|
|
||||||
|
/// JSON response shared by direct Rust callers and transport adapters.
|
||||||
|
pub type AmmResponse = Value;
|
||||||
|
|
||||||
|
/// Result returned by AMM client operations.
|
||||||
|
pub type AmmResult = Result<AmmResponse, AmmApiError>;
|
||||||
|
|
||||||
|
/// Failure produced before an AMM response can be constructed.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct AmmApiError {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AmmApiError {
|
||||||
|
/// Returns the stable human-readable failure detail.
|
||||||
|
#[must_use]
|
||||||
|
pub fn message(&self) -> &str {
|
||||||
|
&self.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for AmmApiError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(&self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for AmmApiError {}
|
||||||
|
|
||||||
|
impl From<String> for AmmApiError {
|
||||||
|
fn from(message: String) -> Self {
|
||||||
|
Self { message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives the AMM configuration account ID.
|
||||||
|
pub fn config_id(request: ConfigIdRequest) -> AmmResult {
|
||||||
|
config::config_id(request).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discovers token definition IDs available to the active wallet and app.
|
||||||
|
pub fn token_ids(request: TokenIdsRequest) -> AmmResult {
|
||||||
|
context::token_ids(request).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives canonical accounts for one token pair.
|
||||||
|
pub fn pair_ids(request: PairIdsRequest) -> AmmResult {
|
||||||
|
pair::pair_ids(request).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds network, token, holding, and fee-tier context.
|
||||||
|
pub fn context(request: ContextRequest) -> AmmResult {
|
||||||
|
context::context(request).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluates a pool-creation or add-liquidity request.
|
||||||
|
pub fn quote(request: QuoteRequest) -> AmmResult {
|
||||||
|
quote::quote(request).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Materializes a previously quoted request into wallet submission arguments.
|
||||||
|
pub fn plan(request: PlanRequest) -> AmmResult {
|
||||||
|
plan::plan(request).map_err(Into::into)
|
||||||
|
}
|
||||||
93
apps/amm/client/src/api/pair.rs
Normal file
93
apps/amm/client/src/api/pair.rs
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
use amm_core::{
|
||||||
|
compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda,
|
||||||
|
compute_vault_pda,
|
||||||
|
};
|
||||||
|
use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID;
|
||||||
|
use nssa_core::{account::AccountId, program::ProgramId};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use twap_oracle_core::compute_current_tick_account_pda;
|
||||||
|
|
||||||
|
use super::{config::load_config, PairIdsRequest};
|
||||||
|
use crate::account::{account_id_hex, parse_base58_id, parse_program_id, AccountRead};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(super) struct PairIds {
|
||||||
|
pub(super) token_a: AccountId,
|
||||||
|
pub(super) token_b: AccountId,
|
||||||
|
pub(super) config: AccountId,
|
||||||
|
pub(super) pool: AccountId,
|
||||||
|
pub(super) vault_a: AccountId,
|
||||||
|
pub(super) vault_b: AccountId,
|
||||||
|
pub(super) lp_definition: AccountId,
|
||||||
|
pub(super) lp_lock_holding: AccountId,
|
||||||
|
pub(super) current_tick: AccountId,
|
||||||
|
pub(super) clock: AccountId,
|
||||||
|
pub(super) token_program: ProgramId,
|
||||||
|
pub(super) twap_program: ProgramId,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn pair_ids(request: PairIdsRequest) -> Result<Value, String> {
|
||||||
|
let amm_program = parse_program_id(&request.amm_program_id)?;
|
||||||
|
let Ok(token_a) = parse_base58_id(&request.token_a_id, "token A id") else {
|
||||||
|
return Ok(json!({ "status": "error", "code": "invalid_token_id" }));
|
||||||
|
};
|
||||||
|
let Ok(token_b) = parse_base58_id(&request.token_b_id, "token B id") else {
|
||||||
|
return Ok(json!({ "status": "error", "code": "invalid_token_id" }));
|
||||||
|
};
|
||||||
|
if token_a == token_b {
|
||||||
|
return Ok(json!({ "status": "error", "code": "same_token_pair" }));
|
||||||
|
}
|
||||||
|
if !is_canonical_pair(token_a, token_b) {
|
||||||
|
return Ok(json!({ "status": "error", "code": "non_canonical_pair" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(pair) = derive_pair(amm_program, token_a, token_b, &request.config) else {
|
||||||
|
return Ok(json!({ "status": "error", "code": "config_unavailable" }));
|
||||||
|
};
|
||||||
|
Ok(pair_json(pair))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_canonical_pair(token_a: AccountId, token_b: AccountId) -> bool {
|
||||||
|
token_a.value() > token_b.value()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn derive_pair(
|
||||||
|
amm_program: ProgramId,
|
||||||
|
token_a: AccountId,
|
||||||
|
token_b: AccountId,
|
||||||
|
config_read: &AccountRead,
|
||||||
|
) -> Result<PairIds, String> {
|
||||||
|
let config_id = compute_config_pda(amm_program);
|
||||||
|
let config = load_config(amm_program, config_read)?;
|
||||||
|
let pool = compute_pool_pda(amm_program, token_a, token_b);
|
||||||
|
Ok(PairIds {
|
||||||
|
token_a,
|
||||||
|
token_b,
|
||||||
|
config: config_id,
|
||||||
|
pool,
|
||||||
|
vault_a: compute_vault_pda(amm_program, pool, token_a),
|
||||||
|
vault_b: compute_vault_pda(amm_program, pool, token_b),
|
||||||
|
lp_definition: compute_liquidity_token_pda(amm_program, pool),
|
||||||
|
lp_lock_holding: compute_lp_lock_holding_pda(amm_program, pool),
|
||||||
|
current_tick: compute_current_tick_account_pda(config.twap_oracle_program_id, pool),
|
||||||
|
clock: CLOCK_01_PROGRAM_ACCOUNT_ID,
|
||||||
|
token_program: config.token_program_id,
|
||||||
|
twap_program: config.twap_oracle_program_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pair_json(pair: PairIds) -> Value {
|
||||||
|
json!({
|
||||||
|
"status": "ok",
|
||||||
|
"tokenAId": account_id_hex(pair.token_a),
|
||||||
|
"tokenBId": account_id_hex(pair.token_b),
|
||||||
|
"configId": account_id_hex(pair.config),
|
||||||
|
"poolId": account_id_hex(pair.pool),
|
||||||
|
"vaultAId": account_id_hex(pair.vault_a),
|
||||||
|
"vaultBId": account_id_hex(pair.vault_b),
|
||||||
|
"lpDefinitionId": account_id_hex(pair.lp_definition),
|
||||||
|
"lpLockHoldingId": account_id_hex(pair.lp_lock_holding),
|
||||||
|
"currentTickId": account_id_hex(pair.current_tick),
|
||||||
|
"clockId": account_id_hex(pair.clock),
|
||||||
|
})
|
||||||
|
}
|
||||||
136
apps/amm/client/src/api/plan.rs
Normal file
136
apps/amm/client/src/api/plan.rs
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
use nssa_core::account::Account;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
clock::decode_clock,
|
||||||
|
position::{NewPositionPlan, QuoteBranch, QuoteComputation},
|
||||||
|
quote::compute_quote,
|
||||||
|
PlanRequest, QuoteRequest, SCHEMA,
|
||||||
|
};
|
||||||
|
use crate::account::{account_id_hex, decode_account, AccountRead};
|
||||||
|
|
||||||
|
const DEADLINE_WINDOW_MS: u64 = 1_200_000;
|
||||||
|
|
||||||
|
pub(super) fn plan(input: PlanRequest) -> Result<Value, String> {
|
||||||
|
let quote_input = QuoteRequest {
|
||||||
|
network_id: input.network_id,
|
||||||
|
network_fingerprint: input.network_fingerprint,
|
||||||
|
amm_program_id: input.amm_program_id.clone(),
|
||||||
|
request: input.request,
|
||||||
|
snapshot: input.snapshot,
|
||||||
|
};
|
||||||
|
let quote = compute_quote("e_input)?;
|
||||||
|
if quote.quote_hash() != Some(input.quote_hash.as_str()) {
|
||||||
|
return Ok(json!({
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"status": "error",
|
||||||
|
"code": "quote_changed",
|
||||||
|
"recoverable": true,
|
||||||
|
"quote": quote.into_value("e_input.request),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let evaluated = match quote {
|
||||||
|
QuoteComputation::Evaluated(evaluated) => evaluated,
|
||||||
|
QuoteComputation::Failed(failure) => {
|
||||||
|
return Ok(json!({
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"status": "error",
|
||||||
|
"code": "quote_not_submittable",
|
||||||
|
"recoverable": true,
|
||||||
|
"quote": failure.into_value("e_input.request),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(plan) = evaluated.plan else {
|
||||||
|
return Ok(json!({
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"status": "error",
|
||||||
|
"code": "quote_not_submittable",
|
||||||
|
"recoverable": true,
|
||||||
|
"quote": evaluated.value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
let fresh_lp = if plan.requires_fresh_lp() {
|
||||||
|
let Some(read) = input.fresh_lp.as_ref() else {
|
||||||
|
return Ok(json!({
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"status": "needs_fresh_lp",
|
||||||
|
"code": "fresh_lp_required",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
let Ok((id, account)) = decode_account(read) else {
|
||||||
|
return Ok(plan_error("wallet_submission_failed"));
|
||||||
|
};
|
||||||
|
if account != Account::default() || plan.accounts.contains(id) {
|
||||||
|
return Ok(plan_error("wallet_submission_failed"));
|
||||||
|
}
|
||||||
|
Some(id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let deadline = input
|
||||||
|
.now_ms
|
||||||
|
.checked_add(DEADLINE_WINDOW_MS)
|
||||||
|
.ok_or_else(|| String::from("transaction deadline overflow"))?;
|
||||||
|
let clock_timestamp = clock_timestamp("e_input.snapshot.clock)?;
|
||||||
|
if clock_timestamp >= deadline {
|
||||||
|
return Ok(plan_error("transaction_deadline_expired"));
|
||||||
|
}
|
||||||
|
let NewPositionPlan { accounts, branch } = plan;
|
||||||
|
let (account_ids, signing_requirements) = accounts.wallet_args(fresh_lp)?;
|
||||||
|
let instruction = match branch {
|
||||||
|
QuoteBranch::Missing { amount_a, amount_b } => {
|
||||||
|
let instruction = amm_core::Instruction::NewDefinition {
|
||||||
|
token_a_amount: amount_a,
|
||||||
|
token_b_amount: amount_b,
|
||||||
|
fees: u128::from(quote_input.request.fee_bps),
|
||||||
|
deadline,
|
||||||
|
};
|
||||||
|
risc0_zkvm::serde::to_vec(&instruction)
|
||||||
|
.map_err(|error| format!("instruction serialization failed: {error}"))?
|
||||||
|
}
|
||||||
|
QuoteBranch::Active {
|
||||||
|
max_a,
|
||||||
|
max_b,
|
||||||
|
minimum_lp,
|
||||||
|
stored_reversed,
|
||||||
|
} => {
|
||||||
|
let (stored_max_a, stored_max_b) = if stored_reversed {
|
||||||
|
(max_b, max_a)
|
||||||
|
} else {
|
||||||
|
(max_a, max_b)
|
||||||
|
};
|
||||||
|
let instruction = amm_core::Instruction::AddLiquidity {
|
||||||
|
min_amount_liquidity: minimum_lp,
|
||||||
|
max_amount_to_add_token_a: stored_max_a,
|
||||||
|
max_amount_to_add_token_b: stored_max_b,
|
||||||
|
deadline,
|
||||||
|
};
|
||||||
|
risc0_zkvm::serde::to_vec(&instruction)
|
||||||
|
.map_err(|error| format!("instruction serialization failed: {error}"))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"status": "ready",
|
||||||
|
"programId": input.amm_program_id,
|
||||||
|
"accountIds": account_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(),
|
||||||
|
"signingRequirements": signing_requirements,
|
||||||
|
"instruction": instruction,
|
||||||
|
"deadlineMs": deadline.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan_error(code: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"status": "error",
|
||||||
|
"code": code,
|
||||||
|
"recoverable": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clock_timestamp(read: &AccountRead) -> Result<u64, String> {
|
||||||
|
decode_clock(read).map(|(_, clock)| clock.timestamp)
|
||||||
|
}
|
||||||
229
apps/amm/client/src/api/position.rs
Normal file
229
apps/amm/client/src/api/position.rs
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
use nssa_core::{account::AccountId, program::ProgramId};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
commitment::SourceCommitment, holding::SelectedHolding, pair::PairIds, quote_error::issue,
|
||||||
|
PairSnapshot, PositionRequest, SCHEMA,
|
||||||
|
};
|
||||||
|
use crate::account::{account_id_from_hex, program_id_base58};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(super) enum QuoteBranch {
|
||||||
|
Missing {
|
||||||
|
amount_a: u128,
|
||||||
|
amount_b: u128,
|
||||||
|
},
|
||||||
|
Active {
|
||||||
|
max_a: u128,
|
||||||
|
max_b: u128,
|
||||||
|
minimum_lp: u128,
|
||||||
|
stored_reversed: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct EvaluatedQuote {
|
||||||
|
pub(super) value: Value,
|
||||||
|
pub(super) quote_hash: String,
|
||||||
|
pub(super) plan: Option<NewPositionPlan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) enum QuoteComputation {
|
||||||
|
Failed(QuoteFailure),
|
||||||
|
Evaluated(EvaluatedQuote),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct QuoteFailure {
|
||||||
|
pub(super) code: &'static str,
|
||||||
|
pub(super) fields: Vec<&'static str>,
|
||||||
|
pub(super) details: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct NewPositionPlan {
|
||||||
|
pub(super) accounts: AccountPlan,
|
||||||
|
pub(super) branch: QuoteBranch,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct AccountPlan {
|
||||||
|
pub(super) rows: Vec<AccountPlanRow>,
|
||||||
|
pub(super) sources: Vec<SourceCommitment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct AccountPlanRow {
|
||||||
|
pub(super) role: &'static str,
|
||||||
|
pub(super) account_id: Option<AccountId>,
|
||||||
|
pub(super) expected_program: Option<ProgramId>,
|
||||||
|
pub(super) action: &'static str,
|
||||||
|
pub(super) signer: bool,
|
||||||
|
pub(super) init: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct AccountPlanHoldings<'a> {
|
||||||
|
pub(super) token_a: Option<&'a SelectedHolding>,
|
||||||
|
pub(super) token_b: Option<&'a SelectedHolding>,
|
||||||
|
pub(super) lp: Option<&'a SelectedHolding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuoteComputation {
|
||||||
|
pub(super) fn into_value(self, request: &PositionRequest) -> Value {
|
||||||
|
match self {
|
||||||
|
Self::Failed(failure) => failure.into_value(request),
|
||||||
|
Self::Evaluated(EvaluatedQuote { value, .. }) => value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn quote_hash(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Self::Failed(_) => None,
|
||||||
|
Self::Evaluated(quote) => Some("e.quote_hash),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuoteFailure {
|
||||||
|
pub(super) fn into_value(self, request: &PositionRequest) -> Value {
|
||||||
|
json!({
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"status": "error",
|
||||||
|
"canSubmit": false,
|
||||||
|
"code": self.code,
|
||||||
|
"poolStatus": "unavailable_pool",
|
||||||
|
"tokenAId": request.token_a_id,
|
||||||
|
"tokenBId": request.token_b_id,
|
||||||
|
"accountPreview": [],
|
||||||
|
"errors": [issue(
|
||||||
|
self.code,
|
||||||
|
"Position quote is unavailable.",
|
||||||
|
&self.fields,
|
||||||
|
self.details,
|
||||||
|
)],
|
||||||
|
"warnings": [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewPositionPlan {
|
||||||
|
pub(super) fn new(accounts: AccountPlan, branch: QuoteBranch) -> Result<Self, String> {
|
||||||
|
accounts.validate_ready()?;
|
||||||
|
Ok(Self { accounts, branch })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn requires_fresh_lp(&self) -> bool {
|
||||||
|
self.accounts.requires_fresh_lp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountPlan {
|
||||||
|
pub(super) fn validate_snapshot_ids(
|
||||||
|
pair: &PairIds,
|
||||||
|
snapshot: &PairSnapshot,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
let expected = [
|
||||||
|
(&snapshot.config, pair.config, "config"),
|
||||||
|
(&snapshot.token_a, pair.token_a, "token_a"),
|
||||||
|
(&snapshot.token_b, pair.token_b, "token_b"),
|
||||||
|
(&snapshot.pool, pair.pool, "pool"),
|
||||||
|
(&snapshot.vault_a, pair.vault_a, "vault_a"),
|
||||||
|
(&snapshot.vault_b, pair.vault_b, "vault_b"),
|
||||||
|
(&snapshot.lp_definition, pair.lp_definition, "lp_definition"),
|
||||||
|
(
|
||||||
|
&snapshot.lp_lock_holding,
|
||||||
|
pair.lp_lock_holding,
|
||||||
|
"lp_lock_holding",
|
||||||
|
),
|
||||||
|
(&snapshot.current_tick, pair.current_tick, "current_tick"),
|
||||||
|
(&snapshot.clock, pair.clock, "clock"),
|
||||||
|
];
|
||||||
|
expected.into_iter().find_map(|(read, id, role)| {
|
||||||
|
match account_id_from_hex(&read.id, "account id") {
|
||||||
|
Ok(actual) if actual == id => None,
|
||||||
|
_ => Some(role),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn preview(&self) -> Vec<Value> {
|
||||||
|
self.rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(order, row)| row.preview(order))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn take_sources(&mut self) -> Vec<SourceCommitment> {
|
||||||
|
std::mem::take(&mut self.sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn requires_fresh_lp(&self) -> bool {
|
||||||
|
self.rows
|
||||||
|
.iter()
|
||||||
|
.any(|row| row.role == "user_holding_lp" && row.account_id.is_none())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn contains(&self, account_id: AccountId) -> bool {
|
||||||
|
self.rows
|
||||||
|
.iter()
|
||||||
|
.any(|row| row.account_id == Some(account_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate_ready(&self) -> Result<(), String> {
|
||||||
|
if self.rows.iter().any(|row| {
|
||||||
|
row.account_id.is_none() && !(row.role == "user_holding_lp" && row.signer && row.init)
|
||||||
|
}) {
|
||||||
|
return Err(String::from("submittable quote has an unresolved account"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn wallet_args(
|
||||||
|
&self,
|
||||||
|
fresh_lp: Option<AccountId>,
|
||||||
|
) -> Result<(Vec<AccountId>, Vec<bool>), String> {
|
||||||
|
let mut account_ids = Vec::with_capacity(self.rows.len());
|
||||||
|
let mut signing_requirements = Vec::with_capacity(self.rows.len());
|
||||||
|
for row in &self.rows {
|
||||||
|
let account_id = match row.account_id {
|
||||||
|
Some(account_id) => account_id,
|
||||||
|
None if row.role == "user_holding_lp" => {
|
||||||
|
fresh_lp.ok_or_else(|| String::from("transaction plan has no LP holding"))?
|
||||||
|
}
|
||||||
|
None => return Err(String::from("transaction plan has an unresolved account")),
|
||||||
|
};
|
||||||
|
account_ids.push(account_id);
|
||||||
|
signing_requirements.push(row.signer);
|
||||||
|
}
|
||||||
|
Ok((account_ids, signing_requirements))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountPlanRow {
|
||||||
|
pub(super) fn new(
|
||||||
|
role: &'static str,
|
||||||
|
account_id: Option<AccountId>,
|
||||||
|
expected_program: Option<ProgramId>,
|
||||||
|
action: &'static str,
|
||||||
|
signer: bool,
|
||||||
|
init: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
role,
|
||||||
|
account_id,
|
||||||
|
expected_program,
|
||||||
|
action,
|
||||||
|
signer,
|
||||||
|
init,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preview(&self, order: usize) -> Value {
|
||||||
|
json!({
|
||||||
|
"order": order,
|
||||||
|
"role": self.role,
|
||||||
|
"accountId": self.account_id.map(|id| id.to_string()),
|
||||||
|
"expectedProgramId": self.expected_program.map(program_id_base58),
|
||||||
|
"action": self.action,
|
||||||
|
"writable": self.action != "read",
|
||||||
|
"signer": self.signer,
|
||||||
|
"init": self.init,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
726
apps/amm/client/src/api/quote.rs
Normal file
726
apps/amm/client/src/api/quote.rs
Normal file
@ -0,0 +1,726 @@
|
|||||||
|
use alloy_primitives::U256;
|
||||||
|
use amm_core::{
|
||||||
|
is_supported_fee_tier, isqrt_product, mul_div_floor, spot_price_q64_64, PoolDefinition,
|
||||||
|
FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY,
|
||||||
|
};
|
||||||
|
use nssa_core::{
|
||||||
|
account::{Account, AccountId},
|
||||||
|
program::ProgramId,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use token_core::TokenDefinition;
|
||||||
|
use twap_oracle_core::CurrentTickAccount;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
accounts::{active_account_plan, missing_account_plan},
|
||||||
|
clock::decode_clock,
|
||||||
|
commitment::{QuoteCommitment, RequestCommitment},
|
||||||
|
context::fungible_definition,
|
||||||
|
funding::{funding_commitments, funding_issues, hash_quote},
|
||||||
|
holding::{decode_fungible_holding, select_holding, wallet_holdings},
|
||||||
|
pair::{derive_pair, is_canonical_pair, PairIds},
|
||||||
|
position::{
|
||||||
|
AccountPlan, AccountPlanHoldings, EvaluatedQuote, NewPositionPlan, QuoteBranch,
|
||||||
|
QuoteComputation,
|
||||||
|
},
|
||||||
|
quote_error::{fatal_quote, issue},
|
||||||
|
QuoteRequest, SCHEMA,
|
||||||
|
};
|
||||||
|
use crate::account::{
|
||||||
|
decode_account, parse_base58_id, parse_program_id, program_id_bytes, AccountRead,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SLIPPAGE_BPS: u32 = 50;
|
||||||
|
const MAX_SLIPPAGE_BPS: u32 = 5_000;
|
||||||
|
const HIGH_SLIPPAGE_BPS: u32 = 2_000;
|
||||||
|
pub(super) const Q64: u128 = 1_u128 << 64;
|
||||||
|
|
||||||
|
pub(super) fn quote(request: QuoteRequest) -> Result<Value, String> {
|
||||||
|
Ok(compute_quote(&request)?.into_value(&request.request))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn compute_quote(input: &QuoteRequest) -> Result<QuoteComputation, String> {
|
||||||
|
if input.request.schema != SCHEMA {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"unsupported_schema",
|
||||||
|
&["schema"],
|
||||||
|
json!({ "received": input.request.schema }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let amm_program = parse_program_id(&input.amm_program_id)?;
|
||||||
|
let token_a = match parse_base58_id(&input.request.token_a_id, "token A id") {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => return Ok(fatal_quote("invalid_token_id", &["tokenAId"], json!({}))),
|
||||||
|
};
|
||||||
|
let token_b = match parse_base58_id(&input.request.token_b_id, "token B id") {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => return Ok(fatal_quote("invalid_token_id", &["tokenBId"], json!({}))),
|
||||||
|
};
|
||||||
|
if token_a == token_b {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"same_token_pair",
|
||||||
|
&["tokenAId", "tokenBId"],
|
||||||
|
json!({}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !is_canonical_pair(token_a, token_b) {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"non_canonical_pair",
|
||||||
|
&["tokenAId", "tokenBId"],
|
||||||
|
json!({}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !is_supported_fee_tier(u128::from(input.request.fee_bps)) {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"invalid_fee_tier",
|
||||||
|
&["feeBps"],
|
||||||
|
json!({ "feeBps": input.request.fee_bps }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pair = match derive_pair(amm_program, token_a, token_b, &input.snapshot.config) {
|
||||||
|
Ok(pair) => pair,
|
||||||
|
Err(_) => return Ok(fatal_quote("config_unavailable", &[], json!({}))),
|
||||||
|
};
|
||||||
|
if let Some(error) = AccountPlan::validate_snapshot_ids(&pair, &input.snapshot) {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"account_read_failed",
|
||||||
|
&[],
|
||||||
|
json!({ "role": error }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (read, token_id, field) in [
|
||||||
|
(&input.snapshot.token_a, token_a, "tokenAId"),
|
||||||
|
(&input.snapshot.token_b, token_b, "tokenBId"),
|
||||||
|
] {
|
||||||
|
if let Err(error) = fungible_definition(Some(read), token_id, pair.token_program) {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
error.code,
|
||||||
|
&[field],
|
||||||
|
json!({ "tokenId": token_id.to_string() }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, pool_account) = match decode_account(&input.snapshot.pool) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"account_read_failed",
|
||||||
|
&[],
|
||||||
|
json!({ "role": "pool", "accountId": pair.pool.to_string() }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if pool_account == Account::default() {
|
||||||
|
compute_missing_quote(input, amm_program, pair)
|
||||||
|
} else {
|
||||||
|
compute_active_quote(input, amm_program, pair, pool_account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_missing_quote(
|
||||||
|
input: &QuoteRequest,
|
||||||
|
amm_program: ProgramId,
|
||||||
|
pair: PairIds,
|
||||||
|
) -> Result<QuoteComputation, String> {
|
||||||
|
for (read, role) in [
|
||||||
|
(&input.snapshot.vault_a, "vault_a"),
|
||||||
|
(&input.snapshot.vault_b, "vault_b"),
|
||||||
|
(&input.snapshot.lp_definition, "lp_definition"),
|
||||||
|
(&input.snapshot.lp_lock_holding, "lp_lock_holding"),
|
||||||
|
(&input.snapshot.current_tick, "current_tick"),
|
||||||
|
] {
|
||||||
|
let Ok((_, account)) = decode_account(read) else {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"account_read_failed",
|
||||||
|
&[],
|
||||||
|
json!({ "role": role }),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if account != Account::default() {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"pool_unavailable",
|
||||||
|
&[],
|
||||||
|
json!({ "role": role }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid_clock(&input.snapshot.clock, pair.clock) {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"account_read_failed",
|
||||||
|
&[],
|
||||||
|
json!({ "role": "clock" }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let requested_price = match raw_value(input.request.initial_price_real_raw.as_deref()) {
|
||||||
|
Ok(value) if value > 0 => value,
|
||||||
|
Ok(_) => {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"amount_must_be_positive",
|
||||||
|
&["initialPriceRealRaw"],
|
||||||
|
json!({}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(code) => return Ok(fatal_quote(code, &["initialPriceRealRaw"], json!({}))),
|
||||||
|
};
|
||||||
|
let (minimum_a, minimum_b) = minimum_opening_pair(requested_price)?;
|
||||||
|
let direct_amounts =
|
||||||
|
input.request.amount_a_raw.is_some() || input.request.amount_b_raw.is_some();
|
||||||
|
let (amount_a, amount_b) = if direct_amounts {
|
||||||
|
let amount_a = match raw_value(input.request.amount_a_raw.as_deref()) {
|
||||||
|
Ok(value) if value > 0 => value,
|
||||||
|
Ok(_) => {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"amount_must_be_positive",
|
||||||
|
&["amountARaw"],
|
||||||
|
json!({}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(code) => return Ok(fatal_quote(code, &["amountARaw"], json!({}))),
|
||||||
|
};
|
||||||
|
let amount_b = match raw_value(input.request.amount_b_raw.as_deref()) {
|
||||||
|
Ok(value) if value > 0 => value,
|
||||||
|
Ok(_) => {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"amount_must_be_positive",
|
||||||
|
&["amountBRaw"],
|
||||||
|
json!({}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(code) => return Ok(fatal_quote(code, &["amountBRaw"], json!({}))),
|
||||||
|
};
|
||||||
|
if spot_price_q64_64(amount_a, amount_b) != requested_price {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"deposit_ratio_mismatch",
|
||||||
|
&["amountARaw", "amountBRaw"],
|
||||||
|
json!({}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(amount_a, amount_b)
|
||||||
|
} else {
|
||||||
|
(minimum_a, minimum_b)
|
||||||
|
};
|
||||||
|
let initial_lp = isqrt_product(amount_a, amount_b);
|
||||||
|
if initial_lp <= MINIMUM_LIQUIDITY {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"amount_too_low",
|
||||||
|
&["amountARaw", "amountBRaw"],
|
||||||
|
json!({ "minimumLiquidityRaw": MINIMUM_LIQUIDITY.to_string() }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
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(
|
||||||
|
input.snapshot.wallet_available,
|
||||||
|
pair,
|
||||||
|
&holding_a,
|
||||||
|
amount_a,
|
||||||
|
&holding_b,
|
||||||
|
amount_b,
|
||||||
|
["amountARaw", "amountBRaw"],
|
||||||
|
);
|
||||||
|
let can_submit = funding.is_empty();
|
||||||
|
let mut account_plan = missing_account_plan(
|
||||||
|
input,
|
||||||
|
pair,
|
||||||
|
amm_program,
|
||||||
|
AccountPlanHoldings {
|
||||||
|
token_a: holding_a.as_ref(),
|
||||||
|
token_b: holding_b.as_ref(),
|
||||||
|
lp: None,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let sources = account_plan.take_sources();
|
||||||
|
let funding_commitment = funding_commitments(pair, &holding_a, amount_a, &holding_b, amount_b);
|
||||||
|
let commitment = QuoteCommitment {
|
||||||
|
schema: String::from(SCHEMA),
|
||||||
|
network_id: input.network_id.clone(),
|
||||||
|
network_fingerprint: input.network_fingerprint.clone(),
|
||||||
|
amm_program_id: program_id_bytes(amm_program),
|
||||||
|
token_a_id: pair.token_a.into_value(),
|
||||||
|
token_b_id: pair.token_b.into_value(),
|
||||||
|
fee_bps: input.request.fee_bps,
|
||||||
|
pool_status: 0,
|
||||||
|
request: RequestCommitment::Missing { amount_a, amount_b },
|
||||||
|
max_a: amount_a,
|
||||||
|
max_b: amount_b,
|
||||||
|
actual_a: amount_a,
|
||||||
|
actual_b: amount_b,
|
||||||
|
expected_lp,
|
||||||
|
lp_guard: MINIMUM_LIQUIDITY,
|
||||||
|
requires_fresh_lp: true,
|
||||||
|
sources,
|
||||||
|
funding: funding_commitment,
|
||||||
|
warnings: Vec::new(),
|
||||||
|
};
|
||||||
|
let quote_hash = hash_quote(&commitment)?;
|
||||||
|
let preview = account_plan.preview();
|
||||||
|
let value = json!({
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"status": "ok",
|
||||||
|
"canSubmit": can_submit,
|
||||||
|
"code": if can_submit { "ready" } else { "funding_required" },
|
||||||
|
"poolStatus": "missing_pool",
|
||||||
|
"instruction": "NewDefinition",
|
||||||
|
"quoteHash": quote_hash,
|
||||||
|
"feeBps": input.request.fee_bps,
|
||||||
|
"poolId": pair.pool.to_string(),
|
||||||
|
"tokenAId": pair.token_a.to_string(),
|
||||||
|
"tokenBId": pair.token_b.to_string(),
|
||||||
|
"maxAmountARaw": amount_a.to_string(),
|
||||||
|
"maxAmountBRaw": amount_b.to_string(),
|
||||||
|
"actualAmountARaw": amount_a.to_string(),
|
||||||
|
"actualAmountBRaw": amount_b.to_string(),
|
||||||
|
"expectedLpRaw": expected_lp.to_string(),
|
||||||
|
"lockedLpRaw": MINIMUM_LIQUIDITY.to_string(),
|
||||||
|
"initialPriceRealRaw": spot_price_q64_64(amount_a, amount_b).to_string(),
|
||||||
|
"minimumAmountARaw": minimum_a.to_string(),
|
||||||
|
"minimumAmountBRaw": minimum_b.to_string(),
|
||||||
|
"requiresFreshLp": true,
|
||||||
|
"accountPreview": preview,
|
||||||
|
"errors": funding,
|
||||||
|
"warnings": [],
|
||||||
|
});
|
||||||
|
let plan = if can_submit {
|
||||||
|
Some(NewPositionPlan::new(
|
||||||
|
account_plan,
|
||||||
|
QuoteBranch::Missing { amount_a, amount_b },
|
||||||
|
)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(QuoteComputation::Evaluated(EvaluatedQuote {
|
||||||
|
value,
|
||||||
|
quote_hash,
|
||||||
|
plan,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_active_quote(
|
||||||
|
input: &QuoteRequest,
|
||||||
|
amm_program: ProgramId,
|
||||||
|
pair: PairIds,
|
||||||
|
pool_account: Account,
|
||||||
|
) -> Result<QuoteComputation, String> {
|
||||||
|
if pool_account.program_owner != amm_program {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"pool_unavailable",
|
||||||
|
&[],
|
||||||
|
json!({ "reason": "owner_mismatch" }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Ok(pool) = PoolDefinition::try_from(&pool_account.data) else {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"pool_unavailable",
|
||||||
|
&[],
|
||||||
|
json!({ "reason": "invalid_pool_data" }),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let stored_reversed = if pool.definition_token_a_id == pair.token_a
|
||||||
|
&& pool.definition_token_b_id == pair.token_b
|
||||||
|
{
|
||||||
|
false
|
||||||
|
} else if pool.definition_token_a_id == pair.token_b
|
||||||
|
&& pool.definition_token_b_id == pair.token_a
|
||||||
|
{
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"pool_unavailable",
|
||||||
|
&[],
|
||||||
|
json!({ "reason": "pair_mismatch" }),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if pool.reserve_a == 0 || pool.reserve_b == 0 || pool.liquidity_pool_supply == 0 {
|
||||||
|
return Ok(fatal_quote("pool_inactive", &[], json!({})));
|
||||||
|
}
|
||||||
|
if pool.fees != u128::from(input.request.fee_bps) {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"fee_tier_mismatch",
|
||||||
|
&["feeBps"],
|
||||||
|
json!({ "poolFeeBps": pool.fees.to_string() }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !is_supported_fee_tier(pool.fees) {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"pool_unavailable",
|
||||||
|
&[],
|
||||||
|
json!({ "reason": "unsupported_pool_fee" }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_a = match raw_value(input.request.max_amount_a_raw.as_deref()) {
|
||||||
|
Ok(value) if value > 0 => value,
|
||||||
|
Ok(_) => {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"amount_must_be_positive",
|
||||||
|
&["maxAmountARaw"],
|
||||||
|
json!({}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(code) => return Ok(fatal_quote(code, &["maxAmountARaw"], json!({}))),
|
||||||
|
};
|
||||||
|
let max_b = match raw_value(input.request.max_amount_b_raw.as_deref()) {
|
||||||
|
Ok(value) if value > 0 => value,
|
||||||
|
Ok(_) => {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"amount_must_be_positive",
|
||||||
|
&["maxAmountBRaw"],
|
||||||
|
json!({}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(code) => return Ok(fatal_quote(code, &["maxAmountBRaw"], json!({}))),
|
||||||
|
};
|
||||||
|
let slippage_bps = input.request.slippage_bps.unwrap_or(DEFAULT_SLIPPAGE_BPS);
|
||||||
|
if slippage_bps > MAX_SLIPPAGE_BPS {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"invalid_slippage",
|
||||||
|
&["slippageBps"],
|
||||||
|
json!({ "maximum": MAX_SLIPPAGE_BPS }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (stored_max_a, stored_max_b) = if stored_reversed {
|
||||||
|
(max_b, max_a)
|
||||||
|
} else {
|
||||||
|
(max_a, max_b)
|
||||||
|
};
|
||||||
|
let ideal_a = mul_div_floor(pool.reserve_a, stored_max_b, pool.reserve_b);
|
||||||
|
let ideal_b = mul_div_floor(pool.reserve_b, stored_max_a, pool.reserve_a);
|
||||||
|
let stored_actual_a = stored_max_a.min(ideal_a);
|
||||||
|
let stored_actual_b = stored_max_b.min(ideal_b);
|
||||||
|
if stored_actual_a == 0 || stored_actual_b == 0 {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"amount_too_low",
|
||||||
|
&["maxAmountARaw", "maxAmountBRaw"],
|
||||||
|
json!({}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let expected_lp =
|
||||||
|
mul_div_floor(pool.liquidity_pool_supply, stored_actual_a, pool.reserve_a).min(
|
||||||
|
mul_div_floor(pool.liquidity_pool_supply, stored_actual_b, pool.reserve_b),
|
||||||
|
);
|
||||||
|
if expected_lp == 0 {
|
||||||
|
return Ok(fatal_quote(
|
||||||
|
"amount_too_low",
|
||||||
|
&["maxAmountARaw", "maxAmountBRaw"],
|
||||||
|
json!({}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let minimum_lp = mul_div_floor(
|
||||||
|
expected_lp,
|
||||||
|
FEE_BPS_DENOMINATOR - u128::from(slippage_bps),
|
||||||
|
FEE_BPS_DENOMINATOR,
|
||||||
|
);
|
||||||
|
if minimum_lp == 0 {
|
||||||
|
return Ok(fatal_quote("minimum_lp_zero", &["slippageBps"], json!({})));
|
||||||
|
}
|
||||||
|
let (actual_a, actual_b, reserve_a, reserve_b) = if stored_reversed {
|
||||||
|
(
|
||||||
|
stored_actual_b,
|
||||||
|
stored_actual_a,
|
||||||
|
pool.reserve_b,
|
||||||
|
pool.reserve_a,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
stored_actual_a,
|
||||||
|
stored_actual_b,
|
||||||
|
pool.reserve_a,
|
||||||
|
pool.reserve_b,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(error) = validate_active_accounts(input, pair, &pool, stored_reversed) {
|
||||||
|
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(
|
||||||
|
input.snapshot.wallet_available,
|
||||||
|
pair,
|
||||||
|
&holding_a,
|
||||||
|
actual_a,
|
||||||
|
&holding_b,
|
||||||
|
actual_b,
|
||||||
|
["maxAmountARaw", "maxAmountBRaw"],
|
||||||
|
);
|
||||||
|
let can_submit = funding.is_empty();
|
||||||
|
let warnings = if slippage_bps >= HIGH_SLIPPAGE_BPS {
|
||||||
|
vec![issue(
|
||||||
|
"high_slippage",
|
||||||
|
"High slippage tolerance.",
|
||||||
|
&["slippageBps"],
|
||||||
|
json!({ "slippageBps": slippage_bps }),
|
||||||
|
)]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let warning_codes = warnings
|
||||||
|
.iter()
|
||||||
|
.filter_map(|warning| warning["code"].as_str().map(String::from))
|
||||||
|
.collect();
|
||||||
|
let mut account_plan = active_account_plan(
|
||||||
|
input,
|
||||||
|
pair,
|
||||||
|
amm_program,
|
||||||
|
&pool,
|
||||||
|
stored_reversed,
|
||||||
|
AccountPlanHoldings {
|
||||||
|
token_a: holding_a.as_ref(),
|
||||||
|
token_b: holding_b.as_ref(),
|
||||||
|
lp: lp_holding.as_ref(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let sources = account_plan.take_sources();
|
||||||
|
let commitment = QuoteCommitment {
|
||||||
|
schema: String::from(SCHEMA),
|
||||||
|
network_id: input.network_id.clone(),
|
||||||
|
network_fingerprint: input.network_fingerprint.clone(),
|
||||||
|
amm_program_id: program_id_bytes(amm_program),
|
||||||
|
token_a_id: pair.token_a.into_value(),
|
||||||
|
token_b_id: pair.token_b.into_value(),
|
||||||
|
fee_bps: input.request.fee_bps,
|
||||||
|
pool_status: 1,
|
||||||
|
request: RequestCommitment::Active {
|
||||||
|
max_a,
|
||||||
|
max_b,
|
||||||
|
slippage_bps,
|
||||||
|
},
|
||||||
|
max_a,
|
||||||
|
max_b,
|
||||||
|
actual_a,
|
||||||
|
actual_b,
|
||||||
|
expected_lp,
|
||||||
|
lp_guard: minimum_lp,
|
||||||
|
requires_fresh_lp,
|
||||||
|
sources,
|
||||||
|
funding: funding_commitments(pair, &holding_a, actual_a, &holding_b, actual_b),
|
||||||
|
warnings: warning_codes,
|
||||||
|
};
|
||||||
|
let quote_hash = hash_quote(&commitment)?;
|
||||||
|
let preview = account_plan.preview();
|
||||||
|
let value = json!({
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"status": "ok",
|
||||||
|
"canSubmit": can_submit,
|
||||||
|
"code": if can_submit { "ready" } else { "funding_required" },
|
||||||
|
"poolStatus": "active_pool",
|
||||||
|
"instruction": "AddLiquidity",
|
||||||
|
"quoteHash": quote_hash,
|
||||||
|
"feeBps": input.request.fee_bps,
|
||||||
|
"poolFeeBps": pool.fees.to_string(),
|
||||||
|
"poolId": pair.pool.to_string(),
|
||||||
|
"tokenAId": pair.token_a.to_string(),
|
||||||
|
"tokenBId": pair.token_b.to_string(),
|
||||||
|
"maxAmountARaw": max_a.to_string(),
|
||||||
|
"maxAmountBRaw": max_b.to_string(),
|
||||||
|
"actualAmountARaw": actual_a.to_string(),
|
||||||
|
"actualAmountBRaw": actual_b.to_string(),
|
||||||
|
"reserveARaw": reserve_a.to_string(),
|
||||||
|
"reserveBRaw": reserve_b.to_string(),
|
||||||
|
"liquiditySupplyRaw": pool.liquidity_pool_supply.to_string(),
|
||||||
|
"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,
|
||||||
|
"accountPreview": preview,
|
||||||
|
"errors": funding,
|
||||||
|
"warnings": warnings,
|
||||||
|
});
|
||||||
|
let plan = if can_submit {
|
||||||
|
Some(NewPositionPlan::new(
|
||||||
|
account_plan,
|
||||||
|
QuoteBranch::Active {
|
||||||
|
max_a,
|
||||||
|
max_b,
|
||||||
|
minimum_lp,
|
||||||
|
stored_reversed,
|
||||||
|
},
|
||||||
|
)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(QuoteComputation::Evaluated(EvaluatedQuote {
|
||||||
|
value,
|
||||||
|
quote_hash,
|
||||||
|
plan,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_active_accounts(
|
||||||
|
input: &QuoteRequest,
|
||||||
|
pair: PairIds,
|
||||||
|
pool: &PoolDefinition,
|
||||||
|
stored_reversed: bool,
|
||||||
|
) -> Option<QuoteComputation> {
|
||||||
|
let expected_vault_a = if stored_reversed {
|
||||||
|
pair.vault_b
|
||||||
|
} else {
|
||||||
|
pair.vault_a
|
||||||
|
};
|
||||||
|
let expected_vault_b = if stored_reversed {
|
||||||
|
pair.vault_a
|
||||||
|
} else {
|
||||||
|
pair.vault_b
|
||||||
|
};
|
||||||
|
if pool.vault_a_id != expected_vault_a
|
||||||
|
|| pool.vault_b_id != expected_vault_b
|
||||||
|
|| pool.liquidity_pool_id != pair.lp_definition
|
||||||
|
{
|
||||||
|
return Some(fatal_quote(
|
||||||
|
"pool_unavailable",
|
||||||
|
&[],
|
||||||
|
json!({ "reason": "pool_account_mismatch" }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (canonical_vault_a, canonical_vault_b) = (
|
||||||
|
decode_holding(&input.snapshot.vault_a, pair.token_program, pair.token_a),
|
||||||
|
decode_holding(&input.snapshot.vault_b, pair.token_program, pair.token_b),
|
||||||
|
);
|
||||||
|
let (Ok(vault_a_balance), Ok(vault_b_balance)) = (canonical_vault_a, canonical_vault_b) else {
|
||||||
|
return Some(fatal_quote(
|
||||||
|
"account_read_failed",
|
||||||
|
&[],
|
||||||
|
json!({ "role": "vault" }),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let (reserve_a, reserve_b) = if stored_reversed {
|
||||||
|
(pool.reserve_b, pool.reserve_a)
|
||||||
|
} else {
|
||||||
|
(pool.reserve_a, pool.reserve_b)
|
||||||
|
};
|
||||||
|
if vault_a_balance < reserve_a || vault_b_balance < reserve_b {
|
||||||
|
return Some(fatal_quote(
|
||||||
|
"pool_unavailable",
|
||||||
|
&[],
|
||||||
|
json!({ "reason": "vault_below_reserve" }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Ok((lp_id, lp_account)) = decode_account(&input.snapshot.lp_definition) else {
|
||||||
|
return Some(fatal_quote(
|
||||||
|
"account_read_failed",
|
||||||
|
&[],
|
||||||
|
json!({ "role": "lp_definition" }),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if lp_id != pair.lp_definition
|
||||||
|
|| lp_account.program_owner != pair.token_program
|
||||||
|
|| !matches!(
|
||||||
|
TokenDefinition::try_from(&lp_account.data),
|
||||||
|
Ok(TokenDefinition::Fungible { .. })
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Some(fatal_quote(
|
||||||
|
"pool_unavailable",
|
||||||
|
&[],
|
||||||
|
json!({ "reason": "invalid_lp_definition" }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Ok((tick_id, tick_account)) = decode_account(&input.snapshot.current_tick) else {
|
||||||
|
return Some(fatal_quote(
|
||||||
|
"account_read_failed",
|
||||||
|
&[],
|
||||||
|
json!({ "role": "current_tick" }),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if tick_id != pair.current_tick
|
||||||
|
|| tick_account.program_owner != pair.twap_program
|
||||||
|
|| CurrentTickAccount::try_from(&tick_account.data).is_err()
|
||||||
|
{
|
||||||
|
return Some(fatal_quote(
|
||||||
|
"pool_unavailable",
|
||||||
|
&[],
|
||||||
|
json!({ "reason": "invalid_current_tick" }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !valid_clock(&input.snapshot.clock, pair.clock) {
|
||||||
|
return Some(fatal_quote(
|
||||||
|
"account_read_failed",
|
||||||
|
&[],
|
||||||
|
json!({ "role": "clock" }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_holding(
|
||||||
|
read: &AccountRead,
|
||||||
|
token_program: ProgramId,
|
||||||
|
definition_id: AccountId,
|
||||||
|
) -> Result<u128, String> {
|
||||||
|
let holding = decode_fungible_holding(read, token_program)?;
|
||||||
|
if holding.definition_id != definition_id {
|
||||||
|
return Err(String::from("invalid fungible holding"));
|
||||||
|
}
|
||||||
|
Ok(holding.balance)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_clock(read: &AccountRead, expected_id: AccountId) -> bool {
|
||||||
|
matches!(decode_clock(read), Ok((id, _)) if id == expected_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_value(value: Option<&str>) -> Result<u128, &'static str> {
|
||||||
|
let Some(value) = value else {
|
||||||
|
return Err("amount_required");
|
||||||
|
};
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err("amount_required");
|
||||||
|
}
|
||||||
|
if !value.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||||
|
return Err("invalid_raw_amount");
|
||||||
|
}
|
||||||
|
value.parse().map_err(|_| "invalid_raw_amount")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn minimum_opening_pair(price: u128) -> Result<(u128, u128), String> {
|
||||||
|
let minimum_initial_lp = U256::from(MINIMUM_LIQUIDITY + 1);
|
||||||
|
let target_product = minimum_initial_lp
|
||||||
|
.checked_mul(minimum_initial_lp)
|
||||||
|
.ok_or_else(|| String::from("minimum liquidity product overflow"))?;
|
||||||
|
if price >= Q64 {
|
||||||
|
let amount_a = binary_search_min(1, MINIMUM_LIQUIDITY + 1, |amount_a| {
|
||||||
|
let amount_b = div_ceil_u256(U256::from(amount_a) * U256::from(price), U256::from(Q64));
|
||||||
|
U256::from(amount_a) * amount_b >= target_product
|
||||||
|
});
|
||||||
|
let amount_b = div_ceil_u256(U256::from(amount_a) * U256::from(price), U256::from(Q64));
|
||||||
|
Ok((
|
||||||
|
amount_a,
|
||||||
|
u128::try_from(amount_b).map_err(|_| String::from("opening amount overflow"))?,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
let amount_b = binary_search_min(1, MINIMUM_LIQUIDITY + 1, |amount_b| {
|
||||||
|
let amount_a = div_ceil_u256(U256::from(amount_b) * U256::from(Q64), U256::from(price));
|
||||||
|
amount_a * U256::from(amount_b) >= target_product
|
||||||
|
});
|
||||||
|
let amount_a = div_ceil_u256(U256::from(amount_b) * U256::from(Q64), U256::from(price));
|
||||||
|
Ok((
|
||||||
|
u128::try_from(amount_a).map_err(|_| String::from("opening amount overflow"))?,
|
||||||
|
amount_b,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn binary_search_min(mut low: u128, mut high: u128, predicate: impl Fn(u128) -> bool) -> u128 {
|
||||||
|
while low < high {
|
||||||
|
let mid = low + (high - low) / 2;
|
||||||
|
if predicate(mid) {
|
||||||
|
high = mid;
|
||||||
|
} else {
|
||||||
|
low = mid + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
low
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn div_ceil_u256(numerator: U256, denominator: U256) -> U256 {
|
||||||
|
numerator.div_ceil(denominator)
|
||||||
|
}
|
||||||
25
apps/amm/client/src/api/quote_error.rs
Normal file
25
apps/amm/client/src/api/quote_error.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use super::position::{QuoteComputation, QuoteFailure};
|
||||||
|
|
||||||
|
pub(super) fn issue(code: &str, message: &str, fields: &[&str], details: Value) -> Value {
|
||||||
|
json!({
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"details": details,
|
||||||
|
"recoverable": true,
|
||||||
|
"blockingFields": fields,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn fatal_quote(
|
||||||
|
code: &'static str,
|
||||||
|
fields: &[&'static str],
|
||||||
|
details: Value,
|
||||||
|
) -> QuoteComputation {
|
||||||
|
QuoteComputation::Failed(QuoteFailure {
|
||||||
|
code,
|
||||||
|
fields: fields.to_vec(),
|
||||||
|
details,
|
||||||
|
})
|
||||||
|
}
|
||||||
116
apps/amm/client/src/api/request.rs
Normal file
116
apps/amm/client/src/api/request.rs
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::account::AccountRead;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ConfigIdRequest {
|
||||||
|
pub amm_program_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TokenIdsRequest {
|
||||||
|
pub amm_program_id: String,
|
||||||
|
pub config: AccountRead,
|
||||||
|
#[serde(default)]
|
||||||
|
pub wallet_accounts: Vec<AccountRead>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub configured_token_ids: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub recent_token_ids: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub resolved_token_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ContextRequest {
|
||||||
|
pub network_id: String,
|
||||||
|
pub network_fingerprint: String,
|
||||||
|
pub amm_program_id: String,
|
||||||
|
pub wallet_available: bool,
|
||||||
|
pub config: AccountRead,
|
||||||
|
#[serde(default)]
|
||||||
|
pub wallet_accounts: Vec<AccountRead>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub token_definitions: Vec<AccountRead>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub configured_token_ids: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub recent_token_ids: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub resolved_token_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PairIdsRequest {
|
||||||
|
pub amm_program_id: String,
|
||||||
|
pub config: AccountRead,
|
||||||
|
pub token_a_id: String,
|
||||||
|
pub token_b_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PositionRequest {
|
||||||
|
pub schema: String,
|
||||||
|
pub token_a_id: String,
|
||||||
|
pub token_b_id: String,
|
||||||
|
pub fee_bps: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub amount_a_raw: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub amount_b_raw: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_amount_a_raw: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_amount_b_raw: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub slippage_bps: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub initial_price_real_raw: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PairSnapshot {
|
||||||
|
pub config: AccountRead,
|
||||||
|
pub token_a: AccountRead,
|
||||||
|
pub token_b: AccountRead,
|
||||||
|
pub pool: AccountRead,
|
||||||
|
pub vault_a: AccountRead,
|
||||||
|
pub vault_b: AccountRead,
|
||||||
|
pub lp_definition: AccountRead,
|
||||||
|
pub lp_lock_holding: AccountRead,
|
||||||
|
pub current_tick: AccountRead,
|
||||||
|
pub clock: AccountRead,
|
||||||
|
pub wallet_available: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub wallet_accounts: Vec<AccountRead>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct QuoteRequest {
|
||||||
|
pub network_id: String,
|
||||||
|
pub network_fingerprint: String,
|
||||||
|
pub amm_program_id: String,
|
||||||
|
pub request: PositionRequest,
|
||||||
|
pub snapshot: PairSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PlanRequest {
|
||||||
|
pub network_id: String,
|
||||||
|
pub network_fingerprint: String,
|
||||||
|
pub amm_program_id: String,
|
||||||
|
pub request: PositionRequest,
|
||||||
|
pub snapshot: PairSnapshot,
|
||||||
|
pub quote_hash: String,
|
||||||
|
pub now_ms: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub fresh_lp: Option<AccountRead>,
|
||||||
|
}
|
||||||
703
apps/amm/client/src/api/tests.rs
Normal file
703
apps/amm/client/src/api/tests.rs
Normal file
@ -0,0 +1,703 @@
|
|||||||
|
use alloy_primitives::U256;
|
||||||
|
use amm_core::{
|
||||||
|
compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda,
|
||||||
|
compute_vault_pda, isqrt_product, spot_price_q64_64, AmmConfig, PoolDefinition,
|
||||||
|
MINIMUM_LIQUIDITY,
|
||||||
|
};
|
||||||
|
use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID};
|
||||||
|
use nssa_core::{
|
||||||
|
account::{Account, AccountId, Data, Nonce},
|
||||||
|
program::ProgramId,
|
||||||
|
};
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use token_core::{TokenDefinition, TokenHolding};
|
||||||
|
use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
accounts::{active_account_plan, missing_account_plan},
|
||||||
|
context::{context, token_ids},
|
||||||
|
holding::{select_holding, wallet_holdings, SelectedHolding},
|
||||||
|
pair::{is_canonical_pair, pair_ids, PairIds},
|
||||||
|
plan::plan,
|
||||||
|
position::AccountPlanHoldings,
|
||||||
|
quote::{div_ceil_u256, minimum_opening_pair, quote, Q64},
|
||||||
|
ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest, QuoteRequest,
|
||||||
|
TokenIdsRequest, SCHEMA,
|
||||||
|
};
|
||||||
|
use crate::{
|
||||||
|
account::{account_id_hex, account_read, decode_account, parse_base58_id, program_id_bytes},
|
||||||
|
AccountRead,
|
||||||
|
};
|
||||||
|
|
||||||
|
const AMM_PROGRAM: ProgramId = [11; 8];
|
||||||
|
const TOKEN_PROGRAM: ProgramId = [22; 8];
|
||||||
|
const TWAP_PROGRAM: ProgramId = [33; 8];
|
||||||
|
|
||||||
|
fn account(owner: ProgramId, data: Data) -> Account {
|
||||||
|
Account {
|
||||||
|
program_owner: owner,
|
||||||
|
balance: 0,
|
||||||
|
data,
|
||||||
|
nonce: Nonce(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_read(id: AccountId) -> AccountRead {
|
||||||
|
account_read(id, &Account::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_account() -> Account {
|
||||||
|
account(
|
||||||
|
AMM_PROGRAM,
|
||||||
|
Data::from(&AmmConfig {
|
||||||
|
token_program_id: TOKEN_PROGRAM,
|
||||||
|
twap_oracle_program_id: TWAP_PROGRAM,
|
||||||
|
authority: AccountId::new([7; 32]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_definition(name: &str, supply: u128) -> Account {
|
||||||
|
account(
|
||||||
|
TOKEN_PROGRAM,
|
||||||
|
Data::from(&TokenDefinition::Fungible {
|
||||||
|
name: String::from(name),
|
||||||
|
total_supply: supply,
|
||||||
|
metadata_id: None,
|
||||||
|
authority: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_holding(definition_id: AccountId, balance: u128) -> Account {
|
||||||
|
account(
|
||||||
|
TOKEN_PROGRAM,
|
||||||
|
Data::from(&TokenHolding::Fungible {
|
||||||
|
definition_id,
|
||||||
|
balance,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clock_account() -> Account {
|
||||||
|
account(
|
||||||
|
[44; 8],
|
||||||
|
Data::try_from(
|
||||||
|
ClockAccountData {
|
||||||
|
block_id: 10,
|
||||||
|
timestamp: 1_000,
|
||||||
|
}
|
||||||
|
.to_bytes(),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ids() -> PairIds {
|
||||||
|
let token_a = AccountId::new([2; 32]);
|
||||||
|
let token_b = AccountId::new([1; 32]);
|
||||||
|
let config = compute_config_pda(AMM_PROGRAM);
|
||||||
|
let pool = compute_pool_pda(AMM_PROGRAM, token_a, token_b);
|
||||||
|
PairIds {
|
||||||
|
token_a,
|
||||||
|
token_b,
|
||||||
|
config,
|
||||||
|
pool,
|
||||||
|
vault_a: compute_vault_pda(AMM_PROGRAM, pool, token_a),
|
||||||
|
vault_b: compute_vault_pda(AMM_PROGRAM, pool, token_b),
|
||||||
|
lp_definition: compute_liquidity_token_pda(AMM_PROGRAM, pool),
|
||||||
|
lp_lock_holding: compute_lp_lock_holding_pda(AMM_PROGRAM, pool),
|
||||||
|
current_tick: compute_current_tick_account_pda(TWAP_PROGRAM, pool),
|
||||||
|
clock: CLOCK_01_PROGRAM_ACCOUNT_ID,
|
||||||
|
token_program: TOKEN_PROGRAM,
|
||||||
|
twap_program: TWAP_PROGRAM,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_snapshot(pair: PairIds) -> PairSnapshot {
|
||||||
|
let holding_a_id = AccountId::new([61; 32]);
|
||||||
|
let holding_b_id = AccountId::new([62; 32]);
|
||||||
|
PairSnapshot {
|
||||||
|
config: account_read(pair.config, &config_account()),
|
||||||
|
token_a: account_read(pair.token_a, &token_definition("A", 1_000_000)),
|
||||||
|
token_b: account_read(pair.token_b, &token_definition("B", 2_000_000)),
|
||||||
|
pool: default_read(pair.pool),
|
||||||
|
vault_a: default_read(pair.vault_a),
|
||||||
|
vault_b: default_read(pair.vault_b),
|
||||||
|
lp_definition: default_read(pair.lp_definition),
|
||||||
|
lp_lock_holding: default_read(pair.lp_lock_holding),
|
||||||
|
current_tick: default_read(pair.current_tick),
|
||||||
|
clock: account_read(pair.clock, &clock_account()),
|
||||||
|
wallet_available: true,
|
||||||
|
wallet_accounts: vec![
|
||||||
|
account_read(holding_a_id, &token_holding(pair.token_a, 1_000_000)),
|
||||||
|
account_read(holding_b_id, &token_holding(pair.token_b, 1_000_000)),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(pair: PairIds) -> PositionRequest {
|
||||||
|
assert!(is_canonical_pair(pair.token_a, pair.token_b));
|
||||||
|
PositionRequest {
|
||||||
|
schema: String::from(SCHEMA),
|
||||||
|
token_a_id: pair.token_a.to_string(),
|
||||||
|
token_b_id: pair.token_b.to_string(),
|
||||||
|
fee_bps: 30,
|
||||||
|
amount_a_raw: None,
|
||||||
|
amount_b_raw: None,
|
||||||
|
max_amount_a_raw: None,
|
||||||
|
max_amount_b_raw: None,
|
||||||
|
slippage_bps: None,
|
||||||
|
initial_price_real_raw: Some(Q64.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn amm_program_id() -> String {
|
||||||
|
hex::encode(program_id_bytes(AMM_PROGRAM))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Scenario {
|
||||||
|
pair: PairIds,
|
||||||
|
request: PositionRequest,
|
||||||
|
snapshot: PairSnapshot,
|
||||||
|
network_id: &'static str,
|
||||||
|
network_fingerprint: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scenario {
|
||||||
|
fn devnet() -> Self {
|
||||||
|
Self::new("devnet", "channel:test")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn testnet() -> Self {
|
||||||
|
Self::new("testnet", "block10:test")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(network_id: &'static str, network_fingerprint: &'static str) -> Self {
|
||||||
|
let pair = ids();
|
||||||
|
Self {
|
||||||
|
pair,
|
||||||
|
request: request(pair),
|
||||||
|
snapshot: base_snapshot(pair),
|
||||||
|
network_id,
|
||||||
|
network_fingerprint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quote_request(&self) -> QuoteRequest {
|
||||||
|
QuoteRequest {
|
||||||
|
network_id: String::from(self.network_id),
|
||||||
|
network_fingerprint: String::from(self.network_fingerprint),
|
||||||
|
amm_program_id: amm_program_id(),
|
||||||
|
request: self.request.clone(),
|
||||||
|
snapshot: self.snapshot.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quote(&self) -> Value {
|
||||||
|
quote(self.quote_request()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan(self, quote_hash: impl Into<String>, fresh_lp: Option<AccountRead>) -> Value {
|
||||||
|
plan(PlanRequest {
|
||||||
|
network_id: String::from(self.network_id),
|
||||||
|
network_fingerprint: String::from(self.network_fingerprint),
|
||||||
|
amm_program_id: amm_program_id(),
|
||||||
|
request: self.request,
|
||||||
|
snapshot: self.snapshot,
|
||||||
|
quote_hash: quote_hash.into(),
|
||||||
|
now_ms: 2_000,
|
||||||
|
fresh_lp,
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_preview_matches_plan(
|
||||||
|
quote_value: &Value,
|
||||||
|
plan_value: &Value,
|
||||||
|
fresh_lp: Option<AccountId>,
|
||||||
|
) {
|
||||||
|
let preview = quote_value["accountPreview"].as_array().unwrap();
|
||||||
|
let account_ids = plan_value["accountIds"].as_array().unwrap();
|
||||||
|
let signing_requirements = plan_value["signingRequirements"].as_array().unwrap();
|
||||||
|
assert_eq!(preview.len(), account_ids.len());
|
||||||
|
assert_eq!(preview.len(), signing_requirements.len());
|
||||||
|
|
||||||
|
for (order, row) in preview.iter().enumerate() {
|
||||||
|
assert_eq!(row["order"], order);
|
||||||
|
assert_eq!(row["signer"], signing_requirements[order]);
|
||||||
|
if let Some(account_id) = row["accountId"].as_str() {
|
||||||
|
let account_id = parse_base58_id(account_id, "preview account id").unwrap();
|
||||||
|
assert_eq!(account_ids[order], account_id_hex(account_id));
|
||||||
|
} else {
|
||||||
|
assert_eq!(row["role"], "user_holding_lp");
|
||||||
|
assert_eq!(account_ids[order], account_id_hex(fresh_lp.unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn account_plan_sources_follow_pool_branch() {
|
||||||
|
let scenario = Scenario::devnet();
|
||||||
|
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 missing = missing_account_plan(
|
||||||
|
&input,
|
||||||
|
pair,
|
||||||
|
AMM_PROGRAM,
|
||||||
|
AccountPlanHoldings {
|
||||||
|
token_a: holding_a.as_ref(),
|
||||||
|
token_b: holding_b.as_ref(),
|
||||||
|
lp: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
missing
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.map(|source| source.role.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
"config",
|
||||||
|
"token_a",
|
||||||
|
"token_b",
|
||||||
|
"pool",
|
||||||
|
"vault_a",
|
||||||
|
"vault_b",
|
||||||
|
"lp_definition",
|
||||||
|
"lp_lock_holding",
|
||||||
|
"current_tick",
|
||||||
|
"holding_a",
|
||||||
|
"holding_b",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let active = active_account_plan(
|
||||||
|
&input,
|
||||||
|
pair,
|
||||||
|
AMM_PROGRAM,
|
||||||
|
&PoolDefinition::default(),
|
||||||
|
false,
|
||||||
|
AccountPlanHoldings {
|
||||||
|
token_a: holding_a.as_ref(),
|
||||||
|
token_b: holding_b.as_ref(),
|
||||||
|
lp: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
active
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.map(|source| source.role.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
"config",
|
||||||
|
"token_a",
|
||||||
|
"token_b",
|
||||||
|
"pool",
|
||||||
|
"vault_a",
|
||||||
|
"vault_b",
|
||||||
|
"lp_definition",
|
||||||
|
"current_tick",
|
||||||
|
"holding_a",
|
||||||
|
"holding_b",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn minimum_pair_exceeds_protocol_lock() {
|
||||||
|
for price in [1, Q64 / 2_500, Q64 / 10, Q64, Q64 * 2, u128::MAX] {
|
||||||
|
let (amount_a, amount_b) = minimum_opening_pair(price).unwrap();
|
||||||
|
assert!(amount_a > 0);
|
||||||
|
assert!(amount_b > 0);
|
||||||
|
assert!(isqrt_product(amount_a, amount_b) > MINIMUM_LIQUIDITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn minimum_pair_is_minimal_on_price_base_side() {
|
||||||
|
let (amount_a, amount_b) = minimum_opening_pair(Q64 * 2).unwrap();
|
||||||
|
assert!(isqrt_product(amount_a, amount_b) > MINIMUM_LIQUIDITY);
|
||||||
|
let previous_b = div_ceil_u256(
|
||||||
|
U256::from(amount_a - 1) * U256::from(Q64 * 2),
|
||||||
|
U256::from(Q64),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
U256::from(amount_a - 1) * previous_b <= U256::from(MINIMUM_LIQUIDITY * MINIMUM_LIQUIDITY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highest_balance_holding_wins_then_lowest_id() {
|
||||||
|
let definition = AccountId::new([9; 32]);
|
||||||
|
let holding = |id: u8, balance| SelectedHolding {
|
||||||
|
id: AccountId::new([id; 32]),
|
||||||
|
definition_id: definition,
|
||||||
|
balance,
|
||||||
|
account: account(
|
||||||
|
TOKEN_PROGRAM,
|
||||||
|
Data::from(&TokenHolding::Fungible {
|
||||||
|
definition_id: definition,
|
||||||
|
balance,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let selected = select_holding(
|
||||||
|
&[holding(4, 10), holding(2, 20), holding(1, 20)],
|
||||||
|
definition,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(selected.id, AccountId::new([1; 32]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pair_manifest_uses_canonical_ids_and_current_program_types() {
|
||||||
|
let token_a = AccountId::new([2; 32]);
|
||||||
|
let token_b = AccountId::new([1; 32]);
|
||||||
|
let config_id = compute_config_pda(AMM_PROGRAM);
|
||||||
|
let result = pair_ids(PairIdsRequest {
|
||||||
|
amm_program_id: amm_program_id(),
|
||||||
|
config: account_read(config_id, &config_account()),
|
||||||
|
token_a_id: token_a.to_string(),
|
||||||
|
token_b_id: token_b.to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["status"], "ok");
|
||||||
|
assert_eq!(result["tokenAId"], account_id_hex(token_a));
|
||||||
|
assert_eq!(result["tokenBId"], account_id_hex(token_b));
|
||||||
|
assert_eq!(
|
||||||
|
result["poolId"],
|
||||||
|
account_id_hex(compute_pool_pda(AMM_PROGRAM, token_a, token_b))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pair_manifest_reports_invalid_token_as_domain_error() {
|
||||||
|
let pair = ids();
|
||||||
|
let result = pair_ids(PairIdsRequest {
|
||||||
|
amm_program_id: amm_program_id(),
|
||||||
|
config: account_read(pair.config, &config_account()),
|
||||||
|
token_a_id: String::from("not-a-token-id"),
|
||||||
|
token_b_id: pair.token_b.to_string(),
|
||||||
|
})
|
||||||
|
.expect("invalid user input is a domain result");
|
||||||
|
|
||||||
|
assert_eq!(result["status"], "error");
|
||||||
|
assert_eq!(result["code"], "invalid_token_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pair_manifest_reports_unavailable_config_as_domain_error() {
|
||||||
|
let pair = ids();
|
||||||
|
let result = pair_ids(PairIdsRequest {
|
||||||
|
amm_program_id: amm_program_id(),
|
||||||
|
config: default_read(pair.config),
|
||||||
|
token_a_id: pair.token_a.to_string(),
|
||||||
|
token_b_id: pair.token_b.to_string(),
|
||||||
|
})
|
||||||
|
.expect("unavailable chain state is a domain result");
|
||||||
|
|
||||||
|
assert_eq!(result["status"], "error");
|
||||||
|
assert_eq!(result["code"], "config_unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_manifest_includes_compatible_wallet_holdings() {
|
||||||
|
let config_id = compute_config_pda(AMM_PROGRAM);
|
||||||
|
let configured = AccountId::new([1; 32]);
|
||||||
|
let held = AccountId::new([2; 32]);
|
||||||
|
let recent = AccountId::new([3; 32]);
|
||||||
|
let resolved = AccountId::new([4; 32]);
|
||||||
|
|
||||||
|
let value = token_ids(TokenIdsRequest {
|
||||||
|
amm_program_id: amm_program_id(),
|
||||||
|
config: account_read(config_id, &config_account()),
|
||||||
|
wallet_accounts: vec![account_read(
|
||||||
|
AccountId::new([5; 32]),
|
||||||
|
&token_holding(held, 9),
|
||||||
|
)],
|
||||||
|
configured_token_ids: vec![account_id_hex(configured)],
|
||||||
|
recent_token_ids: vec![recent.to_string()],
|
||||||
|
resolved_token_ids: vec![resolved.to_string()],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
value["tokenIds"],
|
||||||
|
json!([
|
||||||
|
account_id_hex(configured),
|
||||||
|
account_id_hex(held),
|
||||||
|
account_id_hex(recent),
|
||||||
|
account_id_hex(resolved),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_program_holdings_do_not_contribute_token_candidates() {
|
||||||
|
let config_id = compute_config_pda(AMM_PROGRAM);
|
||||||
|
let config = config_account();
|
||||||
|
let definition = AccountId::new([2; 32]);
|
||||||
|
let wrong_owner_holding = account(
|
||||||
|
[99; 8],
|
||||||
|
Data::from(&TokenHolding::Fungible {
|
||||||
|
definition_id: definition,
|
||||||
|
balance: 9,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let wallet_accounts = vec![account_read(AccountId::new([3; 32]), &wrong_owner_holding)];
|
||||||
|
|
||||||
|
let manifest = token_ids(TokenIdsRequest {
|
||||||
|
amm_program_id: amm_program_id(),
|
||||||
|
config: account_read(config_id, &config),
|
||||||
|
wallet_accounts: wallet_accounts.clone(),
|
||||||
|
configured_token_ids: Vec::new(),
|
||||||
|
recent_token_ids: Vec::new(),
|
||||||
|
resolved_token_ids: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(manifest["tokenIds"], json!([]));
|
||||||
|
|
||||||
|
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),
|
||||||
|
wallet_accounts,
|
||||||
|
token_definitions: vec![account_read(
|
||||||
|
definition,
|
||||||
|
&token_definition("Token", 1_000_000),
|
||||||
|
)],
|
||||||
|
configured_token_ids: Vec::new(),
|
||||||
|
recent_token_ids: Vec::new(),
|
||||||
|
resolved_token_ids: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(value["tokens"], json!([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn context_selects_tokens_without_holdings() {
|
||||||
|
let token_id = AccountId::new([3; 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::new(),
|
||||||
|
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]["selectable"], true);
|
||||||
|
assert_eq!(value["tokens"][0]["sources"], json!(["config"]));
|
||||||
|
assert!(value["tokens"][0].get("holdingId").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_pool_snapshot_defaults_remain_real_accounts() {
|
||||||
|
let id = AccountId::new([5; 32]);
|
||||||
|
let read = default_read(id);
|
||||||
|
let (decoded_id, decoded) = decode_account(&read).unwrap();
|
||||||
|
assert_eq!(decoded_id, id);
|
||||||
|
assert_eq!(decoded, Account::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_pool_quote_and_plan_use_current_account_order() {
|
||||||
|
let scenario = Scenario::devnet();
|
||||||
|
let quote_value = scenario.quote();
|
||||||
|
assert_eq!(quote_value["status"], "ok");
|
||||||
|
assert_eq!(quote_value["poolStatus"], "missing_pool");
|
||||||
|
assert_eq!(quote_value["canSubmit"], true);
|
||||||
|
assert_eq!(quote_value["accountPreview"].as_array().unwrap().len(), 11);
|
||||||
|
let quote_hash = quote_value["quoteHash"].as_str().unwrap().to_owned();
|
||||||
|
|
||||||
|
let fresh_lp = AccountId::new([63; 32]);
|
||||||
|
let plan_value = scenario.plan(quote_hash, Some(default_read(fresh_lp)));
|
||||||
|
assert_eq!(plan_value["status"], "ready");
|
||||||
|
assert_eq!(plan_value["accountIds"].as_array().unwrap().len(), 11);
|
||||||
|
assert_eq!(plan_value["accountIds"][8], account_id_hex(fresh_lp));
|
||||||
|
assert_eq!(plan_value["signingRequirements"][6], true);
|
||||||
|
assert_eq!(plan_value["signingRequirements"][7], true);
|
||||||
|
assert_eq!(plan_value["signingRequirements"][8], true);
|
||||||
|
assert_preview_matches_plan("e_value, &plan_value, Some(fresh_lp));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_pool_plan_rejects_fresh_lp_account_collision() {
|
||||||
|
let scenario = Scenario::devnet();
|
||||||
|
let pool = scenario.pair.pool;
|
||||||
|
let quote_value = scenario.quote();
|
||||||
|
let quote_hash = quote_value["quoteHash"].as_str().unwrap().to_owned();
|
||||||
|
|
||||||
|
let plan_value = scenario.plan(quote_hash, Some(default_read(pool)));
|
||||||
|
|
||||||
|
assert_eq!(plan_value["status"], "error");
|
||||||
|
assert_eq!(plan_value["code"], "wallet_submission_failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_pool_quote_accepts_large_direct_raw_amounts() {
|
||||||
|
let mut scenario = Scenario::devnet();
|
||||||
|
let amount_a = 100_000_000;
|
||||||
|
let amount_b = 150_000_000;
|
||||||
|
scenario.request.amount_a_raw = Some(amount_a.to_string());
|
||||||
|
scenario.request.amount_b_raw = Some(amount_b.to_string());
|
||||||
|
scenario.request.initial_price_real_raw =
|
||||||
|
Some(spot_price_q64_64(amount_a, amount_b).to_string());
|
||||||
|
|
||||||
|
let quote_value = scenario.quote();
|
||||||
|
|
||||||
|
assert_eq!(quote_value["status"], "ok");
|
||||||
|
assert_eq!(quote_value["actualAmountARaw"], amount_a.to_string());
|
||||||
|
assert_eq!(quote_value["actualAmountBRaw"], amount_b.to_string());
|
||||||
|
assert!(quote_value.get("depositScaleBps").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advancing_clock_does_not_stale_quote() {
|
||||||
|
let mut scenario = Scenario::testnet();
|
||||||
|
let quote_value = scenario.quote();
|
||||||
|
|
||||||
|
scenario.snapshot.clock = account_read(
|
||||||
|
scenario.pair.clock,
|
||||||
|
&account(
|
||||||
|
[44; 8],
|
||||||
|
Data::try_from(
|
||||||
|
ClockAccountData {
|
||||||
|
block_id: 11,
|
||||||
|
timestamp: 1_500,
|
||||||
|
}
|
||||||
|
.to_bytes(),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let plan_value = scenario.plan(
|
||||||
|
quote_value["quoteHash"].as_str().unwrap(),
|
||||||
|
Some(default_read(AccountId::new([63; 32]))),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(plan_value["status"], "ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 quote_value = scenario.quote();
|
||||||
|
assert_eq!(quote_value["poolStatus"], "active_pool");
|
||||||
|
assert_eq!(quote_value["actualAmountARaw"], "1000");
|
||||||
|
assert_eq!(quote_value["actualAmountBRaw"], "2000");
|
||||||
|
assert_eq!(quote_value["expectedLpRaw"], "1000");
|
||||||
|
assert_eq!(quote_value["minimumLpRaw"], "995");
|
||||||
|
assert_eq!(quote_value["requiresFreshLp"], false);
|
||||||
|
assert_eq!(quote_value["canSubmit"], true);
|
||||||
|
assert_eq!(quote_value["errors"], json!([]));
|
||||||
|
|
||||||
|
let plan_value = scenario.plan(quote_value["quoteHash"].as_str().unwrap(), None);
|
||||||
|
assert_eq!(plan_value["status"], "ready");
|
||||||
|
assert_eq!(plan_value["accountIds"].as_array().unwrap().len(), 10);
|
||||||
|
assert_eq!(plan_value["accountIds"][7], account_id_hex(lp_holding));
|
||||||
|
assert_eq!(plan_value["signingRequirements"][7], false);
|
||||||
|
assert_preview_matches_plan("e_value, &plan_value, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matching_unfunded_quote_has_no_transaction_plan() {
|
||||||
|
let mut scenario = Scenario::devnet();
|
||||||
|
scenario.snapshot.wallet_available = false;
|
||||||
|
scenario.snapshot.wallet_accounts.clear();
|
||||||
|
let quote_value = scenario.quote();
|
||||||
|
assert_eq!(quote_value["canSubmit"], false);
|
||||||
|
|
||||||
|
let plan_value = scenario.plan(quote_value["quoteHash"].as_str().unwrap(), None);
|
||||||
|
|
||||||
|
assert_eq!(plan_value["status"], "error");
|
||||||
|
assert_eq!(plan_value["code"], "quote_not_submittable");
|
||||||
|
assert_eq!(plan_value["quote"], quote_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_hash_returns_recomputed_quote_without_plan() {
|
||||||
|
let value = Scenario::devnet().plan("sha256:deadbeef", None);
|
||||||
|
assert_eq!(value["status"], "error");
|
||||||
|
assert_eq!(value["code"], "quote_changed");
|
||||||
|
assert_eq!(value["quote"]["status"], "ok");
|
||||||
|
}
|
||||||
140
apps/amm/client/src/ffi.rs
Normal file
140
apps/amm/client/src/ffi.rs
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
use std::{
|
||||||
|
ffi::{c_char, CStr, CString},
|
||||||
|
panic::{catch_unwind, AssertUnwindSafe},
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
|
||||||
|
use crate::api::{
|
||||||
|
self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest,
|
||||||
|
QuoteRequest, TokenIdsRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Envelope {
|
||||||
|
ok: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
value: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Envelope {
|
||||||
|
fn success(value: serde_json::Value) -> Self {
|
||||||
|
Self {
|
||||||
|
ok: true,
|
||||||
|
value: Some(value),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure(error: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
ok: false,
|
||||||
|
value: None,
|
||||||
|
error: Some(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call<T: DeserializeOwned>(request: *const c_char, operation: fn(T) -> AmmResult) -> *mut c_char {
|
||||||
|
let result = catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
let request = request_text(request).map_err(AmmApiError::from)?;
|
||||||
|
let request = serde_json::from_str::<T>(&request)
|
||||||
|
.map_err(|error| AmmApiError::from(format!("invalid request JSON: {error}")))?;
|
||||||
|
operation(request)
|
||||||
|
}));
|
||||||
|
|
||||||
|
let envelope = match result {
|
||||||
|
Ok(Ok(value)) => Envelope::success(value),
|
||||||
|
Ok(Err(error)) => Envelope::failure(error.to_string()),
|
||||||
|
Err(_) => Envelope::failure("internal panic"),
|
||||||
|
};
|
||||||
|
encode_envelope(&envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_text(request: *const c_char) -> Result<String, String> {
|
||||||
|
if request.is_null() {
|
||||||
|
return Err(String::from("request pointer is null"));
|
||||||
|
}
|
||||||
|
// SAFETY: The C++ caller passes a live NUL-terminated UTF-8 buffer for this call.
|
||||||
|
let request = unsafe { CStr::from_ptr(request) };
|
||||||
|
request
|
||||||
|
.to_str()
|
||||||
|
.map(String::from)
|
||||||
|
.map_err(|error| format!("request is not UTF-8: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_envelope(envelope: &Envelope) -> *mut c_char {
|
||||||
|
let json = serde_json::to_string(envelope).unwrap_or_else(|_| {
|
||||||
|
String::from(r#"{"ok":false,"error":"response serialization failed"}"#)
|
||||||
|
});
|
||||||
|
match CString::new(json) {
|
||||||
|
Ok(value) => value.into_raw(),
|
||||||
|
Err(_) => CString::new(r#"{"ok":false,"error":"response contains NUL"}"#)
|
||||||
|
.map_or(std::ptr::null_mut(), CString::into_raw),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn amm_config_id(request_json: *const c_char) -> *mut c_char {
|
||||||
|
call::<ConfigIdRequest>(request_json, api::config_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn amm_token_ids(request_json: *const c_char) -> *mut c_char {
|
||||||
|
call::<TokenIdsRequest>(request_json, api::token_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn amm_pair_ids(request_json: *const c_char) -> *mut c_char {
|
||||||
|
call::<PairIdsRequest>(request_json, api::pair_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn amm_context(request_json: *const c_char) -> *mut c_char {
|
||||||
|
call::<ContextRequest>(request_json, api::context)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn amm_quote(request_json: *const c_char) -> *mut c_char {
|
||||||
|
call::<QuoteRequest>(request_json, api::quote)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn amm_plan(request_json: *const c_char) -> *mut c_char {
|
||||||
|
call::<PlanRequest>(request_json, api::plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases a string returned by an `amm_*` operation.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `value` must be null or a pointer returned by this library that has not been freed.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn amm_free(value: *mut c_char) {
|
||||||
|
if value.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: The caller contract requires a pointer produced by CString::into_raw above.
|
||||||
|
drop(unsafe { CString::from_raw(value) });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::ffi::CString;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_json_uses_boundary_failure_envelope() {
|
||||||
|
let request = CString::new("{").unwrap();
|
||||||
|
let response = amm_config_id(request.as_ptr());
|
||||||
|
assert!(!response.is_null());
|
||||||
|
// SAFETY: response was returned by amm_config_id and remains live until amm_free.
|
||||||
|
let text = unsafe { CStr::from_ptr(response) }.to_str().unwrap();
|
||||||
|
let value: serde_json::Value = serde_json::from_str(text).unwrap();
|
||||||
|
assert_eq!(value["ok"], false);
|
||||||
|
// SAFETY: response was allocated by this library and has not been freed.
|
||||||
|
unsafe { amm_free(response) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,147 +1,12 @@
|
|||||||
#![deny(unsafe_op_in_unsafe_fn)]
|
#![deny(unsafe_op_in_unsafe_fn)]
|
||||||
|
|
||||||
mod account;
|
mod account;
|
||||||
mod protocol;
|
mod ffi;
|
||||||
|
|
||||||
use std::{
|
pub mod api;
|
||||||
ffi::{c_char, CStr, CString},
|
|
||||||
panic::{catch_unwind, AssertUnwindSafe},
|
pub use api::{
|
||||||
|
config_id, context, pair_ids, plan, quote, token_ids, AccountRead, AmmApiError, AmmResponse,
|
||||||
|
AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest,
|
||||||
|
PositionRequest, QuoteRequest, TokenIdsRequest, WalletAccount, NEW_POSITION_SCHEMA,
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::{de::DeserializeOwned, Serialize};
|
|
||||||
|
|
||||||
use crate::protocol::{
|
|
||||||
ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest, QuoteRequest, TokenIdsRequest,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct Envelope {
|
|
||||||
ok: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
value: Option<serde_json::Value>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Envelope {
|
|
||||||
fn success(value: serde_json::Value) -> Self {
|
|
||||||
Self {
|
|
||||||
ok: true,
|
|
||||||
value: Some(value),
|
|
||||||
error: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn failure(error: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
ok: false,
|
|
||||||
value: None,
|
|
||||||
error: Some(error.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call<T: DeserializeOwned>(
|
|
||||||
request: *const c_char,
|
|
||||||
operation: fn(T) -> Result<serde_json::Value, String>,
|
|
||||||
) -> *mut c_char {
|
|
||||||
let result = catch_unwind(AssertUnwindSafe(|| {
|
|
||||||
let request = request_text(request)?;
|
|
||||||
let request = serde_json::from_str::<T>(&request)
|
|
||||||
.map_err(|error| format!("invalid request JSON: {error}"))?;
|
|
||||||
operation(request)
|
|
||||||
}));
|
|
||||||
|
|
||||||
let envelope = match result {
|
|
||||||
Ok(Ok(value)) => Envelope::success(value),
|
|
||||||
Ok(Err(error)) => Envelope::failure(error),
|
|
||||||
Err(_) => Envelope::failure("internal panic"),
|
|
||||||
};
|
|
||||||
encode_envelope(&envelope)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request_text(request: *const c_char) -> Result<String, String> {
|
|
||||||
if request.is_null() {
|
|
||||||
return Err(String::from("request pointer is null"));
|
|
||||||
}
|
|
||||||
// SAFETY: The C++ caller passes a live NUL-terminated UTF-8 buffer for this call.
|
|
||||||
let request = unsafe { CStr::from_ptr(request) };
|
|
||||||
request
|
|
||||||
.to_str()
|
|
||||||
.map(String::from)
|
|
||||||
.map_err(|error| format!("request is not UTF-8: {error}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_envelope(envelope: &Envelope) -> *mut c_char {
|
|
||||||
let json = serde_json::to_string(envelope).unwrap_or_else(|_| {
|
|
||||||
String::from(r#"{"ok":false,"error":"response serialization failed"}"#)
|
|
||||||
});
|
|
||||||
match CString::new(json) {
|
|
||||||
Ok(value) => value.into_raw(),
|
|
||||||
Err(_) => CString::new(r#"{"ok":false,"error":"response contains NUL"}"#)
|
|
||||||
.map_or(std::ptr::null_mut(), CString::into_raw),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn amm_config_id(request_json: *const c_char) -> *mut c_char {
|
|
||||||
call::<ConfigIdRequest>(request_json, protocol::config_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn amm_token_ids(request_json: *const c_char) -> *mut c_char {
|
|
||||||
call::<TokenIdsRequest>(request_json, protocol::token_ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn amm_pair_ids(request_json: *const c_char) -> *mut c_char {
|
|
||||||
call::<PairIdsRequest>(request_json, protocol::pair_ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn amm_context(request_json: *const c_char) -> *mut c_char {
|
|
||||||
call::<ContextRequest>(request_json, protocol::context)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn amm_quote(request_json: *const c_char) -> *mut c_char {
|
|
||||||
call::<QuoteRequest>(request_json, protocol::quote)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn amm_plan(request_json: *const c_char) -> *mut c_char {
|
|
||||||
call::<PlanRequest>(request_json, protocol::plan)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Releases a string returned by an `amm_*` operation.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `value` must be null or a pointer returned by this library that has not been freed.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "C" fn amm_free(value: *mut c_char) {
|
|
||||||
if value.is_null() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SAFETY: The caller contract requires a pointer produced by CString::into_raw above.
|
|
||||||
drop(unsafe { CString::from_raw(value) });
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::ffi::CString;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_json_uses_boundary_failure_envelope() {
|
|
||||||
let request = CString::new("{").unwrap();
|
|
||||||
let response = amm_config_id(request.as_ptr());
|
|
||||||
assert!(!response.is_null());
|
|
||||||
// SAFETY: response was returned by amm_config_id and remains live until amm_free.
|
|
||||||
let text = unsafe { CStr::from_ptr(response) }.to_str().unwrap();
|
|
||||||
let value: serde_json::Value = serde_json::from_str(text).unwrap();
|
|
||||||
assert_eq!(value["ok"], false);
|
|
||||||
// SAFETY: response was allocated by this library and has not been freed.
|
|
||||||
unsafe { amm_free(response) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
13
apps/amm/client/tests/public_api.rs
Normal file
13
apps/amm/client/tests/public_api.rs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
use amm_client::{config_id, ConfigIdRequest, NEW_POSITION_SCHEMA};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_rust_api_does_not_require_ffi() {
|
||||||
|
let response = config_id(ConfigIdRequest {
|
||||||
|
amm_program_id: "0000000000000000000000000000000000000000000000000000000000000000".into(),
|
||||||
|
})
|
||||||
|
.expect("valid program ID should produce a response");
|
||||||
|
|
||||||
|
assert_eq!(response["status"], "ok");
|
||||||
|
assert!(response["configId"].is_string());
|
||||||
|
assert_eq!(NEW_POSITION_SCHEMA, "new-position.v1");
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user