Security (CW-05): ust1-window accepts oracle rate without staleness guard #6
Labels
No labels
enhancement
governance
QA
testing
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
code/ust1-window#6
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
@PlasticDigits finding from security review on #5.
Severity
Medium. Affects user funds if oracle-service stops broadcasting.
Description
ust1-windowdeposit/withdraw paths callquery_oracle_state()and use the returnedratedirectly, without checkinglast_update_secagainst current block time.From
contracts/ust1-window/src/contract.rsdeposit:withdrawis equivalent. Neither checks whetherrateis 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-servicedies, 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
depositandwithdraw:MAX_ORACLE_AGE_SEC should be governance-settable via a new
SetMaxOracleAgeexecute message, with a sane default (e.g. 2x the typical poll interval).Related
mentioned in issue #11
mentioned in issue #5
Implemented (merged to
main)Staleness guard:
depositandwithdrawnow load full oracleState, requirelast_update_sec > 0(at least one on-chainUpdateRatebefore swaps), and reject swaps whenblock_time - last_update_sec > max_oracle_age_sec.Defaults:
DEFAULT_MAX_ORACLE_AGE_SECS = 21_600(6 hours) inust1-common. Stored onConfigwith#[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_secon windowInstantiateMsg;ConfigResponseandEffectiveSwapResponseincludemax_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:
697f54donmain(merge offix/issue-6-oracle-staleness).@PlasticDigits verified fix on
b3b8937.Code review + test run:
ust1-window/src/contract.rsnow rejects swaps whenlast_update_sec == 0(no oracle update ever) ORnow - last_update_sec > max_oracle_age_secsaturating_subhandles clock skew safelySetMaxOracleAgeexecute msg with access control + sanity validation against oracle throttleDEFAULT_MAX_ORACLE_AGE_SECSexported from ust1-commonstale_oracle_blocks_deposit,set_max_oracle_age_governance_only,set_max_oracle_age_below_oracle_throttle_rejectedWorkspace 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.