Skip to content

Commit 1efb216

Browse files
Merge branch 'main' into feat/stream-insurance-governance
2 parents 67f504c + 384091d commit 1efb216

File tree

3 files changed

+559
-8
lines changed

3 files changed

+559
-8
lines changed

ORACLE_INTEGRATION.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,18 @@ The solution consists of two main components:
3131
- **Provider Withdrawals**: Allows providers to withdraw earnings in XLM
3232
- **Conversion Events**: Emits events for transparency
3333
- **Backward Compatibility**: Works with existing custom tokens
34+
- **Emergency Trust Mode**: Enables manual recovery when oracle heartbeat is stale for 72 hours
35+
- **Unanimous Governance Guard**: Emergency actions require 100% approval from all active members
3436

3537
#### New Functions:
3638
- `top_up()` - Enhanced to handle XLM→USD conversion
3739
- `withdraw_earnings()` - New function for USD→XLM conversion
3840
- `get_current_rate()` - Get current exchange rate
41+
- `is_trust_mode()` - Returns true when oracle heartbeat is stale for more than 72 hours
42+
- `propose_emergency_flow_rate()` - Create emergency proposal to set manual flow rate
43+
- `propose_emergency_pause()` - Create emergency proposal to pause a meter cycle
44+
- `approve_emergency_action()` - Approve an emergency proposal (one vote per member)
45+
- `execute_emergency_action()` - Execute only after unanimous member approval
3946

4047
## Usage Flow
4148

@@ -76,6 +83,48 @@ New error types added:
7683
2. **Staleness Checks**: Rejects old price data
7784
3. **Access Control**: Admin controls updater role
7885
4. **Event Logging**: All conversions emit events for transparency
86+
5. **Trust Mode Gate**: Emergency manual controls are blocked while oracle heartbeat is healthy
87+
6. **Strict Unanimity**: Every registered active member must approve emergency actions
88+
7. **Duplicate Vote Prevention**: A member can approve a proposal only once
89+
90+
## Trust Mode and Manual Fallback
91+
92+
### Activation Rule
93+
94+
Trust Mode is derived from on-chain oracle heartbeat state:
95+
96+
- Utility contract fetches oracle `PriceData.last_updated`
97+
- If `now - last_updated > 72 hours`, Trust Mode is active
98+
- If no oracle address is configured, Trust Mode is treated as active for recovery operations
99+
100+
Boundary behavior is strict:
101+
102+
- exactly 72 hours stale: not yet in Trust Mode
103+
- greater than 72 hours stale: Trust Mode active
104+
105+
### Allowed Actions in Trust Mode
106+
107+
Only in Trust Mode, active members can unanimously approve:
108+
109+
1. manual `max_flow_rate_per_hour` update for a meter
110+
2. manual cycle pause (`is_paused = true`) for a meter
111+
112+
Outside Trust Mode these manual emergency actions revert.
113+
114+
### Member Eligibility and Unanimity
115+
116+
- Members are addresses registered through `register_active_user()`
117+
- Membership is tracked uniquely per address
118+
- Proposal creator auto-approves their proposal
119+
- Additional approvals are counted once per member
120+
- Execution requires `approval_count == active_member_count`
121+
122+
### Oracle Recovery Assumption
123+
124+
- When oracle heartbeat becomes healthy again, new emergency proposals and approvals are blocked
125+
- Already executed emergency actions remain in state (no automatic rollback)
126+
127+
This keeps fallback narrowly scoped to catastrophic oracle inactivity without weakening normal oracle-driven behavior.
79128

80129
## Testing
81130

contracts/utility_contracts/src/lib.rs

Lines changed: 285 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,24 @@ pub struct AdminTransferProposal {
237237
pub is_active: bool,
238238
}
239239

