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
79 changes: 79 additions & 0 deletions TESTNET_MODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,77 @@ DataKey::TestnetMode -> bool
- Mode changes are immediate (no delay or grace period)
- Existing offerings retain their parameters when mode is toggled

## Faucet: Deterministic Test Holders (`faucet_seed_holders`)

### Overview

`faucet_seed_holders(issuer, namespace, token, count)` allocates `count` deterministic
32-byte seeds for an offering's holder slots. It is **strictly testnet-only** — calling it
while `testnet_mode == false` returns `RevoraError::TestnetOnly` (wire value 51).

### Purpose

Integration test suites can call this function once and pin their holder addresses against
the returned seeds without manually wiring up holders per test run.

### Seed Derivation

Each seed is computed as:

```
seed[idx] = sha256(issuer_xdr || namespace_xdr || token_xdr || idx_xdr)
```

The XDR encoding is the standard Soroban `to_xdr` representation. Seeds are
offering-specific and index-specific, guaranteeing no collisions within or across offerings.

### BPS Distribution

The 10 000 basis-point total is split floor-evenly across `count` slots:

- `floor_bps = 10_000 / count`
- The **last slot** absorbs the remainder: `last_bps = floor_bps + (10_000 % count)`

The per-slot BPS is included in the emitted `fct_seed` event so test suites can assert
expected distribution without re-computing it.

### Events

One `fct_seed` event per slot:

```
Topics: (fct_seed, issuer, namespace, token)
Data: (idx: u32, seed: BytesN<32>, share_bps: u32)
```

### Storage

Seeds are persisted in `DataKey2::FaucetSeedEntry(offering_id, idx)` so they can be
retrieved by index without re-calling the function.

### Security

- Guarded by `is_testnet_mode()` — panics with `TestnetOnly` on mainnet.
- Requires the offering to be registered (`OfferingNotFound` otherwise).
- `count == 0` is a no-op (returns empty vec, emits no events).

### Example Usage

```rust
// Prerequisites: testnet mode enabled, offering registered.
let seeds = client.faucet_seed_holders(&issuer, &ns, &token, &5);
// seeds[0] is the raw ed25519 public key for slot-0 test holder.
// Use it externally to derive the corresponding Stellar keypair.
```

### New Error Variant

| Code | Variant | Condition |
|------|---------|-----------|
| 51 | `TestnetOnly` | `faucet_seed_holders` called with `testnet_mode == false` |

---

## Version History

