diff --git a/Cargo.lock b/Cargo.lock index 39550c0..3b27e85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,7 @@ dependencies = [ name = "amm_client" version = "0.1.0" dependencies = [ + "alloy-primitives", "amm_core", "amm_program", "clock_core", diff --git a/programs/amm/client/Cargo.toml b/programs/amm/client/Cargo.toml index 0c8097a..f7b13eb 100644 --- a/programs/amm/client/Cargo.toml +++ b/programs/amm/client/Cargo.toml @@ -10,6 +10,7 @@ crate-type = ["cdylib", "rlib"] workspace = true [dependencies] +alloy-primitives = { version = "1", default-features = false } amm_core = { path = "../core" } amm_program = { path = ".." } clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0" } diff --git a/programs/amm/client/README.md b/programs/amm/client/README.md index ab58b8a..e5b1958 100644 --- a/programs/amm/client/README.md +++ b/programs/amm/client/README.md @@ -12,10 +12,16 @@ adapter responsibilities. - `quote` validates fetched config, pool, vault, token-definition, LP-definition, and user-holding snapshots before delegating calculations to `amm_program::quote`. +- `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. - `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 account roles and writable, signer, and init flags. +- `transaction` binds complete snapshots, canonical quotes, exact plans, caller amounts, wallet + prerequisites, and a refreshable quote commitment for create/add/remove/swap tasks. - `TransactionPlan::instruction_data` serializes its `amm_core::Instruction` with `risc0_zkvm::serde::to_vec`. - `wire` exposes lossless JSON adapters for non-Rust hosts. @@ -41,8 +47,8 @@ oracle-price initialization. `prepare_create_pool`, `prepare_add_liquidity`, `prepare_remove_liquidity`, `prepare_swap_exact_input`, and `prepare_swap_exact_output` return a quote plus the exact amount fields to pass to the corresponding planner. Consumers choose a slippage tolerance in basis points but do not calculate chain guards. Prepared add-liquidity maxima -use the quote's actual deposits, so execution cannot spend above the displayed/current quote even -when the caller supplied a lopsided pair of caps. +preserve caller caps because substituting rounded actual deposits can change execution's +proportional integer quote. The task-level transaction API validates funding against those caps. ## Compatibility assumption @@ -68,4 +74,6 @@ Every call returns an owned JSON envelope. Release it exactly once with `amm_cli Raw `u128` and `u64` values cross JSON as decimal strings. Account IDs use their canonical base58 display form, program IDs use eight JSON `u32` words, account data uses hexadecimal, and encoded instruction words remain JSON `u32` numbers. No JavaScript `Number` conversion is required for -chain amounts or deadlines. +chain amounts or deadlines. Plan JSON also includes typed `instructionArgs`, derived directly from +the same `amm_core::Instruction` encoded in `instructionWords`. Both C entrypoints accept the five +snapshot-bound `prepare_*_transaction` operations. diff --git a/programs/amm/client/docs/wire-api.md b/programs/amm/client/docs/wire-api.md index 46b9e37..8eef669 100644 --- a/programs/amm/client/docs/wire-api.md +++ b/programs/amm/client/docs/wire-api.md @@ -3,16 +3,20 @@ The C ABI accepts one tagged JSON object and returns one envelope: ```json -{"ok":true,"value":{}} +{"schema":"amm-client.v1","ok":true,"value":{"schema":"amm-client.v1"}} ``` ```json -{"ok":false,"error":{"code":"invalid_request","message":"..."}} +{"schema":"amm-client.v1","ok":false,"error":{"code":"invalid_request","message":"..."}} ``` +Requests may include `"schema":"amm-client.v1"`. Schema-less requests remain accepted for +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 arrays of eight `u32` -words. Account IDs are base58 strings. Account `data` is an even-length hexadecimal string. +words. Signed ticks are decimal strings. Account IDs are base58 strings. Account `data` is an +even-length hexadecimal string. ## Shared inputs @@ -73,6 +77,24 @@ Existing-pool quote operations include these top-level state fields: } ``` +Discovery and task-transaction operations use the complete caller-ordered pair read set: + +```json +{ + "snapshots": { + "pool": { "...": "account snapshot" }, + "firstTokenDefinition": { "...": "account snapshot" }, + "secondTokenDefinition": { "...": "account snapshot" }, + "firstTokenVault": { "...": "account snapshot" }, + "secondTokenVault": { "...": "account snapshot" }, + "liquidityDefinition": { "...": "account snapshot" }, + "lpLockHolding": { "...": "account snapshot" }, + "currentTick": { "...": "account snapshot" }, + "clock": { "...": "account snapshot" } + } +} +``` + ## Plan operations Send requests to `amm_client_plan` or `wire::plan_json`. @@ -89,12 +111,23 @@ Send requests to `amm_client_plan` or `wire::plan_json`. | `swap_exact_input` | `context`, `pool`, `userInputHolding`, `userOutputHolding`, `swapAmountIn`, `minAmountOut`, `deadline` | | `swap_exact_output` | `context`, `pool`, `userInputHolding`, `userOutputHolding`, `exactAmountOut`, `maxAmountIn`, `deadline` | | `sync_reserves` | `context`, `pool` | +| `prepare_create_pool_transaction` | same task request documented under Task transactions | +| `prepare_add_liquidity_transaction` | same task request documented under Task transactions | +| `prepare_remove_liquidity_transaction` | same task request documented under Task transactions | +| `prepare_swap_exact_input_transaction` | same task request documented under Task transactions | +| `prepare_swap_exact_output_transaction` | same task request documented under Task transactions | A successful plan value contains the following fields (`instructionWords` is abbreviated here): ```json { "instruction": "add_liquidity", + "instructionArgs": { + "minAmountLiquidity": "99", + "maxAmountToAddTokenA": "400", + "maxAmountToAddTokenB": "100", + "deadline": "1900000000000" + }, "programId": [0, 0, 0, 0, 0, 0, 0, 0], "accounts": [ { @@ -105,22 +138,36 @@ A successful plan value contains the following fields (`instructionWords` is abb "init": false } ], + "affectedAccountIds": ["base58-account-id"], "instructionWords": [5] } ``` The real `instructionWords` array contains the complete encoding produced directly from the -canonical `amm_core::Instruction` with RISC Zero Serde. Account rows follow guest/IDL order. +canonical `amm_core::Instruction` with RISC Zero Serde. `instructionArgs` is exhaustively derived +from that same typed instruction, so C++/QML consumers do not decode RISC Zero Serde. Its `u128` +and `u64` fields are decimal strings, optional fields are JSON `null`, and account IDs are base58 +strings. Account rows follow guest/IDL order. ## Quote operations -Send requests to `amm_client_quote` or `wire::quote_json`. Except `protocol_constants`, -`create_pool`, and `prepare_create_pool`, every operation below also includes the existing-pool -quote state described above. +Send requests to `amm_client_quote` or `wire::quote_json`. Pool economic operations use the +existing-pool quote state described above. Discovery, opening intent, and task-transaction +operations use the fields shown in this table and the sections below. | `operation` | Additional fields | |---|---| | `protocol_constants` | none; returns decimal-string `minimumLiquidity`, `feeBpsDenominator`, `slippageBpsDenominator`, and `supportedFeeTiers` | +| `derive_config_id` | `ammProgramId` | +| `inspect_config` | `ammProgramId`, raw `config` snapshot | +| `canonical_pair` | `firstTokenDefinitionId`, `secondTokenDefinitionId` | +| `derive_pair_read_manifest` | `ammProgramId`, raw `config`, `firstTokenDefinitionId`, `secondTokenDefinitionId` | +| `inspect_pair` | fields from `derive_pair_read_manifest` plus complete `snapshots` | +| `prepare_minimum_opening_pair` | `desiredPriceQ64_64`, `feeBps` | +| `prepare_opening_from_token_a` | `tokenAAmount`, `desiredPriceQ64_64`, `feeBps` | +| `prepare_opening_from_token_b` | `tokenBAmount`, `desiredPriceQ64_64`, `feeBps` | +| `validate_explicit_opening_pair` | `tokenAAmount`, `tokenBAmount`, `desiredPriceQ64_64`, `feeBps` | +| `prepare_caller_opening_pair` | caller token IDs, desired price, fee, and tagged `intent` described below | | `pair_order` | `firstTokenDefinitionId`, `secondTokenDefinitionId` | | `create_pool` | `ammProgramId`, `config`, `tokenADefinition`, `tokenBDefinition`, `tokenAAmount`, `tokenBAmount`, `feeBps` | | `prepare_create_pool` | same fields as `create_pool`; returns quote plus `NewDefinition` instruction arguments | @@ -138,6 +185,11 @@ quote state described above. | `swap_exact_output` | `userInputHolding`, `userOutputHolding`, `inputTokenDefinitionId`, `exactAmountOut`, `maximumAmountIn` | | `sync_reserves` | no additional fields | | `create_oracle_price_account` | `windowDuration` | +| `prepare_create_pool_transaction` | task-transaction fields below | +| `prepare_add_liquidity_transaction` | task-transaction fields below | +| `prepare_remove_liquidity_transaction` | task-transaction fields below | +| `prepare_swap_exact_input_transaction` | task-transaction fields below | +| `prepare_swap_exact_output_transaction` | task-transaction fields below | Quote values use these result shapes: @@ -152,6 +204,86 @@ Quote values use these result shapes: A `pool` result contains decimal-string `liquidityPoolSupply`, `reserveA`, `reserveB`, and `spotPriceQ64_64` fields. +## Discovery, inspection, and opening intents + +Discovery functions derive IDs only; adapters fetch the returned accounts and submit raw +snapshots for inspection. `inspect_pair` returns `status` as `missing` or `active`. Missing output +contains the read manifest, caller-ordered definitions, vault lifecycle states, and clock. Active +output contains the manifest, `callerOrder`, stored token/vault/LP IDs, reserves, vault balances, +LP supply, fee, stored Q64.64 spot price, current tick, and clock. Numeric protocol fields remain +strings. + +`prepare_caller_opening_pair` accepts caller token order without reproducing canonical ordering: + +```json +{ + "operation": "prepare_caller_opening_pair", + "firstTokenDefinitionId": "base58-account-id", + "secondTokenDefinitionId": "base58-account-id", + "desiredPriceQ64_64": "18446744073709551616", + "feeBps": "30", + "intent": { "kind": "first_amount", "amount": "2000" } +} +``` + +Other intent shapes are `{ "kind":"minimum" }`, +`{ "kind":"second_amount", "amount":"..." }`, and +`{ "kind":"explicit", "firstAmount":"...", "secondAmount":"..." }`. The result includes +`callerOrder`, caller `firstAmount`/`secondAmount`, and the canonical stored opening quote and +amounts. + +## Task transactions + +The five snapshot-bound task operations are accepted by both `amm_client_plan`/`wire::plan_json` +and `amm_client_quote`/`wire::quote_json`. Every request includes `ammProgramId`, raw `config`, the +complete caller-ordered `snapshots`, and decimal-string `deadline`. + +| `operation` | Additional fields | +|---|---| +| `prepare_create_pool_transaction` | caller token IDs, `firstTokenHolding`, `secondTokenHolding`, `liquidityHolding`, `firstAmount`, `secondAmount`, `feeBps` | +| `prepare_add_liquidity_transaction` | caller token IDs and holdings, `maxFirstAmount`, `maxSecondAmount`, `slippageBps`, optional `expectedFeeBps` | +| `prepare_remove_liquidity_transaction` | caller token IDs and holdings, `removeLiquidityAmount`, `slippageBps`, optional `expectedFeeBps` | +| `prepare_swap_exact_input_transaction` | input/output token IDs and holdings, `amountIn`, `slippageBps`, optional `expectedFeeBps` | +| `prepare_swap_exact_output_transaction` | input/output token IDs and holdings, `exactAmountOut`, `slippageBps`, optional `expectedFeeBps` | + +Successful task output contains: + +```json +{ + "operation": "swap_exact_output", + "quote": {}, + "callerAmounts": { "first": "101", "second": "100" }, + "plan": { + "instruction": "swap_exact_output", + "instructionArgs": { + "exactAmountOut": "100", + "maxAmountIn": "102", + "deadline": "1900000000000" + }, + "instructionWords": [] + }, + "quoteCommitment": "64-lowercase-hex-characters", + "affectedAccountIds": ["base58-account-id"], + "walletPrerequisites": { + "signerAccountIds": ["base58-account-id"], + "freshAccountIds": [], + "funding": [{ + "holdingAccountId": "base58-account-id", + "tokenDefinitionId": "base58-account-id", + "available": "1000", + "required": "102" + }] + }, + "deadline": "1900000000000", + "poolSpotChangeBps": "42" +} +``` + +`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. + ## Prepared instruction arguments The five `prepare_*` operations return the economic result under `quote` and decimal-string chain @@ -171,9 +303,10 @@ quotes. Maximum guards use integer ceil rounding. A maximum above `u128` returns `slippage_bound_overflow`; an out-of-range tolerance returns `slippage_tolerance_out_of_range`. This calculation runs only in the Rust client, never in JavaScript or QML. -Prepared add-liquidity maximums are the quote's `actualAmountA` and `actualAmountB`, not the original -possibly lopsided caps. The exact quote is rerun with those fields before they are returned. This -keeps the eventual plan from spending above the displayed/current quoted deposits. +Prepared add-liquidity maximums preserve the original caller caps. Replacing them with rounded +`actualAmountA` and `actualAmountB` can change the program quote when reserve ratios are not +divisible, because execution performs proportional integer rounding again. Funding prerequisites +therefore cover the caller caps while display amounts remain the canonical quote's actual deposit. ## Ownership and failures diff --git a/programs/amm/client/include/amm_client.h b/programs/amm/client/include/amm_client.h index 24dc84c..4dc680a 100644 --- a/programs/amm/client/include/amm_client.h +++ b/programs/amm/client/include/amm_client.h @@ -9,28 +9,32 @@ extern "C" { * Accepts a tagged UTF-8 JSON request and returns an owned UTF-8 JSON envelope. * Supported operation tags: initialize, update_config, create_price_observations, * create_oracle_price_account, create_pool, add_liquidity, remove_liquidity, - * swap_exact_input, swap_exact_output, and sync_reserves. + * swap_exact_input, swap_exact_output, sync_reserves, and the five + * prepare_*_transaction task operations documented in docs/wire-api.md. * Release the result with amm_client_free. */ 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: protocol_constants, pair_order, create_pool, - * prepare_create_pool, preview_add_liquidity, prepare_add_liquidity, add_liquidity, - * preview_remove_liquidity, prepare_remove_liquidity, remove_liquidity, - * preview_swap_exact_input, prepare_swap_exact_input, swap_exact_input, - * preview_swap_exact_output, prepare_swap_exact_output, swap_exact_output, - * sync_reserves, and create_oracle_price_account. + * Supported operation tags include protocol constants; config and pair discovery; + * pair inspection; caller-order opening preparation; economic quote/preparation + * operations; and prepare_create_pool_transaction, + * prepare_add_liquidity_transaction, prepare_remove_liquidity_transaction, + * prepare_swap_exact_input_transaction, and + * prepare_swap_exact_output_transaction. See docs/wire-api.md for fields. * Release the result with amm_client_free. */ char *amm_client_quote(const char *request_json); /* - * Raw u128 and u64 values are unsigned decimal JSON strings. Program IDs and - * instruction words are JSON u32 arrays. Account IDs are base58 strings and - * account data is hexadecimal. Responses use {"ok":true,"value":...} or - * {"ok":false,"error":{"code":...,"message":...}}. + * Raw u128, u64, and signed tick values are decimal JSON strings. Program IDs + * and instruction words are JSON u32 arrays. Account IDs are base58 strings 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 + * with ok=false and error={"code":...,"message":...}. Plan values contain + * typed instructionArgs as well as exact RISC Zero instructionWords. */ /* diff --git a/programs/amm/client/src/discovery.rs b/programs/amm/client/src/discovery.rs new file mode 100644 index 0000000..c7269f8 --- /dev/null +++ b/programs/amm/client/src/discovery.rs @@ -0,0 +1,628 @@ +//! Deterministic AMM account discovery and pair lifecycle inspection. +//! +//! These functions derive the complete protocol read set, then validate caller-supplied snapshots. +//! They perform no RPC, signing, submission, or deployed-program compatibility lookup. + +use amm_core::{ + canonical_token_pair, compute_config_pda, compute_liquidity_token_pda, + compute_lp_lock_holding_pda, compute_pool_pda, compute_vault_pda, spot_price_q64_64, + MINIMUM_LIQUIDITY, +}; +use amm_program::quote as program_quote; +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; +use nssa_core::{ + account::{Account, AccountId}, + program::ProgramId, +}; +use token_core::TokenHolding; +use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount}; + +use crate::{ + plan::AmmContext, + quote::{ + AccountSnapshot, ValidatedFungibleDefinition, ValidatedFungibleHolding, + ValidatedPoolSnapshot, + }, + ClientError, +}; + +/// Deterministic pre-pool token order used by AMM pool PDA derivation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CanonicalPair { + token_a_id: AccountId, + token_b_id: AccountId, +} + +impl CanonicalPair { + /// Returns canonical token A, whose raw account-ID bytes sort after token B. + #[must_use] + pub const fn token_a_id(&self) -> AccountId { + self.token_a_id + } + + /// Returns canonical token B. + #[must_use] + pub const fn token_b_id(&self) -> AccountId { + self.token_b_id + } +} + +/// One caller-named token definition and its deterministic pool vault. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TokenReadManifest { + definition_id: AccountId, + vault_id: AccountId, +} + +impl TokenReadManifest { + /// Returns the token definition account ID. + #[must_use] + pub const fn definition_id(&self) -> AccountId { + self.definition_id + } + + /// Returns the pool vault derived for this token definition. + #[must_use] + pub const fn vault_id(&self) -> AccountId { + self.vault_id + } +} + +/// Complete deterministic account read set for inspecting a token pair. +/// +/// `first_token` and `second_token` preserve caller order. Their vaults are therefore named by +/// token rather than by stored pool A/B order, which is unavailable until the pool is decoded. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PairReadManifest { + canonical_pair: CanonicalPair, + first_token: TokenReadManifest, + second_token: TokenReadManifest, + config_id: AccountId, + pool_id: AccountId, + liquidity_definition_id: AccountId, + lp_lock_holding_id: AccountId, + current_tick_id: AccountId, + clock_id: AccountId, +} + +impl PairReadManifest { + /// Returns deterministic pre-pool token order. + #[must_use] + pub const fn canonical_pair(&self) -> CanonicalPair { + self.canonical_pair + } + + /// Returns caller's first token and its derived vault. + #[must_use] + pub const fn first_token(&self) -> TokenReadManifest { + self.first_token + } + + /// Returns caller's second token and its derived vault. + #[must_use] + pub const fn second_token(&self) -> TokenReadManifest { + self.second_token + } + + /// Returns singleton AMM config account ID. + #[must_use] + pub const fn config_id(&self) -> AccountId { + self.config_id + } + + /// Returns pair pool account ID. + #[must_use] + pub const fn pool_id(&self) -> AccountId { + self.pool_id + } + + /// Returns deterministic LP token definition account ID. + #[must_use] + pub const fn liquidity_definition_id(&self) -> AccountId { + self.liquidity_definition_id + } + + /// Returns deterministic permanently locked LP holding account ID. + #[must_use] + pub const fn lp_lock_holding_id(&self) -> AccountId { + self.lp_lock_holding_id + } + + /// Returns pool's TWAP current-tick account ID. + #[must_use] + pub const fn current_tick_id(&self) -> AccountId { + self.current_tick_id + } + + /// Returns canonical one-block clock account ID. + #[must_use] + pub const fn clock_id(&self) -> AccountId { + self.clock_id + } + + /// Looks up a derived vault by token definition ID. + #[must_use] + pub fn vault_id_for(&self, definition_id: AccountId) -> Option { + if definition_id == self.first_token.definition_id { + Some(self.first_token.vault_id) + } else if definition_id == self.second_token.definition_id { + Some(self.second_token.vault_id) + } else { + None + } + } +} + +/// Snapshots fetched from a [`PairReadManifest`]. +#[derive(Clone, Copy)] +pub struct PairReadSnapshots<'a> { + pub pool: &'a AccountSnapshot, + pub first_token_definition: &'a AccountSnapshot, + pub second_token_definition: &'a AccountSnapshot, + pub first_token_vault: &'a AccountSnapshot, + pub second_token_vault: &'a AccountSnapshot, + pub liquidity_definition: &'a AccountSnapshot, + pub lp_lock_holding: &'a AccountSnapshot, + pub current_tick: &'a AccountSnapshot, + pub clock: &'a AccountSnapshot, +} + +/// Validated canonical clock values used by current AMM instructions. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ValidatedClockSnapshot { + block_id: u64, + timestamp: u64, +} + +impl ValidatedClockSnapshot { + #[must_use] + pub const fn block_id(&self) -> u64 { + self.block_id + } + + #[must_use] + pub const fn timestamp(&self) -> u64 { + self.timestamp + } +} + +/// State of a derived vault before its pool exists. +/// +/// Pool creation's chained Token Program transfer accepts either a default destination or an +/// existing fungible holding for the same definition. It does not require every derived vault to +/// be default merely because the pool account is default. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MissingVaultState { + Uninitialized, + ExistingFungible { balance: u128 }, +} + +/// Validated view of a pair whose pool account is still uninitialized. +#[derive(Clone)] +pub struct MissingPairInspection { + manifest: PairReadManifest, + first_token_definition: ValidatedFungibleDefinition, + second_token_definition: ValidatedFungibleDefinition, + first_vault: MissingVaultState, + second_vault: MissingVaultState, + clock: ValidatedClockSnapshot, +} + +impl MissingPairInspection { + #[must_use] + pub const fn manifest(&self) -> PairReadManifest { + self.manifest + } + + #[must_use] + pub const fn first_token_definition(&self) -> &ValidatedFungibleDefinition { + &self.first_token_definition + } + + #[must_use] + pub const fn second_token_definition(&self) -> &ValidatedFungibleDefinition { + &self.second_token_definition + } + + #[must_use] + pub const fn first_vault(&self) -> MissingVaultState { + self.first_vault + } + + #[must_use] + pub const fn second_vault(&self) -> MissingVaultState { + self.second_vault + } + + #[must_use] + pub const fn clock(&self) -> ValidatedClockSnapshot { + self.clock + } +} + +/// Validated current view of an initialized pair. +#[derive(Clone)] +pub struct ActivePairInspection { + manifest: PairReadManifest, + caller_order: program_quote::PairOrder, + pool: ValidatedPoolSnapshot, + lp_lock_holding: ValidatedFungibleHolding, + stored_spot_price_q64_64: u128, + current_tick: CurrentTickAccount, + clock: ValidatedClockSnapshot, +} + +impl ActivePairInspection { + #[must_use] + pub const fn manifest(&self) -> PairReadManifest { + self.manifest + } + + /// Returns caller first/second order relative to stored pool A/B order. + #[must_use] + pub const fn caller_order(&self) -> program_quote::PairOrder { + self.caller_order + } + + /// Returns complete validated stored pool, token-definition, LP-definition, and vault state. + #[must_use] + pub const fn pool(&self) -> &ValidatedPoolSnapshot { + &self.pool + } + + /// Returns the validated holding containing permanently locked minimum liquidity. + #[must_use] + pub const fn lp_lock_holding(&self) -> &ValidatedFungibleHolding { + &self.lp_lock_holding + } + + /// Returns spot price from stored pool reserves as Q64.64 token B per token A. + #[must_use] + pub const fn stored_spot_price_q64_64(&self) -> u128 { + self.stored_spot_price_q64_64 + } + + #[must_use] + pub const fn current_tick(&self) -> &CurrentTickAccount { + &self.current_tick + } + + #[must_use] + pub const fn clock(&self) -> ValidatedClockSnapshot { + self.clock + } +} + +/// Current lifecycle state for a fully inspected pair read set. +#[derive(Clone)] +pub enum PairInspection { + Missing(Box), + Active(Box), +} + +/// Derives the singleton config account without reading network state. +#[must_use] +pub fn derive_config_id(amm_program_id: ProgramId) -> AccountId { + compute_config_pda(amm_program_id) +} + +/// Validates and decodes an AMM config snapshot. +/// +/// The program ID is accepted optimistically as the configured transaction target and PDA +/// namespace. This performs no release, ImageID, or deployment-version check. +pub fn inspect_config( + amm_program_id: ProgramId, + config_snapshot: &AccountSnapshot, +) -> Result { + AmmContext::from_config_account(amm_program_id, config_snapshot) +} + +/// Resolves deterministic pre-pool token order through the same helper used by pool PDA derivation. +pub fn canonical_pair( + first_token_id: AccountId, + second_token_id: AccountId, +) -> Result { + let Some((token_a_id, token_b_id)) = canonical_token_pair(first_token_id, second_token_id) + else { + return Err(ClientError::IdenticalTokenDefinitions); + }; + Ok(CanonicalPair { + token_a_id, + token_b_id, + }) +} + +/// Derives every protocol account needed to inspect a caller-ordered pair. +pub fn derive_pair_read_manifest( + context: &AmmContext, + first_token_id: AccountId, + second_token_id: AccountId, +) -> Result { + let canonical_pair = canonical_pair(first_token_id, second_token_id)?; + let pool_id = compute_pool_pda( + context.amm_program_id, + canonical_pair.token_a_id, + canonical_pair.token_b_id, + ); + + Ok(PairReadManifest { + canonical_pair, + first_token: TokenReadManifest { + definition_id: first_token_id, + vault_id: compute_vault_pda(context.amm_program_id, pool_id, first_token_id), + }, + second_token: TokenReadManifest { + definition_id: second_token_id, + vault_id: compute_vault_pda(context.amm_program_id, pool_id, second_token_id), + }, + config_id: context.config_id(), + pool_id, + liquidity_definition_id: compute_liquidity_token_pda(context.amm_program_id, pool_id), + lp_lock_holding_id: compute_lp_lock_holding_pda(context.amm_program_id, pool_id), + current_tick_id: compute_current_tick_account_pda( + context.twap_oracle_program_id(), + pool_id, + ), + clock_id: CLOCK_01_PROGRAM_ACCOUNT_ID, + }) +} + +/// Validates a pair read set and classifies its pool as missing or active. +pub fn inspect_pair( + context: &AmmContext, + first_token_id: AccountId, + second_token_id: AccountId, + snapshots: PairReadSnapshots<'_>, +) -> Result { + let manifest = derive_pair_read_manifest(context, first_token_id, second_token_id)?; + validate_snapshot_ids(manifest, &snapshots)?; + + let first_token_definition = + ValidatedFungibleDefinition::new(context, snapshots.first_token_definition)?; + let second_token_definition = + ValidatedFungibleDefinition::new(context, snapshots.second_token_definition)?; + let clock = validate_clock(snapshots.clock)?; + + if snapshots.pool.account() == &Account::default() { + validate_uninitialized("liquidity definition", snapshots.liquidity_definition)?; + validate_uninitialized("LP lock holding", snapshots.lp_lock_holding)?; + validate_uninitialized("current tick", snapshots.current_tick)?; + + return Ok(PairInspection::Missing(Box::new(MissingPairInspection { + manifest, + first_vault: validate_missing_vault( + "first token vault", + snapshots.first_token_vault, + context.token_program_id(), + first_token_id, + )?, + second_vault: validate_missing_vault( + "second token vault", + snapshots.second_token_vault, + context.token_program_id(), + second_token_id, + )?, + first_token_definition, + second_token_definition, + clock, + }))); + } + + let stored_pool = + amm_core::PoolDefinition::try_from(&snapshots.pool.account().data).map_err(|_| { + ClientError::InvalidAccountData { + account: "AMM pool", + expected: "PoolDefinition", + } + })?; + let caller_order = program_quote::pair_order(&stored_pool, first_token_id, second_token_id)?; + let (token_a_definition, token_b_definition, vault_a, vault_b) = match caller_order { + program_quote::PairOrder::Stored => ( + snapshots.first_token_definition, + snapshots.second_token_definition, + snapshots.first_token_vault, + snapshots.second_token_vault, + ), + program_quote::PairOrder::Reversed => ( + snapshots.second_token_definition, + snapshots.first_token_definition, + snapshots.second_token_vault, + snapshots.first_token_vault, + ), + }; + let pool = ValidatedPoolSnapshot::new( + context, + snapshots.pool, + token_a_definition, + token_b_definition, + vault_a, + vault_b, + snapshots.liquidity_definition, + )?; + + // Reuse program-owned state validation for fee, minimum LP supply, and vault/reserve + // consistency. Donations are intentionally allowed and remain visible in vault balances. + let _ = crate::quote::sync_reserves(&pool)?; + if pool.pool().reserve_a == 0 || pool.pool().reserve_b == 0 { + return Err(ClientError::Quote { + code: "reserve_zero", + message: "Reserves must be nonzero", + }); + } + let lp_lock_holding = ValidatedFungibleHolding::new( + context, + snapshots.lp_lock_holding, + pool.liquidity_definition(), + )?; + if lp_lock_holding.balance() < MINIMUM_LIQUIDITY { + return Err(ClientError::InvalidAccountData { + account: "LP lock holding", + expected: "fungible LP holding with at least the permanently locked minimum liquidity", + }); + } + + if snapshots.current_tick.account().program_owner != context.twap_oracle_program_id() { + return Err(ClientError::ProgramOwnerMismatch { + account: "current tick", + expected: context.twap_oracle_program_id(), + actual: snapshots.current_tick.account().program_owner, + }); + } + let current_tick = CurrentTickAccount::try_from(&snapshots.current_tick.account().data) + .map_err(|_| ClientError::InvalidAccountData { + account: "current tick", + expected: "CurrentTickAccount", + })?; + let stored_spot_price_q64_64 = spot_price_q64_64(pool.pool().reserve_a, pool.pool().reserve_b); + + Ok(PairInspection::Active(Box::new(ActivePairInspection { + manifest, + caller_order, + pool, + lp_lock_holding, + stored_spot_price_q64_64, + current_tick, + clock, + }))) +} + +fn validate_snapshot_ids( + manifest: PairReadManifest, + snapshots: &PairReadSnapshots<'_>, +) -> Result<(), ClientError> { + for (name, snapshot, expected) in [ + ("pool", snapshots.pool, manifest.pool_id), + ( + "first token definition", + snapshots.first_token_definition, + manifest.first_token.definition_id, + ), + ( + "second token definition", + snapshots.second_token_definition, + manifest.second_token.definition_id, + ), + ( + "first token vault", + snapshots.first_token_vault, + manifest.first_token.vault_id, + ), + ( + "second token vault", + snapshots.second_token_vault, + manifest.second_token.vault_id, + ), + ( + "liquidity definition", + snapshots.liquidity_definition, + manifest.liquidity_definition_id, + ), + ( + "LP lock holding", + snapshots.lp_lock_holding, + manifest.lp_lock_holding_id, + ), + ( + "current tick", + snapshots.current_tick, + manifest.current_tick_id, + ), + ("clock", snapshots.clock, manifest.clock_id), + ] { + if snapshot.account_id() != expected { + return Err(ClientError::AccountIdMismatch { + account: name, + expected, + actual: snapshot.account_id(), + }); + } + } + Ok(()) +} + +fn validate_uninitialized( + account_name: &'static str, + snapshot: &AccountSnapshot, +) -> Result<(), ClientError> { + if snapshot.account() != &Account::default() { + return Err(ClientError::InvalidAccountData { + account: account_name, + expected: "uninitialized account", + }); + } + Ok(()) +} + +fn validate_missing_vault( + account_name: &'static str, + snapshot: &AccountSnapshot, + token_program_id: ProgramId, + expected_definition_id: AccountId, +) -> Result { + if snapshot.account() == &Account::default() { + return Ok(MissingVaultState::Uninitialized); + } + + if snapshot.account().program_owner != token_program_id { + return Err(ClientError::ProgramOwnerMismatch { + account: account_name, + expected: token_program_id, + actual: snapshot.account().program_owner, + }); + } + + // Existing recipients must be writable by the Token Program. A default destination remains + // valid because the chained transfer claims it for the Token Program. + let holding = TokenHolding::try_from(&snapshot.account().data).map_err(|_| { + ClientError::InvalidAccountData { + account: account_name, + expected: "TokenHolding", + } + })?; + let TokenHolding::Fungible { + definition_id, + balance, + } = holding + else { + return Err(ClientError::ExpectedFungibleToken { + account: account_name, + }); + }; + if definition_id != expected_definition_id { + return Err(ClientError::TokenDefinitionMismatch { + account: account_name, + expected: expected_definition_id, + actual: definition_id, + }); + } + + Ok(MissingVaultState::ExistingFungible { balance }) +} + +fn validate_clock(snapshot: &AccountSnapshot) -> Result { + let bytes = snapshot.account().data.as_ref(); + if bytes.len() != 16 { + return Err(ClientError::InvalidAccountData { + account: "clock", + expected: "ClockAccountData", + }); + } + let (block_id_bytes, timestamp_bytes) = bytes.split_at(8); + let block_id = u64::from_le_bytes(block_id_bytes.try_into().map_err(|_| { + ClientError::InvalidAccountData { + account: "clock", + expected: "ClockAccountData", + } + })?); + let timestamp = u64::from_le_bytes(timestamp_bytes.try_into().map_err(|_| { + ClientError::InvalidAccountData { + account: "clock", + expected: "ClockAccountData", + } + })?); + + Ok(ValidatedClockSnapshot { + block_id, + timestamp, + }) +} diff --git a/programs/amm/client/src/ffi.rs b/programs/amm/client/src/ffi.rs index ba608bb..93b4f16 100644 --- a/programs/amm/client/src/ffi.rs +++ b/programs/amm/client/src/ffi.rs @@ -19,6 +19,7 @@ type Operation = fn(Value) -> Result; #[derive(Serialize)] struct Envelope { + schema: &'static str, ok: bool, #[serde(skip_serializing_if = "Option::is_none")] value: Option, @@ -29,6 +30,7 @@ struct Envelope { impl Envelope { fn success(value: Value) -> Self { Self { + schema: wire::WIRE_SCHEMA, ok: true, value: Some(value), error: None, @@ -37,6 +39,7 @@ impl Envelope { fn failure(error: ErrorPayload) -> Self { Self { + schema: wire::WIRE_SCHEMA, ok: false, value: None, error: Some(error), @@ -116,14 +119,14 @@ fn encode_envelope(envelope: &Envelope) -> *mut c_char { let json = match serde_json::to_string(envelope) { Ok(json) => json, Err(_) => String::from( - r#"{"ok":false,"error":{"code":"response_serialization_failed","message":"response serialization failed"}}"#, + r#"{"schema":"amm-client.v1","ok":false,"error":{"code":"response_serialization_failed","message":"response serialization failed"}}"#, ), }; match CString::new(json) { Ok(value) => value.into_raw(), Err(_) => CString::new( - r#"{"ok":false,"error":{"code":"response_contains_nul","message":"response contains NUL"}}"#, + r#"{"schema":"amm-client.v1","ok":false,"error":{"code":"response_contains_nul","message":"response contains NUL"}}"#, ) .map_or(std::ptr::null_mut(), CString::into_raw), } diff --git a/programs/amm/client/src/intent.rs b/programs/amm/client/src/intent.rs new file mode 100644 index 0000000..652a005 --- /dev/null +++ b/programs/amm/client/src/intent.rs @@ -0,0 +1,502 @@ +//! Protocol-aware amount preparation for human-facing AMM intents. + +use std::{error::Error, fmt}; + +use alloy_primitives::U512; +use amm_core::{ + canonical_token_pair, checked_mul_div_ceil, isqrt_product, spot_price_q64_64, PoolDefinition, + MINIMUM_LIQUIDITY, +}; +use amm_program::quote::{ + self as program_quote, CreatePoolQuote, PairOrder, SwapDirection, SwapQuote, +}; +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; + +/// Failure while turning a caller intent into executable AMM amounts. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum IntentError { + /// A caller requested a token paired with itself. + IdenticalTokenDefinitions, + /// A Q64.64 desired price must be nonzero. + ZeroDesiredPrice, + /// An edited token amount must be nonzero. + ZeroEditedAmount, + /// A widened calculation produced a result outside the chain's `u128` amount range. + ArithmeticOverflow { operation: &'static str }, + /// Explicit opening amounts do not encode the requested Q64.64 spot price exactly. + SpotPriceMismatch { desired: u128, actual: u128 }, + /// A pool or quoted pool update has a zero directional reserve. + ZeroDirectionalReserve, + /// The supplied quote moves the directional spot price opposite to its swap direction. + SpotMovedAgainstSwap, + /// Canonical program quote logic rejected the prepared amounts. + Quote { + code: &'static str, + message: &'static str, + }, +} + +impl IntentError { + /// Stable machine-readable error code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::IdenticalTokenDefinitions => "identical_token_definitions", + Self::ZeroDesiredPrice => "zero_desired_price", + Self::ZeroEditedAmount => "zero_edited_amount", + Self::ArithmeticOverflow { .. } => "intent_arithmetic_overflow", + Self::SpotPriceMismatch { .. } => "spot_price_mismatch", + Self::ZeroDirectionalReserve => "zero_directional_reserve", + Self::SpotMovedAgainstSwap => "spot_moved_against_swap", + Self::Quote { code, .. } => code, + } + } +} + +impl fmt::Display for IntentError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::IdenticalTokenDefinitions => { + formatter.write_str("pool token definitions must be distinct") + } + Self::ZeroDesiredPrice => formatter.write_str("desired Q64.64 price must be nonzero"), + Self::ZeroEditedAmount => formatter.write_str("edited token amount must be nonzero"), + Self::ArithmeticOverflow { operation } => { + write!(formatter, "{operation} exceeds the u128 amount range") + } + Self::SpotPriceMismatch { desired, actual } => write!( + formatter, + "opening amounts encode Q64.64 price {actual}, not requested price {desired}" + ), + Self::ZeroDirectionalReserve => { + formatter.write_str("directional pool reserves must be nonzero") + } + Self::SpotMovedAgainstSwap => { + formatter.write_str("quoted spot price moved opposite to the swap direction") + } + Self::Quote { message, .. } => formatter.write_str(message), + } + } +} + +impl Error for IntentError {} + +impl From for IntentError { + fn from(error: program_quote::QuoteError) -> Self { + Self::Quote { + code: error.code(), + message: error.message(), + } + } +} + +/// Executable opening amounts plus their canonical program quote. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct PreparedOpeningPair { + /// Requested Q64.64 target used to pair or minimize the amounts. + pub desired_price_q64_64: u128, + /// Exact Q64.64 price encoded by the returned integer amounts. + pub actual_price_q64_64: u128, + /// Stored token-A amount for `NewDefinition`. + pub token_a_amount: u128, + /// Stored token-B amount for `NewDefinition`. + pub token_b_amount: u128, + /// Fee tier passed to canonical pool-creation quote logic. + pub fee_bps: u128, + /// Canonical pool-creation result for the returned amounts. + pub quote: CreatePoolQuote, +} + +/// Caller-facing source for an opening-liquidity pair. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum OpeningLiquidityIntent { + /// Find the smallest executable pair at the requested price. + Minimum, + /// Pair an amount edited for the caller's first token. + FirstAmount(u128), + /// Pair an amount edited for the caller's second token. + SecondAmount(u128), + /// Validate two explicit amounts in caller first/second order. + Explicit { + first_amount: u128, + second_amount: u128, + }, +} + +/// Executable opening amounts in both caller and canonical stored order. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PreparedCallerOpeningPair { + caller_order: PairOrder, + first_amount: u128, + second_amount: u128, + stored: PreparedOpeningPair, +} + +impl PreparedCallerOpeningPair { + /// Caller first/second order relative to canonical stored A/B order. + #[must_use] + pub const fn caller_order(&self) -> PairOrder { + self.caller_order + } + + #[must_use] + pub const fn first_amount(&self) -> u128 { + self.first_amount + } + + #[must_use] + pub const fn second_amount(&self) -> u128 { + self.second_amount + } + + /// Canonical token-A/token-B result ready for pool-creation validation and planning. + #[must_use] + pub const fn stored(&self) -> &PreparedOpeningPair { + &self.stored + } +} + +/// Prepares an opening pair without requiring a caller to reproduce canonical token ordering. +pub fn prepare_caller_opening_pair( + first_token_definition_id: AccountId, + second_token_definition_id: AccountId, + desired_price_q64_64: u128, + fee_bps: u128, + intent: OpeningLiquidityIntent, +) -> Result { + let Some((stored_a_id, _)) = + canonical_token_pair(first_token_definition_id, second_token_definition_id) + else { + return Err(IntentError::IdenticalTokenDefinitions); + }; + let caller_order = if first_token_definition_id == stored_a_id { + PairOrder::Stored + } else { + PairOrder::Reversed + }; + let stored = match intent { + OpeningLiquidityIntent::Minimum => { + prepare_minimum_opening_pair(desired_price_q64_64, fee_bps)? + } + OpeningLiquidityIntent::FirstAmount(first_amount) => match caller_order { + PairOrder::Stored => { + prepare_opening_from_token_a(first_amount, desired_price_q64_64, fee_bps)? + } + PairOrder::Reversed => { + prepare_opening_from_token_b(first_amount, desired_price_q64_64, fee_bps)? + } + }, + OpeningLiquidityIntent::SecondAmount(second_amount) => match caller_order { + PairOrder::Stored => { + prepare_opening_from_token_b(second_amount, desired_price_q64_64, fee_bps)? + } + PairOrder::Reversed => { + prepare_opening_from_token_a(second_amount, desired_price_q64_64, fee_bps)? + } + }, + OpeningLiquidityIntent::Explicit { + first_amount, + second_amount, + } => { + let (token_a_amount, token_b_amount) = + caller_order.amounts_to_stored(first_amount, second_amount); + validate_explicit_opening_pair( + token_a_amount, + token_b_amount, + desired_price_q64_64, + fee_bps, + )? + } + }; + let (first_amount, second_amount) = + caller_order.amounts_from_stored(stored.token_a_amount, stored.token_b_amount); + Ok(PreparedCallerOpeningPair { + caller_order, + first_amount, + second_amount, + stored, + }) +} + +/// Returns the token-B amount paired with an edited token-A amount. +/// +/// The result is `ceil(token_a_amount * desired_price / 2^64)`, computed with the same widened +/// integer helper used by AMM code. Callers can inspect the actual representable price returned by +/// [`prepare_opening_from_token_a`] when integer rounding cannot reproduce the target exactly. +pub fn paired_amount_from_token_a( + token_a_amount: u128, + desired_price_q64_64: u128, +) -> Result { + validate_pairing_inputs(token_a_amount, desired_price_q64_64)?; + checked_mul_div_ceil(token_a_amount, desired_price_q64_64, Q64_64_ONE).ok_or( + IntentError::ArithmeticOverflow { + operation: "token-A to token-B pairing", + }, + ) +} + +/// Returns the token-A amount paired with an edited token-B amount. +/// +/// The result is `ceil(token_b_amount * 2^64 / desired_price)`, using checked widened arithmetic. +pub fn paired_amount_from_token_b( + token_b_amount: u128, + desired_price_q64_64: u128, +) -> Result { + validate_pairing_inputs(token_b_amount, desired_price_q64_64)?; + checked_mul_div_ceil(token_b_amount, Q64_64_ONE, desired_price_q64_64).ok_or( + IntentError::ArithmeticOverflow { + operation: "token-B to token-A pairing", + }, + ) +} + +/// Finds the smallest executable opening pair on the price's base side. +/// +/// For prices at least one, token A is minimized. For prices below one, token B is minimized. The +/// opposite amount is conservatively rounded up. The returned values are always passed through +/// [`amm_program::quote::create_pool`] before success is returned. +pub fn prepare_minimum_opening_pair( + desired_price_q64_64: u128, + fee_bps: u128, +) -> Result { + if desired_price_q64_64 == 0 { + return Err(IntentError::ZeroDesiredPrice); + } + let upper = MINIMUM_LIQUIDITY + .checked_add(1) + .ok_or(IntentError::ArithmeticOverflow { + operation: "minimum opening-liquidity bound", + })?; + + let (token_a_amount, token_b_amount) = if desired_price_q64_64 >= Q64_64_ONE { + let token_a_amount = first_executable(upper, |candidate_a| { + let candidate_b = paired_amount_from_token_a(candidate_a, desired_price_q64_64)?; + Ok(isqrt_product(candidate_a, candidate_b) > MINIMUM_LIQUIDITY) + })?; + ( + token_a_amount, + paired_amount_from_token_a(token_a_amount, desired_price_q64_64)?, + ) + } else { + let token_b_amount = first_executable(upper, |candidate_b| { + let candidate_a = paired_amount_from_token_b(candidate_b, desired_price_q64_64)?; + Ok(isqrt_product(candidate_a, candidate_b) > MINIMUM_LIQUIDITY) + })?; + ( + paired_amount_from_token_b(token_b_amount, desired_price_q64_64)?, + token_b_amount, + ) + }; + + prepare_opening_pair( + desired_price_q64_64, + token_a_amount, + token_b_amount, + fee_bps, + ) +} + +/// Pairs an edited token-A amount and validates the resulting pool creation through program logic. +pub fn prepare_opening_from_token_a( + token_a_amount: u128, + desired_price_q64_64: u128, + fee_bps: u128, +) -> Result { + let token_b_amount = paired_amount_from_token_a(token_a_amount, desired_price_q64_64)?; + prepare_opening_pair( + desired_price_q64_64, + token_a_amount, + token_b_amount, + fee_bps, + ) +} + +/// Pairs an edited token-B amount and validates the resulting pool creation through program logic. +pub fn prepare_opening_from_token_b( + token_b_amount: u128, + desired_price_q64_64: u128, + fee_bps: u128, +) -> Result { + let token_a_amount = paired_amount_from_token_b(token_b_amount, desired_price_q64_64)?; + prepare_opening_pair( + desired_price_q64_64, + token_a_amount, + token_b_amount, + fee_bps, + ) +} + +/// Validates explicit opening amounts and requires their Q64.64 spot price to match exactly. +pub fn validate_explicit_opening_pair( + token_a_amount: u128, + token_b_amount: u128, + desired_price_q64_64: u128, + fee_bps: u128, +) -> Result { + let prepared = prepare_opening_pair( + desired_price_q64_64, + token_a_amount, + token_b_amount, + fee_bps, + )?; + if prepared.actual_price_q64_64 != desired_price_q64_64 { + return Err(IntentError::SpotPriceMismatch { + desired: desired_price_q64_64, + actual: prepared.actual_price_q64_64, + }); + } + Ok(prepared) +} + +/// Converts caller first/second amounts to the pool's stored A/B order. +#[must_use] +pub const fn caller_amounts_to_stored(order: PairOrder, first: u128, second: u128) -> (u128, u128) { + order.amounts_to_stored(first, second) +} + +/// Converts stored pool A/B amounts back to caller first/second order. +#[must_use] +pub const fn stored_amounts_to_caller( + order: PairOrder, + amount_a: u128, + amount_b: u128, +) -> (u128, u128) { + order.amounts_from_stored(amount_a, amount_b) +} + +/// Returns nonnegative directional pool spot movement in basis points for a canonical swap quote. +/// +/// This computes, with one final floor operation: +/// +/// `10_000 * (post_price - pre_price) / pre_price` +/// +/// Reserves are oriented as input/output according to the quote direction. Intermediate products +/// use a widened integer so values remain exact even when reserve products exceed `u128`. +pub fn pool_spot_change_bps( + before: &PoolDefinition, + quote: &SwapQuote, +) -> Result { + let (pre_in, pre_out, post_in, post_out) = match quote.direction { + SwapDirection::AToB => ( + before.reserve_a, + before.reserve_b, + quote.pool.reserve_a, + quote.pool.reserve_b, + ), + SwapDirection::BToA => ( + before.reserve_b, + before.reserve_a, + quote.pool.reserve_b, + quote.pool.reserve_a, + ), + }; + if [pre_in, pre_out, post_in, post_out] + .into_iter() + .any(|reserve| reserve == 0) + { + return Err(IntentError::ZeroDirectionalReserve); + } + + let post_price_numerator = U512::from(post_in).checked_mul(U512::from(pre_out)).ok_or( + IntentError::ArithmeticOverflow { + operation: "directional post-price numerator", + }, + )?; + let relative_change_denominator = U512::from(post_out).checked_mul(U512::from(pre_in)).ok_or( + IntentError::ArithmeticOverflow { + operation: "directional relative-change denominator", + }, + )?; + let increase = post_price_numerator + .checked_sub(relative_change_denominator) + .ok_or(IntentError::SpotMovedAgainstSwap)?; + let numerator = + increase + .checked_mul(U512::from(10_000_u128)) + .ok_or(IntentError::ArithmeticOverflow { + operation: "directional basis-point numerator", + })?; + let change = numerator.checked_div(relative_change_denominator).ok_or( + IntentError::ArithmeticOverflow { + operation: "directional basis-point division", + }, + )?; + u128::try_from(change).map_err(|_| IntentError::ArithmeticOverflow { + operation: "directional basis-point result", + }) +} + +fn validate_pairing_inputs( + edited_amount: u128, + desired_price_q64_64: u128, +) -> Result<(), IntentError> { + if edited_amount == 0 { + return Err(IntentError::ZeroEditedAmount); + } + if desired_price_q64_64 == 0 { + return Err(IntentError::ZeroDesiredPrice); + } + Ok(()) +} + +fn prepare_opening_pair( + desired_price_q64_64: u128, + token_a_amount: u128, + token_b_amount: u128, + fee_bps: u128, +) -> Result { + if desired_price_q64_64 == 0 { + return Err(IntentError::ZeroDesiredPrice); + } + let quote = program_quote::create_pool(token_a_amount, token_b_amount, fee_bps)?; + let actual_price_q64_64 = spot_price_q64_64(token_a_amount, token_b_amount); + Ok(PreparedOpeningPair { + desired_price_q64_64, + actual_price_q64_64, + token_a_amount, + token_b_amount, + fee_bps, + quote, + }) +} + +fn first_executable( + upper: u128, + mut executable: impl FnMut(u128) -> Result, +) -> Result { + let mut lower = 1_u128; + let mut upper = upper; + while lower < upper { + let distance = upper + .checked_sub(lower) + .ok_or(IntentError::ArithmeticOverflow { + operation: "opening-pair search range", + })?; + let half = distance + .checked_div(2) + .ok_or(IntentError::ArithmeticOverflow { + operation: "opening-pair search division", + })?; + let midpoint = lower + .checked_add(half) + .ok_or(IntentError::ArithmeticOverflow { + operation: "opening-pair search midpoint", + })?; + if executable(midpoint)? { + upper = midpoint; + } else { + lower = midpoint + .checked_add(1) + .ok_or(IntentError::ArithmeticOverflow { + operation: "opening-pair search increment", + })?; + } + } + Ok(lower) +} diff --git a/programs/amm/client/src/lib.rs b/programs/amm/client/src/lib.rs index e384f38..d8b9724 100644 --- a/programs/amm/client/src/lib.rs +++ b/programs/amm/client/src/lib.rs @@ -1,14 +1,29 @@ //! Stateless AMM quoting and transaction planning for host consumers. +pub mod discovery; pub mod error; mod ffi; +pub mod intent; pub mod plan; pub mod quote; pub mod slippage; +pub mod transaction; pub mod wire; +pub use discovery::{ + canonical_pair, derive_config_id, derive_pair_read_manifest, inspect_config, inspect_pair, + ActivePairInspection, CanonicalPair, MissingPairInspection, MissingVaultState, PairInspection, + PairReadManifest, PairReadSnapshots, TokenReadManifest, ValidatedClockSnapshot, +}; 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, +}; pub use plan::{ encode_instruction, plan_add_liquidity, plan_create_oracle_price_account, plan_create_pool, plan_create_price_observations, plan_initialize, plan_remove_liquidity, plan_swap_exact_input, @@ -24,3 +39,11 @@ pub use slippage::{ PreparedAddLiquidity, PreparedCreatePool, PreparedRemoveLiquidity, PreparedSwapExactInput, PreparedSwapExactOutput, SlippageTolerance, SLIPPAGE_BPS_DENOMINATOR, }; +pub use transaction::{ + ensure_quote_unchanged, prepare_add_liquidity_transaction, prepare_create_pool_transaction, + prepare_remove_liquidity_transaction, prepare_swap_exact_input_transaction, + prepare_swap_exact_output_transaction, AddLiquidityTransactionInput, CallerAmounts, + CreatePoolTransactionInput, FundingRequirement, PoolAccountSnapshots, PreparedTransaction, + QuoteCommitment, RemoveLiquidityTransactionInput, SwapExactInputTransactionInput, + SwapExactOutputTransactionInput, TransactionError, TransactionOperation, WalletPrerequisites, +}; diff --git a/programs/amm/client/src/plan.rs b/programs/amm/client/src/plan.rs index 29abdee..f75fd76 100644 --- a/programs/amm/client/src/plan.rs +++ b/programs/amm/client/src/plan.rs @@ -247,6 +247,27 @@ impl TransactionPlan { .collect() } + /// Writable account IDs in first-occurrence instruction order. + #[must_use] + pub fn writable_account_ids(&self) -> Vec { + self.accounts + .iter() + .filter(|account| account.writable()) + .map(PlannedAccount::id) + .fold(Vec::new(), |mut ids, id| { + if !ids.contains(&id) { + ids.push(id); + } + ids + }) + } + + /// Account IDs whose state may change if the instruction succeeds. + #[must_use] + pub fn affected_account_ids(&self) -> Vec { + self.writable_account_ids() + } + /// Guest instruction name, kept exhaustive over the canonical enum. #[must_use] pub const fn instruction_name(&self) -> &'static str { diff --git a/programs/amm/client/src/slippage.rs b/programs/amm/client/src/slippage.rs index 6baa19c..e934be5 100644 --- a/programs/amm/client/src/slippage.rs +++ b/programs/amm/client/src/slippage.rs @@ -158,6 +158,10 @@ pub fn prepare_create_pool( } /// Quotes add liquidity and derives its minimum-LP guard. +/// +/// The instruction maxima remain the caller's original caps. Reusing the rounded actual deposits +/// as new maxima is not behavior-preserving for non-divisible reserve ratios: the program rounds +/// the proportional amounts again and can produce a different quote. pub fn prepare_add_liquidity( snapshot: &ValidatedPoolSnapshot, max_amount_a: u128, @@ -166,20 +170,14 @@ pub fn prepare_add_liquidity( ) -> Result { let preview = client_quote::preview_add_liquidity(snapshot, max_amount_a, max_amount_b)?; let min_amount_liquidity = minimum_guard_amount(preview.liquidity_to_mint, tolerance)?; - let max_amount_to_add_token_a = preview.actual_amount_a; - let max_amount_to_add_token_b = preview.actual_amount_b; - let quote = client_quote::add_liquidity( - snapshot, - max_amount_to_add_token_a, - max_amount_to_add_token_b, - min_amount_liquidity, - )?; + let quote = + client_quote::add_liquidity(snapshot, max_amount_a, max_amount_b, min_amount_liquidity)?; Ok(PreparedAddLiquidity { quote, min_amount_liquidity, - max_amount_to_add_token_a, - max_amount_to_add_token_b, + max_amount_to_add_token_a: max_amount_a, + max_amount_to_add_token_b: max_amount_b, }) } @@ -251,6 +249,13 @@ pub fn prepare_swap_exact_output( exact_amount_out, )?; let max_amount_in = maximum_guard_amount(preview.amount_in, tolerance)?; + if user_input.balance() < max_amount_in { + return Err(ClientError::InsufficientBalance { + account: "user input holding", + available: user_input.balance(), + required: max_amount_in, + }); + } let quote = client_quote::swap_exact_output( snapshot, user_input, diff --git a/programs/amm/client/src/transaction.rs b/programs/amm/client/src/transaction.rs new file mode 100644 index 0000000..2d55a77 --- /dev/null +++ b/programs/amm/client/src/transaction.rs @@ -0,0 +1,1196 @@ +//! Snapshot-to-transaction facade for wallet and API consumers. +//! +//! Each operation validates immutable account snapshots, delegates economic calculations to the +//! program-owned quote API, applies the shared slippage policy, and emits the canonical planner +//! output. The facade is deterministic and performs no RPC, signing, submission, clock lookup, or +//! runtime program-identity/version check. + +use std::{error::Error, fmt}; + +use amm_program::quote::{ + AddLiquidityQuote, CreatePoolQuote, PairOrder, RemoveLiquidityQuote, SwapQuote, +}; +use nssa_core::{ + account::{Account, AccountId}, + program::ProgramId, + Commitment, +}; +use risc0_zkvm::sha::{Impl, Sha256 as _}; +use serde::Serialize; +use token_core::TokenHolding; + +use crate::{ + discovery::{ + inspect_config, inspect_pair, ActivePairInspection, PairInspection, PairReadSnapshots, + }, + intent::{pool_spot_change_bps, IntentError}, + plan::{ + plan_add_liquidity, plan_create_pool, plan_remove_liquidity, plan_swap_exact_input, + plan_swap_exact_output, AddLiquidityPlanInput, CreatePoolPlanInput, PoolContext, + RemoveLiquidityPlanInput, SwapExactInputPlanInput, SwapExactOutputPlanInput, + TransactionPlan, + }, + quote::{ + AccountSnapshot, ValidatedFungibleDefinition, ValidatedFungibleHolding, + ValidatedPoolSnapshot, + }, + slippage::{ + prepare_add_liquidity, prepare_create_pool, prepare_remove_liquidity, + prepare_swap_exact_input, prepare_swap_exact_output, SlippageTolerance, + }, + AmmContext, ClientError, +}; + +const COMMITMENT_DOMAIN: &str = "lez.amm.client.prepared-transaction.v1"; + +/// One AMM operation represented by a prepared transaction. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[non_exhaustive] +pub enum TransactionOperation { + CreatePool, + AddLiquidity, + RemoveLiquidity, + SwapExactInput, + SwapExactOutput, +} + +/// Exact operation amounts expressed in caller first/second order. +/// +/// For pool creation and liquidity operations, `first` and `second` correspond to the supplied +/// token-definition IDs. For swaps, `first` is input and `second` is output. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +pub struct CallerAmounts { + first: u128, + second: u128, +} + +impl CallerAmounts { + #[must_use] + pub const fn new(first: u128, second: u128) -> Self { + Self { first, second } + } + + #[must_use] + pub const fn first(self) -> u128 { + self.first + } + + #[must_use] + pub const fn second(self) -> u128 { + self.second + } +} + +/// One selected wallet holding and the spend capacity required by the instruction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FundingRequirement { + holding_account_id: AccountId, + token_definition_id: AccountId, + available: u128, + required: u128, +} + +impl FundingRequirement { + #[must_use] + pub const fn holding_account_id(&self) -> AccountId { + self.holding_account_id + } + + #[must_use] + pub const fn token_definition_id(&self) -> AccountId { + self.token_definition_id + } + + #[must_use] + pub const fn available(&self) -> u128 { + self.available + } + + #[must_use] + pub const fn required(&self) -> u128 { + self.required + } +} + +/// Wallet-owned prerequisites extracted from the exact plan and selected snapshots. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WalletPrerequisites { + signer_account_ids: Vec, + fresh_account_ids: Vec, + funding: Vec, +} + +impl WalletPrerequisites { + /// Accounts that must be authorized, in instruction-account order. + #[must_use] + pub fn signer_account_ids(&self) -> &[AccountId] { + &self.signer_account_ids + } + + /// Selected destination accounts whose supplied snapshot was exactly `Account::default()`. + #[must_use] + pub fn fresh_account_ids(&self) -> &[AccountId] { + &self.fresh_account_ids + } + + /// Funding requirements in caller token order. + #[must_use] + pub fn funding(&self) -> &[FundingRequirement] { + &self.funding + } +} + +/// SHA-256 commitment to the typed request, exact plan, and role-tagged account snapshots. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct QuoteCommitment([u8; 32]); + +impl QuoteCommitment { + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + #[must_use] + pub const fn into_bytes(self) -> [u8; 32] { + self.0 + } + + /// Returns `quote_changed` when a refreshed preparation no longer matches this commitment. + pub fn ensure_unchanged(self, actual: Self) -> Result<(), TransactionError> { + ensure_quote_unchanged(self, actual) + } +} + +/// Compares a previously presented quote commitment with a freshly prepared one. +pub fn ensure_quote_unchanged( + expected: QuoteCommitment, + actual: QuoteCommitment, +) -> Result<(), TransactionError> { + if expected.0 == actual.0 { + Ok(()) + } else { + Err(TransactionError::QuoteChanged { expected, actual }) + } +} + +/// Canonical quote paired with the exact transaction that consumes it. +pub struct PreparedTransaction { + operation: TransactionOperation, + quote: Q, + plan: TransactionPlan, + quote_commitment: QuoteCommitment, + affected_account_ids: Vec, + wallet_prerequisites: WalletPrerequisites, + caller_amounts: CallerAmounts, + deadline: u64, + pool_spot_change_bps: Option, +} + +impl PreparedTransaction { + #[must_use] + pub const fn operation(&self) -> TransactionOperation { + self.operation + } + + #[must_use] + pub const fn quote(&self) -> &Q { + &self.quote + } + + #[must_use] + pub const fn plan(&self) -> &TransactionPlan { + &self.plan + } + + #[must_use] + pub const fn quote_commitment(&self) -> QuoteCommitment { + self.quote_commitment + } + + /// Writable account IDs in first-occurrence instruction order. + #[must_use] + pub fn affected_account_ids(&self) -> &[AccountId] { + &self.affected_account_ids + } + + #[must_use] + pub const fn wallet_prerequisites(&self) -> &WalletPrerequisites { + &self.wallet_prerequisites + } + + #[must_use] + pub const fn caller_amounts(&self) -> CallerAmounts { + self.caller_amounts + } + + #[must_use] + pub const fn deadline(&self) -> u64 { + self.deadline + } + + /// Directional pre/post pool spot movement for swaps; absent for non-swap operations. + #[must_use] + pub const fn pool_spot_change_bps(&self) -> Option { + self.pool_spot_change_bps + } + + #[must_use] + pub fn into_quote_and_plan(self) -> (Q, TransactionPlan) { + (self.quote, self.plan) + } +} + +/// Failure while validating snapshots or materializing a prepared transaction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum TransactionError { + Client(ClientError), + Intent(IntentError), + FeeMismatch { + expected: u128, + actual: u128, + }, + QuoteChanged { + expected: QuoteCommitment, + actual: QuoteCommitment, + }, + DuplicateAccountId { + account_id: AccountId, + }, + InstructionEncoding, + CommitmentEncoding, +} + +impl TransactionError { + #[must_use] + pub const fn code(&self) -> &'static str { + match self { + Self::Client(error) => error.code(), + Self::Intent(error) => error.code(), + Self::FeeMismatch { .. } => "fee_mismatch", + Self::QuoteChanged { .. } => "quote_changed", + Self::DuplicateAccountId { .. } => "duplicate_account_id", + Self::InstructionEncoding => "instruction_encoding_failed", + Self::CommitmentEncoding => "quote_commitment_encoding_failed", + } + } +} + +impl fmt::Display for TransactionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Client(error) => error.fmt(formatter), + Self::Intent(error) => error.fmt(formatter), + Self::FeeMismatch { expected, actual } => write!( + formatter, + "expected pool fee {expected} bps, snapshot contains {actual} bps" + ), + Self::QuoteChanged { .. } => { + formatter.write_str("prepared quote changed after snapshot refresh") + } + Self::DuplicateAccountId { .. } => { + formatter.write_str("transaction plan contains a duplicate account ID") + } + Self::InstructionEncoding => { + formatter.write_str("AMM instruction serialization failed") + } + Self::CommitmentEncoding => { + formatter.write_str("prepared-transaction commitment serialization failed") + } + } + } +} + +impl Error for TransactionError {} + +impl From for TransactionError { + fn from(error: ClientError) -> Self { + Self::Client(error) + } +} + +impl From for TransactionError { + fn from(error: IntentError) -> Self { + Self::Intent(error) + } +} + +/// Raw fetched accounts required to validate an initialized pool. +#[derive(Clone, Copy)] +pub struct PoolAccountSnapshots<'a> { + pub config: &'a AccountSnapshot, + pub pair: PairReadSnapshots<'a>, +} + +impl PoolAccountSnapshots<'_> { + /// Validates config, complete pair lifecycle, current tick, and clock snapshots. + pub fn validate( + self, + amm_program_id: ProgramId, + first_token_definition_id: AccountId, + second_token_definition_id: AccountId, + ) -> Result<(AmmContext, Box), ClientError> { + let context = inspect_config(amm_program_id, self.config)?; + match inspect_pair( + &context, + first_token_definition_id, + second_token_definition_id, + self.pair, + )? { + PairInspection::Active(active) => Ok((context, active)), + PairInspection::Missing(_) => Err(ClientError::InvalidAccountData { + account: "AMM pool", + expected: "initialized pool lifecycle", + }), + } + } +} + +/// Caller-order pool-creation request over raw account snapshots. +#[derive(Clone, Copy)] +pub struct CreatePoolTransactionInput<'a> { + pub amm_program_id: ProgramId, + pub config: &'a AccountSnapshot, + pub pair: PairReadSnapshots<'a>, + pub first_token_definition_id: AccountId, + pub second_token_definition_id: AccountId, + pub first_token_holding: &'a AccountSnapshot, + pub second_token_holding: &'a AccountSnapshot, + pub liquidity_holding: &'a AccountSnapshot, + pub first_amount: u128, + pub second_amount: u128, + pub fee_bps: u128, + pub deadline: u64, +} + +/// Caller-order add-liquidity request over a complete pool snapshot. +#[derive(Clone, Copy)] +pub struct AddLiquidityTransactionInput<'a> { + pub amm_program_id: ProgramId, + pub pool_accounts: PoolAccountSnapshots<'a>, + pub first_token_definition_id: AccountId, + pub second_token_definition_id: AccountId, + pub first_token_holding: &'a AccountSnapshot, + pub second_token_holding: &'a AccountSnapshot, + pub liquidity_holding: &'a AccountSnapshot, + pub max_first_amount: u128, + pub max_second_amount: u128, + pub slippage: SlippageTolerance, + pub expected_fee_bps: Option, + pub deadline: u64, +} + +/// Caller-order remove-liquidity request over a complete pool snapshot. +#[derive(Clone, Copy)] +pub struct RemoveLiquidityTransactionInput<'a> { + pub amm_program_id: ProgramId, + pub pool_accounts: PoolAccountSnapshots<'a>, + pub first_token_definition_id: AccountId, + pub second_token_definition_id: AccountId, + pub first_token_holding: &'a AccountSnapshot, + pub second_token_holding: &'a AccountSnapshot, + pub liquidity_holding: &'a AccountSnapshot, + pub remove_liquidity_amount: u128, + pub slippage: SlippageTolerance, + pub expected_fee_bps: Option, + pub deadline: u64, +} + +/// Exact-input swap request over a complete pool snapshot. +#[derive(Clone, Copy)] +pub struct SwapExactInputTransactionInput<'a> { + pub amm_program_id: ProgramId, + pub pool_accounts: PoolAccountSnapshots<'a>, + pub input_token_definition_id: AccountId, + pub output_token_definition_id: AccountId, + pub input_holding: &'a AccountSnapshot, + pub output_holding: &'a AccountSnapshot, + pub amount_in: u128, + pub slippage: SlippageTolerance, + pub expected_fee_bps: Option, + pub deadline: u64, +} + +/// Exact-output swap request over a complete pool snapshot. +#[derive(Clone, Copy)] +pub struct SwapExactOutputTransactionInput<'a> { + pub amm_program_id: ProgramId, + pub pool_accounts: PoolAccountSnapshots<'a>, + pub input_token_definition_id: AccountId, + pub output_token_definition_id: AccountId, + pub input_holding: &'a AccountSnapshot, + pub output_holding: &'a AccountSnapshot, + pub exact_amount_out: u128, + pub slippage: SlippageTolerance, + pub expected_fee_bps: Option, + pub deadline: u64, +} + +/// Validates funding, quotes creation, and emits one exact `NewDefinition` plan. +pub fn prepare_create_pool_transaction( + input: CreatePoolTransactionInput<'_>, +) -> Result, TransactionError> { + let context = inspect_config(input.amm_program_id, input.config)?; + let missing = match inspect_pair( + &context, + input.first_token_definition_id, + input.second_token_definition_id, + input.pair, + )? { + PairInspection::Missing(missing) => missing, + PairInspection::Active(_) => { + return Err(ClientError::InvalidAccountData { + account: "AMM pool", + expected: "uninitialized pool lifecycle", + } + .into()) + } + }; + let first_definition = missing.first_token_definition(); + let second_definition = missing.second_token_definition(); + let first_holding = + ValidatedFungibleHolding::new(&context, input.first_token_holding, first_definition)?; + let second_holding = + ValidatedFungibleHolding::new(&context, input.second_token_holding, second_definition)?; + + let canonical = missing.manifest().canonical_pair(); + let stored_a_id = canonical.token_a_id(); + let stored_b_id = canonical.token_b_id(); + let order = if first_definition.account_id() == stored_a_id { + PairOrder::Stored + } 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_amount, stored_b_amount) = + order.amounts_to_stored(input.first_amount, input.second_amount); + let prepared = prepare_create_pool( + &context, + stored_a_definition, + stored_b_definition, + stored_a_amount, + stored_b_amount, + input.fee_bps, + )?; + ensure_funded(&first_holding, input.first_amount, "first token holding")?; + ensure_funded(&second_holding, input.second_amount, "second token holding")?; + + let fresh_liquidity = validate_holding_destination( + &context, + input.liquidity_holding, + missing.manifest().liquidity_definition_id(), + "liquidity holding", + )?; + let plan = plan_create_pool(CreatePoolPlanInput { + context: &context, + token_a_definition_id: stored_a_id, + token_b_definition_id: stored_b_id, + user_holding_a: stored_a_holding.account_id(), + user_holding_b: stored_b_holding.account_id(), + user_holding_lp: input.liquidity_holding.account_id(), + token_a_amount: prepared.token_a_amount, + token_b_amount: prepared.token_b_amount, + fees: prepared.fees, + deadline: input.deadline, + })?; + let sources = pair_sources( + input.config, + input.pair, + input.first_token_holding, + input.second_token_holding, + Some(input.liquidity_holding), + ); + let funding = vec![ + funding(&first_holding, input.first_amount), + funding(&second_holding, input.second_amount), + ]; + let fresh = fresh_liquidity + .then_some(input.liquidity_holding.account_id()) + .into_iter() + .collect(); + + PreparedTransaction::new( + TransactionOperation::CreatePool, + TransactionIntent::CreatePool { + first_token_definition_id: input.first_token_definition_id, + second_token_definition_id: input.second_token_definition_id, + first_amount: input.first_amount, + second_amount: input.second_amount, + fee_bps: input.fee_bps, + }, + prepared.quote, + plan, + sources, + fresh, + funding, + CallerAmounts::new(input.first_amount, input.second_amount), + input.deadline, + None, + ) +} + +/// Validates funding, quotes a proportional deposit, and emits one exact add plan. +pub fn prepare_add_liquidity_transaction( + input: AddLiquidityTransactionInput<'_>, +) -> Result, TransactionError> { + let (context, active) = input.pool_accounts.validate( + input.amm_program_id, + input.first_token_definition_id, + input.second_token_definition_id, + )?; + 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_holding = + ValidatedFungibleHolding::new(&context, input.first_token_holding, first_definition)?; + let second_holding = + ValidatedFungibleHolding::new(&context, input.second_token_holding, second_definition)?; + let (stored_max_a, stored_max_b) = + order.amounts_to_stored(input.max_first_amount, input.max_second_amount); + let prepared = prepare_add_liquidity(snapshot, stored_max_a, stored_max_b, input.slippage)?; + let (quoted_first, quoted_second) = order.amounts_from_stored( + prepared.quote.actual_amount_a, + prepared.quote.actual_amount_b, + ); + ensure_funded( + &first_holding, + input.max_first_amount, + "first token holding", + )?; + ensure_funded( + &second_holding, + input.max_second_amount, + "second token holding", + )?; + let fresh_liquidity = validate_holding_destination( + &context, + input.liquidity_holding, + snapshot.liquidity_definition().account_id(), + "liquidity holding", + )?; + let (stored_holding_a, stored_holding_b) = + stored_holdings(order, &first_holding, &second_holding); + let pool = PoolContext::new(&context, snapshot.pool_id(), snapshot.pool())?; + let plan = plan_add_liquidity(AddLiquidityPlanInput { + context: &context, + pool, + user_holding_a: stored_holding_a.account_id(), + user_holding_b: stored_holding_b.account_id(), + user_holding_lp: input.liquidity_holding.account_id(), + min_amount_liquidity: prepared.min_amount_liquidity, + max_amount_to_add_token_a: prepared.max_amount_to_add_token_a, + max_amount_to_add_token_b: prepared.max_amount_to_add_token_b, + deadline: input.deadline, + }); + let sources = pool_sources( + input.pool_accounts, + input.first_token_holding, + input.second_token_holding, + Some(input.liquidity_holding), + ); + let funding = vec![ + funding(&first_holding, input.max_first_amount), + funding(&second_holding, input.max_second_amount), + ]; + let fresh = fresh_liquidity + .then_some(input.liquidity_holding.account_id()) + .into_iter() + .collect(); + + PreparedTransaction::new( + TransactionOperation::AddLiquidity, + TransactionIntent::AddLiquidity { + first_token_definition_id: input.first_token_definition_id, + second_token_definition_id: input.second_token_definition_id, + max_first_amount: input.max_first_amount, + max_second_amount: input.max_second_amount, + slippage_bps: input.slippage.bps(), + expected_fee_bps: input.expected_fee_bps, + }, + prepared.quote, + plan, + sources, + fresh, + funding, + CallerAmounts::new(quoted_first, quoted_second), + input.deadline, + None, + ) +} + +/// Quotes an LP burn and emits one exact remove plan. +pub fn prepare_remove_liquidity_transaction( + input: RemoveLiquidityTransactionInput<'_>, +) -> Result, TransactionError> { + let (context, active) = input.pool_accounts.validate( + input.amm_program_id, + input.first_token_definition_id, + input.second_token_definition_id, + )?; + 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_fresh = validate_holding_destination( + &context, + input.first_token_holding, + first_definition.account_id(), + "first token holding", + )?; + let second_fresh = validate_holding_destination( + &context, + input.second_token_holding, + second_definition.account_id(), + "second token holding", + )?; + let liquidity_holding = ValidatedFungibleHolding::new( + &context, + input.liquidity_holding, + snapshot.liquidity_definition(), + )?; + let prepared = prepare_remove_liquidity( + snapshot, + &liquidity_holding, + input.remove_liquidity_amount, + input.slippage, + )?; + let caller_amounts = order.amounts_from_stored( + prepared.quote.withdraw_amount_a, + prepared.quote.withdraw_amount_b, + ); + let (stored_holding_a, stored_holding_b) = order_pair( + order, + input.first_token_holding.account_id(), + input.second_token_holding.account_id(), + ); + let pool = PoolContext::new(&context, snapshot.pool_id(), snapshot.pool())?; + let plan = plan_remove_liquidity(RemoveLiquidityPlanInput { + context: &context, + pool, + user_holding_a: stored_holding_a, + user_holding_b: stored_holding_b, + user_holding_lp: liquidity_holding.account_id(), + remove_liquidity_amount: prepared.remove_liquidity_amount, + min_amount_to_remove_token_a: prepared.min_amount_to_remove_token_a, + min_amount_to_remove_token_b: prepared.min_amount_to_remove_token_b, + deadline: input.deadline, + }); + let sources = pool_sources( + input.pool_accounts, + input.first_token_holding, + input.second_token_holding, + Some(input.liquidity_holding), + ); + let fresh = [ + first_fresh.then_some(input.first_token_holding.account_id()), + second_fresh.then_some(input.second_token_holding.account_id()), + ] + .into_iter() + .flatten() + .collect(); + let funding = vec![funding( + &liquidity_holding, + prepared.remove_liquidity_amount, + )]; + + PreparedTransaction::new( + TransactionOperation::RemoveLiquidity, + TransactionIntent::RemoveLiquidity { + first_token_definition_id: input.first_token_definition_id, + second_token_definition_id: input.second_token_definition_id, + remove_liquidity_amount: input.remove_liquidity_amount, + slippage_bps: input.slippage.bps(), + expected_fee_bps: input.expected_fee_bps, + }, + prepared.quote, + plan, + sources, + fresh, + funding, + CallerAmounts::new(caller_amounts.0, caller_amounts.1), + input.deadline, + None, + ) +} + +/// Quotes an exact-input swap and emits one exact swap plan. +pub fn prepare_swap_exact_input_transaction( + input: SwapExactInputTransactionInput<'_>, +) -> Result, TransactionError> { + let (context, active) = input.pool_accounts.validate( + input.amm_program_id, + input.input_token_definition_id, + input.output_token_definition_id, + )?; + let snapshot = active.pool(); + validate_expected_fee(snapshot, input.expected_fee_bps)?; + let (input_definition, output_definition) = swap_definitions( + snapshot, + input.input_token_definition_id, + input.output_token_definition_id, + )?; + let input_holding = + ValidatedFungibleHolding::new(&context, input.input_holding, input_definition)?; + let output_holding = + ValidatedFungibleHolding::new(&context, input.output_holding, output_definition)?; + let prepared = prepare_swap_exact_input( + snapshot, + &input_holding, + &output_holding, + input.amount_in, + input.slippage, + )?; + let spot_change = pool_spot_change_bps(snapshot.pool(), &prepared.quote)?; + let pool = PoolContext::new(&context, snapshot.pool_id(), snapshot.pool())?; + let plan = plan_swap_exact_input(SwapExactInputPlanInput { + context: &context, + pool, + user_input_holding: input_holding.account_id(), + user_output_holding: output_holding.account_id(), + swap_amount_in: prepared.swap_amount_in, + min_amount_out: prepared.min_amount_out, + deadline: input.deadline, + }); + let sources = pool_sources( + input.pool_accounts, + input.input_holding, + input.output_holding, + None, + ); + let funding = vec![funding(&input_holding, prepared.quote.amount_in)]; + + PreparedTransaction::new( + TransactionOperation::SwapExactInput, + TransactionIntent::SwapExactInput { + input_token_definition_id: input.input_token_definition_id, + output_token_definition_id: input.output_token_definition_id, + amount_in: input.amount_in, + slippage_bps: input.slippage.bps(), + expected_fee_bps: input.expected_fee_bps, + }, + prepared.quote, + plan, + sources, + Vec::new(), + funding, + CallerAmounts::new(prepared.quote.amount_in, prepared.quote.amount_out), + input.deadline, + Some(spot_change), + ) +} + +/// Quotes an exact-output swap and emits one exact swap plan. +pub fn prepare_swap_exact_output_transaction( + input: SwapExactOutputTransactionInput<'_>, +) -> Result, TransactionError> { + let (context, active) = input.pool_accounts.validate( + input.amm_program_id, + input.input_token_definition_id, + input.output_token_definition_id, + )?; + let snapshot = active.pool(); + validate_expected_fee(snapshot, input.expected_fee_bps)?; + let (input_definition, output_definition) = swap_definitions( + snapshot, + input.input_token_definition_id, + input.output_token_definition_id, + )?; + let input_holding = + ValidatedFungibleHolding::new(&context, input.input_holding, input_definition)?; + let output_holding = + ValidatedFungibleHolding::new(&context, input.output_holding, output_definition)?; + let prepared = prepare_swap_exact_output( + snapshot, + &input_holding, + &output_holding, + input.exact_amount_out, + input.slippage, + )?; + let spot_change = pool_spot_change_bps(snapshot.pool(), &prepared.quote)?; + let pool = PoolContext::new(&context, snapshot.pool_id(), snapshot.pool())?; + let plan = plan_swap_exact_output(SwapExactOutputPlanInput { + context: &context, + pool, + user_input_holding: input_holding.account_id(), + user_output_holding: output_holding.account_id(), + exact_amount_out: prepared.exact_amount_out, + max_amount_in: prepared.max_amount_in, + deadline: input.deadline, + }); + let sources = pool_sources( + input.pool_accounts, + input.input_holding, + input.output_holding, + None, + ); + let funding = vec![funding(&input_holding, prepared.max_amount_in)]; + + PreparedTransaction::new( + TransactionOperation::SwapExactOutput, + TransactionIntent::SwapExactOutput { + input_token_definition_id: input.input_token_definition_id, + output_token_definition_id: input.output_token_definition_id, + exact_amount_out: input.exact_amount_out, + slippage_bps: input.slippage.bps(), + expected_fee_bps: input.expected_fee_bps, + }, + prepared.quote, + plan, + sources, + Vec::new(), + funding, + CallerAmounts::new(prepared.quote.amount_in, prepared.quote.amount_out), + input.deadline, + Some(spot_change), + ) +} + +impl PreparedTransaction { + #[expect( + clippy::too_many_arguments, + reason = "prepared transaction construction binds each externally visible artifact" + )] + fn new( + operation: TransactionOperation, + intent: TransactionIntent, + quote: Q, + plan: TransactionPlan, + sources: Vec, + fresh_account_ids: Vec, + funding: Vec, + caller_amounts: CallerAmounts, + deadline: u64, + pool_spot_change_bps: Option, + ) -> Result { + let mut unique_account_ids = Vec::new(); + for account_id in plan.account_ids() { + if unique_account_ids.contains(&account_id) { + return Err(TransactionError::DuplicateAccountId { account_id }); + } + unique_account_ids.push(account_id); + } + + let instruction_words = plan + .instruction_data() + .map_err(|_| TransactionError::InstructionEncoding)?; + let plan_accounts = plan + .accounts() + .iter() + .map(|account| PlanAccountCommitment { + role: String::from(account.role().as_str()), + account_id: account.id(), + writable: account.writable(), + signer: account.signer(), + init: account.init(), + }) + .collect(); + let payload = PreparedCommitmentPayload { + domain: String::from(COMMITMENT_DOMAIN), + operation, + intent, + program_id: plan.program_id(), + instruction_words, + plan_accounts, + sources, + caller_amounts, + deadline, + }; + let words = risc0_zkvm::serde::to_vec(&payload) + .map_err(|_| TransactionError::CommitmentEncoding)?; + let mut bytes = Vec::with_capacity(words.len().saturating_mul(4)); + for word in words { + bytes.extend_from_slice(&word.to_le_bytes()); + } + let digest = Impl::hash_bytes(&bytes); + let mut commitment_bytes = [0_u8; 32]; + commitment_bytes.copy_from_slice(digest.as_bytes()); + let affected_account_ids = plan.affected_account_ids(); + let wallet_prerequisites = WalletPrerequisites { + signer_account_ids: plan.signer_account_ids(), + fresh_account_ids, + funding, + }; + + Ok(Self { + operation, + quote, + plan, + quote_commitment: QuoteCommitment(commitment_bytes), + affected_account_ids, + wallet_prerequisites, + caller_amounts, + deadline, + pool_spot_change_bps, + }) + } +} + +#[derive(Serialize)] +struct PreparedCommitmentPayload { + domain: String, + operation: TransactionOperation, + intent: TransactionIntent, + program_id: ProgramId, + instruction_words: Vec, + plan_accounts: Vec, + sources: Vec, + caller_amounts: CallerAmounts, + deadline: u64, +} + +#[derive(Serialize)] +enum TransactionIntent { + CreatePool { + first_token_definition_id: AccountId, + second_token_definition_id: AccountId, + first_amount: u128, + second_amount: u128, + fee_bps: u128, + }, + AddLiquidity { + first_token_definition_id: AccountId, + second_token_definition_id: AccountId, + max_first_amount: u128, + max_second_amount: u128, + slippage_bps: u128, + expected_fee_bps: Option, + }, + RemoveLiquidity { + first_token_definition_id: AccountId, + second_token_definition_id: AccountId, + remove_liquidity_amount: u128, + slippage_bps: u128, + expected_fee_bps: Option, + }, + SwapExactInput { + input_token_definition_id: AccountId, + output_token_definition_id: AccountId, + amount_in: u128, + slippage_bps: u128, + expected_fee_bps: Option, + }, + SwapExactOutput { + input_token_definition_id: AccountId, + output_token_definition_id: AccountId, + exact_amount_out: u128, + slippage_bps: u128, + expected_fee_bps: Option, + }, +} + +#[derive(Serialize)] +struct PlanAccountCommitment { + role: String, + account_id: AccountId, + writable: bool, + signer: bool, + init: bool, +} + +#[derive(Clone, Copy, Serialize)] +enum SnapshotRole { + Config, + Pool, + CallerFirstDefinition, + CallerSecondDefinition, + CallerFirstVault, + CallerSecondVault, + LiquidityDefinition, + LpLockHolding, + CallerFirstHolding, + CallerSecondHolding, + LiquidityHolding, +} + +#[derive(Serialize)] +struct SnapshotCommitment { + role: SnapshotRole, + account_id: AccountId, + commitment: [u8; 32], +} + +fn source(role: SnapshotRole, snapshot: &AccountSnapshot) -> SnapshotCommitment { + SnapshotCommitment { + role, + account_id: snapshot.account_id(), + commitment: Commitment::new(&snapshot.account_id(), snapshot.account()).to_byte_array(), + } +} + +fn pool_sources( + pool: PoolAccountSnapshots<'_>, + first_holding: &AccountSnapshot, + second_holding: &AccountSnapshot, + liquidity_holding: Option<&AccountSnapshot>, +) -> Vec { + pair_sources( + pool.config, + pool.pair, + first_holding, + second_holding, + liquidity_holding, + ) +} + +fn pair_sources( + config: &AccountSnapshot, + pair: PairReadSnapshots<'_>, + first_holding: &AccountSnapshot, + second_holding: &AccountSnapshot, + liquidity_holding: Option<&AccountSnapshot>, +) -> Vec { + let mut sources = vec![ + source(SnapshotRole::Config, config), + source(SnapshotRole::Pool, pair.pool), + source( + SnapshotRole::CallerFirstDefinition, + pair.first_token_definition, + ), + source( + SnapshotRole::CallerSecondDefinition, + pair.second_token_definition, + ), + source(SnapshotRole::CallerFirstVault, pair.first_token_vault), + source(SnapshotRole::CallerSecondVault, pair.second_token_vault), + source(SnapshotRole::LiquidityDefinition, pair.liquidity_definition), + source(SnapshotRole::LpLockHolding, pair.lp_lock_holding), + source(SnapshotRole::CallerFirstHolding, first_holding), + source(SnapshotRole::CallerSecondHolding, second_holding), + ]; + if let Some(liquidity_holding) = liquidity_holding { + sources.push(source(SnapshotRole::LiquidityHolding, liquidity_holding)); + } + 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, +) -> Result<(), TransactionError> { + if let Some(expected) = expected_fee_bps { + let actual = snapshot.pool().fees; + if expected != actual { + return Err(TransactionError::FeeMismatch { expected, actual }); + } + } + 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(order: PairOrder, first: T, second: T) -> (T, T) { + match order { + PairOrder::Stored => (first, second), + PairOrder::Reversed => (second, first), + } +} + +fn swap_definitions( + snapshot: &ValidatedPoolSnapshot, + 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())), + } +} + +fn validate_holding_destination( + context: &AmmContext, + snapshot: &AccountSnapshot, + expected_definition_id: AccountId, + account_name: &'static str, +) -> Result { + if snapshot.account() == &Account::default() { + return Ok(true); + } + if snapshot.account().program_owner != context.token_program_id() { + return Err(ClientError::ProgramOwnerMismatch { + account: account_name, + expected: context.token_program_id(), + actual: snapshot.account().program_owner, + }); + } + let holding = TokenHolding::try_from(&snapshot.account().data).map_err(|_| { + ClientError::InvalidAccountData { + account: account_name, + expected: "fungible TokenHolding", + } + })?; + let TokenHolding::Fungible { definition_id, .. } = holding else { + return Err(ClientError::ExpectedFungibleToken { + account: account_name, + }); + }; + if definition_id != expected_definition_id { + return Err(ClientError::TokenDefinitionMismatch { + account: account_name, + expected: expected_definition_id, + actual: definition_id, + }); + } + Ok(false) +} + +fn ensure_funded( + holding: &ValidatedFungibleHolding, + required: u128, + account_name: &'static str, +) -> Result<(), ClientError> { + if holding.balance() < required { + return Err(ClientError::InsufficientBalance { + account: account_name, + available: holding.balance(), + required, + }); + } + Ok(()) +} + +fn funding(holding: &ValidatedFungibleHolding, required: u128) -> FundingRequirement { + FundingRequirement { + holding_account_id: holding.account_id(), + token_definition_id: holding.definition_id(), + available: holding.balance(), + required, + } +} diff --git a/programs/amm/client/src/wire.rs b/programs/amm/client/src/wire.rs index 7d65e5c..ab9625d 100644 --- a/programs/amm/client/src/wire.rs +++ b/programs/amm/client/src/wire.rs @@ -3,7 +3,8 @@ use std::{error::Error, fmt, str::FromStr}; use amm_core::{ - AmmConfig, PoolDefinition, FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY, SUPPORTED_FEE_TIERS, + AmmConfig, Instruction, PoolDefinition, FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY, + SUPPORTED_FEE_TIERS, }; use amm_program::quote::{ AddLiquidityQuote, CreatePoolQuote, OraclePriceAccountQuote, PairOrder, PoolUpdate, @@ -17,6 +18,7 @@ use serde::Deserialize; use serde_json::{json, Value}; use crate::{ + 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, @@ -25,13 +27,21 @@ use crate::{ ValidatedFungibleHolding, ValidatedPoolSnapshot, }, AddLiquidityPlanInput, AmmContext, ClientError, CreateOraclePriceAccountPlanInput, - CreatePoolPlanInput, CreatePriceObservationsPlanInput, InitializePlanInput, PoolContext, - PreparedAddLiquidity, PreparedCreatePool, PreparedRemoveLiquidity, PreparedSwapExactInput, - PreparedSwapExactOutput, RemoveLiquidityPlanInput, SlippageTolerance, SwapExactInputPlanInput, - SwapExactOutputPlanInput, SyncReservesPlanInput, TransactionPlan, UpdateConfigPlanInput, + 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, }; +/// Version of the reusable AMM client JSON contract. +/// +/// This identifies client payload shape only. It is intentionally unrelated to a deployed AMM +/// ProgramId, ImageID, or release version. +pub const WIRE_SCHEMA: &str = "amm-client.v1"; + /// Stable transport failure returned by the JSON and C ABI adapters. #[derive(Clone, Debug, Eq, PartialEq)] pub struct WireError { @@ -68,6 +78,18 @@ impl From for WireError { } } +impl From for WireError { + fn from(error: IntentError) -> Self { + Self::new(error.code(), error.to_string()) + } +} + +impl From for WireError { + fn from(error: TransactionError) -> Self { + Self::new(error.code(), error.to_string()) + } +} + #[derive(Deserialize)] #[serde(tag = "operation", rename_all = "snake_case")] enum PlanRequest { @@ -257,6 +279,196 @@ impl PoolInput { #[serde(tag = "operation", rename_all = "snake_case")] enum QuoteRequest { ProtocolConstants, + DeriveConfigId { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + }, + InspectConfig { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + }, + CanonicalPair { + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + }, + DerivePairReadManifest { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + }, + InspectPair { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + snapshots: PairReadSnapshotsInput, + }, + PrepareCallerOpeningPair { + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + #[serde(rename = "desiredPriceQ64_64")] + desired_price_q64_64: String, + #[serde(rename = "feeBps")] + fee_bps: String, + intent: OpeningLiquidityIntentInput, + }, + PrepareCreatePoolTransaction { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + snapshots: Box, + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + #[serde(rename = "firstTokenHolding")] + first_token_holding: AccountSnapshotInput, + #[serde(rename = "secondTokenHolding")] + second_token_holding: AccountSnapshotInput, + #[serde(rename = "liquidityHolding")] + liquidity_holding: AccountSnapshotInput, + #[serde(rename = "firstAmount")] + first_amount: String, + #[serde(rename = "secondAmount")] + second_amount: String, + #[serde(rename = "feeBps")] + fee_bps: String, + deadline: String, + }, + PrepareAddLiquidityTransaction { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + snapshots: Box, + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + #[serde(rename = "firstTokenHolding")] + first_token_holding: AccountSnapshotInput, + #[serde(rename = "secondTokenHolding")] + second_token_holding: AccountSnapshotInput, + #[serde(rename = "liquidityHolding")] + liquidity_holding: AccountSnapshotInput, + #[serde(rename = "maxFirstAmount")] + max_first_amount: String, + #[serde(rename = "maxSecondAmount")] + max_second_amount: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + #[serde(rename = "expectedFeeBps")] + expected_fee_bps: Option, + deadline: String, + }, + PrepareRemoveLiquidityTransaction { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + snapshots: Box, + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + #[serde(rename = "firstTokenHolding")] + first_token_holding: AccountSnapshotInput, + #[serde(rename = "secondTokenHolding")] + second_token_holding: AccountSnapshotInput, + #[serde(rename = "liquidityHolding")] + liquidity_holding: AccountSnapshotInput, + #[serde(rename = "removeLiquidityAmount")] + remove_liquidity_amount: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + #[serde(rename = "expectedFeeBps")] + expected_fee_bps: Option, + deadline: String, + }, + PrepareSwapExactInputTransaction { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + snapshots: Box, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "outputTokenDefinitionId")] + output_token_definition_id: String, + #[serde(rename = "inputHolding")] + input_holding: AccountSnapshotInput, + #[serde(rename = "outputHolding")] + output_holding: AccountSnapshotInput, + #[serde(rename = "amountIn")] + amount_in: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + #[serde(rename = "expectedFeeBps")] + expected_fee_bps: Option, + deadline: String, + }, + PrepareSwapExactOutputTransaction { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + snapshots: Box, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "outputTokenDefinitionId")] + output_token_definition_id: String, + #[serde(rename = "inputHolding")] + input_holding: AccountSnapshotInput, + #[serde(rename = "outputHolding")] + output_holding: AccountSnapshotInput, + #[serde(rename = "exactAmountOut")] + exact_amount_out: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + #[serde(rename = "expectedFeeBps")] + expected_fee_bps: Option, + deadline: String, + }, + PrepareMinimumOpeningPair { + #[serde(rename = "desiredPriceQ64_64")] + desired_price_q64_64: String, + #[serde(rename = "feeBps")] + fee_bps: String, + }, + PrepareOpeningFromTokenA { + #[serde(rename = "tokenAAmount")] + token_a_amount: String, + #[serde(rename = "desiredPriceQ64_64")] + desired_price_q64_64: String, + #[serde(rename = "feeBps")] + fee_bps: String, + }, + PrepareOpeningFromTokenB { + #[serde(rename = "tokenBAmount")] + token_b_amount: String, + #[serde(rename = "desiredPriceQ64_64")] + desired_price_q64_64: String, + #[serde(rename = "feeBps")] + fee_bps: String, + }, + ValidateExplicitOpeningPair { + #[serde(rename = "tokenAAmount")] + token_a_amount: String, + #[serde(rename = "tokenBAmount")] + token_b_amount: String, + #[serde(rename = "desiredPriceQ64_64")] + desired_price_q64_64: String, + #[serde(rename = "feeBps")] + fee_bps: String, + }, PairOrder { #[serde(flatten)] state: PoolStateInput, @@ -473,6 +685,103 @@ struct PoolSnapshotInput { liquidity_definition: AccountSnapshotInput, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PairReadSnapshotsInput { + pool: AccountSnapshotInput, + first_token_definition: AccountSnapshotInput, + second_token_definition: AccountSnapshotInput, + first_token_vault: AccountSnapshotInput, + second_token_vault: AccountSnapshotInput, + liquidity_definition: AccountSnapshotInput, + lp_lock_holding: AccountSnapshotInput, + current_tick: AccountSnapshotInput, + clock: AccountSnapshotInput, +} + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum OpeningLiquidityIntentInput { + Minimum, + FirstAmount { + amount: String, + }, + SecondAmount { + amount: String, + }, + Explicit { + #[serde(rename = "firstAmount")] + first_amount: String, + #[serde(rename = "secondAmount")] + second_amount: String, + }, +} + +impl OpeningLiquidityIntentInput { + fn into_intent(self) -> Result { + Ok(match self { + Self::Minimum => OpeningLiquidityIntent::Minimum, + Self::FirstAmount { amount } => { + OpeningLiquidityIntent::FirstAmount(decimal_u128(&amount, "intent.amount")?) + } + Self::SecondAmount { amount } => { + OpeningLiquidityIntent::SecondAmount(decimal_u128(&amount, "intent.amount")?) + } + Self::Explicit { + first_amount, + second_amount, + } => OpeningLiquidityIntent::Explicit { + first_amount: decimal_u128(&first_amount, "intent.firstAmount")?, + second_amount: decimal_u128(&second_amount, "intent.secondAmount")?, + }, + }) + } +} + +struct OwnedPairReadSnapshots { + pool: AccountSnapshot, + first_token_definition: AccountSnapshot, + second_token_definition: AccountSnapshot, + first_token_vault: AccountSnapshot, + second_token_vault: AccountSnapshot, + liquidity_definition: AccountSnapshot, + lp_lock_holding: AccountSnapshot, + current_tick: AccountSnapshot, + clock: AccountSnapshot, +} + +impl PairReadSnapshotsInput { + fn into_snapshots(self) -> Result { + Ok(OwnedPairReadSnapshots { + pool: self.pool.into_snapshot()?, + first_token_definition: self.first_token_definition.into_snapshot()?, + second_token_definition: self.second_token_definition.into_snapshot()?, + first_token_vault: self.first_token_vault.into_snapshot()?, + second_token_vault: self.second_token_vault.into_snapshot()?, + liquidity_definition: self.liquidity_definition.into_snapshot()?, + lp_lock_holding: self.lp_lock_holding.into_snapshot()?, + current_tick: self.current_tick.into_snapshot()?, + clock: self.clock.into_snapshot()?, + }) + } +} + +impl OwnedPairReadSnapshots { + const fn as_borrowed(&self) -> discovery::PairReadSnapshots<'_> { + discovery::PairReadSnapshots { + pool: &self.pool, + first_token_definition: &self.first_token_definition, + second_token_definition: &self.second_token_definition, + first_token_vault: &self.first_token_vault, + second_token_vault: &self.second_token_vault, + liquidity_definition: &self.liquidity_definition, + lp_lock_holding: &self.lp_lock_holding, + current_tick: &self.current_tick, + clock: &self.clock, + } + } +} + impl PoolSnapshotInput { fn validate(self, context: &AmmContext) -> Result { let pool = self.pool.into_snapshot()?; @@ -521,8 +830,16 @@ impl AccountSnapshotInput { } } -/// Builds one of the ten canonical transaction plans from tagged JSON. +/// Builds a canonical low-level plan or prepares a snapshot-bound task transaction from JSON. pub fn plan_json(value: Value) -> Result { + validate_wire_schema(&value)?; + if value + .get("operation") + .and_then(Value::as_str) + .is_some_and(is_prepared_transaction_operation) + { + return quote_json(value); + } let request: PlanRequest = serde_json::from_value(value) .map_err(|error| invalid_request(format!("invalid plan request: {error}")))?; let plan = match request { @@ -722,14 +1039,26 @@ pub fn plan_json(value: Value) -> Result { } }; - transaction_plan_json(&plan) + versioned(transaction_plan_json(&plan)) +} + +fn is_prepared_transaction_operation(operation: &str) -> bool { + matches!( + operation, + "prepare_create_pool_transaction" + | "prepare_add_liquidity_transaction" + | "prepare_remove_liquidity_transaction" + | "prepare_swap_exact_input_transaction" + | "prepare_swap_exact_output_transaction" + ) } /// Evaluates one reusable AMM economic quote from tagged JSON. pub fn quote_json(value: Value) -> Result { + validate_wire_schema(&value)?; let request: QuoteRequest = serde_json::from_value(value) .map_err(|error| invalid_request(format!("invalid quote request: {error}")))?; - match request { + versioned(match request { QuoteRequest::ProtocolConstants => Ok(json!({ "minimumLiquidity": MINIMUM_LIQUIDITY.to_string(), "feeBpsDenominator": FEE_BPS_DENOMINATOR.to_string(), @@ -739,6 +1068,339 @@ pub fn quote_json(value: Value) -> Result { .map(u128::to_string) .collect::>(), })), + QuoteRequest::DeriveConfigId { amm_program_id } => Ok(json!({ + "configId": discovery::derive_config_id(amm_program_id).to_string(), + })), + QuoteRequest::InspectConfig { + amm_program_id, + config, + } => { + let config = config.into_snapshot()?; + let context = discovery::inspect_config(amm_program_id, &config)?; + Ok(amm_context_json(&context)) + } + QuoteRequest::CanonicalPair { + first_token_definition_id, + second_token_definition_id, + } => { + let pair = discovery::canonical_pair( + account_id(&first_token_definition_id, "firstTokenDefinitionId")?, + account_id(&second_token_definition_id, "secondTokenDefinitionId")?, + )?; + Ok(canonical_pair_json(pair)) + } + QuoteRequest::DerivePairReadManifest { + amm_program_id, + config, + first_token_definition_id, + second_token_definition_id, + } => { + let config = config.into_snapshot()?; + let context = discovery::inspect_config(amm_program_id, &config)?; + let manifest = discovery::derive_pair_read_manifest( + &context, + account_id(&first_token_definition_id, "firstTokenDefinitionId")?, + account_id(&second_token_definition_id, "secondTokenDefinitionId")?, + )?; + Ok(pair_read_manifest_json(manifest)) + } + QuoteRequest::InspectPair { + amm_program_id, + config, + first_token_definition_id, + second_token_definition_id, + snapshots, + } => { + let config = config.into_snapshot()?; + let context = discovery::inspect_config(amm_program_id, &config)?; + let snapshots = snapshots.into_snapshots()?; + let inspected = discovery::inspect_pair( + &context, + account_id(&first_token_definition_id, "firstTokenDefinitionId")?, + account_id(&second_token_definition_id, "secondTokenDefinitionId")?, + snapshots.as_borrowed(), + )?; + Ok(pair_inspection_json(inspected)) + } + QuoteRequest::PrepareCallerOpeningPair { + first_token_definition_id, + second_token_definition_id, + desired_price_q64_64, + fee_bps, + intent, + } => Ok(prepared_caller_opening_pair_json( + crate::prepare_caller_opening_pair( + account_id(&first_token_definition_id, "firstTokenDefinitionId")?, + account_id(&second_token_definition_id, "secondTokenDefinitionId")?, + decimal_u128(&desired_price_q64_64, "desiredPriceQ64_64")?, + decimal_u128(&fee_bps, "feeBps")?, + intent.into_intent()?, + )?, + )), + QuoteRequest::PrepareCreatePoolTransaction { + amm_program_id, + config, + snapshots, + first_token_definition_id, + second_token_definition_id, + first_token_holding, + second_token_holding, + liquidity_holding, + first_amount, + second_amount, + fee_bps, + deadline, + } => { + let config = config.into_snapshot()?; + let snapshots = (*snapshots).into_snapshots()?; + let first_token_holding = first_token_holding.into_snapshot()?; + let second_token_holding = second_token_holding.into_snapshot()?; + let liquidity_holding = liquidity_holding.into_snapshot()?; + let prepared = + crate::prepare_create_pool_transaction(crate::CreatePoolTransactionInput { + amm_program_id, + config: &config, + pair: snapshots.as_borrowed(), + first_token_definition_id: account_id( + &first_token_definition_id, + "firstTokenDefinitionId", + )?, + second_token_definition_id: account_id( + &second_token_definition_id, + "secondTokenDefinitionId", + )?, + first_token_holding: &first_token_holding, + second_token_holding: &second_token_holding, + liquidity_holding: &liquidity_holding, + first_amount: decimal_u128(&first_amount, "firstAmount")?, + second_amount: decimal_u128(&second_amount, "secondAmount")?, + fee_bps: decimal_u128(&fee_bps, "feeBps")?, + deadline: decimal_u64(&deadline, "deadline")?, + })?; + prepared_transaction_json(&prepared, create_pool_quote_json(*prepared.quote())) + } + QuoteRequest::PrepareAddLiquidityTransaction { + amm_program_id, + config, + snapshots, + first_token_definition_id, + second_token_definition_id, + first_token_holding, + second_token_holding, + liquidity_holding, + max_first_amount, + max_second_amount, + slippage_bps, + expected_fee_bps, + deadline, + } => { + let config = config.into_snapshot()?; + let snapshots = (*snapshots).into_snapshots()?; + let first_token_holding = first_token_holding.into_snapshot()?; + let second_token_holding = second_token_holding.into_snapshot()?; + let liquidity_holding = liquidity_holding.into_snapshot()?; + let prepared = + crate::prepare_add_liquidity_transaction(crate::AddLiquidityTransactionInput { + amm_program_id, + pool_accounts: crate::PoolAccountSnapshots { + config: &config, + pair: snapshots.as_borrowed(), + }, + first_token_definition_id: account_id( + &first_token_definition_id, + "firstTokenDefinitionId", + )?, + second_token_definition_id: account_id( + &second_token_definition_id, + "secondTokenDefinitionId", + )?, + first_token_holding: &first_token_holding, + second_token_holding: &second_token_holding, + liquidity_holding: &liquidity_holding, + max_first_amount: decimal_u128(&max_first_amount, "maxFirstAmount")?, + max_second_amount: decimal_u128(&max_second_amount, "maxSecondAmount")?, + slippage: slippage_tolerance(&slippage_bps)?, + expected_fee_bps: optional_decimal_u128(expected_fee_bps, "expectedFeeBps")?, + deadline: decimal_u64(&deadline, "deadline")?, + })?; + prepared_transaction_json(&prepared, add_liquidity_quote_json(*prepared.quote())) + } + QuoteRequest::PrepareRemoveLiquidityTransaction { + amm_program_id, + config, + snapshots, + first_token_definition_id, + second_token_definition_id, + first_token_holding, + second_token_holding, + liquidity_holding, + remove_liquidity_amount, + slippage_bps, + expected_fee_bps, + deadline, + } => { + let config = config.into_snapshot()?; + let snapshots = (*snapshots).into_snapshots()?; + let first_token_holding = first_token_holding.into_snapshot()?; + let second_token_holding = second_token_holding.into_snapshot()?; + let liquidity_holding = liquidity_holding.into_snapshot()?; + let prepared = crate::prepare_remove_liquidity_transaction( + crate::RemoveLiquidityTransactionInput { + amm_program_id, + pool_accounts: crate::PoolAccountSnapshots { + config: &config, + pair: snapshots.as_borrowed(), + }, + first_token_definition_id: account_id( + &first_token_definition_id, + "firstTokenDefinitionId", + )?, + second_token_definition_id: account_id( + &second_token_definition_id, + "secondTokenDefinitionId", + )?, + first_token_holding: &first_token_holding, + second_token_holding: &second_token_holding, + liquidity_holding: &liquidity_holding, + remove_liquidity_amount: decimal_u128( + &remove_liquidity_amount, + "removeLiquidityAmount", + )?, + slippage: slippage_tolerance(&slippage_bps)?, + expected_fee_bps: optional_decimal_u128(expected_fee_bps, "expectedFeeBps")?, + deadline: decimal_u64(&deadline, "deadline")?, + }, + )?; + prepared_transaction_json(&prepared, remove_liquidity_quote_json(*prepared.quote())) + } + QuoteRequest::PrepareSwapExactInputTransaction { + amm_program_id, + config, + snapshots, + input_token_definition_id, + output_token_definition_id, + input_holding, + output_holding, + amount_in, + slippage_bps, + expected_fee_bps, + deadline, + } => { + let config = config.into_snapshot()?; + let snapshots = (*snapshots).into_snapshots()?; + let input_holding = input_holding.into_snapshot()?; + let output_holding = output_holding.into_snapshot()?; + let prepared = crate::prepare_swap_exact_input_transaction( + crate::SwapExactInputTransactionInput { + amm_program_id, + pool_accounts: crate::PoolAccountSnapshots { + config: &config, + pair: snapshots.as_borrowed(), + }, + input_token_definition_id: account_id( + &input_token_definition_id, + "inputTokenDefinitionId", + )?, + output_token_definition_id: account_id( + &output_token_definition_id, + "outputTokenDefinitionId", + )?, + input_holding: &input_holding, + output_holding: &output_holding, + amount_in: decimal_u128(&amount_in, "amountIn")?, + slippage: slippage_tolerance(&slippage_bps)?, + expected_fee_bps: optional_decimal_u128(expected_fee_bps, "expectedFeeBps")?, + deadline: decimal_u64(&deadline, "deadline")?, + }, + )?; + prepared_transaction_json(&prepared, swap_quote_json(*prepared.quote())) + } + QuoteRequest::PrepareSwapExactOutputTransaction { + amm_program_id, + config, + snapshots, + input_token_definition_id, + output_token_definition_id, + input_holding, + output_holding, + exact_amount_out, + slippage_bps, + expected_fee_bps, + deadline, + } => { + let config = config.into_snapshot()?; + let snapshots = (*snapshots).into_snapshots()?; + let input_holding = input_holding.into_snapshot()?; + let output_holding = output_holding.into_snapshot()?; + let prepared = crate::prepare_swap_exact_output_transaction( + crate::SwapExactOutputTransactionInput { + amm_program_id, + pool_accounts: crate::PoolAccountSnapshots { + config: &config, + pair: snapshots.as_borrowed(), + }, + input_token_definition_id: account_id( + &input_token_definition_id, + "inputTokenDefinitionId", + )?, + output_token_definition_id: account_id( + &output_token_definition_id, + "outputTokenDefinitionId", + )?, + input_holding: &input_holding, + output_holding: &output_holding, + exact_amount_out: decimal_u128(&exact_amount_out, "exactAmountOut")?, + slippage: slippage_tolerance(&slippage_bps)?, + expected_fee_bps: optional_decimal_u128(expected_fee_bps, "expectedFeeBps")?, + deadline: decimal_u64(&deadline, "deadline")?, + }, + )?; + prepared_transaction_json(&prepared, swap_quote_json(*prepared.quote())) + } + QuoteRequest::PrepareMinimumOpeningPair { + desired_price_q64_64, + fee_bps, + } => Ok(prepared_opening_pair_json( + crate::prepare_minimum_opening_pair( + decimal_u128(&desired_price_q64_64, "desiredPriceQ64_64")?, + decimal_u128(&fee_bps, "feeBps")?, + )?, + )), + QuoteRequest::PrepareOpeningFromTokenA { + token_a_amount, + desired_price_q64_64, + fee_bps, + } => Ok(prepared_opening_pair_json( + crate::prepare_opening_from_token_a( + decimal_u128(&token_a_amount, "tokenAAmount")?, + decimal_u128(&desired_price_q64_64, "desiredPriceQ64_64")?, + decimal_u128(&fee_bps, "feeBps")?, + )?, + )), + QuoteRequest::PrepareOpeningFromTokenB { + token_b_amount, + desired_price_q64_64, + fee_bps, + } => Ok(prepared_opening_pair_json( + crate::prepare_opening_from_token_b( + decimal_u128(&token_b_amount, "tokenBAmount")?, + decimal_u128(&desired_price_q64_64, "desiredPriceQ64_64")?, + decimal_u128(&fee_bps, "feeBps")?, + )?, + )), + QuoteRequest::ValidateExplicitOpeningPair { + token_a_amount, + token_b_amount, + desired_price_q64_64, + fee_bps, + } => Ok(prepared_opening_pair_json( + crate::validate_explicit_opening_pair( + decimal_u128(&token_a_amount, "tokenAAmount")?, + decimal_u128(&token_b_amount, "tokenBAmount")?, + decimal_u128(&desired_price_q64_64, "desiredPriceQ64_64")?, + decimal_u128(&fee_bps, "feeBps")?, + )?, + )), QuoteRequest::PairOrder { state, first_token_definition_id, @@ -1074,7 +1736,7 @@ pub fn quote_json(value: Value) -> Result { )?, )) } - } + }) } fn transaction_plan_json(plan: &TransactionPlan) -> Result { @@ -1100,12 +1762,129 @@ fn transaction_plan_json(plan: &TransactionPlan) -> Result { Ok(json!({ "instruction": plan.instruction_name(), + "instructionArgs": instruction_args_json(plan.instruction()), "programId": plan.program_id(), "accounts": accounts, + "affectedAccountIds": plan + .affected_account_ids() + .into_iter() + .map(|id| id.to_string()) + .collect::>(), "instructionWords": instruction_words, })) } +fn instruction_args_json(instruction: &Instruction) -> Value { + match instruction { + Instruction::Initialize { + token_program_id, + twap_oracle_program_id, + authority, + } => json!({ + "tokenProgramId": token_program_id, + "twapOracleProgramId": twap_oracle_program_id, + "authority": authority.to_string(), + }), + Instruction::UpdateConfig { + token_program_id, + twap_oracle_program_id, + new_authority, + } => json!({ + "tokenProgramId": token_program_id, + "twapOracleProgramId": twap_oracle_program_id, + "newAuthority": new_authority.map(|authority| authority.to_string()), + }), + Instruction::CreatePriceObservations { window_duration } + | Instruction::CreateOraclePriceAccount { window_duration } => json!({ + "windowDuration": window_duration.to_string(), + }), + Instruction::NewDefinition { + token_a_amount, + token_b_amount, + fees, + deadline, + } => json!({ + "tokenAAmount": token_a_amount.to_string(), + "tokenBAmount": token_b_amount.to_string(), + "fees": fees.to_string(), + "deadline": deadline.to_string(), + }), + Instruction::AddLiquidity { + min_amount_liquidity, + max_amount_to_add_token_a, + max_amount_to_add_token_b, + deadline, + } => json!({ + "minAmountLiquidity": min_amount_liquidity.to_string(), + "maxAmountToAddTokenA": max_amount_to_add_token_a.to_string(), + "maxAmountToAddTokenB": max_amount_to_add_token_b.to_string(), + "deadline": deadline.to_string(), + }), + Instruction::RemoveLiquidity { + remove_liquidity_amount, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + deadline, + } => json!({ + "removeLiquidityAmount": remove_liquidity_amount.to_string(), + "minAmountToRemoveTokenA": min_amount_to_remove_token_a.to_string(), + "minAmountToRemoveTokenB": min_amount_to_remove_token_b.to_string(), + "deadline": deadline.to_string(), + }), + Instruction::SwapExactInput { + swap_amount_in, + min_amount_out, + deadline, + } => json!({ + "swapAmountIn": swap_amount_in.to_string(), + "minAmountOut": min_amount_out.to_string(), + "deadline": deadline.to_string(), + }), + Instruction::SwapExactOutput { + exact_amount_out, + max_amount_in, + deadline, + } => json!({ + "exactAmountOut": exact_amount_out.to_string(), + "maxAmountIn": max_amount_in.to_string(), + "deadline": deadline.to_string(), + }), + Instruction::SyncReserves => json!({}), + } +} + +fn validate_wire_schema(value: &Value) -> Result<(), WireError> { + let Some(schema) = value.get("schema") else { + return Ok(()); + }; + let Some(schema) = schema.as_str() else { + return Err(invalid_request("schema must be a string")); + }; + if schema == WIRE_SCHEMA { + Ok(()) + } else { + Err(WireError::new( + "unsupported_schema", + format!("unsupported AMM client schema {schema}"), + )) + } +} + +fn versioned(result: Result) -> Result { + let mut value = result?; + let Some(object) = value.as_object_mut() else { + return Err(WireError::new( + "response_serialization_failed", + "AMM client response must be a JSON object", + )); + }; + object.insert( + String::from("schema"), + Value::String(String::from(WIRE_SCHEMA)), + ); + Ok(value) +} + fn pool_definition<'a>( snapshot: &'a ValidatedPoolSnapshot, definition_id: AccountId, @@ -1163,6 +1942,209 @@ fn pool_update_json(pool: PoolUpdate) -> Value { }) } +fn amm_context_json(context: &AmmContext) -> Value { + json!({ + "ammProgramId": context.amm_program_id, + "configId": context.config_id().to_string(), + "tokenProgramId": context.token_program_id(), + "twapOracleProgramId": context.twap_oracle_program_id(), + "authority": context.config.authority.to_string(), + }) +} + +fn canonical_pair_json(pair: CanonicalPair) -> Value { + json!({ + "tokenAId": pair.token_a_id().to_string(), + "tokenBId": pair.token_b_id().to_string(), + }) +} + +fn pair_read_manifest_json(manifest: PairReadManifest) -> Value { + let first_token = manifest.first_token(); + let second_token = manifest.second_token(); + json!({ + "canonicalPair": canonical_pair_json(manifest.canonical_pair()), + "firstToken": { + "definitionId": first_token.definition_id().to_string(), + "vaultId": first_token.vault_id().to_string(), + }, + "secondToken": { + "definitionId": second_token.definition_id().to_string(), + "vaultId": second_token.vault_id().to_string(), + }, + "configId": manifest.config_id().to_string(), + "poolId": manifest.pool_id().to_string(), + "liquidityDefinitionId": manifest.liquidity_definition_id().to_string(), + "lpLockHoldingId": manifest.lp_lock_holding_id().to_string(), + "currentTickId": manifest.current_tick_id().to_string(), + "clockId": manifest.clock_id().to_string(), + }) +} + +fn pair_inspection_json(inspection: discovery::PairInspection) -> Value { + match inspection { + discovery::PairInspection::Missing(missing) => json!({ + "status": "missing", + "manifest": pair_read_manifest_json(missing.manifest()), + "firstTokenDefinition": fungible_definition_json(missing.first_token_definition()), + "secondTokenDefinition": fungible_definition_json(missing.second_token_definition()), + "firstVault": missing_vault_json(missing.first_vault()), + "secondVault": missing_vault_json(missing.second_vault()), + "clock": clock_json(missing.clock()), + }), + discovery::PairInspection::Active(active) => { + let snapshot = active.pool(); + let pool = snapshot.pool(); + json!({ + "status": "active", + "manifest": pair_read_manifest_json(active.manifest()), + "callerOrder": match active.caller_order() { + PairOrder::Stored => "stored", + PairOrder::Reversed => "reversed", + }, + "stored": { + "tokenADefinitionId": pool.definition_token_a_id.to_string(), + "tokenBDefinitionId": pool.definition_token_b_id.to_string(), + "vaultAId": pool.vault_a_id.to_string(), + "vaultBId": pool.vault_b_id.to_string(), + "liquidityDefinitionId": pool.liquidity_pool_id.to_string(), + "lpLockHoldingId": active.lp_lock_holding().account_id().to_string(), + "reserveA": pool.reserve_a.to_string(), + "reserveB": pool.reserve_b.to_string(), + "vaultABalance": snapshot.vault_a().balance().to_string(), + "vaultBBalance": snapshot.vault_b().balance().to_string(), + "liquidityPoolSupply": pool.liquidity_pool_supply.to_string(), + "lpLockBalance": active.lp_lock_holding().balance().to_string(), + "feeBps": pool.fees.to_string(), + }, + "storedSpotPriceQ64_64": active.stored_spot_price_q64_64().to_string(), + "currentTick": { + "tick": active.current_tick().tick.to_string(), + "lastUpdated": active.current_tick().last_updated.to_string(), + }, + "clock": clock_json(active.clock()), + }) + } + } +} + +fn fungible_definition_json(definition: &ValidatedFungibleDefinition) -> Value { + json!({ + "id": definition.account_id().to_string(), + "totalSupply": definition.total_supply().to_string(), + "authority": definition.authority().map(|authority| authority.to_string()), + }) +} + +fn missing_vault_json(vault: discovery::MissingVaultState) -> Value { + match vault { + discovery::MissingVaultState::Uninitialized => json!({ + "status": "uninitialized", + }), + discovery::MissingVaultState::ExistingFungible { balance } => json!({ + "status": "existing_fungible", + "balance": balance.to_string(), + }), + } +} + +fn clock_json(clock: discovery::ValidatedClockSnapshot) -> Value { + json!({ + "blockId": clock.block_id().to_string(), + "timestamp": clock.timestamp().to_string(), + }) +} + +fn prepared_opening_pair_json(prepared: PreparedOpeningPair) -> Value { + json!({ + "desiredPriceQ64_64": prepared.desired_price_q64_64.to_string(), + "actualPriceQ64_64": prepared.actual_price_q64_64.to_string(), + "tokenAAmount": prepared.token_a_amount.to_string(), + "tokenBAmount": prepared.token_b_amount.to_string(), + "feeBps": prepared.fee_bps.to_string(), + "quote": create_pool_quote_json(prepared.quote), + }) +} + +fn prepared_caller_opening_pair_json(prepared: PreparedCallerOpeningPair) -> Value { + json!({ + "callerOrder": match prepared.caller_order() { + PairOrder::Stored => "stored", + PairOrder::Reversed => "reversed", + }, + "firstAmount": prepared.first_amount().to_string(), + "secondAmount": prepared.second_amount().to_string(), + "stored": prepared_opening_pair_json(*prepared.stored()), + }) +} + +fn prepared_transaction_json( + prepared: &PreparedTransaction, + quote: Value, +) -> Result { + Ok(json!({ + "operation": transaction_operation_name(prepared.operation()), + "quote": quote, + "callerAmounts": { + "first": prepared.caller_amounts().first().to_string(), + "second": prepared.caller_amounts().second().to_string(), + }, + "plan": transaction_plan_json(prepared.plan())?, + "quoteCommitment": quote_commitment_hex(prepared.quote_commitment()), + "affectedAccountIds": prepared + .affected_account_ids() + .iter() + .map(ToString::to_string) + .collect::>(), + "walletPrerequisites": wallet_prerequisites_json(prepared.wallet_prerequisites()), + "deadline": prepared.deadline().to_string(), + "poolSpotChangeBps": prepared.pool_spot_change_bps().map(|bps| bps.to_string()), + })) +} + +const fn transaction_operation_name(operation: TransactionOperation) -> &'static str { + match operation { + TransactionOperation::CreatePool => "create_pool", + TransactionOperation::AddLiquidity => "add_liquidity", + TransactionOperation::RemoveLiquidity => "remove_liquidity", + TransactionOperation::SwapExactInput => "swap_exact_input", + TransactionOperation::SwapExactOutput => "swap_exact_output", + } +} + +fn quote_commitment_hex(commitment: crate::QuoteCommitment) -> String { + commitment + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn wallet_prerequisites_json(prerequisites: &WalletPrerequisites) -> Value { + json!({ + "signerAccountIds": prerequisites + .signer_account_ids() + .iter() + .map(ToString::to_string) + .collect::>(), + "freshAccountIds": prerequisites + .fresh_account_ids() + .iter() + .map(ToString::to_string) + .collect::>(), + "funding": prerequisites + .funding() + .iter() + .map(|requirement| json!({ + "holdingAccountId": requirement.holding_account_id().to_string(), + "tokenDefinitionId": requirement.token_definition_id().to_string(), + "available": requirement.available().to_string(), + "required": requirement.required().to_string(), + })) + .collect::>(), + }) +} + fn create_pool_quote_json(quote: CreatePoolQuote) -> Value { json!({ "pool": pool_update_json(quote.pool), @@ -1286,6 +2268,13 @@ fn decimal_u64(value: &str, field: &str) -> Result { decimal(value, field) } +fn optional_decimal_u128(value: Option, field: &str) -> Result, WireError> { + value + .as_deref() + .map(|value| decimal_u128(value, field)) + .transpose() +} + fn slippage_tolerance(value: &str) -> Result { Ok(SlippageTolerance::new(decimal_u128(value, "slippageBps")?)?) } diff --git a/programs/amm/client/tests/discovery_contract.rs b/programs/amm/client/tests/discovery_contract.rs new file mode 100644 index 0000000..278beed --- /dev/null +++ b/programs/amm/client/tests/discovery_contract.rs @@ -0,0 +1,396 @@ +use amm_client::{ + discovery::{ + canonical_pair, derive_config_id, derive_pair_read_manifest, inspect_config, inspect_pair, + MissingVaultState, PairInspection, PairReadSnapshots, + }, + quote::AccountSnapshot, +}; +use amm_core::{AmmConfig, PoolDefinition, FEE_TIER_BPS_30}; +use amm_program::quote::PairOrder; +use clock_core::ClockAccountData; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::CurrentTickAccount; + +const AMM_PROGRAM_ID: ProgramId = [42; 8]; +const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; +const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; + +fn lower_token_id() -> AccountId { + AccountId::new([1; 32]) +} + +fn higher_token_id() -> AccountId { + AccountId::new([2; 32]) +} + +fn account(program_owner: ProgramId, data: Data) -> Account { + Account { + program_owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn config_snapshot() -> AccountSnapshot { + AccountSnapshot::new( + derive_config_id(AMM_PROGRAM_ID), + account( + AMM_PROGRAM_ID, + Data::from(&AmmConfig { + token_program_id: TOKEN_PROGRAM_ID, + twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, + authority: AccountId::new([9; 32]), + }), + ), + ) +} + +fn fungible_definition( + id: AccountId, + total_supply: u128, + authority: Option, +) -> AccountSnapshot { + AccountSnapshot::new( + id, + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenDefinition::Fungible { + name: String::from("Token"), + total_supply, + metadata_id: None, + authority, + }), + ), + ) +} + +fn fungible_holding( + id: AccountId, + program_owner: ProgramId, + definition_id: AccountId, + balance: u128, +) -> AccountSnapshot { + AccountSnapshot::new( + id, + account( + program_owner, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + ), + ) +} + +fn clock_snapshot(id: AccountId) -> AccountSnapshot { + let data = ClockAccountData { + block_id: 123, + timestamp: 456, + } + .to_bytes(); + AccountSnapshot::new( + id, + account([88; 8], Data::try_from(data).expect("clock data must fit")), + ) +} + +#[test] +fn config_and_pair_discovery_are_canonical_and_caller_ordered() { + let config = config_snapshot(); + let context = inspect_config(AMM_PROGRAM_ID, &config).expect("config must validate"); + let forward = derive_pair_read_manifest(&context, lower_token_id(), higher_token_id()) + .expect("distinct pair must derive"); + let reverse = derive_pair_read_manifest(&context, higher_token_id(), lower_token_id()) + .expect("distinct pair must derive"); + + assert_eq!(derive_config_id(AMM_PROGRAM_ID), config.account_id()); + assert_eq!(context.token_program_id(), TOKEN_PROGRAM_ID); + assert_eq!(context.twap_oracle_program_id(), TWAP_ORACLE_PROGRAM_ID); + assert_eq!( + canonical_pair(lower_token_id(), higher_token_id()) + .expect("distinct pair must canonicalize") + .token_a_id(), + higher_token_id() + ); + assert_eq!(forward.pool_id(), reverse.pool_id()); + assert_eq!(forward.first_token().definition_id(), lower_token_id()); + assert_eq!(reverse.second_token().definition_id(), lower_token_id()); + assert_eq!( + forward.vault_id_for(lower_token_id()), + reverse.vault_id_for(lower_token_id()) + ); + assert_eq!(forward.config_id(), config.account_id()); + assert_eq!(forward.clock_id(), clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID); +} + +#[test] +fn missing_pair_allows_transfer_compatible_existing_vault() { + let context = inspect_config(AMM_PROGRAM_ID, &config_snapshot()).expect("config must validate"); + let manifest = derive_pair_read_manifest(&context, lower_token_id(), higher_token_id()) + .expect("pair must derive"); + let pool = AccountSnapshot::new(manifest.pool_id(), Account::default()); + let first_definition = fungible_definition(lower_token_id(), 10_000, None); + let second_definition = fungible_definition(higher_token_id(), 20_000, None); + let first_vault = fungible_holding( + manifest.first_token().vault_id(), + TOKEN_PROGRAM_ID, + lower_token_id(), + 7, + ); + let second_vault = AccountSnapshot::new(manifest.second_token().vault_id(), Account::default()); + let liquidity_definition = + AccountSnapshot::new(manifest.liquidity_definition_id(), Account::default()); + let lp_lock = AccountSnapshot::new(manifest.lp_lock_holding_id(), Account::default()); + let current_tick = AccountSnapshot::new(manifest.current_tick_id(), Account::default()); + let clock = clock_snapshot(manifest.clock_id()); + + let inspected = inspect_pair( + &context, + lower_token_id(), + higher_token_id(), + PairReadSnapshots { + pool: &pool, + first_token_definition: &first_definition, + second_token_definition: &second_definition, + first_token_vault: &first_vault, + second_token_vault: &second_vault, + liquidity_definition: &liquidity_definition, + lp_lock_holding: &lp_lock, + current_tick: ¤t_tick, + clock: &clock, + }, + ) + .expect("current pool-creation preconditions must validate"); + + let PairInspection::Missing(missing) = inspected else { + panic!("default pool must inspect as missing"); + }; + assert_eq!( + missing.first_vault(), + MissingVaultState::ExistingFungible { balance: 7 } + ); + assert_eq!(missing.second_vault(), MissingVaultState::Uninitialized); + assert_eq!(missing.clock().block_id(), 123); + assert_eq!(missing.clock().timestamp(), 456); + + let foreign_vault = fungible_holding( + manifest.first_token().vault_id(), + [99; 8], + lower_token_id(), + 7, + ); + let error = inspect_pair( + &context, + lower_token_id(), + higher_token_id(), + PairReadSnapshots { + pool: &pool, + first_token_definition: &first_definition, + second_token_definition: &second_definition, + first_token_vault: &foreign_vault, + second_token_vault: &second_vault, + liquidity_definition: &liquidity_definition, + lp_lock_holding: &lp_lock, + current_tick: ¤t_tick, + clock: &clock, + }, + ) + .err() + .expect("foreign-owned existing vault cannot be mutated by Token Program"); + assert!(matches!( + error, + amm_client::ClientError::ProgramOwnerMismatch { + account: "first token vault", + .. + } + )); +} + +#[test] +fn active_pair_maps_caller_order_to_stored_pool_order() { + let context = inspect_config(AMM_PROGRAM_ID, &config_snapshot()).expect("config must validate"); + let manifest = derive_pair_read_manifest(&context, lower_token_id(), higher_token_id()) + .expect("pair must derive"); + let lp_id = manifest.liquidity_definition_id(); + let pool_definition = PoolDefinition { + // Stored pool order is opposite the caller's lower/higher order. + definition_token_a_id: higher_token_id(), + definition_token_b_id: lower_token_id(), + vault_a_id: manifest.second_token().vault_id(), + vault_b_id: manifest.first_token().vault_id(), + liquidity_pool_id: lp_id, + liquidity_pool_supply: 2_000, + reserve_a: 1_000, + reserve_b: 500, + fees: FEE_TIER_BPS_30, + }; + let pool = AccountSnapshot::new( + manifest.pool_id(), + account(AMM_PROGRAM_ID, Data::from(&pool_definition)), + ); + let first_definition = fungible_definition(lower_token_id(), 10_000, None); + let second_definition = fungible_definition(higher_token_id(), 20_000, None); + let first_vault = fungible_holding( + manifest.first_token().vault_id(), + TOKEN_PROGRAM_ID, + lower_token_id(), + 550, + ); + let second_vault = fungible_holding( + manifest.second_token().vault_id(), + TOKEN_PROGRAM_ID, + higher_token_id(), + 1_100, + ); + let liquidity_definition = fungible_definition(lp_id, 2_000, Some(lp_id)); + let lp_lock = fungible_holding( + manifest.lp_lock_holding_id(), + TOKEN_PROGRAM_ID, + lp_id, + 1_000, + ); + let current_tick = AccountSnapshot::new( + manifest.current_tick_id(), + account( + TWAP_ORACLE_PROGRAM_ID, + Data::from(&CurrentTickAccount { + tick: -1, + last_updated: 400, + }), + ), + ); + let clock = clock_snapshot(manifest.clock_id()); + + let inspected = inspect_pair( + &context, + lower_token_id(), + higher_token_id(), + PairReadSnapshots { + pool: &pool, + first_token_definition: &first_definition, + second_token_definition: &second_definition, + first_token_vault: &first_vault, + second_token_vault: &second_vault, + liquidity_definition: &liquidity_definition, + lp_lock_holding: &lp_lock, + current_tick: ¤t_tick, + clock: &clock, + }, + ) + .expect("active pair must validate"); + + let PairInspection::Active(active) = inspected else { + panic!("initialized pool must inspect as active"); + }; + assert_eq!(active.caller_order(), PairOrder::Reversed); + assert_eq!( + active.pool().pool().definition_token_a_id, + higher_token_id() + ); + assert_eq!(active.pool().vault_a().balance(), 1_100); + assert_eq!(active.pool().vault_b().balance(), 550); + assert_eq!(active.pool().pool().liquidity_pool_supply, 2_000); + assert_eq!(active.pool().pool().fees, FEE_TIER_BPS_30); + assert_eq!(active.lp_lock_holding().balance(), 1_000); + assert_eq!(active.stored_spot_price_q64_64(), (1u128 << 64) / 2); + assert_eq!(active.current_tick().tick, -1); + + let donated_lp_lock = fungible_holding( + manifest.lp_lock_holding_id(), + TOKEN_PROGRAM_ID, + lp_id, + 1_001, + ); + let donated = inspect_pair( + &context, + lower_token_id(), + higher_token_id(), + PairReadSnapshots { + pool: &pool, + first_token_definition: &first_definition, + second_token_definition: &second_definition, + first_token_vault: &first_vault, + second_token_vault: &second_vault, + liquidity_definition: &liquidity_definition, + lp_lock_holding: &donated_lp_lock, + current_tick: ¤t_tick, + clock: &clock, + }, + ) + .expect("LP donated to the lock holding must not invalidate the pool"); + let PairInspection::Active(donated) = donated else { + panic!("initialized pool with extra locked LP must remain active"); + }; + assert_eq!(donated.lp_lock_holding().balance(), 1_001); + + let wrong_lp_lock = + fungible_holding(manifest.lp_lock_holding_id(), TOKEN_PROGRAM_ID, lp_id, 999); + let error = inspect_pair( + &context, + lower_token_id(), + higher_token_id(), + PairReadSnapshots { + pool: &pool, + first_token_definition: &first_definition, + second_token_definition: &second_definition, + first_token_vault: &first_vault, + second_token_vault: &second_vault, + liquidity_definition: &liquidity_definition, + lp_lock_holding: &wrong_lp_lock, + current_tick: ¤t_tick, + clock: &clock, + }, + ) + .err() + .expect("active pool must retain permanently locked minimum liquidity"); + assert!(matches!( + error, + amm_client::ClientError::InvalidAccountData { + account: "LP lock holding", + .. + } + )); +} + +#[test] +fn missing_pair_rejects_initialized_lp_dependency() { + let context = inspect_config(AMM_PROGRAM_ID, &config_snapshot()).expect("config must validate"); + let manifest = derive_pair_read_manifest(&context, lower_token_id(), higher_token_id()) + .expect("pair must derive"); + let pool = AccountSnapshot::new(manifest.pool_id(), Account::default()); + let first_definition = fungible_definition(lower_token_id(), 10_000, None); + let second_definition = fungible_definition(higher_token_id(), 20_000, None); + let first_vault = AccountSnapshot::new(manifest.first_token().vault_id(), Account::default()); + let second_vault = AccountSnapshot::new(manifest.second_token().vault_id(), Account::default()); + let lp_id = manifest.liquidity_definition_id(); + let liquidity_definition = fungible_definition(lp_id, 0, Some(lp_id)); + let lp_lock = AccountSnapshot::new(manifest.lp_lock_holding_id(), Account::default()); + let current_tick = AccountSnapshot::new(manifest.current_tick_id(), Account::default()); + let clock = clock_snapshot(manifest.clock_id()); + + let error = inspect_pair( + &context, + lower_token_id(), + higher_token_id(), + PairReadSnapshots { + pool: &pool, + first_token_definition: &first_definition, + second_token_definition: &second_definition, + first_token_vault: &first_vault, + second_token_vault: &second_vault, + liquidity_definition: &liquidity_definition, + lp_lock_holding: &lp_lock, + current_tick: ¤t_tick, + clock: &clock, + }, + ) + .err() + .expect("Token Program requires LP definition to be uninitialized"); + + assert_eq!(error.code(), "invalid_account_data"); +} diff --git a/programs/amm/client/tests/ffi_contract.rs b/programs/amm/client/tests/ffi_contract.rs index ec7d094..a8c9d38 100644 --- a/programs/amm/client/tests/ffi_contract.rs +++ b/programs/amm/client/tests/ffi_contract.rs @@ -5,7 +5,7 @@ use std::ffi::{c_char, CStr, CString}; -use amm_client::{amm_client_free, amm_client_plan, amm_client_quote}; +use amm_client::{amm_client_free, amm_client_plan, amm_client_quote, wire::WIRE_SCHEMA}; 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, @@ -95,6 +95,7 @@ fn fungible_holding(program_owner: ProgramId, definition_id: AccountId, balance: fn null_request_returns_structured_error() { let response = call(amm_client_plan, None); + assert_eq!(response["schema"], WIRE_SCHEMA); assert_eq!(response["ok"], false); assert_eq!(response["error"]["code"], "null_request"); } @@ -104,6 +105,7 @@ fn malformed_json_returns_structured_error() { let request = CString::new("{").expect("literal has no NUL"); let response = call(amm_client_quote, Some(&request)); + assert_eq!(response["schema"], WIRE_SCHEMA); assert_eq!(response["ok"], false); assert_eq!(response["error"]["code"], "invalid_json"); } @@ -130,7 +132,9 @@ fn protocol_constants_are_exposed_without_numeric_json_values() { &json!({"operation": "protocol_constants"}), ); + assert_eq!(response["schema"], WIRE_SCHEMA); assert_eq!(response["ok"], true); + assert_eq!(response["value"]["schema"], WIRE_SCHEMA); assert_eq!( response["value"]["minimumLiquidity"], MINIMUM_LIQUIDITY.to_string() diff --git a/programs/amm/client/tests/intent_contract.rs b/programs/amm/client/tests/intent_contract.rs new file mode 100644 index 0000000..9e37356 --- /dev/null +++ b/programs/amm/client/tests/intent_contract.rs @@ -0,0 +1,244 @@ +use amm_client::{ + 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, Q64_64_ONE, +}; +use amm_core::{PoolDefinition, MINIMUM_LIQUIDITY}; +use amm_program::quote::{self as program_quote, PairOrder, SwapDirection}; +use nssa_core::account::AccountId; + +const FEE_BPS: u128 = 30; + +fn pool(reserve_a: u128, reserve_b: u128) -> PoolDefinition { + PoolDefinition { + definition_token_a_id: AccountId::new([1; 32]), + definition_token_b_id: AccountId::new([2; 32]), + vault_a_id: AccountId::new([3; 32]), + vault_b_id: AccountId::new([4; 32]), + liquidity_pool_id: AccountId::new([5; 32]), + liquidity_pool_supply: MINIMUM_LIQUIDITY + .checked_mul(100) + .expect("test liquidity supply fits u128"), + reserve_a, + reserve_b, + fees: FEE_BPS, + } +} + +#[test] +fn minimum_pair_handles_prices_below_equal_and_above_one() { + for price in [Q64_64_ONE - 1, Q64_64_ONE, Q64_64_ONE + 1] { + let prepared = prepare_minimum_opening_pair(price, FEE_BPS).unwrap(); + assert!(prepared.quote.user_liquidity > 0); + assert!(prepared.token_a_amount > 0); + assert!(prepared.token_b_amount > 0); + + if price >= Q64_64_ONE && prepared.token_a_amount > 1 { + let previous_a = prepared.token_a_amount.checked_sub(1).unwrap(); + let previous_b = paired_amount_from_token_a(previous_a, price).unwrap(); + assert!(program_quote::create_pool(previous_a, previous_b, FEE_BPS).is_err()); + } else if price < Q64_64_ONE && prepared.token_b_amount > 1 { + let previous_b = prepared.token_b_amount.checked_sub(1).unwrap(); + let previous_a = paired_amount_from_token_b(previous_b, price).unwrap(); + assert!(program_quote::create_pool(previous_a, previous_b, FEE_BPS).is_err()); + } + } +} + +#[test] +fn zero_price_and_zero_edited_amount_are_rejected() { + assert_eq!( + prepare_minimum_opening_pair(0, FEE_BPS), + Err(IntentError::ZeroDesiredPrice) + ); + assert_eq!( + paired_amount_from_token_a(0, Q64_64_ONE), + Err(IntentError::ZeroEditedAmount) + ); + assert_eq!( + paired_amount_from_token_b(1, 0), + Err(IntentError::ZeroDesiredPrice) + ); +} + +#[test] +fn pairing_uses_checked_widened_math_and_reports_overflow() { + assert_eq!( + paired_amount_from_token_a(u128::MAX, u128::MAX), + Err(IntentError::ArithmeticOverflow { + operation: "token-A to token-B pairing" + }) + ); + assert_eq!( + paired_amount_from_token_b(u128::MAX, 1), + Err(IntentError::ArithmeticOverflow { + operation: "token-B to token-A pairing" + }) + ); +} + +#[test] +fn paired_and_explicit_amounts_are_validated_by_program_quote() { + let from_a = prepare_opening_from_token_a(2_000, Q64_64_ONE * 2, FEE_BPS).unwrap(); + assert_eq!(from_a.token_b_amount, 4_000); + assert_eq!(from_a.quote.pool.reserve_b, 4_000); + + let from_b = prepare_opening_from_token_b(4_000, Q64_64_ONE * 2, FEE_BPS).unwrap(); + assert_eq!(from_b.token_a_amount, 2_000); + + let explicit = validate_explicit_opening_pair(2_000, 4_000, Q64_64_ONE * 2, FEE_BPS).unwrap(); + assert_eq!(explicit.actual_price_q64_64, Q64_64_ONE * 2); + + let mismatch = + validate_explicit_opening_pair(2_000, 4_001, Q64_64_ONE * 2, FEE_BPS).unwrap_err(); + assert!(matches!(mismatch, IntentError::SpotPriceMismatch { .. })); + + let too_small = prepare_opening_from_token_a(1, Q64_64_ONE, FEE_BPS).unwrap_err(); + assert!(matches!(too_small, IntentError::Quote { .. })); +} + +#[test] +fn amounts_above_javascript_integer_range_remain_exact() { + let amount_a = 1_u128 << 80; + let amount_b = amount_a.checked_mul(2).unwrap(); + let prepared = + validate_explicit_opening_pair(amount_a, amount_b, Q64_64_ONE * 2, FEE_BPS).unwrap(); + assert_eq!(prepared.token_a_amount, amount_a); + assert_eq!(prepared.token_b_amount, amount_b); + assert_eq!(prepared.quote.pool.reserve_a, amount_a); + assert_eq!(prepared.quote.pool.reserve_b, amount_b); +} + +#[test] +fn caller_and_stored_order_mapping_is_lossless() { + assert_eq!( + caller_amounts_to_stored(PairOrder::Stored, 11, 22), + (11, 22) + ); + assert_eq!( + caller_amounts_to_stored(PairOrder::Reversed, 11, 22), + (22, 11) + ); + assert_eq!( + stored_amounts_to_caller(PairOrder::Reversed, 22, 11), + (11, 22) + ); +} + +#[test] +fn caller_opening_intent_maps_both_token_orders_without_host_math() { + let lower = AccountId::new([1; 32]); + let higher = AccountId::new([2; 32]); + let desired_price = Q64_64_ONE.checked_mul(2).unwrap(); + + let reversed = prepare_caller_opening_pair( + lower, + higher, + desired_price, + FEE_BPS, + OpeningLiquidityIntent::FirstAmount(4_000), + ) + .unwrap(); + assert_eq!(reversed.caller_order(), PairOrder::Reversed); + assert_eq!(reversed.first_amount(), 4_000); + assert_eq!(reversed.second_amount(), 2_000); + assert_eq!(reversed.stored().token_a_amount, 2_000); + assert_eq!(reversed.stored().token_b_amount, 4_000); + + let stored = prepare_caller_opening_pair( + higher, + lower, + desired_price, + FEE_BPS, + OpeningLiquidityIntent::Explicit { + first_amount: 2_000, + second_amount: 4_000, + }, + ) + .unwrap(); + assert_eq!(stored.caller_order(), PairOrder::Stored); + assert_eq!(stored.first_amount(), 2_000); + assert_eq!(stored.second_amount(), 4_000); + + assert_eq!( + prepare_caller_opening_pair( + lower, + lower, + desired_price, + FEE_BPS, + OpeningLiquidityIntent::Minimum, + ), + Err(IntentError::IdenticalTokenDefinitions) + ); +} + +#[test] +fn pool_spot_change_is_directional_exact_and_floored_once() { + let before = pool(10_000, 20_000); + let quote = program_quote::preview_swap_exact_input( + &before, + before.reserve_a, + before.reserve_b, + SwapDirection::AToB, + 100, + ) + .unwrap(); + + assert_eq!(quote.pool.reserve_a, 10_100); + assert_eq!(quote.pool.reserve_b, 19_804); + assert_eq!(pool_spot_change_bps(&before, "e).unwrap(), 199); + + let large_quote = program_quote::preview_swap_exact_input( + &before, + before.reserve_a, + before.reserve_b, + SwapDirection::AToB, + 9_000, + ) + .unwrap(); + assert!(pool_spot_change_bps(&before, &large_quote).unwrap() > 10_000); +} + +#[test] +fn pool_spot_change_handles_reserves_above_javascript_range() { + let scale = 1_u128 << 60; + let reserve_a = scale.checked_mul(10).unwrap(); + let reserve_b = scale.checked_mul(20).unwrap(); + let before = pool(reserve_a, reserve_b); + let quote = program_quote::preview_swap_exact_input( + &before, + before.reserve_a, + before.reserve_b, + SwapDirection::BToA, + scale, + ) + .unwrap(); + + let change = pool_spot_change_bps(&before, "e).unwrap(); + assert!(change > 0); + assert!(change <= 10_000); +} + +#[test] +fn pool_spot_change_rejects_zero_directional_reserve() { + let valid_before = pool(10_000, 20_000); + let quote = program_quote::preview_swap_exact_input( + &valid_before, + valid_before.reserve_a, + valid_before.reserve_b, + SwapDirection::AToB, + 100, + ) + .unwrap(); + let zero_before = pool(0, 20_000); + + assert_eq!( + pool_spot_change_bps(&zero_before, "e), + Err(IntentError::ZeroDirectionalReserve) + ); + assert_eq!( + IntentError::ZeroDirectionalReserve.code(), + "zero_directional_reserve" + ); +} diff --git a/programs/amm/client/tests/plan_contract.rs b/programs/amm/client/tests/plan_contract.rs index ec52b5b..e1ac077 100644 --- a/programs/amm/client/tests/plan_contract.rs +++ b/programs/amm/client/tests/plan_contract.rs @@ -301,6 +301,25 @@ fn account_ids_and_signer_flags_stay_positionally_aligned() { } } +#[test] +fn affected_ids_are_unique_writable_accounts_in_instruction_order() { + for plan in all_plans() { + let expected = plan + .accounts() + .iter() + .filter(|account| account.writable()) + .map(|account| account.id()) + .fold(Vec::new(), |mut ids, id| { + if !ids.contains(&id) { + ids.push(id); + } + ids + }); + assert_eq!(plan.writable_account_ids(), expected); + assert_eq!(plan.affected_account_ids(), expected); + } +} + #[test] fn quote_results_feed_instruction_amounts_and_guards_without_recalculation() { let context = context(); diff --git a/programs/amm/client/tests/quote_contract.rs b/programs/amm/client/tests/quote_contract.rs index 549767c..957e838 100644 --- a/programs/amm/client/tests/quote_contract.rs +++ b/programs/amm/client/tests/quote_contract.rs @@ -539,16 +539,10 @@ fn prepared_instruction_args_feed_canonical_planners_without_ui_math() { let prepared_add = prepare_add_liquidity(&snapshot, 400, 100, tolerance).expect("add liquidity must prepare"); - assert_eq!(prepared_add.max_amount_to_add_token_a, 200); + assert_eq!(prepared_add.max_amount_to_add_token_a, 400); assert_eq!(prepared_add.max_amount_to_add_token_b, 100); - assert_eq!( - prepared_add.max_amount_to_add_token_a, - prepared_add.quote.actual_amount_a - ); - assert_eq!( - prepared_add.max_amount_to_add_token_b, - prepared_add.quote.actual_amount_b - ); + assert_eq!(prepared_add.quote.actual_amount_a, 200); + assert_eq!(prepared_add.quote.actual_amount_b, 100); let add_plan = plan_add_liquidity(AddLiquidityPlanInput { context: &fixture.context, pool, @@ -645,3 +639,51 @@ fn prepared_instruction_args_feed_canonical_planners_without_ui_math() { && *planned_deadline == deadline )); } + +#[test] +fn prepared_add_keeps_original_caps_across_non_idempotent_rounding() { + let mut fixture = Fixture::new(); + let pool_definition = PoolDefinition { + definition_token_a_id: token_a_id(), + definition_token_b_id: token_b_id(), + vault_a_id: vault_a_id(), + vault_b_id: vault_b_id(), + liquidity_pool_id: liquidity_definition_id(), + liquidity_pool_supply: 2_000, + reserve_a: 3, + reserve_b: 2, + fees: FEE_TIER_BPS_30, + }; + fixture.pool = AccountSnapshot::new( + pool_id(), + account(AMM_PROGRAM_ID, Data::from(&pool_definition)), + ); + fixture.vault_a = fungible_holding(vault_a_id(), token_a_id(), 3); + fixture.vault_b = fungible_holding(vault_b_id(), token_b_id(), 2); + let snapshot = fixture + .validated_pool() + .expect("non-divisible pool must validate"); + + let prepared = prepare_add_liquidity( + &snapshot, + 2, + 2, + SlippageTolerance::new(100).expect("one percent is valid"), + ) + .expect("original caps are executable"); + let executed = quote::add_liquidity( + &snapshot, + prepared.max_amount_to_add_token_a, + prepared.max_amount_to_add_token_b, + prepared.min_amount_liquidity, + ) + .expect("prepared instruction fields must execute"); + + assert_eq!(prepared.max_amount_to_add_token_a, 2); + assert_eq!(prepared.max_amount_to_add_token_b, 2); + assert_eq!(prepared.quote.actual_amount_a, 2); + assert_eq!(prepared.quote.actual_amount_b, 1); + assert_eq!(prepared.quote.liquidity_to_mint, 1_000); + assert_eq!(prepared.min_amount_liquidity, 990); + assert_eq!(executed, prepared.quote); +} diff --git a/programs/amm/client/tests/transaction_contract.rs b/programs/amm/client/tests/transaction_contract.rs new file mode 100644 index 0000000..0874efb --- /dev/null +++ b/programs/amm/client/tests/transaction_contract.rs @@ -0,0 +1,922 @@ +use amm_client::{ + transaction::{ + ensure_quote_unchanged, prepare_add_liquidity_transaction, prepare_create_pool_transaction, + prepare_remove_liquidity_transaction, prepare_swap_exact_input_transaction, + prepare_swap_exact_output_transaction, AddLiquidityTransactionInput, + CreatePoolTransactionInput, PoolAccountSnapshots, RemoveLiquidityTransactionInput, + SwapExactInputTransactionInput, SwapExactOutputTransactionInput, TransactionError, + }, + PairReadSnapshots, SlippageTolerance, +}; +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, + compute_vault_pda, AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, +}; +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount}; + +const AMM_PROGRAM_ID: ProgramId = [42; 8]; +const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; +const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; +const DEADLINE: u64 = 1_900_000_000_000; + +fn lower_token_id() -> AccountId { + AccountId::new([1; 32]) +} + +fn higher_token_id() -> AccountId { + AccountId::new([2; 32]) +} + +fn account(program_owner: ProgramId, data: Data) -> Account { + Account { + program_owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn definition(id: AccountId, total_supply: u128, authority: Option) -> AccountSnapshot { + AccountSnapshot::new( + id, + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenDefinition::Fungible { + name: String::from("Token"), + total_supply, + metadata_id: None, + authority, + }), + ), + ) +} + +fn holding(id: AccountId, definition_id: AccountId, balance: u128) -> AccountSnapshot { + AccountSnapshot::new( + id, + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + ), + ) +} + +fn clock_snapshot() -> AccountSnapshot { + let data = ClockAccountData { + block_id: 123, + timestamp: 456, + } + .to_bytes(); + AccountSnapshot::new( + CLOCK_01_PROGRAM_ACCOUNT_ID, + account([88; 8], Data::try_from(data).expect("clock data must fit")), + ) +} + +use amm_client::quote::AccountSnapshot; + +struct Fixture { + config: AccountSnapshot, + pool: AccountSnapshot, + stored_a_definition: AccountSnapshot, + stored_b_definition: AccountSnapshot, + vault_a: AccountSnapshot, + vault_b: AccountSnapshot, + liquidity_definition: AccountSnapshot, + lp_lock_holding: AccountSnapshot, + current_tick: AccountSnapshot, + clock: AccountSnapshot, + caller_first_holding: AccountSnapshot, + caller_second_holding: AccountSnapshot, + liquidity_holding: AccountSnapshot, +} + +impl Fixture { + fn new() -> Self { + // Pool storage is canonical descending ID order. Callers below deliberately use lower, + // higher order to prove the facade performs the mapping once. + let stored_a = higher_token_id(); + let stored_b = lower_token_id(); + let pool_id = compute_pool_pda(AMM_PROGRAM_ID, stored_a, stored_b); + let vault_a_id = compute_vault_pda(AMM_PROGRAM_ID, pool_id, stored_a); + let vault_b_id = compute_vault_pda(AMM_PROGRAM_ID, pool_id, stored_b); + let liquidity_id = compute_liquidity_token_pda(AMM_PROGRAM_ID, pool_id); + let lp_lock_id = compute_lp_lock_holding_pda(AMM_PROGRAM_ID, pool_id); + let current_tick_id = compute_current_tick_account_pda(TWAP_ORACLE_PROGRAM_ID, pool_id); + let config = AmmConfig { + token_program_id: TOKEN_PROGRAM_ID, + twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, + authority: AccountId::new([9; 32]), + }; + let pool = PoolDefinition { + definition_token_a_id: stored_a, + definition_token_b_id: stored_b, + vault_a_id, + vault_b_id, + liquidity_pool_id: liquidity_id, + liquidity_pool_supply: 2_000, + reserve_a: 1_000, + reserve_b: 500, + fees: FEE_TIER_BPS_30, + }; + + Self { + config: AccountSnapshot::new( + compute_config_pda(AMM_PROGRAM_ID), + account(AMM_PROGRAM_ID, Data::from(&config)), + ), + pool: AccountSnapshot::new(pool_id, account(AMM_PROGRAM_ID, Data::from(&pool))), + stored_a_definition: definition(stored_a, 100_000, None), + stored_b_definition: definition(stored_b, 100_000, None), + vault_a: holding(vault_a_id, stored_a, 1_100), + vault_b: holding(vault_b_id, stored_b, 550), + liquidity_definition: definition(liquidity_id, 2_000, Some(liquidity_id)), + lp_lock_holding: holding(lp_lock_id, liquidity_id, 1_000), + current_tick: AccountSnapshot::new( + current_tick_id, + account( + TWAP_ORACLE_PROGRAM_ID, + Data::from(&CurrentTickAccount { + tick: -1, + last_updated: 400, + }), + ), + ), + clock: clock_snapshot(), + caller_first_holding: holding(AccountId::new([20; 32]), lower_token_id(), 10_000), + caller_second_holding: holding(AccountId::new([21; 32]), higher_token_id(), 10_000), + liquidity_holding: holding(AccountId::new([22; 32]), liquidity_id, 1_000), + } + } + + fn pool_accounts(&self) -> PoolAccountSnapshots<'_> { + self.pool_accounts_with(&self.config, &self.current_tick, &self.clock) + } + + fn pool_accounts_with<'a>( + &'a self, + config: &'a AccountSnapshot, + current_tick: &'a AccountSnapshot, + clock: &'a AccountSnapshot, + ) -> PoolAccountSnapshots<'a> { + PoolAccountSnapshots { + config, + pair: PairReadSnapshots { + pool: &self.pool, + first_token_definition: &self.stored_b_definition, + second_token_definition: &self.stored_a_definition, + first_token_vault: &self.vault_b, + second_token_vault: &self.vault_a, + liquidity_definition: &self.liquidity_definition, + lp_lock_holding: &self.lp_lock_holding, + current_tick, + clock, + }, + } + } + + fn stored_order_pool_accounts(&self) -> PoolAccountSnapshots<'_> { + PoolAccountSnapshots { + config: &self.config, + pair: PairReadSnapshots { + pool: &self.pool, + first_token_definition: &self.stored_a_definition, + second_token_definition: &self.stored_b_definition, + first_token_vault: &self.vault_a, + second_token_vault: &self.vault_b, + liquidity_definition: &self.liquidity_definition, + lp_lock_holding: &self.lp_lock_holding, + current_tick: &self.current_tick, + clock: &self.clock, + }, + } + } + + fn slippage() -> SlippageTolerance { + SlippageTolerance::new(100).expect("one-percent slippage must validate") + } +} + +struct MissingPairFixture { + pool: AccountSnapshot, + first_vault: AccountSnapshot, + second_vault: AccountSnapshot, + liquidity_definition: AccountSnapshot, + lp_lock_holding: AccountSnapshot, + current_tick: AccountSnapshot, + clock: AccountSnapshot, +} + +impl MissingPairFixture { + fn new(fixture: &Fixture) -> Self { + Self { + pool: AccountSnapshot::new(fixture.pool.account_id(), Account::default()), + first_vault: AccountSnapshot::new(fixture.vault_b.account_id(), Account::default()), + second_vault: AccountSnapshot::new(fixture.vault_a.account_id(), Account::default()), + liquidity_definition: AccountSnapshot::new( + fixture.liquidity_definition.account_id(), + Account::default(), + ), + lp_lock_holding: AccountSnapshot::new( + fixture.lp_lock_holding.account_id(), + Account::default(), + ), + current_tick: AccountSnapshot::new( + fixture.current_tick.account_id(), + Account::default(), + ), + clock: clock_snapshot(), + } + } + + fn pair<'a>(&'a self, fixture: &'a Fixture) -> PairReadSnapshots<'a> { + PairReadSnapshots { + pool: &self.pool, + first_token_definition: &fixture.stored_b_definition, + second_token_definition: &fixture.stored_a_definition, + first_token_vault: &self.first_vault, + second_token_vault: &self.second_vault, + liquidity_definition: &self.liquidity_definition, + lp_lock_holding: &self.lp_lock_holding, + current_tick: &self.current_tick, + clock: &self.clock, + } + } +} + +fn add_input<'a>( + fixture: &'a Fixture, + pool_accounts: PoolAccountSnapshots<'a>, + first_holding: &'a AccountSnapshot, + max_first_amount: u128, + max_second_amount: u128, + slippage_bps: u128, + expected_fee_bps: Option, +) -> AddLiquidityTransactionInput<'a> { + AddLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts, + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fixture.liquidity_holding, + max_first_amount, + max_second_amount, + slippage: SlippageTolerance::new(slippage_bps).expect("test slippage must validate"), + expected_fee_bps, + deadline: DEADLINE, + } +} + +#[test] +fn five_facades_emit_exact_plans_and_caller_order_amounts() { + let fixture = Fixture::new(); + let missing = MissingPairFixture::new(&fixture); + let fresh_lp = AccountSnapshot::new(AccountId::new([30; 32]), Account::default()); + let create = prepare_create_pool_transaction(CreatePoolTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + config: &fixture.config, + pair: missing.pair(&fixture), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.caller_first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fresh_lp, + first_amount: 4_000, + second_amount: 9_000, + fee_bps: FEE_TIER_BPS_30, + deadline: DEADLINE, + }) + .expect("funded create request must prepare"); + let Instruction::NewDefinition { + token_a_amount, + token_b_amount, + deadline, + .. + } = create.plan().instruction() + else { + panic!("create facade emitted wrong instruction") + }; + assert_eq!((*token_a_amount, *token_b_amount), (9_000, 4_000)); + assert_eq!(*deadline, DEADLINE); + assert_eq!(create.caller_amounts().first(), 4_000); + assert_eq!(create.caller_amounts().second(), 9_000); + assert_eq!( + create.wallet_prerequisites().fresh_account_ids(), + &[fresh_lp.account_id()] + ); + + let add = prepare_add_liquidity_transaction(AddLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.caller_first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fixture.liquidity_holding, + max_first_amount: 100, + max_second_amount: 400, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .expect("funded add request must prepare"); + let Instruction::AddLiquidity { + max_amount_to_add_token_a, + max_amount_to_add_token_b, + .. + } = add.plan().instruction() + else { + panic!("add facade emitted wrong instruction") + }; + assert_eq!( + (*max_amount_to_add_token_a, *max_amount_to_add_token_b), + (400, 100) + ); + assert_eq!(add.caller_amounts().first(), 100); + assert_eq!(add.caller_amounts().second(), 200); + assert_eq!(add.wallet_prerequisites().funding()[0].required(), 100); + assert_eq!(add.wallet_prerequisites().funding()[1].required(), 400); + + let remove = prepare_remove_liquidity_transaction(RemoveLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.caller_first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fixture.liquidity_holding, + remove_liquidity_amount: 500, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .expect("remove request must prepare"); + assert_eq!(remove.caller_amounts().first(), 125); + assert_eq!(remove.caller_amounts().second(), 250); + + let exact_input = prepare_swap_exact_input_transaction(SwapExactInputTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + input_token_definition_id: lower_token_id(), + output_token_definition_id: higher_token_id(), + input_holding: &fixture.caller_first_holding, + output_holding: &fixture.caller_second_holding, + amount_in: 100, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .expect("exact-input swap must prepare"); + assert_eq!(exact_input.caller_amounts().first(), 100); + assert_eq!( + exact_input.caller_amounts().second(), + exact_input.quote().amount_out + ); + assert_eq!(exact_input.pool_spot_change_bps(), Some(4_371)); + + let exact_output = prepare_swap_exact_output_transaction(SwapExactOutputTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + input_token_definition_id: lower_token_id(), + output_token_definition_id: higher_token_id(), + input_holding: &fixture.caller_first_holding, + output_holding: &fixture.caller_second_holding, + exact_amount_out: 100, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .expect("exact-output swap must prepare"); + assert_eq!( + exact_output.caller_amounts().first(), + exact_output.quote().amount_in + ); + assert_eq!(exact_output.caller_amounts().second(), 100); + assert!(exact_output.pool_spot_change_bps().is_some()); + let Instruction::SwapExactOutput { max_amount_in, .. } = exact_output.plan().instruction() + else { + panic!("exact-output facade emitted wrong instruction"); + }; + assert_eq!( + exact_output.wallet_prerequisites().funding()[0].required(), + *max_amount_in + ); + assert!(*max_amount_in > exact_output.quote().amount_in); + + for (plan, affected) in [ + (create.plan(), create.affected_account_ids()), + (add.plan(), add.affected_account_ids()), + (remove.plan(), remove.affected_account_ids()), + (exact_input.plan(), exact_input.affected_account_ids()), + (exact_output.plan(), exact_output.affected_account_ids()), + ] { + let words = plan + .instruction_data() + .expect("prepared instruction must encode"); + let decoded: Instruction = + risc0_zkvm::serde::from_slice(&words).expect("guest codec must decode plan"); + assert_eq!( + risc0_zkvm::serde::to_vec(&decoded).expect("decoded instruction must encode"), + words + ); + assert_eq!(affected, plan.affected_account_ids()); + } +} + +#[test] +fn exact_output_requires_funding_through_its_maximum_input_guard() { + let fixture = Fixture::new(); + let funded = prepare_swap_exact_output_transaction(SwapExactOutputTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + input_token_definition_id: lower_token_id(), + output_token_definition_id: higher_token_id(), + input_holding: &fixture.caller_first_holding, + output_holding: &fixture.caller_second_holding, + exact_amount_out: 100, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .expect("funded exact-output request must prepare"); + let quoted_input = funded.quote().amount_in; + let required = funded.wallet_prerequisites().funding()[0].required(); + assert!(required > quoted_input); + + let quote_only_balance = holding(AccountId::new([20; 32]), lower_token_id(), quoted_input); + let result = prepare_swap_exact_output_transaction(SwapExactOutputTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + input_token_definition_id: lower_token_id(), + output_token_definition_id: higher_token_id(), + input_holding: "e_only_balance, + output_holding: &fixture.caller_second_holding, + exact_amount_out: 100, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }); + let Err(error) = result else { + panic!("balance below maximum-input guard must fail"); + }; + assert!(matches!( + error, + TransactionError::Client(amm_client::ClientError::InsufficientBalance { + available, + required: actual_required, + .. + }) if available == quoted_input && actual_required == required + )); +} + +#[test] +fn commitment_is_stable_and_changes_with_bound_snapshot_or_deadline() { + let fixture = Fixture::new(); + let missing = MissingPairFixture::new(&fixture); + let fresh_lp = AccountSnapshot::new(AccountId::new([30; 32]), Account::default()); + let prepare = |first_holding: &AccountSnapshot, deadline| { + prepare_create_pool_transaction(CreatePoolTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + config: &fixture.config, + pair: missing.pair(&fixture), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fresh_lp, + first_amount: 4_000, + second_amount: 9_000, + fee_bps: FEE_TIER_BPS_30, + deadline, + }) + .expect("create request must prepare") + }; + + let first = prepare(&fixture.caller_first_holding, DEADLINE); + let repeated = prepare(&fixture.caller_first_holding, DEADLINE); + assert_eq!(first.quote_commitment(), repeated.quote_commitment()); + + let changed_holding = holding(AccountId::new([20; 32]), lower_token_id(), 10_001); + let changed_snapshot = prepare(&changed_holding, DEADLINE); + assert_ne!( + first.quote_commitment(), + changed_snapshot.quote_commitment() + ); + assert!(matches!( + ensure_quote_unchanged( + first.quote_commitment(), + changed_snapshot.quote_commitment() + ), + Err(TransactionError::QuoteChanged { .. }) + )); + + let changed_deadline = prepare(&fixture.caller_first_holding, DEADLINE + 1); + assert_ne!( + first.quote_commitment(), + changed_deadline.quote_commitment() + ); +} + +#[test] +fn create_and_add_reject_underfunded_selected_holdings() { + let fixture = Fixture::new(); + let missing = MissingPairFixture::new(&fixture); + let fresh_lp = AccountSnapshot::new(AccountId::new([30; 32]), Account::default()); + let underfunded_first = holding(AccountId::new([20; 32]), lower_token_id(), 3_999); + let error = prepare_create_pool_transaction(CreatePoolTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + config: &fixture.config, + pair: missing.pair(&fixture), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &underfunded_first, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fresh_lp, + first_amount: 4_000, + second_amount: 9_000, + fee_bps: FEE_TIER_BPS_30, + deadline: DEADLINE, + }) + .err() + .expect("underfunded create must fail"); + assert!(matches!( + error, + TransactionError::Client(amm_client::ClientError::InsufficientBalance { + required: 4_000, + .. + }) + )); + + // Expected transfer is 200, but the instruction may spend up to the caller's 400-unit cap. + let underfunded_second = holding(AccountId::new([21; 32]), higher_token_id(), 399); + let error = prepare_add_liquidity_transaction(AddLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.caller_first_holding, + second_token_holding: &underfunded_second, + liquidity_holding: &fixture.liquidity_holding, + max_first_amount: 100, + max_second_amount: 400, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .err() + .expect("holding below the add spend cap must fail"); + assert!(matches!( + error, + TransactionError::Client(amm_client::ClientError::InsufficientBalance { + required: 400, + .. + }) + )); +} + +#[test] +fn add_accepts_only_explicit_default_snapshot_as_fresh_lp_destination() { + let fixture = Fixture::new(); + let fresh_lp = AccountSnapshot::new(AccountId::new([31; 32]), Account::default()); + let prepared = prepare_add_liquidity_transaction(AddLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.caller_first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fresh_lp, + max_first_amount: 100, + max_second_amount: 400, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .expect("explicit default LP snapshot must be accepted"); + assert_eq!( + prepared.wallet_prerequisites().fresh_account_ids(), + &[fresh_lp.account_id()] + ); + + let wrong_lp = holding(AccountId::new([31; 32]), lower_token_id(), 0); + let error = prepare_add_liquidity_transaction(AddLiquidityTransactionInput { + liquidity_holding: &wrong_lp, + ..AddLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.caller_first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fresh_lp, + max_first_amount: 100, + max_second_amount: 400, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + } + }) + .err() + .expect("initialized holding for wrong definition must fail"); + assert_eq!(error.code(), "token_definition_mismatch"); +} + +#[test] +fn lifecycle_tick_clock_and_expected_fee_are_validated_before_planning() { + let fixture = Fixture::new(); + let fresh_lp = AccountSnapshot::new(AccountId::new([30; 32]), Account::default()); + let active_create = prepare_create_pool_transaction(CreatePoolTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + config: &fixture.config, + pair: fixture.pool_accounts().pair, + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.caller_first_holding, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fresh_lp, + first_amount: 4_000, + second_amount: 9_000, + fee_bps: FEE_TIER_BPS_30, + deadline: DEADLINE, + }) + .err() + .expect("active pool must not prepare as creation"); + assert_eq!(active_create.code(), "invalid_account_data"); + + let wrong_tick = AccountSnapshot::new( + AccountId::new([99; 32]), + fixture.current_tick.account().clone(), + ); + let error = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts_with(&fixture.config, &wrong_tick, &fixture.clock), + &fixture.caller_first_holding, + 100, + 400, + 100, + Some(FEE_TIER_BPS_30), + )) + .err() + .expect("mismatched current tick must fail"); + assert_eq!(error.code(), "account_id_mismatch"); + + let wrong_clock = + AccountSnapshot::new(AccountId::new([98; 32]), fixture.clock.account().clone()); + let error = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts_with(&fixture.config, &fixture.current_tick, &wrong_clock), + &fixture.caller_first_holding, + 100, + 400, + 100, + Some(FEE_TIER_BPS_30), + )) + .err() + .expect("mismatched clock must fail"); + assert_eq!(error.code(), "account_id_mismatch"); + + let mismatch = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &fixture.caller_first_holding, + 100, + 400, + 100, + Some(100), + )) + .err() + .expect("caller fee expectation must be checked"); + assert!(matches!( + mismatch, + TransactionError::FeeMismatch { + expected: 100, + actual: FEE_TIER_BPS_30, + } + )); + + let expected = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &fixture.caller_first_holding, + 100, + 400, + 100, + Some(FEE_TIER_BPS_30), + )) + .expect("matching expected fee must prepare"); + let unspecified = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &fixture.caller_first_holding, + 100, + 400, + 100, + None, + )) + .expect("unspecified expected fee must prepare from pool state"); + assert_eq!( + expected.plan().instruction_data(), + unspecified.plan().instruction_data() + ); + assert_eq!(expected.quote(), unspecified.quote()); +} + +#[test] +fn commitment_binds_intent_order_selection_and_quote_sources_only() { + let fixture = Fixture::new(); + let base = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &fixture.caller_first_holding, + 100, + 400, + 1, + Some(FEE_TIER_BPS_30), + )) + .expect("base add must prepare"); + + // One- and two-basis-point tolerances both floor this quote's minimum LP to the same value. + // The typed intent still distinguishes them. + let changed_slippage = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &fixture.caller_first_holding, + 100, + 400, + 2, + Some(FEE_TIER_BPS_30), + )) + .expect("changed slippage must prepare"); + assert_eq!( + base.plan().instruction_data(), + changed_slippage.plan().instruction_data() + ); + assert_ne!(base.quote_commitment(), changed_slippage.quote_commitment()); + + let changed_cap = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &fixture.caller_first_holding, + 101, + 400, + 1, + Some(FEE_TIER_BPS_30), + )) + .expect("changed cap must prepare"); + assert_ne!(base.quote_commitment(), changed_cap.quote_commitment()); + + let no_fee_expectation = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &fixture.caller_first_holding, + 100, + 400, + 1, + None, + )) + .expect("optional fee expectation must not alter quote logic"); + assert_eq!( + base.plan().instruction_data(), + no_fee_expectation.plan().instruction_data() + ); + assert_ne!( + base.quote_commitment(), + no_fee_expectation.quote_commitment() + ); + + let stored_order = prepare_add_liquidity_transaction(AddLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.stored_order_pool_accounts(), + first_token_definition_id: higher_token_id(), + second_token_definition_id: lower_token_id(), + first_token_holding: &fixture.caller_second_holding, + second_token_holding: &fixture.caller_first_holding, + liquidity_holding: &fixture.liquidity_holding, + max_first_amount: 400, + max_second_amount: 100, + slippage: SlippageTolerance::new(1).expect("test slippage"), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }) + .expect("stored caller order must prepare"); + assert_eq!( + base.plan().instruction_data(), + stored_order.plan().instruction_data() + ); + assert_ne!(base.quote_commitment(), stored_order.quote_commitment()); + + let alternate_holding = holding(AccountId::new([24; 32]), lower_token_id(), 10_000); + let changed_selection = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts(), + &alternate_holding, + 100, + 400, + 1, + Some(FEE_TIER_BPS_30), + )) + .expect("alternate funded holding must prepare"); + assert_ne!( + base.quote_commitment(), + changed_selection.quote_commitment() + ); + + let changed_config_data = AmmConfig { + token_program_id: TOKEN_PROGRAM_ID, + twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, + authority: AccountId::new([8; 32]), + }; + let changed_config = AccountSnapshot::new( + fixture.config.account_id(), + account(AMM_PROGRAM_ID, Data::from(&changed_config_data)), + ); + let changed_source = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts_with(&changed_config, &fixture.current_tick, &fixture.clock), + &fixture.caller_first_holding, + 100, + 400, + 1, + Some(FEE_TIER_BPS_30), + )) + .expect("non-economic config source change must prepare"); + assert_eq!( + base.plan().instruction_data(), + changed_source.plan().instruction_data() + ); + assert_ne!(base.quote_commitment(), changed_source.quote_commitment()); + + let changed_tick = AccountSnapshot::new( + fixture.current_tick.account_id(), + account( + TWAP_ORACLE_PROGRAM_ID, + Data::from(&CurrentTickAccount { + tick: -1, + last_updated: 401, + }), + ), + ); + let changed_clock_data = ClockAccountData { + block_id: 124, + timestamp: 457, + } + .to_bytes(); + let changed_clock = AccountSnapshot::new( + CLOCK_01_PROGRAM_ACCOUNT_ID, + account( + [88; 8], + Data::try_from(changed_clock_data).expect("clock data must fit"), + ), + ); + let ephemeral_change = prepare_add_liquidity_transaction(add_input( + &fixture, + fixture.pool_accounts_with(&fixture.config, &changed_tick, &changed_clock), + &fixture.caller_first_holding, + 100, + 400, + 1, + Some(FEE_TIER_BPS_30), + )) + .expect("valid tick and clock refresh must prepare"); + assert_eq!(base.quote_commitment(), ephemeral_change.quote_commitment()); +} + +#[test] +fn rejects_account_aliases_that_make_the_runtime_plan_unexecutable() { + let fixture = Fixture::new(); + let result = prepare_remove_liquidity_transaction(RemoveLiquidityTransactionInput { + amm_program_id: AMM_PROGRAM_ID, + pool_accounts: fixture.pool_accounts(), + first_token_definition_id: lower_token_id(), + second_token_definition_id: higher_token_id(), + first_token_holding: &fixture.vault_b, + second_token_holding: &fixture.caller_second_holding, + liquidity_holding: &fixture.liquidity_holding, + remove_liquidity_amount: 500, + slippage: Fixture::slippage(), + expected_fee_bps: Some(FEE_TIER_BPS_30), + deadline: DEADLINE, + }); + let Err(error) = result else { + panic!("holding aliases must not produce duplicate planned account IDs"); + }; + + assert_eq!( + error, + TransactionError::DuplicateAccountId { + account_id: fixture.vault_b.account_id(), + } + ); + assert_eq!(error.code(), "duplicate_account_id"); +} diff --git a/programs/amm/client/tests/wire_discovery_intent_contract.rs b/programs/amm/client/tests/wire_discovery_intent_contract.rs new file mode 100644 index 0000000..498d9fd --- /dev/null +++ b/programs/amm/client/tests/wire_discovery_intent_contract.rs @@ -0,0 +1,530 @@ +use amm_client::wire::{quote_json, WIRE_SCHEMA}; +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, + compute_vault_pda, AmmConfig, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, +}; +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use serde_json::{json, Value}; +use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount}; + +const AMM_PROGRAM_ID: ProgramId = [42; 8]; +const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; +const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; +const Q64_64_ONE: u128 = 1_u128 << 64; + +fn config_snapshot() -> Value { + let config = AmmConfig { + token_program_id: TOKEN_PROGRAM_ID, + twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, + authority: AccountId::new([9; 32]), + }; + snapshot( + compute_config_pda(AMM_PROGRAM_ID), + &Account { + program_owner: AMM_PROGRAM_ID, + balance: 0, + data: Data::from(&config), + nonce: Nonce(0), + }, + ) +} + +fn account(program_owner: ProgramId, data: Data) -> Account { + Account { + program_owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn fungible_definition(total_supply: u128, authority: Option) -> Account { + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenDefinition::Fungible { + name: String::from("Token"), + total_supply, + metadata_id: None, + authority, + }), + ) +} + +fn fungible_holding(program_owner: ProgramId, definition_id: AccountId, balance: u128) -> Account { + account( + program_owner, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + ) +} + +fn clock_account() -> Account { + let bytes = ClockAccountData { + block_id: 123, + timestamp: 456, + } + .to_bytes(); + account( + [88; 8], + Data::try_from(bytes).expect("clock account data must fit"), + ) +} + +struct PairIds { + first_token_id: AccountId, + second_token_id: AccountId, + pool_id: AccountId, + first_vault_id: AccountId, + second_vault_id: AccountId, + liquidity_definition_id: AccountId, + lp_lock_holding_id: AccountId, + current_tick_id: AccountId, +} + +impl PairIds { + fn new() -> Self { + let first_token_id = AccountId::new([1; 32]); + let second_token_id = AccountId::new([2; 32]); + let pool_id = compute_pool_pda(AMM_PROGRAM_ID, second_token_id, first_token_id); + Self { + first_token_id, + second_token_id, + pool_id, + first_vault_id: compute_vault_pda(AMM_PROGRAM_ID, pool_id, first_token_id), + second_vault_id: compute_vault_pda(AMM_PROGRAM_ID, pool_id, second_token_id), + liquidity_definition_id: compute_liquidity_token_pda(AMM_PROGRAM_ID, pool_id), + lp_lock_holding_id: compute_lp_lock_holding_pda(AMM_PROGRAM_ID, pool_id), + current_tick_id: compute_current_tick_account_pda(TWAP_ORACLE_PROGRAM_ID, pool_id), + } + } + + fn inspect_request(&self, snapshots: Value) -> Value { + json!({ + "operation": "inspect_pair", + "ammProgramId": AMM_PROGRAM_ID, + "config": config_snapshot(), + "firstTokenDefinitionId": self.first_token_id.to_string(), + "secondTokenDefinitionId": self.second_token_id.to_string(), + "snapshots": snapshots, + }) + } + + fn missing_snapshots(&self) -> Value { + json!({ + "pool": snapshot(self.pool_id, &Account::default()), + "firstTokenDefinition": snapshot( + self.first_token_id, + &fungible_definition(10_000, None), + ), + "secondTokenDefinition": snapshot( + self.second_token_id, + &fungible_definition(20_000, None), + ), + "firstTokenVault": snapshot( + self.first_vault_id, + &fungible_holding(TOKEN_PROGRAM_ID, self.first_token_id, 7), + ), + "secondTokenVault": snapshot(self.second_vault_id, &Account::default()), + "liquidityDefinition": snapshot( + self.liquidity_definition_id, + &Account::default(), + ), + "lpLockHolding": snapshot(self.lp_lock_holding_id, &Account::default()), + "currentTick": snapshot(self.current_tick_id, &Account::default()), + "clock": snapshot(CLOCK_01_PROGRAM_ACCOUNT_ID, &clock_account()), + }) + } + + fn active_snapshots(&self) -> Value { + let pool = PoolDefinition { + definition_token_a_id: self.second_token_id, + definition_token_b_id: self.first_token_id, + vault_a_id: self.second_vault_id, + vault_b_id: self.first_vault_id, + liquidity_pool_id: self.liquidity_definition_id, + liquidity_pool_supply: 2_000, + reserve_a: 1_000, + reserve_b: 500, + fees: FEE_TIER_BPS_30, + }; + json!({ + "pool": snapshot(self.pool_id, &account(AMM_PROGRAM_ID, Data::from(&pool))), + "firstTokenDefinition": snapshot( + self.first_token_id, + &fungible_definition(10_000, None), + ), + "secondTokenDefinition": snapshot( + self.second_token_id, + &fungible_definition(20_000, None), + ), + "firstTokenVault": snapshot( + self.first_vault_id, + &fungible_holding(TOKEN_PROGRAM_ID, self.first_token_id, 550), + ), + "secondTokenVault": snapshot( + self.second_vault_id, + &fungible_holding(TOKEN_PROGRAM_ID, self.second_token_id, 1_100), + ), + "liquidityDefinition": snapshot( + self.liquidity_definition_id, + &fungible_definition(2_000, Some(self.liquidity_definition_id)), + ), + "lpLockHolding": snapshot( + self.lp_lock_holding_id, + &fungible_holding( + TOKEN_PROGRAM_ID, + self.liquidity_definition_id, + MINIMUM_LIQUIDITY, + ), + ), + "currentTick": snapshot( + self.current_tick_id, + &account( + TWAP_ORACLE_PROGRAM_ID, + Data::from(&CurrentTickAccount { + tick: -1, + last_updated: 400, + }), + ), + ), + "clock": snapshot(CLOCK_01_PROGRAM_ACCOUNT_ID, &clock_account()), + }) + } +} + +fn snapshot(id: AccountId, account: &Account) -> Value { + json!({ + "id": id.to_string(), + "programOwner": 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 assert_decimal_string(value: &Value) { + let text = value.as_str().expect("wire amount must be a string"); + assert!( + !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()), + "wire amount must be unsigned decimal" + ); +} + +#[test] +fn discovery_operations_return_exact_string_account_ids() { + let first_token_id = AccountId::new([1; 32]); + let second_token_id = AccountId::new([2; 32]); + + let config_id = quote_json(json!({ + "operation": "derive_config_id", + "ammProgramId": AMM_PROGRAM_ID, + })) + .expect("legacy schema-less request remains accepted"); + assert_eq!(config_id["schema"], WIRE_SCHEMA); + assert_eq!( + config_id["configId"], + compute_config_pda(AMM_PROGRAM_ID).to_string() + ); + + let config = config_snapshot(); + let inspected = quote_json(json!({ + "schema": WIRE_SCHEMA, + "operation": "inspect_config", + "ammProgramId": AMM_PROGRAM_ID, + "config": config.clone(), + })) + .expect("config must inspect"); + assert_eq!(inspected["schema"], WIRE_SCHEMA); + assert_eq!(inspected["ammProgramId"], json!(AMM_PROGRAM_ID)); + assert_eq!(inspected["tokenProgramId"], json!(TOKEN_PROGRAM_ID)); + assert_eq!( + inspected["twapOracleProgramId"], + json!(TWAP_ORACLE_PROGRAM_ID) + ); + assert_eq!(inspected["authority"], AccountId::new([9; 32]).to_string()); + + let canonical = quote_json(json!({ + "operation": "canonical_pair", + "firstTokenDefinitionId": first_token_id.to_string(), + "secondTokenDefinitionId": second_token_id.to_string(), + })) + .expect("distinct pair must canonicalize"); + assert_eq!(canonical["tokenAId"], second_token_id.to_string()); + assert_eq!(canonical["tokenBId"], first_token_id.to_string()); + + let manifest = quote_json(json!({ + "operation": "derive_pair_read_manifest", + "ammProgramId": AMM_PROGRAM_ID, + "config": config, + "firstTokenDefinitionId": first_token_id.to_string(), + "secondTokenDefinitionId": second_token_id.to_string(), + })) + .expect("pair read manifest must derive"); + let pool_id = compute_pool_pda(AMM_PROGRAM_ID, second_token_id, first_token_id); + assert_eq!(manifest["poolId"], pool_id.to_string()); + assert_eq!( + manifest["firstToken"]["definitionId"], + first_token_id.to_string() + ); + assert_eq!( + manifest["firstToken"]["vaultId"], + compute_vault_pda(AMM_PROGRAM_ID, pool_id, first_token_id).to_string() + ); + assert_eq!( + manifest["secondToken"]["vaultId"], + compute_vault_pda(AMM_PROGRAM_ID, pool_id, second_token_id).to_string() + ); + assert_eq!( + manifest["liquidityDefinitionId"], + compute_liquidity_token_pda(AMM_PROGRAM_ID, pool_id).to_string() + ); + assert_eq!( + manifest["lpLockHoldingId"], + compute_lp_lock_holding_pda(AMM_PROGRAM_ID, pool_id).to_string() + ); + assert_eq!( + manifest["currentTickId"], + compute_current_tick_account_pda(TWAP_ORACLE_PROGRAM_ID, pool_id).to_string() + ); + assert_eq!(manifest["clockId"], CLOCK_01_PROGRAM_ACCOUNT_ID.to_string()); +} + +#[test] +fn inspect_pair_reports_missing_caller_ordered_state() { + let ids = PairIds::new(); + let inspected = quote_json(ids.inspect_request(ids.missing_snapshots())) + .expect("missing pair snapshots must inspect"); + + assert_eq!(inspected["status"], "missing"); + assert_eq!(inspected["manifest"]["poolId"], ids.pool_id.to_string()); + assert_eq!( + inspected["firstTokenDefinition"]["id"], + ids.first_token_id.to_string() + ); + assert_eq!(inspected["firstTokenDefinition"]["totalSupply"], "10000"); + assert_eq!( + inspected["secondTokenDefinition"]["id"], + ids.second_token_id.to_string() + ); + assert_eq!(inspected["secondTokenDefinition"]["totalSupply"], "20000"); + assert_eq!(inspected["firstVault"]["status"], "existing_fungible"); + assert_eq!(inspected["firstVault"]["balance"], "7"); + assert_eq!(inspected["secondVault"]["status"], "uninitialized"); + assert_eq!(inspected["clock"]["blockId"], "123"); + assert_eq!(inspected["clock"]["timestamp"], "456"); +} + +#[test] +fn inspect_pair_reports_active_stored_state_for_reversed_caller_order() { + let ids = PairIds::new(); + let inspected = quote_json(ids.inspect_request(ids.active_snapshots())) + .expect("active pair snapshots must inspect"); + + assert_eq!(inspected["status"], "active"); + assert_eq!(inspected["callerOrder"], "reversed"); + assert_eq!( + inspected["stored"]["tokenADefinitionId"], + ids.second_token_id.to_string() + ); + assert_eq!( + inspected["stored"]["tokenBDefinitionId"], + ids.first_token_id.to_string() + ); + assert_eq!( + inspected["stored"]["vaultAId"], + ids.second_vault_id.to_string() + ); + assert_eq!( + inspected["stored"]["vaultBId"], + ids.first_vault_id.to_string() + ); + assert_eq!(inspected["stored"]["reserveA"], "1000"); + assert_eq!(inspected["stored"]["reserveB"], "500"); + assert_eq!(inspected["stored"]["vaultABalance"], "1100"); + assert_eq!(inspected["stored"]["vaultBBalance"], "550"); + assert_eq!(inspected["stored"]["liquidityPoolSupply"], "2000"); + assert_eq!(inspected["stored"]["lpLockBalance"], "1000"); + assert_eq!(inspected["stored"]["feeBps"], "30"); + assert_eq!( + inspected["storedSpotPriceQ64_64"], + (Q64_64_ONE / 2).to_string() + ); + assert_eq!(inspected["currentTick"]["tick"], "-1"); + assert_eq!(inspected["currentTick"]["lastUpdated"], "400"); + assert_eq!(inspected["clock"]["blockId"], "123"); + assert_eq!(inspected["clock"]["timestamp"], "456"); +} + +#[test] +fn inspect_pair_preserves_stable_snapshot_validation_errors() { + let ids = PairIds::new(); + let mut snapshots = ids.missing_snapshots(); + snapshots["pool"]["id"] = Value::String(AccountId::new([99; 32]).to_string()); + + let error = quote_json(ids.inspect_request(snapshots)) + .expect_err("wrong pool snapshot ID must fail before lifecycle inspection"); + assert_eq!(error.code(), "account_id_mismatch"); +} + +#[test] +fn opening_intent_operations_preserve_lossless_decimal_values() { + let desired_price = Q64_64_ONE.checked_mul(2).expect("test price fits"); + let fee_bps = FEE_TIER_BPS_30.to_string(); + + let minimum = quote_json(json!({ + "operation": "prepare_minimum_opening_pair", + "desiredPriceQ64_64": desired_price.to_string(), + "feeBps": fee_bps, + })) + .expect("minimum executable pair must prepare"); + for field in [ + "desiredPriceQ64_64", + "actualPriceQ64_64", + "tokenAAmount", + "tokenBAmount", + "feeBps", + ] { + assert_decimal_string(&minimum[field]); + } + assert_eq!( + minimum["quote"]["pool"]["reserveA"], + minimum["tokenAAmount"] + ); + assert_eq!( + minimum["quote"]["pool"]["reserveB"], + minimum["tokenBAmount"] + ); + + let from_a = quote_json(json!({ + "operation": "prepare_opening_from_token_a", + "tokenAAmount": "2000", + "desiredPriceQ64_64": desired_price.to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + })) + .expect("token-A edit must prepare"); + assert_eq!(from_a["tokenAAmount"], "2000"); + assert_eq!(from_a["tokenBAmount"], "4000"); + assert_eq!(from_a["actualPriceQ64_64"], desired_price.to_string()); + + let from_b = quote_json(json!({ + "operation": "prepare_opening_from_token_b", + "tokenBAmount": "4000", + "desiredPriceQ64_64": desired_price.to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + })) + .expect("token-B edit must prepare"); + assert_eq!(from_b["tokenAAmount"], "2000"); + assert_eq!(from_b["tokenBAmount"], "4000"); + + let above_javascript_integer_range = 1_u128 << 80; + let paired = above_javascript_integer_range + .checked_mul(2) + .expect("test pair fits"); + let explicit = quote_json(json!({ + "operation": "validate_explicit_opening_pair", + "tokenAAmount": above_javascript_integer_range.to_string(), + "tokenBAmount": paired.to_string(), + "desiredPriceQ64_64": desired_price.to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + })) + .expect("explicit pair must validate"); + assert_eq!( + explicit["tokenAAmount"], + above_javascript_integer_range.to_string() + ); + assert_eq!(explicit["tokenBAmount"], paired.to_string()); +} + +#[test] +fn caller_opening_intents_map_reversed_order_without_local_price_math() { + let ids = PairIds::new(); + let desired_price = Q64_64_ONE.checked_mul(2).expect("test price fits"); + let request = |intent: Value| { + json!({ + "operation": "prepare_caller_opening_pair", + "firstTokenDefinitionId": ids.first_token_id.to_string(), + "secondTokenDefinitionId": ids.second_token_id.to_string(), + "desiredPriceQ64_64": desired_price.to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + "intent": intent, + }) + }; + + let first = quote_json(request(json!({ + "kind": "first_amount", + "amount": "4000", + }))) + .expect("caller first amount must prepare"); + assert_eq!(first["callerOrder"], "reversed"); + assert_eq!(first["firstAmount"], "4000"); + assert_eq!(first["secondAmount"], "2000"); + assert_eq!(first["stored"]["tokenAAmount"], "2000"); + assert_eq!(first["stored"]["tokenBAmount"], "4000"); + + let second = quote_json(request(json!({ + "kind": "second_amount", + "amount": "2000", + }))) + .expect("caller second amount must prepare"); + assert_eq!(second["firstAmount"], "4000"); + assert_eq!(second["secondAmount"], "2000"); + + let explicit = quote_json(request(json!({ + "kind": "explicit", + "firstAmount": "4000", + "secondAmount": "2000", + }))) + .expect("caller explicit amounts must prepare"); + assert_eq!( + explicit["stored"]["actualPriceQ64_64"], + desired_price.to_string() + ); + + let minimum = quote_json(request(json!({ "kind": "minimum" }))) + .expect("caller minimum amounts must prepare"); + assert_eq!(minimum["callerOrder"], "reversed"); + assert_decimal_string(&minimum["firstAmount"]); + assert_decimal_string(&minimum["secondAmount"]); +} + +#[test] +fn opening_intent_wire_errors_keep_stable_codes_and_string_inputs() { + let zero_price = quote_json(json!({ + "operation": "prepare_minimum_opening_pair", + "desiredPriceQ64_64": "0", + "feeBps": FEE_TIER_BPS_30.to_string(), + })) + .expect_err("zero desired price must fail"); + assert_eq!(zero_price.code(), "zero_desired_price"); + + let numeric_amount = quote_json(json!({ + "operation": "prepare_opening_from_token_a", + "tokenAAmount": 2000, + "desiredPriceQ64_64": Q64_64_ONE.to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + })) + .expect_err("numeric chain amount must not enter the lossless wire contract"); + assert_eq!(numeric_amount.code(), "invalid_request"); + + let mismatched = quote_json(json!({ + "operation": "validate_explicit_opening_pair", + "tokenAAmount": "2000", + "tokenBAmount": "4001", + "desiredPriceQ64_64": (Q64_64_ONE * 2).to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + })) + .expect_err("nonmatching explicit spot price must fail"); + assert_eq!(mismatched.code(), "spot_price_mismatch"); +} diff --git a/programs/amm/client/tests/wire_prepare_contract.rs b/programs/amm/client/tests/wire_prepare_contract.rs index ffd7a65..a8ba37b 100644 --- a/programs/amm/client/tests/wire_prepare_contract.rs +++ b/programs/amm/client/tests/wire_prepare_contract.rs @@ -184,7 +184,7 @@ fn prepare_wire_operations_return_lossless_instruction_args() { minimum_guard_amount(decimal(&add["quote"]["liquidityToMint"]), tolerance) .expect("minimum LP guard must fit") ); - assert_eq!(add["instructionArgs"]["maxAmountToAddTokenA"], "200"); + assert_eq!(add["instructionArgs"]["maxAmountToAddTokenA"], "400"); assert_eq!(add["instructionArgs"]["maxAmountToAddTokenB"], "100"); let mut remove_request = fixture.request("prepare_remove_liquidity"); diff --git a/programs/amm/client/tests/wire_transaction_contract.rs b/programs/amm/client/tests/wire_transaction_contract.rs new file mode 100644 index 0000000..2359f84 --- /dev/null +++ b/programs/amm/client/tests/wire_transaction_contract.rs @@ -0,0 +1,565 @@ +use amm_client::wire::{plan_json, quote_json}; +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, + compute_vault_pda, AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, +}; +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use serde_json::{json, Value}; +use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount}; + +const AMM_PROGRAM_ID: ProgramId = [42; 8]; +const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; +const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; +const LARGE: u128 = 9_007_199_254_740_993; +const DEADLINE: u64 = 9_007_199_254_740_993; + +fn account(program_owner: ProgramId, data: Data) -> Account { + Account { + program_owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn definition(total_supply: u128, authority: Option) -> Account { + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenDefinition::Fungible { + name: String::from("Token"), + total_supply, + metadata_id: None, + authority, + }), + ) +} + +fn holding(definition_id: AccountId, balance: u128) -> Account { + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + ) +} + +fn snapshot(id: AccountId, account: &Account) -> Value { + json!({ + "id": id.to_string(), + "programOwner": 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::(), + }) +} + +struct TransactionFixture { + first_token_id: AccountId, + second_token_id: AccountId, + pool_id: AccountId, + first_vault_id: AccountId, + second_vault_id: AccountId, + liquidity_definition_id: AccountId, + lp_lock_holding_id: AccountId, + current_tick_id: AccountId, + first_holding_id: AccountId, + second_holding_id: AccountId, + liquidity_holding_id: AccountId, + fresh_liquidity_holding_id: AccountId, + config: Value, + active_snapshots: Value, + missing_snapshots: Value, + first_holding: Value, + second_holding: Value, + liquidity_holding: Value, + fresh_liquidity_holding: Value, +} + +impl TransactionFixture { + fn new() -> Self { + let first_token_id = AccountId::new([1; 32]); + let second_token_id = AccountId::new([2; 32]); + let pool_id = compute_pool_pda(AMM_PROGRAM_ID, second_token_id, first_token_id); + let first_vault_id = compute_vault_pda(AMM_PROGRAM_ID, pool_id, first_token_id); + let second_vault_id = compute_vault_pda(AMM_PROGRAM_ID, pool_id, second_token_id); + let liquidity_definition_id = compute_liquidity_token_pda(AMM_PROGRAM_ID, pool_id); + let lp_lock_holding_id = compute_lp_lock_holding_pda(AMM_PROGRAM_ID, pool_id); + let current_tick_id = compute_current_tick_account_pda(TWAP_ORACLE_PROGRAM_ID, pool_id); + let first_holding_id = AccountId::new([20; 32]); + let second_holding_id = AccountId::new([21; 32]); + let liquidity_holding_id = AccountId::new([22; 32]); + let fresh_liquidity_holding_id = AccountId::new([30; 32]); + let total_supply = LARGE.checked_mul(10).expect("test supply fits"); + let config = snapshot( + compute_config_pda(AMM_PROGRAM_ID), + &account( + AMM_PROGRAM_ID, + Data::from(&AmmConfig { + token_program_id: TOKEN_PROGRAM_ID, + twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, + authority: AccountId::new([9; 32]), + }), + ), + ); + let clock_bytes = ClockAccountData { + block_id: 123, + timestamp: 456, + } + .to_bytes(); + let clock = snapshot( + CLOCK_01_PROGRAM_ACCOUNT_ID, + &account( + [88; 8], + Data::try_from(clock_bytes).expect("clock data must fit"), + ), + ); + let first_definition = snapshot(first_token_id, &definition(total_supply, None)); + let second_definition = snapshot(second_token_id, &definition(total_supply, None)); + + let pool = PoolDefinition { + definition_token_a_id: second_token_id, + definition_token_b_id: first_token_id, + vault_a_id: second_vault_id, + vault_b_id: first_vault_id, + liquidity_pool_id: liquidity_definition_id, + liquidity_pool_supply: 2_000, + reserve_a: 1_000, + reserve_b: 500, + fees: FEE_TIER_BPS_30, + }; + let active_snapshots = json!({ + "pool": snapshot(pool_id, &account(AMM_PROGRAM_ID, Data::from(&pool))), + "firstTokenDefinition": first_definition.clone(), + "secondTokenDefinition": second_definition.clone(), + "firstTokenVault": snapshot(first_vault_id, &holding(first_token_id, 550)), + "secondTokenVault": snapshot(second_vault_id, &holding(second_token_id, 1_100)), + "liquidityDefinition": snapshot( + liquidity_definition_id, + &definition(2_000, Some(liquidity_definition_id)), + ), + "lpLockHolding": snapshot( + lp_lock_holding_id, + &holding(liquidity_definition_id, MINIMUM_LIQUIDITY), + ), + "currentTick": snapshot( + current_tick_id, + &account( + TWAP_ORACLE_PROGRAM_ID, + Data::from(&CurrentTickAccount { + tick: -1, + last_updated: 400, + }), + ), + ), + "clock": clock.clone(), + }); + let missing_snapshots = json!({ + "pool": snapshot(pool_id, &Account::default()), + "firstTokenDefinition": first_definition, + "secondTokenDefinition": second_definition, + "firstTokenVault": snapshot(first_vault_id, &Account::default()), + "secondTokenVault": snapshot(second_vault_id, &Account::default()), + "liquidityDefinition": snapshot(liquidity_definition_id, &Account::default()), + "lpLockHolding": snapshot(lp_lock_holding_id, &Account::default()), + "currentTick": snapshot(current_tick_id, &Account::default()), + "clock": clock, + }); + let holding_balance = LARGE.checked_mul(3).expect("test balance fits"); + + Self { + first_token_id, + second_token_id, + pool_id, + first_vault_id, + second_vault_id, + liquidity_definition_id, + lp_lock_holding_id, + current_tick_id, + first_holding_id, + second_holding_id, + liquidity_holding_id, + fresh_liquidity_holding_id, + config, + active_snapshots, + missing_snapshots, + first_holding: snapshot(first_holding_id, &holding(first_token_id, holding_balance)), + second_holding: snapshot( + second_holding_id, + &holding(second_token_id, holding_balance), + ), + liquidity_holding: snapshot( + liquidity_holding_id, + &holding(liquidity_definition_id, 1_000), + ), + fresh_liquidity_holding: snapshot(fresh_liquidity_holding_id, &Account::default()), + } + } + + fn active_common(&self, operation: &str) -> Value { + json!({ + "operation": operation, + "ammProgramId": AMM_PROGRAM_ID, + "config": self.config.clone(), + "snapshots": self.active_snapshots.clone(), + "firstTokenDefinitionId": self.first_token_id.to_string(), + "secondTokenDefinitionId": self.second_token_id.to_string(), + "firstTokenHolding": self.first_holding.clone(), + "secondTokenHolding": self.second_holding.clone(), + "liquidityHolding": self.liquidity_holding.clone(), + "slippageBps": "100", + "expectedFeeBps": FEE_TIER_BPS_30.to_string(), + "deadline": DEADLINE.to_string(), + }) + } + + fn swap_common(&self, operation: &str) -> Value { + json!({ + "operation": operation, + "ammProgramId": AMM_PROGRAM_ID, + "config": self.config.clone(), + "snapshots": self.active_snapshots.clone(), + "inputTokenDefinitionId": self.first_token_id.to_string(), + "outputTokenDefinitionId": self.second_token_id.to_string(), + "inputHolding": self.first_holding.clone(), + "outputHolding": self.second_holding.clone(), + "slippageBps": "100", + "expectedFeeBps": FEE_TIER_BPS_30.to_string(), + "deadline": DEADLINE.to_string(), + }) + } +} + +fn insert(value: &mut Value, field: &str, inserted: Value) { + drop( + value + .as_object_mut() + .expect("request must be an object") + .insert(String::from(field), inserted), + ); +} + +fn decode_instruction(response: &Value) -> Instruction { + let words = response + .pointer("/plan/instructionWords") + .expect("plan must contain instruction words") + .clone(); + let words: Vec = + serde_json::from_value(words).expect("plan instruction words must be u32 JSON values"); + risc0_zkvm::serde::from_slice(&words).expect("guest codec must decode wire plan") +} + +fn assert_instruction_arg(response: &Value, name: &str, expected: impl ToString) { + let pointer = format!("/plan/instructionArgs/{name}"); + assert_eq!( + response + .pointer(&pointer) + .and_then(Value::as_str) + .expect("typed instruction argument must be a string"), + expected.to_string() + ); +} + +fn assert_common_contract(response: &Value, operation: &str, expect_spot_change: bool) { + assert_eq!(response["operation"], operation); + assert_eq!(response["deadline"], DEADLINE.to_string()); + assert!(response["quote"].is_object()); + assert!(response + .pointer("/callerAmounts/first") + .is_some_and(Value::is_string)); + assert!(response + .pointer("/callerAmounts/second") + .is_some_and(Value::is_string)); + assert!(response + .pointer("/plan/accounts") + .is_some_and(Value::is_array)); + assert!(response + .pointer("/plan/instructionArgs") + .is_some_and(Value::is_object)); + assert_eq!( + response["affectedAccountIds"], + *response + .pointer("/plan/affectedAccountIds") + .expect("plan must contain affected account IDs") + ); + assert!(response + .pointer("/walletPrerequisites/signerAccountIds") + .is_some_and(Value::is_array)); + assert!(response + .pointer("/walletPrerequisites/freshAccountIds") + .is_some_and(Value::is_array)); + assert!(response + .pointer("/walletPrerequisites/funding") + .is_some_and(Value::is_array)); + + let commitment = response["quoteCommitment"] + .as_str() + .expect("commitment must be a hex string"); + assert_eq!(commitment.len(), 64); + assert!(commitment + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))); + assert_eq!( + response["poolSpotChangeBps"].is_string(), + expect_spot_change + ); + assert_eq!(response["poolSpotChangeBps"].is_null(), !expect_spot_change); +} + +#[test] +fn five_transaction_operations_emit_exact_plans_and_task_artifacts() { + let fixture = TransactionFixture::new(); + + let second_amount = LARGE.checked_mul(2).expect("test amount fits"); + let create = quote_json(json!({ + "operation": "prepare_create_pool_transaction", + "ammProgramId": AMM_PROGRAM_ID, + "config": fixture.config.clone(), + "snapshots": fixture.missing_snapshots.clone(), + "firstTokenDefinitionId": fixture.first_token_id.to_string(), + "secondTokenDefinitionId": fixture.second_token_id.to_string(), + "firstTokenHolding": fixture.first_holding.clone(), + "secondTokenHolding": fixture.second_holding.clone(), + "liquidityHolding": fixture.fresh_liquidity_holding.clone(), + "firstAmount": LARGE.to_string(), + "secondAmount": second_amount.to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + "deadline": DEADLINE.to_string(), + })) + .expect("create transaction must prepare"); + assert_common_contract(&create, "create_pool", false); + assert_eq!(create["callerAmounts"]["first"], LARGE.to_string()); + assert_eq!(create["callerAmounts"]["second"], second_amount.to_string()); + assert_eq!( + create["walletPrerequisites"]["freshAccountIds"], + json!([fixture.fresh_liquidity_holding_id.to_string()]) + ); + assert_eq!( + create["walletPrerequisites"]["funding"][0]["required"], + LARGE.to_string() + ); + match decode_instruction(&create) { + Instruction::NewDefinition { + token_a_amount, + token_b_amount, + deadline, + .. + } => { + assert_eq!(token_a_amount, second_amount); + assert_eq!(token_b_amount, LARGE); + assert_eq!(deadline, DEADLINE); + assert_instruction_arg(&create, "tokenAAmount", token_a_amount); + assert_instruction_arg(&create, "tokenBAmount", token_b_amount); + assert_instruction_arg(&create, "fees", FEE_TIER_BPS_30); + assert_instruction_arg(&create, "deadline", deadline); + } + Instruction::Initialize { .. } + | Instruction::UpdateConfig { .. } + | Instruction::CreatePriceObservations { .. } + | Instruction::CreateOraclePriceAccount { .. } + | Instruction::AddLiquidity { .. } + | Instruction::RemoveLiquidity { .. } + | Instruction::SwapExactInput { .. } + | Instruction::SwapExactOutput { .. } + | Instruction::SyncReserves => { + panic!("create wire operation emitted wrong instruction") + } + } + + let mut add_request = fixture.active_common("prepare_add_liquidity_transaction"); + insert(&mut add_request, "maxFirstAmount", json!("100")); + insert(&mut add_request, "maxSecondAmount", json!("400")); + let add = quote_json(add_request.clone()).expect("add transaction must prepare"); + assert_eq!( + plan_json(add_request).expect("plan entrypoint must prepare task transactions"), + add + ); + assert_common_contract(&add, "add_liquidity", false); + assert_eq!(add["callerAmounts"]["first"], "100"); + assert_eq!(add["callerAmounts"]["second"], "200"); + assert_eq!( + add.pointer("/walletPrerequisites/funding") + .and_then(Value::as_array) + .map(Vec::len), + Some(2) + ); + assert_eq!( + add.pointer("/walletPrerequisites/funding/0/required"), + Some(&json!("100")) + ); + assert_eq!( + add.pointer("/walletPrerequisites/funding/1/required"), + Some(&json!("400")) + ); + match decode_instruction(&add) { + Instruction::AddLiquidity { + min_amount_liquidity, + max_amount_to_add_token_a, + max_amount_to_add_token_b, + deadline, + } => { + assert_eq!(max_amount_to_add_token_a, 400); + assert_eq!(max_amount_to_add_token_b, 100); + assert_instruction_arg(&add, "minAmountLiquidity", min_amount_liquidity); + assert_instruction_arg(&add, "maxAmountToAddTokenA", max_amount_to_add_token_a); + assert_instruction_arg(&add, "maxAmountToAddTokenB", max_amount_to_add_token_b); + assert_instruction_arg(&add, "deadline", deadline); + } + Instruction::Initialize { .. } + | Instruction::UpdateConfig { .. } + | Instruction::CreatePriceObservations { .. } + | Instruction::CreateOraclePriceAccount { .. } + | Instruction::NewDefinition { .. } + | Instruction::RemoveLiquidity { .. } + | Instruction::SwapExactInput { .. } + | Instruction::SwapExactOutput { .. } + | Instruction::SyncReserves => panic!("add wire operation emitted wrong instruction"), + } + + let mut remove_request = fixture.active_common("prepare_remove_liquidity_transaction"); + insert(&mut remove_request, "removeLiquidityAmount", json!("500")); + let remove = quote_json(remove_request).expect("remove transaction must prepare"); + assert_common_contract(&remove, "remove_liquidity", false); + assert_eq!(remove["callerAmounts"]["first"], "125"); + assert_eq!(remove["callerAmounts"]["second"], "250"); + assert_eq!( + remove["walletPrerequisites"]["funding"][0]["holdingAccountId"], + fixture.liquidity_holding_id.to_string() + ); + match decode_instruction(&remove) { + Instruction::RemoveLiquidity { + remove_liquidity_amount, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + deadline, + } => { + assert_instruction_arg(&remove, "removeLiquidityAmount", remove_liquidity_amount); + assert_instruction_arg( + &remove, + "minAmountToRemoveTokenA", + min_amount_to_remove_token_a, + ); + assert_instruction_arg( + &remove, + "minAmountToRemoveTokenB", + min_amount_to_remove_token_b, + ); + assert_instruction_arg(&remove, "deadline", deadline); + } + Instruction::Initialize { .. } + | Instruction::UpdateConfig { .. } + | Instruction::CreatePriceObservations { .. } + | Instruction::CreateOraclePriceAccount { .. } + | Instruction::NewDefinition { .. } + | Instruction::AddLiquidity { .. } + | Instruction::SwapExactInput { .. } + | Instruction::SwapExactOutput { .. } + | Instruction::SyncReserves => panic!("remove wire operation emitted wrong instruction"), + } + + let mut exact_input_request = fixture.swap_common("prepare_swap_exact_input_transaction"); + insert(&mut exact_input_request, "amountIn", json!("100")); + let exact_input = + quote_json(exact_input_request).expect("exact-input transaction must prepare"); + assert_common_contract(&exact_input, "swap_exact_input", true); + assert_eq!(exact_input["callerAmounts"]["first"], "100"); + assert_eq!( + exact_input["walletPrerequisites"]["funding"][0]["holdingAccountId"], + fixture.first_holding_id.to_string() + ); + match decode_instruction(&exact_input) { + Instruction::SwapExactInput { + swap_amount_in, + min_amount_out, + deadline, + } => { + assert_instruction_arg(&exact_input, "swapAmountIn", swap_amount_in); + assert_instruction_arg(&exact_input, "minAmountOut", min_amount_out); + assert_instruction_arg(&exact_input, "deadline", deadline); + } + Instruction::Initialize { .. } + | Instruction::UpdateConfig { .. } + | Instruction::CreatePriceObservations { .. } + | Instruction::CreateOraclePriceAccount { .. } + | Instruction::NewDefinition { .. } + | Instruction::AddLiquidity { .. } + | Instruction::RemoveLiquidity { .. } + | Instruction::SwapExactOutput { .. } + | Instruction::SyncReserves => { + panic!("exact-input wire operation emitted wrong instruction") + } + } + + let mut exact_output_request = fixture.swap_common("prepare_swap_exact_output_transaction"); + insert(&mut exact_output_request, "exactAmountOut", json!("100")); + let exact_output = + quote_json(exact_output_request).expect("exact-output transaction must prepare"); + assert_common_contract(&exact_output, "swap_exact_output", true); + assert_eq!(exact_output["callerAmounts"]["second"], "100"); + match decode_instruction(&exact_output) { + Instruction::SwapExactOutput { + exact_amount_out, + max_amount_in, + deadline, + } => { + assert_instruction_arg(&exact_output, "exactAmountOut", exact_amount_out); + assert_instruction_arg(&exact_output, "maxAmountIn", max_amount_in); + assert_instruction_arg(&exact_output, "deadline", deadline); + assert_eq!( + exact_output.pointer("/walletPrerequisites/funding/0/required"), + Some(&json!(max_amount_in.to_string())) + ); + } + Instruction::Initialize { .. } + | Instruction::UpdateConfig { .. } + | Instruction::CreatePriceObservations { .. } + | Instruction::CreateOraclePriceAccount { .. } + | Instruction::NewDefinition { .. } + | Instruction::AddLiquidity { .. } + | Instruction::RemoveLiquidity { .. } + | Instruction::SwapExactInput { .. } + | Instruction::SyncReserves => { + panic!("exact-output wire operation emitted wrong instruction") + } + } + + assert_eq!( + fixture.pool_id.to_string(), + add["plan"]["accounts"][1]["id"] + ); + assert_ne!(fixture.first_vault_id, fixture.second_vault_id); + assert_ne!(fixture.lp_lock_holding_id, fixture.current_tick_id); + assert_eq!( + fixture.liquidity_definition_id.to_string(), + remove["walletPrerequisites"]["funding"][0]["tokenDefinitionId"] + ); + let output_holding = exact_input["plan"]["accounts"] + .as_array() + .expect("plan accounts must be an array") + .iter() + .find(|account| account["role"] == "user_output_holding") + .expect("swap plan must contain output holding"); + assert_eq!(fixture.second_holding_id.to_string(), output_holding["id"]); +} + +#[test] +fn transaction_wire_rejects_expected_fee_mismatch() { + let fixture = TransactionFixture::new(); + let mut request = fixture.active_common("prepare_add_liquidity_transaction"); + insert(&mut request, "maxFirstAmount", json!("100")); + insert(&mut request, "maxSecondAmount", json!("400")); + insert(&mut request, "expectedFeeBps", json!("100")); + + let error = quote_json(request).expect_err("wrong expected fee must fail"); + assert_eq!(error.code(), "fee_mismatch"); +} diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index 25e5b93..a8743df 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -583,19 +583,36 @@ pub fn compute_pool_pda( ) } +/// Returns the deterministic token order used by the pool PDA derivation. +/// +/// The token with the lexicographically greater raw account-ID bytes is token A. This comparison +/// is independent of any textual account representation. Returns `None` when both definitions are +/// the same, because a token cannot be paired with itself. +#[must_use] +pub fn canonical_token_pair( + first_definition_id: AccountId, + second_definition_id: AccountId, +) -> Option<(AccountId, AccountId)> { + match first_definition_id + .value() + .cmp(second_definition_id.value()) + { + std::cmp::Ordering::Less => Some((second_definition_id, first_definition_id)), + std::cmp::Ordering::Greater => Some((first_definition_id, second_definition_id)), + std::cmp::Ordering::Equal => None, + } +} + pub fn compute_pool_pda_seed( definition_token_a_id: AccountId, definition_token_b_id: AccountId, ) -> PdaSeed { use risc0_zkvm::sha::{Impl, Sha256}; - let (token_1, token_2) = match definition_token_a_id - .value() - .cmp(definition_token_b_id.value()) - { - std::cmp::Ordering::Less => (definition_token_b_id, definition_token_a_id), - std::cmp::Ordering::Greater => (definition_token_a_id, definition_token_b_id), - std::cmp::Ordering::Equal => panic!("Definitions match"), + let Some((token_1, token_2)) = + canonical_token_pair(definition_token_a_id, definition_token_b_id) + else { + panic!("Definitions match"); }; let mut bytes = [0; 64]; @@ -711,6 +728,43 @@ mod tests { /// `1.0` in Q64.64 is `2^64`. const ONE_Q64_64: u128 = 1u128 << 64; + fn account_id(byte: u8) -> AccountId { + AccountId::new([byte; 32]) + } + + #[test] + fn canonical_token_pair_uses_raw_descending_account_id_order() { + let lower = account_id(1); + let higher = account_id(2); + + assert_eq!(canonical_token_pair(lower, higher), Some((higher, lower))); + assert_eq!(canonical_token_pair(higher, lower), Some((higher, lower))); + assert_eq!(canonical_token_pair(lower, lower), None); + } + + #[test] + fn pool_pda_is_unchanged_by_caller_token_order() { + let amm_program_id = [42u32; 8]; + let lower = account_id(1); + let higher = account_id(2); + + assert_eq!( + compute_pool_pda(amm_program_id, lower, higher), + compute_pool_pda(amm_program_id, higher, lower) + ); + assert_eq!( + compute_pool_pda_seed(lower, higher), + compute_pool_pda_seed(higher, lower) + ); + } + + #[test] + #[should_panic(expected = "Definitions match")] + fn pool_pda_seed_preserves_identical_definition_panic() { + let definition = account_id(1); + let _ = compute_pool_pda_seed(definition, definition); + } + #[test] fn equal_reserves_map_to_unit_price() { assert_eq!(spot_price_q64_64(1_000, 1_000), ONE_Q64_64);