240+
#[contracttype]
241+
#[derive(Clone)]
242+
pub enum EmergencyAction {
243+
SetFlowRate(i128),
244+
PauseCycle,
245+
}
246+
247+
#[contracttype]
248+
#[derive(Clone)]
249+
pub struct EmergencyProposal {
250+
pub proposal_id: u64,
251+
pub meter_id: u64,
252+
pub action: EmergencyAction,
253+
pub proposed_by: Address,
254+
pub proposed_at: u64,
255+
pub executed: bool,
256+
}
257+
240258
pub fn set_sorosusu_contract(env: Env, addr: Address) {
241259
env.storage()
242260
.instance()
@@ -502,6 +520,7 @@ const LEDGER_LIFETIME_EXTENSION: u32 = 1_000_000; // Extend by 1M ledgers
502520

503521
// Task #4: Wasm Hash Rotation Constants
504522
const UPGRADE_VETO_PERIOD_SECONDS: u64 = 7 * DAY_IN_SECONDS; // 7 days veto period
523+
const ORACLE_HEARTBEAT_TIMEOUT_SECONDS: u64 = 72 * HOUR_IN_SECONDS;
505524

506525
/// Round XLM amount to nearest minimum increment (0.0000001 XLM)
507526
/// This prevents value loss over time due to truncation
@@ -614,6 +633,67 @@ fn get_oracle_or_panic(env: &Env) -> Address {
614633
}
615634
}
616635

