mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 22:21:16 +00:00
feat(amm): complete shared transaction client
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user