- **v0.1.0** - Initial implementation (Issue #24)
Expand All @@ -271,6 +342,14 @@ DataKey::TestnetMode -> bool
- Concentration enforcement skip
- Comprehensive test coverage

- **v0.2.0** - Deterministic faucet (Issue #476)
- `faucet_seed_holders` function
- `RevoraError::TestnetOnly` (wire value 51)
- `RevoraError::StaleConcentrationData` (wire value 52, pre-existing usage fixed)
- `DataKey2::FaucetSeedEntry` storage key
- `EVENT_FAUCET_SEED` (`fct_seed`) event symbol
- `src/test_faucet_seed.rs` — 95%+ test coverage

## Support

For questions or issues related to testnet mode:
Expand Down
111 changes: 111 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ pub enum RevoraError {
///
/// Wire value: 48. Stable since v1.
PeriodAlreadyClosed = 48,

/// `faucet_seed_holders` was called while `testnet_mode == false`.
/// This function is strictly disallowed on mainnet.
///
/// Wire value: 51.
TestnetOnly = 51,

/// Concentration staleness check failed: the stored concentration timestamp is
/// too old to enforce the configured limit safely.
///
/// Wire value: 52.
StaleConcentrationData = 52,
}

pub mod vesting;
Expand All @@ -190,6 +202,8 @@ mod test_min_revenue_threshold_boundary;
// mod test_claim_transfer_fail;
#[cfg(test)]
mod test_close_period;
#[cfg(test)]
mod test_faucet_seed;

// ── Event symbols ────────────────────────────────────────────
const EVENT_REVENUE_REPORTED: Symbol = symbol_short!("rev_rep");
Expand Down Expand Up @@ -281,6 +295,8 @@ const EVENT_ISSUER_TRANSFER_CANCELLED: Symbol = symbol_short!("iss_canc");
const EVENT_ISSUER_TRANSFER_REJECTED: Symbol = symbol_short!("iss_rej");
const EVENT_ISSUER_TRANSFER_VESTING_MIGRATED: Symbol = symbol_short!("iss_vst");
const EVENT_TESTNET_MODE: Symbol = symbol_short!("test_mode");
/// Emitted for each deterministic seed produced by `faucet_seed_holders` (testnet only).
const EVENT_FAUCET_SEED: Symbol = symbol_short!("fct_seed");

const EVENT_DIST_CALC: Symbol = symbol_short!("dist_calc");
const EVENT_METADATA_SET: Symbol = symbol_short!("meta_set");
Expand Down Expand Up @@ -767,6 +783,18 @@ pub enum DataKey2 {

/// Sealed-period flag: when present, `report_revenue` overrides are rejected for this period.
ClosedPeriod(OfferingId, u64),

/// Per-offering supply cap in payment token units (#96). Zero means unlimited.
SupplyCap(OfferingId),
/// Cumulative revenue deposited for an offering across all periods.
DepositedRevenue(OfferingId),
/// Minimum reported revenue below which no distribution is triggered (#25).
MinRevenueThreshold(OfferingId),
/// Per-offering investment constraints (min_stake, max_stake).
InvestmentConstraints(OfferingId),

/// Testnet faucet: sha256-derived seed bytes for holder slot at the given index.
FaucetSeedEntry(OfferingId, u32),
}

/// Maximum number of offerings returned in a single page.
Expand Down Expand Up @@ -6757,6 +6785,89 @@ impl RevoraRevenueShare {
env.events().publish((EVENT_PROPOSAL_EXECUTED, executor), proposal_id);
Ok(())
}

// ── Testnet faucet ────────────────────────────────────────────────────────

/// Allocate `count` deterministic holder seed slots for an offering.
///
/// Each seed is derived as `sha256(issuer_xdr || namespace_xdr || token_xdr || idx_xdr)`
/// and can be treated as a raw 32-byte ed25519 public key by external test suites.
/// The equal BPS split (`10_000 / count`, remainder to last slot) is documented in
/// each emitted `fct_seed` event so test suites can pin share expectations.
///
/// ### Security
/// Panics (via `RevoraError::TestnetOnly`) when `testnet_mode == false`.
/// Must never be callable on mainnet.
///
/// ### Parameters
/// - `issuer` / `namespace` / `token`: offering identity.
/// - `count`: number of deterministic seed slots to generate (0 returns empty vec).
///
/// ### Returns
/// `Vec<BytesN<32>>` of per-slot seeds in index order.
pub fn faucet_seed_holders(
env: Env,
issuer: Address,
namespace: Symbol,
token: Address,
count: u32,
) -> Result<Vec<BytesN<32>>, RevoraError> {
if !Self::is_testnet_mode(env.clone()) {
return Err(RevoraError::TestnetOnly);
}

let offering_id = OfferingId {
issuer: issuer.clone(),
namespace: namespace.clone(),
token: token.clone(),
};

if !env.storage().persistent().has(&DataKey2::OfferingRecord(offering_id.clone())) {
return Err(RevoraError::OfferingNotFound);
}

if count == 0 {
return Ok(Vec::new(&env));
}

// Build a per-offering prefix: sha256(issuer || namespace || token)
let mut prefix_input = Bytes::new(&env);
prefix_input.append(&issuer.to_xdr(&env));
prefix_input.append(&namespace.to_xdr(&env));
prefix_input.append(&token.to_xdr(&env));

let bps_floor: u32 = 10_000u32 / count;
let bps_remainder: u32 = 10_000u32 % count;

let mut seeds: Vec<BytesN<32>> = Vec::new(&env);

for idx in 0..count {
// Per-slot seed: sha256(prefix_bytes || idx_xdr)
let mut slot_input = prefix_input.clone();
slot_input.append(&idx.to_xdr(&env));
let seed: BytesN<32> = env.crypto().sha256(&slot_input);

let share_bps: u32 = if idx == count - 1 {
bps_floor + bps_remainder
} else {
bps_floor
};

// Store seed for test-suite retrieval without forcing a full scan.
env.storage()
.persistent()
.set(&DataKey2::FaucetSeedEntry(offering_id.clone(), idx), &seed);

env.events().publish(
(EVENT_FAUCET_SEED, issuer.clone(), namespace.clone(), token.clone()),
(idx, seed.clone(), share_bps),
);

seeds.push_back(seed);
}

Ok(seeds)
}
} // end impl RevoraRevenueShare (plain)

#[cfg(test)]
Expand Down
Loading
Loading