diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 67de93e..f165f85 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -187,6 +187,20 @@ QVariantMap AmmUiBackend::configAccount() return m_logos->amm_module.configAccount(); } +QVariantMap AmmUiBackend::transferOwnership(QVariantMap request) +{ + // Submit guard — this app's wallet-open state is authoritative even though the shared + // wallet may remain open elsewhere (same guard as createPool / the swaps). + if (!isWalletOpen()) + return QVariantMap { + { QStringLiteral("status"), QStringLiteral("error") }, + { QStringLiteral("error"), QStringLiteral("wallet_unavailable") }, + }; + + // No balance refresh — transferring admin authority doesn't touch token balances. + return m_logos->amm_module.transferOwnership(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 0a2deba..987bcc8 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -56,6 +56,7 @@ public slots: // AMM — all forwarded to the amm_module core module. QVariantMap resolvePoolAccount(QString defAHex, QString defBHex) override; QVariantMap configAccount() override; + QVariantMap transferOwnership(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 5a688c1..708d487 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -53,6 +53,12 @@ class AmmUiBackend // ammProgramId, authority, tokenProgramId, twapOracleProgramId }` (ids base58), or // `{ status:"error", error:"config_missing"|"config_unavailable"|"backend_error" }`. SLOT(QVariantMap configAccount()) + // Submits an UpdateConfig transferring admin authority. `request` carries + // { newAuthorityId } (base58 or hex). Only the current admin can sign, so the + // connected wallet must control it. Returns { status:"ok", error:"", + // transactionId: } or { status:"error", error: } (wallet_unavailable, + // config_missing, invalid_account_id, wallet_submission_failed, backend_error). + SLOT(QVariantMap transferOwnership(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 c255c24..bdedc9f 100644 --- a/modules/amm/ffi/include/amm_ffi.h +++ b/modules/amm/ffi/include/amm_ffi.h @@ -50,6 +50,8 @@ char *amm_remove_liquidity_plan(const char *request_json); char *amm_sync_reserves_plan(const char *request_json); +char *amm_transfer_ownership_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/admin.rs b/modules/amm/ffi/src/api/admin.rs new file mode 100644 index 0000000..ecb9b66 --- /dev/null +++ b/modules/amm/ffi/src/api/admin.rs @@ -0,0 +1,35 @@ +use amm_core::{compute_config_pda, Instruction}; +use serde_json::{json, Value}; + +use super::{config::load_config, TransferOwnershipPlanRequest}; +use crate::account::{account_id_from_hex, account_id_hex, parse_program_id}; + +/// Builds the `UpdateConfig` submission that transfers the AMM's admin authority. The current +/// admin — the config's stored `authority`, decoded from `config` — is the sole signer; +/// `new_authority_id` (hex) becomes the new admin. The guest enforces that only the current admin +/// (signed) may call this and that the immutable program ids can't change. +pub(super) fn transfer_ownership_plan( + request: TransferOwnershipPlanRequest, +) -> Result { + let amm_program = parse_program_id(&request.amm_program_id)?; + let new_authority = account_id_from_hex(&request.new_authority_id, "new authority id")?; + let Ok(config) = load_config(amm_program, &request.config) else { + return Err(String::from("config_unavailable")); + }; + + let instruction = risc0_zkvm::serde::to_vec(&Instruction::UpdateConfig { new_authority }) + .map_err(|error| format!("instruction serialization failed: {error}"))?; + + // Fixed IDL account order for UpdateConfig: the config account (mut, updated in place, not a + // signer) and the current admin authority (signs). `new_authority` is instruction data, not + // an account. + let account_ids = [compute_config_pda(amm_program), config.authority]; + let signing_requirements = [false, true]; + + Ok(json!({ + "programId": request.amm_program_id, + "accountIds": account_ids.into_iter().map(account_id_hex).collect::>(), + "signingRequirements": signing_requirements, + "instruction": instruction, + })) +} diff --git a/modules/amm/ffi/src/api/mod.rs b/modules/amm/ffi/src/api/mod.rs index b78433d..c1e863b 100644 --- a/modules/amm/ffi/src/api/mod.rs +++ b/modules/amm/ffi/src/api/mod.rs @@ -1,5 +1,6 @@ //! Transport-independent AMM client operations. +mod admin; mod config; mod context; mod fee; @@ -22,6 +23,7 @@ pub use request::{ ProgramIdRequest, RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest, ResolvePoolRequest, ResolveTokensRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, SyncReservesPlanRequest, TokenHoldingsRequest, + TransferOwnershipPlanRequest, }; use serde_json::Value; @@ -146,6 +148,11 @@ pub fn sync_reserves_plan(request: SyncReservesPlanRequest) -> AmmResult { liquidity::sync_reserves_plan(request).map_err(Into::into) } +/// Builds the `UpdateConfig` submission that transfers the AMM's admin authority. +pub fn transfer_ownership_plan(request: TransferOwnershipPlanRequest) -> AmmResult { + admin::transfer_ownership_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 31458f0..39997d7 100644 --- a/modules/amm/ffi/src/api/request.rs +++ b/modules/amm/ffi/src/api/request.rs @@ -18,6 +18,17 @@ pub struct ConfigAccountRequest { pub config: AccountRead, } +/// Builds the `UpdateConfig` submission transferring admin authority. `config` is the read of the +/// config PDA (the current admin — the sole signer — is decoded from it); `new_authority_id` is +/// hex (the module normalizes base58→hex). +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TransferOwnershipPlanRequest { + pub amm_program_id: String, + pub config: AccountRead, + pub new_authority_id: String, +} + /// 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 cfcd0f7..7215715 100644 --- a/modules/amm/ffi/src/api/tests.rs +++ b/modules/amm/ffi/src/api/tests.rs @@ -1,7 +1,7 @@ use alloy_primitives::U256; use amm_core::{ compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, - compute_vault_pda, isqrt_product, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, + compute_vault_pda, isqrt_product, AmmConfig, Instruction, PoolDefinition, MINIMUM_LIQUIDITY, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use nssa_core::{ @@ -14,6 +14,7 @@ use token_core::{TokenDefinition, TokenHolding}; use twap_oracle_core::compute_current_tick_account_pda; use super::{ + admin::transfer_ownership_plan, config::config_account as decode_config_account, context::resolve_tokens, holding::{select_holding, SelectedHolding}, @@ -21,7 +22,7 @@ use super::{ quote::{div_ceil_u256, minimum_opening_pair, Q64}, swap::{swap_exact_in_plan, swap_exact_out_plan}, ConfigAccountRequest, PairIdsRequest, ResolveTokensRequest, SwapExactInPlanRequest, - SwapExactOutPlanRequest, + SwapExactOutPlanRequest, TransferOwnershipPlanRequest, }; use crate::{ account::{account_id_hex, account_read, decode_account, program_id_base58, program_id_bytes}, @@ -240,6 +241,44 @@ fn resolve_tokens_returns_lean_rows_held_first_and_omits_unresolvable() { ); } +#[test] +fn transfer_ownership_plan_targets_config_and_current_admin() { + let config_id = compute_config_pda(AMM_PROGRAM); + let new_authority = AccountId::new([5; 32]); + let plan = transfer_ownership_plan(TransferOwnershipPlanRequest { + amm_program_id: amm_program_id(), + config: account_read(config_id, &config_account()), + new_authority_id: account_id_hex(new_authority), + }) + .unwrap(); + + // Accounts: [config (not signer), current admin (signs)]. The current admin is [7; 32] (the + // config_account fixture's authority); new_authority is instruction data, not an account. + assert_eq!( + plan["accountIds"], + json!([ + account_id_hex(config_id), + account_id_hex(AccountId::new([7; 32])) + ]) + ); + assert_eq!(plan["signingRequirements"], json!([false, true])); + + // The instruction decodes back to UpdateConfig { new_authority }. + let words: Vec = plan["instruction"] + .as_array() + .unwrap() + .iter() + .map(|word| word.as_u64().unwrap() as u32) + .collect(); + let Instruction::UpdateConfig { + new_authority: decoded, + } = risc0_zkvm::serde::from_slice(&words).unwrap() + else { + panic!("expected UpdateConfig"); + }; + assert_eq!(decoded, new_authority); +} + #[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 ed23a2f..7164871 100644 --- a/modules/amm/ffi/src/ffi.rs +++ b/modules/amm/ffi/src/ffi.rs @@ -11,7 +11,7 @@ use crate::api::{ FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest, ResolvePoolRequest, ResolveTokensRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, - SyncReservesPlanRequest, TokenHoldingsRequest, + SyncReservesPlanRequest, TokenHoldingsRequest, TransferOwnershipPlanRequest, }; #[derive(Serialize)] @@ -170,6 +170,11 @@ pub extern "C" fn amm_sync_reserves_plan(request_json: *const c_char) -> *mut c_ call::(request_json, api::sync_reserves_plan) } +#[unsafe(no_mangle)] +pub extern "C" fn amm_transfer_ownership_plan(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::transfer_ownership_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 b2a09cd..7e291db 100644 --- a/modules/amm/ffi/src/lib.rs +++ b/modules/amm/ffi/src/lib.rs @@ -8,9 +8,10 @@ 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, AccountRead, AmmApiError, AmmResponse, - AmmResult, ConfigAccountRequest, ConfigIdRequest, CreatePoolPlanRequest, - CreatePoolQuoteRequest, FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, - ResolvePoolRequest, ResolveTokensRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, - SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, WalletAccount, + swap_exact_out_plan, swap_exact_out_quote, swap_pair, transfer_ownership_plan, AccountRead, + AmmApiError, AmmResponse, AmmResult, ConfigAccountRequest, ConfigIdRequest, + CreatePoolPlanRequest, CreatePoolQuoteRequest, 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 7b39486..d8ab769 100644 --- a/modules/amm/src/amm_module_impl.cpp +++ b/modules/amm/src/amm_module_impl.cpp @@ -420,6 +420,52 @@ LogosMap AmmModuleImpl::configAccount() { return result.value; } +LogosMap AmmModuleImpl::transferOwnership(const LogosMap& request) { + 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 needs the config account to decode the CURRENT admin (the sole signer). + const json config = readConfig(amm_program_id); + if (config.is_null()) + return error("config_missing"); + + const std::string new_authority = normalizeAccountId(jStr(request, "newAuthorityId")); + if (new_authority.empty()) + return error("invalid_account_id"); + + const FfiResult planResult = call(amm_transfer_ownership_plan, json{ + {"ammProgramId", amm_program_id}, + {"config", config}, + {"newAuthorityId", new_authority}, + }); + 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("transferOwnership: 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("transferOwnership: 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 9b5e742..f472b09 100644 --- a/modules/amm/src/amm_module_impl.h +++ b/modules/amm/src/amm_module_impl.h @@ -47,6 +47,13 @@ public: /// `backend_error` when the backend FFI call fails. LogosMap configAccount(); + /// Submits an `UpdateConfig` transferring admin authority to `request.newAuthorityId` + /// (base58 or hex). Only the current admin can sign, so the connected wallet must control it. + /// On success `{ status:"ok", error:"", transactionId: }`; on failure: + /// `{ status:"error", error: }` — `config_missing`, `invalid_account_id`, + /// `wallet_submission_failed`, `backend_error`, or a plan code (e.g. `config_unavailable`). + LogosMap transferOwnership(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