feat(amm): complete shared transaction client

This commit is contained in:
Ricardo Guilherme Schmidt
2026-08-10 11:22:44 -03:00
parent a7395aadb7
commit fb9df2fd96
23 changed files with 6353 additions and 63 deletions
+628
View File
@@ -0,0 +1,628 @@
//! Deterministic AMM account discovery and pair lifecycle inspection.
//!
//! These functions derive the complete protocol read set, then validate caller-supplied snapshots.
//! They perform no RPC, signing, submission, or deployed-program compatibility lookup.
use amm_core::{
canonical_token_pair, compute_config_pda, compute_liquidity_token_pda,
compute_lp_lock_holding_pda, compute_pool_pda, compute_vault_pda, spot_price_q64_64,
MINIMUM_LIQUIDITY,
};
use amm_program::quote as program_quote;
use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID;
use nssa_core::{
account::{Account, AccountId},
program::ProgramId,
};
use token_core::TokenHolding;
use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount};
use crate::{
plan::AmmContext,
quote::{
AccountSnapshot, ValidatedFungibleDefinition, ValidatedFungibleHolding,
ValidatedPoolSnapshot,
},
ClientError,
};
/// Deterministic pre-pool token order used by AMM pool PDA derivation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CanonicalPair {
token_a_id: AccountId,
token_b_id: AccountId,
}
impl CanonicalPair {
/// Returns canonical token A, whose raw account-ID bytes sort after token B.
#[must_use]
pub const fn token_a_id(&self) -> AccountId {
self.token_a_id
}
/// Returns canonical token B.
#[must_use]
pub const fn token_b_id(&self) -> AccountId {
self.token_b_id
}
}
/// One caller-named token definition and its deterministic pool vault.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TokenReadManifest {
definition_id: AccountId,
vault_id: AccountId,
}
impl TokenReadManifest {
/// Returns the token definition account ID.
#[must_use]
pub const fn definition_id(&self) -> AccountId {
self.definition_id
}
/// Returns the pool vault derived for this token definition.
#[must_use]
pub const fn vault_id(&self) -> AccountId {
self.vault_id
}
}
/// Complete deterministic account read set for inspecting a token pair.
///
/// `first_token` and `second_token` preserve caller order. Their vaults are therefore named by
/// token rather than by stored pool A/B order, which is unavailable until the pool is decoded.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PairReadManifest {
canonical_pair: CanonicalPair,
first_token: TokenReadManifest,
second_token: TokenReadManifest,
config_id: AccountId,
pool_id: AccountId,
liquidity_definition_id: AccountId,
lp_lock_holding_id: AccountId,
current_tick_id: AccountId,
clock_id: AccountId,
}
impl PairReadManifest {
/// Returns deterministic pre-pool token order.
#[must_use]
pub const fn canonical_pair(&self) -> CanonicalPair {
self.canonical_pair
}
/// Returns caller's first token and its derived vault.
#[must_use]
pub const fn first_token(&self) -> TokenReadManifest {
self.first_token
}
/// Returns caller's second token and its derived vault.
#[must_use]
pub const fn second_token(&self) -> TokenReadManifest {
self.second_token
}
/// Returns singleton AMM config account ID.
#[must_use]
pub const fn config_id(&self) -> AccountId {
self.config_id
}
/// Returns pair pool account ID.
#[must_use]
pub const fn pool_id(&self) -> AccountId {
self.pool_id
}
/// Returns deterministic LP token definition account ID.
#[must_use]
pub const fn liquidity_definition_id(&self) -> AccountId {
self.liquidity_definition_id
}
/// Returns deterministic permanently locked LP holding account ID.
#[must_use]
pub const fn lp_lock_holding_id(&self) -> AccountId {
self.lp_lock_holding_id
}
/// Returns pool's TWAP current-tick account ID.
#[must_use]
pub const fn current_tick_id(&self) -> AccountId {
self.current_tick_id
}
/// Returns canonical one-block clock account ID.
#[must_use]
pub const fn clock_id(&self) -> AccountId {
self.clock_id
}
/// Looks up a derived vault by token definition ID.
#[must_use]
pub fn vault_id_for(&self, definition_id: AccountId) -> Option<AccountId> {
if definition_id == self.first_token.definition_id {
Some(self.first_token.vault_id)
} else if definition_id == self.second_token.definition_id {
Some(self.second_token.vault_id)
} else {
None
}
}
}
/// Snapshots fetched from a [`PairReadManifest`].
#[derive(Clone, Copy)]
pub struct PairReadSnapshots<'a> {
pub pool: &'a AccountSnapshot,
pub first_token_definition: &'a AccountSnapshot,
pub second_token_definition: &'a AccountSnapshot,
pub first_token_vault: &'a AccountSnapshot,
pub second_token_vault: &'a AccountSnapshot,
pub liquidity_definition: &'a AccountSnapshot,
pub lp_lock_holding: &'a AccountSnapshot,
pub current_tick: &'a AccountSnapshot,
pub clock: &'a AccountSnapshot,
}
/// Validated canonical clock values used by current AMM instructions.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ValidatedClockSnapshot {
block_id: u64,
timestamp: u64,
}
impl ValidatedClockSnapshot {
#[must_use]
pub const fn block_id(&self) -> u64 {
self.block_id
}
#[must_use]
pub const fn timestamp(&self) -> u64 {
self.timestamp
}
}
/// State of a derived vault before its pool exists.
///
/// Pool creation's chained Token Program transfer accepts either a default destination or an
/// existing fungible holding for the same definition. It does not require every derived vault to
/// be default merely because the pool account is default.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MissingVaultState {
Uninitialized,
ExistingFungible { balance: u128 },
}
/// Validated view of a pair whose pool account is still uninitialized.
#[derive(Clone)]
pub struct MissingPairInspection {
manifest: PairReadManifest,
first_token_definition: ValidatedFungibleDefinition,
second_token_definition: ValidatedFungibleDefinition,
first_vault: MissingVaultState,
second_vault: MissingVaultState,
clock: ValidatedClockSnapshot,
}
impl MissingPairInspection {
#[must_use]
pub const fn manifest(&self) -> PairReadManifest {
self.manifest
}
#[must_use]
pub const fn first_token_definition(&self) -> &ValidatedFungibleDefinition {
&self.first_token_definition
}
#[must_use]
pub const fn second_token_definition(&self) -> &ValidatedFungibleDefinition {
&self.second_token_definition
}
#[must_use]
pub const fn first_vault(&self) -> MissingVaultState {
self.first_vault
}
#[must_use]
pub const fn second_vault(&self) -> MissingVaultState {
self.second_vault
}
#[must_use]
pub const fn clock(&self) -> ValidatedClockSnapshot {
self.clock
}
}
/// Validated current view of an initialized pair.
#[derive(Clone)]
pub struct ActivePairInspection {
manifest: PairReadManifest,
caller_order: program_quote::PairOrder,
pool: ValidatedPoolSnapshot,
lp_lock_holding: ValidatedFungibleHolding,
stored_spot_price_q64_64: u128,
current_tick: CurrentTickAccount,
clock: ValidatedClockSnapshot,
}
impl ActivePairInspection {
#[must_use]
pub const fn manifest(&self) -> PairReadManifest {
self.manifest
}
/// Returns caller first/second order relative to stored pool A/B order.
#[must_use]
pub const fn caller_order(&self) -> program_quote::PairOrder {
self.caller_order
}
/// Returns complete validated stored pool, token-definition, LP-definition, and vault state.
#[must_use]
pub const fn pool(&self) -> &ValidatedPoolSnapshot {
&self.pool
}
/// Returns the validated holding containing permanently locked minimum liquidity.
#[must_use]
pub const fn lp_lock_holding(&self) -> &ValidatedFungibleHolding {
&self.lp_lock_holding
}
/// Returns spot price from stored pool reserves as Q64.64 token B per token A.
#[must_use]
pub const fn stored_spot_price_q64_64(&self) -> u128 {
self.stored_spot_price_q64_64
}
#[must_use]
pub const fn current_tick(&self) -> &CurrentTickAccount {
&self.current_tick
}
#[must_use]
pub const fn clock(&self) -> ValidatedClockSnapshot {
self.clock
}
}
/// Current lifecycle state for a fully inspected pair read set.
#[derive(Clone)]
pub enum PairInspection {
Missing(Box<MissingPairInspection>),
Active(Box<ActivePairInspection>),
}
/// Derives the singleton config account without reading network state.
#[must_use]
pub fn derive_config_id(amm_program_id: ProgramId) -> AccountId {
compute_config_pda(amm_program_id)
}
/// Validates and decodes an AMM config snapshot.
///
/// The program ID is accepted optimistically as the configured transaction target and PDA
/// namespace. This performs no release, ImageID, or deployment-version check.
pub fn inspect_config(
amm_program_id: ProgramId,
config_snapshot: &AccountSnapshot,
) -> Result<AmmContext, ClientError> {
AmmContext::from_config_account(amm_program_id, config_snapshot)
}
/// Resolves deterministic pre-pool token order through the same helper used by pool PDA derivation.
pub fn canonical_pair(
first_token_id: AccountId,
second_token_id: AccountId,
) -> Result<CanonicalPair, ClientError> {
let Some((token_a_id, token_b_id)) = canonical_token_pair(first_token_id, second_token_id)
else {
return Err(ClientError::IdenticalTokenDefinitions);
};
Ok(CanonicalPair {
token_a_id,
token_b_id,
})
}
/// Derives every protocol account needed to inspect a caller-ordered pair.
pub fn derive_pair_read_manifest(
context: &AmmContext,
first_token_id: AccountId,
second_token_id: AccountId,
) -> Result<PairReadManifest, ClientError> {
let canonical_pair = canonical_pair(first_token_id, second_token_id)?;
let pool_id = compute_pool_pda(
context.amm_program_id,
canonical_pair.token_a_id,
canonical_pair.token_b_id,
);
Ok(PairReadManifest {
canonical_pair,
first_token: TokenReadManifest {
definition_id: first_token_id,
vault_id: compute_vault_pda(context.amm_program_id, pool_id, first_token_id),
},
second_token: TokenReadManifest {
definition_id: second_token_id,
vault_id: compute_vault_pda(context.amm_program_id, pool_id, second_token_id),
},
config_id: context.config_id(),
pool_id,
liquidity_definition_id: compute_liquidity_token_pda(context.amm_program_id, pool_id),
lp_lock_holding_id: compute_lp_lock_holding_pda(context.amm_program_id, pool_id),
current_tick_id: compute_current_tick_account_pda(
context.twap_oracle_program_id(),
pool_id,
),
clock_id: CLOCK_01_PROGRAM_ACCOUNT_ID,
})
}
/// Validates a pair read set and classifies its pool as missing or active.
pub fn inspect_pair(
context: &AmmContext,
first_token_id: AccountId,
second_token_id: AccountId,
snapshots: PairReadSnapshots<'_>,
) -> Result<PairInspection, ClientError> {
let manifest = derive_pair_read_manifest(context, first_token_id, second_token_id)?;
validate_snapshot_ids(manifest, &snapshots)?;
let first_token_definition =
ValidatedFungibleDefinition::new(context, snapshots.first_token_definition)?;
let second_token_definition =
ValidatedFungibleDefinition::new(context, snapshots.second_token_definition)?;
let clock = validate_clock(snapshots.clock)?;
if snapshots.pool.account() == &Account::default() {
validate_uninitialized("liquidity definition", snapshots.liquidity_definition)?;
validate_uninitialized("LP lock holding", snapshots.lp_lock_holding)?;
validate_uninitialized("current tick", snapshots.current_tick)?;
return Ok(PairInspection::Missing(Box::new(MissingPairInspection {
manifest,
first_vault: validate_missing_vault(
"first token vault",
snapshots.first_token_vault,
context.token_program_id(),
first_token_id,
)?,
second_vault: validate_missing_vault(
"second token vault",
snapshots.second_token_vault,
context.token_program_id(),
second_token_id,
)?,
first_token_definition,
second_token_definition,
clock,
})));
}
let stored_pool =
amm_core::PoolDefinition::try_from(&snapshots.pool.account().data).map_err(|_| {
ClientError::InvalidAccountData {
account: "AMM pool",
expected: "PoolDefinition",
}
})?;
let caller_order = program_quote::pair_order(&stored_pool, first_token_id, second_token_id)?;
let (token_a_definition, token_b_definition, vault_a, vault_b) = match caller_order {
program_quote::PairOrder::Stored => (
snapshots.first_token_definition,
snapshots.second_token_definition,
snapshots.first_token_vault,
snapshots.second_token_vault,
),
program_quote::PairOrder::Reversed => (
snapshots.second_token_definition,
snapshots.first_token_definition,
snapshots.second_token_vault,
snapshots.first_token_vault,
),
};
let pool = ValidatedPoolSnapshot::new(
context,
snapshots.pool,
token_a_definition,
token_b_definition,
vault_a,
vault_b,
snapshots.liquidity_definition,
)?;
// Reuse program-owned state validation for fee, minimum LP supply, and vault/reserve
// consistency. Donations are intentionally allowed and remain visible in vault balances.
let _ = crate::quote::sync_reserves(&pool)?;
if pool.pool().reserve_a == 0 || pool.pool().reserve_b == 0 {
return Err(ClientError::Quote {
code: "reserve_zero",
message: "Reserves must be nonzero",
});
}
let lp_lock_holding = ValidatedFungibleHolding::new(
context,
snapshots.lp_lock_holding,
pool.liquidity_definition(),
)?;
if lp_lock_holding.balance() < MINIMUM_LIQUIDITY {
return Err(ClientError::InvalidAccountData {
account: "LP lock holding",
expected: "fungible LP holding with at least the permanently locked minimum liquidity",
});
}
if snapshots.current_tick.account().program_owner != context.twap_oracle_program_id() {
return Err(ClientError::ProgramOwnerMismatch {
account: "current tick",
expected: context.twap_oracle_program_id(),
actual: snapshots.current_tick.account().program_owner,
});
}
let current_tick = CurrentTickAccount::try_from(&snapshots.current_tick.account().data)
.map_err(|_| ClientError::InvalidAccountData {
account: "current tick",
expected: "CurrentTickAccount",
})?;
let stored_spot_price_q64_64 = spot_price_q64_64(pool.pool().reserve_a, pool.pool().reserve_b);
Ok(PairInspection::Active(Box::new(ActivePairInspection {
manifest,
caller_order,
pool,
lp_lock_holding,
stored_spot_price_q64_64,
current_tick,
clock,
})))
}
fn validate_snapshot_ids(
manifest: PairReadManifest,
snapshots: &PairReadSnapshots<'_>,
) -> Result<(), ClientError> {
for (name, snapshot, expected) in [
("pool", snapshots.pool, manifest.pool_id),
(
"first token definition",
snapshots.first_token_definition,
manifest.first_token.definition_id,
),
(
"second token definition",
snapshots.second_token_definition,
manifest.second_token.definition_id,
),
(
"first token vault",
snapshots.first_token_vault,
manifest.first_token.vault_id,
),
(
"second token vault",
snapshots.second_token_vault,
manifest.second_token.vault_id,
),
(
"liquidity definition",
snapshots.liquidity_definition,
manifest.liquidity_definition_id,
),
(
"LP lock holding",
snapshots.lp_lock_holding,
manifest.lp_lock_holding_id,
),
(
"current tick",
snapshots.current_tick,
manifest.current_tick_id,
),
("clock", snapshots.clock, manifest.clock_id),
] {
if snapshot.account_id() != expected {
return Err(ClientError::AccountIdMismatch {
account: name,
expected,
actual: snapshot.account_id(),
});
}
}
Ok(())
}
fn validate_uninitialized(
account_name: &'static str,
snapshot: &AccountSnapshot,
) -> Result<(), ClientError> {
if snapshot.account() != &Account::default() {
return Err(ClientError::InvalidAccountData {
account: account_name,
expected: "uninitialized account",
});
}
Ok(())
}
fn validate_missing_vault(
account_name: &'static str,
snapshot: &AccountSnapshot,
token_program_id: ProgramId,
expected_definition_id: AccountId,
) -> Result<MissingVaultState, ClientError> {
if snapshot.account() == &Account::default() {
return Ok(MissingVaultState::Uninitialized);
}
if snapshot.account().program_owner != token_program_id {
return Err(ClientError::ProgramOwnerMismatch {
account: account_name,
expected: token_program_id,
actual: snapshot.account().program_owner,
});
}
// Existing recipients must be writable by the Token Program. A default destination remains
// valid because the chained transfer claims it for the Token Program.
let holding = TokenHolding::try_from(&snapshot.account().data).map_err(|_| {
ClientError::InvalidAccountData {
account: account_name,
expected: "TokenHolding",
}
})?;
let TokenHolding::Fungible {
definition_id,
balance,
} = holding
else {
return Err(ClientError::ExpectedFungibleToken {
account: account_name,
});
};
if definition_id != expected_definition_id {
return Err(ClientError::TokenDefinitionMismatch {
account: account_name,
expected: expected_definition_id,
actual: definition_id,
});
}
Ok(MissingVaultState::ExistingFungible { balance })
}
fn validate_clock(snapshot: &AccountSnapshot) -> Result<ValidatedClockSnapshot, ClientError> {
let bytes = snapshot.account().data.as_ref();
if bytes.len() != 16 {
return Err(ClientError::InvalidAccountData {
account: "clock",
expected: "ClockAccountData",
});
}
let (block_id_bytes, timestamp_bytes) = bytes.split_at(8);
let block_id = u64::from_le_bytes(block_id_bytes.try_into().map_err(|_| {
ClientError::InvalidAccountData {
account: "clock",
expected: "ClockAccountData",
}
})?);
let timestamp = u64::from_le_bytes(timestamp_bytes.try_into().map_err(|_| {
ClientError::InvalidAccountData {
account: "clock",
expected: "ClockAccountData",
}
})?);
Ok(ValidatedClockSnapshot {
block_id,
timestamp,
})
}
+5 -2
View File
@@ -19,6 +19,7 @@ type Operation = fn(Value) -> Result<Value, WireError>;
#[derive(Serialize)]
struct Envelope {
schema: &'static str,
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<Value>,
@@ -29,6 +30,7 @@ struct Envelope {
impl Envelope {
fn success(value: Value) -> Self {
Self {
schema: wire::WIRE_SCHEMA,
ok: true,
value: Some(value),
error: None,
@@ -37,6 +39,7 @@ impl Envelope {
fn failure(error: ErrorPayload) -> Self {
Self {
schema: wire::WIRE_SCHEMA,
ok: false,
value: None,
error: Some(error),
@@ -116,14 +119,14 @@ fn encode_envelope(envelope: &Envelope) -> *mut c_char {
let json = match serde_json::to_string(envelope) {
Ok(json) => json,
Err(_) => String::from(
r#"{"ok":false,"error":{"code":"response_serialization_failed","message":"response serialization failed"}}"#,
r#"{"schema":"amm-client.v1","ok":false,"error":{"code":"response_serialization_failed","message":"response serialization failed"}}"#,
),
};
match CString::new(json) {
Ok(value) => value.into_raw(),
Err(_) => CString::new(
r#"{"ok":false,"error":{"code":"response_contains_nul","message":"response contains NUL"}}"#,
r#"{"schema":"amm-client.v1","ok":false,"error":{"code":"response_contains_nul","message":"response contains NUL"}}"#,
)
.map_or(std::ptr::null_mut(), CString::into_raw),
}
+502
View File
@@ -0,0 +1,502 @@
//! Protocol-aware amount preparation for human-facing AMM intents.
use std::{error::Error, fmt};
use alloy_primitives::U512;
use amm_core::{
canonical_token_pair, checked_mul_div_ceil, isqrt_product, spot_price_q64_64, PoolDefinition,
MINIMUM_LIQUIDITY,
};
use amm_program::quote::{
self as program_quote, CreatePoolQuote, PairOrder, SwapDirection, SwapQuote,
};
use nssa_core::account::AccountId;
/// One whole unit in the Q64.64 price representation used by the AMM.
pub const Q64_64_ONE: u128 = 1_u128 << 64;
/// Failure while turning a caller intent into executable AMM amounts.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum IntentError {
/// A caller requested a token paired with itself.
IdenticalTokenDefinitions,
/// A Q64.64 desired price must be nonzero.
ZeroDesiredPrice,
/// An edited token amount must be nonzero.
ZeroEditedAmount,
/// A widened calculation produced a result outside the chain's `u128` amount range.
ArithmeticOverflow { operation: &'static str },
/// Explicit opening amounts do not encode the requested Q64.64 spot price exactly.
SpotPriceMismatch { desired: u128, actual: u128 },
/// A pool or quoted pool update has a zero directional reserve.
ZeroDirectionalReserve,
/// The supplied quote moves the directional spot price opposite to its swap direction.
SpotMovedAgainstSwap,
/// Canonical program quote logic rejected the prepared amounts.
Quote {
code: &'static str,
message: &'static str,
},
}
impl IntentError {
/// Stable machine-readable error code.
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::IdenticalTokenDefinitions => "identical_token_definitions",
Self::ZeroDesiredPrice => "zero_desired_price",
Self::ZeroEditedAmount => "zero_edited_amount",
Self::ArithmeticOverflow { .. } => "intent_arithmetic_overflow",
Self::SpotPriceMismatch { .. } => "spot_price_mismatch",
Self::ZeroDirectionalReserve => "zero_directional_reserve",
Self::SpotMovedAgainstSwap => "spot_moved_against_swap",
Self::Quote { code, .. } => code,
}
}
}
impl fmt::Display for IntentError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::IdenticalTokenDefinitions => {
formatter.write_str("pool token definitions must be distinct")
}
Self::ZeroDesiredPrice => formatter.write_str("desired Q64.64 price must be nonzero"),
Self::ZeroEditedAmount => formatter.write_str("edited token amount must be nonzero"),
Self::ArithmeticOverflow { operation } => {
write!(formatter, "{operation} exceeds the u128 amount range")
}
Self::SpotPriceMismatch { desired, actual } => write!(
formatter,
"opening amounts encode Q64.64 price {actual}, not requested price {desired}"
),
Self::ZeroDirectionalReserve => {
formatter.write_str("directional pool reserves must be nonzero")
}
Self::SpotMovedAgainstSwap => {
formatter.write_str("quoted spot price moved opposite to the swap direction")
}
Self::Quote { message, .. } => formatter.write_str(message),
}
}
}
impl Error for IntentError {}
impl From<program_quote::QuoteError> for IntentError {
fn from(error: program_quote::QuoteError) -> Self {
Self::Quote {
code: error.code(),
message: error.message(),
}
}
}
/// Executable opening amounts plus their canonical program quote.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct PreparedOpeningPair {
/// Requested Q64.64 target used to pair or minimize the amounts.
pub desired_price_q64_64: u128,
/// Exact Q64.64 price encoded by the returned integer amounts.
pub actual_price_q64_64: u128,
/// Stored token-A amount for `NewDefinition`.
pub token_a_amount: u128,
/// Stored token-B amount for `NewDefinition`.
pub token_b_amount: u128,
/// Fee tier passed to canonical pool-creation quote logic.
pub fee_bps: u128,
/// Canonical pool-creation result for the returned amounts.
pub quote: CreatePoolQuote,
}
/// Caller-facing source for an opening-liquidity pair.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum OpeningLiquidityIntent {
/// Find the smallest executable pair at the requested price.
Minimum,
/// Pair an amount edited for the caller's first token.
FirstAmount(u128),
/// Pair an amount edited for the caller's second token.
SecondAmount(u128),
/// Validate two explicit amounts in caller first/second order.
Explicit {
first_amount: u128,
second_amount: u128,
},
}
/// Executable opening amounts in both caller and canonical stored order.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PreparedCallerOpeningPair {
caller_order: PairOrder,
first_amount: u128,
second_amount: u128,
stored: PreparedOpeningPair,
}
impl PreparedCallerOpeningPair {
/// Caller first/second order relative to canonical stored A/B order.
#[must_use]
pub const fn caller_order(&self) -> PairOrder {
self.caller_order
}
#[must_use]
pub const fn first_amount(&self) -> u128 {
self.first_amount
}
#[must_use]
pub const fn second_amount(&self) -> u128 {
self.second_amount
}
/// Canonical token-A/token-B result ready for pool-creation validation and planning.
#[must_use]
pub const fn stored(&self) -> &PreparedOpeningPair {
&self.stored
}
}
/// Prepares an opening pair without requiring a caller to reproduce canonical token ordering.
pub fn prepare_caller_opening_pair(
first_token_definition_id: AccountId,
second_token_definition_id: AccountId,
desired_price_q64_64: u128,
fee_bps: u128,
intent: OpeningLiquidityIntent,
) -> Result<PreparedCallerOpeningPair, IntentError> {
let Some((stored_a_id, _)) =
canonical_token_pair(first_token_definition_id, second_token_definition_id)
else {
return Err(IntentError::IdenticalTokenDefinitions);
};
let caller_order = if first_token_definition_id == stored_a_id {
PairOrder::Stored
} else {
PairOrder::Reversed
};
let stored = match intent {
OpeningLiquidityIntent::Minimum => {
prepare_minimum_opening_pair(desired_price_q64_64, fee_bps)?
}
OpeningLiquidityIntent::FirstAmount(first_amount) => match caller_order {
PairOrder::Stored => {
prepare_opening_from_token_a(first_amount, desired_price_q64_64, fee_bps)?
}
PairOrder::Reversed => {
prepare_opening_from_token_b(first_amount, desired_price_q64_64, fee_bps)?
}
},
OpeningLiquidityIntent::SecondAmount(second_amount) => match caller_order {
PairOrder::Stored => {
prepare_opening_from_token_b(second_amount, desired_price_q64_64, fee_bps)?
}
PairOrder::Reversed => {
prepare_opening_from_token_a(second_amount, desired_price_q64_64, fee_bps)?
}
},
OpeningLiquidityIntent::Explicit {
first_amount,
second_amount,
} => {
let (token_a_amount, token_b_amount) =
caller_order.amounts_to_stored(first_amount, second_amount);
validate_explicit_opening_pair(
token_a_amount,
token_b_amount,
desired_price_q64_64,
fee_bps,
)?
}
};
let (first_amount, second_amount) =
caller_order.amounts_from_stored(stored.token_a_amount, stored.token_b_amount);
Ok(PreparedCallerOpeningPair {
caller_order,
first_amount,
second_amount,
stored,
})
}
/// Returns the token-B amount paired with an edited token-A amount.
///
/// The result is `ceil(token_a_amount * desired_price / 2^64)`, computed with the same widened
/// integer helper used by AMM code. Callers can inspect the actual representable price returned by
/// [`prepare_opening_from_token_a`] when integer rounding cannot reproduce the target exactly.
pub fn paired_amount_from_token_a(
token_a_amount: u128,
desired_price_q64_64: u128,
) -> Result<u128, IntentError> {
validate_pairing_inputs(token_a_amount, desired_price_q64_64)?;
checked_mul_div_ceil(token_a_amount, desired_price_q64_64, Q64_64_ONE).ok_or(
IntentError::ArithmeticOverflow {
operation: "token-A to token-B pairing",
},
)
}
/// Returns the token-A amount paired with an edited token-B amount.
///
/// The result is `ceil(token_b_amount * 2^64 / desired_price)`, using checked widened arithmetic.
pub fn paired_amount_from_token_b(
token_b_amount: u128,
desired_price_q64_64: u128,
) -> Result<u128, IntentError> {
validate_pairing_inputs(token_b_amount, desired_price_q64_64)?;
checked_mul_div_ceil(token_b_amount, Q64_64_ONE, desired_price_q64_64).ok_or(
IntentError::ArithmeticOverflow {
operation: "token-B to token-A pairing",
},
)
}
/// Finds the smallest executable opening pair on the price's base side.
///
/// For prices at least one, token A is minimized. For prices below one, token B is minimized. The
/// opposite amount is conservatively rounded up. The returned values are always passed through
/// [`amm_program::quote::create_pool`] before success is returned.
pub fn prepare_minimum_opening_pair(
desired_price_q64_64: u128,
fee_bps: u128,
) -> Result<PreparedOpeningPair, IntentError> {
if desired_price_q64_64 == 0 {
return Err(IntentError::ZeroDesiredPrice);
}
let upper = MINIMUM_LIQUIDITY
.checked_add(1)
.ok_or(IntentError::ArithmeticOverflow {
operation: "minimum opening-liquidity bound",
})?;
let (token_a_amount, token_b_amount) = if desired_price_q64_64 >= Q64_64_ONE {
let token_a_amount = first_executable(upper, |candidate_a| {
let candidate_b = paired_amount_from_token_a(candidate_a, desired_price_q64_64)?;
Ok(isqrt_product(candidate_a, candidate_b) > MINIMUM_LIQUIDITY)
})?;
(
token_a_amount,
paired_amount_from_token_a(token_a_amount, desired_price_q64_64)?,
)
} else {
let token_b_amount = first_executable(upper, |candidate_b| {
let candidate_a = paired_amount_from_token_b(candidate_b, desired_price_q64_64)?;
Ok(isqrt_product(candidate_a, candidate_b) > MINIMUM_LIQUIDITY)
})?;
(
paired_amount_from_token_b(token_b_amount, desired_price_q64_64)?,
token_b_amount,
)
};
prepare_opening_pair(
desired_price_q64_64,
token_a_amount,
token_b_amount,
fee_bps,
)
}
/// Pairs an edited token-A amount and validates the resulting pool creation through program logic.
pub fn prepare_opening_from_token_a(
token_a_amount: u128,
desired_price_q64_64: u128,
fee_bps: u128,
) -> Result<PreparedOpeningPair, IntentError> {
let token_b_amount = paired_amount_from_token_a(token_a_amount, desired_price_q64_64)?;
prepare_opening_pair(
desired_price_q64_64,
token_a_amount,
token_b_amount,
fee_bps,
)
}
/// Pairs an edited token-B amount and validates the resulting pool creation through program logic.
pub fn prepare_opening_from_token_b(
token_b_amount: u128,
desired_price_q64_64: u128,
fee_bps: u128,
) -> Result<PreparedOpeningPair, IntentError> {
let token_a_amount = paired_amount_from_token_b(token_b_amount, desired_price_q64_64)?;
prepare_opening_pair(
desired_price_q64_64,
token_a_amount,
token_b_amount,
fee_bps,
)
}
/// Validates explicit opening amounts and requires their Q64.64 spot price to match exactly.
pub fn validate_explicit_opening_pair(
token_a_amount: u128,
token_b_amount: u128,
desired_price_q64_64: u128,
fee_bps: u128,
) -> Result<PreparedOpeningPair, IntentError> {
let prepared = prepare_opening_pair(
desired_price_q64_64,
token_a_amount,
token_b_amount,
fee_bps,
)?;
if prepared.actual_price_q64_64 != desired_price_q64_64 {
return Err(IntentError::SpotPriceMismatch {
desired: desired_price_q64_64,
actual: prepared.actual_price_q64_64,
});
}
Ok(prepared)
}
/// Converts caller first/second amounts to the pool's stored A/B order.
#[must_use]
pub const fn caller_amounts_to_stored(order: PairOrder, first: u128, second: u128) -> (u128, u128) {
order.amounts_to_stored(first, second)
}
/// Converts stored pool A/B amounts back to caller first/second order.
#[must_use]
pub const fn stored_amounts_to_caller(
order: PairOrder,
amount_a: u128,
amount_b: u128,
) -> (u128, u128) {
order.amounts_from_stored(amount_a, amount_b)
}
/// Returns nonnegative directional pool spot movement in basis points for a canonical swap quote.
///
/// This computes, with one final floor operation:
///
/// `10_000 * (post_price - pre_price) / pre_price`
///
/// Reserves are oriented as input/output according to the quote direction. Intermediate products
/// use a widened integer so values remain exact even when reserve products exceed `u128`.
pub fn pool_spot_change_bps(
before: &PoolDefinition,
quote: &SwapQuote,
) -> Result<u128, IntentError> {
let (pre_in, pre_out, post_in, post_out) = match quote.direction {
SwapDirection::AToB => (
before.reserve_a,
before.reserve_b,
quote.pool.reserve_a,
quote.pool.reserve_b,
),
SwapDirection::BToA => (
before.reserve_b,
before.reserve_a,
quote.pool.reserve_b,
quote.pool.reserve_a,
),
};
if [pre_in, pre_out, post_in, post_out]
.into_iter()
.any(|reserve| reserve == 0)
{
return Err(IntentError::ZeroDirectionalReserve);
}
let post_price_numerator = U512::from(post_in).checked_mul(U512::from(pre_out)).ok_or(
IntentError::ArithmeticOverflow {
operation: "directional post-price numerator",
},
)?;
let relative_change_denominator = U512::from(post_out).checked_mul(U512::from(pre_in)).ok_or(
IntentError::ArithmeticOverflow {
operation: "directional relative-change denominator",
},
)?;
let increase = post_price_numerator
.checked_sub(relative_change_denominator)
.ok_or(IntentError::SpotMovedAgainstSwap)?;
let numerator =
increase
.checked_mul(U512::from(10_000_u128))
.ok_or(IntentError::ArithmeticOverflow {
operation: "directional basis-point numerator",
})?;
let change = numerator.checked_div(relative_change_denominator).ok_or(
IntentError::ArithmeticOverflow {
operation: "directional basis-point division",
},
)?;
u128::try_from(change).map_err(|_| IntentError::ArithmeticOverflow {
operation: "directional basis-point result",
})
}
fn validate_pairing_inputs(
edited_amount: u128,
desired_price_q64_64: u128,
) -> Result<(), IntentError> {
if edited_amount == 0 {
return Err(IntentError::ZeroEditedAmount);
}
if desired_price_q64_64 == 0 {
return Err(IntentError::ZeroDesiredPrice);
}
Ok(())
}
fn prepare_opening_pair(
desired_price_q64_64: u128,
token_a_amount: u128,
token_b_amount: u128,
fee_bps: u128,
) -> Result<PreparedOpeningPair, IntentError> {
if desired_price_q64_64 == 0 {
return Err(IntentError::ZeroDesiredPrice);
}
let quote = program_quote::create_pool(token_a_amount, token_b_amount, fee_bps)?;
let actual_price_q64_64 = spot_price_q64_64(token_a_amount, token_b_amount);
Ok(PreparedOpeningPair {
desired_price_q64_64,
actual_price_q64_64,
token_a_amount,
token_b_amount,
fee_bps,
quote,
})
}
fn first_executable(
upper: u128,
mut executable: impl FnMut(u128) -> Result<bool, IntentError>,
) -> Result<u128, IntentError> {
let mut lower = 1_u128;
let mut upper = upper;
while lower < upper {
let distance = upper
.checked_sub(lower)
.ok_or(IntentError::ArithmeticOverflow {
operation: "opening-pair search range",
})?;
let half = distance
.checked_div(2)
.ok_or(IntentError::ArithmeticOverflow {
operation: "opening-pair search division",
})?;
let midpoint = lower
.checked_add(half)
.ok_or(IntentError::ArithmeticOverflow {
operation: "opening-pair search midpoint",
})?;
if executable(midpoint)? {
upper = midpoint;
} else {
lower = midpoint
.checked_add(1)
.ok_or(IntentError::ArithmeticOverflow {
operation: "opening-pair search increment",
})?;
}
}
Ok(lower)
}
+23
View File
@@ -1,14 +1,29 @@
//! Stateless AMM quoting and transaction planning for host consumers.
pub mod discovery;
pub mod error;
mod ffi;
pub mod intent;
pub mod plan;
pub mod quote;
pub mod slippage;
pub mod transaction;
pub mod wire;
pub use discovery::{
canonical_pair, derive_config_id, derive_pair_read_manifest, inspect_config, inspect_pair,
ActivePairInspection, CanonicalPair, MissingPairInspection, MissingVaultState, PairInspection,
PairReadManifest, PairReadSnapshots, TokenReadManifest, ValidatedClockSnapshot,
};
pub use error::ClientError;
pub use ffi::{amm_client_free, amm_client_plan, amm_client_quote};
pub use intent::{
caller_amounts_to_stored, paired_amount_from_token_a, paired_amount_from_token_b,
pool_spot_change_bps, prepare_caller_opening_pair, prepare_minimum_opening_pair,
prepare_opening_from_token_a, prepare_opening_from_token_b, stored_amounts_to_caller,
validate_explicit_opening_pair, IntentError, OpeningLiquidityIntent, PreparedCallerOpeningPair,
PreparedOpeningPair, Q64_64_ONE,
};
pub use plan::{
encode_instruction, plan_add_liquidity, plan_create_oracle_price_account, plan_create_pool,
plan_create_price_observations, plan_initialize, plan_remove_liquidity, plan_swap_exact_input,
@@ -24,3 +39,11 @@ pub use slippage::{
PreparedAddLiquidity, PreparedCreatePool, PreparedRemoveLiquidity, PreparedSwapExactInput,
PreparedSwapExactOutput, SlippageTolerance, SLIPPAGE_BPS_DENOMINATOR,
};
pub use transaction::{
ensure_quote_unchanged, prepare_add_liquidity_transaction, prepare_create_pool_transaction,
prepare_remove_liquidity_transaction, prepare_swap_exact_input_transaction,
prepare_swap_exact_output_transaction, AddLiquidityTransactionInput, CallerAmounts,
CreatePoolTransactionInput, FundingRequirement, PoolAccountSnapshots, PreparedTransaction,
QuoteCommitment, RemoveLiquidityTransactionInput, SwapExactInputTransactionInput,
SwapExactOutputTransactionInput, TransactionError, TransactionOperation, WalletPrerequisites,
};
+21
View File
@@ -247,6 +247,27 @@ impl TransactionPlan {
.collect()
}
/// Writable account IDs in first-occurrence instruction order.
#[must_use]
pub fn writable_account_ids(&self) -> Vec<AccountId> {
self.accounts
.iter()
.filter(|account| account.writable())
.map(PlannedAccount::id)
.fold(Vec::new(), |mut ids, id| {
if !ids.contains(&id) {
ids.push(id);
}
ids
})
}
/// Account IDs whose state may change if the instruction succeeds.
#[must_use]
pub fn affected_account_ids(&self) -> Vec<AccountId> {
self.writable_account_ids()
}
/// Guest instruction name, kept exhaustive over the canonical enum.
#[must_use]
pub const fn instruction_name(&self) -> &'static str {
+15 -10
View File
@@ -158,6 +158,10 @@ pub fn prepare_create_pool(
}
/// Quotes add liquidity and derives its minimum-LP guard.
///
/// The instruction maxima remain the caller's original caps. Reusing the rounded actual deposits
/// as new maxima is not behavior-preserving for non-divisible reserve ratios: the program rounds
/// the proportional amounts again and can produce a different quote.
pub fn prepare_add_liquidity(
snapshot: &ValidatedPoolSnapshot,
max_amount_a: u128,
@@ -166,20 +170,14 @@ pub fn prepare_add_liquidity(
) -> Result<PreparedAddLiquidity, ClientError> {
let preview = client_quote::preview_add_liquidity(snapshot, max_amount_a, max_amount_b)?;
let min_amount_liquidity = minimum_guard_amount(preview.liquidity_to_mint, tolerance)?;
let max_amount_to_add_token_a = preview.actual_amount_a;
let max_amount_to_add_token_b = preview.actual_amount_b;
let quote = client_quote::add_liquidity(
snapshot,
max_amount_to_add_token_a,
max_amount_to_add_token_b,
min_amount_liquidity,
)?;
let quote =
client_quote::add_liquidity(snapshot, max_amount_a, max_amount_b, min_amount_liquidity)?;
Ok(PreparedAddLiquidity {
quote,
min_amount_liquidity,
max_amount_to_add_token_a,
max_amount_to_add_token_b,
max_amount_to_add_token_a: max_amount_a,
max_amount_to_add_token_b: max_amount_b,
})
}
@@ -251,6 +249,13 @@ pub fn prepare_swap_exact_output(
exact_amount_out,
)?;
let max_amount_in = maximum_guard_amount(preview.amount_in, tolerance)?;
if user_input.balance() < max_amount_in {
return Err(ClientError::InsufficientBalance {
account: "user input holding",
available: user_input.balance(),
required: max_amount_in,
});
}
let quote = client_quote::swap_exact_output(
snapshot,
user_input,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff