Skip to content
Open
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
315 changes: 315 additions & 0 deletions changes.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
diff --git a/src/lib.rs b/src/lib.rs
index afd6a61..e9f47ea 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -41,8 +41,8 @@
clippy::enum_variant_names
)]
use soroban_sdk::{
- contract, contracterror, contractimpl, contracttype, symbol_short, token, xdr::ToXdr, Address,
- Bytes, BytesN, Env, IntoVal, Map, Symbol, Vec,
+ contract, contractclient, contracterror, contractimpl, contracttype, symbol_short, token,
+ xdr::ToXdr, Address, Bytes, BytesN, Env, IntoVal, Map, Symbol, Vec,
};

// Issue #109 — Revenue report correction and audit-summary reconciliation are
@@ -169,6 +169,15 @@ pub enum RevoraError {
///
/// Wire value: 48. Stable since v1.
PeriodAlreadyClosed = 48,
+ /// No FX oracle is configured for a cross-currency revenue report.
+ OracleNotConfigured = 51,
+ /// The configured FX oracle quote is older than the offering's maximum allowed age.
+ OracleQuoteStale = 52,
+}
+
+#[contractclient(name = "FxOracleClient")]
+pub trait FxOracle {
+ fn quote(env: Env, from: Symbol, to: Symbol) -> (i128, u64);
}

pub mod vesting;
@@ -395,6 +404,17 @@ pub struct Offering {
pub payout_asset: Address,
}

+/// Per-offering FX oracle configuration used when `report_revenue` receives a
+/// revenue asset that differs from the offering payout asset.
+#[contracttype]
+#[derive(Clone, Debug, PartialEq)]
+pub struct FxOracleConfig {
+ pub oracle: Address,
+ pub revenue_symbol: Symbol,
+ pub payout_symbol: Symbol,
+ pub max_oracle_age_secs: u64,
+}
+
/// Per-offering concentration guardrail config (#26).
/// max_bps: max allowed single-holder share in basis points (0 = disabled).
/// enforce: if true, report_revenue fails when current concentration > max_bps.
@@ -767,6 +787,8 @@ pub enum DataKey2 {

/// Sealed-period flag: when present, `report_revenue` overrides are rejected for this period.
ClosedPeriod(OfferingId, u64),
+ /// Per-offering FX oracle configuration for cross-currency revenue reports.
+ FxOracleConfig(OfferingId),
}

/// Maximum number of offerings returned in a single page.
@@ -1792,6 +1814,8 @@ impl RevoraRevenueShare {
Self::require_not_frozen(&env)?;
Self::require_not_paused(&env)?;
issuer.require_auth();
+ let mut amount = amount;
+ let mut payout_asset = payout_asset;

let offering_id = OfferingId {
issuer: issuer.clone(),
@@ -2543,6 +2567,93 @@ impl RevoraRevenueShare {
Self::get_locked_payment_token_for_offering(&env, &offering_id)
}

+ /// Configure the FX oracle used to convert cross-currency revenue reports
+ /// into the offering payout asset before storing report and audit state.
+ ///
+ /// The issuer owns this configuration. `revenue_symbol` is passed to the
+ /// oracle as the quote source when `report_revenue` is called with a
+ /// non-payout asset; `payout_symbol` is the quote target for the registered
+ /// offering payout asset.
+ #[allow(clippy::too_many_arguments)]
+ pub fn set_fx_oracle(
+ env: Env,
+ issuer: Address,
+ namespace: Symbol,
+ token: Address,
+ oracle: Address,
+ revenue_symbol: Symbol,
+ payout_symbol: Symbol,
+ max_oracle_age_secs: u64,
+ ) -> 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 config = FxOracleConfig {
+ oracle,
+ revenue_symbol,
+ payout_symbol,
+ max_oracle_age_secs,
+ };
+ env.storage()
+ .persistent()
+ .set(&DataKey2::FxOracleConfig(offering_id), &config);
+ Ok(())
+ }
+
+ /// Return the configured FX oracle for an offering, if one exists.
+ pub fn get_fx_oracle(
+ env: Env,
+ issuer: Address,
+ namespace: Symbol,
+ token: Address,
+ ) -> Option<FxOracleConfig> {
+ let offering_id = OfferingId { issuer, namespace, token };
+ env.storage()
+ .persistent()
+ .get::<DataKey2, FxOracleConfig>(&DataKey2::FxOracleConfig(offering_id))
+ }
+
+ fn convert_report_amount_if_needed(
+ env: &Env,
+ offering_id: &OfferingId,
+ offering: &Offering,
+ reported_asset: &Address,
+ amount: i128,
+ now: u64,
+ ) -> Result<(i128, Address), RevoraError> {
+ if offering.payout_asset == *reported_asset {
+ return Ok((amount, reported_asset.clone()));
+ }
+
+ let config: FxOracleConfig = env
+ .storage()
+ .persistent()
+ .get(&DataKey2::FxOracleConfig(offering_id.clone()))
+ .ok_or(RevoraError::PayoutAssetMismatch)?;
+ let (rate, quoted_at) = FxOracleClient::new(env, &config.oracle)
+ .quote(&config.revenue_symbol, &config.payout_symbol);
+ if config.max_oracle_age_secs > 0
+ && now.saturating_sub(quoted_at) > config.max_oracle_age_secs
+ {
+ return Err(RevoraError::OracleQuoteStale);
+ }
+ let converted_amount = amount.saturating_mul(rate).saturating_div(BPS_DENOMINATOR);
+ Ok((converted_amount, offering.payout_asset.clone()))
+ }
+
/// Record or correct a revenue report for an offering and emit audit events.
///
/// Semantics:
@@ -2607,7 +2718,6 @@ impl RevoraRevenueShare {
token: token.clone(),
};
let last_report_period_key = DataKey2::LastReportedPeriodId(offering_id.clone());
- let threshold = Self::get_min_revenue_threshold_for_offering(&env, &offering_id);
let current_timestamp = env.ledger().timestamp();

Self::require_not_offering_frozen(&env, &offering_id)?;
@@ -2624,9 +2734,16 @@ impl RevoraRevenueShare {
let offering =
Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone())
.ok_or(RevoraError::OfferingNotFound)?;
- if offering.payout_asset != payout_asset {
- return Err(RevoraError::PayoutAssetMismatch);
- }
+ let converted = Self::convert_report_amount_if_needed(
+ &env,
+ &offering_id,
+ &offering,
+ &payout_asset,
+ amount,
+ current_timestamp,
+ )?;
+ amount = converted.0;
+ payout_asset = converted.1;

// Testnet mode bypass: if enabled, skip concentration limit enforcement
// to allow flexible testing of revenue flows without holder constraints.
@@ -2667,6 +2784,8 @@ impl RevoraRevenueShare {
}
}

