Files
lez-programs/programs/amm/client/tests/ffi_contract.rs
Ricardo Guilherme Schmidt 43ed4de64f fix(amm-client)!: align shared quote wire contract
Use canonical ProgramId word arrays, include pool spot movement in standalone swap quotes, and keep quote commitments stable across deadline refreshes.

BREAKING CHANGE: JSON ProgramId values now use eight u32 words. Convert hex or byte representations in host adapters.
2026-08-10 11:22:44 -03:00

393 lines
13 KiB
Rust

#![allow(
unsafe_code,
reason = "contract tests call the exported C ABI and release its owned pointers"
)]
mod common;
use std::ffi::{c_char, CStr, CString};
use amm_client::{amm_client_free, amm_client_plan, amm_client_quote, wire::WIRE_SCHEMA};
use amm_core::{
compute_config_pda, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda,
AmmConfig, Instruction, PoolDefinition, FEE_TIER_BPS_30, MINIMUM_LIQUIDITY,
};
use common::program_id_words;
use nssa_core::{
account::{Account, AccountId, Data, Nonce},
program::ProgramId,
};
use serde_json::{json, Value};
use token_core::{TokenDefinition, TokenHolding};
type Operation = unsafe extern "C" fn(*const c_char) -> *mut c_char;
fn call(operation: Operation, request: Option<&CStr>) -> Value {
let request = request.map_or(std::ptr::null(), CStr::as_ptr);
// SAFETY: `request` is null or points into the borrowed `CStr`, which remains live through the
// call. The returned pointer is checked and released exactly once below.
let response = unsafe { operation(request) };
assert!(!response.is_null());
// SAFETY: A non-null response is a live NUL-terminated string owned by the AMM client until
// `amm_client_free` below.
let text = unsafe { CStr::from_ptr(response) }
.to_str()
.expect("FFI response must be UTF-8");
let value = serde_json::from_str(text).expect("FFI response must be JSON");
// SAFETY: `response` came from this library and has not been released yet.
unsafe { amm_client_free(response) };
value
}
fn call_json(operation: Operation, request: &Value) -> Value {
let request = CString::new(request.to_string()).expect("JSON has no interior NUL");
call(operation, Some(&request))
}
fn snapshot(id: AccountId, account: &Account) -> Value {
json!({
"id": id.to_string(),
"programOwner": program_id_words(account.program_owner),
"balance": account.balance.to_string(),
"nonce": account.nonce.0.to_string(),
"data": hex(account.data.as_ref()),
})
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn account(program_owner: ProgramId, data: Data) -> Account {
Account {
program_owner,
balance: 0,
data,
nonce: Nonce(0),
}
}
fn fungible_definition(
program_owner: ProgramId,
total_supply: u128,
authority: Option<AccountId>,
) -> Account {
account(
program_owner,
Data::from(&TokenDefinition::Fungible {
name: String::from("Token"),
total_supply,
metadata_id: None,
authority,
}),
)
}
fn fungible_holding(program_owner: ProgramId, definition_id: AccountId, balance: u128) -> Account {
account(
program_owner,
Data::from(&TokenHolding::Fungible {
definition_id,
balance,
}),
)
}
#[test]
fn null_request_returns_structured_error() {
let response = call(amm_client_plan, None);
assert_eq!(response["schema"], WIRE_SCHEMA);
assert_eq!(response["ok"], false);
assert_eq!(response["error"]["code"], "null_request");
}
#[test]
fn malformed_json_returns_structured_error() {
let request = CString::new("{").expect("literal has no NUL");
let response = call(amm_client_quote, Some(&request));
assert_eq!(response["schema"], WIRE_SCHEMA);
assert_eq!(response["ok"], false);
assert_eq!(response["error"]["code"], "invalid_json");
}
#[test]
fn invalid_utf8_returns_structured_error() {
let request = CStr::from_bytes_with_nul(&[0xff, 0]).expect("bytes are NUL-terminated");
let response = call(amm_client_quote, Some(request));
assert_eq!(response["ok"], false);
assert_eq!(response["error"]["code"], "invalid_utf8");
}
#[test]
fn free_accepts_null() {
// SAFETY: Null is explicitly accepted by the deallocator contract.
unsafe { amm_client_free(std::ptr::null_mut()) };
}
#[test]
fn protocol_constants_are_exposed_without_numeric_json_values() {
let response = call_json(
amm_client_quote,
&json!({"operation": "protocol_constants"}),
);
assert_eq!(response["schema"], WIRE_SCHEMA);
assert_eq!(response["ok"], true);
assert_eq!(response["value"]["schema"], WIRE_SCHEMA);
assert_eq!(
response["value"]["minimumLiquidity"],
MINIMUM_LIQUIDITY.to_string()
);
assert_eq!(response["value"]["feeBpsDenominator"], "10000");
assert_eq!(response["value"]["slippageBpsDenominator"], "10000");
assert_eq!(
response["value"]["supportedFeeTiers"],
json!(["1", "5", "30", "100"])
);
}
#[test]
fn program_ids_require_exactly_eight_u32_words() {
let canonical = program_id_words([0xabcdef01; 8]);
let authority = AccountId::new([44; 32]).to_string();
for invalid in [
json!("0000000000000000000000000000000000000000000000000000000000000000"),
json!([1, 1, 1, 1, 1, 1, 1]),
json!([1, 1, 1, 1, 1, 1, 1, 4_294_967_296_u64]),
] {
let response = call_json(
amm_client_plan,
&json!({
"operation": "initialize",
"ammProgramId": invalid,
"tokenProgramId": canonical,
"twapOracleProgramId": canonical,
"authority": authority,
}),
);
assert_eq!(response["ok"], false);
assert_eq!(response["error"]["code"], "invalid_request");
}
}
#[test]
fn successful_plan_preserves_u64_above_javascript_range_in_guest_words() {
let amm_program_id: ProgramId = [11; 8];
let token_program_id: ProgramId = [22; 8];
let twap_oracle_program_id: ProgramId = [33; 8];
let authority = AccountId::new([44; 32]);
let pool_id = AccountId::new([55; 32]);
let window_duration = 9_007_199_254_740_993_u64;
let response = call_json(
amm_client_plan,
&json!({
"operation": "create_price_observations",
"context": {
"ammProgramId": program_id_words(amm_program_id),
"tokenProgramId": program_id_words(token_program_id),
"twapOracleProgramId": program_id_words(twap_oracle_program_id),
"authority": authority.to_string(),
},
"poolId": pool_id.to_string(),
"windowDuration": window_duration.to_string(),
}),
);
assert_eq!(response["ok"], true);
assert_eq!(
response["value"]["instruction"],
"create_price_observations"
);
assert_eq!(
response["value"]["programId"],
json!(program_id_words(amm_program_id))
);
assert!(response["value"]["accounts"].is_array());
let words: Vec<u32> = serde_json::from_value(response["value"]["instructionWords"].clone())
.expect("instruction words must be u32 JSON numbers");
let instruction: Instruction =
risc0_zkvm::serde::from_slice(&words).expect("guest codec must decode plan words");
match instruction {
Instruction::CreatePriceObservations {
window_duration: decoded,
} => assert_eq!(decoded, window_duration),
Instruction::Initialize { .. }
| Instruction::UpdateConfig { .. }
| Instruction::CreateOraclePriceAccount { .. }
| Instruction::NewDefinition { .. }
| Instruction::AddLiquidity { .. }
| Instruction::RemoveLiquidity { .. }
| Instruction::SwapExactInput { .. }
| Instruction::SwapExactOutput { .. }
| Instruction::SyncReserves => panic!("expected CreatePriceObservations"),
}
}
#[test]
fn successful_quote_preserves_u128_above_javascript_range_as_decimal() {
let amm_program_id: ProgramId = [11; 8];
let token_program_id: ProgramId = [22; 8];
let twap_oracle_program_id: ProgramId = [33; 8];
let authority = AccountId::new([44; 32]);
let config = AmmConfig {
token_program_id,
twap_oracle_program_id,
authority,
};
let config_account = Account {
program_owner: amm_program_id,
balance: 0,
data: Data::from(&config),
nonce: Nonce(0),
};
let definition = |name: &str| Account {
program_owner: token_program_id,
balance: 0,
data: Data::from(&TokenDefinition::Fungible {
name: String::from(name),
total_supply: 0,
metadata_id: None,
authority: None,
}),
nonce: Nonce(0),
};
let token_a_id = AccountId::new([61; 32]);
let token_b_id = AccountId::new([62; 32]);
let amount = 9_007_199_254_740_993_u128;
let response = call_json(
amm_client_quote,
&json!({
"operation": "create_pool",
"ammProgramId": program_id_words(amm_program_id),
"config": snapshot(compute_config_pda(amm_program_id), &config_account),
"tokenADefinition": snapshot(token_a_id, &definition("A")),
"tokenBDefinition": snapshot(token_b_id, &definition("B")),
"tokenAAmount": amount.to_string(),
"tokenBAmount": amount.to_string(),
"feeBps": "30",
}),
);
assert_eq!(response["ok"], true);
assert_eq!(response["value"]["pool"]["reserveA"], amount.to_string());
assert_eq!(response["value"]["pool"]["reserveB"], amount.to_string());
assert_eq!(
response["value"]["userLiquidity"],
amount
.checked_sub(MINIMUM_LIQUIDITY)
.expect("test amount exceeds liquidity lock")
.to_string()
);
assert!(response["value"]["pool"]["reserveA"].is_string());
let prepared = call_json(
amm_client_quote,
&json!({
"operation": "prepare_create_pool",
"ammProgramId": program_id_words(amm_program_id),
"config": snapshot(compute_config_pda(amm_program_id), &config_account),
"tokenADefinition": snapshot(token_a_id, &definition("A")),
"tokenBDefinition": snapshot(token_b_id, &definition("B")),
"tokenAAmount": amount.to_string(),
"tokenBAmount": amount.to_string(),
"feeBps": "30",
}),
);
assert_eq!(prepared["ok"], true);
assert_eq!(
prepared["value"]["instructionArgs"]["tokenAAmount"],
amount.to_string()
);
assert_eq!(
prepared["value"]["instructionArgs"]["tokenBAmount"],
amount.to_string()
);
assert!(prepared["value"]["instructionArgs"]["tokenAAmount"].is_string());
}
#[test]
fn swap_quote_rejects_unrelated_output_holding() {
let amm_program_id: ProgramId = [11; 8];
let token_program_id: ProgramId = [22; 8];
let twap_oracle_program_id: ProgramId = [33; 8];
let token_a_id = AccountId::new([1; 32]);
let token_b_id = AccountId::new([2; 32]);
let unrelated_token_id = AccountId::new([3; 32]);
let pool_id = compute_pool_pda(amm_program_id, token_a_id, token_b_id);
let vault_a_id = compute_vault_pda(amm_program_id, pool_id, token_a_id);
let vault_b_id = compute_vault_pda(amm_program_id, pool_id, token_b_id);
let liquidity_id = compute_liquidity_token_pda(amm_program_id, pool_id);
let config = AmmConfig {
token_program_id,
twap_oracle_program_id,
authority: AccountId::new([9; 32]),
};
let pool = PoolDefinition {
definition_token_a_id: token_a_id,
definition_token_b_id: token_b_id,
vault_a_id,
vault_b_id,
liquidity_pool_id: liquidity_id,
liquidity_pool_supply: 2_000,
reserve_a: 1_000,
reserve_b: 500,
fees: FEE_TIER_BPS_30,
};
let response = call_json(
amm_client_quote,
&json!({
"operation": "preview_swap_exact_input",
"ammProgramId": program_id_words(amm_program_id),
"config": snapshot(
compute_config_pda(amm_program_id),
&account(amm_program_id, Data::from(&config)),
),
"snapshot": {
"pool": snapshot(
pool_id,
&account(amm_program_id, Data::from(&pool)),
),
"tokenADefinition": snapshot(
token_a_id,
&fungible_definition(token_program_id, 100_000, None),
),
"tokenBDefinition": snapshot(
token_b_id,
&fungible_definition(token_program_id, 100_000, None),
),
"vaultA": snapshot(
vault_a_id,
&fungible_holding(token_program_id, token_a_id, 1_100),
),
"vaultB": snapshot(
vault_b_id,
&fungible_holding(token_program_id, token_b_id, 550),
),
"liquidityDefinition": snapshot(
liquidity_id,
&fungible_definition(token_program_id, 2_000, Some(liquidity_id)),
),
},
"userInputHolding": snapshot(
AccountId::new([20; 32]),
&fungible_holding(token_program_id, token_a_id, 1_000),
),
"userOutputHolding": snapshot(
AccountId::new([21; 32]),
&fungible_holding(token_program_id, unrelated_token_id, 0),
),
"inputTokenDefinitionId": token_a_id.to_string(),
"amountIn": "100",
}),
);
assert_eq!(response["ok"], false);
assert_eq!(response["error"]["code"], "token_definition_mismatch");
}