mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 14:11:09 +00:00
feat(amm-client): add lossless host adapters
This commit is contained in:
@@ -15,7 +15,10 @@ adapter responsibilities.
|
||||
- `discovery` derives config and complete pair read manifests, then classifies raw pair snapshots
|
||||
as missing or active without performing network I/O.
|
||||
- `intent` prepares canonical opening amounts and caller/stored order mappings with integer-only
|
||||
protocol math.
|
||||
protocol math. It also converts exact human price ratios and token decimals into stored-order
|
||||
Q64.64 prices without floating point.
|
||||
- `sequencer` decodes original `getAccount` response text directly into lossless
|
||||
`AccountSnapshot` values, preserving integer fields above `2^53`.
|
||||
- `slippage` converts validated quotes into integer-only instruction guards. Minimum guards round
|
||||
down, maximum guards round up, and checked overflow returns a typed error.
|
||||
- `plan` covers all ten guest instructions and returns the canonical instruction plus ordered
|
||||
@@ -76,4 +79,5 @@ Program IDs use 64-character lowercase hexadecimal strings. Account data uses he
|
||||
encoded instruction words remain JSON `u32` numbers. No JavaScript `Number` conversion is required
|
||||
for chain amounts or deadlines. Plan JSON also includes typed `instructionArgs`, derived directly
|
||||
from the same `amm_core::Instruction` encoded in `instructionWords`. Only `amm_client_plan` accepts
|
||||
the five snapshot-bound `prepare_*_transaction` operations.
|
||||
the five snapshot-bound `prepare_*_transaction` operations. The quote entrypoint also exposes
|
||||
`account_snapshot_from_sequencer_response` and `human_price_ratio_to_q64_64` host adapters.
|
||||
|
||||
@@ -159,6 +159,8 @@ shown in this table and the sections below.
|
||||
| `operation` | Additional fields |
|
||||
|---|---|
|
||||
| `protocol_constants` | none; returns decimal-string `minimumLiquidity`, `feeBpsDenominator`, `slippageBpsDenominator`, and `supportedFeeTiers` |
|
||||
| `account_snapshot_from_sequencer_response` | canonical base58 `accountId`, original `getAccount` response text in `response` |
|
||||
| `human_price_ratio_to_q64_64` | caller-ordered token IDs, `firstAmount`, `secondAmount`, and decimal-string `firstTokenDecimals`/`secondTokenDecimals` |
|
||||
| `derive_config_id` | `ammProgramId` |
|
||||
| `inspect_config` | `ammProgramId`, raw `config` snapshot |
|
||||
| `canonical_pair` | `firstTokenDefinitionId`, `secondTokenDefinitionId` |
|
||||
@@ -200,6 +202,20 @@ Quote values use these result shapes:
|
||||
A `pool` result contains decimal-string `liquidityPoolSupply`, `reserveA`, `reserveB`, and
|
||||
`spotPriceQ64_64` fields.
|
||||
|
||||
## Host adapters
|
||||
|
||||
`account_snapshot_from_sequencer_response` accepts the original JSON-RPC response as a JSON string,
|
||||
not a host-parsed object. It decodes sequencer numeric literals directly as Rust `u128` values and
|
||||
returns the standard snapshot fields: `id`, `programOwner`, `balance`, `nonce`, and `data`. This
|
||||
preserves balances and nonces above `2^53`. Do not route the response through a JavaScript or QML
|
||||
numeric value first.
|
||||
|
||||
`human_price_ratio_to_q64_64` declares that `firstAmount` human units of the first token equal
|
||||
`secondAmount` human units of the second token. Amounts are unsigned decimal text and may contain
|
||||
up to 38 fractional digits. Token decimals are accepted from `0` through `38`. The adapter derives
|
||||
stored token A/B order from the token IDs, applies unequal token decimals, floors once, and returns
|
||||
decimal-string `priceQ64_64`. Callers keep display order; reversed pairs must not invert locally.
|
||||
|
||||
## Discovery, inspection, and opening intents
|
||||
|
||||
Discovery functions derive IDs only; adapters fetch the returned accounts and submit raw
|
||||
@@ -315,7 +331,9 @@ Every failure uses `{ "code": "...", "message": "..." }`. `code` is the stable
|
||||
machine-readable contract; `message` is diagnostic text. JSON adapter failures return
|
||||
`invalid_request` or `unsupported_schema`. The C envelope additionally returns `null_request`,
|
||||
`invalid_utf8`, `invalid_json`, `response_serialization_failed`, or `response_contains_nul` for
|
||||
boundary failures.
|
||||
boundary failures. Sequencer adapters return `invalid_sequencer_response`,
|
||||
`sequencer_account_error`, `sequencer_account_missing`, or `account_data_too_large`. Human-price
|
||||
conversion uses the stable `IntentError` codes documented by the Rust API.
|
||||
|
||||
No request performs network I/O or checks an ImageID, release version, compatibility manifest, or
|
||||
program allowlist. Deployment configuration is expected to select the corresponding AMM build.
|
||||
|
||||
@@ -19,7 +19,8 @@ char *amm_client_plan(const char *request_json);
|
||||
* Accepts a tagged UTF-8 JSON request and returns an owned UTF-8 JSON envelope.
|
||||
* Supported operation tags include protocol constants; config and pair discovery;
|
||||
* pair inspection; caller-order opening preparation; economic quote/preparation
|
||||
* operations; reserve synchronization; and oracle-price initialization.
|
||||
* operations; reserve synchronization; oracle-price initialization; raw sequencer
|
||||
* account normalization; and human-price Q64.64 conversion.
|
||||
* Snapshot-bound prepare_*_transaction operations belong to amm_client_plan.
|
||||
* See docs/wire-api.md for fields.
|
||||
* Release the result with amm_client_free.
|
||||
|
||||
@@ -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`].
|
||||
///
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
mod common;
|
||||
|
||||
use amm_client::{
|
||||
account_snapshot_from_sequencer_response, human_price_ratio_to_q64_64, wire::quote_json,
|
||||
Q64_64_ONE,
|
||||
};
|
||||
use amm_core::canonical_token_pair;
|
||||
use common::program_id_hex;
|
||||
use nssa_core::account::AccountId;
|
||||
use serde_json::json;
|
||||
|
||||
const RAW_SEQUENCER_RESPONSE: &str = r#"{
|
||||
"jsonrpc":"2.0",
|
||||
"id":1,
|
||||
"result":{
|
||||
"program_owner":[1,2,3,4,5,6,7,8],
|
||||
"balance":340282366920938463463374607431768211455,
|
||||
"data":[0,255],
|
||||
"nonce":9007199254740993
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn raw_sequencer_response_becomes_lossless_snapshot() {
|
||||
let account_id = AccountId::new([7; 32]);
|
||||
let snapshot = account_snapshot_from_sequencer_response(account_id, RAW_SEQUENCER_RESPONSE)
|
||||
.expect("raw sequencer account must decode");
|
||||
|
||||
assert_eq!(snapshot.account_id(), account_id);
|
||||
assert_eq!(snapshot.account().program_owner, [1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
assert_eq!(snapshot.account().balance, u128::MAX);
|
||||
assert_eq!(snapshot.account().nonce.0, 9_007_199_254_740_993);
|
||||
assert_eq!(snapshot.account().data.as_ref(), &[0, 255]);
|
||||
|
||||
let wire = quote_json(json!({
|
||||
"operation": "account_snapshot_from_sequencer_response",
|
||||
"accountId": account_id.to_string(),
|
||||
"response": RAW_SEQUENCER_RESPONSE,
|
||||
}))
|
||||
.expect("wire adapter must decode raw response text");
|
||||
assert_eq!(wire["id"], account_id.to_string());
|
||||
assert_eq!(
|
||||
wire["programOwner"],
|
||||
program_id_hex([1, 2, 3, 4, 5, 6, 7, 8])
|
||||
);
|
||||
assert_eq!(wire["balance"], u128::MAX.to_string());
|
||||
assert_eq!(wire["nonce"], "9007199254740993");
|
||||
assert_eq!(wire["data"], "00ff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_price_conversion_handles_large_values_order_and_decimals() {
|
||||
let first_id = AccountId::new([1; 32]);
|
||||
let second_id = AccountId::new([2; 32]);
|
||||
let (stored_a_id, stored_b_id) =
|
||||
canonical_token_pair(first_id, second_id).expect("tokens are distinct");
|
||||
let amount_a = "9007199254740993";
|
||||
let amount_b = "18014398509481986";
|
||||
let expected = Q64_64_ONE
|
||||
.checked_mul(2_000_000_000_000)
|
||||
.expect("expected Q64.64 price fits");
|
||||
|
||||
let stored = human_price_ratio_to_q64_64(stored_a_id, stored_b_id, amount_a, amount_b, 6, 18)
|
||||
.expect("stored-order price must convert");
|
||||
let reversed = human_price_ratio_to_q64_64(stored_b_id, stored_a_id, amount_b, amount_a, 18, 6)
|
||||
.expect("reversed caller price must convert");
|
||||
|
||||
assert_eq!(stored, expected);
|
||||
assert_eq!(reversed, expected);
|
||||
|
||||
let inverse_decimal_scale =
|
||||
human_price_ratio_to_q64_64(stored_a_id, stored_b_id, "1", "2", 18, 6)
|
||||
.expect("negative decimal exponent must convert");
|
||||
let inverse_expected = Q64_64_ONE
|
||||
.checked_mul(2)
|
||||
.and_then(|value| value.checked_div(1_000_000_000_000))
|
||||
.expect("inverse decimal-scale expectation fits");
|
||||
assert_eq!(inverse_decimal_scale, inverse_expected);
|
||||
|
||||
let wire = quote_json(json!({
|
||||
"operation": "human_price_ratio_to_q64_64",
|
||||
"firstTokenDefinitionId": stored_b_id.to_string(),
|
||||
"secondTokenDefinitionId": stored_a_id.to_string(),
|
||||
"firstAmount": amount_b,
|
||||
"secondAmount": amount_a,
|
||||
"firstTokenDecimals": "18",
|
||||
"secondTokenDecimals": "6",
|
||||
}))
|
||||
.expect("wire price conversion must use canonical stored order");
|
||||
assert_eq!(wire["priceQ64_64"], expected.to_string());
|
||||
}
|
||||
Reference in New Issue
Block a user