mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-17 12:29:27 +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
6de8a5fe9a
commit
4a9ea6348d
@ -6,24 +6,24 @@ use nssa_core::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct AccountRead {
|
||||
pub(crate) id: String,
|
||||
pub(crate) status: String,
|
||||
pub struct AccountRead {
|
||||
pub id: String,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub(crate) account: Option<WalletAccount>,
|
||||
pub account: Option<WalletAccount>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(crate) struct WalletAccount {
|
||||
pub(crate) program_owner: String,
|
||||
pub(crate) balance: String,
|
||||
pub(crate) nonce: String,
|
||||
pub(crate) data: String,
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
pub struct WalletAccount {
|
||||
pub program_owner: String,
|
||||
pub balance: String,
|
||||
pub nonce: 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
|
||||
|| !value
|
||||
.bytes()
|
||||
@ -39,7 +39,7 @@ pub fn parse_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> {
|
||||
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 mut program_id = [0_u32; 8];
|
||||
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)]
|
||||
pub fn program_id_hex(program_id: ProgramId) -> String {
|
||||
pub(crate) fn program_id_hex(program_id: ProgramId) -> String {
|
||||
let bytes = program_id
|
||||
.iter()
|
||||
.flat_map(|word| word.to_le_bytes())
|
||||
@ -60,11 +60,11 @@ pub fn program_id_hex(program_id: ProgramId) -> String {
|
||||
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()
|
||||
}
|
||||
|
||||
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];
|
||||
for (chunk, word) in bytes.chunks_exact_mut(4).zip(program_id) {
|
||||
chunk.copy_from_slice(&word.to_le_bytes());
|
||||
@ -72,15 +72,15 @@ pub fn program_id_bytes(program_id: ProgramId) -> [u8; 32] {
|
||||
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}"))
|
||||
}
|
||||
|
||||
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)?))
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
@ -99,7 +99,7 @@ fn parse_le_u128(value: &str, label: &str) -> Result<u128, String> {
|
||||
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" {
|
||||
return Err(String::from("account read failed"));
|
||||
}
|
||||
@ -138,7 +138,7 @@ pub fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), String
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn account_read(id: AccountId, account: &Account) -> AccountRead {
|
||||
pub(crate) fn account_read(id: AccountId, account: &Account) -> AccountRead {
|
||||
AccountRead {
|
||||
id: account_id_hex(id),
|
||||
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)]
|
||||
|
||||
mod account;
|
||||
mod protocol;
|
||||
mod ffi;
|
||||
|
||||
use std::{
|
||||
ffi::{c_char, CStr, CString},
|
||||
panic::{catch_unwind, AssertUnwindSafe},
|
||||
pub mod api;
|
||||
|
||||
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