feat(amm-client): add lossless host adapters

This commit is contained in:
Ricardo Guilherme Schmidt
2026-08-10 11:22:44 -03:00
parent 9d356cfa31
commit ae0bb310b0
9 changed files with 494 additions and 18 deletions
+1 -1
View File
@@ -145,7 +145,7 @@ pub unsafe extern "C" fn amm_client_plan(request_json: *const c_char) -> *mut c_
unsafe { call(request_json, wire::plan_json) }
}
/// Evaluates a canonical AMM economic quote from a tagged JSON request.
/// Evaluates a canonical AMM quote, discovery operation, or host adapter from tagged JSON.
///
/// Returned JSON owns its memory and must be released with [`amm_client_free`].
///
+186
View File
@@ -15,6 +15,14 @@ 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;
/// Largest token decimal count accepted by human-price conversion.
///
/// One whole token at a larger decimal count cannot fit in the protocol's `u128` raw amount.
pub const MAX_TOKEN_DECIMALS: u8 = 38;
/// Largest fractional precision accepted for either side of a human price ratio.
pub const MAX_HUMAN_PRICE_FRACTIONAL_DIGITS: u8 = 38;
/// Failure while turning a caller intent into executable AMM amounts.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
@@ -23,6 +31,19 @@ pub enum IntentError {
IdenticalTokenDefinitions,
/// A Q64.64 desired price must be nonzero.
ZeroDesiredPrice,
/// One side of a human price ratio is not an unsigned decimal amount.
InvalidHumanPriceAmount { field: &'static str },
/// One side of a human price ratio is zero.
ZeroHumanPriceAmount { field: &'static str },
/// One side of a human price ratio has unsupported fractional precision.
HumanPricePrecisionOutOfRange {
field: &'static str,
precision: usize,
},
/// Token metadata reports a decimal count outside the protocol amount range.
TokenDecimalsOutOfRange { field: &'static str, decimals: u8 },
/// A positive human price is smaller than the least positive Q64.64 value.
HumanPriceUnderflow,
/// An edited token amount must be nonzero.
ZeroEditedAmount,
/// A widened calculation produced a result outside the chain's `u128` amount range.
@@ -47,6 +68,11 @@ impl IntentError {
match self {
Self::IdenticalTokenDefinitions => "identical_token_definitions",
Self::ZeroDesiredPrice => "zero_desired_price",
Self::InvalidHumanPriceAmount { .. } => "invalid_human_price_amount",
Self::ZeroHumanPriceAmount { .. } => "zero_human_price_amount",
Self::HumanPricePrecisionOutOfRange { .. } => "human_price_precision_out_of_range",
Self::TokenDecimalsOutOfRange { .. } => "token_decimals_out_of_range",
Self::HumanPriceUnderflow => "human_price_underflow",
Self::ZeroEditedAmount => "zero_edited_amount",
Self::ArithmeticOverflow { .. } => "intent_arithmetic_overflow",
Self::SpotPriceMismatch { .. } => "spot_price_mismatch",
@@ -64,6 +90,23 @@ impl fmt::Display for IntentError {
formatter.write_str("pool token definitions must be distinct")
}
Self::ZeroDesiredPrice => formatter.write_str("desired Q64.64 price must be nonzero"),
Self::InvalidHumanPriceAmount { field } => {
write!(formatter, "{field} must be an unsigned decimal amount")
}
Self::ZeroHumanPriceAmount { field } => {
write!(formatter, "{field} must be greater than zero")
}
Self::HumanPricePrecisionOutOfRange { field, precision } => write!(
formatter,
"{field} has {precision} fractional digits; maximum is {MAX_HUMAN_PRICE_FRACTIONAL_DIGITS}"
),
Self::TokenDecimalsOutOfRange { field, decimals } => write!(
formatter,
"{field} is {decimals}; maximum is {MAX_TOKEN_DECIMALS}"
),
Self::HumanPriceUnderflow => {
formatter.write_str("human price is below the Q64.64 precision range")
}
Self::ZeroEditedAmount => formatter.write_str("edited token amount must be nonzero"),
Self::ArithmeticOverflow { operation } => {
write!(formatter, "{operation} exceeds the u128 amount range")
@@ -162,6 +205,149 @@ impl PreparedCallerOpeningPair {
}
}
#[derive(Clone, Copy)]
struct ParsedHumanAmount {
mantissa: u128,
fractional_digits: u8,
}
/// Converts an exact human token ratio into the pool's canonical raw Q64.64 price.
///
/// `first_amount` units of the caller's first token are declared equal in value to
/// `second_amount` units of the second token. The token IDs select canonical stored A/B order;
/// callers do not invert the ratio when their display order is reversed. Token decimal counts
/// convert the human ratio into raw-unit reserve B per raw-unit reserve A. Calculation uses integer
/// arithmetic and floors once at Q64.64 conversion.
pub fn human_price_ratio_to_q64_64(
first_token_definition_id: AccountId,
second_token_definition_id: AccountId,
first_amount: &str,
second_amount: &str,
first_token_decimals: u8,
second_token_decimals: u8,
) -> Result<u128, IntentError> {
let Some((stored_a_id, _)) =
canonical_token_pair(first_token_definition_id, second_token_definition_id)
else {
return Err(IntentError::IdenticalTokenDefinitions);
};
validate_token_decimals("firstTokenDecimals", first_token_decimals)?;
validate_token_decimals("secondTokenDecimals", second_token_decimals)?;
let first = parse_human_price_amount(first_amount, "firstAmount")?;
let second = parse_human_price_amount(second_amount, "secondAmount")?;
let (base, quote, base_decimals, quote_decimals) = if first_token_definition_id == stored_a_id {
(first, second, first_token_decimals, second_token_decimals)
} else {
(second, first, second_token_decimals, first_token_decimals)
};
let numerator_exponent = u16::from(quote_decimals)
.checked_add(u16::from(base.fractional_digits))
.ok_or(IntentError::ArithmeticOverflow {
operation: "human price numerator exponent",
})?;
let denominator_exponent = u16::from(base_decimals)
.checked_add(u16::from(quote.fractional_digits))
.ok_or(IntentError::ArithmeticOverflow {
operation: "human price denominator exponent",
})?;
let (numerator_exponent, denominator_exponent) = if numerator_exponent >= denominator_exponent {
(
numerator_exponent.checked_sub(denominator_exponent).ok_or(
IntentError::ArithmeticOverflow {
operation: "human price exponent reduction",
},
)?,
0,
)
} else {
(
0,
denominator_exponent.checked_sub(numerator_exponent).ok_or(
IntentError::ArithmeticOverflow {
operation: "human price exponent reduction",
},
)?,
)
};
let numerator = U512::from(quote.mantissa)
.checked_mul(U512::from(Q64_64_ONE))
.and_then(|value| value.checked_mul(pow10(numerator_exponent)?))
.ok_or(IntentError::ArithmeticOverflow {
operation: "human price numerator",
})?;
let denominator = U512::from(base.mantissa)
.checked_mul(
pow10(denominator_exponent).ok_or(IntentError::ArithmeticOverflow {
operation: "human price denominator power",
})?,
)
.ok_or(IntentError::ArithmeticOverflow {
operation: "human price denominator",
})?;
let converted = numerator
.checked_div(denominator)
.ok_or(IntentError::ArithmeticOverflow {
operation: "human price division",
})?;
if converted == U512::ZERO {
return Err(IntentError::HumanPriceUnderflow);
}
u128::try_from(converted).map_err(|_| IntentError::ArithmeticOverflow {
operation: "human Q64.64 price",
})
}
fn validate_token_decimals(field: &'static str, decimals: u8) -> Result<(), IntentError> {
if decimals > MAX_TOKEN_DECIMALS {
Err(IntentError::TokenDecimalsOutOfRange { field, decimals })
} else {
Ok(())
}
}
fn parse_human_price_amount(
value: &str,
field: &'static str,
) -> Result<ParsedHumanAmount, IntentError> {
let (whole, fraction) = value.split_once('.').map_or((value, ""), |parts| parts);
if whole.is_empty()
|| !whole.bytes().all(|byte| byte.is_ascii_digit())
|| !fraction.bytes().all(|byte| byte.is_ascii_digit())
{
return Err(IntentError::InvalidHumanPriceAmount { field });
}
if fraction.len() > usize::from(MAX_HUMAN_PRICE_FRACTIONAL_DIGITS) {
return Err(IntentError::HumanPricePrecisionOutOfRange {
field,
precision: fraction.len(),
});
}
let mut digits = String::from(whole);
digits.push_str(fraction);
let mantissa = digits
.parse::<u128>()
.map_err(|_| IntentError::InvalidHumanPriceAmount { field })?;
if mantissa == 0 {
return Err(IntentError::ZeroHumanPriceAmount { field });
}
let fractional_digits =
u8::try_from(fraction.len()).map_err(|_| IntentError::HumanPricePrecisionOutOfRange {
field,
precision: fraction.len(),
})?;
Ok(ParsedHumanAmount {
mantissa,
fractional_digits,
})
}
fn pow10(exponent: u16) -> Option<U512> {
(0..exponent).try_fold(U512::ONE, |value, _| value.checked_mul(U512::from(10_u8)))
}
/// Prepares an opening pair without requiring a caller to reproduce canonical token ordering.
pub fn prepare_caller_opening_pair(
first_token_definition_id: AccountId,
+9 -5
View File
@@ -6,6 +6,7 @@ mod ffi;
pub mod intent;
pub mod plan;
pub mod quote;
pub mod sequencer;
pub mod slippage;
pub mod transaction;
pub mod wire;
@@ -18,11 +19,12 @@ pub use discovery::{
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,
caller_amounts_to_stored, human_price_ratio_to_q64_64, 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, MAX_HUMAN_PRICE_FRACTIONAL_DIGITS,
MAX_TOKEN_DECIMALS, Q64_64_ONE,
};
pub use plan::{
encode_instruction, plan_add_liquidity, plan_create_oracle_price_account, plan_create_pool,
@@ -33,6 +35,8 @@ pub use plan::{
RemoveLiquidityPlanInput, SwapExactInputPlanInput, SwapExactOutputPlanInput,
SyncReservesPlanInput, TransactionPlan, UpdateConfigPlanInput,
};
pub use quote::AccountSnapshot;
pub use sequencer::{account_snapshot_from_sequencer_response, SequencerAccountError};
pub use slippage::{
maximum_guard_amount, minimum_guard_amount, prepare_add_liquidity, prepare_create_pool,
prepare_remove_liquidity, prepare_swap_exact_input, prepare_swap_exact_output,
+99
View File
@@ -0,0 +1,99 @@
//! Lossless adapters for raw sequencer account responses.
use std::{error::Error, fmt};
use nssa_core::{
account::{Account, AccountId, Data, Nonce},
program::ProgramId,
};
use serde::Deserialize;
use serde_json::Value;
use crate::quote::AccountSnapshot;
/// Failure while decoding a sequencer account response.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum SequencerAccountError {
/// Response was not valid sequencer account JSON.
InvalidResponse,
/// Sequencer returned an RPC error.
RpcError,
/// Sequencer returned no account result.
MissingAccount,
/// Account data exceeds the NSSA account-data limit.
AccountDataTooLarge,
}
impl SequencerAccountError {
/// Stable machine-readable error code.
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::InvalidResponse => "invalid_sequencer_response",
Self::RpcError => "sequencer_account_error",
Self::MissingAccount => "sequencer_account_missing",
Self::AccountDataTooLarge => "account_data_too_large",
}
}
}
impl fmt::Display for SequencerAccountError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::InvalidResponse => "sequencer account response is invalid",
Self::RpcError => "sequencer returned an account error",
Self::MissingAccount => "sequencer returned no account",
Self::AccountDataTooLarge => "sequencer account data is too large",
})
}
}
impl Error for SequencerAccountError {}
#[derive(Deserialize)]
struct SequencerEnvelope {
#[serde(default)]
result: Option<SequencerAccount>,
#[serde(default)]
error: Option<Value>,
}
#[derive(Deserialize)]
struct SequencerAccount {
program_owner: ProgramId,
balance: u128,
data: Vec<u8>,
nonce: u128,
}
/// Decodes a raw `getAccount` JSON-RPC response without routing integer fields through a
/// JavaScript numeric value.
///
/// `response` must be the original response text. Passing a JSON value already parsed by a host
/// with IEEE-754 numbers can lose balances or nonces above `2^53` before this function sees them.
pub fn account_snapshot_from_sequencer_response(
account_id: AccountId,
response: &str,
) -> Result<AccountSnapshot, SequencerAccountError> {
let envelope: SequencerEnvelope =
serde_json::from_str(response).map_err(|_| SequencerAccountError::InvalidResponse)?;
if envelope.error.is_some() {
return Err(SequencerAccountError::RpcError);
}
let account = envelope
.result
.ok_or(SequencerAccountError::MissingAccount)?;
let data =
Data::try_from(account.data).map_err(|_| SequencerAccountError::AccountDataTooLarge)?;
Ok(AccountSnapshot::new(
account_id,
Account {
program_owner: account.program_owner,
balance: account.balance,
data,
nonce: Nonce(account.nonce),
},
))
}
+81 -8
View File
@@ -18,10 +18,11 @@ use serde::Deserialize;
use serde_json::{json, Value};
use crate::{
account_snapshot_from_sequencer_response,
discovery::{self, CanonicalPair, PairReadManifest},
plan_add_liquidity, plan_create_oracle_price_account, plan_create_pool,
plan_create_price_observations, plan_initialize, plan_remove_liquidity, plan_swap_exact_input,
plan_swap_exact_output, plan_sync_reserves, plan_update_config,
human_price_ratio_to_q64_64, plan_add_liquidity, plan_create_oracle_price_account,
plan_create_pool, plan_create_price_observations, plan_initialize, plan_remove_liquidity,
plan_swap_exact_input, plan_swap_exact_output, plan_sync_reserves, plan_update_config,
quote::{
self as client_quote, AccountSnapshot, ValidatedFungibleDefinition,
ValidatedFungibleHolding, ValidatedPoolSnapshot,
@@ -30,10 +31,10 @@ use crate::{
CreatePoolPlanInput, CreatePriceObservationsPlanInput, InitializePlanInput, IntentError,
OpeningLiquidityIntent, PoolContext, PreparedAddLiquidity, PreparedCallerOpeningPair,
PreparedCreatePool, PreparedOpeningPair, PreparedRemoveLiquidity, PreparedSwapExactInput,
PreparedSwapExactOutput, PreparedTransaction, RemoveLiquidityPlanInput, SlippageTolerance,
SwapExactInputPlanInput, SwapExactOutputPlanInput, SyncReservesPlanInput, TransactionError,
TransactionOperation, TransactionPlan, UpdateConfigPlanInput, WalletPrerequisites,
SLIPPAGE_BPS_DENOMINATOR,
PreparedSwapExactOutput, PreparedTransaction, RemoveLiquidityPlanInput, SequencerAccountError,
SlippageTolerance, SwapExactInputPlanInput, SwapExactOutputPlanInput, SyncReservesPlanInput,
TransactionError, TransactionOperation, TransactionPlan, UpdateConfigPlanInput,
WalletPrerequisites, SLIPPAGE_BPS_DENOMINATOR,
};
/// Version of the reusable AMM client JSON contract.
@@ -90,6 +91,12 @@ impl From<TransactionError> for WireError {
}
}
impl From<SequencerAccountError> for WireError {
fn from(error: SequencerAccountError) -> Self {
Self::new(error.code(), error.to_string())
}
}
#[derive(Clone, Copy, Deserialize)]
#[serde(try_from = "String")]
struct ProgramIdInput(ProgramId);
@@ -410,6 +417,25 @@ impl PoolInput {
#[serde(tag = "operation", rename_all = "snake_case")]
enum QuoteRequest {
ProtocolConstants,
AccountSnapshotFromSequencerResponse {
#[serde(rename = "accountId")]
account_id: String,
response: String,
},
HumanPriceRatioToQ64_64 {
#[serde(rename = "firstTokenDefinitionId")]
first_token_definition_id: String,
#[serde(rename = "secondTokenDefinitionId")]
second_token_definition_id: String,
#[serde(rename = "firstAmount")]
first_amount: String,
#[serde(rename = "secondAmount")]
second_amount: String,
#[serde(rename = "firstTokenDecimals")]
first_token_decimals: String,
#[serde(rename = "secondTokenDecimals")]
second_token_decimals: String,
},
DeriveConfigId {
#[serde(rename = "ammProgramId")]
amm_program_id: ProgramIdInput,
@@ -1275,7 +1301,7 @@ pub fn plan_json(value: Value) -> Result<Value, WireError> {
})
}
/// Evaluates one reusable AMM economic quote from tagged JSON.
/// Evaluates one reusable AMM quote, discovery operation, or lossless host adapter from JSON.
pub fn quote_json(value: Value) -> Result<Value, WireError> {
validate_wire_schema(&value)?;
let request: QuoteRequest = serde_json::from_value(value)
@@ -1290,6 +1316,33 @@ pub fn quote_json(value: Value) -> Result<Value, WireError> {
.map(u128::to_string)
.collect::<Vec<_>>(),
})),
QuoteRequest::AccountSnapshotFromSequencerResponse {
account_id: requested_account_id,
response,
} => {
let snapshot = account_snapshot_from_sequencer_response(
account_id(&requested_account_id, "accountId")?,
&response,
)?;
Ok(account_snapshot_json(&snapshot))
}
QuoteRequest::HumanPriceRatioToQ64_64 {
first_token_definition_id,
second_token_definition_id,
first_amount,
second_amount,
first_token_decimals,
second_token_decimals,
} => Ok(json!({
"priceQ64_64": human_price_ratio_to_q64_64(
account_id(&first_token_definition_id, "firstTokenDefinitionId")?,
account_id(&second_token_definition_id, "secondTokenDefinitionId")?,
&first_amount,
&second_amount,
decimal_u8(&first_token_decimals, "firstTokenDecimals")?,
decimal_u8(&second_token_decimals, "secondTokenDecimals")?,
)?.to_string(),
})),
QuoteRequest::DeriveConfigId { amm_program_id } => Ok(json!({
"configId": discovery::derive_config_id(amm_program_id.into()).to_string(),
})),
@@ -1944,6 +1997,22 @@ fn pool_update_json(pool: PoolUpdate) -> Value {
})
}
fn account_snapshot_json(snapshot: &AccountSnapshot) -> Value {
let account = snapshot.account();
json!({
"id": snapshot.account_id().to_string(),
"programOwner": program_id_hex(account.program_owner),
"balance": account.balance.to_string(),
"nonce": account.nonce.0.to_string(),
"data": account
.data
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>(),
})
}
fn amm_context_json(context: &AmmContext) -> Value {
json!({
"ammProgramId": program_id_hex(context.amm_program_id),
@@ -2307,6 +2376,10 @@ fn decimal_u64(value: &str, field: &str) -> Result<u64, WireError> {
decimal(value, field)
}
fn decimal_u8(value: &str, field: &str) -> Result<u8, WireError> {
decimal(value, field)
}
fn optional_decimal_u128(value: Option<String>, field: &str) -> Result<Option<u128>, WireError> {
value
.as_deref()