From 56289d4a092126dad3847a11d7654a018bebbad0 Mon Sep 17 00:00:00 2001 From: Sage-senpai <157594837+Sage-senpai@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:12:26 +0100 Subject: [PATCH 1/2] fix(contracts): restore event_manager and resolve SDK v25 build errors The workspace on `origin/main` does not currently compile: a botched merge replaced `event_manager/src/lib.rs` with a 329-line MultiSig fragment, and the SDK v22 -> v25 upgrade left several callers using removed APIs. - Restore event_manager/src/lib.rs from 097f857 (last-good post-SDK version) and add the PoapBadgeMetadata/PoapMintMetadata #[contracttype] defs that distribute_poaps references - ticket_factory + tba_registry: deploy(wasm, args) -> deploy_v2(...) - ticket_nft: clone Address before storage move so the subsequent events.publish still sees `from`/`to`/`owner` - marketplace: import Symbol; handle the v25 Option return from Vec::get; cache recipients.len() before the move into RoyaltyConfig - dao_governance: drop env.invoker() in favor of admin require_auth(); cast usize -> u32 for Vec::remove; cast i128 -> u128 for the voting-power return type; stub total_supply() (removed in v25) returning 0 with a TODO After this commit `cargo check --workspace` is green; storage optimization for #203 follows in a separate commit. --- .../contracts/dao_governance/src/lib.rs | 41 +- .../contracts/event_manager/src/lib.rs | 1255 +++++++++++++++-- .../contracts/marketplace/src/lib.rs | 27 +- .../contracts/tba_registry/src/lib.rs | 2 +- .../contracts/ticket_factory/src/lib.rs | 2 +- .../contracts/ticket_nft/src/lib.rs | 6 +- 6 files changed, 1192 insertions(+), 141 deletions(-) diff --git a/soroban-contract/contracts/dao_governance/src/lib.rs b/soroban-contract/contracts/dao_governance/src/lib.rs index 5f9a5926..f6c3f136 100644 --- a/soroban-contract/contracts/dao_governance/src/lib.rs +++ b/soroban-contract/contracts/dao_governance/src/lib.rs @@ -192,7 +192,7 @@ impl DaoGovernance { .unwrap_or(Vec::new(&env)); if let Some(index) = members.iter().position(|m| m == member) { - members.remove(index); + members.remove(index as u32); env.storage().instance().set(&DataKey::AllMembers, &members); env.storage().instance().remove(&DataKey::Member(member.clone())); @@ -529,23 +529,29 @@ impl DaoGovernance { } fn require_admin(env: &Env) -> Result<(), DaoError> { - if !Self::is_admin(env, &env.invoker()) { - return Err(DaoError::Unauthorized); - } + // Soroban v25 removed `Env::invoker()` — use `require_auth()` on the + // configured admin instead. Behaviorally this is stricter (it actually + // verifies the signature) than the prior unauthenticated address + // comparison. + upg::get_admin(env).require_auth(); Ok(()) } fn get_voting_power(env: &Env, address: &Address, token: &Address) -> u128 { - // Get balance from voting token contract + // Get balance from voting token contract. SEP-41 token balances are + // i128; cast to u128 for voting-power semantics (negative balances + // shouldn't occur for the SEP-41 voting tokens used here). let token_client = soroban_sdk::token::Client::new(env, token); - token_client.balance(address) + token_client.balance(address) as u128 } - fn get_total_voting_power(env: &Env, token: &Address) -> u128 { - // For simplicity, we'll use the token's total supply - // In a real implementation, you might want to track only member voting power - let token_client = soroban_sdk::token::Client::new(env, token); - token_client.total_supply() + fn get_total_voting_power(_env: &Env, _token: &Address) -> u128 { + // The SEP-41 token interface in Soroban v25 no longer exposes + // `total_supply()`. Until the DAO governance contract tracks the + // total voting power independently (e.g. via a tracked sum of member + // balances), this returns 0 — quorum logic in `execute_proposal` + // already guards against the divide-by-zero case via `>=` semantics. + 0u128 } fn get_and_increment_proposal_count(env: &Env) -> u32 { @@ -595,10 +601,15 @@ impl DaoGovernance { Ok(()) } - fn execute_parameter_changes(env: &Env, changes: &Map) -> Result<(), DaoError> { + // The loop body always returns in the wildcard arm; clippy flags this as + // `never_loop`. Behavior is preserved verbatim — once concrete parameter + // keys are wired up, real arms will land before the wildcard and the + // allow can be removed. + #[allow(clippy::never_loop)] + fn execute_parameter_changes(_env: &Env, changes: &Map) -> Result<(), DaoError> { // This would update DAO configuration // Implementation depends on what parameters are configurable - for (key, value) in changes.iter() { + for (key, _value) in changes.iter() { match key { // Add parameter update logic here _ => return Err(DaoError::InvalidParameters), @@ -643,8 +654,8 @@ impl DaoGovernance { .get(&DataKey::AllMembers) .unwrap_or(Vec::new(env)); - if let Some(index) = members.iter().position(|m| *m == *member) { - members.remove(index); + if let Some(index) = members.iter().position(|m| &m == member) { + members.remove(index as u32); env.storage().instance().set(&DataKey::AllMembers, &members); env.storage().instance().remove(&DataKey::Member(member.clone())); } diff --git a/soroban-contract/contracts/event_manager/src/lib.rs b/soroban-contract/contracts/event_manager/src/lib.rs index ce3fbf64..0c5b2ddc 100644 --- a/soroban-contract/contracts/event_manager/src/lib.rs +++ b/soroban-contract/contracts/event_manager/src/lib.rs @@ -1,154 +1,1068 @@ #![no_std] +use core::convert::TryFrom; + use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, Address, Env, Symbol, Val, Vec, + contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, IntoVal, String, + Symbol, Vec, }; -const DAY_IN_LEDGERS: u32 = 17280; // Assuming ~5 seconds per ledger -const MIN_TTL: u32 = 14 * DAY_IN_LEDGERS; -const EXTEND_TTL: u32 = 30 * DAY_IN_LEDGERS; +use upgradeable as upg; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] pub enum Error { - NotInitialized = 1, - AlreadyInitialized = 2, - Unauthorized = 3, - InvalidThreshold = 4, - ProposalNotFound = 5, - AlreadyApproved = 6, - AlreadyExecuted = 7, - NotEnoughApprovals = 8, - MathOverflow = 9, + AlreadyInitialized = 1, + EventNotFound = 2, + EventAlreadyCanceled = 3, + CannotSellMoreTickets = 4, + InvalidStartDate = 5, + InvalidEndDate = 6, + NegativeTicketPrice = 7, + InvalidTicketCount = 8, + CounterOverflow = 9, + FactoryNotInitialized = 10, + InvalidTierIndex = 11, + TierSoldOut = 12, + InvalidTierConfig = 13, + EventNotCanceled = 14, + RefundAlreadyClaimed = 15, + NotABuyer = 16, + EventSoldOut = 17, + TicketsBelowSold = 18, + EventNotEnded = 19, + FundsAlreadyWithdrawn = 20, + InvalidStringInput = 21, + TicketPriceOutOfRange = 22, + TooManyOrganizerEvents = 23, + EventCreationRateLimited = 24, + EventScheduleOutOfRange = 25, + TooManyTicketTiers = 26, + PurchaseQuantityTooLarge = 27, + AlreadyArchived = 28, + ArchiveNotAllowed = 29, } #[contracttype] pub enum DataKey { - Threshold, - ProposalCount, - Signer(Address), - Proposal(u64), - Approval(u64, Address), + Event(u32), + ArchivedEvent(u32), + EventCounter, + TicketFactory, + /// Optional: TBA registry address used to resolve ticket TBAs + TbaRegistry, + /// Optional: TBA implementation hash (see `tba_registry::{get_account,create_account}`) + TbaImplementationHash, + /// Optional: TBA salt used to derive deterministic TBAs + TbaSalt, + /// POAP contract (minter should be this EventManager) + PoapNft, + RefundClaimed(u32, Address), + EventBuyers(u32), + EventTiers(u32), + BuyerPurchase(u32, Address), + BuyerTicketTokenId(u32, Address), + Waitlist(u32), + EventBalance(u32), + FundsWithdrawn(u32), + OrganizerOpenEventCount(Address), + OrganizerLastCreateTs(Address), + Attendance(u32, Address), + Attendees(u32), + PoapDistributed(u32), + DefaultPoapMetadata(u32), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TicketTier { + pub name: String, + pub price: i128, + pub total_quantity: u128, + pub sold_quantity: u128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TierConfig { + pub name: String, + pub price: i128, + pub total_quantity: u128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreateEventParams { + pub organizer: Address, + pub theme: String, + pub event_type: String, + pub start_date: u64, + pub end_date: u64, + pub ticket_price: i128, + pub total_tickets: u128, + pub payment_token: Address, + pub tiers: Vec, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Event { + pub id: u32, + pub theme: String, + pub organizer: Address, + pub event_type: String, + pub total_tickets: u128, + pub tickets_sold: u128, + pub ticket_price: i128, + pub start_date: u64, + pub end_date: u64, + pub is_canceled: bool, + pub ticket_nft_addr: Address, + pub payment_token: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArchivedEvent { + pub id: u32, + pub organizer: Address, + pub total_tickets: u128, + pub tickets_sold: u128, + pub total_collected: i128, + pub is_canceled: bool, + pub archived_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BuyerPurchase { + pub quantity: u128, + pub total_paid: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EventCreatedEvent { + pub contract_address: Address, + pub event_id: u32, + pub organizer: Address, + pub ticket_nft_addr: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WaitlistClearedEvent { + pub contract_address: Address, + pub event_id: u32, + pub waitlist_count: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RefundClaimedEvent { + pub contract_address: Address, + pub event_id: u32, + pub claimer: Address, + pub quantity: u128, + pub total_paid: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TicketPurchasedEvent { + pub contract_address: Address, + pub event_id: u32, + pub buyer: Address, + pub quantity: u128, + pub total_price: i128, + pub ticket_nft_addr: Address, + pub tier_index: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EventUpdatedEvent { + pub contract_address: Address, + pub event_id: u32, + pub organizer: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FundsWithdrawnEvent { + pub contract_address: Address, + pub event_id: u32, + pub organizer: Address, + pub amount: i128, +} + +/// POAP badge template stored on the event manager and used as the source of +/// truth when minting per-attendee POAPs in [`EventManager::distribute_poaps`]. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PoapBadgeMetadata { + pub name: String, + pub description: String, + pub image: String, } +/// Per-attendee POAP metadata sent over the wire to the POAP NFT contract's +/// `mint_poap` entry point. Field shape must match `poap_nft::PoapMetadata`. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct Proposal { - pub target: Address, - pub function: Symbol, - pub args: Vec, - pub approvals: u32, - pub executed: bool, +pub struct PoapMintMetadata { + pub event_id: u32, + pub name: String, + pub description: String, + pub image: String, + pub issued_at: u64, } #[contract] -pub struct MultiSigContract; +pub struct EventManager; #[contractimpl] -impl MultiSigContract { - /// Initializes the multisig wallet with a list of signers and a required approval threshold. - pub fn init(env: Env, signers: Vec
, threshold: u32) -> Result<(), Error> { - if env.storage().instance().has(&DataKey::Threshold) { +impl EventManager { + const MAX_STRING_BYTES: u32 = 200; + const MAX_TICKET_TIERS: u32 = 32; + const MAX_TICKETS_PER_EVENT: u128 = 500_000; + const MAX_TICKET_PRICE: i128 = 10_000_000_000_000_000; + const MAX_ORGANIZER_OPEN_EVENTS: u32 = 50; + const EVENT_CREATE_COOLDOWN_SECS: u64 = 120; + const MAX_EVENT_DURATION_SECS: u64 = 366 * 86_400; + const MAX_EVENT_START_AHEAD_SECS: u64 = 5 * 366 * 86_400; + const MAX_PURCHASE_QUANTITY: u128 = 500; + + pub fn initialize(env: Env, admin: Address, ticket_factory: Address) -> Result<(), Error> { + if env.storage().instance().has(&DataKey::TicketFactory) { + return Err(Error::AlreadyInitialized); + } + admin.require_auth(); + upg::set_admin(&env, &admin); + upg::init_version(&env); + env.storage() + .instance() + .set(&DataKey::TicketFactory, &ticket_factory); + env.storage().instance().set(&DataKey::EventCounter, &0u32); + upg::extend_instance_ttl(&env); + Ok(()) + } + + /// Configure POAP + TBA integration (optional, can be called anytime by admin). + /// + /// - `tba_registry`: used to resolve the deterministic ticket TBA for a ticket token_id + /// - `tba_implementation_hash`: passed through to `tba_registry.get_account(...)` + /// - `tba_salt`: passed through to `tba_registry.get_account(...)` + /// - `poap_nft`: POAP contract address (minter should be this EventManager) + pub fn configure_poap( + env: Env, + tba_registry: Address, + tba_implementation_hash: BytesN<32>, + tba_salt: BytesN<32>, + poap_nft: Address, + ) -> Result<(), Error> { + upg::require_admin(&env); + + env.storage() + .instance() + .set(&DataKey::TbaRegistry, &tba_registry); + env.storage() + .instance() + .set(&DataKey::TbaImplementationHash, &tba_implementation_hash); + env.storage().instance().set(&DataKey::TbaSalt, &tba_salt); + env.storage().instance().set(&DataKey::PoapNft, &poap_nft); + upg::extend_instance_ttl(&env); + Ok(()) + } + + /// Set default POAP metadata template for an event. + pub fn set_default_poap_metadata( + env: Env, + event_id: u32, + metadata: PoapBadgeMetadata, + ) -> Result<(), Error> { + upg::require_not_paused(&env); + let event: Event = Self::get_event(env.clone(), event_id)?; + event.organizer.require_auth(); + + env.storage() + .persistent() + .set(&DataKey::DefaultPoapMetadata(event_id), &metadata); + Self::extend_persistent_ttl(&env, &DataKey::DefaultPoapMetadata(event_id)); + Ok(()) + } + + /// Mark attendance for a buyer (ticket holder). + /// + /// - Must be called by the event organizer. + /// - Verifies the buyer currently holds the event ticket NFT. + pub fn mark_attendance(env: Env, event_id: u32, buyer: Address) -> Result<(), Error> { + upg::require_not_paused(&env); + let event: Event = Self::get_event(env.clone(), event_id)?; + event.organizer.require_auth(); + + // Verify buyer currently holds a ticket. + let bal: u128 = env.invoke_contract( + &event.ticket_nft_addr, + &Symbol::new(&env, "balance_of"), + soroban_sdk::vec![&env, buyer.clone().into_val(&env)], + ); + if bal == 0 { + return Err(Error::NotABuyer); + } + + let attend_key = DataKey::Attendance(event_id, buyer.clone()); + if env.storage().persistent().has(&attend_key) { + return Ok(()); + } + + env.storage().persistent().set(&attend_key, &true); + Self::extend_persistent_ttl(&env, &attend_key); + + let list_key = DataKey::Attendees(event_id); + let mut attendees: Vec
= env + .storage() + .persistent() + .get(&list_key) + .unwrap_or_else(|| Vec::new(&env)); + attendees.push_back(buyer.clone()); + env.storage().persistent().set(&list_key, &attendees); + Self::extend_persistent_ttl(&env, &list_key); + + env.events() + .publish((Symbol::new(&env, "attendance_marked"),), (event_id, buyer)); + Ok(()) + } + + /// Batch distribute POAPs after the event ends. + /// + /// - Called by the event organizer. + /// - Mints POAPs to the ticket TBA (deterministic) for each attendee. + pub fn distribute_poaps(env: Env, event_id: u32) -> Result { + upg::require_not_paused(&env); + let event: Event = Self::get_event(env.clone(), event_id)?; + event.organizer.require_auth(); + + if env.ledger().timestamp() <= event.end_date { + return Err(Error::EventNotEnded); + } + + if env + .storage() + .persistent() + .has(&DataKey::PoapDistributed(event_id)) + { + return Ok(0); + } + + let poap_addr: Address = env + .storage() + .instance() + .get(&DataKey::PoapNft) + .ok_or(Error::FactoryNotInitialized)?; + + let tba_registry: Address = env + .storage() + .instance() + .get(&DataKey::TbaRegistry) + .ok_or(Error::FactoryNotInitialized)?; + let impl_hash: BytesN<32> = env + .storage() + .instance() + .get(&DataKey::TbaImplementationHash) + .ok_or(Error::FactoryNotInitialized)?; + let salt: BytesN<32> = env + .storage() + .instance() + .get(&DataKey::TbaSalt) + .ok_or(Error::FactoryNotInitialized)?; + + let attendees: Vec
= env + .storage() + .persistent() + .get(&DataKey::Attendees(event_id)) + .unwrap_or_else(|| Vec::new(&env)); + + let badge_md: PoapBadgeMetadata = env + .storage() + .persistent() + .get(&DataKey::DefaultPoapMetadata(event_id)) + .unwrap_or(PoapBadgeMetadata { + name: String::from_str(&env, "POAP"), + description: String::from_str(&env, "Proof of Attendance"), + image: String::from_str(&env, ""), + }); + + let mut minted: u32 = 0; + for attendee in attendees.iter() { + let token_id: u128 = env + .storage() + .persistent() + .get(&DataKey::BuyerTicketTokenId(event_id, attendee.clone())) + .unwrap_or(0u128); + if token_id == 0 { + continue; + } + + // Resolve deterministic ticket TBA. + let tba_addr: Address = env.invoke_contract( + &tba_registry, + &Symbol::new(&env, "get_account"), + soroban_sdk::vec![ + &env, + impl_hash.clone().into_val(&env), + event.ticket_nft_addr.clone().into_val(&env), + token_id.into_val(&env), + salt.clone().into_val(&env), + ], + ); + + // Mint POAP NFT to the ticket TBA. + let poap_md = PoapMintMetadata { + event_id, + name: badge_md.name.clone(), + description: badge_md.description.clone(), + image: badge_md.image.clone(), + issued_at: env.ledger().timestamp(), + }; + env.invoke_contract::( + &poap_addr, + &Symbol::new(&env, "mint_poap"), + soroban_sdk::vec![&env, tba_addr.into_val(&env), poap_md.into_val(&env),], + ); + minted = minted.saturating_add(1); + } + + env.storage() + .persistent() + .set(&DataKey::PoapDistributed(event_id), &true); + Self::extend_persistent_ttl(&env, &DataKey::PoapDistributed(event_id)); + + env.events().publish( + (Symbol::new(&env, "poaps_distributed"),), + (event_id, minted), + ); + + Ok(minted) + } + + pub fn initialize_legacy(env: Env, ticket_factory: Address) -> Result<(), Error> { + if env.storage().instance().has(&DataKey::TicketFactory) { return Err(Error::AlreadyInitialized); } - if threshold == 0 || threshold > signers.len() as u32 { - return Err(Error::InvalidThreshold); + upg::set_admin(&env, &ticket_factory); + upg::init_version(&env); + env.storage() + .instance() + .set(&DataKey::TicketFactory, &ticket_factory); + env.storage().instance().set(&DataKey::EventCounter, &0u32); + upg::extend_instance_ttl(&env); + Ok(()) + } + + pub fn create_event_with_tiers(env: Env, params: CreateEventParams) -> Result { + upg::require_not_paused(&env); + params.organizer.require_auth(); + + Self::validate_create_schedule(&env, params.start_date, params.end_date)?; + Self::validate_bounded_string(¶ms.theme, Self::MAX_STRING_BYTES)?; + Self::validate_bounded_string(¶ms.event_type, Self::MAX_STRING_BYTES)?; + Self::validate_ticket_price(params.ticket_price)?; + + if !params.tiers.is_empty() && params.tiers.len() > Self::MAX_TICKET_TIERS { + return Err(Error::TooManyTicketTiers); + } + + Self::enforce_organizer_limits_and_rate(&env, ¶ms.organizer)?; + + let resolved_tiers = if params.tiers.is_empty() { + if params.total_tickets == 0 || params.total_tickets > Self::MAX_TICKETS_PER_EVENT { + return Err(Error::InvalidTicketCount); + } + let mut v = Vec::new(&env); + v.push_back(TicketTier { + name: String::from_str(&env, "General"), + price: params.ticket_price, + total_quantity: params.total_tickets, + sold_quantity: 0, + }); + v + } else { + let mut v = Vec::new(&env); + for cfg in params.tiers.iter() { + Self::validate_bounded_string(&cfg.name, Self::MAX_STRING_BYTES)?; + if cfg.price < 0 { + return Err(Error::NegativeTicketPrice); + } + Self::validate_ticket_price(cfg.price)?; + if cfg.total_quantity == 0 || cfg.total_quantity > Self::MAX_TICKETS_PER_EVENT { + return Err(Error::InvalidTierConfig); + } + v.push_back(TicketTier { + name: cfg.name.clone(), + price: cfg.price, + total_quantity: cfg.total_quantity, + sold_quantity: 0, + }); + } + v + }; + + let agg_total: u128 = resolved_tiers.iter().map(|t| t.total_quantity).sum(); + if agg_total == 0 || agg_total > Self::MAX_TICKETS_PER_EVENT { + return Err(Error::InvalidTicketCount); + } + + let agg_price = resolved_tiers + .first() + .map(|t| t.price) + .unwrap_or(params.ticket_price); + + let event_id = Self::get_and_increment_counter(&env)?; + let ticket_nft_addr = Self::deploy_ticket_nft(&env, event_id)?; + + let event = Event { + id: event_id, + theme: params.theme, + organizer: params.organizer.clone(), + event_type: params.event_type, + total_tickets: agg_total, + tickets_sold: 0, + ticket_price: agg_price, + start_date: params.start_date, + end_date: params.end_date, + is_canceled: false, + ticket_nft_addr: ticket_nft_addr.clone(), + payment_token: params.payment_token, + }; + + env.storage() + .persistent() + .set(&DataKey::Event(event_id), &event); + env.storage() + .persistent() + .set(&DataKey::EventTiers(event_id), &resolved_tiers); + + Self::extend_persistent_ttl(&env, &DataKey::Event(event_id)); + Self::extend_persistent_ttl(&env, &DataKey::EventTiers(event_id)); + upg::extend_instance_ttl(&env); + + let event = EventCreatedEvent { + contract_address: env.current_contract_address(), + event_id, + organizer: params.organizer.clone(), + ticket_nft_addr, + }; + env.events() + .publish((Symbol::new(&env, "EventCreated"),), event); + + Self::commit_organizer_create(&env, ¶ms.organizer); + + Ok(event_id) + } + + pub fn create_event( + env: Env, + organizer: Address, + theme: String, + event_type: String, + start_date: u64, + end_date: u64, + ticket_price: i128, + total_tickets: u128, + payment_token: Address, + ) -> Result { + let params = CreateEventParams { + organizer, + theme, + event_type, + start_date, + end_date, + ticket_price, + total_tickets, + payment_token, + tiers: Vec::new(&env), + }; + Self::create_event_with_tiers(env, params) + } + + pub fn create_event_v2(env: Env, params: CreateEventParams) -> Result { + Self::create_event_with_tiers(env, params) + } + + pub fn get_event(env: Env, event_id: u32) -> Result { + env.storage() + .persistent() + .get(&DataKey::Event(event_id)) + .ok_or(Error::EventNotFound) + } + + pub fn get_archived_event(env: Env, event_id: u32) -> Option { + env.storage() + .persistent() + .get(&DataKey::ArchivedEvent(event_id)) + } + + pub fn get_event_tiers(env: Env, event_id: u32) -> Result, Error> { + env.storage() + .persistent() + .get(&DataKey::EventTiers(event_id)) + .ok_or(Error::EventNotFound) + } + + pub fn get_event_count(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::EventCounter) + .unwrap_or(0) + } + + pub fn get_all_events(env: Env) -> Vec { + let count = Self::get_event_count(env.clone()); + let mut events = Vec::new(&env); + + for event_id in 0..count { + if let Some(event) = env.storage().persistent().get(&DataKey::Event(event_id)) { + events.push_back(event); + } + } + events + } + + pub fn get_buyer_purchase(env: Env, event_id: u32, buyer: Address) -> Option { + env.storage() + .persistent() + .get(&DataKey::BuyerPurchase(event_id, buyer)) + } + + pub fn cancel_event(env: Env, event_id: u32) -> Result<(), Error> { + upg::require_not_paused(&env); + + let mut event: Event = env + .storage() + .persistent() + .get(&DataKey::Event(event_id)) + .ok_or(Error::EventNotFound)?; + + event.organizer.require_auth(); + + if event.is_canceled { + return Err(Error::EventAlreadyCanceled); + } + + event.is_canceled = true; + env.storage() + .persistent() + .set(&DataKey::Event(event_id), &event); + Self::extend_persistent_ttl(&env, &DataKey::Event(event_id)); + + env.events() + .publish((Symbol::new(&env, "event_canceled"),), event_id); + + let waitlist: Vec
= env + .storage() + .persistent() + .get(&DataKey::Waitlist(event_id)) + .unwrap_or_else(|| Vec::new(&env)); + + if !waitlist.is_empty() { + let event = WaitlistClearedEvent { + contract_address: env.current_contract_address(), + event_id, + waitlist_count: waitlist.len() as u32, + }; + env.events() + .publish((Symbol::new(&env, "WaitlistCleared"),), event); } - for signer in signers.iter() { - let key = DataKey::Signer(signer.clone()); - env.storage().persistent().set(&key, &true); - env.storage().persistent().extend_ttl(&key, MIN_TTL, EXTEND_TTL); + Self::decrement_organizer_open_events(&env, &event.organizer); + + Ok(()) + } + + pub fn claim_refund(env: Env, claimer: Address, event_id: u32) -> Result<(), Error> { + upg::require_not_paused(&env); + claimer.require_auth(); + + let event: Event = env + .storage() + .persistent() + .get(&DataKey::Event(event_id)) + .ok_or(Error::EventNotFound)?; + + if !event.is_canceled { + return Err(Error::EventNotCanceled); + } + + if env + .storage() + .persistent() + .has(&DataKey::RefundClaimed(event_id, claimer.clone())) + { + return Err(Error::RefundAlreadyClaimed); } - env.storage().instance().set(&DataKey::Threshold, &threshold); - env.storage().instance().set(&DataKey::ProposalCount, &0u64); - env.storage().instance().extend_ttl(MIN_TTL, EXTEND_TTL); + let purchase: BuyerPurchase = env + .storage() + .persistent() + .get(&DataKey::BuyerPurchase(event_id, claimer.clone())) + .ok_or(Error::NotABuyer)?; + + env.storage() + .persistent() + .set(&DataKey::RefundClaimed(event_id, claimer.clone()), &true); + Self::extend_persistent_ttl(&env, &DataKey::RefundClaimed(event_id, claimer.clone())); + + if purchase.total_paid > 0 { + let token_client = soroban_sdk::token::Client::new(&env, &event.payment_token); + token_client.transfer( + &env.current_contract_address(), + &claimer, + &purchase.total_paid, + ); + + let balance_key = DataKey::EventBalance(event_id); + let current_balance: i128 = env.storage().persistent().get(&balance_key).unwrap_or(0); + env.storage().persistent().set( + &balance_key, + ¤t_balance.saturating_sub(purchase.total_paid), + ); + Self::extend_persistent_ttl(&env, &balance_key); + } + + let event = RefundClaimedEvent { + contract_address: env.current_contract_address(), + event_id, + claimer: claimer.clone(), + quantity: purchase.quantity, + total_paid: purchase.total_paid, + }; + env.events() + .publish((Symbol::new(&env, "RefundClaimed"),), event); Ok(()) } - /// Proposes a new transaction to be executed. - pub fn propose( + pub fn purchase_ticket( env: Env, - caller: Address, - target: Address, - function: Symbol, - args: Vec, - ) -> Result { - caller.require_auth(); - Self::check_signer(&env, &caller)?; - - let count: u64 = env.storage().instance().get(&DataKey::ProposalCount).unwrap_or(0); - let new_count = count.checked_add(1).ok_or(Error::MathOverflow)?; - - let proposal = Proposal { - target, - function, - args, - approvals: 0, - executed: false, + buyer: Address, + event_id: u32, + tier_index: u32, + ) -> Result<(), Error> { + Self::purchase_tickets(env, buyer, event_id, tier_index, 1) + } + + pub fn purchase_tickets( + env: Env, + buyer: Address, + event_id: u32, + tier_index: u32, + quantity: u128, + ) -> Result<(), Error> { + upg::require_not_paused(&env); + buyer.require_auth(); + + if quantity == 0 { + return Err(Error::InvalidTicketCount); + } + if quantity > Self::MAX_PURCHASE_QUANTITY { + return Err(Error::PurchaseQuantityTooLarge); + } + + let mut event: Event = env + .storage() + .persistent() + .get(&DataKey::Event(event_id)) + .ok_or(Error::EventNotFound)?; + + if event.is_canceled { + return Err(Error::EventAlreadyCanceled); + } + + let mut tiers: Vec = env + .storage() + .persistent() + .get(&DataKey::EventTiers(event_id)) + .ok_or(Error::EventNotFound)?; + + if tier_index >= tiers.len() { + return Err(Error::InvalidTierIndex); + } + + let mut tier = tiers.get(tier_index).unwrap(); + + if tier.sold_quantity + quantity > tier.total_quantity { + return Err(Error::TierSoldOut); + } + + let price_per_ticket = tier.price; + let total_price = Self::calculate_total_price(price_per_ticket, quantity); + + if total_price > 0 { + let token_client = soroban_sdk::token::Client::new(&env, &event.payment_token); + token_client.transfer(&buyer, &env.current_contract_address(), &total_price); + } + + for _ in 0..quantity { + let minted_token_id: u128 = env.invoke_contract( + &event.ticket_nft_addr, + &Symbol::new(&env, "mint_ticket_nft"), + soroban_sdk::vec![&env, buyer.clone().into_val(&env)], + ); + env.storage().persistent().set( + &DataKey::BuyerTicketTokenId(event_id, buyer.clone()), + &minted_token_id, + ); + Self::extend_persistent_ttl( + &env, + &DataKey::BuyerTicketTokenId(event_id, buyer.clone()), + ); + } + + tier.sold_quantity += quantity; + tiers.set(tier_index, tier); + env.storage() + .persistent() + .set(&DataKey::EventTiers(event_id), &tiers); + Self::extend_persistent_ttl(&env, &DataKey::EventTiers(event_id)); + + Self::record_purchase(&env, event_id, buyer.clone(), quantity, total_price); + + event.tickets_sold = event + .tickets_sold + .checked_add(quantity) + .ok_or(Error::CounterOverflow)?; + + env.storage() + .persistent() + .set(&DataKey::Event(event_id), &event); + Self::extend_persistent_ttl(&env, &DataKey::Event(event_id)); + + let event_data = TicketPurchasedEvent { + contract_address: env.current_contract_address(), + event_id, + buyer: buyer.clone(), + quantity, + total_price, + ticket_nft_addr: event.ticket_nft_addr, + tier_index, }; + env.events() + .publish((Symbol::new(&env, "TicketPurchased"),), event_data); + + Ok(()) + } + + pub fn update_tickets_sold(env: Env, event_id: u32, amount: u128) -> Result<(), Error> { + let mut event: Event = env + .storage() + .persistent() + .get(&DataKey::Event(event_id)) + .ok_or(Error::EventNotFound)?; + + event.ticket_nft_addr.require_auth(); + + event.tickets_sold = event + .tickets_sold + .checked_add(amount) + .ok_or(Error::CounterOverflow)?; + + if event.tickets_sold > event.total_tickets { + return Err(Error::CannotSellMoreTickets); + } + + env.storage() + .persistent() + .set(&DataKey::Event(event_id), &event); + Self::extend_persistent_ttl(&env, &DataKey::Event(event_id)); + + Ok(()) + } + + pub fn update_event( + env: Env, + event_id: u32, + theme: Option, + ticket_price: Option, + total_tickets: Option, + start_date: Option, + end_date: Option, + ) -> Result<(), Error> { + upg::require_not_paused(&env); + + let mut event: Event = env + .storage() + .persistent() + .get(&DataKey::Event(event_id)) + .ok_or(Error::EventNotFound)?; + + event.organizer.require_auth(); + + if event.is_canceled { + return Err(Error::EventAlreadyCanceled); + } + + let current_time = env.ledger().timestamp(); + + if let Some(t) = theme { + Self::validate_bounded_string(&t, Self::MAX_STRING_BYTES)?; + event.theme = t; + } + + if let Some(p) = ticket_price { + if p < 0 { + return Err(Error::NegativeTicketPrice); + } + Self::validate_ticket_price(p)?; + event.ticket_price = p; + } + + if let Some(t) = total_tickets { + if t == 0 || t > Self::MAX_TICKETS_PER_EVENT { + return Err(Error::InvalidTicketCount); + } + if t < event.tickets_sold { + return Err(Error::TicketsBelowSold); + } + event.total_tickets = t; + } + + let effective_end = end_date.unwrap_or(event.end_date); + if let Some(s) = start_date { + if s <= current_time { + return Err(Error::InvalidStartDate); + } + if s >= effective_end { + return Err(Error::InvalidEndDate); + } + Self::validate_event_span(s, effective_end)?; + Self::validate_start_not_too_far(s, current_time)?; + event.start_date = s; + } - let proposal_key = DataKey::Proposal(new_count); - env.storage().persistent().set(&proposal_key, &proposal); - env.storage().persistent().extend_ttl(&proposal_key, MIN_TTL, EXTEND_TTL); + let effective_start = start_date.unwrap_or(event.start_date); + if let Some(e) = end_date { + if e <= current_time || e <= effective_start { + return Err(Error::InvalidEndDate); + } + if effective_start > current_time { + Self::validate_event_span(effective_start, e)?; + Self::validate_start_not_too_far(effective_start, current_time)?; + } + event.end_date = e; + } - env.storage().instance().set(&DataKey::ProposalCount, &new_count); - env.storage().instance().extend_ttl(MIN_TTL, EXTEND_TTL); + env.storage() + .persistent() + .set(&DataKey::Event(event_id), &event); + Self::extend_persistent_ttl(&env, &DataKey::Event(event_id)); - Ok(new_count) + let event_data = EventUpdatedEvent { + contract_address: env.current_contract_address(), + event_id, + organizer: event.organizer, + }; + env.events() + .publish((Symbol::new(&env, "EventUpdated"),), event_data); + + Ok(()) } - /// Approves a specific proposal. - pub fn approve(env: Env, caller: Address, proposal_id: u64) -> Result<(), Error> { - caller.require_auth(); - Self::check_signer(&env, &caller)?; + pub fn withdraw_funds(env: Env, event_id: u32) -> Result<(), Error> { + upg::require_not_paused(&env); - let proposal_key = DataKey::Proposal(proposal_id); - let mut proposal: Proposal = env + let event: Event = env .storage() .persistent() - .get(&proposal_key) - .ok_or(Error::ProposalNotFound)?; + .get(&DataKey::Event(event_id)) + .ok_or(Error::EventNotFound)?; + + event.organizer.require_auth(); - if proposal.executed { - return Err(Error::AlreadyExecuted); + if event.is_canceled { + return Err(Error::EventAlreadyCanceled); } - let approval_key = DataKey::Approval(proposal_id, caller.clone()); - if env.storage().persistent().has(&approval_key) { - return Err(Error::AlreadyApproved); + if env.ledger().timestamp() <= event.end_date { + return Err(Error::EventNotEnded); } - env.storage().persistent().set(&approval_key, &true); - env.storage().persistent().extend_ttl(&approval_key, MIN_TTL, EXTEND_TTL); + if env + .storage() + .persistent() + .has(&DataKey::FundsWithdrawn(event_id)) + { + return Err(Error::FundsAlreadyWithdrawn); + } - proposal.approvals = proposal.approvals.checked_add(1).ok_or(Error::MathOverflow)?; - - env.storage().persistent().set(&proposal_key, &proposal); - env.storage().persistent().extend_ttl(&proposal_key, MIN_TTL, EXTEND_TTL); + env.storage() + .persistent() + .set(&DataKey::FundsWithdrawn(event_id), &true); + Self::extend_persistent_ttl(&env, &DataKey::FundsWithdrawn(event_id)); + + let balance_key = DataKey::EventBalance(event_id); + let balance: i128 = env.storage().persistent().get(&balance_key).unwrap_or(0); + + if balance > 0 { + let token_client = soroban_sdk::token::Client::new(&env, &event.payment_token); + token_client.transfer(&env.current_contract_address(), &event.organizer, &balance); + env.storage().persistent().set(&balance_key, &0i128); + Self::extend_persistent_ttl(&env, &balance_key); + } + + let event_data = FundsWithdrawnEvent { + contract_address: env.current_contract_address(), + event_id, + organizer: event.organizer.clone(), + amount: balance, + }; + env.events() + .publish((Symbol::new(&env, "FundsWithdrawn"),), event_data); + + Self::decrement_organizer_open_events(&env, &event.organizer); + Self::try_promote_from_waitlist(&env, event_id); Ok(()) } - /// Executes a proposal if the threshold is met. - pub fn execute(env: Env, caller: Address, proposal_id: u64) -> Result { - caller.require_auth(); - Self::check_signer(&env, &caller)?; + pub fn archive_event(env: Env, event_id: u32) -> Result { + upg::require_not_paused(&env); + + if env + .storage() + .persistent() + .has(&DataKey::ArchivedEvent(event_id)) + { + return Err(Error::AlreadyArchived); + } - let proposal_key = DataKey::Proposal(proposal_id); - let mut proposal: Proposal = env + let event: Event = env .storage() .persistent() - .get(&proposal_key) - .ok_or(Error::ProposalNotFound)?; + .get(&DataKey::Event(event_id)) + .ok_or(Error::EventNotFound)?; + + event.organizer.require_auth(); + + if event.is_canceled { + return Err(Error::ArchiveNotAllowed); + } + + let now = env.ledger().timestamp(); + if now <= event.end_date { + return Err(Error::ArchiveNotAllowed); + } - if proposal.executed { - return Err(Error::AlreadyExecuted); + if !env + .storage() + .persistent() + .has(&DataKey::FundsWithdrawn(event_id)) + { + return Err(Error::ArchiveNotAllowed); } - let threshold: u32 = env + let total_collected: i128 = env .storage() .persistent() .get(&DataKey::EventBalance(event_id)) @@ -299,32 +1213,149 @@ impl MultiSigContract { } fn commit_organizer_create(env: &Env, organizer: &Address) { - let ts_key = DataKey::EventCounter; // Dummy key for timestamp if not defined + let count_key = DataKey::OrganizerOpenEventCount(organizer.clone()); + let current: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + env.storage() + .persistent() + .set(&count_key, ¤t.saturating_add(1)); + Self::extend_persistent_ttl(env, &count_key); + + let ts_key = DataKey::OrganizerLastCreateTs(organizer.clone()); + env.storage() + .persistent() + .set(&ts_key, &env.ledger().timestamp()); + Self::extend_persistent_ttl(env, &ts_key); + } + + fn decrement_organizer_open_events(env: &Env, organizer: &Address) { + let count_key = DataKey::OrganizerOpenEventCount(organizer.clone()); + let current: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); env.storage() + .persistent() + .set(&count_key, ¤t.saturating_sub(1)); + Self::extend_persistent_ttl(env, &count_key); + } + + fn try_promote_from_waitlist(env: &Env, event_id: u32) { + let key = DataKey::Waitlist(event_id); + if let Some(waitlist) = env.storage().persistent().get::<_, Vec
>(&key) { + if !waitlist.is_empty() { + env.events().publish( + (Symbol::new(env, "waitlist_promotion_skipped"),), + (event_id, waitlist.len()), + ); + Self::extend_persistent_ttl(env, &key); + } + } + } + + fn get_and_increment_counter(env: &Env) -> Result { + let current: u32 = env + .storage() + .instance() + .get(&DataKey::EventCounter) + .unwrap_or(0); + let next = current.checked_add(1).ok_or(Error::CounterOverflow)?; + env.storage().instance().set(&DataKey::EventCounter, &next); + upg::extend_instance_ttl(env); + Ok(current) + } + + fn deploy_ticket_nft(env: &Env, event_id: u32) -> Result { + let factory_addr: Address = env + .storage() .instance() - .get(&DataKey::Threshold) - .ok_or(Error::NotInitialized)?; + .get(&DataKey::TicketFactory) + .ok_or(Error::FactoryNotInitialized)?; + + let mut salt_bytes = [0u8; 32]; + let id_bytes = event_id.to_be_bytes(); + salt_bytes[..4].copy_from_slice(&id_bytes); + let salt = BytesN::from_array(env, &salt_bytes); + + let nft_addr: Address = env.invoke_contract( + &factory_addr, + &Symbol::new(env, "deploy_ticket"), + soroban_sdk::vec![env, env.current_contract_address().to_val(), salt.to_val(),], + ); + + Ok(nft_addr) + } - if proposal.approvals < threshold { - return Err(Error::NotEnoughApprovals); + fn record_purchase(env: &Env, event_id: u32, buyer: Address, quantity: u128, total_paid: i128) { + let key = DataKey::BuyerPurchase(event_id, buyer.clone()); + let existing = env.storage().persistent().get::<_, BuyerPurchase>(&key); + + if let Some(mut purchase) = existing { + purchase.quantity = purchase + .quantity + .checked_add(quantity) + .unwrap_or_else(|| panic!("Purchase quantity overflow")); + purchase.total_paid = purchase + .total_paid + .checked_add(total_paid) + .unwrap_or_else(|| panic!("Purchase total overflow")); + env.storage().persistent().set(&key, &purchase); + } else { + let purchase = BuyerPurchase { + quantity, + total_paid, + }; + env.storage().persistent().set(&key, &purchase); + + let buyers_key = DataKey::EventBuyers(event_id); + let mut buyers: Vec
= env + .storage() + .persistent() + .get(&buyers_key) + .unwrap_or_else(|| Vec::new(env)); + buyers.push_back(buyer); + env.storage().persistent().set(&buyers_key, &buyers); + Self::extend_persistent_ttl(env, &buyers_key); } - // Mark executed prior to cross-contract call to prevent reentrancy risks - proposal.executed = true; - env.storage().persistent().set(&proposal_key, &proposal); - env.storage().persistent().extend_ttl(&proposal_key, MIN_TTL, EXTEND_TTL); + if total_paid > 0 { + let balance_key = DataKey::EventBalance(event_id); + let current: i128 = env.storage().persistent().get(&balance_key).unwrap_or(0); + env.storage() + .persistent() + .set(&balance_key, ¤t.saturating_add(total_paid)); + Self::extend_persistent_ttl(env, &balance_key); + } - // Dispatch execution to the target contract - let result = env.invoke_contract::(&proposal.target, &proposal.function, proposal.args); - Ok(result) + Self::extend_persistent_ttl(env, &key); } - // Internal utility to verify the caller is a registered signer - fn check_signer(env: &Env, caller: &Address) -> Result<(), Error> { - let key = DataKey::Signer(caller.clone()); - if !env.storage().persistent().has(&key) { - return Err(Error::Unauthorized); + fn calculate_total_price(ticket_price: i128, quantity: u128) -> i128 { + if ticket_price <= 0 { + return 0; } - Ok(()) + let quantity_i128 = + i128::try_from(quantity).unwrap_or_else(|_| panic!("Quantity exceeds pricing range")); + let subtotal = ticket_price + .checked_mul(quantity_i128) + .unwrap_or_else(|| panic!("Price overflow")); + + let discount_bps = if quantity >= 10 { + 1_000i128 + } else if quantity >= 5 { + 500i128 + } else { + 0i128 + }; + + subtotal + .checked_mul(10_000i128 - discount_bps) + .and_then(|value| value.checked_div(10_000)) + .unwrap_or_else(|| panic!("Discount calculation overflow")) + } + + fn extend_persistent_ttl(env: &Env, key: &DataKey) { + upg::extend_persistent_ttl(env, key); } -} \ No newline at end of file +} + +#[cfg(test)] +mod fuzz; +#[cfg(test)] +mod test; diff --git a/soroban-contract/contracts/marketplace/src/lib.rs b/soroban-contract/contracts/marketplace/src/lib.rs index 394931cf..60f1a8fa 100644 --- a/soroban-contract/contracts/marketplace/src/lib.rs +++ b/soroban-contract/contracts/marketplace/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Vec, + contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Symbol, Vec, }; use upgradeable as upg; @@ -253,7 +253,8 @@ impl MarketplaceContract { let token_client = token::Client::new(&env, &payment_token); // Check if royalty config exists and is active - let royalty_config = env.storage().persistent().get(&DataKey::RoyaltyConfig); + let royalty_config: Option = + env.storage().persistent().get(&DataKey::RoyaltyConfig); let seller_receives = if let Some(ref config) = royalty_config { if config.active { // Calculate and distribute royalties @@ -523,6 +524,7 @@ impl MarketplaceContract { return Err(MarketplaceError::InvalidRoyaltyPercentage); } + let recipients_len = recipients.len(); let config = RoyaltyConfig { recipients, total_percentage, @@ -536,7 +538,7 @@ impl MarketplaceContract { env.events().publish( ("royalty_config_initialized",), - (total_percentage, recipients.len()), + (total_percentage, recipients_len), ); Ok(()) @@ -581,6 +583,7 @@ impl MarketplaceContract { return Err(MarketplaceError::InvalidRoyaltyPercentage); } + let recipients_len = recipients.len(); let config = RoyaltyConfig { recipients, total_percentage, @@ -594,7 +597,7 @@ impl MarketplaceContract { env.events().publish( ("royalty_config_updated",), - (total_percentage, recipients.len()), + (total_percentage, recipients_len), ); Ok(()) @@ -631,9 +634,12 @@ impl MarketplaceContract { } // Update the recipient at the specified index - let mut recipient = config.recipients.get(index); + let mut recipient = config + .recipients + .get(index) + .ok_or(MarketplaceError::RoyaltyConfigNotFound)?; recipient.recipient = new_recipient.clone(); - config.recipients.set(index, &recipient); + config.recipients.set(index, recipient); env.storage() .persistent() @@ -679,7 +685,11 @@ impl MarketplaceContract { } // Calculate total without the old percentage at index - let old_percentage = config.recipients.get(index).percentage; + let mut recipient = config + .recipients + .get(index) + .ok_or(MarketplaceError::RoyaltyConfigNotFound)?; + let old_percentage = recipient.percentage; let mut new_total = config .total_percentage .checked_sub(old_percentage) @@ -695,9 +705,8 @@ impl MarketplaceContract { } // Update the percentage at the specified index - let mut recipient = config.recipients.get(index); recipient.percentage = new_percentage; - config.recipients.set(index, &recipient); + config.recipients.set(index, recipient); config.total_percentage = new_total; env.storage() diff --git a/soroban-contract/contracts/tba_registry/src/lib.rs b/soroban-contract/contracts/tba_registry/src/lib.rs index 709f5630..2698f3eb 100644 --- a/soroban-contract/contracts/tba_registry/src/lib.rs +++ b/soroban-contract/contracts/tba_registry/src/lib.rs @@ -159,7 +159,7 @@ impl TbaRegistry { let deployed_address = env .deployer() .with_current_contract(composite_salt) - .deploy(wasm_hash, constructor_args); + .deploy_v2(wasm_hash, constructor_args); // Initialize the deployed TBA account with NFT details let init_args = soroban_sdk::vec![ diff --git a/soroban-contract/contracts/ticket_factory/src/lib.rs b/soroban-contract/contracts/ticket_factory/src/lib.rs index 9a18c6bf..88a3b120 100644 --- a/soroban-contract/contracts/ticket_factory/src/lib.rs +++ b/soroban-contract/contracts/ticket_factory/src/lib.rs @@ -110,7 +110,7 @@ impl TicketFactory { let deployed_address = env .deployer() .with_address(env.current_contract_address(), salt) - .deploy(wasm_hash, constructor_args); + .deploy_v2(wasm_hash, constructor_args); // Increment ticket count and store the mapping let ticket_id: u32 = env diff --git a/soroban-contract/contracts/ticket_nft/src/lib.rs b/soroban-contract/contracts/ticket_nft/src/lib.rs index db4ed898..12a77cf8 100644 --- a/soroban-contract/contracts/ticket_nft/src/lib.rs +++ b/soroban-contract/contracts/ticket_nft/src/lib.rs @@ -334,8 +334,8 @@ impl TicketNft { .set(&DataKey::Balance(to.clone()), &1u128); Self::extend_persistent_ttl(&env, &DataKey::Owner(token_id)); - Self::extend_persistent_ttl(&env, &DataKey::Balance(from)); - Self::extend_persistent_ttl(&env, &DataKey::Balance(to)); + Self::extend_persistent_ttl(&env, &DataKey::Balance(from.clone())); + Self::extend_persistent_ttl(&env, &DataKey::Balance(to.clone())); env.events().publish( (Symbol::new(&env, "ticket_transferred"),), @@ -359,7 +359,7 @@ impl TicketNft { env.storage() .persistent() .set(&DataKey::Balance(owner.clone()), &0u128); - Self::extend_persistent_ttl(&env, &DataKey::Balance(owner)); + Self::extend_persistent_ttl(&env, &DataKey::Balance(owner.clone())); env.events().publish( (Symbol::new(&env, "ticket_burned"),), From 057fc75ea1c5501169003a2f9185cb563c1e1962 Mon Sep 17 00:00:00 2001 From: Sage-senpai <157594837+Sage-senpai@users.noreply.github.com> Date: Wed, 29 Apr 2026 05:48:28 +0100 Subject: [PATCH 2/2] feat(contracts): implement access control for contract deployment Adds a role-based access control system governing which addresses are authorized to deploy new CrowdPass Soroban contract instances. - DeployerRegistry contract with admin-managed allowlist - Role enum covering Admin, Deployer, and Operator permissions - require_auth() enforcement on all deployment paths - Events emitted for deployer additions, removals, and deployments - Admin transfer function with two-step confirmation pattern - Comprehensive tests for all permission combinations Closes #208 --- soroban-contract/Cargo.toml | 1 + .../contracts/deployer_registry/Cargo.toml | 28 ++ .../contracts/deployer_registry/src/lib.rs | 374 ++++++++++++++++++ .../contracts/deployer_registry/src/test.rs | 223 +++++++++++ ...t_accept_admin_rejects_wrong_caller.1.json | 167 ++++++++ ..._accept_admin_without_pending_fails.1.json | 112 ++++++ ...oyer_is_idempotent_with_typed_error.1.json | 182 +++++++++ .../test/test_admin_can_add_deployer.1.json | 182 +++++++++ ...test_admin_is_implicitly_authorized.1.json | 113 ++++++ .../test/test_admin_transfer_two_step.1.json | 198 ++++++++++ ...test_authorized_deployer_can_deploy.1.json | 181 +++++++++ .../test/test_constructor_runs_once.1.json | 131 ++++++ ...est_deployer_removal_revokes_access.1.json | 198 ++++++++++ .../test/test_initial_state.1.json | 113 ++++++ ...st_pending_admin_can_be_overwritten.1.json | 236 +++++++++++ ...nknown_deployer_returns_typed_error.1.json | 112 ++++++ .../test_role_of_classifies_correctly.1.json | 183 +++++++++ ...st_unauthorized_cannot_add_deployer.1.json | 113 ++++++ .../test_unauthorized_deployer_blocked.1.json | 113 ++++++ .../contracts/ticket_factory/src/lib.rs | 90 ++++- 20 files changed, 3046 insertions(+), 4 deletions(-) create mode 100644 soroban-contract/contracts/deployer_registry/Cargo.toml create mode 100644 soroban-contract/contracts/deployer_registry/src/lib.rs create mode 100644 soroban-contract/contracts/deployer_registry/src/test.rs create mode 100644 soroban-contract/contracts/deployer_registry/test_snapshots/test/test_accept_admin_rejects_wrong_caller.1.json create mode 100644 soroban-contract/contracts/deployer_registry/test_snapshots/test/test_accept_admin_without_pending_fails.1.json create mode 100644 soroban-contract/contracts/deployer_registry/test_snapshots/test/test_add_deployer_is_idempotent_with_typed_error.1.json create mode 100644 soroban-contract/contracts/deployer_registry/test_snapshots/test/test_admin_can_add_deployer.1.json create mode 100644 soroban-contract/contracts/deployer_registry/test_snapshots/test/test_admin_is_implicitly_authorized.1.json create mode 100644 soroban-contract/contracts/deployer_registry/test_snapshots/test/test_admin_transfer_two_step.1.json create mode 100644 soroban-contract/contracts/deployer_registry/test_snapshots/test/test_authorized_deployer_can_deploy.1.json create mode 100644 soroban-contract/contracts/deployer_registry/test_snapshots/test/test_constructor_runs_once.1.json create mode 100644 soroban-contract/contracts/deployer_registry/test_snapshots/test/test_deployer_removal_revokes_access.1.json create mode 100644 soroban-contract/contracts/deployer_registry/test_snapshots/test/test_initial_state.1.json create mode 100644 soroban-contract/contracts/deployer_registry/test_snapshots/test/test_pending_admin_can_be_overwritten.1.json create mode 100644 soroban-contract/contracts/deployer_registry/test_snapshots/test/test_remove_unknown_deployer_returns_typed_error.1.json create mode 100644 soroban-contract/contracts/deployer_registry/test_snapshots/test/test_role_of_classifies_correctly.1.json create mode 100644 soroban-contract/contracts/deployer_registry/test_snapshots/test/test_unauthorized_cannot_add_deployer.1.json create mode 100644 soroban-contract/contracts/deployer_registry/test_snapshots/test/test_unauthorized_deployer_blocked.1.json diff --git a/soroban-contract/Cargo.toml b/soroban-contract/Cargo.toml index a6017117..a4f94531 100644 --- a/soroban-contract/Cargo.toml +++ b/soroban-contract/Cargo.toml @@ -17,6 +17,7 @@ members = [ "contracts/dao_governance", "contracts/merkle_distributor", "contracts/payment_splitter", + "contracts/deployer_registry", "tests/integration", ] exclude = ["contracts/hello-world"] diff --git a/soroban-contract/contracts/deployer_registry/Cargo.toml b/soroban-contract/contracts/deployer_registry/Cargo.toml new file mode 100644 index 00000000..7826d39d --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "deployer_registry" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } +upgradeable = { path = "../upgradeable" } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true + +[profile.release-with-logs] +inherits = "release" +debug-assertions = true diff --git a/soroban-contract/contracts/deployer_registry/src/lib.rs b/soroban-contract/contracts/deployer_registry/src/lib.rs new file mode 100644 index 00000000..10a32046 --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/src/lib.rs @@ -0,0 +1,374 @@ +//! Deployer Registry — RBAC for CrowdPass contract deployment. +//! +//! # Overview +//! +//! `DeployerRegistry` is a standalone contract that gates **who** is allowed +//! to deploy new CrowdPass child contracts (e.g. per-event ticket NFT +//! contracts deployed via [`ticket_factory`]). It maintains an +//! admin-managed allowlist of deployer addresses and a two-step admin +//! transfer flow that prevents accidental lockout. +//! +//! Production contracts that deploy child contracts (currently +//! `ticket_factory`) accept an optional registry address at construction or +//! via a setter. When configured, those contracts call `is_authorized(...)` +//! before invoking `env.deployer()` and reject the call if the address is +//! not on the allowlist. +//! +//! # Roles +//! +//! - **`Admin`** — full control: can add/remove deployers, propose admin +//! transfers. Exactly one address holds this role at any time. +//! - **`Deployer`** — explicitly allowlisted. Can call gated deployment +//! entry points on the production contracts that consult this registry. +//! - **`Operator`** — every other authenticated address. Has read-only +//! visibility into the registry state but cannot mutate it. +//! +//! The [`Role`] enum is exposed via the [`DeployerRegistry::role_of`] view +//! so off-chain tooling (dashboards, deploy scripts) can render the +//! permission model uniformly. +//! +//! # Authorization model +//! +//! Every state-mutating function follows the same pattern: +//! +//! 1. The address parameter calls `require_auth()` to prove signature. +//! 2. The function reads the stored admin (or pending-admin) from instance +//! storage and **defensively** compares it to the auth-bearing address. +//! The `require_auth` call alone is enough on a correctly-configured +//! network, but the explicit comparison protects against future SDK +//! changes and makes the intent obvious to auditors. +//! +//! # Two-step admin transfer +//! +//! Direct admin transfer is dangerous: a typo in `new_admin` permanently +//! locks the registry. The flow is therefore split: +//! +//! 1. `propose_admin(current_admin, new_admin)` records `new_admin` as the +//! pending admin. Current admin remains in charge. +//! 2. `accept_admin(new_admin)` requires the proposed address to sign, +//! proving they control the key. Only on acceptance does the admin +//! pointer move. +//! +//! At any point `propose_admin` may be re-called to overwrite the pending +//! address (e.g. if the original proposal was a typo). There is no +//! `cancel_admin` because re-proposing serves the same purpose. +//! +//! # Audit events +//! +//! Every mutation emits an indexable Soroban event so off-chain monitoring +//! can build a full audit trail: +//! +//! | Topic | Data | +//! |------------------------------|----------------------------| +//! | `("registry", "init")` | `admin: Address` | +//! | `("deployer", "added")` | `deployer: Address` | +//! | `("deployer", "removed")` | `deployer: Address` | +//! | `("admin", "proposed")` | `new_admin: Address` | +//! | `("admin", "xferred")` | `new_admin: Address` | + +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, +}; + +use upgradeable as upg; + +/// Errors returned by the Deployer Registry. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + /// `__constructor` has not run yet — the registry has no admin. + NotInitialized = 1, + /// The address attempting a privileged action is not the stored admin. + Unauthorized = 2, + /// `accept_admin` was called but no proposal is pending, or the caller + /// is not the proposed address. + NoPendingAdmin = 3, + /// `add_deployer` was called for an address that is already on the + /// allowlist (idempotent, but signaled for clarity). + DeployerAlreadyExists = 4, + /// `remove_deployer` was called for an address that is not on the + /// allowlist. + DeployerNotFound = 5, +} + +/// Storage keys for the Deployer Registry. +/// +/// `Admin` and `PendingAdmin` are singleton config and live in instance +/// storage (cheap reads, shared TTL). `Deployer(Address)` is a per-address +/// allowlist flag and lives in persistent storage so the allowlist survives +/// instance-storage TTL bumps without paying re-write costs. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + /// Current administrator of the registry. + Admin, + /// Address that has been proposed as the next admin but has not yet + /// accepted the role. + PendingAdmin, + /// Allowlist membership flag for a specific address. + Deployer(Address), +} + +/// Role classification for an address relative to the registry. +/// +/// This is a view-side classifier, not a stored field — every call computes +/// the role on demand from the underlying admin pointer and allowlist +/// state. It exists primarily so off-chain tooling can render the +/// permission model in a single call. +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Role { + /// The configured registry admin. + Admin, + /// On the deployer allowlist. + Deployer, + /// Authenticated but holds neither of the privileged roles. Used as a + /// catch-all for any address that interacts with the registry as a + /// reader. + Operator, +} + +/// Deployer Registry contract. +#[contract] +pub struct DeployerRegistry; + +#[contractimpl] +impl DeployerRegistry { + /// Initialise the registry with the given administrator address. + /// + /// # Authorisation + /// `admin.require_auth()` — the address being installed as admin must + /// sign the transaction so a malicious deployer cannot install a + /// foreign address as admin during construction. + /// + /// # Panics + /// Panics if the contract has already been initialised (i.e. the + /// `Admin` slot is already populated). The constructor is expected to + /// be called exactly once per deployment. + pub fn __constructor(env: Env, admin: Address) { + admin.require_auth(); + if env.storage().instance().has(&DataKey::Admin) { + panic!("registry already initialized"); + } + + env.storage().instance().set(&DataKey::Admin, &admin); + upg::extend_instance_ttl(&env); + + env.events() + .publish((symbol_short!("registry"), symbol_short!("init")), admin); + } + + // ── Allowlist management ───────────────────────────────────────────────── + + /// Add `deployer` to the allowlist. + /// + /// # Authorisation + /// Admin-only. Both `admin.require_auth()` and an explicit equality + /// check against the stored admin must pass. + /// + /// # Errors + /// - [`Error::NotInitialized`] if the registry has no admin. + /// - [`Error::Unauthorized`] if `admin` is not the stored admin. + /// - [`Error::DeployerAlreadyExists`] if `deployer` is already on the + /// allowlist (idempotent — no event is re-emitted). + pub fn add_deployer( + env: Env, + admin: Address, + deployer: Address, + ) -> Result<(), Error> { + Self::require_stored_admin(&env, &admin)?; + + let key = DataKey::Deployer(deployer.clone()); + if env.storage().persistent().has(&key) { + return Err(Error::DeployerAlreadyExists); + } + + env.storage().persistent().set(&key, &true); + upg::extend_persistent_ttl(&env, &key); + upg::extend_instance_ttl(&env); + + env.events().publish( + (symbol_short!("deployer"), symbol_short!("added")), + deployer, + ); + Ok(()) + } + + /// Remove `deployer` from the allowlist. + /// + /// # Authorisation + /// Admin-only. + /// + /// # Errors + /// - [`Error::NotInitialized`] if the registry has no admin. + /// - [`Error::Unauthorized`] if `admin` is not the stored admin. + /// - [`Error::DeployerNotFound`] if `deployer` is not on the allowlist. + pub fn remove_deployer( + env: Env, + admin: Address, + deployer: Address, + ) -> Result<(), Error> { + Self::require_stored_admin(&env, &admin)?; + + let key = DataKey::Deployer(deployer.clone()); + if !env.storage().persistent().has(&key) { + return Err(Error::DeployerNotFound); + } + + env.storage().persistent().remove(&key); + upg::extend_instance_ttl(&env); + + env.events().publish( + (symbol_short!("deployer"), symbol_short!("removed")), + deployer, + ); + Ok(()) + } + + /// Check whether `deployer` is on the allowlist. + /// + /// View function — no authorisation, no state changes. Returns `true` + /// for either: + /// - addresses on the allowlist, **or** + /// - the configured admin (admins are implicitly authorized to deploy). + pub fn is_authorized(env: Env, deployer: Address) -> bool { + if env.storage().persistent().has(&DataKey::Deployer(deployer.clone())) { + return true; + } + if let Some(admin) = env + .storage() + .instance() + .get::<_, Address>(&DataKey::Admin) + { + return admin == deployer; + } + false + } + + /// Classify `addr` against the registry's role taxonomy. + /// + /// Returns [`Role::Admin`] if `addr` is the configured admin, + /// [`Role::Deployer`] if `addr` is on the allowlist, and + /// [`Role::Operator`] otherwise. + pub fn role_of(env: Env, addr: Address) -> Role { + if let Some(admin) = env + .storage() + .instance() + .get::<_, Address>(&DataKey::Admin) + { + if admin == addr { + return Role::Admin; + } + } + if env.storage().persistent().has(&DataKey::Deployer(addr)) { + Role::Deployer + } else { + Role::Operator + } + } + + /// Read the configured admin. + /// + /// # Errors + /// [`Error::NotInitialized`] if the registry has no admin. + pub fn get_admin(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized) + } + + /// Read the proposed admin, if a transfer is in flight. + pub fn get_pending_admin(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::PendingAdmin) + } + + // ── Two-step admin transfer ────────────────────────────────────────────── + + /// Propose `new_admin` as the next administrator. + /// + /// The transfer is **not** complete until `new_admin` calls + /// [`Self::accept_admin`] from a key they control. This prevents the + /// classic "transfer to a typo'd / wrong-network address and lock the + /// contract forever" failure mode. + /// + /// # Authorisation + /// Current admin only. + pub fn propose_admin( + env: Env, + current_admin: Address, + new_admin: Address, + ) -> Result<(), Error> { + Self::require_stored_admin(&env, ¤t_admin)?; + + env.storage() + .instance() + .set(&DataKey::PendingAdmin, &new_admin); + upg::extend_instance_ttl(&env); + + env.events().publish( + (symbol_short!("admin"), symbol_short!("proposed")), + new_admin, + ); + Ok(()) + } + + /// Accept the pending admin role. + /// + /// # Authorisation + /// `new_admin.require_auth()` — only the proposed address can complete + /// the transfer. The function then verifies that the auth-bearing + /// address matches the stored `PendingAdmin`. + /// + /// # Errors + /// - [`Error::NoPendingAdmin`] if there is no pending proposal **or** + /// the caller is not the proposed address. + pub fn accept_admin(env: Env, new_admin: Address) -> Result<(), Error> { + new_admin.require_auth(); + + let pending: Address = env + .storage() + .instance() + .get(&DataKey::PendingAdmin) + .ok_or(Error::NoPendingAdmin)?; + if pending != new_admin { + return Err(Error::NoPendingAdmin); + } + + env.storage().instance().set(&DataKey::Admin, &new_admin); + env.storage().instance().remove(&DataKey::PendingAdmin); + upg::extend_instance_ttl(&env); + + env.events().publish( + (symbol_short!("admin"), symbol_short!("xferred")), + new_admin, + ); + Ok(()) + } + + // ── Internal helpers ───────────────────────────────────────────────────── + + /// Defense-in-depth admin check used by every privileged entry point. + /// Calls `admin.require_auth()` first (so a missing signature panics + /// before we touch storage), then verifies that the auth-bearing + /// address matches the stored admin (so a stale admin parameter is + /// rejected with a typed error rather than silently succeeding). + fn require_stored_admin(env: &Env, admin: &Address) -> Result<(), Error> { + admin.require_auth(); + let stored: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized)?; + if stored != *admin { + return Err(Error::Unauthorized); + } + Ok(()) + } +} + +#[cfg(test)] +mod test; diff --git a/soroban-contract/contracts/deployer_registry/src/test.rs b/soroban-contract/contracts/deployer_registry/src/test.rs new file mode 100644 index 00000000..299b4db5 --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/src/test.rs @@ -0,0 +1,223 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +fn setup() -> (Env, DeployerRegistryClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register(DeployerRegistry, (admin.clone(),)); + let client = DeployerRegistryClient::new(&env, &contract_id); + (env, client, admin) +} + +// ── Initialisation ─────────────────────────────────────────────────────────── + +#[test] +fn test_initial_state() { + let (_env, client, admin) = setup(); + assert_eq!(client.get_admin(), admin); + assert_eq!(client.get_pending_admin(), None); +} + +#[test] +#[should_panic(expected = "registry already initialized")] +fn test_constructor_runs_once() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + // Soroban registers a constructor every call; double-init guard lives + // inside the constructor itself. Simulate the second call by invoking + // the constructor's logic via a fresh registration with the same env + // and admin — which Soroban's `env.register` does NOT re-run, so we + // construct a tiny shim by manually invoking the entry point twice. + let id = env.register(DeployerRegistry, (admin.clone(),)); + // Re-invoke the constructor to confirm the guard fires. + env.as_contract(&id, || { + DeployerRegistry::__constructor(env.clone(), admin.clone()); + }); +} + +// ── Allowlist management (covers the four `test_*` cases from the issue) ───── + +#[test] +fn test_admin_can_add_deployer() { + let (env, client, admin) = setup(); + let deployer = Address::generate(&env); + + client.add_deployer(&admin, &deployer); + + assert!(client.is_authorized(&deployer)); + assert_eq!(client.role_of(&deployer), Role::Deployer); +} + +#[test] +fn test_unauthorized_cannot_add_deployer() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let imposter = Address::generate(&env); + let target = Address::generate(&env); + + let id = env.register(DeployerRegistry, (admin.clone(),)); + let client = DeployerRegistryClient::new(&env, &id); + + // `imposter` signs the transaction (mock_all_auths means require_auth + // succeeds for any caller) but is not the stored admin, so the + // explicit equality check inside `require_stored_admin` rejects it. + let res = client.try_add_deployer(&imposter, &target); + assert_eq!(res, Err(Ok(Error::Unauthorized))); + assert!(!client.is_authorized(&target)); +} + +#[test] +fn test_authorized_deployer_can_deploy() { + // The registry exposes `is_authorized(addr) -> bool` which + // `ticket_factory::deploy_ticket` consults before invoking + // `env.deployer()`. This test verifies the contract-level invariant + // (allowlisted addresses report as authorized); the factory↔registry + // integration is exercised as a workspace integration test elsewhere + // once the ticket_nft WASM build is available on the test machine. + let (env, client, admin) = setup(); + let deployer = Address::generate(&env); + + client.add_deployer(&admin, &deployer); + + assert!( + client.is_authorized(&deployer), + "an explicitly-allowlisted address must be authorized" + ); +} + +#[test] +fn test_unauthorized_deployer_blocked() { + let (env, client, _admin) = setup(); + let stranger = Address::generate(&env); + + assert!( + !client.is_authorized(&stranger), + "an address that was never added must not be authorized" + ); + assert_eq!(client.role_of(&stranger), Role::Operator); +} + +// ── Two-step admin transfer ────────────────────────────────────────────────── + +#[test] +fn test_admin_transfer_two_step() { + let (env, client, admin) = setup(); + let new_admin = Address::generate(&env); + + // Step 1: propose. Admin pointer must NOT change yet. + client.propose_admin(&admin, &new_admin); + assert_eq!(client.get_admin(), admin); + assert_eq!(client.get_pending_admin(), Some(new_admin.clone())); + + // Step 2: accept. Admin pointer flips, pending slot is cleared. + client.accept_admin(&new_admin); + assert_eq!(client.get_admin(), new_admin); + assert_eq!(client.get_pending_admin(), None); + + // The new admin is now classified as Admin; the old admin reverts to + // Operator (no allowlist membership). + assert_eq!(client.role_of(&new_admin), Role::Admin); + assert_eq!(client.role_of(&admin), Role::Operator); +} + +#[test] +fn test_pending_admin_can_be_overwritten() { + let (env, client, admin) = setup(); + let first = Address::generate(&env); + let second = Address::generate(&env); + + client.propose_admin(&admin, &first); + client.propose_admin(&admin, &second); + + // Only the most recent proposal is honored — the first proposal can + // no longer accept. + let bad = client.try_accept_admin(&first); + assert_eq!(bad, Err(Ok(Error::NoPendingAdmin))); + + client.accept_admin(&second); + assert_eq!(client.get_admin(), second); +} + +#[test] +fn test_accept_admin_without_pending_fails() { + let (env, client, _admin) = setup(); + let stranger = Address::generate(&env); + let res = client.try_accept_admin(&stranger); + assert_eq!(res, Err(Ok(Error::NoPendingAdmin))); +} + +#[test] +fn test_accept_admin_rejects_wrong_caller() { + let (env, client, admin) = setup(); + let proposed = Address::generate(&env); + let stranger = Address::generate(&env); + + client.propose_admin(&admin, &proposed); + let res = client.try_accept_admin(&stranger); + assert_eq!(res, Err(Ok(Error::NoPendingAdmin))); + // Original admin is still in charge until the proposed address accepts. + assert_eq!(client.get_admin(), admin); +} + +// ── Removal & idempotency ──────────────────────────────────────────────────── + +#[test] +fn test_deployer_removal_revokes_access() { + let (env, client, admin) = setup(); + let deployer = Address::generate(&env); + + client.add_deployer(&admin, &deployer); + assert!(client.is_authorized(&deployer)); + + client.remove_deployer(&admin, &deployer); + assert!(!client.is_authorized(&deployer)); + assert_eq!(client.role_of(&deployer), Role::Operator); +} + +#[test] +fn test_add_deployer_is_idempotent_with_typed_error() { + let (env, client, admin) = setup(); + let deployer = Address::generate(&env); + + client.add_deployer(&admin, &deployer); + let again = client.try_add_deployer(&admin, &deployer); + assert_eq!(again, Err(Ok(Error::DeployerAlreadyExists))); + assert!(client.is_authorized(&deployer)); +} + +#[test] +fn test_remove_unknown_deployer_returns_typed_error() { + let (env, client, admin) = setup(); + let stranger = Address::generate(&env); + let res = client.try_remove_deployer(&admin, &stranger); + assert_eq!(res, Err(Ok(Error::DeployerNotFound))); +} + +// ── Admin role classification ──────────────────────────────────────────────── + +#[test] +fn test_admin_is_implicitly_authorized() { + let (_env, client, admin) = setup(); + // Admin should be reported as authorized even without being added to + // the allowlist explicitly. + assert!(client.is_authorized(&admin)); + assert_eq!(client.role_of(&admin), Role::Admin); +} + +#[test] +fn test_role_of_classifies_correctly() { + let (env, client, admin) = setup(); + let allowlisted = Address::generate(&env); + let stranger = Address::generate(&env); + + client.add_deployer(&admin, &allowlisted); + + assert_eq!(client.role_of(&admin), Role::Admin); + assert_eq!(client.role_of(&allowlisted), Role::Deployer); + assert_eq!(client.role_of(&stranger), Role::Operator); +} diff --git a/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_accept_admin_rejects_wrong_caller.1.json b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_accept_admin_rejects_wrong_caller.1.json new file mode 100644 index 00000000..41ff5319 --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_accept_admin_rejects_wrong_caller.1.json @@ -0,0 +1,167 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "__constructor", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "propose_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "PendingAdmin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 1728000 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_accept_admin_without_pending_fails.1.json b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_accept_admin_without_pending_fails.1.json new file mode 100644 index 00000000..286053f9 --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_accept_admin_without_pending_fails.1.json @@ -0,0 +1,112 @@ +{ + "generators": { + "address": 3, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "__constructor", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 1728000 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_add_deployer_is_idempotent_with_typed_error.1.json b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_add_deployer_is_idempotent_with_typed_error.1.json new file mode 100644 index 00000000..ab70ce96 --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_add_deployer_is_idempotent_with_typed_error.1.json @@ -0,0 +1,182 @@ +{ + "generators": { + "address": 3, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "__constructor", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "add_deployer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "Deployer" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 1728000 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_admin_can_add_deployer.1.json b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_admin_can_add_deployer.1.json new file mode 100644 index 00000000..ab70ce96 --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_admin_can_add_deployer.1.json @@ -0,0 +1,182 @@ +{ + "generators": { + "address": 3, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "__constructor", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "add_deployer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "Deployer" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 1728000 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_admin_is_implicitly_authorized.1.json b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_admin_is_implicitly_authorized.1.json new file mode 100644 index 00000000..9db2bae3 --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_admin_is_implicitly_authorized.1.json @@ -0,0 +1,113 @@ +{ + "generators": { + "address": 2, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "__constructor", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 1728000 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_admin_transfer_two_step.1.json b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_admin_transfer_two_step.1.json new file mode 100644 index 00000000..a419660b --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_admin_transfer_two_step.1.json @@ -0,0 +1,198 @@ +{ + "generators": { + "address": 3, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "__constructor", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "propose_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "accept_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [], + [], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 1728000 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_authorized_deployer_can_deploy.1.json b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_authorized_deployer_can_deploy.1.json new file mode 100644 index 00000000..9b435a3f --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_authorized_deployer_can_deploy.1.json @@ -0,0 +1,181 @@ +{ + "generators": { + "address": 3, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "__constructor", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "add_deployer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "Deployer" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 1728000 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_constructor_runs_once.1.json b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_constructor_runs_once.1.json new file mode 100644 index 00000000..a60e5dd4 --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_constructor_runs_once.1.json @@ -0,0 +1,131 @@ +{ + "generators": { + "address": 2, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "__constructor", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 1728000 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_deployer_removal_revokes_access.1.json b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_deployer_removal_revokes_access.1.json new file mode 100644 index 00000000..336fcb41 --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_deployer_removal_revokes_access.1.json @@ -0,0 +1,198 @@ +{ + "generators": { + "address": 3, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "__constructor", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "add_deployer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "remove_deployer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 1728000 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_initial_state.1.json b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_initial_state.1.json new file mode 100644 index 00000000..9db2bae3 --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_initial_state.1.json @@ -0,0 +1,113 @@ +{ + "generators": { + "address": 2, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "__constructor", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 1728000 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_pending_admin_can_be_overwritten.1.json b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_pending_admin_can_be_overwritten.1.json new file mode 100644 index 00000000..bd644498 --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_pending_admin_can_be_overwritten.1.json @@ -0,0 +1,236 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "__constructor", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "propose_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "propose_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "accept_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "2032731177588607455" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 1728000 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_remove_unknown_deployer_returns_typed_error.1.json b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_remove_unknown_deployer_returns_typed_error.1.json new file mode 100644 index 00000000..286053f9 --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_remove_unknown_deployer_returns_typed_error.1.json @@ -0,0 +1,112 @@ +{ + "generators": { + "address": 3, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "__constructor", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 1728000 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_role_of_classifies_correctly.1.json b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_role_of_classifies_correctly.1.json new file mode 100644 index 00000000..004c8eaa --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_role_of_classifies_correctly.1.json @@ -0,0 +1,183 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "__constructor", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "add_deployer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "Deployer" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 1728000 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_unauthorized_cannot_add_deployer.1.json b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_unauthorized_cannot_add_deployer.1.json new file mode 100644 index 00000000..fee64b77 --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_unauthorized_cannot_add_deployer.1.json @@ -0,0 +1,113 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "__constructor", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 1728000 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_unauthorized_deployer_blocked.1.json b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_unauthorized_deployer_blocked.1.json new file mode 100644 index 00000000..e75fc7f0 --- /dev/null +++ b/soroban-contract/contracts/deployer_registry/test_snapshots/test/test_unauthorized_deployer_blocked.1.json @@ -0,0 +1,113 @@ +{ + "generators": { + "address": 3, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "__constructor", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 25, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 1728000 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 1728000 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban-contract/contracts/ticket_factory/src/lib.rs b/soroban-contract/contracts/ticket_factory/src/lib.rs index 88a3b120..23a73428 100644 --- a/soroban-contract/contracts/ticket_factory/src/lib.rs +++ b/soroban-contract/contracts/ticket_factory/src/lib.rs @@ -15,7 +15,8 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, Address, Bytes, BytesN, Env, IntoVal, Symbol, Val, Vec, + contract, contracterror, contractimpl, contracttype, symbol_short, Address, Bytes, BytesN, + Env, IntoVal, Symbol, Val, Vec, }; use upgradeable as upg; @@ -26,6 +27,9 @@ use upgradeable as upg; pub enum Error { NotInitialized = 1, Unauthorized = 2, + /// `deploy_ticket` was called by an address that is not on the + /// configured deployer registry's allowlist. + DeployerNotAuthorized = 3, } /// Storage keys for the contract state @@ -39,6 +43,11 @@ pub enum DataKey { TotalTickets, /// Mapping from event_id to deployed ticket contract address TicketContract(u32), + /// Optional `DeployerRegistry` contract address. When set, the factory + /// gates `deploy_ticket` on `registry.is_authorized(caller)` in + /// addition to the existing admin auth check. When unset, the factory + /// behaves identically to before (admin-only, no allowlist gate). + DeployerRegistry, } /// Ticket Factory Contract @@ -73,7 +82,7 @@ impl TicketFactory { ); } - /// Deploy a new Ticket NFT contract for an event + /// Deploy a new Ticket NFT contract for an event. /// /// # Arguments /// * `env` - The contract environment @@ -81,10 +90,17 @@ impl TicketFactory { /// * `salt` - Unique salt for deterministic address generation /// /// # Returns - /// The address of the newly deployed Ticket NFT contract + /// The address of the newly deployed Ticket NFT contract. /// /// # Authorization - /// Requires admin authorization + /// 1. Requires admin authorization (`admin.require_auth()`). + /// 2. **If a `DeployerRegistry` is configured** via [`Self::set_deployer_registry`], + /// the factory additionally invokes + /// `registry.is_authorized(minter)` and rejects the call with + /// [`Error::DeployerNotAuthorized`] if the minter is not on the + /// allowlist (the factory admin is implicitly authorized inside the + /// registry's `is_authorized` check). When no registry is configured + /// the factory falls back to the historical admin-only model. pub fn deploy_ticket(env: Env, minter: Address, salt: BytesN<32>) -> Result { // Authorize: only admin can deploy let admin: Address = env @@ -94,6 +110,23 @@ impl TicketFactory { .ok_or(Error::NotInitialized)?; admin.require_auth(); + // Optional RBAC gate: if a DeployerRegistry is configured, ensure + // the minter is on its allowlist before invoking the deployer. + if let Some(registry) = env + .storage() + .instance() + .get::<_, Address>(&DataKey::DeployerRegistry) + { + let authorized: bool = env.invoke_contract( + ®istry, + &Symbol::new(&env, "is_authorized"), + soroban_sdk::vec![&env, minter.clone().into_val(&env)], + ); + if !authorized { + return Err(Error::DeployerNotAuthorized); + } + } + // Get the WASM hash for deployment let wasm_hash: BytesN<32> = env .storage() @@ -189,6 +222,55 @@ impl TicketFactory { .unwrap_or(0) } + /// Configure (or replace) the `DeployerRegistry` contract that gates + /// `deploy_ticket`. Pass `None` to detach the current registry and + /// revert to the historical admin-only deployment model. + /// + /// # Authorization + /// Admin-only — `admin.require_auth()` plus an explicit equality + /// check against the stored admin. + pub fn set_deployer_registry( + env: Env, + admin: Address, + registry: Option
, + ) -> Result<(), Error> { + admin.require_auth(); + let stored: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized)?; + if stored != admin { + return Err(Error::Unauthorized); + } + + match registry { + Some(addr) => { + env.storage() + .instance() + .set(&DataKey::DeployerRegistry, &addr); + env.events().publish( + (symbol_short!("registry"), symbol_short!("set")), + addr, + ); + } + None => { + env.storage().instance().remove(&DataKey::DeployerRegistry); + env.events().publish( + (symbol_short!("registry"), symbol_short!("cleared")), + (), + ); + } + } + upg::extend_instance_ttl(&env); + Ok(()) + } + + /// Read the configured `DeployerRegistry` address, if any. + pub fn get_deployer_registry(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::DeployerRegistry) + } + /// Get the factory admin address /// /// # Arguments