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
+6 -2
View File
@@ -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.
+19 -1
View File
@@ -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.
+2 -1
View File
@@ -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.
+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()
@@ -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());
}