From ae0bb310b0d51434fddbb8ca1d4bf56e2ccdca35 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 22 Jul 2026 18:24:24 -0300 Subject: [PATCH] feat(amm-client): add lossless host adapters --- programs/amm/client/README.md | 8 +- programs/amm/client/docs/wire-api.md | 20 +- programs/amm/client/include/amm_client.h | 3 +- programs/amm/client/src/ffi.rs | 2 +- programs/amm/client/src/intent.rs | 186 ++++++++++++++++++ programs/amm/client/src/lib.rs | 14 +- programs/amm/client/src/sequencer.rs | 99 ++++++++++ programs/amm/client/src/wire.rs | 89 ++++++++- .../client/tests/consumer_adapter_contract.rs | 91 +++++++++ 9 files changed, 494 insertions(+), 18 deletions(-) create mode 100644 programs/amm/client/src/sequencer.rs create mode 100644 programs/amm/client/tests/consumer_adapter_contract.rs diff --git a/programs/amm/client/README.md b/programs/amm/client/README.md index b6593ae..55c0944 100644 --- a/programs/amm/client/README.md +++ b/programs/amm/client/README.md @@ -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. diff --git a/programs/amm/client/docs/wire-api.md b/programs/amm/client/docs/wire-api.md index b533215..b0aa99d 100644 --- a/programs/amm/client/docs/wire-api.md +++ b/programs/amm/client/docs/wire-api.md @@ -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. diff --git a/programs/amm/client/include/amm_client.h b/programs/amm/client/include/amm_client.h index 5444644..484495e 100644 --- a/programs/amm/client/include/amm_client.h +++ b/programs/amm/client/include/amm_client.h @@ -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. diff --git a/programs/amm/client/src/ffi.rs b/programs/amm/client/src/ffi.rs index 93b4f16..ba11f13 100644 --- a/programs/amm/client/src/ffi.rs +++ b/programs/amm/client/src/ffi.rs @@ -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`]. /// diff --git a/programs/amm/client/src/intent.rs b/programs/amm/client/src/intent.rs index 652a005..32d197a 100644 --- a/programs/amm/client/src/intent.rs +++ b/programs/amm/client/src/intent.rs @@ -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 { + 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 { + 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::() + .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 { + (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, diff --git a/programs/amm/client/src/lib.rs b/programs/amm/client/src/lib.rs index d8b9724..49ec411 100644 --- a/programs/amm/client/src/lib.rs +++ b/programs/amm/client/src/lib.rs @@ -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, diff --git a/programs/amm/client/src/sequencer.rs b/programs/amm/client/src/sequencer.rs new file mode 100644 index 0000000..511ef5f --- /dev/null +++ b/programs/amm/client/src/sequencer.rs @@ -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, + #[serde(default)] + error: Option, +} + +#[derive(Deserialize)] +struct SequencerAccount { + program_owner: ProgramId, + balance: u128, + data: Vec, + 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 { + 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), + }, + )) +} diff --git a/programs/amm/client/src/wire.rs b/programs/amm/client/src/wire.rs index fa545b0..98682c3 100644 --- a/programs/amm/client/src/wire.rs +++ b/programs/amm/client/src/wire.rs @@ -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 for WireError { } } +impl From 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 { }) } -/// 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 { validate_wire_schema(&value)?; let request: QuoteRequest = serde_json::from_value(value) @@ -1290,6 +1316,33 @@ pub fn quote_json(value: Value) -> Result { .map(u128::to_string) .collect::>(), })), + 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::(), + }) +} + 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 { decimal(value, field) } +fn decimal_u8(value: &str, field: &str) -> Result { + decimal(value, field) +} + fn optional_decimal_u128(value: Option, field: &str) -> Result, WireError> { value .as_deref() diff --git a/programs/amm/client/tests/consumer_adapter_contract.rs b/programs/amm/client/tests/consumer_adapter_contract.rs new file mode 100644 index 0000000..2c6d763 --- /dev/null +++ b/programs/amm/client/tests/consumer_adapter_contract.rs @@ -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()); +}