refactor(amm): consolidate the two client FFIs into one JSON crate

The AMM host FFI was split across two crates with two ABI styles: the
typed-C `amm_client_ffi` (swap primitives, under programs/) and the
JSON/wire `amm_client` (new-position flow, under apps/). Fold both into a
single `amm_client` crate exposing one JSON C ABI, and delete
`programs/amm/client-ffi`.

Rust:
- Re-express the swap path as `api/swap.rs` operations on the existing
  `call::<T>` dispatch — swap_pair, resolve_pool, swap_plan, program_id —
  reusing `pair::derive_pair` (no more duplicated PDA derivation) and
  `risc0_zkvm::serde` for the SwapExactInput words (the same encoding the
  guest decodes). The account list and signer flags stay byte-identical
  to the old typed path.
- Generate a single header (`include/amm_client.h`) covering all ops via
  cbindgen; bump cbindgen 0.27 -> 0.28 for `#[unsafe(no_mangle)]` support.

C++:
- Extend the `AmmClient` wrapper with the four swap ops.
- Add `SwapRuntime` (mirrors `NewPositionRuntime`): reads accounts through
  the wallet, drives the swap ops, submits the transaction.
- `AmmUiBackend` swap methods now delegate to `SwapRuntime`, dropping ~390
  lines of typed-FFI and byte-twiddling. `program_id` becomes a JSON op,
  and the swap clock is derived via `derive_pair` (clock_core::CLOCK_01)
  instead of a hardcoded base58 literal — same account, verified.
