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
109 changes: 109 additions & 0 deletions PR_AUTH_SNAPSHOT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Per-Entrypoint Auth Snapshot Test

Closes #829

## Summary

Adds an integration suite that **snapshots the Soroban authorization required by every
state-changing entrypoint** of `PredictifyHybrid`. Rather than only asserting "authorized
succeeds / unauthorized fails", each test inspects `env.auths()` after the call and pins
**which address the host actually required an authorization from**.

This catches two regressions a pass/fail test misses: a `require_auth` silently dropped
(auth set becomes empty), and an auth subject rebound to the wrong argument.

Writing the suite surfaced three real authorization defects and a set of pre-existing build
breakages that stopped the crate compiling at all. Both are fixed here so the suite can run.

## Changes

**Tests**

- `tests/auth_snapshot.rs` (new) — 24 tests: committed-snapshot assertions for 13
entrypoints, auth-boundary assertions for 3, read-only entrypoints pinned to *no auth*,
plus edge cases (wrong-subject binding, subject tracking across users, non-admin
rejection, `#[should_panic]` no-auth traps). The fixture registers a real Stellar Asset
Contract so stake transfers commit.
- `Cargo.toml` — registered the `auth_snapshot` test target.

**Authorization fixes**

Soroban rejects a second `require_auth` on an already-authorized frame
(`Error(Auth, ExistingValue)`). Three paths authorized the same address twice — entrypoint
*and* inner manager — so they could **never succeed on-chain**:

- `fees.rs` — redundant `admin.require_auth()` in `FeeManager::collect_fees`. It was
`#[cfg(not(test))]`-gated, so unit tests masked it while production builds hit it.
- `disputes.rs` — redundant `user.require_auth()` in `DisputeManager::dispute_market`.
- `rate_limiter.rs` — redundant `user.require_auth()` in `rate_limit_voting` /
`rate_limit_disputes`.

Auth strength is unchanged: each is still enforced by the calling entrypoint
(`require_primary_admin` for admin paths, `user.require_auth()` for user paths).

**Build restoration**

The crate did not compile on `master`: `e5db2d8` collapsed `lib.rs` to a "minimal working
version" and later PRs re-added call sites without restoring their definitions.

- `lib.rs` — restored 14 dropped `mod` declarations; the `initialize` / `deposit` /
`withdraw` / `get_balance` entrypoints; the admin-auth helpers (`stored_primary_admin`,
`require_primary_admin{,_or_panic}`, `require_initialized_admin_root`,
`require_admin_permission`); the `SYM_*` / `PERCENTAGE_DENOMINATOR` constants and the
`resolution_timeout_reached` / `automatic_oracle_result_unavailable` helpers. Removed
duplicate imports, fixed 7 invalid `Address` derefs, repointed `capabilities()`.
- `resolution.rs` — repaired a mis-merge that spliced a payout-distribution body into
`impl ResolutionOutcomeCache` and into `determine_outcome_from_oracle_data`; restored the
cache methods 6 modules call, `ResolutionAnalytics` / `MedianResolutionResult`, and the
`OracleResolutionManager` impl boundary.
- `market_id_generator.rs` — restored the clean file from `28c8db4`.
- `err.rs` — removed a duplicate `ReplayedOverride`; added 6 declared-nowhere variants;
catch-all arms on `description()` / `code()`.
- `storage.rs` — removed a duplicate `AdminOverrideNonce`; added `PlaceBetsIdem` and
`AntiGriefFloor`.
- `gas.rs` — `Env::budget()` is test-only, so `BudgetGuard` degrades to a no-op outside
tests; fixed an oversized `symbol_short!` and an invalid `Default` derive.
- `upgrade_manager.rs` — fixed a `u32`/`u64` branch mismatch.

## Auth matrix

| Entrypoint | Subject | Verified by |
|---|---|---|
| `create_market`, `resolve_market_manual`, `collect_fees`, `set_platform_fee`, `set_treasury`, `set_global_claim_period`, `set_market_claim_period`, `extend_deadline` | admin | committed snapshot |
| `sweep_unclaimed_winnings` | admin | auth boundary |
| `vote`, `place_bet`, `cancel_bet` | user | committed snapshot |
| `claim_winnings`, `dispute_market` | user | auth boundary |
| `get_market`, `get_market_bet_stats` | none | committed snapshot |

