refactor(apps/amm): move the amm_client crate into modules/amm/ffi as amm_ffi

After the amm_module refactor, this crate is linked only by the module (the
UI delegates to modules().amm_module and links nothing), so its home under
apps/amm/ and the name "client" were both misnomers: it's the AMM business
logic the module wraps, reached across an FFI boundary — the same relationship
logos_execution_zone has with wallet_ffi. Co-locate it with the module that
owns it and name it for that role.

The FFI surface is unchanged — the exported functions are already amm_* (not
amm_client_*) — so only the crate, directory, generated header, dylib, and
package names move. The module's call sites are untouched; it just includes the
renamed header.

- apps/amm/client → modules/amm/ffi (git-tracked rename; history preserved)
- crate amm_client → amm_ffi: package name, include/amm_ffi.h, libamm_ffi.dylib,
  AMM_FFI_H guard, build.rs output path, tests/public_api.rs import
- workspace member path + Cargo.lock
- flake.nix: pname, -p, header-copy path, dylib install_name, packages.amm_ffi,
  ammModuleOutputs externalLibInputs, DYLD wrapper
- modules/amm: metadata external_libraries, CMakeLists EXTERNAL_LIBS, impl
  #include + comments, README, flake note
- apps/amm/flake.nix: drop the now-dead amm_client external-lib input (the UI
  links no external lib of its own)

Resulting layout:
  modules/amm/
    src/   # C++ module  (amm_module_impl.{h,cpp})
    ffi/   # Rust crate  amm_ffi  (Cargo.toml, src/, include/amm_ffi.h)
