mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
feat(modules/amm): add sync-reserves module op
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.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<Value, String> {
|
||||
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::<PoolDefinition>(&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::<Vec<String>>();
|
||||
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::<Vec<u64>>())
|
||||
};
|
||||
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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::<RemoveLiquidityPlanRequest>(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::<SyncReservesPlanRequest>(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::<TokenHoldingsRequest>(request_json, api::token_holdings)
|
||||
|
||||
@@ -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<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("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();
|
||||
|
||||
|
||||
@@ -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:<hex tx hash> }`. On failure:
|
||||
/// `{ status:"error", error:<code> }` — `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
|
||||
|
||||
Reference in New Issue
Block a user