From 4b35a788bb972bdd8477044e6c98a59db4afef80 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:46:38 +0200 Subject: [PATCH] feat(amm): add admin authority and UpdateConfig instruction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an admin authority to the AMM config so configuration can be changed after initialization. AmmConfig gains an `authority` field, set by Initialize, and a new UpdateConfig instruction lets that admin change config values. UpdateConfig is access-controlled: the authority account must equal the stored config.authority and be passed authorized (signed). Both fields are optional — token_program_id updates the chained-call token program, and new_authority transfers admin control to a different account. Without this gate any caller could repoint the AMM at a malicious token program. --- artifacts/amm-idl.json | 39 ++++ programs/amm/core/src/lib.rs | 23 +- programs/amm/methods/guest/src/bin/amm.rs | 32 ++- programs/amm/src/initialize.rs | 39 +++- programs/amm/src/lib.rs | 1 + programs/amm/src/tests.rs | 1 + programs/amm/src/update_config.rs | 246 ++++++++++++++++++++++ programs/integration_tests/tests/amm.rs | 104 ++++++++- 8 files changed, 472 insertions(+), 13 deletions(-) create mode 100644 programs/amm/src/update_config.rs diff --git a/artifacts/amm-idl.json b/artifacts/amm-idl.json index 8e27428..dcaa385 100644 --- a/artifacts/amm-idl.json +++ b/artifacts/amm-idl.json @@ -16,6 +16,41 @@ { "name": "token_program_id", "type": "program_id" + }, + { + "name": "authority", + "type": "account_id" + } + ] + }, + { + "name": "update_config", + "accounts": [ + { + "name": "config", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "authority", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "token_program_id", + "type": { + "option": "program_id" + } + }, + { + "name": "new_authority", + "type": { + "option": "account_id" + } } ] }, @@ -434,6 +469,10 @@ { "name": "token_program_id", "type": "program_id" + }, + { + "name": "authority", + "type": "account_id" } ] } diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index 324e8ef..aff7e97 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -20,7 +20,8 @@ pub enum Instruction { /// /// The configuration account is a PDA derived from the constant `"CONFIG"` seed /// (`compute_config_pda(self_program_id)`). It stores the Token Program ID that the AMM - /// uses for every chained call. The Program must be initialized via this instruction before + /// uses for every chained call, plus the admin `authority` allowed to change configuration + /// later via `UpdateConfig`. The Program must be initialized via this instruction before /// any pool can be created or interacted with — the other instructions read the Token /// Program ID from this account and reject calls when it does not yet exist. /// @@ -29,6 +30,24 @@ pub enum Instruction { Initialize { /// Program ID of the Token Program the AMM will issue chained calls to. token_program_id: ProgramId, + /// Admin authority allowed to change configuration via `UpdateConfig`. + authority: AccountId, + }, + + /// Updates the AMM Program's configuration. Only the configured admin `authority` may call + /// this; the authority account must be passed authorized (signed). + /// + /// Each field is optional — `None` leaves the corresponding value unchanged. Setting + /// `new_authority` transfers admin control to a different account. + /// + /// Required accounts: + /// - AMM Config Account (initialized) + /// - Authority Account — must equal the config's current `authority`, passed authorized. + UpdateConfig { + /// New Token Program ID for chained calls, or `None` to keep the current one. + token_program_id: Option, + /// New admin authority (transfers control), or `None` to keep the current admin. + new_authority: Option, }, /// Initializes a new Pool (or re-initializes an existing zero-supply Pool). @@ -204,6 +223,8 @@ impl From<&PoolDefinition> for Data { pub struct AmmConfig { /// Program ID of the Token Program the AMM issues chained calls to. pub token_program_id: ProgramId, + /// Admin authority allowed to change this configuration via `UpdateConfig`. + pub authority: AccountId, } impl TryFrom<&Data> for AmmConfig { diff --git a/programs/amm/methods/guest/src/bin/amm.rs b/programs/amm/methods/guest/src/bin/amm.rs index 2c62ae3..9258181 100644 --- a/programs/amm/methods/guest/src/bin/amm.rs +++ b/programs/amm/methods/guest/src/bin/amm.rs @@ -29,9 +29,37 @@ mod amm { ctx: ProgramContext, config: AccountWithMetadata, token_program_id: ProgramId, + authority: AccountId, ) -> SpelResult { - let post_states = - amm_program::initialize::initialize(config, token_program_id, ctx.self_program_id); + let post_states = amm_program::initialize::initialize( + config, + token_program_id, + authority, + ctx.self_program_id, + ); + Ok(spel_framework::SpelOutput::execute(post_states, vec![])) + } + + /// Updates the AMM Program's configuration. Only the configured admin authority may call this. + /// + /// Expected accounts: + /// 1. `config` — initialized AMM config account. + /// 2. `authority` — the config's current admin, passed authorized (signed). + #[instruction] + pub fn update_config( + ctx: ProgramContext, + config: AccountWithMetadata, + authority: AccountWithMetadata, + token_program_id: Option, + new_authority: Option, + ) -> SpelResult { + let post_states = amm_program::update_config::update_config( + config, + authority, + token_program_id, + new_authority, + ctx.self_program_id, + ); Ok(spel_framework::SpelOutput::execute(post_states, vec![])) } diff --git a/programs/amm/src/initialize.rs b/programs/amm/src/initialize.rs index c3576c9..7f71ec3 100644 --- a/programs/amm/src/initialize.rs +++ b/programs/amm/src/initialize.rs @@ -1,14 +1,15 @@ use amm_core::{compute_config_pda, compute_config_pda_seed, AmmConfig}; use nssa_core::{ - account::{Account, AccountWithMetadata, Data}, + account::{Account, AccountId, AccountWithMetadata, Data}, program::{AccountPostState, Claim, ProgramId}, }; /// Initializes the AMM Program by creating its singleton configuration account. /// /// The config account is a PDA derived from the constant `"CONFIG"` seed -/// (`compute_config_pda(amm_program_id)`) and stores `token_program_id`, the Token Program the -/// AMM issues every chained call to. Its existence is the Program's "initialized" flag: the +/// (`compute_config_pda(amm_program_id)`) and stores `token_program_id` (the Token Program the +/// AMM issues every chained call to) and `authority` (the admin allowed to change configuration +/// later via `update_config`). Its existence is the Program's "initialized" flag: the /// chained-call instructions read the Token Program ID from it and reject calls until it exists. /// /// # Panics @@ -18,6 +19,7 @@ use nssa_core::{ pub fn initialize( config: AccountWithMetadata, token_program_id: ProgramId, + authority: AccountId, amm_program_id: ProgramId, ) -> Vec { assert_eq!( @@ -32,7 +34,10 @@ pub fn initialize( ); let mut config_post = config.account.clone(); - config_post.data = Data::from(&AmmConfig { token_program_id }); + config_post.data = Data::from(&AmmConfig { + token_program_id, + authority, + }); vec![AccountPostState::new_claimed( config_post, @@ -50,6 +55,10 @@ mod tests { const AMM_PROGRAM_ID: ProgramId = [42; 8]; const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; + fn authority() -> AccountId { + AccountId::new([9; 32]) + } + fn config_uninit() -> AccountWithMetadata { AccountWithMetadata { account: Account::default(), @@ -60,7 +69,12 @@ mod tests { #[test] fn returns_single_pda_claimed_post_state() { - let post_states = initialize(config_uninit(), TOKEN_PROGRAM_ID, AMM_PROGRAM_ID); + let post_states = initialize( + config_uninit(), + TOKEN_PROGRAM_ID, + authority(), + AMM_PROGRAM_ID, + ); assert_eq!(post_states.len(), 1); assert_eq!( post_states[0].required_claim(), @@ -69,11 +83,17 @@ mod tests { } #[test] - fn stores_token_program_id() { - let post_states = initialize(config_uninit(), TOKEN_PROGRAM_ID, AMM_PROGRAM_ID); + fn stores_token_program_id_and_authority() { + let post_states = initialize( + config_uninit(), + TOKEN_PROGRAM_ID, + authority(), + AMM_PROGRAM_ID, + ); let config = AmmConfig::try_from(&post_states[0].account().data) .expect("post state must contain a valid AmmConfig"); assert_eq!(config.token_program_id, TOKEN_PROGRAM_ID); + assert_eq!(config.authority, authority()); } #[test] @@ -81,7 +101,7 @@ mod tests { fn wrong_config_account_id_panics() { let mut wrong = config_uninit(); wrong.account_id = AccountId::new([0; 32]); - initialize(wrong, TOKEN_PROGRAM_ID, AMM_PROGRAM_ID); + initialize(wrong, TOKEN_PROGRAM_ID, authority(), AMM_PROGRAM_ID); } #[test] @@ -90,8 +110,9 @@ mod tests { let mut initialized = config_uninit(); initialized.account.data = Data::from(&AmmConfig { token_program_id: TOKEN_PROGRAM_ID, + authority: authority(), }); initialized.account.nonce = Nonce(0); - initialize(initialized, TOKEN_PROGRAM_ID, AMM_PROGRAM_ID); + initialize(initialized, TOKEN_PROGRAM_ID, authority(), AMM_PROGRAM_ID); } } diff --git a/programs/amm/src/lib.rs b/programs/amm/src/lib.rs index 3165998..6866eed 100644 --- a/programs/amm/src/lib.rs +++ b/programs/amm/src/lib.rs @@ -8,5 +8,6 @@ pub mod new_definition; pub mod remove; pub mod swap; pub mod sync; +pub mod update_config; mod tests; diff --git a/programs/amm/src/tests.rs b/programs/amm/src/tests.rs index 25cd7b9..f4b5873 100644 --- a/programs/amm/src/tests.rs +++ b/programs/amm/src/tests.rs @@ -628,6 +628,7 @@ impl AccountWithMetadataForTests { balance: 0u128, data: Data::from(&AmmConfig { token_program_id: TOKEN_PROGRAM_ID, + authority: AccountId::new([9; 32]), }), nonce: Nonce(0), }, diff --git a/programs/amm/src/update_config.rs b/programs/amm/src/update_config.rs new file mode 100644 index 0000000..cd4139c --- /dev/null +++ b/programs/amm/src/update_config.rs @@ -0,0 +1,246 @@ +use amm_core::{compute_config_pda, AmmConfig}; +use nssa_core::{ + account::{AccountId, AccountWithMetadata, Data}, + program::{AccountPostState, ProgramId}, +}; + +/// Updates the AMM Program's singleton configuration account. +/// +/// Only the config's current admin `authority` may call this: the `authority` account must equal +/// the stored authority and be passed authorized (signed). Each field is optional — `None` leaves +/// the current value unchanged. Passing `new_authority` transfers admin control to a new account. +/// +/// The config account is already owned by this Program (created at `initialize`), so its data is +/// updated in place — no claim is required. +/// +/// # Panics +/// Panics if: +/// - `config.account_id` does not match `compute_config_pda(amm_program_id)`, or the config is +/// uninitialized (the Program has not been initialized). +/// - `authority.account_id` is not the config's current admin authority. +/// - `authority.is_authorized` is false (the admin did not sign). +pub fn update_config( + config: AccountWithMetadata, + authority: AccountWithMetadata, + token_program_id: Option, + new_authority: Option, + amm_program_id: ProgramId, +) -> Vec { + assert_eq!( + config.account_id, + compute_config_pda(amm_program_id), + "Update config: AMM config Account ID does not match PDA" + ); + let mut config_data = AmmConfig::try_from(&config.account.data) + .expect("Update config: AMM Program must be initialized before use"); + + // Access control: the caller must be the configured admin and must have signed. + assert_eq!( + authority.account_id, config_data.authority, + "Update config: caller is not the configured admin authority" + ); + assert!( + authority.is_authorized, + "Update config: admin authority must authorize the update" + ); + + if let Some(token_program_id) = token_program_id { + config_data.token_program_id = token_program_id; + } + if let Some(new_authority) = new_authority { + config_data.authority = new_authority; + } + + let mut config_post = config.account.clone(); + config_post.data = Data::from(&config_data); + + vec![ + AccountPostState::new(config_post), + AccountPostState::new(authority.account.clone()), + ] +} + +#[cfg(test)] +mod tests { + use nssa_core::account::{Account, Nonce}; + + use super::*; + + const AMM_PROGRAM_ID: ProgramId = [42; 8]; + const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; + const NEW_TOKEN_PROGRAM_ID: ProgramId = [16; 8]; + + fn admin_id() -> AccountId { + AccountId::new([9; 32]) + } + + fn config_init() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: AMM_PROGRAM_ID, + balance: 0, + data: Data::from(&AmmConfig { + token_program_id: TOKEN_PROGRAM_ID, + authority: admin_id(), + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: compute_config_pda(AMM_PROGRAM_ID), + } + } + + fn admin_authorized() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: admin_id(), + } + } + + fn updated_config(post_states: &[AccountPostState]) -> AmmConfig { + AmmConfig::try_from(&post_states[0].account().data) + .expect("post state must contain a valid AmmConfig") + } + + // ── happy path ──────────────────────────────────────────────────────────── + + #[test] + fn updates_token_program_id() { + let post_states = update_config( + config_init(), + admin_authorized(), + Some(NEW_TOKEN_PROGRAM_ID), + None, + AMM_PROGRAM_ID, + ); + let config = updated_config(&post_states); + assert_eq!(config.token_program_id, NEW_TOKEN_PROGRAM_ID); + // Authority is unchanged. + assert_eq!(config.authority, admin_id()); + } + + #[test] + fn transfers_authority() { + let new_admin = AccountId::new([7; 32]); + let post_states = update_config( + config_init(), + admin_authorized(), + None, + Some(new_admin), + AMM_PROGRAM_ID, + ); + let config = updated_config(&post_states); + assert_eq!(config.authority, new_admin); + // Token program is unchanged. + assert_eq!(config.token_program_id, TOKEN_PROGRAM_ID); + } + + #[test] + fn updates_both_fields() { + let new_admin = AccountId::new([7; 32]); + let post_states = update_config( + config_init(), + admin_authorized(), + Some(NEW_TOKEN_PROGRAM_ID), + Some(new_admin), + AMM_PROGRAM_ID, + ); + let config = updated_config(&post_states); + assert_eq!(config.token_program_id, NEW_TOKEN_PROGRAM_ID); + assert_eq!(config.authority, new_admin); + } + + #[test] + fn no_op_update_leaves_config_unchanged() { + let post_states = update_config( + config_init(), + admin_authorized(), + None, + None, + AMM_PROGRAM_ID, + ); + let config = updated_config(&post_states); + assert_eq!(config.token_program_id, TOKEN_PROGRAM_ID); + assert_eq!(config.authority, admin_id()); + } + + #[test] + fn returns_config_and_echoed_authority_post_states() { + let authority = admin_authorized(); + let post_states = update_config( + config_init(), + authority.clone(), + Some(NEW_TOKEN_PROGRAM_ID), + None, + AMM_PROGRAM_ID, + ); + assert_eq!(post_states.len(), 2); + // The config keeps its program owner (it is updated in place, not claimed). + assert_eq!(post_states[0].account().program_owner, AMM_PROGRAM_ID); + assert_eq!(*post_states[1].account(), authority.account); + } + + // ── precondition violations ─────────────────────────────────────────────── + + #[test] + #[should_panic(expected = "AMM config Account ID does not match PDA")] + fn wrong_config_pda_panics() { + let mut config = config_init(); + config.account_id = AccountId::new([0; 32]); + update_config( + config, + admin_authorized(), + Some(NEW_TOKEN_PROGRAM_ID), + None, + AMM_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "AMM Program must be initialized before use")] + fn uninitialized_config_panics() { + let config = AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: compute_config_pda(AMM_PROGRAM_ID), + }; + update_config( + config, + admin_authorized(), + Some(NEW_TOKEN_PROGRAM_ID), + None, + AMM_PROGRAM_ID, + ); + } + + /// A caller who is not the configured admin cannot change the config, even if they sign. + #[test] + #[should_panic(expected = "caller is not the configured admin authority")] + fn non_admin_authority_panics() { + let mut not_admin = admin_authorized(); + not_admin.account_id = AccountId::new([123; 32]); + update_config( + config_init(), + not_admin, + Some(NEW_TOKEN_PROGRAM_ID), + None, + AMM_PROGRAM_ID, + ); + } + + /// The admin account must actually sign; passing it unauthorized is rejected. + #[test] + #[should_panic(expected = "admin authority must authorize the update")] + fn unauthorized_admin_panics() { + let mut unsigned = admin_authorized(); + unsigned.is_authorized = false; + update_config( + config_init(), + unsigned, + Some(NEW_TOKEN_PROGRAM_ID), + None, + AMM_PROGRAM_ID, + ); + } +} diff --git a/programs/integration_tests/tests/amm.rs b/programs/integration_tests/tests/amm.rs index bd01402..4e54875 100644 --- a/programs/integration_tests/tests/amm.rs +++ b/programs/integration_tests/tests/amm.rs @@ -32,6 +32,10 @@ impl Keys { fn user_lp() -> PrivateKey { PrivateKey::try_new([33; 32]).expect("valid private key") } + + fn admin() -> PrivateKey { + PrivateKey::try_new([34; 32]).expect("valid private key") + } } impl Ids { @@ -98,6 +102,10 @@ impl Ids { fn user_lp() -> AccountId { AccountId::from(&PublicKey::new_from_private_key(&Keys::user_lp())) } + + fn admin() -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(&Keys::admin())) + } } impl Balances { @@ -293,6 +301,7 @@ impl Accounts { balance: 0_u128, data: Data::from(&amm_core::AmmConfig { token_program_id: Ids::token_program(), + authority: Ids::admin(), }), nonce: Nonce(0), } @@ -1180,6 +1189,7 @@ fn execute_remove_liquidity( fn execute_initialize(state: &mut V03State) { let instruction = amm_core::Instruction::Initialize { token_program_id: Ids::token_program(), + authority: Ids::admin(), }; let message = public_transaction::Message::try_new( @@ -1241,10 +1251,102 @@ fn amm_initialize_creates_config_account() { let config_account = state.get_account_by_id(Ids::config()); assert_eq!(config_account, Accounts::config()); - // Explicitly assert the stored Token Program ID round-trips from the instruction argument. + // Explicitly assert the stored Token Program ID and admin authority round-trip from the + // instruction arguments. let config = amm_core::AmmConfig::try_from(&config_account.data) .expect("config account must hold a valid AmmConfig"); assert_eq!(config.token_program_id, Ids::token_program()); + assert_eq!(config.authority, Ids::admin()); +} + +#[cfg(test)] +fn execute_update_config( + state: &mut V03State, + signer: &PrivateKey, + token_program_id: Option, + new_authority: Option, +) -> Result<(), NssaError> { + let signer_id = AccountId::from(&PublicKey::new_from_private_key(signer)); + let instruction = amm_core::Instruction::UpdateConfig { + token_program_id, + new_authority, + }; + + let message = public_transaction::Message::try_new( + Ids::amm_program(), + vec![Ids::config(), signer_id], + vec![current_nonce(state, signer_id)], + instruction, + ) + .unwrap(); + + let witness_set = public_transaction::WitnessSet::for_message(&message, &[signer]); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, 0, 0) +} + +fn config_data(state: &V03State) -> amm_core::AmmConfig { + amm_core::AmmConfig::try_from(&state.get_account_by_id(Ids::config()).data) + .expect("config account must hold a valid AmmConfig") +} + +fn initialized_amm_state() -> V03State { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + deploy_programs(&mut state); + execute_initialize(&mut state); + state +} + +#[test] +fn amm_update_config_changes_token_program_id_and_authority() { + let mut state = initialized_amm_state(); + + let new_token_program = [123u32; 8]; + let new_admin = Ids::user_a(); + + execute_update_config( + &mut state, + &Keys::admin(), + Some(new_token_program), + Some(new_admin), + ) + .unwrap(); + + let config = config_data(&state); + assert_eq!(config.token_program_id, new_token_program); + assert_eq!(config.authority, new_admin); +} + +#[test] +fn amm_update_config_rejects_non_admin() { + let mut state = initialized_amm_state(); + + // user_a is not the admin; even though they sign, the update is rejected and the config is + // left unchanged. + let result = execute_update_config(&mut state, &Keys::user_a(), Some([123u32; 8]), None); + assert!(matches!(result, Err(NssaError::ProgramExecutionFailed(_)))); + + let config = config_data(&state); + assert_eq!(config.token_program_id, Ids::token_program()); + assert_eq!(config.authority, Ids::admin()); +} + +#[test] +fn amm_update_config_authority_handoff_revokes_old_admin() { + let mut state = initialized_amm_state(); + let new_admin = Ids::user_a(); + + // Admin hands off control to user_a. + execute_update_config(&mut state, &Keys::admin(), None, Some(new_admin)).unwrap(); + assert_eq!(config_data(&state).authority, new_admin); + + // The original admin can no longer update. + let result = execute_update_config(&mut state, &Keys::admin(), Some([123u32; 8]), None); + assert!(matches!(result, Err(NssaError::ProgramExecutionFailed(_)))); + + // The new admin can. + execute_update_config(&mut state, &Keys::user_a(), Some([124u32; 8]), None).unwrap(); + assert_eq!(config_data(&state).token_program_id, [124u32; 8]); } #[test]