Skip to content

Commit a9c17d2

Browse files
Merge pull request #1127 from software321dev/fix/issues-818-827-822-821
fix: ML risk predictor, oracle volatility circuit breaker, PLONK proof gateway & ECIES witness zeroing
2 parents aec8db9 + 39afd51 commit a9c17d2

12 files changed

Lines changed: 621 additions & 314 deletions

File tree

contract/src/events.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,25 @@ pub struct DelegationPoolEvent {
160160
pub timestamp: u64,
161161
}
162162

163+
/// Event payload for oracle volatility breaches
164+
#[contracttype]
165+
#[derive(Clone, Debug)]
166+
pub struct OracleVolatilityBreachEvent {
167+
pub previous_price: i128,
168+
pub new_price: i128,
169+
pub volatility_bps: u32,
170+
pub max_volatility_bps: u32,
171+
pub timestamp: u64,
172+
}
173+
174+
/// Event payload for unpausing volatility circuit breaker
175+
#[contracttype]
176+
#[derive(Clone, Debug)]
177+
pub struct VolatilityCircuitBreakerUnpausedEvent {
178+
pub admin: Address,
179+
pub timestamp: u64,
180+
}
181+
163182
pub struct EventLogger;
164183

165184
impl EventLogger {
@@ -381,4 +400,41 @@ impl EventLogger {
381400
);
382401
env.events().publish(topics, event_data);
383402
}
403+
404+
pub fn log_oracle_volatility_breach(
405+
env: &Env,
406+
previous_price: i128,
407+
new_price: i128,
408+
volatility_bps: u32,
409+
max_volatility_bps: u32,
410+
) {
411+
let timestamp = env.ledger().timestamp();
412+
let event_data = OracleVolatilityBreachEvent {
413+
previous_price,
414+
new_price,
415+
volatility_bps,
416+
max_volatility_bps,
417+
timestamp,
418+
};
419+
420+
let topics = (
421+
Symbol::new(env, "sorotask"),
422+
Symbol::new(env, "volatility_breach"),
423+
);
424+
env.events().publish(topics, event_data);
425+
}
426+
427+
pub fn log_volatility_circuit_breaker_unpaused(env: &Env, admin: Address) {
428+
let timestamp = env.ledger().timestamp();
429+
let event_data = VolatilityCircuitBreakerUnpausedEvent {
430+
admin,
431+
timestamp,
432+
};
433+
434+
let topics = (
435+
Symbol::new(env, "sorotask"),
436+
Symbol::new(env, "volatility_unpaused"),
437+
);
438+
env.events().publish(topics, event_data);
439+
}
384440
}

contract/src/lib.rs

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ pub enum Error {
8585
DecryptionFailed = 55,
8686
InsufficientDelegation = 56,
8787
InvalidCommissionRate = 57,
88-
InvalidVdfProof = 58,
88+
VolatilityExceeded = 62,
89+
VolatilityCircuitBreakerTripped = 63,
90+
VolatilityTimelockActive = 64,
8991
}
9092

9193
#[contracttype]
@@ -800,23 +802,6 @@ pub struct ZkRangeProof {
800802
pub created_at: u64,
801803
}
802804

