Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/audit_summary_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ fn setup_offering() -> (Env, Address, Address, Address, Address) {
let payout_asset = Address::generate(&env);

client.initialize(&issuer, &None::<Address>, &None::<bool>);
client.register_offering(&issuer, &Vec::new(&env), &1u32, &symbol_short!("def"), &token, &1_000, &payout_asset, &0);
client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0, &symbol_short!(""), &0);

(env, contract_id, issuer, token, payout_asset)
}
Expand Down
28 changes: 17 additions & 11 deletions src/chunking_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ fn setup_with_offering() -> (Env, RevoraRevenueShareClient, Address, Address, Ad
let token = Address::generate(&env);
let (payment_token, pt_admin) = create_payment_token(&env);
// Register offering and fund issuer so deposit_revenue can transfer tokens
client.register_offering(&issuer, &Vec::new(&env), &1u32, &symbol_short!("def"), &token, &1_000, &payment_token, &0);
client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payment_token, &0, &symbol_short!(""), &0);
mint_tokens(&env, &payment_token, &issuer, &100_000i128);
(env, client, issuer, token, payment_token, pt_admin)
}
Expand All @@ -50,7 +50,7 @@ fn get_revenue_range_chunk_matches_full_sum() {

let issuer = Address::generate(&env);
let token = Address::generate(&env);
client.register_offering(&issuer, &Vec::new(&env), &1u32, &symbol_short!("def"), &token, &1000u32, &token, &0i128);
client.register_offering(&issuer, &symbol_short!("def"), &token, &1000u32, &token, &0i128, &symbol_short!(""), &0);

// Report revenue for periods 1..=10
for p in 1u64..=10u64 {
Expand Down Expand Up @@ -92,7 +92,7 @@ fn get_revenue_range_chunk_inverted_range_returns_zero() {

let issuer = Address::generate(&env);
let token = Address::generate(&env);
client.register_offering(&issuer, &Vec::new(&env), &1u32, &symbol_short!("def"), &token, &1000u32, &token, &0i128);
client.register_offering(&issuer, &symbol_short!("def"), &token, &1000u32, &token, &0i128, &symbol_short!(""), &0);

// inverted range: from > to
let (sum, next) = client.get_revenue_range_chunk(&issuer, &symbol_short!("def"), &token, &10u64, &1u64, &5u32);
Expand All @@ -112,7 +112,7 @@ fn get_revenue_range_chunk_cap_clamps_and_returns_next_start() {

let issuer = Address::generate(&env);
let token = Address::generate(&env);
client.register_offering(&issuer, &Vec::new(&env), &1u32, &symbol_short!("def"), &token, &1000u32, &token, &0i128);
client.register_offering(&issuer, &symbol_short!("def"), &token, &1000u32, &token, &0i128, &symbol_short!(""), &0);

// Report revenue for periods 1..=201 with amount 1 each
for p in 1u64..=201u64 {
Expand Down Expand Up @@ -143,7 +143,7 @@ fn get_revenue_range_chunk_chunked_iteration_off_by_one_sequence() {

let issuer = Address::generate(&env);
let token = Address::generate(&env);
client.register_offering(&issuer, &Vec::new(&env), &1u32, &symbol_short!("def"), &token, &1000u32, &token, &0i128);
client.register_offering(&issuer, &symbol_short!("def"), &token, &1000u32, &token, &0i128, &symbol_short!(""), &0);

// Report revenue for periods 1..=5 with increasing amounts for easier validation
for p in 1u64..=5u64 {
Expand Down Expand Up @@ -203,7 +203,8 @@ fn pending_periods_page_and_claimable_chunk_consistent() {
&1000u32,
&payment_token,
&0i128,
&None);
&symbol_short!(""),
&0);
// Mint to issuer so deposit_revenue token transfer succeeds
mint_tokens(&env, &payment_token, &issuer, &100_000i128);

Expand Down Expand Up @@ -330,7 +331,8 @@ fn get_claimable_chunk_table_driven_invariants() {
&1000u32,
&payment_token,
&0i128,
&None);
&symbol_short!(""),
&0);
mint_tokens(&env, &payment_token, &issuer, &100_000i128);

let test_cases = vec![
Expand Down Expand Up @@ -515,7 +517,8 @@ fn get_claimable_chunk_table_driven_invariants() {
&1000u32,
&payment_token,
&0i128,
&None);
&symbol_short!(""),
&0);
mint_tokens(&env, &payment_token, &issuer, &100_000i128);

// Set up test case conditions
Expand Down Expand Up @@ -621,7 +624,8 @@ fn get_claimable_chunk_cursor_idempotency_repeated_queries() {
&1000u32,
&payment_token,
&0i128,
&None);
&symbol_short!(""),
&0);
mint_tokens(&env, &payment_token, &issuer, &100_000i128);

client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &10_000);
Expand Down Expand Up @@ -695,7 +699,8 @@ fn get_claimable_chunk_sum_matches_full_claimable() {
&1000u32,
&payment_token,
&0i128,
&None);
&symbol_short!(""),
&0);
mint_tokens(&env, &payment_token, &issuer, &100_000i128);

client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000);
Expand Down Expand Up @@ -760,7 +765,8 @@ fn get_claimable_chunk_respects_delay_barrier_parity_with_claim() {
&1000u32,
&payment_token,
&0i128,
&None);
&symbol_short!(""),
&0);
mint_tokens(&env, &payment_token, &issuer, &100_000i128);

