From 20ba2a55e9d93e80c0dc3ef5427e228829237319 Mon Sep 17 00:00:00 2001 From: divicbold47 Date: Mon, 27 Apr 2026 12:20:06 +0000 Subject: [PATCH] feat: add issuer transfer and testnet mode functionality --- package-lock.json | 12 +++ src/lib.rs | 241 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..9493a487 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "revora-contracts", + "version": "0.1.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "revora-contracts", + "version": "0.1.0" + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 28a5d6b4..161c6644 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,6 +61,14 @@ pub enum RevoraError { /// period_id is invalid (e.g. zero when required to be positive) (#35). /// period_id not strictly greater than previous (violates ordering invariant). InvalidPeriodId = 22, + /// Contract is paused; state-changing operations are disabled. + ContractPaused = 35, + /// Issuer transfer proposal has expired before acceptance. + IssuerTransferExpired = 36, + /// Pending admin rotation does not exist. + NoAdminRotationPending = 37, + /// Caller is not the proposed new admin for rotation accept. + UnauthorizedRotationAccept = 38, /// Deposit would exceed the offering's supply cap (#96). SupplyCapExceeded = 23, @@ -489,6 +497,8 @@ pub enum DataKey { LastClaimedIdx(OfferingId, Address), /// Payment token address for an offering. PaymentToken(OfferingId), + /// Per-offering payment token decimal precision. + PaymentTokenDecimals(OfferingId), /// Per-offering claim delay in seconds (#27). 0 = immediate claim. ClaimDelaySecs(OfferingId), /// Ledger timestamp when revenue was deposited for (offering_id, period_id). @@ -497,6 +507,8 @@ pub enum DataKey { Admin, /// Contract frozen flag; when true, state-changing ops are disabled (#32). Frozen, + /// Frozen state for a single offering. + FrozenOffering(OfferingId), /// Proposed new admin address (pending two-step rotation). PendingAdmin, @@ -1155,6 +1167,232 @@ impl RevoraRevenueShare { env.storage().persistent().get(&key) } + fn ensure_issuer_registered(env: &Env, issuer: &Address) { + let issuer_key = DataKey2::IssuerRegistered(issuer.clone()); + if !env.storage().persistent().has(&issuer_key) { + let count: u32 = env.storage().persistent().get(&DataKey2::IssuerCount).unwrap_or(0); + env.storage().persistent().set(&DataKey2::IssuerItem(count), issuer); + env.storage().persistent().set(&DataKey2::IssuerCount, &(count + 1)); + env.storage().persistent().set(&issuer_key, &true); + } + } + + fn ensure_namespace_registered(env: &Env, issuer: &Address, namespace: &Symbol) { + let ns_key = DataKey2::NamespaceRegistered(issuer.clone(), namespace.clone()); + if !env.storage().persistent().has(&ns_key) { + let ns_count: u32 = env.storage().persistent().get(&DataKey2::NamespaceCount(issuer.clone())).unwrap_or(0); + env.storage().persistent().set(&DataKey2::NamespaceItem(issuer.clone(), ns_count), namespace); + env.storage().persistent().set(&DataKey2::NamespaceCount(issuer.clone()), &(ns_count + 1)); + env.storage().persistent().set(&ns_key, &true); + } + } + + fn is_testnet_mode(env: Env) -> bool { + env.storage().persistent().get::(&DataKey::TestnetMode).unwrap_or(false) + } + + pub fn set_testnet_mode(env: Env, enabled: bool) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + let admin: Address = env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; + admin.require_auth(); + env.storage().persistent().set(&DataKey::TestnetMode, &enabled); + env.events().publish((EVENT_TESTNET_MODE,), enabled); + Ok(()) + } + + pub fn get_pending_issuer_transfer( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option
{ + let offering_id = OfferingId { issuer, namespace, token }; + env.storage() + .persistent() + .get::(&DataKey::PendingIssuerTransfer(offering_id)) + .map(|pending| pending.new_issuer) + } + + fn find_pending_transfer_for_new_issuer( + env: &Env, + namespace: &Symbol, + token: &Address, + new_issuer: &Address, + ) -> Option { + let issuer_count: u32 = env.storage().persistent().get(&DataKey2::IssuerCount).unwrap_or(0); + for i in 0..issuer_count { + let issuer: Address = env.storage().persistent().get(&DataKey2::IssuerItem(i)).unwrap(); + let ns_count: u32 = env.storage().persistent().get(&DataKey2::NamespaceCount(issuer.clone())).unwrap_or(0); + for j in 0..ns_count { + let namespace_item: Symbol = env.storage().persistent().get(&DataKey2::NamespaceItem(issuer.clone(), j)).unwrap(); + if namespace_item != *namespace { + continue; + } + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace_item.clone(), + token: token.clone(), + }; + if let Some(pending) = env + .storage() + .persistent() + .get::(&DataKey::PendingIssuerTransfer(offering_id.clone())) + { + if pending.new_issuer == *new_issuer { + return Some(offering_id); + } + } + } + } + None + } + + pub fn propose_issuer_transfer( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + new_issuer: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), token: token.clone() }; + let current_issuer = Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + + let key = DataKey::PendingIssuerTransfer(offering_id.clone()); + if env.storage().persistent().has(&key) { + return Err(RevoraError::IssuerTransferPending); + } + + let timestamp = env.ledger().timestamp(); + env.storage() + .persistent() + .set(&key, &PendingTransfer { new_issuer: new_issuer.clone(), timestamp }); + env.events().publish( + (EVENT_ISSUER_TRANSFER_PROPOSED, issuer.clone(), namespace.clone(), token.clone()), + (new_issuer.clone(), timestamp), + ); + Ok(()) + } + + pub fn accept_issuer_transfer( + env: Env, + new_issuer: Address, + namespace: Symbol, + token: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + new_issuer.require_auth(); + + let offering_id = Self::find_pending_transfer_for_new_issuer(&env, &namespace, &token, &new_issuer) + .ok_or(RevoraError::NoTransferPending)?; + + let pending: PendingTransfer = env + .storage() + .persistent() + .get(&DataKey::PendingIssuerTransfer(offering_id.clone())) + .ok_or(RevoraError::NoTransferPending)?; + + let current_timestamp = env.ledger().timestamp(); + if current_timestamp > pending.timestamp.saturating_add(ISSUER_TRANSFER_EXPIRY_SECS) { + return Err(RevoraError::IssuerTransferExpired); + } + + let old_issuer = offering_id.issuer.clone(); + + if new_issuer == old_issuer { + env.storage() + .persistent() + .remove(&DataKey::PendingIssuerTransfer(offering_id.clone())); + env.events().publish( + (EVENT_ISSUER_TRANSFER_ACCEPTED, offering_id.issuer.clone(), offering_id.namespace.clone(), offering_id.token.clone()), + (old_issuer, new_issuer.clone()), + ); + return Ok(()); + } + + let new_offering_id = OfferingId { + issuer: new_issuer.clone(), + namespace: offering_id.namespace.clone(), + token: offering_id.token.clone(), + }; + + // Prevent duplicate offering entries for the same new issuer / namespace / token. + if Self::get_offering(env.clone(), new_issuer.clone(), offering_id.namespace.clone(), offering_id.token.clone()).is_some() { + return Err(RevoraError::LimitReached); + } + + // Register namespace metadata for the new issuer. + Self::ensure_issuer_registered(&env, &new_issuer); + Self::ensure_namespace_registered(&env, &new_issuer, &offering_id.namespace); + + // Copy the offering registration record to the new issuer's tenant list. + let tenant_id = TenantId { issuer: new_issuer.clone(), namespace: offering_id.namespace.clone() }; + let count_key = DataKey::OfferCount(tenant_id.clone()); + let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + let offering = Self::get_offering(env.clone(), old_issuer.clone(), offering_id.namespace.clone(), offering_id.token.clone()) + .ok_or(RevoraError::OfferingNotFound)?; + let item_key = DataKey::OfferItem(tenant_id.clone(), count); + env.storage().persistent().set(&item_key, &offering); + env.storage().persistent().set(&count_key, &(count + 1)); + + // Update issuer lookups for the old and new offering IDs. + env.storage() + .persistent() + .set(&DataKey::OfferingIssuer(offering_id.clone()), &new_issuer.clone()); + env.storage() + .persistent() + .set(&DataKey::OfferingIssuer(new_offering_id.clone()), &new_issuer.clone()); + + env.storage() + .persistent() + .remove(&DataKey::PendingIssuerTransfer(offering_id.clone())); + + env.events().publish( + (EVENT_ISSUER_TRANSFER_ACCEPTED, offering_id.issuer.clone(), offering_id.namespace.clone(), offering_id.token.clone()), + (old_issuer, new_issuer.clone()), + ); + Ok(()) + } + + pub fn cancel_issuer_transfer( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), token: token.clone() }; + let current_issuer = Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::NotAuthorized); + } + + let key = DataKey::PendingIssuerTransfer(offering_id.clone()); + if !env.storage().persistent().has(&key) { + return Err(RevoraError::NoTransferPending); + } + + let pending: PendingTransfer = env.storage().persistent().get(&key).unwrap(); + env.storage().persistent().remove(&key); + env.events().publish( + (EVENT_ISSUER_TRANSFER_CANCELLED, issuer.clone(), namespace.clone(), token.clone()), + (issuer, pending.new_issuer), + ); + Ok(()) + } + /// Initialize admin and optional safety role for emergency pause (#7). /// `event_only` configures the contract to skip persistent business state (#72). /// Can only be called once; panics if already initialized. @@ -1309,7 +1547,8 @@ impl RevoraRevenueShare { return Err(RevoraError::InvalidRevenueShareBps); } - // Register namespace for issuer if not already present + // Register issuer and namespace metadata if not already present. + Self::ensure_issuer_registered(&env, &issuer); let ns_reg_key = DataKey2::NamespaceRegistered(issuer.clone(), namespace.clone()); if !env.storage().persistent().has(&ns_reg_key) { let ns_count_key = DataKey2::NamespaceCount(issuer.clone());