mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
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:
Generated
+15
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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" }
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 {}
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user