client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &10_000);
Expand Down
9 changes: 6 additions & 3 deletions src/invalid_amount_matrix_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ fn setup_offering() -> (Env, Address, Address, Address, Address) {
&1_000,
&payout_asset,
&0,
&None);
&symbol_short!(""),
&0);

(env, contract_id, issuer, token, payout_asset)
}
Expand All @@ -64,7 +65,8 @@ fn setup_funded_offering() -> (Env, Address, Address, Address, Address) {
&1_000,
&payment_token,
&0,
&None);
&symbol_short!(""),
&0);
mint(&env, &payment_token, &issuer, 1_000_000);

(env, contract_id, issuer, token, payment_token)
Expand Down Expand Up @@ -98,7 +100,8 @@ fn register_offering_rejects_negative_supply_cap_values() {
&1_000,
&payout_asset,
&invalid_cap,
&None);
&symbol_short!(""),
&0);
assert_eq!(result, Err(Ok(RevoraError::InvalidAmount)));
assert_eq!(client.get_offering_count(&issuer, &symbol_short!("def")), 0);
assert!(client.get_offering(&issuer, &symbol_short!("def"), &token).is_none());
Expand Down
118 changes: 90 additions & 28 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,10 @@ pub enum RevoraError {
SnapshotNotFinalized = 49,
/// The recomputed snapshot digest does not match the committed `content_hash`.
SnapshotHashMismatch = 50,
/// Concentration data is missing or stale for an enforced offering.
StaleConcentrationData = 51,
/// `display_decimals` exceeds the maximum allowed precision of 18.
///
/// Wire value: 51. Stable since v1.
DisplayDecimalsOutOfRange = 51,
/// Payout asset mismatch.
PayoutAssetMismatch = 14,
/// A transfer is already pending for this offering.
Expand Down Expand Up @@ -505,6 +507,14 @@ pub struct Offering {
/// Cumulative revenue share for all holders in basis points (0-10000).
pub revenue_share_bps: u32,
pub payout_asset: Address,
/// Human-readable ticker/symbol for the payout denomination (e.g. `USDC`, `XLM`).
/// Used by wallets and dashboards to display amounts without guessing the payment token.
/// Maximum 9 characters (Soroban `Symbol` limit).
pub denomination_symbol: Symbol,
/// Number of decimal places to use when displaying amounts for this offering.
/// Must be ≤ `MAX_TOKEN_DECIMALS` (18) and ≤ the payment token's on-chain decimals.
/// Defaults to 0 when unset.
pub display_decimals: u32,
}

/// Per-offering FX oracle configuration used when `report_revenue` receives a
Expand Down Expand Up @@ -1026,8 +1036,11 @@ pub enum DataKey2 {

/// Sealed-period flag: when present, `report_revenue` overrides are rejected for this period.
ClosedPeriod(OfferingId, u64),
/// Emergency holder freeze for an offering.
EmergencyFreeze(OfferingId, Address),

/// Per-offering denomination display metadata (denomination_symbol, display_decimals).
/// Written once at `register_offering` and never mutated.
/// Allows wallets and dashboards to render amounts correctly without guessing token semantics.
DenominationMetadata(OfferingId),
}

/// Maximum number of offerings returned in a single page.
Expand Down Expand Up @@ -3264,21 +3277,34 @@ impl RevoraRevenueShare {
/// * `issuer` - The address of the offering issuer. Must provide authentication.
/// * `namespace` - A symbol identifying the namespace for this offering.
/// * `token` - The address of the token being offered.
/// * `revenue_share_bps` - The revenue share percentage in basis points (0–10,000).
/// * `revenue_share_bps` - The revenue share percentage in basis points (0-10,000).
/// Values above 10,000 are rejected unless testnet mode is enabled (admin-only,
/// never enable on mainnet — see `TESTNET_MODE.md`).
/// never enable on mainnet - see `TESTNET_MODE.md`).
/// * `payout_asset` - The asset in which revenue will be paid out.
/// * `supply_cap` - Optional cap on the total amount of revenue that can be deposited (0 = no cap).
/// * `denomination_symbol` - Human-readable ticker for the payout denomination (e.g. `USDC`, `XLM`).
/// Stored as-is; not validated against on-chain token registries.
/// Maximum 9 characters (Soroban `Symbol` limit).
/// * `display_decimals` - Decimal precision wallets should use when displaying amounts.
/// Must satisfy `display_decimals <= MAX_TOKEN_DECIMALS (18)`.
/// Callers should also ensure `display_decimals <= payment_token_decimals`; verify
/// via `get_payment_token_decimals` before calling.
///
/// # Returns
/// - `Ok(())` on success.
/// - `Err(RevoraError::InvalidRevenueShareBps)` if `revenue_share_bps` exceeds 10,000
/// and testnet mode is disabled (the default).
/// - `Err(RevoraError::DisplayDecimalsOutOfRange)` if `display_decimals > 18`.
/// - `Err(RevoraError::ContractFrozen)` if the contract is frozen.
/// - `Err(RevoraError::ContractPaused)` if the contract is paused.
///
/// # Events
/// Emits `EVENT_OFFER_REG_V2` and `EVENT_INDEXED_V2`.
/// Emits `EVENT_OFFER_REG_V2` (payload includes `denomination_symbol` and `display_decimals`)
/// and `EVENT_INDEXED_V2`.
///
/// # Security note
/// `denomination_symbol` is informational only and does not affect payout math or transfers.
/// Issuers are responsible for providing values consistent with the actual `payout_asset`.
#[allow(clippy::too_many_arguments)]
pub fn register_offering(
env: Env,
Expand All @@ -3290,7 +3316,8 @@ impl RevoraRevenueShare {
revenue_share_bps: u32,
payout_asset: Address,
supply_cap: i128,
max_total_supply_shares: i128,
denomination_symbol: Symbol,
display_decimals: u32,
) -> Result<(), RevoraError> {
Self::require_not_frozen(&env)?;
Self::require_not_paused(&env)?;
Expand Down Expand Up @@ -3320,16 +3347,15 @@ impl RevoraRevenueShare {
return Err(err);
}

// Negative Amount Validation Matrix: MaxTotalSupplyShares requires >= 0
if let Err((err, _)) =
AmountValidationMatrix::validate(max_total_supply_shares, AmountValidationCategory::MaxTotalSupplyShares)
{
return Err(err);
// display_decimals must not exceed the protocol-wide maximum of 18.
// Prevents callers from supplying nonsensical precision that confuses downstream display.
if display_decimals > MAX_TOKEN_DECIMALS {
return Err(RevoraError::DisplayDecimalsOutOfRange);
}

// Skip bps validation in testnet mode (reads the real flag from storage).
// In production mode (default) revenue_share_bps is always capped at 10 000 (100%).
// Testnet mode is admin-only and must never be enabled on mainnet — see TESTNET_MODE.md.
// Testnet mode is admin-only and must never be enabled on mainnet - see TESTNET_MODE.md.
let testnet_mode = Self::is_testnet_mode(env.clone());
if !testnet_mode && revenue_share_bps > 10_000 {
return Err(RevoraError::InvalidRevenueShareBps);
Expand Down Expand Up @@ -3373,6 +3399,8 @@ impl RevoraRevenueShare {
token: token.clone(),
revenue_share_bps,
payout_asset: payout_asset.clone(),
denomination_symbol: denomination_symbol.clone(),
display_decimals,
};

let item_key = DataKey::OfferItem(tenant_id.clone(), count);
Expand All @@ -3382,6 +3410,12 @@ impl RevoraRevenueShare {
// Direct index for O(1) get_offering (#360).
env.storage().persistent().set(&DataKey2::OfferingRecord(offering_id.clone()), &offering);

// Denomination metadata auxiliary index: O(1) read for display semantics.
env.storage().persistent().set(
&DataKey2::DenominationMetadata(offering_id.clone()),
&(denomination_symbol.clone(), display_decimals),
);

let issuer_lookup_key = DataKey::OfferingIssuer(offering_id.clone());
env.storage().persistent().set(&issuer_lookup_key, &primary_issuer);

Expand All @@ -3390,15 +3424,18 @@ impl RevoraRevenueShare {
env.storage().persistent().set(&cap_key, &supply_cap);
}

if max_total_supply_shares > 0 {
let max_shares_key = DataKey2::MaxTotalSupplyShares(offering_id.clone());
env.storage().persistent().set(&max_shares_key, &max_total_supply_shares);
}

// Primary registration event - denomination metadata included so indexers never
// need a second call to learn display semantics.
Self::emit_v2_event(
&env,
(EVENT_OFFER_REG_V2, primary_issuer.clone(), namespace.clone()),
(token.clone(), revenue_share_bps, payout_asset.clone()),
(EVENT_OFFER_REG_V2, issuer.clone(), namespace.clone()),
(
token.clone(),
revenue_share_bps,
payout_asset.clone(),
denomination_symbol.clone(),
display_decimals,
),
);

env.events().publish(
Expand All @@ -3422,16 +3459,41 @@ impl RevoraRevenueShare {
(EVENT_SCHEMA_VERSION, token.clone(), revenue_share_bps, payout_asset.clone()),
);
}
// Versioned v2 event: [2, token, revenue_share_bps, payout_asset] — always emitted (#RC26Q2-C31)
// Versioned v2 event: always emitted (#RC26Q2-C31).
// Payload: (token, revenue_share_bps, payout_asset, denomination_symbol, display_decimals)
Self::emit_v2_event(
&env,
(EVENT_OFFER_REG_V2, primary_issuer, namespace, token.clone()),
(token, revenue_share_bps, payout_asset),
(EVENT_OFFER_REG_V2, issuer, namespace, token.clone()),
(token, revenue_share_bps, payout_asset, denomination_symbol, display_decimals),
);

Ok(())
}

/// Return the denomination display metadata for an offering.
///
/// This is a cheap O(1) read that does not require iterating offerings.
///
/// ### Parameters
/// - `issuer`: The issuer address.
/// - `namespace`: The offering namespace.
/// - `token`: The offering token address.
///
/// ### Returns
/// `Some((denomination_symbol, display_decimals))` if the offering exists,
/// `None` if no offering with that identity has been registered.
pub fn get_denomination_metadata(
env: Env,
issuer: Address,
namespace: Symbol,
token: Address,
) -> Option<(Symbol, u32)> {
let offering_id = OfferingId { issuer, namespace, token };
env.storage()
.persistent()
.get::<DataKey2, (Symbol, u32)>(&DataKey2::DenominationMetadata(offering_id))
}

/// Fetch a single offering by issuer and token.
///
/// This method scans the issuer's registered offerings to find the one matching the given token.
Expand Down Expand Up @@ -9311,7 +9373,7 @@ mod issue_370_373_tests {
let mut tokens = Vec::new(&env);
for i in 0..25_u32 {
let token = Address::generate(&env);
client.register_offering(&issuer, &Vec::new(&env), &1u32, &namespace, &token, &(1_000 + i), &token, &0);
client.register_offering(&issuer, &namespace, &token, &(1_000 + i), &token, &0, &symbol_short!(""), &0);
tokens.push_back(token);
}

Expand Down Expand Up @@ -9382,13 +9444,13 @@ mod issue_370_373_tests {

let new_token_0 = Address::generate(&env);
let new_token_1 = Address::generate(&env);
client.register_offering(&new_issuer, &Vec::new(&env), &1u32, &namespace, &new_token_0, &1_100, &new_token_0, &0);
client.register_offering(&new_issuer, &Vec::new(&env), &1u32, &namespace, &new_token_1, &1_200, &new_token_1, &0);
client.register_offering(&new_issuer, &namespace, &new_token_0, &1_100, &new_token_0, &0, &symbol_short!(""), &0);
client.register_offering(&new_issuer, &namespace, &new_token_1, &1_200, &new_token_1, &0, &symbol_short!(""), &0);

let mut old_tokens = Vec::new(&env);
for i in 0..25_u32 {
let token = Address::generate(&env);
client.register_offering(&old_issuer, &Vec::new(&env), &1u32, &namespace, &token, &(2_000 + i), &token, &0);
client.register_offering(&old_issuer, &namespace, &token, &(2_000 + i), &token, &0, &symbol_short!(""), &0);
old_tokens.push_back(token);
}

Expand Down
6 changes: 3 additions & 3 deletions src/milestone_signals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ fn setup_offering(env: &Env, client: &RevoraRevenueShareClient) -> (Address, Add
let token = Address::generate(env);
let payout = Address::generate(env);
client.set_admin(&issuer);
client.register_offering(&issuer, &Vec::new(&env), &1u32, &symbol_short!("def"), &token, &1_000u32, &payout, &0i128);
client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000u32, &payout, &0i128, &symbol_short!(""), &0);
(issuer, token, payout)
}

Expand Down Expand Up @@ -469,8 +469,8 @@ fn milestone_audit_summary_isolated_per_offering() {
let ns = symbol_short!("def");

client.set_admin(&issuer);
client.register_offering(&issuer, &Vec::new(&env), &1u32, &ns, &token_a, &1_000u32, &payout, &0i128);
client.register_offering(&issuer, &Vec::new(&env), &1u32, &ns, &token_b, &1_000u32, &payout, &0i128);
client.register_offering(&issuer, &ns, &token_a, &1_000u32, &payout, &0i128, &symbol_short!(""), &0);
client.register_offering(&issuer, &ns, &token_b, &1_000u32, &payout, &0i128, &symbol_short!(""), &0);

client.report_revenue(&issuer, &ns, &token_a, &payout, &5_000i128, &1u64, &false);

Expand Down
Loading
Loading