refactor(amm-client)!: keep transport parsing in hosts

Remove the generic sequencer-response adapter and reuse the transaction order mapper.

BREAKING CHANGE: account_snapshot_from_sequencer_response and its quote JSON operation are removed. Hosts must normalize RPC responses into canonical account snapshots before calling amm_client.
This commit is contained in:
Ricardo Guilherme Schmidt
2026-08-10 11:22:44 -03:00
parent 43ed4de64f
commit d8e2ae3d85
8 changed files with 36 additions and 252 deletions
+3 -4
View File
@@ -17,8 +17,6 @@ adapter responsibilities.
- `intent` prepares canonical opening amounts and caller/stored order mappings with integer-only
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
@@ -80,5 +78,6 @@ Account data uses hexadecimal, and encoded instruction words remain JSON `u32` n
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 quote entrypoint also exposes
`account_snapshot_from_sequencer_response` and `human_price_ratio_to_q64_64` host adapters.
snapshot-bound `prepare_*_transaction` operations. The quote entrypoint exposes
`human_price_ratio_to_q64_64`; hosts normalize RPC responses into canonical snapshots before
calling the client.
+5 -9
View File
@@ -160,7 +160,6 @@ 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 |
@@ -209,11 +208,9 @@ snapshot to the returned quote. It is not execution-price impact.
## 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.
Hosts normalize sequencer or wallet responses into the canonical snapshot fields before calling the
client: base58 `id`, eight-word `programOwner`, decimal-string `balance` and `nonce`, and
hexadecimal `data`. Keep raw RPC parsing and wallet-specific representations outside AMM.
`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
@@ -337,9 +334,8 @@ 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. 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.
boundary failures. 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 -2
View File
@@ -19,8 +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; oracle-price initialization; raw sequencer
* account normalization; and human-price Q64.64 conversion.
* operations; reserve synchronization; oracle-price initialization; and human-price Q64.64
* conversion. Hosts normalize RPC and wallet responses before calling this API.
* Snapshot-bound prepare_*_transaction operations belong to amm_client_plan.
* See docs/wire-api.md for fields.
* Release the result with amm_client_free.
-2
View File
@@ -6,7 +6,6 @@ mod ffi;
pub mod intent;
pub mod plan;
pub mod quote;
pub mod sequencer;
pub mod slippage;
pub mod transaction;
pub mod wire;
@@ -36,7 +35,6 @@ pub use plan::{
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
@@ -1,99 +0,0 @@
//! 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),
},
))
}
+21 -48
View File
@@ -464,21 +464,9 @@ pub fn prepare_create_pool_transaction(
} else {
PairOrder::Reversed
};
let (stored_a_definition, stored_b_definition, stored_a_holding, stored_b_holding) = match order
{
PairOrder::Stored => (
&first_definition,
&second_definition,
&first_holding,
&second_holding,
),
PairOrder::Reversed => (
&second_definition,
&first_definition,
&second_holding,
&first_holding,
),
};
let (stored_a_definition, stored_b_definition) =
order_pair(order, first_definition, second_definition);
let (stored_a_holding, stored_b_holding) = order_pair(order, &first_holding, &second_holding);
let (stored_a_amount, stored_b_amount) =
order.amounts_to_stored(input.first_amount, input.second_amount);
let prepared = prepare_create_pool(
@@ -558,7 +546,11 @@ pub fn prepare_add_liquidity_transaction(
let snapshot = active.pool();
let order = active.caller_order();
validate_expected_fee(snapshot, input.expected_fee_bps)?;
let (first_definition, second_definition) = caller_definitions(snapshot, order);
let (first_definition, second_definition) = order_pair(
order,
snapshot.token_a_definition(),
snapshot.token_b_definition(),
);
let first_holding =
ValidatedFungibleHolding::new(&context, input.first_token_holding, first_definition)?;
let second_holding =
@@ -586,8 +578,7 @@ pub fn prepare_add_liquidity_transaction(
snapshot.liquidity_definition().account_id(),
"liquidity holding",
)?;
let (stored_holding_a, stored_holding_b) =
stored_holdings(order, &first_holding, &second_holding);
let (stored_holding_a, stored_holding_b) = order_pair(order, &first_holding, &second_holding);
let pool = PoolContext::new(&context, snapshot.pool_id(), snapshot.pool())?;
let plan = plan_add_liquidity(AddLiquidityPlanInput {
context: &context,
@@ -648,7 +639,11 @@ pub fn prepare_remove_liquidity_transaction(
let snapshot = active.pool();
let order = active.caller_order();
validate_expected_fee(snapshot, input.expected_fee_bps)?;
let (first_definition, second_definition) = caller_definitions(snapshot, order);
let (first_definition, second_definition) = order_pair(
order,
snapshot.token_a_definition(),
snapshot.token_b_definition(),
);
let first_fresh = validate_holding_destination(
&context,
input.first_token_holding,
@@ -1206,16 +1201,6 @@ fn pair_sources(
sources
}
fn caller_definitions(
snapshot: &ValidatedPoolSnapshot,
order: PairOrder,
) -> (&ValidatedFungibleDefinition, &ValidatedFungibleDefinition) {
match order {
PairOrder::Stored => (snapshot.token_a_definition(), snapshot.token_b_definition()),
PairOrder::Reversed => (snapshot.token_b_definition(), snapshot.token_a_definition()),
}
}
fn validate_expected_fee(
snapshot: &ValidatedPoolSnapshot,
expected_fee_bps: Option<u128>,
@@ -1229,17 +1214,6 @@ fn validate_expected_fee(
Ok(())
}
fn stored_holdings<'a>(
order: PairOrder,
first: &'a ValidatedFungibleHolding,
second: &'a ValidatedFungibleHolding,
) -> (&'a ValidatedFungibleHolding, &'a ValidatedFungibleHolding) {
match order {
PairOrder::Stored => (first, second),
PairOrder::Reversed => (second, first),
}
}
fn order_pair<T>(order: PairOrder, first: T, second: T) -> (T, T) {
match order {
PairOrder::Stored => (first, second),
@@ -1252,14 +1226,13 @@ fn swap_definitions(
input_definition_id: AccountId,
output_definition_id: AccountId,
) -> Result<(&ValidatedFungibleDefinition, &ValidatedFungibleDefinition), ClientError> {
match amm_program::quote::pair_order(
snapshot.pool(),
input_definition_id,
output_definition_id,
)? {
PairOrder::Stored => Ok((snapshot.token_a_definition(), snapshot.token_b_definition())),
PairOrder::Reversed => Ok((snapshot.token_b_definition(), snapshot.token_a_definition())),
}
let order =
amm_program::quote::pair_order(snapshot.pool(), input_definition_id, output_definition_id)?;
Ok(order_pair(
order,
snapshot.token_a_definition(),
snapshot.token_b_definition(),
))
}
fn validate_holding_destination(
+4 -42
View File
@@ -18,7 +18,6 @@ use serde::Deserialize;
use serde_json::{json, Value};
use crate::{
account_snapshot_from_sequencer_response,
discovery::{self, CanonicalPair, PairReadManifest},
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,
@@ -31,10 +30,10 @@ use crate::{
CreatePoolPlanInput, CreatePriceObservationsPlanInput, InitializePlanInput, IntentError,
OpeningLiquidityIntent, PoolContext, PreparedAddLiquidity, PreparedCallerOpeningPair,
PreparedCreatePool, PreparedOpeningPair, PreparedRemoveLiquidity, PreparedSwapExactInput,
PreparedSwapExactOutput, PreparedTransaction, RemoveLiquidityPlanInput, SequencerAccountError,
SlippageTolerance, SwapExactInputPlanInput, SwapExactOutputPlanInput, SyncReservesPlanInput,
TransactionError, TransactionOperation, TransactionPlan, UpdateConfigPlanInput,
WalletPrerequisites, SLIPPAGE_BPS_DENOMINATOR,
PreparedSwapExactOutput, PreparedTransaction, RemoveLiquidityPlanInput, SlippageTolerance,
SwapExactInputPlanInput, SwapExactOutputPlanInput, SyncReservesPlanInput, TransactionError,
TransactionOperation, TransactionPlan, UpdateConfigPlanInput, WalletPrerequisites,
SLIPPAGE_BPS_DENOMINATOR,
};
/// Version of the reusable AMM client JSON contract.
@@ -91,12 +90,6 @@ 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(transparent)]
struct ProgramIdInput(ProgramId);
@@ -409,11 +402,6 @@ 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,
@@ -1308,16 +1296,6 @@ 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,
@@ -1989,22 +1967,6 @@ 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_words(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_words(context.amm_program_id),
@@ -1,53 +1,8 @@
mod common;
use amm_client::{
account_snapshot_from_sequencer_response, human_price_ratio_to_q64_64, wire::quote_json,
Q64_64_ONE,
};
use amm_client::{human_price_ratio_to_q64_64, wire::quote_json, Q64_64_ONE};
use amm_core::canonical_token_pair;
use common::program_id_words;
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"],
json!(program_id_words([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]);