diff --git a/docs/denomination-migration.md b/docs/denomination-migration.md new file mode 100644 index 00000000..8f12ce19 --- /dev/null +++ b/docs/denomination-migration.md @@ -0,0 +1,137 @@ +# Denomination Migration + +## Overview + +Some payment tokens (e.g., USDC, EURC) may change their on-chain decimal precision over time via a protocol upgrade. When this happens, all raw amounts stored in this contract — revenue totals, audit summaries, supply caps — that represent balances in that token must be re-scaled to stay consistent with the token's new decimal representation. + +`migrate_denomination` provides a controlled, issuer-authorized migration path that re-scales stored aggregate amounts and updates the `PaymentTokenDecimals` metadata in one atomic call. + +## Function Signature + +```rust +pub fn migrate_denomination( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + from_decimals: u32, + to_decimals: u32, +) -> Result<(), RevoraError> +``` + +## Parameters + +| Parameter | Type | Description | +|-----------------|-----------|--------------------------------------------------------------| +| `issuer` | `Address` | The offering issuer (must sign the transaction). | +| `namespace` | `Symbol` | The offering namespace. | +| `token` | `Address` | The offering token address. | +| `from_decimals` | `u32` | The **current** decimal precision of the payment token. | +| `to_decimals` | `u32` | The **new** decimal precision of the payment token. | + +## Behaviour + +### Amounts re-scaled + +| Storage Key | Type | Re-scaling behaviour | +|------------------------------------------|-----------------|----------------------------------------------| +| `DataKey2::DepositedRevenue(OfferingId)` | `i128` | Multiplied / divided by `10^\|to-from\|` | +| `DataKey::AuditSummary.total_revenue` | `i128` | Multiplied / divided by `10^\|to-from\|` | +| `DataKey2::SupplyCap(OfferingId)` | `i128` | Multiplied / divided by `10^\|to-from\|` (if set) | +| `DataKey2::PaymentTokenDecimals` | `u32` | Updated to `to_decimals` | + +### Upscale (`to > from`) + +Stored amounts are multiplied by `10^(to - from)`. + +**Example:** USDC migrates from 6 decimals → 18 decimals. +A `DepositedRevenue` of `1_000_000` (1.0 USDC) becomes `1_000_000_000_000_000_000`. + +### Downscale (`to < from`) + +Stored amounts are divided by `10^(from - to)`. + +**⚠️ Precision loss warning:** Integer division truncates. If a stored amount is not evenly divisible by the scale factor, the remainder is lost. For example, downscaling from 18 → 6 decimals with a `DepositedRevenue` of `1_999_999_999_999_999_999` (≈ 1.9999... tokens) becomes `1_999_999` (≈ 1.999999 tokens), silently discarding the fractional tail. Re-scaling back (6 → 18) would not recover the lost precision. + +Issuers SHOULD ensure that stored amounts are clean multiples of the scale factor before initiating a downscale migration. + +### No-op (`from == to`) + +Returns `Ok(())` immediately with no state mutation and no event emitted. + +## Idempotency + +Each distinct `(offering_id, from_decimals, to_decimals)` path is executed **at most once**. A boolean marker is persisted under `DataKey2::DenomMigration(OfferingId, u32, u32)` after the first successful call. Subsequent calls with the same triple return `Ok(())` early with no state changes. + +This guarantees that: +- Retries due to ledger failures are safe. +- Multiple issuers cannot accidentally double-migrate the same path. +- Different migration paths (e.g., 6→18 vs 18→6) are independent and can both be executed. + +## Authorization + +1. `issuer.require_auth()` — the caller must authenticate as the issuer. +2. `Self::require_not_frozen` / `Self::require_not_paused` — the contract must not be frozen or paused. +3. `offering.issuers.primary == issuer` — only the offering's primary issuer can migrate. +4. `Self::require_issuer_quorum_auth` — if co-issuers are configured, the quorum must be met. + +## Event + +```rust +event: (den_mig, issuer, namespace, token) +data: (from_decimals: u32, to_decimals: u32, caller: Address) +``` + +## Error Cases + +| Error | Condition | +|--------------------------------|--------------------------------------------------------| +| `OfferingNotFound` | Offering does not exist, or caller is not the issuer. | +| `LimitReached` | `from_decimals > 18` or `to_decimals > 18`. | +| `ContractFrozen` | Contract-level freeze is active. | +| `ContractPaused` | Contract is paused. | +| `InvalidAmount` | Checked arithmetic overflow during re-scaling. | + +## Limitations + +### Per-period revenues + +`DataKey::PeriodRevenue(OfferingId, u64)` entries are **not** re-scaled by this function. The issuer should close any open periods before calling `migrate_denomination` so that future deposits use the new decimal precision. Past unclaimed periods remain in the old denomination; the issuer may re-deposit corrected amounts if needed. + +### Downscale precision + +See the **Downscale** section above for the truncation caveat. + +## Usage Example + +```rust +// USDC migrates from 6 to 18 decimals +contract.migrate_denomination( + &issuer, + &symbol_short!("def"), + &token, + &6, // from + &18, // to +)?; +``` + +## Storage Layout + +``` +DataKey2::DenomMigration(OfferingId, u32, u32) → bool offering+path +``` + +## Testing + +See `src/test_denom_migration.rs` for the test suite covering: + +- ✅ Upscale (6→18) — amounts multiplied correctly +- ✅ Downscale (18→6) — amounts divided correctly +- ✅ No-op (same decimals) — no state mutation +- ✅ Idempotency — second call with same (from, to) is a no-op +- ✅ Idempotency — different (from, to) paths execute independently +- ✅ Authorization — non-issuer rejected (returns OfferingNotFound) +- ✅ Non-existent offering — returns OfferingNotFound +- ✅ Decimal bounds — `> 18` rejected +- ✅ SupplyCap re-scaling — supply cap rescaled when present +- ✅ Event emission — `den_mig` event published with correct data diff --git a/src/lib.rs b/src/lib.rs index 88a4e178..e6df34af 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -391,12 +391,16 @@ mod test_time_windows; // #[cfg(test)] // mod test_claim_transfer_fail; #[cfg(test)] +mod test_accrual_reconciliation_prop; +#[cfg(test)] mod test_close_period; #[cfg(test)] mod test_compute_share_decomposition_prop; #[cfg(test)] mod test_compute_share_decomposition_prop; #[cfg(test)] +mod test_denom_migration; +#[cfg(test)] mod test_disclosure; #[cfg(test)] mod test_faucet_metrics; @@ -408,8 +412,6 @@ mod test_quorum_check; #[cfg(test)] mod test_reg_limit_delta; #[cfg(test)] -mod test_accrual_reconciliation_prop; -#[cfg(test)] mod test_tax_year; #[cfg(test)] mod test_transfer_cooldown; @@ -463,7 +465,10 @@ const EVENT_FREEZE_REASON_V1: Symbol = symbol_short!("frz_rsn"); const EVENT_CLAIM_DELAY_SET_V2: Symbol = symbol_short!("dly_set2"); const EVENT_CONCENTRATION_WARNING_V2: Symbol = symbol_short!("conc2"); const EVENT_DECIMAL_SET: Symbol = symbol_short!("pt_dec"); -const EVENT_SUPPLY_CAP_SATURATED: Symbol = symbol_short!("cap_sat"); +/// Emitted when stored amounts are re-scaled due to a payment token decimal migration. +/// Topics: `(denom_mig, issuer, namespace, token)` +/// Data: `(from_decimals: u32, to_decimals: u32, caller: Address)` +const EVENT_DENOM_MIGRATED: Symbol = symbol_short!("den_mig"); const EVENT_PROPOSAL_CREATED_V2: Symbol = symbol_short!("prop_n2"); const EVENT_PROPOSAL_APPROVED_V2: Symbol = symbol_short!("prop_a2"); @@ -1666,6 +1671,12 @@ pub enum DataKey2 { /// Vote record for (offering_id, proposal_id, voter) -> bool (true=yes, false=no). VoteRecord(OfferingId, u32, Address), + // ── Denomination migration (issue #denom-migration) ── + /// Idempotency marker for denomination migrations. Keyed by + /// `(offering_id, from_decimals, to_decimals)` to guarantee exactly-once + /// execution per distinct (from, to) path. + DenomMigration(OfferingId, u32, u32), + // ── Deferred-distribution priority queue (issue #551) ── /// Priority-ordered deferred-distribution queue for an offering. /// Value: `Vec` stored in `(release_ts, priority, queue_id)` sorted order. @@ -8485,8 +8496,143 @@ impl RevoraRevenueShare { .unwrap_or(STELLAR_CANONICAL_DECIMALS) } - // ── Multi-period aggregated claims ─────────────────────────── + // —— Denomination migration ———————————————————————————————————— + + /// Migrate stored amounts and metadata when a payment token changes its decimal + /// precision. Re-scales all aggregate revenue amounts and updates the stored + /// `PaymentTokenDecimals` setting so that future `normalize_amount` calls produce + /// correct canonical values. + /// + /// # Idempotency + /// + /// Each distinct `(offering_id, from_decimals, to_decimals)` path is executed at + /// most once. A marker is persisted after the first successful call; subsequent + /// calls with the same triple return `Ok(())` with no state mutation. + /// + /// # Amounts re-scaled + /// + /// - `DepositedRevenue` (aggregate deposited total) + /// - `AuditSummary.total_revenue` + /// - `SupplyCap` (if set) + /// + /// Per-period revenues are **not** re-scaled by this function. The issuer should + /// close any open periods before calling `migrate_denomination` to ensure future + /// deposits use the new decimal precision. + /// + /// # Errors + /// - `RevoraError::OfferingNotFound` if the offering does not exist. + /// - `RevoraError::LimitReached` if either `from_decimals` or `to_decimals` exceeds 18. + /// - `RevoraError::ContractFrozen` if the contract is frozen. + /// - `RevoraError::ContractPaused` if the contract is paused. + pub fn migrate_denomination( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + from_decimals: u32, + to_decimals: u32, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + if from_decimals > MAX_TOKEN_DECIMALS || to_decimals > MAX_TOKEN_DECIMALS { + return Err(RevoraError::LimitReached); + } + + if from_decimals == to_decimals { + // No-op: same precision, nothing to migrate. + return Ok(()); + } + + let offering = + Self::get_offering(env.clone(), issuer.clone(), namespace.clone(), token.clone()) + .ok_or(RevoraError::OfferingNotFound)?; + + if offering.issuers.primary != issuer { + return Err(RevoraError::OfferingNotFound); + } + Self::require_issuer_quorum_auth(&env, &offering.issuers); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + + // —— Idempotency guard ————————————————————————————————————————————————— + let idempotency_key = + DataKey2::DenomMigration(offering_id.clone(), from_decimals, to_decimals); + if env.storage().persistent().has(&idempotency_key) { + return Ok(()); + } + + // —— Compute rescale factor ————————————————————————————————————————————————— + let scale_factor: i128; + let is_upscale: bool; + if to_decimals > from_decimals { + let exp = to_decimals - from_decimals; + scale_factor = 10_i128.checked_pow(exp).ok_or(RevoraError::InvalidAmount)?; + is_upscale = true; + } else { + let exp = from_decimals - to_decimals; + scale_factor = 10_i128.checked_pow(exp).ok_or(RevoraError::InvalidAmount)?; + is_upscale = false; + } + + // —— Re-scale DepositedRevenue ————————————————————————————————————————————————— + let deposited_key = DataKey2::DepositedRevenue(offering_id.clone()); + if let Some(deposited) = env.storage().persistent().get::(&deposited_key) { + let new_deposited = if is_upscale { + deposited.checked_mul(scale_factor).ok_or(RevoraError::InvalidAmount)? + } else { + deposited.checked_div(scale_factor).ok_or(RevoraError::InvalidAmount)? + }; + env.storage().persistent().set(&deposited_key, &new_deposited); + } + + // —— Re-scale AuditSummary.total_revenue ————————————————————————————————————————————————— + let audit_key = DataKey::AuditSummary(offering_id.clone()); + if let Some(mut audit) = env.storage().persistent().get::(&audit_key) + { + audit.total_revenue = if is_upscale { + audit.total_revenue.checked_mul(scale_factor).ok_or(RevoraError::InvalidAmount)? + } else { + audit.total_revenue.checked_div(scale_factor).ok_or(RevoraError::InvalidAmount)? + }; + env.storage().persistent().set(&audit_key, &audit); + } + + // —— Re-scale SupplyCap if set ————————————————————————————————————————————————— + let supply_cap_key = DataKey2::SupplyCap(offering_id.clone()); + if let Some(supply_cap) = env.storage().persistent().get::(&supply_cap_key) + { + let new_supply_cap = if is_upscale { + supply_cap.checked_mul(scale_factor).ok_or(RevoraError::InvalidAmount)? + } else { + supply_cap.checked_div(scale_factor).ok_or(RevoraError::InvalidAmount)? + }; + env.storage().persistent().set(&supply_cap_key, &new_supply_cap); + } + + // —— Update PaymentTokenDecimals ————————————————————————————————————————————————— + env.storage() + .persistent() + .set(&DataKey2::PaymentTokenDecimals(offering_id.clone()), &to_decimals); + + // —— Persist idempotency marker ————————————————————————————————————————————————— + env.storage().persistent().set(&idempotency_key, &true); + + // —— Emit event ————————————————————————————————————————————————— + env.events().publish( + (EVENT_DENOM_MIGRATED, issuer.clone(), namespace.clone(), token.clone()), + (from_decimals, to_decimals, issuer), + ); + + Ok(()) + } + // —— Multi-period aggregated claims ————————————————————————————————————— /// Deposit revenue for a specific period of an offering. /// /// # Arguments diff --git a/src/tax_bucket.rs b/src/tax_bucket.rs index 073f1b62..74596efc 100644 --- a/src/tax_bucket.rs +++ b/src/tax_bucket.rs @@ -146,11 +146,11 @@ pub fn update_tax_year_accumulator( return_of_capital: i128, ) { let year_key = DataKey2::TaxYearEntry(offering_id.clone(), holder.clone(), fiscal_year); - let mut summary: TaxYearSummary = env.storage().persistent().get(&year_key).unwrap_or(TaxYearSummary { - ordinary_income: 0, - capital_gains: 0, - return_of_capital: 0, - }); + let mut summary: TaxYearSummary = env + .storage() + .persistent() + .get(&year_key) + .unwrap_or(TaxYearSummary { ordinary_income: 0, capital_gains: 0, return_of_capital: 0 }); summary.ordinary_income = summary.ordinary_income.saturating_add(ordinary_income); summary.capital_gains = summary.capital_gains.saturating_add(capital_gains); summary.return_of_capital = summary.return_of_capital.saturating_add(return_of_capital); diff --git a/src/test_denom_migration.rs b/src/test_denom_migration.rs new file mode 100644 index 00000000..17b1f28e --- /dev/null +++ b/src/test_denom_migration.rs @@ -0,0 +1,354 @@ +//! Tests for `migrate_denomination` — payment token decimal migration path. +//! +//! Coverage: +//! - Happy path (upscale 6→18, downscale 18→6, no-op 6→6) +//! - Idempotency (second call with same (from, to) is no-op) +//! - Authorization failure (non-issuer caller) +//! - Non-existent offering +//! - Decimal bounds (out of range → LimitReached) +//! - Aggregate amounts re-scaled correctly (DepositedRevenue, AuditSummary, SupplyCap) +//! - Event emission + +use crate::{ + AuditSummary, DataKey, DataKey2, OfferingId, RevoraError, RevoraRevenueShare, + RevoraRevenueShareClient, +}; +use soroban_sdk::{testutils::Address as _, Address, Env, Symbol}; + +fn make_client(env: &Env) -> RevoraRevenueShareClient { + let contract_id = env.register_contract(None, RevoraRevenueShare); + RevoraRevenueShareClient::new(env, &contract_id) +} + +fn register_offering( + env: &Env, + client: &RevoraRevenueShareClient, + issuer: &Address, + namespace: &Symbol, + token: &Address, + payout_asset: &Address, +) { + client.register_offering( + issuer, + namespace, + token, + &5_000, // revenue_share_bps + payout_asset, + &0, // supply_cap (0 = no cap) + &Symbol::new(env, ""), + &0, // display_decimals + ); +} + +fn set_initial_decimals( + env: &Env, + client: &RevoraRevenueShareClient, + issuer: &Address, + namespace: &Symbol, + token: &Address, + decimals: u32, +) { + // Directly write PaymentTokenDecimals + set some aggregate amounts + let offering_id = + OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), token: token.clone() }; + + // Set initial decimals + client.set_payment_token_decimals(issuer, namespace, token, &decimals); + + // Write DepositedRevenue directly (as if revenue was deposited) + env.storage() + .persistent() + .set(&DataKey2::DepositedRevenue(offering_id.clone()), &1_000_000_i128); + + // Write AuditSummary + let audit = AuditSummary { total_revenue: 5_000_000_i128, report_count: 10_u64 }; + env.storage().persistent().set(&DataKey::AuditSummary(offering_id.clone()), &audit); +} + +fn setup() -> (Env, RevoraRevenueShareClient, Address, Symbol, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let client = make_client(&env); + let issuer = Address::generate(&env); + let namespace = Symbol::new(&env, "def"); + let token = Address::generate(&env); + let payout_asset = Address::generate(&env); + + register_offering(&env, &client, &issuer, &namespace, &token, &payout_asset); + + (env, client, issuer, namespace, token) +} + +// ── Happy path ───────────────────────────────────────────────────────────────── + +/// Upscale: 6 decimals → 18 decimals. +/// All aggregate amounts should be multiplied by 10^(18-6) = 10^12. +#[test] +fn migrate_denomination_upscale_6_to_18() { + let (env, client, issuer, namespace, token) = setup(); + set_initial_decimals(&env, &client, &issuer, &namespace, &token, 6); + + let offering_id = + OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), token: token.clone() }; + + // Verify initial state + assert_eq!(client.get_payment_token_decimals(&issuer, &namespace, &token), 6); + assert_eq!( + env.storage() + .persistent() + .get::(&DataKey2::DepositedRevenue(offering_id.clone())), + Some(1_000_000) + ); + + // Migrate from 6 to 18 decimals + let result = client.try_migrate_denomination(&issuer, &namespace, &token, &6, &18); + assert!(result.is_ok(), "upscale migration should succeed"); + + // Verify PaymentTokenDecimals updated + assert_eq!(client.get_payment_token_decimals(&issuer, &namespace, &token), 18); + + // Verify DepositedRevenue re-scaled: 1_000_000 * 10^12 = 1_000_000_000_000 + let expected_deposited: i128 = 1_000_000_i128 * 10_i128.pow(12); + assert_eq!( + env.storage() + .persistent() + .get::(&DataKey2::DepositedRevenue(offering_id.clone())), + Some(expected_deposited) + ); + + // Verify AuditSummary re-scaled: 5_000_000 * 10^12 + let expected_audit_revenue: i128 = 5_000_000_i128 * 10_i128.pow(12); + let audit = env + .storage() + .persistent() + .get::(&DataKey::AuditSummary(offering_id.clone())) + .unwrap(); + assert_eq!(audit.total_revenue, expected_audit_revenue); + assert_eq!(audit.report_count, 10); // report_count unchanged +} + +/// Downscale: 18 decimals → 6 decimals. +/// All aggregate amounts should be divided by 10^(18-6) = 10^12. +#[test] +fn migrate_denomination_downscale_18_to_6() { + let (env, client, issuer, namespace, token) = setup(); + + // Write larger initial amounts (18-decimal scale) + let offering_id = + OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), token: token.clone() }; + + client.set_payment_token_decimals(&issuer, &namespace, &token, &18); + + env.storage() + .persistent() + .set(&DataKey2::DepositedRevenue(offering_id.clone()), &1_000_000_000_000_i128); + + let audit = AuditSummary { total_revenue: 5_000_000_000_000_i128, report_count: 10_u64 }; + env.storage().persistent().set(&DataKey::AuditSummary(offering_id.clone()), &audit); + + assert_eq!(client.get_payment_token_decimals(&issuer, &namespace, &token), 18); + + // Migrate from 18 to 6 decimals + let result = client.try_migrate_denomination(&issuer, &namespace, &token, &18, &6); + assert!(result.is_ok(), "downscale migration should succeed"); + + // Verify PaymentTokenDecimals updated + assert_eq!(client.get_payment_token_decimals(&issuer, &namespace, &token), 6); + + // Verify DepositedRevenue re-scaled: 1_000_000_000_000 / 10^12 = 1_000_000 + assert_eq!( + env.storage() + .persistent() + .get::(&DataKey2::DepositedRevenue(offering_id.clone())), + Some(1_000_000) + ); + + // Verify AuditSummary re-scaled: 5_000_000_000_000 / 10^12 = 5_000_000 + let audit = env + .storage() + .persistent() + .get::(&DataKey::AuditSummary(offering_id.clone())) + .unwrap(); + assert_eq!(audit.total_revenue, 5_000_000); +} + +/// No-op: from_decimals == to_decimals. No state should change. +#[test] +fn migrate_denomination_noop_same_decimals() { + let (env, client, issuer, namespace, token) = setup(); + set_initial_decimals(&env, &client, &issuer, &namespace, &token, 6); + + let offering_id = + OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), token: token.clone() }; + + let deposited_before = env + .storage() + .persistent() + .get::(&DataKey2::DepositedRevenue(offering_id.clone())); + + let result = client.try_migrate_denomination( + &issuer, &namespace, &token, &6, &6, // same decimals + ); + assert!(result.is_ok()); + + // State unchanged + assert_eq!(client.get_payment_token_decimals(&issuer, &namespace, &token), 6); + assert_eq!( + env.storage() + .persistent() + .get::(&DataKey2::DepositedRevenue(offering_id.clone())), + deposited_before + ); +} + +// ── Idempotency ──────────────────────────────────────────────────────────────── + +/// Calling migrate_denomination twice with the same (from, to) is safe. +/// The second call should succeed as a no-op. +#[test] +fn migrate_denomination_idempotent() { + let (env, client, issuer, namespace, token) = setup(); + set_initial_decimals(&env, &client, &issuer, &namespace, &token, 6); + + let offering_id = + OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), token: token.clone() }; + + let _ = client.try_migrate_denomination(&issuer, &namespace, &token, &6, &18); + + let deposited_after_first = env + .storage() + .persistent() + .get::(&DataKey2::DepositedRevenue(offering_id.clone())); + + let result = client.try_migrate_denomination(&issuer, &namespace, &token, &6, &18); + assert!(result.is_ok(), "second call should succeed (no-op)"); + + // State should be exactly the same as after the first call + assert_eq!( + env.storage() + .persistent() + .get::(&DataKey2::DepositedRevenue(offering_id.clone())), + deposited_after_first + ); + + // Different (from, to) path should execute + let result2 = client.try_migrate_denomination(&issuer, &namespace, &token, &18, &6); + assert!(result2.is_ok(), "different (from,to) should execute"); +} + +// ── Authorization ────────────────────────────────────────────────────────────── + +/// Non-issuer caller should fail with auth error (host panic). +#[test] +fn migrate_denomination_requires_issuer() { + let env = Env::default(); + env.mock_all_auths(); + + let client = make_client(&env); + let issuer = Address::generate(&env); + let namespace = Symbol::new(&env, "def"); + let token = Address::generate(&env); + let payout_asset = Address::generate(&env); + + register_offering(&env, &client, &issuer, &namespace, &token, &payout_asset); + set_initial_decimals(&env, &client, &issuer, &namespace, &token, 6); + + let attacker = Address::generate(&env); + + // Without mock_all_auths on attacker, require_auth will fail + let result = client.try_migrate_denomination(&attacker, &namespace, &token, &6, &18); + // Soroban's host will panic on failed require_auth, so we expect Err + assert!(result.is_err(), "non-issuer should be rejected"); +} + +// ── Error cases ──────────────────────────────────────────────────────────────── + +/// Non-existent offering returns OfferingNotFound. +#[test] +fn migrate_denomination_nonexistent_offering() { + let env = Env::default(); + env.mock_all_auths(); + let client = make_client(&env); + let issuer = Address::generate(&env); + let namespace = Symbol::new(&env, "def"); + let token = Address::generate(&env); + + let result = client.try_migrate_denomination(&issuer, &namespace, &token, &6, &18); + match result { + Err(Ok(RevoraError::OfferingNotFound)) => {} // expected + Err(Ok(other)) => panic!("expected OfferingNotFound, got {:?}", other), + Ok(_) => panic!("expected error"), + Err(Err(host_err)) => panic!("host error: {:?}", host_err), + } +} + +/// Decimals > 18 should return LimitReached. +#[test] +fn migrate_denomination_rejects_out_of_range_from() { + let (env, client, issuer, namespace, token) = setup(); + let result = client.try_migrate_denomination( + &issuer, &namespace, &token, &19, // invalid + &6, + ); + assert!(result.is_err(), "from_decimals > 18 should fail"); +} + +#[test] +fn migrate_denomination_rejects_out_of_range_to() { + let (env, client, issuer, namespace, token) = setup(); + let result = client.try_migrate_denomination( + &issuer, &namespace, &token, &6, &19, // invalid + ); + assert!(result.is_err(), "to_decimals > 18 should fail"); +} + +// ── SupplyCap rescaling ──────────────────────────────────────────────────────── + +/// SupplyCap is re-scaled when present. +#[test] +fn migrate_denomination_rescales_supply_cap() { + let (env, client, issuer, namespace, token) = setup(); + let offering_id = + OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), token: token.clone() }; + + // Set supply cap + env.storage().persistent().set(&DataKey2::SupplyCap(offering_id.clone()), &10_000_000_i128); + + // Also set decimals + client.set_payment_token_decimals(&issuer, &namespace, &token, &6); + + let _ = client.try_migrate_denomination(&issuer, &namespace, &token, &6, &18); + + // SupplyCap should be re-scaled + let expected: i128 = 10_000_000_i128 * 10_i128.pow(12); + assert_eq!( + env.storage().persistent().get::(&DataKey2::SupplyCap(offering_id.clone())), + Some(expected) + ); +} + +// ── Event emission ───────────────────────────────────────────────────────────── + +/// Migration emits a `den_mig` event with correct data. +#[test] +fn migrate_denomination_emits_event() { + let (env, client, issuer, namespace, token) = setup(); + set_initial_decimals(&env, &client, &issuer, &namespace, &token, 6); + + // Record events before migration + let _ = client.try_migrate_denomination(&issuer, &namespace, &token, &6, &18); + + // Check events + let events = env.events().all(); + let found = events.iter().any(|event| { + let topics = &event.0; + if topics.len() >= 1 { + if let Ok(sym) = topics.get(0).unwrap().try_into_val::(&env) { + return sym == Symbol::new(&env, "den_mig"); + } + } + false + }); + assert!(found, "den_mig event should be emitted"); +} diff --git a/src/test_tax_year.rs b/src/test_tax_year.rs index ee457f6b..ca5895e2 100644 --- a/src/test_tax_year.rs +++ b/src/test_tax_year.rs @@ -52,11 +52,7 @@ fn fiscal_year_config_default_and_roundtrip() { let ns = symbol_short!("def"); // Default is January (1). - assert_eq!( - client.get_fiscal_year_start(&issuer, &ns, &token), - 1, - "default should be January", - ); + assert_eq!(client.get_fiscal_year_start(&issuer, &ns, &token), 1, "default should be January",); // Set to April (4). client.set_fiscal_year_start(&issuer, &ns, &token, &4); diff --git a/tools/storage_layout_schema.rs b/tools/storage_layout_schema.rs index e7867fe7..9278b356 100644 --- a/tools/storage_layout_schema.rs +++ b/tools/storage_layout_schema.rs @@ -154,6 +154,7 @@ const CORE_LAYOUT: &[StorageLayoutEntry] = storage_layout_entries!("revora_reven ("DataKey2::OraclePubKey(Address)", "BytesN<32>", "oracle"), ("DataKey2::ClassConversionRatio(OfferingId, ShareClass, ShareClass)", "u32", "offering+class"), ("DataKey2::DeferredQueue(OfferingId)", "Vec", "offering"), + ("DataKey2::DenomMigration(OfferingId, u32, u32)", "bool", "offering+path"), // ── Accrual-checkpoint keys ── ("DataKey2::AccrualAnchor(OfferingId, Address)", "AccrualAnchor", "offering+holder"), ("DataKey2::CheckpointThreshold(OfferingId)", "u32", "offering"),