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