From a7395aadb73672444c9cd5bba83c9627325d6d2a Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 22 Jul 2026 13:46:54 -0300 Subject: [PATCH] feat(amm): add reusable client APIs Add validated shared quote orchestration, canonical planners for every guest instruction, and exact RISC Zero serialization. Expose integer-only slippage preparation and lossless JSON/C adapters without runtime deployment identity checks. --- Cargo.lock | 15 + Cargo.toml | 2 + programs/amm/client/Cargo.toml | 21 + programs/amm/client/README.md | 71 + programs/amm/client/build.rs | 17 + programs/amm/client/docs/wire-api.md | 185 +++ programs/amm/client/include/amm_client.h | 46 + programs/amm/client/src/error.rs | 126 ++ programs/amm/client/src/ffi.rs | 172 +++ programs/amm/client/src/lib.rs | 26 + programs/amm/client/src/plan.rs | 904 +++++++++++ programs/amm/client/src/quote.rs | 698 +++++++++ programs/amm/client/src/slippage.rs | 267 ++++ programs/amm/client/src/wire.rs | 1350 +++++++++++++++++ programs/amm/client/tests/ffi_contract.rs | 356 +++++ programs/amm/client/tests/plan_contract.rs | 636 ++++++++ programs/amm/client/tests/quote_contract.rs | 647 ++++++++ .../amm/client/tests/slippage_contract.rs | 91 ++ .../amm/client/tests/wire_prepare_contract.rs | 274 ++++ programs/amm/core/src/lib.rs | 2 +- programs/amm/src/quote.rs | 267 +++- programs/amm/tests/quote_api.rs | 284 ++-- 22 files changed, 6300 insertions(+), 157 deletions(-) create mode 100644 programs/amm/client/Cargo.toml create mode 100644 programs/amm/client/README.md create mode 100644 programs/amm/client/build.rs create mode 100644 programs/amm/client/docs/wire-api.md create mode 100644 programs/amm/client/include/amm_client.h create mode 100644 programs/amm/client/src/error.rs create mode 100644 programs/amm/client/src/ffi.rs create mode 100644 programs/amm/client/src/lib.rs create mode 100644 programs/amm/client/src/plan.rs create mode 100644 programs/amm/client/src/quote.rs create mode 100644 programs/amm/client/src/slippage.rs create mode 100644 programs/amm/client/src/wire.rs create mode 100644 programs/amm/client/tests/ffi_contract.rs create mode 100644 programs/amm/client/tests/plan_contract.rs create mode 100644 programs/amm/client/tests/quote_contract.rs create mode 100644 programs/amm/client/tests/slippage_contract.rs create mode 100644 programs/amm/client/tests/wire_prepare_contract.rs diff --git a/Cargo.lock b/Cargo.lock index 33bb2f9..39550c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,21 @@ dependencies = [ "risc0-zkvm", ] +[[package]] +name = "amm_client" +version = "0.1.0" +dependencies = [ + "amm_core", + "amm_program", + "clock_core", + "lee_core", + "risc0-zkvm", + "serde", + "serde_json", + "token_core", + "twap_oracle_core", +] + [[package]] name = "amm_core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 76b127f..7a89068 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "programs/token/methods", "programs/amm/core", "programs/amm", + "programs/amm/client", "programs/amm/methods", "programs/ata/core", "programs/ata", @@ -39,6 +40,7 @@ token_core = { path = "programs/token/core" } token_program = { path = "programs/token" } amm_core = { path = "programs/amm/core" } amm_program = { path = "programs/amm" } +amm_client = { path = "programs/amm/client" } ata_core = { path = "programs/ata/core" } ata_program = { path = "programs/ata" } twap_oracle_core = { path = "programs/twap_oracle/core" } diff --git a/programs/amm/client/Cargo.toml b/programs/amm/client/Cargo.toml new file mode 100644 index 0000000..0c8097a --- /dev/null +++ b/programs/amm/client/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "amm_client" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[lints] +workspace = true + +[dependencies] +amm_core = { path = "../core" } +amm_program = { path = ".." } +clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0" } +nssa_core = { workspace = true } +risc0-zkvm = { version = "=3.0.5", default-features = false } +serde = { workspace = true } +serde_json = { workspace = true } +token_core = { workspace = true } +twap_oracle_core = { path = "../../twap_oracle/core" } diff --git a/programs/amm/client/README.md b/programs/amm/client/README.md new file mode 100644 index 0000000..ab58b8a --- /dev/null +++ b/programs/amm/client/README.md @@ -0,0 +1,71 @@ +# AMM client + +`amm_client` is the stateless host boundary for the AMM program. It reuses +`amm_program::quote` for economic calculations, builds the actual +`amm_core::Instruction` variants, derives protocol accounts through core PDA helpers, and encodes +instructions with the RISC Zero Serde codec consumed by the guest. + +The crate does not fetch accounts, manage keys, sign, or submit transactions. Those remain host +adapter responsibilities. + +## Rust API + +- `quote` validates fetched config, pool, vault, token-definition, LP-definition, and user-holding + snapshots before delegating calculations to `amm_program::quote`. +- `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. +- `TransactionPlan::instruction_data` serializes its `amm_core::Instruction` with + `risc0_zkvm::serde::to_vec`. +- `wire` exposes lossless JSON adapters for non-Rust hosts. + +Planner coverage: + +| Guest instruction | Planner | +|---|---| +| `Initialize` | `plan_initialize` | +| `UpdateConfig` | `plan_update_config` | +| `CreatePriceObservations` | `plan_create_price_observations` | +| `CreateOraclePriceAccount` | `plan_create_oracle_price_account` | +| `NewDefinition` | `plan_create_pool` | +| `AddLiquidity` | `plan_add_liquidity` | +| `RemoveLiquidity` | `plan_remove_liquidity` | +| `SwapExactInput` | `plan_swap_exact_input` | +| `SwapExactOutput` | `plan_swap_exact_output` | +| `SyncReserves` | `plan_sync_reserves` | + +Quote coverage includes protocol constants, pair ordering, pool creation, preview and exact +add/remove liquidity, preview and exact-input/output swaps, reserve synchronization, and +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. + +## Compatibility assumption + +The client and deployed AMM are expected to be built from the corresponding source version. The +client performs no runtime ImageID, release-version, or program allowlist check. The supplied AMM +program ID is used for transaction targeting and canonical PDA derivation. Snapshot owner, account +relationship, and PDA checks remain normal protocol validation. + +## C and JSON boundary + +The built library exports: + +```c +char *amm_client_plan(const char *request_json); +char *amm_client_quote(const char *request_json); +void amm_client_free(char *value); +``` + +Every call returns an owned JSON envelope. Release it exactly once with `amm_client_free`; passing +`NULL` to the free function is allowed. See [`include/amm_client.h`](include/amm_client.h) and +[`docs/wire-api.md`](docs/wire-api.md) for the complete transport contract. + +Raw `u128` and `u64` values cross JSON as decimal strings. Account IDs use 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. diff --git a/programs/amm/client/build.rs b/programs/amm/client/build.rs new file mode 100644 index 0000000..0f5c2dd --- /dev/null +++ b/programs/amm/client/build.rs @@ -0,0 +1,17 @@ +use std::env; + +fn main() { + let Ok(target_os) = env::var("CARGO_CFG_TARGET_OS") else { + return; + }; + + // RISC Zero's host-side serde dependency contains guest syscall shims with exported C names. + // They are implementation details of this cdylib and would otherwise leak beside the three + // supported amm_client_* entry points. + if matches!( + target_os.as_str(), + "android" | "dragonfly" | "freebsd" | "linux" | "netbsd" | "openbsd" + ) { + println!("cargo:rustc-cdylib-link-arg=-Wl,--exclude-libs,ALL"); + } +} diff --git a/programs/amm/client/docs/wire-api.md b/programs/amm/client/docs/wire-api.md new file mode 100644 index 0000000..46b9e37 --- /dev/null +++ b/programs/amm/client/docs/wire-api.md @@ -0,0 +1,185 @@ +# AMM client JSON wire API + +The C ABI accepts one tagged JSON object and returns one envelope: + +```json +{"ok":true,"value":{}} +``` + +```json +{"ok":false,"error":{"code":"invalid_request","message":"..."}} +``` + +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. + +## Shared inputs + +Plan context: + +```json +{ + "ammProgramId": [0, 0, 0, 0, 0, 0, 0, 0], + "tokenProgramId": [0, 0, 0, 0, 0, 0, 0, 0], + "twapOracleProgramId": [0, 0, 0, 0, 0, 0, 0, 0], + "authority": "base58-account-id" +} +``` + +Decoded pool input used by existing-pool planners: + +```json +{ + "poolId": "base58-account-id", + "definitionTokenAId": "base58-account-id", + "definitionTokenBId": "base58-account-id", + "vaultAId": "base58-account-id", + "vaultBId": "base58-account-id", + "liquidityPoolId": "base58-account-id", + "liquidityPoolSupply": "2000", + "reserveA": "1000", + "reserveB": "500", + "fees": "30" +} +``` + +Fetched account snapshot used by quotes: + +```json +{ + "id": "base58-account-id", + "programOwner": [0, 0, 0, 0, 0, 0, 0, 0], + "balance": "0", + "nonce": "0", + "data": "00ff" +} +``` + +Existing-pool quote operations include these top-level state fields: + +```json +{ + "ammProgramId": [0, 0, 0, 0, 0, 0, 0, 0], + "config": { "...": "account snapshot" }, + "snapshot": { + "pool": { "...": "account snapshot" }, + "tokenADefinition": { "...": "account snapshot" }, + "tokenBDefinition": { "...": "account snapshot" }, + "vaultA": { "...": "account snapshot" }, + "vaultB": { "...": "account snapshot" }, + "liquidityDefinition": { "...": "account snapshot" } + } +} +``` + +## Plan operations + +Send requests to `amm_client_plan` or `wire::plan_json`. + +| `operation` | Additional fields | +|---|---| +| `initialize` | `ammProgramId`, `tokenProgramId`, `twapOracleProgramId`, `authority` | +| `update_config` | `context`, optional `tokenProgramId`, optional `twapOracleProgramId`, optional `newAuthority` | +| `create_price_observations` | `context`, `poolId`, `windowDuration` | +| `create_oracle_price_account` | `context`, `poolId`, `windowDuration` | +| `create_pool` | `context`, `tokenADefinitionId`, `tokenBDefinitionId`, `userHoldingA`, `userHoldingB`, `userHoldingLp`, `tokenAAmount`, `tokenBAmount`, `fees`, `deadline` | +| `add_liquidity` | `context`, `pool`, `userHoldingA`, `userHoldingB`, `userHoldingLp`, `minAmountLiquidity`, `maxAmountToAddTokenA`, `maxAmountToAddTokenB`, `deadline` | +| `remove_liquidity` | `context`, `pool`, `userHoldingA`, `userHoldingB`, `userHoldingLp`, `removeLiquidityAmount`, `minAmountToRemoveTokenA`, `minAmountToRemoveTokenB`, `deadline` | +| `swap_exact_input` | `context`, `pool`, `userInputHolding`, `userOutputHolding`, `swapAmountIn`, `minAmountOut`, `deadline` | +| `swap_exact_output` | `context`, `pool`, `userInputHolding`, `userOutputHolding`, `exactAmountOut`, `maxAmountIn`, `deadline` | +| `sync_reserves` | `context`, `pool` | + +A successful plan value contains the following fields (`instructionWords` is abbreviated here): + +```json +{ + "instruction": "add_liquidity", + "programId": [0, 0, 0, 0, 0, 0, 0, 0], + "accounts": [ + { + "id": "base58-account-id", + "role": "config", + "writable": false, + "signer": false, + "init": false + } + ], + "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. + +## 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. + +| `operation` | Additional fields | +|---|---| +| `protocol_constants` | none; returns decimal-string `minimumLiquidity`, `feeBpsDenominator`, `slippageBpsDenominator`, and `supportedFeeTiers` | +| `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 | +| `preview_add_liquidity` | `maxAmountA`, `maxAmountB` | +| `prepare_add_liquidity` | `maxAmountA`, `maxAmountB`, `slippageBps` | +| `add_liquidity` | `maxAmountA`, `maxAmountB`, `minimumLiquidity` | +| `preview_remove_liquidity` | `userLiquidityHolding`, `removeLiquidityAmount` | +| `prepare_remove_liquidity` | `userLiquidityHolding`, `removeLiquidityAmount`, `slippageBps` | +| `remove_liquidity` | `userLiquidityHolding`, `removeLiquidityAmount`, `minimumAmountA`, `minimumAmountB` | +| `preview_swap_exact_input` | `userInputHolding`, `userOutputHolding`, `inputTokenDefinitionId`, `amountIn` | +| `prepare_swap_exact_input` | `userInputHolding`, `userOutputHolding`, `inputTokenDefinitionId`, `amountIn`, `slippageBps` | +| `swap_exact_input` | `userInputHolding`, `userOutputHolding`, `inputTokenDefinitionId`, `amountIn`, `minimumAmountOut` | +| `preview_swap_exact_output` | `userInputHolding`, `userOutputHolding`, `inputTokenDefinitionId`, `exactAmountOut` | +| `prepare_swap_exact_output` | `userInputHolding`, `userOutputHolding`, `inputTokenDefinitionId`, `exactAmountOut`, `slippageBps` | +| `swap_exact_output` | `userInputHolding`, `userOutputHolding`, `inputTokenDefinitionId`, `exactAmountOut`, `maximumAmountIn` | +| `sync_reserves` | no additional fields | +| `create_oracle_price_account` | `windowDuration` | + +Quote values use these result shapes: + +- pool creation: `pool`, `lockedLiquidity`, `userLiquidity`; +- add liquidity: `actualAmountA`, `actualAmountB`, `liquidityToMint`, `pool`; +- remove liquidity: `withdrawAmountA`, `withdrawAmountB`, `liquidityToBurn`, `pool`; +- swaps: `direction`, `amountIn`, `effectiveAmountIn`, `feeAmount`, `amountOut`, `pool`; +- reserve sync: `donatedAmountA`, `donatedAmountB`, `pool`; +- oracle price: `baseAsset`, `quoteAsset`, `initialPriceQ64_64`, `windowDuration`; and +- pair order: `order` (`stored` or `reversed`). + +A `pool` result contains decimal-string `liquidityPoolSupply`, `reserveA`, `reserveB`, and +`spotPriceQ64_64` fields. + +## Prepared instruction arguments + +The five `prepare_*` operations return the economic result under `quote` and decimal-string chain +arguments under `instructionArgs`. Those fields map directly to the matching plan operation: + +- `prepare_create_pool`: `tokenAAmount`, `tokenBAmount`, `fees`; +- `prepare_add_liquidity`: `minAmountLiquidity`, `maxAmountToAddTokenA`, + `maxAmountToAddTokenB`; +- `prepare_remove_liquidity`: `removeLiquidityAmount`, `minAmountToRemoveTokenA`, + `minAmountToRemoveTokenB`; +- `prepare_swap_exact_input`: `swapAmountIn`, `minAmountOut`; and +- `prepare_swap_exact_output`: `exactAmountOut`, `maxAmountIn`. + +`slippageBps` accepts `0` through `slippageBpsDenominator` (`10,000`) as an unsigned decimal +string. Minimum guards use integer floor rounding and stay at least one raw unit for positive +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. + +## Ownership and failures + +The client validates account decoding, configured owners, canonical PDAs, pool/vault/token/LP +relationships, swap input/output pairing, and required input balances. Quote arithmetic failures +retain the stable `amm_program::quote::QuoteError` code. + +No request performs network I/O or checks an ImageID, release version, compatibility manifest, or +program allowlist. Deployment configuration is expected to select the corresponding AMM build. diff --git a/programs/amm/client/include/amm_client.h b/programs/amm/client/include/amm_client.h new file mode 100644 index 0000000..24dc84c --- /dev/null +++ b/programs/amm/client/include/amm_client.h @@ -0,0 +1,46 @@ +#ifndef AMM_CLIENT_H +#define AMM_CLIENT_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * 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. + * 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. + * 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":...}}. + */ + +/* + * Releases a response returned by amm_client_plan or amm_client_quote. + * Passing NULL is allowed. Every non-NULL response must be released exactly once. + */ +void amm_client_free(char *value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/programs/amm/client/src/error.rs b/programs/amm/client/src/error.rs new file mode 100644 index 0000000..c905bb5 --- /dev/null +++ b/programs/amm/client/src/error.rs @@ -0,0 +1,126 @@ +use std::{error::Error, fmt}; + +use nssa_core::{account::AccountId, program::ProgramId}; + +/// Failure while validating AMM client input or constructing a request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ClientError { + /// An account ID differs from its canonical or stored value. + AccountIdMismatch { + account: &'static str, + expected: AccountId, + actual: AccountId, + }, + /// An account owner differs from the owner required by the program. + ProgramOwnerMismatch { + account: &'static str, + expected: ProgramId, + actual: ProgramId, + }, + /// Account bytes cannot be decoded as the required program type. + InvalidAccountData { + account: &'static str, + expected: &'static str, + }, + /// A token account is not a fungible holding. + ExpectedFungibleToken { account: &'static str }, + /// A token holding points at the wrong definition. + TokenDefinitionMismatch { + account: &'static str, + expected: AccountId, + actual: AccountId, + }, + /// A holding cannot cover the amount required by a quoted operation. + InsufficientBalance { + account: &'static str, + available: u128, + required: u128, + }, + /// A pool was requested with the same token on both sides. + IdenticalTokenDefinitions, + /// Slippage basis points exceed one whole quoted amount. + SlippageToleranceOutOfRange { bps: u128, maximum_bps: u128 }, + /// A slippage-adjusted upper guard exceeds the chain amount range. + SlippageBoundOverflow { + quoted_amount: u128, + slippage_bps: u128, + }, + /// Program-owned quote logic rejected the requested transition. + Quote { + code: &'static str, + message: &'static str, + }, +} + +impl ClientError { + /// Stable machine-readable error code. + #[must_use] + pub const fn code(&self) -> &'static str { + match self { + Self::AccountIdMismatch { .. } => "account_id_mismatch", + Self::ProgramOwnerMismatch { .. } => "program_owner_mismatch", + Self::InvalidAccountData { .. } => "invalid_account_data", + Self::ExpectedFungibleToken { .. } => "expected_fungible_token", + Self::TokenDefinitionMismatch { .. } => "token_definition_mismatch", + Self::InsufficientBalance { .. } => "insufficient_balance", + Self::IdenticalTokenDefinitions => "identical_token_definitions", + Self::SlippageToleranceOutOfRange { .. } => "slippage_tolerance_out_of_range", + Self::SlippageBoundOverflow { .. } => "slippage_bound_overflow", + Self::Quote { code, .. } => code, + } + } +} + +impl fmt::Display for ClientError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AccountIdMismatch { account, .. } => { + write!(formatter, "{account} account ID mismatch") + } + Self::ProgramOwnerMismatch { account, .. } => { + write!(formatter, "{account} program owner mismatch") + } + Self::InvalidAccountData { account, expected } => { + write!( + formatter, + "{account} does not contain valid {expected} data" + ) + } + Self::ExpectedFungibleToken { account } => { + write!(formatter, "{account} must be a fungible token holding") + } + Self::TokenDefinitionMismatch { account, .. } => { + write!(formatter, "{account} token definition mismatch") + } + Self::InsufficientBalance { + account, + available, + required, + } => write!( + formatter, + "{account} balance {available} is less than required amount {required}" + ), + Self::IdenticalTokenDefinitions => { + formatter.write_str("pool token definitions must be distinct") + } + Self::SlippageToleranceOutOfRange { + bps, + maximum_bps, + } => write!( + formatter, + "slippage tolerance {bps} bps exceeds maximum {maximum_bps} bps" + ), + Self::SlippageBoundOverflow { + quoted_amount, + slippage_bps, + } => write!( + formatter, + "slippage-adjusted upper guard for {quoted_amount} at {slippage_bps} bps exceeds u128" + ), + Self::Quote { message, .. } => formatter.write_str(message), + } + } +} + +impl Error for ClientError {} diff --git a/programs/amm/client/src/ffi.rs b/programs/amm/client/src/ffi.rs new file mode 100644 index 0000000..ba608bb --- /dev/null +++ b/programs/amm/client/src/ffi.rs @@ -0,0 +1,172 @@ +//! C ABI for the lossless JSON AMM client protocol. + +#![allow( + unsafe_code, + reason = "raw C strings and paired allocation ownership are confined to this module" +)] + +use std::{ + ffi::{c_char, CStr, CString}, + panic::{catch_unwind, AssertUnwindSafe}, +}; + +use serde::Serialize; +use serde_json::Value; + +use crate::wire::{self, WireError}; + +type Operation = fn(Value) -> Result; + +#[derive(Serialize)] +struct Envelope { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl Envelope { + fn success(value: Value) -> Self { + Self { + ok: true, + value: Some(value), + error: None, + } + } + + fn failure(error: ErrorPayload) -> Self { + Self { + ok: false, + value: None, + error: Some(error), + } + } +} + +#[derive(Serialize)] +struct ErrorPayload { + code: String, + message: String, +} + +impl ErrorPayload { + fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } + + fn from_wire(error: WireError) -> Self { + Self::new(error.code(), error.to_string()) + } +} + +/// Calls one JSON operation and converts every outcome into an owned C string. +/// +/// # Safety +/// +/// `request_json` must be null or point to a live NUL-terminated byte string for the duration of +/// this call. A non-null return value must be released exactly once with [`amm_client_free`]. +unsafe fn call(request_json: *const c_char, operation: Operation) -> *mut c_char { + let result = catch_unwind(AssertUnwindSafe(|| { + // SAFETY: The exported caller contract establishes pointer validity and lifetime. The + // helper validates nullness before constructing `CStr`. + let request = unsafe { request_value(request_json) }?; + operation(request).map_err(ErrorPayload::from_wire) + })); + + let envelope = match result { + Ok(Ok(value)) => Envelope::success(value), + Ok(Err(error)) => Envelope::failure(error), + Err(_) => Envelope::failure(ErrorPayload::new( + "internal_panic", + "AMM client operation panicked", + )), + }; + encode_envelope(&envelope) +} + +/// Reads and parses one caller-owned JSON C string. +/// +/// # Safety +/// +/// `request_json` must be null or point to a live NUL-terminated byte string for this call. +unsafe fn request_value(request_json: *const c_char) -> Result { + if request_json.is_null() { + return Err(ErrorPayload::new("null_request", "request pointer is null")); + } + + // SAFETY: Nullness was checked above. Remaining validity, lifetime, and NUL-termination are + // required by the exported caller contract. + let request = unsafe { CStr::from_ptr(request_json) }; + let request = request.to_str().map_err(|error| { + ErrorPayload::new("invalid_utf8", format!("request is not UTF-8: {error}")) + })?; + serde_json::from_str(request).map_err(|error| { + ErrorPayload::new( + "invalid_json", + format!("request is not valid JSON: {error}"), + ) + }) +} + +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"}}"#, + ), + }; + + match CString::new(json) { + Ok(value) => value.into_raw(), + Err(_) => CString::new( + r#"{"ok":false,"error":{"code":"response_contains_nul","message":"response contains NUL"}}"#, + ) + .map_or(std::ptr::null_mut(), CString::into_raw), + } +} + +/// Builds a canonical AMM transaction plan from a tagged JSON request. +/// +/// Returned JSON owns its memory and must be released with [`amm_client_free`]. +/// +/// # Safety +/// +/// `request_json` must be null or point to a live NUL-terminated UTF-8 byte string for this call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn amm_client_plan(request_json: *const c_char) -> *mut c_char { + // SAFETY: This function exposes the same pointer contract as `call`. + unsafe { call(request_json, wire::plan_json) } +} + +/// Evaluates a canonical AMM economic quote from a tagged JSON request. +/// +/// Returned JSON owns its memory and must be released with [`amm_client_free`]. +/// +/// # Safety +/// +/// `request_json` must be null or point to a live NUL-terminated UTF-8 byte string for this call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn amm_client_quote(request_json: *const c_char) -> *mut c_char { + // SAFETY: This function exposes the same pointer contract as `call`. + unsafe { call(request_json, wire::quote_json) } +} + +/// Releases a response returned by [`amm_client_plan`] or [`amm_client_quote`]. +/// +/// # Safety +/// +/// `value` must be null or a pointer returned by this library that has not already been freed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn amm_client_free(value: *mut c_char) { + if value.is_null() { + return; + } + + // SAFETY: The caller contract requires a unique, live pointer produced by + // `CString::into_raw` in `encode_envelope`. + drop(unsafe { CString::from_raw(value) }); +} diff --git a/programs/amm/client/src/lib.rs b/programs/amm/client/src/lib.rs new file mode 100644 index 0000000..e384f38 --- /dev/null +++ b/programs/amm/client/src/lib.rs @@ -0,0 +1,26 @@ +//! Stateless AMM quoting and transaction planning for host consumers. + +pub mod error; +mod ffi; +pub mod plan; +pub mod quote; +pub mod slippage; +pub mod wire; + +pub use error::ClientError; +pub use ffi::{amm_client_free, amm_client_plan, amm_client_quote}; +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, + plan_swap_exact_output, plan_sync_reserves, plan_update_config, AccountRole, + AddLiquidityPlanInput, AmmContext, CreateOraclePriceAccountPlanInput, CreatePoolPlanInput, + CreatePriceObservationsPlanInput, InitializePlanInput, PlannedAccount, PoolContext, + RemoveLiquidityPlanInput, SwapExactInputPlanInput, SwapExactOutputPlanInput, + SyncReservesPlanInput, TransactionPlan, UpdateConfigPlanInput, +}; +pub use slippage::{ + maximum_guard_amount, minimum_guard_amount, prepare_add_liquidity, prepare_create_pool, + prepare_remove_liquidity, prepare_swap_exact_input, prepare_swap_exact_output, + PreparedAddLiquidity, PreparedCreatePool, PreparedRemoveLiquidity, PreparedSwapExactInput, + PreparedSwapExactOutput, SlippageTolerance, SLIPPAGE_BPS_DENOMINATOR, +}; diff --git a/programs/amm/client/src/plan.rs b/programs/amm/client/src/plan.rs new file mode 100644 index 0000000..29abdee --- /dev/null +++ b/programs/amm/client/src/plan.rs @@ -0,0 +1,904 @@ +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, + compute_vault_pda, AmmConfig, Instruction, PoolDefinition, +}; +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; +use nssa_core::{ + account::AccountId, + program::{InstructionData, ProgramId}, +}; +use twap_oracle_core::{ + compute_current_tick_account_pda, compute_oracle_price_account_pda, + compute_price_observations_pda, +}; + +use crate::ClientError; + +/// Configured AMM program context used by deterministic planners. +/// +/// `amm_program_id` is accepted optimistically. The client derives addresses for that program but +/// does not perform release, ImageID, or deployment-version checks. +#[derive(Clone)] +pub struct AmmContext { + pub amm_program_id: ProgramId, + pub config: AmmConfig, +} + +impl AmmContext { + #[must_use] + pub const fn new(amm_program_id: ProgramId, config: AmmConfig) -> Self { + Self { + amm_program_id, + config, + } + } + + #[must_use] + pub fn config_id(&self) -> AccountId { + compute_config_pda(self.amm_program_id) + } + + #[must_use] + pub const fn token_program_id(&self) -> ProgramId { + self.config.token_program_id + } + + #[must_use] + pub const fn twap_oracle_program_id(&self) -> ProgramId { + self.config.twap_oracle_program_id + } +} + +/// An initialized pool and its canonical stored identity fields. +#[derive(Clone, Copy)] +pub struct PoolContext<'a> { + pool_id: AccountId, + pool: &'a PoolDefinition, +} + +impl<'a> PoolContext<'a> { + /// Validates the stored pool identity fields against canonical AMM PDA derivation. + pub fn new( + context: &AmmContext, + pool_id: AccountId, + pool: &'a PoolDefinition, + ) -> Result { + if pool.definition_token_a_id == pool.definition_token_b_id { + return Err(ClientError::IdenticalTokenDefinitions); + } + + validate_account_id( + "pool", + compute_pool_pda( + context.amm_program_id, + pool.definition_token_a_id, + pool.definition_token_b_id, + ), + pool_id, + )?; + validate_account_id( + "vault_a", + compute_vault_pda(context.amm_program_id, pool_id, pool.definition_token_a_id), + pool.vault_a_id, + )?; + validate_account_id( + "vault_b", + compute_vault_pda(context.amm_program_id, pool_id, pool.definition_token_b_id), + pool.vault_b_id, + )?; + validate_account_id( + "pool_definition_lp", + compute_liquidity_token_pda(context.amm_program_id, pool_id), + pool.liquidity_pool_id, + )?; + + Ok(Self { pool_id, pool }) + } + + #[must_use] + pub const fn pool_id(&self) -> AccountId { + self.pool_id + } + + #[must_use] + pub const fn pool(&self) -> &PoolDefinition { + self.pool + } +} + +/// Semantic name of an account in an AMM instruction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum AccountRole { + Config, + Authority, + Pool, + VaultA, + VaultB, + PoolDefinitionLp, + LpLockHolding, + UserHoldingA, + UserHoldingB, + UserHoldingLp, + UserInputHolding, + UserOutputHolding, + CurrentTickAccount, + PriceObservations, + OraclePriceAccount, + Clock, +} + +impl AccountRole { + /// Exact role name emitted by the AMM IDL. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Config => "config", + Self::Authority => "authority", + Self::Pool => "pool", + Self::VaultA => "vault_a", + Self::VaultB => "vault_b", + Self::PoolDefinitionLp => "pool_definition_lp", + Self::LpLockHolding => "lp_lock_holding", + Self::UserHoldingA => "user_holding_a", + Self::UserHoldingB => "user_holding_b", + Self::UserHoldingLp => "user_holding_lp", + Self::UserInputHolding => "user_input_holding", + Self::UserOutputHolding => "user_output_holding", + Self::CurrentTickAccount => "current_tick_account", + Self::PriceObservations => "price_observations", + Self::OraclePriceAccount => "oracle_price_account", + Self::Clock => "clock", + } + } +} + +/// Ordered account row required by an AMM instruction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PlannedAccount { + id: AccountId, + role: AccountRole, + writable: bool, + signer: bool, + init: bool, +} + +impl PlannedAccount { + #[must_use] + pub const fn id(&self) -> AccountId { + self.id + } + + #[must_use] + pub const fn role(&self) -> AccountRole { + self.role + } + + #[must_use] + pub const fn writable(&self) -> bool { + self.writable + } + + #[must_use] + pub const fn signer(&self) -> bool { + self.signer + } + + #[must_use] + pub const fn init(&self) -> bool { + self.init + } +} + +/// Canonical instruction plus ordered accounts for wallet submission. +pub struct TransactionPlan { + program_id: ProgramId, + instruction: Instruction, + accounts: Vec, +} + +impl TransactionPlan { + fn new(program_id: ProgramId, instruction: Instruction, accounts: Vec) -> Self { + Self { + program_id, + instruction, + accounts, + } + } + + #[must_use] + pub const fn program_id(&self) -> ProgramId { + self.program_id + } + + #[must_use] + pub const fn instruction(&self) -> &Instruction { + &self.instruction + } + + /// Exact guest-compatible RISC Zero Serde instruction words. + pub fn instruction_data(&self) -> risc0_zkvm::serde::Result { + encode_instruction(&self.instruction) + } + + #[must_use] + pub fn accounts(&self) -> &[PlannedAccount] { + &self.accounts + } + + #[must_use] + pub fn account_ids(&self) -> Vec { + self.accounts.iter().map(PlannedAccount::id).collect() + } + + /// One signer requirement for each ordered account ID. + #[must_use] + pub fn signer_flags(&self) -> Vec { + self.accounts.iter().map(PlannedAccount::signer).collect() + } + + /// Signer IDs in their original account-list order. + #[must_use] + pub fn signer_account_ids(&self) -> Vec { + self.accounts + .iter() + .filter(|account| account.signer()) + .map(PlannedAccount::id) + .collect() + } + + /// Guest instruction name, kept exhaustive over the canonical enum. + #[must_use] + pub const fn instruction_name(&self) -> &'static str { + match &self.instruction { + Instruction::Initialize { .. } => "initialize", + Instruction::UpdateConfig { .. } => "update_config", + Instruction::CreatePriceObservations { .. } => "create_price_observations", + Instruction::CreateOraclePriceAccount { .. } => "create_oracle_price_account", + Instruction::NewDefinition { .. } => "new_definition", + Instruction::AddLiquidity { .. } => "add_liquidity", + Instruction::RemoveLiquidity { .. } => "remove_liquidity", + Instruction::SwapExactInput { .. } => "swap_exact_input", + Instruction::SwapExactOutput { .. } => "swap_exact_output", + Instruction::SyncReserves => "sync_reserves", + } + } +} + +/// Encode the actual instruction enum through the codec consumed by the AMM guest. +pub fn encode_instruction(instruction: &Instruction) -> risc0_zkvm::serde::Result { + risc0_zkvm::serde::to_vec(instruction) +} + +pub struct InitializePlanInput { + pub amm_program_id: ProgramId, + pub token_program_id: ProgramId, + pub twap_oracle_program_id: ProgramId, + pub authority: AccountId, +} + +pub struct UpdateConfigPlanInput<'a> { + pub context: &'a AmmContext, + pub token_program_id: Option, + pub twap_oracle_program_id: Option, + pub new_authority: Option, +} + +pub struct CreatePriceObservationsPlanInput<'a> { + pub context: &'a AmmContext, + pub pool_id: AccountId, + pub window_duration: u64, +} + +pub struct CreateOraclePriceAccountPlanInput<'a> { + pub context: &'a AmmContext, + pub pool_id: AccountId, + pub window_duration: u64, +} + +pub struct CreatePoolPlanInput<'a> { + pub context: &'a AmmContext, + pub token_a_definition_id: AccountId, + pub token_b_definition_id: AccountId, + pub user_holding_a: AccountId, + pub user_holding_b: AccountId, + pub user_holding_lp: AccountId, + pub token_a_amount: u128, + pub token_b_amount: u128, + pub fees: u128, + pub deadline: u64, +} + +pub struct AddLiquidityPlanInput<'a> { + pub context: &'a AmmContext, + pub pool: PoolContext<'a>, + pub user_holding_a: AccountId, + pub user_holding_b: AccountId, + pub user_holding_lp: AccountId, + pub min_amount_liquidity: u128, + pub max_amount_to_add_token_a: u128, + pub max_amount_to_add_token_b: u128, + pub deadline: u64, +} + +pub struct RemoveLiquidityPlanInput<'a> { + pub context: &'a AmmContext, + pub pool: PoolContext<'a>, + pub user_holding_a: AccountId, + pub user_holding_b: AccountId, + pub user_holding_lp: AccountId, + pub remove_liquidity_amount: u128, + pub min_amount_to_remove_token_a: u128, + pub min_amount_to_remove_token_b: u128, + pub deadline: u64, +} + +pub struct SwapExactInputPlanInput<'a> { + pub context: &'a AmmContext, + pub pool: PoolContext<'a>, + pub user_input_holding: AccountId, + pub user_output_holding: AccountId, + pub swap_amount_in: u128, + pub min_amount_out: u128, + pub deadline: u64, +} + +pub struct SwapExactOutputPlanInput<'a> { + pub context: &'a AmmContext, + pub pool: PoolContext<'a>, + pub user_input_holding: AccountId, + pub user_output_holding: AccountId, + pub exact_amount_out: u128, + pub max_amount_in: u128, + pub deadline: u64, +} + +pub struct SyncReservesPlanInput<'a> { + pub context: &'a AmmContext, + pub pool: PoolContext<'a>, +} + +#[must_use] +pub fn plan_initialize(input: InitializePlanInput) -> TransactionPlan { + TransactionPlan::new( + input.amm_program_id, + Instruction::Initialize { + token_program_id: input.token_program_id, + twap_oracle_program_id: input.twap_oracle_program_id, + authority: input.authority, + }, + vec![planned( + compute_config_pda(input.amm_program_id), + AccountRole::Config, + true, + false, + true, + )], + ) +} + +#[must_use] +pub fn plan_update_config(input: UpdateConfigPlanInput<'_>) -> TransactionPlan { + TransactionPlan::new( + input.context.amm_program_id, + Instruction::UpdateConfig { + token_program_id: input.token_program_id, + twap_oracle_program_id: input.twap_oracle_program_id, + new_authority: input.new_authority, + }, + vec![ + planned( + input.context.config_id(), + AccountRole::Config, + true, + false, + false, + ), + planned( + input.context.config.authority, + AccountRole::Authority, + false, + true, + false, + ), + ], + ) +} + +#[must_use] +pub fn plan_create_price_observations( + input: CreatePriceObservationsPlanInput<'_>, +) -> TransactionPlan { + let oracle_program_id = input.context.twap_oracle_program_id(); + TransactionPlan::new( + input.context.amm_program_id, + Instruction::CreatePriceObservations { + window_duration: input.window_duration, + }, + vec![ + planned( + input.context.config_id(), + AccountRole::Config, + false, + false, + false, + ), + planned(input.pool_id, AccountRole::Pool, false, false, false), + planned( + compute_current_tick_account_pda(oracle_program_id, input.pool_id), + AccountRole::CurrentTickAccount, + false, + false, + false, + ), + planned( + compute_price_observations_pda( + oracle_program_id, + input.pool_id, + input.window_duration, + ), + AccountRole::PriceObservations, + true, + false, + true, + ), + planned( + CLOCK_01_PROGRAM_ACCOUNT_ID, + AccountRole::Clock, + false, + false, + false, + ), + ], + ) +} + +#[must_use] +pub fn plan_create_oracle_price_account( + input: CreateOraclePriceAccountPlanInput<'_>, +) -> TransactionPlan { + let oracle_program_id = input.context.twap_oracle_program_id(); + TransactionPlan::new( + input.context.amm_program_id, + Instruction::CreateOraclePriceAccount { + window_duration: input.window_duration, + }, + vec![ + planned( + input.context.config_id(), + AccountRole::Config, + false, + false, + false, + ), + planned(input.pool_id, AccountRole::Pool, false, false, false), + planned( + compute_oracle_price_account_pda( + oracle_program_id, + input.pool_id, + input.window_duration, + ), + AccountRole::OraclePriceAccount, + true, + false, + true, + ), + planned( + CLOCK_01_PROGRAM_ACCOUNT_ID, + AccountRole::Clock, + false, + false, + false, + ), + ], + ) +} + +pub fn plan_create_pool(input: CreatePoolPlanInput<'_>) -> Result { + if input.token_a_definition_id == input.token_b_definition_id { + return Err(ClientError::IdenticalTokenDefinitions); + } + + let program_id = input.context.amm_program_id; + let pool_id = compute_pool_pda( + program_id, + input.token_a_definition_id, + input.token_b_definition_id, + ); + let vault_a = compute_vault_pda(program_id, pool_id, input.token_a_definition_id); + let vault_b = compute_vault_pda(program_id, pool_id, input.token_b_definition_id); + let liquidity_token = compute_liquidity_token_pda(program_id, pool_id); + let lock_holding = compute_lp_lock_holding_pda(program_id, pool_id); + let current_tick = + compute_current_tick_account_pda(input.context.twap_oracle_program_id(), pool_id); + + Ok(TransactionPlan::new( + program_id, + Instruction::NewDefinition { + token_a_amount: input.token_a_amount, + token_b_amount: input.token_b_amount, + fees: input.fees, + deadline: input.deadline, + }, + vec![ + planned( + input.context.config_id(), + AccountRole::Config, + false, + false, + false, + ), + planned(pool_id, AccountRole::Pool, true, false, true), + planned(vault_a, AccountRole::VaultA, true, false, false), + planned(vault_b, AccountRole::VaultB, true, false, false), + planned( + liquidity_token, + AccountRole::PoolDefinitionLp, + true, + false, + true, + ), + planned(lock_holding, AccountRole::LpLockHolding, true, false, true), + planned( + input.user_holding_a, + AccountRole::UserHoldingA, + true, + true, + false, + ), + planned( + input.user_holding_b, + AccountRole::UserHoldingB, + true, + true, + false, + ), + planned( + input.user_holding_lp, + AccountRole::UserHoldingLp, + true, + true, + false, + ), + planned( + current_tick, + AccountRole::CurrentTickAccount, + true, + false, + true, + ), + planned( + CLOCK_01_PROGRAM_ACCOUNT_ID, + AccountRole::Clock, + false, + false, + false, + ), + ], + )) +} + +#[must_use] +pub fn plan_add_liquidity(input: AddLiquidityPlanInput<'_>) -> TransactionPlan { + let tick = current_tick(input.context, input.pool.pool_id); + TransactionPlan::new( + input.context.amm_program_id, + Instruction::AddLiquidity { + min_amount_liquidity: input.min_amount_liquidity, + max_amount_to_add_token_a: input.max_amount_to_add_token_a, + max_amount_to_add_token_b: input.max_amount_to_add_token_b, + deadline: input.deadline, + }, + vec![ + planned( + input.context.config_id(), + AccountRole::Config, + false, + false, + false, + ), + planned(input.pool.pool_id, AccountRole::Pool, true, false, false), + planned( + input.pool.pool.vault_a_id, + AccountRole::VaultA, + true, + false, + false, + ), + planned( + input.pool.pool.vault_b_id, + AccountRole::VaultB, + true, + false, + false, + ), + planned( + input.pool.pool.liquidity_pool_id, + AccountRole::PoolDefinitionLp, + true, + false, + false, + ), + planned( + input.user_holding_a, + AccountRole::UserHoldingA, + true, + true, + false, + ), + planned( + input.user_holding_b, + AccountRole::UserHoldingB, + true, + true, + false, + ), + planned( + input.user_holding_lp, + AccountRole::UserHoldingLp, + true, + false, + false, + ), + planned(tick, AccountRole::CurrentTickAccount, true, false, false), + planned( + CLOCK_01_PROGRAM_ACCOUNT_ID, + AccountRole::Clock, + false, + false, + false, + ), + ], + ) +} + +#[must_use] +pub fn plan_remove_liquidity(input: RemoveLiquidityPlanInput<'_>) -> TransactionPlan { + let tick = current_tick(input.context, input.pool.pool_id); + TransactionPlan::new( + input.context.amm_program_id, + Instruction::RemoveLiquidity { + remove_liquidity_amount: input.remove_liquidity_amount, + min_amount_to_remove_token_a: input.min_amount_to_remove_token_a, + min_amount_to_remove_token_b: input.min_amount_to_remove_token_b, + deadline: input.deadline, + }, + vec![ + planned( + input.context.config_id(), + AccountRole::Config, + false, + false, + false, + ), + planned(input.pool.pool_id, AccountRole::Pool, true, false, false), + planned( + input.pool.pool.vault_a_id, + AccountRole::VaultA, + true, + false, + false, + ), + planned( + input.pool.pool.vault_b_id, + AccountRole::VaultB, + true, + false, + false, + ), + planned( + input.pool.pool.liquidity_pool_id, + AccountRole::PoolDefinitionLp, + true, + false, + false, + ), + planned( + input.user_holding_a, + AccountRole::UserHoldingA, + true, + false, + false, + ), + planned( + input.user_holding_b, + AccountRole::UserHoldingB, + true, + false, + false, + ), + planned( + input.user_holding_lp, + AccountRole::UserHoldingLp, + true, + true, + false, + ), + planned(tick, AccountRole::CurrentTickAccount, true, false, false), + planned( + CLOCK_01_PROGRAM_ACCOUNT_ID, + AccountRole::Clock, + false, + false, + false, + ), + ], + ) +} + +#[must_use] +pub fn plan_swap_exact_input(input: SwapExactInputPlanInput<'_>) -> TransactionPlan { + swap_plan( + input.context, + input.pool, + input.user_input_holding, + input.user_output_holding, + Instruction::SwapExactInput { + swap_amount_in: input.swap_amount_in, + min_amount_out: input.min_amount_out, + deadline: input.deadline, + }, + ) +} + +#[must_use] +pub fn plan_swap_exact_output(input: SwapExactOutputPlanInput<'_>) -> TransactionPlan { + swap_plan( + input.context, + input.pool, + input.user_input_holding, + input.user_output_holding, + Instruction::SwapExactOutput { + exact_amount_out: input.exact_amount_out, + max_amount_in: input.max_amount_in, + deadline: input.deadline, + }, + ) +} + +#[must_use] +pub fn plan_sync_reserves(input: SyncReservesPlanInput<'_>) -> TransactionPlan { + TransactionPlan::new( + input.context.amm_program_id, + Instruction::SyncReserves, + vec![ + planned( + input.context.config_id(), + AccountRole::Config, + false, + false, + false, + ), + planned(input.pool.pool_id, AccountRole::Pool, true, false, false), + planned( + input.pool.pool.vault_a_id, + AccountRole::VaultA, + false, + false, + false, + ), + planned( + input.pool.pool.vault_b_id, + AccountRole::VaultB, + false, + false, + false, + ), + planned( + current_tick(input.context, input.pool.pool_id), + AccountRole::CurrentTickAccount, + true, + false, + false, + ), + planned( + CLOCK_01_PROGRAM_ACCOUNT_ID, + AccountRole::Clock, + false, + false, + false, + ), + ], + ) +} + +fn swap_plan( + context: &AmmContext, + pool: PoolContext<'_>, + input_holding: AccountId, + output_holding: AccountId, + instruction: Instruction, +) -> TransactionPlan { + TransactionPlan::new( + context.amm_program_id, + instruction, + vec![ + planned( + context.config_id(), + AccountRole::Config, + false, + false, + false, + ), + planned(pool.pool_id, AccountRole::Pool, true, false, false), + planned( + pool.pool.vault_a_id, + AccountRole::VaultA, + true, + false, + false, + ), + planned( + pool.pool.vault_b_id, + AccountRole::VaultB, + true, + false, + false, + ), + planned( + input_holding, + AccountRole::UserInputHolding, + true, + true, + false, + ), + planned( + output_holding, + AccountRole::UserOutputHolding, + true, + false, + false, + ), + planned( + current_tick(context, pool.pool_id), + AccountRole::CurrentTickAccount, + true, + false, + false, + ), + planned( + CLOCK_01_PROGRAM_ACCOUNT_ID, + AccountRole::Clock, + false, + false, + false, + ), + ], + ) +} + +fn current_tick(context: &AmmContext, pool_id: AccountId) -> AccountId { + compute_current_tick_account_pda(context.twap_oracle_program_id(), pool_id) +} + +fn validate_account_id( + account: &'static str, + expected: AccountId, + actual: AccountId, +) -> Result<(), ClientError> { + if expected == actual { + Ok(()) + } else { + Err(ClientError::AccountIdMismatch { + account, + expected, + actual, + }) + } +} + +const fn planned( + id: AccountId, + role: AccountRole, + writable: bool, + signer: bool, + init: bool, +) -> PlannedAccount { + PlannedAccount { + id, + role, + writable, + signer, + init, + } +} diff --git a/programs/amm/client/src/quote.rs b/programs/amm/client/src/quote.rs new file mode 100644 index 0000000..cb06ce1 --- /dev/null +++ b/programs/amm/client/src/quote.rs @@ -0,0 +1,698 @@ +//! Validated account snapshots and high-level AMM quote orchestration. +//! +//! This module validates fetched protocol accounts, then delegates every economic calculation to +//! [`amm_program::quote`]. It performs no RPC, signing, submission, floating-point conversion, or +//! runtime program-version check. + +use amm_core::{compute_config_pda, AmmConfig, PoolDefinition}; +use amm_program::quote as program_quote; +use nssa_core::{ + account::{Account, AccountId}, + program::ProgramId, +}; +use token_core::{TokenDefinition, TokenHolding}; + +use crate::{AmmContext, ClientError, PoolContext}; + +/// An immutable fetched account paired with the ID used to fetch it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountSnapshot { + account_id: AccountId, + account: Account, +} + +impl AccountSnapshot { + /// Creates an account snapshot from canonical NSSA account data. + #[must_use] + pub fn new(account_id: AccountId, account: Account) -> Self { + Self { + account_id, + account, + } + } + + /// Returns the fetched account ID. + #[must_use] + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Returns the fetched canonical account. + #[must_use] + pub const fn account(&self) -> &Account { + &self.account + } +} + +impl AmmContext { + /// Validates and decodes the singleton config account for the supplied AMM program ID. + /// + /// The supplied program ID is used optimistically. This checks protocol ownership and the + /// config PDA, but intentionally performs no ImageID, version, or build-compatibility lookup. + pub fn from_config_account( + amm_program_id: ProgramId, + config_account: &AccountSnapshot, + ) -> Result { + ensure_account_id( + "AMM config", + config_account, + compute_config_pda(amm_program_id), + )?; + ensure_program_owner("AMM config", config_account, amm_program_id)?; + let config = AmmConfig::try_from(&config_account.account.data).map_err(|_| { + ClientError::InvalidAccountData { + account: "AMM config", + expected: "AmmConfig", + } + })?; + + Ok(Self::new(amm_program_id, config)) + } +} + +/// A token definition proven to be a configured-token-program fungible definition. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedFungibleDefinition { + account_id: AccountId, + token_program_id: ProgramId, + total_supply: u128, + authority: Option, +} + +impl ValidatedFungibleDefinition { + /// Validates a fungible token definition account against an AMM context. + pub fn new( + context: &AmmContext, + definition_account: &AccountSnapshot, + ) -> Result { + ensure_program_owner( + "token definition", + definition_account, + context.token_program_id(), + )?; + let definition = + TokenDefinition::try_from(&definition_account.account.data).map_err(|_| { + ClientError::InvalidAccountData { + account: "token definition", + expected: "TokenDefinition", + } + })?; + let TokenDefinition::Fungible { + total_supply, + authority, + .. + } = definition + else { + return Err(ClientError::ExpectedFungibleToken { + account: "token definition", + }); + }; + + Ok(Self { + account_id: definition_account.account_id, + token_program_id: context.token_program_id(), + total_supply, + authority, + }) + } + + /// Returns the token definition account ID. + #[must_use] + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Returns the exact raw supply stored by the token program. + #[must_use] + pub const fn total_supply(&self) -> u128 { + self.total_supply + } + + /// Returns the token definition's current mint authority. + #[must_use] + pub const fn authority(&self) -> Option { + self.authority + } +} + +/// A token holding proven to be fungible, configured-token-program owned, and tied to an expected +/// definition. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedFungibleHolding { + account_id: AccountId, + definition_id: AccountId, + balance: u128, + token_program_id: ProgramId, +} + +impl ValidatedFungibleHolding { + /// Validates a fungible holding against an expected token definition. + pub fn new( + context: &AmmContext, + holding_account: &AccountSnapshot, + expected_definition: &ValidatedFungibleDefinition, + ) -> Result { + ensure_definition_context(context, expected_definition, "expected token definition")?; + Self::for_definition_id(context, holding_account, expected_definition.account_id) + } + + fn for_definition_id( + context: &AmmContext, + holding_account: &AccountSnapshot, + expected_definition_id: AccountId, + ) -> Result { + ensure_program_owner("token holding", holding_account, context.token_program_id())?; + let holding = TokenHolding::try_from(&holding_account.account.data).map_err(|_| { + ClientError::InvalidAccountData { + account: "token holding", + expected: "TokenHolding", + } + })?; + let TokenHolding::Fungible { + definition_id, + balance, + } = holding + else { + return Err(ClientError::ExpectedFungibleToken { + account: "token holding", + }); + }; + if definition_id != expected_definition_id { + return Err(ClientError::TokenDefinitionMismatch { + account: "token holding", + expected: expected_definition_id, + actual: definition_id, + }); + } + + Ok(Self { + account_id: holding_account.account_id, + definition_id, + balance, + token_program_id: context.token_program_id(), + }) + } + + /// Returns the holding account ID. + #[must_use] + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Returns the held token definition account ID. + #[must_use] + pub const fn definition_id(&self) -> AccountId { + self.definition_id + } + + /// Returns the exact raw fungible balance. + #[must_use] + pub const fn balance(&self) -> u128 { + self.balance + } +} + +/// A decoded pool whose owner, PDA, stored account IDs, vault holdings, and fungible token +/// definitions have been validated together. +#[derive(Clone)] +pub struct ValidatedPoolSnapshot { + pool_id: AccountId, + pool: PoolDefinition, + token_a_definition: ValidatedFungibleDefinition, + token_b_definition: ValidatedFungibleDefinition, + liquidity_definition: ValidatedFungibleDefinition, + vault_a: ValidatedFungibleHolding, + vault_b: ValidatedFungibleHolding, +} + +impl ValidatedPoolSnapshot { + /// Validates a complete initialized pool snapshot. + pub fn new( + context: &AmmContext, + pool_account: &AccountSnapshot, + token_a_definition_account: &AccountSnapshot, + token_b_definition_account: &AccountSnapshot, + vault_a_account: &AccountSnapshot, + vault_b_account: &AccountSnapshot, + liquidity_definition_account: &AccountSnapshot, + ) -> Result { + ensure_program_owner("AMM pool", pool_account, context.amm_program_id)?; + let pool = PoolDefinition::try_from(&pool_account.account.data).map_err(|_| { + ClientError::InvalidAccountData { + account: "AMM pool", + expected: "PoolDefinition", + } + })?; + if pool.definition_token_a_id == pool.definition_token_b_id { + return Err(ClientError::IdenticalTokenDefinitions); + } + PoolContext::new(context, pool_account.account_id, &pool)?; + + let token_a_definition = + ValidatedFungibleDefinition::new(context, token_a_definition_account)?; + ensure_definition_id( + "token A definition", + &token_a_definition, + pool.definition_token_a_id, + )?; + let token_b_definition = + ValidatedFungibleDefinition::new(context, token_b_definition_account)?; + ensure_definition_id( + "token B definition", + &token_b_definition, + pool.definition_token_b_id, + )?; + let liquidity_definition = + ValidatedFungibleDefinition::new(context, liquidity_definition_account)?; + ensure_definition_id( + "liquidity definition", + &liquidity_definition, + pool.liquidity_pool_id, + )?; + if liquidity_definition.total_supply != pool.liquidity_pool_supply { + return Err(ClientError::InvalidAccountData { + account: "liquidity definition", + expected: "fungible LP definition with supply equal to pool liquidity supply", + }); + } + if liquidity_definition.authority != Some(pool.liquidity_pool_id) { + return Err(ClientError::InvalidAccountData { + account: "liquidity definition", + expected: "self-authorized fungible LP definition", + }); + } + + ensure_account_id("vault A", vault_a_account, pool.vault_a_id)?; + ensure_account_id("vault B", vault_b_account, pool.vault_b_id)?; + let vault_a = ValidatedFungibleHolding::for_definition_id( + context, + vault_a_account, + pool.definition_token_a_id, + )?; + let vault_b = ValidatedFungibleHolding::for_definition_id( + context, + vault_b_account, + pool.definition_token_b_id, + )?; + + Ok(Self { + pool_id: pool_account.account_id, + pool, + token_a_definition, + token_b_definition, + liquidity_definition, + vault_a, + vault_b, + }) + } + + /// Returns the pool account ID. + #[must_use] + pub const fn pool_id(&self) -> AccountId { + self.pool_id + } + + /// Returns the decoded pool state. + #[must_use] + pub const fn pool(&self) -> &PoolDefinition { + &self.pool + } + + /// Returns the validated token-A definition. + #[must_use] + pub const fn token_a_definition(&self) -> &ValidatedFungibleDefinition { + &self.token_a_definition + } + + /// Returns the validated token-B definition. + #[must_use] + pub const fn token_b_definition(&self) -> &ValidatedFungibleDefinition { + &self.token_b_definition + } + + /// Returns the validated liquidity-token definition. + #[must_use] + pub const fn liquidity_definition(&self) -> &ValidatedFungibleDefinition { + &self.liquidity_definition + } + + /// Returns the validated token-A vault holding. + #[must_use] + pub const fn vault_a(&self) -> &ValidatedFungibleHolding { + &self.vault_a + } + + /// Returns the validated token-B vault holding. + #[must_use] + pub const fn vault_b(&self) -> &ValidatedFungibleHolding { + &self.vault_b + } +} + +impl From for ClientError { + fn from(error: program_quote::QuoteError) -> Self { + Self::Quote { + code: error.code(), + message: error.message(), + } + } +} + +/// Resolves caller token order against a validated pool. +pub fn pair_order( + snapshot: &ValidatedPoolSnapshot, + first_token: &ValidatedFungibleDefinition, + second_token: &ValidatedFungibleDefinition, +) -> Result { + Ok(program_quote::pair_order( + &snapshot.pool, + first_token.account_id, + second_token.account_id, + )?) +} + +/// Quotes initial pool liquidity for two validated fungible definitions. +pub fn create_pool( + context: &AmmContext, + token_a: &ValidatedFungibleDefinition, + token_b: &ValidatedFungibleDefinition, + token_a_amount: u128, + token_b_amount: u128, + fee_bps: u128, +) -> Result { + ensure_definition_context(context, token_a, "token A definition")?; + ensure_definition_context(context, token_b, "token B definition")?; + if token_a.account_id == token_b.account_id { + return Err(ClientError::IdenticalTokenDefinitions); + } + + Ok(program_quote::create_pool( + token_a_amount, + token_b_amount, + fee_bps, + )?) +} + +/// Previews an add-liquidity transition from validated pool and vault state. +pub fn preview_add_liquidity( + snapshot: &ValidatedPoolSnapshot, + max_amount_a: u128, + max_amount_b: u128, +) -> Result { + Ok(program_quote::preview_add_liquidity( + &snapshot.pool, + snapshot.vault_a.balance, + snapshot.vault_b.balance, + max_amount_a, + max_amount_b, + )?) +} + +/// Quotes an add-liquidity transition with the exact execution guard. +pub fn add_liquidity( + snapshot: &ValidatedPoolSnapshot, + max_amount_a: u128, + max_amount_b: u128, + minimum_liquidity: u128, +) -> Result { + Ok(program_quote::add_liquidity( + &snapshot.pool, + snapshot.vault_a.balance, + snapshot.vault_b.balance, + max_amount_a, + max_amount_b, + minimum_liquidity, + )?) +} + +/// Previews a remove-liquidity transition using a validated LP holding. +pub fn preview_remove_liquidity( + snapshot: &ValidatedPoolSnapshot, + user_liquidity: &ValidatedFungibleHolding, + remove_liquidity_amount: u128, +) -> Result { + ensure_pool_holding( + snapshot, + user_liquidity, + snapshot.pool.liquidity_pool_id, + "user liquidity holding", + )?; + Ok(program_quote::preview_remove_liquidity( + &snapshot.pool, + user_liquidity.balance, + remove_liquidity_amount, + )?) +} + +/// Quotes a remove-liquidity transition with the exact execution guards. +pub fn remove_liquidity( + snapshot: &ValidatedPoolSnapshot, + user_liquidity: &ValidatedFungibleHolding, + remove_liquidity_amount: u128, + minimum_amount_a: u128, + minimum_amount_b: u128, +) -> Result { + ensure_pool_holding( + snapshot, + user_liquidity, + snapshot.pool.liquidity_pool_id, + "user liquidity holding", + )?; + Ok(program_quote::remove_liquidity( + &snapshot.pool, + user_liquidity.balance, + remove_liquidity_amount, + minimum_amount_a, + minimum_amount_b, + )?) +} + +/// Previews an exact-input swap, deriving direction from the validated input holding. +pub fn preview_swap_exact_input( + snapshot: &ValidatedPoolSnapshot, + user_input: &ValidatedFungibleHolding, + user_output: &ValidatedFungibleHolding, + amount_in: u128, +) -> Result { + let direction = validated_swap_direction(snapshot, user_input, user_output)?; + let quote = program_quote::preview_swap_exact_input( + &snapshot.pool, + snapshot.vault_a.balance, + snapshot.vault_b.balance, + direction, + amount_in, + )?; + ensure_available_balance(user_input, quote.amount_in, "user input holding")?; + Ok(quote) +} + +/// Quotes an exact-input swap with its exact minimum-output guard. +pub fn swap_exact_input( + snapshot: &ValidatedPoolSnapshot, + user_input: &ValidatedFungibleHolding, + user_output: &ValidatedFungibleHolding, + amount_in: u128, + minimum_amount_out: u128, +) -> Result { + let direction = validated_swap_direction(snapshot, user_input, user_output)?; + let quote = program_quote::swap_exact_input( + &snapshot.pool, + snapshot.vault_a.balance, + snapshot.vault_b.balance, + direction, + amount_in, + minimum_amount_out, + )?; + ensure_available_balance(user_input, quote.amount_in, "user input holding")?; + Ok(quote) +} + +/// Previews an exact-output swap, deriving direction from the validated input holding. +pub fn preview_swap_exact_output( + snapshot: &ValidatedPoolSnapshot, + user_input: &ValidatedFungibleHolding, + user_output: &ValidatedFungibleHolding, + exact_amount_out: u128, +) -> Result { + let direction = validated_swap_direction(snapshot, user_input, user_output)?; + let quote = program_quote::preview_swap_exact_output( + &snapshot.pool, + snapshot.vault_a.balance, + snapshot.vault_b.balance, + direction, + exact_amount_out, + )?; + ensure_available_balance(user_input, quote.amount_in, "user input holding")?; + Ok(quote) +} + +/// Quotes an exact-output swap with its exact maximum-input guard. +pub fn swap_exact_output( + snapshot: &ValidatedPoolSnapshot, + user_input: &ValidatedFungibleHolding, + user_output: &ValidatedFungibleHolding, + exact_amount_out: u128, + maximum_amount_in: u128, +) -> Result { + let direction = validated_swap_direction(snapshot, user_input, user_output)?; + let quote = program_quote::swap_exact_output( + &snapshot.pool, + snapshot.vault_a.balance, + snapshot.vault_b.balance, + direction, + exact_amount_out, + maximum_amount_in, + )?; + ensure_available_balance(user_input, quote.amount_in, "user input holding")?; + Ok(quote) +} + +/// Quotes reserve synchronization from validated pool and vault state. +pub fn sync_reserves( + snapshot: &ValidatedPoolSnapshot, +) -> Result { + Ok(program_quote::sync_reserves( + &snapshot.pool, + snapshot.vault_a.balance, + snapshot.vault_b.balance, + )?) +} + +/// Quotes pool-derived initialization values for an oracle price account. +pub fn create_oracle_price_account( + snapshot: &ValidatedPoolSnapshot, + window_duration: u64, +) -> Result { + Ok(program_quote::create_oracle_price_account( + &snapshot.pool, + window_duration, + )?) +} + +fn validated_swap_direction( + snapshot: &ValidatedPoolSnapshot, + user_input: &ValidatedFungibleHolding, + user_output: &ValidatedFungibleHolding, +) -> Result { + ensure_holding_context(snapshot, user_input, "user input holding")?; + ensure_holding_context(snapshot, user_output, "user output holding")?; + let direction = program_quote::swap_direction(&snapshot.pool, user_input.definition_id)?; + let expected_output_definition = match direction { + program_quote::SwapDirection::AToB => snapshot.pool.definition_token_b_id, + program_quote::SwapDirection::BToA => snapshot.pool.definition_token_a_id, + }; + ensure_pool_holding( + snapshot, + user_output, + expected_output_definition, + "user output holding", + )?; + Ok(direction) +} + +fn ensure_account_id( + account_name: &'static str, + snapshot: &AccountSnapshot, + expected: AccountId, +) -> Result<(), ClientError> { + if snapshot.account_id != expected { + return Err(ClientError::AccountIdMismatch { + account: account_name, + expected, + actual: snapshot.account_id, + }); + } + Ok(()) +} + +fn ensure_program_owner( + account_name: &'static str, + snapshot: &AccountSnapshot, + expected: ProgramId, +) -> Result<(), ClientError> { + if snapshot.account.program_owner != expected { + return Err(ClientError::ProgramOwnerMismatch { + account: account_name, + expected, + actual: snapshot.account.program_owner, + }); + } + Ok(()) +} + +fn ensure_definition_context( + context: &AmmContext, + definition: &ValidatedFungibleDefinition, + account_name: &'static str, +) -> Result<(), ClientError> { + if definition.token_program_id != context.token_program_id() { + return Err(ClientError::ProgramOwnerMismatch { + account: account_name, + expected: context.token_program_id(), + actual: definition.token_program_id, + }); + } + Ok(()) +} + +fn ensure_definition_id( + account_name: &'static str, + definition: &ValidatedFungibleDefinition, + expected: AccountId, +) -> Result<(), ClientError> { + if definition.account_id != expected { + return Err(ClientError::TokenDefinitionMismatch { + account: account_name, + expected, + actual: definition.account_id, + }); + } + Ok(()) +} + +fn ensure_holding_context( + snapshot: &ValidatedPoolSnapshot, + holding: &ValidatedFungibleHolding, + account_name: &'static str, +) -> Result<(), ClientError> { + if holding.token_program_id != snapshot.vault_a.token_program_id { + return Err(ClientError::ProgramOwnerMismatch { + account: account_name, + expected: snapshot.vault_a.token_program_id, + actual: holding.token_program_id, + }); + } + Ok(()) +} + +fn ensure_pool_holding( + snapshot: &ValidatedPoolSnapshot, + holding: &ValidatedFungibleHolding, + expected_definition_id: AccountId, + account_name: &'static str, +) -> Result<(), ClientError> { + ensure_holding_context(snapshot, holding, account_name)?; + if holding.definition_id != expected_definition_id { + return Err(ClientError::TokenDefinitionMismatch { + account: account_name, + expected: expected_definition_id, + actual: holding.definition_id, + }); + } + Ok(()) +} + +fn ensure_available_balance( + 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(()) +} diff --git a/programs/amm/client/src/slippage.rs b/programs/amm/client/src/slippage.rs new file mode 100644 index 0000000..6baa19c --- /dev/null +++ b/programs/amm/client/src/slippage.rs @@ -0,0 +1,267 @@ +//! Integer-only construction of AMM instruction guards from validated quotes. + +use amm_core::{checked_mul_div_ceil, checked_mul_div_floor, FEE_BPS_DENOMINATOR}; +use amm_program::quote::{AddLiquidityQuote, CreatePoolQuote, RemoveLiquidityQuote, SwapQuote}; + +use crate::{ + quote::{ + self as client_quote, ValidatedFungibleDefinition, ValidatedFungibleHolding, + ValidatedPoolSnapshot, + }, + AmmContext, ClientError, +}; + +/// Denominator used by client slippage tolerances. +/// +/// This aliases the program's canonical basis-point denominator so wire consumers do not maintain +/// a separate numeric convention. +pub const SLIPPAGE_BPS_DENOMINATOR: u128 = FEE_BPS_DENOMINATOR; + +/// Validated price-movement tolerance in basis points. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SlippageTolerance { + bps: u128, +} + +impl SlippageTolerance { + /// Creates a tolerance between zero and 10,000 basis points, inclusive. + pub fn new(bps: u128) -> Result { + if bps > SLIPPAGE_BPS_DENOMINATOR { + return Err(ClientError::SlippageToleranceOutOfRange { + bps, + maximum_bps: SLIPPAGE_BPS_DENOMINATOR, + }); + } + Ok(Self { bps }) + } + + /// Returns the exact basis-point value. + #[must_use] + pub const fn bps(self) -> u128 { + self.bps + } +} + +/// Builds a conservative minimum chain guard with integer floor rounding. +/// +/// Positive quotes are clamped to one raw unit because AMM liquidity instructions reject zero +/// minimums and a one-unit quote has no smaller executable guard. A zero quote remains zero. +pub fn minimum_guard_amount( + quoted_amount: u128, + tolerance: SlippageTolerance, +) -> Result { + let retained_bps = SLIPPAGE_BPS_DENOMINATOR.checked_sub(tolerance.bps).ok_or( + ClientError::SlippageToleranceOutOfRange { + bps: tolerance.bps, + maximum_bps: SLIPPAGE_BPS_DENOMINATOR, + }, + )?; + let guard = checked_mul_div_floor(quoted_amount, retained_bps, SLIPPAGE_BPS_DENOMINATOR) + .ok_or(ClientError::SlippageBoundOverflow { + quoted_amount, + slippage_bps: tolerance.bps, + })?; + + Ok(if quoted_amount == 0 { 0 } else { guard.max(1) }) +} + +/// Builds a conservative maximum chain guard with integer ceil rounding. +pub fn maximum_guard_amount( + quoted_amount: u128, + tolerance: SlippageTolerance, +) -> Result { + let expanded_bps = SLIPPAGE_BPS_DENOMINATOR.checked_add(tolerance.bps).ok_or( + ClientError::SlippageBoundOverflow { + quoted_amount, + slippage_bps: tolerance.bps, + }, + )?; + checked_mul_div_ceil(quoted_amount, expanded_bps, SLIPPAGE_BPS_DENOMINATOR).ok_or( + ClientError::SlippageBoundOverflow { + quoted_amount, + slippage_bps: tolerance.bps, + }, + ) +} + +/// Pool-creation quote plus exact `NewDefinition` amount fields. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PreparedCreatePool { + pub quote: CreatePoolQuote, + pub token_a_amount: u128, + pub token_b_amount: u128, + pub fees: u128, +} + +/// Add-liquidity quote plus slippage-safe `AddLiquidity` amount fields. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PreparedAddLiquidity { + pub quote: AddLiquidityQuote, + pub min_amount_liquidity: u128, + pub max_amount_to_add_token_a: u128, + pub max_amount_to_add_token_b: u128, +} + +/// Remove-liquidity quote plus slippage-safe `RemoveLiquidity` amount fields. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PreparedRemoveLiquidity { + pub quote: RemoveLiquidityQuote, + pub remove_liquidity_amount: u128, + pub min_amount_to_remove_token_a: u128, + pub min_amount_to_remove_token_b: u128, +} + +/// Exact-input quote plus slippage-safe `SwapExactInput` amount fields. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PreparedSwapExactInput { + pub quote: SwapQuote, + pub swap_amount_in: u128, + pub min_amount_out: u128, +} + +/// Exact-output quote plus slippage-safe `SwapExactOutput` amount fields. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PreparedSwapExactOutput { + pub quote: SwapQuote, + pub exact_amount_out: u128, + pub max_amount_in: u128, +} + +/// Quotes pool creation and returns the exact instruction amount fields. +pub fn prepare_create_pool( + context: &AmmContext, + token_a: &ValidatedFungibleDefinition, + token_b: &ValidatedFungibleDefinition, + token_a_amount: u128, + token_b_amount: u128, + fee_bps: u128, +) -> Result { + let quote = client_quote::create_pool( + context, + token_a, + token_b, + token_a_amount, + token_b_amount, + fee_bps, + )?; + Ok(PreparedCreatePool { + quote, + token_a_amount: quote.pool.reserve_a, + token_b_amount: quote.pool.reserve_b, + fees: fee_bps, + }) +} + +/// Quotes add liquidity and derives its minimum-LP guard. +pub fn prepare_add_liquidity( + snapshot: &ValidatedPoolSnapshot, + max_amount_a: u128, + max_amount_b: u128, + tolerance: SlippageTolerance, +) -> 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, + )?; + + Ok(PreparedAddLiquidity { + quote, + min_amount_liquidity, + max_amount_to_add_token_a, + max_amount_to_add_token_b, + }) +} + +/// Quotes remove liquidity and derives both minimum-withdrawal guards. +pub fn prepare_remove_liquidity( + snapshot: &ValidatedPoolSnapshot, + user_liquidity: &ValidatedFungibleHolding, + remove_liquidity_amount: u128, + tolerance: SlippageTolerance, +) -> Result { + let preview = + client_quote::preview_remove_liquidity(snapshot, user_liquidity, remove_liquidity_amount)?; + let min_amount_to_remove_token_a = minimum_guard_amount(preview.withdraw_amount_a, tolerance)?; + let min_amount_to_remove_token_b = minimum_guard_amount(preview.withdraw_amount_b, tolerance)?; + let quote = client_quote::remove_liquidity( + snapshot, + user_liquidity, + remove_liquidity_amount, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + )?; + + Ok(PreparedRemoveLiquidity { + quote, + remove_liquidity_amount: quote.liquidity_to_burn, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + }) +} + +/// Quotes an exact-input swap and derives its minimum-output guard. +pub fn prepare_swap_exact_input( + snapshot: &ValidatedPoolSnapshot, + user_input: &ValidatedFungibleHolding, + user_output: &ValidatedFungibleHolding, + amount_in: u128, + tolerance: SlippageTolerance, +) -> Result { + let preview = + client_quote::preview_swap_exact_input(snapshot, user_input, user_output, amount_in)?; + let min_amount_out = minimum_guard_amount(preview.amount_out, tolerance)?; + let quote = client_quote::swap_exact_input( + snapshot, + user_input, + user_output, + amount_in, + min_amount_out, + )?; + + Ok(PreparedSwapExactInput { + quote, + swap_amount_in: quote.amount_in, + min_amount_out, + }) +} + +/// Quotes an exact-output swap and derives its maximum-input guard. +pub fn prepare_swap_exact_output( + snapshot: &ValidatedPoolSnapshot, + user_input: &ValidatedFungibleHolding, + user_output: &ValidatedFungibleHolding, + exact_amount_out: u128, + tolerance: SlippageTolerance, +) -> Result { + let preview = client_quote::preview_swap_exact_output( + snapshot, + user_input, + user_output, + exact_amount_out, + )?; + let max_amount_in = maximum_guard_amount(preview.amount_in, tolerance)?; + let quote = client_quote::swap_exact_output( + snapshot, + user_input, + user_output, + exact_amount_out, + max_amount_in, + )?; + + Ok(PreparedSwapExactOutput { + quote, + exact_amount_out: quote.amount_out, + max_amount_in, + }) +} diff --git a/programs/amm/client/src/wire.rs b/programs/amm/client/src/wire.rs new file mode 100644 index 0000000..7d65e5c --- /dev/null +++ b/programs/amm/client/src/wire.rs @@ -0,0 +1,1350 @@ +//! Lossless JSON transport adapters for the typed AMM client API. + +use std::{error::Error, fmt, str::FromStr}; + +use amm_core::{ + AmmConfig, PoolDefinition, FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY, SUPPORTED_FEE_TIERS, +}; +use amm_program::quote::{ + AddLiquidityQuote, CreatePoolQuote, OraclePriceAccountQuote, PairOrder, PoolUpdate, + RemoveLiquidityQuote, SwapDirection, SwapQuote, SyncReservesQuote, +}; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::{ + plan_add_liquidity, plan_create_oracle_price_account, plan_create_pool, + plan_create_price_observations, plan_initialize, plan_remove_liquidity, plan_swap_exact_input, + plan_swap_exact_output, plan_sync_reserves, plan_update_config, + quote::{ + self as client_quote, AccountSnapshot, ValidatedFungibleDefinition, + ValidatedFungibleHolding, ValidatedPoolSnapshot, + }, + AddLiquidityPlanInput, AmmContext, ClientError, CreateOraclePriceAccountPlanInput, + CreatePoolPlanInput, CreatePriceObservationsPlanInput, InitializePlanInput, PoolContext, + PreparedAddLiquidity, PreparedCreatePool, PreparedRemoveLiquidity, PreparedSwapExactInput, + PreparedSwapExactOutput, RemoveLiquidityPlanInput, SlippageTolerance, SwapExactInputPlanInput, + SwapExactOutputPlanInput, SyncReservesPlanInput, TransactionPlan, UpdateConfigPlanInput, + SLIPPAGE_BPS_DENOMINATOR, +}; + +/// Stable transport failure returned by the JSON and C ABI adapters. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WireError { + code: String, + message: String, +} + +impl WireError { + fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } + + /// Stable machine-readable error code. + #[must_use] + pub fn code(&self) -> &str { + &self.code + } +} + +impl fmt::Display for WireError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for WireError {} + +impl From for WireError { + fn from(error: ClientError) -> Self { + Self::new(error.code(), error.to_string()) + } +} + +#[derive(Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +enum PlanRequest { + Initialize { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + #[serde(rename = "tokenProgramId")] + token_program_id: ProgramId, + #[serde(rename = "twapOracleProgramId")] + twap_oracle_program_id: ProgramId, + authority: String, + }, + UpdateConfig { + context: ContextInput, + #[serde(rename = "tokenProgramId")] + token_program_id: Option, + #[serde(rename = "twapOracleProgramId")] + twap_oracle_program_id: Option, + #[serde(rename = "newAuthority")] + new_authority: Option, + }, + CreatePriceObservations { + context: ContextInput, + #[serde(rename = "poolId")] + pool_id: String, + #[serde(rename = "windowDuration")] + window_duration: String, + }, + CreateOraclePriceAccount { + context: ContextInput, + #[serde(rename = "poolId")] + pool_id: String, + #[serde(rename = "windowDuration")] + window_duration: String, + }, + CreatePool { + context: ContextInput, + #[serde(rename = "tokenADefinitionId")] + token_a_definition_id: String, + #[serde(rename = "tokenBDefinitionId")] + token_b_definition_id: String, + #[serde(rename = "userHoldingA")] + user_holding_a: String, + #[serde(rename = "userHoldingB")] + user_holding_b: String, + #[serde(rename = "userHoldingLp")] + user_holding_lp: String, + #[serde(rename = "tokenAAmount")] + token_a_amount: String, + #[serde(rename = "tokenBAmount")] + token_b_amount: String, + fees: String, + deadline: String, + }, + AddLiquidity { + context: ContextInput, + pool: PoolInput, + #[serde(rename = "userHoldingA")] + user_holding_a: String, + #[serde(rename = "userHoldingB")] + user_holding_b: String, + #[serde(rename = "userHoldingLp")] + user_holding_lp: String, + #[serde(rename = "minAmountLiquidity")] + min_amount_liquidity: String, + #[serde(rename = "maxAmountToAddTokenA")] + max_amount_to_add_token_a: String, + #[serde(rename = "maxAmountToAddTokenB")] + max_amount_to_add_token_b: String, + deadline: String, + }, + RemoveLiquidity { + context: ContextInput, + pool: PoolInput, + #[serde(rename = "userHoldingA")] + user_holding_a: String, + #[serde(rename = "userHoldingB")] + user_holding_b: String, + #[serde(rename = "userHoldingLp")] + user_holding_lp: String, + #[serde(rename = "removeLiquidityAmount")] + remove_liquidity_amount: String, + #[serde(rename = "minAmountToRemoveTokenA")] + min_amount_to_remove_token_a: String, + #[serde(rename = "minAmountToRemoveTokenB")] + min_amount_to_remove_token_b: String, + deadline: String, + }, + SwapExactInput { + context: ContextInput, + pool: PoolInput, + #[serde(rename = "userInputHolding")] + user_input_holding: String, + #[serde(rename = "userOutputHolding")] + user_output_holding: String, + #[serde(rename = "swapAmountIn")] + swap_amount_in: String, + #[serde(rename = "minAmountOut")] + min_amount_out: String, + deadline: String, + }, + SwapExactOutput { + context: ContextInput, + pool: PoolInput, + #[serde(rename = "userInputHolding")] + user_input_holding: String, + #[serde(rename = "userOutputHolding")] + user_output_holding: String, + #[serde(rename = "exactAmountOut")] + exact_amount_out: String, + #[serde(rename = "maxAmountIn")] + max_amount_in: String, + deadline: String, + }, + SyncReserves { + context: ContextInput, + pool: PoolInput, + }, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ContextInput { + amm_program_id: ProgramId, + token_program_id: ProgramId, + twap_oracle_program_id: ProgramId, + authority: String, +} + +impl ContextInput { + fn into_context(self) -> Result { + Ok(AmmContext::new( + self.amm_program_id, + AmmConfig { + token_program_id: self.token_program_id, + twap_oracle_program_id: self.twap_oracle_program_id, + authority: account_id(&self.authority, "context.authority")?, + }, + )) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PoolInput { + pool_id: String, + definition_token_a_id: String, + definition_token_b_id: String, + vault_a_id: String, + vault_b_id: String, + liquidity_pool_id: String, + liquidity_pool_supply: String, + reserve_a: String, + reserve_b: String, + fees: String, +} + +impl PoolInput { + fn into_pool(self) -> Result<(AccountId, PoolDefinition), WireError> { + Ok(( + account_id(&self.pool_id, "pool.poolId")?, + PoolDefinition { + definition_token_a_id: account_id( + &self.definition_token_a_id, + "pool.definitionTokenAId", + )?, + definition_token_b_id: account_id( + &self.definition_token_b_id, + "pool.definitionTokenBId", + )?, + vault_a_id: account_id(&self.vault_a_id, "pool.vaultAId")?, + vault_b_id: account_id(&self.vault_b_id, "pool.vaultBId")?, + liquidity_pool_id: account_id(&self.liquidity_pool_id, "pool.liquidityPoolId")?, + liquidity_pool_supply: decimal_u128( + &self.liquidity_pool_supply, + "pool.liquidityPoolSupply", + )?, + reserve_a: decimal_u128(&self.reserve_a, "pool.reserveA")?, + reserve_b: decimal_u128(&self.reserve_b, "pool.reserveB")?, + fees: decimal_u128(&self.fees, "pool.fees")?, + }, + )) + } +} + +#[derive(Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +enum QuoteRequest { + ProtocolConstants, + PairOrder { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "firstTokenDefinitionId")] + first_token_definition_id: String, + #[serde(rename = "secondTokenDefinitionId")] + second_token_definition_id: String, + }, + CreatePool { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + #[serde(rename = "tokenADefinition")] + token_a_definition: AccountSnapshotInput, + #[serde(rename = "tokenBDefinition")] + token_b_definition: AccountSnapshotInput, + #[serde(rename = "tokenAAmount")] + token_a_amount: String, + #[serde(rename = "tokenBAmount")] + token_b_amount: String, + #[serde(rename = "feeBps")] + fee_bps: String, + }, + PrepareCreatePool { + #[serde(rename = "ammProgramId")] + amm_program_id: ProgramId, + config: AccountSnapshotInput, + #[serde(rename = "tokenADefinition")] + token_a_definition: AccountSnapshotInput, + #[serde(rename = "tokenBDefinition")] + token_b_definition: AccountSnapshotInput, + #[serde(rename = "tokenAAmount")] + token_a_amount: String, + #[serde(rename = "tokenBAmount")] + token_b_amount: String, + #[serde(rename = "feeBps")] + fee_bps: String, + }, + PreviewAddLiquidity { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "maxAmountA")] + max_amount_a: String, + #[serde(rename = "maxAmountB")] + max_amount_b: String, + }, + PrepareAddLiquidity { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "maxAmountA")] + max_amount_a: String, + #[serde(rename = "maxAmountB")] + max_amount_b: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + }, + AddLiquidity { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "maxAmountA")] + max_amount_a: String, + #[serde(rename = "maxAmountB")] + max_amount_b: String, + #[serde(rename = "minimumLiquidity")] + minimum_liquidity: String, + }, + PreviewRemoveLiquidity { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userLiquidityHolding")] + user_liquidity_holding: AccountSnapshotInput, + #[serde(rename = "removeLiquidityAmount")] + remove_liquidity_amount: String, + }, + PrepareRemoveLiquidity { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userLiquidityHolding")] + user_liquidity_holding: AccountSnapshotInput, + #[serde(rename = "removeLiquidityAmount")] + remove_liquidity_amount: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + }, + RemoveLiquidity { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userLiquidityHolding")] + user_liquidity_holding: AccountSnapshotInput, + #[serde(rename = "removeLiquidityAmount")] + remove_liquidity_amount: String, + #[serde(rename = "minimumAmountA")] + minimum_amount_a: String, + #[serde(rename = "minimumAmountB")] + minimum_amount_b: String, + }, + PreviewSwapExactInput { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userInputHolding")] + user_input_holding: AccountSnapshotInput, + #[serde(rename = "userOutputHolding")] + user_output_holding: AccountSnapshotInput, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "amountIn")] + amount_in: String, + }, + PrepareSwapExactInput { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userInputHolding")] + user_input_holding: AccountSnapshotInput, + #[serde(rename = "userOutputHolding")] + user_output_holding: AccountSnapshotInput, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "amountIn")] + amount_in: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + }, + SwapExactInput { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userInputHolding")] + user_input_holding: AccountSnapshotInput, + #[serde(rename = "userOutputHolding")] + user_output_holding: AccountSnapshotInput, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "amountIn")] + amount_in: String, + #[serde(rename = "minimumAmountOut")] + minimum_amount_out: String, + }, + PreviewSwapExactOutput { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userInputHolding")] + user_input_holding: AccountSnapshotInput, + #[serde(rename = "userOutputHolding")] + user_output_holding: AccountSnapshotInput, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "exactAmountOut")] + exact_amount_out: String, + }, + PrepareSwapExactOutput { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userInputHolding")] + user_input_holding: AccountSnapshotInput, + #[serde(rename = "userOutputHolding")] + user_output_holding: AccountSnapshotInput, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "exactAmountOut")] + exact_amount_out: String, + #[serde(rename = "slippageBps")] + slippage_bps: String, + }, + SwapExactOutput { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "userInputHolding")] + user_input_holding: AccountSnapshotInput, + #[serde(rename = "userOutputHolding")] + user_output_holding: AccountSnapshotInput, + #[serde(rename = "inputTokenDefinitionId")] + input_token_definition_id: String, + #[serde(rename = "exactAmountOut")] + exact_amount_out: String, + #[serde(rename = "maximumAmountIn")] + maximum_amount_in: String, + }, + SyncReserves { + #[serde(flatten)] + state: PoolStateInput, + }, + CreateOraclePriceAccount { + #[serde(flatten)] + state: PoolStateInput, + #[serde(rename = "windowDuration")] + window_duration: String, + }, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PoolStateInput { + amm_program_id: ProgramId, + config: AccountSnapshotInput, + snapshot: PoolSnapshotInput, +} + +impl PoolStateInput { + fn validate(self) -> Result<(AmmContext, ValidatedPoolSnapshot), WireError> { + let config = self.config.into_snapshot()?; + let context = AmmContext::from_config_account(self.amm_program_id, &config)?; + let snapshot = self.snapshot.validate(&context)?; + Ok((context, snapshot)) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PoolSnapshotInput { + pool: AccountSnapshotInput, + token_a_definition: AccountSnapshotInput, + token_b_definition: AccountSnapshotInput, + vault_a: AccountSnapshotInput, + vault_b: AccountSnapshotInput, + liquidity_definition: AccountSnapshotInput, +} + +impl PoolSnapshotInput { + fn validate(self, context: &AmmContext) -> Result { + let pool = self.pool.into_snapshot()?; + let token_a_definition = self.token_a_definition.into_snapshot()?; + let token_b_definition = self.token_b_definition.into_snapshot()?; + let vault_a = self.vault_a.into_snapshot()?; + let vault_b = self.vault_b.into_snapshot()?; + let liquidity_definition = self.liquidity_definition.into_snapshot()?; + + Ok(ValidatedPoolSnapshot::new( + context, + &pool, + &token_a_definition, + &token_b_definition, + &vault_a, + &vault_b, + &liquidity_definition, + )?) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AccountSnapshotInput { + id: String, + program_owner: ProgramId, + balance: String, + nonce: String, + data: String, +} + +impl AccountSnapshotInput { + fn into_snapshot(self) -> Result { + let bytes = hex_bytes(&self.data, "account.data")?; + let data = Data::try_from(bytes) + .map_err(|error| invalid_request(format!("account.data is too large: {error}")))?; + Ok(AccountSnapshot::new( + account_id(&self.id, "account.id")?, + Account { + program_owner: self.program_owner, + balance: decimal_u128(&self.balance, "account.balance")?, + data, + nonce: Nonce(decimal_u128(&self.nonce, "account.nonce")?), + }, + )) + } +} + +/// Builds one of the ten canonical transaction plans from tagged JSON. +pub fn plan_json(value: Value) -> Result { + let request: PlanRequest = serde_json::from_value(value) + .map_err(|error| invalid_request(format!("invalid plan request: {error}")))?; + let plan = match request { + PlanRequest::Initialize { + amm_program_id, + token_program_id, + twap_oracle_program_id, + authority, + } => plan_initialize(InitializePlanInput { + amm_program_id, + token_program_id, + twap_oracle_program_id, + authority: account_id(&authority, "authority")?, + }), + PlanRequest::UpdateConfig { + context, + token_program_id, + twap_oracle_program_id, + new_authority, + } => { + let context = context.into_context()?; + let new_authority = new_authority + .as_deref() + .map(|value| account_id(value, "newAuthority")) + .transpose()?; + plan_update_config(UpdateConfigPlanInput { + context: &context, + token_program_id, + twap_oracle_program_id, + new_authority, + }) + } + PlanRequest::CreatePriceObservations { + context, + pool_id, + window_duration, + } => { + let context = context.into_context()?; + plan_create_price_observations(CreatePriceObservationsPlanInput { + context: &context, + pool_id: account_id(&pool_id, "poolId")?, + window_duration: decimal_u64(&window_duration, "windowDuration")?, + }) + } + PlanRequest::CreateOraclePriceAccount { + context, + pool_id, + window_duration, + } => { + let context = context.into_context()?; + plan_create_oracle_price_account(CreateOraclePriceAccountPlanInput { + context: &context, + pool_id: account_id(&pool_id, "poolId")?, + window_duration: decimal_u64(&window_duration, "windowDuration")?, + }) + } + PlanRequest::CreatePool { + context, + token_a_definition_id, + token_b_definition_id, + user_holding_a, + user_holding_b, + user_holding_lp, + token_a_amount, + token_b_amount, + fees, + deadline, + } => { + let context = context.into_context()?; + plan_create_pool(CreatePoolPlanInput { + context: &context, + token_a_definition_id: account_id(&token_a_definition_id, "tokenADefinitionId")?, + token_b_definition_id: account_id(&token_b_definition_id, "tokenBDefinitionId")?, + user_holding_a: account_id(&user_holding_a, "userHoldingA")?, + user_holding_b: account_id(&user_holding_b, "userHoldingB")?, + user_holding_lp: account_id(&user_holding_lp, "userHoldingLp")?, + token_a_amount: decimal_u128(&token_a_amount, "tokenAAmount")?, + token_b_amount: decimal_u128(&token_b_amount, "tokenBAmount")?, + fees: decimal_u128(&fees, "fees")?, + deadline: decimal_u64(&deadline, "deadline")?, + })? + } + PlanRequest::AddLiquidity { + context, + pool, + user_holding_a, + user_holding_b, + user_holding_lp, + min_amount_liquidity, + max_amount_to_add_token_a, + max_amount_to_add_token_b, + deadline, + } => { + let context = context.into_context()?; + let (pool_id, pool) = pool.into_pool()?; + plan_add_liquidity(AddLiquidityPlanInput { + context: &context, + pool: PoolContext::new(&context, pool_id, &pool)?, + user_holding_a: account_id(&user_holding_a, "userHoldingA")?, + user_holding_b: account_id(&user_holding_b, "userHoldingB")?, + user_holding_lp: account_id(&user_holding_lp, "userHoldingLp")?, + min_amount_liquidity: decimal_u128(&min_amount_liquidity, "minAmountLiquidity")?, + max_amount_to_add_token_a: decimal_u128( + &max_amount_to_add_token_a, + "maxAmountToAddTokenA", + )?, + max_amount_to_add_token_b: decimal_u128( + &max_amount_to_add_token_b, + "maxAmountToAddTokenB", + )?, + deadline: decimal_u64(&deadline, "deadline")?, + }) + } + PlanRequest::RemoveLiquidity { + context, + pool, + user_holding_a, + user_holding_b, + user_holding_lp, + remove_liquidity_amount, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + deadline, + } => { + let context = context.into_context()?; + let (pool_id, pool) = pool.into_pool()?; + plan_remove_liquidity(RemoveLiquidityPlanInput { + context: &context, + pool: PoolContext::new(&context, pool_id, &pool)?, + user_holding_a: account_id(&user_holding_a, "userHoldingA")?, + user_holding_b: account_id(&user_holding_b, "userHoldingB")?, + user_holding_lp: account_id(&user_holding_lp, "userHoldingLp")?, + remove_liquidity_amount: decimal_u128( + &remove_liquidity_amount, + "removeLiquidityAmount", + )?, + min_amount_to_remove_token_a: decimal_u128( + &min_amount_to_remove_token_a, + "minAmountToRemoveTokenA", + )?, + min_amount_to_remove_token_b: decimal_u128( + &min_amount_to_remove_token_b, + "minAmountToRemoveTokenB", + )?, + deadline: decimal_u64(&deadline, "deadline")?, + }) + } + PlanRequest::SwapExactInput { + context, + pool, + user_input_holding, + user_output_holding, + swap_amount_in, + min_amount_out, + deadline, + } => { + let context = context.into_context()?; + let (pool_id, pool) = pool.into_pool()?; + plan_swap_exact_input(SwapExactInputPlanInput { + context: &context, + pool: PoolContext::new(&context, pool_id, &pool)?, + user_input_holding: account_id(&user_input_holding, "userInputHolding")?, + user_output_holding: account_id(&user_output_holding, "userOutputHolding")?, + swap_amount_in: decimal_u128(&swap_amount_in, "swapAmountIn")?, + min_amount_out: decimal_u128(&min_amount_out, "minAmountOut")?, + deadline: decimal_u64(&deadline, "deadline")?, + }) + } + PlanRequest::SwapExactOutput { + context, + pool, + user_input_holding, + user_output_holding, + exact_amount_out, + max_amount_in, + deadline, + } => { + let context = context.into_context()?; + let (pool_id, pool) = pool.into_pool()?; + plan_swap_exact_output(SwapExactOutputPlanInput { + context: &context, + pool: PoolContext::new(&context, pool_id, &pool)?, + user_input_holding: account_id(&user_input_holding, "userInputHolding")?, + user_output_holding: account_id(&user_output_holding, "userOutputHolding")?, + exact_amount_out: decimal_u128(&exact_amount_out, "exactAmountOut")?, + max_amount_in: decimal_u128(&max_amount_in, "maxAmountIn")?, + deadline: decimal_u64(&deadline, "deadline")?, + }) + } + PlanRequest::SyncReserves { context, pool } => { + let context = context.into_context()?; + let (pool_id, pool) = pool.into_pool()?; + plan_sync_reserves(SyncReservesPlanInput { + context: &context, + pool: PoolContext::new(&context, pool_id, &pool)?, + }) + } + }; + + transaction_plan_json(&plan) +} + +/// Evaluates one reusable AMM economic quote from tagged JSON. +pub fn quote_json(value: Value) -> Result { + let request: QuoteRequest = serde_json::from_value(value) + .map_err(|error| invalid_request(format!("invalid quote request: {error}")))?; + match request { + QuoteRequest::ProtocolConstants => Ok(json!({ + "minimumLiquidity": MINIMUM_LIQUIDITY.to_string(), + "feeBpsDenominator": FEE_BPS_DENOMINATOR.to_string(), + "slippageBpsDenominator": SLIPPAGE_BPS_DENOMINATOR.to_string(), + "supportedFeeTiers": SUPPORTED_FEE_TIERS + .iter() + .map(u128::to_string) + .collect::>(), + })), + QuoteRequest::PairOrder { + state, + first_token_definition_id, + second_token_definition_id, + } => { + let (_, snapshot) = state.validate()?; + let first_id = account_id(&first_token_definition_id, "firstTokenDefinitionId")?; + let second_id = account_id(&second_token_definition_id, "secondTokenDefinitionId")?; + let first = pool_definition(&snapshot, first_id, "firstTokenDefinitionId")?; + let second = pool_definition(&snapshot, second_id, "secondTokenDefinitionId")?; + let order = client_quote::pair_order(&snapshot, first, second)?; + Ok(json!({ + "order": match order { + PairOrder::Stored => "stored", + PairOrder::Reversed => "reversed", + }, + })) + } + QuoteRequest::CreatePool { + amm_program_id, + config, + token_a_definition, + token_b_definition, + token_a_amount, + token_b_amount, + fee_bps, + } => { + let config = config.into_snapshot()?; + let context = AmmContext::from_config_account(amm_program_id, &config)?; + let token_a_definition = token_a_definition.into_snapshot()?; + let token_b_definition = token_b_definition.into_snapshot()?; + let token_a = ValidatedFungibleDefinition::new(&context, &token_a_definition)?; + let token_b = ValidatedFungibleDefinition::new(&context, &token_b_definition)?; + let quote = client_quote::create_pool( + &context, + &token_a, + &token_b, + decimal_u128(&token_a_amount, "tokenAAmount")?, + decimal_u128(&token_b_amount, "tokenBAmount")?, + decimal_u128(&fee_bps, "feeBps")?, + )?; + Ok(create_pool_quote_json(quote)) + } + QuoteRequest::PrepareCreatePool { + amm_program_id, + config, + token_a_definition, + token_b_definition, + token_a_amount, + token_b_amount, + fee_bps, + } => { + let config = config.into_snapshot()?; + let context = AmmContext::from_config_account(amm_program_id, &config)?; + let token_a_definition = token_a_definition.into_snapshot()?; + let token_b_definition = token_b_definition.into_snapshot()?; + let token_a = ValidatedFungibleDefinition::new(&context, &token_a_definition)?; + let token_b = ValidatedFungibleDefinition::new(&context, &token_b_definition)?; + let prepared = crate::prepare_create_pool( + &context, + &token_a, + &token_b, + decimal_u128(&token_a_amount, "tokenAAmount")?, + decimal_u128(&token_b_amount, "tokenBAmount")?, + decimal_u128(&fee_bps, "feeBps")?, + )?; + Ok(prepared_create_pool_json(prepared)) + } + QuoteRequest::PreviewAddLiquidity { + state, + max_amount_a, + max_amount_b, + } => { + let (_, snapshot) = state.validate()?; + let quote = client_quote::preview_add_liquidity( + &snapshot, + decimal_u128(&max_amount_a, "maxAmountA")?, + decimal_u128(&max_amount_b, "maxAmountB")?, + )?; + Ok(add_liquidity_quote_json(quote)) + } + QuoteRequest::PrepareAddLiquidity { + state, + max_amount_a, + max_amount_b, + slippage_bps, + } => { + let (_, snapshot) = state.validate()?; + let prepared = crate::prepare_add_liquidity( + &snapshot, + decimal_u128(&max_amount_a, "maxAmountA")?, + decimal_u128(&max_amount_b, "maxAmountB")?, + slippage_tolerance(&slippage_bps)?, + )?; + Ok(prepared_add_liquidity_json(prepared)) + } + QuoteRequest::AddLiquidity { + state, + max_amount_a, + max_amount_b, + minimum_liquidity, + } => { + let (_, snapshot) = state.validate()?; + let quote = client_quote::add_liquidity( + &snapshot, + decimal_u128(&max_amount_a, "maxAmountA")?, + decimal_u128(&max_amount_b, "maxAmountB")?, + decimal_u128(&minimum_liquidity, "minimumLiquidity")?, + )?; + Ok(add_liquidity_quote_json(quote)) + } + QuoteRequest::PreviewRemoveLiquidity { + state, + user_liquidity_holding, + remove_liquidity_amount, + } => { + let (context, snapshot) = state.validate()?; + let user_liquidity = validated_holding( + &context, + user_liquidity_holding, + snapshot.liquidity_definition(), + )?; + let quote = client_quote::preview_remove_liquidity( + &snapshot, + &user_liquidity, + decimal_u128(&remove_liquidity_amount, "removeLiquidityAmount")?, + )?; + Ok(remove_liquidity_quote_json(quote)) + } + QuoteRequest::PrepareRemoveLiquidity { + state, + user_liquidity_holding, + remove_liquidity_amount, + slippage_bps, + } => { + let (context, snapshot) = state.validate()?; + let user_liquidity = validated_holding( + &context, + user_liquidity_holding, + snapshot.liquidity_definition(), + )?; + let prepared = crate::prepare_remove_liquidity( + &snapshot, + &user_liquidity, + decimal_u128(&remove_liquidity_amount, "removeLiquidityAmount")?, + slippage_tolerance(&slippage_bps)?, + )?; + Ok(prepared_remove_liquidity_json(prepared)) + } + QuoteRequest::RemoveLiquidity { + state, + user_liquidity_holding, + remove_liquidity_amount, + minimum_amount_a, + minimum_amount_b, + } => { + let (context, snapshot) = state.validate()?; + let user_liquidity = validated_holding( + &context, + user_liquidity_holding, + snapshot.liquidity_definition(), + )?; + let quote = client_quote::remove_liquidity( + &snapshot, + &user_liquidity, + decimal_u128(&remove_liquidity_amount, "removeLiquidityAmount")?, + decimal_u128(&minimum_amount_a, "minimumAmountA")?, + decimal_u128(&minimum_amount_b, "minimumAmountB")?, + )?; + Ok(remove_liquidity_quote_json(quote)) + } + QuoteRequest::PreviewSwapExactInput { + state, + user_input_holding, + user_output_holding, + input_token_definition_id, + amount_in, + } => { + let (context, snapshot) = state.validate()?; + let (user_input, user_output) = validated_swap_holdings( + &context, + &snapshot, + user_input_holding, + user_output_holding, + &input_token_definition_id, + )?; + let quote = client_quote::preview_swap_exact_input( + &snapshot, + &user_input, + &user_output, + decimal_u128(&amount_in, "amountIn")?, + )?; + Ok(swap_quote_json(quote)) + } + QuoteRequest::PrepareSwapExactInput { + state, + user_input_holding, + user_output_holding, + input_token_definition_id, + amount_in, + slippage_bps, + } => { + let (context, snapshot) = state.validate()?; + let (user_input, user_output) = validated_swap_holdings( + &context, + &snapshot, + user_input_holding, + user_output_holding, + &input_token_definition_id, + )?; + let prepared = crate::prepare_swap_exact_input( + &snapshot, + &user_input, + &user_output, + decimal_u128(&amount_in, "amountIn")?, + slippage_tolerance(&slippage_bps)?, + )?; + Ok(prepared_swap_exact_input_json(prepared)) + } + QuoteRequest::SwapExactInput { + state, + user_input_holding, + user_output_holding, + input_token_definition_id, + amount_in, + minimum_amount_out, + } => { + let (context, snapshot) = state.validate()?; + let (user_input, user_output) = validated_swap_holdings( + &context, + &snapshot, + user_input_holding, + user_output_holding, + &input_token_definition_id, + )?; + let quote = client_quote::swap_exact_input( + &snapshot, + &user_input, + &user_output, + decimal_u128(&amount_in, "amountIn")?, + decimal_u128(&minimum_amount_out, "minimumAmountOut")?, + )?; + Ok(swap_quote_json(quote)) + } + QuoteRequest::PreviewSwapExactOutput { + state, + user_input_holding, + user_output_holding, + input_token_definition_id, + exact_amount_out, + } => { + let (context, snapshot) = state.validate()?; + let (user_input, user_output) = validated_swap_holdings( + &context, + &snapshot, + user_input_holding, + user_output_holding, + &input_token_definition_id, + )?; + let quote = client_quote::preview_swap_exact_output( + &snapshot, + &user_input, + &user_output, + decimal_u128(&exact_amount_out, "exactAmountOut")?, + )?; + Ok(swap_quote_json(quote)) + } + QuoteRequest::PrepareSwapExactOutput { + state, + user_input_holding, + user_output_holding, + input_token_definition_id, + exact_amount_out, + slippage_bps, + } => { + let (context, snapshot) = state.validate()?; + let (user_input, user_output) = validated_swap_holdings( + &context, + &snapshot, + user_input_holding, + user_output_holding, + &input_token_definition_id, + )?; + let prepared = crate::prepare_swap_exact_output( + &snapshot, + &user_input, + &user_output, + decimal_u128(&exact_amount_out, "exactAmountOut")?, + slippage_tolerance(&slippage_bps)?, + )?; + Ok(prepared_swap_exact_output_json(prepared)) + } + QuoteRequest::SwapExactOutput { + state, + user_input_holding, + user_output_holding, + input_token_definition_id, + exact_amount_out, + maximum_amount_in, + } => { + let (context, snapshot) = state.validate()?; + let (user_input, user_output) = validated_swap_holdings( + &context, + &snapshot, + user_input_holding, + user_output_holding, + &input_token_definition_id, + )?; + let quote = client_quote::swap_exact_output( + &snapshot, + &user_input, + &user_output, + decimal_u128(&exact_amount_out, "exactAmountOut")?, + decimal_u128(&maximum_amount_in, "maximumAmountIn")?, + )?; + Ok(swap_quote_json(quote)) + } + QuoteRequest::SyncReserves { state } => { + let (_, snapshot) = state.validate()?; + Ok(sync_reserves_quote_json(client_quote::sync_reserves( + &snapshot, + )?)) + } + QuoteRequest::CreateOraclePriceAccount { + state, + window_duration, + } => { + let (_, snapshot) = state.validate()?; + Ok(oracle_price_quote_json( + client_quote::create_oracle_price_account( + &snapshot, + decimal_u64(&window_duration, "windowDuration")?, + )?, + )) + } + } +} + +fn transaction_plan_json(plan: &TransactionPlan) -> Result { + let instruction_words = plan.instruction_data().map_err(|error| { + WireError::new( + "instruction_encoding_failed", + format!("instruction serialization failed: {error}"), + ) + })?; + let accounts = plan + .accounts() + .iter() + .map(|account| { + json!({ + "id": account.id().to_string(), + "role": account.role().as_str(), + "writable": account.writable(), + "signer": account.signer(), + "init": account.init(), + }) + }) + .collect::>(); + + Ok(json!({ + "instruction": plan.instruction_name(), + "programId": plan.program_id(), + "accounts": accounts, + "instructionWords": instruction_words, + })) +} + +fn pool_definition<'a>( + snapshot: &'a ValidatedPoolSnapshot, + definition_id: AccountId, + field: &str, +) -> Result<&'a ValidatedFungibleDefinition, WireError> { + if snapshot.token_a_definition().account_id() == definition_id { + Ok(snapshot.token_a_definition()) + } else if snapshot.token_b_definition().account_id() == definition_id { + Ok(snapshot.token_b_definition()) + } else { + Err(invalid_request(format!( + "{field} is not one of the pool token definitions" + ))) + } +} + +fn validated_holding( + context: &AmmContext, + holding: AccountSnapshotInput, + definition: &ValidatedFungibleDefinition, +) -> Result { + let holding = holding.into_snapshot()?; + Ok(ValidatedFungibleHolding::new( + context, &holding, definition, + )?) +} + +fn validated_swap_holdings( + context: &AmmContext, + snapshot: &ValidatedPoolSnapshot, + input_holding: AccountSnapshotInput, + output_holding: AccountSnapshotInput, + definition_id: &str, +) -> Result<(ValidatedFungibleHolding, ValidatedFungibleHolding), WireError> { + let definition_id = account_id(definition_id, "inputTokenDefinitionId")?; + let input_definition = pool_definition(snapshot, definition_id, "inputTokenDefinitionId")?; + let output_definition = + if input_definition.account_id() == snapshot.token_a_definition().account_id() { + snapshot.token_b_definition() + } else { + snapshot.token_a_definition() + }; + Ok(( + validated_holding(context, input_holding, input_definition)?, + validated_holding(context, output_holding, output_definition)?, + )) +} + +fn pool_update_json(pool: PoolUpdate) -> Value { + json!({ + "liquidityPoolSupply": pool.liquidity_pool_supply.to_string(), + "reserveA": pool.reserve_a.to_string(), + "reserveB": pool.reserve_b.to_string(), + "spotPriceQ64_64": pool.spot_price_q64_64.to_string(), + }) +} + +fn create_pool_quote_json(quote: CreatePoolQuote) -> Value { + json!({ + "pool": pool_update_json(quote.pool), + "lockedLiquidity": quote.locked_liquidity.to_string(), + "userLiquidity": quote.user_liquidity.to_string(), + }) +} + +fn prepared_create_pool_json(prepared: PreparedCreatePool) -> Value { + json!({ + "quote": create_pool_quote_json(prepared.quote), + "instructionArgs": { + "tokenAAmount": prepared.token_a_amount.to_string(), + "tokenBAmount": prepared.token_b_amount.to_string(), + "fees": prepared.fees.to_string(), + }, + }) +} + +fn add_liquidity_quote_json(quote: AddLiquidityQuote) -> Value { + json!({ + "actualAmountA": quote.actual_amount_a.to_string(), + "actualAmountB": quote.actual_amount_b.to_string(), + "liquidityToMint": quote.liquidity_to_mint.to_string(), + "pool": pool_update_json(quote.pool), + }) +} + +fn prepared_add_liquidity_json(prepared: PreparedAddLiquidity) -> Value { + json!({ + "quote": add_liquidity_quote_json(prepared.quote), + "instructionArgs": { + "minAmountLiquidity": prepared.min_amount_liquidity.to_string(), + "maxAmountToAddTokenA": prepared.max_amount_to_add_token_a.to_string(), + "maxAmountToAddTokenB": prepared.max_amount_to_add_token_b.to_string(), + }, + }) +} + +fn remove_liquidity_quote_json(quote: RemoveLiquidityQuote) -> Value { + json!({ + "withdrawAmountA": quote.withdraw_amount_a.to_string(), + "withdrawAmountB": quote.withdraw_amount_b.to_string(), + "liquidityToBurn": quote.liquidity_to_burn.to_string(), + "pool": pool_update_json(quote.pool), + }) +} + +fn prepared_remove_liquidity_json(prepared: PreparedRemoveLiquidity) -> Value { + json!({ + "quote": remove_liquidity_quote_json(prepared.quote), + "instructionArgs": { + "removeLiquidityAmount": prepared.remove_liquidity_amount.to_string(), + "minAmountToRemoveTokenA": prepared.min_amount_to_remove_token_a.to_string(), + "minAmountToRemoveTokenB": prepared.min_amount_to_remove_token_b.to_string(), + }, + }) +} + +fn swap_quote_json(quote: SwapQuote) -> Value { + json!({ + "direction": match quote.direction { + SwapDirection::AToB => "a_to_b", + SwapDirection::BToA => "b_to_a", + }, + "amountIn": quote.amount_in.to_string(), + "effectiveAmountIn": quote.effective_amount_in.to_string(), + "feeAmount": quote.fee_amount.to_string(), + "amountOut": quote.amount_out.to_string(), + "pool": pool_update_json(quote.pool), + }) +} + +fn prepared_swap_exact_input_json(prepared: PreparedSwapExactInput) -> Value { + json!({ + "quote": swap_quote_json(prepared.quote), + "instructionArgs": { + "swapAmountIn": prepared.swap_amount_in.to_string(), + "minAmountOut": prepared.min_amount_out.to_string(), + }, + }) +} + +fn prepared_swap_exact_output_json(prepared: PreparedSwapExactOutput) -> Value { + json!({ + "quote": swap_quote_json(prepared.quote), + "instructionArgs": { + "exactAmountOut": prepared.exact_amount_out.to_string(), + "maxAmountIn": prepared.max_amount_in.to_string(), + }, + }) +} + +fn sync_reserves_quote_json(quote: SyncReservesQuote) -> Value { + json!({ + "donatedAmountA": quote.donated_amount_a.to_string(), + "donatedAmountB": quote.donated_amount_b.to_string(), + "pool": pool_update_json(quote.pool), + }) +} + +fn oracle_price_quote_json(quote: OraclePriceAccountQuote) -> Value { + json!({ + "baseAsset": quote.base_asset.to_string(), + "quoteAsset": quote.quote_asset.to_string(), + "initialPriceQ64_64": quote.initial_price_q64_64.to_string(), + "windowDuration": quote.window_duration.to_string(), + }) +} + +fn account_id(value: &str, field: &str) -> Result { + AccountId::from_str(value) + .map_err(|error| invalid_request(format!("{field} is not a valid account ID: {error}"))) +} + +fn decimal_u128(value: &str, field: &str) -> Result { + decimal(value, field) +} + +fn decimal_u64(value: &str, field: &str) -> Result { + decimal(value, field) +} + +fn slippage_tolerance(value: &str) -> Result { + Ok(SlippageTolerance::new(decimal_u128(value, "slippageBps")?)?) +} + +fn decimal(value: &str, field: &str) -> Result +where + T: FromStr, +{ + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(invalid_request(format!( + "{field} must be a non-empty unsigned decimal string" + ))); + } + value.parse().map_err(|_| { + invalid_request(format!( + "{field} is outside the supported unsigned integer range" + )) + }) +} + +fn hex_bytes(value: &str, field: &str) -> Result, WireError> { + let mut bytes = Vec::new(); + let mut chunks = value.as_bytes().chunks_exact(2); + for chunk in &mut chunks { + let Some(high) = chunk.first().and_then(|byte| hex_nibble(*byte)) else { + return Err(invalid_request(format!( + "{field} must be an even-length hexadecimal string" + ))); + }; + let Some(low) = chunk.get(1).and_then(|byte| hex_nibble(*byte)) else { + return Err(invalid_request(format!( + "{field} must be an even-length hexadecimal string" + ))); + }; + bytes.push((high << 4) | low); + } + if !chunks.remainder().is_empty() { + return Err(invalid_request(format!( + "{field} must be an even-length hexadecimal string" + ))); + } + Ok(bytes) +} + +const fn hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => byte.checked_sub(b'0'), + b'a'..=b'f' => match byte.checked_sub(b'a') { + Some(value) => value.checked_add(10), + None => None, + }, + b'A'..=b'F' => match byte.checked_sub(b'A') { + Some(value) => value.checked_add(10), + None => None, + }, + _ => None, + } +} + +fn invalid_request(message: impl Into) -> WireError { + WireError::new("invalid_request", message) +} diff --git a/programs/amm/client/tests/ffi_contract.rs b/programs/amm/client/tests/ffi_contract.rs new file mode 100644 index 0000000..ec7d094 --- /dev/null +++ b/programs/amm/client/tests/ffi_contract.rs @@ -0,0 +1,356 @@ +#![allow( + unsafe_code, + reason = "contract tests call the exported C ABI and release its owned pointers" +)] + +use std::ffi::{c_char, CStr, CString}; + +use amm_client::{amm_client_free, amm_client_plan, amm_client_quote}; +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda, + AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, +}; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use serde_json::{json, Value}; +use token_core::{TokenDefinition, TokenHolding}; + +type Operation = unsafe extern "C" fn(*const c_char) -> *mut c_char; + +fn call(operation: Operation, request: Option<&CStr>) -> Value { + let request = request.map_or(std::ptr::null(), CStr::as_ptr); + // SAFETY: `request` is null or points into the borrowed `CStr`, which remains live through the + // call. The returned pointer is checked and released exactly once below. + let response = unsafe { operation(request) }; + assert!(!response.is_null()); + + // SAFETY: A non-null response is a live NUL-terminated string owned by the AMM client until + // `amm_client_free` below. + let text = unsafe { CStr::from_ptr(response) } + .to_str() + .expect("FFI response must be UTF-8"); + let value = serde_json::from_str(text).expect("FFI response must be JSON"); + // SAFETY: `response` came from this library and has not been released yet. + unsafe { amm_client_free(response) }; + value +} + +fn call_json(operation: Operation, request: &Value) -> Value { + let request = CString::new(request.to_string()).expect("JSON has no interior NUL"); + call(operation, Some(&request)) +} + +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": hex(account.data.as_ref()), + }) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn account(program_owner: ProgramId, data: Data) -> Account { + Account { + program_owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn fungible_definition( + program_owner: ProgramId, + total_supply: u128, + authority: Option, +) -> Account { + account( + program_owner, + 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, + }), + ) +} + +#[test] +fn null_request_returns_structured_error() { + let response = call(amm_client_plan, None); + + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "null_request"); +} + +#[test] +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["ok"], false); + assert_eq!(response["error"]["code"], "invalid_json"); +} + +#[test] +fn invalid_utf8_returns_structured_error() { + let request = CStr::from_bytes_with_nul(&[0xff, 0]).expect("bytes are NUL-terminated"); + let response = call(amm_client_quote, Some(request)); + + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "invalid_utf8"); +} + +#[test] +fn free_accepts_null() { + // SAFETY: Null is explicitly accepted by the deallocator contract. + unsafe { amm_client_free(std::ptr::null_mut()) }; +} + +#[test] +fn protocol_constants_are_exposed_without_numeric_json_values() { + let response = call_json( + amm_client_quote, + &json!({"operation": "protocol_constants"}), + ); + + assert_eq!(response["ok"], true); + assert_eq!( + response["value"]["minimumLiquidity"], + MINIMUM_LIQUIDITY.to_string() + ); + assert_eq!(response["value"]["feeBpsDenominator"], "10000"); + assert_eq!(response["value"]["slippageBpsDenominator"], "10000"); + assert_eq!( + response["value"]["supportedFeeTiers"], + json!(["1", "5", "30", "100"]) + ); +} + +#[test] +fn successful_plan_preserves_u64_above_javascript_range_in_guest_words() { + let amm_program_id: ProgramId = [11; 8]; + let token_program_id: ProgramId = [22; 8]; + let twap_oracle_program_id: ProgramId = [33; 8]; + let authority = AccountId::new([44; 32]); + let pool_id = AccountId::new([55; 32]); + let window_duration = 9_007_199_254_740_993_u64; + let response = call_json( + amm_client_plan, + &json!({ + "operation": "create_price_observations", + "context": { + "ammProgramId": amm_program_id, + "tokenProgramId": token_program_id, + "twapOracleProgramId": twap_oracle_program_id, + "authority": authority.to_string(), + }, + "poolId": pool_id.to_string(), + "windowDuration": window_duration.to_string(), + }), + ); + + assert_eq!(response["ok"], true); + assert_eq!( + response["value"]["instruction"], + "create_price_observations" + ); + assert_eq!(response["value"]["programId"], json!(amm_program_id)); + assert!(response["value"]["accounts"].is_array()); + let words: Vec = serde_json::from_value(response["value"]["instructionWords"].clone()) + .expect("instruction words must be u32 JSON numbers"); + let instruction: Instruction = + risc0_zkvm::serde::from_slice(&words).expect("guest codec must decode plan words"); + match instruction { + Instruction::CreatePriceObservations { + window_duration: decoded, + } => assert_eq!(decoded, window_duration), + Instruction::Initialize { .. } + | Instruction::UpdateConfig { .. } + | Instruction::CreateOraclePriceAccount { .. } + | Instruction::NewDefinition { .. } + | Instruction::AddLiquidity { .. } + | Instruction::RemoveLiquidity { .. } + | Instruction::SwapExactInput { .. } + | Instruction::SwapExactOutput { .. } + | Instruction::SyncReserves => panic!("expected CreatePriceObservations"), + } +} + +#[test] +fn successful_quote_preserves_u128_above_javascript_range_as_decimal() { + let amm_program_id: ProgramId = [11; 8]; + let token_program_id: ProgramId = [22; 8]; + let twap_oracle_program_id: ProgramId = [33; 8]; + let authority = AccountId::new([44; 32]); + let config = AmmConfig { + token_program_id, + twap_oracle_program_id, + authority, + }; + let config_account = Account { + program_owner: amm_program_id, + balance: 0, + data: Data::from(&config), + nonce: Nonce(0), + }; + let definition = |name: &str| Account { + program_owner: token_program_id, + balance: 0, + data: Data::from(&TokenDefinition::Fungible { + name: String::from(name), + total_supply: 0, + metadata_id: None, + authority: None, + }), + nonce: Nonce(0), + }; + let token_a_id = AccountId::new([61; 32]); + let token_b_id = AccountId::new([62; 32]); + let amount = 9_007_199_254_740_993_u128; + let response = call_json( + amm_client_quote, + &json!({ + "operation": "create_pool", + "ammProgramId": amm_program_id, + "config": snapshot(compute_config_pda(amm_program_id), &config_account), + "tokenADefinition": snapshot(token_a_id, &definition("A")), + "tokenBDefinition": snapshot(token_b_id, &definition("B")), + "tokenAAmount": amount.to_string(), + "tokenBAmount": amount.to_string(), + "feeBps": "30", + }), + ); + + assert_eq!(response["ok"], true); + assert_eq!(response["value"]["pool"]["reserveA"], amount.to_string()); + assert_eq!(response["value"]["pool"]["reserveB"], amount.to_string()); + assert_eq!( + response["value"]["userLiquidity"], + amount + .checked_sub(MINIMUM_LIQUIDITY) + .expect("test amount exceeds liquidity lock") + .to_string() + ); + assert!(response["value"]["pool"]["reserveA"].is_string()); + + let prepared = call_json( + amm_client_quote, + &json!({ + "operation": "prepare_create_pool", + "ammProgramId": amm_program_id, + "config": snapshot(compute_config_pda(amm_program_id), &config_account), + "tokenADefinition": snapshot(token_a_id, &definition("A")), + "tokenBDefinition": snapshot(token_b_id, &definition("B")), + "tokenAAmount": amount.to_string(), + "tokenBAmount": amount.to_string(), + "feeBps": "30", + }), + ); + assert_eq!(prepared["ok"], true); + assert_eq!( + prepared["value"]["instructionArgs"]["tokenAAmount"], + amount.to_string() + ); + assert_eq!( + prepared["value"]["instructionArgs"]["tokenBAmount"], + amount.to_string() + ); + assert!(prepared["value"]["instructionArgs"]["tokenAAmount"].is_string()); +} + +#[test] +fn swap_quote_rejects_unrelated_output_holding() { + let amm_program_id: ProgramId = [11; 8]; + let token_program_id: ProgramId = [22; 8]; + let twap_oracle_program_id: ProgramId = [33; 8]; + let token_a_id = AccountId::new([1; 32]); + let token_b_id = AccountId::new([2; 32]); + let unrelated_token_id = AccountId::new([3; 32]); + let pool_id = compute_pool_pda(amm_program_id, token_a_id, token_b_id); + let vault_a_id = compute_vault_pda(amm_program_id, pool_id, token_a_id); + let vault_b_id = compute_vault_pda(amm_program_id, pool_id, token_b_id); + let liquidity_id = compute_liquidity_token_pda(amm_program_id, pool_id); + let config = AmmConfig { + token_program_id, + twap_oracle_program_id, + authority: AccountId::new([9; 32]), + }; + let pool = PoolDefinition { + definition_token_a_id: token_a_id, + definition_token_b_id: token_b_id, + 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, + }; + let response = call_json( + amm_client_quote, + &json!({ + "operation": "preview_swap_exact_input", + "ammProgramId": amm_program_id, + "config": snapshot( + compute_config_pda(amm_program_id), + &account(amm_program_id, Data::from(&config)), + ), + "snapshot": { + "pool": snapshot( + pool_id, + &account(amm_program_id, Data::from(&pool)), + ), + "tokenADefinition": snapshot( + token_a_id, + &fungible_definition(token_program_id, 100_000, None), + ), + "tokenBDefinition": snapshot( + token_b_id, + &fungible_definition(token_program_id, 100_000, None), + ), + "vaultA": snapshot( + vault_a_id, + &fungible_holding(token_program_id, token_a_id, 1_100), + ), + "vaultB": snapshot( + vault_b_id, + &fungible_holding(token_program_id, token_b_id, 550), + ), + "liquidityDefinition": snapshot( + liquidity_id, + &fungible_definition(token_program_id, 2_000, Some(liquidity_id)), + ), + }, + "userInputHolding": snapshot( + AccountId::new([20; 32]), + &fungible_holding(token_program_id, token_a_id, 1_000), + ), + "userOutputHolding": snapshot( + AccountId::new([21; 32]), + &fungible_holding(token_program_id, unrelated_token_id, 0), + ), + "inputTokenDefinitionId": token_a_id.to_string(), + "amountIn": "100", + }), + ); + + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "token_definition_mismatch"); +} diff --git a/programs/amm/client/tests/plan_contract.rs b/programs/amm/client/tests/plan_contract.rs new file mode 100644 index 0000000..ec52b5b --- /dev/null +++ b/programs/amm/client/tests/plan_contract.rs @@ -0,0 +1,636 @@ +use amm_client::{ + 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, + plan_swap_exact_output, plan_sync_reserves, plan_update_config, AccountRole, + AddLiquidityPlanInput, AmmContext, ClientError, CreateOraclePriceAccountPlanInput, + CreatePoolPlanInput, CreatePriceObservationsPlanInput, InitializePlanInput, PoolContext, + RemoveLiquidityPlanInput, SwapExactInputPlanInput, SwapExactOutputPlanInput, + SyncReservesPlanInput, TransactionPlan, UpdateConfigPlanInput, +}; +use amm_core::{AmmConfig, Instruction, PoolDefinition}; +use amm_program::quote as program_quote; +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; +use nssa_core::{account::AccountId, program::ProgramId}; +use serde_json::Value; +use twap_oracle_core::{ + compute_current_tick_account_pda, compute_oracle_price_account_pda, + compute_price_observations_pda, +}; + +const LARGE_EXACT_INTEGER: u128 = 9_007_199_254_740_993; +const WINDOW_DURATION: u64 = 86_400_000; + +fn account(byte: u8) -> AccountId { + AccountId::new([byte; 32]) +} + +const fn program(word: u32) -> ProgramId { + [word; 8] +} + +fn context() -> AmmContext { + AmmContext::new( + program(42), + AmmConfig { + token_program_id: program(15), + twap_oracle_program_id: program(77), + authority: account(9), + }, + ) +} + +fn pool_fixture(context: &AmmContext) -> (AccountId, PoolDefinition) { + let definition_a = account(3); + let definition_b = account(4); + let pool_id = amm_core::compute_pool_pda(context.amm_program_id, definition_a, definition_b); + ( + pool_id, + PoolDefinition { + definition_token_a_id: definition_a, + definition_token_b_id: definition_b, + vault_a_id: amm_core::compute_vault_pda(context.amm_program_id, pool_id, definition_a), + vault_b_id: amm_core::compute_vault_pda(context.amm_program_id, pool_id, definition_b), + liquidity_pool_id: amm_core::compute_liquidity_token_pda( + context.amm_program_id, + pool_id, + ), + liquidity_pool_supply: 10_000, + reserve_a: 20_000, + reserve_b: 30_000, + fees: amm_core::FEE_TIER_BPS_30, + }, + ) +} + +fn all_plans() -> Vec { + let context = context(); + let (pool_id, pool) = pool_fixture(&context); + let pool = PoolContext::new(&context, pool_id, &pool).expect("valid pool fixture"); + + vec![ + plan_initialize(InitializePlanInput { + amm_program_id: context.amm_program_id, + token_program_id: context.token_program_id(), + twap_oracle_program_id: context.twap_oracle_program_id(), + authority: context.config.authority, + }), + plan_update_config(UpdateConfigPlanInput { + context: &context, + token_program_id: Some(program(16)), + twap_oracle_program_id: Some(program(78)), + new_authority: Some(account(10)), + }), + plan_create_price_observations(CreatePriceObservationsPlanInput { + context: &context, + pool_id, + window_duration: WINDOW_DURATION, + }), + plan_create_oracle_price_account(CreateOraclePriceAccountPlanInput { + context: &context, + pool_id, + window_duration: WINDOW_DURATION, + }), + plan_create_pool(CreatePoolPlanInput { + context: &context, + token_a_definition_id: account(3), + token_b_definition_id: account(4), + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + token_a_amount: 20_000, + token_b_amount: 30_000, + fees: amm_core::FEE_TIER_BPS_30, + deadline: u64::MAX, + }) + .expect("distinct pool definitions"), + plan_add_liquidity(AddLiquidityPlanInput { + context: &context, + pool, + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + min_amount_liquidity: 1, + max_amount_to_add_token_a: 200, + max_amount_to_add_token_b: 300, + deadline: u64::MAX, + }), + plan_remove_liquidity(RemoveLiquidityPlanInput { + context: &context, + pool, + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + remove_liquidity_amount: 100, + min_amount_to_remove_token_a: 1, + min_amount_to_remove_token_b: 1, + deadline: u64::MAX, + }), + plan_swap_exact_input(SwapExactInputPlanInput { + context: &context, + pool, + user_input_holding: account(31), + user_output_holding: account(32), + swap_amount_in: LARGE_EXACT_INTEGER, + min_amount_out: 1, + deadline: u64::MAX, + }), + plan_swap_exact_output(SwapExactOutputPlanInput { + context: &context, + pool, + user_input_holding: account(32), + user_output_holding: account(31), + exact_amount_out: 10, + max_amount_in: LARGE_EXACT_INTEGER, + deadline: u64::MAX, + }), + plan_sync_reserves(SyncReservesPlanInput { + context: &context, + pool, + }), + ] +} + +#[test] +fn every_instruction_round_trips_through_guest_codec() { + let expected_indices = [0_u32, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + let plans = all_plans(); + assert_eq!(plans.len(), expected_indices.len()); + + for (plan, expected_index) in plans.iter().zip(expected_indices) { + let words = plan.instruction_data().expect("instruction must serialize"); + assert_eq!( + words, + encode_instruction(plan.instruction()).expect("direct encoding") + ); + assert_eq!(words.first().copied(), Some(expected_index)); + + let decoded: Instruction = + risc0_zkvm::serde::from_slice(&words).expect("guest codec must decode words"); + assert_eq!(variant_index(&decoded), expected_index); + assert_eq!( + encode_instruction(&decoded).expect("decoded instruction must serialize"), + words + ); + } +} + +#[test] +fn update_config_none_options_round_trip() { + let instruction = Instruction::UpdateConfig { + token_program_id: None, + twap_oracle_program_id: None, + new_authority: None, + }; + let words = encode_instruction(&instruction).expect("instruction must serialize"); + let decoded: Instruction = + risc0_zkvm::serde::from_slice(&words).expect("instruction must deserialize"); + + assert!(matches!( + decoded, + Instruction::UpdateConfig { + token_program_id: None, + twap_oracle_program_id: None, + new_authority: None, + } + )); +} + +#[test] +fn u128_above_javascript_integer_range_is_exact() { + let instruction = Instruction::SwapExactInput { + swap_amount_in: LARGE_EXACT_INTEGER, + min_amount_out: LARGE_EXACT_INTEGER, + deadline: u64::MAX, + }; + let words = encode_instruction(&instruction).expect("instruction must serialize"); + let decoded: Instruction = + risc0_zkvm::serde::from_slice(&words).expect("instruction must deserialize"); + + let Instruction::SwapExactInput { + swap_amount_in, + min_amount_out, + deadline, + } = decoded + else { + panic!("decoded wrong instruction variant"); + }; + assert_eq!(swap_amount_in, LARGE_EXACT_INTEGER); + assert_eq!(min_amount_out, LARGE_EXACT_INTEGER); + assert_eq!(deadline, u64::MAX); +} + +#[test] +fn planner_account_contract_matches_checked_in_idl() { + let idl: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../artifacts/amm-idl.json" + ))) + .expect("checked-in AMM IDL must be JSON"); + assert_eq!( + idl.get("instruction_type").and_then(Value::as_str), + Some("amm_core::Instruction") + ); + let idl_instructions = idl + .get("instructions") + .and_then(Value::as_array) + .expect("IDL instructions array"); + let plans = all_plans(); + assert_eq!(idl_instructions.len(), plans.len()); + + for (idl_instruction, plan) in idl_instructions.iter().zip(plans.iter()) { + assert_eq!( + string_field(idl_instruction, "name"), + plan.instruction_name() + ); + let idl_accounts = idl_instruction + .get("accounts") + .and_then(Value::as_array) + .expect("IDL accounts array"); + assert_eq!(idl_accounts.len(), plan.accounts().len()); + + for (idl_account, planned_account) in idl_accounts.iter().zip(plan.accounts()) { + assert_eq!( + string_field(idl_account, "name"), + planned_account.role().as_str() + ); + assert_eq!( + bool_field(idl_account, "writable"), + planned_account.writable() + ); + assert_eq!(bool_field(idl_account, "signer"), planned_account.signer()); + assert_eq!(bool_field(idl_account, "init"), planned_account.init()); + } + } +} + +#[test] +fn signer_sets_follow_guest_account_order() { + let plans = all_plans(); + let expected = vec![ + vec![], + vec![account(9)], + vec![], + vec![], + vec![account(31), account(32), account(33)], + vec![account(31), account(32)], + vec![account(33)], + vec![account(31)], + vec![account(32)], + vec![], + ]; + assert_eq!(plans.len(), expected.len()); + + for (plan, expected_signers) in plans.iter().zip(expected) { + assert_eq!(plan.signer_account_ids(), expected_signers); + } +} + +#[test] +fn account_ids_and_signer_flags_stay_positionally_aligned() { + for plan in all_plans() { + let account_ids = plan.account_ids(); + let signer_flags = plan.signer_flags(); + assert_eq!(account_ids.len(), signer_flags.len()); + + let filtered_ids: Vec = account_ids + .into_iter() + .zip(signer_flags) + .filter_map(|(account_id, signer)| signer.then_some(account_id)) + .collect(); + assert_eq!(filtered_ids, plan.signer_account_ids()); + } +} + +#[test] +fn quote_results_feed_instruction_amounts_and_guards_without_recalculation() { + let context = context(); + let (pool_id, pool_definition) = pool_fixture(&context); + let pool = PoolContext::new(&context, pool_id, &pool_definition).expect("valid pool fixture"); + + let create_quote = + program_quote::create_pool(20_000, 30_000, amm_core::FEE_TIER_BPS_30).expect("pool quote"); + let create_plan = plan_create_pool(CreatePoolPlanInput { + context: &context, + token_a_definition_id: pool_definition.definition_token_a_id, + token_b_definition_id: pool_definition.definition_token_b_id, + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + token_a_amount: create_quote.pool.reserve_a, + token_b_amount: create_quote.pool.reserve_b, + fees: amm_core::FEE_TIER_BPS_30, + deadline: u64::MAX, + }) + .expect("create plan"); + let Instruction::NewDefinition { + token_a_amount, + token_b_amount, + fees, + .. + } = create_plan.instruction() + else { + panic!("create planner emitted wrong instruction"); + }; + assert_eq!(*token_a_amount, create_quote.pool.reserve_a); + assert_eq!(*token_b_amount, create_quote.pool.reserve_b); + assert_eq!(*fees, amm_core::FEE_TIER_BPS_30); + + let add_preview = program_quote::preview_add_liquidity( + &pool_definition, + pool_definition.reserve_a, + pool_definition.reserve_b, + 200, + 300, + ) + .expect("add preview"); + let add_quote = program_quote::add_liquidity( + &pool_definition, + pool_definition.reserve_a, + pool_definition.reserve_b, + add_preview.actual_amount_a, + add_preview.actual_amount_b, + add_preview.liquidity_to_mint, + ) + .expect("exact add quote"); + let add_plan = plan_add_liquidity(AddLiquidityPlanInput { + context: &context, + pool, + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + min_amount_liquidity: add_quote.liquidity_to_mint, + max_amount_to_add_token_a: add_quote.actual_amount_a, + max_amount_to_add_token_b: add_quote.actual_amount_b, + deadline: u64::MAX, + }); + let Instruction::AddLiquidity { + min_amount_liquidity, + max_amount_to_add_token_a, + max_amount_to_add_token_b, + .. + } = add_plan.instruction() + else { + panic!("add planner emitted wrong instruction"); + }; + assert_eq!(*min_amount_liquidity, add_quote.liquidity_to_mint); + assert_eq!(*max_amount_to_add_token_a, add_quote.actual_amount_a); + assert_eq!(*max_amount_to_add_token_b, add_quote.actual_amount_b); + + let remove_preview = program_quote::preview_remove_liquidity(&pool_definition, 500, 100) + .expect("remove preview"); + let remove_quote = program_quote::remove_liquidity( + &pool_definition, + 500, + remove_preview.liquidity_to_burn, + remove_preview.withdraw_amount_a, + remove_preview.withdraw_amount_b, + ) + .expect("exact remove quote"); + let remove_plan = plan_remove_liquidity(RemoveLiquidityPlanInput { + context: &context, + pool, + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + remove_liquidity_amount: remove_quote.liquidity_to_burn, + min_amount_to_remove_token_a: remove_quote.withdraw_amount_a, + min_amount_to_remove_token_b: remove_quote.withdraw_amount_b, + deadline: u64::MAX, + }); + let Instruction::RemoveLiquidity { + remove_liquidity_amount, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + .. + } = remove_plan.instruction() + else { + panic!("remove planner emitted wrong instruction"); + }; + assert_eq!(*remove_liquidity_amount, remove_quote.liquidity_to_burn); + assert_eq!( + *min_amount_to_remove_token_a, + remove_quote.withdraw_amount_a + ); + assert_eq!( + *min_amount_to_remove_token_b, + remove_quote.withdraw_amount_b + ); + + let swap_input_preview = program_quote::preview_swap_exact_input( + &pool_definition, + pool_definition.reserve_a, + pool_definition.reserve_b, + program_quote::SwapDirection::AToB, + 100, + ) + .expect("exact-input preview"); + let swap_input_quote = program_quote::swap_exact_input( + &pool_definition, + pool_definition.reserve_a, + pool_definition.reserve_b, + program_quote::SwapDirection::AToB, + swap_input_preview.amount_in, + swap_input_preview.amount_out, + ) + .expect("exact-input quote"); + let swap_input_plan = plan_swap_exact_input(SwapExactInputPlanInput { + context: &context, + pool, + user_input_holding: account(31), + user_output_holding: account(32), + swap_amount_in: swap_input_quote.amount_in, + min_amount_out: swap_input_quote.amount_out, + deadline: u64::MAX, + }); + let Instruction::SwapExactInput { + swap_amount_in, + min_amount_out, + .. + } = swap_input_plan.instruction() + else { + panic!("exact-input planner emitted wrong instruction"); + }; + assert_eq!(*swap_amount_in, swap_input_quote.amount_in); + assert_eq!(*min_amount_out, swap_input_quote.amount_out); + + let swap_output_preview = program_quote::preview_swap_exact_output( + &pool_definition, + pool_definition.reserve_a, + pool_definition.reserve_b, + program_quote::SwapDirection::BToA, + 100, + ) + .expect("exact-output preview"); + let swap_output_quote = program_quote::swap_exact_output( + &pool_definition, + pool_definition.reserve_a, + pool_definition.reserve_b, + program_quote::SwapDirection::BToA, + swap_output_preview.amount_out, + swap_output_preview.amount_in, + ) + .expect("exact-output quote"); + let swap_output_plan = plan_swap_exact_output(SwapExactOutputPlanInput { + context: &context, + pool, + user_input_holding: account(32), + user_output_holding: account(31), + exact_amount_out: swap_output_quote.amount_out, + max_amount_in: swap_output_quote.amount_in, + deadline: u64::MAX, + }); + let Instruction::SwapExactOutput { + exact_amount_out, + max_amount_in, + .. + } = swap_output_plan.instruction() + else { + panic!("exact-output planner emitted wrong instruction"); + }; + assert_eq!(*exact_amount_out, swap_output_quote.amount_out); + assert_eq!(*max_amount_in, swap_output_quote.amount_in); +} + +#[test] +fn planners_derive_protocol_accounts_from_canonical_helpers() { + let context = context(); + let (pool_id, pool) = pool_fixture(&context); + + let observations = plan_create_price_observations(CreatePriceObservationsPlanInput { + context: &context, + pool_id, + window_duration: WINDOW_DURATION, + }); + assert_eq!( + account_for_role(&observations, AccountRole::CurrentTickAccount), + compute_current_tick_account_pda(context.twap_oracle_program_id(), pool_id) + ); + assert_eq!( + account_for_role(&observations, AccountRole::PriceObservations), + compute_price_observations_pda(context.twap_oracle_program_id(), pool_id, WINDOW_DURATION) + ); + assert_eq!( + account_for_role(&observations, AccountRole::Clock), + CLOCK_01_PROGRAM_ACCOUNT_ID + ); + + let oracle = plan_create_oracle_price_account(CreateOraclePriceAccountPlanInput { + context: &context, + pool_id, + window_duration: WINDOW_DURATION, + }); + assert_eq!( + account_for_role(&oracle, AccountRole::OraclePriceAccount), + compute_oracle_price_account_pda( + context.twap_oracle_program_id(), + pool_id, + WINDOW_DURATION + ) + ); + + let create = plan_create_pool(CreatePoolPlanInput { + context: &context, + token_a_definition_id: pool.definition_token_a_id, + token_b_definition_id: pool.definition_token_b_id, + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + token_a_amount: 20_000, + token_b_amount: 30_000, + fees: amm_core::FEE_TIER_BPS_30, + deadline: u64::MAX, + }) + .expect("distinct definitions"); + assert_eq!(account_for_role(&create, AccountRole::Pool), pool_id); + assert_eq!( + account_for_role(&create, AccountRole::VaultA), + pool.vault_a_id + ); + assert_eq!( + account_for_role(&create, AccountRole::VaultB), + pool.vault_b_id + ); + assert_eq!( + account_for_role(&create, AccountRole::PoolDefinitionLp), + pool.liquidity_pool_id + ); + assert_eq!( + account_for_role(&create, AccountRole::LpLockHolding), + amm_core::compute_lp_lock_holding_pda(context.amm_program_id, pool_id) + ); +} + +#[test] +fn equal_token_pool_returns_error_without_panicking() { + let context = context(); + let result = plan_create_pool(CreatePoolPlanInput { + context: &context, + token_a_definition_id: account(3), + token_b_definition_id: account(3), + user_holding_a: account(31), + user_holding_b: account(32), + user_holding_lp: account(33), + token_a_amount: 20_000, + token_b_amount: 30_000, + fees: amm_core::FEE_TIER_BPS_30, + deadline: u64::MAX, + }); + + assert!(matches!( + result, + Err(ClientError::IdenticalTokenDefinitions) + )); +} + +#[test] +fn pool_context_rejects_noncanonical_identity_fields() { + let context = context(); + let (pool_id, mut pool) = pool_fixture(&context); + pool.vault_a_id = account(200); + + let result = PoolContext::new(&context, pool_id, &pool); + assert!(matches!( + result, + Err(ClientError::AccountIdMismatch { + account: "vault_a", + .. + }) + )); +} + +fn account_for_role(plan: &TransactionPlan, role: AccountRole) -> AccountId { + plan.accounts() + .iter() + .find(|account| account.role() == role) + .map(|account| account.id()) + .expect("plan must contain requested role") +} + +fn string_field<'a>(value: &'a Value, field: &str) -> &'a str { + value + .get(field) + .and_then(Value::as_str) + .expect("IDL string field") +} + +fn bool_field(value: &Value, field: &str) -> bool { + value + .get(field) + .and_then(Value::as_bool) + .expect("IDL boolean field") +} + +const fn variant_index(instruction: &Instruction) -> u32 { + match instruction { + Instruction::Initialize { .. } => 0, + Instruction::UpdateConfig { .. } => 1, + Instruction::CreatePriceObservations { .. } => 2, + Instruction::CreateOraclePriceAccount { .. } => 3, + Instruction::NewDefinition { .. } => 4, + Instruction::AddLiquidity { .. } => 5, + Instruction::RemoveLiquidity { .. } => 6, + Instruction::SwapExactInput { .. } => 7, + Instruction::SwapExactOutput { .. } => 8, + Instruction::SyncReserves => 9, + } +} diff --git a/programs/amm/client/tests/quote_contract.rs b/programs/amm/client/tests/quote_contract.rs new file mode 100644 index 0000000..549767c --- /dev/null +++ b/programs/amm/client/tests/quote_contract.rs @@ -0,0 +1,647 @@ +use amm_client::{ + plan_add_liquidity, plan_create_pool, plan_remove_liquidity, plan_swap_exact_input, + plan_swap_exact_output, prepare_add_liquidity, prepare_create_pool, prepare_remove_liquidity, + prepare_swap_exact_input, prepare_swap_exact_output, + quote::{ + self, AccountSnapshot, ValidatedFungibleDefinition, ValidatedFungibleHolding, + ValidatedPoolSnapshot, + }, + AddLiquidityPlanInput, AmmContext, ClientError, CreatePoolPlanInput, PoolContext, + RemoveLiquidityPlanInput, SlippageTolerance, SwapExactInputPlanInput, SwapExactOutputPlanInput, +}; +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda, + AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY, +}; +use amm_program::quote as program_quote; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::OBSERVATIONS_CAPACITY; + +const AMM_PROGRAM_ID: ProgramId = [42; 8]; +const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; +const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; +const LP_SUPPLY: u128 = 2_000; +const RESERVE_A: u128 = 1_000; +const RESERVE_B: u128 = 500; +const VAULT_A_BALANCE: u128 = 1_100; +const VAULT_B_BALANCE: u128 = 550; + +fn token_a_id() -> AccountId { + AccountId::new([1; 32]) +} + +fn token_b_id() -> AccountId { + AccountId::new([2; 32]) +} + +fn pool_id() -> AccountId { + compute_pool_pda(AMM_PROGRAM_ID, token_a_id(), token_b_id()) +} + +fn vault_a_id() -> AccountId { + compute_vault_pda(AMM_PROGRAM_ID, pool_id(), token_a_id()) +} + +fn vault_b_id() -> AccountId { + compute_vault_pda(AMM_PROGRAM_ID, pool_id(), token_b_id()) +} + +fn liquidity_definition_id() -> AccountId { + compute_liquidity_token_pda(AMM_PROGRAM_ID, pool_id()) +} + +fn account(program_owner: ProgramId, data: Data) -> Account { + Account { + program_owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn fungible_definition( + account_id: AccountId, + total_supply: u128, + authority: Option, +) -> AccountSnapshot { + AccountSnapshot::new( + account_id, + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenDefinition::Fungible { + name: String::from("Token"), + total_supply, + metadata_id: None, + authority, + }), + ), + ) +} + +fn fungible_holding( + account_id: AccountId, + definition_id: AccountId, + balance: u128, +) -> AccountSnapshot { + AccountSnapshot::new( + account_id, + account( + TOKEN_PROGRAM_ID, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + ), + ) +} + +struct Fixture { + context: AmmContext, + pool: AccountSnapshot, + token_a_definition: AccountSnapshot, + token_b_definition: AccountSnapshot, + vault_a: AccountSnapshot, + vault_b: AccountSnapshot, + liquidity_definition: AccountSnapshot, +} + +impl Fixture { + fn new() -> Self { + let config = AmmConfig { + token_program_id: TOKEN_PROGRAM_ID, + twap_oracle_program_id: TWAP_ORACLE_PROGRAM_ID, + authority: AccountId::new([9; 32]), + }; + let config_account = AccountSnapshot::new( + compute_config_pda(AMM_PROGRAM_ID), + account(AMM_PROGRAM_ID, Data::from(&config)), + ); + let context = AmmContext::from_config_account(AMM_PROGRAM_ID, &config_account) + .expect("canonical config snapshot must validate"); + 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: LP_SUPPLY, + reserve_a: RESERVE_A, + reserve_b: RESERVE_B, + fees: FEE_TIER_BPS_30, + }; + + Self { + context, + pool: AccountSnapshot::new( + pool_id(), + account(AMM_PROGRAM_ID, Data::from(&pool_definition)), + ), + token_a_definition: fungible_definition(token_a_id(), 100_000, None), + token_b_definition: fungible_definition(token_b_id(), 100_000, None), + vault_a: fungible_holding(vault_a_id(), token_a_id(), VAULT_A_BALANCE), + vault_b: fungible_holding(vault_b_id(), token_b_id(), VAULT_B_BALANCE), + liquidity_definition: fungible_definition( + liquidity_definition_id(), + LP_SUPPLY, + Some(liquidity_definition_id()), + ), + } + } + + fn validated_pool(&self) -> Result { + ValidatedPoolSnapshot::new( + &self.context, + &self.pool, + &self.token_a_definition, + &self.token_b_definition, + &self.vault_a, + &self.vault_b, + &self.liquidity_definition, + ) + } + + fn token_a(&self) -> ValidatedFungibleDefinition { + ValidatedFungibleDefinition::new(&self.context, &self.token_a_definition) + .expect("token A definition must validate") + } + + fn token_b(&self) -> ValidatedFungibleDefinition { + ValidatedFungibleDefinition::new(&self.context, &self.token_b_definition) + .expect("token B definition must validate") + } + + fn liquidity_token(&self) -> ValidatedFungibleDefinition { + ValidatedFungibleDefinition::new(&self.context, &self.liquidity_definition) + .expect("liquidity definition must validate") + } +} + +#[test] +fn validates_context_pool_vaults_and_fungible_definitions() { + let fixture = Fixture::new(); + let snapshot = fixture + .validated_pool() + .expect("canonical pool snapshot must validate"); + + assert_eq!(fixture.context.amm_program_id, AMM_PROGRAM_ID); + assert_eq!(fixture.context.token_program_id(), TOKEN_PROGRAM_ID); + assert_eq!(snapshot.pool_id(), pool_id()); + assert_eq!(snapshot.pool().reserve_a, RESERVE_A); + assert_eq!(snapshot.pool().reserve_b, RESERVE_B); + assert_eq!(snapshot.vault_a().balance(), VAULT_A_BALANCE); + assert_eq!(snapshot.vault_b().balance(), VAULT_B_BALANCE); + assert_eq!(snapshot.token_a_definition().account_id(), token_a_id()); + assert_eq!(snapshot.token_b_definition().account_id(), token_b_id()); + assert_eq!( + snapshot.liquidity_definition().total_supply(), + snapshot.pool().liquidity_pool_supply + ); +} + +#[test] +fn rejects_unrelated_vault_even_when_its_holding_data_matches() { + let fixture = Fixture::new(); + let unrelated_vault = + AccountSnapshot::new(AccountId::new([99; 32]), fixture.vault_a.account().clone()); + let result = ValidatedPoolSnapshot::new( + &fixture.context, + &fixture.pool, + &fixture.token_a_definition, + &fixture.token_b_definition, + &unrelated_vault, + &fixture.vault_b, + &fixture.liquidity_definition, + ); + let error = result.err().expect("unrelated vault must be rejected"); + + assert_eq!(error.code(), "account_id_mismatch"); + assert!(matches!( + error, + ClientError::AccountIdMismatch { + account: "vault A", + expected, + actual, + } if expected == vault_a_id() && actual == AccountId::new([99; 32]) + )); +} + +#[test] +fn rejects_inconsistent_liquidity_definition_state() { + let fixture = Fixture::new(); + let wrong_supply = fungible_definition( + liquidity_definition_id(), + 1_999, + Some(liquidity_definition_id()), + ); + let result = ValidatedPoolSnapshot::new( + &fixture.context, + &fixture.pool, + &fixture.token_a_definition, + &fixture.token_b_definition, + &fixture.vault_a, + &fixture.vault_b, + &wrong_supply, + ); + let error = result + .err() + .expect("LP definition supply mismatch must be rejected"); + + assert_eq!(error.code(), "invalid_account_data"); +} + +#[test] +fn client_quotes_match_program_quotes_for_every_economic_operation() { + let fixture = Fixture::new(); + let snapshot = fixture + .validated_pool() + .expect("canonical pool snapshot must validate"); + let token_a = fixture.token_a(); + let token_b = fixture.token_b(); + let liquidity_token = fixture.liquidity_token(); + let user_a = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(AccountId::new([20; 32]), token_a_id(), 10_000), + &token_a, + ) + .expect("user token-A holding must validate"); + let user_b = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(AccountId::new([21; 32]), token_b_id(), 10_000), + &token_b, + ) + .expect("user token-B holding must validate"); + let user_liquidity = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(AccountId::new([22; 32]), liquidity_definition_id(), 1_000), + &liquidity_token, + ) + .expect("user LP holding must validate"); + + assert_eq!( + quote::create_pool( + &fixture.context, + &token_a, + &token_b, + 4_000, + 9_000, + FEE_TIER_BPS_30 + ), + program_quote::create_pool(4_000, 9_000, FEE_TIER_BPS_30).map_err(ClientError::from) + ); + assert_eq!( + quote::preview_add_liquidity(&snapshot, 400, 100), + program_quote::preview_add_liquidity( + snapshot.pool(), + VAULT_A_BALANCE, + VAULT_B_BALANCE, + 400, + 100, + ) + .map_err(ClientError::from) + ); + assert_eq!( + quote::add_liquidity(&snapshot, 400, 100, 399), + program_quote::add_liquidity( + snapshot.pool(), + VAULT_A_BALANCE, + VAULT_B_BALANCE, + 400, + 100, + 399, + ) + .map_err(ClientError::from) + ); + assert_eq!( + quote::preview_remove_liquidity(&snapshot, &user_liquidity, 500), + program_quote::preview_remove_liquidity(snapshot.pool(), 1_000, 500) + .map_err(ClientError::from) + ); + assert_eq!( + quote::remove_liquidity(&snapshot, &user_liquidity, 500, 250, 125), + program_quote::remove_liquidity(snapshot.pool(), 1_000, 500, 250, 125) + .map_err(ClientError::from) + ); + assert_eq!( + quote::preview_swap_exact_input(&snapshot, &user_a, &user_b, 100), + program_quote::preview_swap_exact_input( + snapshot.pool(), + VAULT_A_BALANCE, + VAULT_B_BALANCE, + program_quote::SwapDirection::AToB, + 100, + ) + .map_err(ClientError::from) + ); + assert_eq!( + quote::swap_exact_input(&snapshot, &user_b, &user_a, 100, 165), + program_quote::swap_exact_input( + snapshot.pool(), + VAULT_A_BALANCE, + VAULT_B_BALANCE, + program_quote::SwapDirection::BToA, + 100, + 165, + ) + .map_err(ClientError::from) + ); + assert_eq!( + quote::preview_swap_exact_output(&snapshot, &user_a, &user_b, 45), + program_quote::preview_swap_exact_output( + snapshot.pool(), + VAULT_A_BALANCE, + VAULT_B_BALANCE, + program_quote::SwapDirection::AToB, + 45, + ) + .map_err(ClientError::from) + ); + assert_eq!( + quote::swap_exact_output(&snapshot, &user_a, &user_b, 45, 100), + program_quote::swap_exact_output( + snapshot.pool(), + VAULT_A_BALANCE, + VAULT_B_BALANCE, + program_quote::SwapDirection::AToB, + 45, + 100, + ) + .map_err(ClientError::from) + ); + assert_eq!( + quote::sync_reserves(&snapshot), + program_quote::sync_reserves(snapshot.pool(), VAULT_A_BALANCE, VAULT_B_BALANCE) + .map_err(ClientError::from) + ); + let window_duration = u64::from(OBSERVATIONS_CAPACITY); + assert_eq!( + quote::create_oracle_price_account(&snapshot, window_duration), + program_quote::create_oracle_price_account(snapshot.pool(), window_duration) + .map_err(ClientError::from) + ); + assert_eq!( + quote::pair_order(&snapshot, &token_b, &token_a), + Ok(program_quote::PairOrder::Reversed) + ); +} + +#[test] +fn swap_rejects_unrelated_output_and_insufficient_input_balance() { + let fixture = Fixture::new(); + let snapshot = fixture + .validated_pool() + .expect("canonical pool snapshot must validate"); + let token_a = fixture.token_a(); + let token_b = fixture.token_b(); + let token_c_account = fungible_definition(AccountId::new([3; 32]), 100_000, None); + let token_c = ValidatedFungibleDefinition::new(&fixture.context, &token_c_account) + .expect("third fungible definition must validate"); + let user_a = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(AccountId::new([20; 32]), token_a_id(), 99), + &token_a, + ) + .expect("user token-A holding must validate"); + let user_b = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(AccountId::new([21; 32]), token_b_id(), 0), + &token_b, + ) + .expect("user token-B holding must validate"); + let user_c = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(AccountId::new([23; 32]), token_c.account_id(), 0), + &token_c, + ) + .expect("user token-C holding must validate"); + + let unrelated_output = quote::swap_exact_input(&snapshot, &user_a, &user_c, 99, 1) + .expect_err("unrelated output holding must be rejected"); + assert_eq!(unrelated_output.code(), "token_definition_mismatch"); + + let insufficient = quote::swap_exact_input(&snapshot, &user_a, &user_b, 100, 1) + .expect_err("input above the holding balance must be rejected"); + assert_eq!(insufficient.code(), "insufficient_balance"); + assert!(matches!( + insufficient, + ClientError::InsufficientBalance { + account: "user input holding", + available: 99, + required: 100, + } + )); +} + +#[test] +fn raw_amounts_above_javascript_integer_range_remain_exact() { + const ABOVE_TWO_POW_53: u128 = 9_007_199_254_740_993; + const USER_LIQUIDITY: u128 = 9_007_199_254_739_993; + + let fixture = Fixture::new(); + let token_a = fixture.token_a(); + let token_b = fixture.token_b(); + let quote = quote::create_pool( + &fixture.context, + &token_a, + &token_b, + ABOVE_TWO_POW_53, + ABOVE_TWO_POW_53, + FEE_TIER_BPS_30, + ) + .expect("large exact integer amounts must quote"); + let holding = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(AccountId::new([20; 32]), token_a_id(), ABOVE_TWO_POW_53), + &token_a, + ) + .expect("large exact integer holding must validate"); + + assert_eq!(quote.pool.reserve_a, ABOVE_TWO_POW_53); + assert_eq!(quote.pool.reserve_b, ABOVE_TWO_POW_53); + assert_eq!(quote.pool.liquidity_pool_supply, ABOVE_TWO_POW_53); + assert_eq!(quote.locked_liquidity, MINIMUM_LIQUIDITY); + assert_eq!(quote.user_liquidity, USER_LIQUIDITY); + assert_eq!(holding.balance(), ABOVE_TWO_POW_53); +} + +#[test] +fn prepared_instruction_args_feed_canonical_planners_without_ui_math() { + let fixture = Fixture::new(); + let snapshot = fixture + .validated_pool() + .expect("canonical pool snapshot must validate"); + let pool = PoolContext::new(&fixture.context, snapshot.pool_id(), snapshot.pool()) + .expect("validated pool has canonical identity"); + let token_a = fixture.token_a(); + let token_b = fixture.token_b(); + let liquidity_token = fixture.liquidity_token(); + let user_holding_a_id = AccountId::new([20; 32]); + let user_holding_b_id = AccountId::new([21; 32]); + let user_holding_lp_id = AccountId::new([22; 32]); + let user_a = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(user_holding_a_id, token_a_id(), 10_000), + &token_a, + ) + .expect("user token-A holding must validate"); + let user_b = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(user_holding_b_id, token_b_id(), 10_000), + &token_b, + ) + .expect("user token-B holding must validate"); + let user_liquidity = ValidatedFungibleHolding::new( + &fixture.context, + &fungible_holding(user_holding_lp_id, liquidity_definition_id(), 1_000), + &liquidity_token, + ) + .expect("user LP holding must validate"); + let tolerance = SlippageTolerance::new(100).expect("one percent is valid"); + let deadline = u64::MAX; + + let prepared_create = prepare_create_pool( + &fixture.context, + &token_a, + &token_b, + 4_000, + 9_000, + FEE_TIER_BPS_30, + ) + .expect("pool creation must prepare"); + let create_plan = plan_create_pool(CreatePoolPlanInput { + context: &fixture.context, + token_a_definition_id: token_a.account_id(), + token_b_definition_id: token_b.account_id(), + user_holding_a: user_holding_a_id, + user_holding_b: user_holding_b_id, + user_holding_lp: user_holding_lp_id, + token_a_amount: prepared_create.token_a_amount, + token_b_amount: prepared_create.token_b_amount, + fees: prepared_create.fees, + deadline, + }) + .expect("prepared create args must plan"); + assert!(matches!( + create_plan.instruction(), + Instruction::NewDefinition { + token_a_amount, + token_b_amount, + fees, + deadline: planned_deadline, + } if *token_a_amount == prepared_create.token_a_amount + && *token_b_amount == prepared_create.token_b_amount + && *fees == prepared_create.fees + && *planned_deadline == deadline + )); + + 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_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 + ); + let add_plan = plan_add_liquidity(AddLiquidityPlanInput { + context: &fixture.context, + pool, + user_holding_a: user_holding_a_id, + user_holding_b: user_holding_b_id, + user_holding_lp: user_holding_lp_id, + min_amount_liquidity: prepared_add.min_amount_liquidity, + max_amount_to_add_token_a: prepared_add.max_amount_to_add_token_a, + max_amount_to_add_token_b: prepared_add.max_amount_to_add_token_b, + deadline, + }); + assert!(matches!( + add_plan.instruction(), + Instruction::AddLiquidity { + min_amount_liquidity, + max_amount_to_add_token_a, + max_amount_to_add_token_b, + deadline: planned_deadline, + } if *min_amount_liquidity == prepared_add.min_amount_liquidity + && *max_amount_to_add_token_a == prepared_add.max_amount_to_add_token_a + && *max_amount_to_add_token_b == prepared_add.max_amount_to_add_token_b + && *planned_deadline == deadline + )); + + let prepared_remove = prepare_remove_liquidity(&snapshot, &user_liquidity, 500, tolerance) + .expect("remove liquidity must prepare"); + let remove_plan = plan_remove_liquidity(RemoveLiquidityPlanInput { + context: &fixture.context, + pool, + user_holding_a: user_holding_a_id, + user_holding_b: user_holding_b_id, + user_holding_lp: user_holding_lp_id, + remove_liquidity_amount: prepared_remove.remove_liquidity_amount, + min_amount_to_remove_token_a: prepared_remove.min_amount_to_remove_token_a, + min_amount_to_remove_token_b: prepared_remove.min_amount_to_remove_token_b, + deadline, + }); + assert!(matches!( + remove_plan.instruction(), + Instruction::RemoveLiquidity { + remove_liquidity_amount, + min_amount_to_remove_token_a, + min_amount_to_remove_token_b, + deadline: planned_deadline, + } if *remove_liquidity_amount == prepared_remove.remove_liquidity_amount + && *min_amount_to_remove_token_a == prepared_remove.min_amount_to_remove_token_a + && *min_amount_to_remove_token_b == prepared_remove.min_amount_to_remove_token_b + && *planned_deadline == deadline + )); + + let prepared_exact_input = + prepare_swap_exact_input(&snapshot, &user_a, &user_b, 100, tolerance) + .expect("exact-input swap must prepare"); + let exact_input_plan = plan_swap_exact_input(SwapExactInputPlanInput { + context: &fixture.context, + pool, + user_input_holding: user_holding_a_id, + user_output_holding: user_holding_b_id, + swap_amount_in: prepared_exact_input.swap_amount_in, + min_amount_out: prepared_exact_input.min_amount_out, + deadline, + }); + assert!(matches!( + exact_input_plan.instruction(), + Instruction::SwapExactInput { + swap_amount_in, + min_amount_out, + deadline: planned_deadline, + } if *swap_amount_in == prepared_exact_input.swap_amount_in + && *min_amount_out == prepared_exact_input.min_amount_out + && *planned_deadline == deadline + )); + + let prepared_exact_output = + prepare_swap_exact_output(&snapshot, &user_a, &user_b, 45, tolerance) + .expect("exact-output swap must prepare"); + let exact_output_plan = plan_swap_exact_output(SwapExactOutputPlanInput { + context: &fixture.context, + pool, + user_input_holding: user_holding_a_id, + user_output_holding: user_holding_b_id, + exact_amount_out: prepared_exact_output.exact_amount_out, + max_amount_in: prepared_exact_output.max_amount_in, + deadline, + }); + assert!(matches!( + exact_output_plan.instruction(), + Instruction::SwapExactOutput { + exact_amount_out, + max_amount_in, + deadline: planned_deadline, + } if *exact_amount_out == prepared_exact_output.exact_amount_out + && *max_amount_in == prepared_exact_output.max_amount_in + && *planned_deadline == deadline + )); +} diff --git a/programs/amm/client/tests/slippage_contract.rs b/programs/amm/client/tests/slippage_contract.rs new file mode 100644 index 0000000..df3afdb --- /dev/null +++ b/programs/amm/client/tests/slippage_contract.rs @@ -0,0 +1,91 @@ +use amm_client::{ + maximum_guard_amount, minimum_guard_amount, ClientError, SlippageTolerance, + SLIPPAGE_BPS_DENOMINATOR, +}; + +const ABOVE_TWO_POW_53: u128 = 9_007_199_254_740_993; + +#[test] +fn tolerance_accepts_closed_basis_point_range() { + assert_eq!(SlippageTolerance::new(0).expect("zero is valid").bps(), 0); + assert_eq!( + SlippageTolerance::new(SLIPPAGE_BPS_DENOMINATOR) + .expect("one hundred percent is valid") + .bps(), + SLIPPAGE_BPS_DENOMINATOR + ); + + let error = SlippageTolerance::new(SLIPPAGE_BPS_DENOMINATOR + 1) + .expect_err("more than one hundred percent must be rejected"); + assert_eq!(error.code(), "slippage_tolerance_out_of_range"); + assert!(matches!( + error, + ClientError::SlippageToleranceOutOfRange { + bps, + maximum_bps, + } if bps == 10_001 && maximum_bps == 10_000 + )); +} + +#[test] +fn minimum_guards_round_down_and_stay_executable() { + let one_percent = SlippageTolerance::new(100).expect("valid tolerance"); + assert_eq!(minimum_guard_amount(100, one_percent), Ok(99)); + assert_eq!(minimum_guard_amount(101, one_percent), Ok(99)); + assert_eq!(minimum_guard_amount(1, one_percent), Ok(1)); + assert_eq!( + minimum_guard_amount(1, SlippageTolerance::new(10_000).expect("valid tolerance")), + Ok(1) + ); + assert_eq!(minimum_guard_amount(0, one_percent), Ok(0)); +} + +#[test] +fn maximum_guards_round_up() { + let one_percent = SlippageTolerance::new(100).expect("valid tolerance"); + assert_eq!(maximum_guard_amount(100, one_percent), Ok(101)); + assert_eq!(maximum_guard_amount(101, one_percent), Ok(103)); + assert_eq!(maximum_guard_amount(0, one_percent), Ok(0)); +} + +#[test] +fn maximum_guard_reports_u128_overflow() { + let error = maximum_guard_amount( + u128::MAX, + SlippageTolerance::new(1).expect("valid tolerance"), + ) + .expect_err("expanded maximum must not saturate"); + + assert_eq!(error.code(), "slippage_bound_overflow"); + assert!(matches!( + error, + ClientError::SlippageBoundOverflow { + quoted_amount: u128::MAX, + slippage_bps: 1, + } + )); +} + +#[test] +fn guards_preserve_amounts_above_javascript_integer_range() { + let tolerance = SlippageTolerance::new(1).expect("valid tolerance"); + + assert_eq!( + minimum_guard_amount(ABOVE_TWO_POW_53, tolerance), + amm_core::checked_mul_div_floor(ABOVE_TWO_POW_53, 9_999, 10_000).ok_or( + ClientError::SlippageBoundOverflow { + quoted_amount: ABOVE_TWO_POW_53, + slippage_bps: 1, + } + ) + ); + assert_eq!( + maximum_guard_amount(ABOVE_TWO_POW_53, tolerance), + amm_core::checked_mul_div_ceil(ABOVE_TWO_POW_53, 10_001, 10_000).ok_or( + ClientError::SlippageBoundOverflow { + quoted_amount: ABOVE_TWO_POW_53, + slippage_bps: 1, + } + ) + ); +} diff --git a/programs/amm/client/tests/wire_prepare_contract.rs b/programs/amm/client/tests/wire_prepare_contract.rs new file mode 100644 index 0000000..ffd7a65 --- /dev/null +++ b/programs/amm/client/tests/wire_prepare_contract.rs @@ -0,0 +1,274 @@ +use amm_client::{maximum_guard_amount, minimum_guard_amount, wire::quote_json, SlippageTolerance}; +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda, + AmmConfig, PoolDefinition, FEE_TIER_BPS_30, +}; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use serde_json::{json, Value}; +use token_core::{TokenDefinition, TokenHolding}; + +const AMM_PROGRAM_ID: ProgramId = [42; 8]; +const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; +const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; + +fn account(program_owner: ProgramId, data: Data) -> Account { + Account { + program_owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +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 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, + }), + ) +} + +struct WireFixture { + token_a_id: AccountId, + token_b_id: AccountId, + config: Value, + state: Value, + user_a: Value, + user_b: Value, + user_lp: Value, +} + +impl WireFixture { + fn new() -> Self { + let token_a_id = AccountId::new([1; 32]); + let token_b_id = AccountId::new([2; 32]); + let pool_id = compute_pool_pda(AMM_PROGRAM_ID, token_a_id, token_b_id); + let vault_a_id = compute_vault_pda(AMM_PROGRAM_ID, pool_id, token_a_id); + let vault_b_id = compute_vault_pda(AMM_PROGRAM_ID, pool_id, token_b_id); + let liquidity_id = compute_liquidity_token_pda(AMM_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 config = snapshot( + compute_config_pda(AMM_PROGRAM_ID), + &account(AMM_PROGRAM_ID, Data::from(&config)), + ); + let pool = PoolDefinition { + definition_token_a_id: token_a_id, + definition_token_b_id: token_b_id, + 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, + }; + let state = json!({ + "ammProgramId": AMM_PROGRAM_ID, + "config": config, + "snapshot": { + "pool": snapshot(pool_id, &account(AMM_PROGRAM_ID, Data::from(&pool))), + "tokenADefinition": snapshot(token_a_id, &definition(100_000, None)), + "tokenBDefinition": snapshot(token_b_id, &definition(100_000, None)), + "vaultA": snapshot(vault_a_id, &holding(token_a_id, 1_100)), + "vaultB": snapshot(vault_b_id, &holding(token_b_id, 550)), + "liquidityDefinition": snapshot( + liquidity_id, + &definition(2_000, Some(liquidity_id)), + ), + }, + }); + + Self { + token_a_id, + token_b_id, + config, + state, + user_a: snapshot(AccountId::new([20; 32]), &holding(token_a_id, 10_000)), + user_b: snapshot(AccountId::new([21; 32]), &holding(token_b_id, 10_000)), + user_lp: snapshot(AccountId::new([22; 32]), &holding(liquidity_id, 1_000)), + } + } + + fn request(&self, operation: &str) -> Value { + let mut request = self.state.clone(); + insert( + &mut request, + "operation", + Value::String(String::from(operation)), + ); + request + } +} + +fn insert(object: &mut Value, field: &str, value: Value) { + drop( + object + .as_object_mut() + .expect("fixture request must be an object") + .insert(String::from(field), value), + ); +} + +fn decimal(value: &Value) -> u128 { + value + .as_str() + .expect("chain amounts must be JSON strings") + .parse() + .expect("chain amounts must be decimal u128") +} + +#[test] +fn prepare_wire_operations_return_lossless_instruction_args() { + let fixture = WireFixture::new(); + let tolerance = SlippageTolerance::new(100).expect("one percent is valid"); + let large = 9_007_199_254_740_993_u128; + + let create = quote_json(json!({ + "operation": "prepare_create_pool", + "ammProgramId": AMM_PROGRAM_ID, + "config": fixture.config.clone(), + "tokenADefinition": snapshot(fixture.token_a_id, &definition(100_000, None)), + "tokenBDefinition": snapshot(fixture.token_b_id, &definition(100_000, None)), + "tokenAAmount": large.to_string(), + "tokenBAmount": large.to_string(), + "feeBps": FEE_TIER_BPS_30.to_string(), + })) + .expect("create pool must prepare"); + assert_eq!(create["instructionArgs"]["tokenAAmount"], large.to_string()); + assert_eq!(create["instructionArgs"]["tokenBAmount"], large.to_string()); + assert_eq!(create["instructionArgs"]["fees"], "30"); + + let mut add_request = fixture.request("prepare_add_liquidity"); + insert(&mut add_request, "maxAmountA", json!("400")); + insert(&mut add_request, "maxAmountB", json!("100")); + insert(&mut add_request, "slippageBps", json!("100")); + let add = quote_json(add_request).expect("add liquidity must prepare"); + assert_eq!( + decimal(&add["instructionArgs"]["minAmountLiquidity"]), + minimum_guard_amount(decimal(&add["quote"]["liquidityToMint"]), tolerance) + .expect("minimum LP guard must fit") + ); + assert_eq!(add["instructionArgs"]["maxAmountToAddTokenA"], "200"); + assert_eq!(add["instructionArgs"]["maxAmountToAddTokenB"], "100"); + + let mut remove_request = fixture.request("prepare_remove_liquidity"); + insert( + &mut remove_request, + "userLiquidityHolding", + fixture.user_lp.clone(), + ); + insert(&mut remove_request, "removeLiquidityAmount", json!("500")); + insert(&mut remove_request, "slippageBps", json!("100")); + let remove = quote_json(remove_request).expect("remove liquidity must prepare"); + assert_eq!(remove["instructionArgs"]["removeLiquidityAmount"], "500"); + assert_eq!( + decimal(&remove["instructionArgs"]["minAmountToRemoveTokenA"]), + minimum_guard_amount(decimal(&remove["quote"]["withdrawAmountA"]), tolerance) + .expect("minimum A guard must fit") + ); + assert_eq!( + decimal(&remove["instructionArgs"]["minAmountToRemoveTokenB"]), + minimum_guard_amount(decimal(&remove["quote"]["withdrawAmountB"]), tolerance) + .expect("minimum B guard must fit") + ); + + let mut exact_input_request = fixture.request("prepare_swap_exact_input"); + insert( + &mut exact_input_request, + "userInputHolding", + fixture.user_a.clone(), + ); + insert( + &mut exact_input_request, + "userOutputHolding", + fixture.user_b.clone(), + ); + insert( + &mut exact_input_request, + "inputTokenDefinitionId", + json!(fixture.token_a_id.to_string()), + ); + insert(&mut exact_input_request, "amountIn", json!("100")); + insert(&mut exact_input_request, "slippageBps", json!("100")); + let exact_input = quote_json(exact_input_request).expect("exact-input swap must prepare"); + assert_eq!(exact_input["instructionArgs"]["swapAmountIn"], "100"); + assert_eq!( + decimal(&exact_input["instructionArgs"]["minAmountOut"]), + minimum_guard_amount(decimal(&exact_input["quote"]["amountOut"]), tolerance) + .expect("minimum output guard must fit") + ); + + let mut exact_output_request = fixture.request("prepare_swap_exact_output"); + insert( + &mut exact_output_request, + "userInputHolding", + fixture.user_a, + ); + insert( + &mut exact_output_request, + "userOutputHolding", + fixture.user_b, + ); + insert( + &mut exact_output_request, + "inputTokenDefinitionId", + json!(fixture.token_a_id.to_string()), + ); + insert(&mut exact_output_request, "exactAmountOut", json!("45")); + insert(&mut exact_output_request, "slippageBps", json!("100")); + let exact_output = quote_json(exact_output_request).expect("exact-output swap must prepare"); + assert_eq!(exact_output["instructionArgs"]["exactAmountOut"], "45"); + assert_eq!( + decimal(&exact_output["instructionArgs"]["maxAmountIn"]), + maximum_guard_amount(decimal(&exact_output["quote"]["amountIn"]), tolerance) + .expect("maximum input guard must fit") + ); +} + +#[test] +fn prepare_wire_rejects_out_of_range_slippage() { + let fixture = WireFixture::new(); + let mut request = fixture.request("prepare_add_liquidity"); + insert(&mut request, "maxAmountA", json!("400")); + insert(&mut request, "maxAmountB", json!("100")); + insert(&mut request, "slippageBps", json!("10001")); + + let error = quote_json(request).expect_err("invalid slippage must be rejected"); + assert_eq!(error.code(), "slippage_tolerance_out_of_range"); +} diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index 3faa301..25e5b93 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -257,7 +257,7 @@ pub const FEE_TIER_BPS_5: u128 = 5; pub const FEE_TIER_BPS_30: u128 = 30; pub const FEE_TIER_BPS_100: u128 = 100; /// Fee tiers accepted by pool creation and all initialized-pool operations. -pub const SUPPORTED_FEE_TIERS: [u128; 4] = [ +pub const SUPPORTED_FEE_TIERS: &[u128] = &[ FEE_TIER_BPS_1, FEE_TIER_BPS_5, FEE_TIER_BPS_30, diff --git a/programs/amm/src/quote.rs b/programs/amm/src/quote.rs index 917beae..6e29a94 100644 --- a/programs/amm/src/quote.rs +++ b/programs/amm/src/quote.rs @@ -14,25 +14,150 @@ use amm_core::{ use nssa_core::account::AccountId; use twap_oracle_core::OBSERVATIONS_CAPACITY; +/// Stable categories for quote failures. +/// +/// Consumers matching this enum must retain a fallback because new categories may be added as the +/// quote surface grows. [`QuoteErrorCode::as_str`] provides the stable API/FFI representation. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum QuoteErrorCode { + /// A checked amount calculation exceeded its representable range. + ArithmeticOverflow, + /// A proportional liquidity deposit rounded to zero. + DepositAmountZero, + /// A swap input rounded to zero after fees. + EffectiveSwapInputZero, + /// An exact-output request would consume the output reserve. + ExactOutputExceedsReserve, + /// An exact-output request was zero. + ExactOutputZero, + /// Initial liquidity did not exceed the permanent lock. + InitialLiquidityTooLow, + /// The selected swap input token is not in the pool. + InputTokenNotInPool, + /// The supplied LP balance is inconsistent with pool supply. + InvalidLiquidityAccount, + /// Pool LP supply is below the permanent lock. + LiquiditySupplyBelowMinimum, + /// At least one maximum liquidity deposit was zero. + MaximumDepositZero, + /// The minimum LP output guard was zero. + MinimumLiquidityZero, + /// At least one minimum withdrawal guard was zero. + MinimumWithdrawalZero, + /// Minted liquidity was below the caller's minimum. + MintedLiquidityBelowMinimum, + /// Minted liquidity rounded to zero. + MintedLiquidityZero, + /// The derived oracle price was the no-price sentinel. + OraclePriceZero, + /// The requested oracle window cannot hold the observation capacity. + OracleWindowTooShort, + /// A withdrawal was attempted from a pool containing only locked liquidity. + PoolContainsOnlyLockedLiquidity, + /// A withdrawal would consume permanently locked liquidity. + RemoveAmountExceedsUnlockedLiquidity, + /// A withdrawal exceeds the caller's LP balance. + RemoveAmountExceedsUserBalance, + /// The requested LP withdrawal was zero. + RemoveLiquidityAmountZero, + /// Exact-output input exceeded the caller's maximum. + RequiredInputExceedsMaximum, + /// Token-A reserve was zero where a spot price was required. + ReserveAZero, + /// At least one pool reserve was zero. + ReserveZero, + /// Exact-input output was below the caller's minimum. + SwapOutputBelowMinimum, + /// Swap output rounded to zero. + SwapOutputZero, + /// Initial token-A liquidity was zero. + TokenAAmountZero, + /// Initial token-B liquidity was zero. + TokenBAmountZero, + /// A token pair does not match the pool. + TokenPairNotInPool, + /// A pool fee is not one of the canonical tiers. + UnsupportedFeeTier, + /// Token-A vault balance is below the tracked reserve. + VaultABalanceBelowReserve, + /// Token-B vault balance is below the tracked reserve. + VaultBBalanceBelowReserve, + /// Token-A withdrawal was below the caller's minimum. + WithdrawalABelowMinimum, + /// Token-B withdrawal was below the caller's minimum. + WithdrawalBBelowMinimum, +} + +impl QuoteErrorCode { + /// Returns the stable machine-readable representation. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ArithmeticOverflow => "arithmetic_overflow", + Self::DepositAmountZero => "deposit_amount_zero", + Self::EffectiveSwapInputZero => "effective_swap_input_zero", + Self::ExactOutputExceedsReserve => "exact_output_exceeds_reserve", + Self::ExactOutputZero => "exact_output_zero", + Self::InitialLiquidityTooLow => "initial_liquidity_too_low", + Self::InputTokenNotInPool => "input_token_not_in_pool", + Self::InvalidLiquidityAccount => "invalid_liquidity_account", + Self::LiquiditySupplyBelowMinimum => "liquidity_supply_below_minimum", + Self::MaximumDepositZero => "maximum_deposit_zero", + Self::MinimumLiquidityZero => "minimum_liquidity_zero", + Self::MinimumWithdrawalZero => "minimum_withdrawal_zero", + Self::MintedLiquidityBelowMinimum => "minted_liquidity_below_minimum", + Self::MintedLiquidityZero => "minted_liquidity_zero", + Self::OraclePriceZero => "oracle_price_zero", + Self::OracleWindowTooShort => "oracle_window_too_short", + Self::PoolContainsOnlyLockedLiquidity => "pool_contains_only_locked_liquidity", + Self::RemoveAmountExceedsUnlockedLiquidity => { + "remove_amount_exceeds_unlocked_liquidity" + } + Self::RemoveAmountExceedsUserBalance => "remove_amount_exceeds_user_balance", + Self::RemoveLiquidityAmountZero => "remove_liquidity_amount_zero", + Self::RequiredInputExceedsMaximum => "required_input_exceeds_maximum", + Self::ReserveAZero => "reserve_a_zero", + Self::ReserveZero => "reserve_zero", + Self::SwapOutputBelowMinimum => "swap_output_below_minimum", + Self::SwapOutputZero => "swap_output_zero", + Self::TokenAAmountZero => "token_a_amount_zero", + Self::TokenBAmountZero => "token_b_amount_zero", + Self::TokenPairNotInPool => "token_pair_not_in_pool", + Self::UnsupportedFeeTier => "unsupported_fee_tier", + Self::VaultABalanceBelowReserve => "vault_a_balance_below_reserve", + Self::VaultBBalanceBelowReserve => "vault_b_balance_below_reserve", + Self::WithdrawalABelowMinimum => "withdrawal_a_below_minimum", + Self::WithdrawalBBelowMinimum => "withdrawal_b_below_minimum", + } + } +} + /// A stable, machine-readable quote failure with its program-facing message. /// -/// Consumers should branch on [`QuoteError::code`] and treat [`QuoteError::message`] as display or -/// diagnostic text. New codes may be added without changing this type's layout. +/// Consumers should branch on [`QuoteError::kind`] or [`QuoteError::code`] and treat +/// [`QuoteError::message`] as display or diagnostic text. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct QuoteError { - code: &'static str, + kind: QuoteErrorCode, message: &'static str, } impl QuoteError { - const fn new(code: &'static str, message: &'static str) -> Self { - Self { code, message } + const fn new(kind: QuoteErrorCode, message: &'static str) -> Self { + Self { kind, message } + } + + /// Returns the typed error category. + #[must_use] + pub const fn kind(&self) -> QuoteErrorCode { + self.kind } /// Returns the stable machine-readable error code. #[must_use] pub const fn code(&self) -> &'static str { - self.code + self.kind.as_str() } /// Returns the program-facing failure message. @@ -94,7 +219,7 @@ pub fn pair_order( Ok(PairOrder::Reversed) } else { Err(QuoteError::new( - "token_pair_not_in_pool", + QuoteErrorCode::TokenPairNotInPool, "Token pair does not match the pool", )) } @@ -120,13 +245,14 @@ pub fn swap_direction( Ok(SwapDirection::BToA) } else { Err(QuoteError::new( - "input_token_not_in_pool", + QuoteErrorCode::InputTokenNotInPool, "Input token is not part of the pool", )) } } /// Pool scalar values after a quoted operation. +#[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct PoolUpdate { /// Total LP supply after the operation. @@ -153,6 +279,7 @@ impl PoolUpdate { } /// Result of creating a pool's initial liquidity position. +#[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct CreatePoolQuote { /// Initial pool scalar values. @@ -171,13 +298,13 @@ pub fn create_pool( ) -> Result { if token_a_amount == 0 { return Err(QuoteError::new( - "token_a_amount_zero", + QuoteErrorCode::TokenAAmountZero, "token_a_amount must be nonzero", )); } if token_b_amount == 0 { return Err(QuoteError::new( - "token_b_amount_zero", + QuoteErrorCode::TokenBAmountZero, "token_b_amount must be nonzero", )); } @@ -186,7 +313,7 @@ pub fn create_pool( let initial_liquidity = isqrt_product(token_a_amount, token_b_amount); if initial_liquidity <= MINIMUM_LIQUIDITY { return Err(QuoteError::new( - "initial_liquidity_too_low", + QuoteErrorCode::InitialLiquidityTooLow, "Initial liquidity must exceed minimum liquidity lock", )); } @@ -194,7 +321,7 @@ pub fn create_pool( .checked_sub(MINIMUM_LIQUIDITY) .ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "initial liquidity must exceed minimum liquidity after validation", ) })?; @@ -208,6 +335,7 @@ pub fn create_pool( } /// Result of adding liquidity to an initialized pool. +#[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct AddLiquidityQuote { /// Token-A amount transferred into the pool. @@ -253,13 +381,13 @@ pub fn add_liquidity( ensure_supported_fee_tier(pool.fees)?; if minimum_liquidity == 0 { return Err(QuoteError::new( - "minimum_liquidity_zero", + QuoteErrorCode::MinimumLiquidityZero, "min_amount_liquidity must be nonzero", )); } if max_amount_a == 0 || max_amount_b == 0 { return Err(QuoteError::new( - "maximum_deposit_zero", + QuoteErrorCode::MaximumDepositZero, "Both max-balances must be nonzero", )); } @@ -271,7 +399,10 @@ pub fn add_liquidity( "Vaults' balances must be at least the reserve amounts", )?; if pool.reserve_a == 0 || pool.reserve_b == 0 { - return Err(QuoteError::new("reserve_zero", "Reserves must be nonzero")); + return Err(QuoteError::new( + QuoteErrorCode::ReserveZero, + "Reserves must be nonzero", + )); } let ideal_a = checked_floor( @@ -290,7 +421,7 @@ pub fn add_liquidity( let actual_amount_b = max_amount_b.min(ideal_b); if actual_amount_a == 0 || actual_amount_b == 0 { return Err(QuoteError::new( - "deposit_amount_zero", + QuoteErrorCode::DepositAmountZero, "A trade amount is 0", )); } @@ -310,13 +441,13 @@ pub fn add_liquidity( let liquidity_to_mint = liquidity_from_a.min(liquidity_from_b); if liquidity_to_mint == 0 { return Err(QuoteError::new( - "minted_liquidity_zero", + QuoteErrorCode::MintedLiquidityZero, "Payable LP must be nonzero", )); } if liquidity_to_mint < minimum_liquidity { return Err(QuoteError::new( - "minted_liquidity_below_minimum", + QuoteErrorCode::MintedLiquidityBelowMinimum, "Payable LP is less than provided minimum LP amount", )); } @@ -326,19 +457,19 @@ pub fn add_liquidity( .checked_add(liquidity_to_mint) .ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "liquidity_pool_supply + delta_lp overflows u128", ) })?; let reserve_a = pool.reserve_a.checked_add(actual_amount_a).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve_a + actual_amount_a overflows u128", ) })?; let reserve_b = pool.reserve_b.checked_add(actual_amount_b).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve_b + actual_amount_b overflows u128", ) })?; @@ -352,6 +483,7 @@ pub fn add_liquidity( } /// Result of removing liquidity from a pool. +#[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct RemoveLiquidityQuote { /// Token-A amount withdrawn from the pool. @@ -387,37 +519,37 @@ pub fn remove_liquidity( ensure_supported_fee_tier(pool.fees)?; if pool.liquidity_pool_supply < MINIMUM_LIQUIDITY { return Err(QuoteError::new( - "liquidity_supply_below_minimum", + QuoteErrorCode::LiquiditySupplyBelowMinimum, "Pool liquidity supply is below minimum liquidity", )); } if minimum_amount_a == 0 || minimum_amount_b == 0 { return Err(QuoteError::new( - "minimum_withdrawal_zero", + QuoteErrorCode::MinimumWithdrawalZero, "Minimum withdraw amount must be nonzero", )); } if user_liquidity_balance > pool.liquidity_pool_supply { return Err(QuoteError::new( - "invalid_liquidity_account", + QuoteErrorCode::InvalidLiquidityAccount, "Invalid liquidity account provided", )); } if pool.liquidity_pool_supply == MINIMUM_LIQUIDITY { return Err(QuoteError::new( - "pool_contains_only_locked_liquidity", + QuoteErrorCode::PoolContainsOnlyLockedLiquidity, "Pool only contains locked liquidity", )); } if remove_liquidity_amount == 0 { return Err(QuoteError::new( - "remove_liquidity_amount_zero", + QuoteErrorCode::RemoveLiquidityAmountZero, "remove_liquidity_amount must be nonzero", )); } if remove_liquidity_amount > user_liquidity_balance { return Err(QuoteError::new( - "remove_amount_exceeds_user_balance", + QuoteErrorCode::RemoveAmountExceedsUserBalance, "Remove amount exceeds user LP balance", )); } @@ -426,13 +558,13 @@ pub fn remove_liquidity( .checked_sub(MINIMUM_LIQUIDITY) .ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "liquidity supply must be at least the locked minimum after validation", ) })?; if remove_liquidity_amount > unlocked_liquidity { return Err(QuoteError::new( - "remove_amount_exceeds_unlocked_liquidity", + QuoteErrorCode::RemoveAmountExceedsUnlockedLiquidity, "Cannot remove locked minimum liquidity", )); } @@ -451,13 +583,13 @@ pub fn remove_liquidity( )?; if withdraw_amount_a < minimum_amount_a { return Err(QuoteError::new( - "withdrawal_a_below_minimum", + QuoteErrorCode::WithdrawalABelowMinimum, "Insufficient minimal withdraw amount (Token A) provided for liquidity amount", )); } if withdraw_amount_b < minimum_amount_b { return Err(QuoteError::new( - "withdrawal_b_below_minimum", + QuoteErrorCode::WithdrawalBBelowMinimum, "Insufficient minimal withdraw amount (Token B) provided for liquidity amount", )); } @@ -467,7 +599,7 @@ pub fn remove_liquidity( .checked_sub(remove_liquidity_amount) .ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "liquidity_pool_supply - delta_lp underflows", ) })?; @@ -476,7 +608,7 @@ pub fn remove_liquidity( .checked_sub(withdraw_amount_a) .ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve_a - withdraw_amount_a underflows", ) })?; @@ -485,7 +617,7 @@ pub fn remove_liquidity( .checked_sub(withdraw_amount_b) .ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve_b - withdraw_amount_b underflows", ) })?; @@ -499,6 +631,7 @@ pub fn remove_liquidity( } /// Result of either exact-input or exact-output swap quoting. +#[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct SwapQuote { /// Direction relative to stored pool order. @@ -556,13 +689,13 @@ pub fn swap_exact_input( )?; if effective_amount_in == 0 { return Err(QuoteError::new( - "effective_swap_input_zero", + QuoteErrorCode::EffectiveSwapInputZero, "Effective swap amount should be nonzero", )); } let reserve_plus_effective = reserve_in.checked_add(effective_amount_in).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve + effective_amount_in overflows u128", ) })?; @@ -574,13 +707,13 @@ pub fn swap_exact_input( )?; if amount_out < minimum_amount_out { return Err(QuoteError::new( - "swap_output_below_minimum", + QuoteErrorCode::SwapOutputBelowMinimum, "Withdraw amount is less than minimal amount out", )); } if amount_out == 0 { return Err(QuoteError::new( - "swap_output_zero", + QuoteErrorCode::SwapOutputZero, "Withdraw amount should be nonzero", )); } @@ -621,7 +754,7 @@ pub fn swap_exact_output( validate_swap_pool(pool, vault_a_balance, vault_b_balance)?; if exact_amount_out == 0 { return Err(QuoteError::new( - "exact_output_zero", + QuoteErrorCode::ExactOutputZero, "Exact amount out must be nonzero", )); } @@ -629,13 +762,16 @@ pub fn swap_exact_output( let (reserve_in, reserve_out) = directional_reserves(pool, direction); if exact_amount_out >= reserve_out { return Err(QuoteError::new( - "exact_output_exceeds_reserve", + QuoteErrorCode::ExactOutputExceedsReserve, "Exact amount out exceeds reserve", )); } let effective_input_denominator = reserve_out.checked_sub(exact_amount_out).ok_or_else(|| { - QuoteError::new("arithmetic_overflow", "reserve_out - amount_out underflows") + QuoteError::new( + QuoteErrorCode::ArithmeticOverflow, + "reserve_out - amount_out underflows", + ) })?; let minimum_effective_input = checked_ceil( reserve_in, @@ -652,7 +788,7 @@ pub fn swap_exact_output( )?; if amount_in > maximum_amount_in { return Err(QuoteError::new( - "required_input_exceeds_maximum", + QuoteErrorCode::RequiredInputExceedsMaximum, "Required input exceeds maximum amount in", )); } @@ -673,6 +809,7 @@ pub fn swap_exact_output( } /// Result of synchronizing stored reserves to vault balances. +#[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct SyncReservesQuote { /// Untracked token-A balance incorporated into the reserve. @@ -692,7 +829,7 @@ pub fn sync_reserves( ensure_supported_fee_tier(pool.fees)?; if pool.liquidity_pool_supply < MINIMUM_LIQUIDITY { return Err(QuoteError::new( - "liquidity_supply_below_minimum", + QuoteErrorCode::LiquiditySupplyBelowMinimum, "Pool liquidity supply is below minimum liquidity", )); } @@ -705,13 +842,13 @@ pub fn sync_reserves( )?; let donated_amount_a = vault_a_balance.checked_sub(pool.reserve_a).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "vault A balance - reserve A underflows", ) })?; let donated_amount_b = vault_b_balance.checked_sub(pool.reserve_b).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "vault B balance - reserve B underflows", ) })?; @@ -724,6 +861,7 @@ pub fn sync_reserves( } /// Values used to initialize a pool-backed TWAP oracle price account. +#[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct OraclePriceAccountQuote { /// Pool token A, used as the oracle base asset. @@ -743,20 +881,20 @@ pub fn create_oracle_price_account( ) -> Result { if window_duration < u64::from(OBSERVATIONS_CAPACITY) { return Err(QuoteError::new( - "oracle_window_too_short", + QuoteErrorCode::OracleWindowTooShort, "Create oracle price account: window_duration must be >= OBSERVATIONS_CAPACITY so a matching PriceObservations account can exist and PublishPrice can update this price account", )); } if pool.reserve_a == 0 { return Err(QuoteError::new( - "reserve_a_zero", + QuoteErrorCode::ReserveAZero, "spot_price_q64_64: reserve_base must be non-zero", )); } let initial_price_q64_64 = spot_price_q64_64(pool.reserve_a, pool.reserve_b); if initial_price_q64_64 == 0 { return Err(QuoteError::new( - "oracle_price_zero", + QuoteErrorCode::OraclePriceZero, "Create oracle price account: pool spot price must be non-zero (zero is the no-price sentinel; pool reserve_b is zero or negligible relative to reserve_a)", )); } @@ -774,7 +912,7 @@ fn ensure_supported_fee_tier(fee_bps: u128) -> Result<(), QuoteError> { Ok(()) } else { Err(QuoteError::new( - "unsupported_fee_tier", + QuoteErrorCode::UnsupportedFeeTier, "Fee tier must be one of 1, 5, 30, or 100 basis points", )) } @@ -789,13 +927,13 @@ fn ensure_vault_balances( ) -> Result<(), QuoteError> { if vault_a_balance < pool.reserve_a { return Err(QuoteError::new( - "vault_a_balance_below_reserve", + QuoteErrorCode::VaultABalanceBelowReserve, vault_a_message, )); } if vault_b_balance < pool.reserve_b { return Err(QuoteError::new( - "vault_b_balance_below_reserve", + QuoteErrorCode::VaultBBalanceBelowReserve, vault_b_message, )); } @@ -811,7 +949,7 @@ fn validate_swap_pool( ensure_supported_fee_tier(pool.fees)?; if pool.liquidity_pool_supply < MINIMUM_LIQUIDITY { return Err(QuoteError::new( - "liquidity_supply_below_minimum", + QuoteErrorCode::LiquiditySupplyBelowMinimum, "Pool liquidity supply is below minimum liquidity", )); } @@ -840,7 +978,7 @@ fn finish_swap_quote( ) -> Result { let fee_amount = amount_in.checked_sub(effective_amount_in).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "gross input - effective input underflows", ) })?; @@ -848,13 +986,13 @@ fn finish_swap_quote( SwapDirection::AToB => ( pool.reserve_a.checked_add(amount_in).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve_a + deposit_a overflows u128", ) })?, pool.reserve_b.checked_sub(amount_out).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve_b + deposit_b - withdraw_b underflows", ) })?, @@ -862,13 +1000,13 @@ fn finish_swap_quote( SwapDirection::BToA => ( pool.reserve_a.checked_sub(amount_out).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve_a + deposit_a - withdraw_a underflows", ) })?, pool.reserve_b.checked_add(amount_in).ok_or_else(|| { QuoteError::new( - "arithmetic_overflow", + QuoteErrorCode::ArithmeticOverflow, "reserve_b + deposit_b overflows u128", ) })?, @@ -886,9 +1024,12 @@ fn finish_swap_quote( } fn fee_multiplier(fee_bps: u128) -> Result { - FEE_BPS_DENOMINATOR - .checked_sub(fee_bps) - .ok_or_else(|| QuoteError::new("unsupported_fee_tier", "fee_bps exceeds fee denominator")) + FEE_BPS_DENOMINATOR.checked_sub(fee_bps).ok_or_else(|| { + QuoteError::new( + QuoteErrorCode::UnsupportedFeeTier, + "fee_bps exceeds fee denominator", + ) + }) } fn pool_update( @@ -898,7 +1039,7 @@ fn pool_update( ) -> Result { if reserve_a == 0 { return Err(QuoteError::new( - "reserve_a_zero", + QuoteErrorCode::ReserveAZero, "spot_price_q64_64: reserve_base must be non-zero", )); } @@ -918,7 +1059,7 @@ fn checked_floor( overflow_message: &'static str, ) -> Result { checked_mul_div_floor(left, right, denominator) - .ok_or_else(|| QuoteError::new("arithmetic_overflow", overflow_message)) + .ok_or_else(|| QuoteError::new(QuoteErrorCode::ArithmeticOverflow, overflow_message)) } fn checked_ceil( @@ -928,5 +1069,5 @@ fn checked_ceil( overflow_message: &'static str, ) -> Result { checked_mul_div_ceil(left, right, denominator) - .ok_or_else(|| QuoteError::new("arithmetic_overflow", overflow_message)) + .ok_or_else(|| QuoteError::new(QuoteErrorCode::ArithmeticOverflow, overflow_message)) } diff --git a/programs/amm/tests/quote_api.rs b/programs/amm/tests/quote_api.rs index c8879d7..ccd7708 100644 --- a/programs/amm/tests/quote_api.rs +++ b/programs/amm/tests/quote_api.rs @@ -1,9 +1,9 @@ use amm_program::{ - core::{spot_price_q64_64, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY}, - quote::{ - self, AddLiquidityQuote, CreatePoolQuote, PairOrder, PoolUpdate, RemoveLiquidityQuote, - SwapDirection, SwapQuote, SyncReservesQuote, + core::{ + spot_price_q64_64, PoolDefinition, FEE_TIER_BPS_1, FEE_TIER_BPS_100, FEE_TIER_BPS_30, + FEE_TIER_BPS_5, MINIMUM_LIQUIDITY, SUPPORTED_FEE_TIERS, }, + quote::{self, PairOrder, PoolUpdate, QuoteErrorCode, SwapDirection}, }; use nssa_core::account::AccountId; use twap_oracle_core::OBSERVATIONS_CAPACITY; @@ -30,39 +30,63 @@ fn pool() -> PoolDefinition { } } +fn assert_pool_update( + update: PoolUpdate, + liquidity_pool_supply: u128, + reserve_a: u128, + reserve_b: u128, +) { + assert_eq!(update.liquidity_pool_supply, liquidity_pool_supply); + assert_eq!(update.reserve_a, reserve_a); + assert_eq!(update.reserve_b, reserve_b); + assert_eq!( + update.spot_price_q64_64, + spot_price_q64_64(reserve_a, reserve_b) + ); +} + #[test] fn create_pool_quotes_locked_and_user_liquidity() { + let quoted = quote::create_pool(4_000, 9_000, FEE_TIER_BPS_30) + .expect("valid initial liquidity should quote"); + + assert_pool_update(quoted.pool, 6_000, 4_000, 9_000); + assert_eq!(quoted.locked_liquidity, MINIMUM_LIQUIDITY); + assert_eq!(quoted.user_liquidity, 5_000); +} + +#[test] +fn create_pool_quote_preserves_spot_price_saturation() { + let quoted = quote::create_pool(1, u128::MAX, FEE_TIER_BPS_30) + .expect("spot-price range overflow should saturate, not reject the amount quote"); + + assert_eq!(quoted.pool.spot_price_q64_64, u128::MAX); +} + +#[test] +fn supported_fee_tiers_are_exposed_as_a_slice() { + let tiers: &[u128] = SUPPORTED_FEE_TIERS; + assert_eq!( - quote::create_pool(4_000, 9_000, FEE_TIER_BPS_30), - Ok(CreatePoolQuote { - pool: PoolUpdate { - liquidity_pool_supply: 6_000, - reserve_a: 4_000, - reserve_b: 9_000, - spot_price_q64_64: spot_price_q64_64(4_000, 9_000), - }, - locked_liquidity: MINIMUM_LIQUIDITY, - user_liquidity: 5_000, - }) + tiers, + &[ + FEE_TIER_BPS_1, + FEE_TIER_BPS_5, + FEE_TIER_BPS_30, + FEE_TIER_BPS_100, + ] ); } #[test] fn add_liquidity_quotes_program_rounding_and_post_pool() { - assert_eq!( - quote::add_liquidity(&pool(), 1_000, 500, 400, 100, 399), - Ok(AddLiquidityQuote { - actual_amount_a: 200, - actual_amount_b: 100, - liquidity_to_mint: 400, - pool: PoolUpdate { - liquidity_pool_supply: 2_400, - reserve_a: 1_200, - reserve_b: 600, - spot_price_q64_64: spot_price_q64_64(1_200, 600), - }, - }) - ); + let quoted = quote::add_liquidity(&pool(), 1_000, 500, 400, 100, 399) + .expect("valid proportional deposit should quote"); + + assert_eq!(quoted.actual_amount_a, 200); + assert_eq!(quoted.actual_amount_b, 100); + assert_eq!(quoted.liquidity_to_mint, 400); + assert_pool_update(quoted.pool, 2_400, 1_200, 600); } #[test] @@ -86,83 +110,52 @@ fn preview_helpers_return_amounts_before_client_slippage_policy() { #[test] fn remove_liquidity_quotes_program_rounding_and_post_pool() { - assert_eq!( - quote::remove_liquidity(&pool(), 1_000, 500, 250, 125), - Ok(RemoveLiquidityQuote { - withdraw_amount_a: 250, - withdraw_amount_b: 125, - liquidity_to_burn: 500, - pool: PoolUpdate { - liquidity_pool_supply: 1_500, - reserve_a: 750, - reserve_b: 375, - spot_price_q64_64: spot_price_q64_64(750, 375), - }, - }) - ); + let quoted = quote::remove_liquidity(&pool(), 1_000, 500, 250, 125) + .expect("valid proportional withdrawal should quote"); + + assert_eq!(quoted.withdraw_amount_a, 250); + assert_eq!(quoted.withdraw_amount_b, 125); + assert_eq!(quoted.liquidity_to_burn, 500); + assert_pool_update(quoted.pool, 1_500, 750, 375); } #[test] fn exact_input_and_output_quotes_share_the_same_boundary() { - let expected = SwapQuote { - direction: SwapDirection::AToB, - amount_in: 100, - effective_amount_in: 99, - fee_amount: 1, - amount_out: 45, - pool: PoolUpdate { - liquidity_pool_supply: 2_000, - reserve_a: 1_100, - reserve_b: 455, - spot_price_q64_64: spot_price_q64_64(1_100, 455), - }, - }; + let exact_input = quote::swap_exact_input(&pool(), 1_000, 500, SwapDirection::AToB, 100, 45) + .expect("valid exact-input trade should quote"); + let exact_output = quote::swap_exact_output(&pool(), 1_000, 500, SwapDirection::AToB, 45, 100) + .expect("valid exact-output trade should quote"); - assert_eq!( - quote::swap_exact_input(&pool(), 1_000, 500, SwapDirection::AToB, 100, 45), - Ok(expected) - ); - assert_eq!( - quote::swap_exact_output(&pool(), 1_000, 500, SwapDirection::AToB, 45, 100), - Ok(expected) - ); + assert_eq!(exact_input, exact_output); + assert_eq!(exact_input.direction, SwapDirection::AToB); + assert_eq!(exact_input.amount_in, 100); + assert_eq!(exact_input.effective_amount_in, 99); + assert_eq!(exact_input.fee_amount, 1); + assert_eq!(exact_input.amount_out, 45); + assert_pool_update(exact_input.pool, 2_000, 1_100, 455); } #[test] fn reverse_swap_quote_keeps_pool_updates_in_stored_order() { - assert_eq!( - quote::swap_exact_input(&pool(), 1_000, 500, SwapDirection::BToA, 100, 165), - Ok(SwapQuote { - direction: SwapDirection::BToA, - amount_in: 100, - effective_amount_in: 99, - fee_amount: 1, - amount_out: 165, - pool: PoolUpdate { - liquidity_pool_supply: 2_000, - reserve_a: 835, - reserve_b: 600, - spot_price_q64_64: spot_price_q64_64(835, 600), - }, - }) - ); + let quoted = quote::swap_exact_input(&pool(), 1_000, 500, SwapDirection::BToA, 100, 165) + .expect("valid reverse trade should quote"); + + assert_eq!(quoted.direction, SwapDirection::BToA); + assert_eq!(quoted.amount_in, 100); + assert_eq!(quoted.effective_amount_in, 99); + assert_eq!(quoted.fee_amount, 1); + assert_eq!(quoted.amount_out, 165); + assert_pool_update(quoted.pool, 2_000, 835, 600); } #[test] fn sync_reserves_reports_donations_and_post_pool() { - assert_eq!( - quote::sync_reserves(&pool(), 1_100, 550), - Ok(SyncReservesQuote { - donated_amount_a: 100, - donated_amount_b: 50, - pool: PoolUpdate { - liquidity_pool_supply: 2_000, - reserve_a: 1_100, - reserve_b: 550, - spot_price_q64_64: spot_price_q64_64(1_100, 550), - }, - }) - ); + let quoted = quote::sync_reserves(&pool(), 1_100, 550) + .expect("vault donations above reserves should quote"); + + assert_eq!(quoted.donated_amount_a, 100); + assert_eq!(quoted.donated_amount_b, 50); + assert_pool_update(quoted.pool, 2_000, 1_100, 550); } #[test] @@ -204,6 +197,7 @@ fn quote_errors_expose_stable_machine_codes() { let error = quote::add_liquidity(&pool(), 1_000, 500, 400, 100, 401) .expect_err("minimum above minted liquidity must fail"); + assert_eq!(error.kind(), QuoteErrorCode::MintedLiquidityBelowMinimum); assert_eq!(error.code(), "minted_liquidity_below_minimum"); assert_eq!( error.message(), @@ -211,6 +205,110 @@ fn quote_errors_expose_stable_machine_codes() { ); } +#[test] +fn quote_error_codes_have_stable_strings() { + let cases = [ + (QuoteErrorCode::ArithmeticOverflow, "arithmetic_overflow"), + (QuoteErrorCode::DepositAmountZero, "deposit_amount_zero"), + ( + QuoteErrorCode::EffectiveSwapInputZero, + "effective_swap_input_zero", + ), + ( + QuoteErrorCode::ExactOutputExceedsReserve, + "exact_output_exceeds_reserve", + ), + (QuoteErrorCode::ExactOutputZero, "exact_output_zero"), + ( + QuoteErrorCode::InitialLiquidityTooLow, + "initial_liquidity_too_low", + ), + ( + QuoteErrorCode::InputTokenNotInPool, + "input_token_not_in_pool", + ), + ( + QuoteErrorCode::InvalidLiquidityAccount, + "invalid_liquidity_account", + ), + ( + QuoteErrorCode::LiquiditySupplyBelowMinimum, + "liquidity_supply_below_minimum", + ), + (QuoteErrorCode::MaximumDepositZero, "maximum_deposit_zero"), + ( + QuoteErrorCode::MinimumLiquidityZero, + "minimum_liquidity_zero", + ), + ( + QuoteErrorCode::MinimumWithdrawalZero, + "minimum_withdrawal_zero", + ), + ( + QuoteErrorCode::MintedLiquidityBelowMinimum, + "minted_liquidity_below_minimum", + ), + (QuoteErrorCode::MintedLiquidityZero, "minted_liquidity_zero"), + (QuoteErrorCode::OraclePriceZero, "oracle_price_zero"), + ( + QuoteErrorCode::OracleWindowTooShort, + "oracle_window_too_short", + ), + ( + QuoteErrorCode::PoolContainsOnlyLockedLiquidity, + "pool_contains_only_locked_liquidity", + ), + ( + QuoteErrorCode::RemoveAmountExceedsUnlockedLiquidity, + "remove_amount_exceeds_unlocked_liquidity", + ), + ( + QuoteErrorCode::RemoveAmountExceedsUserBalance, + "remove_amount_exceeds_user_balance", + ), + ( + QuoteErrorCode::RemoveLiquidityAmountZero, + "remove_liquidity_amount_zero", + ), + ( + QuoteErrorCode::RequiredInputExceedsMaximum, + "required_input_exceeds_maximum", + ), + (QuoteErrorCode::ReserveAZero, "reserve_a_zero"), + (QuoteErrorCode::ReserveZero, "reserve_zero"), + ( + QuoteErrorCode::SwapOutputBelowMinimum, + "swap_output_below_minimum", + ), + (QuoteErrorCode::SwapOutputZero, "swap_output_zero"), + (QuoteErrorCode::TokenAAmountZero, "token_a_amount_zero"), + (QuoteErrorCode::TokenBAmountZero, "token_b_amount_zero"), + (QuoteErrorCode::TokenPairNotInPool, "token_pair_not_in_pool"), + (QuoteErrorCode::UnsupportedFeeTier, "unsupported_fee_tier"), + ( + QuoteErrorCode::VaultABalanceBelowReserve, + "vault_a_balance_below_reserve", + ), + ( + QuoteErrorCode::VaultBBalanceBelowReserve, + "vault_b_balance_below_reserve", + ), + ( + QuoteErrorCode::WithdrawalABelowMinimum, + "withdrawal_a_below_minimum", + ), + ( + QuoteErrorCode::WithdrawalBBelowMinimum, + "withdrawal_b_below_minimum", + ), + ]; + + assert_eq!(cases.len(), 33); + for (kind, expected) in cases { + assert_eq!(kind.as_str(), expected); + } +} + #[test] fn exact_quotes_apply_instruction_slippage_guards() { let add = quote::add_liquidity(&pool(), 1_000, 500, 400, 100, 401)