Skip to content

Commit 12f2764

Browse files
authored
Merge pull request #1155 from ayinde38/fix/issue-1111-oracle-access-control
fix(oracle): gate update_price on the registered oracle address
2 parents 4341f50 + 7a52e7d commit 12f2764

2 files changed

Lines changed: 239 additions & 2 deletions

File tree

backend/contracts/oracle/src/lib.rs

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ use soroban_sdk::{
1212
contract, contractimpl, contracttype, Address, Env, Symbol,
1313
};
1414

15+
#[cfg(test)]
16+
mod test;
17+
1518
// ---------------------------------------------------------------------------
1619
// Constants
1720
// ---------------------------------------------------------------------------
@@ -62,6 +65,8 @@ pub struct ValuationResult {
6265

6366
#[contracttype]
6467
enum DataKey {
68+
/// Contract administrator, set once by `initialize`.
69+
Admin,
6570
/// Address of the registered oracle contract.
6671
OracleAddress,
6772
/// Last accepted price snapshot.
@@ -79,20 +84,68 @@ pub struct OracleContract;
7984
impl OracleContract {
8085
// ── Admin ────────────────────────────────────────────────────────────────
8186

82-
/// Register the address of the upstream oracle contract.
87+
/// Set the contract administrator. Callable once.
88+
///
89+
/// Without this, `set_oracle`'s `admin` parameter was self-asserted: the
90+
/// caller passed whichever address they controlled, `require_auth()`
91+
/// confirmed only that they controlled *that* address, and nothing tied it
92+
/// to any privileged role. Anyone could re-point the oracle.
93+
pub fn initialize(env: Env, admin: Address) {
94+
assert!(
95+
!env.storage().persistent().has(&DataKey::Admin),
96+
"Contract already initialized"
97+
);
98+
admin.require_auth();
99+
env.storage().persistent().set(&DataKey::Admin, &admin);
100+
}
101+
102+
/// Register the address of the upstream oracle contract. Admin-only.
83103
pub fn set_oracle(env: Env, admin: Address, oracle: Address) {
84104
admin.require_auth();
105+
Self::require_admin(&env, &admin);
85106
env.storage()
86107
.persistent()
87108
.set(&DataKey::OracleAddress, &oracle);
109+
110+
env.events().publish(
111+
(Symbol::new(&env, "oracle"), Symbol::new(&env, "oracle_set")),
112+
oracle,
113+
);
114+
}
115+
116+
/// Return the registered oracle address, if one has been set.
117+
pub fn get_oracle(env: Env) -> Option<Address> {
118+
env.storage().persistent().get(&DataKey::OracleAddress)
119+
}
120+
121+
/// Return the administrator, if the contract has been initialized.
122+
pub fn get_admin(env: Env) -> Option<Address> {
123+
env.storage().persistent().get(&DataKey::Admin)
88124
}
89125

90126
// ── Price feed ───────────────────────────────────────────────────────────
91127

92-
/// Push a new price observation (called by the oracle aggregator).
128+
/// Push a new price observation. Callable only by the registered oracle.
93129
pub fn update_price(env: Env, caller: Address, price_data: PriceData) {
94130
caller.require_auth();
95131

132+
// The check this contract is named for, and previously did not perform.
133+
//
134+
// `require_auth()` alone proves only that `caller` authorised the call —
135+
// it says nothing about *who* `caller` is. Any address could satisfy it
136+
// with its own signature and write the authoritative price.
137+
//
138+
// Rejecting when no oracle is registered matters as much as the
139+
// comparison: with an empty LastPrice there is nothing to deviate from,
140+
// so the first writer could set any positive price at all, and every
141+
// subsequent update would then be anchored to that value.
142+
let registered: Address = env
143+
.storage()
144+
.persistent()
145+
.get(&DataKey::OracleAddress)
146+
.expect("No oracle registered; call set_oracle first");
147+
assert!(caller == registered, "Caller is not the registered oracle");
148+
96149
// Reject obviously anomalous prices (zero or negative).
97150
assert!(price_data.price_micro_usd > 0, "Price must be positive");
98151

@@ -167,6 +220,22 @@ impl OracleContract {
167220
used_fallback,
168221
}
169222
}
223+
224+
// ── Internal ─────────────────────────────────────────────────────────────
225+
226+
/// Assert that `caller` is the stored administrator.
227+
///
228+
/// Panics when the contract has not been initialized, rather than treating
229+
/// an absent admin as "anyone may proceed" — an uninitialized contract must
230+
/// be closed, not open.
231+
fn require_admin(env: &Env, caller: &Address) {
232+
let admin: Address = env
233+
.storage()
234+
.persistent()
235+
.get(&DataKey::Admin)
236+
.expect("Contract not initialized; call initialize first");
237+
assert!(*caller == admin, "Caller is not the admin");
238+
}
170239
}
171240

172241
// ---------------------------------------------------------------------------
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
#![cfg(test)]
2+
3+
//! Access-control tests for the oracle price feed (issue #1111).
4+
//!
5+
//! The contract's purpose is to be a trustworthy price source for fiat-pegged
6+
//! bounties. Before this fix `update_price` checked only that the caller had
7+
//! signed for itself, never that it was the registered oracle — so these tests
8+
//! are mostly about proving the door is now shut, from each direction it was
9+
//! previously open.
10+
11+
use super::*;
12+
use soroban_sdk::testutils::Address as _;
13+
14+
fn setup() -> (Env, OracleContractClient<'static>, Address, Address) {
15+
let env = Env::default();
16+
env.mock_all_auths();
17+
let id = env.register_contract(None, OracleContract);
18+
let client = OracleContractClient::new(&env, &id);
19+
let admin = Address::generate(&env);
20+
let oracle = Address::generate(&env);
21+
(env, client, admin, oracle)
22+
}
23+
24+
fn price(env: &Env, micro_usd: i128) -> PriceData {
25+
PriceData {
26+
price_micro_usd: micro_usd,
27+
timestamp: env.ledger().timestamp(),
28+
}
29+
}
30+
31+
// ── The hole itself ──────────────────────────────────────────────────────────
32+
33+
#[test]
34+
#[should_panic(expected = "Caller is not the registered oracle")]
35+
fn arbitrary_address_cannot_push_a_price() {
36+
let (env, client, admin, oracle) = setup();
37+
client.initialize(&admin);
38+
client.set_oracle(&admin, &oracle);
39+
40+
// Before the fix this succeeded: require_auth() proved only that the
41+
// attacker controlled its own address, which it always does.
42+
let attacker = Address::generate(&env);
43+
client.update_price(&attacker, &price(&env, 999_999));
44+
}
45+
46+
#[test]
47+
#[should_panic(expected = "No oracle registered")]
48+
fn price_cannot_be_set_before_an_oracle_is_registered() {
49+
let (env, client, admin, _oracle) = setup();
50+
client.initialize(&admin);
51+
52+
// The worst case previously: with no LastPrice there is nothing to deviate
53+
// from, so the first writer could set *any* positive price, and every later
54+
// update would be anchored to it — the deviation guard would then protect
55+
// the attacker's number rather than the real one.
56+
let attacker = Address::generate(&env);
57+
client.update_price(&attacker, &price(&env, 1));
58+
}
59+
60+
#[test]
61+
fn registered_oracle_can_push_a_price() {
62+
let (env, client, admin, oracle) = setup();
63+
client.initialize(&admin);
64+
client.set_oracle(&admin, &oracle);
65+
66+
client.update_price(&oracle, &price(&env, 120_000));
67+
68+
assert_eq!(client.get_price().price_micro_usd, 120_000);
69+
}
70+
71+
#[test]
72+
#[should_panic(expected = "Caller is not the registered oracle")]
73+
fn previous_oracle_cannot_push_after_being_replaced() {
74+
let (env, client, admin, oracle) = setup();
75+
client.initialize(&admin);
76+
client.set_oracle(&admin, &oracle);
77+
client.update_price(&oracle, &price(&env, 120_000));
78+
79+
// Rotating the oracle must actually revoke the old one.
80+
let new_oracle = Address::generate(&env);
81+
client.set_oracle(&admin, &new_oracle);
82+
client.update_price(&oracle, &price(&env, 121_000));
83+
}
84+
85+
// ── Admin gating ─────────────────────────────────────────────────────────────
86+
87+
#[test]
88+
#[should_panic(expected = "Caller is not the admin")]
89+
fn non_admin_cannot_register_an_oracle() {
90+
let (env, client, admin, oracle) = setup();
91+
client.initialize(&admin);
92+
93+
// Previously set_oracle's `admin` parameter was self-asserted — the caller
94+
// passed whichever address it controlled and require_auth() was satisfied.
95+
let impostor = Address::generate(&env);
96+
client.set_oracle(&impostor, &oracle);
97+
}
98+
99+
#[test]
100+
#[should_panic(expected = "Contract not initialized")]
101+
fn set_oracle_fails_before_initialize() {
102+
let (_env, client, admin, oracle) = setup();
103+
104+
// An uninitialized contract must be closed, not open — an absent admin is
105+
// not "anyone may proceed".
106+
client.set_oracle(&admin, &oracle);
107+
}
108+
109+
#[test]
110+
#[should_panic(expected = "Contract already initialized")]
111+
fn initialize_is_once_only() {
112+
let (env, client, admin, _oracle) = setup();
113+
client.initialize(&admin);
114+
115+
// Otherwise anyone could re-initialize and take over as admin.
116+
client.initialize(&Address::generate(&env));
117+
}
118+
119+
#[test]
120+
fn admin_can_rotate_the_oracle() {
121+
let (env, client, admin, oracle) = setup();
122+
client.initialize(&admin);
123+
client.set_oracle(&admin, &oracle);
124+
125+
let new_oracle = Address::generate(&env);
126+
client.set_oracle(&admin, &new_oracle);
127+
128+
assert_eq!(client.get_oracle(), Some(new_oracle.clone()));
129+
client.update_price(&new_oracle, &price(&env, 120_000));
130+
assert_eq!(client.get_price().price_micro_usd, 120_000);
131+
}
132+
133+
// ── Existing guards still apply to the registered oracle ─────────────────────
134+
135+
#[test]
136+
#[should_panic(expected = "Price must be positive")]
137+
fn registered_oracle_still_cannot_push_a_non_positive_price() {
138+
let (env, client, admin, oracle) = setup();
139+
client.initialize(&admin);
140+
client.set_oracle(&admin, &oracle);
141+
142+
client.update_price(&oracle, &price(&env, 0));
143+
}
144+
145+
#[test]
146+
#[should_panic(expected = "Price deviation exceeds allowed threshold")]
147+
fn registered_oracle_still_cannot_exceed_the_deviation_bound() {
148+
let (env, client, admin, oracle) = setup();
149+
client.initialize(&admin);
150+
client.set_oracle(&admin, &oracle);
151+
client.update_price(&oracle, &price(&env, 100_000));
152+
153+
// +50%, well beyond MAX_PRICE_DEVIATION_BPS (10%). Authentication must not
154+
// become a bypass for the sanity checks.
155+
client.update_price(&oracle, &price(&env, 150_000));
156+
}
157+
158+
#[test]
159+
fn deviation_within_bound_is_accepted() {
160+
let (env, client, admin, oracle) = setup();
161+
client.initialize(&admin);
162+
client.set_oracle(&admin, &oracle);
163+
client.update_price(&oracle, &price(&env, 100_000));
164+
165+
// +5%, inside the bound.
166+
client.update_price(&oracle, &price(&env, 105_000));
167+
assert_eq!(client.get_price().price_micro_usd, 105_000);
168+
}

0 commit comments

Comments
 (0)