From 62dc45177da2ecfc66348fbff34f0fc59efa9b46 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:36:45 +0200 Subject: [PATCH] feat(modules/amm): add sync-reserves module op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A permissionless keeper op that refreshes a pool's stored reserves to the live vault balances (and its TWAP tick). Same lean pattern as the other plans, but minimal: SyncReserves is a unit instruction — no quote, no user inputs (no amounts/slippage/deadline/holdings), and nothing signs. FFI (modules/amm/ffi): - sync_reserves_plan: encodes SyncReserves over the fixed 6-account IDL order (config, pool, vault_a, vault_b, current_tick, clock), all non-signing. config/pool/current_tick/clock are order-independent PDAs from derive_pair; the vaults come from the pool's stored ids in pool_data (read-only, but the guest still asserts them — a non-canonically-stored pool would otherwise mismatch). Fails closed: same_token_pair, config_unavailable, no_pool. - Wired through mod.rs / ffi.rs (cbindgen header regenerated). Tests cover the stored-vault + zero-signer + unit-instruction layout and the fail-closed paths. amm_ffi: 43 tests pass, clippy clean. (lib.rs is a cargo fmt re-wrap.) C++ module (modules/amm/src): - syncReserves reads config + pool server-side, calls the plan, and submits. request is just { tokenAId, tokenBId } — no holdings/amounts/deadline. Public method → auto-exposed via the universal-module dispatch. --- modules/amm/ffi/include/amm_ffi.h | 2 + modules/amm/ffi/src/api/liquidity.rs | 137 +++++++++++++++++++++++++++ modules/amm/ffi/src/api/mod.rs | 7 +- modules/amm/ffi/src/api/request.rs | 17 ++++ modules/amm/ffi/src/ffi.rs | 8 +- modules/amm/src/amm_module_impl.cpp | 67 +++++++++++++ modules/amm/src/amm_module_impl.h | 10 ++ 7 files changed, 246 insertions(+), 2 deletions(-) diff --git a/modules/amm/ffi/include/amm_ffi.h b/modules/amm/ffi/include/amm_ffi.h index bbafc55..2df3285 100644 --- a/modules/amm/ffi/include/amm_ffi.h +++ b/modules/amm/ffi/include/amm_ffi.h @@ -48,6 +48,8 @@ char *amm_remove_liquidity_quote(const char *request_json); char *amm_remove_liquidity_plan(const char *request_json); +char *amm_sync_reserves_plan(const char *request_json); + char *amm_token_holdings(const char *request_json); char *amm_program_id(const char *request_json); diff --git a/modules/amm/ffi/src/api/liquidity.rs b/modules/amm/ffi/src/api/liquidity.rs index 3b3adf7..a41400d 100644 --- a/modules/amm/ffi/src/api/liquidity.rs +++ b/modules/amm/ffi/src/api/liquidity.rs @@ -23,6 +23,7 @@ use super::{ quote::minimum_opening_pair, AddLiquidityPlanRequest, AddLiquidityQuoteRequest, CreatePoolPlanRequest, LiquidityQuoteRequest, RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest, + SyncReservesPlanRequest, }; use crate::account::{account_id_from_hex, account_id_hex, parse_program_id}; @@ -574,6 +575,58 @@ pub(super) fn remove_liquidity_plan(request: RemoveLiquidityPlanRequest) -> Resu )) } +/// Builds the `SyncReserves` submission — a permissionless keeper op that refreshes the pool's +/// stored reserves to the live vault balances (and its TWAP tick). A unit instruction: no +/// amounts, deadline, or holdings, and nothing signs. config / pool / current_tick / clock are +/// order-independent PDAs from `derive_pair`; the vaults come from the pool's stored ids in +/// `pool_data` (read-only, but the guest still asserts them). Fixed 6-account IDL order. +/// Recoverable failures fail closed as `Err` (`same_token_pair`, `config_unavailable`, +/// `no_pool`). +pub(super) fn sync_reserves_plan(request: SyncReservesPlanRequest) -> Result { + let amm_program = parse_program_id(&request.amm_program_id)?; + let token_a = account_id_from_hex(&request.token_a_id, "token A id")?; + let token_b = account_id_from_hex(&request.token_b_id, "token B id")?; + if token_a == token_b { + return Err(String::from("same_token_pair")); + } + + // config / pool / current_tick / clock are order-independent PDAs, so the token order does + // not matter for derive_pair. + let Ok(pair) = derive_pair(amm_program, token_a, token_b, &request.config) else { + return Err(String::from("config_unavailable")); + }; + + // The vaults are asserted against the pool's stored ids, so take them from pool_data (a pool + // created outside the FFI can store a non-canonical order — see add/remove/swap plans). + 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 instruction = risc0_zkvm::serde::to_vec(&amm_core::Instruction::SyncReserves) + .map_err(|error| format!("instruction serialization failed: {error}"))?; + + // Fixed IDL account order for SyncReserves; nothing signs (permissionless keeper op). + let account_ids = [ + pair.config, + pair.pool, + pool.vault_a_id, + pool.vault_b_id, + pair.current_tick, + pair.clock, + ]; + let signing_requirements = [false, false, false, false, false, false]; + + Ok(plan_response( + &request.amm_program_id, + account_ids, + &signing_requirements, + instruction, + )) +} + #[cfg(test)] mod tests { use amm_core::{compute_config_pda, compute_pool_pda, compute_vault_pda, AmmConfig}; @@ -1382,4 +1435,88 @@ mod tests { no_pool.config = valid_config(amm); assert_eq!(remove_liquidity_plan(no_pool), Err(String::from("no_pool"))); } + + #[test] + fn sync_plan_emits_pool_stored_vaults_and_signs_nothing() { + let program = "00".repeat(32); + let amm = parse_program_id(&program).unwrap(); + let token_a = AccountId::new([0x11; 32]); + let token_b = AccountId::new([0x22; 32]); + let vault_a = AccountId::new([0xA1; 32]); + let vault_b = AccountId::new([0xB1; 32]); + let pool = PoolDefinition { + definition_token_a_id: token_a, + definition_token_b_id: token_b, + vault_a_id: vault_a, + vault_b_id: vault_b, + liquidity_pool_id: AccountId::new([0xCC; 32]), + liquidity_pool_supply: 1_000_000, + reserve_a: 1_000_000, + reserve_b: 2_000_000, + fees: 30, + }; + let value = sync_reserves_plan(SyncReservesPlanRequest { + amm_program_id: program.clone(), + config: valid_config(amm), + token_a_id: account_id_hex(token_a), + token_b_id: account_id_hex(token_b), + pool_data: pool_hex(&pool), + }) + .unwrap(); + + let ids = value["accountIds"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect::>(); + assert_eq!(ids.len(), 6); + assert_eq!(ids[0], account_id_hex(compute_config_pda(amm))); + assert_eq!( + ids[1], + account_id_hex(compute_pool_pda(amm, token_a, token_b)) + ); + assert_eq!(ids[2], account_id_hex(vault_a)); // pool's stored vaults + assert_eq!(ids[3], account_id_hex(vault_b)); + // ids[4] current_tick, ids[5] clock — order-independent PDAs. + assert_eq!( + value["signingRequirements"], + serde_json::json!([false, false, false, false, false, false]) + ); + let expected_instruction = { + let words = risc0_zkvm::serde::to_vec(&amm_core::Instruction::SyncReserves).unwrap(); + serde_json::json!(words.iter().map(|w| u64::from(*w)).collect::>()) + }; + assert_eq!(value["instruction"], expected_instruction); + } + + #[test] + fn sync_plan_fails_closed() { + let token = AccountId::new([0xAA; 32]); + let other = AccountId::new([0xBB; 32]); + let base = + |token_a: AccountId, token_b: AccountId, pool_data: String| SyncReservesPlanRequest { + amm_program_id: "00".repeat(32), + config: read_failed(), + token_a_id: account_id_hex(token_a), + token_b_id: account_id_hex(token_b), + pool_data, + }; + + // Same token pair — rejected before any config/pool work. + assert_eq!( + sync_reserves_plan(base(token, token, String::new())), + Err(String::from("same_token_pair")) + ); + // Unavailable config (read_failed) surfaces before the pool decode. + assert_eq!( + sync_reserves_plan(base(token, other, String::new())), + Err(String::from("config_unavailable")) + ); + // Valid config but no pool data → no_pool (decode happens after derive_pair). + let amm = parse_program_id(&"00".repeat(32)).unwrap(); + let mut no_pool = base(token, other, String::new()); + no_pool.config = valid_config(amm); + assert_eq!(sync_reserves_plan(no_pool), Err(String::from("no_pool"))); + } } diff --git a/modules/amm/ffi/src/api/mod.rs b/modules/amm/ffi/src/api/mod.rs index a24a8f5..af06439 100644 --- a/modules/amm/ffi/src/api/mod.rs +++ b/modules/amm/ffi/src/api/mod.rs @@ -21,7 +21,8 @@ pub use request::{ CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, - SwapExactOutQuoteRequest, SwapPairRequest, TokenHoldingsRequest, TokenIdsRequest, + SwapExactOutQuoteRequest, SwapPairRequest, SyncReservesPlanRequest, TokenHoldingsRequest, + TokenIdsRequest, }; use serde_json::Value; @@ -142,6 +143,10 @@ pub fn remove_liquidity_plan(request: RemoveLiquidityPlanRequest) -> AmmResult { liquidity::remove_liquidity_plan(request).map_err(Into::into) } +pub fn sync_reserves_plan(request: SyncReservesPlanRequest) -> AmmResult { + liquidity::sync_reserves_plan(request).map_err(Into::into) +} + /// Lists the wallet's fungible token holdings for the account selector. pub fn token_holdings(request: TokenHoldingsRequest) -> AmmResult { token_holdings::token_holdings(request).map_err(Into::into) diff --git a/modules/amm/ffi/src/api/request.rs b/modules/amm/ffi/src/api/request.rs index a218975..b2ee2de 100644 --- a/modules/amm/ffi/src/api/request.rs +++ b/modules/amm/ffi/src/api/request.rs @@ -254,6 +254,23 @@ pub struct RemoveLiquidityPlanRequest { pub pool_data: String, } +/// Builds the `SyncReserves` submission — a permissionless keeper op refreshing the pool's +/// stored reserves to the live vault balances (and its TWAP tick). No amounts / deadline / +/// holdings: it is a unit instruction over pool-derived accounts. `pool_data` supplies the +/// stored vault ids the guest asserts against. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SyncReservesPlanRequest { + /// Resolved by the module from `AMM_PROGRAM_BIN` (like every id-deriving op). + pub amm_program_id: String, + /// AMM config account read — decoded by `derive_pair` for the `twap_oracle_program_id` + /// the `current_tick` PDA depends on (same as the swap / liquidity plan requests). + pub config: AccountRead, + pub token_a_id: String, + pub token_b_id: String, + pub pool_data: String, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct TokenHoldingsRequest { diff --git a/modules/amm/ffi/src/ffi.rs b/modules/amm/ffi/src/ffi.rs index 2b4561a..6de71b9 100644 --- a/modules/amm/ffi/src/ffi.rs +++ b/modules/amm/ffi/src/ffi.rs @@ -10,7 +10,8 @@ use crate::api::{ ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, LiquidityQuoteRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, - SwapExactOutQuoteRequest, SwapPairRequest, TokenHoldingsRequest, TokenIdsRequest, + SwapExactOutQuoteRequest, SwapPairRequest, SyncReservesPlanRequest, TokenHoldingsRequest, + TokenIdsRequest, }; #[derive(Serialize)] @@ -164,6 +165,11 @@ pub extern "C" fn amm_remove_liquidity_plan(request_json: *const c_char) -> *mut call::(request_json, api::remove_liquidity_plan) } +#[unsafe(no_mangle)] +pub extern "C" fn amm_sync_reserves_plan(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::sync_reserves_plan) +} + #[unsafe(no_mangle)] pub extern "C" fn amm_token_holdings(request_json: *const c_char) -> *mut c_char { call::(request_json, api::token_holdings) diff --git a/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp index afc6fc7..4093f00 100644 --- a/modules/amm/src/amm_module_impl.cpp +++ b/modules/amm/src/amm_module_impl.cpp @@ -1173,6 +1173,73 @@ LogosMap AmmModuleImpl::removeLiquidity(const LogosMap& request) { return LogosMap{{"status", "ok"}, {"error", ""}, {"transactionId", jStr(obj, "tx_hash")}}; } +LogosMap AmmModuleImpl::syncReserves(const LogosMap& request) { + auto error = [](const std::string& err) { + return LogosMap{{"status", "error"}, {"error", err}}; + }; + + // config_missing == no program id from AMM_PROGRAM_BIN (same as the other submits). + const std::string amm_program_id = ammProgramId(); + if (amm_program_id.empty()) + return error("config_missing"); + + // amm_sync_reserves_plan needs the config account for the twap program id the current-tick + // PDA derives from; a bad/absent config surfaces from the plan. + const FfiResult configResult = + call(amm_config_id, json{{"ammProgramId", amm_program_id}}); + if (!configResult.ok) + return error("backend_error"); + const json config = readPublicAccount(jStr(configResult.value, "configId")); + + // Normalize the pair to hex (transitional). Sync has no user inputs beyond the pair — no + // holdings, amounts, or deadline, and nothing signs. + const std::string token_a = normalizeAccountId(jStr(request, "tokenAId")); + const std::string token_b = normalizeAccountId(jStr(request, "tokenBId")); + if (token_a.empty() || token_b.empty()) + return error("invalid_token_id"); + + // Read the pool so the plan can use its stored vault ids (the guest asserts the provided + // vaults against them — see amm_sync_reserves_plan). + const FfiResult poolId = call(amm_pool_id, json{ + {"ammProgramId", amm_program_id}, + {"tokenInId", token_a}, + {"tokenOutId", token_b}, + }); + if (!poolId.ok) + return error(poolId.error.empty() ? "backend_error" : poolId.error); + const json pool = readPublicAccount(jStr(poolId.value, "poolId")); + const std::string pool_data = jStr(pool.value("account", json::object()), "data"); + + const FfiResult planResult = call(amm_sync_reserves_plan, json{ + {"ammProgramId", amm_program_id}, + {"config", config}, + {"tokenAId", token_a}, + {"tokenBId", token_b}, + {"poolData", pool_data}, + }); + if (!planResult.ok) + return error(planResult.error.empty() ? "backend_error" : planResult.error); + 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("syncReserves: 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("syncReserves: tx reply=" << reply); + + const auto obj = json::parse(reply, nullptr, /*allow_exceptions=*/false); + if (!obj.is_object() || !obj.value("success", false)) + return error("wallet_submission_failed"); + + return LogosMap{{"status", "ok"}, {"error", ""}, {"transactionId", 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 27df309..33f8e1d 100644 --- a/modules/amm/src/amm_module_impl.h +++ b/modules/amm/src/amm_module_impl.h @@ -184,6 +184,16 @@ public: /// `no_pool`, `config_unavailable`). LogosMap removeLiquidity(const LogosMap& request); + /// Submits a `SyncReserves` transaction for the (tokenAId, tokenBId) pool — a + /// permissionless keeper op that refreshes the pool's stored reserves to the live vault + /// balances and its TWAP tick. `request` carries just `{ tokenAId, tokenBId }` (ids hex or + /// base58, normalized to hex): no amounts, deadline, or holdings, and nothing signs. On + /// success: `{ status:"ok", error:"", transactionId: }`. On failure: + /// `{ status:"error", error: }` — `invalid_token_id`, `config_missing`, + /// `backend_error`, `wallet_submission_failed`, or a plan code (e.g. `no_pool`, + /// `config_unavailable`). + LogosMap syncReserves(const LogosMap& request); + /// Lists the connected wallet's fungible token holdings for the account /// selector: `[{ accountId (hex), accountType:"TokenHolding", definitionId /// (base58), definitionIdHex (hex), balanceRaw }]` — one row per holding