diff --git a/Cargo.lock b/Cargo.lock index 5898df04..83930b56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -810,6 +810,7 @@ name = "revora-contracts" version = "0.1.0" dependencies = [ "arbitrary", + "ed25519-dalek", "soroban-sdk", ] diff --git a/Cargo.toml b/Cargo.toml index 49ed34e7..4ee454ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ soroban-sdk = "=20.5.0" [dev-dependencies] soroban-sdk = { version = "=20.5.0", features = ["testutils"] } arbitrary = { version = "=1.3.2", features = ["derive"] } +ed25519-dalek = "=2.0.0" [features] default = [] diff --git a/src/lib.rs b/src/lib.rs index 75adbf98..4cc58966 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,8 +2,8 @@ #![deny(unsafe_code)] #![deny(clippy::dbg_macro, clippy::todo, clippy::unimplemented)] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env, Map, - String, Symbol, Vec, + contract, contracterror, contractimpl, contracttype, symbol_short, token, xdr::ToXdr, Address, + BytesN, Env, Map, String, Symbol, Vec, }; /// Centralized contract error codes. Auth failures are signaled by host panic (require_auth). @@ -58,6 +58,18 @@ pub enum RevoraError { InvalidPeriodId = 22, /// Deposit would exceed the offering's supply cap (#96). SupplyCapExceeded = 23, + /// Metadata format is invalid for configured scheme rules. + MetadataInvalidFormat = 24, + /// Current ledger timestamp is outside configured reporting window. + ReportingWindowClosed = 25, + /// Current ledger timestamp is outside configured claiming window. + ClaimWindowClosed = 26, + /// Off-chain signature has expired. + SignatureExpired = 27, + /// Signature nonce has already been used. + SignatureReplay = 28, + /// Off-chain signer key has not been registered. + SignerKeyNotRegistered = 29, } // ── Event symbols ──────────────────────────────────────────── @@ -151,6 +163,19 @@ const EVENT_SUPPLY_CAP_REACHED: Symbol = symbol_short!("cap_reach"); const EVENT_INV_CONSTRAINTS: Symbol = symbol_short!("inv_cfg"); /// Emitted when per-offering or platform per-asset fee is set (#98). const EVENT_FEE_CONFIG: Symbol = symbol_short!("fee_cfg"); +const EVENT_INDEXED_V2: Symbol = symbol_short!("ev_idx2"); +const EVENT_TYPE_OFFER: Symbol = symbol_short!("offer"); +const EVENT_TYPE_REV_INIT: Symbol = symbol_short!("rv_init"); +const EVENT_TYPE_REV_OVR: Symbol = symbol_short!("rv_ovr"); +const EVENT_TYPE_REV_REJ: Symbol = symbol_short!("rv_rej"); +const EVENT_TYPE_REV_REP: Symbol = symbol_short!("rv_rep"); +const EVENT_TYPE_CLAIM: Symbol = symbol_short!("claim"); +const EVENT_REPORT_WINDOW_SET: Symbol = symbol_short!("rep_win"); +const EVENT_CLAIM_WINDOW_SET: Symbol = symbol_short!("clm_win"); +const EVENT_META_SIGNER_SET: Symbol = symbol_short!("meta_key"); +const EVENT_META_DELEGATE_SET: Symbol = symbol_short!("meta_del"); +const EVENT_META_SHARE_SET: Symbol = symbol_short!("meta_shr"); +const EVENT_META_REV_APPROVE: Symbol = symbol_short!("meta_rev"); const BPS_DENOMINATOR: i128 = 10_000; @@ -241,6 +266,88 @@ pub struct SimulateDistributionResult { pub payouts: Vec<(Address, i128)>, } +/// Versioned structured topic payload for indexers. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct EventIndexTopicV2 { + pub version: u32, + pub event_type: Symbol, + pub issuer: Address, + pub namespace: Symbol, + pub token: Address, + /// 0 when the event is not period-scoped. + pub period_id: u64, +} + +/// Versioned domain-separated payload for off-chain authorized actions. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct MetaAuthorization { + pub version: u32, + pub contract: Address, + pub signer: Address, + pub nonce: u64, + pub expiry: u64, + pub action: MetaAction, +} + +/// Off-chain authorized action variants. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum MetaAction { + SetHolderShare(MetaSetHolderSharePayload), + ApproveRevenueReport(MetaRevenueApprovalPayload), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct MetaSetHolderSharePayload { + pub issuer: Address, + pub namespace: Symbol, + pub token: Address, + pub holder: Address, + pub share_bps: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct MetaRevenueApprovalPayload { + pub issuer: Address, + pub namespace: Symbol, + pub token: Address, + pub payout_asset: Address, + pub amount: i128, + pub period_id: u64, + pub override_existing: bool, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AccessWindow { + pub start_timestamp: u64, + pub end_timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum WindowDataKey { + Report(OfferingId), + Claim(OfferingId), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum MetaDataKey { + /// Off-chain signer public key (ed25519) bound to signer address. + SignerKey(Address), + /// Offering-scoped delegate signer allowed for meta-actions. + Delegate(OfferingId), + /// Replay protection key: signer + nonce consumed marker. + NonceUsed(Address, u64), + /// Approved revenue report marker keyed by offering and period. + RevenueApproved(OfferingId, u64), +} + /// Defines how fractional shares are handled during distribution calculations. #[contracttype] #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -376,6 +483,8 @@ pub struct RevoraRevenueShare; #[contractimpl] impl RevoraRevenueShare { + const META_AUTH_VERSION: u32 = 1; + fn is_event_versioning_enabled(env: Env) -> bool { let key = DataKey::EventVersioningEnabled; env.storage() @@ -393,6 +502,111 @@ impl RevoraRevenueShare { Ok(()) } + fn validate_window(window: &AccessWindow) -> Result<(), RevoraError> { + if window.start_timestamp > window.end_timestamp { + return Err(RevoraError::LimitReached); + } + Ok(()) + } + + fn require_valid_meta_nonce_and_expiry( + env: &Env, + signer: &Address, + nonce: u64, + expiry: u64, + ) -> Result<(), RevoraError> { + if env.ledger().timestamp() > expiry { + return Err(RevoraError::SignatureExpired); + } + let nonce_key = MetaDataKey::NonceUsed(signer.clone(), nonce); + if env.storage().persistent().has(&nonce_key) { + return Err(RevoraError::SignatureReplay); + } + Ok(()) + } + + fn is_window_open(env: &Env, window: &AccessWindow) -> bool { + let now = env.ledger().timestamp(); + now >= window.start_timestamp && now <= window.end_timestamp + } + + fn require_report_window_open(env: &Env, offering_id: &OfferingId) -> Result<(), RevoraError> { + let key = WindowDataKey::Report(offering_id.clone()); + if let Some(window) = env.storage().persistent().get::(&key) { + if !Self::is_window_open(env, &window) { + return Err(RevoraError::ReportingWindowClosed); + } + } + Ok(()) + } + + fn require_claim_window_open(env: &Env, offering_id: &OfferingId) -> Result<(), RevoraError> { + let key = WindowDataKey::Claim(offering_id.clone()); + if let Some(window) = env.storage().persistent().get::(&key) { + if !Self::is_window_open(env, &window) { + return Err(RevoraError::ClaimWindowClosed); + } + } + Ok(()) + } + + fn mark_meta_nonce_used(env: &Env, signer: &Address, nonce: u64) { + let nonce_key = MetaDataKey::NonceUsed(signer.clone(), nonce); + env.storage().persistent().set(&nonce_key, &true); + } + + fn verify_meta_signature( + env: &Env, + signer: &Address, + nonce: u64, + expiry: u64, + action: MetaAction, + signature: &BytesN<64>, + ) -> Result<(), RevoraError> { + Self::require_valid_meta_nonce_and_expiry(env, signer, nonce, expiry)?; + let pk_key = MetaDataKey::SignerKey(signer.clone()); + let public_key: BytesN<32> = env + .storage() + .persistent() + .get(&pk_key) + .ok_or(RevoraError::SignerKeyNotRegistered)?; + let payload = MetaAuthorization { + version: Self::META_AUTH_VERSION, + contract: env.current_contract_address(), + signer: signer.clone(), + nonce, + expiry, + action, + }; + let payload_bytes = payload.to_xdr(env); + env.crypto().ed25519_verify(&public_key, &payload_bytes, signature); + Ok(()) + } + + fn set_holder_share_internal( + env: &Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + share_bps: u32, + ) -> Result<(), RevoraError> { + if share_bps > 10_000 { + return Err(RevoraError::InvalidShareBps); + } + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + env.storage() + .persistent() + .set(&DataKey::HolderShare(offering_id, holder.clone()), &share_bps); + env.events() + .publish((EVENT_SHARE_SET, issuer, namespace, token), (holder, share_bps)); + Ok(()) + } + /// Internal helper for revenue deposits. fn do_deposit_revenue( @@ -753,6 +967,20 @@ impl RevoraRevenueShare { (symbol_short!("offer_reg"), issuer.clone(), namespace.clone()), (token.clone(), revenue_share_bps, payout_asset.clone()), ); + env.events().publish( + ( + EVENT_INDEXED_V2, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_OFFER, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id: 0, + }, + ), + (revenue_share_bps, payout_asset.clone()), + ); // Optionally emit a versioned v1 event with explicit version field if Self::is_event_versioning_enabled(env.clone()) { @@ -838,6 +1066,7 @@ impl RevoraRevenueShare { namespace: namespace.clone(), token: token.clone(), }; + Self::require_report_window_open(&env, &offering_id)?; if !event_only { // Verify offering exists and issuer is current @@ -909,6 +1138,20 @@ impl RevoraRevenueShare { (EVENT_REVENUE_REPORT_OVERRIDE, issuer.clone(), namespace.clone(), token.clone()), (amount, period_id, existing_amount, blacklist.clone()), ); + env.events().publish( + ( + EVENT_INDEXED_V2, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_REV_OVR, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, + }, + ), + (amount, existing_amount, payout_asset.clone()), + ); env.events().publish( ( @@ -924,6 +1167,20 @@ impl RevoraRevenueShare { (EVENT_REVENUE_REPORT_REJECTED, issuer.clone(), namespace.clone(), token.clone()), (amount, period_id, existing_amount, blacklist.clone()), ); + env.events().publish( + ( + EVENT_INDEXED_V2, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_REV_REJ, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, + }, + ), + (amount, existing_amount, payout_asset.clone()), + ); env.events().publish( ( @@ -950,6 +1207,20 @@ impl RevoraRevenueShare { (EVENT_REVENUE_REPORT_INITIAL, issuer.clone(), namespace.clone(), token.clone()), (amount, period_id, blacklist.clone()), ); + env.events().publish( + ( + EVENT_INDEXED_V2, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_REV_INIT, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, + }, + ), + (amount, payout_asset.clone()), + ); env.events().publish( ( @@ -973,6 +1244,20 @@ impl RevoraRevenueShare { (EVENT_REVENUE_REPORTED, issuer.clone(), namespace.clone(), token.clone()), (amount, period_id, blacklist.clone()), ); + env.events().publish( + ( + EVENT_INDEXED_V2, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_REV_REP, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id, + }, + ), + (amount, payout_asset.clone(), override_existing), + ); env.events().publish( ( @@ -1813,15 +2098,197 @@ impl RevoraRevenueShare { } issuer.require_auth(); + Self::set_holder_share_internal(&env, offering_id.issuer, offering_id.namespace, offering_id.token, holder, share_bps) + } - if share_bps > 10_000 { - return Err(RevoraError::InvalidShareBps); + /// Register an ed25519 public key for a signer address. + /// The signer must authorize this binding. + pub fn register_meta_signer_key( + env: Env, + signer: Address, + public_key: BytesN<32>, + ) -> Result<(), RevoraError> { + signer.require_auth(); + env.storage() + .persistent() + .set(&MetaDataKey::SignerKey(signer.clone()), &public_key); + env.events() + .publish((EVENT_META_SIGNER_SET, signer), public_key); + Ok(()) + } + + /// Set or update an offering-level delegate signer for off-chain authorizations. + /// Only the current issuer may set this value. + pub fn set_meta_delegate( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + delegate: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + 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); } + issuer.require_auth(); + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + env.storage() + .persistent() + .set(&MetaDataKey::Delegate(offering_id), &delegate); + env.events() + .publish((EVENT_META_DELEGATE_SET, issuer, namespace, token), delegate); + Ok(()) + } - let key = DataKey::HolderShare(offering_id, holder.clone()); - env.storage().persistent().set(&key, &share_bps); + /// Get the configured offering-level delegate signer. + pub fn get_meta_delegate( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option
{ + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env.storage() + .persistent() + .get(&MetaDataKey::Delegate(offering_id)) + } - env.events().publish((EVENT_SHARE_SET, issuer, namespace, token), (holder, share_bps)); + /// Meta-transaction variant of `set_holder_share`. + /// A registered delegate signer authorizes this action via off-chain ed25519 signature. + #[allow(clippy::too_many_arguments)] + pub fn meta_set_holder_share( + env: Env, + signer: Address, + payload: MetaSetHolderSharePayload, + nonce: u64, + expiry: u64, + signature: BytesN<64>, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env); + let current_issuer = Self::get_current_issuer( + &env, + payload.issuer.clone(), + payload.namespace.clone(), + payload.token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != payload.issuer { + return Err(RevoraError::OfferingNotFound); + } + let offering_id = OfferingId { + issuer: payload.issuer.clone(), + namespace: payload.namespace.clone(), + token: payload.token.clone(), + }; + let configured_delegate: Address = env + .storage() + .persistent() + .get(&MetaDataKey::Delegate(offering_id)) + .ok_or(RevoraError::NotAuthorized)?; + if configured_delegate != signer { + return Err(RevoraError::NotAuthorized); + } + let action = MetaAction::SetHolderShare(payload.clone()); + Self::verify_meta_signature(&env, &signer, nonce, expiry, action, &signature)?; + Self::set_holder_share_internal( + &env, + payload.issuer.clone(), + payload.namespace.clone(), + payload.token.clone(), + payload.holder.clone(), + payload.share_bps, + )?; + Self::mark_meta_nonce_used(&env, &signer, nonce); + env.events().publish( + ( + EVENT_META_SHARE_SET, + payload.issuer, + payload.namespace, + payload.token, + ), + (signer, payload.holder, payload.share_bps, nonce, expiry), + ); + Ok(()) + } + + /// Meta-transaction authorization for a revenue report payload. + /// This does not mutate revenue data directly; it records a signed approval. + #[allow(clippy::too_many_arguments)] + pub fn meta_approve_revenue_report( + env: Env, + signer: Address, + payload: MetaRevenueApprovalPayload, + nonce: u64, + expiry: u64, + signature: BytesN<64>, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env); + let current_issuer = Self::get_current_issuer( + &env, + payload.issuer.clone(), + payload.namespace.clone(), + payload.token.clone(), + ) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != payload.issuer { + return Err(RevoraError::OfferingNotFound); + } + let offering_id = OfferingId { + issuer: payload.issuer.clone(), + namespace: payload.namespace.clone(), + token: payload.token.clone(), + }; + let configured_delegate: Address = env + .storage() + .persistent() + .get(&MetaDataKey::Delegate(offering_id.clone())) + .ok_or(RevoraError::NotAuthorized)?; + if configured_delegate != signer { + return Err(RevoraError::NotAuthorized); + } + let action = MetaAction::ApproveRevenueReport(payload.clone()); + Self::verify_meta_signature(&env, &signer, nonce, expiry, action, &signature)?; + env.storage() + .persistent() + .set( + &MetaDataKey::RevenueApproved(offering_id, payload.period_id), + &true, + ); + Self::mark_meta_nonce_used(&env, &signer, nonce); + env.events().publish( + ( + EVENT_META_REV_APPROVE, + payload.issuer, + payload.namespace, + payload.token, + ), + ( + signer, + payload.payout_asset, + payload.amount, + payload.period_id, + payload.override_existing, + nonce, + expiry, + ), + ); Ok(()) } @@ -1867,6 +2334,7 @@ impl RevoraRevenueShare { } let offering_id = OfferingId { issuer, namespace, token }; + Self::require_claim_window_open(&env, &offering_id)?; let count_key = DataKey::PeriodCount(offering_id.clone()); let period_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); @@ -1929,13 +2397,150 @@ impl RevoraRevenueShare { env.storage().persistent().set(&idx_key, &last_claimed_idx); env.events().publish( - (EVENT_CLAIM, offering_id.issuer, offering_id.namespace, offering_id.token), + ( + EVENT_CLAIM, + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), (holder, total_payout, claimed_periods), ); + env.events().publish( + ( + EVENT_INDEXED_V2, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_CLAIM, + issuer: offering_id.issuer, + namespace: offering_id.namespace, + token: offering_id.token, + period_id: 0, + }, + ), + (total_payout,), + ); Ok(total_payout) } + /// Configure the reporting access window for an offering. + /// If unset, reporting remains always permitted. + pub fn set_report_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + start_timestamp: u64, + end_timestamp: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + 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); + } + issuer.require_auth(); + let window = AccessWindow { + start_timestamp, + end_timestamp, + }; + Self::validate_window(&window)?; + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + env.storage() + .persistent() + .set(&WindowDataKey::Report(offering_id), &window); + env.events().publish( + (EVENT_REPORT_WINDOW_SET, issuer, namespace, token), + (start_timestamp, end_timestamp), + ); + Ok(()) + } + + /// Configure the claiming access window for an offering. + /// If unset, claiming remains always permitted. + pub fn set_claim_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + start_timestamp: u64, + end_timestamp: u64, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + 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); + } + issuer.require_auth(); + let window = AccessWindow { + start_timestamp, + end_timestamp, + }; + Self::validate_window(&window)?; + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + env.storage() + .persistent() + .set(&WindowDataKey::Claim(offering_id), &window); + env.events().publish( + (EVENT_CLAIM_WINDOW_SET, issuer, namespace, token), + (start_timestamp, end_timestamp), + ); + Ok(()) + } + + /// Read configured reporting window (if any) for an offering. + pub fn get_report_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env.storage() + .persistent() + .get(&WindowDataKey::Report(offering_id)) + } + + /// Read configured claiming window (if any) for an offering. + pub fn get_claim_window( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { + issuer, + namespace, + token, + }; + env.storage() + .persistent() + .get(&WindowDataKey::Claim(offering_id)) + } + /// Return unclaimed period IDs for a holder on an offering. /// Ordering: by deposit index (creation order), deterministic (#38). pub fn get_pending_periods(env: Env, issuer: Address, namespace: Symbol, token: Address, holder: Address) -> Vec { @@ -2635,6 +3240,43 @@ impl RevoraRevenueShare { /// Maximum allowed length for metadata strings (256 bytes). /// Supports IPFS CIDs (46 chars), URLs, and content hashes. const MAX_METADATA_LENGTH: usize = 256; + const META_SCHEME_IPFS: &'static [u8] = b"ipfs://"; + const META_SCHEME_HTTPS: &'static [u8] = b"https://"; + const META_SCHEME_AR: &'static [u8] = b"ar://"; + const META_SCHEME_SHA256: &'static [u8] = b"sha256:"; + + fn has_prefix(bytes: &[u8], prefix: &[u8]) -> bool { + if bytes.len() < prefix.len() { + return false; + } + for i in 0..prefix.len() { + if bytes[i] != prefix[i] { + return false; + } + } + true + } + + fn validate_metadata_reference(metadata: &String) -> Result<(), RevoraError> { + if metadata.len() == 0 { + return Ok(()); + } + if metadata.len() > Self::MAX_METADATA_LENGTH as u32 { + return Err(RevoraError::MetadataTooLarge); + } + let mut bytes = [0u8; Self::MAX_METADATA_LENGTH]; + let len = metadata.len() as usize; + metadata.copy_into_slice(&mut bytes[0..len]); + let slice = &bytes[0..len]; + if Self::has_prefix(slice, Self::META_SCHEME_IPFS) + || Self::has_prefix(slice, Self::META_SCHEME_HTTPS) + || Self::has_prefix(slice, Self::META_SCHEME_AR) + || Self::has_prefix(slice, Self::META_SCHEME_SHA256) + { + return Ok(()); + } + Err(RevoraError::MetadataInvalidFormat) + } /// Set or update metadata reference for an offering. /// @@ -2669,11 +3311,8 @@ impl RevoraRevenueShare { issuer.require_auth(); - // Validate metadata length - let metadata_bytes = metadata.len(); - if metadata_bytes > Self::MAX_METADATA_LENGTH as u32 { - return Err(RevoraError::MetadataTooLarge); - } + // Validate metadata length and allowed scheme prefixes. + Self::validate_metadata_reference(&metadata)?; let key = DataKey::OfferingMetadata(offering_id); let is_update = env.storage().persistent().has(&key);