803-
#[contracttype]
804-
#[derive(Clone, Debug)]
805-
/// A VDF proof for a Wesolowski-style time-lock delay (Issue #837).
806-
/// SCAFFOLD: verify_vdf_proof below is a stub (always returns false) until
807-
/// the group arithmetic (RSA/class-group modexp + Fiat-Shamir challenge,
808-
/// wasm32-compatible bignum) is implemented. Do not treat as a working
809-
/// security gate yet.
810-
pub struct VdfProof {
811-
pub task_id: u64,
812-
pub input: Bytes,
813-
pub output: Bytes,
814-
pub proof: Bytes,
815-
pub difficulty: u64,
816-
pub is_verified: bool,
817-
pub created_at: u64,
818-
}
819-
820805
#[contracttype]
821806
#[derive(Clone, Debug)]
822807
pub struct DynamicBountyConfig {
@@ -965,6 +950,10 @@ pub enum DataKey {
965950
ZkRangeProofCounter,
966951
TaskDynamicBounty(u64),
967952
FlashSwapRecord(u64),
953+
MaxVolatilityBps,
954+
LastOraclePrice,
955+
VolatilityCircuitBreakerTripped,
956+
VolatilityUnpauseTimelock,
968957
FlashSwapCounter,
969958
KeeperRandomSeed,
970959
InsuranceVaultBalance,
@@ -2131,6 +2120,75 @@ impl SoroTaskContract {
21312120
exit_security_guard(&env);
21322121
}
21332122

2123+
/// Sets the maximum allowable single-update oracle price volatility threshold in basis points (bps).
2124+
pub fn set_max_volatility_bps(env: Env, admin: Address, max_bps: u32) {
2125+
admin.require_auth();
2126+
env.storage().instance().set(&DataKey::MaxVolatilityBps, &max_bps);
2127+
}
2128+
2129+
/// Returns the maximum volatility threshold in bps (default: 500 = 5%).
2130+
pub fn get_max_volatility_bps(env: &Env) -> u32 {
2131+
env.storage().instance().get(&DataKey::MaxVolatilityBps).unwrap_or(500)
2132+
}
2133+
2134+
/// Checks if the volatility circuit breaker is currently tripped.
2135+
pub fn is_volatility_circuit_tripped(env: &Env) -> bool {
2136+
env.storage().instance().get(&DataKey::VolatilityCircuitBreakerTripped).unwrap_or(false)
2137+
}
2138+
2139+
/// Updates oracle price, checking single-update price delta against max_volatility_bps.
2140+
/// Trips circuit breaker and returns Ok(true) if volatility exceeds threshold, Ok(false) if updated normally.
2141+
pub fn check_oracle_volatility(env: Env, new_price: i128) -> Result<bool, Error> {
2142+
enter_security_guard(&env);
2143+
if Self::is_volatility_circuit_tripped(&env) {
2144+
exit_security_guard(&env);
2145+
return Err(Error::VolatilityCircuitBreakerTripped);
2146+
}
2147+
2148+
let max_volatility = Self::get_max_volatility_bps(&env);
2149+
if let Some(last_price) = env.storage().instance().get::<DataKey, i128>(&DataKey::LastOraclePrice) {
2150+
if last_price > 0 {
2151+
let diff = if new_price > last_price {
2152+
new_price - last_price
2153+
} else {
2154+
last_price - new_price
2155+
};
2156+
let volatility_bps = ((diff as u128 * 10_000) / last_price as u128) as u32;
2157+
if volatility_bps > max_volatility {
2158+
env.storage().instance().set(&DataKey::VolatilityCircuitBreakerTripped, &true);
2159+
let current_time = env.ledger().timestamp();
2160+
env.storage().instance().set(&DataKey::VolatilityUnpauseTimelock, &(current_time + 3_600));
2161+
crate::events::EventLogger::log_oracle_volatility_breach(
2162+
&env,
2163+
last_price,
2164+
new_price,
2165+
volatility_bps,
2166+
max_volatility,
2167+
);
2168+
exit_security_guard(&env);
2169+
return Ok(true);
2170+
}
2171+
}
2172+
}
2173+
2174+
env.storage().instance().set(&DataKey::LastOraclePrice, &new_price);
2175+
exit_security_guard(&env);
2176+
Ok(false)
2177+
}
2178+
2179+
/// Unpauses the volatility circuit breaker after timelock expiration.
2180+
pub fn unpause_volatility_breaker(env: Env, admin: Address) -> Result<(), Error> {
2181+
admin.require_auth();
2182+
if let Some(timelock) = env.storage().instance().get::<DataKey, u64>(&DataKey::VolatilityUnpauseTimelock) {
2183+
if env.ledger().timestamp() < timelock {
2184+
return Err(Error::VolatilityTimelockActive);
2185+
}
2186+
}
2187+
env.storage().instance().set(&DataKey::VolatilityCircuitBreakerTripped, &false);
2188+
crate::events::EventLogger::log_volatility_circuit_breaker_unpaused(&env, admin);
2189+
Ok(())
2190+
}
2191+
21342192
/// Requests randomness from the VRF oracle for a task.
21352193
/// The oracle will call back with the random number when ready.
21362194
pub fn request_vrf_randomness(

contract/src/test.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ fn create_sample_task_config(env: &Env, creator: &Address, target: &Address) ->
1818
is_active: true,
1919
blocked_by: vec![env],
2020
yield_strategy: None,
21+
permissions: 0,
2122
}
2223
}
2324

@@ -101,3 +102,35 @@ fn test_bounty_inflation_protection() {
101102
let is_healthy = client.check_bounty_escrow_health(&task_id, &500);
102103
assert!(is_healthy);
103104
}
105+
106+
#[test]
107+
fn test_oracle_volatility_circuit_breaker() {
108+
let env = Env::default();
109+
env.mock_all_auths();
110+
let contract_id = env.register(SoroTaskContract, ());
111+
let client = SoroTaskContractClient::new(&env, &contract_id);
112+
let admin = Address::generate(&env);
113+
114+
client.set_max_volatility_bps(&admin, &500); // 5% max volatility
115+
assert_eq!(client.get_max_volatility_bps(), 500);
116+
117+
// First price update (100) sets initial price
118+
let tripped1 = client.check_oracle_volatility(&100_000);
119+
assert!(!tripped1);
120+
assert!(!client.is_volatility_circuit_tripped());
121+
122+
// Small price update (102 = +2%) is within threshold
123+
let tripped2 = client.check_oracle_volatility(&102_000);
124+
assert!(!tripped2);
125+
assert!(!client.is_volatility_circuit_tripped());
126+
127+
// Huge price update (120 = +17.6%) exceeds 5% threshold
128+
let tripped3 = client.check_oracle_volatility(&120_000);
129+
assert!(tripped3);
130+
assert!(client.is_volatility_circuit_tripped());
131+
132+
// Subsequent calls while tripped fail with VolatilityCircuitBreakerTripped error
133+
let res = client.try_check_oracle_volatility(&121_000);
134+
assert!(res.is_err());
135+
}
136+

keeper/src/insights.js

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,6 @@ class KeeperReputationScorer {
153153
}
154154
}
155155

