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
137 changes: 137 additions & 0 deletions docs/denomination-migration.md
Original file line number Diff line number Diff line change
@@ -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
154 changes: 150 additions & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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<DeferredQueueEntry>` stored in `(release_ts, priority, queue_id)` sorted order.
Expand Down Expand Up @@ -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::<DataKey2, i128>(&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::<DataKey, AuditSummary>(&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::<DataKey2, i128>(&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
Expand Down
10 changes: 5 additions & 5 deletions src/tax_bucket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading