feat(modules/amm): add swap_exact_out_plan op and module swapExactOutput

Server-side SwapExactOutput submission, mirroring the exact-in path. The
swap_exact_out_plan FFI op resolves the pool via the shared derive_pair,
encodes amm_core::Instruction::SwapExactOutput { exact_amount_out,
max_amount_in, deadline }, and returns the same four tx-submission fields as
swap_exact_in_plan (programId, accountIds, signingRequirements, instruction)
in the identical fixed 8-account IDL order — only the user's input holding
signs. Recoverable domain failures (same_token_pair, config_unavailable) go
through the FFI envelope as Err. C export amm_swap_exact_out_plan (cbindgen
header regenerated).

The module swapExactOutput(defA, defB, userIn, userOut, amountOut, maxIn,
deadline) method reads the AMM config, calls the op, and submits the plan via
send_generic_public_transaction, returning the tx hash (empty string on
failure). Same argument conventions and I/O-free split as swapExactInput.

Not yet wired into AmmUiBackend/QML — the SwapCard rewire is a follow-up. The
existing swap UI path is untouched, so nothing breaks.
This commit is contained in:
r4bbit
2026-08-06 23:15:01 +02:00
parent b2f0e4b851
commit 56b2d3a282
9 changed files with 312 additions and 16 deletions
+2
View File
@@ -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);
/**
+7 -1
View File
@@ -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)
+18
View File
@@ -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 {
+105 -9
View File
@@ -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<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")?;
@@ -296,7 +299,7 @@ pub(super) fn swap_exact_in_plan(request: SwapExactInPlanRequest) -> Result<Valu
.ok()
.and_then(|bytes| borsh::from_slice::<PoolDefinition>(&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<Valu
}))
}
/// Builds the `SwapExactOutput` submission for a pair: the same fixed 8-account
/// IDL order and error contract as `swap_exact_in_plan` — every recoverable
/// failure fails closed as `Err` (`same_token_pair`, `config_unavailable`, and
/// `Err("no_pool")` for a missing/undecodable pool). Only the encoded
/// instruction differs, carrying `exact_amount_out` / `max_amount_in` in place
/// of the exact-in bounds.
pub(super) fn swap_exact_out_plan(request: SwapExactOutPlanRequest) -> 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 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::<PoolDefinition>(&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::<Vec<_>>(),
"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())
}
+72 -2
View File
@@ -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)
);
}
+7 -1
View File
@@ -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::<SwapExactInPlanRequest>(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::<SwapExactOutPlanRequest>(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::<ProgramIdRequest>(request_json, api::program_id)
+4 -3
View File
@@ -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,
};
+83
View File
@@ -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<std::string> accounts = jsonStrVec(plan.value("accountIds", json::array()));
const std::vector<bool> signers = jsonBoolVec(plan.value("signingRequirements", json::array()));
const std::vector<uint8_t> 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();
+14
View File
@@ -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