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
61 changes: 61 additions & 0 deletions docs/payment-token-decimal-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Payment Token Decimal Compatibility

## Overview

Different payment tokens on the Stellar network use different decimal precisions. For example:

| Token | Decimals | Example raw amount | Canonical (7-dec) |
|-------|----------|-------------------|-------------------|
| XLM | 7 | 1_000_000_0 | 1_000_000_0 |
| USDC | 6 | 1_000_000 | 10_000_000 |
| WBTC | 8 | 1_000_000_00 | 1_000_000_0 |

Without normalization, reporting USDC revenue in raw amounts and then computing holder shares produces silent arithmetic errors — holders receive 10× too little or too much.

## How It Works

This contract stores a per-offering decimal configuration for the payout asset. Before any holder share computation (in `claim`, `get_claimable`, and `get_claimable_chunk`), the raw revenue amount is normalized to Stellar's canonical 7-decimal precision.

### Normalization Rules

- **`from_decimals == 7`**: no-op, amount returned unchanged.
- **`from_decimals < 7`** (e.g., USDC at 6): scale **up** by `10^(7 - from_decimals)`.
- **`from_decimals > 7`** (e.g., WBTC at 8): scale **down** by `10^(from_decimals - 7)` using integer truncation.
- **Overflow protection**: if multiplication overflows `i128`, the function returns `0` to prevent fund inflation. This is logged as a zero-payout distribution.

## API

### `set_payment_token_decimals(issuer, namespace, token, decimals: u32)`

Sets the decimal precision of the payout asset for an offering. Requires issuer authorization.

- **Range**: `0..=18`. Values outside this range return `RevoraError::LimitReached`.
- **Default**: If not set, `7` (canonical Stellar stroops) is assumed.
- **Event**: Emits `dec_set` event with the configured value.

### `get_payment_token_decimals(issuer, namespace, token) -> u32`

Returns the configured decimal precision or `7` if not set.

## Security Assumptions

1. **Issuer responsibility**: The `issuer` is trusted to supply the correct on-chain token decimal value. An incorrect value directly affects all future claim payouts. Issuers should verify the decimal on-chain before calling this function.
2. **Immutable after set**: There is no restriction on updating decimals after the fact, but changing decimals mid-offering will affect future claims inconsistently with past revenue reports. Issuers should set decimals before the first revenue report.
3. **Overflow is safe**: All multiplications are guarded with `checked_mul`. Overflow returns `0`, preventing fund inflation but potentially causing zero payouts for extremely large amounts with low-decimal tokens.
4. **Scope**: Decimals are per-offering, not per-asset globally. Two offerings with the same payout asset may have different decimal configurations.

## Example

```rust
// Register offering with USDC (6 decimals) as payout asset
client.register_offering(&issuer, &ns, &token, &shares_bps, &usdc_address, &0);

// Configure decimals
client.set_payment_token_decimals(&issuer, &ns, &token, &6);

// Report 1,000,000 raw USDC units = 0.1 USDC
client.deposit_revenue(&issuer, &ns, &token, &usdc_address, &1_000_000, &1);

// After normalization: 1_000_000 (6-dec) → 10_000_000 (7-dec)
// Holder with 50% share receives: 10_000_000 * 5_000 / 10_000 = 5_000_000 canonical units
```
81 changes: 81 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@ const EVENT_ROUNDING_MODE_SET: Symbol = symbol_short!("rnd_mode");
const EVENT_ADMIN_SET: Symbol = symbol_short!("admin_set");
const EVENT_PLATFORM_FEE_SET: Symbol = symbol_short!("fee_set");
const BPS_DENOMINATOR: i128 = 10_000;
/// Stellar network canonical decimal precision (7 decimal places, i.e., stroops).
const STELLAR_CANONICAL_DECIMALS: u32 = 7;
/// Maximum accepted decimal precision (safety cap for normalization math).
const MAX_TOKEN_DECIMALS: u32 = 18;

/// Represents a revenue-share offering registered on-chain.
/// Offerings are immutable once registered.
Expand Down Expand Up @@ -2617,6 +2621,83 @@ impl RevoraRevenueShare {
core::cmp::min(core::cmp::max(share, lo), hi)
}

/// Normalize `amount` from the token's native decimal precision to Stellar's canonical 7-decimal
/// (stroop) precision used internally by this contract.
///
/// - If `from_decimals == 7`: returns `amount` unchanged.
/// - If `from_decimals < 7`: scales **up** by `10^(7 - from_decimals)` (e.g., 6-decimal USDC → 7).
/// - If `from_decimals > 7`: scales **down** by `10^(from_decimals - 7)` using integer truncation.
///
/// Returns `0` if intermediate arithmetic overflows to prevent fund inflation bugs.
fn normalize_amount(amount: i128, from_decimals: u32) -> i128 {
if from_decimals == STELLAR_CANONICAL_DECIMALS {
return amount;
}
if from_decimals < STELLAR_CANONICAL_DECIMALS {
let exp = STELLAR_CANONICAL_DECIMALS - from_decimals;
let factor: i128 = match 10_i128.checked_pow(exp) {
Some(f) => f,
None => return 0,
};
amount.checked_mul(factor).unwrap_or(0)
} else {
let exp = from_decimals - STELLAR_CANONICAL_DECIMALS;
let factor: i128 = match 10_i128.checked_pow(exp) {
Some(f) => f,
None => return 0,
};
amount.checked_div(factor).unwrap_or(0)
}
}

/// Set the decimal precision of the payout asset for an offering.
///
/// Must be called by the offering `issuer`. Accepted range is `0..=18`.
/// If not set, the contract defaults to `7` (Stellar canonical stroops).
///
/// ### Security
/// - Only the offering issuer may configure decimals.
/// - Misconfigured decimals directly affect payout arithmetic; issuers must supply
/// the on-chain token's actual decimal value.
///
/// ### Errors
/// - `RevoraError::NotAuthorized` if caller is not the issuer.
/// - `RevoraError::LimitReached` if `decimals > 18`.
pub fn set_payment_token_decimals(
env: Env,
issuer: Address,
namespace: Symbol,
token: Address,
decimals: u32,
) -> Result<(), RevoraError> {
issuer.require_auth();
if decimals > MAX_TOKEN_DECIMALS {
return Err(RevoraError::LimitReached);
}
let offering_id = OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), token: token.clone() };
env.storage()
.persistent()
.set(&DataKey::PaymentTokenDecimals(offering_id), &decimals);
env.events()
.publish((EVENT_DECIMAL_SET, issuer, namespace, token), decimals);
Ok(())
}

/// Get the configured decimal precision of the payout asset for an offering.
/// Defaults to `7` (Stellar canonical stroops) if not explicitly set.
pub fn get_payment_token_decimals(
env: Env,
issuer: Address,
namespace: Symbol,
token: Address,
) -> u32 {
let offering_id = OfferingId { issuer, namespace, token };
env.storage()
.persistent()
.get(&DataKey::PaymentTokenDecimals(offering_id))
.unwrap_or(STELLAR_CANONICAL_DECIMALS)
}

// ── Multi-period aggregated claims ───────────────────────────

/// Deposit revenue for a specific period of an offering.
Expand Down
Loading
Loading