feat(amm): add transferOwnership (UpdateConfig admin transfer)

Expose the authority-only UpdateConfig as a module op so the admin can transfer
AMM ownership. The guest change (UpdateConfig restricted to the current admin) is
already shipped; this is the module wrapper.
This commit is contained in:
r4bbit
2026-08-13 21:31:47 +02:00
parent 56c80ce29d
commit d051292710
12 changed files with 182 additions and 8 deletions
+14
View File
@@ -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)
+1
View File
@@ -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;
+6
View File
@@ -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:<hex> } or { status:"error", error:<code> } (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
+2
View File
@@ -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);
+35
View File
@@ -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<Value, String> {
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::<Vec<_>>(),
"signingRequirements": signing_requirements,
"instruction": instruction,
}))
}
+7
View File
@@ -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)
+11
View File
@@ -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.
+41 -2
View File
@@ -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<u32> = 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);
+6 -1
View File
@@ -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::<SyncReservesPlanRequest>(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::<TransferOwnershipPlanRequest>(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::<TokenHoldingsRequest>(request_json, api::token_holdings)
+6 -5
View File
@@ -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,
};
+46
View File
@@ -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<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("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,
+7
View File
@@ -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:<hex> }`; on failure:
/// `{ status:"error", error:<code> }` — `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