diff --git a/modules/amm/ffi/include/amm_ffi.h b/modules/amm/ffi/include/amm_ffi.h index e6c72d7..a3d2b5e 100644 --- a/modules/amm/ffi/include/amm_ffi.h +++ b/modules/amm/ffi/include/amm_ffi.h @@ -38,6 +38,8 @@ char *amm_swap_exact_out_quote(const char *request_json); char *amm_swap_exact_in_plan(const char *request_json); +char *amm_swap_exact_out_plan(const char *request_json); + char *amm_program_id(const char *request_json); /** diff --git a/modules/amm/ffi/src/api/mod.rs b/modules/amm/ffi/src/api/mod.rs index 9145ff2..f23a0cb 100644 --- a/modules/amm/ffi/src/api/mod.rs +++ b/modules/amm/ffi/src/api/mod.rs @@ -23,7 +23,8 @@ use std::{error::Error, fmt}; pub use request::{ ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PoolIdRequest, PositionRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest, - SwapExactInQuoteRequest, SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, + SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, + TokenIdsRequest, }; use serde_json::Value; @@ -123,6 +124,11 @@ pub fn swap_exact_in_plan(request: SwapExactInPlanRequest) -> AmmResult { swap::swap_exact_in_plan(request).map_err(Into::into) } +/// Builds the `SwapExactOutput` wallet submission for a token pair. +pub fn swap_exact_out_plan(request: SwapExactOutPlanRequest) -> AmmResult { + swap::swap_exact_out_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) diff --git a/modules/amm/ffi/src/api/request.rs b/modules/amm/ffi/src/api/request.rs index 4fe9046..600640d 100644 --- a/modules/amm/ffi/src/api/request.rs +++ b/modules/amm/ffi/src/api/request.rs @@ -117,6 +117,24 @@ pub struct SwapExactInPlanRequest { pub pool_data: String, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SwapExactOutPlanRequest { + 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_out: String, + pub max_in: String, + pub deadline_ms: String, + /// Pool account data (hex Borsh `PoolDefinition`) — its stored `vault_a_id` / + /// `vault_b_id` are used verbatim (the guest asserts the vaults in the pool's + /// creation order, which needn't match the canonical token order). + pub pool_data: String, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProgramIdRequest { diff --git a/modules/amm/ffi/src/api/swap.rs b/modules/amm/ffi/src/api/swap.rs index 4dca9b6..8473aae 100644 --- a/modules/amm/ffi/src/api/swap.rs +++ b/modules/amm/ffi/src/api/swap.rs @@ -14,7 +14,7 @@ use serde_json::{json, Value}; use super::{ pair::{derive_pair, is_canonical_pair}, PoolIdRequest, ProgramIdRequest, ResolvePoolRequest, SwapExactInPlanRequest, - SwapExactInQuoteRequest, SwapExactOutQuoteRequest, SwapPairRequest, + SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, }; use crate::account::{ account_id_from_hex, account_id_hex, decode_account, parse_program_id, program_id_bytes, @@ -268,13 +268,16 @@ pub(super) fn swap_exact_out_quote(request: SwapExactOutQuoteRequest) -> Result< } /// 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). Returns exactly the tx-submission fields (`programId`, -/// `accountIds`, `signingRequirements`, `instruction`; the deadline is already -/// baked into the instruction bytes). Recoverable domain failures -/// (`same_token_pair`, `config_unavailable`) surface through the FFI envelope -/// as `{ ok: false, error }`. +/// order (vaults from the pool's stored ids, only the user's input holding +/// signs) and the instruction words (`risc0_zkvm::serde` — the same encoding +/// the guest decodes). On success returns exactly the tx-submission fields +/// (`programId`, `accountIds`, `signingRequirements`, `instruction`; the +/// deadline is already baked into the instruction bytes). Every recoverable +/// domain failure fails CLOSED as `Err` (surfaced through the FFI envelope as +/// `{ ok: false, error }`): `same_token_pair`, `config_unavailable`, and a +/// missing/undecodable pool as `Err("no_pool")` — so the caller stops on +/// `planResult.ok` rather than submit a tx with empty account/instruction +/// vectors. pub(super) fn swap_exact_in_plan(request: SwapExactInPlanRequest) -> Result { let amm_program = parse_program_id(&request.amm_program_id)?; let token_in = account_id_from_hex(&request.token_in_id, "token in id")?; @@ -296,7 +299,7 @@ pub(super) fn swap_exact_in_plan(request: SwapExactInPlanRequest) -> Result(&bytes).ok()) else { - return Ok(json!({ "status": "error", "code": "no_pool" })); + return Err(String::from("no_pool")); }; let user_input_holding = @@ -336,6 +339,73 @@ pub(super) fn swap_exact_in_plan(request: SwapExactInPlanRequest) -> Result Result { + 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 Err(String::from("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 Err(String::from("config_unavailable")); + }; + + // Take the vaults from the pool's STORED ids, in its `def_a`/`def_b` (creation) + // order — the guest asserts the provided vaults against `pool.vault_a_id` / + // `vault_b_id`, which needn't match `derive_pair`'s canonical order for a + // non-canonically created pool. (`pair.pool`/`current_tick`/`clock` are + // order-independent, so they still come from `derive_pair`.) + let Some(pool) = hex::decode(&request.pool_data) + .ok() + .and_then(|bytes| borsh::from_slice::(&bytes).ok()) + else { + return Err(String::from("no_pool")); + }; + + 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 exact_amount_out = parse_u128(&request.amount_out, "amountOut")?; + let max_amount_in = parse_u128(&request.max_in, "maxIn")?; + let deadline = parse_u64(&request.deadline_ms, "deadlineMs")?; + + let instruction = risc0_zkvm::serde::to_vec(&amm_core::Instruction::SwapExactOutput { + exact_amount_out, + max_amount_in, + deadline, + }) + .map_err(|error| format!("instruction serialization failed: {error}"))?; + + // Fixed IDL account order for SwapExactOutput; only user_input_holding signs. + let account_ids = [ + pair.config, + pair.pool, + pool.vault_a_id, + pool.vault_b_id, + user_input_holding, + user_output_holding, + pair.current_tick, + pair.clock, + ]; + let signing_requirements = [false, false, false, false, true, false, false, false]; + + Ok(json!({ + "programId": request.amm_program_id, + "accountIds": account_ids.into_iter().map(account_id_hex).collect::>(), + "signingRequirements": signing_requirements, + "instruction": instruction, + })) +} + /// 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. @@ -478,6 +548,32 @@ mod tests { assert_eq!(plan_error, "config_unavailable"); } + #[test] + fn swap_exact_out_plan_routes_same_token_through_the_envelope() { + let program = "00".repeat(32); + let same = "aa".repeat(32); + let dummy_config = AccountRead { + id: String::new(), + status: String::from("read_failed"), + account: None, + }; + + let plan_error = swap_exact_out_plan(SwapExactOutPlanRequest { + 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_out: String::new(), + max_in: String::new(), + deadline_ms: String::new(), + pool_data: String::new(), + }) + .unwrap_err(); + assert_eq!(plan_error, "same_token_pair"); + } + fn pool_data_hex(pool: &PoolDefinition) -> String { hex::encode(borsh::to_vec(pool).unwrap()) } diff --git a/modules/amm/ffi/src/api/tests.rs b/modules/amm/ffi/src/api/tests.rs index da462ec..81f53f2 100644 --- a/modules/amm/ffi/src/api/tests.rs +++ b/modules/amm/ffi/src/api/tests.rs @@ -22,9 +22,9 @@ use super::{ plan::plan, position::AccountPlanHoldings, quote::{div_ceil_u256, minimum_opening_pair, quote, Q64}, - swap::swap_exact_in_plan, + swap::{swap_exact_in_plan, swap_exact_out_plan}, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest, QuoteRequest, - SwapExactInPlanRequest, TokenIdsRequest, + SwapExactInPlanRequest, SwapExactOutPlanRequest, TokenIdsRequest, }; use crate::{ account::{account_id_hex, account_read, decode_account, parse_base58_id, program_id_bytes}, @@ -752,3 +752,73 @@ fn swap_plan_uses_the_pool_stored_vaults_not_canonical_order() { compute_vault_pda(AMM_PROGRAM, pool_id, token_large) ); } + +#[test] +fn swap_exact_in_plan_missing_pool_fails_closed_with_err() { + // A valid config (so derive_pair succeeds) but undecodable pool_data must + // surface as Err("no_pool") — NOT an Ok envelope. The FFI wraps Ok as + // { ok: true, .. }, so an Ok envelope would leave AmmModuleImpl's + // `planResult.ok` true and let it submit a tx with empty account/instruction + // vectors. Failing closed matches swap_exact_out_plan. + let token_a = AccountId::new([1; 32]); + let token_b = AccountId::new([2; 32]); + let holding = AccountId::new([9; 32]); + let err = swap_exact_in_plan(SwapExactInPlanRequest { + amm_program_id: amm_program_id(), + token_in_id: account_id_hex(token_a), + token_out_id: account_id_hex(token_b), + config: account_read(compute_config_pda(AMM_PROGRAM), &config_account()), + user_input_holding_id: account_id_hex(holding), + user_output_holding_id: account_id_hex(holding), + amount_in: String::from("100"), + min_out: String::from("0"), + deadline_ms: String::from("0"), + pool_data: String::new(), // absent/undecodable + }) + .unwrap_err(); + assert_eq!(err, "no_pool"); +} + +#[test] +fn swap_exact_out_plan_uses_the_pool_stored_vaults_not_canonical_order() { + // Same non-canonical pool as the exact-input case: the exact-output plan must + // likewise emit the pool's stored vaults, not the canonical derivation. + let token_small = AccountId::new([1; 32]); + let token_large = AccountId::new([2; 32]); + assert!(is_canonical_pair(token_large, token_small)); // large is canonical token_a + + let pool_id = compute_pool_pda(AMM_PROGRAM, token_small, token_large); + let pool = PoolDefinition { + definition_token_a_id: token_small, // stored non-canonically (small first) + definition_token_b_id: token_large, + vault_a_id: compute_vault_pda(AMM_PROGRAM, pool_id, token_small), + vault_b_id: compute_vault_pda(AMM_PROGRAM, pool_id, token_large), + liquidity_pool_id: compute_liquidity_token_pda(AMM_PROGRAM, pool_id), + liquidity_pool_supply: 1_000, + reserve_a: 1_000, + reserve_b: 1_000, + fees: 30, + }; + + let holding = AccountId::new([9; 32]); + let plan = swap_exact_out_plan(SwapExactOutPlanRequest { + amm_program_id: amm_program_id(), + token_in_id: account_id_hex(token_small), + token_out_id: account_id_hex(token_large), + config: account_read(compute_config_pda(AMM_PROGRAM), &config_account()), + user_input_holding_id: account_id_hex(holding), + user_output_holding_id: account_id_hex(holding), + amount_out: String::from("100"), + max_in: String::from("1000"), + deadline_ms: String::from("0"), + pool_data: hex::encode(borsh::to_vec(&pool).unwrap()), + }) + .unwrap(); + + assert_eq!(plan["accountIds"][2], account_id_hex(pool.vault_a_id)); + assert_eq!(plan["accountIds"][3], account_id_hex(pool.vault_b_id)); + assert_ne!( + pool.vault_a_id, + compute_vault_pda(AMM_PROGRAM, pool_id, token_large) + ); +} diff --git a/modules/amm/ffi/src/ffi.rs b/modules/amm/ffi/src/ffi.rs index d4c91df..6f05626 100644 --- a/modules/amm/ffi/src/ffi.rs +++ b/modules/amm/ffi/src/ffi.rs @@ -8,7 +8,8 @@ use serde::{de::DeserializeOwned, Serialize}; use crate::api::{ self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest, PoolIdRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest, - SwapExactInQuoteRequest, SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, + SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, + TokenIdsRequest, }; #[derive(Serialize)] @@ -137,6 +138,11 @@ pub extern "C" fn amm_swap_exact_in_plan(request_json: *const c_char) -> *mut c_ call::(request_json, api::swap_exact_in_plan) } +#[unsafe(no_mangle)] +pub extern "C" fn amm_swap_exact_out_plan(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::swap_exact_out_plan) +} + #[unsafe(no_mangle)] pub extern "C" fn amm_program_id(request_json: *const c_char) -> *mut c_char { call::(request_json, api::program_id) diff --git a/modules/amm/ffi/src/lib.rs b/modules/amm/ffi/src/lib.rs index c5115b5..15fc708 100644 --- a/modules/amm/ffi/src/lib.rs +++ b/modules/amm/ffi/src/lib.rs @@ -7,9 +7,10 @@ pub mod api; pub use api::{ config_id, context, pair_ids, plan, pool_id, program_id, quote, resolve_pool, - swap_exact_in_plan, swap_exact_in_quote, swap_exact_out_quote, swap_pair, token_ids, - AccountRead, AmmApiError, AmmResponse, AmmResult, ConfigIdRequest, ContextRequest, + swap_exact_in_plan, swap_exact_in_quote, swap_exact_out_plan, swap_exact_out_quote, swap_pair, + token_ids, AccountRead, AmmApiError, AmmResponse, AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PoolIdRequest, PositionRequest, ProgramIdRequest, QuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, - SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, WalletAccount, + SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, + WalletAccount, }; diff --git a/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp index 6bd9bf9..d8e723e 100644 --- a/modules/amm/src/amm_module_impl.cpp +++ b/modules/amm/src/amm_module_impl.cpp @@ -663,6 +663,89 @@ std::string AmmModuleImpl::swapExactInput(const std::string& def_a_hex, return jStr(obj, "tx_hash"); } +std::string AmmModuleImpl::swapExactOutput(const std::string& def_a_hex, + const std::string& def_b_hex, + const std::string& user_input_holding_hex, + const std::string& user_output_holding_hex, + const nlohmann::json& amount_out, + const nlohmann::json& max_in, + const nlohmann::json& deadline) { + std::string amount_out_decimal; + std::string max_in_decimal; + std::string deadline_decimal; + if (!jsonAmountToDecimal(amount_out, amount_out_decimal) + || !jsonAmountToDecimal(max_in, max_in_decimal) + || !jsonAmountToDecimal(deadline, deadline_decimal)) { + AMM_TRACE("swapExactOutput: FAIL amount/deadline not a number or decimal string"); + return {}; + } + + const Network net = network(); + if (net.status != "ready") { + AMM_TRACE("swapExactOutput: FAIL network not ready (" << net.status << ")"); + return {}; + } + + const json config = readConfig(net); + if (config.is_null()) { + AMM_TRACE("swapExactOutput: FAIL config_id op failed"); + return {}; + } + + // Read the pool so the plan can use its stored vault ids (the guest asserts + // the vaults in the pool's creation order — see amm_swap_exact_out_plan). + const FfiResult poolId = call(amm_pool_id, json{ + {"ammProgramId", net.amm_program_id}, + {"tokenInId", def_a_hex}, + {"tokenOutId", def_b_hex}, + }); + if (!poolId.ok) { + AMM_TRACE("swapExactOutput: FAIL amm_pool_id"); + return {}; + } + const json pool = readPublicAccount(jStr(poolId.value, "poolId")); + const std::string pool_data = jStr(pool.value("account", json::object()), "data"); + + // amm_swap_exact_out_plan resolves the pool accounts, encodes SwapExactOutput, + // and returns a ready-to-submit plan. + const FfiResult planResult = call(amm_swap_exact_out_plan, json{ + {"ammProgramId", net.amm_program_id}, + {"tokenInId", def_a_hex}, + {"tokenOutId", def_b_hex}, + {"config", config}, + {"poolData", pool_data}, + {"userInputHoldingId", user_input_holding_hex}, + {"userOutputHoldingId", user_output_holding_hex}, + {"amountOut", amount_out_decimal}, + {"maxIn", max_in_decimal}, + {"deadlineMs", deadline_decimal}, + }); + if (!planResult.ok) { + AMM_TRACE("swapExactOutput: FAIL amm_swap_exact_out_plan: " << planResult.error); + return {}; + } + const json plan = planResult.value; + + const std::vector accounts = jsonStrVec(plan.value("accountIds", json::array())); + const std::vector signers = jsonBoolVec(plan.value("signingRequirements", json::array())); + const std::vector instruction = jsonWordsToLeBytes(plan.value("instruction", json::array())); + const std::string program_id = jStr(plan, "programId"); + + AMM_TRACE("swapExactOutput: SUBMIT programId=" << program_id + << " instrBytes=" << instruction.size() << " accounts=" << accounts.size()); + + const std::string reply = modules().logos_execution_zone.send_generic_public_transaction( + accounts, signers, instruction, program_id); + AMM_TRACE("swapExactOutput: tx reply=" << reply); + + const auto obj = json::parse(reply, nullptr, /*allow_exceptions=*/false); + if (!obj.is_object() || !obj.value("success", false)) { + AMM_TRACE("swapExactOutput: FAIL tx not successful"); + return {}; + } + return jStr(obj, "tx_hash"); +} + LogosList AmmModuleImpl::tokenList() { LogosList out = LogosList::array(); diff --git a/modules/amm/src/amm_module_impl.h b/modules/amm/src/amm_module_impl.h index aa68113..5432be2 100644 --- a/modules/amm/src/amm_module_impl.h +++ b/modules/amm/src/amm_module_impl.h @@ -85,6 +85,20 @@ public: const nlohmann::json& min_out, const nlohmann::json& deadline); + /// Submits an on-chain SwapExactOutput transaction against the pool for + /// (def_a_hex = token in, def_b_hex = token out). amount_out / max_in are + /// u128 base-unit amounts; deadline is a u64 unix-ms timestamp. Same argument + /// conventions as swapExactInput (JSON integer or decimal string; floats + /// rejected). Returns the tx hash, or an empty string on failure (no pool, + /// unreadable AMM_PROGRAM_BIN, bad inputs, failed tx). + std::string swapExactOutput(const std::string& def_a_hex, + const std::string& def_b_hex, + const std::string& user_input_holding_hex, + const std::string& user_output_holding_hex, + const nlohmann::json& amount_out, + const nlohmann::json& max_in, + const nlohmann::json& deadline); + /// Reads the token list config at TOKENS_CONFIG (a JSON array of /// { symbol, name, definitionId, holding, decimals }) and returns it, /// normalizing definitionId/holding to lowercase hex. Empty list if