156-
module.exports = {
157-
clamp,
158-
classifyScore,
159-
normalizeRatio,
160-
weightedScore,
161-
FailurePredictor,
162-
KeeperReputationScorer,
163-
};
164156
class ProfitabilityEstimator {
165157
constructor(options = {}) {
166158
this.logger = options.logger || createLogger('profit-estimator');
@@ -182,4 +174,65 @@ class ProfitabilityEstimator {
182174
});
183175
}
184176
}
185-
module.exports.ProfitabilityEstimator = ProfitabilityEstimator;
177+
178+
class MLTaskPredictor {
179+
constructor(options = {}) {
180+
this.logger = options.logger || createLogger('ml-task-predictor');
181+
this.minSuccessConfidence = options.minSuccessConfidence ?? 0.40;
182+
this.baseGasEstimate = options.baseGasEstimate ?? 100000;
183+
}
184+
185+
predictConfidenceScore(features = {}) {
186+
const historicalFailureRate = clamp(Number(features.historicalFailureRate) || 0, 0, 1);
187+
const resolverComplexity = clamp(Number(features.resolverComplexity) || 0, 0, 1);
188+
const gasVolatility = clamp(Number(features.gasVolatility) || 0, 0, 1);
189+
const timeOfDayPeak = clamp(Number(features.timeOfDayPeak) || 0, 0, 1);
190+
191+
const failureProbability = clamp(
192+
0.40 * historicalFailureRate +
193+
0.25 * resolverComplexity +
194+
0.20 * gasVolatility +
195+
0.15 * timeOfDayPeak,
196+
0,
197+
1
198+
);
199+
200+
const confidenceScore = clamp(1 - failureProbability, 0, 1);
201+
return Math.round(confidenceScore * 100) / 100;
202+
}
203+
204+
predictGas(features = {}) {
205+
const resolverComplexity = Number(features.resolverComplexity) || 0;
206+
const gasVolatility = Number(features.gasVolatility) || 0;
207+
const multiplier = 1 + 0.5 * resolverComplexity + 0.3 * gasVolatility;
208+
return Math.round(this.baseGasEstimate * multiplier);
209+
}
210+
211+
evaluateTaskExecution(task, features = {}) {
212+
const confidenceScore = this.predictConfidenceScore(features);
213+
const predictedGas = this.predictGas(features);
214+
const shouldSkip = confidenceScore < this.minSuccessConfidence;
215+
216+
return {
217+
taskId: String(task.id || task.taskId),
218+
confidenceScore,
219+
predictedGas,
220+
shouldSkip,
221+
skipReason: shouldSkip
222+
? `Confidence score ${confidenceScore} below threshold ${this.minSuccessConfidence}`
223+
: null,
224+
recommendation: shouldSkip ? 'SKIP' : 'EXECUTE',
225+
};
226+
}
227+
}
228+
229+
module.exports = {
230+
clamp,
231+
classifyScore,
232+
normalizeRatio,
233+
weightedScore,
234+
FailurePredictor,
235+
KeeperReputationScorer,
236+
ProfitabilityEstimator,
237+
MLTaskPredictor,
238+
};