*Committed snapshot* — the call is driven through a fully satisfied happy path and the auth
is read back from `env.auths()`. *Auth boundary* — the host rolls back recorded auths when a
call traps or returns `Err`, so entrypoints needing state the fixture does not build assert
instead that an authorized call gets *past* `require_auth` while an unauthorized one traps.

## API / visible changes

None. The suite is test-only, and the three fixes remove redundant authorization calls
without weakening any check.

## Testing

```bash
cargo test -p predictify-hybrid --test auth_snapshot
```

```
test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
```

`cargo build -p predictify-hybrid --lib` also succeeds, where it previously failed with ~40
parse and resolution errors.

## Notes for reviewers

- **Auth strength is unchanged** — every removed call was a redundant second authorization
within the same frame.
- **Out of scope** — several `#[cfg(test)]` modules under `src/` still fail to compile from
pre-existing API drift, and `tests/err_stability.rs` needs nightly (`variant_count`).
- **Local Windows builds** hit a MinGW `export ordinal too large` link error on the `cdylib`
when `testutils` is enabled; run locally with `crate-type = ["lib"]`. Linux CI is
unaffected.
4 changes: 4 additions & 0 deletions contracts/predictify-hybrid/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ wee_alloc = "0.4.5"
name = "err_stability"
path = "tests/err_stability.rs"

