diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index df7d0518..88f20091 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -47,6 +47,10 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) connect(m_registry.get(), &RegistryLoader::changed, this, [this]() { m_logos->amm_module.setAmmProgramId(QVariantMap{ {QStringLiteral("ammProgramId"), m_registry->activeAmmProgramId()}}); + // Point ops at the active network's AMM instance by its config-PDA id + // (empty ⇒ the module falls back to AMM_CONFIG_ID). + m_logos->amm_module.setConfigId(QVariantMap{ + {QStringLiteral("configId"), m_registry->activeAmmConfigId()}}); setNetworks(m_registry->networks()); setActiveNetwork(m_registry->activeNetwork()); setRegistryRevision(m_registry->revision()); diff --git a/apps/amm/src/RegistryLoader.cpp b/apps/amm/src/RegistryLoader.cpp index 90e97691..3c56594d 100644 --- a/apps/amm/src/RegistryLoader.cpp +++ b/apps/amm/src/RegistryLoader.cpp @@ -210,13 +210,16 @@ bool RegistryLoader::applySelection() if (activeId.isEmpty()) { qWarning() << "AMM registry: no networks declared; nothing applied"; m_activeAmmProgramId.clear(); + m_activeAmmConfigId.clear(); publish({}, {}, m_lastSource, {}); return false; } - // Adopt the active network's declared AMM program id so the backend can point - // ops at it (setAmmProgramId) without an AMM_PROGRAM_BIN. + // Adopt the active network's declared AMM program id and config-PDA id so the + // backend can point ops at that instance (setAmmProgramId / setConfigId) without + // an AMM_PROGRAM_BIN / AMM_CONFIG_ID. m_activeAmmProgramId.clear(); + m_activeAmmConfigId.clear(); for (const QJsonValue& entry : networks) { const QJsonObject net = entry.toObject(); if (net.value(QStringLiteral("id")).toString() == activeId) { @@ -224,6 +227,7 @@ bool RegistryLoader::applySelection() .toObject() .value(QStringLiteral("amm")) .toString(); + m_activeAmmConfigId = net.value(QStringLiteral("ammConfigId")).toString(); break; } } diff --git a/apps/amm/src/RegistryLoader.h b/apps/amm/src/RegistryLoader.h index 0b105a19..459e6a72 100644 --- a/apps/amm/src/RegistryLoader.h +++ b/apps/amm/src/RegistryLoader.h @@ -54,6 +54,11 @@ class RegistryLoader : public QObject { // target this network without an AMM_PROGRAM_BIN. QString activeAmmProgramId() const { return m_activeAmmProgramId; } + // The active network's AMM instance, identified by the account id of its config + // PDA (registry field `ammConfigId`). The backend hands it to the module + // (setConfigId) so ops target that instance. Empty when the network omits it. + QString activeAmmConfigId() const { return m_activeAmmConfigId; } + // Whether a local-file source (TOKENS_CONFIG / AMM_POOLS_CONFIG) is configured — // it takes precedence over the remote registry (local-replaces-remote). static bool hasLocalSource(); @@ -99,6 +104,7 @@ public slots: QString m_source = QStringLiteral("none"); QString m_activeNetwork; QString m_activeAmmProgramId; + QString m_activeAmmConfigId; QString m_configuredUrl; // UI-configured registry URL (env overrides) // The last-loaded registry document, kept so selectNetwork() can re-filter to a diff --git a/apps/amm/tests/testnet/setup-amm-testnet.sh b/apps/amm/tests/testnet/setup-amm-testnet.sh index be412b53..03380367 100755 --- a/apps/amm/tests/testnet/setup-amm-testnet.sh +++ b/apps/amm/tests/testnet/setup-amm-testnet.sh @@ -99,7 +99,9 @@ TEST_SEQ_POLL_TIMEOUT="${TEST_SEQ_POLL_TIMEOUT:-3s}" # the faucet mints into. They deliberately start with NO token A so the test can prove # the account only appears in the swap picker after a faucet mint + UI refresh. The # faucet requires recipient != user_holding, hence two accounts. -ACCOUNT_LABELS=(token-a-def token-a-holding token-b-def token-b-holding lp-holding token-c-def token-c-holding token-d-def token-d-holding holder2 holder2-a-holding) +# `amm-owner` is a dedicated account that signs `initialize` — the AMM instance's +# namespace owner. All three are appended last so the existing accounts keep their ids. +ACCOUNT_LABELS=(token-a-def token-a-holding token-b-def token-b-holding lp-holding token-c-def token-c-holding token-d-def token-d-holding holder2 holder2-a-holding amm-owner) ############################################################################### # CONFIG — non-account parameters (edit freely) @@ -396,7 +398,9 @@ TOKEN_D_HOLDING="$(acct_id token-d-holding)" || die "token-d-holding not registe # "Token A Holder 2" — the faucet recipient/signer and its (initially empty) token A holding. HOLDER2="$(acct_id holder2)" || die "holder2 not registered — run with FORCE_BOOTSTRAP=1" HOLDER2_A_HOLDING="$(acct_id holder2-a-holding)" || die "holder2-a-holding not registered" -for v in TOKEN_A_DEF TOKEN_A_HOLDING TOKEN_B_DEF TOKEN_B_HOLDING USER_HOLDING_LP TOKEN_C_DEF TOKEN_C_HOLDING TOKEN_D_DEF TOKEN_D_HOLDING HOLDER2 HOLDER2_A_HOLDING; do +# `amm-owner` signs initialize — the AMM instance's namespace owner. +AMM_OWNER="$(acct_id amm-owner)" || die "amm-owner not registered" +for v in TOKEN_A_DEF TOKEN_A_HOLDING TOKEN_B_DEF TOKEN_B_HOLDING USER_HOLDING_LP TOKEN_C_DEF TOKEN_C_HOLDING TOKEN_D_DEF TOKEN_D_HOLDING HOLDER2 HOLDER2_A_HOLDING AMM_OWNER; do [ -n "${!v}" ] || die "failed to resolve account id for $v" done @@ -405,6 +409,9 @@ done # the faucet PDA, set in step 3 once the faucet binary is deployed and its ImageID # (hence the PDA) is known. AMM_AUTHORITY="$TOKEN_A_HOLDING" +# Namespace of the AMM instance: `amm-owner` signs initialize; the all-zero nonce is +# its default instance. The config PDA (below) is derived from (AMM_OWNER, AMM_NONCE). +AMM_NONCE="0000000000000000000000000000000000000000000000000000000000000000" USER_HOLDING_A="$TOKEN_A_HOLDING"; USER_HOLDING_B="$TOKEN_B_HOLDING" kv "token-a-def" "$TOKEN_A_DEF" @@ -418,6 +425,7 @@ kv "token-d-def" "$TOKEN_D_DEF" kv "token-d-holding" "$TOKEN_D_HOLDING" kv "holder2" "$HOLDER2" kv "holder2-a-holding" "$HOLDER2_A_HOLDING" +kv "amm-owner" "$AMM_OWNER" ############################################################################### # 2. Deploy programs @@ -502,9 +510,9 @@ inspect "$TOKEN_IDL" "$TOKEN_D_HOLDING" "TokenHolding" # 6. Derive AMM PDAs from the program ids + token pair ############################################################################### sec "Deriving AMM PDAs (amm_pdas example)" -log "${DIM}\$ cargo run -q -p amm_program --example amm_pdas -- $AMM_PID $TWAP_PID $TOKEN_A_DEF $TOKEN_B_DEF${RST}" +log "${DIM}\$ cargo run -q -p amm_program --example amm_pdas -- $AMM_PID $AMM_OWNER $TWAP_PID $TOKEN_A_DEF $TOKEN_B_DEF${RST}" PDAS="$(RISC0_DEV_MODE=1 RISC0_SKIP_BUILD=1 cargo run -q -p amm_program --example amm_pdas -- \ - "$AMM_PID" "$TWAP_PID" "$TOKEN_A_DEF" "$TOKEN_B_DEF")" + "$AMM_PID" "$AMM_OWNER" "$TWAP_PID" "$TOKEN_A_DEF" "$TOKEN_B_DEF")" printf '%s\n' "$PDAS" pda() { printf '%s' "$PDAS" | awk -v k="$1" '$1==k {print $2; exit}'; } @@ -534,7 +542,9 @@ kv "current_tick_account" "$TICK" ############################################################################### run_tx strict "initialize AMM config" -- \ spel --idl "$AMM_IDL" --program "$AMM_BIN" -- initialize \ + --owner "$AMM_OWNER" \ --config "$CONFIG" \ + --nonce "$AMM_NONCE" \ --token-program-id "$TOKEN_PID" \ --twap-oracle-program-id "$TWAP_PID" \ --authority "$AMM_AUTHORITY" @@ -648,7 +658,7 @@ sec "Write UI registry config -> $REGISTRY_CONFIG_OUT" "name": "AMM local registry", "version": "0.1.0", "networks": [ - { "id": "local", "name": "Local", "programIds": { "amm": "$AMM_PID", "token": "$TOKEN_PID", "tokenMintAuthority": "$MINT_AUTHORITY_PID" } } + { "id": "local", "name": "Local", "programIds": { "amm": "$AMM_PID", "token": "$TOKEN_PID", "tokenMintAuthority": "$MINT_AUTHORITY_PID" }, "ammConfigId": "$CONFIG" } ], "tokens": [ { "network": "local", "symbol": "$TOKEN_A_SYMBOL", "name": "$TOKEN_A_NAME", "definitionId": "$TOKEN_A_DEF" }, 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/modules/amm/src/amm_module_impl.cpp b/modules/amm/src/amm_module_impl.cpp index a342728c..e64d065f 100644 --- a/modules/amm/src/amm_module_impl.cpp +++ b/modules/amm/src/amm_module_impl.cpp @@ -43,6 +43,10 @@ bool ammDebug() { // what determine the program id (and every PDA derived from it). constexpr char AMM_PROGRAM_BIN_ENV[] = "AMM_PROGRAM_BIN"; +// Account id (base58 or hex) of the active instance's config PDA, for local / +// headless use when no registry (setConfigId) supplies one. +constexpr char AMM_CONFIG_ID_ENV[] = "AMM_CONFIG_ID"; + int hexVal(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; @@ -346,11 +350,34 @@ nlohmann::json AmmModuleImpl::walletAccountReads(bool wallet_open) { return out; } -nlohmann::json AmmModuleImpl::readConfig(const std::string& amm_program_id) { - const FfiResult configResult = - call(amm_config_id, json{{"ammProgramId", amm_program_id}}); - if (!configResult.ok) return json(); // null: config_id op failed - return readPublicAccount(jStr(configResult.value, "configId")); +std::string AmmModuleImpl::ammConfigId() { + // An app-selected id (setConfigId) takes precedence; AMM_CONFIG_ID is the + // fallback for local / headless use. + if (!m_activeConfigId.empty()) return m_activeConfigId; + + const char* env = std::getenv(AMM_CONFIG_ID_ENV); + if (env == nullptr || *env == '\0') return {}; + return normalizeAccountId(env); +} + +nlohmann::json AmmModuleImpl::readConfig() { + // The active instance is identified by its config PDA's account id, resolved + // from the app's registry (or AMM_CONFIG_ID). We just read that account — + // owner/nonce derivation lives in the deploy tooling that emits the registry. + const std::string config_id = ammConfigId(); + if (config_id.empty()) return json(); // null: no config id configured + return readPublicAccount(config_id); +} + +LogosMap AmmModuleImpl::setConfigId(const LogosMap& request) { + const std::string raw = jStr(request, "configId"); + const std::string normalized = normalizeAccountId(raw); + if (!raw.empty() && normalized.empty()) + return LogosMap{{"status", "error"}, {"error", "invalid_account_id"}}; + + // Adopt the caller's chosen config id (normalized to hex; empty reverts to env). + m_activeConfigId = normalized; + return LogosMap{{"status", "ok"}, {"error", ""}}; } LogosMap AmmModuleImpl::resolvePoolAccount(const std::string& def_a_hex, @@ -369,9 +396,9 @@ LogosMap AmmModuleImpl::resolvePoolAccount(const std::string& def_a_hex, // no program id from AMM_PROGRAM_BIN (unset/unreadable/bad). return failed("no_program_bin"); - const json config = readConfig(amm_program_id); + const json config = readConfig(); if (config.is_null()) - return failed("bad_config"); // amm_config_id op failed (malformed program id) + return failed("bad_config"); // no config id configured (setConfigId / AMM_CONFIG_ID unset) // The liquidity view passes base58 ids; the swap card passes hex. Normalize both to hex // (idempotent for hex) so the FFI id derivation works either way. @@ -418,7 +445,7 @@ LogosMap AmmModuleImpl::configAccount() { if (amm_program_id.empty()) return LogosMap{{"status", "error"}, {"error", "config_missing"}}; - const json config = readConfig(amm_program_id); + const json config = readConfig(); if (config.is_null()) return LogosMap{{"status", "error"}, {"error", "backend_error"}}; @@ -442,7 +469,7 @@ LogosMap AmmModuleImpl::transferOwnership(const LogosMap& request) { return error("config_missing"); // The plan needs the config account to decode the CURRENT admin (the sole signer). - const json config = readConfig(amm_program_id); + const json config = readConfig(); if (config.is_null()) return error("config_missing"); @@ -496,7 +523,7 @@ LogosMap AmmModuleImpl::oracleSetupSubmit(const LogosMap& request, bool observat return error("config_missing"); // The plan derives the feed PDAs from the config's twap_oracle_program_id + the pool. - const json config = readConfig(amm_program_id); + const json config = readConfig(); if (config.is_null()) return error("config_missing"); @@ -595,10 +622,18 @@ LogosMap AmmModuleImpl::swapExactInQuote(const std::string& token_in_hex, if (token_in.empty() || token_out.empty()) return error("invalid_token_id"); + // A configured program id but no config id is still an unconfigured instance. Read + // and null-check the config here so we return a stable config_missing rather than + // letting a null `config` fail PoolIdRequest deserialization as opaque backend_error. + const json config = readConfig(); + if (config.is_null()) + return error("config_missing"); + // Derive the pool id (config-free) and read the pool account; its raw data is // handed to the pricing op. An absent account has no data → `no_pool`. const FfiResult poolId = call(amm_pool_id, json{ {"ammProgramId", amm_program_id}, + {"config", config}, {"tokenInId", token_in}, {"tokenOutId", token_out}, }); @@ -646,10 +681,18 @@ LogosMap AmmModuleImpl::swapExactOutQuote(const std::string& token_in_hex, const std::string token_in = normalizeAccountId(token_in_hex); const std::string token_out = normalizeAccountId(token_out_hex); + // A configured program id but no config id is still an unconfigured instance. Read + // and null-check the config here so we return a stable config_missing rather than + // letting a null `config` fail PoolIdRequest deserialization as opaque backend_error. + const json config = readConfig(); + if (config.is_null()) + return error("config_missing"); + // Derive the pool id (config-free) and read the pool account; its raw data is // handed to the pricing op. An absent account has no data → `no_pool`. const FfiResult poolId = call(amm_pool_id, json{ {"ammProgramId", amm_program_id}, + {"config", config}, {"tokenInId", token_in}, {"tokenOutId", token_out}, }); @@ -698,7 +741,7 @@ std::string AmmModuleImpl::swapExactInput(const std::string& def_a_hex, return {}; } - const json config = readConfig(amm_program_id); + const json config = readConfig(); if (config.is_null()) { AMM_TRACE("swapExactInput: FAIL config_id op failed"); return {}; @@ -719,6 +762,7 @@ std::string AmmModuleImpl::swapExactInput(const std::string& def_a_hex, // the vaults in the pool's creation order — see amm_swap_exact_in_plan). const FfiResult poolId = call(amm_pool_id, json{ {"ammProgramId", amm_program_id}, + {"config", readConfig()}, {"tokenInId", def_a}, {"tokenOutId", def_b}, }); @@ -792,7 +836,7 @@ std::string AmmModuleImpl::swapExactOutput(const std::string& def_a_hex, return {}; } - const json config = readConfig(amm_program_id); + const json config = readConfig(); if (config.is_null()) { AMM_TRACE("swapExactOutput: FAIL config_id op failed"); return {}; @@ -809,6 +853,7 @@ std::string AmmModuleImpl::swapExactOutput(const std::string& def_a_hex, // the vaults in the pool's creation order — see amm_swap_exact_out_plan). const FfiResult poolId = call(amm_pool_id, json{ {"ammProgramId", amm_program_id}, + {"config", readConfig()}, {"tokenInId", def_a}, {"tokenOutId", def_b}, }); @@ -922,11 +967,9 @@ LogosMap AmmModuleImpl::createPool(const LogosMap& request) { // amm_create_pool_plan needs the config account for the twap program id the // current-tick PDA derives from; a bad/absent config surfaces from the plan as // config_unavailable (no bespoke check here — same as the swap plans). - const FfiResult configResult = - call(amm_config_id, json{{"ammProgramId", amm_program_id}}); - if (!configResult.ok) + const json config = readConfig(); + if (config.is_null()) return error("backend_error"); - const json config = readPublicAccount(jStr(configResult.value, "configId")); // Normalize the pair + user holdings (incl. the caller-provided LP holding) to hex // (base58 tolerated — transitional). A new pool has no pre-existing LP holding, so @@ -1015,10 +1058,18 @@ LogosMap AmmModuleImpl::addLiquidityQuote(const LogosMap& request) { || !jsonAmountToDecimal(request.value("maxAmountB", json()), max_b_decimal)) return error("bad_amount"); + // A configured program id but no config id is still an unconfigured instance. Read + // and null-check the config here so we return a stable config_missing rather than + // letting a null `config` fail PoolIdRequest deserialization as opaque backend_error. + const json config = readConfig(); + if (config.is_null()) + return error("config_missing"); + // Derive the pool id (config-free) and read the pool account; its raw data is handed // to the pricing op. An absent account has no data → `no_pool`. const FfiResult poolId = call(amm_pool_id, json{ {"ammProgramId", amm_program_id}, + {"config", config}, {"tokenInId", token_a}, {"tokenOutId", token_b}, }); @@ -1071,11 +1122,9 @@ LogosMap AmmModuleImpl::addLiquidity(const LogosMap& request) { // amm_add_liquidity_plan needs the config account for the twap program id the // current-tick PDA derives from; a bad/absent config surfaces from the plan. - const FfiResult configResult = - call(amm_config_id, json{{"ammProgramId", amm_program_id}}); - if (!configResult.ok) + const json config = readConfig(); + if (config.is_null()) return error("backend_error"); - const json config = readPublicAccount(jStr(configResult.value, "configId")); // Normalize the pair + user holdings (incl. the LP holding that receives the minted LP) // to hex (base58 tolerated — transitional). @@ -1102,6 +1151,7 @@ LogosMap AmmModuleImpl::addLiquidity(const LogosMap& request) { // asserts the provided vaults/LP against them — see amm_add_liquidity_plan). const FfiResult poolId = call(amm_pool_id, json{ {"ammProgramId", amm_program_id}, + {"config", readConfig()}, {"tokenInId", token_a}, {"tokenOutId", token_b}, }); @@ -1168,10 +1218,18 @@ LogosMap AmmModuleImpl::removeLiquidityQuote(const LogosMap& request) { if (!jsonAmountToDecimal(request.value("lpAmount", json()), lp_amount_decimal)) return error("bad_amount"); + // A configured program id but no config id is still an unconfigured instance. Read + // and null-check the config here so we return a stable config_missing rather than + // letting a null `config` fail PoolIdRequest deserialization as opaque backend_error. + const json config = readConfig(); + if (config.is_null()) + return error("config_missing"); + // Derive the pool id (config-free) and read the pool account; its raw data is handed to // the pricing op. An absent account has no data → `no_pool`. const FfiResult poolId = call(amm_pool_id, json{ {"ammProgramId", amm_program_id}, + {"config", config}, {"tokenInId", token_a}, {"tokenOutId", token_b}, }); @@ -1222,11 +1280,9 @@ LogosMap AmmModuleImpl::removeLiquidity(const LogosMap& request) { // amm_remove_liquidity_plan needs the config account for the twap program id the // current-tick PDA derives from; a bad/absent config surfaces from the plan. - const FfiResult configResult = - call(amm_config_id, json{{"ammProgramId", amm_program_id}}); - if (!configResult.ok) + const json config = readConfig(); + if (config.is_null()) return error("backend_error"); - const json config = readPublicAccount(jStr(configResult.value, "configId")); // Normalize the pair + user holdings. Unlike add/create there is no fresh account: the LP // holding already exists (it is burned) and the token a/b holdings receive the withdrawal. @@ -1253,6 +1309,7 @@ LogosMap AmmModuleImpl::removeLiquidity(const LogosMap& request) { // asserts the provided vaults/LP against them — see amm_remove_liquidity_plan). const FfiResult poolId = call(amm_pool_id, json{ {"ammProgramId", amm_program_id}, + {"config", readConfig()}, {"tokenInId", token_a}, {"tokenOutId", token_b}, }); @@ -1312,11 +1369,9 @@ LogosMap AmmModuleImpl::syncReserves(const LogosMap& request) { // amm_sync_reserves_plan needs the config account for the twap program id the current-tick // PDA derives from; a bad/absent config surfaces from the plan. - const FfiResult configResult = - call(amm_config_id, json{{"ammProgramId", amm_program_id}}); - if (!configResult.ok) + const json config = readConfig(); + if (config.is_null()) return error("backend_error"); - const json config = readPublicAccount(jStr(configResult.value, "configId")); // Normalize the pair to hex (transitional). Sync has no user inputs beyond the pair — no // holdings, amounts, or deadline, and nothing signs. @@ -1329,6 +1384,7 @@ LogosMap AmmModuleImpl::syncReserves(const LogosMap& request) { // vaults against them — see amm_sync_reserves_plan). const FfiResult poolId = call(amm_pool_id, json{ {"ammProgramId", amm_program_id}, + {"config", readConfig()}, {"tokenInId", token_a}, {"tokenOutId", token_b}, }); @@ -1374,11 +1430,9 @@ LogosList AmmModuleImpl::tokenHoldings(bool wallet_open) { // The config gives the token_program_id that identifies which wallet accounts // are token holdings (decoded by the FFI op). - const FfiResult configResult = - call(amm_config_id, json{{"ammProgramId", amm_program_id}}); - if (!configResult.ok) + const json config = readConfig(); + if (config.is_null()) return LogosList::array(); - const json config = readPublicAccount(jStr(configResult.value, "configId")); // Fresh wallet read each call — the selector wants current holdings/balances. const json wallet_accounts = walletAccountReads(wallet_open); @@ -1418,11 +1472,9 @@ LogosList AmmModuleImpl::resolveTokens(const LogosMap& request, bool wallet_open return LogosList::array(); // The config gives the token_program_id the FFI needs to decode definitions/holdings. - const FfiResult configResult = - call(amm_config_id, json{{"ammProgramId", amm_program_id}}); - if (!configResult.ok) + const json config = readConfig(); + if (config.is_null()) return LogosList::array(); - const json config = readPublicAccount(jStr(configResult.value, "configId")); // Normalize the app-provided ids (base58 or hex) → hex, de-dup, and read each // definition account. The FFI is stateless, so it gets the reads pre-fetched. diff --git a/modules/amm/src/amm_module_impl.h b/modules/amm/src/amm_module_impl.h index 581c5c8e..219d1bf6 100644 --- a/modules/amm/src/amm_module_impl.h +++ b/modules/amm/src/amm_module_impl.h @@ -54,6 +54,12 @@ class AmmModuleImpl : public LogosModuleContext { /// caller's choice. Headless callers never touch it. Returns `{ status:"ok" }`. LogosMap setAmmProgramId(const LogosMap& request); + /// Selects which namespaced AMM instance the module talks to, by the account id of its config + /// PDA (`request.configId`, base58 or hex). The app resolves this from its registry (the + /// active network's `ammConfigId`) — the same way it publishes `poolId`s — so no owner/nonce + /// derivation happens here. Empty reverts to the `AMM_CONFIG_ID` env fallback. `{ status:"ok" }`. + LogosMap setConfigId(const LogosMap& request); + /// Submits an `UpdateConfig` transferring admin authority to `request.newAuthorityId` /// (base58 or hex). Only the current admin can sign, so the connected wallet must control it. /// On success `{ status:"ok", error:"", transactionId: }`; on failure: @@ -262,6 +268,11 @@ class AmmModuleImpl : public LogosModuleContext { // over the AMM_PROGRAM_BIN bytes (empty if unset/unreadable/bad). std::string ammProgramId(); + // 64-char lowercase-hex account id of the active instance's config PDA. An + // app-selected id (setConfigId) takes precedence; AMM_CONFIG_ID is the + // env fallback for local / headless use. Empty when neither is set. + std::string ammConfigId(); + // Reads AMM_PROGRAM_BIN into a byte vector (empty on unset/unreadable/empty). std::vector loadAmmElf(); @@ -269,10 +280,10 @@ class AmmModuleImpl : public LogosModuleContext { // (base58 via the wallet module). Empty string if it is neither. std::string normalizeAccountId(const std::string& id); - // Derives the config account id (amm_config_id) and reads it, returning the - // account-read shape the amm_ffi ops embed. Null json when the config_id - // op itself fails (readPublicAccount always yields at least {id,status}). - nlohmann::json readConfig(const std::string& amm_program_id); + // Reads the active instance's config account (at ammConfigId()) and returns the + // account-read shape the amm_ffi ops embed. Null json when no config id is + // configured (readPublicAccount always yields at least {id,status}). + nlohmann::json readConfig(); // Reads a public account through the wallet module and returns the // { id, status, account:{ program_owner, balance, nonce, data } } shape the @@ -291,4 +302,8 @@ class AmmModuleImpl : public LogosModuleContext { // The AMM program id the app selected (via setAmmProgramId). Empty ⇒ // ammProgramId() falls back to deriving it from AMM_PROGRAM_BIN. std::string m_activeProgramId; + + // The config-PDA account id the app selected (via setConfigId), 64-char + // lowercase hex. Empty ⇒ ammConfigId() falls back to AMM_CONFIG_ID. + std::string m_activeConfigId; }; 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,