From e0416729a4c69d4d80ac06a18a9113ea1b70d269 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 30 Mar 2026 05:31:05 +0100 Subject: [PATCH 1/2] security: implement rate limiting middleware for api --- Cargo.lock | 236 +++++++++++++++++++++- backend/indexer/Cargo.toml | 3 + backend/indexer/src/main.rs | 4 + backend/indexer/src/rate_limit.rs | 321 ++++++++++++++++++++++++++++++ 4 files changed, 559 insertions(+), 5 deletions(-) create mode 100644 backend/indexer/src/rate_limit.rs diff --git a/Cargo.lock b/Cargo.lock index 1c5d230..9d91c3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,6 +292,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-future" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" + [[package]] name = "autocfg" version = "1.5.0" @@ -350,6 +356,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-test" +version = "17.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb1dfb84bd48bad8e4aa1acb82ed24c2bb5e855b659959b4e03b4dca118fcac" +dependencies = [ + "anyhow", + "assert-json-diff", + "auto-future", + "axum", + "bytes", + "bytesize", + "cookie", + "http", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -455,6 +491,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" + [[package]] name = "cc" version = "1.2.56" @@ -586,6 +628,16 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -777,6 +829,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -836,6 +902,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1069,6 +1141,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1134,6 +1212,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.32" @@ -1206,6 +1290,29 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.2", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "group" version = "0.13.0" @@ -1260,6 +1367,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1268,7 +1381,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1276,6 +1389,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -1457,9 +1575,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", "socket2 0.6.2", - "system-configuration", "tokio", "tower-service", "tracing", @@ -1610,12 +1726,17 @@ dependencies = [ "anyhow", "async-trait", "axum", + "axum-test", "base64", "chrono", + "dashmap", "dotenvy", "ed25519-dalek", + "governor", "hex", "hmac", + "lazy_static", + "prometheus", "redis", "reqwest", "serde", @@ -1930,6 +2051,12 @@ dependencies = [ "libc", ] +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2344,6 +2471,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -2368,6 +2501,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -2461,6 +2604,21 @@ version = "2.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -2480,8 +2638,6 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.36", - "socket2", - "rustls", "socket2 0.6.2", "thiserror 2.0.18", "tokio", @@ -2607,6 +2763,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "redis" version = "0.27.6" @@ -2738,6 +2903,15 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "reserve-port" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94070964579245eb2f76e62a7668fe87bd9969ed6c41256f3bf614e3323dd3cc" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -2782,6 +2956,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "mime", + "rand 0.9.2", + "thiserror 2.0.18", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -3485,6 +3674,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -4463,6 +4661,28 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -4838,6 +5058,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.1" diff --git a/backend/indexer/Cargo.toml b/backend/indexer/Cargo.toml index 7066fa3..b8c1d1b 100644 --- a/backend/indexer/Cargo.toml +++ b/backend/indexer/Cargo.toml @@ -34,6 +34,9 @@ sha2 = "0.10" async-trait = "0.1" prometheus = { version = "0.13", features = ["process"] } lazy_static = "1" +governor = { version = "0.10", features = ["dashmap"] } +dashmap = "6" [dev-dependencies] tokio = { version = "1", features = ["full"] } +axum-test = "17" diff --git a/backend/indexer/src/main.rs b/backend/indexer/src/main.rs index 47ec674..25c716b 100644 --- a/backend/indexer/src/main.rs +++ b/backend/indexer/src/main.rs @@ -13,6 +13,7 @@ mod events; mod indexer; mod profiles; mod metrics; +mod rate_limit; mod rpc; mod webhook; @@ -109,6 +110,9 @@ async fn main() -> anyhow::Result<()> { "/profiles/:address", axum::routing::delete(api::delete_profile), ) + .layer(rate_limit::RateLimitLayer::in_memory( + rate_limit::DEFAULT_REQUESTS_PER_MINUTE, + )) .layer(CorsLayer::permissive()) .layer(TraceLayer::new_for_http()) .with_state(api_state); diff --git a/backend/indexer/src/rate_limit.rs b/backend/indexer/src/rate_limit.rs new file mode 100644 index 0000000..c0f77d4 --- /dev/null +++ b/backend/indexer/src/rate_limit.rs @@ -0,0 +1,321 @@ +//! Sliding-window rate limiter middleware for the Axum API. +//! +//! Uses `governor`'s GCRA algorithm (a leaky-bucket variant that closely +//! approximates a sliding window) keyed on the client IP address. +//! +//! Every response gets three informational headers: +//! - `X-RateLimit-Limit` – total quota per window +//! - `X-RateLimit-Remaining` – estimated remaining calls in the current window +//! - `X-RateLimit-Reset` – seconds until the quota fully replenishes +//! +//! When the quota is exhausted the middleware short-circuits with +//! `429 Too Many Requests` and a JSON error body. +//! +//! # Future Redis migration +//! `RateLimiterStore` is the seam for swapping the in-memory store for a +//! Redis-backed one without touching any middleware logic. + +use std::{ + future::Future, + net::IpAddr, + num::NonZeroU32, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; + +use axum::{ + body::Body, + extract::ConnectInfo, + http::{Request, Response, StatusCode}, +}; +use governor::{ + clock::{Clock, DefaultClock}, + middleware::NoOpMiddleware, + state::keyed::DefaultKeyedStateStore, + Quota, RateLimiter, +}; +use serde_json::json; +use tower::{Layer, Service}; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/// Default quota: 100 requests per 60-second window. +pub const DEFAULT_REQUESTS_PER_MINUTE: u32 = 100; + +// ── Store abstraction (seam for Redis migration) ────────────────────────────── + +/// Abstracts the rate-limit state store. +/// +/// `Ok(remaining)` → request is within quota, `remaining` is an estimate of +/// how many more calls are allowed right now. +/// `Err(wait_secs)` → quota exceeded; caller should wait this many seconds. +pub trait RateLimiterStore: Send + Sync + 'static { + fn check(&self, key: IpAddr) -> Result; +} + +// ── In-memory store ─────────────────────────────────────────────────────────── + +type GovernorLimiter = + RateLimiter, DefaultClock, NoOpMiddleware>; + +pub struct InMemoryStore { + limiter: GovernorLimiter, + quota_per_minute: u32, + clock: DefaultClock, +} + +impl InMemoryStore { + pub fn new(requests_per_minute: u32) -> Self { + let rpm = NonZeroU32::new(requests_per_minute).expect("quota must be > 0"); + let quota = Quota::per_minute(rpm); + Self { + limiter: RateLimiter::keyed(quota), + quota_per_minute: requests_per_minute, + clock: DefaultClock::default(), + } + } +} + +impl RateLimiterStore for InMemoryStore { + fn check(&self, key: IpAddr) -> Result { + match self.limiter.check_key(&key) { + Ok(_snapshot) => { + // Estimate remaining without consuming additional tokens: + // try check_key_n for decreasing n until one succeeds. + // This is a read-only probe — we don't call check_key again. + let remaining = + estimate_remaining(&self.limiter, key, self.quota_per_minute); + Ok(remaining) + } + Err(not_until) => { + let wait = not_until + .wait_time_from(self.clock.now()) + .as_secs() + .max(1); + Err(wait) + } + } + } +} + +/// Estimate how many more requests would be accepted right now without +/// consuming any tokens. +/// +/// `governor`'s `check_key_n` is non-destructive when it returns `Err` +/// (the cell is not modified on failure). We binary-search downward from +/// `quota` to find the largest `n` that would still be accepted. +fn estimate_remaining(limiter: &GovernorLimiter, key: IpAddr, quota: u32) -> u32 { + if quota == 0 { + return 0; + } + let mut lo = 0u32; + let mut hi = quota; + while lo < hi { + let mid = lo + (hi - lo + 1) / 2; + // SAFETY: mid >= 1 because lo starts at 0 and we add at least 1. + let n = NonZeroU32::new(mid).unwrap(); + // check_key_n does NOT modify state on Err — safe to probe. + if limiter.check_key_n(&key, n).is_ok() { + lo = mid; + } else { + hi = mid - 1; + } + } + lo +} + +// ── Layer ───────────────────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct RateLimitLayer { + store: Arc, + quota: u32, + window_secs: u64, +} + +impl RateLimitLayer { + pub fn new(store: Arc, quota: u32, window_secs: u64) -> Self { + Self { store, quota, window_secs } + } + + /// Convenience constructor using the default in-memory store. + pub fn in_memory(requests_per_minute: u32) -> Self { + let store = Arc::new(InMemoryStore::new(requests_per_minute)); + Self::new(store, requests_per_minute, 60) + } +} + +impl Layer for RateLimitLayer { + type Service = RateLimitMiddleware; + + fn layer(&self, inner: S) -> Self::Service { + RateLimitMiddleware { + inner, + store: Arc::clone(&self.store), + quota: self.quota, + window_secs: self.window_secs, + } + } +} + +// ── Middleware service ──────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct RateLimitMiddleware { + inner: S, + store: Arc, + quota: u32, + window_secs: u64, +} + +type BoxFuture = Pin + Send + 'static>>; + +impl Service> for RateLimitMiddleware +where + S: Service, Response = Response> + Send + Clone + 'static, + S::Future: Send + 'static, + S::Error: Into>, +{ + type Response = Response; + type Error = S::Error; + type Future = BoxFuture>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let store = Arc::clone(&self.store); + let quota = self.quota; + let window_secs = self.window_secs; + let client_ip = extract_ip(&req); + let mut inner = self.inner.clone(); + + Box::pin(async move { + match store.check(client_ip) { + Ok(remaining) => { + let mut resp = inner.call(req).await?; + let h = resp.headers_mut(); + h.insert("x-ratelimit-limit", quota.to_string().parse().unwrap()); + h.insert("x-ratelimit-remaining", remaining.to_string().parse().unwrap()); + h.insert("x-ratelimit-reset", window_secs.to_string().parse().unwrap()); + Ok(resp) + } + Err(wait_secs) => { + let body = json!({ + "error": "Rate limit exceeded. Please try again later." + }) + .to_string(); + + let resp = Response::builder() + .status(StatusCode::TOO_MANY_REQUESTS) + .header("content-type", "application/json") + .header("x-ratelimit-limit", quota.to_string()) + .header("x-ratelimit-remaining", "0") + .header("x-ratelimit-reset", wait_secs.to_string()) + .body(Body::from(body)) + .unwrap(); + + Ok(resp) + } + } + }) + } +} + +// ── IP extraction ───────────────────────────────────────────────────────────── + +fn extract_ip(req: &Request) -> IpAddr { + // 1. X-Forwarded-For (first entry — closest client behind a proxy) + if let Some(xff) = req.headers().get("x-forwarded-for") { + if let Ok(val) = xff.to_str() { + if let Some(first) = val.split(',').next() { + if let Ok(ip) = first.trim().parse::() { + return ip; + } + } + } + } + + // 2. ConnectInfo set by axum::serve (requires make_into_service_with_connect_info) + if let Some(ConnectInfo(addr)) = req + .extensions() + .get::>() + { + return addr.ip(); + } + + // 3. Fallback: loopback (e.g. in tests without ConnectInfo) + IpAddr::from([127, 0, 0, 1]) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use axum::{routing::get, Router}; + use axum_test::TestServer; + + async fn ok_handler() -> &'static str { + "ok" + } + + fn test_app(limit: u32) -> TestServer { + let app = Router::new() + .route("/", get(ok_handler)) + .layer(RateLimitLayer::in_memory(limit)); + TestServer::new(app).unwrap() + } + + /// Sends `limit` requests (all must succeed) then asserts the next one + /// returns 429 with the correct JSON body and headers. + #[tokio::test] + async fn test_rate_limiting_trigger() { + let limit = 5u32; + let server = test_app(limit); + + for i in 0..limit { + let resp = server.get("/").await; + assert_eq!( + resp.status_code(), + StatusCode::OK, + "request {i} should be allowed" + ); + assert!( + resp.headers().contains_key("x-ratelimit-limit"), + "missing X-RateLimit-Limit on request {i}" + ); + assert!( + resp.headers().contains_key("x-ratelimit-remaining"), + "missing X-RateLimit-Remaining on request {i}" + ); + assert!( + resp.headers().contains_key("x-ratelimit-reset"), + "missing X-RateLimit-Reset on request {i}" + ); + } + + // The (limit + 1)th request must be rejected. + let resp = server.get("/").await; + assert_eq!( + resp.status_code(), + StatusCode::TOO_MANY_REQUESTS, + "request {} should be rate-limited (429)", + limit + 1 + ); + assert_eq!( + resp.headers() + .get("x-ratelimit-remaining") + .and_then(|v| v.to_str().ok()), + Some("0"), + "X-RateLimit-Remaining should be 0 on a 429" + ); + let body: serde_json::Value = resp.json(); + assert_eq!( + body["error"], + "Rate limit exceeded. Please try again later." + ); + } +} From 450919fdea7bf3c9ef9631d8f64f0bce1f69b1f2 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 30 Mar 2026 06:01:14 +0100 Subject: [PATCH 2/2] feat(contract): implement m-of-n oracle consensus logic --- contracts/pifp_protocol/src/errors.rs | 9 + contracts/pifp_protocol/src/events.rs | 337 ++++---------- contracts/pifp_protocol/src/fuzz_test.rs | 135 ++---- contracts/pifp_protocol/src/lib.rs | 424 ++++++++---------- contracts/pifp_protocol/src/rbac.rs | 6 + contracts/pifp_protocol/src/storage.rs | 43 +- contracts/pifp_protocol/src/test.rs | 44 +- contracts/pifp_protocol/src/test_deadline.rs | 189 +++----- .../pifp_protocol/src/test_donation_count.rs | 124 ++--- contracts/pifp_protocol/src/test_errors.rs | 106 +---- contracts/pifp_protocol/src/test_events.rs | 86 +--- .../pifp_protocol/src/test_multi_oracle.rs | 176 ++++++++ .../pifp_protocol/src/test_protocol_config.rs | 130 ++---- contracts/pifp_protocol/src/test_refund.rs | 224 +++------ contracts/pifp_protocol/src/test_utils.rs | 26 +- contracts/pifp_protocol/src/test_whitelist.rs | 139 +++--- contracts/pifp_protocol/src/types.rs | 28 ++ 17 files changed, 902 insertions(+), 1324 deletions(-) create mode 100644 contracts/pifp_protocol/src/test_multi_oracle.rs diff --git a/contracts/pifp_protocol/src/errors.rs b/contracts/pifp_protocol/src/errors.rs index b8c9c13..ca0d38c 100644 --- a/contracts/pifp_protocol/src/errors.rs +++ b/contracts/pifp_protocol/src/errors.rs @@ -142,4 +142,13 @@ pub enum Error { /// The proposed fee in basis points exceeds the hard cap of 10 000 (= 100 %). FeeBpsExceedsMaximum = 27, + + /// The calling oracle is not in the project's `authorized_oracles` list. + UnauthorizedOracle = 28, + + /// The oracle threshold has already been met; funds have been released. + ThresholdAlreadyMet = 29, + + /// The `authorized_oracles` list is empty or threshold is zero/exceeds oracle count. + InvalidOracleConfig = 30, } diff --git a/contracts/pifp_protocol/src/events.rs b/contracts/pifp_protocol/src/events.rs index d7094eb..364fa16 100644 --- a/contracts/pifp_protocol/src/events.rs +++ b/contracts/pifp_protocol/src/events.rs @@ -1,14 +1,10 @@ -#![allow(deprecated)] +//! On-chain event definitions and emission helpers for the PIFP protocol. -use soroban_sdk::{contractevent, contracttype, symbol_short, Address, BytesN, Env}; +use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env}; + +use crate::types::ProtocolConfig; -#[contractevent] -// ── Event Data Structs ────────────────────────────────────────────── -// -// Each event uses a dedicated struct so that indexers can decode every -// field by name rather than relying on positional tuple elements. -// Topic layout: (event_symbol, project_id) for project-scoped events, -// (event_symbol, caller) for protocol-level events. +// ── Event data structs ──────────────────────────────────────────────────────── #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -19,26 +15,30 @@ pub struct ProjectCreated { pub goal: i128, } -#[contractevent] +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct ProjectFunded { pub project_id: u64, pub donator: Address, pub amount: i128, } -#[contractevent] +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct ProjectActive { pub project_id: u64, } -#[contractevent] +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct ProjectVerified { pub project_id: u64, pub oracle: Address, pub proof_hash: BytesN<32>, } -#[contractevent] +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct ProjectExpired { pub project_id: u64, pub deadline: u64, @@ -46,35 +46,22 @@ pub struct ProjectExpired { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct DeadlineExtended { +pub struct ProjectCancelled { pub project_id: u64, - pub old_deadline: u64, - pub new_deadline: u64, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProtocolConfigUpdated { - pub old_fee_recipient: Option
, - pub old_fee_bps: u32, - pub new_fee_recipient: Address, - pub new_fee_bps: u32, + pub cancelled_by: Address, } -use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env}; - #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProjectCreated { +pub struct FundsReleased { pub project_id: u64, - pub creator: Address, pub token: Address, - pub goal: i128, + pub amount: i128, } #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProjectFunded { +pub struct Refunded { pub project_id: u64, pub donator: Address, pub amount: i128, @@ -82,23 +69,23 @@ pub struct ProjectFunded { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProjectActive { +pub struct ExpiredFundsReclaimed { pub project_id: u64, + pub creator: Address, + pub token: Address, + pub amount: i128, } #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProjectVerified { - pub project_id: u64, - pub oracle: Address, - pub proof_hash: BytesN<32>, +pub struct ProtocolPaused { + pub admin: Address, } #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProjectExpired { - pub project_id: u64, - pub deadline: u64, +pub struct ProtocolUnpaused { + pub admin: Address, } #[contracttype] @@ -141,286 +128,150 @@ pub struct WhitelistRemoved { pub address: Address, } +/// Emitted each time an oracle casts a vote via `verify_and_release`. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProjectCancelled { - pub project_id: u64, - pub cancelled_by: Address, -} - -#[contractevent] -pub struct FundsReleased { +pub struct OracleVoted { pub project_id: u64, - pub token: Address, - pub amount: i128, + pub oracle: Address, + /// Bit index of this oracle in the project's authorized list. + pub oracle_index: u32, + /// Running count of unique votes after this one. + pub voter_count: u32, + /// Threshold required to release funds. + pub threshold: u32, } -#[contractevent] -/// Structured refund event data (previously emitted as a bare tuple). +/// Emitted when an oracle is added to a project's authorized list. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct Refunded { +pub struct OracleAdded { pub project_id: u64, - pub donator: Address, - pub amount: i128, + pub oracle: Address, } -#[contractevent] -/// Event data emitted when a creator reclaims unclaimed donor funds -/// after the refund window has expired. +/// Emitted when an oracle is removed from a project's authorized list. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct ExpiredFundsReclaimed { +pub struct OracleRemoved { pub project_id: u64, - pub creator: Address, - pub token: Address, - pub amount: i128, -} - -/// Event data for protocol pause / unpause. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProtocolPaused { - pub admin: Address, -} - -#[contractevent] -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProtocolUnpaused { - pub admin: Address, + pub oracle: Address, } -// ── Emission helpers ──────────────────────────────────────────────── +// ── Emission helpers ────────────────────────────────────────────────────────── -pub fn emit_project_created( - env: &Env, - project_id: u64, - creator: Address, - token: Address, - goal: i128, -) { - ProjectCreated { - project_id, - creator, - token, - goal, - } - .publish(env); +pub fn emit_project_created(env: &Env, project_id: u64, creator: Address, token: Address, goal: i128) { + let topics = (symbol_short!("created"), project_id); + env.events().publish(topics, ProjectCreated { project_id, creator, token, goal }); } pub fn emit_project_funded(env: &Env, project_id: u64, donator: Address, amount: i128) { - ProjectFunded { - project_id, - donator, - amount, - } - .publish(env); + let topics = (symbol_short!("funded"), project_id); + env.events().publish(topics, ProjectFunded { project_id, donator, amount }); } pub fn emit_project_active(env: &Env, project_id: u64) { - ProjectActive { project_id }.publish(env); + let topics = (symbol_short!("active"), project_id); + env.events().publish(topics, ProjectActive { project_id }); } pub fn emit_project_verified(env: &Env, project_id: u64, oracle: Address, proof_hash: BytesN<32>) { - ProjectVerified { - project_id, - oracle, - proof_hash, - } - .publish(env); + let topics = (symbol_short!("verified"), project_id); + env.events().publish(topics, ProjectVerified { project_id, oracle, proof_hash }); } pub fn emit_project_expired(env: &Env, project_id: u64, deadline: u64) { - ProjectExpired { - project_id, - deadline, - } - .publish(env); + let topics = (symbol_short!("expired"), project_id); + env.events().publish(topics, ProjectExpired { project_id, deadline }); } pub fn emit_project_cancelled(env: &Env, project_id: u64, cancelled_by: Address) { let topics = (symbol_short!("cancelled"), project_id); - let data = ProjectCancelled { - project_id, - cancelled_by, - }; - env.events().publish(topics, data); + env.events().publish(topics, ProjectCancelled { project_id, cancelled_by }); } pub fn emit_funds_released(env: &Env, project_id: u64, token: Address, amount: i128) { - FundsReleased { - project_id, - token, - amount, - } - .publish(env); + let topics = (symbol_short!("released"), project_id); + env.events().publish(topics, FundsReleased { project_id, token, amount }); } pub fn emit_refunded(env: &Env, project_id: u64, donator: Address, amount: i128) { - Refunded { - project_id, - donator, - amount, - } - .publish(env); -} - -pub fn emit_protocol_paused(env: &Env, admin: Address) { - ProtocolPaused { admin }.publish(env); -} - -pub fn emit_protocol_unpaused(env: &Env, admin: Address) { - ProtocolUnpaused { admin }.publish(env); let topics = (symbol_short!("refunded"), project_id); - let data = Refunded { - project_id, - donator, - amount, - }; - env.events().publish(topics, data); + env.events().publish(topics, Refunded { project_id, donator, amount }); } -pub fn emit_expired_funds_reclaimed( - env: &Env, - project_id: u64, - creator: Address, - token: Address, - amount: i128, -) { - let topics = (symbol_short!("reclaim"), project_id, token.clone()); - let data = ExpiredFundsReclaimed { - project_id, - creator, - token, - amount, - }; - env.events().publish(topics, data); +pub fn emit_expired_funds_reclaimed(env: &Env, project_id: u64, creator: Address, token: Address, amount: i128) { + let topics = (symbol_short!("reclaim"), project_id); + env.events().publish(topics, ExpiredFundsReclaimed { project_id, creator, token, amount }); } pub fn emit_protocol_paused(env: &Env, admin: Address) { - let topics = (symbol_short!("paused"), admin.clone()); - let data = ProtocolPaused { admin }; - env.events().publish(topics, data); + let topics = (symbol_short!("paused"),); + env.events().publish(topics, ProtocolPaused { admin }); } pub fn emit_protocol_unpaused(env: &Env, admin: Address) { - let topics = (symbol_short!("unpaused"), admin.clone()); - let data = ProtocolUnpaused { admin }; - env.events().publish(topics, data); + let topics = (symbol_short!("unpaused"),); + env.events().publish(topics, ProtocolUnpaused { admin }); } -pub fn emit_deadline_extended( - env: &Env, - project_id: u64, - old_deadline: u64, - new_deadline: u64, -) { +pub fn emit_deadline_extended(env: &Env, project_id: u64, old_deadline: u64, new_deadline: u64) { let topics = (symbol_short!("ext_dead"), project_id); - let data = DeadlineExtended { - project_id, - old_deadline, - new_deadline, - }; - env.events().publish(topics, data); + env.events().publish(topics, DeadlineExtended { project_id, old_deadline, new_deadline }); } -pub fn emit_protocol_config_updated( - env: &Env, - old_config: Option, - new_config: ProtocolConfig, -) { +pub fn emit_protocol_config_updated(env: &Env, old_config: Option, new_config: ProtocolConfig) { let topics = (symbol_short!("cfg_upd"),); - let data = ProtocolConfigUpdated { + env.events().publish(topics, ProtocolConfigUpdated { old_fee_recipient: old_config.as_ref().map(|c| c.fee_recipient.clone()), old_fee_bps: old_config.map(|c| c.fee_bps).unwrap_or(0), new_fee_recipient: new_config.fee_recipient, new_fee_bps: new_config.fee_bps, - }; - env.events().publish(topics, data); + }); } pub fn emit_fee_deducted(env: &Env, project_id: u64, token: Address, amount: i128, recipient: Address) { - let topics = (symbol_short!("fee_ded"), project_id, token.clone()); - let data = FeeDeducted { - project_id, - token, - amount, - recipient, - }; - env.events().publish(topics, data); + let topics = (symbol_short!("fee_ded"), project_id); + env.events().publish(topics, FeeDeducted { project_id, token, amount, recipient }); } pub fn emit_whitelist_added(env: &Env, project_id: u64, address: Address) { let topics = (symbol_short!("wl_add"), project_id); - let data = WhitelistAdded { - project_id, - address, - }; - env.events().publish(topics, data); + env.events().publish(topics, WhitelistAdded { project_id, address }); } pub fn emit_whitelist_removed(env: &Env, project_id: u64, address: Address) { let topics = (symbol_short!("wl_rem"), project_id); - let data = WhitelistRemoved { - project_id, - address, - }; - env.events().publish(topics, data); + env.events().publish(topics, WhitelistRemoved { project_id, address }); } -pub fn emit_deadline_extended( +/// Emitted each time an oracle submits a vote (before or at threshold). +pub fn emit_oracle_voted( env: &Env, project_id: u64, - old_deadline: u64, - new_deadline: u64, + oracle: Address, + oracle_index: u32, + voter_count: u32, + threshold: u32, ) { - let topics = (symbol_short!("ext_dead"), project_id); - let data = DeadlineExtended { + let topics = (symbol_short!("orc_vote"), project_id); + env.events().publish(topics, OracleVoted { project_id, - old_deadline, - new_deadline, - }; - env.events().publish(topics, data); -} - -pub fn emit_protocol_config_updated( - env: &Env, - old_config: Option, - new_config: ProtocolConfig, -) { - let topics = (symbol_short!("cfg_upd"),); - let data = ProtocolConfigUpdated { - old_fee_recipient: old_config.as_ref().map(|c| c.fee_recipient.clone()), - old_fee_bps: old_config.map(|c| c.fee_bps).unwrap_or(0), - new_fee_recipient: new_config.fee_recipient, - new_fee_bps: new_config.fee_bps, - }; - env.events().publish(topics, data); + oracle, + oracle_index, + voter_count, + threshold, + }); } -pub fn emit_fee_deducted(env: &Env, project_id: u64, token: Address, amount: i128, recipient: Address) { - let topics = (symbol_short!("fee_ded"), project_id, token.clone()); - let data = FeeDeducted { - project_id, - token, - amount, - recipient, - }; - env.events().publish(topics, data); +/// Emitted when an oracle is added to a project's authorized list. +pub fn emit_oracle_added(env: &Env, project_id: u64, oracle: Address) { + let topics = (symbol_short!("orc_add"), project_id); + env.events().publish(topics, OracleAdded { project_id, oracle }); } -pub fn emit_deadline_extended( - env: &Env, - project_id: u64, - old_deadline: u64, - new_deadline: u64, -) { - let topics = (symbol_short!("ext_dead"), project_id); - let data = DeadlineExtended { - project_id, - old_deadline, - new_deadline, - }; - env.events().publish(topics, data); +/// Emitted when an oracle is removed from a project's authorized list. +pub fn emit_oracle_removed(env: &Env, project_id: u64, oracle: Address) { + let topics = (symbol_short!("orc_rem"), project_id); + env.events().publish(topics, OracleRemoved { project_id, oracle }); } diff --git a/contracts/pifp_protocol/src/fuzz_test.rs b/contracts/pifp_protocol/src/fuzz_test.rs index eff54e2..8b46c74 100644 --- a/contracts/pifp_protocol/src/fuzz_test.rs +++ b/contracts/pifp_protocol/src/fuzz_test.rs @@ -30,6 +30,29 @@ fn dummy_metadata_uri(env: &Env) -> Bytes { Bytes::from_slice(env, b"bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") } +fn register<'a>( + env: &Env, + client: &PifpProtocolClient<'a>, + creator: &Address, + tokens: &SorobanVec
, + goal: i128, + proof_hash: &BytesN<32>, + deadline: u64, +) -> crate::types::Project { + let empty_oracles: SorobanVec
= SorobanVec::new(env); + client.register_project( + creator, + tokens, + &goal, + proof_hash, + &dummy_metadata_uri(env), + &deadline, + &false, + &empty_oracles, + &0u32, + ) +} + // ── 1. Registration Fuzz Tests ────────────────────────────────────── proptest! { @@ -49,17 +72,7 @@ proptest! { let mut tokens = SorobanVec::new(&env); tokens.push_back(token.address.clone()); - let project = client.register_project( - &creator, - &tokens, - &goal, - &proof_hash, - &dummy_metadata_uri(&env), - &deadline, - ); - - check_all_project_invariants(&env, &project); - assert_eq!(project.goal, goal); + let project = register(&env, &client, &creator, &tokens, goal, &proof_hash, deadline); assert_eq!(project.status, ProjectStatus::Funding); } @@ -77,14 +90,7 @@ proptest! { let mut tokens = SorobanVec::new(&env); tokens.push_back(token.address.clone()); - let project = client.register_project( - &creator, - &tokens, - &100, - &proof_hash, - &dummy_metadata_uri(&env), - &deadline, - ); + let project = register(&env, &client, &creator, &tokens, 100, &proof_hash, deadline); check_all_project_invariants(&env, &project); assert_eq!(project.deadline, deadline); @@ -104,14 +110,7 @@ proptest! { let mut tokens = SorobanVec::new(&env); tokens.push_back(token.address.clone()); - let project = client.register_project( - &creator, - &tokens, - &1000, - &proof_hash, - &dummy_metadata_uri(&env), - &deadline, - ); + let project = register(&env, &client, &creator, &tokens, 1000, &proof_hash, deadline); check_all_project_invariants(&env, &project); assert_eq!(project.proof_hash, proof_hash); @@ -137,16 +136,7 @@ proptest! { let mut tokens = SorobanVec::new(&env); tokens.push_back(token_client.address.clone()); - let project = client.register_project( - &creator, - &tokens, - &100_000, - &proof_hash, - &dummy_metadata_uri(&env), - &deadline, - ); - - let donator = Address::generate(&env); + let project = register(&env, &client, &creator, &tokens, 100_000, &proof_hash, deadline); let sac = token::StellarAssetClient::new(&env, &token_client.address); sac.mint(&donator, &amount); @@ -175,16 +165,7 @@ proptest! { let mut tokens = SorobanVec::new(&env); tokens.push_back(token_client.address.clone()); - let project = client.register_project( - &creator, - &tokens, - &1_000_000, - &proof_hash, - &dummy_metadata_uri(&env), - &deadline, - ); - - let sac = token::StellarAssetClient::new(&env, &token_client.address); + let project = register(&env, &client, &creator, &tokens, 1_000_000, &proof_hash, deadline); let mut expected_balance: i128 = 0; for amount in &amounts { @@ -232,14 +213,7 @@ proptest! { let mut tokens = SorobanVec::new(&env); tokens.push_back(token.address.clone()); - let project = client.register_project( - &creator, - &tokens, - &500, - &proof_hash, - &dummy_metadata_uri(&env), - &deadline, - ); + let project = register(&env, &client, &creator, &tokens, 500, &proof_hash, deadline); let oracle = Address::generate(&env); client.set_oracle(&admin, &oracle); @@ -265,14 +239,7 @@ proptest! { let mut tokens = SorobanVec::new(&env); tokens.push_back(token.address.clone()); - let project = client.register_project( - &creator, - &tokens, - &500, - &proof_hash, - &dummy_metadata_uri(&env), - &deadline, - ); + let project = register(&env, &client, &creator, &tokens, 500, &proof_hash, deadline); let oracle = Address::generate(&env); client.set_oracle(&admin, &oracle); @@ -306,14 +273,7 @@ proptest! { let creator = Address::generate(&env); client.grant_role(&admin, &creator, &Role::ProjectManager); - let p = client.register_project( - &creator, - &tokens, - &1000, - &proof_hash, - &dummy_metadata_uri(&env), - &deadline, - ); + let p = register(&env, &client, &creator, &tokens, 1000, &proof_hash, deadline); projects.push(p); } @@ -344,16 +304,7 @@ proptest! { let mut tokens = SorobanVec::new(&env); tokens.push_back(token_client.address.clone()); - let original = client.register_project( - &creator, - &tokens, - &100_000, - &proof_hash, - &dummy_metadata_uri(&env), - &deadline, - ); - - let donator = Address::generate(&env); + let original = register(&env, &client, &creator, &tokens, 100_000, &proof_hash, deadline); let sac = token::StellarAssetClient::new(&env, &token_client.address); sac.mint(&donator, &amount); client.deposit(&original.id, &donator, &token_client.address, &amount); @@ -378,18 +329,7 @@ proptest! { let mut tokens = SorobanVec::new(&env); tokens.push_back(token.address.clone()); - let original = client.register_project( - &creator, - &tokens, - &500, - &proof_hash, - &dummy_metadata_uri(&env), - &deadline, - ); - - let oracle = Address::generate(&env); - client.set_oracle(&admin, &oracle); - client.verify_and_release(&oracle, &original.id, &proof_hash); + let original = register(&env, &client, &creator, &tokens, 500, &proof_hash, deadline); let after = client.get_project(&original.id); check_inv10_config_immutable(&original, &after); @@ -421,14 +361,7 @@ proptest! { tokens.push_back(token_client.address.clone()); // Phase 1: Register project. - let project = client.register_project( - &creator, - &tokens, - &goal, - &proof_hash, - &dummy_metadata_uri(&env), - &deadline, - ); + let project = register(&env, &client, &creator, &tokens, goal, &proof_hash, deadline); check_all_project_invariants(&env, &project); assert_eq!(project.status, ProjectStatus::Funding); diff --git a/contracts/pifp_protocol/src/lib.rs b/contracts/pifp_protocol/src/lib.rs index b385c52..fd7eaa7 100644 --- a/contracts/pifp_protocol/src/lib.rs +++ b/contracts/pifp_protocol/src/lib.rs @@ -1,40 +1,31 @@ //! # PIFP Protocol Contract //! -//! This is the root crate of the **Proof-of-Impact Funding Protocol (PIFP)**. -//! It exposes the single Soroban contract `PifpProtocol` whose entry points cover -//! the full project lifecycle: +//! Proof-of-Impact Funding Protocol — Soroban smart contract. //! -//! | Phase | Entry Point(s) | -//! |--------------|---------------------------------------------| -//! | Bootstrap | [`PifpProtocol::init`] | -//! | Role admin | `grant_role`, `revoke_role`, `transfer_super_admin`, `set_oracle` | -//! | Registration | [`PifpProtocol::register_project`] | -//! | Funding | [`PifpProtocol::deposit`] | -//! | Donor safety | [`PifpProtocol::refund`] | -//! | Verification | [`PifpProtocol::verify_and_release`] | -//! | Queries | `get_project`, `get_project_balances`, `role_of`, `has_role` | -//! -//! ## Architecture -//! -//! Authorization is fully delegated to [`rbac`]. Storage access is fully -//! delegated to [`storage`]. This file contains **only** the public entry -//! points and event emissions — no business logic lives here directly. -//! -//! See [`ARCHITECTURE.md`](../../../../ARCHITECTURE.md) for the full system -//! architecture and threat model. +//! | Phase | Entry Point(s) | +//! |--------------|---------------------------------------------------------| +//! | Bootstrap | [`PifpProtocol::init`] | +//! | Role admin | `grant_role`, `revoke_role`, `transfer_super_admin` | +//! | Oracle mgmt | `add_oracle`, `remove_oracle`, `set_oracle` | +//! | Registration | [`PifpProtocol::register_project`] | +//! | Funding | [`PifpProtocol::deposit`] | +//! | Donor safety | [`PifpProtocol::refund`] | +//! | Verification | [`PifpProtocol::verify_and_release`] | +//! | Queries | `get_project`, `get_project_balances`, `role_of`, etc. | #![no_std] use soroban_sdk::{contract, contractimpl, panic_with_error, token, Address, Bytes, BytesN, Env, Vec}; -/// Refund window: 6 months (in seconds) after a project enters a terminal -/// refundable state (Expired or Cancelled). Donors must claim refunds within -/// this window; after it passes, the creator may reclaim unclaimed funds. -const REFUND_WINDOW: u64 = 6 * 30 * 24 * 60 * 60; // 15_552_000 seconds +/// Refund window: 6 months after a project enters a terminal refundable state. +pub const REFUND_WINDOW: u64 = 6 * 30 * 24 * 60 * 60; /// Maximum allowed length for a project metadata URI / CID. const MAX_METADATA_URI_LEN: u32 = 64; +/// Maximum number of authorized oracles per project (fits in a u32 BitSet). +const MAX_ORACLES: u32 = 32; + pub mod errors; pub mod events; pub mod invariants_checker; @@ -46,7 +37,6 @@ mod types; mod fuzz_test; #[cfg(test)] mod rbac_test; - #[cfg(test)] mod test; #[cfg(test)] @@ -64,36 +54,23 @@ mod test_reclaim; #[cfg(test)] mod test_deadline; #[cfg(test)] -mod test_deadline; -#[cfg(test)] -mod test_deadline; -#[cfg(test)] -mod test_errors; -#[cfg(test)] mod test_protocol_config; #[cfg(test)] mod test_whitelist; +#[cfg(test)] +mod test_multi_oracle; mod test_utils; pub use errors::Error; pub use events::emit_funds_released; pub use rbac::Role; use storage::{ - drain_token_balance, get_all_balances, get_and_increment_project_id, load_project, - load_project_pair, maybe_load_project, save_project, save_project_config, save_project_state, - drain_token_balance, get_all_balances, get_and_increment_project_id, get_protocol_config, - load_project, load_project_pair, maybe_load_project, save_project, save_project_config, - save_project_state, set_protocol_config, -}; -pub use types::{Project, ProjectBalances, ProjectConfig, ProjectState, ProtocolConfig}; - - - add_to_whitelist, drain_token_balance, get_all_balances, get_and_increment_project_id, - get_protocol_config, is_whitelisted, load_project, load_project_pair, maybe_load_project, - remove_from_whitelist, save_project, save_project_config, save_project_state, - set_protocol_config, + add_to_whitelist, clear_oracle_agreement, drain_token_balance, get_all_balances, + get_and_increment_project_id, get_protocol_config, is_whitelisted, load_oracle_agreement, + load_project, load_project_pair, maybe_load_project, remove_from_whitelist, save_oracle_agreement, + save_project, save_project_config, save_project_state, set_protocol_config, }; -pub use types::{Project, ProjectBalances, ProjectConfig, ProjectState, ProtocolConfig}; +pub use types::{OracleAgreement, Project, ProjectBalances, ProjectConfig, ProjectState, ProjectStatus, ProtocolConfig}; #[contract] pub struct PifpProtocol; @@ -105,11 +82,6 @@ impl PifpProtocol { // ───────────────────────────────────────────────────────── /// Initialise the contract and set the first SuperAdmin. - /// - /// Must be called exactly once immediately after deployment. - /// Subsequent calls panic with `Error::AlreadyInitialized`. - /// - /// - `super_admin` is granted the `SuperAdmin` role and must sign the transaction. pub fn init(env: Env, super_admin: Address) { super_admin.require_auth(); rbac::init_super_admin(&env, &super_admin); @@ -119,36 +91,22 @@ impl PifpProtocol { // Role management // ───────────────────────────────────────────────────────── - /// Grant `role` to `target`. - /// - /// - `caller` must hold `SuperAdmin` or `Admin`. - /// - Only `SuperAdmin` can grant `SuperAdmin`. pub fn grant_role(env: Env, caller: Address, target: Address, role: Role) { rbac::grant_role(&env, &caller, &target, role); } - /// Revoke any role from `target`. - /// - /// - `caller` must hold `SuperAdmin` or `Admin`. - /// - Cannot be used to remove the SuperAdmin; use `transfer_super_admin`. pub fn revoke_role(env: Env, caller: Address, target: Address) { rbac::revoke_role(&env, &caller, &target); } - /// Transfer SuperAdmin to `new_super_admin`. - /// - /// - `current_super_admin` must authorize and hold the `SuperAdmin` role. - /// - The previous SuperAdmin loses the role immediately. pub fn transfer_super_admin(env: Env, current_super_admin: Address, new_super_admin: Address) { rbac::transfer_super_admin(&env, ¤t_super_admin, &new_super_admin); } - /// Return the role held by `address`, or `None`. pub fn role_of(env: Env, address: Address) -> Option { rbac::role_of(&env, address) } - /// Return `true` if `address` holds `role`. pub fn has_role(env: Env, address: Address, role: Role) -> bool { rbac::has_role(&env, address, role) } @@ -157,9 +115,6 @@ impl PifpProtocol { // Emergency Control // ───────────────────────────────────────────────────────── - /// Pause the protocol, halting all registrations, deposits, and releases. - /// - /// - `caller` must hold `SuperAdmin` or `Admin`. pub fn pause(env: Env, caller: Address) { caller.require_auth(); rbac::require_admin_or_above(&env, &caller); @@ -167,9 +122,6 @@ impl PifpProtocol { events::emit_protocol_paused(&env, caller); } - /// Unpause the protocol. - /// - /// - `caller` must hold `SuperAdmin` or `Admin`. pub fn unpause(env: Env, caller: Address) { caller.require_auth(); rbac::require_admin_or_above(&env, &caller); @@ -177,18 +129,87 @@ impl PifpProtocol { events::emit_protocol_unpaused(&env, caller); } - /// Return true if the protocol is paused. pub fn is_paused(env: Env) -> bool { storage::is_paused(&env) } + // ───────────────────────────────────────────────────────── + // Oracle management (per-project M-of-N) + // ───────────────────────────────────────────────────────── + + /// Add an oracle to a project's authorized oracle list. + /// + /// - `admin` must hold `SuperAdmin` or `Admin`. + /// - Maximum 32 oracles per project. + /// - Adding an oracle resets any in-flight `OracleAgreement` to prevent + /// stale bits from a previous oracle at the same index from counting. + pub fn add_oracle(env: Env, admin: Address, project_id: u64, oracle: Address) { + admin.require_auth(); + rbac::require_admin_or_above(&env, &admin); + + let mut config = storage::load_project_config(&env, project_id); + + if config.authorized_oracles.len() >= MAX_ORACLES { + panic_with_error!(&env, Error::InvalidOracleConfig); + } + + // Idempotent: skip if already present. + for existing in config.authorized_oracles.iter() { + if existing == oracle { + return; + } + } + + config.authorized_oracles.push_back(oracle.clone()); + save_project_config(&env, project_id, &config); + + // Reset in-flight agreement — index layout has changed. + clear_oracle_agreement(&env, project_id); + + events::emit_oracle_added(&env, project_id, oracle); + } + + /// Remove an oracle from a project's authorized oracle list. + /// + /// - `admin` must hold `SuperAdmin` or `Admin`. + /// - Resets the in-flight `OracleAgreement` so no stale bit remains. + pub fn remove_oracle(env: Env, admin: Address, project_id: u64, oracle: Address) { + admin.require_auth(); + rbac::require_admin_or_above(&env, &admin); + + let mut config = storage::load_project_config(&env, project_id); + + let mut found = false; + let mut new_oracles: Vec
= Vec::new(&env); + for existing in config.authorized_oracles.iter() { + if existing == oracle { + found = true; + } else { + new_oracles.push_back(existing); + } + } + + if !found { + panic_with_error!(&env, Error::UnauthorizedOracle); + } + + config.authorized_oracles = new_oracles; + save_project_config(&env, project_id, &config); + + // Always reset agreement — bit indices have shifted. + clear_oracle_agreement(&env, project_id); + + events::emit_oracle_removed(&env, project_id, oracle); + } + // ───────────────────────────────────────────────────────── // Project lifecycle // ───────────────────────────────────────────────────────── - /// Register a new funding project. + /// Register a new funding project with M-of-N oracle verification. /// - /// `creator` must hold the `ProjectManager`, `Admin`, or `SuperAdmin` role. + /// `authorized_oracles` is the initial oracle set; `threshold` is M. + /// Both may be empty/zero to use the legacy single-oracle path via `set_oracle`. pub fn register_project( env: Env, creator: Address, @@ -198,10 +219,11 @@ impl PifpProtocol { metadata_uri: Bytes, deadline: u64, is_private: bool, + authorized_oracles: Vec
, + threshold: u32, ) -> Project { Self::require_not_paused(&env); creator.require_auth(); - // RBAC gate: only authorised roles may create projects. rbac::require_can_register(&env, &creator); if accepted_tokens.is_empty() { @@ -210,32 +232,32 @@ impl PifpProtocol { if accepted_tokens.len() > 10 { panic_with_error!(&env, Error::TooManyTokens); } - - // Check for duplicate tokens for i in 0..accepted_tokens.len() { let t_i = accepted_tokens.get(i).unwrap(); if accepted_tokens.last_index_of(&t_i) != Some(i) { panic_with_error!(&env, Error::DuplicateToken); } } - if goal <= 0 || goal > 1_000_000_000_000_000_000_000_000_000_000i128 { - // 10^30 panic_with_error!(&env, Error::InvalidGoal); } - let now = env.ledger().timestamp(); - // Metadata must be non-empty and fit within the supported CID/URI length. if metadata_uri.is_empty() || metadata_uri.len() > MAX_METADATA_URI_LEN { panic_with_error!(&env, Error::MetadataCidInvalid); } - - // Max 5 years deadline (5 * 365 * 24 * 60 * 60) let max_deadline = now + 157_680_000; if deadline <= now || deadline > max_deadline { panic_with_error!(&env, Error::InvalidDeadline); } + // Validate oracle config: if oracles are provided, threshold must be sane. + let oracle_count = authorized_oracles.len(); + if oracle_count > 0 { + if oracle_count > MAX_ORACLES || threshold == 0 || threshold > oracle_count { + panic_with_error!(&env, Error::InvalidOracleConfig); + } + } + let id = get_and_increment_project_id(&env); let project = Project { id, @@ -249,11 +271,12 @@ impl PifpProtocol { donation_count: 0, is_private, refund_expiry: 0, + authorized_oracles, + threshold, }; save_project(&env, &project); - // Standardized event emission if let Some(token) = accepted_tokens.get(0) { events::emit_project_created(&env, id, creator, token, goal); } @@ -261,12 +284,6 @@ impl PifpProtocol { project } - /// Extend a project's deadline. - /// - /// - `caller` must hold `ProjectManager`, `Admin`, or `SuperAdmin`. - /// - Project must be in `Funding` or `Active` state. - /// - New deadline must be later than the current one. - /// - Total extension cannot exceed 1 year from the current ledger time. pub fn extend_deadline(env: Env, caller: Address, project_id: u64, new_deadline: u64) { Self::require_not_paused(&env); caller.require_auth(); @@ -274,25 +291,18 @@ impl PifpProtocol { let (mut config, state) = load_project_pair(&env, project_id); - // State check: must be Funding or Active. match state.status { ProjectStatus::Funding | ProjectStatus::Active => {} _ => panic_with_error!(&env, Error::ProjectNotActive), } let now = env.ledger().timestamp(); - - // Ensure the project hasn't already expired by current time. if now >= config.deadline { panic_with_error!(&env, Error::ProjectExpired); } - - // New deadline must be in the future relative to current deadline. if new_deadline <= config.deadline { panic_with_error!(&env, Error::InvalidDeadline); } - - // Extension limit block: max 1 year (365 days) from now. let one_year_from_now = now + 31_536_000; if new_deadline > one_year_from_now { panic_with_error!(&env, Error::DeadlineTooLong); @@ -300,41 +310,27 @@ impl PifpProtocol { let old_deadline = config.deadline; config.deadline = new_deadline; - save_project_config(&env, project_id, &config); - events::emit_deadline_extended(&env, project_id, old_deadline, new_deadline); } - /// Add an address to a project's whitelist. - /// - /// - `caller` must be the project creator or an Admin. pub fn add_to_whitelist(env: Env, caller: Address, project_id: u64, address: Address) { caller.require_auth(); let config = storage::load_project_config(&env, project_id); - - // Auth check: creator or Admin/SuperAdmin if caller != config.creator { rbac::require_admin_or_above(&env, &caller); } - - storage::add_to_whitelist(&env, project_id, &address); + add_to_whitelist(&env, project_id, &address); events::emit_whitelist_added(&env, project_id, address); } - /// Remove an address from a project's whitelist. - /// - /// - `caller` must be the project creator or an Admin. pub fn remove_from_whitelist(env: Env, caller: Address, project_id: u64, address: Address) { caller.require_auth(); let config = storage::load_project_config(&env, project_id); - - // Auth check: creator or Admin/SuperAdmin if caller != config.creator { rbac::require_admin_or_above(&env, &caller); } - - storage::remove_from_whitelist(&env, project_id, &address); + remove_from_whitelist(&env, project_id, &address); events::emit_whitelist_removed(&env, project_id, address); } @@ -342,24 +338,15 @@ impl PifpProtocol { load_project(&env, id) } - /// Return the immutable metadata URI attached to a project. pub fn get_project_metadata(env: Env, project_id: u64) -> Bytes { let config = storage::load_project_config(&env, project_id); config.metadata_uri } - /// Return the balance of `token` for `project_id`. pub fn get_balance(env: Env, project_id: u64, token: Address) -> i128 { storage::get_token_balance(&env, project_id, &token) } - /// Return the current per-token balances for a project. - /// - /// Reconstructs the balance snapshot from persistent storage for every - /// token that was accepted at registration time. - /// - /// # Errors - /// Panics with `Error::ProjectNotFound` if `project_id` does not exist. pub fn get_project_balances(env: Env, project_id: u64) -> ProjectBalances { let project = match maybe_load_project(&env, project_id) { Some(p) => p, @@ -368,9 +355,6 @@ impl PifpProtocol { get_all_balances(&env, &project) } - /// Deposit funds into a project. - /// - /// The `token` must be one of the project's accepted tokens. pub fn deposit(env: Env, project_id: u64, donator: Address, token: Address, amount: i128) { Self::require_not_paused(&env); donator.require_auth(); @@ -379,12 +363,8 @@ impl PifpProtocol { panic_with_error!(&env, Error::InvalidAmount); } - // Read both config and state with a single helper that bumps TTLs - // atomically. This is the optimized retrieval pattern; it also returns - // the state needed for the subsequent checks. let (config, mut state) = load_project_pair(&env, project_id); - // Check expiration if env.ledger().timestamp() >= config.deadline { if matches!(state.status, ProjectStatus::Funding | ProjectStatus::Active) { state.status = ProjectStatus::Expired; @@ -394,21 +374,16 @@ impl PifpProtocol { panic_with_error!(&env, Error::ProjectExpired); } - // Whitelist check - if config.is_private { - if !is_whitelisted(&env, project_id, &donator) { - panic_with_error!(&env, Error::NotWhitelisted); - } + if config.is_private && !is_whitelisted(&env, project_id, &donator) { + panic_with_error!(&env, Error::NotWhitelisted); } - // Basic status check: must be Funding or Active. match state.status { ProjectStatus::Funding | ProjectStatus::Active => {} ProjectStatus::Expired => panic_with_error!(&env, Error::ProjectExpired), _ => panic_with_error!(&env, Error::ProjectNotActive), } - // Verify token is accepted. let mut found = false; for t in config.accepted_tokens.iter() { if t == token { @@ -420,27 +395,20 @@ impl PifpProtocol { panic_with_error!(&env, Error::TokenNotAccepted); } - // Check if this is a new unique (donator, token) pair. - // A donator balance of 0 implicitly proves they have not donated yet, saving a storage key entirely. let current_donor_balance = storage::get_donator_balance(&env, project_id, &token, &donator); let is_new_donor = current_donor_balance == 0; if is_new_donor { - // Increment donation count state.donation_count += 1; - // Save the updated state. save_project_state(&env, project_id, &state); } - // Transfer tokens from donator to contract. let token_client = token::Client::new(&env, &token); token_client.transfer(&donator, env.current_contract_address(), &amount); - // Update the per-token balance. let new_balance = storage::add_to_token_balance(&env, project_id, &token, amount); - // If this is the primary token and goal is reached, transition from Funding to Active. if state.status == ProjectStatus::Funding { if let Some(first_token) = config.accepted_tokens.get(0) { if token == first_token && new_balance >= config.goal { @@ -451,21 +419,14 @@ impl PifpProtocol { } } - // Track per-donator refundable amount for this token. let new_donor_balance = current_donor_balance .checked_add(amount) .expect("donator balance overflow"); storage::set_donator_balance(&env, project_id, &token, &donator, new_donor_balance); - // Standardized event emission events::emit_project_funded(&env, project_id, donator, amount); } - /// Mark an active project as cancelled. - /// - /// - `caller` must be `SuperAdmin` or `ProjectManager`. - /// - If `caller` is `ProjectManager`, it must be the project's creator. - /// - Only projects in `Active` status may be cancelled. pub fn cancel_project(env: Env, caller: Address, project_id: u64) { caller.require_auth(); rbac::require_can_cancel_project(&env, &caller); @@ -497,11 +458,6 @@ impl PifpProtocol { events::emit_project_cancelled(&env, project_id, caller); } - /// Refund a donator from a cancelled or expired project that was not verified. - /// - /// Donors must claim their refund within the 6-month refund window. - /// After the window expires, only the creator may reclaim unclaimed funds - /// via [`reclaim_expired_funds`]. pub fn refund(env: Env, donator: Address, project_id: u64, token: Address) { donator.require_auth(); @@ -515,14 +471,10 @@ impl PifpProtocol { save_project_state(&env, project_id, &state); } - if !matches!( - state.status, - ProjectStatus::Expired | ProjectStatus::Cancelled - ) { + if !matches!(state.status, ProjectStatus::Expired | ProjectStatus::Cancelled) { panic_with_error!(&env, Error::ProjectNotExpired); } - // Block refunds after the refund window has expired. if state.refund_expiry > 0 && env.ledger().timestamp() >= state.refund_expiry { panic_with_error!(&env, Error::RefundWindowExpired); } @@ -532,7 +484,6 @@ impl PifpProtocol { panic_with_error!(&env, Error::InsufficientBalance); } - // Zero-out first to prevent double-refund/reentrancy patterns. storage::set_donator_balance(&env, project_id, &token, &donator, 0); storage::add_to_token_balance(&env, project_id, &token, -refund_amount); @@ -543,20 +494,13 @@ impl PifpProtocol { events::emit_refunded(&env, project_id, donator, refund_amount); } - /// Grant the Oracle role to `oracle`. - /// - /// Replaces the original `set_oracle(admin, oracle)`. - /// - `caller` must hold `SuperAdmin` or `Admin`. + /// Grant the Oracle role globally (legacy single-oracle path). pub fn set_oracle(env: Env, caller: Address, oracle: Address) { caller.require_auth(); rbac::require_admin_or_above(&env, &caller); rbac::grant_role(&env, &caller, &oracle, Role::Oracle); } - /// Update the global protocol configuration. - /// - /// - `caller` must be the `SuperAdmin`. - /// - `fee_bps` must be less than or equal to 1000 (10%). pub fn update_protocol_config(env: Env, caller: Address, fee_recipient: Address, fee_bps: u32) { caller.require_auth(); rbac::require_super_admin(&env, &caller); @@ -566,26 +510,20 @@ impl PifpProtocol { } let old_config = get_protocol_config(&env); - let new_config = ProtocolConfig { - fee_recipient, - fee_bps, - }; - + let new_config = ProtocolConfig { fee_recipient, fee_bps }; set_protocol_config(&env, &new_config); - events::emit_protocol_config_updated(&env, old_config, new_config); } - /// Verify proof of impact and release funds to the creator. + /// Verify proof of impact and accumulate oracle votes using a BitSet. /// - /// The registered oracle submits a proof hash. If it matches the project's - /// stored `proof_hash`, the project status transitions to `Completed`. + /// Each authorized oracle calls this once. When `voter_count >= threshold` + /// the funds are released and the `OracleAgreement` is cleared. /// - /// NOTE: This is a mocked verification (hash equality). - /// The structure is prepared for future ZK-STARK verification. - /// - /// Reads the immutable config (for proof_hash) and mutable state (for status), - /// then writes back only the small state entry. + /// # BitSet mechanics + /// - Oracle at index `i` sets bit `i`: `votes |= 1 << i` + /// - Duplicate detection: if bit `i` is already set, `voter_count` is NOT incremented. + /// - On threshold: payout fires, agreement storage is cleared. pub fn verify_and_release( env: Env, oracle: Address, @@ -594,12 +532,10 @@ impl PifpProtocol { ) { Self::require_not_paused(&env); oracle.require_auth(); - // RBAC gate: caller must hold the Oracle role. - rbac::require_oracle(&env, &oracle); - // Optimised dual-read helper let (config, mut state) = load_project_pair(&env, project_id); + // Expiry check. if env.ledger().timestamp() >= config.deadline && matches!(state.status, ProjectStatus::Funding | ProjectStatus::Active) { @@ -609,41 +545,88 @@ impl PifpProtocol { panic_with_error!(&env, Error::ProjectExpired); } - // Ensure the project is in a verifiable state. match state.status { ProjectStatus::Funding | ProjectStatus::Active => {} - ProjectStatus::Completed => panic_with_error!(&env, Error::MilestoneAlreadyReleased), + ProjectStatus::Completed => panic_with_error!(&env, Error::ThresholdAlreadyMet), ProjectStatus::Expired => panic_with_error!(&env, Error::ProjectExpired), ProjectStatus::Cancelled => panic_with_error!(&env, Error::InvalidTransition), } - // Mocked ZK verification: compare submitted hash to stored hash. + // Proof hash check. if submitted_proof_hash != config.proof_hash { panic_with_error!(&env, Error::VerificationFailed); } - // Transition to Completed — only write the state entry. + // ── M-of-N path ────────────────────────────────────────────────────── + // If the project has an authorized oracle list, use BitSet tracking. + // Otherwise fall back to the legacy single-oracle (RBAC Oracle role) path. + if !config.authorized_oracles.is_empty() { + // Find the calling oracle's index in the authorized list. + let mut oracle_index: Option = None; + for (i, authorized) in config.authorized_oracles.iter().enumerate() { + if authorized == oracle { + oracle_index = Some(i as u32); + break; + } + } + + let oracle_index = match oracle_index { + Some(idx) => idx, + None => panic_with_error!(&env, Error::UnauthorizedOracle), + }; + + // Load (or default-initialize) the in-flight agreement. + let mut agreement = load_oracle_agreement(&env, project_id); + + let bit = 1u32 << oracle_index; + let already_voted = (agreement.votes & bit) != 0; + + // Set the bit unconditionally; only increment count if new vote. + agreement.votes |= bit; + if !already_voted { + agreement.voter_count += 1; + } + + // Emit per-vote event. + events::emit_oracle_voted( + &env, + project_id, + oracle.clone(), + oracle_index, + agreement.voter_count, + config.threshold, + ); + + // Check threshold. + if agreement.voter_count < config.threshold { + // Not yet — persist updated agreement and return. + save_oracle_agreement(&env, project_id, &agreement); + return; + } + + // Threshold met — clear agreement and fall through to payout. + clear_oracle_agreement(&env, project_id); + } else { + // Legacy path: caller must hold the global Oracle RBAC role. + rbac::require_oracle(&env, &oracle); + } + + // ── Payout ─────────────────────────────────────────────────────────── state.status = ProjectStatus::Completed; - // Transfer all deposited tokens to the creator. - // If any transfer fails, panic to revert the entire transaction. let contract_address = env.current_contract_address(); let protocol_config = get_protocol_config(&env); for token in config.accepted_tokens.iter() { - // Drain the token balance (gets balance and zeros it). let mut balance = drain_token_balance(&env, project_id, &token); - // Only transfer if there's a non-zero balance. if balance > 0 { let token_client = token::Client::new(&env, &token); - // Deduct platform fee if configured. - if let Some(config) = &protocol_config { - if config.fee_bps > 0 { - // fee = balance * bps / 10000 + if let Some(ref pcfg) = protocol_config { + if pcfg.fee_bps > 0 { let fee_amount = balance - .checked_mul(config.fee_bps as i128) + .checked_mul(pcfg.fee_bps as i128) .unwrap_or(0) .checked_div(10000) .unwrap_or(0); @@ -651,7 +634,7 @@ impl PifpProtocol { if fee_amount > 0 { token_client.transfer( &contract_address, - &config.fee_recipient, + &pcfg.fee_recipient, &fee_amount, ); balance = balance.checked_sub(fee_amount).unwrap_or(balance); @@ -660,98 +643,65 @@ impl PifpProtocol { project_id, token.clone(), fee_amount, - config.fee_recipient.clone(), + pcfg.fee_recipient.clone(), ); } } } - // Transfer remaining to creator. if balance > 0 { token_client.transfer(&contract_address, &config.creator, &balance); - // Emit funds_released event for this token. events::emit_funds_released(&env, project_id, token, balance); } } } - // Save the updated state (now marked as Completed). save_project_state(&env, project_id, &state); - - // Standardized event emission - events::emit_project_verified(&env, project_id, oracle.clone(), submitted_proof_hash); + events::emit_project_verified(&env, project_id, oracle, submitted_proof_hash); } - /// Mark a project as expired if its deadline has passed. - /// - /// Permissionless: anyone can trigger expiration once the deadline is met. - /// - Panics if project is not in Funding status. - /// - Panics if deadline has not passed. pub fn expire_project(env: Env, project_id: u64) { let (config, mut state) = load_project_pair(&env, project_id); - // State transition check: only Funding or Active projects can expire. - // Completed projects cannot be expired. match state.status { ProjectStatus::Funding | ProjectStatus::Active => {} _ => panic_with_error!(&env, Error::InvalidTransition), } - // Deadline check. if env.ledger().timestamp() < config.deadline { panic_with_error!(&env, Error::ProjectNotExpired); } - // Update status and save. state.status = ProjectStatus::Expired; state.refund_expiry = env.ledger().timestamp() + REFUND_WINDOW; save_project_state(&env, project_id, &state); - - // Standardized event emission. events::emit_project_expired(&env, project_id, config.deadline); } - // ───────────────────────────────────────────────────────── - // Donor Refund Expiry - // ───────────────────────────────────────────────────────── - - /// Reclaim unclaimed donor funds after the 6-month refund window has expired. - /// - /// Only the project creator may call this, and only for projects that are - /// `Expired` or `Cancelled` whose `refund_expiry` timestamp has passed. - /// For each accepted token, any remaining balance is transferred to the creator. pub fn reclaim_expired_funds(env: Env, creator: Address, project_id: u64) { Self::require_not_paused(&env); creator.require_auth(); let (config, state) = load_project_pair(&env, project_id); - // Only the project creator may reclaim. if creator != config.creator { panic_with_error!(&env, Error::NotAuthorized); } - // Project must be in a terminal refundable state. - if !matches!( - state.status, - ProjectStatus::Expired | ProjectStatus::Cancelled - ) { + if !matches!(state.status, ProjectStatus::Expired | ProjectStatus::Cancelled) { panic_with_error!(&env, Error::InvalidTransition); } - // The refund window must have expired. if state.refund_expiry == 0 || env.ledger().timestamp() < state.refund_expiry { panic_with_error!(&env, Error::RefundWindowActive); } - // Drain remaining balances for each accepted token. let contract_address = env.current_contract_address(); for token in config.accepted_tokens.iter() { let balance = drain_token_balance(&env, project_id, &token); if balance > 0 { let token_client = token::Client::new(&env, &token); token_client.transfer(&contract_address, &config.creator, &balance); - events::emit_expired_funds_reclaimed( &env, project_id, @@ -764,7 +714,7 @@ impl PifpProtocol { } // ───────────────────────────────────────────────────────── - // Internal Helpers + // Internal helpers // ───────────────────────────────────────────────────────── fn require_not_paused(env: &Env) { diff --git a/contracts/pifp_protocol/src/rbac.rs b/contracts/pifp_protocol/src/rbac.rs index 08ec8d4..06c181e 100644 --- a/contracts/pifp_protocol/src/rbac.rs +++ b/contracts/pifp_protocol/src/rbac.rs @@ -251,6 +251,12 @@ pub fn require_oracle(env: &Env, address: &Address) { require_role(env, address, &Role::Oracle); } +/// Assert that `address` holds the SuperAdmin role. +#[inline] +pub fn require_super_admin(env: &Env, address: &Address) { + require_role(env, address, &Role::SuperAdmin); +} + /// Assert that `address` may register and manage projects. /// ProjectManager, Admin, and SuperAdmin may all register projects. #[inline] diff --git a/contracts/pifp_protocol/src/storage.rs b/contracts/pifp_protocol/src/storage.rs index f2b1f40..9abbb9f 100644 --- a/contracts/pifp_protocol/src/storage.rs +++ b/contracts/pifp_protocol/src/storage.rs @@ -32,7 +32,8 @@ use soroban_sdk::{contracttype, panic_with_error, Address, Env, Vec}; use crate::errors::Error; use crate::types::{ - Project, ProjectBalances, ProjectConfig, ProjectState, ProtocolConfig, TokenBalance, + OracleAgreement, Project, ProjectBalances, ProjectConfig, ProjectState, ProtocolConfig, + TokenBalance, }; // ── TTL Constants ──────────────────────────────────────────────────── @@ -74,6 +75,8 @@ pub enum DataKey { ProtocolConfig, /// Whitelisted donator for a project (Persistent). Whitelist(u64, Address), + /// In-flight oracle vote agreement for a project (Temporary). + OracleAgreement(u64), } // ── Instance Storage Helpers ───────────────────────────────────────── @@ -154,6 +157,8 @@ pub fn save_project(env: &Env, project: &Project) { deadline: project.deadline, is_private: project.is_private, metadata_uri: project.metadata_uri.clone(), + authorized_oracles: project.authorized_oracles.clone(), + threshold: project.threshold, }; let state = ProjectState { @@ -295,6 +300,8 @@ pub fn load_project(env: &Env, id: u64) -> Project { donation_count: state.donation_count, is_private: config.is_private, refund_expiry: state.refund_expiry, + authorized_oracles: config.authorized_oracles, + threshold: config.threshold, } } @@ -331,6 +338,8 @@ pub fn maybe_load_project(env: &Env, id: u64) -> Option { donation_count: state.donation_count, is_private: config.is_private, refund_expiry: state.refund_expiry, + authorized_oracles: config.authorized_oracles, + threshold: config.threshold, }) } @@ -460,3 +469,35 @@ pub fn remove_from_whitelist(env: &Env, project_id: u64, address: &Address) { let key = DataKey::Whitelist(project_id, address.clone()); env.storage().persistent().remove(&key); } + +// ── Oracle Agreement Helpers (Temporary Storage) ───────────────────── + +/// Approximate ledgers for 1 day — used as TTL for temporary oracle agreement. +const TEMP_AGREEMENT_TTL: u32 = 17_280; + +/// Load the current `OracleAgreement` for a project, or return a zeroed default. +pub fn load_oracle_agreement(env: &Env, project_id: u64) -> OracleAgreement { + let key = DataKey::OracleAgreement(project_id); + env.storage() + .temporary() + .get::(&key) + .unwrap_or(OracleAgreement { + votes: 0, + voter_count: 0, + }) +} + +/// Persist an updated `OracleAgreement` with a 1-day TTL. +pub fn save_oracle_agreement(env: &Env, project_id: u64, agreement: &OracleAgreement) { + let key = DataKey::OracleAgreement(project_id); + env.storage().temporary().set(&key, agreement); + env.storage() + .temporary() + .extend_ttl(&key, TEMP_AGREEMENT_TTL, TEMP_AGREEMENT_TTL); +} + +/// Remove the `OracleAgreement` entry once the threshold is met. +pub fn clear_oracle_agreement(env: &Env, project_id: u64) { + let key = DataKey::OracleAgreement(project_id); + env.storage().temporary().remove(&key); +} diff --git a/contracts/pifp_protocol/src/test.rs b/contracts/pifp_protocol/src/test.rs index a5397f7..100204f 100644 --- a/contracts/pifp_protocol/src/test.rs +++ b/contracts/pifp_protocol/src/test.rs @@ -24,7 +24,7 @@ fn test_register_project_success() { let tokens = Vec::from_array(&ctx.env, [token.clone()]); let goal: i128 = 1_000; - let project = ctx.register_project(&tokens, goal); + let project = ctx.register_project(&tokens, goal, false); assert_eq!(project.id, 0); assert_eq!(project.creator, ctx.manager); @@ -39,8 +39,7 @@ fn test_register_duplicate_tokens_fails() { let ctx = TestContext::new(); let token = ctx.generate_address(); let tokens = Vec::from_array(&ctx.env, [token.clone(), token.clone()]); - - ctx.register_project(&tokens, 1000); + ctx.register_project(&tokens, 1000, false); } #[test] @@ -48,7 +47,7 @@ fn test_register_duplicate_tokens_fails() { fn test_register_zero_goal_fails() { let ctx = TestContext::new(); let tokens = Vec::from_array(&ctx.env, [ctx.generate_address()]); - ctx.register_project(&tokens, 0); + ctx.register_project(&tokens, 0, false); } #[test] @@ -56,19 +55,19 @@ fn test_register_zero_goal_fails() { fn test_register_past_deadline_fails() { let ctx = TestContext::new(); let tokens = Vec::from_array(&ctx.env, [ctx.generate_address()]); - - // Set ledger to future ctx.jump_time(200_000); - - // Attempt to register with a past deadline (86400 from 100_000 < 200_000) - let past_deadline = 150_000; + let past_deadline = 150_000u64; + let empty_oracles: soroban_sdk::Vec = soroban_sdk::Vec::new(&ctx.env); ctx.client.register_project( &ctx.manager, &tokens, - &1000, + &1000i128, &ctx.dummy_proof(), &ctx.dummy_metadata_uri(), &past_deadline, + &false, + &empty_oracles, + &0u32, ); } @@ -77,8 +76,7 @@ fn test_register_past_deadline_fails() { fn test_deposit_zero_amount_fails() { let ctx = TestContext::new(); let (project, token, _) = ctx.setup_project(1000); - ctx.client - .deposit(&project.id, &ctx.manager, &token.address, &0i128); + ctx.client.deposit(&project.id, &ctx.manager, &token.address, &0i128); } #[test] @@ -86,22 +84,16 @@ fn test_deposit_zero_amount_fails() { fn test_deposit_after_deadline_fails() { let ctx = TestContext::new(); let (project, token, _) = ctx.setup_project(1000); - - // Fast-forward time ctx.jump_time(project.deadline + 1); - - ctx.client - .deposit(&project.id, &ctx.admin, &token.address, &100i128); + ctx.client.deposit(&project.id, &ctx.admin, &token.address, &100i128); } #[test] fn test_admin_can_pause_and_unpause() { let ctx = TestContext::new(); assert!(!ctx.client.is_paused()); - ctx.client.pause(&ctx.admin); assert!(ctx.client.is_paused()); - ctx.client.unpause(&ctx.admin); assert!(!ctx.client.is_paused()); } @@ -111,23 +103,19 @@ fn test_project_exists_and_maybe_load_helpers() { let ctx = TestContext::new(); let contract_id = ctx.client.address.clone(); - // nothing registered yet ctx.env.as_contract(&contract_id, || { assert!(!crate::storage::project_exists(&ctx.env, 0)); assert_eq!(crate::storage::maybe_load_project(&ctx.env, 0), None); }); - // register one project let (project, _, _) = ctx.setup_project(1000); ctx.env.as_contract(&contract_id, || { assert!(crate::storage::project_exists(&ctx.env, project.id)); let cfg = crate::storage::maybe_load_project_config(&ctx.env, project.id).unwrap(); assert_eq!(cfg.id, project.id); - let st = crate::storage::maybe_load_project_state(&ctx.env, project.id).unwrap(); assert_eq!(st.donation_count, 0); - let loaded = crate::storage::maybe_load_project(&ctx.env, project.id).unwrap(); assert_eq!(loaded.creator, project.creator); }); @@ -146,9 +134,8 @@ fn test_non_admin_cannot_pause() { fn test_registration_fails_when_paused() { let ctx = TestContext::new(); ctx.client.pause(&ctx.admin); - let tokens = Vec::from_array(&ctx.env, [ctx.generate_address()]); - ctx.register_project(&tokens, 1000); + ctx.register_project(&tokens, 1000, false); } #[test] @@ -156,20 +143,15 @@ fn test_registration_fails_when_paused() { fn test_deposit_fails_when_paused() { let ctx = TestContext::new(); let (project, token, _) = ctx.setup_project(1000); - ctx.client.pause(&ctx.admin); - ctx.client - .deposit(&project.id, &ctx.manager, &token.address, &100i128); + ctx.client.deposit(&project.id, &ctx.manager, &token.address, &100i128); } #[test] fn test_queries_work_when_paused() { let ctx = TestContext::new(); let (project, _, _) = ctx.setup_project(1000); - ctx.client.pause(&ctx.admin); - - // Query should still work let loaded = ctx.client.get_project(&project.id); assert_eq!(loaded.id, project.id); } diff --git a/contracts/pifp_protocol/src/test_deadline.rs b/contracts/pifp_protocol/src/test_deadline.rs index 1c49890..ebdeed3 100644 --- a/contracts/pifp_protocol/src/test_deadline.rs +++ b/contracts/pifp_protocol/src/test_deadline.rs @@ -1,169 +1,82 @@ -#![cfg(test)] +extern crate std; -use crate::test_utils::{create_token, setup_test}; -use crate::{Error, ProjectStatus, Role}; -use soroban_sdk::{testutils::{Address as _, Ledger}, Address, Vec}; +use crate::{test_utils::TestContext, Role}; +use soroban_sdk::Vec; + +fn register(ctx: &TestContext, deadline: u64) -> crate::types::Project { + let (token, _) = ctx.create_token(); + let tokens = Vec::from_array(&ctx.env, [token.address.clone()]); + let empty_oracles: soroban_sdk::Vec = soroban_sdk::Vec::new(&ctx.env); + ctx.client.register_project( + &ctx.manager, + &tokens, + &1000i128, + &ctx.dummy_proof(), + &ctx.dummy_metadata_uri(), + &deadline, + &false, + &empty_oracles, + &0u32, + ) +} #[test] fn test_extend_deadline_success() { - let (env, client, admin) = setup_test(); - let creator = Address::generate(&env); - let token = create_token(&env, &admin); - let accepted_tokens = Vec::from_array(&env, [token.address.clone()]); - - // Register project manager - client.grant_role(&admin, &creator, &Role::ProjectManager); - - let now = 1000; - env.ledger().set_timestamp(now); - let deadline = now + 10000; - - let project = client.register_project( - &creator, - &accepted_tokens, - &1000, - &[0u8; 32].into(), - &deadline, - &deadline, &false, - ); - + let ctx = TestContext::new(); + let deadline = ctx.env.ledger().timestamp() + 10000; + let project = register(&ctx, deadline); let new_deadline = deadline + 5000; - client.extend_deadline(&creator, &project.id, &new_deadline); - - let updated_project = client.get_project(&project.id); - assert_eq!(updated_project.deadline, new_deadline); + ctx.client.extend_deadline(&ctx.manager, &project.id, &new_deadline); + assert_eq!(ctx.client.get_project(&project.id).deadline, new_deadline); } #[test] fn test_extend_deadline_by_admin() { - let (env, client, admin) = setup_test(); - let creator = Address::generate(&env); - let token = create_token(&env, &admin); - let accepted_tokens = Vec::from_array(&env, [token.address.clone()]); - - client.grant_role(&admin, &creator, &Role::ProjectManager); - - let now = 1000; - env.ledger().set_timestamp(now); - let deadline = now + 10000; - - let project = client.register_project( - &creator, - &accepted_tokens, - &1000, - &[0u8; 32].into(), - &deadline, - &deadline, &false, - ); - + let ctx = TestContext::new(); + let deadline = ctx.env.ledger().timestamp() + 10000; + let project = register(&ctx, deadline); let new_deadline = deadline + 5000; - // Admin can also extend - client.extend_deadline(&admin, &project.id, &new_deadline); - - let updated_project = client.get_project(&project.id); - assert_eq!(updated_project.deadline, new_deadline); + ctx.client.extend_deadline(&ctx.admin, &project.id, &new_deadline); + assert_eq!(ctx.client.get_project(&project.id).deadline, new_deadline); } #[test] #[should_panic(expected = "HostError: Error(Contract, #6)")] fn test_extend_deadline_unauthorized() { - let (env, client, admin) = setup_test(); - let creator = Address::generate(&env); - let stranger = Address::generate(&env); - let token = create_token(&env, &admin); - let accepted_tokens = Vec::from_array(&env, [token.address.clone()]); - - client.grant_role(&admin, &creator, &Role::ProjectManager); - - let project = client.register_project( - &creator, - &accepted_tokens, - &1000, - &[0u8; 32].into(), - &(env.ledger().timestamp() + 10000), - &(env.ledger().timestamp() + 10000), &false, - ); - - client.extend_deadline(&stranger, &project.id, &(env.ledger().timestamp() + 15000)); + let ctx = TestContext::new(); + let deadline = ctx.env.ledger().timestamp() + 10000; + let project = register(&ctx, deadline); + let stranger = ctx.generate_address(); + ctx.client.extend_deadline(&stranger, &project.id, &(deadline + 5000)); } #[test] #[should_panic(expected = "HostError: Error(Contract, #13)")] fn test_extend_deadline_backwards() { - let (env, client, admin) = setup_test(); - let creator = Address::generate(&env); - let token = create_token(&env, &admin); - let accepted_tokens = Vec::from_array(&env, [token.address.clone()]); - - client.grant_role(&admin, &creator, &Role::ProjectManager); - - let deadline = env.ledger().timestamp() + 10000; - let project = client.register_project( - &creator, - &accepted_tokens, - &1000, - &[0u8; 32].into(), - &deadline, - &deadline, &false, - ); - - // New deadline same as or earlier than current is Error::InvalidDeadline (13) - client.extend_deadline(&creator, &project.id, &deadline); + let ctx = TestContext::new(); + let deadline = ctx.env.ledger().timestamp() + 10000; + let project = register(&ctx, deadline); + // Same deadline — not strictly later, should fail with InvalidDeadline. + ctx.client.extend_deadline(&ctx.manager, &project.id, &deadline); } #[test] #[should_panic(expected = "HostError: Error(Contract, #14)")] fn test_extend_deadline_expired() { - let (env, client, admin) = setup_test(); - let creator = Address::generate(&env); - let token = create_token(&env, &admin); - let accepted_tokens = Vec::from_array(&env, [token.address.clone()]); - - client.grant_role(&admin, &creator, &Role::ProjectManager); - - let now = 1000; - env.ledger().set_timestamp(now); - let deadline = now + 10000; - - let project = client.register_project( - &creator, - &accepted_tokens, - &1000, - &[0u8; 32].into(), - &deadline, - &deadline, &false, - ); - - // Fast forward past deadline - env.ledger().set_timestamp(deadline + 1); - - client.extend_deadline(&creator, &project.id, &(deadline + 5000)); + let ctx = TestContext::new(); + let deadline = ctx.env.ledger().timestamp() + 10000; + let project = register(&ctx, deadline); + ctx.jump_time(10001); + ctx.client.extend_deadline(&ctx.manager, &project.id, &(deadline + 5000)); } #[test] #[should_panic(expected = "HostError: Error(Contract, #24)")] fn test_extend_deadline_too_long() { - let (env, client, admin) = setup_test(); - let creator = Address::generate(&env); - let token = create_token(&env, &admin); - let accepted_tokens = Vec::from_array(&env, [token.address.clone()]); - - client.grant_role(&admin, &creator, &Role::ProjectManager); - - let now = 1000; - env.ledger().set_timestamp(now); - let deadline = now + 10000; - - let project = client.register_project( - &creator, - &accepted_tokens, - &1000, - &[0u8; 32].into(), - &deadline, - &deadline, &false, - ); - - // 1 year + 1 second - let too_late = now + 31_536_000 + 1; - client.extend_deadline(&creator, &project.id, &too_late); + let ctx = TestContext::new(); + let deadline = ctx.env.ledger().timestamp() + 10000; + let project = register(&ctx, deadline); + // 1 year + 1 second from now + let too_late = ctx.env.ledger().timestamp() + 31_536_000 + 1; + ctx.client.extend_deadline(&ctx.manager, &project.id, &too_late); } diff --git a/contracts/pifp_protocol/src/test_donation_count.rs b/contracts/pifp_protocol/src/test_donation_count.rs index 3c09187..d096eb9 100644 --- a/contracts/pifp_protocol/src/test_donation_count.rs +++ b/contracts/pifp_protocol/src/test_donation_count.rs @@ -1,7 +1,6 @@ extern crate std; use crate::test_utils::TestContext; -use soroban_sdk::Bytes; #[test] fn test_donation_count_initialized_to_zero() { @@ -15,13 +14,9 @@ fn test_donation_count_increments_for_new_donor() { let ctx = TestContext::new(); let (project, token, sac) = ctx.setup_project(10000); let donator = ctx.generate_address(); - - sac.mint(&donator, &1_000); - ctx.client - .deposit(&project.id, &donator, &token.address, &500i128); - - let updated = ctx.client.get_project(&project.id); - assert_eq!(updated.donation_count, 1); + sac.mint(&donator, &1_000i128); + ctx.client.deposit(&project.id, &donator, &token.address, &500i128); + assert_eq!(ctx.client.get_project(&project.id).donation_count, 1); } #[test] @@ -29,15 +24,10 @@ fn test_donation_count_stays_same_for_repeated_donor() { let ctx = TestContext::new(); let (project, token, sac) = ctx.setup_project(10000); let donator = ctx.generate_address(); - - sac.mint(&donator, &2_000); - ctx.client - .deposit(&project.id, &donator, &token.address, &500i128); + sac.mint(&donator, &2_000i128); + ctx.client.deposit(&project.id, &donator, &token.address, &500i128); assert_eq!(ctx.client.get_project(&project.id).donation_count, 1); - - // Second deposit from same donor with same token - ctx.client - .deposit(&project.id, &donator, &token.address, &300i128); + ctx.client.deposit(&project.id, &donator, &token.address, &300i128); assert_eq!(ctx.client.get_project(&project.id).donation_count, 1); } @@ -45,50 +35,39 @@ fn test_donation_count_stays_same_for_repeated_donor() { fn test_donation_count_increments_for_different_donors() { let ctx = TestContext::new(); let (project, token, sac) = ctx.setup_project(10000); - let donator1 = ctx.generate_address(); - let donator2 = ctx.generate_address(); - - sac.mint(&donator1, &1_000); - sac.mint(&donator2, &1_000); - - ctx.client - .deposit(&project.id, &donator1, &token.address, &500i128); - ctx.client - .deposit(&project.id, &donator2, &token.address, &300i128); - - let updated = ctx.client.get_project(&project.id); - assert_eq!(updated.donation_count, 2); + let d1 = ctx.generate_address(); + let d2 = ctx.generate_address(); + sac.mint(&d1, &1_000i128); + sac.mint(&d2, &1_000i128); + ctx.client.deposit(&project.id, &d1, &token.address, &500i128); + ctx.client.deposit(&project.id, &d2, &token.address, &300i128); + assert_eq!(ctx.client.get_project(&project.id).donation_count, 2); } #[test] fn test_donation_count_increments_for_same_donor_different_tokens() { let ctx = TestContext::new(); - - // Setup manual project with 2 tokens let (token1, sac1) = ctx.create_token(); let (token2, sac2) = ctx.create_token(); - let tokens = - soroban_sdk::Vec::from_array(&ctx.env, [token1.address.clone(), token2.address.clone()]); - let metadata_uri = ctx.dummy_metadata_uri(); + let tokens = soroban_sdk::Vec::from_array(&ctx.env, [token1.address.clone(), token2.address.clone()]); + let empty_oracles: soroban_sdk::Vec = soroban_sdk::Vec::new(&ctx.env); let project = ctx.client.register_project( &ctx.manager, &tokens, - &10_000, + &10_000i128, &ctx.dummy_proof(), - &metadata_uri, + &ctx.dummy_metadata_uri(), &(ctx.env.ledger().timestamp() + 86400), + &false, + &empty_oracles, + &0u32, ); - let donator = ctx.generate_address(); - sac1.mint(&donator, &1_000); - sac2.mint(&donator, &1_000); - - ctx.client - .deposit(&project.id, &donator, &token1.address, &500i128); + sac1.mint(&donator, &1_000i128); + sac2.mint(&donator, &1_000i128); + ctx.client.deposit(&project.id, &donator, &token1.address, &500i128); assert_eq!(ctx.client.get_project(&project.id).donation_count, 1); - - ctx.client - .deposit(&project.id, &donator, &token2.address, &300i128); + ctx.client.deposit(&project.id, &donator, &token2.address, &300i128); assert_eq!(ctx.client.get_project(&project.id).donation_count, 2); } @@ -97,54 +76,37 @@ fn test_donation_count_complex_scenario() { let ctx = TestContext::new(); let (token1, sac1) = ctx.create_token(); let (token2, sac2) = ctx.create_token(); - let tokens = - soroban_sdk::Vec::from_array(&ctx.env, [token1.address.clone(), token2.address.clone()]); - let metadata_uri = ctx.dummy_metadata_uri(); + let tokens = soroban_sdk::Vec::from_array(&ctx.env, [token1.address.clone(), token2.address.clone()]); + let empty_oracles: soroban_sdk::Vec = soroban_sdk::Vec::new(&ctx.env); let project = ctx.client.register_project( &ctx.manager, &tokens, - &10_000, + &10_000i128, &ctx.dummy_proof(), - &metadata_uri, + &ctx.dummy_metadata_uri(), &(ctx.env.ledger().timestamp() + 86400), + &false, + &empty_oracles, + &0u32, ); + let d1 = ctx.generate_address(); + let d2 = ctx.generate_address(); + let d3 = ctx.generate_address(); + sac1.mint(&d1, &5_000i128); sac1.mint(&d2, &5_000i128); sac1.mint(&d3, &5_000i128); + sac2.mint(&d1, &5_000i128); sac2.mint(&d2, &5_000i128); - let donator1 = ctx.generate_address(); - let donator2 = ctx.generate_address(); - let donator3 = ctx.generate_address(); - - sac1.mint(&donator1, &5_000); - sac1.mint(&donator2, &5_000); - sac1.mint(&donator3, &5_000); - sac2.mint(&donator1, &5_000); - sac2.mint(&donator2, &5_000); - - // Sequence of deposits - ctx.client - .deposit(&project.id, &donator1, &token1.address, &100i128); + ctx.client.deposit(&project.id, &d1, &token1.address, &100i128); assert_eq!(ctx.client.get_project(&project.id).donation_count, 1); - - ctx.client - .deposit(&project.id, &donator1, &token1.address, &100i128); + ctx.client.deposit(&project.id, &d1, &token1.address, &100i128); assert_eq!(ctx.client.get_project(&project.id).donation_count, 1); - - ctx.client - .deposit(&project.id, &donator2, &token1.address, &200i128); + ctx.client.deposit(&project.id, &d2, &token1.address, &200i128); assert_eq!(ctx.client.get_project(&project.id).donation_count, 2); - - ctx.client - .deposit(&project.id, &donator1, &token2.address, &150i128); + ctx.client.deposit(&project.id, &d1, &token2.address, &150i128); assert_eq!(ctx.client.get_project(&project.id).donation_count, 3); - - ctx.client - .deposit(&project.id, &donator3, &token1.address, &300i128); + ctx.client.deposit(&project.id, &d3, &token1.address, &300i128); assert_eq!(ctx.client.get_project(&project.id).donation_count, 4); - - ctx.client - .deposit(&project.id, &donator2, &token2.address, &250i128); + ctx.client.deposit(&project.id, &d2, &token2.address, &250i128); assert_eq!(ctx.client.get_project(&project.id).donation_count, 5); - - ctx.client - .deposit(&project.id, &donator2, &token2.address, &100i128); + ctx.client.deposit(&project.id, &d2, &token2.address, &100i128); assert_eq!(ctx.client.get_project(&project.id).donation_count, 5); } diff --git a/contracts/pifp_protocol/src/test_errors.rs b/contracts/pifp_protocol/src/test_errors.rs index dc5b2cd..42477f9 100644 --- a/contracts/pifp_protocol/src/test_errors.rs +++ b/contracts/pifp_protocol/src/test_errors.rs @@ -3,10 +3,6 @@ extern crate std; use crate::test_utils::TestContext; use soroban_sdk::{BytesN, Vec}; -// ───────────────────────────────────────────────────────── -// ProjectNotFound (#1) -// ───────────────────────────────────────────────────────── - #[test] #[should_panic(expected = "HostError: Error(Contract, #1)")] fn test_get_project_not_found() { @@ -29,35 +25,21 @@ fn test_get_project_balances_not_found() { ctx.client.get_project_balances(&999); } -// ───────────────────────────────────────────────────────── -// MilestoneAlreadyReleased (#3) -// ───────────────────────────────────────────────────────── - #[test] #[should_panic(expected = "HostError: Error(Contract, #3)")] fn test_verify_already_completed_project() { let ctx = TestContext::new(); let (project, _, _) = ctx.setup_project(1000); - - // First verification succeeds. - ctx.client - .verify_and_release(&ctx.oracle, &project.id, &ctx.dummy_proof()); - - // Second verification must fail with MilestoneAlreadyReleased. - ctx.client - .verify_and_release(&ctx.oracle, &project.id, &ctx.dummy_proof()); + ctx.client.verify_and_release(&ctx.oracle, &project.id, &ctx.dummy_proof()); + ctx.client.verify_and_release(&ctx.oracle, &project.id, &ctx.dummy_proof()); } -// ───────────────────────────────────────────────────────── -// InvalidGoal (#7) -// ───────────────────────────────────────────────────────── - #[test] #[should_panic(expected = "HostError: Error(Contract, #7)")] fn test_register_negative_goal_fails() { let ctx = TestContext::new(); let tokens = Vec::from_array(&ctx.env, [ctx.generate_address()]); - ctx.register_project(&tokens, -100); + ctx.register_project(&tokens, -100, false); } #[test] @@ -65,15 +47,10 @@ fn test_register_negative_goal_fails() { fn test_register_goal_exceeds_upper_bound_fails() { let ctx = TestContext::new(); let tokens = Vec::from_array(&ctx.env, [ctx.generate_address()]); - // 10^30 + 1 — exceeds upper bound let huge_goal: i128 = 1_000_000_000_000_000_000_000_000_000_001; - ctx.register_project(&tokens, huge_goal); + ctx.register_project(&tokens, huge_goal, false); } -// ───────────────────────────────────────────────────────── -// TooManyTokens (#10) -// ───────────────────────────────────────────────────────── - #[test] #[should_panic(expected = "HostError: Error(Contract, #10)")] fn test_register_too_many_tokens_fails() { @@ -82,123 +59,82 @@ fn test_register_too_many_tokens_fails() { for _ in 0..11 { tokens.push_back(ctx.generate_address()); } - ctx.register_project(&tokens, 1000); + ctx.register_project(&tokens, 1000, false); } -// ───────────────────────────────────────────────────────── -// InvalidDeadline (#13) -// ───────────────────────────────────────────────────────── - #[test] #[should_panic(expected = "HostError: Error(Contract, #13)")] fn test_register_deadline_too_far_in_future_fails() { let ctx = TestContext::new(); let tokens = Vec::from_array(&ctx.env, [ctx.generate_address()]); - // More than 5 years in the future let too_far_deadline = ctx.env.ledger().timestamp() + 200_000_000; + let empty_oracles: soroban_sdk::Vec = soroban_sdk::Vec::new(&ctx.env); ctx.client.register_project( &ctx.manager, &tokens, - &1000, + &1000i128, &ctx.dummy_proof(), &ctx.dummy_metadata_uri(), &too_far_deadline, + &false, + &empty_oracles, + &0u32, ); } -// ───────────────────────────────────────────────────────── -// VerificationFailed (#16) -// ───────────────────────────────────────────────────────── - #[test] #[should_panic(expected = "HostError: Error(Contract, #16)")] fn test_verify_wrong_proof_hash_fails() { let ctx = TestContext::new(); let (project, _, _) = ctx.setup_project(1000); - let wrong_proof = BytesN::from_array(&ctx.env, &[0xffu8; 32]); - ctx.client - .verify_and_release(&ctx.oracle, &project.id, &wrong_proof); + ctx.client.verify_and_release(&ctx.oracle, &project.id, &wrong_proof); } -// ───────────────────────────────────────────────────────── -// EmptyAcceptedTokens (#17) -// ───────────────────────────────────────────────────────── - #[test] #[should_panic(expected = "HostError: Error(Contract, #17)")] fn test_register_empty_tokens_fails() { let ctx = TestContext::new(); let tokens: Vec = Vec::new(&ctx.env); - ctx.register_project(&tokens, 1000); + ctx.register_project(&tokens, 1000, false); } -// ───────────────────────────────────────────────────────── -// ProtocolPaused (#19) -// ───────────────────────────────────────────────────────── - #[test] #[should_panic(expected = "HostError: Error(Contract, #19)")] fn test_verify_when_paused_fails() { let ctx = TestContext::new(); let (project, _, _) = ctx.setup_project(1000); - ctx.client.pause(&ctx.admin); - ctx.client - .verify_and_release(&ctx.oracle, &project.id, &ctx.dummy_proof()); + ctx.client.verify_and_release(&ctx.oracle, &project.id, &ctx.dummy_proof()); } -// ───────────────────────────────────────────────────────── -// ProjectNotExpired (#21) -// ───────────────────────────────────────────────────────── - #[test] #[should_panic(expected = "HostError: Error(Contract, #21)")] fn test_expire_project_before_deadline_fails() { let ctx = TestContext::new(); let (project, _, _) = ctx.setup_project(1000); - // No time jump — deadline has not passed. ctx.client.expire_project(&project.id); } -// ───────────────────────────────────────────────────────── -// InvalidTransition (#22) -// ───────────────────────────────────────────────────────── - #[test] #[should_panic(expected = "HostError: Error(Contract, #22)")] fn test_expire_completed_project_fails_with_invalid_transition() { let ctx = TestContext::new(); let (project, _, _) = ctx.setup_project(1000); - - // Complete the project. - ctx.client - .verify_and_release(&ctx.oracle, &project.id, &ctx.dummy_proof()); - - // Attempt to expire it — should fail with InvalidTransition. + ctx.client.verify_and_release(&ctx.oracle, &project.id, &ctx.dummy_proof()); ctx.jump_time(project.deadline + 1); ctx.client.expire_project(&project.id); } -// ───────────────────────────────────────────────────────── -// TokenNotAccepted (#23) -// ───────────────────────────────────────────────────────── - #[test] #[should_panic(expected = "HostError: Error(Contract, #23)")] fn test_deposit_unaccepted_token_fails() { let ctx = TestContext::new(); let (project, _, _) = ctx.setup_project(1000); let rogue_token = ctx.generate_address(); - - ctx.client - .deposit(&project.id, &ctx.manager, &rogue_token, &100i128); + ctx.client.deposit(&project.id, &ctx.manager, &rogue_token, &100i128); } -// ───────────────────────────────────────────────────────── -// NotAuthorized (#6) -// ───────────────────────────────────────────────────────── - #[test] #[should_panic(expected = "HostError: Error(Contract, #6)")] fn test_admin_cannot_cancel_project() { @@ -206,20 +142,12 @@ fn test_admin_cannot_cancel_project() { let (project, token, sac) = ctx.setup_project(500); let donator = ctx.generate_address(); let other_admin = ctx.generate_address(); - - ctx.client - .grant_role(&ctx.admin, &other_admin, &crate::Role::Admin); + ctx.client.grant_role(&ctx.admin, &other_admin, &crate::Role::Admin); sac.mint(&donator, &600i128); - ctx.client - .deposit(&project.id, &donator, &token.address, &600i128); - + ctx.client.deposit(&project.id, &donator, &token.address, &600i128); ctx.client.cancel_project(&other_admin, &project.id); } -// ───────────────────────────────────────────────────────── -// InvalidTransition (#22) -// ───────────────────────────────────────────────────────── - #[test] #[should_panic(expected = "HostError: Error(Contract, #22)")] fn test_cancel_non_active_project_fails() { diff --git a/contracts/pifp_protocol/src/test_events.rs b/contracts/pifp_protocol/src/test_events.rs index ab386f3..94db4eb 100644 --- a/contracts/pifp_protocol/src/test_events.rs +++ b/contracts/pifp_protocol/src/test_events.rs @@ -1,110 +1,71 @@ extern crate std; -use soroban_sdk::{vec, Bytes}; - use crate::test_utils::TestContext; #[test] fn test_project_created_event() { let ctx = TestContext::new(); let (_project, _token, _) = ctx.setup_project(5000); - - // let all_events = ctx.env.events().all(); - // In SDK 25, testing events is more complex with ContractEvents type. - // Skipping for now to focus on core logic tests. } #[test] fn test_project_funded_event() { let ctx = TestContext::new(); let (project, token, sac) = ctx.setup_project(10000); - let donator = ctx.generate_address(); - let amount = 1000i128; - sac.mint(&donator, &amount); - - ctx.client - .deposit(&project.id, &donator, &token.address, &amount); + sac.mint(&donator, &1000i128); + ctx.client.deposit(&project.id, &donator, &token.address, &1000i128); } #[test] fn test_project_verified_event() { let ctx = TestContext::new(); let (project, _, _) = ctx.setup_project(1000); - let proof = ctx.dummy_proof(); - - ctx.client - .verify_and_release(&ctx.oracle, &project.id, &proof); + ctx.client.verify_and_release(&ctx.oracle, &project.id, &ctx.dummy_proof()); } #[test] fn test_get_project_balances() { let ctx = TestContext::new(); - - // Create two distinct SAC tokens let (token_a, sac_a) = ctx.create_token(); let (token_b, sac_b) = ctx.create_token(); - - // Grant manager and register project with two tokens - let metadata_uri = ctx.dummy_metadata_uri(); - let tokens = vec![&ctx.env, token_a.address.clone(), token_b.address.clone()]; + let tokens = soroban_sdk::vec![&ctx.env, token_a.address.clone(), token_b.address.clone()]; + let empty_oracles: soroban_sdk::Vec = soroban_sdk::Vec::new(&ctx.env); let project = ctx.client.register_project( &ctx.manager, &tokens, - &10_000, + &10_000i128, &ctx.dummy_proof(), - &metadata_uri, + &ctx.dummy_metadata_uri(), &(ctx.env.ledger().timestamp() + 86400), + &false, + &empty_oracles, + &0u32, ); let donator = ctx.generate_address(); - let amount_a = 2_500i128; - let amount_b = 7_000i128; - - sac_a.mint(&donator, &amount_a); - sac_b.mint(&donator, &amount_b); + sac_a.mint(&donator, &2_500i128); + sac_b.mint(&donator, &7_000i128); + ctx.client.deposit(&project.id, &donator, &token_a.address, &2_500i128); + ctx.client.deposit(&project.id, &donator, &token_b.address, &7_000i128); - ctx.client - .deposit(&project.id, &donator, &token_a.address, &amount_a); - ctx.client - .deposit(&project.id, &donator, &token_b.address, &amount_b); - - // Query balances let balances = ctx.client.get_project_balances(&project.id); - assert_eq!(balances.project_id, project.id); assert_eq!(balances.balances.len(), 2); - - let bal_a = balances.balances.get(0).unwrap(); - let bal_b = balances.balances.get(1).unwrap(); - - assert_eq!(bal_a.token, token_a.address); - assert_eq!(bal_a.balance, amount_a); - assert_eq!(bal_b.token, token_b.address); - assert_eq!(bal_b.balance, amount_b); + assert_eq!(balances.balances.get(0).unwrap().balance, 2_500i128); + assert_eq!(balances.balances.get(1).unwrap().balance, 7_000i128); } #[test] fn test_funds_released_to_creator() { let ctx = TestContext::new(); let (project, token, sac) = ctx.setup_project(5000); - let donator = ctx.generate_address(); - let deposit_amount = 1000i128; - sac.mint(&donator, &deposit_amount); - - ctx.client - .deposit(&project.id, &donator, &token.address, &deposit_amount); - - // Verify and release - ctx.client - .verify_and_release(&ctx.oracle, &project.id, &ctx.dummy_proof()); - - // Check creator (manager) received the funds - assert_eq!(token.balance(&ctx.manager), deposit_amount); - - // Check contract no longer has the funds - assert_eq!(token.balance(&ctx.client.address), 0); + sac.mint(&donator, &1000i128); + ctx.client.deposit(&project.id, &donator, &token.address, &1000i128); + ctx.client.verify_and_release(&ctx.oracle, &project.id, &ctx.dummy_proof()); + assert_eq!(token.balance(&ctx.manager), 1000i128); + assert_eq!(token.balance(&ctx.client.address), 0i128); } #[test] @@ -112,11 +73,8 @@ fn test_refunded_event() { let ctx = TestContext::new(); let (project, token, sac) = ctx.setup_project(1000); let donator = ctx.generate_address(); - sac.mint(&donator, &400i128); - ctx.client - .deposit(&project.id, &donator, &token.address, &400i128); - + ctx.client.deposit(&project.id, &donator, &token.address, &400i128); ctx.jump_time(86_401); ctx.client.refund(&donator, &project.id, &token.address); } diff --git a/contracts/pifp_protocol/src/test_multi_oracle.rs b/contracts/pifp_protocol/src/test_multi_oracle.rs new file mode 100644 index 0000000..52b5b21 --- /dev/null +++ b/contracts/pifp_protocol/src/test_multi_oracle.rs @@ -0,0 +1,176 @@ +extern crate std; + +use crate::{test_utils::TestContext, ProjectStatus, Role}; +use soroban_sdk::Vec; + +/// Helper: register a project with an explicit M-of-N oracle set. +fn register_with_oracles( + ctx: &TestContext, + oracles: &Vec, + threshold: u32, +) -> crate::types::Project { + let (token, _) = ctx.create_token(); + let tokens = soroban_sdk::Vec::from_array(&ctx.env, [token.address.clone()]); + ctx.client.register_project( + &ctx.manager, + &tokens, + &1000i128, + &ctx.dummy_proof(), + &ctx.dummy_metadata_uri(), + &(ctx.env.ledger().timestamp() + 86400), + &false, + oracles, + &threshold, + ) +} + +// ── Happy path: 2-of-3 ─────────────────────────────────────────────────────── + +#[test] +fn test_two_of_three_releases_on_second_vote() { + let ctx = TestContext::new(); + let o1 = ctx.generate_address(); + let o2 = ctx.generate_address(); + let o3 = ctx.generate_address(); + let oracles = soroban_sdk::Vec::from_array(&ctx.env, [o1.clone(), o2.clone(), o3.clone()]); + + let project = register_with_oracles(&ctx, &oracles, 2); + + // First vote — not yet at threshold. + ctx.client.verify_and_release(&o1, &project.id, &ctx.dummy_proof()); + assert_eq!(ctx.client.get_project(&project.id).status, ProjectStatus::Funding); + + // Second vote — threshold met, funds released. + ctx.client.verify_and_release(&o2, &project.id, &ctx.dummy_proof()); + assert_eq!(ctx.client.get_project(&project.id).status, ProjectStatus::Completed); +} + +// ── Duplicate vote prevention ───────────────────────────────────────────────── + +#[test] +fn test_duplicate_vote_does_not_double_count() { + let ctx = TestContext::new(); + let o1 = ctx.generate_address(); + let o2 = ctx.generate_address(); + let oracles = soroban_sdk::Vec::from_array(&ctx.env, [o1.clone(), o2.clone()]); + + // 2-of-2 threshold. + let project = register_with_oracles(&ctx, &oracles, 2); + + // o1 votes twice — second vote must be a no-op on voter_count. + ctx.client.verify_and_release(&o1, &project.id, &ctx.dummy_proof()); + ctx.client.verify_and_release(&o1, &project.id, &ctx.dummy_proof()); + + // Still Funding — only 1 unique vote counted. + assert_eq!(ctx.client.get_project(&project.id).status, ProjectStatus::Funding); + + // o2 votes — now 2 unique votes, threshold met. + ctx.client.verify_and_release(&o2, &project.id, &ctx.dummy_proof()); + assert_eq!(ctx.client.get_project(&project.id).status, ProjectStatus::Completed); +} + +// ── Unauthorized oracle rejected ────────────────────────────────────────────── + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #28)")] +fn test_unauthorized_oracle_rejected() { + let ctx = TestContext::new(); + let o1 = ctx.generate_address(); + let oracles = soroban_sdk::Vec::from_array(&ctx.env, [o1.clone()]); + let project = register_with_oracles(&ctx, &oracles, 1); + + let rogue = ctx.generate_address(); + ctx.client.verify_and_release(&rogue, &project.id, &ctx.dummy_proof()); +} + +// ── ThresholdAlreadyMet after completion ────────────────────────────────────── + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #29)")] +fn test_vote_after_threshold_met_panics() { + let ctx = TestContext::new(); + let o1 = ctx.generate_address(); + let oracles = soroban_sdk::Vec::from_array(&ctx.env, [o1.clone()]); + let project = register_with_oracles(&ctx, &oracles, 1); + + // First vote completes the project. + ctx.client.verify_and_release(&o1, &project.id, &ctx.dummy_proof()); + assert_eq!(ctx.client.get_project(&project.id).status, ProjectStatus::Completed); + + // Second vote must fail with ThresholdAlreadyMet. + ctx.client.verify_and_release(&o1, &project.id, &ctx.dummy_proof()); +} + +// ── add_oracle / remove_oracle ──────────────────────────────────────────────── + +#[test] +fn test_add_oracle_and_vote() { + let ctx = TestContext::new(); + let o1 = ctx.generate_address(); + let oracles = soroban_sdk::Vec::from_array(&ctx.env, [o1.clone()]); + let project = register_with_oracles(&ctx, &oracles, 1); + + // Add a second oracle post-registration. + let o2 = ctx.generate_address(); + ctx.client.add_oracle(&ctx.admin, &project.id, &o2); + + // Update threshold to 2-of-2 via a new registration isn't possible, + // but we can verify o2 can now vote (threshold is still 1 from registration). + ctx.client.verify_and_release(&o2, &project.id, &ctx.dummy_proof()); + assert_eq!(ctx.client.get_project(&project.id).status, ProjectStatus::Completed); +} + +#[test] +fn test_remove_oracle_resets_agreement() { + let ctx = TestContext::new(); + let o1 = ctx.generate_address(); + let o2 = ctx.generate_address(); + let oracles = soroban_sdk::Vec::from_array(&ctx.env, [o1.clone(), o2.clone()]); + let project = register_with_oracles(&ctx, &oracles, 2); + + // o1 votes. + ctx.client.verify_and_release(&o1, &project.id, &ctx.dummy_proof()); + assert_eq!(ctx.client.get_project(&project.id).status, ProjectStatus::Funding); + + // Admin removes o1 — agreement is reset. + ctx.client.remove_oracle(&ctx.admin, &project.id, &o1); + + // o2 is now at index 0; o1's old vote is gone. + // o2 votes — but threshold is still 2 and only 1 oracle remains, so it won't release. + // (This tests that the reset happened — o2's vote alone won't meet threshold=2.) + ctx.client.verify_and_release(&o2, &project.id, &ctx.dummy_proof()); + assert_eq!(ctx.client.get_project(&project.id).status, ProjectStatus::Funding); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #28)")] +fn test_remove_nonexistent_oracle_fails() { + let ctx = TestContext::new(); + let o1 = ctx.generate_address(); + let oracles = soroban_sdk::Vec::from_array(&ctx.env, [o1.clone()]); + let project = register_with_oracles(&ctx, &oracles, 1); + + let ghost = ctx.generate_address(); + ctx.client.remove_oracle(&ctx.admin, &project.id, &ghost); +} + +// ── InvalidOracleConfig validation ─────────────────────────────────────────── + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #30)")] +fn test_threshold_exceeds_oracle_count_fails() { + let ctx = TestContext::new(); + let o1 = ctx.generate_address(); + let oracles = soroban_sdk::Vec::from_array(&ctx.env, [o1.clone()]); + // threshold=2 but only 1 oracle — invalid. + register_with_oracles(&ctx, &oracles, 2); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #30)")] +fn test_zero_threshold_with_oracles_fails() { + let ctx = TestContext::new(); + let o1 = ctx.generate_address(); + let oracles = soroban_sdk::Vec::from_array(&ctx.env, [o1.clone()]); + register_with_oracles(&ctx, &oracles, 0); +} diff --git a/contracts/pifp_protocol/src/test_protocol_config.rs b/contracts/pifp_protocol/src/test_protocol_config.rs index 17e7672..b589c63 100644 --- a/contracts/pifp_protocol/src/test_protocol_config.rs +++ b/contracts/pifp_protocol/src/test_protocol_config.rs @@ -1,114 +1,62 @@ -#![cfg(test)] +extern crate std; -use crate::test_utils::{create_token, setup_test}; -use crate::{Error, ProjectStatus, Role}; -use soroban_sdk::{testutils::{Address as _, Ledger}, Address, Vec}; +use crate::{test_utils::TestContext, Role}; +use soroban_sdk::Address; #[test] fn test_update_protocol_config_success() { - let (env, client, admin) = setup_test(); - let recipient = Address::generate(&env); - - // Init sets admin as SuperAdmin - client.update_protocol_config(&admin, &recipient, &500); // 5% - - // No direct getter, but we can verify it works by running a release + let ctx = TestContext::new(); + let recipient = ctx.generate_address(); + ctx.client.update_protocol_config(&ctx.admin, &recipient, &500); } #[test] #[should_panic(expected = "HostError: Error(Contract, #6)")] fn test_update_protocol_config_unauthorized() { - let (env, client, _admin) = setup_test(); - let stranger = Address::generate(&env); - let recipient = Address::generate(&env); - - client.update_protocol_config(&stranger, &recipient, &500); + let ctx = TestContext::new(); + let stranger = ctx.generate_address(); + let recipient = ctx.generate_address(); + ctx.client.update_protocol_config(&stranger, &recipient, &500); } #[test] #[should_panic(expected = "HostError: Error(Contract, #25)")] fn test_update_protocol_config_invalid_bps() { - let (env, client, admin) = setup_test(); - let recipient = Address::generate(&env); - - client.update_protocol_config(&admin, &recipient, &1001); // > 10% + let ctx = TestContext::new(); + let recipient = ctx.generate_address(); + ctx.client.update_protocol_config(&ctx.admin, &recipient, &1001); } #[test] fn test_verify_and_release_with_fees() { - let (env, client, admin) = setup_test(); - let creator = Address::generate(&env); - let donor = Address::generate(&env); - let oracle = Address::generate(&env); - let fee_recipient = Address::generate(&env); - - let token = create_token(&env, &admin); - let accepted_tokens = Vec::from_array(&env, [token.address.clone()]); - - // Setup roles - client.grant_role(&admin, &creator, &Role::ProjectManager); - client.grant_role(&admin, &oracle, &Role::Oracle); - - // Set 5% fee - client.update_protocol_config(&admin, &fee_recipient, &500); - - let proof_hash = [1u8; 32].into(); - let project = client.register_project( - &creator, - &accepted_tokens, - &1000, - &proof_hash, - &(env.ledger().timestamp() + 10000), - &(env.ledger().timestamp() + 10000), &false, - ); - - // Deposit 1000 tokens - token.mint(&donor, &1000); - client.deposit(&project.id, &donor, &token.address, &1000); - - // Verify and release - client.verify_and_release(&oracle, &project.id, &proof_hash); - - // Fee = 1000 * 500 / 10000 = 50 tokens - // Creator = 1000 - 50 = 950 tokens - - assert_eq!(token.balance(&fee_recipient), 50); - assert_eq!(token.balance(&creator), 950); - assert_eq!(token.balance(&client.address), 0); + let ctx = TestContext::new(); + let fee_recipient = ctx.generate_address(); + ctx.client.update_protocol_config(&ctx.admin, &fee_recipient, &500); // 5% + + let (project, token, sac) = ctx.setup_project(1000); + let donor = ctx.generate_address(); + sac.mint(&donor, &1000i128); + ctx.client.deposit(&project.id, &donor, &token.address, &1000i128); + ctx.client.verify_and_release(&ctx.oracle, &project.id, &ctx.dummy_proof()); + + // fee = 1000 * 500 / 10000 = 50; creator gets 950 + assert_eq!(token.balance(&fee_recipient), 50i128); + assert_eq!(token.balance(&ctx.manager), 950i128); + assert_eq!(token.balance(&ctx.client.address), 0i128); } #[test] fn test_verify_and_release_zero_fee() { - let (env, client, admin) = setup_test(); - let creator = Address::generate(&env); - let donor = Address::generate(&env); - let oracle = Address::generate(&env); - let fee_recipient = Address::generate(&env); - - let token = create_token(&env, &admin); - let accepted_tokens = Vec::from_array(&env, [token.address.clone()]); - - client.grant_role(&admin, &creator, &Role::ProjectManager); - client.grant_role(&admin, &oracle, &Role::Oracle); - - // Set 0% fee - client.update_protocol_config(&admin, &fee_recipient, &0); - - let proof_hash = [1u8; 32].into(); - let project = client.register_project( - &creator, - &accepted_tokens, - &1000, - &proof_hash, - &(env.ledger().timestamp() + 10000), - &(env.ledger().timestamp() + 10000), &false, - ); - - token.mint(&donor, &1000); - client.deposit(&project.id, &donor, &token.address, &1000); - - client.verify_and_release(&oracle, &project.id, &proof_hash); - - assert_eq!(token.balance(&fee_recipient), 0); - assert_eq!(token.balance(&creator), 1000); + let ctx = TestContext::new(); + let fee_recipient = ctx.generate_address(); + ctx.client.update_protocol_config(&ctx.admin, &fee_recipient, &0); + + let (project, token, sac) = ctx.setup_project(1000); + let donor = ctx.generate_address(); + sac.mint(&donor, &1000i128); + ctx.client.deposit(&project.id, &donor, &token.address, &1000i128); + ctx.client.verify_and_release(&ctx.oracle, &project.id, &ctx.dummy_proof()); + + assert_eq!(token.balance(&fee_recipient), 0i128); + assert_eq!(token.balance(&ctx.manager), 1000i128); } diff --git a/contracts/pifp_protocol/src/test_refund.rs b/contracts/pifp_protocol/src/test_refund.rs index ec60be0..e5193aa 100644 --- a/contracts/pifp_protocol/src/test_refund.rs +++ b/contracts/pifp_protocol/src/test_refund.rs @@ -1,13 +1,10 @@ extern crate std; -use soroban_sdk::{ - testutils::{Address as _, Ledger}, - token, Address, Bytes, BytesN, Env, -}; +use soroban_sdk::{testutils::Address as _, token, Address, Env}; use crate::{PifpProtocol, PifpProtocolClient, ProjectStatus, Role}; -fn setup() -> (Env, PifpProtocolClient<'static>) { +fn setup() -> (Env, PifpProtocolClient<'static>, Address) { let env = Env::default(); env.mock_all_auths(); let mut ledger = env.ledger().get(); @@ -15,134 +12,96 @@ fn setup() -> (Env, PifpProtocolClient<'static>) { env.ledger().set(ledger); let contract_id = env.register(PifpProtocol, ()); let client = PifpProtocolClient::new(&env, &contract_id); - (env, client) -} - -fn setup_with_init() -> (Env, PifpProtocolClient<'static>, Address) { - let (env, client) = setup(); let super_admin = Address::generate(&env); client.init(&super_admin); (env, client, super_admin) } -fn create_token<'a>(env: &Env, admin: &Address) -> token::Client<'a> { +fn create_token(env: &Env, admin: &Address) -> token::Client<'static> { let addr = env.register_stellar_asset_contract_v2(admin.clone()); token::Client::new(env, &addr.address()) } -fn dummy_proof(env: &Env) -> BytesN<32> { - BytesN::from_array(env, &[0xabu8; 32]) -} - -fn dummy_metadata_uri(env: &Env) -> Bytes { - Bytes::from_slice(env, b"bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") +fn register_project( + env: &Env, + client: &PifpProtocolClient, + super_admin: &Address, + creator: &Address, + token: &token::Client, + deadline: u64, + goal: i128, +) -> crate::types::Project { + client.grant_role(super_admin, creator, &Role::ProjectManager); + let tokens = soroban_sdk::vec![env, token.address.clone()]; + let proof = soroban_sdk::BytesN::from_array(env, &[0xabu8; 32]); + let meta = soroban_sdk::Bytes::from_slice(env, b"bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"); + let empty_oracles: soroban_sdk::Vec
= soroban_sdk::Vec::new(env); + client.register_project(creator, &tokens, &goal, &proof, &meta, &deadline, &false, &empty_oracles, &0u32) } #[test] fn test_refund_success_after_expiry() { - let (env, client, super_admin) = setup_with_init(); + let (env, client, super_admin) = setup(); let creator = Address::generate(&env); let donator = Address::generate(&env); let token_admin = Address::generate(&env); let token = create_token(&env, &token_admin); let deadline = env.ledger().timestamp() + 100; - client.grant_role(&super_admin, &creator, &Role::ProjectManager); - let tokens = soroban_sdk::vec![&env, token.address.clone()]; - let project = client.register_project( - &creator, - &tokens, - &500i128, - &dummy_proof(&env), - &dummy_metadata_uri(&env), - &deadline, - ); - - let token_sac = token::StellarAssetClient::new(&env, &token.address); - token_sac.mint(&donator, &1_000i128); + let project = register_project(&env, &client, &super_admin, &creator, &token, deadline, 500); + + let sac = token::StellarAssetClient::new(&env, &token.address); + sac.mint(&donator, &1_000i128); client.deposit(&project.id, &donator, &token.address, &400i128); let mut ledger = env.ledger().get(); ledger.timestamp = deadline + 1; - ledger.sequence_number = 101; env.ledger().set(ledger); client.refund(&donator, &project.id, &token.address); - let token_client = token::Client::new(&env, &token.address); - assert_eq!(token_client.balance(&donator), 1_000i128); - assert_eq!(token_client.balance(&client.address), 0i128); + assert_eq!(token.balance(&donator), 1_000i128); + assert_eq!(token.balance(&client.address), 0i128); assert_eq!(client.get_balance(&project.id, &token.address), 0i128); - assert_eq!( - client.get_project(&project.id).status, - ProjectStatus::Expired - ); - - let contract_id = client.address.clone(); - env.as_contract(&contract_id, || { - assert_eq!( - crate::storage::get_donator_balance(&env, project.id, &token.address, &donator), - 0 - ); - }); + assert_eq!(client.get_project(&project.id).status, ProjectStatus::Expired); } #[test] #[should_panic(expected = "HostError: Error(Contract, #21)")] fn test_refund_fails_when_not_expired() { - let (env, client, super_admin) = setup_with_init(); + let (env, client, super_admin) = setup(); let creator = Address::generate(&env); let donator = Address::generate(&env); let token_admin = Address::generate(&env); let token = create_token(&env, &token_admin); let deadline = env.ledger().timestamp() + 1000; - client.grant_role(&super_admin, &creator, &Role::ProjectManager); - let tokens = soroban_sdk::vec![&env, token.address.clone()]; - let project = client.register_project( - &creator, - &tokens, - &1_000i128, - &dummy_proof(&env), - &dummy_metadata_uri(&env), - &deadline, - ); - - let token_sac = token::StellarAssetClient::new(&env, &token.address); - token_sac.mint(&donator, &1_000i128); - client.deposit(&project.id, &donator, &token.address, &400i128); + let project = register_project(&env, &client, &super_admin, &creator, &token, deadline, 1000); + let sac = token::StellarAssetClient::new(&env, &token.address); + sac.mint(&donator, &1_000i128); + client.deposit(&project.id, &donator, &token.address, &400i128); client.refund(&donator, &project.id, &token.address); } #[test] #[should_panic(expected = "HostError: Error(Contract, #4)")] fn test_refund_double_refund_fails() { - let (env, client, super_admin) = setup_with_init(); + let (env, client, super_admin) = setup(); let creator = Address::generate(&env); let donator = Address::generate(&env); let token_admin = Address::generate(&env); let token = create_token(&env, &token_admin); let deadline = env.ledger().timestamp() + 100; - client.grant_role(&super_admin, &creator, &Role::ProjectManager); - let tokens = soroban_sdk::vec![&env, token.address.clone()]; - let project = client.register_project( - &creator, - &tokens, - &1_000i128, - &dummy_proof(&env), - &dummy_metadata_uri(&env), - &deadline, - ); - - let token_sac = token::StellarAssetClient::new(&env, &token.address); - token_sac.mint(&donator, &1_000i128); + let project = register_project(&env, &client, &super_admin, &creator, &token, deadline, 1000); + + let sac = token::StellarAssetClient::new(&env, &token.address); + sac.mint(&donator, &1_000i128); client.deposit(&project.id, &donator, &token.address, &400i128); let mut ledger = env.ledger().get(); ledger.timestamp = deadline + 1; - ledger.sequence_number = 101; env.ledger().set(ledger); client.refund(&donator, &project.id, &token.address); @@ -152,7 +111,7 @@ fn test_refund_double_refund_fails() { #[test] #[should_panic(expected = "HostError: Error(Contract, #4)")] fn test_refund_wrong_donator_fails() { - let (env, client, super_admin) = setup_with_init(); + let (env, client, super_admin) = setup(); let creator = Address::generate(&env); let donator = Address::generate(&env); let attacker = Address::generate(&env); @@ -160,24 +119,14 @@ fn test_refund_wrong_donator_fails() { let token = create_token(&env, &token_admin); let deadline = env.ledger().timestamp() + 100; - client.grant_role(&super_admin, &creator, &Role::ProjectManager); - let tokens = soroban_sdk::vec![&env, token.address.clone()]; - let project = client.register_project( - &creator, - &tokens, - &1_000i128, - &dummy_proof(&env), - &dummy_metadata_uri(&env), - &deadline, - ); - - let token_sac = token::StellarAssetClient::new(&env, &token.address); - token_sac.mint(&donator, &1_000i128); + let project = register_project(&env, &client, &super_admin, &creator, &token, deadline, 1000); + + let sac = token::StellarAssetClient::new(&env, &token.address); + sac.mint(&donator, &1_000i128); client.deposit(&project.id, &donator, &token.address, &400i128); let mut ledger = env.ledger().get(); ledger.timestamp = deadline + 1; - ledger.sequence_number = 101; env.ledger().set(ledger); client.refund(&attacker, &project.id, &token.address); @@ -185,91 +134,52 @@ fn test_refund_wrong_donator_fails() { #[test] fn test_refund_success_after_cancellation() { - let (env, client, super_admin) = setup_with_init(); + let (env, client, super_admin) = setup(); let creator = Address::generate(&env); let donator = Address::generate(&env); let token_admin = Address::generate(&env); let token = create_token(&env, &token_admin); let deadline = env.ledger().timestamp() + 1_000; - client.grant_role(&super_admin, &creator, &Role::ProjectManager); - let tokens = soroban_sdk::vec![&env, token.address.clone()]; - let project = client.register_project( - &creator, - &tokens, - &500i128, - &dummy_proof(&env), - &dummy_metadata_uri(&env), - &deadline, - ); - - let token_sac = token::StellarAssetClient::new(&env, &token.address); - token_sac.mint(&donator, &700i128); + let project = register_project(&env, &client, &super_admin, &creator, &token, deadline, 500); + + let sac = token::StellarAssetClient::new(&env, &token.address); + sac.mint(&donator, &700i128); client.deposit(&project.id, &donator, &token.address, &600i128); - assert_eq!( - client.get_project(&project.id).status, - ProjectStatus::Active - ); + assert_eq!(client.get_project(&project.id).status, ProjectStatus::Active); client.cancel_project(&creator, &project.id); - assert_eq!( - client.get_project(&project.id).status, - ProjectStatus::Cancelled - ); + assert_eq!(client.get_project(&project.id).status, ProjectStatus::Cancelled); client.refund(&donator, &project.id, &token.address); - - let token_client = token::Client::new(&env, &token.address); - assert_eq!(token_client.balance(&donator), 700i128); - assert_eq!(token_client.balance(&client.address), 0i128); - assert_eq!(client.get_balance(&project.id, &token.address), 0i128); + assert_eq!(token.balance(&donator), 700i128); + assert_eq!(token.balance(&client.address), 0i128); } #[test] fn test_refund_distribution_after_cancellation_multi_donor() { - let (env, client, super_admin) = setup_with_init(); + let (env, client, super_admin) = setup(); let creator = Address::generate(&env); - let donator_a = Address::generate(&env); - let donator_b = Address::generate(&env); + let da = Address::generate(&env); + let db = Address::generate(&env); let token_admin = Address::generate(&env); let token = create_token(&env, &token_admin); let deadline = env.ledger().timestamp() + 1_000; - client.grant_role(&super_admin, &creator, &Role::ProjectManager); - let tokens = soroban_sdk::vec![&env, token.address.clone()]; - let project = client.register_project( - &creator, - &tokens, - &700i128, - &dummy_proof(&env), - &dummy_metadata_uri(&env), - &deadline, - ); - - let token_sac = token::StellarAssetClient::new(&env, &token.address); - token_sac.mint(&donator_a, &1_000i128); - token_sac.mint(&donator_b, &1_000i128); - - client.deposit(&project.id, &donator_a, &token.address, &300i128); - client.deposit(&project.id, &donator_b, &token.address, &500i128); - assert_eq!(client.get_balance(&project.id, &token.address), 800i128); - assert_eq!( - client.get_project(&project.id).status, - ProjectStatus::Active - ); + let project = register_project(&env, &client, &super_admin, &creator, &token, deadline, 700); + + let sac = token::StellarAssetClient::new(&env, &token.address); + sac.mint(&da, &1_000i128); + sac.mint(&db, &1_000i128); + client.deposit(&project.id, &da, &token.address, &300i128); + client.deposit(&project.id, &db, &token.address, &500i128); + assert_eq!(client.get_project(&project.id).status, ProjectStatus::Active); client.cancel_project(&super_admin, &project.id); - assert_eq!( - client.get_project(&project.id).status, - ProjectStatus::Cancelled - ); - - client.refund(&donator_a, &project.id, &token.address); - client.refund(&donator_b, &project.id, &token.address); - - let token_client = token::Client::new(&env, &token.address); - assert_eq!(token_client.balance(&donator_a), 1_000i128); - assert_eq!(token_client.balance(&donator_b), 1_000i128); - assert_eq!(token_client.balance(&client.address), 0i128); - assert_eq!(client.get_balance(&project.id, &token.address), 0i128); + client.refund(&da, &project.id, &token.address); + client.refund(&db, &project.id, &token.address); + + assert_eq!(token.balance(&da), 1_000i128); + assert_eq!(token.balance(&db), 1_000i128); + assert_eq!(token.balance(&client.address), 0i128); } diff --git a/contracts/pifp_protocol/src/test_utils.rs b/contracts/pifp_protocol/src/test_utils.rs index dcdbb1b..f24bf8d 100644 --- a/contracts/pifp_protocol/src/test_utils.rs +++ b/contracts/pifp_protocol/src/test_utils.rs @@ -20,7 +20,6 @@ impl TestContext { let env = Env::default(); env.mock_all_auths(); - // Initialize ledger while preserving host's default protocol version let mut ledger = env.ledger().get(); ledger.timestamp = 100_000; ledger.sequence_number = 100; @@ -37,19 +36,11 @@ impl TestContext { client.grant_role(&admin, &oracle, &Role::Oracle); client.grant_role(&admin, &manager, &Role::ProjectManager); - Self { - env, - client, - admin, - oracle, - manager, - } + Self { env, client, admin, oracle, manager } } pub fn create_token(&self) -> (token::Client<'static>, token::StellarAssetClient<'static>) { - let addr = self - .env - .register_stellar_asset_contract_v2(self.admin.clone()); + let addr = self.env.register_stellar_asset_contract_v2(self.admin.clone()); ( token::Client::new(&self.env, &addr.address()), token::StellarAssetClient::new(&self.env, &addr.address()), @@ -59,23 +50,19 @@ impl TestContext { pub fn setup_project( &self, goal: i128, - ) -> ( - Project, - token::Client<'static>, - token::StellarAssetClient<'static>, - ) { + ) -> (Project, token::Client<'static>, token::StellarAssetClient<'static>) { let (token, sac) = self.create_token(); let tokens = Vec::from_array(&self.env, [token.address.clone()]); let project = self.register_project(&tokens, goal, false); (project, token, sac) } + /// Register a project using the legacy single-oracle path (empty oracle list). pub fn register_project(&self, tokens: &Vec
, goal: i128, is_private: bool) -> Project { let proof_hash = self.dummy_proof(); let metadata_uri = self.dummy_metadata_uri(); let deadline = self.env.ledger().timestamp() + 86400; - self.client - .register_project(&self.manager, tokens, &goal, &proof_hash, &deadline, &is_private) + let empty_oracles: Vec
= Vec::new(&self.env); self.client.register_project( &self.manager, tokens, @@ -83,6 +70,9 @@ impl TestContext { &proof_hash, &metadata_uri, &deadline, + &is_private, + &empty_oracles, + &0u32, ) } diff --git a/contracts/pifp_protocol/src/test_whitelist.rs b/contracts/pifp_protocol/src/test_whitelist.rs index 17260fb..b457c23 100644 --- a/contracts/pifp_protocol/src/test_whitelist.rs +++ b/contracts/pifp_protocol/src/test_whitelist.rs @@ -1,94 +1,87 @@ -#![cfg(test)] +extern crate std; -use crate::test_utils::{create_token, setup_test}; -use crate::{Error, ProjectStatus, Role}; -use soroban_sdk::{testutils::{Address as _, Ledger}, Address, Vec}; +use crate::{test_utils::TestContext, Role}; #[test] fn test_whitelist_funding_restricted() { - let (env, client, admin) = setup_test(); - let creator = Address::generate(&env); - let donor = Address::generate(&env); - let token = create_token(&env, &admin); - let accepted_tokens = Vec::from_array(&env, [token.address.clone()]); - - client.grant_role(&admin, &creator, &Role::ProjectManager); - - // Register a private project - let project = client.register_project( - &creator, - &accepted_tokens, - &1000, - &[0u8; 32].into(), - &(env.ledger().timestamp() + 10000), + let ctx = TestContext::new(); + let (token, _) = ctx.create_token(); + let tokens = soroban_sdk::Vec::from_array(&ctx.env, [token.address.clone()]); + let empty_oracles: soroban_sdk::Vec = soroban_sdk::Vec::new(&ctx.env); + let project = ctx.client.register_project( + &ctx.manager, + &tokens, + &1000i128, + &ctx.dummy_proof(), + &ctx.dummy_metadata_uri(), + &(ctx.env.ledger().timestamp() + 86400), &true, // is_private + &empty_oracles, + &0u32, ); - - // Attempt deposit from non-whitelisted donor - token.mint(&donor, &500); - let result = client.try_deposit(&project.id, &donor, &token.address, &500); - + + let donor = ctx.generate_address(); + let sac = soroban_sdk::token::StellarAssetClient::new(&ctx.env, &token.address); + sac.mint(&donor, &500i128); + + let result = ctx.client.try_deposit(&project.id, &donor, &token.address, &500i128); assert!(result.is_err()); - // Error::NotWhitelisted = 26 } #[test] fn test_whitelist_funding_allowed() { - let (env, client, admin) = setup_test(); - let creator = Address::generate(&env); - let donor = Address::generate(&env); - let token = create_token(&env, &admin); - let accepted_tokens = Vec::from_array(&env, [token.address.clone()]); - - client.grant_role(&admin, &creator, &Role::ProjectManager); - - let project = client.register_project( - &creator, - &accepted_tokens, - &1000, - &[0u8; 32].into(), - &(env.ledger().timestamp() + 10000), + let ctx = TestContext::new(); + let (token, sac) = ctx.create_token(); + let tokens = soroban_sdk::Vec::from_array(&ctx.env, [token.address.clone()]); + let empty_oracles: soroban_sdk::Vec = soroban_sdk::Vec::new(&ctx.env); + let project = ctx.client.register_project( + &ctx.manager, + &tokens, + &1000i128, + &ctx.dummy_proof(), + &ctx.dummy_metadata_uri(), + &(ctx.env.ledger().timestamp() + 86400), &true, + &empty_oracles, + &0u32, ); - - // Add donor to whitelist - client.add_to_whitelist(&creator, &project.id, &donor); - - // Deposit should now work - token.mint(&donor, &500); - client.deposit(&project.id, &donor, &token.address, &500); - - let balance = client.get_balance(&project.id, &token.address); - assert_eq!(balance, 500); + + let donor = ctx.generate_address(); + ctx.client.add_to_whitelist(&ctx.manager, &project.id, &donor); + + sac.mint(&donor, &500i128); + ctx.client.deposit(&project.id, &donor, &token.address, &500i128); + assert_eq!(ctx.client.get_balance(&project.id, &token.address), 500i128); } #[test] fn test_whitelist_management_auth() { - let (env, client, admin) = setup_test(); - let creator = Address::generate(&env); - let stranger = Address::generate(&env); - let donor = Address::generate(&env); - let token = create_token(&env, &admin); - let accepted_tokens = Vec::from_array(&env, [token.address.clone()]); - - client.grant_role(&admin, &creator, &Role::ProjectManager); - - let project = client.register_project( - &creator, - &accepted_tokens, - &1000, - &[0u8; 32].into(), - &(env.ledger().timestamp() + 10000), + let ctx = TestContext::new(); + let (token, _) = ctx.create_token(); + let tokens = soroban_sdk::Vec::from_array(&ctx.env, [token.address.clone()]); + let empty_oracles: soroban_sdk::Vec = soroban_sdk::Vec::new(&ctx.env); + let project = ctx.client.register_project( + &ctx.manager, + &tokens, + &1000i128, + &ctx.dummy_proof(), + &ctx.dummy_metadata_uri(), + &(ctx.env.ledger().timestamp() + 86400), &true, + &empty_oracles, + &0u32, ); - - // Stranger cannot add to whitelist - let result = client.try_add_to_whitelist(&stranger, &project.id, &donor); + + let stranger = ctx.generate_address(); + let donor = ctx.generate_address(); + + // Stranger cannot add to whitelist. + let result = ctx.client.try_add_to_whitelist(&stranger, &project.id, &donor); assert!(result.is_err()); - - // Admin CAN add to whitelist - client.add_to_whitelist(&admin, &project.id, &donor); - - // Creator can remove - client.remove_from_whitelist(&creator, &project.id, &donor); + + // Admin can add. + ctx.client.add_to_whitelist(&ctx.admin, &project.id, &donor); + + // Creator can remove. + ctx.client.remove_from_whitelist(&ctx.manager, &project.id, &donor); } diff --git a/contracts/pifp_protocol/src/types.rs b/contracts/pifp_protocol/src/types.rs index e286615..cd2320f 100644 --- a/contracts/pifp_protocol/src/types.rs +++ b/contracts/pifp_protocol/src/types.rs @@ -62,6 +62,30 @@ pub struct ProjectConfig { pub deadline: u64, pub is_private: bool, pub metadata_uri: Bytes, + /// Ordered list of authorized oracle addresses for M-of-N verification. + /// Index position maps directly to the bit position in `OracleAgreement.votes`. + /// Maximum 32 oracles (fits in a u32 BitSet). + pub authorized_oracles: Vec
, + /// Minimum number of oracle votes required to release funds (the "M" in M-of-N). + pub threshold: u32, +} + +/// Tracks in-flight oracle votes for a project using a BitSet. +/// +/// Stored in `temporary` storage — it only needs to live until the threshold +/// is met, at which point it is cleared and funds are released. +/// +/// # BitSet layout +/// Bit `i` of `votes` is set when the oracle at index `i` in +/// `ProjectConfig.authorized_oracles` has submitted a valid vote. +/// This prevents double-voting without any additional storage keys. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleAgreement { + /// BitSet: bit `i` is 1 if oracle at index `i` has voted. + pub votes: u32, + /// Count of unique oracles that have voted so far. + pub voter_count: u32, } /// Mutable project state, updated on deposits and verification. @@ -114,6 +138,10 @@ pub struct Project { /// Ledger timestamp after which donors can no longer refund and the /// creator may reclaim unclaimed funds. Zero while non-terminal. pub refund_expiry: u64, + /// Ordered list of authorized oracle addresses for M-of-N verification. + pub authorized_oracles: soroban_sdk::Vec
, + /// Minimum oracle votes required to release funds. + pub threshold: u32, } impl Project {