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.
This commit is contained in:
Ricardo Guilherme Schmidt
2026-08-10 11:22:44 -03:00
parent f1b15bebdd
commit a7395aadb7
22 changed files with 6300 additions and 157 deletions
Generated
+15
View File
@@ -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"
+2
View File
@@ -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" }
+21
View File
@@ -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" }
+71
View File
@@ -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.
+17
View File
@@ -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");
}
}
+185
View File
@@ -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.
+46
View File
@@ -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
+126
View File
@@ -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 {}
+172
View File
@@ -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<Value, WireError>;
#[derive(Serialize)]
struct Envelope {
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<ErrorPayload>,
}
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<String>, message: impl Into<String>) -> 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<Value, ErrorPayload> {
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) });
}
+26
View File
@@ -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,
};
+904
View File
@@ -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<Self, ClientError> {
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<PlannedAccount>,
}
impl TransactionPlan {
fn new(program_id: ProgramId, instruction: Instruction, accounts: Vec<PlannedAccount>) -> 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<InstructionData> {
encode_instruction(&self.instruction)
}
#[must_use]
pub fn accounts(&self) -> &[PlannedAccount] {
&self.accounts
}
#[must_use]
pub fn account_ids(&self) -> Vec<AccountId> {
self.accounts.iter().map(PlannedAccount::id).collect()
}
/// One signer requirement for each ordered account ID.
#[must_use]
pub fn signer_flags(&self) -> Vec<bool> {
self.accounts.iter().map(PlannedAccount::signer).collect()
}
/// Signer IDs in their original account-list order.
#[must_use]
pub fn signer_account_ids(&self) -> Vec<AccountId> {
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<InstructionData> {
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<ProgramId>,
pub twap_oracle_program_id: Option<ProgramId>,
pub new_authority: Option<AccountId>,
}
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<TransactionPlan, ClientError> {
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,
}
}
+698
View File
@@ -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<Self, ClientError> {
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<AccountId>,
}
impl ValidatedFungibleDefinition {
/// Validates a fungible token definition account against an AMM context.
pub fn new(
context: &AmmContext,
definition_account: &AccountSnapshot,
) -> Result<Self, ClientError> {
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<AccountId> {
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<Self, ClientError> {
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<Self, ClientError> {
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<Self, ClientError> {
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<program_quote::QuoteError> 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<program_quote::PairOrder, ClientError> {
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<program_quote::CreatePoolQuote, ClientError> {
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<program_quote::AddLiquidityQuote, ClientError> {
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<program_quote::AddLiquidityQuote, ClientError> {
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<program_quote::RemoveLiquidityQuote, ClientError> {
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<program_quote::RemoveLiquidityQuote, ClientError> {
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<program_quote::SwapQuote, ClientError> {
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<program_quote::SwapQuote, ClientError> {
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<program_quote::SwapQuote, ClientError> {
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<program_quote::SwapQuote, ClientError> {
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<program_quote::SyncReservesQuote, ClientError> {
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<program_quote::OraclePriceAccountQuote, ClientError> {
Ok(program_quote::create_oracle_price_account(
&snapshot.pool,
window_duration,
)?)
}
fn validated_swap_direction(
snapshot: &ValidatedPoolSnapshot,
user_input: &ValidatedFungibleHolding,
user_output: &ValidatedFungibleHolding,
) -> Result<program_quote::SwapDirection, ClientError> {
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(())
}
+267
View File
@@ -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<Self, ClientError> {
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<u128, ClientError> {
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<u128, ClientError> {
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<PreparedCreatePool, ClientError> {
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<PreparedAddLiquidity, ClientError> {
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<PreparedRemoveLiquidity, ClientError> {
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<PreparedSwapExactInput, ClientError> {
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<PreparedSwapExactOutput, ClientError> {
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,
})
}
File diff suppressed because it is too large Load Diff
+356
View File
@@ -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<AccountId>,
) -> 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<u32> = 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");
}
+636
View File
@@ -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<TransactionPlan> {
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<AccountId> = 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,
}
}
+647
View File
@@ -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<AccountId>,
) -> 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, ClientError> {
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
));
}
@@ -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,
}
)
);
}
@@ -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::<String>(),
})
}
fn definition(total_supply: u128, authority: Option<AccountId>) -> 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");
}
+1 -1
View File
@@ -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,
+204 -63
View File
@@ -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<CreatePoolQuote, QuoteError> {
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<OraclePriceAccountQuote, QuoteError> {
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<SwapQuote, QuoteError> {
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<u128, QuoteError> {
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<PoolUpdate, QuoteError> {
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<u128, QuoteError> {
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<u128, QuoteError> {
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))
}
+191 -93
View File
@@ -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)