Security (CW-05): ust1-window accepts oracle rate without staleness guard #6

Closed
opened 2026-04-21 08:12:02 +00:00 by Brouie · 4 comments
Brouie commented 2026-04-21 08:12:02 +00:00 (Migrated from gitlab.com)

@PlasticDigits finding from security review on #5.

Severity

Medium. Affects user funds if oracle-service stops broadcasting.

Description

ust1-window deposit/withdraw paths call query_oracle_state() and use the returned rate directly, without checking last_update_sec against current block time.

From contracts/ust1-window/src/contract.rs deposit:

let rate = query_oracle_state(deps.as_ref(), &cfg.oracle)?.rate;
let ust1_out = ust1_common::math::deposit_vfdusd_to_ust1(amount_vfdusd, rate, cfg.fee_bps)?;

withdraw is equivalent. Neither checks whether rate is from 4 hours ago or 4 days ago.

Why this matters

INV-ORACLE-THROTTLE-001 (oracle_policy.rs) guarantees rate updates are at least 4h apart. It does NOT bound how stale a rate can get.

If oracle-service dies, stops running, loses its BSC RPC, or fails to broadcast for any reason, the on-chain rate stays put and swaps continue against it indefinitely. In a rising-rate market (which is the guaranteed direction — rates are monotonic), this would let users deposit vFDUSD at a stale low rate to mint UST1 at below-market value, or withdraw UST1 at a stale high rate to extract more vFDUSD than warranted.

This pairs with EVM-10 (no liveness alerting): the service can silently fail for hours without anyone noticing, while the swap window keeps executing.

Recommendation

Add a max-age check in deposit and withdraw:

const MAX_ORACLE_AGE_SEC: u64 = 21_600; // 6h, tunable
if env.block.time.seconds().saturating_sub(oracle_state.last_update_sec) > MAX_ORACLE_AGE_SEC {
    return Err(ContractError::OracleStale {});
}

MAX_ORACLE_AGE_SEC should be governance-settable via a new SetMaxOracleAge execute message, with a sane default (e.g. 2x the typical poll interval).

  • Review parent: #5
  • Related finding: EVM-10 (liveness alerting)
@PlasticDigits finding from security review on #5. ## Severity Medium. Affects user funds if oracle-service stops broadcasting. ## Description `ust1-window` deposit/withdraw paths call `query_oracle_state()` and use the returned `rate` directly, without checking `last_update_sec` against current block time. From `contracts/ust1-window/src/contract.rs` `deposit`: ```rust let rate = query_oracle_state(deps.as_ref(), &cfg.oracle)?.rate; let ust1_out = ust1_common::math::deposit_vfdusd_to_ust1(amount_vfdusd, rate, cfg.fee_bps)?; ``` `withdraw` is equivalent. Neither checks whether `rate` is from 4 hours ago or 4 days ago. ## Why this matters INV-ORACLE-THROTTLE-001 (`oracle_policy.rs`) guarantees rate updates are **at least** 4h apart. It does NOT bound how stale a rate can get. If `oracle-service` dies, stops running, loses its BSC RPC, or fails to broadcast for any reason, the on-chain rate stays put and swaps continue against it indefinitely. In a rising-rate market (which is the guaranteed direction — rates are monotonic), this would let users deposit vFDUSD at a stale low rate to mint UST1 at below-market value, or withdraw UST1 at a stale high rate to extract more vFDUSD than warranted. This pairs with EVM-10 (no liveness alerting): the service can silently fail for hours without anyone noticing, while the swap window keeps executing. ## Recommendation Add a max-age check in `deposit` and `withdraw`: ```rust const MAX_ORACLE_AGE_SEC: u64 = 21_600; // 6h, tunable if env.block.time.seconds().saturating_sub(oracle_state.last_update_sec) > MAX_ORACLE_AGE_SEC { return Err(ContractError::OracleStale {}); } ``` MAX_ORACLE_AGE_SEC should be governance-settable via a new `SetMaxOracleAge` execute message, with a sane default (e.g. 2x the typical poll interval). ## Related - Review parent: #5 - Related finding: EVM-10 (liveness alerting)
Brouie commented 2026-04-21 08:14:45 +00:00 (Migrated from gitlab.com)

mentioned in issue #11

mentioned in issue #11
Brouie commented 2026-04-21 08:17:07 +00:00 (Migrated from gitlab.com)

