From 7e45e44eac74a1e5358f738a0b2943446d6fe10b Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:28:08 +0200 Subject: [PATCH] feat(amm): add oracle setup ops (createPriceObservations / createOraclePriceAccount) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the two TWAP oracle-setup instructions as module ops so a pool's price feeds can be seeded from the app. Both chain into the configured oracle, seeded from validated pool state (initial tick read on-chain) — nothing is caller-priced, and each window is a distinct feed account. --- apps/amm/src/AmmUiBackend.cpp | 21 ++++++ apps/amm/src/AmmUiBackend.h | 2 + apps/amm/src/AmmUiBackend.rep | 8 ++ modules/amm/ffi/include/amm_ffi.h | 4 + modules/amm/ffi/src/api/mod.rs | 14 +++- modules/amm/ffi/src/api/oracle.rs | 110 ++++++++++++++++++++++++++++ modules/amm/ffi/src/api/pair.rs | 3 + modules/amm/ffi/src/api/request.rs | 25 +++++++ modules/amm/ffi/src/api/tests.rs | 105 +++++++++++++++++++++++++- modules/amm/ffi/src/ffi.rs | 13 +++- modules/amm/ffi/src/lib.rs | 12 +-- modules/amm/src/amm_module_impl.cpp | 85 +++++++++++++++++++++ modules/amm/src/amm_module_impl.h | 15 ++++ 13 files changed, 407 insertions(+), 10 deletions(-) create mode 100644 modules/amm/ffi/src/api/oracle.rs diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index f165f85..29b2a3c 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -201,6 +201,27 @@ QVariantMap AmmUiBackend::transferOwnership(QVariantMap request) return m_logos->amm_module.transferOwnership(request); } +QVariantMap AmmUiBackend::createPriceObservations(QVariantMap request) +{ + if (!isWalletOpen()) + return QVariantMap { + { QStringLiteral("status"), QStringLiteral("error") }, + { QStringLiteral("error"), QStringLiteral("wallet_unavailable") }, + }; + // Oracle setup doesn't touch token balances — no refresh. + return m_logos->amm_module.createPriceObservations(request); +} + +QVariantMap AmmUiBackend::createOraclePriceAccount(QVariantMap request) +{ + if (!isWalletOpen()) + return QVariantMap { + { QStringLiteral("status"), QStringLiteral("error") }, + { QStringLiteral("error"), QStringLiteral("wallet_unavailable") }, + }; + return m_logos->amm_module.createOraclePriceAccount(request); +} + QString AmmUiBackend::swapExactInput(QString defAHex, QString defBHex, QString userInputHoldingHex, QString userOutputHoldingHex, QString amountInDecimal, QString minOutDecimal, QString deadlineDecimal) diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 987bcc8..a0105da 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -57,6 +57,8 @@ public slots: QVariantMap resolvePoolAccount(QString defAHex, QString defBHex) override; QVariantMap configAccount() override; QVariantMap transferOwnership(QVariantMap request) override; + QVariantMap createPriceObservations(QVariantMap request) override; + QVariantMap createOraclePriceAccount(QVariantMap request) override; QString swapExactInput(QString defAHex, QString defBHex, QString userInputHoldingHex, QString userOutputHoldingHex, QString amountInDecimal, QString minOutDecimal, QString deadlineDecimal) override; diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index 708d487..4e5b597 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -59,6 +59,14 @@ class AmmUiBackend // transactionId: } or { status:"error", error: } (wallet_unavailable, // config_missing, invalid_account_id, wallet_submission_failed, backend_error). SLOT(QVariantMap transferOwnership(QVariantMap request)) + // Oracle setup (keeper): seed a pool's TWAP feed / create its oracle price account for a + // window. `request` carries { tokenAId, tokenBId, windowDurationMs }. Direct submits + // (chained into the oracle, seeded from validated pool state — nothing signs). Return + // { status:"ok", error:"", transactionId: } or { status:"error", error: } + // (wallet_unavailable, config_missing, invalid_token_id, invalid_window, same_token_pair, + // config_unavailable, already_exists, wallet_submission_failed, backend_error). + SLOT(QVariantMap createPriceObservations(QVariantMap request)) + SLOT(QVariantMap createOraclePriceAccount(QVariantMap request)) // Submits a real on-chain SwapExactInput transaction against the pool for // (defAHex, defBHex). amountInDecimal/minOutDecimal are decimal-string // u128 amounts in base units; deadlineDecimal is a decimal-string u64 unix diff --git a/modules/amm/ffi/include/amm_ffi.h b/modules/amm/ffi/include/amm_ffi.h index bdedc9f..7111382 100644 --- a/modules/amm/ffi/include/amm_ffi.h +++ b/modules/amm/ffi/include/amm_ffi.h @@ -52,6 +52,10 @@ char *amm_sync_reserves_plan(const char *request_json); char *amm_transfer_ownership_plan(const char *request_json); +char *amm_create_price_observations_plan(const char *request_json); + +char *amm_create_oracle_price_account_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/mod.rs b/modules/amm/ffi/src/api/mod.rs index c1e863b..08dfea5 100644 --- a/modules/amm/ffi/src/api/mod.rs +++ b/modules/amm/ffi/src/api/mod.rs @@ -6,6 +6,7 @@ mod context; mod fee; mod holding; mod liquidity; +mod oracle; mod pair; mod quote; mod request; @@ -19,7 +20,8 @@ use std::{error::Error, fmt}; pub use request::{ AddLiquidityPlanRequest, AddLiquidityQuoteRequest, ConfigAccountRequest, ConfigIdRequest, - CreatePoolPlanRequest, CreatePoolQuoteRequest, FeeTiersRequest, PairIdsRequest, PoolIdRequest, + CreateOraclePriceAccountPlanRequest, CreatePoolPlanRequest, CreatePoolQuoteRequest, + CreatePriceObservationsPlanRequest, FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest, ResolvePoolRequest, ResolveTokensRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, SyncReservesPlanRequest, TokenHoldingsRequest, @@ -153,6 +155,16 @@ pub fn transfer_ownership_plan(request: TransferOwnershipPlanRequest) -> AmmResu admin::transfer_ownership_plan(request).map_err(Into::into) } +/// Builds the `CreatePriceObservations` submission — seeds a pool's TWAP observations feed. +pub fn create_price_observations_plan(request: CreatePriceObservationsPlanRequest) -> AmmResult { + oracle::create_price_observations_plan(request).map_err(Into::into) +} + +/// Builds the `CreateOraclePriceAccount` submission — creates a pool's TWAP oracle price account. +pub fn create_oracle_price_account_plan(request: CreateOraclePriceAccountPlanRequest) -> AmmResult { + oracle::create_oracle_price_account_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/oracle.rs b/modules/amm/ffi/src/api/oracle.rs new file mode 100644 index 0000000..f2c2318 --- /dev/null +++ b/modules/amm/ffi/src/api/oracle.rs @@ -0,0 +1,110 @@ +use amm_core::Instruction; +use nssa_core::account::AccountId; +use serde_json::{json, Value}; +use twap_oracle_core::{compute_oracle_price_account_pda, compute_price_observations_pda}; + +use super::{ + pair::derive_pair, CreateOraclePriceAccountPlanRequest, CreatePriceObservationsPlanRequest, +}; +use crate::account::{account_id_from_hex, account_id_hex, parse_program_id}; + +/// The tx-submission envelope shared by the two oracle-setup plans: the fixed IDL account ids as +/// hex, their signer flags (nothing signs — both are chained calls into the TWAP oracle seeded +/// from validated pool state), and the risc0-encoded instruction words. +fn plan_response(program_id: &str, account_ids: &[AccountId], instruction: Vec) -> Value { + let signing_requirements = vec![false; account_ids.len()]; + json!({ + "programId": program_id, + "accountIds": account_ids.iter().copied().map(account_id_hex).collect::>(), + "signingRequirements": signing_requirements, + "instruction": instruction, + }) +} + +/// Resolves the pair's config / pool / current-tick / clock, returning them plus the TWAP oracle +/// program id (for the window-seeded PDAs). Shared prelude for the two oracle-setup plans. +fn resolve( + request_amm_program_id: &str, + token_a_id: &str, + token_b_id: &str, + config: &crate::account::AccountRead, +) -> Result { + let amm_program = parse_program_id(request_amm_program_id)?; + let token_a = account_id_from_hex(token_a_id, "token A id")?; + let token_b = account_id_from_hex(token_b_id, "token B id")?; + if token_a == token_b { + return Err(String::from("same_token_pair")); + } + derive_pair(amm_program, token_a, token_b, config) + .map_err(|_| String::from("config_unavailable")) +} + +/// Builds the `CreatePriceObservations` submission: creates the pool's TWAP observations account +/// for `window_duration_ms`. A chained call into the configured oracle — the feed's initial tick +/// is read on-chain from the pool's current-tick account, so nothing is caller-priced. +pub(super) fn create_price_observations_plan( + request: CreatePriceObservationsPlanRequest, +) -> Result { + let pair = resolve( + &request.amm_program_id, + &request.token_a_id, + &request.token_b_id, + &request.config, + )?; + let window = request.window_duration_ms; + if window < u64::from(twap_oracle_core::OBSERVATIONS_CAPACITY) { + return Err(String::from("invalid_window")); + } + let price_observations = + compute_price_observations_pda(pair.twap_oracle_program, pair.pool, window); + + let instruction = risc0_zkvm::serde::to_vec(&Instruction::CreatePriceObservations { + window_duration: window, + }) + .map_err(|error| format!("instruction serialization failed: {error}"))?; + + // Fixed IDL account order: config, pool (the price source), current_tick (supplies the initial + // tick), price_observations (init), clock. Nothing signs. + let account_ids = [ + pair.config, + pair.pool, + pair.current_tick, + price_observations, + pair.clock, + ]; + Ok(plan_response( + &request.amm_program_id, + &account_ids, + instruction, + )) +} + +/// Builds the `CreateOraclePriceAccount` submission: creates the pool's TWAP oracle price account +/// for `window_duration_ms`. A chained call into the configured oracle — no caller pricing. +pub(super) fn create_oracle_price_account_plan( + request: CreateOraclePriceAccountPlanRequest, +) -> Result { + let pair = resolve( + &request.amm_program_id, + &request.token_a_id, + &request.token_b_id, + &request.config, + )?; + let window = request.window_duration_ms; + let oracle_price_account = + compute_oracle_price_account_pda(pair.twap_oracle_program, pair.pool, window); + + let instruction = risc0_zkvm::serde::to_vec(&Instruction::CreateOraclePriceAccount { + window_duration: window, + }) + .map_err(|error| format!("instruction serialization failed: {error}"))?; + + // Fixed IDL account order: config, pool (the price source), oracle_price_account (init), clock. + // Nothing signs. + let account_ids = [pair.config, pair.pool, oracle_price_account, pair.clock]; + Ok(plan_response( + &request.amm_program_id, + &account_ids, + instruction, + )) +} diff --git a/modules/amm/ffi/src/api/pair.rs b/modules/amm/ffi/src/api/pair.rs index 89972a0..36e97b5 100644 --- a/modules/amm/ffi/src/api/pair.rs +++ b/modules/amm/ffi/src/api/pair.rs @@ -22,6 +22,8 @@ pub(super) struct PairIds { pub(super) lp_lock_holding: AccountId, pub(super) current_tick: AccountId, pub(super) clock: AccountId, + /// The configured TWAP oracle program — the price/observation PDAs are seeded by it. + pub(super) twap_oracle_program: ProgramId, } pub(super) fn pair_ids(request: PairIdsRequest) -> Result { @@ -69,6 +71,7 @@ pub(super) fn derive_pair( lp_lock_holding: compute_lp_lock_holding_pda(amm_program, pool), current_tick: compute_current_tick_account_pda(config.twap_oracle_program_id, pool), clock: CLOCK_01_PROGRAM_ACCOUNT_ID, + twap_oracle_program: config.twap_oracle_program_id, }) } diff --git a/modules/amm/ffi/src/api/request.rs b/modules/amm/ffi/src/api/request.rs index 39997d7..59119d6 100644 --- a/modules/amm/ffi/src/api/request.rs +++ b/modules/amm/ffi/src/api/request.rs @@ -29,6 +29,31 @@ pub struct TransferOwnershipPlanRequest { pub new_authority_id: String, } +/// Builds the `CreatePriceObservations` submission — seeds the pool's TWAP observations feed for a +/// window. `config` is the read of the config PDA (its `twap_oracle_program_id` seeds the feed +/// PDAs); `token_ids` are hex; `window_duration_ms` is the TWAP window in milliseconds. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreatePriceObservationsPlanRequest { + pub amm_program_id: String, + pub config: AccountRead, + pub token_a_id: String, + pub token_b_id: String, + pub window_duration_ms: u64, +} + +/// Builds the `CreateOraclePriceAccount` submission — creates the pool's TWAP oracle price account +/// for a window. Same inputs as `CreatePriceObservationsPlanRequest`. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreateOraclePriceAccountPlanRequest { + pub amm_program_id: String, + pub config: AccountRead, + pub token_a_id: String, + pub token_b_id: String, + pub window_duration_ms: u64, +} + /// Resolves an app-provided set of token ids into selector rows. `token_ids` are hex — the /// module normalizes base58→hex and reads each definition into `token_definitions` (keyed by /// hex id) plus the wallet accounts; the FFI is stateless and reads nothing itself. diff --git a/modules/amm/ffi/src/api/tests.rs b/modules/amm/ffi/src/api/tests.rs index 7215715..ad30006 100644 --- a/modules/amm/ffi/src/api/tests.rs +++ b/modules/amm/ffi/src/api/tests.rs @@ -11,18 +11,23 @@ use nssa_core::{ use pretty_assertions::assert_eq; use serde_json::json; use token_core::{TokenDefinition, TokenHolding}; -use twap_oracle_core::compute_current_tick_account_pda; +use twap_oracle_core::{ + compute_current_tick_account_pda, compute_oracle_price_account_pda, + compute_price_observations_pda, +}; use super::{ admin::transfer_ownership_plan, config::config_account as decode_config_account, context::resolve_tokens, holding::{select_holding, SelectedHolding}, + oracle::{create_oracle_price_account_plan, create_price_observations_plan}, pair::{is_canonical_pair, pair_ids, PairIds}, quote::{div_ceil_u256, minimum_opening_pair, Q64}, swap::{swap_exact_in_plan, swap_exact_out_plan}, - ConfigAccountRequest, PairIdsRequest, ResolveTokensRequest, SwapExactInPlanRequest, - SwapExactOutPlanRequest, TransferOwnershipPlanRequest, + ConfigAccountRequest, CreateOraclePriceAccountPlanRequest, CreatePriceObservationsPlanRequest, + PairIdsRequest, ResolveTokensRequest, SwapExactInPlanRequest, SwapExactOutPlanRequest, + TransferOwnershipPlanRequest, }; use crate::{ account::{account_id_hex, account_read, decode_account, program_id_base58, program_id_bytes}, @@ -95,6 +100,7 @@ fn ids() -> PairIds { lp_lock_holding: compute_lp_lock_holding_pda(AMM_PROGRAM, pool), current_tick: compute_current_tick_account_pda(TWAP_PROGRAM, pool), clock: CLOCK_01_PROGRAM_ACCOUNT_ID, + twap_oracle_program: TWAP_PROGRAM, } } @@ -279,6 +285,99 @@ fn transfer_ownership_plan_targets_config_and_current_admin() { assert_eq!(decoded, new_authority); } +#[test] +fn create_price_observations_plan_targets_the_window_feed_accounts() { + let token_a = AccountId::new([2; 32]); + let token_b = AccountId::new([1; 32]); + let config_id = compute_config_pda(AMM_PROGRAM); + let pool = compute_pool_pda(AMM_PROGRAM, token_a, token_b); + let window = 3_600_000_u64; + + let plan = create_price_observations_plan(CreatePriceObservationsPlanRequest { + amm_program_id: amm_program_id(), + config: account_read(config_id, &config_account()), + token_a_id: account_id_hex(token_a), + token_b_id: account_id_hex(token_b), + window_duration_ms: window, + }) + .unwrap(); + + // config, pool (price source), current_tick (initial tick), price_observations (init), clock. + assert_eq!( + plan["accountIds"], + json!([ + account_id_hex(config_id), + account_id_hex(pool), + account_id_hex(compute_current_tick_account_pda(TWAP_PROGRAM, pool)), + account_id_hex(compute_price_observations_pda(TWAP_PROGRAM, pool, window)), + account_id_hex(CLOCK_01_PROGRAM_ACCOUNT_ID), + ]) + ); + assert_eq!( + plan["signingRequirements"], + json!([false, false, false, false, false]) + ); + + let words: Vec = plan["instruction"] + .as_array() + .unwrap() + .iter() + .map(|word| word.as_u64().unwrap() as u32) + .collect(); + let Instruction::CreatePriceObservations { window_duration } = + risc0_zkvm::serde::from_slice(&words).unwrap() + else { + panic!("expected CreatePriceObservations"); + }; + assert_eq!(window_duration, window); +} + +#[test] +fn create_oracle_price_account_plan_targets_the_window_price_account() { + let token_a = AccountId::new([2; 32]); + let token_b = AccountId::new([1; 32]); + let config_id = compute_config_pda(AMM_PROGRAM); + let pool = compute_pool_pda(AMM_PROGRAM, token_a, token_b); + let window = 900_000_u64; + + let plan = create_oracle_price_account_plan(CreateOraclePriceAccountPlanRequest { + amm_program_id: amm_program_id(), + config: account_read(config_id, &config_account()), + token_a_id: account_id_hex(token_a), + token_b_id: account_id_hex(token_b), + window_duration_ms: window, + }) + .unwrap(); + + // config, pool (price source), oracle_price_account (init), clock. + assert_eq!( + plan["accountIds"], + json!([ + account_id_hex(config_id), + account_id_hex(pool), + account_id_hex(compute_oracle_price_account_pda(TWAP_PROGRAM, pool, window)), + account_id_hex(CLOCK_01_PROGRAM_ACCOUNT_ID), + ]) + ); + assert_eq!( + plan["signingRequirements"], + json!([false, false, false, false]) + ); + + let words: Vec = plan["instruction"] + .as_array() + .unwrap() + .iter() + .map(|word| word.as_u64().unwrap() as u32) + .collect(); + let Instruction::CreateOraclePriceAccount { window_duration } = + risc0_zkvm::serde::from_slice(&words).unwrap() + else { + panic!("expected CreateOraclePriceAccount"); + }; + assert_eq!(window_duration, window); +} + #[test] fn config_account_decodes_authority_and_program_ids() { let config_id = compute_config_pda(AMM_PROGRAM); diff --git a/modules/amm/ffi/src/ffi.rs b/modules/amm/ffi/src/ffi.rs index 7164871..4c5df15 100644 --- a/modules/amm/ffi/src/ffi.rs +++ b/modules/amm/ffi/src/ffi.rs @@ -7,7 +7,8 @@ use serde::{de::DeserializeOwned, Serialize}; use crate::api::{ self, AddLiquidityPlanRequest, AddLiquidityQuoteRequest, AmmApiError, AmmResult, - ConfigAccountRequest, ConfigIdRequest, CreatePoolPlanRequest, CreatePoolQuoteRequest, + ConfigAccountRequest, ConfigIdRequest, CreateOraclePriceAccountPlanRequest, + CreatePoolPlanRequest, CreatePoolQuoteRequest, CreatePriceObservationsPlanRequest, FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest, ResolvePoolRequest, ResolveTokensRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, @@ -175,6 +176,16 @@ pub extern "C" fn amm_transfer_ownership_plan(request_json: *const c_char) -> *m call::(request_json, api::transfer_ownership_plan) } +#[unsafe(no_mangle)] +pub extern "C" fn amm_create_price_observations_plan(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::create_price_observations_plan) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_create_oracle_price_account_plan(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::create_oracle_price_account_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/ffi/src/lib.rs b/modules/amm/ffi/src/lib.rs index 7e291db..a3a1fdb 100644 --- a/modules/amm/ffi/src/lib.rs +++ b/modules/amm/ffi/src/lib.rs @@ -6,11 +6,13 @@ mod ffi; pub mod api; pub use api::{ - config_account, config_id, create_pool_plan, create_pool_quote, fee_tiers, pair_ids, pool_id, - program_id, resolve_pool, resolve_tokens, swap_exact_in_plan, swap_exact_in_quote, - swap_exact_out_plan, swap_exact_out_quote, swap_pair, transfer_ownership_plan, AccountRead, - AmmApiError, AmmResponse, AmmResult, ConfigAccountRequest, ConfigIdRequest, - CreatePoolPlanRequest, CreatePoolQuoteRequest, FeeTiersRequest, PairIdsRequest, PoolIdRequest, + config_account, config_id, create_oracle_price_account_plan, create_pool_plan, + create_pool_quote, create_price_observations_plan, fee_tiers, pair_ids, pool_id, program_id, + resolve_pool, resolve_tokens, swap_exact_in_plan, swap_exact_in_quote, swap_exact_out_plan, + swap_exact_out_quote, swap_pair, transfer_ownership_plan, AccountRead, AmmApiError, + AmmResponse, AmmResult, ConfigAccountRequest, ConfigIdRequest, + CreateOraclePriceAccountPlanRequest, CreatePoolPlanRequest, CreatePoolQuoteRequest, + CreatePriceObservationsPlanRequest, FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, ResolvePoolRequest, ResolveTokensRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, TransferOwnershipPlanRequest, WalletAccount, diff --git a/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp index d8ab769..2ec9a6e 100644 --- a/modules/amm/src/amm_module_impl.cpp +++ b/modules/amm/src/amm_module_impl.cpp @@ -466,6 +466,91 @@ LogosMap AmmModuleImpl::transferOwnership(const LogosMap& request) { return LogosMap{{"status", "ok"}, {"error", ""}, {"transactionId", jStr(obj, "tx_hash")}}; } +LogosMap AmmModuleImpl::createPriceObservations(const LogosMap& request) { + return oracleSetupSubmit(request, /*observations=*/true); +} + +LogosMap AmmModuleImpl::createOraclePriceAccount(const LogosMap& request) { + return oracleSetupSubmit(request, /*observations=*/false); +} + +LogosMap AmmModuleImpl::oracleSetupSubmit(const LogosMap& request, bool observations) { + auto error = [](const std::string& err) { + return LogosMap{{"status", "error"}, {"error", err}}; + }; + + const std::string amm_program_id = ammProgramId(); + if (amm_program_id.empty()) + return error("config_missing"); + + // The plan derives the feed PDAs from the config's twap_oracle_program_id + the pool. + const json config = readConfig(amm_program_id); + if (config.is_null()) + return error("config_missing"); + + 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"); + + // windowDurationMs may arrive as a JSON number (UI) or a decimal string (CLI); the FFI wants + // a u64. A zero / unparsable window is rejected — each window is a distinct feed PDA. + const json window_json = request.value("windowDurationMs", json()); + uint64_t window = 0; + if (window_json.is_number_unsigned()) { + window = window_json.get(); + } else if (window_json.is_number_integer() && window_json.get() > 0) { + window = window_json.get(); + } else if (window_json.is_string()) { + try { + window = std::stoull(window_json.get()); + } catch (...) { + window = 0; + } + } + if (window == 0) + return error("invalid_window"); + + auto* const plan_op = + observations ? amm_create_price_observations_plan : amm_create_oracle_price_account_plan; + const FfiResult planResult = call(plan_op, json{ + {"ammProgramId", amm_program_id}, + {"config", config}, + {"tokenAId", token_a}, + {"tokenBId", token_b}, + {"windowDurationMs", window}, + }); + 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"); + + // Surface a stable error code when the target PDA already exists instead of failing at submit. + const size_t target_index = observations ? 3 : 2; + if (accounts.size() > target_index) { + const json existing = readPublicAccount(accounts[target_index]); + if (jStr(existing, "status") == "ok") + return error("already_exists"); + } + + AMM_TRACE("oracleSetup(" << (observations ? "observations" : "priceAccount") + << "): SUBMIT programId=" << program_id << " accounts=" << accounts.size()); + const std::string reply = modules().logos_execution_zone.send_generic_public_transaction( + accounts, signers, instruction, program_id); + AMM_TRACE("oracleSetup: 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")}}; +} + LogosMap AmmModuleImpl::swapExactInQuote(const std::string& token_in_hex, const std::string& token_out_hex, const nlohmann::json& amount_in, diff --git a/modules/amm/src/amm_module_impl.h b/modules/amm/src/amm_module_impl.h index f472b09..62c1fee 100644 --- a/modules/amm/src/amm_module_impl.h +++ b/modules/amm/src/amm_module_impl.h @@ -54,6 +54,17 @@ public: /// `wallet_submission_failed`, `backend_error`, or a plan code (e.g. `config_unavailable`). LogosMap transferOwnership(const LogosMap& request); + /// Submits `CreatePriceObservations` / `CreateOraclePriceAccount` — seeds a pool's TWAP feed / + /// creates its oracle price account for `request.windowDurationMs` (a distinct account per + /// window). `request` carries `{ tokenAId, tokenBId, windowDurationMs }`. Both are direct + /// submits (chained into the oracle, seeded from validated pool state — nothing signs). On + /// success `{ status:"ok", error:"", transactionId: }`; on failure + /// `{ status:"error", error: }` — `config_missing`, `invalid_token_id`, + /// `invalid_window`, `same_token_pair`, `config_unavailable`, `wallet_submission_failed`, + /// `backend_error` (`already_exists` if the target account is already created). + LogosMap createPriceObservations(const LogosMap& request); + LogosMap createOraclePriceAccount(const LogosMap& request); + /// Prices a `SwapExactInput` for the (token_in_hex, token_out_hex) pair: /// reads the pool and returns `{ status:"ok", error:"", expectedOutRaw, /// minReceivedRaw, priceImpactBps }`, oriented and computed server-side via @@ -271,4 +282,8 @@ private: // The user's own public account reads, fresh each call (empty when the wallet // is closed). Each read is a live sequencer round-trip. nlohmann::json walletAccountReads(bool wallet_open); + + // Shared body for createPriceObservations / createOraclePriceAccount: reads the config, + // builds the window-seeded oracle plan (observations vs price account), and submits it. + LogosMap oracleSetupSubmit(const LogosMap& request, bool observations); };