diff --git a/contracts/prompt-hash/src/contract.rs b/contracts/prompt-hash/src/contract.rs index b868c0f0..ed35a8f5 100644 --- a/contracts/prompt-hash/src/contract.rs +++ b/contracts/prompt-hash/src/contract.rs @@ -48,22 +48,43 @@ pub struct PromptHashContract; impl PromptHashTrait for PromptHashContract { fn __constructor( env: Env, - admin: Address, - admin_two: Address, - admin_three: Address, + config_admin: Address, + config_admin_two: Address, + config_admin_three: Address, + upgrade_admin: Address, + upgrade_admin_two: Address, + upgrade_admin_three: Address, fee_wallet: Address, xlm_sac: Address, ) -> Result<(), Error> { - ensure( - admin != admin_two && admin != admin_three && admin_two != admin_three, - Error::Unauthorized, + ensure(!Storage::is_initialized(&env), Error::AlreadyInitialized)?; + validate_admin_roles( + &config_admin, + &config_admin_two, + &config_admin_three, + &upgrade_admin, + &upgrade_admin_two, + &upgrade_admin_three, )?; - ownable::set_owner(&env, &admin); - let admin_signers = Vec::from_array( + ownable::set_owner(&env, &config_admin); + let config_admin_signers = Vec::from_array( + &env, + [ + config_admin.clone(), + config_admin_two.clone(), + config_admin_three.clone(), + ], + ); + let upgrade_admin_signers = Vec::from_array( &env, - [admin.clone(), admin_two.clone(), admin_three.clone()], + [ + upgrade_admin.clone(), + upgrade_admin_two.clone(), + upgrade_admin_three.clone(), + ], ); - Storage::set_admin_signers(&env, &admin_signers); + Storage::set_config_admin_signers(&env, &config_admin_signers); + Storage::set_upgrade_admin_signers(&env, &upgrade_admin_signers); Storage::set_fee_wallet(&env, &fee_wallet); Storage::set_fee_percentage(&env, &DEFAULT_FEE_BPS); Storage::set_xlm_address(&env, &xlm_sac); @@ -664,9 +685,9 @@ impl PromptHashTrait for PromptHashContract { approver_a: Address, approver_b: Address, ) -> Result<(), Error> { - require_admin_multisig(&env, &approver_a, &approver_b)?; + require_config_admin_multisig(&env, &approver_a, &approver_b)?; Storage::require_no_reentrancy(&env)?; - ensure(new_fee_percentage <= MAX_BPS, Error::InvalidFeePercentage)?; + ensure(new_fee_percentage <= MAX_FEE_BPS, Error::FeeExceedsMaximum)?; Storage::set_fee_percentage(&env, &new_fee_percentage); Events::emit_fee_updated(&env, new_fee_percentage); Ok(()) @@ -678,7 +699,7 @@ impl PromptHashTrait for PromptHashContract { approver_a: Address, approver_b: Address, ) -> Result<(), Error> { - require_admin_multisig(&env, &approver_a, &approver_b)?; + require_config_admin_multisig(&env, &approver_a, &approver_b)?; Storage::require_no_reentrancy(&env)?; Storage::set_fee_wallet(&env, &new_fee_wallet); Events::emit_fee_wallet_updated(&env, new_fee_wallet); @@ -703,7 +724,7 @@ impl PromptHashTrait for PromptHashContract { approver_a: Address, approver_b: Address, ) -> Result<(), Error> { - require_admin_multisig(&env, &approver_a, &approver_b)?; + require_config_admin_multisig(&env, &approver_a, &approver_b)?; Storage::require_no_reentrancy(&env)?; Storage::set_pause_status(&env, paused); Events::emit_contract_paused_state_changed(&env, paused); @@ -808,7 +829,7 @@ impl PromptHashTrait for PromptHashContract { approver_a: Address, approver_b: Address, ) -> Result<(), Error> { - require_admin_multisig(&env, &approver_a, &approver_b)?; + require_upgrade_admin_multisig(&env, &approver_a, &approver_b)?; ensure(!Storage::is_paused(&env), Error::ContractIsPaused)?; // (1) Reject an invalid implementation: a zero hash is never a deployable // WASM, and re-proposing the currently-deployed bytecode is a no-op. @@ -827,7 +848,7 @@ impl PromptHashTrait for PromptHashContract { } fn confirm_upgrade(env: Env, approver_a: Address, approver_b: Address) -> Result<(), Error> { - require_admin_multisig(&env, &approver_a, &approver_b)?; + require_upgrade_admin_multisig(&env, &approver_a, &approver_b)?; let pending = Storage::get_pending_upgrade(&env).ok_or(Error::UpgradeNotProposed)?; // (timelock) Enforce the cooldown before executing the upgrade. let proposed_at = @@ -858,7 +879,7 @@ impl PromptHashTrait for PromptHashContract { } fn cancel_upgrade(env: Env, approver_a: Address, approver_b: Address) -> Result<(), Error> { - require_admin_multisig(&env, &approver_a, &approver_b)?; + require_upgrade_admin_multisig(&env, &approver_a, &approver_b)?; let pending = Storage::get_pending_upgrade(&env).ok_or(Error::UpgradeNotProposed)?; Storage::clear_pending_upgrade(&env); Storage::clear_upgrade_proposer(&env); @@ -2126,14 +2147,36 @@ fn ensure(condition: bool, error: Error) -> Result<(), Error> { } } -fn require_admin_multisig( +fn require_config_admin_multisig( + env: &Env, + approver_a: &Address, + approver_b: &Address, +) -> Result<(), Error> { + require_role_multisig(env, approver_a, approver_b, Storage::is_config_admin_signer) +} + +fn require_upgrade_admin_multisig( + env: &Env, + approver_a: &Address, + approver_b: &Address, +) -> Result<(), Error> { + require_role_multisig( + env, + approver_a, + approver_b, + Storage::is_upgrade_admin_signer, + ) +} + +fn require_role_multisig( env: &Env, approver_a: &Address, approver_b: &Address, + is_signer: fn(&Env, &Address) -> bool, ) -> Result<(), Error> { ensure(approver_a != approver_b, Error::Unauthorized)?; ensure( - Storage::is_admin_signer(env, approver_a) && Storage::is_admin_signer(env, approver_b), + is_signer(env, approver_a) && is_signer(env, approver_b), Error::Unauthorized, )?; approver_a.require_auth(); @@ -2141,6 +2184,37 @@ fn require_admin_multisig( Ok(()) } +fn validate_admin_roles( + config_admin: &Address, + config_admin_two: &Address, + config_admin_three: &Address, + upgrade_admin: &Address, + upgrade_admin_two: &Address, + upgrade_admin_three: &Address, +) -> Result<(), Error> { + ensure( + config_admin != config_admin_two + && config_admin != config_admin_three + && config_admin_two != config_admin_three + && upgrade_admin != upgrade_admin_two + && upgrade_admin != upgrade_admin_three + && upgrade_admin_two != upgrade_admin_three, + Error::Unauthorized, + )?; + ensure( + config_admin != upgrade_admin + && config_admin != upgrade_admin_two + && config_admin != upgrade_admin_three + && config_admin_two != upgrade_admin + && config_admin_two != upgrade_admin_two + && config_admin_two != upgrade_admin_three + && config_admin_three != upgrade_admin + && config_admin_three != upgrade_admin_two + && config_admin_three != upgrade_admin_three, + Error::Unauthorized, + ) +} + fn validate_classification(env: &Env, classification: &String) -> Result<(), Error> { for name in ALL_CLASSIFICATIONS { if classification == &String::from_str(env, name) { diff --git a/contracts/prompt-hash/src/fuzz.rs b/contracts/prompt-hash/src/fuzz.rs index 133d4691..1d122e04 100644 --- a/contracts/prompt-hash/src/fuzz.rs +++ b/contracts/prompt-hash/src/fuzz.rs @@ -27,11 +27,25 @@ struct FuzzContext { fn setup(env: &Env) -> FuzzContext { env.mock_all_auths(); let admin = Address::generate(env); + let admin_two = Address::generate(env); + let admin_three = Address::generate(env); + let upgrade_admin = Address::generate(env); + let upgrade_admin_two = Address::generate(env); + let upgrade_admin_three = Address::generate(env); let fee_wallet = Address::generate(env); let xlm = env.register(FungibleTokenContract, (admin.clone(),)); let contract = env.register( PromptHashContract, - (admin.clone(), fee_wallet.clone(), xlm.clone()), + ( + admin.clone(), + admin_two, + admin_three, + upgrade_admin, + upgrade_admin_two, + upgrade_admin_three, + fee_wallet.clone(), + xlm.clone(), + ), ); FuzzContext { contract, xlm } } diff --git a/contracts/prompt-hash/src/gas_bench.rs b/contracts/prompt-hash/src/gas_bench.rs index 4c473285..9c4fc69f 100644 --- a/contracts/prompt-hash/src/gas_bench.rs +++ b/contracts/prompt-hash/src/gas_bench.rs @@ -39,6 +39,9 @@ struct Context { admin: Address, admin_two: Address, admin_three: Address, + upgrade_admin: Address, + upgrade_admin_two: Address, + upgrade_admin_three: Address, fee_wallet: Address, xlm: Address, contract: Address, @@ -49,6 +52,9 @@ fn setup(env: &Env) -> Context { let admin = Address::generate(env); let admin_two = Address::generate(env); let admin_three = Address::generate(env); + let upgrade_admin = Address::generate(env); + let upgrade_admin_two = Address::generate(env); + let upgrade_admin_three = Address::generate(env); let fee_wallet = Address::generate(env); let xlm = env.register(FungibleTokenContract, (admin.clone(),)); let contract = env.register( @@ -57,6 +63,9 @@ fn setup(env: &Env) -> Context { admin.clone(), admin_two.clone(), admin_three.clone(), + upgrade_admin.clone(), + upgrade_admin_two.clone(), + upgrade_admin_three.clone(), fee_wallet.clone(), xlm.clone(), ), @@ -65,6 +74,9 @@ fn setup(env: &Env) -> Context { admin, admin_two, admin_three, + upgrade_admin, + upgrade_admin_two, + upgrade_admin_three, fee_wallet, xlm, contract, @@ -202,11 +214,23 @@ fn gas_benchmarks_all_contract_operations() { let admin = Address::generate(&env); let admin_two = Address::generate(&env); let admin_three = Address::generate(&env); + let upgrade_admin = Address::generate(&env); + let upgrade_admin_two = Address::generate(&env); + let upgrade_admin_three = Address::generate(&env); let fee_wallet = Address::generate(&env); let xlm = env.register(FungibleTokenContract, (admin.clone(),)); let _ = env.register( PromptHashContract, - (admin, admin_two, admin_three, fee_wallet, xlm), + ( + admin, + admin_two, + admin_three, + upgrade_admin, + upgrade_admin_two, + upgrade_admin_three, + fee_wallet, + xlm, + ), ); }); diff --git a/contracts/prompt-hash/src/storage.rs b/contracts/prompt-hash/src/storage.rs index 739be88c..0ef2eb39 100644 --- a/contracts/prompt-hash/src/storage.rs +++ b/contracts/prompt-hash/src/storage.rs @@ -24,21 +24,34 @@ fn ensure(condition: bool, error: Error) -> Result<(), Error> { } impl Storage { - pub fn set_admin_signers(env: &Env, signers: &Vec
) { + pub fn set_config_admin_signers(env: &Env, signers: &Vec
) { let key = DataKey::AdminSigners; env.storage().persistent().set(&key, signers); Self::extend_key_ttl(env, &key); } - pub fn is_admin_signer(env: &Env, signer: &Address) -> bool { - let key = DataKey::AdminSigners; + pub fn is_config_admin_signer(env: &Env, signer: &Address) -> bool { + Self::is_signer(env, &DataKey::AdminSigners, signer) + } + + pub fn set_upgrade_admin_signers(env: &Env, signers: &Vec
) { + let key = DataKey::UpgradeAdminSigners; + env.storage().persistent().set(&key, signers); + Self::extend_key_ttl(env, &key); + } + + pub fn is_upgrade_admin_signer(env: &Env, signer: &Address) -> bool { + Self::is_signer(env, &DataKey::UpgradeAdminSigners, signer) + } + + fn is_signer(env: &Env, key: &DataKey, signer: &Address) -> bool { let signers: Vec
= env .storage() .persistent() - .get(&key) + .get(key) .unwrap_or_else(|| Vec::new(env)); - if env.storage().persistent().has(&key) { - Self::extend_key_ttl(env, &key); + if env.storage().persistent().has(key) { + Self::extend_key_ttl(env, key); } for index in 0..signers.len() { if signers.get(index).unwrap() == signer.clone() { diff --git a/contracts/prompt-hash/src/test.rs b/contracts/prompt-hash/src/test.rs index 52504fb9..06bbe4a5 100644 --- a/contracts/prompt-hash/src/test.rs +++ b/contracts/prompt-hash/src/test.rs @@ -13,6 +13,9 @@ struct PromptHashContext { admin: Address, admin_two: Address, admin_three: Address, + upgrade_admin: Address, + upgrade_admin_two: Address, + upgrade_admin_three: Address, fee_wallet: Address, xlm: Address, contract: Address, @@ -24,6 +27,9 @@ fn setup(env: &Env) -> PromptHashContext { let admin = Address::generate(env); let admin_two = Address::generate(env); let admin_three = Address::generate(env); + let upgrade_admin = Address::generate(env); + let upgrade_admin_two = Address::generate(env); + let upgrade_admin_three = Address::generate(env); let fee_wallet = Address::generate(env); let xlm = env.register(FungibleTokenContract, (admin.clone(),)); let contract = env.register( @@ -32,6 +38,9 @@ fn setup(env: &Env) -> PromptHashContext { admin.clone(), admin_two.clone(), admin_three.clone(), + upgrade_admin.clone(), + upgrade_admin_two.clone(), + upgrade_admin_three.clone(), fee_wallet.clone(), xlm.clone(), ), @@ -41,6 +50,9 @@ fn setup(env: &Env) -> PromptHashContext { admin, admin_two, admin_three, + upgrade_admin, + upgrade_admin_two, + upgrade_admin_three, fee_wallet, xlm, contract, @@ -188,6 +200,11 @@ fn test_constructor_rejects_repeated_initialization() { ::__constructor( env.clone(), attacker_admin.clone(), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), attacker_fee_wallet.clone(), context.xlm.clone(), ) @@ -200,6 +217,35 @@ fn test_constructor_rejects_repeated_initialization() { assert_eq!(client.get_fee_percentage(), 500); } +#[test] +fn test_constructor_rejects_overlapping_config_and_upgrade_admins() { + let env: Env = Default::default(); + let contract = Address::generate(&env); + let shared_admin = Address::generate(&env); + let config_admin_two = Address::generate(&env); + let config_admin_three = Address::generate(&env); + let upgrade_admin_two = Address::generate(&env); + let upgrade_admin_three = Address::generate(&env); + let fee_wallet = Address::generate(&env); + let xlm = env.register(FungibleTokenContract, (shared_admin.clone(),)); + + let result: Result<(), Error> = env.as_contract(&contract, || { + ::__constructor( + env.clone(), + shared_admin.clone(), + config_admin_two, + config_admin_three, + shared_admin, + upgrade_admin_two, + upgrade_admin_three, + fee_wallet, + xlm, + ) + }); + + assert_eq!(result, Err(Error::Unauthorized)); +} + #[test] fn test_creator_can_pause_reactivate_and_update_price() { let env: Env = Default::default(); @@ -1015,14 +1061,14 @@ fn test_set_fee_percentage_above_max_rejected() { let client = PromptHashContractClient::new(&env, &context.contract); // #41: 2,000 bps (20%) is a hard ceiling; anything above must be rejected. - let result = client.try_set_fee_percentage(&2_001); + let result = client.try_set_fee_percentage(&2_001, &context.admin, &context.admin_two); match result { Err(Ok(Error::FeeExceedsMaximum)) => {} other => panic!("expected FeeExceedsMaximum, got {:?}", other), } // The boundary itself must still be accepted. - client.set_fee_percentage(&2_000); + client.set_fee_percentage(&2_000, &context.admin, &context.admin_two); assert_eq!(client.get_fee_percentage(), 2_000); } @@ -1070,6 +1116,52 @@ fn test_sensitive_admin_functions_reject_duplicate_or_unknown_approvers() { assert_eq!(client.get_fee_percentage(), 500); } +#[test] +fn test_config_admins_cannot_authorize_contract_upgrade() { + let env: Env = Default::default(); + let context = setup(&env); + let client = PromptHashContractClient::new(&env, &context.contract); + + let wasm_hash = BytesN::from_array(&env, &[1u8; 32]); + let result = client.try_propose_upgrade(&wasm_hash, &context.admin, &context.admin_two); + match result { + Err(Ok(Error::Unauthorized)) => {} + other => panic!("expected Unauthorized for config-admin upgrade, got {:?}", other), + } + assert_eq!(client.get_pending_upgrade(), None); +} + +#[test] +fn test_upgrade_admins_cannot_change_fee_configuration() { + let env: Env = Default::default(); + let context = setup(&env); + let client = PromptHashContractClient::new(&env, &context.contract); + let replacement_wallet = Address::generate(&env); + + let fee_result = client.try_set_fee_percentage( + &1_000, + &context.upgrade_admin, + &context.upgrade_admin_two, + ); + match fee_result { + Err(Ok(Error::Unauthorized)) => {} + other => panic!("expected Unauthorized for upgrade-admin fee change, got {:?}", other), + } + + let wallet_result = client.try_set_fee_wallet( + &replacement_wallet, + &context.upgrade_admin, + &context.upgrade_admin_two, + ); + match wallet_result { + Err(Ok(Error::Unauthorized)) => {} + other => panic!("expected Unauthorized for upgrade-admin wallet change, got {:?}", other), + } + + assert_eq!(client.get_fee_percentage(), 500); + assert_eq!(client.get_fee_wallet(), Some(context.fee_wallet)); +} + #[test] fn test_unauthorized_seller_actions_fail() { let env: Env = Default::default(); @@ -6062,14 +6154,15 @@ fn test_upgrade_propose_requires_two_distinct_admins() { let wasm_hash = BytesN::from_array(&env, &[1u8; 32]); // Same admin used for both approver slots must be rejected. - let result = client.try_propose_upgrade(&wasm_hash, &context.admin, &context.admin); + let result = + client.try_propose_upgrade(&wasm_hash, &context.upgrade_admin, &context.upgrade_admin); match result { Err(Ok(Error::Unauthorized)) => {} other => panic!("expected Unauthorized for same-admin propose_upgrade, got {:?}", other), } // One admin + one stranger must be rejected too. let stranger = Address::generate(&env); - let result = client.try_propose_upgrade(&wasm_hash, &context.admin, &stranger); + let result = client.try_propose_upgrade(&wasm_hash, &context.upgrade_admin, &stranger); match result { Err(Ok(Error::Unauthorized)) => {} other => panic!("expected Unauthorized for mixed-admin propose_upgrade, got {:?}", other), @@ -6084,7 +6177,11 @@ fn test_upgrade_rejects_invalid_implementation() { // A zero WASM hash is never a valid implementation. let zero_hash = BytesN::from_array(&env, &[0u8; 32]); - let result = client.try_propose_upgrade(&zero_hash, &context.admin, &context.admin_two); + let result = client.try_propose_upgrade( + &zero_hash, + &context.upgrade_admin, + &context.upgrade_admin_two, + ); match result { Err(Ok(Error::InvalidImplementation)) => {} other => panic!("expected InvalidImplementation for zero wasm hash, got {:?}", other), @@ -6098,12 +6195,16 @@ fn test_upgrade_propose_twice_rejected() { let client = PromptHashContractClient::new(&env, &context.contract); let wasm_hash = BytesN::from_array(&env, &[1u8; 32]); - client.propose_upgrade(&wasm_hash, &context.admin, &context.admin_two); + client.propose_upgrade(&wasm_hash, &context.upgrade_admin, &context.upgrade_admin_two); assert_eq!(client.get_pending_upgrade(), Some(wasm_hash)); // A second proposal while one is pending must be rejected. let other_hash = BytesN::from_array(&env, &[2u8; 32]); - let result = client.try_propose_upgrade(&other_hash, &context.admin, &context.admin_two); + let result = client.try_propose_upgrade( + &other_hash, + &context.upgrade_admin, + &context.upgrade_admin_two, + ); match result { Err(Ok(Error::UpgradeAlreadyProposed)) => {} other => panic!("expected UpgradeAlreadyProposed for duplicate proposal, got {:?}", other), @@ -6116,7 +6217,7 @@ fn test_upgrade_confirm_without_proposal_rejected() { let context = setup(&env); let client = PromptHashContractClient::new(&env, &context.contract); - let result = client.try_confirm_upgrade(&context.admin, &context.admin_two); + let result = client.try_confirm_upgrade(&context.upgrade_admin, &context.upgrade_admin_two); match result { Err(Ok(Error::UpgradeNotProposed)) => {} other => panic!("expected UpgradeNotProposed when nothing is proposed, got {:?}", other), @@ -6131,11 +6232,11 @@ fn test_upgrade_confirm_before_cooldown_rejected() { env.ledger().with_mut(|ledger| ledger.timestamp = 1_000); let wasm_hash = BytesN::from_array(&env, &[1u8; 32]); - client.propose_upgrade(&wasm_hash, &context.admin, &context.admin_two); + client.propose_upgrade(&wasm_hash, &context.upgrade_admin, &context.upgrade_admin_two); // Confirm too early, within the timelock window. env.ledger().with_mut(|ledger| ledger.timestamp = 1_000 + UPGRADE_COOLDOWN - 1); - let result = client.try_confirm_upgrade(&context.admin, &context.admin_two); + let result = client.try_confirm_upgrade(&context.upgrade_admin, &context.upgrade_admin_two); match result { Err(Ok(Error::UpgradeCooldownNotElapsed)) => {} other => panic!("expected UpgradeCooldownNotElapsed, got {:?}", other), @@ -6149,14 +6250,14 @@ fn test_upgrade_cancel_clears_proposal() { let client = PromptHashContractClient::new(&env, &context.contract); let wasm_hash = BytesN::from_array(&env, &[1u8; 32]); - client.propose_upgrade(&wasm_hash, &context.admin, &context.admin_two); + client.propose_upgrade(&wasm_hash, &context.upgrade_admin, &context.upgrade_admin_two); assert_eq!(client.get_pending_upgrade(), Some(wasm_hash)); - client.cancel_upgrade(&context.admin, &context.admin_two); + client.cancel_upgrade(&context.upgrade_admin, &context.upgrade_admin_two); assert_eq!(client.get_pending_upgrade(), None); // After cancellation, confirming must fail. - let result = client.try_confirm_upgrade(&context.admin, &context.admin_two); + let result = client.try_confirm_upgrade(&context.upgrade_admin, &context.upgrade_admin_two); match result { Err(Ok(Error::UpgradeNotProposed)) => {} other => panic!("expected UpgradeNotProposed after cancel, got {:?}", other), @@ -6171,12 +6272,12 @@ fn test_upgrade_propose_confirm_success() { env.ledger().with_mut(|ledger| ledger.timestamp = 1_000); let wasm_hash = BytesN::from_array(&env, &[1u8; 32]); - client.propose_upgrade(&wasm_hash, &context.admin, &context.admin_two); + client.propose_upgrade(&wasm_hash, &context.upgrade_admin, &context.upgrade_admin_two); assert_eq!(client.get_pending_upgrade(), Some(wasm_hash)); // Wait out the timelock, then confirm. env.ledger().with_mut(|ledger| ledger.timestamp = 1_000 + UPGRADE_COOLDOWN + 1); - client.confirm_upgrade(&context.admin, &context.admin_two); + client.confirm_upgrade(&context.upgrade_admin, &context.upgrade_admin_two); assert_eq!(client.get_pending_upgrade(), None); } @@ -6199,9 +6300,9 @@ fn test_upgrade_preserves_license_holders() { // Propose and confirm an upgrade after the timelock. let wasm_hash = BytesN::from_array(&env, &[1u8; 32]); - client.propose_upgrade(&wasm_hash, &context.admin, &context.admin_two); + client.propose_upgrade(&wasm_hash, &context.upgrade_admin, &context.upgrade_admin_two); env.ledger().with_mut(|ledger| ledger.timestamp = 1_000 + UPGRADE_COOLDOWN + 1); - client.confirm_upgrade(&context.admin, &context.admin_two); + client.confirm_upgrade(&context.upgrade_admin, &context.upgrade_admin_two); // The license holder keeps access and the listing data is intact. assert!(client.has_access(&buyer, &prompt_id)); diff --git a/contracts/prompt-hash/src/types.rs b/contracts/prompt-hash/src/types.rs index 3a5ad48b..71b38cb9 100644 --- a/contracts/prompt-hash/src/types.rs +++ b/contracts/prompt-hash/src/types.rs @@ -132,6 +132,7 @@ pub enum DataKey { Subscription(Address, Address), SubscriptionEligible(u128), AdminSigners, + UpgradeAdminSigners, Initialized, SchemaVersion, PromptEncryptedPayload(u128, u32), @@ -146,6 +147,7 @@ pub enum DataKey { UpgradeProposedAt, Discount(u128), // #192 – per-prompt price history log. + PromptExpiryWarning(u128), PriceHistory(u128), } @@ -166,7 +168,6 @@ pub struct PriceHistoryEntry { /// Monotonic per-prompt sequence number, starting at 1 for the initial /// listing price. Used to keep history entries ordered and de-duplicated. pub seq: u64, - PromptExpiryWarning(u128), } #[contracttype] @@ -384,9 +385,12 @@ pub struct Discount { pub trait PromptHashTrait { fn __constructor( env: Env, - admin: Address, - admin_two: Address, - admin_three: Address, + config_admin: Address, + config_admin_two: Address, + config_admin_three: Address, + upgrade_admin: Address, + upgrade_admin_two: Address, + upgrade_admin_three: Address, fee_wallet: Address, xlm_sac: Address, ) -> Result<(), Error>; @@ -570,7 +574,7 @@ pub trait PromptHashTrait { hashed_code: BytesN<32>, ) -> Result<(), Error>; fn get_xlm_sac(env: Env) -> Option
; - /// Propose a timelocked contract upgrade. Requires 2-of-3 admin multisig. + /// Propose a timelocked contract upgrade. Requires 2-of-3 upgrade-admin multisig. /// Records the pending WASM hash, the proposer (via the two approvers) and /// the proposal timestamp so that `confirm_upgrade` can enforce a safety /// cooldown and validate the existing on-chain state before deploying the @@ -582,12 +586,12 @@ pub trait PromptHashTrait { approver_b: Address, ) -> Result<(), Error>; /// Confirm and execute a previously proposed upgrade once the timelock - /// cooldown has elapsed. Requires 2-of-3 admin multisig. Applies upgrade + /// cooldown has elapsed. Requires 2-of-3 upgrade-admin multisig. Applies upgrade /// safety checks (implementation validity, storage integrity, license-holder /// preservation) before atomically swapping the contract bytecode. fn confirm_upgrade(env: Env, approver_a: Address, approver_b: Address) -> Result<(), Error>; /// Cancel a pending upgrade before the timelock elapses (emergency abort). - /// Requires 2-of-3 admin multisig. Clears the pending upgrade state. + /// Requires 2-of-3 upgrade-admin multisig. Clears the pending upgrade state. fn cancel_upgrade(env: Env, approver_a: Address, approver_b: Address) -> Result<(), Error>; /// Returns the currently pending WASM hash, if any. fn get_pending_upgrade(env: Env) -> Option>; diff --git a/docs/developer-quickstart.md b/docs/developer-quickstart.md index 7004361c..01ce5899 100644 --- a/docs/developer-quickstart.md +++ b/docs/developer-quickstart.md @@ -107,7 +107,7 @@ This is the fastest first contract interaction when you are onboarding without a ## 5. First live contract interaction (optional, 8+ minutes) -Use this path when you have Stellar testnet access and want a real RPC interaction. The script creates or reuses the `admin`, `admin_two`, `admin_three`, and `fee_wallet` identities, funds them through Friendbot, deploys the contract, initializes it, and calls `get_all_prompts`. +Use this path when you have Stellar testnet access and want a real RPC interaction. The script creates or reuses the `config_admin`, `config_admin_two`, `config_admin_three`, `upgrade_admin`, `upgrade_admin_two`, `upgrade_admin_three`, and `fee_wallet` identities, funds them through Friendbot, deploys the contract, initializes it, and calls `get_all_prompts`. Run from Git Bash or WSL at the repository root: diff --git a/docs/operations/contract-upgrades.md b/docs/operations/contract-upgrades.md index 3fa2d820..a3fc0061 100644 --- a/docs/operations/contract-upgrades.md +++ b/docs/operations/contract-upgrades.md @@ -2,12 +2,12 @@ PromptHash Stellar utilizes a Soroban smart contract that stores prompt listings and purchase rights. As the protocol evolves, it may be necessary to upgrade the smart contract without losing the underlying state (prompt data, purchase records, balances). -The contract implements the `Ownable` trait, meaning that the `admin` who initialized the contract has the exclusive right to upgrade the contract's Wasm logic. +Contract upgrades are authorized by the dedicated upgrade-admin signer group configured during initialization. Fee and pause configuration use a separate config-admin signer group, so a config administrator cannot propose, confirm, or cancel contract upgrades. ## Upgrade Assumptions & Requirements To successfully perform an upgrade, the following conditions must be met: -1. **Admin Key Access:** You must have access to the Stellar identity/private key that was configured as the `admin` during the contract's `__constructor` initialization. Without this key, the `upgrade` invocation will fail with an authorization error. +1. **Upgrade Admin Key Access:** You must have access to two distinct Stellar identities/private keys from the upgrade-admin signer group configured during the contract's `__constructor` initialization. Without two upgrade-admin approvals, upgrade operations fail with an authorization error. 2. **State Compatibility:** The new Wasm code must maintain state compatibility with the existing storage. This means: - Data structures (like `Prompt`) must be backward compatible if modifying existing fields. - Storage keys must not overlap unintentionally or break the current mapping of data. @@ -26,12 +26,13 @@ export CONTRACT_ID=C... ``` ### 2. Configure Your Environment -Ensure your `ADMIN_ALIAS` identity exists in your local `stellar-cli` configuration (`stellar keys address admin`). +Ensure the two upgrade admin identities you will use exist in your local `stellar-cli` configuration, for example `stellar keys address upgrade_admin` and `stellar keys address upgrade_admin_two`. By default, the script targets `testnet`. To target a different network, set the `NETWORK` variable: ```bash export NETWORK=mainnet -export ADMIN_ALIAS=admin_mainnet +export UPGRADE_ADMIN_ALIAS=upgrade_admin_mainnet +export UPGRADE_ADMIN_TWO_ALIAS=upgrade_admin_two_mainnet ``` ### 3. Run the Upgrade Script @@ -83,7 +84,7 @@ The contract tracks this with two additional owner-only/read-only endpoints: 2. Bump `CONTRACT_SCHEMA_VERSION` in `contract.rs` and add the actual migration steps to `migrate` (e.g. backfilling a new key from an old one). 3. Deploy via `upgrade` as usual. -4. Immediately call `migrate(new_version)` as the admin. Until this call +4. Immediately call `migrate(new_version)` as the config admin owner. Until this call succeeds, `get_schema_version()` still reports the old version, so off-chain tooling can detect an upgrade that hasn't been migrated yet. 5. Verify with `get_schema_version()` before resuming normal writes that diff --git a/docs/operations/deployment-runbook.md b/docs/operations/deployment-runbook.md index db52c35e..6240472a 100644 --- a/docs/operations/deployment-runbook.md +++ b/docs/operations/deployment-runbook.md @@ -86,14 +86,18 @@ Before initiating any deployment to staging or production: ```bash stellar contract invoke \ --id "$CONTRACT_ID" \ - --source ADMIN_SECRET \ + --source CONFIG_ADMIN_SECRET \ --network testnet \ -- \ - initialize \ - --admin "$ADMIN_ADDRESS" \ - --fee_percentage 250 \ + __constructor \ + --config_admin "$CONFIG_ADMIN_ADDRESS" \ + --config_admin_two "$CONFIG_ADMIN_TWO_ADDRESS" \ + --config_admin_three "$CONFIG_ADMIN_THREE_ADDRESS" \ + --upgrade_admin "$UPGRADE_ADMIN_ADDRESS" \ + --upgrade_admin_two "$UPGRADE_ADMIN_TWO_ADDRESS" \ + --upgrade_admin_three "$UPGRADE_ADMIN_THREE_ADDRESS" \ --fee_wallet "$FEE_TREASURY_ADDRESS" \ - --xlm_address "$XLM_SAC_CONTRACT_ADDRESS" + --xlm_sac "$XLM_SAC_CONTRACT_ADDRESS" ``` ### 2.3 Frontend & API Deployment on Vercel diff --git a/environments.toml b/environments.toml index a83607de..3091ef91 100644 --- a/environments.toml +++ b/environments.toml @@ -7,11 +7,21 @@ run-locally = false # automatically start the local network container, if not al [[development.accounts]] name = "me" # Required. Keys for this account will be saved to `./.stellar/identity` default = true # Optional. Whether to use this account as the `--source` for commands that need one. +[[development.accounts]] +name = "config-admin-two" +[[development.accounts]] +name = "config-admin-three" +[[development.accounts]] +name = "upgrade-admin" +[[development.accounts]] +name = "upgrade-admin-two" +[[development.accounts]] +name = "upgrade-admin-three" [development.contracts] fungible_allowlist_example = { client = true, constructor_args = "--admin me --manager me --initial_supply 1000000000000000000000000" } nft_enumerable_example = { client = true, constructor_args = "--owner me" } -prompt_hash = { client = true, constructor_args = "--admin me --fee_wallet me --xlm CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" } +prompt_hash = { client = true, constructor_args = "--config_admin me --config_admin_two config-admin-two --config_admin_three config-admin-three --upgrade_admin upgrade-admin --upgrade_admin_two upgrade-admin-two --upgrade_admin_three upgrade-admin-three --fee_wallet me --xlm_sac CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" } # Rather than in one list, TOML allows specifying contracts in their own "sections" [development.contracts.guess_the_number] @@ -75,9 +85,19 @@ network-passphrase = "Test SDF Network ; September 2015" [[staging.accounts]] name = "testnet-user" default = true +[[staging.accounts]] +name = "config-admin-two" +[[staging.accounts]] +name = "config-admin-three" +[[staging.accounts]] +name = "upgrade-admin" +[[staging.accounts]] +name = "upgrade-admin-two" +[[staging.accounts]] +name = "upgrade-admin-three" [staging.contracts] -new_prompt_hash = { client = true, constructor_args = "--admin testnet-user --fee_wallet testnet-user --xlm CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" } +new_prompt_hash = { client = true, constructor_args = "--config_admin testnet-user --config_admin_two config-admin-two --config_admin_three config-admin-three --upgrade_admin upgrade-admin --upgrade_admin_two upgrade-admin-two --upgrade_admin_three upgrade-admin-three --fee_wallet testnet-user --xlm_sac CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" } xlm_token = { client = true, id = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" } # soroban-atomic-swap-contract = { id = "C123..." } # soroban-auth-contract = { id = "C234..." } diff --git a/scripts/deploy.sh b/scripts/deploy.sh index de516aac..9a608577 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -29,9 +29,12 @@ else fi # Identities -ADMIN_ALIAS=${ADMIN_ALIAS:-admin} -ADMIN_TWO_ALIAS=${ADMIN_TWO_ALIAS:-admin_two} -ADMIN_THREE_ALIAS=${ADMIN_THREE_ALIAS:-admin_three} +CONFIG_ADMIN_ALIAS=${CONFIG_ADMIN_ALIAS:-config_admin} +CONFIG_ADMIN_TWO_ALIAS=${CONFIG_ADMIN_TWO_ALIAS:-config_admin_two} +CONFIG_ADMIN_THREE_ALIAS=${CONFIG_ADMIN_THREE_ALIAS:-config_admin_three} +UPGRADE_ADMIN_ALIAS=${UPGRADE_ADMIN_ALIAS:-upgrade_admin} +UPGRADE_ADMIN_TWO_ALIAS=${UPGRADE_ADMIN_TWO_ALIAS:-upgrade_admin_two} +UPGRADE_ADMIN_THREE_ALIAS=${UPGRADE_ADMIN_THREE_ALIAS:-upgrade_admin_three} FEE_WALLET_ALIAS=${FEE_WALLET_ALIAS:-fee_wallet} echo "🌐 Using network: $NETWORK ($STELLAR_NETWORK)" @@ -68,14 +71,20 @@ setup_identity() { fi } -setup_identity $ADMIN_ALIAS -setup_identity $ADMIN_TWO_ALIAS -setup_identity $ADMIN_THREE_ALIAS +setup_identity $CONFIG_ADMIN_ALIAS +setup_identity $CONFIG_ADMIN_TWO_ALIAS +setup_identity $CONFIG_ADMIN_THREE_ALIAS +setup_identity $UPGRADE_ADMIN_ALIAS +setup_identity $UPGRADE_ADMIN_TWO_ALIAS +setup_identity $UPGRADE_ADMIN_THREE_ALIAS setup_identity $FEE_WALLET_ALIAS -ADMIN_ADDRESS=$(stellar keys address $ADMIN_ALIAS) -ADMIN_TWO_ADDRESS=$(stellar keys address $ADMIN_TWO_ALIAS) -ADMIN_THREE_ADDRESS=$(stellar keys address $ADMIN_THREE_ALIAS) +CONFIG_ADMIN_ADDRESS=$(stellar keys address $CONFIG_ADMIN_ALIAS) +CONFIG_ADMIN_TWO_ADDRESS=$(stellar keys address $CONFIG_ADMIN_TWO_ALIAS) +CONFIG_ADMIN_THREE_ADDRESS=$(stellar keys address $CONFIG_ADMIN_THREE_ALIAS) +UPGRADE_ADMIN_ADDRESS=$(stellar keys address $UPGRADE_ADMIN_ALIAS) +UPGRADE_ADMIN_TWO_ADDRESS=$(stellar keys address $UPGRADE_ADMIN_TWO_ALIAS) +UPGRADE_ADMIN_THREE_ADDRESS=$(stellar keys address $UPGRADE_ADMIN_THREE_ALIAS) FEE_WALLET_ADDRESS=$(stellar keys address $FEE_WALLET_ALIAS) # Handle XLM SAC @@ -85,7 +94,7 @@ if [ "$NETWORK" == "testnet" ]; then XLM_SAC="CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" else # For local, we might need to deploy it if it doesn't exist - stellar contract asset deploy --asset native --source $ADMIN_ALIAS --network $NETWORK > /dev/null 2>&1 || true + stellar contract asset deploy --asset native --source $CONFIG_ADMIN_ALIAS --network $NETWORK > /dev/null 2>&1 || true XLM_SAC=$(stellar contract id asset --asset native --network $NETWORK) fi echo "XLM SAC ID: $XLM_SAC" @@ -95,7 +104,7 @@ echo "" echo "🚀 Deploying contract..." CONTRACT_ID=$(stellar contract deploy \ --wasm $WASM_PATH \ - --source $ADMIN_ALIAS \ + --source $CONFIG_ADMIN_ALIAS \ --network $NETWORK \ --alias prompt_hash) @@ -106,13 +115,16 @@ echo "" echo "⚙️ Initializing contract..." stellar contract invoke \ --id $CONTRACT_ID \ - --source $ADMIN_ALIAS \ + --source $CONFIG_ADMIN_ALIAS \ --network $NETWORK \ -- \ __constructor \ - --admin $ADMIN_ADDRESS \ - --admin_two $ADMIN_TWO_ADDRESS \ - --admin_three $ADMIN_THREE_ADDRESS \ + --config_admin $CONFIG_ADMIN_ADDRESS \ + --config_admin_two $CONFIG_ADMIN_TWO_ADDRESS \ + --config_admin_three $CONFIG_ADMIN_THREE_ADDRESS \ + --upgrade_admin $UPGRADE_ADMIN_ADDRESS \ + --upgrade_admin_two $UPGRADE_ADMIN_TWO_ADDRESS \ + --upgrade_admin_three $UPGRADE_ADMIN_THREE_ADDRESS \ --fee_wallet $FEE_WALLET_ADDRESS \ --xlm_sac $XLM_SAC @@ -153,7 +165,7 @@ echo "🔍 Running basic verification..." # Call a getter to ensure it works PROMPTS_COUNT=$(stellar contract invoke \ --id $CONTRACT_ID \ - --source $ADMIN_ALIAS \ + --source $CONFIG_ADMIN_ALIAS \ --network $NETWORK \ -- \ get_all_prompts) @@ -162,8 +174,11 @@ echo "Current prompts count: $PROMPTS_COUNT" echo "--------------------------------------------------------" echo "Deployment successful!" echo "Contract ID: $CONTRACT_ID" -echo "Admin: $ADMIN_ADDRESS" -echo "Admin 2: $ADMIN_TWO_ADDRESS" -echo "Admin 3: $ADMIN_THREE_ADDRESS" +echo "Config admin: $CONFIG_ADMIN_ADDRESS" +echo "Config admin 2: $CONFIG_ADMIN_TWO_ADDRESS" +echo "Config admin 3: $CONFIG_ADMIN_THREE_ADDRESS" +echo "Upgrade admin: $UPGRADE_ADMIN_ADDRESS" +echo "Upgrade admin 2: $UPGRADE_ADMIN_TWO_ADDRESS" +echo "Upgrade admin 3: $UPGRADE_ADMIN_THREE_ADDRESS" echo "--------------------------------------------------------"