This commit is contained in:
r4bbit
2026-08-03 15:37:37 +02:00
parent 8358cfa2f1
commit 737b2f674a
27 changed files with 680 additions and 1069 deletions
-32
View File
@@ -1,32 +0,0 @@
[package]
name = "amm_client_ffi"
version = "0.1.0"
# 2021 (not the repo's 2024): matches amm_core and keeps plain #[no_mangle]
# (edition 2024 requires #[unsafe(no_mangle)]).
edition = "2021"
[lib]
name = "amm_client_ffi"
# The logos module builder's macOS find_library only locates SHARED libs
# (lib<name>.dylib), never a static .a — so we must ship the cdylib. The flake
# gives the dylib an ABSOLUTE store-path install-name (not @rpath), so the plugin
# links it by its /nix/store path (kept in the closure) and dlopen resolves it at
# runtime without any rpath staging. staticlib/rlib kept for other consumers/tests.
crate-type = ["staticlib", "cdylib", "rlib"]
[lints]
workspace = true
[dependencies]
amm_core = { path = "../core" }
twap_oracle_core = { path = "../../twap_oracle/core" }
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0", features = ["host"], package = "lee_core" }
borsh = { version = "1.5", features = ["derive"] }
risc0-zkvm = { version = "=3.0.5", default-features = false }
risc0-binfmt = { version = "=3.0.4", default-features = false }
# amm_core's ruint is default-features=false (for the no_std guest); enable std
# here (host-only) so its Uint::root compiles. Guest build is unaffected.
ruint = { version = "=1.17.0", default-features = false, features = ["std"] }
[build-dependencies]
cbindgen = "0.27"
-155
View File
@@ -1,155 +0,0 @@
#ifndef AMM_CLIENT_FFI_H
#define AMM_CLIENT_FFI_H
#pragma once
/* Generated by cbindgen. Do not edit. */
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
/**
* Heap-allocated buffer of RISC0 instruction words, returned across the C
* boundary. Free with `amm_client_free_words`.
*/
typedef struct AmmWords {
uint32_t *ptr;
uintptr_t len;
bool ok;
} AmmWords;
/**
* C-ABI representation of `nssa_core::program::ProgramId` (an Image ID),
* which is itself defined as `[u32; 8]`. Declared as a concrete array here
* (rather than an alias through `nssa_core::program::ProgramId`) so
* cbindgen — which only inspects this crate's source, not its
* dependencies' — can emit a real typedef instead of an opaque,
* self-referential `ProgramId` in the generated header. Since both are
* plain type aliases to `[u32; 8]`, they remain interchangeable to rustc.
*/
typedef uint32_t ProgramId[8];
/**
* C-ABI mirror of `pool::PoolView`. u128 fields are little-endian bytes.
*/
typedef struct FfiPoolView {
uint8_t def_a[32];
uint8_t def_b[32];
uint8_t vault_a[32];
uint8_t vault_b[32];
uint8_t reserve_a[16];
uint8_t reserve_b[16];
uint8_t liquidity_supply[16];
uint32_t fees;
bool ok;
} FfiPoolView;
/**
* C-ABI mirror of `pool::ConfigView`.
*/
typedef struct FfiConfigView {
uint32_t token_program_id[8];
uint32_t twap_oracle_program_id[8];
uint8_t authority[32];
bool ok;
} FfiConfigView;
/**
* Builds the RISC0 instruction words for a `SwapExactInput`. `amount_in` and
* `min_out` are little-endian encoded `u128` values (`[u8; 16]`). The
* returned buffer must be freed with `amm_client_free_words`.
*
* # Safety
* `amount_in` and `min_out` must be valid, non-null pointers to readable
* memory of the indicated sizes.
*/
struct AmmWords amm_client_swap_words(const uint8_t (*amount_in)[16],
const uint8_t (*min_out)[16],
uint64_t deadline);
/**
* Frees a buffer previously returned by `amm_client_swap_words`.
*
* # Safety
* `w` must be a value previously returned by `amm_client_swap_words` that
* has not already been freed.
*/
void amm_client_free_words(struct AmmWords w);
/**
* Computes the `ProgramId` (Image ID) of a compiled guest ELF. Returns
* `false` on an invalid ELF, leaving `out` unwritten.
*
* # Safety
* `elf` must be valid for reads of `elf_len` bytes, and `out` must be a
* valid, non-null pointer to writable memory for a `ProgramId`.
*/
bool amm_client_program_id_from_elf(const uint8_t *elf, uintptr_t elf_len, ProgramId *out);
/**
* Fills `out` with the AMM config PDA.
*
* # Safety
* `amm` must be a valid, non-null pointer to a readable `ProgramId`, and
* `out` must be a valid, non-null pointer to writable memory.
*/
void amm_client_config_pda(const ProgramId *amm, uint8_t (*out)[32]);
/**
* Fills `out` with the pool PDA for the two definition ids.
*
* # Safety
* All pointer arguments must be valid, non-null, and point to readable (or,
* for `out`, writable) memory of the indicated sizes.
*/
void amm_client_pool_pda(const ProgramId *amm,
const uint8_t (*def_a)[32],
const uint8_t (*def_b)[32],
uint8_t (*out)[32]);
/**
* Fills `out` with the vault PDA for a pool + token definition.
*
* # Safety
* All pointer arguments must be valid, non-null, and point to readable (or,
* for `out`, writable) memory of the indicated sizes.
*/
void amm_client_vault_pda(const ProgramId *amm,
const uint8_t (*pool)[32],
const uint8_t (*def)[32],
uint8_t (*out)[32]);
/**
* Fills `out` with the TWAP oracle current-tick PDA for a pool.
*
* # Safety
* All pointer arguments must be valid, non-null, and point to readable (or,
* for `out`, writable) memory of the indicated sizes.
*/
void amm_client_current_tick_pda(const ProgramId *twap,
const uint8_t (*pool)[32],
uint8_t (*out)[32]);
/**
* Decodes a `PoolDefinition` account's raw bytes into `out`. Returns `false`
* on decode failure, leaving `out` unwritten.
*
* # Safety
* `bytes` must be valid for reads of `len` bytes, and `out` must be a
* valid, non-null pointer to writable memory for a `FfiPoolView`.
*/
bool amm_client_decode_pool(const uint8_t *bytes, uintptr_t len, struct FfiPoolView *out);
/**
* Decodes an `AmmConfig` account's raw bytes into `out`. Returns `false` on
* decode failure, leaving `out` unwritten.
*
* # Safety
* `bytes` must be valid for reads of `len` bytes, and `out` must be a
* valid, non-null pointer to writable memory for a `FfiConfigView`.
*/
bool amm_client_decode_config(const uint8_t *bytes, uintptr_t len, struct FfiConfigView *out);
#endif /* AMM_CLIENT_FFI_H */
-10
View File
@@ -1,10 +0,0 @@
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}/amm_client_ffi.h"));
println!("cargo:rerun-if-changed=src/lib.rs");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=cbindgen.toml");
}
-7
View File
@@ -1,7 +0,0 @@
language = "C"
include_guard = "AMM_CLIENT_FFI_H"
pragma_once = true
autogen_warning = "/* Generated by cbindgen. Do not edit. */"
[export]
prefix = ""
-244
View File
@@ -1,244 +0,0 @@
//! C-ABI client helpers for calling the AMM program from host apps.
//! All AMM instruction/PDA logic is delegated to `amm_core`; encoding is
//! delegated to `lee` — this crate only bridges to a C ABI.
#![allow(
unsafe_code,
reason = "this crate exists solely to expose a C ABI; every unsafe fn \
is a documented pointer dereference at the FFI boundary"
)]
mod pda;
mod pool;
mod swap;
use nssa_core::account::AccountId;
/// C-ABI representation of `nssa_core::program::ProgramId` (an Image ID),
/// which is itself defined as `[u32; 8]`. Declared as a concrete array here
/// (rather than an alias through `nssa_core::program::ProgramId`) so
/// cbindgen — which only inspects this crate's source, not its
/// dependencies' — can emit a real typedef instead of an opaque,
/// self-referential `ProgramId` in the generated header. Since both are
/// plain type aliases to `[u32; 8]`, they remain interchangeable to rustc.
pub type ProgramId = [u32; 8];
/// Heap-allocated buffer of RISC0 instruction words, returned across the C
/// boundary. Free with `amm_client_free_words`.
#[repr(C)]
pub struct AmmWords {
pub ptr: *mut u32,
pub len: usize,
pub ok: bool,
}
/// C-ABI mirror of `pool::PoolView`. u128 fields are little-endian bytes.
#[repr(C)]
pub struct FfiPoolView {
pub def_a: [u8; 32],
pub def_b: [u8; 32],
pub vault_a: [u8; 32],
pub vault_b: [u8; 32],
pub reserve_a: [u8; 16],
pub reserve_b: [u8; 16],
pub liquidity_supply: [u8; 16],
pub fees: u32,
pub ok: bool,
}
/// C-ABI mirror of `pool::ConfigView`.
#[repr(C)]
pub struct FfiConfigView {
pub token_program_id: [u32; 8],
pub twap_oracle_program_id: [u32; 8],
pub authority: [u8; 32],
pub ok: bool,
}
/// # Safety
/// `p` must be a valid, non-null pointer to a readable `[u8; 32]`.
unsafe fn acc(p: *const [u8; 32]) -> AccountId {
AccountId::new(*p)
}
/// Builds the RISC0 instruction words for a `SwapExactInput`. `amount_in` and
/// `min_out` are little-endian encoded `u128` values (`[u8; 16]`). The
/// returned buffer must be freed with `amm_client_free_words`.
///
/// # Safety
/// `amount_in` and `min_out` must be valid, non-null pointers to readable
/// memory of the indicated sizes.
#[no_mangle]
pub unsafe extern "C" fn amm_client_swap_words(
amount_in: *const [u8; 16],
min_out: *const [u8; 16],
deadline: u64,
) -> AmmWords {
let (a, m) = (
u128::from_le_bytes(*amount_in),
u128::from_le_bytes(*min_out),
);
match swap::swap_exact_input_words(a, m, deadline) {
Ok(w) => {
let boxed = w.into_boxed_slice();
let len = boxed.len();
let ptr = Box::into_raw(boxed).cast::<u32>();
AmmWords { ptr, len, ok: true }
}
Err(_) => AmmWords {
ptr: core::ptr::null_mut(),
len: 0,
ok: false,
},
}
}
/// Frees a buffer previously returned by `amm_client_swap_words`.
///
/// # Safety
/// `w` must be a value previously returned by `amm_client_swap_words` that
/// has not already been freed.
#[no_mangle]
pub unsafe extern "C" fn amm_client_free_words(w: AmmWords) {
if !w.ptr.is_null() {
drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(
w.ptr, w.len,
)));
}
}
/// Computes the `ProgramId` (Image ID) of a deployed program binary — the RISC
/// Zero `ProgramBinary` (`.bin`) format, NOT a raw ELF (the `elf` bytes are
/// decoded via `ProgramBinary::decode`). Returns `false` on invalid input,
/// leaving `out` unwritten.
///
/// # Safety
/// `elf` must be valid for reads of `elf_len` bytes, and `out` must be a
/// valid, non-null pointer to writable memory for a `ProgramId`.
#[no_mangle]
pub unsafe extern "C" fn amm_client_program_id_from_elf(
elf: *const u8,
elf_len: usize,
out: *mut ProgramId,
) -> bool {
let bytes = core::slice::from_raw_parts(elf, elf_len);
match pda::program_id_from_elf(bytes) {
Ok(id) => {
*out = id;
true
}
Err(_) => false,
}
}
/// Fills `out` with the AMM config PDA.
///
/// # Safety
/// `amm` must be a valid, non-null pointer to a readable `ProgramId`, and
/// `out` must be a valid, non-null pointer to writable memory.
#[no_mangle]
pub unsafe extern "C" fn amm_client_config_pda(amm: *const ProgramId, out: *mut [u8; 32]) {
*out = pda::config_pda(*amm).into_value();
}
/// Fills `out` with the pool PDA for the two definition ids.
///
/// # Safety
/// All pointer arguments must be valid, non-null, and point to readable (or,
/// for `out`, writable) memory of the indicated sizes.
#[no_mangle]
pub unsafe extern "C" fn amm_client_pool_pda(
amm: *const ProgramId,
def_a: *const [u8; 32],
def_b: *const [u8; 32],
out: *mut [u8; 32],
) {
*out = pda::pool_pda(*amm, acc(def_a), acc(def_b)).into_value();
}
/// Fills `out` with the vault PDA for a pool + token definition.
///
/// # Safety
/// All pointer arguments must be valid, non-null, and point to readable (or,
/// for `out`, writable) memory of the indicated sizes.
#[no_mangle]
pub unsafe extern "C" fn amm_client_vault_pda(
amm: *const ProgramId,
pool: *const [u8; 32],
def: *const [u8; 32],
out: *mut [u8; 32],
) {
*out = pda::vault_pda(*amm, acc(pool), acc(def)).into_value();
}
/// Fills `out` with the TWAP oracle current-tick PDA for a pool.
///
/// # Safety
/// All pointer arguments must be valid, non-null, and point to readable (or,
/// for `out`, writable) memory of the indicated sizes.
#[no_mangle]
pub unsafe extern "C" fn amm_client_current_tick_pda(
twap: *const ProgramId,
pool: *const [u8; 32],
out: *mut [u8; 32],
) {
*out = pda::current_tick_pda(*twap, acc(pool)).into_value();
}
/// Decodes a `PoolDefinition` account's raw bytes into `out`. Returns `false`
/// on decode failure, leaving `out` unwritten.
///
/// # Safety
/// `bytes` must be valid for reads of `len` bytes, and `out` must be a
/// valid, non-null pointer to writable memory for a `FfiPoolView`.
#[no_mangle]
pub unsafe extern "C" fn amm_client_decode_pool(
bytes: *const u8,
len: usize,
out: *mut FfiPoolView,
) -> bool {
let b = core::slice::from_raw_parts(bytes, len);
match pool::decode_pool(b) {
Ok(v) => {
*out = FfiPoolView {
def_a: v.def_a,
def_b: v.def_b,
vault_a: v.vault_a,
vault_b: v.vault_b,
reserve_a: v.reserve_a.to_le_bytes(),
reserve_b: v.reserve_b.to_le_bytes(),
liquidity_supply: v.liquidity_supply.to_le_bytes(),
fees: v.fees,
ok: true,
};
true
}
Err(_) => false,
}
}
/// Decodes an `AmmConfig` account's raw bytes into `out`. Returns `false` on
/// decode failure, leaving `out` unwritten.
///
/// # Safety
/// `bytes` must be valid for reads of `len` bytes, and `out` must be a
/// valid, non-null pointer to writable memory for a `FfiConfigView`.
#[no_mangle]
pub unsafe extern "C" fn amm_client_decode_config(
bytes: *const u8,
len: usize,
out: *mut FfiConfigView,
) -> bool {
let b = core::slice::from_raw_parts(bytes, len);
match pool::decode_config(b) {
Ok(v) => {
*out = FfiConfigView {
token_program_id: v.token_program_id,
twap_oracle_program_id: v.twap_oracle_program_id,
authority: v.authority,
ok: true,
};
true
}
Err(_) => false,
}
}
-49
View File
@@ -1,49 +0,0 @@
//! PDA derivation and program-id helpers, delegating to `amm_core` /
//! `twap_oracle_core` so the client never re-implements seed hashing.
use amm_core::{compute_config_pda, compute_pool_pda, compute_vault_pda};
use nssa_core::{account::AccountId, program::ProgramId};
use risc0_binfmt::ProgramBinary;
use twap_oracle_core::compute_current_tick_account_pda;
/// Computes the `ProgramId` (Image ID) of a deployed program binary — the RISC
/// Zero `ProgramBinary` (`.bin`) format produced by the guest build, NOT a raw
/// ELF (the `elf` bytes are decoded via `ProgramBinary::decode`) — the same way
/// the sequencer/wallet does when deploying a program.
pub fn program_id_from_elf(elf: &[u8]) -> Result<ProgramId, String> {
let binary = ProgramBinary::decode(elf).map_err(|e| format!("{e:?}"))?;
let id = binary.compute_image_id().map_err(|e| format!("{e:?}"))?;
Ok(id.into())
}
pub fn config_pda(amm: ProgramId) -> AccountId {
compute_config_pda(amm)
}
pub fn pool_pda(amm: ProgramId, def_a: AccountId, def_b: AccountId) -> AccountId {
compute_pool_pda(amm, def_a, def_b)
}
pub fn vault_pda(amm: ProgramId, pool: AccountId, def: AccountId) -> AccountId {
compute_vault_pda(amm, pool, def)
}
pub fn current_tick_pda(twap: ProgramId, pool: AccountId) -> AccountId {
compute_current_tick_account_pda(twap, pool)
}
#[cfg(test)]
mod tests {
use amm_core::compute_pool_pda;
use nssa_core::{account::AccountId, program::ProgramId};
use super::*;
#[test]
fn pool_pda_matches_core() {
let amm: ProgramId = [1u32; 8];
let a = AccountId::new([2u8; 32]);
let b = AccountId::new([3u8; 32]);
assert_eq!(pool_pda(amm, a, b), compute_pool_pda(amm, a, b));
}
}
-77
View File
@@ -1,77 +0,0 @@
//! Decoding for the AMM's on-chain `PoolDefinition` account, so client apps
//! can read reserves / fees / vault ids without depending on `amm_core`
//! directly.
use amm_core::{AmmConfig, PoolDefinition};
pub struct PoolView {
pub def_a: [u8; 32],
pub def_b: [u8; 32],
pub vault_a: [u8; 32],
pub vault_b: [u8; 32],
pub reserve_a: u128,
pub reserve_b: u128,
pub liquidity_supply: u128,
/// Fee tier in basis points. Source is `u128` but supported tiers are all
/// <= 100, so downcasting to `u32` is safe.
pub fees: u32,
}
pub fn decode_pool(bytes: &[u8]) -> Result<PoolView, String> {
let p: PoolDefinition = borsh::from_slice(bytes).map_err(|e| format!("{e:?}"))?;
Ok(PoolView {
def_a: p.definition_token_a_id.into_value(),
def_b: p.definition_token_b_id.into_value(),
vault_a: p.vault_a_id.into_value(),
vault_b: p.vault_b_id.into_value(),
reserve_a: p.reserve_a,
reserve_b: p.reserve_b,
liquidity_supply: p.liquidity_pool_supply,
fees: u32::try_from(p.fees).map_err(|_| format!("pool fee {} exceeds u32::MAX", p.fees))?,
})
}
pub struct ConfigView {
pub token_program_id: [u32; 8],
pub twap_oracle_program_id: [u32; 8],
pub authority: [u8; 32],
}
pub fn decode_config(bytes: &[u8]) -> Result<ConfigView, String> {
let c: AmmConfig = borsh::from_slice(bytes).map_err(|e| format!("{e:?}"))?;
Ok(ConfigView {
token_program_id: c.token_program_id,
twap_oracle_program_id: c.twap_oracle_program_id,
authority: c.authority.into_value(),
})
}
#[cfg(test)]
mod tests {
use amm_core::PoolDefinition;
use nssa_core::account::AccountId;
use super::*;
#[test]
fn decode_roundtrip() {
let p = PoolDefinition::default();
let bytes = borsh::to_vec(&p).unwrap();
let v = decode_pool(&bytes).unwrap();
assert_eq!(v.reserve_a, 0);
}
#[test]
fn decode_config_roundtrip() {
let c = AmmConfig {
token_program_id: [7u32; 8],
twap_oracle_program_id: [9u32; 8],
authority: AccountId::new([0u8; 32]),
};
let bytes = borsh::to_vec(&c).unwrap();
let v = decode_config(&bytes).unwrap();
assert_eq!(v.token_program_id, [7u32; 8]);
assert_eq!(v.twap_oracle_program_id, [9u32; 8]);
assert_eq!(v.authority, [0u8; 32]);
}
}
-52
View File
@@ -1,52 +0,0 @@
use amm_core::Instruction;
/// Build the RISC0 instruction words for a SwapExactInput, via the exact
/// serializer the public-transaction path and the guest decoder share.
pub fn swap_exact_input_words(
amount_in: u128,
min_out: u128,
deadline: u64,
) -> Result<Vec<u32>, String> {
let instruction = Instruction::SwapExactInput {
swap_amount_in: amount_in,
min_amount_out: min_out,
deadline,
};
risc0_zkvm::serde::to_vec(&instruction).map_err(|e| format!("{e:?}"))
}
#[cfg(test)]
mod tests {
use amm_core::Instruction;
use super::*;
#[test]
fn words_roundtrip_to_same_instruction() {
let words = swap_exact_input_words(1000, 1, u64::MAX).unwrap();
// Deserialize back through the exact serde the guest decoder uses.
// `Instruction` derives neither `Debug` nor `PartialEq`, so assert
// field-by-field instead of matching with a `{:?}` fallback arm.
let decoded: Instruction = risc0_zkvm::serde::from_slice(&words).unwrap();
match decoded {
Instruction::SwapExactInput {
swap_amount_in,
min_amount_out,
deadline,
} => {
assert_eq!(swap_amount_in, 1000);
assert_eq!(min_amount_out, 1);
assert_eq!(deadline, u64::MAX);
}
Instruction::Initialize { .. }
| Instruction::UpdateConfig { .. }
| Instruction::CreatePriceObservations { .. }
| Instruction::CreateOraclePriceAccount { .. }
| Instruction::NewDefinition { .. }
| Instruction::AddLiquidity { .. }
| Instruction::RemoveLiquidity { .. }
| Instruction::SwapExactOutput { .. }
| Instruction::SyncReserves => panic!("wrong variant: expected SwapExactInput"),
}
}
}