feat(amm): add Initialize instruction with config-gated chained calls

Introduce a singleton AMM configuration account, a PDA derived from the
constant "CONFIG" seed, created once via a new `Initialize` instruction.
The config stores the Token Program ID the AMM issues every chained call
to, replacing the previous behavior of trusting the program owner of a
caller-supplied holding.

The config account's existence is the Program's initialization gate: the
chained-call instructions (new_definition, add_liquidity, remove_liquidity,
swap_exact_input, swap_exact_output) now take the config as their first
account, validate it against `compute_config_pda(self_program_id)`, and
read the Token Program ID from it on demand — rejecting calls until the
Program is initialized. Vaults and user holdings are asserted to match the
configured Token Program. sync_reserves is left ungated, as it cannot act
on a pool that could not have existed before initialization.

- amm_core: AmmConfig type, compute_config_pda/_seed, Initialize variant
- amm: initialize.rs + config threading through chained-call instructions
- guest: initialize instruction; config + self_program_id on gated calls
- tests: config fixtures, init-gate unit tests, end-to-end Initialize VM test
This commit is contained in:
r4bbit
2026-06-18 16:11:28 +02:00
parent e8fe634a2c
commit 3624ea1451
11 changed files with 724 additions and 61 deletions
+27 -6
View File
@@ -1,12 +1,12 @@
use std::num::NonZeroU128;
use amm_core::{
assert_supported_fee_tier, compute_liquidity_token_pda_seed, read_vault_fungible_balances,
PoolDefinition,
assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda_seed,
read_vault_fungible_balances, AmmConfig, PoolDefinition,
};
use nssa_core::{
account::{AccountWithMetadata, Data},
program::{AccountPostState, ChainedCall},
program::{AccountPostState, ChainedCall, ProgramId},
};
#[expect(
@@ -14,6 +14,7 @@ use nssa_core::{
reason = "instruction surface passes explicit pool, vault, and user accounts"
)]
pub fn add_liquidity(
config: AccountWithMetadata,
pool: AccountWithMetadata,
vault_a: AccountWithMetadata,
vault_b: AccountWithMetadata,
@@ -24,7 +25,19 @@ pub fn add_liquidity(
min_amount_liquidity: NonZeroU128,
max_amount_to_add_token_a: u128,
max_amount_to_add_token_b: u128,
amm_program_id: ProgramId,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
// The Token Program is taken from the config account, not trusted from a caller-supplied
// holding. Validating the config PDA is also the Program's initialization gate.
assert_eq!(
config.account_id,
compute_config_pda(amm_program_id),
"Add liquidity: AMM config Account ID does not match PDA"
);
let token_program_id = AmmConfig::try_from(&config.account.data)
.expect("Add liquidity: AMM Program must be initialized before use")
.token_program_id;
// 1. Fetch Pool state
let pool_def_data = PoolDefinition::try_from(&pool.account.data)
.expect("Add liquidity: AMM Program expects valid Pool Definition Account");
@@ -45,14 +58,21 @@ pub fn add_liquidity(
"Vault B was not provided"
);
let token_program_id = vault_a.account.program_owner;
assert_eq!(
vault_a.account.program_owner, token_program_id,
"Vault A must be owned by the configured Token Program"
);
assert_eq!(
vault_b.account.program_owner, token_program_id,
"Vault B must be owned by the configured Token Program"
);
assert_eq!(
user_holding_a.account.program_owner, token_program_id,
"User Token A holding must be owned by the vault's Token Program"
"User Token A holding must be owned by the configured Token Program"
);
assert_eq!(
user_holding_b.account.program_owner, token_program_id,
"User Token B holding must be owned by the vault's Token Program"
"User Token B holding must be owned by the configured Token Program"
);
assert!(
@@ -187,6 +207,7 @@ pub fn add_liquidity(
let chained_calls = vec![call_token_lp, call_token_b, call_token_a];
let post_states = vec![
AccountPostState::new(config.account.clone()),
AccountPostState::new(pool_post),
AccountPostState::new(vault_a.account.clone()),
AccountPostState::new(vault_b.account.clone()),
+97
View File
@@ -0,0 +1,97 @@
use amm_core::{compute_config_pda, compute_config_pda_seed, AmmConfig};
use nssa_core::{
account::{Account, 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
/// chained-call instructions read the Token Program ID from it and reject calls until it exists.
///
/// # Panics
/// Panics if:
/// - `config.account_id` does not match `compute_config_pda(amm_program_id)`.
/// - `config.account` is not the default (the Program is already initialized).
pub fn initialize(
config: AccountWithMetadata,
token_program_id: ProgramId,
amm_program_id: ProgramId,
) -> Vec<AccountPostState> {
assert_eq!(
config.account_id,
compute_config_pda(amm_program_id),
"Initialize: AMM config Account ID does not match PDA"
);
assert_eq!(
config.account,
Account::default(),
"Initialize: AMM config account must be uninitialized"
);
let mut config_post = config.account.clone();
config_post.data = Data::from(&AmmConfig { token_program_id });
vec![AccountPostState::new_claimed(
config_post,
Claim::Pda(compute_config_pda_seed()),
)]
}
#[cfg(test)]
mod tests {
use amm_core::compute_config_pda;
use nssa_core::account::{AccountId, Nonce};
use super::*;
const AMM_PROGRAM_ID: ProgramId = [42; 8];
const TOKEN_PROGRAM_ID: ProgramId = [15; 8];
fn config_uninit() -> AccountWithMetadata {
AccountWithMetadata {
account: Account::default(),
is_authorized: false,
account_id: compute_config_pda(AMM_PROGRAM_ID),
}
}
#[test]
fn returns_single_pda_claimed_post_state() {
let post_states = initialize(config_uninit(), TOKEN_PROGRAM_ID, AMM_PROGRAM_ID);
assert_eq!(post_states.len(), 1);
assert_eq!(
post_states[0].required_claim(),
Some(Claim::Pda(compute_config_pda_seed()))
);
}
#[test]
fn stores_token_program_id() {
let post_states = initialize(config_uninit(), TOKEN_PROGRAM_ID, 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);
}
#[test]
#[should_panic(expected = "AMM config Account ID does not match PDA")]
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);
}
#[test]
#[should_panic(expected = "AMM config account must be uninitialized")]
fn already_initialized_config_panics() {
let mut initialized = config_uninit();
initialized.account.data = Data::from(&AmmConfig {
token_program_id: TOKEN_PROGRAM_ID,
});
initialized.account.nonce = Nonce(0);
initialize(initialized, TOKEN_PROGRAM_ID, AMM_PROGRAM_ID);
}
}
+1
View File
@@ -3,6 +3,7 @@
pub use amm_core as core;
pub mod add;
pub mod initialize;
pub mod new_definition;
pub mod remove;
pub mod swap;
+23 -11
View File
@@ -1,10 +1,10 @@
use std::num::NonZeroU128;
use amm_core::{
assert_supported_fee_tier, compute_liquidity_token_pda, compute_liquidity_token_pda_seed,
compute_lp_lock_holding_pda, compute_lp_lock_holding_pda_seed, compute_pool_pda,
compute_pool_pda_seed, compute_vault_pda, compute_vault_pda_seed, PoolDefinition,
MINIMUM_LIQUIDITY,
assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda,
compute_liquidity_token_pda_seed, compute_lp_lock_holding_pda,
compute_lp_lock_holding_pda_seed, compute_pool_pda, compute_pool_pda_seed, compute_vault_pda,
compute_vault_pda_seed, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY,
};
use nssa_core::{
account::{Account, AccountWithMetadata, Data},
@@ -17,6 +17,7 @@ use token_core::TokenDefinition;
reason = "instruction surface passes explicit pool, vault, mint, lock, and user accounts"
)]
pub fn new_definition(
config: AccountWithMetadata,
pool: AccountWithMetadata,
vault_a: AccountWithMetadata,
vault_b: AccountWithMetadata,
@@ -37,12 +38,24 @@ pub fn new_definition(
.expect("New definition: AMM Program expects valid Token Holding account for Token B")
.definition_id();
let token_program = user_holding_a.account.program_owner;
// both instances of the same token program
// The Token Program is taken from the config account, not trusted from a caller-supplied
// holding. Validating the config PDA is also the Program's initialization gate.
assert_eq!(
user_holding_b.account.program_owner, token_program,
"User Token holdings must use the same Token Program"
config.account_id,
compute_config_pda(amm_program_id),
"New definition: AMM config Account ID does not match PDA"
);
let token_program_id = AmmConfig::try_from(&config.account.data)
.expect("New definition: AMM Program must be initialized before use")
.token_program_id;
assert_eq!(
user_holding_a.account.program_owner, token_program_id,
"User Token A holding must be owned by the configured Token Program"
);
assert_eq!(
user_holding_b.account.program_owner, token_program_id,
"User Token B holding must be owned by the configured Token Program"
);
// Verify token_a and token_b are different
assert!(
@@ -124,8 +137,6 @@ pub fn new_definition(
)),
);
let token_program_id = user_holding_a.account.program_owner;
// Chain call for Token A (user_holding_a -> Vault_A)
let mut vault_a_authorized = vault_a.clone();
vault_a_authorized.is_authorized = true;
@@ -199,6 +210,7 @@ pub fn new_definition(
];
let post_states = vec![
AccountPostState::new(config.account.clone()),
pool_post.clone(),
AccountPostState::new(vault_a.account.clone()),
AccountPostState::new(vault_b.account.clone()),
+27 -6
View File
@@ -1,12 +1,12 @@
use std::num::NonZeroU128;
use amm_core::{
assert_supported_fee_tier, compute_liquidity_token_pda_seed, compute_vault_pda_seed,
PoolDefinition, MINIMUM_LIQUIDITY,
assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda_seed,
compute_vault_pda_seed, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY,
};
use nssa_core::{
account::{AccountWithMetadata, Data},
program::{AccountPostState, ChainedCall},
program::{AccountPostState, ChainedCall, ProgramId},
};
#[expect(
@@ -14,6 +14,7 @@ use nssa_core::{
reason = "instruction surface passes explicit pool, vault, and user accounts"
)]
pub fn remove_liquidity(
config: AccountWithMetadata,
pool: AccountWithMetadata,
vault_a: AccountWithMetadata,
vault_b: AccountWithMetadata,
@@ -24,9 +25,21 @@ pub fn remove_liquidity(
remove_liquidity_amount: NonZeroU128,
min_amount_to_remove_token_a: u128,
min_amount_to_remove_token_b: u128,
amm_program_id: ProgramId,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
let remove_liquidity_amount: u128 = remove_liquidity_amount.into();
// The Token Program is taken from the config account, not trusted from a caller-supplied
// holding. Validating the config PDA is also the Program's initialization gate.
assert_eq!(
config.account_id,
compute_config_pda(amm_program_id),
"Remove liquidity: AMM config Account ID does not match PDA"
);
let token_program_id = AmmConfig::try_from(&config.account.data)
.expect("Remove liquidity: AMM Program must be initialized before use")
.token_program_id;
// 1. Fetch Pool state
let pool_def_data = PoolDefinition::try_from(&pool.account.data)
.expect("Remove liquidity: AMM Program expects a valid Pool Definition Account");
@@ -49,14 +62,21 @@ pub fn remove_liquidity(
"Vault B was not provided"
);
let token_program_id = vault_a.account.program_owner;
assert_eq!(
vault_a.account.program_owner, token_program_id,
"Vault A must be owned by the configured Token Program"
);
assert_eq!(
vault_b.account.program_owner, token_program_id,
"Vault B must be owned by the configured Token Program"
);
assert_eq!(
user_holding_a.account.program_owner, token_program_id,
"User Token A holding must be owned by the vault's Token Program"
"User Token A holding must be owned by the configured Token Program"
);
assert_eq!(
user_holding_b.account.program_owner, token_program_id,
"User Token B holding must be owned by the vault's Token Program"
"User Token B holding must be owned by the configured Token Program"
);
// Vault addresses do not need to be checked with PDA
@@ -204,6 +224,7 @@ pub fn remove_liquidity(
let chained_calls = vec![call_token_lp, call_token_b, call_token_a];
let post_states = vec![
AccountPostState::new(config.account.clone()),
AccountPostState::new(pool_post.clone()),
AccountPostState::new(vault_a.account.clone()),
AccountPostState::new(vault_b.account.clone()),
+51 -8
View File
@@ -1,10 +1,11 @@
use amm_core::{
assert_supported_fee_tier, read_vault_fungible_balances, FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY,
assert_supported_fee_tier, compute_config_pda, read_vault_fungible_balances, AmmConfig,
FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY,
};
pub use amm_core::{compute_liquidity_token_pda_seed, compute_vault_pda_seed, PoolDefinition};
use nssa_core::{
account::{AccountId, AccountWithMetadata, Data},
program::{AccountPostState, ChainedCall},
program::{AccountPostState, ChainedCall, ProgramId},
};
/// Validates swap setup: checks pool liquidity is ready, vaults match, and reserves are sufficient.
@@ -55,6 +56,7 @@ fn validate_swap_setup(
reason = "consistent with codebase style"
)]
fn create_swap_post_states(
config: AccountWithMetadata,
pool: AccountWithMetadata,
pool_def_data: PoolDefinition,
vault_a: AccountWithMetadata,
@@ -86,6 +88,7 @@ fn create_swap_post_states(
pool_post.data = Data::from(&pool_post_definition);
vec![
AccountPostState::new(config.account),
AccountPostState::new(pool_post),
AccountPostState::new(vault_a.account),
AccountPostState::new(vault_b.account),
@@ -100,6 +103,7 @@ fn create_swap_post_states(
)]
#[must_use]
pub fn swap_exact_input(
config: AccountWithMetadata,
pool: AccountWithMetadata,
vault_a: AccountWithMetadata,
vault_b: AccountWithMetadata,
@@ -108,17 +112,35 @@ pub fn swap_exact_input(
swap_amount_in: u128,
min_amount_out: u128,
token_in_id: AccountId,
amm_program_id: ProgramId,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
let pool_def_data = validate_swap_setup(&pool, &vault_a, &vault_b);
let token_program_id = vault_a.account.program_owner;
// The Token Program is taken from the config account, not trusted from a caller-supplied
// account. Validating the config PDA is also the Program's initialization gate.
assert_eq!(
config.account_id,
compute_config_pda(amm_program_id),
"Swap exact input: AMM config Account ID does not match PDA"
);
let token_program_id = AmmConfig::try_from(&config.account.data)
.expect("Swap exact input: AMM Program must be initialized before use")
.token_program_id;
assert_eq!(
vault_a.account.program_owner, token_program_id,
"Vault A must be owned by the configured Token Program"
);
assert_eq!(
vault_b.account.program_owner, token_program_id,
"Vault B must be owned by the configured Token Program"
);
assert_eq!(
user_holding_a.account.program_owner, token_program_id,
"User Token A holding must be owned by the vault's Token Program"
"User Token A holding must be owned by the configured Token Program"
);
assert_eq!(
user_holding_b.account.program_owner, token_program_id,
"User Token B holding must be owned by the vault's Token Program"
"User Token B holding must be owned by the configured Token Program"
);
let (chained_calls, [deposit_a, withdraw_a], [deposit_b, withdraw_b]) =
@@ -157,6 +179,7 @@ pub fn swap_exact_input(
};
let post_states = create_swap_post_states(
config,
pool,
pool_def_data,
vault_a,
@@ -262,6 +285,7 @@ fn swap_logic(
)]
#[must_use]
pub fn swap_exact_output(
config: AccountWithMetadata,
pool: AccountWithMetadata,
vault_a: AccountWithMetadata,
vault_b: AccountWithMetadata,
@@ -270,17 +294,35 @@ pub fn swap_exact_output(
exact_amount_out: u128,
max_amount_in: u128,
token_in_id: AccountId,
amm_program_id: ProgramId,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
let pool_def_data = validate_swap_setup(&pool, &vault_a, &vault_b);
let token_program_id = vault_a.account.program_owner;
// The Token Program is taken from the config account, not trusted from a caller-supplied
// account. Validating the config PDA is also the Program's initialization gate.
assert_eq!(
config.account_id,
compute_config_pda(amm_program_id),
"Swap exact output: AMM config Account ID does not match PDA"
);
let token_program_id = AmmConfig::try_from(&config.account.data)
.expect("Swap exact output: AMM Program must be initialized before use")
.token_program_id;
assert_eq!(
vault_a.account.program_owner, token_program_id,
"Vault A must be owned by the configured Token Program"
);
assert_eq!(
vault_b.account.program_owner, token_program_id,
"Vault B must be owned by the configured Token Program"
);
assert_eq!(
user_holding_a.account.program_owner, token_program_id,
"User Token A holding must be owned by the vault's Token Program"
"User Token A holding must be owned by the configured Token Program"
);
assert_eq!(
user_holding_b.account.program_owner, token_program_id,
"User Token B holding must be owned by the vault's Token Program"
"User Token B holding must be owned by the configured Token Program"
);
let (chained_calls, [deposit_a, withdraw_a], [deposit_b, withdraw_b]) =
@@ -319,6 +361,7 @@ pub fn swap_exact_output(
};
let post_states = create_swap_post_states(
config,
pool,
pool_def_data,
vault_a,
+262 -29
View File
File diff suppressed because it is too large Load Diff