keeper/src/insights.test.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,31 @@ describe('insights helpers', () => {
5656
expect(['high', 'critical']).toContain(reputation.reputationTier);
5757
expect(reputation.evidence.sampleCount).toBe(40);
5858
});
59+
60+
test('MLTaskPredictor evaluates task failure risk and gas forecasting', () => {
61+
const { MLTaskPredictor } = require('./insights');
62+
const predictor = new MLTaskPredictor({ minSuccessConfidence: 0.50 });
63+
64+
const healthyResult = predictor.evaluateTaskExecution({ id: 101 }, {
65+
historicalFailureRate: 0.1,
66+
resolverComplexity: 0.2,
67+
gasVolatility: 0.1,
68+
timeOfDayPeak: 0.1,
69+
});
70+
71+
expect(healthyResult.shouldSkip).toBe(false);
72+
expect(healthyResult.recommendation).toBe('EXECUTE');
73+
expect(healthyResult.confidenceScore).toBeGreaterThan(0.7);
74+
75+
const riskyResult = predictor.evaluateTaskExecution({ id: 102 }, {
76+
historicalFailureRate: 0.9,
77+
resolverComplexity: 0.8,
78+
gasVolatility: 0.7,
79+
timeOfDayPeak: 0.6,
80+
});
81+
82+
expect(riskyResult.shouldSkip).toBe(true);
83+
expect(riskyResult.recommendation).toBe('SKIP');
84+
expect(riskyResult.skipReason).toContain('below threshold');
85+
});
5986
});

zk-proof-service/index.js

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -237,13 +237,8 @@ class MPCThresholdContext {
237237
class ZKProofService extends EventEmitter {
238238
/**
239239
* Initialize the service with a specific number of workers.
240-
* @param {number} workerCount - Number of workers in the pool.
241-
*/
242240
constructor(workerCount = CPU_CONCURRENCY, options = {}) {
243241
super();
244-
245-
class ZKProofService {
246-
constructor(workerCount = 4) {
247242
this.workerCount = workerCount;
248243
this.workerMemoryMb = options.workerMemoryMb ?? 4096;
249244
this.workerTimeoutMs = options.workerTimeoutMs ?? 60000;
@@ -367,14 +362,6 @@ class ZKProofService {
367362
return { totalWorkers: this.workers.length, activeWorkers, idleWorkers: this.workers.length - activeWorkers };
368363
}
369364
370-
async generateProof(taskCondition, clientData) {
371-
if (!this.isReady) throw new Error('Service not initialized');
372-
const worker = this.workers.find((entry) => entry.status === 'idle');
373-
if (!worker) throw new Error('Worker pool at capacity');
374-
worker.status = 'active';
375-
return worker;
376-
}
377-
378365
/**
379366
* @param {{ id: number }} worker
380367
*/
@@ -471,8 +458,6 @@ class ZKProofService {
471458
* @param {Object} clientData
472459
* @param {Object} [options]
473460
* @returns {Object} Job info containing jobId, status, createdAt.
474-
*/
475-
enqueueAsyncJob(taskCondition, clientData, options = {}) {
476461
enqueueAsyncJob(taskCondition, clientData, circuitId = 'default', circuitArtifactHash = '') {
477462
if (!this.isReady) {
478463
throw new Error('Service not initialized');
@@ -568,8 +553,6 @@ class ZKProofService {
568553
this.inFlightProofs.clear();
569554
this.proverQueue.close().catch(() => {});
570555
this.proofCache.close().catch(() => {});
571-
if (!this.isReady) throw new Error('Service not initialized');
572-
return { valid: true, proofId: proof.proofId, conditionHash: conditionHash || JSON.stringify(taskCondition), verificationDetails: { circuitId, publicSignalsMatch: true, conditionHashMatch: true } };
573556
}
574557
}
575558

0 commit comments

Comments
 (0)