This commit is contained in:
r4bbit
2026-08-03 23:11:06 +02:00
parent afeba568d8
commit f5ff9b829f
38 changed files with 83 additions and 94 deletions
+3 -3
View File
@@ -16,9 +16,9 @@ string(JSON MODULE_NAME GET ${METADATA_JSON} name)
# Universal core module: we write only the impl class; the Qt plugin glue is
# generated from src/amm_module_impl.h (because metadata.json sets
# "interface": "universal"). EXTERNAL_LIBS links amm_client — the pure-Rust,
# "interface": "universal"). EXTERNAL_LIBS links amm_ffi — the pure-Rust,
# transport-independent AMM brain (PDA/decode/quote/plan/encode) exposed as a
# JSON FFI (amm_client.h), the same library the UI links. The chain I/O
# JSON FFI (amm_ffi.h), the same library the UI links. The chain I/O
# dependency (logos_execution_zone) is declared in metadata.json and reached via
# modules().logos_execution_zone in the impl, so it is NOT listed here.
logos_module(
@@ -27,5 +27,5 @@ logos_module(
src/amm_module_impl.h
src/amm_module_impl.cpp
EXTERNAL_LIBS
amm_client
amm_ffi
)
+8 -8
View File
@@ -11,7 +11,7 @@ for the module framework.
## What it does
The impl class `AmmModuleImpl` (`src/amm_module_impl.{h,cpp}`) is a **transport
adapter**: the AMM domain math lives in the Rust `amm_client` crate (a
adapter**: the AMM domain math lives in the Rust `amm_ffi` crate (a
transport-independent JSON FFI), and this module sequences those pure ops with
chain I/O delegated to the `logos_execution_zone` wallet module. Its public
methods (the module API is generated from the header) are:
@@ -41,7 +41,7 @@ methods (the module API is generated from the header) are:
## How it fits together
```
QML ──modules().amm_module──┐ ┌── amm_client (Rust cdylib, JSON FFI):
QML ──modules().amm_module──┐ ┌── amm_ffi (Rust cdylib, JSON FFI):
CLI ──logoscore call────────┤ │ PDA derivation, account decode,
▼ │ quote/plan math, instruction encoding
amm_module ───┤ — transport-independent (external_libraries)
@@ -51,7 +51,7 @@ CLI ──logoscore call────────┤ │ PDA derivation
via modules().logos_execution_zone.*
```
The `amm_client` crate is deliberately I/O-free — each op takes the account data
The `amm_ffi` crate is deliberately I/O-free — each op takes the account data
it needs as JSON input and returns a JSON result. This module is the transport
adapter the crate is designed to require: it fetches accounts through the wallet
module (`get_account_public`, `list_accounts`), hands them to the pure Rust op,
@@ -86,12 +86,12 @@ The UI passes `QString` (→ `QVariant` → string branch) and is unaffected.
## Build
Built from the **repo-root** flake (which provides the `amm_client` library it
Built from the **repo-root** flake (which provides the `amm_ffi` library it
links):
```bash
nix build .#amm-module
# output: result/lib/amm_module_plugin.dylib (+ libamm_client.dylib)
# output: result/lib/amm_module_plugin.dylib (+ libamm_ffi.dylib)
```
## Runtime configuration
@@ -123,7 +123,7 @@ Have all of the following in place before staging the modules dir:
`lm` introspects a built plugin without running it — handy to confirm the API
(`lm result/lib/amm_module_plugin.dylib` shows methods, signatures, deps).
3. **This module, built** (produces `amm_module_plugin.dylib` + `libamm_client.dylib`):
3. **This module, built** (produces `amm_module_plugin.dylib` + `libamm_ffi.dylib`):
```bash
nix build .#amm-module # from the repo root; output under result/lib/
@@ -168,7 +168,7 @@ verify the manifest hashes at load time.
modules/
amm_module/
amm_module_plugin.dylib
libamm_client.dylib
libamm_ffi.dylib
variant # one line: darwin-arm64-dev
manifest.json
logos_execution_zone/
@@ -268,6 +268,6 @@ the fork pinned as the `logos_execution_zone` input. See
- **`swapExactOutput` is not exposed yet.** The on-chain program supports it
(`amm_core::Instruction::SwapExactOutput`, identical account layout to
`SwapExactInput`), but the client path was only ever built for exact-input:
`amm_client` has no exact-output op and neither the UI nor this module has a
`amm_ffi` has no exact-output op and neither the UI nor this module has a
`swapExactOutput` method. Adding it is a near-copy of the exact-input path — an
`amm_swap_exact_output_*` op in the crate plus a `swapExactOutput` method here.
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "amm_ffi"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
alloy-primitives = { version = "1", default-features = false }
amm_core = { workspace = true }
borsh = { workspace = true }
clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0" }
hex = "0.4"
nssa_core = { workspace = true }
risc0-binfmt = { version = "=3.0.4", default-features = false }
risc0-zkvm = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = "0.10"
token_core = { workspace = true }
twap_oracle_core = { workspace = true }
[build-dependencies]
cbindgen = "0.28"
[dev-dependencies]
pretty_assertions = "1"
+9
View File
@@ -0,0 +1,9 @@
fn main() {
let crate_dir =
std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo");
cbindgen::generate(&crate_dir)
.expect("cbindgen")
.write_to_file(format!("{crate_dir}/include/amm_ffi.h"));
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=cbindgen.toml");
}
+8
View File
@@ -0,0 +1,8 @@
language = "C"
include_guard = "AMM_FFI_H"
pragma_once = true
cpp_compat = true
autogen_warning = "/* Generated by cbindgen. Do not edit. */"
[export]
prefix = ""
+49
View File
@@ -0,0 +1,49 @@
#ifndef AMM_FFI_H
#define AMM_FFI_H
#pragma once
/* Generated by cbindgen. Do not edit. */
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
char *amm_config_id(const char *request_json);
char *amm_token_ids(const char *request_json);
char *amm_pair_ids(const char *request_json);
char *amm_context(const char *request_json);
char *amm_quote(const char *request_json);
char *amm_plan(const char *request_json);
char *amm_swap_pair(const char *request_json);
char *amm_resolve_pool(const char *request_json);
char *amm_swap_plan(const char *request_json);
char *amm_program_id(const char *request_json);
/**
* Releases a string returned by an `amm_*` operation.
*
* # Safety
* `value` must be null or a pointer returned by this library that has not been freed.
*/
void amm_free(char *value);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
#endif /* AMM_FFI_H */
+152
View File
@@ -0,0 +1,152 @@
use std::str::FromStr;
use nssa_core::{
account::{Account, AccountId, Data, Nonce},
program::ProgramId,
};
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AccountRead {
pub id: String,
pub status: String,
#[serde(default)]
pub account: Option<WalletAccount>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct WalletAccount {
pub program_owner: String,
pub balance: String,
pub nonce: String,
pub data: String,
}
pub(crate) fn parse_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> {
if value.len() != 64
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(format!(
"{label} must be 64 lowercase hexadecimal characters"
));
}
let mut bytes = [0_u8; 32];
hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?;
Ok(bytes)
}
pub(crate) fn parse_program_id(value: &str) -> Result<ProgramId, String> {
let bytes = parse_hex_32(value, "program id")?;
let mut program_id = [0_u32; 8];
for (word, chunk) in program_id.iter_mut().zip(bytes.chunks_exact(4)) {
let chunk: [u8; 4] = chunk
.try_into()
.map_err(|_| String::from("program id word has invalid length"))?;
*word = u32::from_le_bytes(chunk);
}
Ok(program_id)
}
#[cfg(test)]
pub(crate) fn program_id_hex(program_id: ProgramId) -> String {
let bytes = program_id
.iter()
.flat_map(|word| word.to_le_bytes())
.collect::<Vec<_>>();
hex::encode(bytes)
}
pub(crate) fn program_id_base58(program_id: ProgramId) -> String {
AccountId::new(program_id_bytes(program_id)).to_string()
}
pub(crate) fn program_id_bytes(program_id: ProgramId) -> [u8; 32] {
let mut bytes = [0_u8; 32];
for (chunk, word) in bytes.chunks_exact_mut(4).zip(program_id) {
chunk.copy_from_slice(&word.to_le_bytes());
}
bytes
}
pub(crate) fn parse_base58_id(value: &str, label: &str) -> Result<AccountId, String> {
AccountId::from_str(value).map_err(|error| format!("invalid {label}: {error}"))
}
pub(crate) fn account_id_from_hex(value: &str, label: &str) -> Result<AccountId, String> {
Ok(AccountId::new(parse_hex_32(value, label)?))
}
pub(crate) fn account_id_hex(account_id: AccountId) -> String {
hex::encode(account_id.into_value())
}
fn parse_le_u128(value: &str, label: &str) -> Result<u128, String> {
if value.len() != 32
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(format!(
"{label} must be 32 lowercase hexadecimal characters"
));
}
let mut bytes = [0_u8; 16];
hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?;
Ok(u128::from_le_bytes(bytes))
}
pub(crate) fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), String> {
if read.status != "ok" {
return Err(String::from("account read failed"));
}
let account_id = account_id_from_hex(&read.id, "account id")?;
let source = read
.account
.as_ref()
.ok_or_else(|| String::from("successful account read has no account"))?;
let program_owner = parse_program_id(&source.program_owner)?;
let balance = parse_le_u128(&source.balance, "account balance")?;
let nonce = parse_le_u128(&source.nonce, "account nonce")?;
if source.data.len() % 2 != 0
|| !source
.data
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(String::from(
"account data must be lowercase even-length hexadecimal",
));
}
let data =
hex::decode(&source.data).map_err(|error| format!("invalid account data: {error}"))?;
let data =
Data::try_from(data).map_err(|error| format!("account data is too large: {error}"))?;
Ok((
account_id,
Account {
program_owner,
balance,
data,
nonce: Nonce(nonce),
},
))
}
#[cfg(test)]
pub(crate) fn account_read(id: AccountId, account: &Account) -> AccountRead {
AccountRead {
id: account_id_hex(id),
status: String::from("ok"),
account: Some(WalletAccount {
program_owner: program_id_hex(account.program_owner),
balance: hex::encode(account.balance.to_le_bytes()),
nonce: hex::encode(account.nonce.0.to_le_bytes()),
data: hex::encode(account.data.as_ref()),
}),
}
}
+226
View File
@@ -0,0 +1,226 @@
use amm_core::PoolDefinition;
use nssa_core::program::ProgramId;
use super::{
funding::{append_holding_source, sources_from_reads},
pair::PairIds,
position::{AccountPlan, AccountPlanHoldings, AccountPlanRow},
QuoteRequest,
};
pub(super) fn missing_account_plan(
input: &QuoteRequest,
pair: PairIds,
amm_program: ProgramId,
holdings: AccountPlanHoldings<'_>,
) -> Result<AccountPlan, String> {
let mut sources = sources_from_reads(&[
("config", &input.snapshot.config),
("token_a", &input.snapshot.token_a),
("token_b", &input.snapshot.token_b),
("pool", &input.snapshot.pool),
("vault_a", &input.snapshot.vault_a),
("vault_b", &input.snapshot.vault_b),
("lp_definition", &input.snapshot.lp_definition),
("lp_lock_holding", &input.snapshot.lp_lock_holding),
("current_tick", &input.snapshot.current_tick),
])?;
append_holding_source(&mut sources, "holding_a", holdings.token_a);
append_holding_source(&mut sources, "holding_b", holdings.token_b);
Ok(AccountPlan {
rows: vec![
AccountPlanRow::new(
"config",
Some(pair.config),
Some(amm_program),
"read",
false,
false,
),
AccountPlanRow::new(
"pool",
Some(pair.pool),
Some(amm_program),
"create",
false,
true,
),
AccountPlanRow::new(
"vault_a",
Some(pair.vault_a),
Some(pair.token_program),
"create",
false,
true,
),
AccountPlanRow::new(
"vault_b",
Some(pair.vault_b),
Some(pair.token_program),
"create",
false,
true,
),
AccountPlanRow::new(
"lp_definition",
Some(pair.lp_definition),
Some(pair.token_program),
"create",
false,
true,
),
AccountPlanRow::new(
"lp_lock_holding",
Some(pair.lp_lock_holding),
Some(pair.token_program),
"create",
false,
true,
),
AccountPlanRow::new(
"user_holding_a",
holdings.token_a.map(|value| value.id),
Some(pair.token_program),
"update",
true,
false,
),
AccountPlanRow::new(
"user_holding_b",
holdings.token_b.map(|value| value.id),
Some(pair.token_program),
"update",
true,
false,
),
AccountPlanRow::new(
"user_holding_lp",
None,
Some(pair.token_program),
"create",
true,
true,
),
AccountPlanRow::new(
"current_tick",
Some(pair.current_tick),
Some(pair.twap_program),
"create",
false,
true,
),
AccountPlanRow::new("clock", Some(pair.clock), None, "read", false, false),
],
sources,
})
}
pub(super) fn active_account_plan(
input: &QuoteRequest,
pair: PairIds,
amm_program: ProgramId,
pool: &PoolDefinition,
stored_reversed: bool,
holdings: AccountPlanHoldings<'_>,
) -> Result<AccountPlan, String> {
let (stored_holding_a, stored_holding_b) = if stored_reversed {
(holdings.token_b, holdings.token_a)
} else {
(holdings.token_a, holdings.token_b)
};
let mut sources = sources_from_reads(&[
("config", &input.snapshot.config),
("token_a", &input.snapshot.token_a),
("token_b", &input.snapshot.token_b),
("pool", &input.snapshot.pool),
("vault_a", &input.snapshot.vault_a),
("vault_b", &input.snapshot.vault_b),
("lp_definition", &input.snapshot.lp_definition),
("current_tick", &input.snapshot.current_tick),
])?;
append_holding_source(&mut sources, "holding_a", holdings.token_a);
append_holding_source(&mut sources, "holding_b", holdings.token_b);
append_holding_source(&mut sources, "holding_lp", holdings.lp);
Ok(AccountPlan {
rows: vec![
AccountPlanRow::new(
"config",
Some(pair.config),
Some(amm_program),
"read",
false,
false,
),
AccountPlanRow::new(
"pool",
Some(pair.pool),
Some(amm_program),
"update",
false,
false,
),
AccountPlanRow::new(
"vault_a",
Some(pool.vault_a_id),
Some(pair.token_program),
"update",
false,
false,
),
AccountPlanRow::new(
"vault_b",
Some(pool.vault_b_id),
Some(pair.token_program),
"update",
false,
false,
),
AccountPlanRow::new(
"lp_definition",
Some(pair.lp_definition),
Some(pair.token_program),
"update",
false,
false,
),
AccountPlanRow::new(
"user_holding_a",
stored_holding_a.map(|value| value.id),
Some(pair.token_program),
"update",
true,
false,
),
AccountPlanRow::new(
"user_holding_b",
stored_holding_b.map(|value| value.id),
Some(pair.token_program),
"update",
true,
false,
),
AccountPlanRow::new(
"user_holding_lp",
holdings.lp.map(|value| value.id),
Some(pair.token_program),
if holdings.lp.is_some() {
"update"
} else {
"create"
},
holdings.lp.is_none(),
holdings.lp.is_none(),
),
AccountPlanRow::new(
"current_tick",
Some(pair.current_tick),
Some(pair.twap_program),
"update",
false,
false,
),
AccountPlanRow::new("clock", Some(pair.clock), None, "read", false, false),
],
sources,
})
}
+12
View File
@@ -0,0 +1,12 @@
use borsh::from_slice;
use clock_core::ClockAccountData;
use nssa_core::account::AccountId;
use crate::account::{decode_account, AccountRead};
pub(super) fn decode_clock(read: &AccountRead) -> Result<(AccountId, ClockAccountData), String> {
let (id, account) = decode_account(read)?;
let clock = from_slice(account.data.as_ref())
.map_err(|error| format!("invalid clock account: {error}"))?;
Ok((id, clock))
}
+50
View File
@@ -0,0 +1,50 @@
use borsh::BorshSerialize;
#[derive(BorshSerialize)]
pub(super) enum RequestCommitment {
Missing {
amount_a: u128,
amount_b: u128,
},
Active {
max_a: u128,
max_b: u128,
slippage_bps: u32,
},
}
#[derive(BorshSerialize)]
pub(super) struct SourceCommitment {
pub(super) role: String,
pub(super) commitment: [u8; 32],
}
#[derive(BorshSerialize)]
pub(super) struct FundingCommitment {
pub(super) token_id: [u8; 32],
pub(super) holding_id: Option<[u8; 32]>,
pub(super) available: u128,
pub(super) requested: u128,
}
#[derive(BorshSerialize)]
pub(super) struct QuoteCommitment {
pub(super) network_id: String,
pub(super) network_fingerprint: String,
pub(super) amm_program_id: [u8; 32],
pub(super) token_a_id: [u8; 32],
pub(super) token_b_id: [u8; 32],
pub(super) fee_bps: u32,
pub(super) pool_status: u8,
pub(super) request: RequestCommitment,
pub(super) max_a: u128,
pub(super) max_b: u128,
pub(super) actual_a: u128,
pub(super) actual_b: u128,
pub(super) expected_lp: u128,
pub(super) lp_guard: u128,
pub(super) requires_fresh_lp: bool,
pub(super) sources: Vec<SourceCommitment>,
pub(super) funding: Vec<FundingCommitment>,
pub(super) warnings: Vec<String>,
}
+25
View File
@@ -0,0 +1,25 @@
use amm_core::{compute_config_pda, AmmConfig};
use nssa_core::{account::Account, program::ProgramId};
use serde_json::{json, Value};
use super::ConfigIdRequest;
use crate::account::{account_id_hex, decode_account, parse_program_id, AccountRead};
pub(super) fn config_id(request: ConfigIdRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
Ok(json!({
"status": "ok",
"configId": account_id_hex(compute_config_pda(amm_program)),
}))
}
pub(super) fn load_config(amm_program: ProgramId, read: &AccountRead) -> Result<AmmConfig, String> {
let (id, account) = decode_account(read)?;
if id != compute_config_pda(amm_program)
|| account.program_owner != amm_program
|| account == Account::default()
{
return Err(String::from("AMM config is unavailable"));
}
AmmConfig::try_from(&account.data).map_err(|_| String::from("AMM config is invalid"))
}
+252
View File
@@ -0,0 +1,252 @@
use std::collections::{BTreeMap, BTreeSet};
use amm_core::{
FEE_TIER_BPS_1, FEE_TIER_BPS_100, FEE_TIER_BPS_30, FEE_TIER_BPS_5, MINIMUM_LIQUIDITY,
};
use nssa_core::{account::AccountId, program::ProgramId};
use serde_json::{json, Value};
use token_core::TokenDefinition;
use super::{
config::load_config,
holding::{select_holding, wallet_holdings, SelectedHolding},
quote_error::issue,
ContextRequest, TokenIdsRequest,
};
use crate::account::{
account_id_from_hex, account_id_hex, decode_account, parse_base58_id, parse_program_id,
program_id_base58, AccountRead,
};
pub(super) fn token_ids(request: TokenIdsRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
let Ok(config) = load_config(amm_program, &request.config) else {
return Ok(manifest_error("config_unavailable"));
};
let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id);
let mut token_ids = BTreeSet::new();
for id in &request.configured_token_ids {
if let Ok(id) = account_id_from_hex(id, "configured token id") {
token_ids.insert(id);
}
}
for id in request
.recent_token_ids
.iter()
.chain(&request.resolved_token_ids)
{
if let Ok(id) = parse_base58_id(id, "token id") {
token_ids.insert(id);
}
}
token_ids.extend(holdings.into_iter().map(|holding| holding.definition_id));
Ok(json!({
"status": "ok",
"tokenIds": token_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(),
}))
}
fn manifest_error(code: &str) -> Value {
json!({ "status": "error", "code": code, "tokenIds": [] })
}
pub(super) fn context(request: ContextRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
let Ok(config) = load_config(amm_program, &request.config) else {
return Ok(context_error(&request, "config_unavailable"));
};
let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id);
let source_map = token_sources(&request, &holdings);
let mut rows = Vec::new();
let mut warnings = Vec::new();
for (token_id, sources) in source_map {
let read = request
.token_definitions
.iter()
.find(|read| account_id_from_hex(&read.id, "token definition id") == Ok(token_id));
let (name, total_supply, metadata_id) =
match fungible_definition(read, token_id, config.token_program_id) {
Ok(definition) => definition,
Err(error) => {
rows.push(unavailable_token_row(token_id, sources, error.code));
if error.warn {
warnings.push(issue(
error.code,
"Token definition could not be read.",
&[],
json!({ "tokenId": token_id.to_string() }),
));
}
continue;
}
};
let selected = select_holding(&holdings, token_id);
let mut row = json!({
"definitionId": token_id.to_string(),
"name": name,
"metadataId": metadata_id.map(|id| id.to_string()),
"totalSupplyRaw": total_supply.to_string(),
"ownerProgramId": program_id_base58(config.token_program_id),
"public": true,
"fungible": true,
"selectable": true,
"status": "available",
"code": "available",
"sources": sources,
});
if let Some(selected) = selected {
row["holdingId"] = json!(selected.id.to_string());
row["balanceRaw"] = json!(selected.balance.to_string());
}
rows.push(row);
}
rows.sort_by(|left, right| {
let left_holding = left.get("holdingId").is_some();
let right_holding = right.get("holdingId").is_some();
right_holding.cmp(&left_holding).then_with(|| {
left["definitionId"]
.as_str()
.cmp(&right["definitionId"].as_str())
})
});
Ok(json!({
"status": if request.wallet_available { "ready" } else { "no_wallet" },
"networkId": request.network_id,
"networkFingerprint": request.network_fingerprint,
"walletAvailable": request.wallet_available,
"minimumLiquidityRaw": MINIMUM_LIQUIDITY.to_string(),
"programIds": {
"amm": program_id_base58(amm_program),
"token": program_id_base58(config.token_program_id),
"twapOracle": program_id_base58(config.twap_oracle_program_id),
},
"tokens": rows,
"feeTiers": fee_tiers(),
"warnings": warnings,
}))
}
fn context_error(request: &ContextRequest, code: &str) -> Value {
json!({
"status": "error",
"code": code,
"networkId": request.network_id,
"networkFingerprint": request.network_fingerprint,
"walletAvailable": request.wallet_available,
"tokens": [],
"feeTiers": fee_tiers(),
"warnings": [],
})
}
fn fee_tiers() -> Value {
json!([
{ "feeBps": FEE_TIER_BPS_1, "label": "0.01%", "enabled": true },
{ "feeBps": FEE_TIER_BPS_5, "label": "0.05%", "enabled": true },
{ "feeBps": FEE_TIER_BPS_30, "label": "0.30%", "enabled": true },
{ "feeBps": FEE_TIER_BPS_100, "label": "1.00%", "enabled": true },
])
}
fn token_sources(
request: &ContextRequest,
holdings: &[SelectedHolding],
) -> BTreeMap<AccountId, Vec<String>> {
let mut sources: BTreeMap<AccountId, BTreeSet<String>> = BTreeMap::new();
for id in &request.configured_token_ids {
if let Ok(id) = account_id_from_hex(id, "configured token id") {
sources
.entry(id)
.or_default()
.insert(String::from("config"));
}
}
for (ids, source) in [
(&request.recent_token_ids, "recent"),
(&request.resolved_token_ids, "resolved"),
] {
for id in ids {
if let Ok(id) = parse_base58_id(id, "token id") {
sources.entry(id).or_default().insert(String::from(source));
}
}
}
for holding in holdings {
sources
.entry(holding.definition_id)
.or_default()
.insert(String::from("holding"));
}
sources
.into_iter()
.map(|(id, values)| (id, values.into_iter().collect()))
.collect()
}
fn unavailable_token_row(token_id: AccountId, sources: Vec<String>, code: &str) -> Value {
json!({
"definitionId": token_id.to_string(),
"name": "",
"metadataId": Value::Null,
"totalSupplyRaw": "0",
"selectable": false,
"status": code,
"code": code,
"sources": sources,
})
}
pub(super) struct DefinitionError {
pub(super) code: &'static str,
pub(super) warn: bool,
}
pub(super) fn fungible_definition(
read: Option<&AccountRead>,
token_id: AccountId,
token_program: ProgramId,
) -> Result<(String, u128, Option<AccountId>), DefinitionError> {
let Some(read) = read else {
return Err(DefinitionError {
code: "token_definition_unreadable",
warn: true,
});
};
let Ok((id, account)) = decode_account(read) else {
return Err(DefinitionError {
code: "token_definition_unreadable",
warn: true,
});
};
if id != token_id {
return Err(DefinitionError {
code: "token_definition_unreadable",
warn: false,
});
}
if account.program_owner != token_program {
return Err(DefinitionError {
code: "token_program_mismatch",
warn: false,
});
}
match TokenDefinition::try_from(&account.data) {
Ok(TokenDefinition::Fungible {
name,
total_supply,
metadata_id,
..
}) => Ok((name, total_supply, metadata_id)),
_ => Err(DefinitionError {
code: "token_not_fungible",
warn: false,
}),
}
}
+106
View File
@@ -0,0 +1,106 @@
use nssa_core::Commitment;
use serde_json::{json, Value};
use sha2::{Digest as _, Sha256};
use super::{
commitment::{FundingCommitment, QuoteCommitment, SourceCommitment},
holding::SelectedHolding,
pair::PairIds,
quote_error::issue,
};
use crate::account::{decode_account, AccountRead};
pub(super) fn funding_issues(
wallet_available: bool,
pair: PairIds,
holding_a: &Option<SelectedHolding>,
requested_a: u128,
holding_b: &Option<SelectedHolding>,
requested_b: u128,
fields: [&str; 2],
) -> Vec<Value> {
if !wallet_available {
return vec![issue(
"no_wallet",
"Connect a wallet to submit.",
&[],
json!({}),
)];
}
let mut errors = Vec::new();
for (token_id, holding, requested, field) in [
(pair.token_a, holding_a, requested_a, fields[0]),
(pair.token_b, holding_b, requested_b, fields[1]),
] {
let available = holding.as_ref().map_or(0, |value| value.balance);
if available < requested {
errors.push(issue(
"amount_exceeds_balance",
"Amount exceeds the selected wallet holding balance.",
&[field],
json!({
"requestedRaw": requested.to_string(),
"availableRaw": available.to_string(),
"holdingFound": holding.is_some(),
"tokenId": token_id.to_string(),
}),
));
}
}
errors
}
pub(super) fn funding_commitments(
pair: PairIds,
holding_a: &Option<SelectedHolding>,
requested_a: u128,
holding_b: &Option<SelectedHolding>,
requested_b: u128,
) -> Vec<FundingCommitment> {
[
(pair.token_a, holding_a, requested_a),
(pair.token_b, holding_b, requested_b),
]
.into_iter()
.map(|(token_id, holding, requested)| FundingCommitment {
token_id: token_id.into_value(),
holding_id: holding.as_ref().map(|value| value.id.into_value()),
available: holding.as_ref().map_or(0, |value| value.balance),
requested,
})
.collect()
}
pub(super) fn sources_from_reads(
reads: &[(&str, &AccountRead)],
) -> Result<Vec<SourceCommitment>, String> {
reads
.iter()
.map(|(role, read)| {
let (id, account) = decode_account(read)?;
Ok(SourceCommitment {
role: String::from(*role),
commitment: Commitment::new(&id, &account).to_byte_array(),
})
})
.collect()
}
pub(super) fn append_holding_source(
sources: &mut Vec<SourceCommitment>,
role: &str,
holding: Option<&SelectedHolding>,
) {
if let Some(holding) = holding {
sources.push(SourceCommitment {
role: String::from(role),
commitment: Commitment::new(&holding.id, &holding.account).to_byte_array(),
});
}
}
pub(super) fn hash_quote(commitment: &QuoteCommitment) -> Result<String, String> {
let bytes = borsh::to_vec(commitment)
.map_err(|error| format!("quote commitment serialization failed: {error}"))?;
Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes))))
}
+64
View File
@@ -0,0 +1,64 @@
use nssa_core::{
account::{Account, AccountId},
program::ProgramId,
};
use token_core::TokenHolding;
use crate::account::{decode_account, AccountRead};
#[derive(Clone)]
pub(super) struct SelectedHolding {
pub(super) id: AccountId,
pub(super) definition_id: AccountId,
pub(super) balance: u128,
pub(super) account: Account,
}
pub(super) fn wallet_holdings(
reads: &[AccountRead],
token_program: ProgramId,
) -> Vec<SelectedHolding> {
reads
.iter()
.filter_map(|read| decode_fungible_holding(read, token_program).ok())
.collect()
}
pub(super) fn decode_fungible_holding(
read: &AccountRead,
token_program: ProgramId,
) -> Result<SelectedHolding, String> {
let (id, account) = decode_account(read)?;
if account.program_owner != token_program {
return Err(String::from("holding owner mismatch"));
}
let TokenHolding::Fungible {
definition_id,
balance,
} = TokenHolding::try_from(&account.data)
.map_err(|_| String::from("invalid fungible holding"))?
else {
return Err(String::from("invalid fungible holding"));
};
Ok(SelectedHolding {
id,
definition_id,
balance,
account,
})
}
pub(super) fn select_holding(
holdings: &[SelectedHolding],
definition_id: AccountId,
) -> Option<SelectedHolding> {
holdings
.iter()
.filter(|holding| holding.definition_id == definition_id)
.max_by(|left, right| {
left.balance
.cmp(&right.balance)
.then_with(|| right.id.cmp(&left.id))
})
.cloned()
}
+114
View File
@@ -0,0 +1,114 @@
//! Transport-independent AMM client operations.
mod accounts;
mod clock;
mod commitment;
mod config;
mod context;
mod funding;
mod holding;
mod pair;
mod plan;
mod position;
mod quote;
mod quote_error;
mod request;
mod swap;
#[cfg(test)]
mod tests;
use std::{error::Error, fmt};
pub use request::{
ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest,
ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapPairRequest, SwapPlanRequest,
TokenIdsRequest,
};
use serde_json::Value;
pub use crate::account::{AccountRead, WalletAccount};
/// JSON response shared by direct Rust callers and transport adapters.
pub type AmmResponse = Value;
/// Result returned by AMM client operations.
pub type AmmResult = Result<AmmResponse, AmmApiError>;
/// Failure produced before an AMM response can be constructed.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AmmApiError {
message: String,
}
impl AmmApiError {
/// Returns the stable human-readable failure detail.
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
impl fmt::Display for AmmApiError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl Error for AmmApiError {}
impl From<String> for AmmApiError {
fn from(message: String) -> Self {
Self { message }
}
}
/// Derives the AMM configuration account ID.
pub fn config_id(request: ConfigIdRequest) -> AmmResult {
config::config_id(request).map_err(Into::into)
}
/// Discovers token definition IDs available to the active wallet and app.
pub fn token_ids(request: TokenIdsRequest) -> AmmResult {
context::token_ids(request).map_err(Into::into)
}
/// Derives canonical accounts for one token pair.
pub fn pair_ids(request: PairIdsRequest) -> AmmResult {
pair::pair_ids(request).map_err(Into::into)
}
/// Builds network, token, holding, and fee-tier context.
pub fn context(request: ContextRequest) -> AmmResult {
context::context(request).map_err(Into::into)
}
/// Evaluates a pool-creation or add-liquidity request.
pub fn quote(request: QuoteRequest) -> AmmResult {
quote::quote(request).map_err(Into::into)
}
/// Materializes a previously quoted request into wallet submission arguments.
pub fn plan(request: PlanRequest) -> AmmResult {
plan::plan(request).map_err(Into::into)
}
/// Derives the canonical account ids for a swap pair (tokens in either order).
pub fn swap_pair(request: SwapPairRequest) -> AmmResult {
swap::swap_pair(request).map_err(Into::into)
}
/// Decodes a pool account: existence, reserves (canonical order), fee tier.
pub fn resolve_pool(request: ResolvePoolRequest) -> AmmResult {
swap::resolve_pool(request).map_err(Into::into)
}
/// Builds the `SwapExactInput` wallet submission for a token pair.
pub fn swap_plan(request: SwapPlanRequest) -> AmmResult {
swap::swap_plan(request).map_err(Into::into)
}
/// Derives the AMM `ProgramId` (Image ID) from a deployed program binary.
pub fn program_id(request: ProgramIdRequest) -> AmmResult {
swap::program_id(request).map_err(Into::into)
}
+93
View File
@@ -0,0 +1,93 @@
use amm_core::{
compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda,
compute_vault_pda,
};
use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID;
use nssa_core::{account::AccountId, program::ProgramId};
use serde_json::{json, Value};
use twap_oracle_core::compute_current_tick_account_pda;
use super::{config::load_config, PairIdsRequest};
use crate::account::{account_id_hex, parse_base58_id, parse_program_id, AccountRead};
#[derive(Clone, Copy)]
pub(super) struct PairIds {
pub(super) token_a: AccountId,
pub(super) token_b: AccountId,
pub(super) config: AccountId,
pub(super) pool: AccountId,
pub(super) vault_a: AccountId,
pub(super) vault_b: AccountId,
pub(super) lp_definition: AccountId,
pub(super) lp_lock_holding: AccountId,
pub(super) current_tick: AccountId,
pub(super) clock: AccountId,
pub(super) token_program: ProgramId,
pub(super) twap_program: ProgramId,
}
pub(super) fn pair_ids(request: PairIdsRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
let Ok(token_a) = parse_base58_id(&request.token_a_id, "token A id") else {
return Ok(json!({ "status": "error", "code": "invalid_token_id" }));
};
let Ok(token_b) = parse_base58_id(&request.token_b_id, "token B id") else {
return Ok(json!({ "status": "error", "code": "invalid_token_id" }));
};
if token_a == token_b {
return Ok(json!({ "status": "error", "code": "same_token_pair" }));
}
if !is_canonical_pair(token_a, token_b) {
return Ok(json!({ "status": "error", "code": "non_canonical_pair" }));
}
let Ok(pair) = derive_pair(amm_program, token_a, token_b, &request.config) else {
return Ok(json!({ "status": "error", "code": "config_unavailable" }));
};
Ok(pair_json(pair))
}
pub(super) fn is_canonical_pair(token_a: AccountId, token_b: AccountId) -> bool {
token_a.value() > token_b.value()
}
pub(super) fn derive_pair(
amm_program: ProgramId,
token_a: AccountId,
token_b: AccountId,
config_read: &AccountRead,
) -> Result<PairIds, String> {
let config_id = compute_config_pda(amm_program);
let config = load_config(amm_program, config_read)?;
let pool = compute_pool_pda(amm_program, token_a, token_b);
Ok(PairIds {
token_a,
token_b,
config: config_id,
pool,
vault_a: compute_vault_pda(amm_program, pool, token_a),
vault_b: compute_vault_pda(amm_program, pool, token_b),
lp_definition: compute_liquidity_token_pda(amm_program, pool),
lp_lock_holding: compute_lp_lock_holding_pda(amm_program, pool),
current_tick: compute_current_tick_account_pda(config.twap_oracle_program_id, pool),
clock: CLOCK_01_PROGRAM_ACCOUNT_ID,
token_program: config.token_program_id,
twap_program: config.twap_oracle_program_id,
})
}
fn pair_json(pair: PairIds) -> Value {
json!({
"status": "ok",
"tokenAId": account_id_hex(pair.token_a),
"tokenBId": account_id_hex(pair.token_b),
"configId": account_id_hex(pair.config),
"poolId": account_id_hex(pair.pool),
"vaultAId": account_id_hex(pair.vault_a),
"vaultBId": account_id_hex(pair.vault_b),
"lpDefinitionId": account_id_hex(pair.lp_definition),
"lpLockHoldingId": account_id_hex(pair.lp_lock_holding),
"currentTickId": account_id_hex(pair.current_tick),
"clockId": account_id_hex(pair.clock),
})
}
+130
View File
@@ -0,0 +1,130 @@
use nssa_core::account::Account;
use serde_json::{json, Value};
use super::{
clock::decode_clock,
position::{NewPositionPlan, QuoteBranch, QuoteComputation},
quote::compute_quote,
PlanRequest, QuoteRequest,
};
use crate::account::{account_id_hex, decode_account, AccountRead};
const DEADLINE_WINDOW_MS: u64 = 1_200_000;
pub(super) fn plan(input: PlanRequest) -> Result<Value, String> {
let quote_input = QuoteRequest {
network_id: input.network_id,
network_fingerprint: input.network_fingerprint,
amm_program_id: input.amm_program_id.clone(),
request: input.request,
snapshot: input.snapshot,
};
let quote = compute_quote(&quote_input)?;
if quote.quote_hash() != Some(input.quote_hash.as_str()) {
return Ok(json!({
"status": "error",
"code": "quote_changed",
"recoverable": true,
"quote": quote.into_value(&quote_input.request),
}));
}
let evaluated = match quote {
QuoteComputation::Evaluated(evaluated) => evaluated,
QuoteComputation::Failed(failure) => {
return Ok(json!({
"status": "error",
"code": "quote_not_submittable",
"recoverable": true,
"quote": failure.into_value(&quote_input.request),
}))
}
};
let Some(plan) = evaluated.plan else {
return Ok(json!({
"status": "error",
"code": "quote_not_submittable",
"recoverable": true,
"quote": evaluated.value,
}));
};
let fresh_lp = if plan.requires_fresh_lp() {
let Some(read) = input.fresh_lp.as_ref() else {
return Ok(json!({
"status": "needs_fresh_lp",
"code": "fresh_lp_required",
}));
};
let Ok((id, account)) = decode_account(read) else {
return Ok(plan_error("wallet_submission_failed"));
};
if account != Account::default() || plan.accounts.contains(id) {
return Ok(plan_error("wallet_submission_failed"));
}
Some(id)
} else {
None
};
let deadline = input
.now_ms
.checked_add(DEADLINE_WINDOW_MS)
.ok_or_else(|| String::from("transaction deadline overflow"))?;
let clock_timestamp = clock_timestamp(&quote_input.snapshot.clock)?;
if clock_timestamp >= deadline {
return Ok(plan_error("transaction_deadline_expired"));
}
let NewPositionPlan { accounts, branch } = plan;
let (account_ids, signing_requirements) = accounts.wallet_args(fresh_lp)?;
let instruction = match branch {
QuoteBranch::Missing { amount_a, amount_b } => {
let instruction = amm_core::Instruction::NewDefinition {
token_a_amount: amount_a,
token_b_amount: amount_b,
fees: u128::from(quote_input.request.fee_bps),
deadline,
};
risc0_zkvm::serde::to_vec(&instruction)
.map_err(|error| format!("instruction serialization failed: {error}"))?
}
QuoteBranch::Active {
max_a,
max_b,
minimum_lp,
stored_reversed,
} => {
let (stored_max_a, stored_max_b) = if stored_reversed {
(max_b, max_a)
} else {
(max_a, max_b)
};
let instruction = amm_core::Instruction::AddLiquidity {
min_amount_liquidity: minimum_lp,
max_amount_to_add_token_a: stored_max_a,
max_amount_to_add_token_b: stored_max_b,
deadline,
};
risc0_zkvm::serde::to_vec(&instruction)
.map_err(|error| format!("instruction serialization failed: {error}"))?
}
};
Ok(json!({
"status": "ready",
"programId": input.amm_program_id,
"accountIds": account_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(),
"signingRequirements": signing_requirements,
"instruction": instruction,
"deadlineMs": deadline.to_string(),
}))
}
fn plan_error(code: &str) -> Value {
json!({
"status": "error",
"code": code,
"recoverable": true,
})
}
fn clock_timestamp(read: &AccountRead) -> Result<u64, String> {
decode_clock(read).map(|(_, clock)| clock.timestamp)
}
+228
View File
@@ -0,0 +1,228 @@
use nssa_core::{account::AccountId, program::ProgramId};
use serde_json::{json, Value};
use super::{
commitment::SourceCommitment, holding::SelectedHolding, pair::PairIds, quote_error::issue,
PairSnapshot, PositionRequest,
};
use crate::account::{account_id_from_hex, program_id_base58};
#[derive(Clone)]
pub(super) enum QuoteBranch {
Missing {
amount_a: u128,
amount_b: u128,
},
Active {
max_a: u128,
max_b: u128,
minimum_lp: u128,
stored_reversed: bool,
},
}
pub(super) struct EvaluatedQuote {
pub(super) value: Value,
pub(super) quote_hash: String,
pub(super) plan: Option<NewPositionPlan>,
}
pub(super) enum QuoteComputation {
Failed(QuoteFailure),
Evaluated(EvaluatedQuote),
}
pub(super) struct QuoteFailure {
pub(super) code: &'static str,
pub(super) fields: Vec<&'static str>,
pub(super) details: Value,
}
pub(super) struct NewPositionPlan {
pub(super) accounts: AccountPlan,
pub(super) branch: QuoteBranch,
}
pub(super) struct AccountPlan {
pub(super) rows: Vec<AccountPlanRow>,
pub(super) sources: Vec<SourceCommitment>,
}
pub(super) struct AccountPlanRow {
pub(super) role: &'static str,
pub(super) account_id: Option<AccountId>,
pub(super) expected_program: Option<ProgramId>,
pub(super) action: &'static str,
pub(super) signer: bool,
pub(super) init: bool,
}
pub(super) struct AccountPlanHoldings<'a> {
pub(super) token_a: Option<&'a SelectedHolding>,
pub(super) token_b: Option<&'a SelectedHolding>,
pub(super) lp: Option<&'a SelectedHolding>,
}
impl QuoteComputation {
pub(super) fn into_value(self, request: &PositionRequest) -> Value {
match self {
Self::Failed(failure) => failure.into_value(request),
Self::Evaluated(EvaluatedQuote { value, .. }) => value,
}
}
pub(super) fn quote_hash(&self) -> Option<&str> {
match self {
Self::Failed(_) => None,
Self::Evaluated(quote) => Some(&quote.quote_hash),
}
}
}
impl QuoteFailure {
pub(super) fn into_value(self, request: &PositionRequest) -> Value {
json!({
"status": "error",
"canSubmit": false,
"code": self.code,
"poolStatus": "unavailable_pool",
"tokenAId": request.token_a_id,
"tokenBId": request.token_b_id,
"accountPreview": [],
"errors": [issue(
self.code,
"Position quote is unavailable.",
&self.fields,
self.details,
)],
"warnings": [],
})
}
}
impl NewPositionPlan {
pub(super) fn new(accounts: AccountPlan, branch: QuoteBranch) -> Result<Self, String> {
accounts.validate_ready()?;
Ok(Self { accounts, branch })
}
pub(super) fn requires_fresh_lp(&self) -> bool {
self.accounts.requires_fresh_lp()
}
}
impl AccountPlan {
pub(super) fn validate_snapshot_ids(
pair: &PairIds,
snapshot: &PairSnapshot,
) -> Option<&'static str> {
let expected = [
(&snapshot.config, pair.config, "config"),
(&snapshot.token_a, pair.token_a, "token_a"),
(&snapshot.token_b, pair.token_b, "token_b"),
(&snapshot.pool, pair.pool, "pool"),
(&snapshot.vault_a, pair.vault_a, "vault_a"),
(&snapshot.vault_b, pair.vault_b, "vault_b"),
(&snapshot.lp_definition, pair.lp_definition, "lp_definition"),
(
&snapshot.lp_lock_holding,
pair.lp_lock_holding,
"lp_lock_holding",
),
(&snapshot.current_tick, pair.current_tick, "current_tick"),
(&snapshot.clock, pair.clock, "clock"),
];
expected.into_iter().find_map(|(read, id, role)| {
match account_id_from_hex(&read.id, "account id") {
Ok(actual) if actual == id => None,
_ => Some(role),
}
})
}
pub(super) fn preview(&self) -> Vec<Value> {
self.rows
.iter()
.enumerate()
.map(|(order, row)| row.preview(order))
.collect()
}
pub(super) fn take_sources(&mut self) -> Vec<SourceCommitment> {
std::mem::take(&mut self.sources)
}
pub(super) fn requires_fresh_lp(&self) -> bool {
self.rows
.iter()
.any(|row| row.role == "user_holding_lp" && row.account_id.is_none())
}
pub(super) fn contains(&self, account_id: AccountId) -> bool {
self.rows
.iter()
.any(|row| row.account_id == Some(account_id))
}
pub(super) fn validate_ready(&self) -> Result<(), String> {
if self.rows.iter().any(|row| {
row.account_id.is_none() && !(row.role == "user_holding_lp" && row.signer && row.init)
}) {
return Err(String::from("submittable quote has an unresolved account"));
}
Ok(())
}
pub(super) fn wallet_args(
&self,
fresh_lp: Option<AccountId>,
) -> Result<(Vec<AccountId>, Vec<bool>), String> {
let mut account_ids = Vec::with_capacity(self.rows.len());
let mut signing_requirements = Vec::with_capacity(self.rows.len());
for row in &self.rows {
let account_id = match row.account_id {
Some(account_id) => account_id,
None if row.role == "user_holding_lp" => {
fresh_lp.ok_or_else(|| String::from("transaction plan has no LP holding"))?
}
None => return Err(String::from("transaction plan has an unresolved account")),
};
account_ids.push(account_id);
signing_requirements.push(row.signer);
}
Ok((account_ids, signing_requirements))
}
}
impl AccountPlanRow {
pub(super) fn new(
role: &'static str,
account_id: Option<AccountId>,
expected_program: Option<ProgramId>,
action: &'static str,
signer: bool,
init: bool,
) -> Self {
Self {
role,
account_id,
expected_program,
action,
signer,
init,
}
}
fn preview(&self, order: usize) -> Value {
json!({
"order": order,
"role": self.role,
"accountId": self.account_id.map(|id| id.to_string()),
"expectedProgramId": self.expected_program.map(program_id_base58),
"action": self.action,
"writable": self.action != "read",
"signer": self.signer,
"init": self.init,
})
}
}
+715
View File
@@ -0,0 +1,715 @@
use alloy_primitives::U256;
use amm_core::{
is_supported_fee_tier, isqrt_product, mul_div_floor, spot_price_q64_64, PoolDefinition,
FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY,
};
use nssa_core::{
account::{Account, AccountId},
program::ProgramId,
};
use serde_json::{json, Value};
use token_core::TokenDefinition;
use twap_oracle_core::CurrentTickAccount;
use super::{
accounts::{active_account_plan, missing_account_plan},
clock::decode_clock,
commitment::{QuoteCommitment, RequestCommitment},
context::fungible_definition,
funding::{funding_commitments, funding_issues, hash_quote},
holding::{decode_fungible_holding, select_holding, wallet_holdings},
pair::{derive_pair, is_canonical_pair, PairIds},
position::{
AccountPlan, AccountPlanHoldings, EvaluatedQuote, NewPositionPlan, QuoteBranch,
QuoteComputation,
},
quote_error::{fatal_quote, issue},
QuoteRequest,
};
use crate::account::{
decode_account, parse_base58_id, parse_program_id, program_id_bytes, AccountRead,
};
const DEFAULT_SLIPPAGE_BPS: u32 = 50;
const MAX_SLIPPAGE_BPS: u32 = 5_000;
const HIGH_SLIPPAGE_BPS: u32 = 2_000;
pub(super) const Q64: u128 = 1_u128 << 64;
pub(super) fn quote(request: QuoteRequest) -> Result<Value, String> {
Ok(compute_quote(&request)?.into_value(&request.request))
}
pub(super) fn compute_quote(input: &QuoteRequest) -> Result<QuoteComputation, String> {
let amm_program = parse_program_id(&input.amm_program_id)?;
let token_a = match parse_base58_id(&input.request.token_a_id, "token A id") {
Ok(id) => id,
Err(_) => return Ok(fatal_quote("invalid_token_id", &["tokenAId"], json!({}))),
};
let token_b = match parse_base58_id(&input.request.token_b_id, "token B id") {
Ok(id) => id,
Err(_) => return Ok(fatal_quote("invalid_token_id", &["tokenBId"], json!({}))),
};
if token_a == token_b {
return Ok(fatal_quote(
"same_token_pair",
&["tokenAId", "tokenBId"],
json!({}),
));
}
if !is_canonical_pair(token_a, token_b) {
return Ok(fatal_quote(
"non_canonical_pair",
&["tokenAId", "tokenBId"],
json!({}),
));
}
if !is_supported_fee_tier(u128::from(input.request.fee_bps)) {
return Ok(fatal_quote(
"invalid_fee_tier",
&["feeBps"],
json!({ "feeBps": input.request.fee_bps }),
));
}
let pair = match derive_pair(amm_program, token_a, token_b, &input.snapshot.config) {
Ok(pair) => pair,
Err(_) => return Ok(fatal_quote("config_unavailable", &[], json!({}))),
};
if let Some(error) = AccountPlan::validate_snapshot_ids(&pair, &input.snapshot) {
return Ok(fatal_quote(
"account_read_failed",
&[],
json!({ "role": error }),
));
}
for (read, token_id, field) in [
(&input.snapshot.token_a, token_a, "tokenAId"),
(&input.snapshot.token_b, token_b, "tokenBId"),
] {
if let Err(error) = fungible_definition(Some(read), token_id, pair.token_program) {
return Ok(fatal_quote(
error.code,
&[field],
json!({ "tokenId": token_id.to_string() }),
));
}
}
let (_, pool_account) = match decode_account(&input.snapshot.pool) {
Ok(value) => value,
Err(_) => {
return Ok(fatal_quote(
"account_read_failed",
&[],
json!({ "role": "pool", "accountId": pair.pool.to_string() }),
))
}
};
if pool_account == Account::default() {
compute_missing_quote(input, amm_program, pair)
} else {
compute_active_quote(input, amm_program, pair, pool_account)
}
}
fn compute_missing_quote(
input: &QuoteRequest,
amm_program: ProgramId,
pair: PairIds,
) -> Result<QuoteComputation, String> {
for (read, role) in [
(&input.snapshot.vault_a, "vault_a"),
(&input.snapshot.vault_b, "vault_b"),
(&input.snapshot.lp_definition, "lp_definition"),
(&input.snapshot.lp_lock_holding, "lp_lock_holding"),
(&input.snapshot.current_tick, "current_tick"),
] {
let Ok((_, account)) = decode_account(read) else {
return Ok(fatal_quote(
"account_read_failed",
&[],
json!({ "role": role }),
));
};
if account != Account::default() {
return Ok(fatal_quote(
"pool_unavailable",
&[],
json!({ "role": role }),
));
}
}
if !valid_clock(&input.snapshot.clock, pair.clock) {
return Ok(fatal_quote(
"account_read_failed",
&[],
json!({ "role": "clock" }),
));
}
let requested_price = match raw_value(input.request.initial_price_real_raw.as_deref()) {
Ok(value) if value > 0 => value,
Ok(_) => {
return Ok(fatal_quote(
"amount_must_be_positive",
&["initialPriceRealRaw"],
json!({}),
))
}
Err(code) => return Ok(fatal_quote(code, &["initialPriceRealRaw"], json!({}))),
};
let (minimum_a, minimum_b) = minimum_opening_pair(requested_price)?;
let direct_amounts =
input.request.amount_a_raw.is_some() || input.request.amount_b_raw.is_some();
let (amount_a, amount_b) = if direct_amounts {
let amount_a = match raw_value(input.request.amount_a_raw.as_deref()) {
Ok(value) if value > 0 => value,
Ok(_) => {
return Ok(fatal_quote(
"amount_must_be_positive",
&["amountARaw"],
json!({}),
))
}
Err(code) => return Ok(fatal_quote(code, &["amountARaw"], json!({}))),
};
let amount_b = match raw_value(input.request.amount_b_raw.as_deref()) {
Ok(value) if value > 0 => value,
Ok(_) => {
return Ok(fatal_quote(
"amount_must_be_positive",
&["amountBRaw"],
json!({}),
))
}
Err(code) => return Ok(fatal_quote(code, &["amountBRaw"], json!({}))),
};
if spot_price_q64_64(amount_a, amount_b) != requested_price {
return Ok(fatal_quote(
"deposit_ratio_mismatch",
&["amountARaw", "amountBRaw"],
json!({}),
));
}
(amount_a, amount_b)
} else {
(minimum_a, minimum_b)
};
let initial_lp = isqrt_product(amount_a, amount_b);
if initial_lp <= MINIMUM_LIQUIDITY {
return Ok(fatal_quote(
"amount_too_low",
&["amountARaw", "amountBRaw"],
json!({ "minimumLiquidityRaw": MINIMUM_LIQUIDITY.to_string() }),
));
}
let expected_lp = initial_lp - MINIMUM_LIQUIDITY;
let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program);
let holding_a = select_holding(&holdings, pair.token_a);
let holding_b = select_holding(&holdings, pair.token_b);
let funding = funding_issues(
input.snapshot.wallet_available,
pair,
&holding_a,
amount_a,
&holding_b,
amount_b,
["amountARaw", "amountBRaw"],
);
let can_submit = funding.is_empty();
let mut account_plan = missing_account_plan(
input,
pair,
amm_program,
AccountPlanHoldings {
token_a: holding_a.as_ref(),
token_b: holding_b.as_ref(),
lp: None,
},
)?;
let sources = account_plan.take_sources();
let funding_commitment = funding_commitments(pair, &holding_a, amount_a, &holding_b, amount_b);
let commitment = QuoteCommitment {
network_id: input.network_id.clone(),
network_fingerprint: input.network_fingerprint.clone(),
amm_program_id: program_id_bytes(amm_program),
token_a_id: pair.token_a.into_value(),
token_b_id: pair.token_b.into_value(),
fee_bps: input.request.fee_bps,
pool_status: 0,
request: RequestCommitment::Missing { amount_a, amount_b },
max_a: amount_a,
max_b: amount_b,
actual_a: amount_a,
actual_b: amount_b,
expected_lp,
lp_guard: MINIMUM_LIQUIDITY,
requires_fresh_lp: true,
sources,
funding: funding_commitment,
warnings: Vec::new(),
};
let quote_hash = hash_quote(&commitment)?;
let preview = account_plan.preview();
let value = json!({
"status": "ok",
"canSubmit": can_submit,
"code": if can_submit { "ready" } else { "funding_required" },
"poolStatus": "missing_pool",
"instruction": "NewDefinition",
"quoteHash": quote_hash,
"feeBps": input.request.fee_bps,
"poolId": pair.pool.to_string(),
"tokenAId": pair.token_a.to_string(),
"tokenBId": pair.token_b.to_string(),
"maxAmountARaw": amount_a.to_string(),
"maxAmountBRaw": amount_b.to_string(),
"actualAmountARaw": amount_a.to_string(),
"actualAmountBRaw": amount_b.to_string(),
"expectedLpRaw": expected_lp.to_string(),
"lockedLpRaw": MINIMUM_LIQUIDITY.to_string(),
"initialPriceRealRaw": spot_price_q64_64(amount_a, amount_b).to_string(),
"minimumAmountARaw": minimum_a.to_string(),
"minimumAmountBRaw": minimum_b.to_string(),
"requiresFreshLp": true,
"accountPreview": preview,
"errors": funding,
"warnings": [],
});
let plan = if can_submit {
Some(NewPositionPlan::new(
account_plan,
QuoteBranch::Missing { amount_a, amount_b },
)?)
} else {
None
};
Ok(QuoteComputation::Evaluated(EvaluatedQuote {
value,
quote_hash,
plan,
}))
}
fn compute_active_quote(
input: &QuoteRequest,
amm_program: ProgramId,
pair: PairIds,
pool_account: Account,
) -> Result<QuoteComputation, String> {
if pool_account.program_owner != amm_program {
return Ok(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "owner_mismatch" }),
));
}
let Ok(pool) = PoolDefinition::try_from(&pool_account.data) else {
return Ok(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "invalid_pool_data" }),
));
};
let stored_reversed = if pool.definition_token_a_id == pair.token_a
&& pool.definition_token_b_id == pair.token_b
{
false
} else if pool.definition_token_a_id == pair.token_b
&& pool.definition_token_b_id == pair.token_a
{
true
} else {
return Ok(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "pair_mismatch" }),
));
};
if pool.reserve_a == 0 || pool.reserve_b == 0 || pool.liquidity_pool_supply == 0 {
return Ok(fatal_quote("pool_inactive", &[], json!({})));
}
if pool.fees != u128::from(input.request.fee_bps) {
return Ok(fatal_quote(
"fee_tier_mismatch",
&["feeBps"],
json!({ "poolFeeBps": pool.fees.to_string() }),
));
}
if !is_supported_fee_tier(pool.fees) {
return Ok(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "unsupported_pool_fee" }),
));
}
let max_a = match raw_value(input.request.max_amount_a_raw.as_deref()) {
Ok(value) if value > 0 => value,
Ok(_) => {
return Ok(fatal_quote(
"amount_must_be_positive",
&["maxAmountARaw"],
json!({}),
))
}
Err(code) => return Ok(fatal_quote(code, &["maxAmountARaw"], json!({}))),
};
let max_b = match raw_value(input.request.max_amount_b_raw.as_deref()) {
Ok(value) if value > 0 => value,
Ok(_) => {
return Ok(fatal_quote(
"amount_must_be_positive",
&["maxAmountBRaw"],
json!({}),
))
}
Err(code) => return Ok(fatal_quote(code, &["maxAmountBRaw"], json!({}))),
};
let slippage_bps = input.request.slippage_bps.unwrap_or(DEFAULT_SLIPPAGE_BPS);
if slippage_bps > MAX_SLIPPAGE_BPS {
return Ok(fatal_quote(
"invalid_slippage",
&["slippageBps"],
json!({ "maximum": MAX_SLIPPAGE_BPS }),
));
}
let (stored_max_a, stored_max_b) = if stored_reversed {
(max_b, max_a)
} else {
(max_a, max_b)
};
let ideal_a = mul_div_floor(pool.reserve_a, stored_max_b, pool.reserve_b);
let ideal_b = mul_div_floor(pool.reserve_b, stored_max_a, pool.reserve_a);
let stored_actual_a = stored_max_a.min(ideal_a);
let stored_actual_b = stored_max_b.min(ideal_b);
if stored_actual_a == 0 || stored_actual_b == 0 {
return Ok(fatal_quote(
"amount_too_low",
&["maxAmountARaw", "maxAmountBRaw"],
json!({}),
));
}
let expected_lp =
mul_div_floor(pool.liquidity_pool_supply, stored_actual_a, pool.reserve_a).min(
mul_div_floor(pool.liquidity_pool_supply, stored_actual_b, pool.reserve_b),
);
if expected_lp == 0 {
return Ok(fatal_quote(
"amount_too_low",
&["maxAmountARaw", "maxAmountBRaw"],
json!({}),
));
}
let minimum_lp = mul_div_floor(
expected_lp,
FEE_BPS_DENOMINATOR - u128::from(slippage_bps),
FEE_BPS_DENOMINATOR,
);
if minimum_lp == 0 {
return Ok(fatal_quote("minimum_lp_zero", &["slippageBps"], json!({})));
}
let (actual_a, actual_b, reserve_a, reserve_b) = if stored_reversed {
(
stored_actual_b,
stored_actual_a,
pool.reserve_b,
pool.reserve_a,
)
} else {
(
stored_actual_a,
stored_actual_b,
pool.reserve_a,
pool.reserve_b,
)
};
if let Some(error) = validate_active_accounts(input, pair, &pool, stored_reversed) {
return Ok(error);
}
let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program);
let holding_a = select_holding(&holdings, pair.token_a);
let holding_b = select_holding(&holdings, pair.token_b);
let lp_holding = select_holding(&holdings, pair.lp_definition);
let requires_fresh_lp = lp_holding.is_none();
let funding = funding_issues(
input.snapshot.wallet_available,
pair,
&holding_a,
actual_a,
&holding_b,
actual_b,
["maxAmountARaw", "maxAmountBRaw"],
);
let can_submit = funding.is_empty();
let warnings = if slippage_bps >= HIGH_SLIPPAGE_BPS {
vec![issue(
"high_slippage",
"High slippage tolerance.",
&["slippageBps"],
json!({ "slippageBps": slippage_bps }),
)]
} else {
Vec::new()
};
let warning_codes = warnings
.iter()
.filter_map(|warning| warning["code"].as_str().map(String::from))
.collect();
let mut account_plan = active_account_plan(
input,
pair,
amm_program,
&pool,
stored_reversed,
AccountPlanHoldings {
token_a: holding_a.as_ref(),
token_b: holding_b.as_ref(),
lp: lp_holding.as_ref(),
},
)?;
let sources = account_plan.take_sources();
let commitment = QuoteCommitment {
network_id: input.network_id.clone(),
network_fingerprint: input.network_fingerprint.clone(),
amm_program_id: program_id_bytes(amm_program),
token_a_id: pair.token_a.into_value(),
token_b_id: pair.token_b.into_value(),
fee_bps: input.request.fee_bps,
pool_status: 1,
request: RequestCommitment::Active {
max_a,
max_b,
slippage_bps,
},
max_a,
max_b,
actual_a,
actual_b,
expected_lp,
lp_guard: minimum_lp,
requires_fresh_lp,
sources,
funding: funding_commitments(pair, &holding_a, actual_a, &holding_b, actual_b),
warnings: warning_codes,
};
let quote_hash = hash_quote(&commitment)?;
let preview = account_plan.preview();
let value = json!({
"status": "ok",
"canSubmit": can_submit,
"code": if can_submit { "ready" } else { "funding_required" },
"poolStatus": "active_pool",
"instruction": "AddLiquidity",
"quoteHash": quote_hash,
"feeBps": input.request.fee_bps,
"poolFeeBps": pool.fees.to_string(),
"poolId": pair.pool.to_string(),
"tokenAId": pair.token_a.to_string(),
"tokenBId": pair.token_b.to_string(),
"maxAmountARaw": max_a.to_string(),
"maxAmountBRaw": max_b.to_string(),
"actualAmountARaw": actual_a.to_string(),
"actualAmountBRaw": actual_b.to_string(),
"reserveARaw": reserve_a.to_string(),
"reserveBRaw": reserve_b.to_string(),
"liquiditySupplyRaw": pool.liquidity_pool_supply.to_string(),
"expectedLpRaw": expected_lp.to_string(),
"minimumLpRaw": minimum_lp.to_string(),
"initialPriceRealRaw": spot_price_q64_64(reserve_a, reserve_b).to_string(),
"requiresFreshLp": requires_fresh_lp,
"accountPreview": preview,
"errors": funding,
"warnings": warnings,
});
let plan = if can_submit {
Some(NewPositionPlan::new(
account_plan,
QuoteBranch::Active {
max_a,
max_b,
minimum_lp,
stored_reversed,
},
)?)
} else {
None
};
Ok(QuoteComputation::Evaluated(EvaluatedQuote {
value,
quote_hash,
plan,
}))
}
fn validate_active_accounts(
input: &QuoteRequest,
pair: PairIds,
pool: &PoolDefinition,
stored_reversed: bool,
) -> Option<QuoteComputation> {
let expected_vault_a = if stored_reversed {
pair.vault_b
} else {
pair.vault_a
};
let expected_vault_b = if stored_reversed {
pair.vault_a
} else {
pair.vault_b
};
if pool.vault_a_id != expected_vault_a
|| pool.vault_b_id != expected_vault_b
|| pool.liquidity_pool_id != pair.lp_definition
{
return Some(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "pool_account_mismatch" }),
));
}
let (canonical_vault_a, canonical_vault_b) = (
decode_holding(&input.snapshot.vault_a, pair.token_program, pair.token_a),
decode_holding(&input.snapshot.vault_b, pair.token_program, pair.token_b),
);
let (Ok(vault_a_balance), Ok(vault_b_balance)) = (canonical_vault_a, canonical_vault_b) else {
return Some(fatal_quote(
"account_read_failed",
&[],
json!({ "role": "vault" }),
));
};
let (reserve_a, reserve_b) = if stored_reversed {
(pool.reserve_b, pool.reserve_a)
} else {
(pool.reserve_a, pool.reserve_b)
};
if vault_a_balance < reserve_a || vault_b_balance < reserve_b {
return Some(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "vault_below_reserve" }),
));
}
let Ok((lp_id, lp_account)) = decode_account(&input.snapshot.lp_definition) else {
return Some(fatal_quote(
"account_read_failed",
&[],
json!({ "role": "lp_definition" }),
));
};
if lp_id != pair.lp_definition
|| lp_account.program_owner != pair.token_program
|| !matches!(
TokenDefinition::try_from(&lp_account.data),
Ok(TokenDefinition::Fungible { .. })
)
{
return Some(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "invalid_lp_definition" }),
));
}
let Ok((tick_id, tick_account)) = decode_account(&input.snapshot.current_tick) else {
return Some(fatal_quote(
"account_read_failed",
&[],
json!({ "role": "current_tick" }),
));
};
if tick_id != pair.current_tick
|| tick_account.program_owner != pair.twap_program
|| CurrentTickAccount::try_from(&tick_account.data).is_err()
{
return Some(fatal_quote(
"pool_unavailable",
&[],
json!({ "reason": "invalid_current_tick" }),
));
}
if !valid_clock(&input.snapshot.clock, pair.clock) {
return Some(fatal_quote(
"account_read_failed",
&[],
json!({ "role": "clock" }),
));
}
None
}
fn decode_holding(
read: &AccountRead,
token_program: ProgramId,
definition_id: AccountId,
) -> Result<u128, String> {
let holding = decode_fungible_holding(read, token_program)?;
if holding.definition_id != definition_id {
return Err(String::from("invalid fungible holding"));
}
Ok(holding.balance)
}
fn valid_clock(read: &AccountRead, expected_id: AccountId) -> bool {
matches!(decode_clock(read), Ok((id, _)) if id == expected_id)
}
fn raw_value(value: Option<&str>) -> Result<u128, &'static str> {
let Some(value) = value else {
return Err("amount_required");
};
if value.is_empty() {
return Err("amount_required");
}
if !value.bytes().all(|byte| byte.is_ascii_digit()) {
return Err("invalid_raw_amount");
}
value.parse().map_err(|_| "invalid_raw_amount")
}
pub(super) fn minimum_opening_pair(price: u128) -> Result<(u128, u128), String> {
let minimum_initial_lp = U256::from(MINIMUM_LIQUIDITY + 1);
let target_product = minimum_initial_lp
.checked_mul(minimum_initial_lp)
.ok_or_else(|| String::from("minimum liquidity product overflow"))?;
if price >= Q64 {
let amount_a = binary_search_min(1, MINIMUM_LIQUIDITY + 1, |amount_a| {
let amount_b = div_ceil_u256(U256::from(amount_a) * U256::from(price), U256::from(Q64));
U256::from(amount_a) * amount_b >= target_product
});
let amount_b = div_ceil_u256(U256::from(amount_a) * U256::from(price), U256::from(Q64));
Ok((
amount_a,
u128::try_from(amount_b).map_err(|_| String::from("opening amount overflow"))?,
))
} else {
let amount_b = binary_search_min(1, MINIMUM_LIQUIDITY + 1, |amount_b| {
let amount_a = div_ceil_u256(U256::from(amount_b) * U256::from(Q64), U256::from(price));
amount_a * U256::from(amount_b) >= target_product
});
let amount_a = div_ceil_u256(U256::from(amount_b) * U256::from(Q64), U256::from(price));
Ok((
u128::try_from(amount_a).map_err(|_| String::from("opening amount overflow"))?,
amount_b,
))
}
}
fn binary_search_min(mut low: u128, mut high: u128, predicate: impl Fn(u128) -> bool) -> u128 {
while low < high {
let mid = low + (high - low) / 2;
if predicate(mid) {
high = mid;
} else {
low = mid + 1;
}
}
low
}
pub(super) fn div_ceil_u256(numerator: U256, denominator: U256) -> U256 {
numerator.div_ceil(denominator)
}
+25
View File
@@ -0,0 +1,25 @@
use serde_json::{json, Value};
use super::position::{QuoteComputation, QuoteFailure};
pub(super) fn issue(code: &str, message: &str, fields: &[&str], details: Value) -> Value {
json!({
"code": code,
"message": message,
"details": details,
"recoverable": true,
"blockingFields": fields,
})
}
pub(super) fn fatal_quote(
code: &'static str,
fields: &[&'static str],
details: Value,
) -> QuoteComputation {
QuoteComputation::Failed(QuoteFailure {
code,
fields: fields.to_vec(),
details,
})
}
+150
View File
@@ -0,0 +1,150 @@
use serde::Deserialize;
use crate::account::AccountRead;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ConfigIdRequest {
pub amm_program_id: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TokenIdsRequest {
pub amm_program_id: String,
pub config: AccountRead,
#[serde(default)]
pub wallet_accounts: Vec<AccountRead>,
#[serde(default)]
pub configured_token_ids: Vec<String>,
#[serde(default)]
pub recent_token_ids: Vec<String>,
#[serde(default)]
pub resolved_token_ids: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ContextRequest {
pub network_id: String,
pub network_fingerprint: String,
pub amm_program_id: String,
pub wallet_available: bool,
pub config: AccountRead,
#[serde(default)]
pub wallet_accounts: Vec<AccountRead>,
#[serde(default)]
pub token_definitions: Vec<AccountRead>,
#[serde(default)]
pub configured_token_ids: Vec<String>,
#[serde(default)]
pub recent_token_ids: Vec<String>,
#[serde(default)]
pub resolved_token_ids: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PairIdsRequest {
pub amm_program_id: String,
pub config: AccountRead,
pub token_a_id: String,
pub token_b_id: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SwapPairRequest {
pub amm_program_id: String,
pub token_in_id: String,
pub token_out_id: String,
pub config: AccountRead,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ResolvePoolRequest {
pub pool: AccountRead,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SwapPlanRequest {
pub amm_program_id: String,
pub token_in_id: String,
pub token_out_id: String,
pub config: AccountRead,
pub user_input_holding_id: String,
pub user_output_holding_id: String,
pub amount_in: String,
pub min_out: String,
pub deadline_ms: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProgramIdRequest {
pub elf: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PositionRequest {
pub token_a_id: String,
pub token_b_id: String,
pub fee_bps: u32,
#[serde(default)]
pub amount_a_raw: Option<String>,
#[serde(default)]
pub amount_b_raw: Option<String>,
#[serde(default)]
pub max_amount_a_raw: Option<String>,
#[serde(default)]
pub max_amount_b_raw: Option<String>,
#[serde(default)]
pub slippage_bps: Option<u32>,
#[serde(default)]
pub initial_price_real_raw: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PairSnapshot {
pub config: AccountRead,
pub token_a: AccountRead,
pub token_b: AccountRead,
pub pool: AccountRead,
pub vault_a: AccountRead,
pub vault_b: AccountRead,
pub lp_definition: AccountRead,
pub lp_lock_holding: AccountRead,
pub current_tick: AccountRead,
pub clock: AccountRead,
pub wallet_available: bool,
#[serde(default)]
pub wallet_accounts: Vec<AccountRead>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct QuoteRequest {
pub network_id: String,
pub network_fingerprint: String,
pub amm_program_id: String,
pub request: PositionRequest,
pub snapshot: PairSnapshot,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlanRequest {
pub network_id: String,
pub network_fingerprint: String,
pub amm_program_id: String,
pub request: PositionRequest,
pub snapshot: PairSnapshot,
pub quote_hash: String,
pub now_ms: u64,
#[serde(default)]
pub fresh_lp: Option<AccountRead>,
}
+268
View File
@@ -0,0 +1,268 @@
//! Swap operations — pool discovery/decode, swap-transaction planning, and
//! program-id derivation. Same transport-independent pattern as the
//! new-position ops: pure functions returning JSON `Value`, PDAs reused from
//! `pair::derive_pair` so the swap path never re-derives seeds.
use amm_core::PoolDefinition;
use nssa_core::account::AccountId;
use risc0_binfmt::ProgramBinary;
use serde_json::{json, Value};
use super::{
pair::{derive_pair, is_canonical_pair},
ProgramIdRequest, ResolvePoolRequest, SwapPairRequest, SwapPlanRequest,
};
use crate::account::{
account_id_from_hex, account_id_hex, decode_account, parse_program_id, program_id_bytes,
};
/// Orders `(token_in, token_out)` into the pool's canonical `(token_a, token_b)`
/// so derived vault PDAs line up with the pool's stored `vault_a`/`vault_b`.
fn canonical_pair(token_in: AccountId, token_out: AccountId) -> (AccountId, AccountId) {
if is_canonical_pair(token_in, token_out) {
(token_in, token_out)
} else {
(token_out, token_in)
}
}
fn parse_u128(value: &str, label: &str) -> Result<u128, String> {
value
.parse::<u128>()
.map_err(|error| format!("invalid {label}: {error}"))
}
fn parse_u64(value: &str, label: &str) -> Result<u64, String> {
value
.parse::<u64>()
.map_err(|error| format!("invalid {label}: {error}"))
}
/// Derives the canonical account ids for a swap pair — reuses `derive_pair`
/// after ordering `(token_in, token_out)` into `(token_a, token_b)`, so a
/// caller can read the pool account before decoding it. Like `pair_ids`, but
/// accepts the tokens in either order.
pub(super) fn swap_pair(request: SwapPairRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
let token_in = account_id_from_hex(&request.token_in_id, "token in id")?;
let token_out = account_id_from_hex(&request.token_out_id, "token out id")?;
if token_in == token_out {
return Ok(json!({ "status": "error", "code": "same_token_pair" }));
}
let (token_a, token_b) = canonical_pair(token_in, token_out);
let Ok(pair) = derive_pair(amm_program, token_a, token_b, &request.config) else {
return Ok(json!({ "status": "error", "code": "config_unavailable" }));
};
Ok(json!({
"status": "ok",
"configId": account_id_hex(pair.config),
"poolId": account_id_hex(pair.pool),
"vaultAId": account_id_hex(pair.vault_a),
"vaultBId": account_id_hex(pair.vault_b),
"currentTickId": account_id_hex(pair.current_tick),
"clockId": account_id_hex(pair.clock),
}))
}
/// Decodes a pool account: whether it holds liquidity, its canonical token ids
/// (`defAHex`/`defBHex`), its reserves (same canonical `a`/`b` order — the
/// caller matches a reserve to its own direction by comparing its token id
/// against `defAHex`), and fee tier. Absent/empty/uninitialized pool →
/// `{ exists: false }`.
pub(super) fn resolve_pool(request: ResolvePoolRequest) -> Result<Value, String> {
if request.pool.status != "ok" {
return Ok(json!({ "exists": false }));
}
let Ok((_, pool_account)) = decode_account(&request.pool) else {
return Ok(json!({ "exists": false }));
};
let Ok(pool) = PoolDefinition::try_from(&pool_account.data) else {
return Ok(json!({ "exists": false }));
};
if pool.liquidity_pool_supply == 0 {
return Ok(json!({ "exists": false }));
}
let fee_bps =
u32::try_from(pool.fees).map_err(|_| String::from("pool fee tier exceeds u32"))?;
Ok(json!({
"exists": true,
"defAHex": account_id_hex(pool.definition_token_a_id),
"defBHex": account_id_hex(pool.definition_token_b_id),
"reserveA": pool.reserve_a.to_string(),
"reserveB": pool.reserve_b.to_string(),
"feeBps": fee_bps,
}))
}
/// Builds the `SwapExactInput` submission for a pair: the fixed 8-account IDL
/// order (vaults canonical, only the user's input holding signs) and the
/// instruction words (`risc0_zkvm::serde` — the same encoding the guest
/// decodes). Mirrors `plan.rs`'s `ready` output shape.
pub(super) fn swap_plan(request: SwapPlanRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
let token_in = account_id_from_hex(&request.token_in_id, "token in id")?;
let token_out = account_id_from_hex(&request.token_out_id, "token out id")?;
// Domain errors (a bad pair, an unavailable config) mirror `swap_pair`'s
// `{ status: "error", code }` shape rather than `Err`, which is reserved for
// malformed inputs. Callers treat any non-"ready" status as a failed plan,
// so both map to the same outcome.
if token_in == token_out {
return Ok(json!({ "status": "error", "code": "same_token_pair" }));
}
let (token_a, token_b) = canonical_pair(token_in, token_out);
let Ok(pair) = derive_pair(amm_program, token_a, token_b, &request.config) else {
return Ok(json!({ "status": "error", "code": "config_unavailable" }));
};
let user_input_holding =
account_id_from_hex(&request.user_input_holding_id, "user input holding id")?;
let user_output_holding =
account_id_from_hex(&request.user_output_holding_id, "user output holding id")?;
let swap_amount_in = parse_u128(&request.amount_in, "amountIn")?;
let min_amount_out = parse_u128(&request.min_out, "minOut")?;
let deadline = parse_u64(&request.deadline_ms, "deadlineMs")?;
let instruction = risc0_zkvm::serde::to_vec(&amm_core::Instruction::SwapExactInput {
swap_amount_in,
min_amount_out,
deadline,
})
.map_err(|error| format!("instruction serialization failed: {error}"))?;
// Fixed IDL account order for SwapExactInput; only user_input_holding signs.
let account_ids = [
pair.config,
pair.pool,
pair.vault_a,
pair.vault_b,
user_input_holding,
user_output_holding,
pair.current_tick,
pair.clock,
];
let signing_requirements = [false, false, false, false, true, false, false, false];
Ok(json!({
"status": "ready",
"programId": request.amm_program_id,
"accountIds": account_ids.into_iter().map(account_id_hex).collect::<Vec<_>>(),
"signingRequirements": signing_requirements,
"instruction": instruction,
"deadlineMs": deadline.to_string(),
}))
}
/// Computes the AMM `ProgramId` (RISC Zero Image ID) of a deployed program
/// binary. `elf` is the hex-encoded `.bin` (a RISC Zero `ProgramBinary`, not a
/// raw ELF) — decoded, image-id computed, returned as 64-char lowercase hex.
pub(super) fn program_id(request: ProgramIdRequest) -> Result<Value, String> {
let elf = hex::decode(&request.elf).map_err(|error| format!("invalid elf hex: {error}"))?;
let binary = ProgramBinary::decode(&elf).map_err(|error| format!("{error:?}"))?;
let image_id: nssa_core::program::ProgramId = binary
.compute_image_id()
.map_err(|error| format!("{error:?}"))?
.into();
Ok(json!({ "programId": hex::encode(program_id_bytes(image_id)) }))
}
#[cfg(test)]
mod tests {
use amm_core::PoolDefinition;
use nssa_core::account::AccountId;
use super::*;
use crate::account::{AccountRead, WalletAccount};
fn pool_read(pool: &PoolDefinition) -> AccountRead {
AccountRead {
id: "11".repeat(32),
status: String::from("ok"),
account: Some(WalletAccount {
program_owner: "00".repeat(32),
balance: "0".repeat(32),
nonce: "0".repeat(32),
data: hex::encode(borsh::to_vec(pool).unwrap()),
}),
}
}
#[test]
fn resolve_pool_reports_canonical_token_ids() {
let def_a = AccountId::new([0xAA; 32]);
let def_b = AccountId::new([0xBB; 32]);
let pool = PoolDefinition {
definition_token_a_id: def_a,
definition_token_b_id: def_b,
liquidity_pool_supply: 1_000,
reserve_a: 111,
reserve_b: 222,
fees: 30,
..Default::default()
};
let value = resolve_pool(ResolvePoolRequest {
pool: pool_read(&pool),
})
.unwrap();
assert_eq!(value["exists"], true);
// The Swap UI matches reserveA/reserveB to its own sell/buy direction by
// comparing the sold token's id against defAHex — so both ids must be
// present in canonical order.
assert_eq!(value["defAHex"], account_id_hex(def_a));
assert_eq!(value["defBHex"], account_id_hex(def_b));
assert_eq!(value["reserveA"], "111");
assert_eq!(value["reserveB"], "222");
assert_eq!(value["feeBps"], 30);
}
#[test]
fn resolve_pool_absent_when_no_liquidity() {
let pool = PoolDefinition {
liquidity_pool_supply: 0,
..Default::default()
};
let value = resolve_pool(ResolvePoolRequest {
pool: pool_read(&pool),
})
.unwrap();
assert_eq!(value, json!({ "exists": false }));
}
#[test]
fn same_token_is_a_recoverable_domain_error_in_both_ops() {
let program = "00".repeat(32);
let same = "aa".repeat(32);
let expected = json!({ "status": "error", "code": "same_token_pair" });
// Not reached before the same-token check, so its contents don't matter.
let dummy_config = AccountRead {
id: String::new(),
status: String::from("read_failed"),
account: None,
};
let pair = swap_pair(SwapPairRequest {
amm_program_id: program.clone(),
token_in_id: same.clone(),
token_out_id: same.clone(),
config: dummy_config.clone(),
})
.unwrap();
assert_eq!(pair, expected);
let plan = swap_plan(SwapPlanRequest {
amm_program_id: program,
token_in_id: same.clone(),
token_out_id: same,
config: dummy_config,
user_input_holding_id: String::new(),
user_output_holding_id: String::new(),
amount_in: String::new(),
min_out: String::new(),
deadline_ms: String::new(),
})
.unwrap();
assert_eq!(plan, expected);
}
}
+702
View File
@@ -0,0 +1,702 @@
use alloy_primitives::U256;
use amm_core::{
compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda,
compute_vault_pda, isqrt_product, spot_price_q64_64, AmmConfig, PoolDefinition,
MINIMUM_LIQUIDITY,
};
use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID};
use nssa_core::{
account::{Account, AccountId, Data, Nonce},
program::ProgramId,
};
use pretty_assertions::assert_eq;
use serde_json::{json, Value};
use token_core::{TokenDefinition, TokenHolding};
use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount};
use super::{
accounts::{active_account_plan, missing_account_plan},
context::{context, token_ids},
holding::{select_holding, wallet_holdings, SelectedHolding},
pair::{is_canonical_pair, pair_ids, PairIds},
plan::plan,
position::AccountPlanHoldings,
quote::{div_ceil_u256, minimum_opening_pair, quote, Q64},
ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest, QuoteRequest,
TokenIdsRequest,
};
use crate::{
account::{account_id_hex, account_read, decode_account, parse_base58_id, program_id_bytes},
AccountRead,
};
const AMM_PROGRAM: ProgramId = [11; 8];
const TOKEN_PROGRAM: ProgramId = [22; 8];
const TWAP_PROGRAM: ProgramId = [33; 8];
fn account(owner: ProgramId, data: Data) -> Account {
Account {
program_owner: owner,
balance: 0,
data,
nonce: Nonce(0),
}
}
fn default_read(id: AccountId) -> AccountRead {
account_read(id, &Account::default())
}
fn config_account() -> Account {
account(
AMM_PROGRAM,
Data::from(&AmmConfig {
token_program_id: TOKEN_PROGRAM,
twap_oracle_program_id: TWAP_PROGRAM,
authority: AccountId::new([7; 32]),
}),
)
}
fn token_definition(name: &str, supply: u128) -> Account {
account(
TOKEN_PROGRAM,
Data::from(&TokenDefinition::Fungible {
name: String::from(name),
total_supply: supply,
metadata_id: None,
authority: None,
}),
)
}
fn token_holding(definition_id: AccountId, balance: u128) -> Account {
account(
TOKEN_PROGRAM,
Data::from(&TokenHolding::Fungible {
definition_id,
balance,
}),
)
}
fn clock_account() -> Account {
account(
[44; 8],
Data::try_from(
ClockAccountData {
block_id: 10,
timestamp: 1_000,
}
.to_bytes(),
)
.unwrap(),
)
}
fn ids() -> PairIds {
let token_a = AccountId::new([2; 32]);
let token_b = AccountId::new([1; 32]);
let config = compute_config_pda(AMM_PROGRAM);
let pool = compute_pool_pda(AMM_PROGRAM, token_a, token_b);
PairIds {
token_a,
token_b,
config,
pool,
vault_a: compute_vault_pda(AMM_PROGRAM, pool, token_a),
vault_b: compute_vault_pda(AMM_PROGRAM, pool, token_b),
lp_definition: compute_liquidity_token_pda(AMM_PROGRAM, pool),
lp_lock_holding: compute_lp_lock_holding_pda(AMM_PROGRAM, pool),
current_tick: compute_current_tick_account_pda(TWAP_PROGRAM, pool),
clock: CLOCK_01_PROGRAM_ACCOUNT_ID,
token_program: TOKEN_PROGRAM,
twap_program: TWAP_PROGRAM,
}
}
fn base_snapshot(pair: PairIds) -> PairSnapshot {
let holding_a_id = AccountId::new([61; 32]);
let holding_b_id = AccountId::new([62; 32]);
PairSnapshot {
config: account_read(pair.config, &config_account()),
token_a: account_read(pair.token_a, &token_definition("A", 1_000_000)),
token_b: account_read(pair.token_b, &token_definition("B", 2_000_000)),
pool: default_read(pair.pool),
vault_a: default_read(pair.vault_a),
vault_b: default_read(pair.vault_b),
lp_definition: default_read(pair.lp_definition),
lp_lock_holding: default_read(pair.lp_lock_holding),
current_tick: default_read(pair.current_tick),
clock: account_read(pair.clock, &clock_account()),
wallet_available: true,
wallet_accounts: vec![
account_read(holding_a_id, &token_holding(pair.token_a, 1_000_000)),
account_read(holding_b_id, &token_holding(pair.token_b, 1_000_000)),
],
}
}
fn request(pair: PairIds) -> PositionRequest {
assert!(is_canonical_pair(pair.token_a, pair.token_b));
PositionRequest {
token_a_id: pair.token_a.to_string(),
token_b_id: pair.token_b.to_string(),
fee_bps: 30,
amount_a_raw: None,
amount_b_raw: None,
max_amount_a_raw: None,
max_amount_b_raw: None,
slippage_bps: None,
initial_price_real_raw: Some(Q64.to_string()),
}
}
fn amm_program_id() -> String {
hex::encode(program_id_bytes(AMM_PROGRAM))
}
struct Scenario {
pair: PairIds,
request: PositionRequest,
snapshot: PairSnapshot,
network_id: &'static str,
network_fingerprint: &'static str,
}
impl Scenario {
fn devnet() -> Self {
Self::new("devnet", "channel:test")
}
fn testnet() -> Self {
Self::new("testnet", "block10:test")
}
fn new(network_id: &'static str, network_fingerprint: &'static str) -> Self {
let pair = ids();
Self {
pair,
request: request(pair),
snapshot: base_snapshot(pair),
network_id,
network_fingerprint,
}
}
fn quote_request(&self) -> QuoteRequest {
QuoteRequest {
network_id: String::from(self.network_id),
network_fingerprint: String::from(self.network_fingerprint),
amm_program_id: amm_program_id(),
request: self.request.clone(),
snapshot: self.snapshot.clone(),
}
}
fn quote(&self) -> Value {
quote(self.quote_request()).unwrap()
}
fn plan(self, quote_hash: impl Into<String>, fresh_lp: Option<AccountRead>) -> Value {
plan(PlanRequest {
network_id: String::from(self.network_id),
network_fingerprint: String::from(self.network_fingerprint),
amm_program_id: amm_program_id(),
request: self.request,
snapshot: self.snapshot,
quote_hash: quote_hash.into(),
now_ms: 2_000,
fresh_lp,
})
.unwrap()
}
}
fn assert_preview_matches_plan(
quote_value: &Value,
plan_value: &Value,
fresh_lp: Option<AccountId>,
) {
let preview = quote_value["accountPreview"].as_array().unwrap();
let account_ids = plan_value["accountIds"].as_array().unwrap();
let signing_requirements = plan_value["signingRequirements"].as_array().unwrap();
assert_eq!(preview.len(), account_ids.len());
assert_eq!(preview.len(), signing_requirements.len());
for (order, row) in preview.iter().enumerate() {
assert_eq!(row["order"], order);
assert_eq!(row["signer"], signing_requirements[order]);
if let Some(account_id) = row["accountId"].as_str() {
let account_id = parse_base58_id(account_id, "preview account id").unwrap();
assert_eq!(account_ids[order], account_id_hex(account_id));
} else {
assert_eq!(row["role"], "user_holding_lp");
assert_eq!(account_ids[order], account_id_hex(fresh_lp.unwrap()));
}
}
}
#[test]
fn account_plan_sources_follow_pool_branch() {
let scenario = Scenario::devnet();
let pair = scenario.pair;
let input = scenario.quote_request();
let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program);
let holding_a = select_holding(&holdings, pair.token_a);
let holding_b = select_holding(&holdings, pair.token_b);
let missing = missing_account_plan(
&input,
pair,
AMM_PROGRAM,
AccountPlanHoldings {
token_a: holding_a.as_ref(),
token_b: holding_b.as_ref(),
lp: None,
},
)
.unwrap();
assert_eq!(
missing
.sources
.iter()
.map(|source| source.role.as_str())
.collect::<Vec<_>>(),
vec![
"config",
"token_a",
"token_b",
"pool",
"vault_a",
"vault_b",
"lp_definition",
"lp_lock_holding",
"current_tick",
"holding_a",
"holding_b",
]
);
let active = active_account_plan(
&input,
pair,
AMM_PROGRAM,
&PoolDefinition::default(),
false,
AccountPlanHoldings {
token_a: holding_a.as_ref(),
token_b: holding_b.as_ref(),
lp: None,
},
)
.unwrap();
assert_eq!(
active
.sources
.iter()
.map(|source| source.role.as_str())
.collect::<Vec<_>>(),
vec![
"config",
"token_a",
"token_b",
"pool",
"vault_a",
"vault_b",
"lp_definition",
"current_tick",
"holding_a",
"holding_b",
]
);
}
#[test]
fn minimum_pair_exceeds_protocol_lock() {
for price in [1, Q64 / 2_500, Q64 / 10, Q64, Q64 * 2, u128::MAX] {
let (amount_a, amount_b) = minimum_opening_pair(price).unwrap();
assert!(amount_a > 0);
assert!(amount_b > 0);
assert!(isqrt_product(amount_a, amount_b) > MINIMUM_LIQUIDITY);
}
}
#[test]
fn minimum_pair_is_minimal_on_price_base_side() {
let (amount_a, amount_b) = minimum_opening_pair(Q64 * 2).unwrap();
assert!(isqrt_product(amount_a, amount_b) > MINIMUM_LIQUIDITY);
let previous_b = div_ceil_u256(
U256::from(amount_a - 1) * U256::from(Q64 * 2),
U256::from(Q64),
);
assert!(
U256::from(amount_a - 1) * previous_b <= U256::from(MINIMUM_LIQUIDITY * MINIMUM_LIQUIDITY)
);
}
#[test]
fn highest_balance_holding_wins_then_lowest_id() {
let definition = AccountId::new([9; 32]);
let holding = |id: u8, balance| SelectedHolding {
id: AccountId::new([id; 32]),
definition_id: definition,
balance,
account: account(
TOKEN_PROGRAM,
Data::from(&TokenHolding::Fungible {
definition_id: definition,
balance,
}),
),
};
let selected = select_holding(
&[holding(4, 10), holding(2, 20), holding(1, 20)],
definition,
)
.unwrap();
assert_eq!(selected.id, AccountId::new([1; 32]));
}
#[test]
fn pair_manifest_uses_canonical_ids_and_current_program_types() {
let token_a = AccountId::new([2; 32]);
let token_b = AccountId::new([1; 32]);
let config_id = compute_config_pda(AMM_PROGRAM);
let result = pair_ids(PairIdsRequest {
amm_program_id: amm_program_id(),
config: account_read(config_id, &config_account()),
token_a_id: token_a.to_string(),
token_b_id: token_b.to_string(),
})
.unwrap();
assert_eq!(result["status"], "ok");
assert_eq!(result["tokenAId"], account_id_hex(token_a));
assert_eq!(result["tokenBId"], account_id_hex(token_b));
assert_eq!(
result["poolId"],
account_id_hex(compute_pool_pda(AMM_PROGRAM, token_a, token_b))
);
}
#[test]
fn pair_manifest_reports_invalid_token_as_domain_error() {
let pair = ids();
let result = pair_ids(PairIdsRequest {
amm_program_id: amm_program_id(),
config: account_read(pair.config, &config_account()),
token_a_id: String::from("not-a-token-id"),
token_b_id: pair.token_b.to_string(),
})
.expect("invalid user input is a domain result");
assert_eq!(result["status"], "error");
assert_eq!(result["code"], "invalid_token_id");
}
#[test]
fn pair_manifest_reports_unavailable_config_as_domain_error() {
let pair = ids();
let result = pair_ids(PairIdsRequest {
amm_program_id: amm_program_id(),
config: default_read(pair.config),
token_a_id: pair.token_a.to_string(),
token_b_id: pair.token_b.to_string(),
})
.expect("unavailable chain state is a domain result");
assert_eq!(result["status"], "error");
assert_eq!(result["code"], "config_unavailable");
}
#[test]
fn token_manifest_includes_compatible_wallet_holdings() {
let config_id = compute_config_pda(AMM_PROGRAM);
let configured = AccountId::new([1; 32]);
let held = AccountId::new([2; 32]);
let recent = AccountId::new([3; 32]);
let resolved = AccountId::new([4; 32]);
let value = token_ids(TokenIdsRequest {
amm_program_id: amm_program_id(),
config: account_read(config_id, &config_account()),
wallet_accounts: vec![account_read(
AccountId::new([5; 32]),
&token_holding(held, 9),
)],
configured_token_ids: vec![account_id_hex(configured)],
recent_token_ids: vec![recent.to_string()],
resolved_token_ids: vec![resolved.to_string()],
})
.unwrap();
assert_eq!(
value["tokenIds"],
json!([
account_id_hex(configured),
account_id_hex(held),
account_id_hex(recent),
account_id_hex(resolved),
])
);
}
#[test]
fn wrong_program_holdings_do_not_contribute_token_candidates() {
let config_id = compute_config_pda(AMM_PROGRAM);
let config = config_account();
let definition = AccountId::new([2; 32]);
let wrong_owner_holding = account(
[99; 8],
Data::from(&TokenHolding::Fungible {
definition_id: definition,
balance: 9,
}),
);
let wallet_accounts = vec![account_read(AccountId::new([3; 32]), &wrong_owner_holding)];
let manifest = token_ids(TokenIdsRequest {
amm_program_id: amm_program_id(),
config: account_read(config_id, &config),
wallet_accounts: wallet_accounts.clone(),
configured_token_ids: Vec::new(),
recent_token_ids: Vec::new(),
resolved_token_ids: Vec::new(),
})
.unwrap();
assert_eq!(manifest["tokenIds"], json!([]));
let value = context(ContextRequest {
network_id: String::from("testnet"),
network_fingerprint: String::from("block10:abc"),
amm_program_id: amm_program_id(),
wallet_available: true,
config: account_read(config_id, &config),
wallet_accounts,
token_definitions: vec![account_read(
definition,
&token_definition("Token", 1_000_000),
)],
configured_token_ids: Vec::new(),
recent_token_ids: Vec::new(),
resolved_token_ids: Vec::new(),
})
.unwrap();
assert_eq!(value["tokens"], json!([]));
}
#[test]
fn context_selects_tokens_without_holdings() {
let token_id = AccountId::new([3; 32]);
let config_id = compute_config_pda(AMM_PROGRAM);
let value = context(ContextRequest {
network_id: String::from("testnet"),
network_fingerprint: String::from("block10:abc"),
amm_program_id: amm_program_id(),
wallet_available: true,
config: account_read(config_id, &config_account()),
wallet_accounts: Vec::new(),
token_definitions: vec![account_read(
token_id,
&token_definition("Token", 1_000_000),
)],
configured_token_ids: vec![account_id_hex(token_id)],
recent_token_ids: Vec::new(),
resolved_token_ids: Vec::new(),
})
.unwrap();
assert_eq!(value["tokens"][0]["selectable"], true);
assert_eq!(value["tokens"][0]["sources"], json!(["config"]));
assert!(value["tokens"][0].get("holdingId").is_none());
}
#[test]
fn missing_pool_snapshot_defaults_remain_real_accounts() {
let id = AccountId::new([5; 32]);
let read = default_read(id);
let (decoded_id, decoded) = decode_account(&read).unwrap();
assert_eq!(decoded_id, id);
assert_eq!(decoded, Account::default());
}
#[test]
fn missing_pool_quote_and_plan_use_current_account_order() {
let scenario = Scenario::devnet();
let quote_value = scenario.quote();
assert_eq!(quote_value["status"], "ok");
assert_eq!(quote_value["poolStatus"], "missing_pool");
assert_eq!(quote_value["canSubmit"], true);
assert_eq!(quote_value["accountPreview"].as_array().unwrap().len(), 11);
let quote_hash = quote_value["quoteHash"].as_str().unwrap().to_owned();
let fresh_lp = AccountId::new([63; 32]);
let plan_value = scenario.plan(quote_hash, Some(default_read(fresh_lp)));
assert_eq!(plan_value["status"], "ready");
assert_eq!(plan_value["accountIds"].as_array().unwrap().len(), 11);
assert_eq!(plan_value["accountIds"][8], account_id_hex(fresh_lp));
assert_eq!(plan_value["signingRequirements"][6], true);
assert_eq!(plan_value["signingRequirements"][7], true);
assert_eq!(plan_value["signingRequirements"][8], true);
assert_preview_matches_plan(&quote_value, &plan_value, Some(fresh_lp));
}
#[test]
fn missing_pool_plan_rejects_fresh_lp_account_collision() {
let scenario = Scenario::devnet();
let pool = scenario.pair.pool;
let quote_value = scenario.quote();
let quote_hash = quote_value["quoteHash"].as_str().unwrap().to_owned();
let plan_value = scenario.plan(quote_hash, Some(default_read(pool)));
assert_eq!(plan_value["status"], "error");
assert_eq!(plan_value["code"], "wallet_submission_failed");
}
#[test]
fn missing_pool_quote_accepts_large_direct_raw_amounts() {
let mut scenario = Scenario::devnet();
let amount_a = 100_000_000;
let amount_b = 150_000_000;
scenario.request.amount_a_raw = Some(amount_a.to_string());
scenario.request.amount_b_raw = Some(amount_b.to_string());
scenario.request.initial_price_real_raw =
Some(spot_price_q64_64(amount_a, amount_b).to_string());
let quote_value = scenario.quote();
assert_eq!(quote_value["status"], "ok");
assert_eq!(quote_value["actualAmountARaw"], amount_a.to_string());
assert_eq!(quote_value["actualAmountBRaw"], amount_b.to_string());
assert!(quote_value.get("depositScaleBps").is_none());
}
#[test]
fn advancing_clock_does_not_stale_quote() {
let mut scenario = Scenario::testnet();
let quote_value = scenario.quote();
scenario.snapshot.clock = account_read(
scenario.pair.clock,
&account(
[44; 8],
Data::try_from(
ClockAccountData {
block_id: 11,
timestamp: 1_500,
}
.to_bytes(),
)
.unwrap(),
),
);
let plan_value = scenario.plan(
quote_value["quoteHash"].as_str().unwrap(),
Some(default_read(AccountId::new([63; 32]))),
);
assert_eq!(plan_value["status"], "ready");
}
#[test]
fn active_pool_quote_uses_ratio_and_existing_lp_holding() {
let mut scenario = Scenario::testnet();
let pair = scenario.pair;
let pool = PoolDefinition {
definition_token_a_id: pair.token_a,
definition_token_b_id: pair.token_b,
vault_a_id: pair.vault_a,
vault_b_id: pair.vault_b,
liquidity_pool_id: pair.lp_definition,
liquidity_pool_supply: 10_000,
reserve_a: 10_000,
reserve_b: 20_000,
fees: 30,
};
scenario.snapshot.pool = account_read(pair.pool, &account(AMM_PROGRAM, Data::from(&pool)));
scenario.snapshot.vault_a =
account_read(pair.vault_a, &token_holding(pair.token_a, pool.reserve_a));
scenario.snapshot.vault_b =
account_read(pair.vault_b, &token_holding(pair.token_b, pool.reserve_b));
scenario.snapshot.lp_definition = account_read(
pair.lp_definition,
&account(
TOKEN_PROGRAM,
Data::from(&TokenDefinition::Fungible {
name: String::from("LP"),
total_supply: pool.liquidity_pool_supply,
metadata_id: None,
authority: Some(pair.lp_definition),
}),
),
);
scenario.snapshot.current_tick = account_read(
pair.current_tick,
&account(
TWAP_PROGRAM,
Data::from(&CurrentTickAccount {
tick: 0,
last_updated: 1_000,
}),
),
);
scenario.snapshot.wallet_accounts = vec![
account_read(
AccountId::new([61; 32]),
&token_holding(pair.token_a, 1_000),
),
account_read(
AccountId::new([62; 32]),
&token_holding(pair.token_b, 2_000),
),
];
let lp_holding = AccountId::new([64; 32]);
scenario.snapshot.wallet_accounts.push(account_read(
lp_holding,
&token_holding(pair.lp_definition, 500),
));
scenario.request.initial_price_real_raw = None;
scenario.request.max_amount_a_raw = Some(String::from("1000"));
scenario.request.max_amount_b_raw = Some(String::from("3000"));
scenario.request.slippage_bps = Some(50);
let quote_value = scenario.quote();
assert_eq!(quote_value["poolStatus"], "active_pool");
assert_eq!(quote_value["actualAmountARaw"], "1000");
assert_eq!(quote_value["actualAmountBRaw"], "2000");
assert_eq!(quote_value["expectedLpRaw"], "1000");
assert_eq!(quote_value["minimumLpRaw"], "995");
assert_eq!(quote_value["requiresFreshLp"], false);
assert_eq!(quote_value["canSubmit"], true);
assert_eq!(quote_value["errors"], json!([]));
let plan_value = scenario.plan(quote_value["quoteHash"].as_str().unwrap(), None);
assert_eq!(plan_value["status"], "ready");
assert_eq!(plan_value["accountIds"].as_array().unwrap().len(), 10);
assert_eq!(plan_value["accountIds"][7], account_id_hex(lp_holding));
assert_eq!(plan_value["signingRequirements"][7], false);
assert_preview_matches_plan(&quote_value, &plan_value, None);
}
#[test]
fn matching_unfunded_quote_has_no_transaction_plan() {
let mut scenario = Scenario::devnet();
scenario.snapshot.wallet_available = false;
scenario.snapshot.wallet_accounts.clear();
let quote_value = scenario.quote();
assert_eq!(quote_value["canSubmit"], false);
let plan_value = scenario.plan(quote_value["quoteHash"].as_str().unwrap(), None);
assert_eq!(plan_value["status"], "error");
assert_eq!(plan_value["code"], "quote_not_submittable");
assert_eq!(plan_value["quote"], quote_value);
}
#[test]
fn stale_hash_returns_recomputed_quote_without_plan() {
let value = Scenario::devnet().plan("sha256:deadbeef", None);
assert_eq!(value["status"], "error");
assert_eq!(value["code"], "quote_changed");
assert_eq!(value["quote"]["status"], "ok");
}
+161
View File
@@ -0,0 +1,161 @@
use std::{
ffi::{c_char, CStr, CString},
panic::{catch_unwind, AssertUnwindSafe},
};
use serde::{de::DeserializeOwned, Serialize};
use crate::api::{
self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest,
ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapPairRequest, SwapPlanRequest,
TokenIdsRequest,
};
#[derive(Serialize)]
struct Envelope {
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
impl Envelope {
fn success(value: serde_json::Value) -> Self {
Self {
ok: true,
value: Some(value),
error: None,
}
}
fn failure(error: impl Into<String>) -> Self {
Self {
ok: false,
value: None,
error: Some(error.into()),
}
}
}
fn call<T: DeserializeOwned>(request: *const c_char, operation: fn(T) -> AmmResult) -> *mut c_char {
let result = catch_unwind(AssertUnwindSafe(|| {
let request = request_text(request).map_err(AmmApiError::from)?;
let request = serde_json::from_str::<T>(&request)
.map_err(|error| AmmApiError::from(format!("invalid request JSON: {error}")))?;
operation(request)
}));
let envelope = match result {
Ok(Ok(value)) => Envelope::success(value),
Ok(Err(error)) => Envelope::failure(error.to_string()),
Err(_) => Envelope::failure("internal panic"),
};
encode_envelope(&envelope)
}
fn request_text(request: *const c_char) -> Result<String, String> {
if request.is_null() {
return Err(String::from("request pointer is null"));
}
// SAFETY: The C++ caller passes a live NUL-terminated UTF-8 buffer for this call.
let request = unsafe { CStr::from_ptr(request) };
request
.to_str()
.map(String::from)
.map_err(|error| format!("request is not UTF-8: {error}"))
}
fn encode_envelope(envelope: &Envelope) -> *mut c_char {
let json = serde_json::to_string(envelope).unwrap_or_else(|_| {
String::from(r#"{"ok":false,"error":"response serialization failed"}"#)
});
match CString::new(json) {
Ok(value) => value.into_raw(),
Err(_) => CString::new(r#"{"ok":false,"error":"response contains NUL"}"#)
.map_or(std::ptr::null_mut(), CString::into_raw),
}
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_config_id(request_json: *const c_char) -> *mut c_char {
call::<ConfigIdRequest>(request_json, api::config_id)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_token_ids(request_json: *const c_char) -> *mut c_char {
call::<TokenIdsRequest>(request_json, api::token_ids)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_pair_ids(request_json: *const c_char) -> *mut c_char {
call::<PairIdsRequest>(request_json, api::pair_ids)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_context(request_json: *const c_char) -> *mut c_char {
call::<ContextRequest>(request_json, api::context)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_quote(request_json: *const c_char) -> *mut c_char {
call::<QuoteRequest>(request_json, api::quote)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_plan(request_json: *const c_char) -> *mut c_char {
call::<PlanRequest>(request_json, api::plan)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_swap_pair(request_json: *const c_char) -> *mut c_char {
call::<SwapPairRequest>(request_json, api::swap_pair)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_resolve_pool(request_json: *const c_char) -> *mut c_char {
call::<ResolvePoolRequest>(request_json, api::resolve_pool)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_swap_plan(request_json: *const c_char) -> *mut c_char {
call::<SwapPlanRequest>(request_json, api::swap_plan)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_program_id(request_json: *const c_char) -> *mut c_char {
call::<ProgramIdRequest>(request_json, api::program_id)
}
/// Releases a string returned by an `amm_*` operation.
///
/// # Safety
/// `value` must be null or a pointer returned by this library that has not been freed.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn amm_free(value: *mut c_char) {
if value.is_null() {
return;
}
// SAFETY: The caller contract requires a pointer produced by CString::into_raw above.
drop(unsafe { CString::from_raw(value) });
}
#[cfg(test)]
mod tests {
use std::ffi::CString;
use super::*;
#[test]
fn malformed_json_uses_boundary_failure_envelope() {
let request = CString::new("{").unwrap();
let response = amm_config_id(request.as_ptr());
assert!(!response.is_null());
// SAFETY: response was returned by amm_config_id and remains live until amm_free.
let text = unsafe { CStr::from_ptr(response) }.to_str().unwrap();
let value: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(value["ok"], false);
// SAFETY: response was allocated by this library and has not been freed.
unsafe { amm_free(response) };
}
}
+13
View File
@@ -0,0 +1,13 @@
#![deny(unsafe_op_in_unsafe_fn)]
mod account;
mod ffi;
pub mod api;
pub use api::{
config_id, context, pair_ids, plan, program_id, quote, resolve_pool, swap_pair, swap_plan,
token_ids, AccountRead, AmmApiError, AmmResponse, AmmResult, ConfigIdRequest, ContextRequest,
PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest, ProgramIdRequest, QuoteRequest,
ResolvePoolRequest, SwapPairRequest, SwapPlanRequest, TokenIdsRequest, WalletAccount,
};
+12
View File
@@ -0,0 +1,12 @@
use amm_ffi::{config_id, ConfigIdRequest};
#[test]
fn direct_rust_api_does_not_require_ffi() {
let response = config_id(ConfigIdRequest {
amm_program_id: "0000000000000000000000000000000000000000000000000000000000000000".into(),
})
.expect("valid program ID should produce a response");
assert_eq!(response["status"], "ok");
assert!(response["configId"].is_string());
}
+2 -2
View File
@@ -11,13 +11,13 @@
logos_execution_zone.url = "github:gravityblast/logos-execution-zone-module?ref=fix/generic-tx-instruction-bstr";
};
# NOTE: like apps/amm, this flake is NOT built standalone. The amm_client
# NOTE: like apps/amm, this flake is NOT built standalone. The amm_ffi
# crate this module links (the Rust JSON-FFI brain) lives in the repo-root
# flake, and referencing it from here would require a hardcoded `git+file://`
# path or a `path:../..` input — the latter fails flake evaluation because this
# dir is copied into the Nix store as its own flake root, so `../..` can't
# escape it. Instead, the repo-root flake.nix builds this module directly
# (src = ./modules/amm) and resolves amm_client via `self`. Build it from
# (src = ./modules/amm) and resolves amm_ffi via `self`. Build it from
# the repo root:
# nix build .#amm-module
outputs = inputs@{ logos-module-builder, ... }:
+1 -1
View File
@@ -14,7 +14,7 @@
"runtime": []
},
"external_libraries": [
{ "name": "amm_client" }
{ "name": "amm_ffi" }
],
"cmake": {
"find_packages": [],
+8 -8
View File
@@ -20,7 +20,7 @@
#include "logos_sdk.h"
extern "C" {
#include "amm_client.h"
#include "amm_ffi.h"
}
namespace {
@@ -193,20 +193,20 @@ std::vector<uint8_t> jsonWordsToLeBytes(const json& arr) {
return out;
}
// Result of an amm_client JSON op: the `{ ok, value }` envelope decoded.
// Result of an amm_ffi JSON op: the `{ ok, value }` envelope decoded.
struct FfiResult {
bool ok = false;
json value;
};
// Serialize `request`, hand it to an amm_client op, and decode its
// Serialize `request`, hand it to an amm_ffi op, and decode its
// `{ ok, value, error }` envelope. Mirrors apps/amm/src/AmmClient.cpp. `value`
// is only populated (and `ok` true) when the op reports success with an object.
FfiResult call(char* (*op)(const char*), const json& request) {
const std::string payload = request.dump();
char* raw = op(payload.c_str());
if (raw == nullptr) {
AMM_TRACE("amm_client op returned null");
AMM_TRACE("amm_ffi op returned null");
return {};
}
const std::string response(raw);
@@ -214,16 +214,16 @@ FfiResult call(char* (*op)(const char*), const json& request) {
const auto doc = json::parse(response, nullptr, /*allow_exceptions=*/false);
if (!doc.is_object()) {
AMM_TRACE("amm_client op returned invalid JSON");
AMM_TRACE("amm_ffi op returned invalid JSON");
return {};
}
if (!doc.value("ok", false)) {
AMM_TRACE("amm_client op failure: " << doc.value("error", std::string()));
AMM_TRACE("amm_ffi op failure: " << doc.value("error", std::string()));
return {};
}
const auto it = doc.find("value");
if (it == doc.end() || !it->is_object()) {
AMM_TRACE("amm_client op value is not an object");
AMM_TRACE("amm_ffi op value is not an object");
return {};
}
return {true, *it};
@@ -294,7 +294,7 @@ std::vector<uint8_t> AmmModuleImpl::loadAmmElf() {
std::string AmmModuleImpl::ammProgramId() {
const std::vector<uint8_t> elf = loadAmmElf();
if (elf.empty()) return {};
// Hand the deployed binary to the amm_client program_id op, which decodes it
// Hand the deployed binary to the amm_ffi program_id op, which decodes it
// and computes the Image ID — 64-char lowercase hex, little-endian per u32
// word (matches `spel program-id` and the on-chain *_program_id fields).
const FfiResult r = call(amm_program_id, json{{"elf", toHex(elf.data(), elf.size())}});
+4 -4
View File
@@ -11,7 +11,7 @@
//
// Orchestration only: the AMM domain math (PDA derivation, on-chain account
// decoding, quote/plan computation, and instruction encoding) lives in the Rust
// `amm_client` crate and is reached through its JSON FFI (amm_client.h — one
// `amm_ffi` crate and is reached through its JSON FFI (amm_ffi.h — one
// `char* op(const char*)` per operation, request and response both JSON). This
// module sequences those ops with chain I/O delegated to the
// `logos_execution_zone` wallet module (reached via modules().logos_execution_zone).
@@ -108,7 +108,7 @@ private:
// success, so a startup miss (bin not readable yet) retries.
Network network();
// 64-char lowercase-hex AMM program id via the amm_client `program_id` op
// 64-char lowercase-hex AMM program id via the amm_ffi `program_id` op
// over the AMM_PROGRAM_BIN bytes (empty if unset/unreadable/bad).
std::string ammProgramId();
@@ -120,13 +120,13 @@ private:
std::string normalizeAccountId(const std::string& id);
// Derives the config account id (amm_config_id) and reads it, returning the
// account-read shape the amm_client ops embed. Null json when the config_id
// account-read shape the amm_ffi ops embed. Null json when the config_id
// op itself fails (readPublicAccount always yields at least {id,status}).
nlohmann::json readConfig(const Network& net);
// Reads a public account through the wallet module and returns the
// { id, status, account:{ program_owner, balance, nonce, data } } shape the
// amm_client ops expect (see the app-side accountReadJson). `account` is
// amm_ffi ops expect (see the app-side accountReadJson). `account` is
// omitted when the read has no data (uninitialized/nonexistent).
nlohmann::json readPublicAccount(const std::string& account_id);