mentioned in issue #5

mentioned in issue #5
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-04-21 13:44:02 +00:00
PlasticDigits commented 2026-04-21 13:44:21 +00:00 (Migrated from gitlab.com)

Implemented (merged to main)

Staleness guard: deposit and withdraw now load full oracle State, require last_update_sec > 0 (at least one on-chain UpdateRate before swaps), and reject swaps when block_time - last_update_sec > max_oracle_age_sec.

Defaults: DEFAULT_MAX_ORACLE_AGE_SECS = 21_600 (6 hours) in ust1-common. Stored on Config with #[serde(default)] so migrated storage without the field picks up the default.

Governance: SetMaxOracleAge { max_oracle_age_sec } — value must be >= MIN_ORACLE_UPDATE_INTERVAL_SECS (4h) so swaps remain possible between oracle updates.

API: Optional max_oracle_age_sec on window InstantiateMsg; ConfigResponse and EffectiveSwapResponse include max_oracle_age_sec.

Tests: cw-multi-test coverage for stale deposit, governance-only max-age updates, and below-min rejection; integration tests prime oracle where needed.

Commit: 697f54d on main (merge of fix/issue-6-oracle-staleness).

## Implemented (merged to `main`) **Staleness guard:** `deposit` and `withdraw` now load full oracle `State`, require `last_update_sec > 0` (at least one on-chain `UpdateRate` before swaps), and reject swaps when `block_time - last_update_sec > max_oracle_age_sec`. **Defaults:** `DEFAULT_MAX_ORACLE_AGE_SECS = 21_600` (6 hours) in `ust1-common`. Stored on `Config` with `#[serde(default)]` so migrated storage without the field picks up the default. **Governance:** `SetMaxOracleAge { max_oracle_age_sec }` — value must be `>= MIN_ORACLE_UPDATE_INTERVAL_SECS` (4h) so swaps remain possible between oracle updates. **API:** Optional `max_oracle_age_sec` on window `InstantiateMsg`; `ConfigResponse` and `EffectiveSwapResponse` include `max_oracle_age_sec`. **Tests:** cw-multi-test coverage for stale deposit, governance-only max-age updates, and below-min rejection; integration tests prime oracle where needed. Commit: `697f54d` on `main` (merge of `fix/issue-6-oracle-staleness`).
PlasticDigits (Migrated from gitlab.com) reopened this issue 2026-04-21 13:45:17 +00:00
Brouie commented 2026-04-22 00:22:32 +00:00 (Migrated from gitlab.com)

@PlasticDigits verified fix on b3b8937.

Code review + test run:

  • ust1-window/src/contract.rs now rejects swaps when last_update_sec == 0 (no oracle update ever) OR now - last_update_sec > max_oracle_age_sec
  • saturating_sub handles clock skew safely
  • Governance-settable via new SetMaxOracleAge execute msg with access control + sanity validation against oracle throttle
  • Optional instantiate override, defaults to DEFAULT_MAX_ORACLE_AGE_SECS exported from ust1-common
  • 3 new multitest cases: stale_oracle_blocks_deposit, set_max_oracle_age_governance_only, set_max_oracle_age_below_oracle_throttle_rejected

Workspace test count: 40/40 pass (was 28/28 before fixes). All new tests green on cargo test --workspace.

Fix quality exceeds recommendation — closing this sign-off.

@PlasticDigits verified fix on b3b8937. Code review + test run: - `ust1-window/src/contract.rs` now rejects swaps when `last_update_sec == 0` (no oracle update ever) OR `now - last_update_sec > max_oracle_age_sec` - `saturating_sub` handles clock skew safely - Governance-settable via new `SetMaxOracleAge` execute msg with access control + sanity validation against oracle throttle - Optional instantiate override, defaults to `DEFAULT_MAX_ORACLE_AGE_SECS` exported from ust1-common - 3 new multitest cases: `stale_oracle_blocks_deposit`, `set_max_oracle_age_governance_only`, `set_max_oracle_age_below_oracle_throttle_rejected` Workspace test count: 40/40 pass (was 28/28 before fixes). All new tests green on `cargo test --workspace`. Fix quality exceeds recommendation — closing this sign-off.
Brouie (Migrated from gitlab.com) closed this issue 2026-04-22 00:22:47 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
code/ust1-window#6
No description provided.