From 43ed4de64fbd8b32eb966b3ff637b930256d7c86 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 22 Jul 2026 23:22:32 -0300 Subject: [PATCH] fix(amm-client)!: align shared quote wire contract Use canonical ProgramId word arrays, include pool spot movement in standalone swap quotes, and keep quote commitments stable across deadline refreshes. BREAKING CHANGE: JSON ProgramId values now use eight u32 words. Convert hex or byte representations in host adapters. --- programs/amm/client/README.md | 11 +- programs/amm/client/docs/wire-api.md | 28 +-- programs/amm/client/include/amm_client.h | 5 +- programs/amm/client/src/transaction.rs | 144 +++++++++++++++- programs/amm/client/src/wire.rs | 112 ++++++------ programs/amm/client/tests/common/mod.rs | 10 +- .../client/tests/consumer_adapter_contract.rs | 4 +- programs/amm/client/tests/ffi_contract.rs | 27 +-- .../amm/client/tests/transaction_contract.rs | 8 +- .../tests/wire_discovery_intent_contract.rs | 21 ++- .../amm/client/tests/wire_prepare_contract.rs | 161 +++++++++++++++++- .../client/tests/wire_transaction_contract.rs | 10 +- 12 files changed, 411 insertions(+), 130 deletions(-) diff --git a/programs/amm/client/README.md b/programs/amm/client/README.md index 55c0944..cda8d08 100644 --- a/programs/amm/client/README.md +++ b/programs/amm/client/README.md @@ -75,9 +75,10 @@ Every call returns an owned JSON envelope. Release it exactly once with `amm_cli [`docs/wire-api.md`](docs/wire-api.md) for the complete transport contract. Raw `u128` and `u64` values cross JSON as decimal strings. Account IDs use canonical base58. -Program IDs use 64-character lowercase hexadecimal strings. Account data uses hexadecimal, and -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 quote entrypoint also exposes +Program IDs use exactly eight JSON `u32` words; hexadecimal and byte layouts are host-adapter-only. +Account data uses hexadecimal, and 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 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 b0aa99d..399bd93 100644 --- a/programs/amm/client/docs/wire-api.md +++ b/programs/amm/client/docs/wire-api.md @@ -14,9 +14,10 @@ Requests may include `"schema":"amm-client.v1"`. Schema-less requests remain acc compatibility. Every successful wire value and every C envelope identifies the response schema. All `u128` amounts, reserves, supplies, fees, nonces, and balances are unsigned decimal strings. -All `u64` windows and deadlines are also decimal strings. Program IDs are exactly 64 lowercase -hexadecimal characters: the 32 bytes formed by concatenating the eight `u32` words in -little-endian byte order. Signed ticks are decimal strings. Account IDs use canonical base58. +All `u64` windows and deadlines are also decimal strings. Program IDs are JSON arrays containing +exactly eight `u32` words in their canonical order. Hexadecimal and byte layouts are host-adapter +concerns and are neither accepted nor emitted by this shared wire API. Signed ticks are decimal +strings. Account IDs use canonical base58. Account `data` is an even-length hexadecimal string. ## Shared inputs @@ -25,9 +26,9 @@ Plan context: ```json { - "ammProgramId": "0000000000000000000000000000000000000000000000000000000000000000", - "tokenProgramId": "0000000000000000000000000000000000000000000000000000000000000000", - "twapOracleProgramId": "0000000000000000000000000000000000000000000000000000000000000000", + "ammProgramId": [0, 0, 0, 0, 0, 0, 0, 0], + "tokenProgramId": [0, 0, 0, 0, 0, 0, 0, 0], + "twapOracleProgramId": [0, 0, 0, 0, 0, 0, 0, 0], "authority": "base58-account-id" } ``` @@ -54,7 +55,7 @@ Fetched account snapshot used by quotes: ```json { "id": "base58-account-id", - "programOwner": "0000000000000000000000000000000000000000000000000000000000000000", + "programOwner": [0, 0, 0, 0, 0, 0, 0, 0], "balance": "0", "nonce": "0", "data": "00ff" @@ -65,7 +66,7 @@ Existing-pool quote operations include these top-level state fields: ```json { - "ammProgramId": "0000000000000000000000000000000000000000000000000000000000000000", + "ammProgramId": [0, 0, 0, 0, 0, 0, 0, 0], "config": { "...": "account snapshot" }, "snapshot": { "pool": { "...": "account snapshot" }, @@ -129,7 +130,7 @@ A successful plan value contains the following fields (`instructionWords` is abb "maxAmountToAddTokenB": "100", "deadline": "1900000000000" }, - "programId": "0000000000000000000000000000000000000000000000000000000000000000", + "programId": [0, 0, 0, 0, 0, 0, 0, 0], "accounts": [ { "id": "base58-account-id", @@ -194,7 +195,8 @@ Quote values use these result shapes: - pool creation: `pool`, `lockedLiquidity`, `userLiquidity`; - add liquidity: `actualAmountA`, `actualAmountB`, `liquidityToMint`, `pool`; - remove liquidity: `withdrawAmountA`, `withdrawAmountB`, `liquidityToBurn`, `pool`; -- swaps: `direction`, `amountIn`, `effectiveAmountIn`, `feeAmount`, `amountOut`, `pool`; +- swaps: `direction`, `amountIn`, `effectiveAmountIn`, `feeAmount`, `amountOut`, `pool`, and + decimal-string `poolSpotChangeBps`; - reserve sync: `donatedAmountA`, `donatedAmountB`, `pool`; - oracle price: `baseAsset`, `quoteAsset`, `initialPriceQ64_64`, `windowDuration`; and - pair order: `order` (`stored` or `reversed`). @@ -202,6 +204,9 @@ Quote values use these result shapes: A `pool` result contains decimal-string `liquidityPoolSupply`, `reserveA`, `reserveB`, and `spotPriceQ64_64` fields. +`poolSpotChangeBps` is the exact directional movement of the pool's spot price from the pre-swap +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, @@ -295,7 +300,8 @@ Successful task output contains: `poolSpotChangeBps` is `null` for non-swap tasks. Add-liquidity funding requirements use the caller caps. Exact-output swap funding uses the plan's slippage-adjusted `maxAmountIn`. Hosts should refresh snapshots, prepare again, compare `quoteCommitment`, and submit only the refreshed -plan. +plan. A deadline-only refresh keeps the same `quoteCommitment`; the refreshed plan still carries +the deadline to submit. ## Prepared instruction arguments diff --git a/programs/amm/client/include/amm_client.h b/programs/amm/client/include/amm_client.h index 484495e..c09a9f4 100644 --- a/programs/amm/client/include/amm_client.h +++ b/programs/amm/client/include/amm_client.h @@ -29,8 +29,9 @@ char *amm_client_quote(const char *request_json); /* * Raw u128, u64, and signed tick values are decimal JSON strings. Program IDs - * are 64-character lowercase hexadecimal strings. Instruction words are JSON - * u32 arrays. Account IDs use canonical base58 and account data is hexadecimal. + * are JSON arrays of eight u32 words. Hexadecimal and byte layouts are host-adapter-only. + * Instruction words are JSON u32 arrays. Account IDs use canonical base58 and account data is + * hexadecimal. * Requests may carry schema "amm-client.v1"; * schema-less legacy requests remain accepted. Responses use * {"schema":"amm-client.v1","ok":true,"value":...} or the same envelope diff --git a/programs/amm/client/src/transaction.rs b/programs/amm/client/src/transaction.rs index 2d55a77..94d248b 100644 --- a/programs/amm/client/src/transaction.rs +++ b/programs/amm/client/src/transaction.rs @@ -140,7 +140,10 @@ impl WalletPrerequisites { } } -/// SHA-256 commitment to the typed request, exact plan, and role-tagged account snapshots. +/// SHA-256 commitment to quote-relevant intent, guards, account selection, and snapshots. +/// +/// The transaction deadline remains part of the returned plan, but is deliberately not committed: +/// hosts may refresh their deadline window without invalidating an otherwise unchanged quote. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct QuoteCommitment([u8; 32]); @@ -443,7 +446,7 @@ pub fn prepare_create_pool_transaction( account: "AMM pool", expected: "uninitialized pool lifecycle", } - .into()) + .into()); } }; let first_definition = missing.first_token_definition(); @@ -885,8 +888,7 @@ impl PreparedTransaction { unique_account_ids.push(account_id); } - let instruction_words = plan - .instruction_data() + plan.instruction_data() .map_err(|_| TransactionError::InstructionEncoding)?; let plan_accounts = plan .accounts() @@ -904,11 +906,10 @@ impl PreparedTransaction { operation, intent, program_id: plan.program_id(), - instruction_words, + guards: CommitmentGuards::from(plan.instruction()), plan_accounts, sources, caller_amounts, - deadline, }; let words = risc0_zkvm::serde::to_vec(&payload) .map_err(|_| TransactionError::CommitmentEncoding)?; @@ -946,11 +947,138 @@ struct PreparedCommitmentPayload { operation: TransactionOperation, intent: TransactionIntent, program_id: ProgramId, - instruction_words: Vec, + guards: CommitmentGuards, plan_accounts: Vec, sources: Vec, caller_amounts: CallerAmounts, - deadline: u64, +} + +/// Instruction fields generated from a quote that affect its economic or account-selection +/// meaning. Deadlines are intentionally excluded because they are host transaction policy. +#[derive(Serialize)] +enum CommitmentGuards { + Initialize { + token_program_id: ProgramId, + twap_oracle_program_id: ProgramId, + authority: AccountId, + }, + UpdateConfig { + token_program_id: Option, + twap_oracle_program_id: Option, + new_authority: Option, + }, + CreatePriceObservations { + window_duration: u64, + }, + CreateOraclePriceAccount { + window_duration: u64, + }, + CreatePool { + token_a_amount: u128, + token_b_amount: u128, + fees: u128, + }, + AddLiquidity { + min_amount_liquidity: u128, + max_amount_to_add_token_a: u128, + max_amount_to_add_token_b: u128, + }, + RemoveLiquidity { + remove_liquidity_amount: u128, + min_amount_to_remove_token_a: u128, + min_amount_to_remove_token_b: u128, + }, + SwapExactInput { + swap_amount_in: u128, + min_amount_out: u128, + }, + SwapExactOutput { + exact_amount_out: u128, + max_amount_in: u128, + }, + SyncReserves, +} + +impl From<&amm_core::Instruction> for CommitmentGuards { + fn from(instruction: &amm_core::Instruction) -> Self { + match instruction { + amm_core::Instruction::Initialize { + token_program_id, + twap_oracle_program_id, + authority, + } => Self::Initialize { + token_program_id: *token_program_id, + twap_oracle_program_id: *twap_oracle_program_id, + authority: *authority, + }, + amm_core::Instruction::UpdateConfig { + token_program_id, + twap_oracle_program_id, + new_authority, + } => Self::UpdateConfig { + token_program_id: *token_program_id, + twap_oracle_program_id: *twap_oracle_program_id, + new_authority: *new_authority, + }, + amm_core::Instruction::CreatePriceObservations { window_duration } => { + Self::CreatePriceObservations { + window_duration: *window_duration, + } + } + amm_core::Instruction::CreateOraclePriceAccount { window_duration } => { + Self::CreateOraclePriceAccount { + window_duration: *window_duration, + } + } + amm_core::Instruction::NewDefinition { + token_a_amount, + token_b_amount, + fees, + .. + } => Self::CreatePool { + token_a_amount: *token_a_amount, + token_b_amount: *token_b_amount, + fees: *fees, + }, + amm_core::Instruction::AddLiquidity { + min_amount_liquidity, + max_amount_to_add_token_a, + max_amount_to_add_token_b, + .. + } => Self::AddLiquidity { + min_amount_liquidity: *min_amount_liquidity, + max_amount_to_add_token_a: *max_amount_to_add_token_a, + max_amount_to_add_token_b: *max_amount_to_add_token_b, + }, + amm_core::Instruction::RemoveLiquidity { + remove_liquidity_amount, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + .. + } => Self::RemoveLiquidity { + remove_liquidity_amount: *remove_liquidity_amount, + min_amount_to_remove_token_a: *min_amount_to_remove_token_a, + min_amount_to_remove_token_b: *min_amount_to_remove_token_b, + }, + amm_core::Instruction::SwapExactInput { + swap_amount_in, + min_amount_out, + .. + } => Self::SwapExactInput { + swap_amount_in: *swap_amount_in, + min_amount_out: *min_amount_out, + }, + amm_core::Instruction::SwapExactOutput { + exact_amount_out, + max_amount_in, + .. + } => Self::SwapExactOutput { + exact_amount_out: *exact_amount_out, + max_amount_in: *max_amount_in, + }, + amm_core::Instruction::SyncReserves => Self::SyncReserves, + } + } } #[derive(Serialize)] diff --git a/programs/amm/client/src/wire.rs b/programs/amm/client/src/wire.rs index 98682c3..be6e525 100644 --- a/programs/amm/client/src/wire.rs +++ b/programs/amm/client/src/wire.rs @@ -98,7 +98,7 @@ impl From for WireError { } #[derive(Clone, Copy, Deserialize)] -#[serde(try_from = "String")] +#[serde(transparent)] struct ProgramIdInput(ProgramId); impl From for ProgramId { @@ -107,14 +107,6 @@ impl From for ProgramId { } } -impl TryFrom for ProgramIdInput { - type Error = String; - - fn try_from(value: String) -> Result { - parse_program_id(&value).map(Self) - } -} - #[derive(Deserialize)] #[serde(tag = "operation", rename_all = "snake_case")] enum PlanRequest { @@ -1648,7 +1640,7 @@ pub fn quote_json(value: Value) -> Result { &user_output, decimal_u128(&amount_in, "amountIn")?, )?; - Ok(swap_quote_json(quote)) + swap_quote_with_pool_spot_change_json(snapshot.pool(), quote) } QuoteRequest::PrepareSwapExactInput { state, @@ -1673,7 +1665,7 @@ pub fn quote_json(value: Value) -> Result { decimal_u128(&amount_in, "amountIn")?, slippage_tolerance(&slippage_bps)?, )?; - Ok(prepared_swap_exact_input_json(prepared)) + prepared_swap_exact_input_json(snapshot.pool(), prepared) } QuoteRequest::SwapExactInput { state, @@ -1698,7 +1690,7 @@ pub fn quote_json(value: Value) -> Result { decimal_u128(&amount_in, "amountIn")?, decimal_u128(&minimum_amount_out, "minimumAmountOut")?, )?; - Ok(swap_quote_json(quote)) + swap_quote_with_pool_spot_change_json(snapshot.pool(), quote) } QuoteRequest::PreviewSwapExactOutput { state, @@ -1721,7 +1713,7 @@ pub fn quote_json(value: Value) -> Result { &user_output, decimal_u128(&exact_amount_out, "exactAmountOut")?, )?; - Ok(swap_quote_json(quote)) + swap_quote_with_pool_spot_change_json(snapshot.pool(), quote) } QuoteRequest::PrepareSwapExactOutput { state, @@ -1746,7 +1738,7 @@ pub fn quote_json(value: Value) -> Result { decimal_u128(&exact_amount_out, "exactAmountOut")?, slippage_tolerance(&slippage_bps)?, )?; - Ok(prepared_swap_exact_output_json(prepared)) + prepared_swap_exact_output_json(snapshot.pool(), prepared) } QuoteRequest::SwapExactOutput { state, @@ -1771,7 +1763,7 @@ pub fn quote_json(value: Value) -> Result { decimal_u128(&exact_amount_out, "exactAmountOut")?, decimal_u128(&maximum_amount_in, "maximumAmountIn")?, )?; - Ok(swap_quote_json(quote)) + swap_quote_with_pool_spot_change_json(snapshot.pool(), quote) } QuoteRequest::SyncReserves { state } => { let (_, snapshot) = state.validate()?; @@ -1818,7 +1810,7 @@ fn transaction_plan_json(plan: &TransactionPlan) -> Result { Ok(json!({ "instruction": plan.instruction_name(), "instructionArgs": instruction_args_json(plan.instruction()), - "programId": program_id_hex(plan.program_id()), + "programId": program_id_words(plan.program_id()), "accounts": accounts, "affectedAccountIds": plan .affected_account_ids() @@ -1836,8 +1828,8 @@ fn instruction_args_json(instruction: &Instruction) -> Value { twap_oracle_program_id, authority, } => json!({ - "tokenProgramId": program_id_hex(*token_program_id), - "twapOracleProgramId": program_id_hex(*twap_oracle_program_id), + "tokenProgramId": program_id_words(*token_program_id), + "twapOracleProgramId": program_id_words(*twap_oracle_program_id), "authority": authority.to_string(), }), Instruction::UpdateConfig { @@ -1845,8 +1837,8 @@ fn instruction_args_json(instruction: &Instruction) -> Value { twap_oracle_program_id, new_authority, } => json!({ - "tokenProgramId": token_program_id.map(program_id_hex), - "twapOracleProgramId": twap_oracle_program_id.map(program_id_hex), + "tokenProgramId": token_program_id.map(program_id_words), + "twapOracleProgramId": twap_oracle_program_id.map(program_id_words), "newAuthority": new_authority.map(|authority| authority.to_string()), }), Instruction::CreatePriceObservations { window_duration } @@ -2001,7 +1993,7 @@ 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), + "programOwner": program_id_words(account.program_owner), "balance": account.balance.to_string(), "nonce": account.nonce.0.to_string(), "data": account @@ -2015,10 +2007,10 @@ fn account_snapshot_json(snapshot: &AccountSnapshot) -> Value { fn amm_context_json(context: &AmmContext) -> Value { json!({ - "ammProgramId": program_id_hex(context.amm_program_id), + "ammProgramId": program_id_words(context.amm_program_id), "configId": context.config_id().to_string(), - "tokenProgramId": program_id_hex(context.token_program_id()), - "twapOracleProgramId": program_id_hex(context.twap_oracle_program_id()), + "tokenProgramId": program_id_words(context.token_program_id()), + "twapOracleProgramId": program_id_words(context.twap_oracle_program_id()), "authority": context.config.authority.to_string(), }) } @@ -2289,24 +2281,49 @@ fn swap_quote_json(quote: SwapQuote) -> Value { }) } -fn prepared_swap_exact_input_json(prepared: PreparedSwapExactInput) -> Value { - json!({ - "quote": swap_quote_json(prepared.quote), +fn swap_quote_with_pool_spot_change_json( + before: &PoolDefinition, + quote: SwapQuote, +) -> Result { + let pool_spot_change_bps = crate::pool_spot_change_bps(before, "e)?; + let mut value = swap_quote_json(quote); + let Some(object) = value.as_object_mut() else { + return Err(WireError::new( + "response_serialization_failed", + "swap quote response must be a JSON object", + )); + }; + object.insert( + String::from("poolSpotChangeBps"), + Value::String(pool_spot_change_bps.to_string()), + ); + Ok(value) +} + +fn prepared_swap_exact_input_json( + before: &PoolDefinition, + prepared: PreparedSwapExactInput, +) -> Result { + Ok(json!({ + "quote": swap_quote_with_pool_spot_change_json(before, prepared.quote)?, "instructionArgs": { "swapAmountIn": prepared.swap_amount_in.to_string(), "minAmountOut": prepared.min_amount_out.to_string(), }, - }) + })) } -fn prepared_swap_exact_output_json(prepared: PreparedSwapExactOutput) -> Value { - json!({ - "quote": swap_quote_json(prepared.quote), +fn prepared_swap_exact_output_json( + before: &PoolDefinition, + prepared: PreparedSwapExactOutput, +) -> Result { + Ok(json!({ + "quote": swap_quote_with_pool_spot_change_json(before, prepared.quote)?, "instructionArgs": { "exactAmountOut": prepared.exact_amount_out.to_string(), "maxAmountIn": prepared.max_amount_in.to_string(), }, - }) + })) } fn sync_reserves_quote_json(quote: SyncReservesQuote) -> Value { @@ -2326,35 +2343,8 @@ fn oracle_price_quote_json(quote: OraclePriceAccountQuote) -> Value { }) } -fn parse_program_id(value: &str) -> Result { - if value.len() != 64 - || !value - .bytes() - .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) - { - return Err(String::from( - "program ID must be exactly 64 lowercase hexadecimal characters", - )); - } - - let bytes = hex_bytes(value, "program ID").map_err(|error| error.to_string())?; - let mut program_id = [0_u32; 8]; - for (word, bytes) in program_id.iter_mut().zip(bytes.chunks_exact(4)) { - let mut word_bytes = [0_u8; 4]; - word_bytes.copy_from_slice(bytes); - *word = u32::from_le_bytes(word_bytes); - } - Ok(program_id) -} - -fn program_id_hex(program_id: ProgramId) -> String { - let mut output = String::with_capacity(64); - for word in program_id { - for byte in word.to_le_bytes() { - output.push_str(&format!("{byte:02x}")); - } - } - output +const fn program_id_words(program_id: ProgramId) -> ProgramId { + program_id } fn account_id(value: &str, field: &str) -> Result { diff --git a/programs/amm/client/tests/common/mod.rs b/programs/amm/client/tests/common/mod.rs index e4dfc60..51e5488 100644 --- a/programs/amm/client/tests/common/mod.rs +++ b/programs/amm/client/tests/common/mod.rs @@ -1,11 +1,5 @@ use nssa_core::program::ProgramId; -pub fn program_id_hex(program_id: ProgramId) -> String { - let mut output = String::with_capacity(64); - for word in program_id { - for byte in word.to_le_bytes() { - output.push_str(&format!("{byte:02x}")); - } - } - output +pub const fn program_id_words(program_id: ProgramId) -> ProgramId { + program_id } diff --git a/programs/amm/client/tests/consumer_adapter_contract.rs b/programs/amm/client/tests/consumer_adapter_contract.rs index 2c6d763..8dd5a8c 100644 --- a/programs/amm/client/tests/consumer_adapter_contract.rs +++ b/programs/amm/client/tests/consumer_adapter_contract.rs @@ -5,7 +5,7 @@ use amm_client::{ Q64_64_ONE, }; use amm_core::canonical_token_pair; -use common::program_id_hex; +use common::program_id_words; use nssa_core::account::AccountId; use serde_json::json; @@ -41,7 +41,7 @@ fn raw_sequencer_response_becomes_lossless_snapshot() { assert_eq!(wire["id"], account_id.to_string()); assert_eq!( wire["programOwner"], - program_id_hex([1, 2, 3, 4, 5, 6, 7, 8]) + 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"); diff --git a/programs/amm/client/tests/ffi_contract.rs b/programs/amm/client/tests/ffi_contract.rs index 72a31ee..f59c474 100644 --- a/programs/amm/client/tests/ffi_contract.rs +++ b/programs/amm/client/tests/ffi_contract.rs @@ -12,7 +12,7 @@ use amm_core::{ compute_config_pda, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda, AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, }; -use common::program_id_hex; +use common::program_id_words; use nssa_core::{ account::{Account, AccountId, Data, Nonce}, program::ProgramId, @@ -48,7 +48,7 @@ fn call_json(operation: Operation, request: &Value) -> Value { fn snapshot(id: AccountId, account: &Account) -> Value { json!({ "id": id.to_string(), - "programOwner": program_id_hex(account.program_owner), + "programOwner": program_id_words(account.program_owner), "balance": account.balance.to_string(), "nonce": account.nonce.0.to_string(), "data": hex(account.data.as_ref()), @@ -151,13 +151,14 @@ fn protocol_constants_are_exposed_without_numeric_json_values() { } #[test] -fn program_ids_require_canonical_lowercase_hex_strings() { - let canonical = program_id_hex([0xabcdef01; 8]); +fn program_ids_require_exactly_eight_u32_words() { + let canonical = program_id_words([0xabcdef01; 8]); let authority = AccountId::new([44; 32]).to_string(); for invalid in [ - json!([1, 1, 1, 1, 1, 1, 1, 1]), - json!(canonical.to_uppercase()), + json!("0000000000000000000000000000000000000000000000000000000000000000"), + json!([1, 1, 1, 1, 1, 1, 1]), + json!([1, 1, 1, 1, 1, 1, 1, 4_294_967_296_u64]), ] { let response = call_json( amm_client_plan, @@ -188,9 +189,9 @@ fn successful_plan_preserves_u64_above_javascript_range_in_guest_words() { &json!({ "operation": "create_price_observations", "context": { - "ammProgramId": program_id_hex(amm_program_id), - "tokenProgramId": program_id_hex(token_program_id), - "twapOracleProgramId": program_id_hex(twap_oracle_program_id), + "ammProgramId": program_id_words(amm_program_id), + "tokenProgramId": program_id_words(token_program_id), + "twapOracleProgramId": program_id_words(twap_oracle_program_id), "authority": authority.to_string(), }, "poolId": pool_id.to_string(), @@ -205,7 +206,7 @@ fn successful_plan_preserves_u64_above_javascript_range_in_guest_words() { ); assert_eq!( response["value"]["programId"], - program_id_hex(amm_program_id) + json!(program_id_words(amm_program_id)) ); assert!(response["value"]["accounts"].is_array()); let words: Vec = serde_json::from_value(response["value"]["instructionWords"].clone()) @@ -263,7 +264,7 @@ fn successful_quote_preserves_u128_above_javascript_range_as_decimal() { amm_client_quote, &json!({ "operation": "create_pool", - "ammProgramId": program_id_hex(amm_program_id), + "ammProgramId": program_id_words(amm_program_id), "config": snapshot(compute_config_pda(amm_program_id), &config_account), "tokenADefinition": snapshot(token_a_id, &definition("A")), "tokenBDefinition": snapshot(token_b_id, &definition("B")), @@ -289,7 +290,7 @@ fn successful_quote_preserves_u128_above_javascript_range_as_decimal() { amm_client_quote, &json!({ "operation": "prepare_create_pool", - "ammProgramId": program_id_hex(amm_program_id), + "ammProgramId": program_id_words(amm_program_id), "config": snapshot(compute_config_pda(amm_program_id), &config_account), "tokenADefinition": snapshot(token_a_id, &definition("A")), "tokenBDefinition": snapshot(token_b_id, &definition("B")), @@ -342,7 +343,7 @@ fn swap_quote_rejects_unrelated_output_holding() { amm_client_quote, &json!({ "operation": "preview_swap_exact_input", - "ammProgramId": program_id_hex(amm_program_id), + "ammProgramId": program_id_words(amm_program_id), "config": snapshot( compute_config_pda(amm_program_id), &account(amm_program_id, Data::from(&config)), diff --git a/programs/amm/client/tests/transaction_contract.rs b/programs/amm/client/tests/transaction_contract.rs index 0874efb..0df5021 100644 --- a/programs/amm/client/tests/transaction_contract.rs +++ b/programs/amm/client/tests/transaction_contract.rs @@ -481,7 +481,7 @@ fn exact_output_requires_funding_through_its_maximum_input_guard() { } #[test] -fn commitment_is_stable_and_changes_with_bound_snapshot_or_deadline() { +fn commitment_is_stable_across_deadline_refresh_and_changes_with_bound_snapshot() { let fixture = Fixture::new(); let missing = MissingPairFixture::new(&fixture); let fresh_lp = AccountSnapshot::new(AccountId::new([30; 32]), Account::default()); @@ -522,10 +522,14 @@ fn commitment_is_stable_and_changes_with_bound_snapshot_or_deadline() { )); let changed_deadline = prepare(&fixture.caller_first_holding, DEADLINE + 1); - assert_ne!( + assert_eq!( first.quote_commitment(), changed_deadline.quote_commitment() ); + assert_ne!( + first.plan().instruction_data(), + changed_deadline.plan().instruction_data() + ); } #[test] diff --git a/programs/amm/client/tests/wire_discovery_intent_contract.rs b/programs/amm/client/tests/wire_discovery_intent_contract.rs index 0420a44..d8e4ae2 100644 --- a/programs/amm/client/tests/wire_discovery_intent_contract.rs +++ b/programs/amm/client/tests/wire_discovery_intent_contract.rs @@ -6,7 +6,7 @@ use amm_core::{ compute_vault_pda, AmmConfig, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, }; use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; -use common::program_id_hex; +use common::program_id_words; use nssa_core::{ account::{Account, AccountId, Data, Nonce}, program::ProgramId, @@ -111,7 +111,7 @@ impl PairIds { fn inspect_request(&self, snapshots: Value) -> Value { json!({ "operation": "inspect_pair", - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": config_snapshot(), "firstTokenDefinitionId": self.first_token_id.to_string(), "secondTokenDefinitionId": self.second_token_id.to_string(), @@ -205,7 +205,7 @@ impl PairIds { fn snapshot(id: AccountId, account: &Account) -> Value { json!({ "id": id.to_string(), - "programOwner": program_id_hex(account.program_owner), + "programOwner": program_id_words(account.program_owner), "balance": account.balance.to_string(), "nonce": account.nonce.0.to_string(), "data": account @@ -232,7 +232,7 @@ fn discovery_operations_return_exact_string_account_ids() { let config_id = quote_json(json!({ "operation": "derive_config_id", - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), })) .expect("legacy schema-less request remains accepted"); assert_eq!(config_id["schema"], WIRE_SCHEMA); @@ -245,19 +245,22 @@ fn discovery_operations_return_exact_string_account_ids() { let inspected = quote_json(json!({ "schema": WIRE_SCHEMA, "operation": "inspect_config", - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": config.clone(), })) .expect("config must inspect"); assert_eq!(inspected["schema"], WIRE_SCHEMA); - assert_eq!(inspected["ammProgramId"], program_id_hex(AMM_PROGRAM_ID)); + assert_eq!( + inspected["ammProgramId"], + json!(program_id_words(AMM_PROGRAM_ID)) + ); assert_eq!( inspected["tokenProgramId"], - program_id_hex(TOKEN_PROGRAM_ID) + json!(program_id_words(TOKEN_PROGRAM_ID)) ); assert_eq!( inspected["twapOracleProgramId"], - program_id_hex(TWAP_ORACLE_PROGRAM_ID) + json!(program_id_words(TWAP_ORACLE_PROGRAM_ID)) ); assert_eq!(inspected["authority"], AccountId::new([9; 32]).to_string()); @@ -272,7 +275,7 @@ fn discovery_operations_return_exact_string_account_ids() { let manifest = quote_json(json!({ "operation": "derive_pair_read_manifest", - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": config, "firstTokenDefinitionId": first_token_id.to_string(), "secondTokenDefinitionId": second_token_id.to_string(), diff --git a/programs/amm/client/tests/wire_prepare_contract.rs b/programs/amm/client/tests/wire_prepare_contract.rs index f4d2e68..a5e84f9 100644 --- a/programs/amm/client/tests/wire_prepare_contract.rs +++ b/programs/amm/client/tests/wire_prepare_contract.rs @@ -5,7 +5,7 @@ use amm_core::{ compute_config_pda, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda, AmmConfig, PoolDefinition, FEE_TIER_BPS_30, }; -use common::program_id_hex; +use common::program_id_words; use nssa_core::{ account::{Account, AccountId, Data, Nonce}, program::ProgramId, @@ -29,7 +29,7 @@ fn account(program_owner: ProgramId, data: Data) -> Account { fn snapshot(id: AccountId, account: &Account) -> Value { json!({ "id": id.to_string(), - "programOwner": program_id_hex(account.program_owner), + "programOwner": program_id_words(account.program_owner), "balance": account.balance.to_string(), "nonce": account.nonce.0.to_string(), "data": account @@ -102,7 +102,7 @@ impl WireFixture { fees: FEE_TIER_BPS_30, }; let state = json!({ - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": config, "snapshot": { "pool": snapshot(pool_id, &account(AMM_PROGRAM_ID, Data::from(&pool))), @@ -164,7 +164,7 @@ fn prepare_wire_operations_return_lossless_instruction_args() { let create = quote_json(json!({ "operation": "prepare_create_pool", - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": fixture.config.clone(), "tokenADefinition": snapshot(fixture.token_a_id, &definition(100_000, None)), "tokenBDefinition": snapshot(fixture.token_b_id, &definition(100_000, None)), @@ -264,6 +264,159 @@ fn prepare_wire_operations_return_lossless_instruction_args() { ); } +#[test] +fn standalone_swap_quotes_include_exact_pool_spot_change() { + let fixture = WireFixture::new(); + let input_holding = fixture.user_a.clone(); + let output_holding = fixture.user_b.clone(); + let input_token_definition_id = fixture.token_a_id.to_string(); + + let mut preview_exact_input = fixture.request("preview_swap_exact_input"); + insert( + &mut preview_exact_input, + "userInputHolding", + input_holding.clone(), + ); + insert( + &mut preview_exact_input, + "userOutputHolding", + output_holding.clone(), + ); + insert( + &mut preview_exact_input, + "inputTokenDefinitionId", + json!(input_token_definition_id.clone()), + ); + insert(&mut preview_exact_input, "amountIn", json!("100")); + let preview_exact_input = quote_json(preview_exact_input).expect("preview must quote"); + + let mut prepare_exact_input = fixture.request("prepare_swap_exact_input"); + insert( + &mut prepare_exact_input, + "userInputHolding", + input_holding.clone(), + ); + insert( + &mut prepare_exact_input, + "userOutputHolding", + output_holding.clone(), + ); + insert( + &mut prepare_exact_input, + "inputTokenDefinitionId", + json!(input_token_definition_id.clone()), + ); + insert(&mut prepare_exact_input, "amountIn", json!("100")); + insert(&mut prepare_exact_input, "slippageBps", json!("100")); + let prepare_exact_input = quote_json(prepare_exact_input).expect("preparation must quote"); + + let mut exact_input = fixture.request("swap_exact_input"); + insert(&mut exact_input, "userInputHolding", input_holding.clone()); + insert( + &mut exact_input, + "userOutputHolding", + output_holding.clone(), + ); + insert( + &mut exact_input, + "inputTokenDefinitionId", + json!(input_token_definition_id.clone()), + ); + insert(&mut exact_input, "amountIn", json!("100")); + insert(&mut exact_input, "minimumAmountOut", json!("1")); + let exact_input = quote_json(exact_input).expect("exact-input quote must succeed"); + + let mut preview_exact_output = fixture.request("preview_swap_exact_output"); + insert( + &mut preview_exact_output, + "userInputHolding", + input_holding.clone(), + ); + insert( + &mut preview_exact_output, + "userOutputHolding", + output_holding.clone(), + ); + insert( + &mut preview_exact_output, + "inputTokenDefinitionId", + json!(input_token_definition_id.clone()), + ); + insert(&mut preview_exact_output, "exactAmountOut", json!("45")); + let preview_exact_output = quote_json(preview_exact_output).expect("preview must quote"); + + let mut prepare_exact_output = fixture.request("prepare_swap_exact_output"); + insert( + &mut prepare_exact_output, + "userInputHolding", + input_holding.clone(), + ); + insert( + &mut prepare_exact_output, + "userOutputHolding", + output_holding.clone(), + ); + insert( + &mut prepare_exact_output, + "inputTokenDefinitionId", + json!(input_token_definition_id.clone()), + ); + insert(&mut prepare_exact_output, "exactAmountOut", json!("45")); + insert(&mut prepare_exact_output, "slippageBps", json!("100")); + let prepare_exact_output = + quote_json(prepare_exact_output).expect("preparation must quote exact output"); + + let mut exact_output = fixture.request("swap_exact_output"); + insert(&mut exact_output, "userInputHolding", input_holding); + insert(&mut exact_output, "userOutputHolding", output_holding); + insert( + &mut exact_output, + "inputTokenDefinitionId", + json!(input_token_definition_id), + ); + insert(&mut exact_output, "exactAmountOut", json!("45")); + insert(&mut exact_output, "maximumAmountIn", json!("100")); + let exact_output = quote_json(exact_output).expect("exact-output quote must succeed"); + + let top_level_results = [ + &preview_exact_input, + &exact_input, + &preview_exact_output, + &exact_output, + ]; + for result in top_level_results { + assert_eq!(result["poolSpotChangeBps"], "2087"); + } + for result in [&prepare_exact_input, &prepare_exact_output] { + assert_eq!(result["quote"]["poolSpotChangeBps"], "2087"); + } + + let large_amount = 1_u128 << 80; + let mut large_input = fixture.request("preview_swap_exact_input"); + insert( + &mut large_input, + "userInputHolding", + snapshot( + AccountId::new([20; 32]), + &holding(fixture.token_a_id, large_amount), + ), + ); + insert(&mut large_input, "userOutputHolding", fixture.user_b); + insert( + &mut large_input, + "inputTokenDefinitionId", + json!(fixture.token_a_id.to_string()), + ); + insert( + &mut large_input, + "amountIn", + json!(large_amount.to_string()), + ); + let large_input = quote_json(large_input).expect("large preview must quote"); + let large_movement = decimal(&large_input["poolSpotChangeBps"]); + assert!(large_movement > (1_u128 << 53)); +} + #[test] fn prepare_wire_rejects_out_of_range_slippage() { let fixture = WireFixture::new(); diff --git a/programs/amm/client/tests/wire_transaction_contract.rs b/programs/amm/client/tests/wire_transaction_contract.rs index f89020f..5b743cc 100644 --- a/programs/amm/client/tests/wire_transaction_contract.rs +++ b/programs/amm/client/tests/wire_transaction_contract.rs @@ -6,7 +6,7 @@ use amm_core::{ compute_vault_pda, AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, }; use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; -use common::program_id_hex; +use common::program_id_words; use nssa_core::{ account::{Account, AccountId, Data, Nonce}, program::ProgramId, @@ -55,7 +55,7 @@ fn holding(definition_id: AccountId, balance: u128) -> Account { fn snapshot(id: AccountId, account: &Account) -> Value { json!({ "id": id.to_string(), - "programOwner": program_id_hex(account.program_owner), + "programOwner": program_id_words(account.program_owner), "balance": account.balance.to_string(), "nonce": account.nonce.0.to_string(), "data": account @@ -212,7 +212,7 @@ impl TransactionFixture { fn active_common(&self, operation: &str) -> Value { json!({ "operation": operation, - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": self.config.clone(), "snapshots": self.active_snapshots.clone(), "firstTokenDefinitionId": self.first_token_id.to_string(), @@ -229,7 +229,7 @@ impl TransactionFixture { fn swap_common(&self, operation: &str) -> Value { json!({ "operation": operation, - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": self.config.clone(), "snapshots": self.active_snapshots.clone(), "inputTokenDefinitionId": self.first_token_id.to_string(), @@ -326,7 +326,7 @@ fn five_transaction_operations_emit_exact_plans_and_task_artifacts() { let second_amount = LARGE.checked_mul(2).expect("test amount fits"); let create = plan_json(json!({ "operation": "prepare_create_pool_transaction", - "ammProgramId": program_id_hex(AMM_PROGRAM_ID), + "ammProgramId": program_id_words(AMM_PROGRAM_ID), "config": fixture.config.clone(), "snapshots": fixture.missing_snapshots.clone(), "firstTokenDefinitionId": fixture.first_token_id.to_string(),