feat(amm): complete shared transaction client

This commit is contained in:
Ricardo Guilherme Schmidt
2026-08-10 11:22:44 -03:00
parent a7395aadb7
commit fb9df2fd96
23 changed files with 6353 additions and 63 deletions
Generated
+1
View File
@@ -80,6 +80,7 @@ dependencies = [
name = "amm_client"
version = "0.1.0"
dependencies = [
"alloy-primitives",
"amm_core",
"amm_program",
"clock_core",
+1
View File
@@ -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" }
+11 -3
View File
@@ -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.
+143 -10
View File
@@ -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
+15 -11
View File
@@ -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.
*/
/*
+628
View File
@@ -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,
})
}
+5 -2
View File
@@ -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),
}
+502
View File
@@ -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)
}
+23
View File
@@ -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,
};
+21
View File
@@ -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 {
+15 -10
View File
@@ -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: &current_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: &current_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: &current_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: &current_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: &current_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: &current_tick,
clock: &clock,
},
)
.err()
.expect("Token Program requires LP definition to be uninitialized");
assert_eq!(error.code(), "invalid_account_data");
}
+5 -1
View File
@@ -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, &quote).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, &quote).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, &quote),
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();
+51 -9
View File
@@ -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: &quote_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");
}
+61 -7
View File
@@ -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);