mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
refactor(amm): remove the dead newPosition quote path
Both liquidity branches now quote through the lean ops (liquidityQuote /
addLiquidityQuote), so quoteNewPosition and the heavy amm_quote machinery it
drove are unreachable. Remove them end to end.
FFI (modules/amm/ffi):
- Drop the amm_quote entry point and the whole quote-evaluation graph:
api/{accounts,commitment,funding,position}.rs, the QuoteRequest /
PositionRequest / PairSnapshot request types, quote_error::fatal_quote, and
api/clock.rs (its decode_clock was quote-only). quote.rs keeps only the shared
opening-deposit math (minimum_opening_pair + helpers) that liquidity_quote
reuses.
- Trim the fields the quote path was the sole reader of: SelectedHolding.account
and PairIds.{token_program,twap_program}.
- Drop the quote-path unit tests; keep the math / pair / context / holding /
swap ones (37 pass, clippy clean).
Module (modules/amm/src):
- Remove AmmModuleImpl::quoteNewPosition and its buildQuoteInput snapshot helper.
App (apps/amm):
- Remove the AmmUiBackend quoteNewPosition slot (.rep/.h/.cpp) and the dead QML
backend mock + obsolete fresh-quote test.
- finishSubmitFailure no longer keeps a submit-returned re-quote (the lean submit
ops never return one); it always re-quotes on failure.
- submissionSnapshot drops the always-empty quoteHash and derives the confirm
dialog's action from the resolved pool state instead of the dead
quotePayload.instruction (restores the "Create pool" / "Add liquidity" label).
This commit is contained in:
@@ -8,12 +8,8 @@ ColumnLayout {
|
||||
|
||||
spacing: 8
|
||||
|
||||
function actionText(instruction) {
|
||||
if (instruction === "NewDefinition")
|
||||
return qsTr("Create pool")
|
||||
if (instruction === "AddLiquidity")
|
||||
return qsTr("Add liquidity")
|
||||
return instruction || "-"
|
||||
function actionText() {
|
||||
return root.snapshot.poolExists ? qsTr("Add liquidity") : qsTr("Create pool")
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
@@ -25,7 +21,7 @@ ColumnLayout {
|
||||
SummaryRow {
|
||||
Layout.fillWidth: true
|
||||
label: qsTr("Action")
|
||||
value: root.actionText(root.snapshot.instruction)
|
||||
value: root.actionText()
|
||||
}
|
||||
|
||||
SummaryRow {
|
||||
|
||||
@@ -1440,7 +1440,6 @@ AmmActionCard {
|
||||
var built = root.buildQuoteRequest()
|
||||
return {
|
||||
"request": built.request,
|
||||
"quoteHash": String(root.quotePayload.quoteHash || ""),
|
||||
// Canonical-order holdings for the createPool / addLiquidity calls: the
|
||||
// request's tokenAId/amountARaw are canonical, so holdingAId must be the
|
||||
// canonical token A's holding too (the module re-canonicalizes as a no-op).
|
||||
@@ -1456,7 +1455,8 @@ AmmActionCard {
|
||||
"depositAText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A"),
|
||||
"depositBText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "B"),
|
||||
"expectedLpText": root.rawLpText(root.quotePayload.expectedLpRaw),
|
||||
"instruction": String(root.quotePayload.instruction || "")
|
||||
// Confirm-dialog action derives from the resolved pool state (add vs create).
|
||||
"poolExists": root.activePool
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -431,18 +431,11 @@ QtObject {
|
||||
|
||||
function finishSubmitFailure(result) {
|
||||
root.submitting = false
|
||||
const hasFreshQuote = result && result.quote
|
||||
&& result.quote.status
|
||||
if (hasFreshQuote) {
|
||||
root.newPositionQuote = result.quote
|
||||
root.quoteLoading = false
|
||||
root.quoteStale = false
|
||||
}
|
||||
const code = result && result.code ? result.code : "wallet_submission_failed"
|
||||
root.flowErrorCode = code
|
||||
root.submitFailed()
|
||||
if (hasFreshQuote)
|
||||
return
|
||||
// The lean submit ops report only a status/error (never a re-quote), so a failure
|
||||
// always re-quotes to refresh state against the current pool.
|
||||
root.scheduleQuote(true, root.pendingQuoteRequest)
|
||||
}
|
||||
|
||||
|
||||
@@ -132,11 +132,6 @@ void AmmUiBackend::refreshNewPositionContext(QVariantMap request)
|
||||
request, isWalletOpen(), refreshWalletAccounts));
|
||||
}
|
||||
|
||||
QVariantMap AmmUiBackend::quoteNewPosition(QVariantMap request)
|
||||
{
|
||||
return m_logos->amm_module.quoteNewPosition(request, isWalletOpen());
|
||||
}
|
||||
|
||||
void AmmUiBackend::syncWalletState()
|
||||
{
|
||||
const WalletUiState& state = m_walletController->state();
|
||||
|
||||
@@ -47,7 +47,6 @@ public slots:
|
||||
void refreshBalances() override;
|
||||
QString getBalance(QString accountIdHex, bool isPublic) override;
|
||||
void refreshNewPositionContext(QVariantMap request) override;
|
||||
QVariantMap quoteNewPosition(QVariantMap request) override;
|
||||
// Return the new wallet's BIP39 mnemonic (empty string on failure) so the
|
||||
// UI can force a one-time seed-phrase backup step.
|
||||
QString createNewDefault(QString password) override;
|
||||
|
||||
@@ -31,7 +31,6 @@ class AmmUiBackend
|
||||
// transactions or duplicates quote state.
|
||||
PROP(QVariantMap newPositionContext READONLY)
|
||||
SLOT(void refreshNewPositionContext(QVariantMap request))
|
||||
SLOT(QVariantMap quoteNewPosition(QVariantMap request))
|
||||
|
||||
// Wallet lifecycle. createNewDefault() is the happy path: it creates a
|
||||
// fresh wallet at the canonical walletHome with no path picking. createNew()
|
||||
|
||||
@@ -15,19 +15,11 @@ TestCase {
|
||||
|
||||
QtObject {
|
||||
property bool walletStateReady: false
|
||||
property var quoteResult: ({
|
||||
"status": "ok",
|
||||
"poolStatus": "missing_pool"
|
||||
})
|
||||
property var newPositionContext: ({
|
||||
"status": "ready",
|
||||
"tokens": [],
|
||||
"feeTiers": []
|
||||
})
|
||||
|
||||
function quoteNewPosition(request) {
|
||||
return quoteResult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,30 +112,4 @@ TestCase {
|
||||
compare(page.flow.contextErrorCode, "")
|
||||
}
|
||||
|
||||
function test_submitFailureKeepsReturnedFreshQuoteWithoutRequery() {
|
||||
var backend = createTemporaryObject(backendComponent, testCase, {
|
||||
"walletStateReady": true
|
||||
})
|
||||
var page = createTemporaryObject(pageComponent, testCase, {
|
||||
"backend": backend
|
||||
})
|
||||
verify(backend)
|
||||
verify(page)
|
||||
|
||||
page.flow.quoteSerial = 7
|
||||
page.flow.finishSubmitFailure({
|
||||
"status": "error",
|
||||
"code": "quote_not_submittable",
|
||||
"quote": {
|
||||
"status": "ok",
|
||||
"canSubmit": false,
|
||||
"quoteHash": "sha256:fresh"
|
||||
}
|
||||
})
|
||||
|
||||
compare(page.flow.quoteSerial, 7)
|
||||
compare(page.flow.newPositionQuote.quoteHash, "sha256:fresh")
|
||||
compare(page.flow.quoteStale, false)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ char *amm_pair_ids(const char *request_json);
|
||||
|
||||
char *amm_context(const char *request_json);
|
||||
|
||||
char *amm_quote(const char *request_json);
|
||||
|
||||
char *amm_swap_pair(const char *request_json);
|
||||
|
||||
char *amm_resolve_pool(const char *request_json);
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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) 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>,
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
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))))
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
use nssa_core::{
|
||||
account::{Account, AccountId},
|
||||
program::ProgramId,
|
||||
};
|
||||
use nssa_core::{account::AccountId, program::ProgramId};
|
||||
use token_core::TokenHolding;
|
||||
|
||||
use crate::account::{decode_account, AccountRead};
|
||||
@@ -11,7 +8,6 @@ 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(
|
||||
@@ -44,7 +40,6 @@ pub(super) fn decode_fungible_holding(
|
||||
id,
|
||||
definition_id,
|
||||
balance,
|
||||
account,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
//! Transport-independent AMM client operations.
|
||||
|
||||
mod accounts;
|
||||
mod clock;
|
||||
mod commitment;
|
||||
mod config;
|
||||
mod context;
|
||||
mod funding;
|
||||
mod holding;
|
||||
mod liquidity;
|
||||
mod pair;
|
||||
mod position;
|
||||
mod quote;
|
||||
mod quote_error;
|
||||
mod request;
|
||||
@@ -23,10 +18,9 @@ use std::{error::Error, fmt};
|
||||
|
||||
pub use request::{
|
||||
AddLiquidityPlanRequest, AddLiquidityQuoteRequest, ConfigIdRequest, ContextRequest,
|
||||
CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest, PairSnapshot, PoolIdRequest,
|
||||
PositionRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest,
|
||||
SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest,
|
||||
TokenHoldingsRequest, TokenIdsRequest,
|
||||
CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest,
|
||||
ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
|
||||
SwapExactOutQuoteRequest, SwapPairRequest, TokenHoldingsRequest, TokenIdsRequest,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -86,11 +80,6 @@ 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)
|
||||
}
|
||||
|
||||
/// Derives the canonical account ids for a swap pair (tokens in either order).
|
||||
pub fn swap_pair(request: SwapPairRequest) -> AmmResult {
|
||||
swap::swap_pair(request).map_err(Into::into)
|
||||
|
||||
@@ -22,8 +22,6 @@ pub(super) struct PairIds {
|
||||
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> {
|
||||
@@ -71,8 +69,6 @@ pub(super) fn derive_pair(
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
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,
|
||||
};
|
||||
use crate::account::{account_id_from_hex, program_id_base58};
|
||||
|
||||
pub(super) struct EvaluatedQuote {
|
||||
pub(super) value: Value,
|
||||
}
|
||||
|
||||
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 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QuoteFailure {
|
||||
pub(super) fn into_value(self, request: &PositionRequest) -> Value {
|
||||
json!({
|
||||
"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 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)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,643 +1,17 @@
|
||||
//! Shared opening-deposit math for pool creation.
|
||||
//!
|
||||
//! Reused by `liquidity::liquidity_quote` to size the smallest deposit that clears
|
||||
//! `MINIMUM_LIQUIDITY` for a given opening price.
|
||||
|
||||
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 amm_core::MINIMUM_LIQUIDITY;
|
||||
|
||||
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, QuoteComputation},
|
||||
quote_error::{fatal_quote, issue},
|
||||
QuoteRequest,
|
||||
};
|
||||
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;
|
||||
/// `Q64.64` scaling factor (`2^64`).
|
||||
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> {
|
||||
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 {
|
||||
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!({
|
||||
"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": [],
|
||||
});
|
||||
Ok(QuoteComputation::Evaluated(EvaluatedQuote { value }))
|
||||
}
|
||||
|
||||
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 {
|
||||
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!({
|
||||
"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,
|
||||
});
|
||||
Ok(QuoteComputation::Evaluated(EvaluatedQuote { value }))
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
/// Smallest `(amount_a, amount_b)` deposit whose geometric-mean LP clears
|
||||
/// `MINIMUM_LIQUIDITY`, holding the canonical opening `price` (token B per token A,
|
||||
/// `Q64.64`). Binary-searches the smaller side, then derives the paired amount by ceil.
|
||||
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
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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,
|
||||
@@ -11,15 +9,3 @@ pub(super) fn issue(code: &str, message: &str, fields: &[&str], details: Value)
|
||||
"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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -230,51 +230,3 @@ pub struct TokenHoldingsRequest {
|
||||
pub struct ProgramIdRequest {
|
||||
pub elf: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PositionRequest {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
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,
|
||||
compute_vault_pda, isqrt_product, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY,
|
||||
};
|
||||
use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID};
|
||||
use clock_core::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 serde_json::json;
|
||||
use token_core::{TokenDefinition, TokenHolding};
|
||||
use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount};
|
||||
use twap_oracle_core::compute_current_tick_account_pda;
|
||||
|
||||
use super::{
|
||||
accounts::{active_account_plan, missing_account_plan},
|
||||
context::{context, token_ids},
|
||||
holding::{select_holding, wallet_holdings, SelectedHolding},
|
||||
holding::{select_holding, SelectedHolding},
|
||||
pair::{is_canonical_pair, pair_ids, PairIds},
|
||||
position::AccountPlanHoldings,
|
||||
quote::{div_ceil_u256, minimum_opening_pair, quote, Q64},
|
||||
quote::{div_ceil_u256, minimum_opening_pair, Q64},
|
||||
swap::{swap_exact_in_plan, swap_exact_out_plan},
|
||||
ContextRequest, PairIdsRequest, PairSnapshot, PositionRequest, QuoteRequest,
|
||||
SwapExactInPlanRequest, SwapExactOutPlanRequest, TokenIdsRequest,
|
||||
ContextRequest, PairIdsRequest, SwapExactInPlanRequest, SwapExactOutPlanRequest,
|
||||
TokenIdsRequest,
|
||||
};
|
||||
use crate::{
|
||||
account::{account_id_hex, account_read, decode_account, program_id_bytes},
|
||||
@@ -80,20 +77,6 @@ fn token_holding(definition_id: AccountId, balance: u128) -> Account {
|
||||
)
|
||||
}
|
||||
|
||||
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]);
|
||||
@@ -110,45 +93,6 @@ fn ids() -> PairIds {
|
||||
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 {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,124 +100,6 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
#[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] {
|
||||
@@ -304,13 +130,6 @@ fn highest_balance_holding_wins_then_lowest_id() {
|
||||
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)],
|
||||
@@ -481,137 +300,6 @@ fn missing_pool_snapshot_defaults_remain_real_accounts() {
|
||||
assert_eq!(decoded, Account::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_pool_quote_uses_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);
|
||||
// The 11-account NewDefinition preview order (config, pool, vaults, lp def/lock,
|
||||
// holdings a/b/lp, current_tick, clock) — the create submit consumes the same shape.
|
||||
let preview = quote_value["accountPreview"].as_array().unwrap();
|
||||
assert_eq!(preview.len(), 11);
|
||||
assert_eq!(preview[6]["role"], "user_holding_a");
|
||||
assert_eq!(preview[6]["signer"], true);
|
||||
assert_eq!(preview[7]["signer"], true);
|
||||
assert_eq!(preview[8]["role"], "user_holding_lp");
|
||||
assert_eq!(preview[8]["signer"], true);
|
||||
}
|
||||
|
||||
#[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 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!([]));
|
||||
|
||||
// The existing LP holding (not a fresh one) fills the LP slot of the 10-account
|
||||
// AddLiquidity preview, and only the two token holdings sign.
|
||||
let preview = quote_value["accountPreview"].as_array().unwrap();
|
||||
assert_eq!(preview.len(), 10);
|
||||
assert_eq!(preview[7]["role"], "user_holding_lp");
|
||||
assert_eq!(
|
||||
preview[7]["accountId"].as_str().unwrap(),
|
||||
lp_holding.to_string()
|
||||
);
|
||||
assert_eq!(preview[7]["signer"], false);
|
||||
assert_eq!(preview[5]["signer"], true);
|
||||
assert_eq!(preview[6]["signer"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfunded_quote_cannot_submit() {
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_plan_uses_the_pool_stored_vaults_not_canonical_order() {
|
||||
// A pool created NON-canonically: its stored def_a is the smaller-valued
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde::{de::DeserializeOwned, Serialize};
|
||||
use crate::api::{
|
||||
self, AddLiquidityPlanRequest, AddLiquidityQuoteRequest, AmmApiError, AmmResult,
|
||||
ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest,
|
||||
PoolIdRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest,
|
||||
PoolIdRequest, ProgramIdRequest, ResolvePoolRequest, SwapExactInPlanRequest,
|
||||
SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest,
|
||||
TokenHoldingsRequest, TokenIdsRequest,
|
||||
};
|
||||
@@ -99,11 +99,6 @@ 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_swap_pair(request_json: *const c_char) -> *mut c_char {
|
||||
call::<SwapPairRequest>(request_json, api::swap_pair)
|
||||
|
||||
@@ -6,11 +6,11 @@ mod ffi;
|
||||
pub mod api;
|
||||
|
||||
pub use api::{
|
||||
config_id, context, create_pool_plan, liquidity_quote, pair_ids, pool_id, program_id, quote,
|
||||
config_id, context, create_pool_plan, liquidity_quote, pair_ids, pool_id, program_id,
|
||||
resolve_pool, swap_exact_in_plan, swap_exact_in_quote, swap_exact_out_plan,
|
||||
swap_exact_out_quote, swap_pair, token_ids, AccountRead, AmmApiError, AmmResponse, AmmResult,
|
||||
ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest,
|
||||
PairSnapshot, PoolIdRequest, PositionRequest, ProgramIdRequest, QuoteRequest,
|
||||
ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
|
||||
SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, WalletAccount,
|
||||
PoolIdRequest, ProgramIdRequest, ResolvePoolRequest, SwapExactInPlanRequest,
|
||||
SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest,
|
||||
TokenIdsRequest, WalletAccount,
|
||||
};
|
||||
|
||||
@@ -1096,63 +1096,6 @@ LogosList AmmModuleImpl::tokenHoldings(bool wallet_open) {
|
||||
return out;
|
||||
}
|
||||
|
||||
nlohmann::json AmmModuleImpl::buildQuoteInput(const LogosMap& request,
|
||||
const Network& net,
|
||||
bool wallet_open,
|
||||
bool fresh_wallet_accounts,
|
||||
nlohmann::json* error) {
|
||||
if (net.status != "ready") {
|
||||
*error = publicError(net.status);
|
||||
return json();
|
||||
}
|
||||
const FfiResult configResult =
|
||||
call(amm_config_id, json{{"ammProgramId", net.amm_program_id}});
|
||||
if (!configResult.ok) {
|
||||
*error = publicError("backend_error");
|
||||
return json();
|
||||
}
|
||||
const json config = readPublicAccount(jStr(configResult.value, "configId"));
|
||||
|
||||
const FfiResult pairResult = call(amm_pair_ids, json{
|
||||
{"ammProgramId", net.amm_program_id},
|
||||
{"config", config},
|
||||
{"tokenAId", request.value("tokenAId", json())},
|
||||
{"tokenBId", request.value("tokenBId", json())},
|
||||
});
|
||||
if (!pairResult.ok) {
|
||||
*error = publicError("backend_error");
|
||||
return json();
|
||||
}
|
||||
const json pairManifest = pairResult.value;
|
||||
if (jStr(pairManifest, "status") != "ok") {
|
||||
*error = publicError(jStr(pairManifest, "code"));
|
||||
return json();
|
||||
}
|
||||
|
||||
const json walletAccounts = walletAccountReads(wallet_open, fresh_wallet_accounts);
|
||||
const json snapshot = {
|
||||
{"config", config},
|
||||
{"tokenA", readPublicAccount(jStr(pairManifest, "tokenAId"))},
|
||||
{"tokenB", readPublicAccount(jStr(pairManifest, "tokenBId"))},
|
||||
{"pool", readPublicAccount(jStr(pairManifest, "poolId"))},
|
||||
{"vaultA", readPublicAccount(jStr(pairManifest, "vaultAId"))},
|
||||
{"vaultB", readPublicAccount(jStr(pairManifest, "vaultBId"))},
|
||||
{"lpDefinition", readPublicAccount(jStr(pairManifest, "lpDefinitionId"))},
|
||||
{"lpLockHolding", readPublicAccount(jStr(pairManifest, "lpLockHoldingId"))},
|
||||
{"currentTick", readPublicAccount(jStr(pairManifest, "currentTickId"))},
|
||||
{"clock", readPublicAccount(jStr(pairManifest, "clockId"))},
|
||||
{"walletAvailable", wallet_open},
|
||||
{"walletAccounts", walletAccounts},
|
||||
};
|
||||
return {
|
||||
{"networkId", net.id},
|
||||
{"networkFingerprint", net.fingerprint},
|
||||
{"ammProgramId", net.amm_program_id},
|
||||
{"request", request},
|
||||
{"snapshot", snapshot},
|
||||
};
|
||||
}
|
||||
|
||||
LogosMap AmmModuleImpl::newPositionContext(const LogosMap& request,
|
||||
bool wallet_open,
|
||||
bool refresh_wallet_accounts) {
|
||||
@@ -1210,13 +1153,3 @@ LogosMap AmmModuleImpl::newPositionContext(const LogosMap& request,
|
||||
: contextState("error", net.id, net.fingerprint, "backend_error");
|
||||
}
|
||||
|
||||
LogosMap AmmModuleImpl::quoteNewPosition(const LogosMap& request, bool wallet_open) {
|
||||
const Network net = network();
|
||||
json error;
|
||||
const json input = buildQuoteInput(request, net, wallet_open, /*fresh=*/false, &error);
|
||||
if (!error.is_null()) return error;
|
||||
|
||||
const FfiResult result = call(amm_quote, input);
|
||||
return result.ok ? result.value : publicError("backend_error");
|
||||
}
|
||||
|
||||
|
||||
@@ -184,11 +184,6 @@ public:
|
||||
bool wallet_open,
|
||||
bool refresh_wallet_accounts);
|
||||
|
||||
/// Prices an add-liquidity request against current on-chain state and
|
||||
/// returns the new-position quote map (quoteHash, canSubmit,
|
||||
/// requiresFreshLp, amounts, warnings). Read-only — no submission.
|
||||
LogosMap quoteNewPosition(const LogosMap& request, bool wallet_open);
|
||||
|
||||
private:
|
||||
// Off-chain "network" context, derived from the process env (the same
|
||||
// sources the app backend used): AMM deployment id from AMM_PROGRAM_BIN,
|
||||
@@ -235,16 +230,6 @@ private:
|
||||
// live sequencer round-trip.
|
||||
nlohmann::json walletAccountReads(bool wallet_open, bool refresh);
|
||||
|
||||
// Builds the { networkId, networkFingerprint, ammProgramId, request,
|
||||
// snapshot } input for quoteNewPosition's amm_quote call. On a recoverable
|
||||
// precondition failure, sets *error to a new-position error map and returns
|
||||
// a null json.
|
||||
nlohmann::json buildQuoteInput(const LogosMap& request,
|
||||
const Network& net,
|
||||
bool wallet_open,
|
||||
bool fresh_wallet_accounts,
|
||||
nlohmann::json* error);
|
||||
|
||||
// Process-lifetime network config, resolved once (see network()). Serialized
|
||||
// module dispatch means no locking is needed; there is no invalidation, as
|
||||
// runtime env reload is not supported.
|
||||
|
||||
Reference in New Issue
Block a user