diff --git a/artifacts/amm-idl.json b/artifacts/amm-idl.json index 6f126979..61483fbf 100644 --- a/artifacts/amm-idl.json +++ b/artifacts/amm-idl.json @@ -5,6 +5,12 @@ { "name": "initialize", "accounts": [ + { + "name": "owner", + "writable": true, + "signer": true, + "init": false + }, { "name": "config", "writable": true, @@ -13,6 +19,15 @@ } ], "args": [ + { + "name": "nonce", + "type": { + "array": [ + "u8", + 32 + ] + } + }, { "name": "token_program_id", "type": "program_id" diff --git a/modules/amm/ffi/src/api/admin.rs b/modules/amm/ffi/src/api/admin.rs index ecb9b664..f29b560e 100644 --- a/modules/amm/ffi/src/api/admin.rs +++ b/modules/amm/ffi/src/api/admin.rs @@ -1,4 +1,4 @@ -use amm_core::{compute_config_pda, Instruction}; +use amm_core::Instruction; use serde_json::{json, Value}; use super::{config::load_config, TransferOwnershipPlanRequest}; @@ -13,7 +13,7 @@ pub(super) fn transfer_ownership_plan( ) -> 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 { + let Ok((config_id, config)) = load_config(amm_program, &request.config) else { return Err(String::from("config_unavailable")); }; @@ -23,7 +23,7 @@ pub(super) fn transfer_ownership_plan( // 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 account_ids = [config_id, config.authority]; let signing_requirements = [false, true]; Ok(json!({ diff --git a/modules/amm/ffi/src/api/config.rs b/modules/amm/ffi/src/api/config.rs index fb264acc..d6e69f80 100644 --- a/modules/amm/ffi/src/api/config.rs +++ b/modules/amm/ffi/src/api/config.rs @@ -1,17 +1,28 @@ use amm_core::{compute_config_pda, AmmConfig}; -use lee_core::{account::Account, program::ProgramId}; +use lee_core::{ + account::{Account, AccountId}, + program::ProgramId, +}; use serde_json::{json, Value}; use super::{ConfigAccountRequest, ConfigIdRequest}; use crate::account::{ - account_id_hex, decode_account, parse_program_id, program_id_base58, AccountRead, + account_id_hex, decode_account, parse_base58_id, parse_hex_32, parse_program_id, + program_id_base58, AccountRead, }; pub(super) fn config_id(request: ConfigIdRequest) -> Result { let amm_program = parse_program_id(&request.amm_program_id)?; + let owner = parse_base58_id(&request.owner, "owner")?; + // An omitted / empty nonce selects the owner's default (all-zero) instance. + let nonce = if request.nonce.is_empty() { + [0_u8; 32] + } else { + parse_hex_32(&request.nonce, "nonce")? + }; Ok(json!({ "status": "ok", - "configId": account_id_hex(compute_config_pda(amm_program)), + "configId": account_id_hex(compute_config_pda(amm_program, owner, nonce)), })) } @@ -21,13 +32,13 @@ pub(super) fn config_id(request: ConfigIdRequest) -> Result { /// `config_id` for address derivation. pub(super) fn config_account(request: ConfigAccountRequest) -> Result { let amm_program = parse_program_id(&request.amm_program_id)?; - let Ok(config) = load_config(amm_program, &request.config) else { + let Ok((config_id, config)) = load_config(amm_program, &request.config) else { return Ok(json!({ "status": "error", "error": "config_unavailable" })); }; Ok(json!({ "status": "ok", "error": "", - "configId": compute_config_pda(amm_program).to_string(), + "configId": config_id.to_string(), "ammProgramId": program_id_base58(amm_program), "authority": config.authority.to_string(), "tokenProgramId": program_id_base58(config.token_program_id), @@ -35,13 +46,20 @@ pub(super) fn config_account(request: ConfigAccountRequest) -> Result Result { +/// Decodes and validates a passed AMM config account, returning its id (the namespace root +/// callers derive pools under) alongside the decoded config. Since the config PDA is now +/// namespaced by `(owner, nonce)`, the id can no longer be recomputed here without those +/// inputs — the caller-supplied account's id IS the namespace root. Program ownership and a +/// non-default, parseable account are still enforced. +pub(super) fn load_config( + amm_program: ProgramId, + read: &AccountRead, +) -> Result<(AccountId, AmmConfig), String> { let (id, account) = decode_account(read)?; - if id != compute_config_pda(amm_program) - || account.program_owner != amm_program - || account == Account::default() - { + if account.program_owner != amm_program || account == Account::default() { return Err(String::from("AMM config is unavailable")); } - AmmConfig::try_from(&account.data).map_err(|_| String::from("AMM config is invalid")) + let config = + AmmConfig::try_from(&account.data).map_err(|_| String::from("AMM config is invalid"))?; + Ok((id, config)) } diff --git a/modules/amm/ffi/src/api/context.rs b/modules/amm/ffi/src/api/context.rs index e34324b5..e3b2c25a 100644 --- a/modules/amm/ffi/src/api/context.rs +++ b/modules/amm/ffi/src/api/context.rs @@ -24,7 +24,7 @@ use crate::account::{account_id_from_hex, decode_account, parse_program_id, Acco /// requested id with no returned row as unresolved/unavailable. pub(super) fn resolve_tokens(request: ResolveTokensRequest) -> Result { let amm_program = parse_program_id(&request.amm_program_id)?; - let Ok(config) = load_config(amm_program, &request.config) else { + let Ok((_, config)) = load_config(amm_program, &request.config) else { return Ok(json!({ "status": "error", "code": "config_unavailable", "tokens": [] })); }; diff --git a/modules/amm/ffi/src/api/liquidity.rs b/modules/amm/ffi/src/api/liquidity.rs index c1cff9fb..4ffa67eb 100644 --- a/modules/amm/ffi/src/api/liquidity.rs +++ b/modules/amm/ffi/src/api/liquidity.rs @@ -656,6 +656,13 @@ mod tests { } } + /// The namespace root (config PDA id) the plan tests derive pools under. A fixed + /// `(owner, nonce)` instance is enough — the tests only need it to be consistent between + /// `valid_config` and the expected `compute_pool_pda`. + fn config_id(amm: lee_core::program::ProgramId) -> AccountId { + compute_config_pda(amm, AccountId::new([0x07; 32]), [0; 32]) + } + /// A valid AMM config account read so `derive_pair` succeeds in plan tests. fn valid_config(amm: lee_core::program::ProgramId) -> AccountRead { let token_program = parse_program_id(&"01".repeat(32)).unwrap(); @@ -669,7 +676,7 @@ mod tests { }), ..Account::default() }; - account_read(compute_config_pda(amm), &account) + account_read(config_id(amm), &account) } #[test] @@ -797,8 +804,8 @@ mod tests { .map(|value| value.as_bool().unwrap()) .collect(); - let pool = compute_pool_pda(amm, canonical_a, canonical_b); - assert_eq!(ids[0], account_id_hex(compute_config_pda(amm))); + let pool = compute_pool_pda(amm, config_id(amm), canonical_a, canonical_b); + assert_eq!(ids[0], account_id_hex(config_id(amm))); assert_eq!(ids[1], account_id_hex(pool)); // Canonical vaults, in canonical order. assert_eq!( @@ -1062,10 +1069,10 @@ mod tests { serde_json::json!(words.iter().map(|w| u64::from(*w)).collect::>()) }; let assert_aligned = |ids: &[String], instruction: &serde_json::Value| { - assert_eq!(ids[0], account_id_hex(compute_config_pda(amm))); + assert_eq!(ids[0], account_id_hex(config_id(amm))); assert_eq!( ids[1], - account_id_hex(compute_pool_pda(amm, token_a, token_b)) + account_id_hex(compute_pool_pda(amm, config_id(amm), token_a, token_b)) ); assert_eq!(ids[2], account_id_hex(vault_a)); assert_eq!(ids[3], account_id_hex(vault_b)); @@ -1363,10 +1370,10 @@ mod tests { ]); let assert_aligned = |ids: &[String], instruction: &serde_json::Value, signers: &serde_json::Value| { - assert_eq!(ids[0], account_id_hex(compute_config_pda(amm))); + assert_eq!(ids[0], account_id_hex(config_id(amm))); assert_eq!( ids[1], - account_id_hex(compute_pool_pda(amm, token_a, token_b)) + account_id_hex(compute_pool_pda(amm, config_id(amm), token_a, token_b)) ); assert_eq!(ids[2], account_id_hex(vault_a)); assert_eq!(ids[3], account_id_hex(vault_b)); @@ -1475,10 +1482,10 @@ mod tests { .map(|v| v.as_str().unwrap().to_string()) .collect::>(); assert_eq!(ids.len(), 6); - assert_eq!(ids[0], account_id_hex(compute_config_pda(amm))); + assert_eq!(ids[0], account_id_hex(config_id(amm))); assert_eq!( ids[1], - account_id_hex(compute_pool_pda(amm, token_a, token_b)) + account_id_hex(compute_pool_pda(amm, config_id(amm), token_a, token_b)) ); assert_eq!(ids[2], account_id_hex(vault_a)); // pool's stored vaults assert_eq!(ids[3], account_id_hex(vault_b)); diff --git a/modules/amm/ffi/src/api/pair.rs b/modules/amm/ffi/src/api/pair.rs index accf1c98..6b9cfc65 100644 --- a/modules/amm/ffi/src/api/pair.rs +++ b/modules/amm/ffi/src/api/pair.rs @@ -1,6 +1,5 @@ use amm_core::{ - compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, - compute_vault_pda, + compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, compute_vault_pda, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use lee_core::{account::AccountId, program::ProgramId}; @@ -57,9 +56,8 @@ pub(super) fn derive_pair( token_b: AccountId, config_read: &AccountRead, ) -> Result { - let config_id = compute_config_pda(amm_program); - let config = load_config(amm_program, config_read)?; - let pool = compute_pool_pda(amm_program, token_a, token_b); + let (config_id, config) = load_config(amm_program, config_read)?; + let pool = compute_pool_pda(amm_program, config_id, token_a, token_b); Ok(PairIds { token_a, token_b, diff --git a/modules/amm/ffi/src/api/request.rs b/modules/amm/ffi/src/api/request.rs index 5f4629cb..e5be3109 100644 --- a/modules/amm/ffi/src/api/request.rs +++ b/modules/amm/ffi/src/api/request.rs @@ -2,10 +2,17 @@ use serde::Deserialize; use crate::account::AccountRead; +/// Derives a namespaced AMM instance's config PDA. `owner` (base58 account id) and `nonce` +/// (64-char hex; omitted / empty ⇒ the all-zero default, i.e. the owner's default instance) +/// select the instance. This is the one op that supplies the namespace directly — every other +/// op derives it from the config account it is passed. #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ConfigIdRequest { pub amm_program_id: String, + pub owner: String, + #[serde(default)] + pub nonce: String, } /// Decodes the singleton AMM config account. `config` is the read of the config PDA the module @@ -124,6 +131,9 @@ pub struct PoolIdRequest { pub amm_program_id: String, pub token_in_id: String, pub token_out_id: String, + /// AMM config account read — its id is the namespace root the pool PDA is derived under + /// (pools are namespaced by config since the namespacing change). + pub config: AccountRead, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] diff --git a/modules/amm/ffi/src/api/swap.rs b/modules/amm/ffi/src/api/swap.rs index c23dee97..f2297180 100644 --- a/modules/amm/ffi/src/api/swap.rs +++ b/modules/amm/ffi/src/api/swap.rs @@ -114,10 +114,10 @@ pub(super) fn resolve_pool(request: ResolvePoolRequest) -> Result })) } -/// Derives the pool PDA for a swap pair (tokens in either order). Config-free — -/// the pool address depends only on the AMM program id and the two token ids, so -/// a caller that just needs to read the pool doesn't have to load the config -/// first (unlike `swap_pair`, which also derives the config-dependent tick PDA). +/// Derives the pool PDA for a swap pair (tokens in either order). The pool is now namespaced by +/// its AMM instance, so the caller supplies the instance's `config` account: its id is the +/// namespace root the pool PDA is derived under (only the config account is decoded — unlike +/// `swap_pair`, no config-dependent tick PDA is derived, so `AmmConfig` need not be valid). pub(super) fn pool_id(request: PoolIdRequest) -> Result { let amm_program = parse_program_id(&request.amm_program_id)?; let token_in = account_id_from_hex(&request.token_in_id, "token in id")?; @@ -125,8 +125,11 @@ pub(super) fn pool_id(request: PoolIdRequest) -> Result { if token_in == token_out { return Err(String::from("same_token_pair")); } + let (config_id, _) = decode_account(&request.config)?; let (token_a, token_b) = canonical_pair(token_in, token_out); - Ok(json!({ "poolId": account_id_hex(compute_pool_pda(amm_program, token_a, token_b)) })) + Ok(json!({ + "poolId": account_id_hex(compute_pool_pda(amm_program, config_id, token_a, token_b)) + })) } /// Prices a `SwapExactInput`: orients the pool's reserves to the requested in/out @@ -827,9 +830,25 @@ mod tests { ); } + /// A config account read whose id is `config_id` (the namespace root pool derivation uses). + /// `pool_id` only decodes the account for its id, so the config bytes/owner are irrelevant. + fn config_read(config_id: AccountId) -> AccountRead { + AccountRead { + id: account_id_hex(config_id), + status: String::from("ok"), + account: Some(WalletAccount { + program_owner: "00".repeat(32), + balance: "0".repeat(32), + nonce: "0".repeat(32), + data: String::new(), + }), + } + } + #[test] fn pool_id_is_order_independent_and_matches_core() { let program = "00".repeat(32); + let config_id = AccountId::new([0xEE; 32]); let a = AccountId::new([0xCC; 32]); let b = AccountId::new([0xDD; 32]); @@ -837,31 +856,37 @@ mod tests { amm_program_id: program.clone(), token_in_id: account_id_hex(a), token_out_id: account_id_hex(b), + config: config_read(config_id), }) .unwrap(); let ba = pool_id(PoolIdRequest { amm_program_id: program.clone(), token_in_id: account_id_hex(b), token_out_id: account_id_hex(a), + config: config_read(config_id), }) .unwrap(); // Canonical ordering makes the pool id independent of swap direction. assert_eq!(ab, ba); - // And it matches amm_core's PDA for the canonical pair. + // And it matches amm_core's PDA for the canonical pair under this config namespace. let amm = parse_program_id(&program).unwrap(); let (ca, cb) = if is_canonical_pair(a, b) { (a, b) } else { (b, a) }; - assert_eq!(ab["poolId"], account_id_hex(compute_pool_pda(amm, ca, cb))); + assert_eq!( + ab["poolId"], + account_id_hex(compute_pool_pda(amm, config_id, ca, cb)) + ); // Same token in/out is rejected. assert!(pool_id(PoolIdRequest { amm_program_id: program, token_in_id: account_id_hex(a), token_out_id: account_id_hex(a), + config: config_read(config_id), }) .is_err()); } diff --git a/modules/amm/ffi/src/api/tests.rs b/modules/amm/ffi/src/api/tests.rs index 4f74142b..6098f523 100644 --- a/modules/amm/ffi/src/api/tests.rs +++ b/modules/amm/ffi/src/api/tests.rs @@ -51,6 +51,13 @@ fn default_read(id: AccountId) -> AccountRead { account_read(id, &Account::default()) } +/// The namespace root (config PDA id) the tests derive pools under. A fixed `(owner, nonce)` +/// instance under `AMM_PROGRAM`; the pool derivations and the config `account_read` id must both +/// use this so `derive_pair` (which reads the config's id back) lines up with the expected pools. +fn config_id() -> AccountId { + compute_config_pda(AMM_PROGRAM, AccountId::new([0x50; 32]), [0; 32]) +} + fn config_account() -> Account { account( AMM_PROGRAM, @@ -87,8 +94,8 @@ fn token_holding(definition_id: AccountId, balance: u128) -> Account { fn ids() -> PairIds { let token_a = AccountId::new([2; 32]); let token_b = AccountId::new([1; 32]); - let config = compute_config_pda(AMM_PROGRAM); - let pool = compute_pool_pda(AMM_PROGRAM, token_a, token_b); + let config = config_id(); + let pool = compute_pool_pda(AMM_PROGRAM, config, token_a, token_b); PairIds { token_a, token_b, @@ -151,7 +158,7 @@ fn highest_balance_holding_wins_then_lowest_id() { fn pair_manifest_uses_canonical_ids_and_current_program_types() { let token_a = AccountId::new([2; 32]); let token_b = AccountId::new([1; 32]); - let config_id = compute_config_pda(AMM_PROGRAM); + let config_id = config_id(); let result = pair_ids(PairIdsRequest { amm_program_id: amm_program_id(), config: account_read(config_id, &config_account()), @@ -164,7 +171,7 @@ fn pair_manifest_uses_canonical_ids_and_current_program_types() { assert_eq!(result["tokenBId"], account_id_hex(token_b)); assert_eq!( result["poolId"], - account_id_hex(compute_pool_pda(AMM_PROGRAM, token_a, token_b)) + account_id_hex(compute_pool_pda(AMM_PROGRAM, config_id, token_a, token_b)) ); } @@ -203,7 +210,7 @@ fn resolve_tokens_returns_lean_rows_held_first_and_omits_unresolvable() { let held = AccountId::new([2; 32]); let listed = AccountId::new([5; 32]); let missing = AccountId::new([9; 32]); // requested but no definition read supplied - let config_id = compute_config_pda(AMM_PROGRAM); + let config_id = config_id(); let value = resolve_tokens(ResolveTokensRequest { amm_program_id: amm_program_id(), @@ -249,7 +256,7 @@ 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 config_id = config_id(); let new_authority = AccountId::new([5; 32]); let plan = transfer_ownership_plan(TransferOwnershipPlanRequest { amm_program_id: amm_program_id(), @@ -289,8 +296,8 @@ fn transfer_ownership_plan_targets_config_and_current_admin() { 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 config_id = config_id(); + let pool = compute_pool_pda(AMM_PROGRAM, config_id, token_a, token_b); let window = 3_600_000_u64; let plan = create_price_observations_plan(CreatePriceObservationsPlanRequest { @@ -336,8 +343,8 @@ fn create_price_observations_plan_targets_the_window_feed_accounts() { 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 config_id = config_id(); + let pool = compute_pool_pda(AMM_PROGRAM, config_id, token_a, token_b); let window = 900_000_u64; let plan = create_oracle_price_account_plan(CreateOraclePriceAccountPlanRequest { @@ -380,7 +387,7 @@ fn create_oracle_price_account_plan_targets_the_window_price_account() { #[test] fn config_account_decodes_authority_and_program_ids() { - let config_id = compute_config_pda(AMM_PROGRAM); + let config_id = config_id(); let value = decode_config_account(ConfigAccountRequest { amm_program_id: amm_program_id(), config: account_read(config_id, &config_account()), @@ -400,7 +407,7 @@ fn config_account_decodes_authority_and_program_ids() { #[test] fn config_account_is_unavailable_when_not_on_chain() { - let config_id = compute_config_pda(AMM_PROGRAM); + let config_id = config_id(); let value = decode_config_account(ConfigAccountRequest { amm_program_id: amm_program_id(), config: default_read(config_id), @@ -430,7 +437,7 @@ fn swap_plan_uses_the_pool_stored_vaults_not_canonical_order() { let token_large = AccountId::new([2; 32]); assert!(is_canonical_pair(token_large, token_small)); // large is canonical token_a - let pool_id = compute_pool_pda(AMM_PROGRAM, token_small, token_large); + let pool_id = compute_pool_pda(AMM_PROGRAM, config_id(), token_small, token_large); let pool = PoolDefinition { definition_token_a_id: token_small, // stored non-canonically (small first) definition_token_b_id: token_large, @@ -448,7 +455,7 @@ fn swap_plan_uses_the_pool_stored_vaults_not_canonical_order() { amm_program_id: amm_program_id(), token_in_id: account_id_hex(token_small), token_out_id: account_id_hex(token_large), - config: account_read(compute_config_pda(AMM_PROGRAM), &config_account()), + config: account_read(config_id(), &config_account()), user_input_holding_id: account_id_hex(holding), user_output_holding_id: account_id_hex(holding), amount_in: String::from("100"), @@ -485,7 +492,7 @@ fn swap_exact_in_plan_missing_pool_fails_closed_with_err() { amm_program_id: amm_program_id(), token_in_id: account_id_hex(token_a), token_out_id: account_id_hex(token_b), - config: account_read(compute_config_pda(AMM_PROGRAM), &config_account()), + config: account_read(config_id(), &config_account()), user_input_holding_id: account_id_hex(holding), user_output_holding_id: account_id_hex(holding), amount_in: String::from("100"), @@ -505,7 +512,7 @@ fn swap_exact_out_plan_uses_the_pool_stored_vaults_not_canonical_order() { let token_large = AccountId::new([2; 32]); assert!(is_canonical_pair(token_large, token_small)); // large is canonical token_a - let pool_id = compute_pool_pda(AMM_PROGRAM, token_small, token_large); + let pool_id = compute_pool_pda(AMM_PROGRAM, config_id(), token_small, token_large); let pool = PoolDefinition { definition_token_a_id: token_small, // stored non-canonically (small first) definition_token_b_id: token_large, @@ -523,7 +530,7 @@ fn swap_exact_out_plan_uses_the_pool_stored_vaults_not_canonical_order() { amm_program_id: amm_program_id(), token_in_id: account_id_hex(token_small), token_out_id: account_id_hex(token_large), - config: account_read(compute_config_pda(AMM_PROGRAM), &config_account()), + config: account_read(config_id(), &config_account()), user_input_holding_id: account_id_hex(holding), user_output_holding_id: account_id_hex(holding), amount_out: String::from("100"), diff --git a/modules/amm/ffi/src/api/token_holdings.rs b/modules/amm/ffi/src/api/token_holdings.rs index b15100dc..4c5f7198 100644 --- a/modules/amm/ffi/src/api/token_holdings.rs +++ b/modules/amm/ffi/src/api/token_holdings.rs @@ -18,7 +18,7 @@ use crate::account::{account_id_hex, parse_program_id}; pub(super) fn token_holdings(request: TokenHoldingsRequest) -> Result { let amm_program = parse_program_id(&request.amm_program_id)?; - let config = load_config(amm_program, &request.config)?; + let (_, config) = load_config(amm_program, &request.config)?; let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id); let rows = holdings .into_iter() @@ -76,7 +76,10 @@ mod tests { }), ..Account::default() }; - account_read(compute_config_pda(amm), &account) + account_read( + compute_config_pda(amm, AccountId::new([0x07; 32]), [0; 32]), + &account, + ) } #[test] diff --git a/modules/amm/ffi/tests/public_api.rs b/modules/amm/ffi/tests/public_api.rs index 87c55969..cbcb985b 100644 --- a/modules/amm/ffi/tests/public_api.rs +++ b/modules/amm/ffi/tests/public_api.rs @@ -7,6 +7,9 @@ use amm_ffi::{ fn direct_rust_api_does_not_require_ffi() { let response = config_id(ConfigIdRequest { amm_program_id: "0000000000000000000000000000000000000000000000000000000000000000".into(), + // Base58 account id of the instance owner; an omitted nonce ⇒ the owner's default instance. + owner: lee_core::account::AccountId::new([0x07; 32]).to_string(), + nonce: String::new(), }) .expect("valid program ID should produce a response"); diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index f570cbd8..f21fe557 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -16,19 +16,29 @@ const LP_LOCK_HOLDING_PDA_SEED: &[u8] = b"LP_LOCK_HOLDING"; /// AMM Program Instruction. #[derive(Serialize, Deserialize)] pub enum Instruction { - /// Initializes the AMM Program by creating its singleton configuration account. + /// Initializes a **namespaced** AMM instance by creating its configuration account. /// - /// The configuration account is a PDA derived from the constant `"CONFIG"` seed - /// (`compute_config_pda(self_program_id)`). It stores the program IDs the AMM issues chained - /// calls to (the Token Program and the TWAP oracle program), plus the admin `authority` - /// allowed to transfer admin control later via `UpdateConfig`. The Program must be initialized - /// via this instruction before any pool can be created or interacted with — the other - /// instructions read these program IDs from this account and reject calls when it does not - /// yet exist. + /// A single deployed AMM Program hosts many independent instances, each identified by a + /// namespace `(owner, nonce)`. The configuration account is a PDA derived from that namespace + /// (`compute_config_pda(self_program_id, owner, nonce)`), where `owner` is the account that + /// signs this instruction. Signing squat-proofs the namespace: nobody can initialize an + /// instance (and set its program IDs) under an account they do not control. The `nonce` + /// discriminates multiple instances under the same owner; the all-zero nonce is the owner's + /// default instance. + /// + /// The config stores the program IDs the AMM issues chained calls to (the Token Program and + /// the TWAP oracle program) — so each namespace can point at its own deployments — plus the + /// admin `authority` allowed to transfer admin control later via `UpdateConfig`. Every pool + /// and downstream PDA is derived from this config's account id, so instances are fully + /// isolated even for the same token pair. Rejects if the config already exists. /// /// Required accounts: - /// - AMM Config Account, uninitialized, derived as `compute_config_pda(self_program_id)` + /// - Owner Account — signs this instruction; its account id is the namespace owner. + /// - AMM Config Account, uninitialized, derived as `compute_config_pda(self_program_id, + /// owner.account_id, nonce)` Initialize { + /// Namespace discriminator under `owner`. `[0; 32]` is the owner's default instance. + nonce: [u8; 32], /// Program ID of the Token Program the AMM will issue chained calls to. token_program_id: ProgramId, /// Program ID of the TWAP oracle program the AMM will issue chained calls to. @@ -526,41 +536,58 @@ impl From<&AmmConfig> for Data { } } -// Stable seed marker for the singleton config PDA. The literal `"CONFIG"` bytes are hashed into -// the 32-byte seed; this must stay unchanged for address compatibility. +// Stable domain-separation marker for the config PDA. Hashed together with the namespace +// `(owner, nonce)` into the 32-byte seed; must stay unchanged for address compatibility. const CONFIG_PDA_SEED: &[u8] = b"CONFIG"; -/// Derives the [`AccountId`] of the AMM Program's singleton config PDA. +/// Derives the [`AccountId`] of a namespaced AMM instance's config PDA from its `(owner, nonce)` +/// namespace. Each `(owner, nonce)` pair is an independent AMM instance. #[must_use] -pub fn compute_config_pda(amm_program_id: ProgramId) -> AccountId { - AccountId::for_public_pda(&amm_program_id, &compute_config_pda_seed()) +pub fn compute_config_pda( + amm_program_id: ProgramId, + owner: AccountId, + nonce: [u8; 32], +) -> AccountId { + AccountId::for_public_pda(&amm_program_id, &compute_config_pda_seed(owner, nonce)) } -/// Derives the [`PdaSeed`] of the AMM Program's singleton config PDA from the `"CONFIG"` bytes. +/// Derives the [`PdaSeed`] of a namespaced config PDA as `hash("CONFIG" || owner || nonce)`. +/// The all-zero `nonce` yields the owner's default instance. #[must_use] -pub fn compute_config_pda_seed() -> PdaSeed { +pub fn compute_config_pda_seed(owner: AccountId, nonce: [u8; 32]) -> PdaSeed { use risc0_zkvm::sha::{Impl, Sha256}; + let mut bytes = Vec::new(); + bytes.extend_from_slice(CONFIG_PDA_SEED); + bytes.extend_from_slice(&owner.to_bytes()); + bytes.extend_from_slice(&nonce); + PdaSeed::new( - Impl::hash_bytes(CONFIG_PDA_SEED) + Impl::hash_bytes(&bytes) .as_bytes() .try_into() .expect("Hash output must be exactly 32 bytes long"), ) } +/// Derives the [`AccountId`] of a pool PDA. The pool is namespaced by `config_id` (the account id +/// of the owning instance's config PDA), so the same token pair in different instances yields +/// different, fully isolated pools. pub fn compute_pool_pda( amm_program_id: ProgramId, + config_id: AccountId, definition_token_a_id: AccountId, definition_token_b_id: AccountId, ) -> AccountId { AccountId::for_public_pda( &amm_program_id, - &compute_pool_pda_seed(definition_token_a_id, definition_token_b_id), + &compute_pool_pda_seed(config_id, definition_token_a_id, definition_token_b_id), ) } +/// Derives the [`PdaSeed`] of a pool PDA as `hash(config_id || sorted(token_a, token_b))`. pub fn compute_pool_pda_seed( + config_id: AccountId, definition_token_a_id: AccountId, definition_token_b_id: AccountId, ) -> PdaSeed { @@ -575,10 +602,10 @@ pub fn compute_pool_pda_seed( std::cmp::Ordering::Equal => panic!("Definitions match"), }; - let mut bytes = [0; 64]; - let (token_1_bytes, token_2_bytes) = bytes.split_at_mut(32); - token_1_bytes.copy_from_slice(&token_1.to_bytes()); - token_2_bytes.copy_from_slice(&token_2.to_bytes()); + let mut bytes = [0; 96]; + bytes[0..32].copy_from_slice(&config_id.to_bytes()); + bytes[32..64].copy_from_slice(&token_1.to_bytes()); + bytes[64..96].copy_from_slice(&token_2.to_bytes()); PdaSeed::new( Impl::hash_bytes(&bytes) diff --git a/programs/amm/examples/amm_pdas.rs b/programs/amm/examples/amm_pdas.rs index a1596bf9..a4e84461 100644 --- a/programs/amm/examples/amm_pdas.rs +++ b/programs/amm/examples/amm_pdas.rs @@ -1,11 +1,12 @@ -//! Print the AMM PDAs for a deployment (and, given a token pair, a pool's PDAs). +//! Print the AMM PDAs for a namespaced instance (and, given a token pair, a pool's PDAs). //! //! Usage: -//! cargo run -q -p amm_program --example amm_pdas -- [ ] +//! cargo run -q -p amm_program --example amm_pdas -- [ ] //! //! `*_pid` are ProgramIds as 8 comma-separated u32 limbs (as printed by `spel program-id`); -//! `defA`/`defB` are base58 token-definition account ids. With only `` it prints the -//! singleton config PDA; with all four args it also prints the pool/vault/LP/lock/tick PDAs. +//! `owner`/`defA`/`defB` are base58 account ids. AMM instances are namespaced by `(owner, nonce)`; +//! this prints the owner's default instance (all-zero nonce). With ` ` it prints +//! the instance's config PDA; with all args it also prints the pool/vault/LP/lock/tick PDAs. use std::str::FromStr; @@ -52,19 +53,22 @@ fn parse_pid(s: &str) -> ProgramId { fn main() { let args: Vec = std::env::args().skip(1).collect(); - let Some((amm_s, rest)) = args.split_first() else { - eprintln!("usage: amm_pdas [ ]"); + let [amm_s, owner_s, rest @ ..] = args.as_slice() else { + eprintln!("usage: amm_pdas [ ]"); std::process::exit(1); }; let amm = parse_pid(amm_s); - let config = compute_config_pda(amm); + let owner = AccountId::from_str(owner_s).expect("owner must be base58"); + // The owner's default instance uses the all-zero nonce. + let nonce = [0u8; 32]; + let config = compute_config_pda(amm, owner, nonce); println!("config {config}"); if let [twap_s, def_a_s, def_b_s] = rest { let twap = parse_pid(twap_s); let def_a = AccountId::from_str(def_a_s).expect("defA must be base58"); let def_b = AccountId::from_str(def_b_s).expect("defB must be base58"); - let pool = compute_pool_pda(amm, def_a, def_b); + let pool = compute_pool_pda(amm, config, def_a, def_b); println!("pool {pool}"); println!( "vault_a {}", diff --git a/programs/amm/methods/guest/src/bin/amm.rs b/programs/amm/methods/guest/src/bin/amm.rs index 4f1a0fd9..8f26d5d9 100644 --- a/programs/amm/methods/guest/src/bin/amm.rs +++ b/programs/amm/methods/guest/src/bin/amm.rs @@ -24,21 +24,35 @@ mod amm { )] use super::*; - /// Initializes the AMM Program by creating its singleton config account. + /// Initializes a namespaced AMM instance by creating its config account. + /// + /// A single deployed AMM Program hosts many independent instances, each keyed by a + /// namespace `(owner, nonce)`. The owner signs to squat-proof the namespace; the config + /// PDA's account id is the namespace root every pool and downstream PDA derives from. + /// (See `amm_program::initialize::initialize` for the full semantics.) /// /// Expected accounts: - /// 1. `config` — uninitialized config PDA derived from `compute_config_pda(self_program_id)`. + /// 1. `owner` — signs this instruction (the namespace owner). A fresh owner is claimed by + /// the AMM on first use (hence writable); on later instances under the same owner it is + /// already AMM-owned and echoed unchanged. + /// 2. `config` — uninitialized config PDA at + /// `compute_config_pda(self_program_id, owner.account_id, nonce)`. #[instruction] pub fn initialize( ctx: ProgramContext, + #[account(mut, signer)] + owner: AccountWithMetadata, #[account(init)] config: AccountWithMetadata, + nonce: [u8; 32], token_program_id: ProgramId, twap_oracle_program_id: ProgramId, authority: AccountId, ) -> SpelResult { let post_states = amm_program::initialize::initialize( + owner, config, + nonce, token_program_id, twap_oracle_program_id, authority, diff --git a/programs/amm/src/add.rs b/programs/amm/src/add.rs index 17ea849c..e8e3821b 100644 --- a/programs/amm/src/add.rs +++ b/programs/amm/src/add.rs @@ -1,7 +1,7 @@ use std::num::NonZeroU128; use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda_seed, + assert_supported_fee_tier, compute_liquidity_token_pda_seed, compute_pool_pda, compute_pool_pda_seed, mul_div_floor, read_vault_fungible_balances, spot_price_q64_64, AmmConfig, PoolDefinition, }; @@ -35,9 +35,8 @@ pub fn add_liquidity( // The program IDs are 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" + config.account.program_owner, amm_program_id, + "Add liquidity: AMM config account must be owned by the AMM Program" ); let config_data = AmmConfig::try_from(&config.account.data) .expect("Add liquidity: AMM Program must be initialized before use"); @@ -47,6 +46,23 @@ pub fn add_liquidity( // 1. Fetch Pool state let pool_def_data = PoolDefinition::try_from(&pool.account.data) .expect("Add liquidity: AMM Program expects valid Pool Definition Account"); + + // The pool must be derived under THIS config's namespace. config.account_id is the + // namespace root, so a pool belonging to another instance — even a valid AMM-owned + // pool with the same token pair — derives a different PDA and is rejected here. This + // stops a caller from pairing a config from one instance with a pool from another + // (matching the check new_definition makes when it creates the pool). + assert_eq!( + pool.account_id, + compute_pool_pda( + amm_program_id, + config.account_id, + pool_def_data.definition_token_a_id, + pool_def_data.definition_token_b_id, + ), + "Add liquidity: pool account is not derived under this config's namespace" + ); + assert_supported_fee_tier(pool_def_data.fees); assert_eq!( @@ -241,6 +257,7 @@ pub fn add_liquidity( &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, ) .with_pda_seeds(vec![compute_pool_pda_seed( + config.account_id, pool_def_data.definition_token_a_id, pool_def_data.definition_token_b_id, )]); diff --git a/programs/amm/src/create_oracle_price_account.rs b/programs/amm/src/create_oracle_price_account.rs index 5d957942..9d2d27f4 100644 --- a/programs/amm/src/create_oracle_price_account.rs +++ b/programs/amm/src/create_oracle_price_account.rs @@ -1,6 +1,5 @@ use amm_core::{ - compute_config_pda, compute_pool_pda, compute_pool_pda_seed, spot_price_q64_64, AmmConfig, - PoolDefinition, + compute_pool_pda, compute_pool_pda_seed, spot_price_q64_64, AmmConfig, PoolDefinition, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use lee_core::{ @@ -52,9 +51,8 @@ pub fn create_oracle_price_account( ) -> (Vec, Vec) { // Config gate: validate the config PDA and read the TWAP oracle program ID from it. assert_eq!( - config.account_id, - compute_config_pda(amm_program_id), - "Create oracle price account: AMM config Account ID does not match PDA" + config.account.program_owner, amm_program_id, + "Create oracle price account: AMM config account must be owned by the AMM Program" ); let twap_oracle_program_id = AmmConfig::try_from(&config.account.data) .expect("Create oracle price account: AMM Program must be initialized before use") @@ -85,6 +83,7 @@ pub fn create_oracle_price_account( pool.account_id, compute_pool_pda( amm_program_id, + config.account_id, pool_def.definition_token_a_id, pool_def.definition_token_b_id, ), @@ -135,6 +134,7 @@ pub fn create_oracle_price_account( }, ) .with_pda_seeds(vec![compute_pool_pda_seed( + config.account_id, pool_def.definition_token_a_id, pool_def.definition_token_b_id, )]); @@ -151,7 +151,7 @@ pub fn create_oracle_price_account( #[cfg(test)] mod tests { - use amm_core::compute_pool_pda_seed; + use amm_core::{compute_config_pda, compute_pool_pda_seed}; use lee_core::account::{Account, AccountId, Data, Nonce}; use super::*; @@ -163,6 +163,16 @@ mod tests { const WINDOW_24H: u64 = 24 * 60 * 60 * 1_000; const RESERVE_A: u128 = 5_000; const RESERVE_B: u128 = 2_500; + /// Canonical test namespace: the owner that signs Initialize and the default (all-zero) nonce. + const TEST_NONCE: [u8; 32] = [0; 32]; + + fn amm_owner() -> AccountId { + AccountId::new([200; 32]) + } + + fn config_id() -> AccountId { + compute_config_pda(AMM_PROGRAM_ID, amm_owner(), TEST_NONCE) + } fn token_a_id() -> AccountId { AccountId::new([3; 32]) @@ -173,7 +183,7 @@ mod tests { } fn pool_id() -> AccountId { - compute_pool_pda(AMM_PROGRAM_ID, token_a_id(), token_b_id()) + compute_pool_pda(AMM_PROGRAM_ID, config_id(), token_a_id(), token_b_id()) } fn config_init() -> AccountWithMetadata { @@ -189,7 +199,7 @@ mod tests { nonce: Nonce(0), }, is_authorized: false, - account_id: compute_config_pda(AMM_PROGRAM_ID), + account_id: config_id(), } } @@ -286,7 +296,11 @@ mod tests { window_duration: WINDOW_24H, }, ) - .with_pda_seeds(vec![compute_pool_pda_seed(token_a_id(), token_b_id())]); + .with_pda_seeds(vec![compute_pool_pda_seed( + config_id(), + token_a_id(), + token_b_id(), + )]); assert_eq!(chained_calls[0], expected); } @@ -294,10 +308,10 @@ mod tests { // ── precondition violations ─────────────────────────────────────────────── #[test] - #[should_panic(expected = "AMM config Account ID does not match PDA")] - fn wrong_config_pda_panics() { + #[should_panic(expected = "must be owned by the AMM Program")] + fn config_not_owned_by_amm_panics() { let mut config = config_init(); - config.account_id = AccountId::new([0; 32]); + config.account.program_owner = [0; 8]; create_oracle_price_account( config, pool(), @@ -311,10 +325,15 @@ mod tests { #[test] #[should_panic(expected = "AMM Program must be initialized before use")] fn uninitialized_config_panics() { + // Owned by the AMM Program (passes the ownership gate) but carrying no AmmConfig data, so + // the config parse is what fails. let config = AccountWithMetadata { - account: Account::default(), + account: Account { + program_owner: AMM_PROGRAM_ID, + ..Account::default() + }, is_authorized: false, - account_id: compute_config_pda(AMM_PROGRAM_ID), + account_id: config_id(), }; create_oracle_price_account( config, diff --git a/programs/amm/src/create_price_observations.rs b/programs/amm/src/create_price_observations.rs index 1a350695..cae49f3c 100644 --- a/programs/amm/src/create_price_observations.rs +++ b/programs/amm/src/create_price_observations.rs @@ -1,6 +1,4 @@ -use amm_core::{ - compute_config_pda, compute_pool_pda, compute_pool_pda_seed, AmmConfig, PoolDefinition, -}; +use amm_core::{compute_pool_pda, compute_pool_pda_seed, AmmConfig, PoolDefinition}; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use lee_core::{ account::{Account, AccountWithMetadata}, @@ -49,9 +47,8 @@ pub fn create_price_observations( ) -> (Vec, Vec) { // Config gate: validate the config PDA and read the TWAP oracle program ID from it. assert_eq!( - config.account_id, - compute_config_pda(amm_program_id), - "Create price observations: AMM config Account ID does not match PDA" + config.account.program_owner, amm_program_id, + "Create price observations: AMM config account must be owned by the AMM Program" ); let twap_oracle_program_id = AmmConfig::try_from(&config.account.data) .expect("Create price observations: AMM Program must be initialized before use") @@ -72,6 +69,7 @@ pub fn create_price_observations( pool.account_id, compute_pool_pda( amm_program_id, + config.account_id, pool_def.definition_token_a_id, pool_def.definition_token_b_id, ), @@ -116,6 +114,7 @@ pub fn create_price_observations( }, ) .with_pda_seeds(vec![compute_pool_pda_seed( + config.account_id, pool_def.definition_token_a_id, pool_def.definition_token_b_id, )]); @@ -133,7 +132,7 @@ pub fn create_price_observations( #[cfg(test)] mod tests { - use amm_core::compute_pool_pda_seed; + use amm_core::{compute_config_pda, compute_pool_pda_seed}; use lee_core::account::{Account, AccountId, Data, Nonce}; use twap_oracle_core::compute_current_tick_account_pda; @@ -146,6 +145,16 @@ mod tests { const WINDOW_24H: u64 = 24 * 60 * 60 * 1_000; /// The authoritative tick stored in the pool's `CurrentTickAccount`. const CURRENT_TICK: i32 = -1_234; + /// Canonical test namespace: the owner that signs Initialize and the default (all-zero) nonce. + const TEST_NONCE: [u8; 32] = [0; 32]; + + fn amm_owner() -> AccountId { + AccountId::new([200; 32]) + } + + fn config_id() -> AccountId { + compute_config_pda(AMM_PROGRAM_ID, amm_owner(), TEST_NONCE) + } fn token_a_id() -> AccountId { AccountId::new([3; 32]) @@ -156,7 +165,7 @@ mod tests { } fn pool_id() -> AccountId { - compute_pool_pda(AMM_PROGRAM_ID, token_a_id(), token_b_id()) + compute_pool_pda(AMM_PROGRAM_ID, config_id(), token_a_id(), token_b_id()) } fn config_init() -> AccountWithMetadata { @@ -172,7 +181,7 @@ mod tests { nonce: Nonce(0), }, is_authorized: false, - account_id: compute_config_pda(AMM_PROGRAM_ID), + account_id: config_id(), } } @@ -280,7 +289,11 @@ mod tests { window_duration: WINDOW_24H, }, ) - .with_pda_seeds(vec![compute_pool_pda_seed(token_a_id(), token_b_id())]); + .with_pda_seeds(vec![compute_pool_pda_seed( + config_id(), + token_a_id(), + token_b_id(), + )]); assert_eq!(chained_calls[0], expected); } @@ -288,10 +301,10 @@ mod tests { // ── precondition violations ─────────────────────────────────────────────── #[test] - #[should_panic(expected = "AMM config Account ID does not match PDA")] - fn wrong_config_pda_panics() { + #[should_panic(expected = "must be owned by the AMM Program")] + fn config_not_owned_by_amm_panics() { let mut config = config_init(); - config.account_id = AccountId::new([0; 32]); + config.account.program_owner = [0; 8]; create_price_observations( config, pool(), @@ -306,10 +319,15 @@ mod tests { #[test] #[should_panic(expected = "AMM Program must be initialized before use")] fn uninitialized_config_panics() { + // Owned by the AMM Program (passes the ownership gate) but carrying no AmmConfig data, so + // the config parse is what fails. let config = AccountWithMetadata { - account: Account::default(), + account: Account { + program_owner: AMM_PROGRAM_ID, + ..Account::default() + }, is_authorized: false, - account_id: compute_config_pda(AMM_PROGRAM_ID), + account_id: config_id(), }; create_price_observations( config, diff --git a/programs/amm/src/initialize.rs b/programs/amm/src/initialize.rs index 30799e88..9fd01c7f 100644 --- a/programs/amm/src/initialize.rs +++ b/programs/amm/src/initialize.rs @@ -4,30 +4,44 @@ use lee_core::{ program::{AccountPostState, Claim, ProgramId}, }; -/// Initializes the AMM Program by creating its singleton configuration account. +/// Initializes a namespaced AMM instance by creating its configuration account. /// -/// The config account is a PDA derived from the constant `"CONFIG"` seed -/// (`compute_config_pda(amm_program_id)`) and stores the program IDs the AMM issues chained calls -/// to — `token_program_id` (the Token Program) and `twap_oracle_program_id` (the TWAP oracle) — -/// plus `authority` (the admin allowed to change configuration later via `update_config`). Its -/// existence is the Program's "initialized" flag: the chained-call instructions read these -/// program IDs from it and reject calls until it exists. +/// A single deployed AMM Program hosts many independent instances, each identified by a namespace +/// `(owner, nonce)`. `owner` is the account that signs this instruction; signing squat-proofs the +/// namespace, so nobody can create an instance (and set its program IDs) under an account they do +/// not control. `nonce` discriminates multiple instances under the same owner — the all-zero nonce +/// is the owner's default instance. +/// +/// The config account is a PDA derived as `compute_config_pda(amm_program_id, owner.account_id, +/// nonce)` and stores the program IDs the AMM issues chained calls to — `token_program_id` and +/// `twap_oracle_program_id` — plus `authority` (the admin allowed to change configuration later via +/// `update_config`). Its existence is the instance's "initialized" flag, and its account id is the +/// namespace root every pool and downstream PDA derives from. /// /// # 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). +/// - `owner.is_authorized` is false (the owner did not sign). +/// - `config.account_id` does not match `compute_config_pda(amm_program_id, owner.account_id, +/// nonce)`. +/// - `config.account` is not the default (the instance is already initialized). pub fn initialize( + owner: AccountWithMetadata, config: AccountWithMetadata, + nonce: [u8; 32], token_program_id: ProgramId, twap_oracle_program_id: ProgramId, authority: AccountId, amm_program_id: ProgramId, ) -> Vec { + assert!( + owner.is_authorized, + "Initialize: owner account must sign to claim its namespace" + ); + assert_eq!( config.account_id, - compute_config_pda(amm_program_id), - "Initialize: AMM config Account ID does not match PDA" + compute_config_pda(amm_program_id, owner.account_id, nonce), + "Initialize: AMM config Account ID does not match namespaced PDA" ); assert_eq!( config.account, @@ -42,74 +56,159 @@ pub fn initialize( authority, }); - vec![AccountPostState::new_claimed( - config_post, - Claim::Pda(compute_config_pda_seed()), - )] + // On first use the owner is a fresh EOA; the program claims it (the owner authorizes this by + // signing), binding the account to the AMM as a persistent namespace-owner marker. On every + // later `initialize` under the same owner (a different `nonce`) the owner is already AMM-owned, + // so it is echoed unchanged. This is what lets one owner open multiple instances. + // + // Consequence: the owner account becomes AMM-owned, so it must be a fresh, dedicated account + // (a pre-used wallet cannot be claimed) rather than an everyday wallet. + let owner_post = if owner.account == Account::default() { + AccountPostState::new_claimed(owner.account.clone(), Claim::Authorized) + } else { + assert_eq!( + owner.account.program_owner, amm_program_id, + "Initialize: owner must be a fresh account or an existing AMM namespace owner" + ); + AccountPostState::new(owner.account.clone()) + }; + + vec![ + owner_post, + AccountPostState::new_claimed( + config_post, + Claim::Pda(compute_config_pda_seed(owner.account_id, nonce)), + ), + ] } #[cfg(test)] mod tests { - use amm_core::compute_config_pda; - use lee_core::account::{AccountId, Nonce}; + use lee_core::account::Nonce; use super::*; const AMM_PROGRAM_ID: ProgramId = [42; 8]; const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; + const NONCE: [u8; 32] = [3; 32]; fn authority() -> AccountId { AccountId::new([9; 32]) } + fn owner_id() -> AccountId { + AccountId::new([5; 32]) + } + + fn owner_signed() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: owner_id(), + } + } + fn config_uninit() -> AccountWithMetadata { AccountWithMetadata { account: Account::default(), is_authorized: false, - account_id: compute_config_pda(AMM_PROGRAM_ID), + account_id: compute_config_pda(AMM_PROGRAM_ID, owner_id(), NONCE), } } - #[test] - fn returns_single_pda_claimed_post_state() { - let post_states = initialize( + fn run() -> Vec { + initialize( + owner_signed(), config_uninit(), + NONCE, TOKEN_PROGRAM_ID, TWAP_ORACLE_PROGRAM_ID, authority(), AMM_PROGRAM_ID, - ); - assert_eq!(post_states.len(), 1); + ) + } + + #[test] + fn fresh_owner_is_claimed_and_config_pda_claimed() { + let post_states = run(); + assert_eq!(post_states.len(), 2); + // 0: fresh owner claimed into the AMM (Authorized); 1: config claimed via its PDA seed. + assert_eq!(post_states[0].required_claim(), Some(Claim::Authorized)); assert_eq!( - post_states[0].required_claim(), - Some(Claim::Pda(compute_config_pda_seed())) + post_states[1].required_claim(), + Some(Claim::Pda(compute_config_pda_seed(owner_id(), NONCE))) ); } + /// A second instance under the same owner: the owner is already AMM-owned, so it is echoed + /// unchanged (no claim) — this is what makes multi-instance-per-owner work. #[test] - fn stores_program_ids_and_authority() { + fn already_owned_owner_is_echoed_for_next_instance() { + let mut amm_owned = owner_signed(); + amm_owned.account.program_owner = AMM_PROGRAM_ID; + amm_owned.account.nonce = Nonce(1); let post_states = initialize( - config_uninit(), + amm_owned, + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: compute_config_pda(AMM_PROGRAM_ID, owner_id(), [1; 32]), + }, + [1; 32], TOKEN_PROGRAM_ID, TWAP_ORACLE_PROGRAM_ID, authority(), AMM_PROGRAM_ID, ); - let config = AmmConfig::try_from(&post_states[0].account().data) + assert_eq!(post_states[0].required_claim(), None); + assert_eq!(post_states[0].account().program_owner, AMM_PROGRAM_ID); + } + + #[test] + fn stores_program_ids_and_authority() { + let post_states = run(); + let config = AmmConfig::try_from(&post_states[1].account().data) .expect("post state must contain a valid AmmConfig"); assert_eq!(config.token_program_id, TOKEN_PROGRAM_ID); assert_eq!(config.twap_oracle_program_id, TWAP_ORACLE_PROGRAM_ID); assert_eq!(config.authority, authority()); } + /// A different nonce is a different instance: same owner, distinct config PDA. + #[test] + fn distinct_nonce_yields_distinct_config() { + assert_ne!( + compute_config_pda(AMM_PROGRAM_ID, owner_id(), [0; 32]), + compute_config_pda(AMM_PROGRAM_ID, owner_id(), [1; 32]), + ); + } + + #[test] + #[should_panic(expected = "owner account must sign")] + fn unauthorized_owner_panics() { + let mut unsigned = owner_signed(); + unsigned.is_authorized = false; + initialize( + unsigned, + config_uninit(), + NONCE, + TOKEN_PROGRAM_ID, + TWAP_ORACLE_PROGRAM_ID, + authority(), + AMM_PROGRAM_ID, + ); + } + #[test] - #[should_panic(expected = "AMM config Account ID does not match PDA")] + #[should_panic(expected = "does not match namespaced PDA")] fn wrong_config_account_id_panics() { let mut wrong = config_uninit(); wrong.account_id = AccountId::new([0; 32]); initialize( + owner_signed(), wrong, + NONCE, TOKEN_PROGRAM_ID, TWAP_ORACLE_PROGRAM_ID, authority(), @@ -128,7 +227,9 @@ mod tests { }); initialized.account.nonce = Nonce(0); initialize( + owner_signed(), initialized, + NONCE, TOKEN_PROGRAM_ID, TWAP_ORACLE_PROGRAM_ID, authority(), diff --git a/programs/amm/src/new_definition.rs b/programs/amm/src/new_definition.rs index f0ce8eba..c1dcd4a4 100644 --- a/programs/amm/src/new_definition.rs +++ b/programs/amm/src/new_definition.rs @@ -1,11 +1,10 @@ use std::num::NonZeroU128; use amm_core::{ - 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, isqrt_product, spot_price_q64_64, AmmConfig, PoolDefinition, - MINIMUM_LIQUIDITY, + 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, isqrt_product, + spot_price_q64_64, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; use lee_core::{ @@ -46,9 +45,8 @@ pub fn new_definition( // 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), - "New definition: AMM config Account ID does not match PDA" + config.account.program_owner, amm_program_id, + "New definition: AMM config account must be owned by the AMM Program" ); let config_data = AmmConfig::try_from(&config.account.data) .expect("New definition: AMM Program must be initialized before use"); @@ -70,7 +68,12 @@ pub fn new_definition( ); assert_eq!( pool.account_id, - compute_pool_pda(amm_program_id, definition_token_a_id, definition_token_b_id), + compute_pool_pda( + amm_program_id, + config.account_id, + definition_token_a_id, + definition_token_b_id + ), "Pool Definition Account ID does not match PDA" ); assert_eq!( @@ -147,6 +150,7 @@ pub fn new_definition( let pool_post: AccountPostState = AccountPostState::new_claimed( pool_initialized.clone(), Claim::Pda(compute_pool_pda_seed( + config.account_id, definition_token_a_id, definition_token_b_id, )), @@ -245,6 +249,7 @@ pub fn new_definition( &twap_oracle_core::Instruction::CreateCurrentTickAccount { initial_price }, ) .with_pda_seeds(vec![compute_pool_pda_seed( + config.account_id, definition_token_a_id, definition_token_b_id, )]); diff --git a/programs/amm/src/remove.rs b/programs/amm/src/remove.rs index 8e3e2bf7..2dd8e789 100644 --- a/programs/amm/src/remove.rs +++ b/programs/amm/src/remove.rs @@ -1,7 +1,7 @@ use std::num::NonZeroU128; use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_liquidity_token_pda_seed, + assert_supported_fee_tier, compute_liquidity_token_pda_seed, compute_pool_pda, compute_pool_pda_seed, compute_vault_pda_seed, mul_div_floor, spot_price_q64_64, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, }; @@ -37,9 +37,8 @@ pub fn remove_liquidity( // The program IDs are 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" + config.account.program_owner, amm_program_id, + "Remove liquidity: AMM config account must be owned by the AMM Program" ); let config_data = AmmConfig::try_from(&config.account.data) .expect("Remove liquidity: AMM Program must be initialized before use"); @@ -49,6 +48,23 @@ pub fn remove_liquidity( // 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"); + + // The pool must be derived under THIS config's namespace. config.account_id is the + // namespace root, so a pool belonging to another instance — even a valid AMM-owned + // pool with the same token pair — derives a different PDA and is rejected here. This + // stops a caller from pairing a config from one instance with a pool from another + // (matching the check new_definition makes when it creates the pool). + assert_eq!( + pool.account_id, + compute_pool_pda( + amm_program_id, + config.account_id, + pool_def_data.definition_token_a_id, + pool_def_data.definition_token_b_id, + ), + "Remove liquidity: pool account is not derived under this config's namespace" + ); + assert_supported_fee_tier(pool_def_data.fees); assert!( @@ -260,6 +276,7 @@ pub fn remove_liquidity( &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, ) .with_pda_seeds(vec![compute_pool_pda_seed( + config.account_id, pool_def_data.definition_token_a_id, pool_def_data.definition_token_b_id, )]); diff --git a/programs/amm/src/swap.rs b/programs/amm/src/swap.rs index 4de03982..4b5d0481 100644 --- a/programs/amm/src/swap.rs +++ b/programs/amm/src/swap.rs @@ -1,5 +1,5 @@ use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_pool_pda_seed, + assert_supported_fee_tier, compute_pool_pda, compute_pool_pda_seed, read_vault_fungible_balances, spot_price_q64_64, swap_exact_in_amounts, swap_exact_out_amounts, AmmConfig, MINIMUM_LIQUIDITY, }; @@ -49,6 +49,29 @@ fn validate_swap_setup( pool_def_data } +/// The pool must be derived under THIS config's namespace. config.account_id is the +/// namespace root, so a pool belonging to another instance — even a valid AMM-owned pool +/// with the same token pair — derives a different PDA and is rejected here. This stops a +/// caller from pairing a config from one instance with a pool from another (matching the +/// check new_definition makes when it creates the pool). +fn assert_pool_in_config_namespace( + pool: &AccountWithMetadata, + config: &AccountWithMetadata, + pool_def_data: &PoolDefinition, + amm_program_id: ProgramId, +) { + assert_eq!( + pool.account_id, + compute_pool_pda( + amm_program_id, + config.account_id, + pool_def_data.definition_token_a_id, + pool_def_data.definition_token_b_id, + ), + "Swap: pool account is not derived under this config's namespace" + ); +} + /// Assembles the swap post-states (including the echoed current-tick and clock accounts) and the /// chained call that refreshes the pool's TWAP current tick from the post-swap spot price. #[expect( @@ -117,6 +140,7 @@ fn finalize_swap( &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, ) .with_pda_seeds(vec![compute_pool_pda_seed( + config.account_id, pool_def_data.definition_token_a_id, pool_def_data.definition_token_b_id, )]); @@ -158,14 +182,14 @@ pub fn swap_exact_input( // The program IDs are 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" + config.account.program_owner, amm_program_id, + "Swap exact input: AMM config account must be owned by the AMM Program" ); let config_data = AmmConfig::try_from(&config.account.data) .expect("Swap exact input: AMM Program must be initialized before use"); let token_program_id = config_data.token_program_id; let twap_oracle_program_id = config_data.twap_oracle_program_id; + assert_pool_in_config_namespace(&pool, &config, &pool_def_data, amm_program_id); assert_eq!( vault_a.account.program_owner, token_program_id, "Vault A must be owned by the configured Token Program" @@ -372,14 +396,14 @@ pub fn swap_exact_output( // The program IDs are 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" + config.account.program_owner, amm_program_id, + "Swap exact output: AMM config account must be owned by the AMM Program" ); let config_data = AmmConfig::try_from(&config.account.data) .expect("Swap exact output: AMM Program must be initialized before use"); let token_program_id = config_data.token_program_id; let twap_oracle_program_id = config_data.twap_oracle_program_id; + assert_pool_in_config_namespace(&pool, &config, &pool_def_data, amm_program_id); assert_eq!( vault_a.account.program_owner, token_program_id, "Vault A must be owned by the configured Token Program" diff --git a/programs/amm/src/sync.rs b/programs/amm/src/sync.rs index 91da4e42..22f042fb 100644 --- a/programs/amm/src/sync.rs +++ b/programs/amm/src/sync.rs @@ -1,5 +1,5 @@ use amm_core::{ - assert_supported_fee_tier, compute_config_pda, compute_pool_pda_seed, + assert_supported_fee_tier, compute_pool_pda, compute_pool_pda_seed, read_vault_fungible_balances, spot_price_q64_64, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, }; use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; @@ -25,14 +25,29 @@ pub fn sync_reserves( // The TWAP oracle program ID is taken from the config account. Validating the config PDA is // also the Program's initialization gate. assert_eq!( - config.account_id, - compute_config_pda(amm_program_id), - "Sync reserves: AMM config Account ID does not match PDA" + config.account.program_owner, amm_program_id, + "Sync reserves: AMM config account must be owned by the AMM Program" ); let twap_oracle_program_id = AmmConfig::try_from(&config.account.data) .expect("Sync reserves: AMM Program must be initialized before use") .twap_oracle_program_id; + // The pool must be derived under THIS config's namespace. config.account_id is the + // namespace root, so a pool belonging to another instance — even a valid AMM-owned + // pool with the same token pair — derives a different PDA and is rejected here. This + // stops a caller from pairing any AMM-owned config with an arbitrary pool (matching + // the check new_definition makes when it creates the pool). + assert_eq!( + pool.account_id, + compute_pool_pda( + amm_program_id, + config.account_id, + pool_def_data.definition_token_a_id, + pool_def_data.definition_token_b_id, + ), + "Sync reserves: pool account is not derived under this config's namespace" + ); + assert!( pool_def_data.liquidity_pool_supply >= MINIMUM_LIQUIDITY, "Pool liquidity supply is below minimum liquidity" @@ -94,6 +109,7 @@ pub fn sync_reserves( &twap_oracle_core::Instruction::UpdateCurrentTick { price: new_price }, ) .with_pda_seeds(vec![compute_pool_pda_seed( + config.account_id, pool_def_data.definition_token_a_id, pool_def_data.definition_token_b_id, )]); diff --git a/programs/amm/src/tests.rs b/programs/amm/src/tests.rs index 2a8fee2e..8274c0de 100644 --- a/programs/amm/src/tests.rs +++ b/programs/amm/src/tests.rs @@ -32,6 +32,10 @@ const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; const AMM_PROGRAM_ID: ProgramId = [42; 8]; const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; const MALICIOUS_TOKEN_PROGRAM_ID: ProgramId = [99; 8]; +/// Canonical test namespace: the owner that signs Initialize and the default (all-zero) nonce. +/// Every pool/vault/config fixture derives from `IdForTests::config_id()`, the config PDA of this +/// `(owner, nonce)` instance. +const TEST_NONCE: [u8; 32] = [0; 32]; struct BalanceForTests; struct ChainedCallForTests; @@ -589,6 +593,7 @@ impl ChainedCallForTests { &twap_oracle_core::Instruction::CreateCurrentTickAccount { initial_price }, ) .with_pda_seeds(vec![compute_pool_pda_seed( + IdForTests::config_id(), IdForTests::token_a_definition_id(), IdForTests::token_b_definition_id(), )]) @@ -596,6 +601,14 @@ impl ChainedCallForTests { } impl IdForTests { + fn amm_owner() -> AccountId { + AccountId::new([200; 32]) + } + + fn config_id() -> AccountId { + compute_config_pda(AMM_PROGRAM_ID, IdForTests::amm_owner(), TEST_NONCE) + } + fn token_a_definition_id() -> AccountId { AccountId::new([42; 32]) } @@ -627,6 +640,7 @@ impl IdForTests { fn pool_definition_id() -> AccountId { compute_pool_pda( AMM_PROGRAM_ID, + IdForTests::config_id(), IdForTests::token_a_definition_id(), IdForTests::token_b_definition_id(), ) @@ -663,23 +677,28 @@ impl AccountWithMetadataForTests { nonce: Nonce(0), }, is_authorized: false, - account_id: compute_config_pda(AMM_PROGRAM_ID), + account_id: IdForTests::config_id(), } } - /// Config PDA that has never been initialized (default, empty data). + /// Config PDA owned by the AMM Program but never initialized (empty data), so it fails to parse + /// as an `AmmConfig` — the "AMM Program must be initialized before use" case. fn config_uninit() -> AccountWithMetadata { AccountWithMetadata { - account: Account::default(), + account: Account { + program_owner: AMM_PROGRAM_ID, + ..Account::default() + }, is_authorized: false, - account_id: compute_config_pda(AMM_PROGRAM_ID), + account_id: IdForTests::config_id(), } } - /// An initialized config carrying valid data but stored at the wrong account ID. - fn config_with_wrong_id() -> AccountWithMetadata { + /// An otherwise-valid config account that is not owned by the AMM Program, exercising the + /// program-owner gate (`must be owned by the AMM Program`). + fn config_not_owned_by_amm() -> AccountWithMetadata { let mut config = AccountWithMetadataForTests::config_init(); - config.account_id = AccountId::new([7; 32]); + config.account.program_owner = [0; 8]; config } @@ -1502,10 +1521,12 @@ fn test_pool_pda_produces_unique_id_for_token_pair() { assert!( amm_core::compute_pool_pda( AMM_PROGRAM_ID, + IdForTests::config_id(), IdForTests::token_a_definition_id(), IdForTests::token_b_definition_id() ) == compute_pool_pda( AMM_PROGRAM_ID, + IdForTests::config_id(), IdForTests::token_b_definition_id(), IdForTests::token_a_definition_id() ) @@ -1575,6 +1596,30 @@ fn test_call_add_liquidity_lp_definition_mismatch() { ); } +// A pool with the right token pair but an id NOT derived under config_init's namespace +// (pool_definition_with_wrong_id uses a bogus account id) must be rejected, so a config +// from one instance can't be paired with a pool from another. +#[should_panic(expected = "pool account is not derived under this config's namespace")] +#[test] +fn test_call_add_liquidity_pool_outside_config_namespace() { + let _post_states = add_liquidity( + AccountWithMetadataForTests::config_init(), + AccountWithMetadataForTests::pool_definition_with_wrong_id(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), + NonZero::new(BalanceForTests::add_min_amount_lp()).unwrap(), + BalanceForTests::add_max_amount_a(), + BalanceForTests::add_max_amount_b(), + AMM_PROGRAM_ID, + ); +} + #[should_panic(expected = "Both max-balances must be nonzero")] #[test] fn test_call_add_liquidity_zero_balance_1() { @@ -1872,11 +1917,11 @@ fn test_call_add_liquidity_uninitialized_config_panics() { ); } -#[should_panic(expected = "AMM config Account ID does not match PDA")] +#[should_panic(expected = "must be owned by the AMM Program")] #[test] -fn test_call_add_liquidity_wrong_config_pda_panics() { +fn test_call_add_liquidity_config_not_owned_by_amm_panics() { let _post_states = add_liquidity( - AccountWithMetadataForTests::config_with_wrong_id(), + AccountWithMetadataForTests::config_not_owned_by_amm(), AccountWithMetadataForTests::pool_definition_init(), AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), @@ -1914,6 +1959,29 @@ fn test_call_remove_liquidity_vault_a_omitted() { ); } +// A pool with the right token pair but an id NOT derived under config_init's namespace +// must be rejected, so a config from one instance can't be paired with a pool from another. +#[should_panic(expected = "pool account is not derived under this config's namespace")] +#[test] +fn test_call_remove_liquidity_pool_outside_config_namespace() { + let _post_states = remove_liquidity( + AccountWithMetadataForTests::config_init(), + AccountWithMetadataForTests::pool_definition_with_wrong_id(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::pool_lp_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::user_holding_lp_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), + NonZero::new(BalanceForTests::remove_amount_lp()).unwrap(), + BalanceForTests::remove_min_amount_a(), + BalanceForTests::remove_min_amount_b(), + AMM_PROGRAM_ID, + ); +} + #[should_panic(expected = "Vault B was not provided")] #[test] fn test_call_remove_liquidity_vault_b_omitted() { @@ -2400,6 +2468,7 @@ fn test_call_new_definition_chained_call_successful() { assert_eq!( pool_post.required_claim(), Some(Claim::Pda(compute_pool_pda_seed( + IdForTests::config_id(), IdForTests::token_a_definition_id(), IdForTests::token_b_definition_id(), ))) @@ -2424,6 +2493,26 @@ fn test_call_new_definition_chained_call_successful() { assert_eq!(post_states.len(), 11); } +// A pool with the right token pair but an id NOT derived under config_init's namespace +// must be rejected, so a config from one instance can't be paired with a pool from another. +#[should_panic(expected = "pool account is not derived under this config's namespace")] +#[test] +fn test_call_swap_pool_outside_config_namespace() { + let _post_states = swap_exact_input( + AccountWithMetadataForTests::config_init(), + AccountWithMetadataForTests::pool_definition_with_wrong_id(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), + BalanceForTests::add_max_amount_a(), + BalanceForTests::min_amount_out(), + AMM_PROGRAM_ID, + ); +} + #[should_panic(expected = "Swap exact input: input holding token is not part of the pool")] #[test] fn test_call_swap_incorrect_token_type() { @@ -2685,6 +2774,7 @@ fn assert_update_tick_call(chained_calls: &[ChainedCall], pool_post_account: &Ac }, ) .with_pda_seeds(vec![compute_pool_pda_seed( + IdForTests::config_id(), IdForTests::token_a_definition_id(), IdForTests::token_b_definition_id(), )]); @@ -3432,6 +3522,22 @@ fn test_sync_reserves_with_donation() { assert_update_tick_call(&chained_calls, post_states[1].account()); } +// A pool with the right token pair but an id NOT derived under config_init's namespace +// must be rejected, so any AMM-owned config can't be paired with an arbitrary pool. +#[should_panic(expected = "pool account is not derived under this config's namespace")] +#[test] +fn test_sync_reserves_pool_outside_config_namespace() { + let _ = sync_reserves( + AccountWithMetadataForTests::config_init(), + AccountWithMetadataForTests::pool_definition_with_wrong_id(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::current_tick_account_uninit(), + AccountWithMetadataForTests::clock(), + AMM_PROGRAM_ID, + ); +} + #[should_panic(expected = "Sync reserves: vault A balance is less than its reserve")] #[test] fn test_sync_reserves_panics_when_vault_a_under_collateralized() { diff --git a/programs/amm/src/update_config.rs b/programs/amm/src/update_config.rs index bf7ca85d..15ba6d07 100644 --- a/programs/amm/src/update_config.rs +++ b/programs/amm/src/update_config.rs @@ -1,4 +1,4 @@ -use amm_core::{compute_config_pda, AmmConfig}; +use amm_core::AmmConfig; use lee_core::{ account::{AccountId, AccountWithMetadata, Data}, program::{AccountPostState, ProgramId}, @@ -27,9 +27,8 @@ pub fn update_config( 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" + config.account.program_owner, amm_program_id, + "Update config: AMM config account must be owned by the AMM Program" ); let mut config_data = AmmConfig::try_from(&config.account.data) .expect("Update config: AMM Program must be initialized before use"); @@ -57,6 +56,7 @@ pub fn update_config( #[cfg(test)] mod tests { + use amm_core::compute_config_pda; use lee_core::account::{Account, Nonce}; use super::*; @@ -64,6 +64,16 @@ mod tests { const AMM_PROGRAM_ID: ProgramId = [42; 8]; const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; const TWAP_ORACLE_PROGRAM_ID: ProgramId = [77; 8]; + /// Canonical test namespace: the owner that signs Initialize and the default (all-zero) nonce. + const TEST_NONCE: [u8; 32] = [0; 32]; + + fn amm_owner() -> AccountId { + AccountId::new([200; 32]) + } + + fn config_id() -> AccountId { + compute_config_pda(AMM_PROGRAM_ID, amm_owner(), TEST_NONCE) + } fn admin_id() -> AccountId { AccountId::new([9; 32]) @@ -86,7 +96,7 @@ mod tests { nonce: Nonce(0), }, is_authorized: false, - account_id: compute_config_pda(AMM_PROGRAM_ID), + account_id: config_id(), } } @@ -138,20 +148,25 @@ mod tests { // ── precondition violations ─────────────────────────────────────────────── #[test] - #[should_panic(expected = "AMM config Account ID does not match PDA")] - fn wrong_config_pda_panics() { + #[should_panic(expected = "must be owned by the AMM Program")] + fn config_not_owned_by_amm_panics() { let mut config = config_init(); - config.account_id = AccountId::new([0; 32]); + config.account.program_owner = [0; 8]; update_config(config, admin_authorized(), new_admin_id(), AMM_PROGRAM_ID); } #[test] #[should_panic(expected = "AMM Program must be initialized before use")] fn uninitialized_config_panics() { + // Owned by the AMM Program (passes the ownership gate) but carrying no AmmConfig data, so + // the config parse is what fails. let config = AccountWithMetadata { - account: Account::default(), + account: Account { + program_owner: AMM_PROGRAM_ID, + ..Account::default() + }, is_authorized: false, - account_id: compute_config_pda(AMM_PROGRAM_ID), + account_id: config_id(), }; update_config(config, admin_authorized(), new_admin_id(), AMM_PROGRAM_ID); } diff --git a/programs/integration_tests/tests/amm.rs b/programs/integration_tests/tests/amm.rs index e1ae1006..e887107a 100644 --- a/programs/integration_tests/tests/amm.rs +++ b/programs/integration_tests/tests/amm.rs @@ -21,7 +21,20 @@ struct Ids; struct Balances; struct Accounts; +/// Canonical test namespace nonce: the owner's default (all-zero) AMM instance. +const TEST_NONCE: [u8; 32] = [0; 32]; + impl Keys { + /// Signing key for the account that owns the canonical test AMM instance's namespace. + fn amm_owner() -> PrivateKey { + PrivateKey::try_new([30; 32]).expect("valid private key") + } + + /// Signing key for a second, independent AMM instance owner (namespace isolation test). + fn amm_owner_b() -> PrivateKey { + PrivateKey::try_new([35; 32]).expect("valid private key") + } + fn user_a() -> PrivateKey { PrivateKey::try_new([31; 32]).expect("valid private key") } @@ -52,8 +65,18 @@ impl Ids { twap_oracle_methods::TWAP_ORACLE_ID } + /// The account that owns (and signs for) the canonical test AMM instance's namespace. + fn amm_owner() -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(&Keys::amm_owner())) + } + + /// A second, independent AMM instance owner (namespace isolation test). + fn amm_owner_b() -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(&Keys::amm_owner_b())) + } + fn config() -> AccountId { - amm_core::compute_config_pda(Self::amm_program()) + amm_core::compute_config_pda(Self::amm_program(), Self::amm_owner(), TEST_NONCE) } fn price_observations(window_duration: u64) -> AccountId { @@ -90,6 +113,7 @@ impl Ids { fn pool_definition() -> AccountId { amm_core::compute_pool_pda( Self::amm_program(), + Self::config(), Self::token_a_definition(), Self::token_b_definition(), ) @@ -1428,7 +1452,28 @@ fn execute_remove_liquidity( #[cfg(test)] fn execute_initialize(state: &mut V03State) { + execute_initialize_for( + state, + &Keys::amm_owner(), + TEST_NONCE, + amm_core::compute_config_pda(Ids::amm_program(), Ids::amm_owner(), TEST_NONCE), + ) + .unwrap(); +} + +/// Initializes a namespaced AMM instance keyed by `(owner, nonce)`. The owner is the first, +/// signing account (its key squat-proofs the namespace); `config_id` is the instance's config PDA +/// derived from that namespace. Matches the guest account order `[owner (signer), config (init)]`. +#[cfg(test)] +fn execute_initialize_for( + state: &mut V03State, + owner_key: &PrivateKey, + nonce: [u8; 32], + config_id: AccountId, +) -> Result<(), LeeError> { + let owner_id = AccountId::from(&PublicKey::new_from_private_key(owner_key)); let instruction = amm_core::Instruction::Initialize { + nonce, token_program_id: Ids::token_program(), twap_oracle_program_id: Ids::twap_oracle_program(), authority: Ids::admin(), @@ -1436,16 +1481,116 @@ fn execute_initialize(state: &mut V03State) { let message = public_transaction::Message::try_new( Ids::amm_program(), - vec![Ids::config()], - vec![], + vec![owner_id, config_id], + vec![current_nonce(state, owner_id)], instruction, ) .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[owner_key]); let tx = PublicTransaction::new(message, witness_set); - state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + state.transition_from_public_transaction(&tx, 0, 0) +} + +/// The full set of PDAs an AMM instance (`(owner, nonce)` namespace) derives for the canonical +/// A/B token pair. Only `config` and `pool` carry the namespace directly; everything downstream +/// inherits it through `pool`. +#[cfg(test)] +struct Namespace { + config: AccountId, + pool: AccountId, + vault_a: AccountId, + vault_b: AccountId, + lp_def: AccountId, + lp_lock: AccountId, + current_tick: AccountId, +} + +#[cfg(test)] +fn namespace_for(owner: AccountId, nonce: [u8; 32]) -> Namespace { + let config = amm_core::compute_config_pda(Ids::amm_program(), owner, nonce); + let pool = amm_core::compute_pool_pda( + Ids::amm_program(), + config, + Ids::token_a_definition(), + Ids::token_b_definition(), + ); + Namespace { + config, + pool, + vault_a: amm_core::compute_vault_pda(Ids::amm_program(), pool, Ids::token_a_definition()), + vault_b: amm_core::compute_vault_pda(Ids::amm_program(), pool, Ids::token_b_definition()), + lp_def: amm_core::compute_liquidity_token_pda(Ids::amm_program(), pool), + lp_lock: amm_core::compute_lp_lock_holding_pda(Ids::amm_program(), pool), + current_tick: twap_oracle_core::compute_current_tick_account_pda( + Ids::twap_oracle_program(), + pool, + ), + } +} + +/// Creates the A/B pool of a specific namespace via `NewDefinition`, minting the initial LP to +/// `lp_key`'s account. Mirrors [`try_execute_new_definition`] but targets an arbitrary instance's +/// PDAs so the isolation test can populate two instances independently. +#[cfg(test)] +fn execute_new_definition_in( + state: &mut V03State, + ns: &Namespace, + lp_key: &PrivateKey, +) -> Result<(), LeeError> { + let lp_id = AccountId::from(&PublicKey::new_from_private_key(lp_key)); + let instruction = amm_core::Instruction::NewDefinition { + token_a_amount: Balances::vault_a_init(), + token_b_amount: Balances::vault_b_init(), + fees: Balances::fee_tier(), + deadline: u64::MAX, + }; + + let message = public_transaction::Message::try_new( + Ids::amm_program(), + vec![ + ns.config, + ns.pool, + ns.vault_a, + ns.vault_b, + ns.lp_def, + ns.lp_lock, + Ids::user_a(), + Ids::user_b(), + lp_id, + ns.current_tick, + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + vec![ + current_nonce(state, Ids::user_a()), + current_nonce(state, Ids::user_b()), + current_nonce(state, lp_id), + ], + instruction, + ) + .unwrap(); + + let witness_set = public_transaction::WitnessSet::for_message( + &message, + &[&Keys::user_a(), &Keys::user_b(), lp_key], + ); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, 0, 0) +} + +/// Builds a fungible token holding account for `definition_id` with the given balance. +#[cfg(test)] +fn fungible_holding(definition_id: AccountId, balance: u128) -> Account { + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + nonce: Nonce(0), + } } #[cfg(test)] @@ -1635,6 +1780,166 @@ fn amm_initialize_creates_config_account() { assert_eq!(config.authority, Ids::admin()); } +/// One owner opens multiple isolated instances via distinct `nonce`s. The first initialize claims +/// the fresh owner into the AMM; the second, under the same (now AMM-owned) owner, echoes it +/// unchanged — both must succeed end-to-end through the guest. +#[test] +fn amm_same_owner_multiple_instances_via_nonce() { + let mut state = V03State::new(); + deploy_programs(&mut state); + + let owner_key = Keys::amm_owner(); + let owner_id = Ids::amm_owner(); + let amm = Ids::amm_program(); + + let cfg0 = amm_core::compute_config_pda(amm, owner_id, [0; 32]); + let cfg1 = amm_core::compute_config_pda(amm, owner_id, [1; 32]); + assert_ne!( + cfg0, cfg1, + "different nonces must yield different config PDAs" + ); + + // First instance: owner is a fresh EOA → initialize claims it into the AMM. + execute_initialize_for(&mut state, &owner_key, [0; 32], cfg0) + .expect("first initialize (fresh owner) must succeed"); + assert_eq!( + state.get_account_by_id(owner_id).program_owner, + amm, + "owner should be owned by the AMM after the first initialize" + ); + + // Second instance under the SAME owner, different nonce: the owner is now AMM-owned and is + // echoed unchanged. This is the case that previously failed and must now pass. + execute_initialize_for(&mut state, &owner_key, [1; 32], cfg1) + .expect("second initialize (same owner, new nonce) must succeed"); + + // Both instances exist, are AMM-owned, and are independent. + assert_eq!(state.get_account_by_id(cfg0).program_owner, amm); + assert_eq!(state.get_account_by_id(cfg1).program_owner, amm); + let c0 = amm_core::AmmConfig::try_from(&state.get_account_by_id(cfg0).data) + .expect("instance 0 config must decode"); + let c1 = amm_core::AmmConfig::try_from(&state.get_account_by_id(cfg1).data) + .expect("instance 1 config must decode"); + assert_eq!(c0.token_program_id, Ids::token_program()); + assert_eq!(c1.token_program_id, Ids::token_program()); +} + +#[test] +fn amm_initialize_requires_owner_signature() { + // Squat-resistance: nobody can claim a namespace (and set its program IDs) under an owner + // account they do not control. Declaring the owner without signing for it must be rejected. + let mut state = V03State::new(); + deploy_programs(&mut state); + + let owner_id = Ids::amm_owner(); + let config_id = amm_core::compute_config_pda(Ids::amm_program(), owner_id, TEST_NONCE); + let instruction = amm_core::Instruction::Initialize { + nonce: TEST_NONCE, + token_program_id: Ids::token_program(), + twap_oracle_program_id: Ids::twap_oracle_program(), + authority: Ids::admin(), + }; + + // The owner account is declared, but no signature (and no nonce) is supplied for it. + let message = public_transaction::Message::try_new( + Ids::amm_program(), + vec![owner_id, config_id], + vec![], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + assert!(matches!( + state.transition_from_public_transaction(&tx, 0, 0), + Err(LeeError::ProgramExecutionFailed(_)) + )); + + // The config PDA was not created. + assert_eq!(state.get_account_by_id(config_id), Account::default()); +} + +#[test] +fn amm_two_namespaces_same_token_pair_are_isolated() { + // One deployment, one token pair, two different owners → two fully independent AMM instances. + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account( + Ids::token_a_definition(), + Accounts::token_a_definition_account(), + ); + state.force_insert_account( + Ids::token_b_definition(), + Accounts::token_b_definition_account(), + ); + // Fund the shared depositors generously enough to seed both pools and still swap afterwards. + state.force_insert_account( + Ids::user_a(), + fungible_holding(Ids::token_a_definition(), 30_000), + ); + state.force_insert_account( + Ids::user_b(), + fungible_holding(Ids::token_b_definition(), 30_000), + ); + advance_clock(&mut state, 0); + + let ns0 = namespace_for(Ids::amm_owner(), TEST_NONCE); + let ns1 = namespace_for(Ids::amm_owner_b(), TEST_NONCE); + + // Every root and derived PDA differs between the two instances. + assert_ne!(ns0.config, ns1.config); + assert_ne!(ns0.pool, ns1.pool); + assert_ne!(ns0.vault_a, ns1.vault_a); + assert_ne!(ns0.vault_b, ns1.vault_b); + assert_ne!(ns0.lp_def, ns1.lp_def); + assert_ne!(ns0.current_tick, ns1.current_tick); + // Instance 0 is the canonical namespace the standard helpers target. + assert_eq!(ns0.config, Ids::config()); + assert_eq!(ns0.pool, Ids::pool_definition()); + + execute_initialize_for(&mut state, &Keys::amm_owner(), TEST_NONCE, ns0.config).unwrap(); + execute_initialize_for(&mut state, &Keys::amm_owner_b(), TEST_NONCE, ns1.config).unwrap(); + + // Both configs exist as distinct AMM-owned accounts. + assert_eq!( + state.get_account_by_id(ns0.config).program_owner, + Ids::amm_program() + ); + assert_eq!( + state.get_account_by_id(ns1.config).program_owner, + Ids::amm_program() + ); + assert_ne!(state.get_account_by_id(ns0.config), Account::default()); + + // Create the same A/B pool in each instance (distinct LP recipients: they hold distinct LP + // definitions). + execute_new_definition_in(&mut state, &ns0, &Keys::user_lp()).unwrap(); + execute_new_definition_in(&mut state, &ns1, &Keys::admin()).unwrap(); + + // Both pools now exist and are separate accounts holding the same opening reserves. + let ns0_pool_created = pool_definition(&state.get_account_by_id(ns0.pool)); + let ns1_pool_created = pool_definition(&state.get_account_by_id(ns1.pool)); + assert_eq!(ns0_pool_created.reserve_a, Balances::vault_a_init()); + assert_eq!(ns1_pool_created.reserve_a, Balances::vault_a_init()); + + // Snapshot instance 1 before operating on instance 0. + let ns1_pool_before = state.get_account_by_id(ns1.pool); + let ns1_vault_a_before = state.get_account_by_id(ns1.vault_a); + let ns1_vault_b_before = state.get_account_by_id(ns1.vault_b); + + // Swap in instance 0 only. + execute_swap_a_to_b(&mut state, 1_000, 1); + + // Instance 0's reserves moved… + let ns0_pool_after = pool_definition(&state.get_account_by_id(ns0.pool)); + assert_ne!(ns0_pool_after.reserve_a, ns0_pool_created.reserve_a); + // …while instance 1's pool and vaults are byte-for-byte untouched. + assert_eq!(state.get_account_by_id(ns1.pool), ns1_pool_before); + assert_eq!(state.get_account_by_id(ns1.vault_a), ns1_vault_a_before); + assert_eq!(state.get_account_by_id(ns1.vault_b), ns1_vault_b_before); +} + #[cfg(test)] fn execute_update_config( state: &mut V03State,