From 1ed20e319d1eda8038c00dd14302d006e9206d3f Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:02:43 +0200 Subject: [PATCH] feat(amm)!: namespace all PDAs to host many AMM instances per deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A LEZ program can only be deployed once, so a single AMM deployment previously had one singleton config and one pool per token pair. Namespace the whole system so independent teams can run isolated AMMs under one deployment. Only the two root PDAs carry the namespace; everything downstream inherits isolation via pool_id: namespace = (owner, nonce) config_id = PDA(amm, hash("CONFIG" || owner || nonce)) pool_id = PDA(amm, config_id, sorted(token_a, token_b)) vault / lp_token / lp_lock / twap (unchanged; derived from pool_id) BREAKING CHANGES: * Initialize gains an `owner` account and a `nonce` argument. `owner` must sign (this squat-proofs the namespace) and is now WRITABLE — the AMM claims it on first use, so it must be a fresh, dedicated account, not a reused wallet. `nonce` selects the instance under that owner (all-zero = the owner's default instance). There is no longer a single fixed config address. * All AMM PDA addresses change. Both root PDAs are re-derived and every downstream account (vaults, LP token, LP lock, TWAP tick / price observations / oracle price) moves with the new pool_id. Any persisted or hardcoded AMM account addresses are invalid and must be re-derived. * amm_core PDA helper signatures changed: compute_config_pda(amm) -> compute_config_pda(amm, owner, nonce) compute_pool_pda(amm, token_a, token_b) -> compute_pool_pda(amm, config_id, token_a, token_b) compute_pool_pda_seed likewise gains config_id Anything linking amm_core must update call sites. * config and pool must now belong to the same namespace. Every pool- consuming instruction (new_definition, add/remove liquidity, swap, sync_reserves, and the oracle keeper ops) asserts pool.account_id == compute_pool_pda(amm, config.account_id, token_a, token_b), so a config and pool from different instances can no longer be combined. --- artifacts/amm-idl.json | 15 + modules/amm/ffi/src/api/admin.rs | 6 +- modules/amm/ffi/src/api/config.rs | 40 ++- modules/amm/ffi/src/api/context.rs | 2 +- modules/amm/ffi/src/api/liquidity.rs | 25 +- modules/amm/ffi/src/api/pair.rs | 8 +- modules/amm/ffi/src/api/request.rs | 10 + modules/amm/ffi/src/api/swap.rs | 39 ++- modules/amm/ffi/src/api/tests.rs | 41 ++- modules/amm/ffi/src/api/token_holdings.rs | 7 +- modules/amm/ffi/tests/public_api.rs | 3 + programs/amm/core/src/lib.rs | 71 ++-- programs/amm/examples/amm_pdas.rs | 20 +- programs/amm/methods/guest/src/bin/amm.rs | 18 +- programs/amm/src/add.rs | 25 +- .../amm/src/create_oracle_price_account.rs | 47 ++- programs/amm/src/create_price_observations.rs | 48 ++- programs/amm/src/initialize.rs | 159 +++++++-- programs/amm/src/new_definition.rs | 23 +- programs/amm/src/remove.rs | 25 +- programs/amm/src/swap.rs | 38 ++- programs/amm/src/sync.rs | 24 +- programs/amm/src/tests.rs | 126 ++++++- programs/amm/src/update_config.rs | 35 +- programs/integration_tests/tests/amm.rs | 315 +++++++++++++++++- 25 files changed, 972 insertions(+), 198 deletions(-) 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,