+ let threshold = Self::get_min_revenue_threshold_for_offering(&env, &offering_id);
+
// Use bounded read for event snapshots to avoid unbounded payloads
// Cap at MAX_PAGE_LIMIT (20) to prevent gas spikes from large blacklists
let blacklist = if event_only {
@@ -6759,6 +6878,116 @@ impl RevoraRevenueShare {
}
} // end impl RevoraRevenueShare (plain)

+#[cfg(test)]
+mod issue_455_fx_oracle_tests {
+ use super::*;
+ use soroban_sdk::{contract, contractimpl, testutils::Address as _, Address, Env, Symbol};
+
+ #[contract]
+ pub struct FreshFxOracleStub;
+
+ #[contractimpl]
+ impl FreshFxOracleStub {
+ pub fn quote(env: Env, from: Symbol, to: Symbol) -> (i128, u64) {
+ assert_eq!(from, Symbol::new(&env, "EUR"));
+ assert_eq!(to, Symbol::new(&env, "USDC"));
+ (12_000, env.ledger().timestamp())
+ }
+ }
+
+ #[contract]
+ pub struct StaleFxOracleStub;
+
+ #[contractimpl]
+ impl StaleFxOracleStub {
+ pub fn quote(env: Env, from: Symbol, to: Symbol) -> (i128, u64) {
+ assert_eq!(from, Symbol::new(&env, "EUR"));
+ assert_eq!(to, Symbol::new(&env, "USDC"));
+ (12_000, env.ledger().timestamp().saturating_sub(120))
+ }
+ }
+
+ fn setup() -> (Env, RevoraRevenueShareClient<'static>, Address, Symbol, Address, Address) {
+ let env = Env::default();
+ env.mock_all_auths();
+ env.ledger().with_mut(|ledger| ledger.timestamp = 1_000);
+
+ let contract_id = env.register_contract(None, RevoraRevenueShare);
+ let client = RevoraRevenueShareClient::new(&env, &contract_id);
+ let issuer = Address::generate(&env);
+ let namespace = Symbol::new(&env, "def");
+ let token = Address::generate(&env);
+ let payout_asset = Address::generate(&env);
+
+ client.register_offering(&issuer, &namespace, &token, &5_000, &payout_asset, &0);
+ (env, client, issuer, namespace, token, payout_asset)
+ }
+
+ #[test]
+ fn report_revenue_converts_cross_currency_amount_with_registered_oracle() {
+ let (env, client, issuer, namespace, token, _payout_asset) = setup();
+ let oracle = env.register_contract(None, FreshFxOracleStub);
+ let reported_asset = Address::generate(&env);
+
+ client.set_fx_oracle(
+ &issuer,
+ &namespace,
+ &token,
+ &oracle,
+ &Symbol::new(&env, "EUR"),
+ &Symbol::new(&env, "USDC"),
+ &60,
+ );
+
+ client.report_revenue(
+ &issuer,
+ &namespace,
+ &token,
+ &reported_asset,
+ &1_000,
+ &1,
+ &false,
+ );
+
+ assert_eq!(client.get_revenue_by_period(&issuer, &namespace, &token, &1), 1_200);
+ assert_eq!(
+ client.get_audit_summary(&issuer, &namespace, &token).unwrap().total_revenue,
+ 1_200
+ );
+ }
+
+ #[test]
+ fn stale_oracle_quote_rejects_report_without_state_change() {
+ let (env, client, issuer, namespace, token, _payout_asset) = setup();
+ let oracle = env.register_contract(None, StaleFxOracleStub);
+ let reported_asset = Address::generate(&env);
+
+ client.set_fx_oracle(
+ &issuer,
+ &namespace,
+ &token,
+ &oracle,
+ &Symbol::new(&env, "EUR"),
+ &Symbol::new(&env, "USDC"),
+ &60,
+ );
+
+ let result = client.try_report_revenue(
+ &issuer,
+ &namespace,
+ &token,
+ &reported_asset,
+ &1_000,
+ &1,
+ &false,
+ );
+
+ assert_eq!(result, Err(Ok(RevoraError::OracleQuoteStale)));
+ assert_eq!(client.get_revenue_by_period(&issuer, &namespace, &token, &1), 0);
+ assert_eq!(client.get_audit_summary(&issuer, &namespace, &token), None);
+ }
+}
+
#[cfg(test)]
mod issue_370_373_tests {
use super::*;
Loading
Loading