[[test]]
name = "auth_snapshot"
path = "tests/auth_snapshot.rs"

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
proptest = "1.4"
6 changes: 4 additions & 2 deletions contracts/predictify-hybrid/src/disputes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -898,8 +898,10 @@ impl DisputeManager {
stake: i128,
reason: Option<String>,
) -> Result<(), Error> {
// Require authentication from the user
user.require_auth();
// Authorization is enforced by the `dispute_market` entrypoint, which
// calls `user.require_auth()` before delegating here. Re-authorizing the
// same address in the same frame made the host abort with
// `Error(Auth, ExistingValue)` ("frame is already authorized").

// Get and validate market
let mut market = MarketStateManager::get_market(env, &market_id)?;
Expand Down
20 changes: 18 additions & 2 deletions contracts/predictify-hybrid/src/err.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,8 @@ pub enum Error {
// ===== VALIDATION ERRORS (435-437) =====
/// Market ID already exists in the registry. Cannot create duplicate market IDs.
DuplicateMarketId = 441,
/// Override replay detected. Nonce has already been used.
ReplayedOverride = 442,
// `ReplayedOverride` is defined once below (= 526); the duplicate that lived
// here (= 442) was removed to fix E0428.

// ===== CIRCUIT BREAKER ERRORS =====
/// Circuit breaker has not been initialized. Initialize before use.
Expand Down Expand Up @@ -245,6 +245,20 @@ pub enum Error {
ReplayedOverride = 526,
/// Oracle quote is an outlier relative to the rolling median history.
OracleQuoteOutlier = 527,
// Variants referenced across the codebase whose declarations were lost in a
// prior merge collapse; restored here with fresh discriminants.
/// Persistent-key allocation lacks sufficient storage rent.
InsufficientStorageRent = 509,
/// The supplied `place_bets` idempotency key has already been consumed.
IdempotentBatchAlreadyApplied = 510,
/// A stake amount was zero, negative, or otherwise invalid.
InvalidStakeAmount = 511,
/// A checked arithmetic operation overflowed.
Overflow = 512,
/// An admin force-resolve idempotency key was replayed.
ForceResolveReplayed = 517,
/// An admin force-resolve was submitted with an empty reason.
ForceResolveReasonEmpty = 518,
}

// ===== ERROR CATEGORIZATION AND RECOVERY SYSTEM =====
Expand Down Expand Up @@ -1563,6 +1577,7 @@ impl Error {
Error::CumulativeExtensionCapHit => "Cumulative extension cap reached; no further extensions allowed",
Error::IllegalMarketStateTransition => "Illegal market state transition attempted",
Error::OracleQuoteOutlier => "Oracle quote is an outlier relative to the rolling median",
_ => "An unspecified error occurred.",
}
}

Expand Down Expand Up @@ -1672,6 +1687,7 @@ impl Error {
Error::CumulativeExtensionCapHit => "CUMULATIVE_EXTENSION_CAP_HIT",
Error::IllegalMarketStateTransition => "ILLEGAL_MARKET_STATE_TRANSITION",
Error::OracleQuoteOutlier => "ORACLE_QUOTE_OUTLIER",
_ => "UNSPECIFIED_ERROR",
}
}
}
Expand Down
12 changes: 5 additions & 7 deletions contracts/predictify-hybrid/src/fees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -721,13 +721,11 @@ pub struct FeeManager;
impl FeeManager {
/// Collect platform fees from a market
pub fn collect_fees(env: &Env, admin: Address, market_id: Symbol) -> Result<i128, Error> {
// Require authentication from the admin
// Note: admin.require_auth() causes "Error(Auth, ExistingValue)" panic in tests with mock_all_auths
// We disable it for tests but keep it for production safety.
#[cfg(not(test))]
admin.require_auth();

// Validate admin permissions
// Authorization is enforced by the `collect_fees` entrypoint via
// `require_primary_admin` (require_auth + stored-admin identity check).
// Re-authorizing the same address in the same frame here made the host
// abort with `Error(Auth, ExistingValue)` ("frame is already
// authorized"), which broke the entrypoint outside `cfg(test)`.
FeeValidator::validate_admin_permissions(env, &admin)?;

// Get and validate market
Expand Down
36 changes: 27 additions & 9 deletions contracts/predictify-hybrid/src/gas.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
#![allow(dead_code)]
use soroban_sdk::{contracttype, panic_with_error, symbol_short, Env, Symbol, Vec};
use soroban_sdk::{contracttype, panic_with_error, symbol_short, Env, String, Symbol, Vec};

/// Read the host's consumed CPU-instruction count.
///
/// `Env::budget()` is only available under the soroban `testutils`/test build,
/// so in a production (non-test) build this returns 0, turning [`BudgetGuard`]
/// into a graceful no-op that defers to the host's own metering limits.
#[inline]
fn cpu_instruction_cost(env: &Env) -> u64 {
#[cfg(test)]
{
env.budget().cpu_instruction_cost()
}
#[cfg(not(test))]
{
let _ = env;
0
}
}
use crate::config::GAS_TRACKING_WINDOW_SIZE;
use crate::events::PerformanceMetricEvent;

Expand All @@ -19,7 +37,7 @@ pub enum GasConfigKey {

/// Represents consumed resources for an operation.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq, Default)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GasUsage {
pub cpu: u64,
pub mem: u64,
Expand Down Expand Up @@ -212,15 +230,15 @@ impl GasTracker {
if used > threshold {
// Emit performance metric event
let event = PerformanceMetricEvent {
metric_name: Symbol::new(env, "gas_low_water").into(),
metric_name: String::from_str(env, "gas_low_water"),
value: used as i128,
unit: Symbol::new(env, "cpu").into(),
context: operation.into(),
unit: String::from_str(env, "cpu"),
context: String::from_str(env, "gas_alert"),
timestamp: env.ledger().timestamp(),
};

env.events().publish(
(symbol_short!("performance_metric"), operation.clone()),
(Symbol::new(env, "perf_metric"), operation.clone()),
event,
);
}
Expand Down Expand Up @@ -321,7 +339,7 @@ impl BudgetGuard {
/// The threshold should be high enough to complete the current iteration
/// plus any post-loop cleanup operations.
pub fn new(env: &Env, threshold_remaining: u64) -> Self {
let start_instructions = env.budget().cpu_instruction_cost();
let start_instructions = cpu_instruction_cost(env);
BudgetGuard {
env: env.clone(),
start_instructions,
Expand All @@ -342,7 +360,7 @@ impl BudgetGuard {
/// This is a lightweight call that reads a single value from the host.
/// It should be called at regular intervals, not on every iteration.
pub fn check(&self) -> Result<(), Error> {
let current = self.env.budget().cpu_instruction_cost();
let current = cpu_instruction_cost(&self.env);
let consumed = current.saturating_sub(self.start_instructions);

if consumed >= self.threshold_remaining {
Expand All @@ -357,7 +375,7 @@ impl BudgetGuard {
/// # Returns
/// The number of CPU instructions consumed since the guard was created.
pub fn consumed(&self) -> u64 {
let current = self.env.budget().cpu_instruction_cost();
let current = cpu_instruction_cost(&self.env);
current.saturating_sub(self.start_instructions)
}

Expand Down
Loading
Loading