636+
fn is_trust_mode_active(env: &Env) -> bool {
637+
let now = env.ledger().timestamp();
638+
match env
639+
.storage()
640+
.instance()
641+
.get::<DataKey, Address>(&DataKey::Oracle)
642+
{
643+
Some(oracle_address) => {
644+
let oracle_client = PriceOracleClient::new(env, &oracle_address);
645+
let price_data = oracle_client.get_price();
646+
now.saturating_sub(price_data.last_updated) > ORACLE_HEARTBEAT_TIMEOUT_SECONDS
647+
}
648+
None => true,
649+
}
650+
}
651+
652+
fn require_trust_mode(env: &Env) {
653+
if !is_trust_mode_active(env) {
654+
panic_with_error!(env, ContractError::OracleHeartbeatHealthy);
655+
}
656+
}
657+
658+
fn active_member_count(env: &Env) -> u32 {
659+
env.storage()
660+
.instance()
661+
.get::<DataKey, u32>(&DataKey::ActiveUsers)
662+
.unwrap_or(0)
663+
}
664+
665+
fn require_emergency_member(env: &Env, member: &Address) {
666+
member.require_auth();
667+
let is_member = env
668+
.storage()
669+
.instance()
670+
.get::<DataKey, bool>(&DataKey::ActiveUserMember(member.clone()))
671+
.unwrap_or(false);
672+
if !is_member {
673+
panic_with_error!(env, ContractError::NotEmergencyMember);
674+
}
675+
}
676+
677+
fn next_emergency_proposal_id(env: &Env) -> u64 {
678+
let current = env
679+
.storage()
680+
.instance()
681+
.get::<DataKey, u64>(&DataKey::EmergencyProposalSeq)
682+
.unwrap_or(0);
683+
let next = current.saturating_add(1);
684+
env.storage()
685+
.instance()
686+
.set(&DataKey::EmergencyProposalSeq, &next);
687+
next
688+
}
689+
690+
fn get_emergency_proposal_or_panic(env: &Env, proposal_id: u64) -> EmergencyProposal {
691+
env.storage()
692+
.instance()
693+
.get::<DataKey, EmergencyProposal>(&DataKey::EmergencyProposal(proposal_id))
694+
.unwrap_or_else(|| panic_with_error!(env, ContractError::EmergencyProposalNotFound))
695+
}
696+
617697
fn convert_xlm_to_usd_if_needed(
618698
env: &Env,
619699
amount: i128,
@@ -2066,6 +2146,193 @@ impl UtilityContract {
20662146
.publish((symbol_short!("Paused"), meter_id), paused);
20672147
}
20682148

2149+
pub fn is_trust_mode(env: Env) -> bool {
2150+
is_trust_mode_active(&env)
2151+
}
2152+
2153+
pub fn propose_emergency_flow_rate(
2154+
env: Env,
2155+
member: Address,
2156+
meter_id: u64,
2157+
max_rate_per_hour: i128,
2158+
) -> u64 {
2159+
require_trust_mode(&env);
2160+
require_emergency_member(&env, &member);
2161+
if active_member_count(&env) == 0 {
2162+
panic_with_error!(&env, ContractError::EmergencyNoMembers);
2163+
}
2164+
if max_rate_per_hour <= 0 {
2165+
panic_with_error!(&env, ContractError::InvalidTokenAmount);
2166+
}
2167+
2168+
// Ensure the target meter exists before proposal creation.
2169+
let _meter = get_meter_or_panic(&env, meter_id);
2170+
let proposal_id = next_emergency_proposal_id(&env);
2171+
let proposal = EmergencyProposal {
2172+
proposal_id,
2173+
meter_id,
2174+
action: EmergencyAction::SetFlowRate(max_rate_per_hour),
2175+
proposed_by: member.clone(),
2176+
proposed_at: env.ledger().timestamp(),
2177+
executed: false,
2178+
};
2179+
2180+
env.storage()
2181+
.instance()
2182+
.set(&DataKey::EmergencyProposal(proposal_id), &proposal);
2183+
env.storage().instance().set(
2184+
&DataKey::EmergencyProposalApproval(proposal_id, member),
2185+
&true,
2186+
);
2187+
env.storage()
2188+
.instance()
2189+
.set(&DataKey::EmergencyProposalApprovalCount(proposal_id), &1u32);
2190+
2191+
env.events().publish(
2192+
(symbol_short!("EmgProp"), proposal_id),
2193+
(meter_id, env.ledger().timestamp()),
2194+
);
2195+
2196+
proposal_id
2197+
}
2198+
2199+
pub fn propose_emergency_pause(env: Env, member: Address, meter_id: u64) -> u64 {
2200+
require_trust_mode(&env);
2201+
require_emergency_member(&env, &member);
2202+
if active_member_count(&env) == 0 {
2203+
panic_with_error!(&env, ContractError::EmergencyNoMembers);
2204+
}
2205+
2206+
// Ensure the target meter exists before proposal creation.
2207+
let _meter = get_meter_or_panic(&env, meter_id);
2208+
let proposal_id = next_emergency_proposal_id(&env);
2209+
let proposal = EmergencyProposal {
2210+
proposal_id,
2211+
meter_id,
2212+
action: EmergencyAction::PauseCycle,
2213+
proposed_by: member.clone(),
2214+
proposed_at: env.ledger().timestamp(),
2215+
executed: false,
2216+
};
2217+
2218+
env.storage()
2219+
.instance()
2220+
.set(&DataKey::EmergencyProposal(proposal_id), &proposal);
2221+
env.storage().instance().set(
2222+
&DataKey::EmergencyProposalApproval(proposal_id, member),
2223+
&true,
2224+
);
2225+
env.storage()
2226+
.instance()
2227+
.set(&DataKey::EmergencyProposalApprovalCount(proposal_id), &1u32);
2228+
2229+
env.events().publish(
2230+
(symbol_short!("EmgProp"), proposal_id),
2231+
(meter_id, env.ledger().timestamp()),
2232+
);
2233+
2234+
proposal_id
2235+
}
2236+
2237+
pub fn approve_emergency_action(env: Env, member: Address, proposal_id: u64) {
2238+
require_trust_mode(&env);
2239+
require_emergency_member(&env, &member);
2240+
if active_member_count(&env) == 0 {
2241+
panic_with_error!(&env, ContractError::EmergencyNoMembers);
2242+
}
2243+
2244+
let proposal = get_emergency_proposal_or_panic(&env, proposal_id);
2245+
if proposal.executed {
2246+
panic_with_error!(&env, ContractError::EmergencyProposalExecuted);
2247+
}
2248+
2249+
if env.storage().instance().has(&DataKey::EmergencyProposalApproval(
2250+
proposal_id,
2251+
member.clone(),
2252+
)) {
2253+
panic_with_error!(&env, ContractError::AlreadyVoted);
2254+
}
2255+
2256+
env.storage().instance().set(
2257+
&DataKey::EmergencyProposalApproval(proposal_id, member),
2258+
&true,
2259+
);
2260+
2261+
let approvals = env
2262+
.storage()
2263+
.instance()
2264+
.get::<DataKey, u32>(&DataKey::EmergencyProposalApprovalCount(proposal_id))
2265+
.unwrap_or(0)
2266+
.saturating_add(1);
2267+
env.storage()
2268+
.instance()
2269+
.set(&DataKey::EmergencyProposalApprovalCount(proposal_id), &approvals);
2270+
2271+
env.events().publish(
2272+
(symbol_short!("EmgAppr"), proposal_id),
2273+
approvals,
2274+
);
2275+
}
2276+
2277+
pub fn execute_emergency_action(env: Env, member: Address, proposal_id: u64) {
2278+
require_trust_mode(&env);
2279+
require_emergency_member(&env, &member);
2280+
2281+
let total_members = active_member_count(&env);
2282+
if total_members == 0 {
2283+
panic_with_error!(&env, ContractError::EmergencyNoMembers);
2284+
}
2285+
2286+
let mut proposal = get_emergency_proposal_or_panic(&env, proposal_id);
2287+
if proposal.executed {
2288+
panic_with_error!(&env, ContractError::EmergencyProposalExecuted);
2289+
}
2290+
2291+
let approvals = env
2292+
.storage()
2293+
.instance()
2294+
.get::<DataKey, u32>(&DataKey::EmergencyProposalApprovalCount(proposal_id))
2295+
.unwrap_or(0);
2296+
if approvals != total_members {
2297+
panic_with_error!(&env, ContractError::EmergencyApprovalIncomplete);
2298+
}
2299+
2300+
let mut meter = get_meter_or_panic(&env, proposal.meter_id);
2301+
match proposal.action {
2302+
EmergencyAction::SetFlowRate(rate) => {
2303+
if rate <= 0 {
2304+
panic_with_error!(&env, ContractError::InvalidTokenAmount);
2305+
}
2306+
meter.max_flow_rate_per_hour = rate;
2307+
}
2308+
EmergencyAction::PauseCycle => {
2309+
meter.is_paused = true;
2310+
let now = env.ledger().timestamp();
2311+
refresh_activity(&mut meter, now);
2312+
}
2313+
}
2314+
2315+
env.storage()
2316+
.instance()
2317+
.set(&DataKey::Meter(proposal.meter_id), &meter);
2318+
2319+
proposal.executed = true;
2320+
env.storage()
2321+
.instance()
2322+
.set(&DataKey::EmergencyProposal(proposal_id), &proposal);
2323+
2324+
env.events().publish(
2325+
(symbol_short!("EmgExec"), proposal_id),
2326+
proposal.meter_id,
2327+
);
2328+
}
2329+
2330+
pub fn get_emergency_proposal(env: Env, proposal_id: u64) -> Option<EmergencyProposal> {
2331+
env.storage()
2332+
.instance()
2333+
.get(&DataKey::EmergencyProposal(proposal_id))
2334+
}
2335+
20692336
pub fn set_tiered_pricing(env: Env, meter_id: u64, threshold: i128, rate: i128) {
20702337
let mut meter = get_meter_or_panic(&env, meter_id);
20712338
meter.provider.require_auth();
@@ -3508,15 +3775,26 @@ impl UtilityContract {
35083775
pub fn register_active_user(env: Env, user: Address) {
35093776
user.require_auth();
35103777

3511-
// Simplified: just increment counter
3512-
let count: u32 = env
3778+
// Track members uniquely so unanimous emergency approvals are exact.
3779+
let already_member = env
35133780
.storage()
35143781
.instance()
3515-
.get(&DataKey::ActiveUsers)
3516-
.unwrap_or(0);
3517-
env.storage()
3518-
.instance()
3519-
.set(&DataKey::ActiveUsers, &(count + 1));
3782+
.get::<DataKey, bool>(&DataKey::ActiveUserMember(user.clone()))
3783+
.unwrap_or(false);
3784+
3785+
if !already_member {
3786+
let count: u32 = env
3787+
.storage()
3788+
.instance()
3789+
.get(&DataKey::ActiveUsers)
3790+
.unwrap_or(0);
3791+
env.storage()
3792+
.instance()
3793+
.set(&DataKey::ActiveUsers, &(count + 1));
3794+
env.storage()
3795+
.instance()
3796+
.set(&DataKey::ActiveUserMember(user.clone()), &true);
3797+
}
35203798

35213799
env.events()
35223800
.publish((soroban_sdk::symbol_short!("ActvUser"),), user);

0 commit comments

Comments
 (0)