diff --git a/backend/.cargo/config.toml b/backend/.cargo/config.toml index 15f9047..a766730 100644 --- a/backend/.cargo/config.toml +++ b/backend/.cargo/config.toml @@ -2,4 +2,6 @@ [env] # Tell SQLx to use offline mode (cached query metadata) for compilation -SQLX_OFFLINE = "true" +# NOTE: Commented out because it requires .sqlx cache to be pre-generated +# To enable offline mode, run: cargo sqlx prepare +# SQLX_OFFLINE = "true" diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 5016cee..cf9a57c 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -79,7 +79,7 @@ dependencies = [ "flate2", "foldhash", "futures-core", - "h2 0.3.27", + "h2", "http 0.2.12", "httparse", "httpdate", @@ -665,16 +665,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1027,21 +1017,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1187,9 +1162,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1211,25 +1188,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http 1.4.0", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.12.3" @@ -1370,7 +1328,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", "http 1.4.0", "http-body", "httparse", @@ -1396,22 +1353,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", + "webpki-roots 1.0.5", ] [[package]] @@ -1433,11 +1375,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.1", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1711,12 +1651,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - [[package]] name = "litemap" version = "0.8.1" @@ -1755,6 +1689,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1808,23 +1748,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1902,50 +1825,6 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "openssl" -version = "0.10.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "parking" version = "2.2.1" @@ -2123,6 +2002,61 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.43" @@ -2299,29 +2233,26 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", - "encoding_rs", "futures-core", - "h2 0.4.13", "http 1.4.0", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -2329,6 +2260,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.5", ] [[package]] @@ -2410,6 +2342,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2419,19 +2357,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - [[package]] name = "rustls" version = "0.23.36" @@ -2452,6 +2377,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -2478,15 +2404,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" -[[package]] -name = "schannel" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -2499,29 +2416,6 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.27" @@ -2993,46 +2887,12 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags", - "core-foundation", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tap" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "tempfile" -version = "3.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" -dependencies = [ - "fastrand", - "getrandom 0.3.4", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -3166,16 +3026,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -3592,6 +3442,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -3661,17 +3521,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index df79fed..ccd38ee 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -25,7 +25,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "std"] } sha2 = "0.10" dotenvy = "0.15" actix-cors = "0.6" -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } base64 = "0.22" ed25519-dalek = { version = "2.1", features = ["rand_core"] } rand = "0.8" diff --git a/backend/src/api_error.rs b/backend/src/api_error.rs index 25c11d6..57d0527 100644 --- a/backend/src/api_error.rs +++ b/backend/src/api_error.rs @@ -35,6 +35,37 @@ pub enum ApiError { ValidationError(String), } +// Helper methods for convenience +impl ApiError { + pub fn bad_request(message: impl Into) -> Self { + ApiError::BadRequest(message.into()) + } + + pub fn internal_error(message: impl Into) -> Self { + ApiError::InternalServerError + } + + pub fn database_error(e: impl Into) -> Self { + ApiError::DatabaseError(e.into()) + } + + pub fn not_found(message: impl Into) -> Self { + ApiError::NotFound + } + + pub fn unauthorized(message: impl Into) -> Self { + ApiError::Unauthorized + } + + pub fn forbidden(message: impl Into) -> Self { + ApiError::Forbidden + } + + pub fn conflict(message: impl Into) -> Self { + ApiError::Conflict(message.into()) + } +} + #[derive(Serialize)] struct ErrorResponse { error: String, @@ -45,16 +76,30 @@ struct ErrorResponse { impl ResponseError for ApiError { fn error_response(&self) -> HttpResponse { let (status, message) = match self { - ApiError::InternalServerError => (actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), + ApiError::InternalServerError => ( + actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + self.to_string(), + ), ApiError::BadRequest(_) => (actix_web::http::StatusCode::BAD_REQUEST, self.to_string()), ApiError::Unauthorized => (actix_web::http::StatusCode::UNAUTHORIZED, self.to_string()), ApiError::Forbidden => (actix_web::http::StatusCode::FORBIDDEN, self.to_string()), ApiError::NotFound => (actix_web::http::StatusCode::NOT_FOUND, self.to_string()), ApiError::Conflict(_) => (actix_web::http::StatusCode::CONFLICT, self.to_string()), - ApiError::DatabaseError(_) => (actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string()), - ApiError::RedisError(_) => (actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, "Cache error".to_string()), - ApiError::StellarError(_) => (actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, "Blockchain error".to_string()), - ApiError::ValidationError(_) => (actix_web::http::StatusCode::BAD_REQUEST, self.to_string()), + ApiError::DatabaseError(_) => ( + actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + "Database error".to_string(), + ), + ApiError::RedisError(_) => ( + actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + "Cache error".to_string(), + ), + ApiError::StellarError(_) => ( + actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + "Blockchain error".to_string(), + ), + ApiError::ValidationError(_) => { + (actix_web::http::StatusCode::BAD_REQUEST, self.to_string()) + } }; let error_response = ErrorResponse { diff --git a/backend/src/auth/device_service.rs b/backend/src/auth/device_service.rs index e7a3466..62b3c8d 100644 --- a/backend/src/auth/device_service.rs +++ b/backend/src/auth/device_service.rs @@ -153,7 +153,7 @@ impl SecurityMonitor { let key = format!("device:login:{}", device_id); let value = if success { "1" } else { "0" }; - + redis::cmd("LPUSH") .arg(&key) .arg(value) @@ -281,7 +281,7 @@ impl DeviceService { /// Generate a device fingerprint from device information pub fn generate_fingerprint(&self, device_info: &DeviceInfo) -> String { let mut hasher = Sha256::new(); - + // Combine device characteristics let fingerprint_data = format!( "{}{}{}{}{}{}{}{}{}", @@ -289,10 +289,22 @@ impl DeviceService { device_info.user_agent, device_info.platform, device_info.os, - device_info.browser.as_ref().unwrap_or(&"unknown".to_string()), - device_info.screen_resolution.as_ref().unwrap_or(&"unknown".to_string()), - device_info.timezone.as_ref().unwrap_or(&"unknown".to_string()), - device_info.language.as_ref().unwrap_or(&"unknown".to_string()), + device_info + .browser + .as_ref() + .unwrap_or(&"unknown".to_string()), + device_info + .screen_resolution + .as_ref() + .unwrap_or(&"unknown".to_string()), + device_info + .timezone + .as_ref() + .unwrap_or(&"unknown".to_string()), + device_info + .language + .as_ref() + .unwrap_or(&"unknown".to_string()), device_info.ip_address, ); @@ -338,7 +350,7 @@ impl DeviceService { device.updated_at = Utc::now(); sqlx::query( - "UPDATE devices SET last_seen = $1, is_active = $2, login_count = $3, + "UPDATE devices SET last_seen = $1, is_active = $2, login_count = $3, last_login = $4, ip_address = $5, updated_at = $6 WHERE id = $7", ) .bind(device.last_seen) @@ -411,12 +423,10 @@ impl DeviceService { .await?; // Get the created device - let device = sqlx::query_as::<_, Device>( - "SELECT * FROM devices WHERE id = $1", - ) - .bind(device_id) - .fetch_one(&self.db_pool) - .await?; + let device = sqlx::query_as::<_, Device>("SELECT * FROM devices WHERE id = $1") + .bind(device_id) + .fetch_one(&self.db_pool) + .await?; info!( device_id = %device_id, @@ -441,35 +451,27 @@ impl DeviceService { /// Get a specific device by ID pub async fn get_device(&self, device_id: Uuid) -> Result { - let device = sqlx::query_as::<_, Device>( - "SELECT * FROM devices WHERE id = $1", - ) - .bind(device_id) - .fetch_optional(&self.db_pool) - .await? - .ok_or(DeviceError::DeviceNotFound)?; + let device = sqlx::query_as::<_, Device>("SELECT * FROM devices WHERE id = $1") + .bind(device_id) + .fetch_optional(&self.db_pool) + .await? + .ok_or(DeviceError::DeviceNotFound)?; Ok(device) } /// Get device count for a user async fn get_user_device_count(&self, user_id: Uuid) -> Result { - let count: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM devices WHERE user_id = $1", - ) - .bind(user_id) - .fetch_one(&self.db_pool) - .await?; + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM devices WHERE user_id = $1") + .bind(user_id) + .fetch_one(&self.db_pool) + .await?; Ok(count.0) } /// Revoke/remove a device - pub async fn revoke_device( - &self, - user_id: Uuid, - device_id: Uuid, - ) -> Result<(), DeviceError> { + pub async fn revoke_device(&self, user_id: Uuid, device_id: Uuid) -> Result<(), DeviceError> { // Verify device belongs to user let device = self.get_device(device_id).await?; if device.user_id != user_id { @@ -630,11 +632,7 @@ impl DeviceService { } /// Block a device - pub async fn block_device( - &self, - user_id: Uuid, - device_id: Uuid, - ) -> Result<(), DeviceError> { + pub async fn block_device(&self, user_id: Uuid, device_id: Uuid) -> Result<(), DeviceError> { let device = self.get_device(device_id).await?; if device.user_id != user_id { return Err(DeviceError::DeviceNotFound); @@ -656,11 +654,7 @@ impl DeviceService { } /// Unblock a device - pub async fn unblock_device( - &self, - user_id: Uuid, - device_id: Uuid, - ) -> Result<(), DeviceError> { + pub async fn unblock_device(&self, user_id: Uuid, device_id: Uuid) -> Result<(), DeviceError> { let device = self.get_device(device_id).await?; if device.user_id != user_id { return Err(DeviceError::DeviceNotFound); @@ -682,11 +676,7 @@ impl DeviceService { } /// Trust a device - pub async fn trust_device( - &self, - user_id: Uuid, - device_id: Uuid, - ) -> Result<(), DeviceError> { + pub async fn trust_device(&self, user_id: Uuid, device_id: Uuid) -> Result<(), DeviceError> { let device = self.get_device(device_id).await?; if device.user_id != user_id { return Err(DeviceError::DeviceNotFound); @@ -724,7 +714,10 @@ impl DeviceService { }; let devices: Vec = if let Some(uid) = user_id { - sqlx::query_as(query).bind(uid).fetch_all(&self.db_pool).await? + sqlx::query_as(query) + .bind(uid) + .fetch_all(&self.db_pool) + .await? } else { sqlx::query_as(query).fetch_all(&self.db_pool).await? }; @@ -732,10 +725,7 @@ impl DeviceService { let total_devices = devices.len() as i64; let active_devices = devices.iter().filter(|d| d.is_active).count() as i64; let blocked_devices = devices.iter().filter(|d| d.is_blocked).count() as i64; - let suspicious_devices = devices - .iter() - .filter(|d| d.failed_login_count > 5) - .count() as i64; + let suspicious_devices = devices.iter().filter(|d| d.failed_login_count > 5).count() as i64; let mut devices_by_type = HashMap::new(); let mut devices_by_platform = HashMap::new(); @@ -745,7 +735,9 @@ impl DeviceService { for device in &devices { let device_type_str = format!("{:?}", device.device_type); *devices_by_type.entry(device_type_str).or_insert(0) += 1; - *devices_by_platform.entry(device.platform.clone()).or_insert(0) += 1; + *devices_by_platform + .entry(device.platform.clone()) + .or_insert(0) += 1; if device.last_login.is_some() { recent_logins += 1; @@ -767,22 +759,17 @@ impl DeviceService { /// Clean up inactive devices pub async fn cleanup_inactive_devices(&self) -> Result { - let cutoff_date = Utc::now() - - chrono::Duration::days(self.config.device_inactivity_days as i64); + let cutoff_date = + Utc::now() - chrono::Duration::days(self.config.device_inactivity_days as i64); - let result = sqlx::query( - "DELETE FROM devices WHERE last_seen < $1 AND is_active = false", - ) - .bind(cutoff_date) - .execute(&self.db_pool) - .await?; + let result = sqlx::query("DELETE FROM devices WHERE last_seen < $1 AND is_active = false") + .bind(cutoff_date) + .execute(&self.db_pool) + .await?; let deleted_count = result.rows_affected(); - info!( - deleted_count = deleted_count, - "Cleaned up inactive devices" - ); + info!(deleted_count = deleted_count, "Cleaned up inactive devices"); Ok(deleted_count) } @@ -797,37 +784,54 @@ impl DeviceService { // Note: This assumes a device_security_alerts table exists // For now, we'll return alerts from memory or create a simplified version - let alerts: Vec<(Uuid, Uuid, String, String, String, Option, DateTime)> = - sqlx::query_as( - "SELECT device_id, user_id, alert_type, severity, message, details, created_at - FROM device_security_alerts - WHERE device_id = $1 - ORDER BY created_at DESC + let alerts: Vec<( + Uuid, + Uuid, + String, + String, + String, + Option, + DateTime, + )> = sqlx::query_as( + "SELECT device_id, user_id, alert_type, severity, message, details, created_at + FROM device_security_alerts + WHERE device_id = $1 + ORDER BY created_at DESC LIMIT $2", - ) - .bind(device_id) - .bind(limit) - .fetch_all(&self.db_pool) - .await?; + ) + .bind(device_id) + .bind(limit) + .fetch_all(&self.db_pool) + .await?; let security_alerts = alerts .into_iter() - .map(|(device_id, user_id, alert_type_str, severity_str, message, details, created_at)| { - let alert_type: AlertType = serde_json::from_str(&alert_type_str) - .unwrap_or(AlertType::UnusualActivity); - let severity: AlertSeverity = serde_json::from_str(&severity_str) - .unwrap_or(AlertSeverity::Medium); - - SecurityAlert { + .map( + |( device_id, user_id, - alert_type, - severity, + alert_type_str, + severity_str, message, details, created_at, - } - }) + )| { + let alert_type: AlertType = + serde_json::from_str(&alert_type_str).unwrap_or(AlertType::UnusualActivity); + let severity: AlertSeverity = + serde_json::from_str(&severity_str).unwrap_or(AlertSeverity::Medium); + + SecurityAlert { + device_id, + user_id, + alert_type, + severity, + message, + details, + created_at, + } + }, + ) .collect(); Ok(security_alerts) diff --git a/backend/src/auth/jwt_service.rs b/backend/src/auth/jwt_service.rs index ddf048e..dbcd7d0 100644 --- a/backend/src/auth/jwt_service.rs +++ b/backend/src/auth/jwt_service.rs @@ -53,10 +53,10 @@ impl From for JwtError { /// JWT Claims structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Claims { - pub sub: String, // Subject (user ID) - pub exp: i64, // Expiration time - pub iat: i64, // Issued at - pub jti: String, // JWT ID (unique token identifier) + pub sub: String, // Subject (user ID) + pub exp: i64, // Expiration time + pub iat: i64, // Issued at + pub jti: String, // JWT ID (unique token identifier) pub token_type: TokenType, pub device_id: Option, pub session_id: String, @@ -248,8 +248,12 @@ impl JwtService { roles: Vec, device_id: Option, ) -> Result { - let access_token = self.generate_access_token(user_id, roles.clone(), device_id.clone()).await?; - let refresh_token = self.generate_refresh_token(user_id, roles, device_id).await?; + let access_token = self + .generate_access_token(user_id, roles.clone(), device_id.clone()) + .await?; + let refresh_token = self + .generate_refresh_token(user_id, roles, device_id) + .await?; Ok(TokenPair { access_token, @@ -323,8 +327,8 @@ impl JwtService { return Err(JwtError::InvalidToken); } - let user_id = Uuid::parse_str(&claims.sub) - .map_err(|e| JwtError::TokenValidation(e.to_string()))?; + let user_id = + Uuid::parse_str(&claims.sub).map_err(|e| JwtError::TokenValidation(e.to_string()))?; // Generate new token pair let token_pair = self @@ -354,7 +358,8 @@ impl JwtService { let blacklist_key = format!("blacklist:{}", claims.jti); let mut conn = self.redis.clone(); - conn.set_ex(&blacklist_key, reason, exp_duration as u64).await?; + conn.set_ex(&blacklist_key, reason, exp_duration as u64) + .await?; // Increment analytics self.increment_analytics("blacklisted").await?; @@ -412,8 +417,11 @@ impl JwtService { // Add to user's active sessions let user_sessions_key = format!("user_sessions:{}", user_id); conn.sadd(&user_sessions_key, session_id).await?; - conn.expire(&user_sessions_key, self.config.refresh_token_expiry.num_seconds() as i64) - .await?; + conn.expire( + &user_sessions_key, + self.config.refresh_token_expiry.num_seconds() as i64, + ) + .await?; Ok(()) } @@ -435,13 +443,13 @@ impl JwtService { let session_json: Option = conn.get(&session_key).await?; if let Some(json) = session_json { - let mut session: SessionData = serde_json::from_str(&json) - .map_err(|e| JwtError::RedisError(e.to_string()))?; + let mut session: SessionData = + serde_json::from_str(&json).map_err(|e| JwtError::RedisError(e.to_string()))?; session.last_activity = Utc::now().timestamp(); - let updated_json = serde_json::to_string(&session) - .map_err(|e| JwtError::RedisError(e.to_string()))?; + let updated_json = + serde_json::to_string(&session).map_err(|e| JwtError::RedisError(e.to_string()))?; conn.set_ex( &session_key, diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index 370592b..58f525d 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -1,14 +1,13 @@ +pub mod device_service; pub mod jwt_service; pub mod middleware; +pub use device_service::{ + AlertSeverity, AlertType, Device, DeviceAnalytics, DeviceConfig, DeviceError, DeviceInfo, + DeviceService, DeviceType, SecurityAlert, +}; pub use jwt_service::{ Claims, JwtConfig, JwtError, JwtService, KeyRotation, SessionData, TokenAnalytics, TokenPair, TokenType, }; pub use middleware::AuthMiddleware; -pub mod device_service; - -pub use device_service::{ - Device, DeviceInfo, DeviceService, DeviceError, DeviceType, DeviceConfig, - SecurityAlert, AlertType, AlertSeverity, DeviceAnalytics, -}; diff --git a/backend/src/config.rs b/backend/src/config.rs index 01c8b90..d6da249 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -121,8 +121,14 @@ impl Config { soroban_contract_reputation, soroban_contract_arenax_token, }, - ai: AiConfig { model_path: ai_model_path }, - server: ServerConfig { port, host, rust_log }, + ai: AiConfig { + model_path: ai_model_path, + }, + server: ServerConfig { + port, + host, + rust_log, + }, rate_limit: RateLimitConfig { requests: rate_limit_requests, window: rate_limit_window, diff --git a/backend/src/db.rs b/backend/src/db.rs index 8fafecc..41f0e4b 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -1,6 +1,6 @@ -use sqlx::{PgPool, postgres::PgPoolOptions}; -use crate::config::Config; use crate::api_error::ApiError; +use crate::config::Config; +use sqlx::{postgres::PgPoolOptions, PgPool}; pub type DbPool = PgPool; diff --git a/backend/src/http/health.rs b/backend/src/http/health.rs index af994ce..1abec97 100644 --- a/backend/src/http/health.rs +++ b/backend/src/http/health.rs @@ -1,6 +1,6 @@ -use actix_web::{web, HttpResponse, Result}; -use crate::db::DbPool; use crate::api_error::ApiError; +use crate::db::DbPool; +use actix_web::{web, HttpResponse, Result}; pub async fn health_check(db_pool: web::Data) -> Result { // Check database diff --git a/backend/src/http/match_authority_handler.rs b/backend/src/http/match_authority_handler.rs index 61ddff7..3e4a675 100644 --- a/backend/src/http/match_authority_handler.rs +++ b/backend/src/http/match_authority_handler.rs @@ -183,10 +183,7 @@ pub async fn get_match( info!(match_id = %match_id, "Received get match request"); - let result = state - .match_authority_service - .get_match(match_id) - .await?; + let result = state.match_authority_service.get_match(match_id).await?; Ok(HttpResponse::Ok().json(result)) } diff --git a/backend/src/http/match_ws_handler.rs b/backend/src/http/match_ws_handler.rs index 449be4d..8333947 100644 --- a/backend/src/http/match_ws_handler.rs +++ b/backend/src/http/match_ws_handler.rs @@ -11,9 +11,13 @@ use uuid::Uuid; #[serde(tag = "type", rename_all = "snake_case")] pub enum WsMessage { /// Subscribe to match updates - Subscribe { match_id: Uuid }, + Subscribe { + match_id: Uuid, + }, /// Unsubscribe from match updates - Unsubscribe { match_id: Uuid }, + Unsubscribe { + match_id: Uuid, + }, /// Match state changed MatchStateChanged { match_id: Uuid, @@ -51,7 +55,9 @@ pub enum WsMessage { finalized_at: String, }, /// Error message - Error { message: String }, + Error { + message: String, + }, /// Ping/Pong for keepalive Ping, Pong, @@ -152,7 +158,12 @@ impl MatchWebSocket { } /// Broadcast message to this session if subscribed to match - pub fn broadcast_if_subscribed(&self, match_id: Uuid, message: &WsMessage, ctx: &mut ::Context) { + pub fn broadcast_if_subscribed( + &self, + match_id: Uuid, + message: &WsMessage, + ctx: &mut ::Context, + ) { if self.subscriptions.contains(&match_id) { debug!( session_id = %self.id, diff --git a/backend/src/http/mod.rs b/backend/src/http/mod.rs index ab31b49..3924947 100644 --- a/backend/src/http/mod.rs +++ b/backend/src/http/mod.rs @@ -2,7 +2,8 @@ pub mod health; pub mod match_authority_handler; pub mod match_ws_handler; pub mod notification_handler; + // TODO: Add more HTTP modules as implemented: -// pub mod tournaments; -// pub mod matches; // pub mod auth; +// pub mod matches; +// pub mod tournaments; diff --git a/backend/src/http/notification_handler.rs b/backend/src/http/notification_handler.rs index a41a502..0d65d0e 100644 --- a/backend/src/http/notification_handler.rs +++ b/backend/src/http/notification_handler.rs @@ -86,11 +86,7 @@ pub async fn create_notification( ) -> Result { let user_id = req.user_id().ok_or(ApiError::Unauthorized)?; - let typ = body - .typ - .as_deref() - .unwrap_or("info") - .to_string(); + let typ = body.typ.as_deref().unwrap_or("info").to_string(); let message = body.message.as_deref().unwrap_or("").to_string(); let row = sqlx::query_as::<_, NotificationRow>( diff --git a/backend/src/lib.rs b/backend/src/lib.rs index e149291..68feda9 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -3,8 +3,7 @@ pub mod auth; pub mod config; pub mod db; pub mod http; +pub mod middleware; pub mod models; pub mod service; pub mod telemetry; -pub mod middleware; -pub mod auth; diff --git a/backend/src/main.rs b/backend/src/main.rs index 90f860b..3f7097a 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -2,19 +2,19 @@ use actix_web::{web, App, HttpServer}; use std::io; use tokio::signal; -mod config; -mod db; mod api_error; -mod telemetry; -mod middleware; mod auth; +mod config; +mod db; mod http; +mod middleware; mod service; +mod telemetry; use crate::config::Config; use crate::db::create_pool; -use crate::telemetry::init_telemetry; use crate::middleware::cors_middleware; +use crate::telemetry::init_telemetry; #[tokio::main] async fn main() -> io::Result<()> { @@ -32,7 +32,11 @@ async fn main() -> io::Result<()> { // Create Redis client (placeholder) // let redis_client = redis::Client::open(config.redis.url.clone()).unwrap(); - tracing::info!("Starting ArenaX backend server on {}:{}", config.server.host, config.server.port); + tracing::info!( + "Starting ArenaX backend server on {}:{}", + config.server.host, + config.server.port + ); let server = HttpServer::new(move || { App::new() @@ -43,11 +47,26 @@ async fn main() -> io::Result<()> { .service( web::scope("/api") .route("/health", web::get().to(crate::http::health::health_check)) - .route("/notifications", web::get().to(crate::http::notification_handler::get_notifications)) - .route("/notifications", web::post().to(crate::http::notification_handler::create_notification)) - .route("/notifications/read-all", web::patch().to(crate::http::notification_handler::mark_all_read)) - .route("/notifications/{id}/read", web::patch().to(crate::http::notification_handler::mark_notification_read)) - .route("/notifications/{id}", web::delete().to(crate::http::notification_handler::delete_notification)) + .route( + "/notifications", + web::get().to(crate::http::notification_handler::get_notifications), + ) + .route( + "/notifications", + web::post().to(crate::http::notification_handler::create_notification), + ) + .route( + "/notifications/read-all", + web::patch().to(crate::http::notification_handler::mark_all_read), + ) + .route( + "/notifications/{id}/read", + web::patch().to(crate::http::notification_handler::mark_notification_read), + ) + .route( + "/notifications/{id}", + web::delete().to(crate::http::notification_handler::delete_notification), + ), ) }) .bind((config.server.host.clone(), config.server.port))? @@ -56,7 +75,9 @@ async fn main() -> io::Result<()> { // Graceful shutdown let server_handle = server.handle(); tokio::spawn(async move { - signal::ctrl_c().await.expect("Failed to listen for shutdown signal"); + signal::ctrl_c() + .await + .expect("Failed to listen for shutdown signal"); tracing::info!("Shutdown signal received, stopping server..."); server_handle.stop(true).await; }); diff --git a/backend/src/models/match_authority.rs b/backend/src/models/match_authority.rs index 76fc251..957b26a 100644 --- a/backend/src/models/match_authority.rs +++ b/backend/src/models/match_authority.rs @@ -1,12 +1,15 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; -use chrono::{DateTime, Utc}; use uuid::Uuid; use validator::Validate; /// Match Authority State - represents the finite state machine states #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, sqlx::Type)] -#[sqlx(type_name = "match_authority_state", rename_all = "SCREAMING_SNAKE_CASE")] +#[sqlx( + type_name = "match_authority_state", + rename_all = "SCREAMING_SNAKE_CASE" +)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum MatchAuthorityState { Created, @@ -242,7 +245,10 @@ mod tests { #[test] fn test_valid_next_states() { let created = MatchAuthorityState::Created; - assert_eq!(created.valid_next_states(), vec![MatchAuthorityState::Started]); + assert_eq!( + created.valid_next_states(), + vec![MatchAuthorityState::Started] + ); let completed = MatchAuthorityState::Completed; let next_states = completed.valid_next_states(); diff --git a/backend/src/models/match_models.rs b/backend/src/models/match_models.rs index ed0973b..08db360 100644 --- a/backend/src/models/match_models.rs +++ b/backend/src/models/match_models.rs @@ -1,6 +1,6 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; -use chrono::{DateTime, Utc}; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] @@ -35,7 +35,7 @@ pub struct MatchScore { pub match_id: Uuid, pub player_id: Uuid, pub score: i32, - pub proof_url: Option, // URL to screenshot/video proof + pub proof_url: Option, // URL to screenshot/video proof pub telemetry_data: Option, // JSON string of game telemetry pub submitted_at: DateTime, pub verified: bool, @@ -225,7 +225,7 @@ pub struct EloResponse { pub win_rate: f64, pub win_streak: i32, pub loss_streak: i32, - pub rank: Option, // Global rank + pub rank: Option, // Global rank pub percentile: Option, // Top X% of players } diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index c1c5365..38aae89 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,18 +1,19 @@ // Core models -pub mod user; -pub mod tournament; -pub mod match_models; pub mod match_authority; -pub mod wallet; +pub mod match_models; pub mod reward_settlement; pub mod stellar_account; pub mod stellar_transaction; +pub mod tournament; +pub mod user; +pub mod wallet; // Re-export commonly used types -pub use user::*; -pub use tournament::*; -pub use match_models::*; pub use match_authority::*; -pub use wallet::*; +pub use match_models::*; +pub use reward_settlement::*; pub use stellar_account::*; -pub use stellar_transaction::*; \ No newline at end of file +pub use stellar_transaction::*; +pub use tournament::*; +pub use user::*; +pub use wallet::*; diff --git a/backend/src/models/tournament.rs b/backend/src/models/tournament.rs index ef4b46d..ef41b62 100644 --- a/backend/src/models/tournament.rs +++ b/backend/src/models/tournament.rs @@ -223,7 +223,7 @@ pub struct PrizePool { #[derive(Debug, Serialize, Deserialize)] pub struct JoinTournamentRequest { - pub payment_method: String, // "fiat" or "arenax_token" + pub payment_method: String, // "fiat" or "arenax_token" pub payment_reference: Option, // For fiat payments } diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs index 30cd74d..4478614 100644 --- a/backend/src/models/user.rs +++ b/backend/src/models/user.rs @@ -41,4 +41,4 @@ pub struct UserProfile { pub email: String, pub is_verified: bool, pub created_at: DateTime, -} \ No newline at end of file +} diff --git a/backend/src/models/wallet.rs b/backend/src/models/wallet.rs index 33af434..761aec4 100644 --- a/backend/src/models/wallet.rs +++ b/backend/src/models/wallet.rs @@ -226,4 +226,4 @@ pub struct WithdrawalRequest { #[validate(length(min = 1, max = 255))] pub destination: String, // Bank account, Stellar address, etc. pub payment_method: String, -} \ No newline at end of file +} diff --git a/backend/src/service/governance_service.rs b/backend/src/service/governance_service.rs index a85ec37..31adf17 100644 --- a/backend/src/service/governance_service.rs +++ b/backend/src/service/governance_service.rs @@ -180,10 +180,9 @@ impl GovernanceService { .map_err(|e| GovernanceServiceError::SorobanError(e.to_string()))?; // Store in database - let execute_after_dt = dto.execute_after.map(|ts| { - chrono::DateTime::from_timestamp(ts, 0) - .unwrap_or_else(chrono::Utc::now) - }); + let execute_after_dt = dto + .execute_after + .map(|ts| chrono::DateTime::from_timestamp(ts, 0).unwrap_or_else(chrono::Utc::now)); sqlx::query( r#" @@ -255,7 +254,12 @@ impl GovernanceService { // Submit to chain let tx_result = self .soroban - .invoke(&self.governance_contract_id, "approve", &args, signer_secret) + .invoke( + &self.governance_contract_id, + "approve", + &args, + signer_secret, + ) .await .map_err(|e| GovernanceServiceError::SorobanError(e.to_string()))?; @@ -329,7 +333,12 @@ impl GovernanceService { // Submit to chain let tx_result = self .soroban - .invoke(&self.governance_contract_id, "execute", &args, executor_secret) + .invoke( + &self.governance_contract_id, + "execute", + &args, + executor_secret, + ) .await .map_err(|e| GovernanceServiceError::SorobanError(e.to_string()))?; diff --git a/backend/src/service/match_authority_service.rs b/backend/src/service/match_authority_service.rs index e8ad0c8..f1768d5 100644 --- a/backend/src/service/match_authority_service.rs +++ b/backend/src/service/match_authority_service.rs @@ -442,18 +442,12 @@ impl MatchAuthorityService { // ============================================================================= /// Get match by ID - pub async fn get_match( - &self, - match_id: Uuid, - ) -> Result { + pub async fn get_match(&self, match_id: Uuid) -> Result { self.get_match_with_transitions(match_id).await } /// Get match entity (internal) - async fn get_match_entity( - &self, - match_id: Uuid, - ) -> Result { + async fn get_match_entity(&self, match_id: Uuid) -> Result { sqlx::query_as!( MatchAuthorityEntity, r#" @@ -541,10 +535,7 @@ impl MatchAuthorityService { /// Reconcile match state with blockchain /// Checks if on-chain and off-chain states match - pub async fn reconcile_match( - &self, - match_id: Uuid, - ) -> Result { + pub async fn reconcile_match(&self, match_id: Uuid) -> Result { let match_entity = self.get_match_entity(match_id).await?; info!( @@ -798,10 +789,7 @@ impl MatchAuthorityService { } /// Get match state from blockchain - async fn get_match_state_from_chain( - &self, - on_chain_match_id: &str, - ) -> Result { + async fn get_match_state_from_chain(&self, on_chain_match_id: &str) -> Result { // In a real implementation, this would: // 1. Query the contract state // 2. Decode the response @@ -833,10 +821,7 @@ mod tests { // Valid transition assert!(service - .validate_transition( - &MatchAuthorityState::Created, - &MatchAuthorityState::Started - ) + .validate_transition(&MatchAuthorityState::Created, &MatchAuthorityState::Started) .is_ok()); // Invalid transition diff --git a/backend/src/service/match_service.rs b/backend/src/service/match_service.rs index f0b8eb7..10af58e 100644 --- a/backend/src/service/match_service.rs +++ b/backend/src/service/match_service.rs @@ -1,13 +1,13 @@ -use crate::models::*; -use crate::db::DbPool; use crate::api_error::ApiError; -use sqlx::Row; -use uuid::Uuid; +use crate::db::DbPool; +use crate::models::*; use chrono::{DateTime, Utc}; -use std::collections::HashMap; +use redis::Client as RedisClient; +use sqlx::Row; use std::cmp::Ordering; +use std::collections::HashMap; use std::sync::Arc; -use redis::Client as RedisClient; +use uuid::Uuid; pub struct MatchService { db_pool: DbPool, @@ -16,7 +16,7 @@ pub struct MatchService { impl MatchService { pub fn new(db_pool: DbPool) -> Self { - Self { + Self { db_pool, redis_client: None, } @@ -38,7 +38,7 @@ impl MatchService { round_id: Option, ) -> Result { let match_id = Uuid::new_v4(); - + // Get player Elo ratings let player1_elo = self.get_user_elo(player1_id, &game_mode).await?; let player2_elo = if let Some(p2_id) = player2_id { @@ -78,16 +78,16 @@ impl MatchService { } /// Get match details - pub async fn get_match(&self, match_id: Uuid, user_id: Option) -> Result { - let match_record = sqlx::query_as!( - Match, - "SELECT * FROM matches WHERE id = $1", - match_id - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))? - .ok_or(ApiError::not_found("Match not found"))?; + pub async fn get_match( + &self, + match_id: Uuid, + user_id: Option, + ) -> Result { + let match_record = sqlx::query_as!(Match, "SELECT * FROM matches WHERE id = $1", match_id) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))? + .ok_or(ApiError::not_found("Match not found"))?; // Get player information let player1 = self.get_player_info(match_record.player1_id).await?; @@ -140,7 +140,7 @@ impl MatchService { MatchScore, r#" INSERT INTO match_scores ( - id, match_id, player_id, score, proof_url, telemetry_data, + id, match_id, player_id, score, proof_url, telemetry_data, submitted_at, verified ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8 @@ -160,7 +160,8 @@ impl MatchService { .map_err(|e| ApiError::database_error(e))?; // Update match with score - self.update_match_score(match_id, user_id, request.score).await?; + self.update_match_score(match_id, user_id, request.score) + .await?; // Publish score reported event self.publish_match_event(serde_json::json!({ @@ -169,7 +170,8 @@ impl MatchService { "tournament_id": match_record.tournament_id, "user_id": user_id, "score": request.score - })).await?; + })) + .await?; // Check if both players have reported scores let both_reported = self.both_players_reported_scores(match_id).await?; @@ -189,14 +191,15 @@ impl MatchService { ) -> Result { // Validate dispute creation let match_record = self.get_match_by_id(match_id).await?; - self.validate_dispute_creation(&match_record, user_id).await?; + self.validate_dispute_creation(&match_record, user_id) + .await?; // Create dispute record let dispute = sqlx::query_as!( MatchDispute, r#" INSERT INTO match_disputes ( - id, match_id, disputing_player_id, reason, evidence_urls, + id, match_id, disputing_player_id, reason, evidence_urls, status, created_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7 @@ -206,7 +209,9 @@ impl MatchService { match_id, user_id, request.reason, - request.evidence_urls.map(|urls| serde_json::to_string(&urls).unwrap_or_default()), + request + .evidence_urls + .map(|urls| serde_json::to_string(&urls).unwrap_or_default()), DisputeStatus::Pending as _, Utc::now() ) @@ -215,7 +220,8 @@ impl MatchService { .map_err(|e| ApiError::database_error(e))?; // Update match status to disputed - self.update_match_status(match_id, MatchStatus::Disputed).await?; + self.update_match_status(match_id, MatchStatus::Disputed) + .await?; // Publish dispute event self.publish_match_event(serde_json::json!({ @@ -224,7 +230,8 @@ impl MatchService { "tournament_id": match_record.tournament_id, "user_id": user_id, "reason": request.reason - })).await?; + })) + .await?; Ok(dispute) } @@ -237,17 +244,20 @@ impl MatchService { ) -> Result { // Check if user is already in queue if self.is_user_in_queue(user_id, &request.game).await? { - return Err(ApiError::bad_request("User is already in matchmaking queue")); + return Err(ApiError::bad_request( + "User is already in matchmaking queue", + )); } // Get user's current Elo rating let current_elo = self.get_user_elo(user_id, &request.game).await?; - + // Calculate Elo range for matchmaking let (min_elo, max_elo) = self.calculate_elo_range(current_elo); // Set expiration time - let expires_at = Utc::now() + chrono::Duration::minutes(request.max_wait_time.unwrap_or(10) as i64); + let expires_at = + Utc::now() + chrono::Duration::minutes(request.max_wait_time.unwrap_or(10) as i64); // Add to queue let queue_entry = sqlx::query_as!( @@ -276,13 +286,17 @@ impl MatchService { .map_err(|e| ApiError::database_error(e))?; // Try to find a match immediately - self.try_matchmaking(&request.game, &request.game_mode).await?; + self.try_matchmaking(&request.game, &request.game_mode) + .await?; Ok(queue_entry) } /// Get matchmaking status for user - pub async fn get_matchmaking_status(&self, user_id: Uuid) -> Result { + pub async fn get_matchmaking_status( + &self, + user_id: Uuid, + ) -> Result { let queue_entry = sqlx::query_as!( MatchmakingQueue, "SELECT * FROM matchmaking_queue WHERE user_id = $1 AND status = $2", @@ -296,9 +310,11 @@ impl MatchService { if let Some(entry) = queue_entry { // Calculate queue position let position = self.get_queue_position(user_id, &entry.game).await?; - + // Estimate wait time - let estimated_wait = self.estimate_wait_time(&entry.game, &entry.game_mode).await?; + let estimated_wait = self + .estimate_wait_time(&entry.game, &entry.game_mode) + .await?; Ok(MatchmakingStatusResponse { in_queue: true, @@ -309,7 +325,7 @@ impl MatchService { } else { // Check if user has an active match let active_match = self.get_user_active_match(user_id).await?; - + Ok(MatchmakingStatusResponse { in_queue: false, queue_position: None, @@ -320,7 +336,11 @@ impl MatchService { } /// Get user's Elo rating - pub async fn get_user_elo_rating(&self, user_id: Uuid, game: &str) -> Result { + pub async fn get_user_elo_rating( + &self, + user_id: Uuid, + game: &str, + ) -> Result { let elo_record = sqlx::query_as!( UserElo, "SELECT * FROM user_elo WHERE user_id = $1 AND game = $2", @@ -376,15 +396,11 @@ impl MatchService { } async fn get_player_info(&self, user_id: Uuid) -> Result { - let user = sqlx::query_as!( - User, - "SELECT * FROM users WHERE id = $1", - user_id - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))? - .ok_or(ApiError::not_found("User not found"))?; + let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", user_id) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))? + .ok_or(ApiError::not_found("User not found"))?; // Get user's Elo rating for the game (assuming we have a default game) let elo_rating = self.get_user_elo(user_id, "default").await?; @@ -397,7 +413,11 @@ impl MatchService { }) } - async fn can_user_report_score(&self, user_id: Option, match_record: &Match) -> Result { + async fn can_user_report_score( + &self, + user_id: Option, + match_record: &Match, + ) -> Result { if user_id.is_none() { return Ok(false); } @@ -405,7 +425,12 @@ impl MatchService { let user_id = user_id.unwrap(); // Check if user is a player in this match - if user_id != match_record.player1_id && match_record.player2_id.map(|p2| p2 != user_id).unwrap_or(true) { + if user_id != match_record.player1_id + && match_record + .player2_id + .map(|p2| p2 != user_id) + .unwrap_or(true) + { return Ok(false); } @@ -427,7 +452,11 @@ impl MatchService { Ok(existing_score.is_none()) } - async fn can_user_dispute_match(&self, user_id: Option, match_record: &Match) -> Result { + async fn can_user_dispute_match( + &self, + user_id: Option, + match_record: &Match, + ) -> Result { if user_id.is_none() { return Ok(false); } @@ -435,7 +464,12 @@ impl MatchService { let user_id = user_id.unwrap(); // Check if user is a player in this match - if user_id != match_record.player1_id && match_record.player2_id.map(|p2| p2 != user_id).unwrap_or(true) { + if user_id != match_record.player1_id + && match_record + .player2_id + .map(|p2| p2 != user_id) + .unwrap_or(true) + { return Ok(false); } @@ -457,7 +491,10 @@ impl MatchService { Ok(existing_dispute.is_none()) } - async fn get_match_dispute_status(&self, match_id: Uuid) -> Result, ApiError> { + async fn get_match_dispute_status( + &self, + match_id: Uuid, + ) -> Result, ApiError> { let dispute = sqlx::query!( "SELECT status FROM match_disputes WHERE match_id = $1 ORDER BY created_at DESC LIMIT 1", match_id @@ -470,20 +507,25 @@ impl MatchService { } async fn get_match_by_id(&self, match_id: Uuid) -> Result { - sqlx::query_as!( - Match, - "SELECT * FROM matches WHERE id = $1", - match_id - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))? - .ok_or(ApiError::not_found("Match not found".to_string())) + sqlx::query_as!(Match, "SELECT * FROM matches WHERE id = $1", match_id) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))? + .ok_or(ApiError::not_found("Match not found".to_string())) } - async fn validate_score_report(&self, match_record: &Match, user_id: Uuid) -> Result<(), ApiError> { + async fn validate_score_report( + &self, + match_record: &Match, + user_id: Uuid, + ) -> Result<(), ApiError> { // Check if user is a player in this match - if user_id != match_record.player1_id && match_record.player2_id.map(|p2| p2 != user_id).unwrap_or(true) { + if user_id != match_record.player1_id + && match_record + .player2_id + .map(|p2| p2 != user_id) + .unwrap_or(true) + { return Err(ApiError::forbidden("User is not a player in this match")); } @@ -503,13 +545,20 @@ impl MatchService { .map_err(|e| ApiError::database_error(e))?; if existing_score.is_some() { - return Err(ApiError::bad_request("Score already reported for this match")); + return Err(ApiError::bad_request( + "Score already reported for this match", + )); } Ok(()) } - async fn update_match_score(&self, match_id: Uuid, user_id: Uuid, score: i32) -> Result<(), ApiError> { + async fn update_match_score( + &self, + match_id: Uuid, + user_id: Uuid, + score: i32, + ) -> Result<(), ApiError> { let match_record = self.get_match_by_id(match_id).await?; if user_id == match_record.player1_id { @@ -522,7 +571,11 @@ impl MatchService { .execute(&self.db_pool) .await .map_err(|e| ApiError::database_error(e))?; - } else if match_record.player2_id.map(|p2| p2 == user_id).unwrap_or(false) { + } else if match_record + .player2_id + .map(|p2| p2 == user_id) + .unwrap_or(false) + { sqlx::query!( "UPDATE matches SET player2_score = $1, updated_at = $2 WHERE id = $3", score, @@ -539,7 +592,7 @@ impl MatchService { async fn both_players_reported_scores(&self, match_id: Uuid) -> Result { let match_record = self.get_match_by_id(match_id).await?; - + let player1_score = sqlx::query!( "SELECT id FROM match_scores WHERE match_id = $1 AND player_id = $2", match_id, @@ -567,14 +620,14 @@ impl MatchService { async fn process_match_completion(&self, match_id: Uuid) -> Result<(), ApiError> { let match_record = self.get_match_by_id(match_id).await?; - + // Determine winner let winner_id = self.determine_winner(&match_record).await?; - + // Update match with winner and completion time sqlx::query!( r#" - UPDATE matches + UPDATE matches SET winner_id = $1, status = $2, completed_at = $3, updated_at = $4 WHERE id = $5 "#, @@ -602,7 +655,8 @@ impl MatchService { "winner_id": winner_id, "player1_score": match_record.player1_score.unwrap_or(0), "player2_score": match_record.player2_score.unwrap_or(0) - })).await?; + })) + .await?; // Publish global event if it's a ranked match if match_record.match_type == MatchType::Ranked { @@ -612,7 +666,8 @@ impl MatchService { "match_id": match_id, "game_mode": match_record.game_mode, "winner_id": winner - })).await?; + })) + .await?; } } @@ -630,7 +685,11 @@ impl MatchService { } } - async fn update_elo_ratings(&self, match_record: &Match, winner_id: Option) -> Result<(), ApiError> { + async fn update_elo_ratings( + &self, + match_record: &Match, + winner_id: Option, + ) -> Result<(), ApiError> { if match_record.player2_id.is_none() { return Ok(()); // Bye match, no Elo update needed } @@ -665,10 +724,22 @@ impl MatchService { }; // Update player 1 Elo - self.update_user_elo(match_record.player1_id, &match_record.game_mode, new_player1_elo, player1_result).await?; + self.update_user_elo( + match_record.player1_id, + &match_record.game_mode, + new_player1_elo, + player1_result, + ) + .await?; // Update player 2 Elo - self.update_user_elo(match_record.player2_id.unwrap(), &match_record.game_mode, new_player2_elo, player2_result).await?; + self.update_user_elo( + match_record.player2_id.unwrap(), + &match_record.game_mode, + new_player2_elo, + player2_result, + ) + .await?; // Update match record with new Elo ratings sqlx::query!( @@ -700,7 +771,8 @@ impl MatchService { const K_FACTOR: f64 = 32.0; // Calculate expected scores - let expected_player1 = 1.0 / (1.0 + 10.0_f64.powf((player2_elo - player1_elo) as f64 / 400.0)); + let expected_player1 = + 1.0 / (1.0 + 10.0_f64.powf((player2_elo - player1_elo) as f64 / 400.0)); let expected_player2 = 1.0 - expected_player1; // Determine actual scores @@ -724,7 +796,13 @@ impl MatchService { (new_player1_elo, new_player2_elo) } - async fn update_user_elo(&self, user_id: Uuid, game: &str, new_elo: i32, result: MatchResult) -> Result<(), ApiError> { + async fn update_user_elo( + &self, + user_id: Uuid, + game: &str, + new_elo: i32, + result: MatchResult, + ) -> Result<(), ApiError> { // Get current Elo record let current_elo = sqlx::query_as!( UserElo, @@ -819,7 +897,11 @@ impl MatchService { Ok(()) } - async fn create_elo_history(&self, match_record: &Match, winner_id: Option) -> Result<(), ApiError> { + async fn create_elo_history( + &self, + match_record: &Match, + winner_id: Option, + ) -> Result<(), ApiError> { if match_record.player2_id.is_none() { return Ok(()); // Bye match, no history needed } @@ -900,9 +982,18 @@ impl MatchService { Ok(()) } - async fn validate_dispute_creation(&self, match_record: &Match, user_id: Uuid) -> Result<(), ApiError> { + async fn validate_dispute_creation( + &self, + match_record: &Match, + user_id: Uuid, + ) -> Result<(), ApiError> { // Check if user is a player in this match - if user_id != match_record.player1_id && match_record.player2_id.map(|p2| p2 != user_id).unwrap_or(true) { + if user_id != match_record.player1_id + && match_record + .player2_id + .map(|p2| p2 != user_id) + .unwrap_or(true) + { return Err(ApiError::forbidden("User is not a player in this match")); } @@ -922,13 +1013,19 @@ impl MatchService { .map_err(|e| ApiError::database_error(e))?; if existing_dispute.is_some() { - return Err(ApiError::bad_request("Dispute already exists for this match")); + return Err(ApiError::bad_request( + "Dispute already exists for this match", + )); } Ok(()) } - async fn update_match_status(&self, match_id: Uuid, status: MatchStatus) -> Result<(), ApiError> { + async fn update_match_status( + &self, + match_id: Uuid, + status: MatchStatus, + ) -> Result<(), ApiError> { sqlx::query!( "UPDATE matches SET status = $1, updated_at = $2 WHERE id = $3", status as _, @@ -968,7 +1065,7 @@ impl MatchService { let candidates = sqlx::query_as!( MatchmakingQueue, r#" - SELECT * FROM matchmaking_queue + SELECT * FROM matchmaking_queue WHERE game = $1 AND game_mode = $2 AND status = $3 ORDER BY joined_at ASC LIMIT 10 @@ -990,17 +1087,20 @@ impl MatchService { // Check if Elo ranges overlap if self.elo_ranges_overlap(player1, player2) { // Create match - let match_record = self.create_match( - player1.user_id, - Some(player2.user_id), - MatchType::Ranked, - game_mode.to_string(), - None, - None, - ).await?; + let match_record = self + .create_match( + player1.user_id, + Some(player2.user_id), + MatchType::Ranked, + game_mode.to_string(), + None, + None, + ) + .await?; // Update queue entries - self.update_queue_entries_to_matched(player1.id, player2.id, match_record.id).await?; + self.update_queue_entries_to_matched(player1.id, player2.id, match_record.id) + .await?; // Only create one match per call return Ok(()); @@ -1015,7 +1115,12 @@ impl MatchService { player1.min_elo <= player2.max_elo && player2.min_elo <= player1.max_elo } - async fn update_queue_entries_to_matched(&self, player1_queue_id: Uuid, player2_queue_id: Uuid, match_id: Uuid) -> Result<(), ApiError> { + async fn update_queue_entries_to_matched( + &self, + player1_queue_id: Uuid, + player2_queue_id: Uuid, + match_id: Uuid, + ) -> Result<(), ApiError> { // Update player 1 sqlx::query!( "UPDATE matchmaking_queue SET status = $1, matched_at = $2, match_id = $3 WHERE id = $4", @@ -1046,7 +1151,7 @@ impl MatchService { async fn get_queue_position(&self, user_id: Uuid, game: &str) -> Result { let position = sqlx::query!( r#" - SELECT COUNT(*) as position FROM matchmaking_queue + SELECT COUNT(*) as position FROM matchmaking_queue WHERE game = $1 AND status = $2 AND joined_at < ( SELECT joined_at FROM matchmaking_queue WHERE user_id = $3 AND game = $1 AND status = $2 ) @@ -1082,14 +1187,17 @@ impl MatchService { Ok((queue_size as i32) * 120) } - async fn get_user_active_match(&self, user_id: Uuid) -> Result, ApiError> { + async fn get_user_active_match( + &self, + user_id: Uuid, + ) -> Result, ApiError> { let match_record = sqlx::query_as!( Match, r#" - SELECT m.* FROM matches m - WHERE (m.player1_id = $1 OR m.player2_id = $1) + SELECT m.* FROM matches m + WHERE (m.player1_id = $1 OR m.player2_id = $1) AND m.status IN ($2, $3) - ORDER BY m.created_at DESC + ORDER BY m.created_at DESC LIMIT 1 "#, user_id, @@ -1107,7 +1215,11 @@ impl MatchService { } } - async fn calculate_rank_and_percentile(&self, user_id: Uuid, game: &str) -> Result<(Option, Option), ApiError> { + async fn calculate_rank_and_percentile( + &self, + user_id: Uuid, + game: &str, + ) -> Result<(Option, Option), ApiError> { // Get user's current rating let user_rating = self.get_user_elo(user_id, game).await?; @@ -1139,7 +1251,8 @@ impl MatchService { } let rank = higher_rated_count as i32 + 1; - let percentile = ((total_players - higher_rated_count) as f64 / total_players as f64) * 100.0; + let percentile = + ((total_players - higher_rated_count) as f64 / total_players as f64) * 100.0; Ok((Some(rank), Some(percentile))) } @@ -1168,15 +1281,15 @@ impl MatchService { game: Option, ) -> Result { let offset = (page - 1) * per_page; - + let matches = sqlx::query_as!( Match, r#" - SELECT * FROM matches - WHERE (player1_id = $1 OR player2_id = $1) + SELECT * FROM matches + WHERE (player1_id = $1 OR player2_id = $1) AND ($2::text IS NULL OR game_mode = $2) AND status = $3 - ORDER BY completed_at DESC + ORDER BY completed_at DESC LIMIT $4 OFFSET $5 "#, user_id, @@ -1192,8 +1305,8 @@ impl MatchService { // Get total count let total = sqlx::query!( r#" - SELECT COUNT(*) as count FROM matches - WHERE (player1_id = $1 OR player2_id = $1) + SELECT COUNT(*) as count FROM matches + WHERE (player1_id = $1 OR player2_id = $1) AND ($2::text IS NULL OR game_mode = $2) AND status = $3 "#, @@ -1255,10 +1368,10 @@ impl MatchService { per_page: i32, ) -> Result { let offset = (page - 1) * per_page; - + let rankings = sqlx::query!( r#" - SELECT ue.*, u.username, u.avatar_url + SELECT ue.*, u.username, u.avatar_url FROM user_elo ue JOIN users u ON ue.user_id = u.id WHERE ue.game = $1 @@ -1297,7 +1410,10 @@ impl MatchService { leaderboard_entries.push(LeaderboardEntry { rank: rank as i32, user_id: ranking.user_id, - username: ranking.username.clone().unwrap_or_else(|| "Unknown".to_string()), + username: ranking + .username + .clone() + .unwrap_or_else(|| "Unknown".to_string()), avatar_url: ranking.avatar_url.clone(), current_rating: ranking.current_rating, peak_rating: ranking.peak_rating, @@ -1432,7 +1548,7 @@ impl MatchService { let dispute = sqlx::query_as!( MatchDispute, r#" - UPDATE match_disputes + UPDATE match_disputes SET status = $1, admin_reviewer_id = $2, resolution = $3, resolved_at = $4 WHERE id = $5 RETURNING * @@ -1461,7 +1577,8 @@ impl MatchService { // Recalculate Elo ratings with new winner let match_record = self.get_match_by_id(dispute.match_id).await?; - self.update_elo_ratings(&match_record, Some(new_winner)).await?; + self.update_elo_ratings(&match_record, Some(new_winner)) + .await?; } tracing::info!("Dispute {} resolved by admin {}", dispute_id, admin_id); @@ -1478,7 +1595,7 @@ impl MatchService { let dispute = sqlx::query_as!( MatchDispute, r#" - UPDATE match_disputes + UPDATE match_disputes SET status = $1, admin_reviewer_id = $2, admin_notes = $3, resolved_at = $4 WHERE id = $5 RETURNING * @@ -1502,13 +1619,15 @@ impl MatchService { let match_record = self.get_match_by_id(match_id).await?; if match_record.status != MatchStatus::Scheduled { - return Err(ApiError::bad_request("Match cannot be started from current status".to_string())); + return Err(ApiError::bad_request( + "Match cannot be started from current status".to_string(), + )); } let updated_match = sqlx::query_as!( Match, r#" - UPDATE matches + UPDATE matches SET status = $1, started_at = $2, updated_at = $3 WHERE id = $4 RETURNING * @@ -1527,7 +1646,8 @@ impl MatchService { "type": "started", "match_id": match_id, "tournament_id": match_record.tournament_id - })).await?; + })) + .await?; Ok(updated_match) } @@ -1541,17 +1661,21 @@ impl MatchService { let match_record = self.get_match_by_id(match_id).await?; if match_record.status != MatchStatus::Pending { - return Err(ApiError::bad_request("Match cannot be scheduled from current status".to_string())); + return Err(ApiError::bad_request( + "Match cannot be scheduled from current status".to_string(), + )); } if scheduled_time <= Utc::now() { - return Err(ApiError::bad_request("Scheduled time must be in the future".to_string())); + return Err(ApiError::bad_request( + "Scheduled time must be in the future".to_string(), + )); } let updated_match = sqlx::query_as!( Match, r#" - UPDATE matches + UPDATE matches SET status = $1, scheduled_time = $2, updated_at = $3 WHERE id = $4 RETURNING * @@ -1571,7 +1695,8 @@ impl MatchService { "match_id": match_id, "tournament_id": match_record.tournament_id, "scheduled_time": scheduled_time - })).await?; + })) + .await?; Ok(updated_match) } @@ -1584,14 +1709,18 @@ impl MatchService { ) -> Result { let match_record = self.get_match_by_id(match_id).await?; - if match_record.status == MatchStatus::Completed || match_record.status == MatchStatus::Cancelled { - return Err(ApiError::bad_request("Cannot cancel a completed or already cancelled match".to_string())); + if match_record.status == MatchStatus::Completed + || match_record.status == MatchStatus::Cancelled + { + return Err(ApiError::bad_request( + "Cannot cancel a completed or already cancelled match".to_string(), + )); } let updated_match = sqlx::query_as!( Match, r#" - UPDATE matches + UPDATE matches SET status = $1, updated_at = $2 WHERE id = $3 RETURNING * @@ -1612,7 +1741,7 @@ impl MatchService { pub async fn cleanup_expired_queue_entries(&self) -> Result { let result = sqlx::query!( r#" - UPDATE matchmaking_queue + UPDATE matchmaking_queue SET status = $1 WHERE status = $2 AND expires_at < $3 "#, @@ -1668,7 +1797,7 @@ impl MatchService { let disputes = sqlx::query_as!( MatchDispute, r#" - SELECT * FROM match_disputes + SELECT * FROM match_disputes WHERE status = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3 @@ -1728,7 +1857,8 @@ impl MatchService { }; // Calculate expected scores - let expected_player1 = 1.0 / (1.0 + 10.0_f64.powf((player2_elo - player1_elo) as f64 / 400.0)); + let expected_player1 = + 1.0 / (1.0 + 10.0_f64.powf((player2_elo - player1_elo) as f64 / 400.0)); let expected_player2 = 1.0 - expected_player1; // Determine actual scores @@ -1746,8 +1876,10 @@ impl MatchService { }; // Calculate new ratings with adjusted K-factors - let new_player1_elo = (player1_elo as f64 + k_factor * (actual_player1 - expected_player1)).round() as i32; - let new_player2_elo = (player2_elo as f64 + k_factor_p2 * (actual_player2 - expected_player2)).round() as i32; + let new_player1_elo = + (player1_elo as f64 + k_factor * (actual_player1 - expected_player1)).round() as i32; + let new_player2_elo = + (player2_elo as f64 + k_factor_p2 * (actual_player2 - expected_player2)).round() as i32; // Ensure ratings don't go below 100 or above 3000 let new_player1_elo = new_player1_elo.max(100).min(3000); diff --git a/backend/src/service/mod.rs b/backend/src/service/mod.rs index 9c8f701..7898457 100644 --- a/backend/src/service/mod.rs +++ b/backend/src/service/mod.rs @@ -1,17 +1,23 @@ // Service layer module for ArenaX -pub mod tournament_service; -pub mod match_service; +pub mod governance_service; pub mod match_authority_service; -pub mod wallet_service; +pub mod match_service; pub mod reward_settlement_service; -pub mod stellar_service; pub mod soroban_service; -pub mod governance_service; +pub mod stellar_service; +pub mod tournament_service; +pub mod wallet_service; -pub use tournament_service::TournamentService; -pub use match_service::MatchService; +pub use governance_service::{ + CreateProposalDto, GovernanceService, GovernanceServiceError, ProposalRecord, + ProposalStatus as GovProposalStatus, +}; pub use match_authority_service::MatchAuthorityService; -pub use wallet_service::WalletService; +pub use match_service::MatchService; +pub use soroban_service::{ + DecodedEvent, NetworkConfig, RetryConfig, SorobanError, SorobanService, SorobanTxResult, + TxStatus, +}; pub use stellar_service::StellarService; -pub use soroban_service::{SorobanService, SorobanTxResult, NetworkConfig, TxStatus, DecodedEvent, RetryConfig, SorobanError}; -pub use governance_service::{GovernanceService, GovernanceServiceError, CreateProposalDto, ProposalRecord, ProposalStatus as GovProposalStatus}; +pub use tournament_service::TournamentService; +pub use wallet_service::WalletService; diff --git a/backend/src/service/reward_settlement_service.rs b/backend/src/service/reward_settlement_service.rs index 7cd655b..1c43e77 100644 --- a/backend/src/service/reward_settlement_service.rs +++ b/backend/src/service/reward_settlement_service.rs @@ -117,7 +117,10 @@ impl RewardSettlementService { } /// Get existing settlement by match ID. - pub async fn get_settlement(&self, match_id: &str) -> Result, ApiError> { + pub async fn get_settlement( + &self, + match_id: &str, + ) -> Result, ApiError> { let settlements = SETTLEMENTS .read() .map_err(|_| ApiError::internal_error("Failed to read settlements"))?; @@ -170,8 +173,11 @@ mod tests { #[tokio::test] async fn test_idempotent_settlement() { let service = create_test_service(); - let match_id = format!("test_match_{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)); - + let match_id = format!( + "test_match_{}", + Utc::now().timestamp_nanos_opt().unwrap_or(0) + ); + // First settlement let result1 = service .settle_match_reward( @@ -201,8 +207,11 @@ mod tests { #[tokio::test] async fn test_settlement_persisted() { let service = create_test_service(); - let match_id = format!("persist_test_{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)); - + let match_id = format!( + "persist_test_{}", + Utc::now().timestamp_nanos_opt().unwrap_or(0) + ); + service .settle_match_reward( match_id.clone(), diff --git a/backend/src/service/soroban_service.rs b/backend/src/service/soroban_service.rs index f74d869..e14f7f4 100644 --- a/backend/src/service/soroban_service.rs +++ b/backend/src/service/soroban_service.rs @@ -150,12 +150,8 @@ struct RpcResponse { #[derive(Debug, Deserialize)] #[serde(untagged)] enum RpcResult { - Success { - result: serde_json::Value, - }, - Error { - error: RpcError, - }, + Success { result: serde_json::Value }, + Error { error: RpcError }, } #[derive(Debug, Deserialize)] @@ -330,9 +326,7 @@ impl SorobanService { "transaction": tx_envelope }); - let response: SimulateResponse = self - .rpc_call("simulateTransaction", params) - .await?; + let response: SimulateResponse = self.rpc_call("simulateTransaction", params).await?; Ok(response) } @@ -378,8 +372,7 @@ impl SorobanService { // Encode to base64 XDR (simplified) use base64::{engine::general_purpose, Engine as _}; - let xdr = general_purpose::STANDARD - .encode(serde_json::to_string(&signed_tx)?); + let xdr = general_purpose::STANDARD.encode(serde_json::to_string(&signed_tx)?); Ok(xdr) } @@ -390,9 +383,7 @@ impl SorobanService { "transaction": signed_tx }); - let response: SendTransactionResponse = self - .rpc_call("sendTransaction", params) - .await?; + let response: SendTransactionResponse = self.rpc_call("sendTransaction", params).await?; info!(tx_hash = response.hash, "Transaction submitted"); @@ -400,10 +391,7 @@ impl SorobanService { } /// Monitor a transaction until it completes or fails - async fn monitor_transaction( - &self, - tx_hash: &str, - ) -> Result { + async fn monitor_transaction(&self, tx_hash: &str) -> Result { let mut attempt = 0; let mut delay = self.retry_config.initial_delay_ms; @@ -484,25 +472,18 @@ impl SorobanService { "hash": tx_hash }); - let response: GetTransactionResponse = self - .rpc_call("getTransaction", params) - .await?; + let response: GetTransactionResponse = self.rpc_call("getTransaction", params).await?; Ok(response.status) } /// Decode events from a transaction - pub async fn decode_events( - &self, - tx_hash: &str, - ) -> Result, SorobanError> { + pub async fn decode_events(&self, tx_hash: &str) -> Result, SorobanError> { let params = serde_json::json!({ "hash": tx_hash }); - let response: GetTransactionResponse = self - .rpc_call("getTransaction", params) - .await?; + let response: GetTransactionResponse = self.rpc_call("getTransaction", params).await?; // Decode events from resultMetaXdr // In production, properly decode XDR to extract events @@ -530,11 +511,7 @@ impl SorobanService { } /// Make an RPC call to the Soroban RPC endpoint - async fn rpc_call( - &self, - method: &str, - params: serde_json::Value, - ) -> Result + async fn rpc_call(&self, method: &str, params: serde_json::Value) -> Result where T: for<'de> Deserialize<'de>, { @@ -556,10 +533,7 @@ impl SorobanService { let text = response.text().await?; if !status.is_success() { - return Err(SorobanError::RpcError(format!( - "HTTP {}: {}", - status, text - ))); + return Err(SorobanError::RpcError(format!("HTTP {}: {}", status, text))); } let rpc_response: RpcResponse = serde_json::from_str(&text)?; @@ -590,8 +564,7 @@ impl SorobanService { }); use base64::{engine::general_purpose, Engine as _}; - Ok(general_purpose::STANDARD - .encode(serde_json::to_string(&envelope)?)) + Ok(general_purpose::STANDARD.encode(serde_json::to_string(&envelope)?)) } /// Extract public key from secret key @@ -687,7 +660,8 @@ mod tests { let service = SorobanService::new(network); // Valid secret key format - let result = service.secret_to_public_key("SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + let result = + service.secret_to_public_key("SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); assert!(result.is_ok()); // Invalid secret key format @@ -839,7 +813,10 @@ mod tests { "operation": "invoke" }); - let result = service.sign_transaction(&tx_data, "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + let result = service.sign_transaction( + &tx_data, + "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); assert!(result.is_ok()); let signature = result.unwrap(); assert!(signature.starts_with("sig_")); diff --git a/backend/src/service/stellar_service.rs b/backend/src/service/stellar_service.rs index 5b1fda6..60dd421 100644 --- a/backend/src/service/stellar_service.rs +++ b/backend/src/service/stellar_service.rs @@ -141,7 +141,11 @@ impl StellarService { } /// Fund a Stellar account (for testnet only) - pub async fn fund_account(&self, public_key: &str, amount: i64) -> Result { + pub async fn fund_account( + &self, + public_key: &str, + amount: i64, + ) -> Result { // TODO: Implement actual Stellar funding // For testnet, you can use the friendbot // For mainnet, this would require transferring XLM from the admin account @@ -473,8 +477,14 @@ impl StellarService { // Ok((keypair.public_key(), keypair.secret_key())) // Placeholder implementation - let public_key = format!("G{}", Uuid::new_v4().to_string().replace("-", "").to_uppercase()); - let secret_key = format!("S{}", Uuid::new_v4().to_string().replace("-", "").to_uppercase()); + let public_key = format!( + "G{}", + Uuid::new_v4().to_string().replace("-", "").to_uppercase() + ); + let secret_key = format!( + "S{}", + Uuid::new_v4().to_string().replace("-", "").to_uppercase() + ); Ok((public_key, secret_key)) } diff --git a/backend/src/service/tournament_service.rs b/backend/src/service/tournament_service.rs index ac38853..4440748 100644 --- a/backend/src/service/tournament_service.rs +++ b/backend/src/service/tournament_service.rs @@ -1,13 +1,13 @@ -use crate::models::*; -use crate::db::DbPool; use crate::api_error::ApiError; -use sqlx::Row; -use uuid::Uuid; +use crate::db::DbPool; +use crate::models::*; use chrono::{DateTime, Utc}; +use redis::Client as RedisClient; +use serde::{Deserialize, Serialize}; +use sqlx::Row; use std::collections::HashMap; use std::sync::Arc; -use redis::Client as RedisClient; -use serde::{Serialize, Deserialize}; +use uuid::Uuid; pub struct TournamentService { db_pool: DbPool, @@ -73,7 +73,8 @@ impl TournamentService { .map_err(|e| ApiError::database_error(e))?; // Create prize pool record - self.create_prize_pool(&tournament.id, &request.entry_fee_currency).await?; + self.create_prize_pool(&tournament.id, &request.entry_fee_currency) + .await?; // Publish tournament created event self.publish_tournament_event(serde_json::json!({ @@ -82,7 +83,8 @@ impl TournamentService { "name": tournament.name.clone(), "game": tournament.game.clone(), "max_participants": tournament.max_participants, - })).await?; + })) + .await?; // Publish global event self.publish_global_event(serde_json::json!({ @@ -90,7 +92,8 @@ impl TournamentService { "tournament_id": tournament.id, "name": tournament.name.clone(), "game": tournament.game.clone(), - })).await?; + })) + .await?; Ok(tournament) } @@ -105,11 +108,11 @@ impl TournamentService { game_filter: Option, ) -> Result { let offset = (page - 1) * per_page; - + let mut query = String::from( - "SELECT t.*, COUNT(tp.id) as current_participants FROM tournaments t - LEFT JOIN tournament_participants tp ON t.id = tp.tournament_id - WHERE 1=1" + "SELECT t.*, COUNT(tp.id) as current_participants FROM tournaments t + LEFT JOIN tournament_participants tp ON t.id = tp.tournament_id + WHERE 1=1", ); let mut params: Vec + Send + Sync>> = Vec::new(); let mut param_count = 0; @@ -127,11 +130,11 @@ impl TournamentService { } query.push_str(" GROUP BY t.id ORDER BY t.created_at DESC"); - + param_count += 1; query.push_str(&format!(" LIMIT ${}", param_count)); params.push(Box::new(per_page)); - + param_count += 1; query.push_str(&format!(" OFFSET ${}", param_count)); params.push(Box::new(offset)); @@ -139,13 +142,13 @@ impl TournamentService { // For now, we'll use a simpler approach with sqlx::query let tournaments = sqlx::query!( r#" - SELECT t.*, COUNT(tp.id) as current_participants - FROM tournaments t - LEFT JOIN tournament_participants tp ON t.id = tp.tournament_id + SELECT t.*, COUNT(tp.id) as current_participants + FROM tournaments t + LEFT JOIN tournament_participants tp ON t.id = tp.tournament_id WHERE ($1::text IS NULL OR t.status = $1::tournament_status) AND ($2::text IS NULL OR t.game = $2) - GROUP BY t.id - ORDER BY t.created_at DESC + GROUP BY t.id + ORDER BY t.created_at DESC LIMIT $3 OFFSET $4 "#, status_filter.map(|s| s as i32), @@ -160,8 +163,8 @@ impl TournamentService { // Get total count let total = sqlx::query!( r#" - SELECT COUNT(*) as count - FROM tournaments t + SELECT COUNT(*) as count + FROM tournaments t WHERE ($1::text IS NULL OR t.status = $1::tournament_status) AND ($2::text IS NULL OR t.game = $2) "#, @@ -184,12 +187,17 @@ impl TournamentService { }; let participant_status = if is_participant { - self.get_participant_status(user_id.unwrap(), row.id).await.ok() + self.get_participant_status(user_id.unwrap(), row.id) + .await + .ok() } else { None }; - let can_join = self.can_user_join_tournament(user_id, row.id).await.unwrap_or(false); + let can_join = self + .can_user_join_tournament(user_id, row.id) + .await + .unwrap_or(false); tournament_responses.push(TournamentResponse { id: row.id, @@ -222,12 +230,16 @@ impl TournamentService { } /// Get a specific tournament by ID - pub async fn get_tournament(&self, tournament_id: Uuid, user_id: Option) -> Result { + pub async fn get_tournament( + &self, + tournament_id: Uuid, + user_id: Option, + ) -> Result { let tournament = sqlx::query!( r#" - SELECT t.*, COUNT(tp.id) as current_participants - FROM tournaments t - LEFT JOIN tournament_participants tp ON t.id = tp.tournament_id + SELECT t.*, COUNT(tp.id) as current_participants + FROM tournaments t + LEFT JOIN tournament_participants tp ON t.id = tp.tournament_id WHERE t.id = $1 GROUP BY t.id "#, @@ -239,18 +251,25 @@ impl TournamentService { .ok_or(ApiError::not_found("Tournament not found"))?; let is_participant = if let Some(uid) = user_id { - self.is_user_participant(uid, tournament_id).await.unwrap_or(false) + self.is_user_participant(uid, tournament_id) + .await + .unwrap_or(false) } else { false }; let participant_status = if is_participant { - self.get_participant_status(user_id.unwrap(), tournament_id).await.ok() + self.get_participant_status(user_id.unwrap(), tournament_id) + .await + .ok() } else { None }; - let can_join = self.can_user_join_tournament(user_id, tournament_id).await.unwrap_or(false); + let can_join = self + .can_user_join_tournament(user_id, tournament_id) + .await + .unwrap_or(false); Ok(TournamentResponse { id: tournament.id, @@ -291,7 +310,8 @@ impl TournamentService { } // Process payment - self.process_entry_fee_payment(user_id, &tournament, &request).await?; + self.process_entry_fee_payment(user_id, &tournament, &request) + .await?; // Add participant let participant = sqlx::query_as!( @@ -315,13 +335,18 @@ impl TournamentService { .map_err(|e| ApiError::database_error(e))?; // Update prize pool - self.update_prize_pool(tournament_id, tournament.entry_fee).await?; + self.update_prize_pool(tournament_id, tournament.entry_fee) + .await?; // Update tournament status if needed - self.update_tournament_status_if_needed(tournament_id).await?; + self.update_tournament_status_if_needed(tournament_id) + .await?; // Get username for event - let username = self.get_user_username(user_id).await.unwrap_or_else(|| "Unknown".to_string()); + let username = self + .get_user_username(user_id) + .await + .unwrap_or_else(|| "Unknown".to_string()); // Publish participant joined event self.publish_tournament_event(serde_json::json!({ @@ -330,7 +355,8 @@ impl TournamentService { "user_id": user_id, "username": username, "participant_count": self.get_participant_count(tournament_id).await?, - })).await?; + })) + .await?; Ok(participant) } @@ -344,7 +370,7 @@ impl TournamentService { let tournament = sqlx::query_as!( Tournament, r#" - UPDATE tournaments + UPDATE tournaments SET status = $1, updated_at = $2 WHERE id = $3 RETURNING * @@ -375,20 +401,26 @@ impl TournamentService { "tournament_id": tournament_id, "old_status": old_status, "new_status": new_status, - })).await?; + })) + .await?; Ok(tournament) } // Private helper methods - async fn validate_tournament_creation(&self, request: &CreateTournamentRequest) -> Result<(), ApiError> { + async fn validate_tournament_creation( + &self, + request: &CreateTournamentRequest, + ) -> Result<(), ApiError> { if request.name.is_empty() { return Err(ApiError::bad_request("Tournament name is required")); } if request.max_participants < 2 { - return Err(ApiError::bad_request("Tournament must have at least 2 participants")); + return Err(ApiError::bad_request( + "Tournament must have at least 2 participants", + )); } if request.entry_fee < 0 { @@ -400,15 +432,23 @@ impl TournamentService { } if request.registration_deadline >= request.start_time { - return Err(ApiError::bad_request("Registration deadline must be before start time")); + return Err(ApiError::bad_request( + "Registration deadline must be before start time", + )); } Ok(()) } - async fn validate_tournament_join(&self, tournament: &Tournament, user_id: Uuid) -> Result<(), ApiError> { + async fn validate_tournament_join( + &self, + tournament: &Tournament, + user_id: Uuid, + ) -> Result<(), ApiError> { if tournament.status != TournamentStatus::RegistrationOpen { - return Err(ApiError::bad_request("Tournament is not accepting registrations")); + return Err(ApiError::bad_request( + "Tournament is not accepting registrations", + )); } if Utc::now() > tournament.registration_deadline { @@ -422,10 +462,14 @@ impl TournamentService { } // Check skill level requirements - if let (Some(min_skill), Some(max_skill)) = (tournament.min_skill_level, tournament.max_skill_level) { + if let (Some(min_skill), Some(max_skill)) = + (tournament.min_skill_level, tournament.max_skill_level) + { let user_elo = self.get_user_elo(user_id, &tournament.game).await?; if user_elo < min_skill || user_elo > max_skill { - return Err(ApiError::bad_request("User skill level does not meet tournament requirements")); + return Err(ApiError::bad_request( + "User skill level does not meet tournament requirements", + )); } } @@ -441,11 +485,13 @@ impl TournamentService { match request.payment_method.as_str() { "fiat" => { // Process fiat payment via Paystack/Flutterwave - self.process_fiat_payment(user_id, tournament, &request.payment_reference).await?; + self.process_fiat_payment(user_id, tournament, &request.payment_reference) + .await?; } "arenax_token" => { // Process ArenaX token payment - self.process_arenax_token_payment(user_id, tournament).await?; + self.process_arenax_token_payment(user_id, tournament) + .await?; } _ => { return Err(ApiError::bad_request("Invalid payment method")); @@ -462,14 +508,18 @@ impl TournamentService { payment_reference: &Option, ) -> Result<(), ApiError> { if payment_reference.is_none() { - return Err(ApiError::bad_request("Payment reference is required for fiat payments")); + return Err(ApiError::bad_request( + "Payment reference is required for fiat payments", + )); } let reference = payment_reference.as_ref().unwrap(); // Verify payment with payment provider - let payment_verified = self.verify_payment_with_provider(reference, tournament.entry_fee).await?; - + let payment_verified = self + .verify_payment_with_provider(reference, tournament.entry_fee) + .await?; + if !payment_verified { return Err(ApiError::bad_request("Payment verification failed")); } @@ -484,21 +534,30 @@ impl TournamentService { tournament.entry_fee, tournament.entry_fee_currency.clone(), format!("Entry fee for tournament: {}", tournament.name), - ).await?; + ) + .await?; Ok(()) } - async fn verify_payment_with_provider(&self, reference: &str, amount: i64) -> Result { + async fn verify_payment_with_provider( + &self, + reference: &str, + amount: i64, + ) -> Result { // In a real implementation, this would: // 1. Make API call to Paystack/Flutterwave // 2. Verify the payment reference and amount // 3. Check payment status - + // For now, simulate payment verification // In production, you would use the actual payment provider APIs - tracing::info!("Verifying payment: reference={}, amount={}", reference, amount); - + tracing::info!( + "Verifying payment: reference={}, amount={}", + reference, + amount + ); + // Simulate successful verification Ok(true) } @@ -523,13 +582,14 @@ impl TournamentService { ) -> Result<(), ApiError> { // Check user's ArenaX token balance let wallet = self.get_user_wallet(user_id).await?; - + if wallet.balance_arenax_tokens < tournament.entry_fee { return Err(ApiError::bad_request("Insufficient ArenaX token balance")); } // Deduct tokens from user's wallet - self.deduct_arenax_tokens(user_id, tournament.entry_fee).await?; + self.deduct_arenax_tokens(user_id, tournament.entry_fee) + .await?; // Create transaction record self.create_transaction( @@ -538,19 +598,24 @@ impl TournamentService { tournament.entry_fee, "ARENAX_TOKEN".to_string(), format!("Entry fee for tournament: {}", tournament.name), - ).await?; + ) + .await?; Ok(()) } - async fn create_prize_pool(&self, tournament_id: &Uuid, currency: &str) -> Result<(), ApiError> { + async fn create_prize_pool( + &self, + tournament_id: &Uuid, + currency: &str, + ) -> Result<(), ApiError> { // Create Stellar account for prize pool let stellar_account = self.create_stellar_prize_pool_account().await?; sqlx::query!( r#" INSERT INTO prize_pools ( - id, tournament_id, total_amount, currency, stellar_account, + id, tournament_id, total_amount, currency, stellar_account, distribution_percentages, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8 @@ -575,7 +640,7 @@ impl TournamentService { async fn update_prize_pool(&self, tournament_id: Uuid, entry_fee: i64) -> Result<(), ApiError> { sqlx::query!( r#" - UPDATE prize_pools + UPDATE prize_pools SET total_amount = total_amount + $1, updated_at = $2 WHERE tournament_id = $3 "#, @@ -593,11 +658,11 @@ impl TournamentService { async fn start_tournament(&self, tournament_id: Uuid) -> Result<(), ApiError> { // Generate tournament bracket self.generate_tournament_bracket(tournament_id).await?; - + // Update all participants to active status sqlx::query!( r#" - UPDATE tournament_participants + UPDATE tournament_participants SET status = $1 WHERE tournament_id = $2 AND status = $3 "#, @@ -615,7 +680,7 @@ impl TournamentService { async fn complete_tournament(&self, tournament_id: Uuid) -> Result<(), ApiError> { // Calculate final rankings self.calculate_final_rankings(tournament_id).await?; - + // Distribute prizes self.distribute_prizes(tournament_id).await?; @@ -627,7 +692,7 @@ impl TournamentService { let participants = sqlx::query_as!( TournamentParticipant, r#" - SELECT * FROM tournament_participants + SELECT * FROM tournament_participants WHERE tournament_id = $1 AND status = $2 ORDER BY registered_at "#, @@ -644,16 +709,20 @@ impl TournamentService { // Generate bracket based on type match tournament.bracket_type { BracketType::SingleElimination => { - self.generate_single_elimination_bracket(tournament_id, participants).await?; + self.generate_single_elimination_bracket(tournament_id, participants) + .await?; } BracketType::DoubleElimination => { - self.generate_double_elimination_bracket(tournament_id, participants).await?; + self.generate_double_elimination_bracket(tournament_id, participants) + .await?; } BracketType::RoundRobin => { - self.generate_round_robin_bracket(tournament_id, participants).await?; + self.generate_round_robin_bracket(tournament_id, participants) + .await?; } BracketType::Swiss => { - self.generate_swiss_bracket(tournament_id, participants).await?; + self.generate_swiss_bracket(tournament_id, participants) + .await?; } } @@ -672,7 +741,7 @@ impl TournamentService { // Calculate number of rounds needed let rounds = (participant_count as f64).log2().ceil() as i32; - + // Create rounds for round_num in 1..=rounds { let round = sqlx::query_as!( @@ -687,7 +756,11 @@ impl TournamentService { Uuid::new_v4(), tournament_id, round_num, - if round_num == rounds { RoundType::Final } else { RoundType::Elimination } as _, + if round_num == rounds { + RoundType::Final + } else { + RoundType::Elimination + } as _, RoundStatus::Pending as _, Utc::now() ) @@ -720,7 +793,11 @@ impl TournamentService { round.id, match_num as i32, participants[player1_idx].user_id, - if player2_idx < participants.len() { Some(participants[player2_idx].user_id) } else { None }, + if player2_idx < participants.len() { + Some(participants[player2_idx].user_id) + } else { + None + }, MatchStatus::Pending as _, Utc::now(), Utc::now() @@ -749,7 +826,11 @@ impl TournamentService { .ok_or(ApiError::not_found("Tournament not found".to_string())) } - async fn is_user_participant(&self, user_id: Uuid, tournament_id: Uuid) -> Result { + async fn is_user_participant( + &self, + user_id: Uuid, + tournament_id: Uuid, + ) -> Result { let count = sqlx::query!( "SELECT COUNT(*) as count FROM tournament_participants WHERE user_id = $1 AND tournament_id = $2", user_id, @@ -764,7 +845,11 @@ impl TournamentService { Ok(count > 0) } - async fn get_participant_status(&self, user_id: Uuid, tournament_id: Uuid) -> Result { + async fn get_participant_status( + &self, + user_id: Uuid, + tournament_id: Uuid, + ) -> Result { let participant = sqlx::query!( "SELECT status FROM tournament_participants WHERE user_id = $1 AND tournament_id = $2", user_id, @@ -778,7 +863,11 @@ impl TournamentService { Ok(participant.status.into()) } - async fn can_user_join_tournament(&self, user_id: Option, tournament_id: Uuid) -> Result { + async fn can_user_join_tournament( + &self, + user_id: Option, + tournament_id: Uuid, + ) -> Result { if user_id.is_none() { return Ok(false); } @@ -838,15 +927,11 @@ impl TournamentService { } async fn get_user_wallet(&self, user_id: Uuid) -> Result { - sqlx::query_as!( - Wallet, - "SELECT * FROM wallets WHERE user_id = $1", - user_id - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))? - .ok_or(ApiError::not_found("Wallet not found")) + sqlx::query_as!(Wallet, "SELECT * FROM wallets WHERE user_id = $1", user_id) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))? + .ok_or(ApiError::not_found("Wallet not found")) } async fn deduct_arenax_tokens(&self, user_id: Uuid, amount: i64) -> Result<(), ApiError> { @@ -903,19 +988,31 @@ impl TournamentService { // 2. Create the account on Stellar network // 3. Fund it with XLM // 4. Return the public key - + // For now, generate a realistic-looking Stellar public key - let account_id = format!("G{}", uuid::Uuid::new_v4().to_string().replace('-', "").to_uppercase()); + let account_id = format!( + "G{}", + uuid::Uuid::new_v4() + .to_string() + .replace('-', "") + .to_uppercase() + ); Ok(account_id) } - async fn update_tournament_status_if_needed(&self, tournament_id: Uuid) -> Result<(), ApiError> { + async fn update_tournament_status_if_needed( + &self, + tournament_id: Uuid, + ) -> Result<(), ApiError> { let tournament = self.get_tournament_by_id(tournament_id).await?; let participant_count = self.get_participant_count(tournament_id).await?; // Auto-close registration if tournament is full - if participant_count >= tournament.max_participants && tournament.status == TournamentStatus::RegistrationOpen { - self.update_tournament_status(tournament_id, TournamentStatus::RegistrationClosed).await?; + if participant_count >= tournament.max_participants + && tournament.status == TournamentStatus::RegistrationOpen + { + self.update_tournament_status(tournament_id, TournamentStatus::RegistrationClosed) + .await?; } Ok(()) @@ -935,19 +1032,22 @@ impl TournamentService { // Calculate rankings based on tournament type let tournament = self.get_tournament_by_id(tournament_id).await?; - + match tournament.bracket_type { BracketType::SingleElimination | BracketType::DoubleElimination => { // For elimination tournaments, rank by elimination order - self.calculate_elimination_rankings(tournament_id, participants).await?; + self.calculate_elimination_rankings(tournament_id, participants) + .await?; } BracketType::RoundRobin => { // For round robin, rank by win/loss record - self.calculate_round_robin_rankings(tournament_id, participants).await?; + self.calculate_round_robin_rankings(tournament_id, participants) + .await?; } BracketType::Swiss => { // For Swiss, rank by points and tiebreakers - self.calculate_swiss_rankings(tournament_id, participants).await?; + self.calculate_swiss_rankings(tournament_id, participants) + .await?; } } @@ -977,14 +1077,16 @@ impl TournamentService { // Parse distribution percentages let percentages: Vec = serde_json::from_str(&prize_pool.distribution_percentages) - .map_err(|e| ApiError::internal_error(format!("Invalid distribution percentages: {}", e)))?; + .map_err(|e| { + ApiError::internal_error(format!("Invalid distribution percentages: {}", e)) + })?; // Distribute prizes for (index, participant) in participants.iter().enumerate() { if index < percentages.len() && participant.final_rank.unwrap_or(0) <= 3 { let percentage = percentages[index]; let prize_amount = (prize_pool.total_amount as f64 * percentage / 100.0) as i64; - + // Update participant with prize amount sqlx::query!( "UPDATE tournament_participants SET prize_amount = $1, prize_currency = $2 WHERE id = $3", @@ -998,7 +1100,12 @@ impl TournamentService { // TODO: In a real implementation, initiate Stellar transaction to send prize // For now, we'll just record the prize amount - tracing::info!("Prize distributed: {} {} to user {}", prize_amount, prize_pool.currency, participant.user_id); + tracing::info!( + "Prize distributed: {} {} to user {}", + prize_amount, + prize_pool.currency, + participant.user_id + ); } } @@ -1006,7 +1113,11 @@ impl TournamentService { } // Additional bracket generation methods - async fn generate_double_elimination_bracket(&self, tournament_id: Uuid, participants: Vec) -> Result<(), ApiError> { + async fn generate_double_elimination_bracket( + &self, + tournament_id: Uuid, + participants: Vec, + ) -> Result<(), ApiError> { let participant_count = participants.len(); if participant_count < 2 { return Err(ApiError::bad_request("Not enough participants for bracket")); @@ -1014,7 +1125,7 @@ impl TournamentService { // Calculate number of rounds needed let rounds = (participant_count as f64).log2().ceil() as i32; - + // Winners bracket for round_num in 1..=rounds { let round = sqlx::query_as!( @@ -1056,7 +1167,11 @@ impl TournamentService { round.id, match_num as i32, participants[player1_idx].user_id, - if player2_idx < participants.len() { Some(participants[player2_idx].user_id) } else { None }, + if player2_idx < participants.len() { + Some(participants[player2_idx].user_id) + } else { + None + }, MatchStatus::Pending as _, Utc::now(), Utc::now() @@ -1068,11 +1183,18 @@ impl TournamentService { } // Losers bracket would be generated after winners bracket matches - tracing::info!("Double elimination bracket generated for tournament: {}", tournament_id); + tracing::info!( + "Double elimination bracket generated for tournament: {}", + tournament_id + ); Ok(()) } - async fn generate_round_robin_bracket(&self, tournament_id: Uuid, participants: Vec) -> Result<(), ApiError> { + async fn generate_round_robin_bracket( + &self, + tournament_id: Uuid, + participants: Vec, + ) -> Result<(), ApiError> { let participant_count = participants.len(); if participant_count < 2 { return Err(ApiError::bad_request("Not enough participants for bracket")); @@ -1130,11 +1252,19 @@ impl TournamentService { } } - tracing::info!("Round robin bracket generated for tournament: {} with {} matches", tournament_id, match_number - 1); + tracing::info!( + "Round robin bracket generated for tournament: {} with {} matches", + tournament_id, + match_number - 1 + ); Ok(()) } - async fn generate_swiss_bracket(&self, tournament_id: Uuid, participants: Vec) -> Result<(), ApiError> { + async fn generate_swiss_bracket( + &self, + tournament_id: Uuid, + participants: Vec, + ) -> Result<(), ApiError> { let participant_count = participants.len(); if participant_count < 2 { return Err(ApiError::bad_request("Not enough participants for bracket")); @@ -1186,7 +1316,11 @@ impl TournamentService { round.id, match_num as i32, participants[player1_idx].user_id, - if player2_idx < participants.len() { Some(participants[player2_idx].user_id) } else { None }, + if player2_idx < participants.len() { + Some(participants[player2_idx].user_id) + } else { + None + }, MatchStatus::Pending as _, Utc::now(), Utc::now() @@ -1199,11 +1333,19 @@ impl TournamentService { // Subsequent Swiss rounds would be pairing based on standings and strength of schedule } - tracing::info!("Swiss bracket generated for tournament: {} with {} rounds", tournament_id, rounds); + tracing::info!( + "Swiss bracket generated for tournament: {} with {} rounds", + tournament_id, + rounds + ); Ok(()) } - async fn calculate_elimination_rankings(&self, tournament_id: Uuid, participants: Vec) -> Result<(), ApiError> { + async fn calculate_elimination_rankings( + &self, + tournament_id: Uuid, + participants: Vec, + ) -> Result<(), ApiError> { // For elimination tournaments, rank by elimination order // Get matches in reverse order to determine elimination sequence let matches = sqlx::query_as!( @@ -1256,14 +1398,18 @@ impl TournamentService { Ok(()) } - async fn calculate_round_robin_rankings(&self, tournament_id: Uuid, participants: Vec) -> Result<(), ApiError> { + async fn calculate_round_robin_rankings( + &self, + tournament_id: Uuid, + participants: Vec, + ) -> Result<(), ApiError> { // For round robin, calculate win/loss records let mut player_stats = std::collections::HashMap::new(); for participant in &participants { let wins = sqlx::query!( r#" - SELECT COUNT(*) as count FROM tournament_matches + SELECT COUNT(*) as count FROM tournament_matches WHERE tournament_id = $1 AND winner_id = $2 AND status = $3 "#, tournament_id, @@ -1278,8 +1424,8 @@ impl TournamentService { let losses = sqlx::query!( r#" - SELECT COUNT(*) as count FROM tournament_matches - WHERE tournament_id = $1 AND (player1_id = $2 OR player2_id = $2) + SELECT COUNT(*) as count FROM tournament_matches + WHERE tournament_id = $1 AND (player1_id = $2 OR player2_id = $2) AND winner_id != $2 AND status = $3 "#, tournament_id, @@ -1320,14 +1466,18 @@ impl TournamentService { Ok(()) } - async fn calculate_swiss_rankings(&self, tournament_id: Uuid, participants: Vec) -> Result<(), ApiError> { + async fn calculate_swiss_rankings( + &self, + tournament_id: Uuid, + participants: Vec, + ) -> Result<(), ApiError> { // For Swiss tournaments, rank by points and tiebreakers let mut player_stats = std::collections::HashMap::new(); for participant in &participants { let wins = sqlx::query!( r#" - SELECT COUNT(*) as count FROM tournament_matches + SELECT COUNT(*) as count FROM tournament_matches WHERE tournament_id = $1 AND winner_id = $2 AND status = $3 "#, tournament_id, @@ -1342,8 +1492,8 @@ impl TournamentService { let draws = sqlx::query!( r#" - SELECT COUNT(*) as count FROM tournament_matches - WHERE tournament_id = $1 AND (player1_id = $2 OR player2_id = $2) + SELECT COUNT(*) as count FROM tournament_matches + WHERE tournament_id = $1 AND (player1_id = $2 OR player2_id = $2) AND winner_id IS NULL AND status = $3 "#, tournament_id, @@ -1383,7 +1533,10 @@ impl TournamentService { // Real-time event publishing methods // TODO: Implement proper realtime module with event types - async fn publish_tournament_event(&self, _event_data: serde_json::Value) -> Result<(), ApiError> { + async fn publish_tournament_event( + &self, + _event_data: serde_json::Value, + ) -> Result<(), ApiError> { // Placeholder for real-time tournament event publishing // Will be implemented when realtime module is added Ok(()) @@ -1396,7 +1549,10 @@ impl TournamentService { } /// Get tournament participants - pub async fn get_tournament_participants(&self, tournament_id: Uuid) -> Result, ApiError> { + pub async fn get_tournament_participants( + &self, + tournament_id: Uuid, + ) -> Result, ApiError> { let participants = sqlx::query_as!( TournamentParticipant, "SELECT * FROM tournament_participants WHERE tournament_id = $1 ORDER BY registered_at", @@ -1410,7 +1566,10 @@ impl TournamentService { } /// Get tournament bracket - pub async fn get_tournament_bracket(&self, tournament_id: Uuid) -> Result { + pub async fn get_tournament_bracket( + &self, + tournament_id: Uuid, + ) -> Result { // Get tournament rounds let rounds = sqlx::query_as!( TournamentRound, @@ -1438,16 +1597,19 @@ impl TournamentService { round_number: round.round_number, round_type: round.round_type.into(), status: round.status.into(), - matches: matches.into_iter().map(|m| BracketMatch { - match_id: m.id, - match_number: m.match_number, - player1_id: m.player1_id, - player2_id: m.player2_id, - winner_id: m.winner_id, - player1_score: m.player1_score, - player2_score: m.player2_score, - status: m.status.into(), - }).collect(), + matches: matches + .into_iter() + .map(|m| BracketMatch { + match_id: m.id, + match_number: m.match_number, + player1_id: m.player1_id, + player2_id: m.player2_id, + winner_id: m.winner_id, + player1_score: m.player1_score, + player2_score: m.player2_score, + status: m.status.into(), + }) + .collect(), }); } @@ -1458,14 +1620,11 @@ impl TournamentService { } async fn get_user_username(&self, user_id: Uuid) -> Result { - let user = sqlx::query!( - "SELECT username FROM users WHERE id = $1", - user_id - ) - .fetch_optional(&self.db_pool) - .await - .map_err(|e| ApiError::database_error(e))? - .ok_or(ApiError::not_found("User not found"))?; + let user = sqlx::query!("SELECT username FROM users WHERE id = $1", user_id) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| ApiError::database_error(e))? + .ok_or(ApiError::not_found("User not found"))?; Ok(user.username) } diff --git a/backend/src/service/wallet_service.rs b/backend/src/service/wallet_service.rs index c0c7b73..53c13ab 100644 --- a/backend/src/service/wallet_service.rs +++ b/backend/src/service/wallet_service.rs @@ -1,6 +1,5 @@ use crate::models::{ - Wallet, Transaction, TransactionType, TransactionStatus, - WalletResponse, TransactionResponse, + Transaction, TransactionResponse, TransactionStatus, TransactionType, Wallet, WalletResponse, }; use anyhow::Result; use chrono::Utc; @@ -272,11 +271,7 @@ impl WalletService { } /// Release escrow back to balance - pub async fn release_from_escrow( - &self, - user_id: Uuid, - amount: i64, - ) -> Result<(), WalletError> { + pub async fn release_from_escrow(&self, user_id: Uuid, amount: i64) -> Result<(), WalletError> { if amount <= 0 { return Err(WalletError::InvalidAmount( "Amount must be positive".to_string(), @@ -512,8 +507,11 @@ impl WalletService { let verified = self.verify_paystack_payment(ref_id, amount).await?; if verified { self.add_fiat_balance(user_id, amount).await?; - self.update_transaction_status(transaction.id, TransactionStatus::Completed) - .await?; + self.update_transaction_status( + transaction.id, + TransactionStatus::Completed, + ) + .await?; transaction.status = TransactionStatus::Completed; } else { self.update_transaction_status(transaction.id, TransactionStatus::Failed) @@ -527,8 +525,11 @@ impl WalletService { let verified = self.verify_flutterwave_payment(ref_id, amount).await?; if verified { self.add_fiat_balance(user_id, amount).await?; - self.update_transaction_status(transaction.id, TransactionStatus::Completed) - .await?; + self.update_transaction_status( + transaction.id, + TransactionStatus::Completed, + ) + .await?; transaction.status = TransactionStatus::Completed; } else { self.update_transaction_status(transaction.id, TransactionStatus::Failed) diff --git a/backend/src/telemetry.rs b/backend/src/telemetry.rs index 0b89da4..ea90476 100644 --- a/backend/src/telemetry.rs +++ b/backend/src/telemetry.rs @@ -2,10 +2,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilte pub fn init_telemetry() { tracing_subscriber::registry() - .with( - EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "backend=info".into()), - ) + .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| "backend=info".into())) .with(tracing_subscriber::fmt::layer()) .init(); } diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 1804395..9459ca1 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -32,6 +32,13 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arenax-anti-cheat-oracle" +version = "0.0.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "arenax-registry" version = "0.0.0" @@ -46,6 +53,13 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "arenax-reputation-index" +version = "0.0.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "ark-bls12-381" version = "0.4.0" @@ -170,6 +184,13 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "ax-token" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -481,6 +502,13 @@ dependencies = [ "subtle", ] +[[package]] +name = "dispute-resolution" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -858,6 +886,16 @@ dependencies = [ ] [[package]] +<<<<<<< HEAD +name = "match-lifecycle" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + +[[package]] +======= +>>>>>>> 99cfb318a51dd06fdbbff4fd1fd598156b02db34 name = "match_escrow_vault" version = "0.1.0" dependencies = [ diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 5aca680..194eff1 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -1,15 +1,23 @@ [workspace] members = [ "example", + "dispute-resolution", "protocol-params", "user_identity_contract", "reputation_aggregation", "match_contract", "match_escrow_vault", + "match-lifecycle", "registry", "slashing_contract", "governance_multisig", "tournament_finalizer", + "reputation-index", + "contract-registry", + "auth-gateway", + "ax-token", + "anti-cheat-oracle", + "staking-manager", ] resolver = "2" diff --git a/contracts/anti-cheat-oracle/Cargo.toml b/contracts/anti-cheat-oracle/Cargo.toml new file mode 100644 index 0000000..6fa8363 --- /dev/null +++ b/contracts/anti-cheat-oracle/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "arenax-anti-cheat-oracle" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/anti-cheat-oracle/src/error.rs b/contracts/anti-cheat-oracle/src/error.rs new file mode 100644 index 0000000..ef2f47b --- /dev/null +++ b/contracts/anti-cheat-oracle/src/error.rs @@ -0,0 +1,12 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum AntiCheatError { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + InvalidSeverity = 4, + ReputationNotSet = 5, +} diff --git a/contracts/anti-cheat-oracle/src/events.rs b/contracts/anti-cheat-oracle/src/events.rs new file mode 100644 index 0000000..16c507d --- /dev/null +++ b/contracts/anti-cheat-oracle/src/events.rs @@ -0,0 +1,32 @@ +use soroban_sdk::{contractevent, Address}; + +/// Event type for anti-cheat; includes AntiCheatFlag for confirmations. +#[contractevent(topics = ["ArenaXAntiCheat", "FLAG"])] +pub struct AntiCheatFlag { + pub player: Address, + pub match_id: u64, + pub severity: u32, + pub penalty_applied: i128, + pub oracle: Address, + pub timestamp: u64, +} + +pub fn emit_anticheat_flag( + env: &soroban_sdk::Env, + player: &Address, + match_id: u64, + severity: u32, + penalty_applied: i128, + oracle: &Address, + timestamp: u64, +) { + AntiCheatFlag { + player: player.clone(), + match_id, + severity, + penalty_applied, + oracle: oracle.clone(), + timestamp, + } + .publish(env); +} diff --git a/contracts/anti-cheat-oracle/src/lib.rs b/contracts/anti-cheat-oracle/src/lib.rs new file mode 100644 index 0000000..c4c9036 --- /dev/null +++ b/contracts/anti-cheat-oracle/src/lib.rs @@ -0,0 +1,178 @@ +#![no_std] + +mod error; +mod events; +mod storage; + +use soroban_sdk::auth::{ContractContext, InvokerContractAuthEntry, SubContractInvocation}; +use soroban_sdk::{contract, contractimpl, Address, Env, IntoVal, Symbol, Vec}; +use storage::{AntiCheatConfirmation, DataKey}; + +pub use error::AntiCheatError; + +/// Severity levels: 1 = low, 2 = medium, 3 = high. Maps to bounded penalties (capped in Reputation Index). +const PENALTY_LOW: i128 = 5; +const PENALTY_MEDIUM: i128 = 15; +const PENALTY_HIGH: i128 = 30; + +#[contract] +pub struct AntiCheatOracle; + +#[contractimpl] +impl AntiCheatOracle { + /// Initialize the anti-cheat oracle (admin only). + pub fn initialize(env: Env, admin: Address) -> Result<(), AntiCheatError> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(AntiCheatError::AlreadyInitialized); + } + env.storage().instance().set(&DataKey::Admin, &admin); + Ok(()) + } + + /// Add an authorized oracle address that can submit flags. + pub fn add_authorized_oracle(env: Env, oracle: Address) -> Result<(), AntiCheatError> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(AntiCheatError::NotInitialized)?; + admin.require_auth(); + env.storage() + .instance() + .set(&DataKey::AuthorizedOracle(oracle.clone()), &true); + Ok(()) + } + + /// Remove an authorized oracle. + pub fn remove_authorized_oracle(env: Env, oracle: Address) -> Result<(), AntiCheatError> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(AntiCheatError::NotInitialized)?; + admin.require_auth(); + env.storage() + .instance() + .remove(&DataKey::AuthorizedOracle(oracle)); + Ok(()) + } + + /// Returns true if the given address is an authorized oracle. + pub fn is_authorized_oracle(env: Env, oracle: Address) -> bool { + env.storage() + .instance() + .get(&DataKey::AuthorizedOracle(oracle)) + .unwrap_or(false) + } + + /// Set the Reputation Index contract address (admin only). Required before submit_flag can apply penalties. + pub fn set_reputation_contract(env: Env, reputation: Address) -> Result<(), AntiCheatError> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(AntiCheatError::NotInitialized)?; + admin.require_auth(); + env.storage().instance().set(&DataKey::ReputationContract, &reputation); + Ok(()) + } + + /// Submit an anti-cheat flag for a player in a match. Only authorized oracle addresses can call. + /// Severity: 1 = low, 2 = medium, 3 = high. Penalties are bounded and applied to the Reputation Index. + pub fn submit_flag( + env: Env, + oracle: Address, + player: Address, + match_id: u64, + severity: u32, + ) -> Result<(), AntiCheatError> { + oracle.require_auth(); + if !Self::is_authorized_oracle(env.clone(), oracle.clone()) { + return Err(AntiCheatError::Unauthorized); + } + if severity == 0 || severity > 3 { + return Err(AntiCheatError::InvalidSeverity); + } + + let penalty = match severity { + 1 => PENALTY_LOW, + 2 => PENALTY_MEDIUM, + 3 => PENALTY_HIGH, + _ => return Err(AntiCheatError::InvalidSeverity), + }; + + let timestamp = env.ledger().timestamp(); + let confirmation = AntiCheatConfirmation { + player: player.clone(), + match_id, + severity, + penalty_applied: penalty, + timestamp, + oracle: oracle.clone(), + }; + env.storage() + .instance() + .set(&DataKey::Confirmation(player.clone(), match_id), &confirmation); + + if let Some(reputation_addr) = env + .storage() + .instance() + .get::(&DataKey::ReputationContract) + { + let mut args = Vec::new(&env); + args.push_back(env.current_contract_address().into_val(&env)); + args.push_back(player.clone().into_val(&env)); + args.push_back(match_id.into_val(&env)); + args.push_back(penalty.into_val(&env)); + let context = ContractContext { + contract: reputation_addr.clone(), + fn_name: Symbol::new(&env, "apply_anticheat_penalty"), + args, + }; + let sub_invocations: Vec = Vec::new(&env); + let mut auth_entries = Vec::new(&env); + auth_entries.push_back(InvokerContractAuthEntry::Contract(SubContractInvocation { + context, + sub_invocations, + })); + env.authorize_as_current_contract(auth_entries); + let args = ( + env.current_contract_address(), + player.clone(), + match_id, + penalty, + ) + .into_val(&env); + let _: () = env.invoke_contract( + &reputation_addr, + &Symbol::new(&env, "apply_anticheat_penalty"), + args, + ); + } + + events::emit_anticheat_flag( + &env, + &player, + match_id, + severity, + penalty, + &oracle, + timestamp, + ); + Ok(()) + } + + /// Get the confirmation for a (player, match_id), if any. For consumers and auditing. + pub fn get_confirmation( + env: Env, + player: Address, + match_id: u64, + ) -> Option { + env.storage() + .instance() + .get(&DataKey::Confirmation(player, match_id)) + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/anti-cheat-oracle/src/storage.rs b/contracts/anti-cheat-oracle/src/storage.rs new file mode 100644 index 0000000..627b986 --- /dev/null +++ b/contracts/anti-cheat-oracle/src/storage.rs @@ -0,0 +1,22 @@ +use soroban_sdk::{contracttype, Address}; + +#[derive(Clone)] +#[contracttype] +pub enum DataKey { + Admin, + AuthorizedOracle(Address), + Confirmation(Address, u64), // (player, match_id) -> AntiCheatConfirmation + ReputationContract, +} + +/// Stored confirmation for an anti-cheat flag (queryable and auditable). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AntiCheatConfirmation { + pub player: Address, + pub match_id: u64, + pub severity: u32, + pub penalty_applied: i128, + pub timestamp: u64, + pub oracle: Address, +} diff --git a/contracts/anti-cheat-oracle/src/test.rs b/contracts/anti-cheat-oracle/src/test.rs new file mode 100644 index 0000000..d40260f --- /dev/null +++ b/contracts/anti-cheat-oracle/src/test.rs @@ -0,0 +1,97 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +#[test] +fn test_initialize_and_add_oracle() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + + let contract_id = env.register(AntiCheatOracle, ()); + let client = AntiCheatOracleClient::new(&env, &contract_id); + + client.initialize(&admin); + assert!(!client.is_authorized_oracle(&oracle)); + + client.add_authorized_oracle(&oracle); + assert!(client.is_authorized_oracle(&oracle)); + + client.remove_authorized_oracle(&oracle); + assert!(!client.is_authorized_oracle(&oracle)); +} + +#[test] +fn test_submit_flag_unauthorized() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let unauthorized = Address::generate(&env); + let player = Address::generate(&env); + + let contract_id = env.register(AntiCheatOracle, ()); + let client = AntiCheatOracleClient::new(&env, &contract_id); + client.initialize(&admin); + + let result = client.try_submit_flag(&unauthorized, &player, &1u64, &2u32); + assert_eq!(result, Err(Ok(AntiCheatError::Unauthorized))); +} + +#[test] +fn test_submit_flag_invalid_severity() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let player = Address::generate(&env); + + let contract_id = env.register(AntiCheatOracle, ()); + let client = AntiCheatOracleClient::new(&env, &contract_id); + client.initialize(&admin); + client.add_authorized_oracle(&oracle); + + assert_eq!( + client.try_submit_flag(&oracle, &player, &1u64, &0u32), + Err(Ok(AntiCheatError::InvalidSeverity)) + ); + assert_eq!( + client.try_submit_flag(&oracle, &player, &1u64, &4u32), + Err(Ok(AntiCheatError::InvalidSeverity)) + ); +} + +#[test] +fn test_submit_flag_and_get_confirmation() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let player = Address::generate(&env); + let match_id = 42u64; + + let contract_id = env.register(AntiCheatOracle, ()); + let client = AntiCheatOracleClient::new(&env, &contract_id); + client.initialize(&admin); + client.add_authorized_oracle(&oracle); + + assert!(client.get_confirmation(&player, &match_id).is_none()); + + client.submit_flag(&oracle, &player, &match_id, &2u32); // severity 2 = medium + + let conf = client.get_confirmation(&player, &match_id).unwrap(); + assert_eq!(conf.player, player); + assert_eq!(conf.match_id, match_id); + assert_eq!(conf.severity, 2); + assert_eq!(conf.penalty_applied, 15); // PENALTY_MEDIUM + assert_eq!(conf.oracle, oracle); +} + +// Integration with Reputation Index is tested by calling submit_flag with +// set_reputation_contract set: the contract uses invoke_contract to call +// apply_anticheat_penalty. See reputation-index tests for penalty capping and no underflow. diff --git a/contracts/anti-cheat-oracle/test_snapshots/test/test_initialize_and_add_oracle.1.json b/contracts/anti-cheat-oracle/test_snapshots/test/test_initialize_and_add_oracle.1.json new file mode 100644 index 0000000..59f505f --- /dev/null +++ b/contracts/anti-cheat-oracle/test_snapshots/test/test_initialize_and_add_oracle.1.json @@ -0,0 +1,197 @@ +{ + "generators": { + "address": 3, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "add_authorized_oracle", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "remove_authorized_oracle", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/anti-cheat-oracle/test_snapshots/test/test_submit_flag_and_get_confirmation.1.json b/contracts/anti-cheat-oracle/test_snapshots/test/test_submit_flag_and_get_confirmation.1.json new file mode 100644 index 0000000..ab43944 --- /dev/null +++ b/contracts/anti-cheat-oracle/test_snapshots/test/test_submit_flag_and_get_confirmation.1.json @@ -0,0 +1,287 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "add_authorized_oracle", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "submit_flag", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "u64": "42" + }, + { + "u32": 2 + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AuthorizedOracle" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "Confirmation" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "u64": "42" + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "match_id" + }, + "val": { + "u64": "42" + } + }, + { + "key": { + "symbol": "oracle" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "penalty_applied" + }, + "val": { + "i128": "15" + } + }, + { + "key": { + "symbol": "player" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "symbol": "severity" + }, + "val": { + "u32": 2 + } + }, + { + "key": { + "symbol": "timestamp" + }, + "val": { + "u64": "0" + } + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/anti-cheat-oracle/test_snapshots/test/test_submit_flag_invalid_severity.1.json b/contracts/anti-cheat-oracle/test_snapshots/test/test_submit_flag_invalid_severity.1.json new file mode 100644 index 0000000..c8fe7e4 --- /dev/null +++ b/contracts/anti-cheat-oracle/test_snapshots/test/test_submit_flag_invalid_severity.1.json @@ -0,0 +1,159 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "add_authorized_oracle", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AuthorizedOracle" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "val": { + "bool": true + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/anti-cheat-oracle/test_snapshots/test/test_submit_flag_unauthorized.1.json b/contracts/anti-cheat-oracle/test_snapshots/test/test_submit_flag_unauthorized.1.json new file mode 100644 index 0000000..65ac088 --- /dev/null +++ b/contracts/anti-cheat-oracle/test_snapshots/test/test_submit_flag_unauthorized.1.json @@ -0,0 +1,91 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/auth-gateway/Cargo.toml b/contracts/auth-gateway/Cargo.toml new file mode 100644 index 0000000..11c73b7 --- /dev/null +++ b/contracts/auth-gateway/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "auth-gateway" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "ArenaX Cross-Contract Authorization Gateway - Centralized role management" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk.workspace = true + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/auth-gateway/src/lib.rs b/contracts/auth-gateway/src/lib.rs new file mode 100644 index 0000000..41af4a1 --- /dev/null +++ b/contracts/auth-gateway/src/lib.rs @@ -0,0 +1,424 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractevent, contractimpl, contracttype, Address, Env, Map, Symbol, +}; + +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Role { + None = 0, + Admin = 1, + Operator = 2, + Referee = 3, + Player = 4, + TournamentManager = 5, + Treasury = 6, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + Admin, + Role(Address), + ContractWhitelist(Address), + Paused, +} + +#[contractevent] +pub struct Initialized { + pub admin: Address, +} + +#[contractevent] +pub struct RoleAssigned { + pub address: Address, + pub role: Role, + pub assigned_by: Address, +} + +#[contractevent] +pub struct RoleRevoked { + pub address: Address, + pub role: Role, + pub revoked_by: Address, +} + +#[contractevent] +pub struct ContractWhitelisted { + pub contract_address: Address, + pub whitelisted_by: Address, +} + +#[contractevent] +pub struct ContractRemoved { + pub contract_address: Address, + pub removed_by: Address, +} + +#[contractevent] +pub struct ContractPaused { + pub paused: bool, + pub paused_by: Address, +} + +#[contract] +pub struct AuthGateway; + +#[contractimpl] +impl AuthGateway { + /// Initialize the authorization gateway with an admin address + /// + /// # Arguments + /// * `admin` - The admin address with full control over the gateway + /// + /// # Panics + /// * If contract is already initialized + pub fn initialize(env: Env, admin: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Paused, &false); + + Initialized { admin }.publish(&env); + } + + /// Assign a role to an address + /// + /// # Arguments + /// * `address` - The address to assign the role to + /// * `role` - The role to assign + /// + /// # Panics + /// * If contract is paused + /// * If caller is not admin + /// * If trying to assign None role (use revoke_role instead) + pub fn assign_role(env: Env, address: Address, role: Role) { + Self::require_admin(&env); + Self::require_not_paused(&env); + + if role == Role::None { + panic!("use revoke_role to remove roles"); + } + + let admin = env.current_contract_address(); + let current_role = Self::get_role(&env, address.clone()); + + env.storage() + .instance() + .set(&DataKey::Role(address.clone()), &role); + + RoleAssigned { + address, + role, + assigned_by: admin, + } + .publish(&env); + } + + /// Revoke a role from an address + /// + /// # Arguments + /// * `address` - The address to revoke the role from + /// + /// # Panics + /// * If contract is paused + /// * If caller is not admin + /// * If address has no role to revoke + pub fn revoke_role(env: Env, address: Address) { + Self::require_admin(&env); + Self::require_not_paused(&env); + + let current_role = Self::get_role(&env, address.clone()); + if current_role == Role::None { + panic!("address has no role to revoke"); + } + + let admin = env.current_contract_address(); + env.storage() + .instance() + .remove(&DataKey::Role(address.clone())); + + RoleRevoked { + address, + role: current_role, + revoked_by: admin, + } + .publish(&env); + } + + /// Whitelist a contract to use the auth gateway + /// + /// # Arguments + /// * `contract_address` - The contract address to whitelist + /// + /// # Panics + /// * If contract is paused + /// * If caller is not admin + /// * If contract is already whitelisted + pub fn whitelist_contract(env: Env, contract_address: Address) { + Self::require_admin(&env); + Self::require_not_paused(&env); + + if env + .storage() + .instance() + .has(&DataKey::ContractWhitelist(contract_address.clone())) + { + panic!("contract already whitelisted"); + } + + let admin = env.current_contract_address(); + env.storage() + .instance() + .set(&DataKey::ContractWhitelist(contract_address.clone()), &true); + + ContractWhitelisted { + contract_address, + whitelisted_by: admin, + } + .publish(&env); + } + + /// Remove a contract from the whitelist + /// + /// # Arguments + /// * `contract_address` - The contract address to remove + /// + /// # Panics + /// * If contract is paused + /// * If caller is not admin + /// * If contract is not whitelisted + pub fn remove_contract(env: Env, contract_address: Address) { + Self::require_admin(&env); + Self::require_not_paused(&env); + + if !env + .storage() + .instance() + .has(&DataKey::ContractWhitelist(contract_address.clone())) + { + panic!("contract not whitelisted"); + } + + let admin = env.current_contract_address(); + env.storage() + .instance() + .remove(&DataKey::ContractWhitelist(contract_address.clone())); + + ContractRemoved { + contract_address, + removed_by: admin, + } + .publish(&env); + } + + /// Pause/unpause the contract + /// + /// # Arguments + /// * `paused` - Whether to pause the contract + /// + /// # Panics + /// * If caller is not admin + pub fn set_paused(env: Env, paused: bool) { + Self::require_admin(&env); + let admin = env.current_contract_address(); + + env.storage().instance().set(&DataKey::Paused, &paused); + + ContractPaused { + paused, + paused_by: admin, + } + .publish(&env); + } + + /// Get the role of an address + /// + /// # Arguments + /// * `address` - The address to check + /// + /// # Returns + /// The role of the address (Role::None if no role assigned) + pub fn get_role(env: Env, address: Address) -> Role { + env.storage() + .instance() + .get(&DataKey::Role(address)) + .unwrap_or(Role::None) + } + + /// Check if an address has a specific role + /// + /// # Arguments + /// * `address` - The address to check + /// * `role` - The role to check for + /// + /// # Returns + /// True if the address has the role, false otherwise + pub fn has_role(env: Env, address: Address, role: Role) -> bool { + let user_role = Self::get_role(env, address); + user_role == role + } + + /// Check if an address has any of the specified roles + /// + /// # Arguments + /// * `address` - The address to check + /// * `roles` - Array of roles to check against + /// + /// # Returns + /// True if the address has any of the roles, false otherwise + pub fn has_any_role(env: Env, address: Address, roles: Vec) -> bool { + let user_role = Self::get_role(env, address); + roles.iter().any(|&role| user_role == role) + } + + /// Check if a contract is whitelisted + /// + /// # Arguments + /// * `contract_address` - The contract address to check + /// + /// # Returns + /// True if the contract is whitelisted, false otherwise + pub fn is_contract_whitelisted(env: Env, contract_address: Address) -> bool { + env.storage() + .instance() + .has(&DataKey::ContractWhitelist(contract_address)) + } + + /// Get the admin address + /// + /// # Returns + /// The admin address + /// + /// # Panics + /// * If contract is not initialized + pub fn get_admin(env: Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized") + } + + /// Check if the contract is paused + /// + /// # Returns + /// True if the contract is paused, false otherwise + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + /// Batch role assignment for multiple addresses + /// + /// # Arguments + /// * `addresses` - Array of addresses to assign roles to + /// * `roles` - Array of roles corresponding to the addresses + /// + /// # Panics + /// * If contract is paused + /// * If caller is not admin + /// * If arrays have different lengths + /// * If any role is None + pub fn batch_assign_roles(env: Env, addresses: Vec
, roles: Vec) { + Self::require_admin(&env); + Self::require_not_paused(&env); + + if addresses.len() != roles.len() { + panic!("addresses and roles arrays must have same length"); + } + + for (i, address) in addresses.iter().enumerate() { + let role = roles.get(i).unwrap(); + if *role == Role::None { + panic!("use revoke_role to remove roles"); + } + + env.storage() + .instance() + .set(&DataKey::Role(address.clone()), role); + + RoleAssigned { + address: address.clone(), + role: *role, + assigned_by: env.current_contract_address(), + } + .publish(&env); + } + } + + /// Get all addresses with a specific role + /// + /// # Arguments + /// * `role` - The role to search for + /// + /// # Returns + /// Array of addresses that have the specified role + /// + /// # Note + /// This is an expensive operation and should be used sparingly + pub fn get_addresses_with_role(env: Env, role: Role) -> Vec
{ + // In a real implementation, you might want to maintain + // reverse indexes for better performance + // For now, this is a placeholder that would need + // additional storage structures to work efficiently + Vec::new(&env) + } + + /// Transfer admin role to a new address + /// + /// # Arguments + /// * `new_admin` - The new admin address + /// + /// # Panics + /// * If caller is not current admin + /// * If new_admin already has a role (must be None first) + pub fn transfer_admin(env: Env, new_admin: Address) { + let current_admin = Self::get_admin(env.clone()); + current_admin.require_auth(); + + let new_admin_role = Self::get_role(env.clone(), new_admin.clone()); + if new_admin_role != Role::None { + panic!("new admin must have no existing role"); + } + + env.storage().instance().set(&DataKey::Admin, &new_admin); + env.storage() + .instance() + .set(&DataKey::Role(new_admin.clone()), &Role::Admin); + + RoleAssigned { + address: new_admin.clone(), + role: Role::Admin, + assigned_by: current_admin, + } + .publish(&env); + + RoleRevoked { + address: current_admin, + role: Role::Admin, + revoked_by: new_admin, + } + .publish(&env); + } + + // Helper functions for internal use + + fn require_admin(env: &Env) { + let admin = Self::get_admin(env.clone()); + admin.require_auth(); + } + + fn require_not_paused(env: &Env) { + let paused = Self::is_paused(env.clone()); + if paused { + panic!("contract is paused"); + } + } +} diff --git a/contracts/auth-gateway/src/test.rs b/contracts/auth-gateway/src/test.rs new file mode 100644 index 0000000..5889461 --- /dev/null +++ b/contracts/auth-gateway/src/test.rs @@ -0,0 +1,454 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + Address, Env, Vec, +}; + +fn create_test_env() -> (Env, Address, Address, Address, Address) { + let env = Env::default(); + let admin = Address::generate(&env); + let operator = Address::generate(&env); + let referee = Address::generate(&env); + let player = Address::generate(&env); + (env, admin, operator, referee, player) +} + +fn initialize_contract(env: &Env, admin: &Address) -> Address { + let contract_id = Address::generate(env); + env.register_contract(&contract_id, AuthGateway); + let client = AuthGatewayClient::new(env, &contract_id); + + env.mock_all_auths(); + client.initialize(admin); + + contract_id +} + +#[test] +fn test_initialization() { + let (env, admin, _, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + assert_eq!(client.get_admin(), admin); + assert!(!client.is_paused()); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_double_initialization() { + let (env, admin, _, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + client.initialize(&admin); +} + +#[test] +fn test_assign_role() { + let (env, admin, operator, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.assign_role(&operator, Role::Operator); + + assert_eq!(client.get_role(&operator), Role::Operator); + assert!(client.has_role(&operator, Role::Operator)); + assert!(!client.has_role(&operator, Role::Admin)); +} + +#[test] +#[should_panic(expected = "use revoke_role to remove roles")] +fn test_assign_none_role_fails() { + let (env, admin, operator, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.assign_role(&operator, Role::None); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_assign_role_unauthorized() { + let (env, admin, operator, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + client.assign_role(&operator, Role::Operator); +} + +#[test] +fn test_revoke_role() { + let (env, admin, operator, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.assign_role(&operator, Role::Operator); + assert_eq!(client.get_role(&operator), Role::Operator); + + client.revoke_role(&operator); + assert_eq!(client.get_role(&operator), Role::None); + assert!(!client.has_role(&operator, Role::Operator)); +} + +#[test] +#[should_panic(expected = "address has no role to revoke")] +fn test_revoke_nonexistent_role_fails() { + let (env, admin, operator, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.revoke_role(&operator); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_revoke_role_unauthorized() { + let (env, admin, operator, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.assign_role(&operator, Role::Operator); + + client.revoke_role(&operator); +} + +#[test] +fn test_whitelist_contract() { + let (env, admin, _, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + let target_contract = Address::generate(&env); + + env.mock_all_auths(); + client.whitelist_contract(&target_contract); + + assert!(client.is_contract_whitelisted(&target_contract)); +} + +#[test] +#[should_panic(expected = "contract already whitelisted")] +fn test_whitelist_duplicate_contract_fails() { + let (env, admin, _, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + let target_contract = Address::generate(&env); + + env.mock_all_auths(); + client.whitelist_contract(&target_contract); + client.whitelist_contract(&target_contract); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_whitelist_contract_unauthorized() { + let (env, admin, _, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + let target_contract = Address::generate(&env); + client.whitelist_contract(&target_contract); +} + +#[test] +fn test_remove_contract() { + let (env, admin, _, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + let target_contract = Address::generate(&env); + + env.mock_all_auths(); + client.whitelist_contract(&target_contract); + assert!(client.is_contract_whitelisted(&target_contract)); + + client.remove_contract(&target_contract); + assert!(!client.is_contract_whitelisted(&target_contract)); +} + +#[test] +#[should_panic(expected = "contract not whitelisted")] +fn test_remove_non_whitelisted_contract_fails() { + let (env, admin, _, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + let target_contract = Address::generate(&env); + + env.mock_all_auths(); + client.remove_contract(&target_contract); +} + +#[test] +fn test_pause_contract() { + let (env, admin, _, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + + assert!(!client.is_paused()); + client.set_paused(&true); + assert!(client.is_paused()); + client.set_paused(&false); + assert!(!client.is_paused()); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_pause_contract_unauthorized() { + let (env, admin, _, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + client.set_paused(&true); +} + +#[test] +#[should_panic(expected = "contract is paused")] +fn test_operations_when_paused() { + let (env, admin, operator, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.set_paused(&true); + + client.assign_role(&operator, Role::Operator); +} + +#[test] +fn test_has_any_role() { + let (env, admin, operator, referee, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + + client.assign_role(&operator, Role::Operator); + client.assign_role(&referee, Role::Referee); + + let roles = vec![&env, Role::Operator, Role::Referee]; + assert!(client.has_any_role(&operator, roles.clone())); + assert!(client.has_any_role(&referee, roles)); + + let admin_roles = vec![&env, Role::Admin, Role::Operator]; + assert!(!client.has_any_role(&operator, admin_roles)); +} + +#[test] +fn test_batch_assign_roles() { + let (env, admin, operator, referee, player) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + + let addresses = vec![&env, operator.clone(), referee.clone(), player.clone()]; + let roles = vec![&env, Role::Operator, Role::Referee, Role::Player]; + + client.batch_assign_roles(addresss, roles); + + assert_eq!(client.get_role(&operator), Role::Operator); + assert_eq!(client.get_role(&referee), Role::Referee); + assert_eq!(client.get_role(&player), Role::Player); +} + +#[test] +#[should_panic(expected = "addresses and roles arrays must have same length")] +fn test_batch_assign_roles_mismatched_length_fails() { + let (env, admin, operator, referee, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + + let addresses = vec![&env, operator.clone(), referee.clone()]; + let roles = vec![&env, Role::Operator]; + + client.batch_assign_roles(addresses, roles); +} + +#[test] +#[should_panic(expected = "use revoke_role to remove roles")] +fn test_batch_assign_roles_with_none_fails() { + let (env, admin, operator, referee, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + + let addresses = vec![&env, operator.clone(), referee.clone()]; + let roles = vec![&env, Role::Operator, Role::None]; + + client.batch_assign_roles(addresses, roles); +} + +#[test] +fn test_transfer_admin() { + let (env, admin, operator, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + + assert_eq!(client.get_admin(), admin); + client.transfer_admin(&operator); + + assert_eq!(client.get_admin(), operator); + assert_eq!(client.get_role(&operator), Role::Admin); + assert_eq!(client.get_role(&admin), Role::None); +} + +#[test] +#[should_panic(expected = "new admin must have no existing role")] +fn test_transfer_admin_with_existing_role_fails() { + let (env, admin, operator, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + + client.assign_role(&operator, Role::Operator); + client.transfer_admin(&operator); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_transfer_admin_unauthorized() { + let (env, admin, operator, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + client.transfer_admin(&operator); +} + +#[test] +fn test_all_role_types() { + let (env, admin, operator, referee, player) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + + client.assign_role(&operator, Role::Operator); + client.assign_role(&referee, Role::Referee); + client.assign_role(&player, Role::Player); + + assert_eq!(client.get_role(&admin), Role::Admin); + assert_eq!(client.get_role(&operator), Role::Operator); + assert_eq!(client.get_role(&referee), Role::Referee); + assert_eq!(client.get_role(&player), Role::Player); + + assert!(client.has_role(&admin, Role::Admin)); + assert!(client.has_role(&operator, Role::Operator)); + assert!(client.has_role(&referee, Role::Referee)); + assert!(client.has_role(&player, Role::Player)); + + assert!(!client.has_role(&admin, Role::Operator)); + assert!(!client.has_role(&operator, Role::Referee)); + assert!(!client.has_role(&referee, Role::Player)); + assert!(!client.has_role(&player, Role::Admin)); +} + +#[test] +fn test_role_overwrite() { + let (env, admin, operator, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + + client.assign_role(&operator, Role::Operator); + assert_eq!(client.get_role(&operator), Role::Operator); + + client.assign_role(&operator, Role::Referee); + assert_eq!(client.get_role(&operator), Role::Referee); + assert!(!client.has_role(&operator, Role::Operator)); + assert!(client.has_role(&operator, Role::Referee)); +} + +#[test] +fn test_multiple_contracts_whitelisting() { + let (env, admin, _, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + let contract1 = Address::generate(&env); + let contract2 = Address::generate(&env); + let contract3 = Address::generate(&env); + + env.mock_all_auths(); + + client.whitelist_contract(&contract1); + client.whitelist_contract(&contract2); + client.whitelist_contract(&contract3); + + assert!(client.is_contract_whitelisted(&contract1)); + assert!(client.is_contract_whitelisted(&contract2)); + assert!(client.is_contract_whitelisted(&contract3)); + + client.remove_contract(&contract2); + assert!(client.is_contract_whitelisted(&contract1)); + assert!(!client.is_contract_whitelisted(&contract2)); + assert!(client.is_contract_whitelisted(&contract3)); +} + +#[test] +fn test_cross_contract_integration_simulation() { + let (env, admin, operator, referee, player) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + let match_contract = Address::generate(&env); + let prize_contract = Address::generate(&env); + + env.mock_all_auths(); + + client.whitelist_contract(&match_contract); + client.whitelist_contract(&prize_contract); + + client.assign_role(&operator, Role::Operator); + client.assign_role(&referee, Role::Referee); + client.assign_role(&player, Role::Player); + + assert!(client.is_contract_whitelisted(&match_contract)); + assert!(client.is_contract_whitelisted(&prize_contract)); + + let resolver_roles = vec![&env, Role::Admin, Role::Referee]; + assert!(client.has_any_role(&referee, resolver_roles)); + assert!(!client.has_any_role(&player, resolver_roles)); + + let participant_roles = vec![&env, Role::Operator, Role::Player]; + assert!(client.has_any_role(&operator, participant_roles)); + assert!(client.has_any_role(&player, participant_roles)); + assert!(!client.has_any_role(&referee, participant_roles)); +} + +#[test] +fn test_edge_cases() { + let (env, admin, operator, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AuthGatewayClient::new(&env, &contract_id); + + env.mock_all_auths(); + + let non_existent_address = Address::generate(&env); + assert_eq!(client.get_role(&non_existent_address), Role::None); + assert!(!client.has_role(&non_existent_address, Role::Admin)); + + let non_whitelisted_contract = Address::generate(&env); + assert!(!client.is_contract_whitelisted(&non_whitelisted_contract)); + + let empty_roles = Vec::new(&env); + assert!(!client.has_any_role(&operator, empty_roles)); +} diff --git a/contracts/ax-token/Cargo.toml b/contracts/ax-token/Cargo.toml new file mode 100644 index 0000000..e1b1b17 --- /dev/null +++ b/contracts/ax-token/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ax-token" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "ArenaX Token (AX) - Native governance and utility token" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk.workspace = true + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/ax-token/src/lib.rs b/contracts/ax-token/src/lib.rs new file mode 100644 index 0000000..e31e880 --- /dev/null +++ b/contracts/ax-token/src/lib.rs @@ -0,0 +1,172 @@ +#![no_std] + +use soroban_sdk::{ + contracttype, + contractimpl, + contractevent, + Address, + Env, + Map, + Vec +}; + +#[derive(Clone)] +#[contracttype] +pub enum DataKey { + Admin, + Balance(Address), + TotalSupply, +} + +#[contractevent] +pub struct MintEvent { + pub to: Address, + pub amount: i128, +} + +#[contractevent] +pub struct BurnEvent { + pub from: Address, + pub amount: i128, +} + +#[contractevent] +pub struct TransferEvent { + pub from: Address, + pub to: Address, + pub amount: i128, +} + +pub struct AxToken; + +#[contractimpl] +impl AxToken { + pub fn initialize(env: &Env, admin: Address) { + if Self::has_admin(env) { + panic!("already initialized"); + } + + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::TotalSupply, &0i128); + } + + pub fn mint(env: &Env, to: Address, amount: i128) { + Self::require_admin(env); + + if amount <= 0 { + panic!("amount must be positive"); + } + + let current_balance = Self::balance(env, to.clone()); + let new_balance = current_balance + amount; + env.storage().instance().set(&DataKey::Balance(to.clone()), &new_balance); + + let current_supply = Self::total_supply(env); + let new_supply = current_supply + amount; + env.storage().instance().set(&DataKey::TotalSupply, &new_supply); + + env.events().publish( + MintEvent { + to, + amount, + } + ); + } + + pub fn burn(env: &Env, from: Address, amount: i128) { + Self::require_admin(env); + + if amount <= 0 { + panic!("amount must be positive"); + } + + let current_balance = Self::balance(env, from.clone()); + if current_balance < amount { + panic!("insufficient balance"); + } + + let new_balance = current_balance - amount; + env.storage().instance().set(&DataKey::Balance(from.clone()), &new_balance); + + let current_supply = Self::total_supply(env); + let new_supply = current_supply - amount; + env.storage().instance().set(&DataKey::TotalSupply, &new_supply); + + env.events().publish( + BurnEvent { + from, + amount, + } + ); + } + + pub fn transfer(env: &Env, from: Address, to: Address, amount: i128) { + from.require_auth(); + + if amount <= 0 { + panic!("amount must be positive"); + } + + if from == to { + panic!("cannot transfer to self"); + } + + let from_balance = Self::balance(env, from.clone()); + if from_balance < amount { + panic!("insufficient balance"); + } + + let new_from_balance = from_balance - amount; + env.storage().instance().set(&DataKey::Balance(from.clone()), &new_from_balance); + + let to_balance = Self::balance(env, to.clone()); + let new_to_balance = to_balance + amount; + env.storage().instance().set(&DataKey::Balance(to.clone()), &new_to_balance); + + env.events().publish( + TransferEvent { + from, + to, + amount, + } + ); + } + + pub fn balance(env: &Env, addr: Address) -> i128 { + env.storage() + .instance() + .get(&DataKey::Balance(addr)) + .unwrap_or(0) + } + + pub fn total_supply(env: &Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::TotalSupply) + .unwrap_or(0) + } + + pub fn get_admin(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .unwrap() + } + + pub fn set_admin(env: &Env, new_admin: Address) { + Self::require_admin(env); + env.storage().instance().set(&DataKey::Admin, &new_admin); + } + + fn has_admin(env: &Env) -> bool { + env.storage() + .instance() + .get::(&DataKey::Admin) + .is_some() + } + + fn require_admin(env: &Env) { + let admin = Self::get_admin(env); + admin.require_auth(); + } +} diff --git a/contracts/ax-token/src/test.rs b/contracts/ax-token/src/test.rs new file mode 100644 index 0000000..505eeb5 --- /dev/null +++ b/contracts/ax-token/src/test.rs @@ -0,0 +1,317 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + Address, Env, +}; + +fn create_test_env() -> (Env, Address, Address, Address) { + let env = Env::default(); + let admin = Address::generate(&env); + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + (env, admin, user1, user2) +} + +fn initialize_contract(env: &Env, admin: &Address) -> Address { + let contract_id = Address::generate(env); + env.register_contract(&contract_id, AxToken); + let client = AxTokenClient::new(env, &contract_id); + client.initialize(admin); + contract_id +} + +#[test] +fn test_initialization() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + assert_eq!(client.get_admin(), admin); + assert_eq!(client.total_supply(), 0); + assert_eq!(client.balance(&user1), 0); + assert_eq!(client.balance(&user2), 0); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_double_initialization() { + let (env, admin, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + client.initialize(&admin); +} + +#[test] +fn test_mint() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + + client.mint(&user1, 1000); + assert_eq!(client.balance(&user1), 1000); + assert_eq!(client.total_supply(), 1000); + + client.mint(&user2, 500); + assert_eq!(client.balance(&user2), 500); + assert_eq!(client.total_supply(), 1500); +} + +#[test] +#[should_panic(expected = "amount must be positive")] +fn test_mint_zero_amount() { + let (env, admin, user1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.mint(&user1, 0); +} + +#[test] +#[should_panic(expected = "amount must be positive")] +fn test_mint_negative_amount() { + let (env, admin, user1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.mint(&user1, -100); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_mint_unauthorized() { + let (env, admin, user1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + client.mint(&user1, 1000); +} + +#[test] +fn test_burn() { + let (env, admin, user1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + + client.mint(&user1, 1000); + assert_eq!(client.balance(&user1), 1000); + assert_eq!(client.total_supply(), 1000); + + client.burn(&user1, 300); + assert_eq!(client.balance(&user1), 700); + assert_eq!(client.total_supply(), 700); +} + +#[test] +#[should_panic(expected = "insufficient balance")] +fn test_burn_insufficient_balance() { + let (env, admin, user1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + + client.mint(&user1, 1000); + client.burn(&user1, 1500); +} + +#[test] +#[should_panic(expected = "amount must be positive")] +fn test_burn_zero_amount() { + let (env, admin, user1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + + client.mint(&user1, 1000); + client.burn(&user1, 0); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_burn_unauthorized() { + let (env, admin, user1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.mint(&user1, 1000); + + client.burn(&user1, 100); +} + +#[test] +fn test_transfer() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + + client.mint(&user1, 1000); + client.mint(&user2, 500); + + client.transfer(&user1, &user2, 300); + assert_eq!(client.balance(&user1), 700); + assert_eq!(client.balance(&user2), 800); + assert_eq!(client.total_supply(), 1500); +} + +#[test] +#[should_panic(expected = "insufficient balance")] +fn test_transfer_insufficient_balance() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + + client.mint(&user1, 1000); + client.transfer(&user1, &user2, 1500); +} + +#[test] +#[should_panic(expected = "amount must be positive")] +fn test_transfer_zero_amount() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + + client.mint(&user1, 1000); + client.transfer(&user1, &user2, 0); +} + +#[test] +#[should_panic(expected = "cannot transfer to self")] +fn test_transfer_to_self() { + let (env, admin, user1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + + client.mint(&user1, 1000); + client.transfer(&user1, &user1, 100); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_transfer_unauthorized() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + + client.mint(&user1, 1000); + client.mint(&user2, 500); + + client.transfer(&user1, &user2, 100); +} + +#[test] +fn test_set_admin() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + + assert_eq!(client.get_admin(), admin); + client.set_admin(&user1); + assert_eq!(client.get_admin(), user1); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_set_admin_unauthorized() { + let (env, admin, user1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + client.set_admin(&user1); +} + +#[test] +fn test_full_lifecycle() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + + client.mint(&user1, 1000); + client.mint(&user2, 1000); + assert_eq!(client.total_supply(), 2000); + + client.transfer(&user1, &user2, 300); + assert_eq!(client.balance(&user1), 700); + assert_eq!(client.balance(&user2), 1300); + + client.burn(&user1, 200); + client.burn(&user2, 400); + assert_eq!(client.balance(&user1), 500); + assert_eq!(client.balance(&user2), 900); + assert_eq!(client.total_supply(), 1400); +} + +#[test] +fn test_large_amounts() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + + let large_amount = i128::MAX / 4; + client.mint(&user1, large_amount); + client.mint(&user2, large_amount); + + assert_eq!(client.total_supply(), large_amount * 2); + assert_eq!(client.balance(&user1), large_amount); + assert_eq!(client.balance(&user2), large_amount); + + client.transfer(&user1, &user2, large_amount / 2); + assert_eq!(client.balance(&user1), large_amount / 2); + assert_eq!(client.balance(&user2), large_amount * 3 / 2); +} + +#[test] +fn test_multiple_users() { + let (env, admin, user1, user2) = create_test_env(); + let user3 = Address::generate(&env); + let user4 = Address::generate(&env); + let contract_id = initialize_contract(&env, &admin); + let client = AxTokenClient::new(&env, &contract_id); + + env.mock_all_auths(); + + let users = vec![user1.clone(), user2.clone(), user3.clone(), user4.clone()]; + let amounts = vec![1000, 2000, 3000, 4000]; + + for (i, user) in users.iter().enumerate() { + client.mint(user, amounts[i]); + } + + assert_eq!(client.total_supply(), 10000); + + client.transfer(&user1, &user2, 500); + client.transfer(&user3, &user4, 1000); + + assert_eq!(client.balance(&user1), 500); + assert_eq!(client.balance(&user2), 2500); + assert_eq!(client.balance(&user3), 2000); + assert_eq!(client.balance(&user4), 5000); + assert_eq!(client.total_supply(), 10000); +} diff --git a/contracts/contract-registry/Cargo.toml b/contracts/contract-registry/Cargo.toml new file mode 100644 index 0000000..19a1076 --- /dev/null +++ b/contracts/contract-registry/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "contract-registry" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "ArenaX Contract Registry - Centralized contract registration and lookup" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk.workspace = true + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/contract-registry/src/lib.rs b/contracts/contract-registry/src/lib.rs new file mode 100644 index 0000000..1bf8d04 --- /dev/null +++ b/contracts/contract-registry/src/lib.rs @@ -0,0 +1,497 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractevent, contractimpl, contracttype, Address, Env, Map, Symbol, Vec, +}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + Admin, + Contract(Symbol), + ContractList, + Paused, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractInfo { + pub address: Address, + pub name: Symbol, + pub registered_at: u64, + pub updated_at: Option, + pub registered_by: Address, +} + +#[contractevent] +pub struct Initialized { + pub admin: Address, +} + +#[contractevent] +pub struct ContractRegistered { + pub name: Symbol, + pub address: Address, + pub registered_by: Address, +} + +#[contractevent] +pub struct ContractUpdated { + pub name: Symbol, + pub old_address: Address, + pub new_address: Address, + pub updated_by: Address, +} + +#[contractevent] +pub struct ContractRemoved { + pub name: Symbol, + pub address: Address, + pub removed_by: Address, +} + +#[contractevent] +pub struct RegistryPaused { + pub paused: bool, + pub paused_by: Address, +} + +#[contract] +pub struct ContractRegistry; + +#[contractimpl] +impl ContractRegistry { + /// Initialize the contract registry with an admin address + /// + /// # Arguments + /// * `admin` - The admin address with full control over the registry + /// + /// # Panics + /// * If contract is already initialized + pub fn initialize(env: Env, admin: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Paused, &false); + env.storage().instance().set(&DataKey::ContractList, &Vec::::new(&env)); + + Initialized { admin }.publish(&env); + } + + /// Register a new contract with a unique name + /// + /// # Arguments + /// * `name` - Unique identifier for the contract + /// * `address` - The contract address to register + /// + /// # Panics + /// * If contract is paused + /// * If caller is not admin + /// * If name is already registered + /// * If name is empty + pub fn register_contract(env: Env, name: Symbol, address: Address) { + Self::require_admin(&env); + Self::require_not_paused(&env); + + if name.is_empty() { + panic!("contract name cannot be empty"); + } + + if env.storage().instance().has(&DataKey::Contract(name.clone())) { + panic!("contract name already registered"); + } + + let contract_info = ContractInfo { + address: address.clone(), + name: name.clone(), + registered_at: env.ledger().timestamp(), + updated_at: None, + registered_by: env.current_contract_address(), + }; + + env.storage() + .instance() + .set(&DataKey::Contract(name.clone()), &contract_info); + + let mut contract_list: Vec = env + .storage() + .instance() + .get(&DataKey::ContractList) + .unwrap_or(Vec::new(&env)); + + contract_list.push_back(name.clone()); + env.storage() + .instance() + .set(&DataKey::ContractList, &contract_list); + + ContractRegistered { + name, + address, + registered_by: env.current_contract_address(), + } + .publish(&env); + } + + /// Update an existing contract's address + /// + /// # Arguments + /// * `name` - The name of the contract to update + /// * `new_address` - The new contract address + /// + /// # Panics + /// * If contract is paused + /// * If caller is not admin + /// * If contract name is not registered + /// * If new address is the same as current address + pub fn update_contract(env: Env, name: Symbol, new_address: Address) { + Self::require_admin(&env); + Self::require_not_paused(&env); + + let mut contract_info: ContractInfo = env + .storage() + .instance() + .get(&DataKey::Contract(name.clone())) + .expect("contract not registered"); + + if contract_info.address == new_address { + panic!("new address is the same as current address"); + } + + let old_address = contract_info.address.clone(); + contract_info.address = new_address.clone(); + contract_info.updated_at = Some(env.ledger().timestamp()); + + env.storage() + .instance() + .set(&DataKey::Contract(name.clone()), &contract_info); + + ContractUpdated { + name, + old_address, + new_address, + updated_by: env.current_contract_address(), + } + .publish(&env); + } + + /// Remove a contract from the registry + /// + /// # Arguments + /// * `name` - The name of the contract to remove + /// + /// # Panics + /// * If contract is paused + /// * If caller is not admin + /// * If contract name is not registered + pub fn remove_contract(env: Env, name: Symbol) { + Self::require_admin(&env); + Self::require_not_paused(&env); + + let contract_info: ContractInfo = env + .storage() + .instance() + .get(&DataKey::Contract(name.clone())) + .expect("contract not registered"); + + let address = contract_info.address.clone(); + + env.storage() + .instance() + .remove(&DataKey::Contract(name.clone())); + + let mut contract_list: Vec = env + .storage() + .instance() + .get(&DataKey::ContractList) + .unwrap_or(Vec::new(&env)); + + let index = contract_list.iter().position(|&item| item == name); + if let Some(idx) = index { + contract_list.remove(idx); + env.storage() + .instance() + .set(&DataKey::ContractList, &contract_list); + } + + ContractRemoved { + name, + address, + removed_by: env.current_contract_address(), + } + .publish(&env); + } + + /// Get the address of a registered contract + /// + /// # Arguments + /// * `name` - The name of the contract to look up + /// + /// # Returns + /// The contract address + /// + /// # Panics + /// * If contract name is not registered + pub fn get_contract(env: Env, name: Symbol) -> Address { + let contract_info: ContractInfo = env + .storage() + .instance() + .get(&DataKey::Contract(name)) + .expect("contract not registered"); + contract_info.address + } + + /// Get detailed information about a registered contract + /// + /// # Arguments + /// * `name` - The name of the contract to look up + /// + /// # Returns + /// The contract information including metadata + /// + /// # Panics + /// * If contract name is not registered + pub fn get_contract_info(env: Env, name: Symbol) -> ContractInfo { + env.storage() + .instance() + .get(&DataKey::Contract(name)) + .expect("contract not registered") + } + + /// Check if a contract name is registered + /// + /// # Arguments + /// * `name` - The name to check + /// + /// # Returns + /// True if the name is registered, false otherwise + pub fn is_contract_registered(env: Env, name: Symbol) -> bool { + env.storage() + .instance() + .has(&DataKey::Contract(name)) + } + + /// Get a list of all registered contract names + /// + /// # Returns + /// Vector of all registered contract names + pub fn list_contracts(env: Env) -> Vec { + env.storage() + .instance() + .get(&DataKey::ContractList) + .unwrap_or(Vec::new(&env)) + } + + /// Get the total number of registered contracts + /// + /// # Returns + /// The count of registered contracts + pub fn get_contract_count(env: Env) -> u32 { + let contract_list: Vec = env + .storage() + .instance() + .get(&DataKey::ContractList) + .unwrap_or(Vec::new(&env)); + contract_list.len() as u32 + } + + /// Get all contracts registered by a specific address + /// + /// # Arguments + /// * `registered_by` - The address to filter by + /// + /// # Returns + /// Vector of contract names registered by the address + pub fn get_contracts_by_registrar(env: Env, registered_by: Address) -> Vec { + let contract_list: Vec = env + .storage() + .instance() + .get(&DataKey::ContractList) + .unwrap_or(Vec::new(&env)); + + let mut result = Vec::new(&env); + for name in contract_list.iter() { + if let Some(contract_info) = env + .storage() + .instance() + .get::(&DataKey::Contract(name)) + { + if contract_info.registered_by == registered_by { + result.push_back(name); + } + } + } + result + } + + /// Get contracts updated within a specific time range + /// + /// # Arguments + /// * `start_time` - Start timestamp (inclusive) + /// * `end_time` - End timestamp (inclusive) + /// + /// # Returns + /// Vector of contract names updated in the time range + pub fn get_contracts_updated_in_range(env: Env, start_time: u64, end_time: u64) -> Vec { + let contract_list: Vec = env + .storage() + .instance() + .get(&DataKey::ContractList) + .unwrap_or(Vec::new(&env)); + + let mut result = Vec::new(&env); + for name in contract_list.iter() { + if let Some(contract_info) = env + .storage() + .instance() + .get::(&DataKey::Contract(name)) + { + if let Some(updated_at) = contract_info.updated_at { + if updated_at >= start_time && updated_at <= end_time { + result.push_back(name); + } + } + } + } + result + } + + /// Pause/unpause the contract registry + /// + /// # Arguments + /// * `paused` - Whether to pause the registry + /// + /// # Panics + /// * If caller is not admin + pub fn set_paused(env: Env, paused: bool) { + Self::require_admin(&env); + let admin = env.current_contract_address(); + + env.storage().instance().set(&DataKey::Paused, &paused); + + RegistryPaused { + paused, + paused_by: admin, + } + .publish(&env); + } + + /// Get the admin address + /// + /// # Returns + /// The admin address + /// + /// # Panics + /// * If contract is not initialized + pub fn get_admin(env: Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized") + } + + /// Check if the contract registry is paused + /// + /// # Returns + /// True if the registry is paused, false otherwise + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + /// Batch register multiple contracts + /// + /// # Arguments + /// * `names` - Array of contract names + /// * `addresses` - Array of contract addresses + /// + /// # Panics + /// * If contract is paused + /// * If caller is not admin + /// * If arrays have different lengths + /// * If any name is already registered or empty + pub fn batch_register_contracts(env: Env, names: Vec, addresses: Vec
) { + Self::require_admin(&env); + Self::require_not_paused(&env); + + if names.len() != addresses.len() { + panic!("names and addresses arrays must have same length"); + } + + for (i, name) in names.iter().enumerate() { + if name.is_empty() { + panic!("contract name cannot be empty"); + } + + if env.storage().instance().has(&DataKey::Contract(name.clone())) { + panic!("contract name already registered"); + } + + let address = addresses.get(i).unwrap(); + let contract_info = ContractInfo { + address: address.clone(), + name: name.clone(), + registered_at: env.ledger().timestamp(), + updated_at: None, + registered_by: env.current_contract_address(), + }; + + env.storage() + .instance() + .set(&DataKey::Contract(name.clone()), &contract_info); + + ContractRegistered { + name: name.clone(), + address: address.clone(), + registered_by: env.current_contract_address(), + } + .publish(&env); + } + + let mut contract_list: Vec = env + .storage() + .instance() + .get(&DataKey::ContractList) + .unwrap_or(Vec::new(&env)); + + for name in names.iter() { + contract_list.push_back(name.clone()); + } + + env.storage() + .instance() + .set(&DataKey::ContractList, &contract_list); + } + + /// Transfer admin role to a new address + /// + /// # Arguments + /// * `new_admin` - The new admin address + /// + /// # Panics + /// * If caller is not current admin + pub fn transfer_admin(env: Env, new_admin: Address) { + let current_admin = Self::get_admin(env.clone()); + current_admin.require_auth(); + + env.storage().instance().set(&DataKey::Admin, &new_admin); + } + + // Helper functions for internal use + + fn require_admin(env: &Env) { + let admin = Self::get_admin(env.clone()); + admin.require_auth(); + } + + fn require_not_paused(env: &Env) { + let paused = Self::is_paused(env.clone()); + if paused { + panic!("contract is paused"); + } + } +} diff --git a/contracts/contract-registry/src/test.rs b/contracts/contract-registry/src/test.rs new file mode 100644 index 0000000..1ceaf12 --- /dev/null +++ b/contracts/contract-registry/src/test.rs @@ -0,0 +1,524 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + Address, Env, Symbol, Vec, +}; + +fn create_test_env() -> (Env, Address, Address, Address) { + let env = Env::default(); + let admin = Address::generate(&env); + let contract1 = Address::generate(&env); + let contract2 = Address::generate(&env); + (env, admin, contract1, contract2) +} + +fn initialize_contract(env: &Env, admin: &Address) -> Address { + let contract_id = Address::generate(env); + env.register_contract(&contract_id, ContractRegistry); + let client = ContractRegistryClient::new(env, &contract_id); + + env.mock_all_auths(); + client.initialize(admin); + + contract_id +} + +#[test] +fn test_initialization() { + let (env, admin, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + assert_eq!(client.get_admin(), admin); + assert!(!client.is_paused()); + assert_eq!(client.get_contract_count(), 0); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_double_initialization() { + let (env, admin, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + client.initialize(&admin); +} + +#[test] +fn test_register_contract() { + let (env, admin, contract1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name = Symbol::new(&env, "match_contract"); + + env.mock_all_auths(); + client.register_contract(&name, &contract1); + + assert!(client.is_contract_registered(&name)); + assert_eq!(client.get_contract(&name), contract1); + assert_eq!(client.get_contract_count(), 1); + + let contract_list = client.list_contracts(); + assert_eq!(contract_list.len(), 1); + assert_eq!(contract_list.get(0), name); +} + +#[test] +#[should_panic(expected = "contract name cannot be empty")] +fn test_register_empty_name_fails() { + let (env, admin, contract1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let empty_name = Symbol::new(&env, ""); + + env.mock_all_auths(); + client.register_contract(&empty_name, &contract1); +} + +#[test] +#[should_panic(expected = "contract name already registered")] +fn test_register_duplicate_name_fails() { + let (env, admin, contract1, contract2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name = Symbol::new(&env, "match_contract"); + + env.mock_all_auths(); + client.register_contract(&name, &contract1); + client.register_contract(&name, &contract2); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_register_contract_unauthorized() { + let (env, admin, contract1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name = Symbol::new(&env, "match_contract"); + client.register_contract(&name, &contract1); +} + +#[test] +fn test_update_contract() { + let (env, admin, contract1, contract2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name = Symbol::new(&env, "match_contract"); + + env.mock_all_auths(); + client.register_contract(&name, &contract1); + assert_eq!(client.get_contract(&name), contract1); + + client.update_contract(&name, &contract2); + assert_eq!(client.get_contract(&name), contract2); + + let contract_info = client.get_contract_info(&name); + assert_eq!(contract_info.address, contract2); + assert!(contract_info.updated_at.is_some()); +} + +#[test] +#[should_panic(expected = "new address is the same as current address")] +fn test_update_same_address_fails() { + let (env, admin, contract1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name = Symbol::new(&env, "match_contract"); + + env.mock_all_auths(); + client.register_contract(&name, &contract1); + client.update_contract(&name, &contract1); +} + +#[test] +#[should_panic(expected = "contract not registered")] +fn test_update_nonexistent_contract_fails() { + let (env, admin, contract1, contract2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name = Symbol::new(&env, "nonexistent"); + + env.mock_all_auths(); + client.update_contract(&name, &contract2); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_update_contract_unauthorized() { + let (env, admin, contract1, contract2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name = Symbol::new(&env, "match_contract"); + + env.mock_all_auths(); + client.register_contract(&name, &contract1); + + client.update_contract(&name, &contract2); +} + +#[test] +fn test_remove_contract() { + let (env, admin, contract1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name = Symbol::new(&env, "match_contract"); + + env.mock_all_auths(); + client.register_contract(&name, &contract1); + assert!(client.is_contract_registered(&name)); + assert_eq!(client.get_contract_count(), 1); + + client.remove_contract(&name); + assert!(!client.is_contract_registered(&name)); + assert_eq!(client.get_contract_count(), 0); +} + +#[test] +#[should_panic(expected = "contract not registered")] +fn test_remove_nonexistent_contract_fails() { + let (env, admin, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name = Symbol::new(&env, "nonexistent"); + + env.mock_all_auths(); + client.remove_contract(&name); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_remove_contract_unauthorized() { + let (env, admin, contract1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name = Symbol::new(&env, "match_contract"); + + env.mock_all_auths(); + client.register_contract(&name, &contract1); + + client.remove_contract(&name); +} + +#[test] +fn test_pause_contract() { + let (env, admin, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + env.mock_all_auths(); + + assert!(!client.is_paused()); + client.set_paused(&true); + assert!(client.is_paused()); + client.set_paused(&false); + assert!(!client.is_paused()); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_pause_contract_unauthorized() { + let (env, admin, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + client.set_paused(&true); +} + +#[test] +#[should_panic(expected = "contract is paused")] +fn test_operations_when_paused() { + let (env, admin, contract1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name = Symbol::new(&env, "match_contract"); + + env.mock_all_auths(); + client.set_paused(&true); + + client.register_contract(&name, &contract1); +} + +#[test] +fn test_get_contract_info() { + let (env, admin, contract1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name = Symbol::new(&env, "match_contract"); + + env.mock_all_auths(); + client.register_contract(&name, &contract1); + + let contract_info = client.get_contract_info(&name); + assert_eq!(contract_info.address, contract1); + assert_eq!(contract_info.name, name); + assert!(contract_info.registered_at > 0); + assert!(contract_info.updated_at.is_none()); +} + +#[test] +fn test_list_contracts() { + let (env, admin, contract1, contract2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name1 = Symbol::new(&env, "match_contract"); + let name2 = Symbol::new(&env, "token_contract"); + let name3 = Symbol::new(&env, "registry_contract"); + + env.mock_all_auths(); + + client.register_contract(&name1, &contract1); + client.register_contract(&name2, &contract2); + client.register_contract(&name3, &contract1); + + let contract_list = client.list_contracts(); + assert_eq!(contract_list.len(), 3); + assert_eq!(client.get_contract_count(), 3); + + assert!(contract_list.contains(&name1)); + assert!(contract_list.contains(&name2)); + assert!(contract_list.contains(&name3)); +} + +#[test] +fn test_batch_register_contracts() { + let (env, admin, contract1, contract2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let names = vec![&env, + Symbol::new(&env, "match_contract"), + Symbol::new(&env, "token_contract"), + Symbol::new(&env, "registry_contract") + ]; + let addresses = vec![&env, contract1.clone(), contract2.clone(), contract1.clone()]; + + env.mock_all_auths(); + client.batch_register_contracts(names, addresses); + + assert_eq!(client.get_contract_count(), 3); + assert!(client.is_contract_registered(&Symbol::new(&env, "match_contract"))); + assert!(client.is_contract_registered(&Symbol::new(&env, "token_contract"))); + assert!(client.is_contract_registered(&Symbol::new(&env, "registry_contract"))); +} + +#[test] +#[should_panic(expected = "names and addresses arrays must have same length")] +fn test_batch_register_mismatched_length_fails() { + let (env, admin, contract1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let names = vec![&env, + Symbol::new(&env, "match_contract"), + Symbol::new(&env, "token_contract") + ]; + let addresses = vec![&env, contract1]; + + env.mock_all_auths(); + client.batch_register_contracts(names, addresses); +} + +#[test] +#[should_panic(expected = "contract name cannot be empty")] +fn test_batch_register_empty_name_fails() { + let (env, admin, contract1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let names = vec![&env, + Symbol::new(&env, "match_contract"), + Symbol::new(&env, "") + ]; + let addresses = vec![&env, contract1.clone(), contract1]; + + env.mock_all_auths(); + client.batch_register_contracts(names, addresses); +} + +#[test] +fn test_get_contracts_by_registrar() { + let (env, admin, contract1, contract2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name1 = Symbol::new(&env, "match_contract"); + let name2 = Symbol::new(&env, "token_contract"); + + env.mock_all_auths(); + + client.register_contract(&name1, &contract1); + client.register_contract(&name2, &contract2); + + let registry_address = env.current_contract_address(); + let contracts_by_registrar = client.get_contracts_by_registrar(registry_address); + + assert_eq!(contracts_by_registrar.len(), 2); + assert!(contracts_by_registrar.contains(&name1)); + assert!(contracts_by_registrar.contains(&name2)); +} + +#[test] +fn test_get_contracts_updated_in_range() { + let (env, admin, contract1, contract2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name1 = Symbol::new(&env, "match_contract"); + let name2 = Symbol::new(&env, "token_contract"); + + env.mock_all_auths(); + + env.ledger().set_timestamp(1000); + client.register_contract(&name1, &contract1); + client.register_contract(&name2, &contract2); + + env.ledger().set_timestamp(2000); + client.update_contract(&name1, &contract2); + + let updated_contracts = client.get_contracts_updated_in_range(1500, 2500); + assert_eq!(updated_contracts.len(), 1); + assert!(updated_contracts.contains(&name1)); + + let updated_contracts = client.get_contracts_updated_in_range(500, 1500); + assert_eq!(updated_contracts.len(), 0); +} + +#[test] +fn test_transfer_admin() { + let (env, admin, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let new_admin = Address::generate(&env); + + env.mock_all_auths(); + client.transfer_admin(&new_admin); + + assert_eq!(client.get_admin(), new_admin); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_transfer_admin_unauthorized() { + let (env, admin, _, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let new_admin = Address::generate(&env); + client.transfer_admin(&new_admin); +} + +#[test] +fn test_contract_info_metadata() { + let (env, admin, contract1, contract2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name = Symbol::new(&env, "match_contract"); + + env.mock_all_auths(); + + env.ledger().set_timestamp(1000); + client.register_contract(&name, &contract1); + + let contract_info = client.get_contract_info(&name); + assert_eq!(contract_info.registered_at, 1000); + assert!(contract_info.updated_at.is_none()); + assert_eq!(contract_info.registered_by, env.current_contract_address()); + + env.ledger().set_timestamp(2000); + client.update_contract(&name, &contract2); + + let updated_info = client.get_contract_info(&name); + assert_eq!(updated_info.registered_at, 1000); + assert_eq!(updated_info.updated_at, Some(2000)); + assert_eq!(updated_info.address, contract2); +} + +#[test] +fn test_multiple_operations() { + let (env, admin, contract1, contract2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let match_name = Symbol::new(&env, "match_contract"); + let token_name = Symbol::new(&env, "token_contract"); + let registry_name = Symbol::new(&env, "registry_contract"); + + env.mock_all_auths(); + + client.register_contract(&match_name, &contract1); + client.register_contract(&token_name, &contract2); + client.register_contract(®istry_name, &contract1); + + assert_eq!(client.get_contract_count(), 3); + assert_eq!(client.get_contract(&match_name), contract1); + assert_eq!(client.get_contract(&token_name), contract2); + assert_eq!(client.get_contract(®istry_name), contract1); + + client.update_contract(&match_name, &contract2); + assert_eq!(client.get_contract(&match_name), contract2); + + client.remove_contract(&token_name); + assert!(!client.is_contract_registered(&token_name)); + assert_eq!(client.get_contract_count(), 2); + + let remaining_contracts = client.list_contracts(); + assert_eq!(remaining_contracts.len(), 2); + assert!(remaining_contracts.contains(&match_name)); + assert!(remaining_contracts.contains(®istry_name)); +} + +#[test] +fn test_edge_cases() { + let (env, admin, contract1, _) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let nonexistent_name = Symbol::new(&env, "nonexistent"); + assert!(!client.is_contract_registered(&nonexistent_name)); + + let empty_list = client.list_contracts(); + assert_eq!(empty_list.len(), 0); + + let empty_count = client.get_contract_count(); + assert_eq!(empty_count, 0); +} + +#[test] +fn test_deterministic_resolution() { + let (env, admin, contract1, contract2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = ContractRegistryClient::new(&env, &contract_id); + + let name = Symbol::new(&env, "match_contract"); + + env.mock_all_auths(); + client.register_contract(&name, &contract1); + + let address1 = client.get_contract(&name); + let address2 = client.get_contract(&name); + let address3 = client.get_contract(&name); + + assert_eq!(address1, contract1); + assert_eq!(address2, contract1); + assert_eq!(address3, contract1); + assert_eq!(address1, address2); + assert_eq!(address2, address3); +} diff --git a/contracts/dispute-resolution/Cargo.toml b/contracts/dispute-resolution/Cargo.toml new file mode 100644 index 0000000..9f653af --- /dev/null +++ b/contracts/dispute-resolution/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "dispute-resolution" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Dispute Resolution Module: handles dispute open/close, evidence references, adjudication and timeouts" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk.workspace = true + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/dispute-resolution/src/lib.rs b/contracts/dispute-resolution/src/lib.rs new file mode 100644 index 0000000..905db02 --- /dev/null +++ b/contracts/dispute-resolution/src/lib.rs @@ -0,0 +1,194 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractevent, contractimpl, contracttype, Address, BytesN, Env, IntoVal, String, Symbol, +}; + +#[contractevent(topics = ["ArenaXDispute", "OPENED"])] +pub struct DisputeOpened { + pub match_id: BytesN<32>, + pub reason: String, + pub evidence_ref: String, + pub deadline: u64, +} + +#[contractevent(topics = ["ArenaXDispute", "RESOLVED"])] +pub struct DisputeResolved { + pub match_id: BytesN<32>, + pub decision: String, + pub resolved_at: u64, + pub operator: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DisputeStatus { + Open = 0, + Resolved = 1, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DisputeData { + pub match_id: BytesN<32>, + pub reason: String, + pub evidence_ref: String, + pub status: u32, + pub opened_at: u64, + pub deadline: u64, + pub decision: Option, + pub resolved_at: Option, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + Admin, + IdentityContract, + ResolutionWindow, + Dispute(BytesN<32>), +} + +#[contract] +pub struct DisputeResolutionContract; + +#[contractimpl] +impl DisputeResolutionContract { + pub fn initialize( + env: Env, + admin: Address, + identity_contract: Address, + resolution_window: u64, + ) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::IdentityContract, &identity_contract); + env.storage() + .instance() + .set(&DataKey::ResolutionWindow, &resolution_window); + } + + pub fn open_dispute(env: Env, match_id: BytesN<32>, reason: String, evidence_ref: String) { + if env + .storage() + .persistent() + .has(&DataKey::Dispute(match_id.clone())) + { + panic!("dispute already opened"); + } + + let resolution_window: u64 = env + .storage() + .instance() + .get(&DataKey::ResolutionWindow) + .expect("contract not initialized"); + + let opened_at = env.ledger().timestamp(); + let deadline = opened_at + resolution_window; + + let dispute = DisputeData { + match_id: match_id.clone(), + reason: reason.clone(), + evidence_ref: evidence_ref.clone(), + status: DisputeStatus::Open as u32, + opened_at, + deadline, + decision: None, + resolved_at: None, + }; + + env.storage() + .persistent() + .set(&DataKey::Dispute(match_id.clone()), &dispute); + + DisputeOpened { + match_id, + reason, + evidence_ref, + deadline, + } + .publish(&env); + } + + pub fn resolve_dispute(env: Env, match_id: BytesN<32>, caller: Address, decision: String) { + caller.require_auth(); + + if !Self::is_operator(&env, &caller) { + panic!("unauthorized call: only operators can adjudicate disputes"); + } + + let mut dispute: DisputeData = env + .storage() + .persistent() + .get(&DataKey::Dispute(match_id.clone())) + .expect("dispute not found"); + + if dispute.status != DisputeStatus::Open as u32 { + panic!("dispute is not open"); + } + + let current_time = env.ledger().timestamp(); + if current_time > dispute.deadline { + panic!("resolution deadline has passed"); + } + + dispute.status = DisputeStatus::Resolved as u32; + dispute.decision = Some(decision.clone()); + dispute.resolved_at = Some(current_time); + + env.storage() + .persistent() + .set(&DataKey::Dispute(match_id.clone()), &dispute); + + DisputeResolved { + match_id, + decision, + resolved_at: current_time, + operator: caller, + } + .publish(&env); + } + + pub fn is_disputed(env: Env, match_id: BytesN<32>) -> bool { + if let Some(dispute) = env + .storage() + .persistent() + .get::(&DataKey::Dispute(match_id)) + { + return dispute.status == DisputeStatus::Open as u32; + } + false + } + + fn is_operator(env: &Env, addr: &Address) -> bool { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("contract not initialized"); + + if addr == &admin { + return true; + } + + if let Some(identity_contract) = env + .storage() + .instance() + .get::(&DataKey::IdentityContract) + { + let role: u32 = env.invoke_contract( + &identity_contract, + &Symbol::new(env, "get_role"), + (addr.clone(),).into_val(env), + ); + return role == 1 || role == 2; + } + + false + } +} diff --git a/contracts/match-lifecycle/Cargo.toml b/contracts/match-lifecycle/Cargo.toml new file mode 100644 index 0000000..fa7f3da --- /dev/null +++ b/contracts/match-lifecycle/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "match-lifecycle" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Match Lifecycle Manager: creation, participation, result submission, and finalization with strict state transitions" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk.workspace = true + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/match-lifecycle/src/lib.rs b/contracts/match-lifecycle/src/lib.rs new file mode 100644 index 0000000..7385050 --- /dev/null +++ b/contracts/match-lifecycle/src/lib.rs @@ -0,0 +1,348 @@ +#![no_std] + +//! # Match Lifecycle Manager +//! +//! Manages creation, participation, result submission, and finalization of matches +//! with strict state transitions and authorization. Supports dual-reporting: +//! two participants must submit matching results before a match can be finalized. + +use soroban_sdk::{ + contract, contractevent, contractimpl, contracttype, Address, BytesN, Env, IntoVal, Symbol, Vec, +}; + +// Events (match_created, result_submitted, match_finalized) +#[contractevent(topics = ["ArenaXMatchLifecycle", "CREATED"])] +pub struct MatchCreated { + pub match_id: BytesN<32>, + pub players: Vec
, + pub stake_asset: Address, + pub stake_amount: i128, + pub created_at: u64, +} + +#[contractevent(topics = ["ArenaXMatchLifecycle", "RESULT"])] +pub struct ResultSubmitted { + pub match_id: BytesN<32>, + pub reporter: Address, + pub score: i64, + pub report_number: u32, +} + +#[contractevent(topics = ["ArenaXMatchLifecycle", "FINALIZED"])] +pub struct MatchFinalized { + pub match_id: BytesN<32>, + pub winner: Address, + pub finalized_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + Match(BytesN<32>), + Admin, + IdentityContract, +} + +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum MatchState { + Created = 0, + InProgress = 1, + PendingResult = 2, + Finalized = 3, + Disputed = 4, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MatchData { + pub players: Vec
, + pub stake_asset: Address, + pub stake_amount: i128, + pub state: u32, + pub created_at: u64, + pub report1_reporter: Option
, + pub report1_score: Option, + pub report2_reporter: Option
, + pub report2_score: Option, + pub winner: Option
, + pub finalized_at: Option, +} + +#[contract] +pub struct MatchLifecycleContract; + +#[contractimpl] +impl MatchLifecycleContract { + /// Initialize the contract with an admin (optional; used for set_identity_contract). + pub fn initialize(env: Env, admin: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + } + + /// Set the Identity Contract address for operator (Referee/Admin) checks on finalize. + pub fn set_identity_contract(env: Env, identity_contract: Address) { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); + admin.require_auth(); + env.storage() + .instance() + .set(&DataKey::IdentityContract, &identity_contract); + } + + /// Create a new match with the given players, stake asset, and stake amount. + /// State: Created. + pub fn create_match( + env: Env, + match_id: BytesN<32>, + players: Vec
, + stake_asset: Address, + stake_amount: i128, + ) { + if env + .storage() + .persistent() + .has(&DataKey::Match(match_id.clone())) + { + panic!("match already exists"); + } + if players.len() < 2 { + panic!("at least two players required"); + } + if stake_amount <= 0 { + panic!("stake_amount must be positive"); + } + + let created_at = env.ledger().timestamp(); + let match_data = MatchData { + players: players.clone(), + stake_asset: stake_asset.clone(), + stake_amount, + state: MatchState::Created as u32, + created_at, + report1_reporter: None, + report1_score: None, + report2_reporter: None, + report2_score: None, + winner: None, + finalized_at: None, + }; + + env.storage() + .persistent() + .set(&DataKey::Match(match_id.clone()), &match_data); + + MatchCreated { + match_id, + players, + stake_asset: match_data.stake_asset.clone(), + stake_amount: match_data.stake_amount, + created_at, + } + .publish(&env); + } + + /// Submit a result for a match. Reporter must be a participant. + /// First report: transition Created -> InProgress and store report. + /// Second report: if same score from another participant -> PendingResult; if different score -> Disputed. + pub fn submit_result(env: Env, match_id: BytesN<32>, reporter: Address, score: i64) { + reporter.require_auth(); + + let mut match_data: MatchData = env + .storage() + .persistent() + .get(&DataKey::Match(match_id.clone())) + .expect("match not found"); + + let state = match_data.state; + if state != MatchState::Created as u32 + && state != MatchState::InProgress as u32 + { + panic!("invalid state for result submission"); + } + + if !Self::is_participant(&match_data.players, &reporter) { + panic!("reporter must be a participant"); + } + + if state == MatchState::Created as u32 { + match_data.state = MatchState::InProgress as u32; + } + + if match_data.report1_reporter.is_none() { + match_data.report1_reporter = Some(reporter.clone()); + match_data.report1_score = Some(score); + env.storage() + .persistent() + .set(&DataKey::Match(match_id.clone()), &match_data); + ResultSubmitted { + match_id: match_id.clone(), + reporter, + score, + report_number: 1, + } + .publish(&env); + return; + } + + if match_data.report1_reporter.as_ref() == Some(&reporter) { + panic!("same reporter cannot submit twice"); + } + + match_data.report2_reporter = Some(reporter.clone()); + match_data.report2_score = Some(score); + + let score1 = match_data.report1_score.unwrap(); + if score == score1 { + match_data.state = MatchState::PendingResult as u32; + } else { + match_data.state = MatchState::Disputed as u32; + } + + env.storage() + .persistent() + .set(&DataKey::Match(match_id.clone()), &match_data); + + ResultSubmitted { + match_id, + reporter, + score, + report_number: 2, + } + .publish(&env); + } + + /// Finalize a match. Caller must be a participant or an operator (Referee/Admin via identity contract). + /// Only allowed when state is PendingResult. Sets winner from agreed score (score = player index). + pub fn finalize_match(env: Env, match_id: BytesN<32>, caller: Address) { + let mut match_data: MatchData = env + .storage() + .persistent() + .get(&DataKey::Match(match_id.clone())) + .expect("match not found"); + + if match_data.state != MatchState::PendingResult as u32 { + panic!("match must be in PendingResult to finalize"); + } + + let is_participant = Self::is_participant(&match_data.players, &caller); + let is_operator = Self::is_operator(&env, &caller); + + if !is_participant && !is_operator { + panic!("only participants or operators can finalize"); + } + + caller.require_auth(); + + let score = match_data.report1_score.unwrap(); + let winner = Self::winner_from_score(&env, &match_data.players, score) + .expect("agreed score must be a valid player index"); + + match_data.state = MatchState::Finalized as u32; + match_data.winner = Some(winner.clone()); + match_data.finalized_at = Some(env.ledger().timestamp()); + + env.storage() + .persistent() + .set(&DataKey::Match(match_id.clone()), &match_data); + + MatchFinalized { + match_id, + winner, + finalized_at: match_data.finalized_at.unwrap(), + } + .publish(&env); + } + + /// Mark match as disputed (e.g. from external dispute flow). Operator or participant only. + pub fn raise_dispute(env: Env, match_id: BytesN<32>, caller: Address) { + caller.require_auth(); + + let mut match_data: MatchData = env + .storage() + .persistent() + .get(&DataKey::Match(match_id.clone())) + .expect("match not found"); + + if match_data.state != MatchState::InProgress as u32 + && match_data.state != MatchState::PendingResult as u32 + { + panic!("invalid state for dispute"); + } + + let is_participant = Self::is_participant(&match_data.players, &caller); + let is_operator = Self::is_operator(&env, &caller); + if !is_participant && !is_operator { + panic!("only participants or operators can raise dispute"); + } + + match_data.state = MatchState::Disputed as u32; + env.storage() + .persistent() + .set(&DataKey::Match(match_id), &match_data); + } + + pub fn get_match(env: Env, match_id: BytesN<32>) -> MatchData { + env.storage() + .persistent() + .get(&DataKey::Match(match_id)) + .expect("match not found") + } + + pub fn match_exists(env: Env, match_id: BytesN<32>) -> bool { + env.storage().persistent().has(&DataKey::Match(match_id)) + } + + fn is_participant(players: &Vec
, addr: &Address) -> bool { + for i in 0..players.len() { + if players.get(i).unwrap() == *addr { + return true; + } + } + false + } + + fn winner_from_score(_env: &Env, players: &Vec
, score: i64) -> Option
{ + if score < 0 { + return None; + } + let idx = score as u32; + if idx >= players.len() { + return None; + } + Some(players.get(idx).unwrap()) + } + + fn is_operator(env: &Env, addr: &Address) -> bool { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); + if addr == &admin { + return true; + } + if let Some(identity_contract) = env + .storage() + .instance() + .get::(&DataKey::IdentityContract) + { + let role: u32 = env.invoke_contract( + &identity_contract, + &Symbol::new(env, "get_role"), + (addr.clone(),).into_val(env), + ); + return role == 1 || role == 2; + } + false + } +} + +mod test; diff --git a/contracts/match-lifecycle/src/test.rs b/contracts/match-lifecycle/src/test.rs new file mode 100644 index 0000000..fe9527d --- /dev/null +++ b/contracts/match-lifecycle/src/test.rs @@ -0,0 +1,163 @@ +#![cfg(test)] +use super::*; +use soroban_sdk::testutils::{Address as _, Ledger as _}; +use soroban_sdk::{BytesN, Env, Vec}; + +fn setup(env: &Env) -> (MatchLifecycleContractClient<'_>, Address, Vec
, BytesN<32>) { + env.mock_all_auths(); + env.ledger().set_timestamp(12345); + + let contract_id = env.register(MatchLifecycleContract, ()); + let client = MatchLifecycleContractClient::new(env, &contract_id); + + let admin = Address::generate(env); + client.initialize(&admin); + + let mut players: Vec
= Vec::new(env); + let player_a = Address::generate(env); + let player_b = Address::generate(env); + players.push_back(player_a.clone()); + players.push_back(player_b.clone()); + + let stake_asset = Address::generate(env); + let match_id = BytesN::from_array(env, &[1u8; 32]); + + (client, stake_asset, players, match_id) +} + +#[test] +fn test_create_match_and_lifecycle() { + let env = Env::default(); + let (client, stake_asset, players, match_id) = setup(&env); + + client.create_match(&match_id, &players, &stake_asset, &1000); + let data = client.get_match(&match_id); + assert_eq!(data.state, MatchState::Created as u32); + assert_eq!(data.stake_amount, 1000); + assert_eq!(data.players.len(), 2); +} + +#[test] +fn test_submit_result_dual_reporting_agree() { + let env = Env::default(); + let (client, stake_asset, players, match_id) = setup(&env); + let player_a = players.get(0).unwrap(); + let player_b = players.get(1).unwrap(); + + client.create_match(&match_id, &players, &stake_asset, &1000); + + client.submit_result(&match_id, &player_a, &0); // score 0 = player 0 wins + let data = client.get_match(&match_id); + assert_eq!(data.state, MatchState::InProgress as u32); + assert!(data.report1_reporter.is_some()); + assert_eq!(data.report1_score, Some(0)); + + client.submit_result(&match_id, &player_b, &0); // same score + let data = client.get_match(&match_id); + assert_eq!(data.state, MatchState::PendingResult as u32); + assert!(data.report2_reporter.is_some()); + assert_eq!(data.report2_score, Some(0)); +} + +#[test] +fn test_submit_result_dual_reporting_dispute() { + let env = Env::default(); + let (client, stake_asset, players, match_id) = setup(&env); + let player_a = players.get(0).unwrap(); + let player_b = players.get(1).unwrap(); + + client.create_match(&match_id, &players, &stake_asset, &1000); + client.submit_result(&match_id, &player_a, &0); + client.submit_result(&match_id, &player_b, &1); // different score -> dispute + let data = client.get_match(&match_id); + assert_eq!(data.state, MatchState::Disputed as u32); +} + +#[test] +fn test_finalize_match_as_participant() { + let env = Env::default(); + let (client, stake_asset, players, match_id) = setup(&env); + let player_a = players.get(0).unwrap(); + let player_b = players.get(1).unwrap(); + + client.create_match(&match_id, &players, &stake_asset, &1000); + client.submit_result(&match_id, &player_a, &0); + client.submit_result(&match_id, &player_b, &0); + + client.finalize_match(&match_id, &player_a); + let data = client.get_match(&match_id); + assert_eq!(data.state, MatchState::Finalized as u32); + assert_eq!(data.winner, Some(player_a)); + assert!(data.finalized_at.is_some()); +} + +#[test] +#[should_panic(expected = "same reporter cannot submit twice")] +fn test_submit_result_same_reporter_twice_fails() { + let env = Env::default(); + let (client, stake_asset, players, match_id) = setup(&env); + let player_a = players.get(0).unwrap(); + + client.create_match(&match_id, &players, &stake_asset, &1000); + client.submit_result(&match_id, &player_a, &0); + client.submit_result(&match_id, &player_a, &0); +} + +#[test] +#[should_panic(expected = "match must be in PendingResult to finalize")] +fn test_finalize_before_pending_result_fails() { + let env = Env::default(); + let (client, stake_asset, players, match_id) = setup(&env); + let player_a = players.get(0).unwrap(); + + client.create_match(&match_id, &players, &stake_asset, &1000); + client.finalize_match(&match_id, &player_a); +} + +#[test] +#[should_panic(expected = "reporter must be a participant")] +fn test_submit_result_non_participant_fails() { + let env = Env::default(); + let (client, stake_asset, players, match_id) = setup(&env); + let outsider = Address::generate(&env); + + client.create_match(&match_id, &players, &stake_asset, &1000); + client.submit_result(&match_id, &outsider, &0); +} + +#[test] +fn test_finalize_match_as_operator() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register(MatchLifecycleContract, ()); + let client = MatchLifecycleContractClient::new(&env, &contract_id); + client.initialize(&admin); + let mut players: Vec
= Vec::new(&env); + let player_a = Address::generate(&env); + let player_b = Address::generate(&env); + players.push_back(player_a.clone()); + players.push_back(player_b.clone()); + let stake_asset = Address::generate(&env); + let match_id = BytesN::from_array(&env, &[3u8; 32]); + client.create_match(&match_id, &players, &stake_asset, &1000); + client.submit_result(&match_id, &player_a, &1); + client.submit_result(&match_id, &player_b, &1); + client.finalize_match(&match_id, &admin); + let data = client.get_match(&match_id); + assert_eq!(data.state, MatchState::Finalized as u32); + assert_eq!(data.winner, Some(player_b)); +} + +#[test] +fn test_match_exists() { + let env = Env::default(); + let (client, stake_asset, players, match_id) = setup(&env); + let other_id = BytesN::from_array(&env, &[2u8; 32]); + + assert!(!client.match_exists(&match_id)); + assert!(!client.match_exists(&other_id)); + client.create_match(&match_id, &players, &stake_asset, &1000); + assert!(client.match_exists(&match_id)); + assert!(!client.match_exists(&other_id)); +} diff --git a/contracts/match-lifecycle/test_snapshots/test/test_create_match_and_lifecycle.1.json b/contracts/match-lifecycle/test_snapshots/test/test_create_match_and_lifecycle.1.json new file mode 100644 index 0000000..7c1b5b7 --- /dev/null +++ b/contracts/match-lifecycle/test_snapshots/test/test_create_match_and_lifecycle.1.json @@ -0,0 +1,272 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 12345, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": "12345" + } + }, + { + "key": { + "symbol": "finalized_at" + }, + "val": "void" + }, + { + "key": { + "symbol": "players" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + } + }, + { + "key": { + "symbol": "report1_reporter" + }, + "val": "void" + }, + { + "key": { + "symbol": "report1_score" + }, + "val": "void" + }, + { + "key": { + "symbol": "report2_reporter" + }, + "val": "void" + }, + { + "key": { + "symbol": "report2_score" + }, + "val": "void" + }, + { + "key": { + "symbol": "stake_amount" + }, + "val": { + "i128": "1000" + } + }, + { + "key": { + "symbol": "stake_asset" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "symbol": "state" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "winner" + }, + "val": "void" + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/match-lifecycle/test_snapshots/test/test_finalize_before_pending_result_fails.1.json b/contracts/match-lifecycle/test_snapshots/test/test_finalize_before_pending_result_fails.1.json new file mode 100644 index 0000000..7c1b5b7 --- /dev/null +++ b/contracts/match-lifecycle/test_snapshots/test/test_finalize_before_pending_result_fails.1.json @@ -0,0 +1,272 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 12345, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": "12345" + } + }, + { + "key": { + "symbol": "finalized_at" + }, + "val": "void" + }, + { + "key": { + "symbol": "players" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + } + }, + { + "key": { + "symbol": "report1_reporter" + }, + "val": "void" + }, + { + "key": { + "symbol": "report1_score" + }, + "val": "void" + }, + { + "key": { + "symbol": "report2_reporter" + }, + "val": "void" + }, + { + "key": { + "symbol": "report2_score" + }, + "val": "void" + }, + { + "key": { + "symbol": "stake_amount" + }, + "val": { + "i128": "1000" + } + }, + { + "key": { + "symbol": "stake_asset" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "symbol": "state" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "winner" + }, + "val": "void" + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/match-lifecycle/test_snapshots/test/test_finalize_match_as_operator.1.json b/contracts/match-lifecycle/test_snapshots/test/test_finalize_match_as_operator.1.json new file mode 100644 index 0000000..0d4f3e9 --- /dev/null +++ b/contracts/match-lifecycle/test_snapshots/test/test_finalize_match_as_operator.1.json @@ -0,0 +1,455 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "submit_result", + "args": [ + { + "bytes": "0303030303030303030303030303030303030303030303030303030303030303" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "i64": "1" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "submit_result", + "args": [ + { + "bytes": "0303030303030303030303030303030303030303030303030303030303030303" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "i64": "1" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "finalize_match", + "args": [ + { + "bytes": "0303030303030303030303030303030303030303030303030303030303030303" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "4837995959683129791" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "4837995959683129791" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0303030303030303030303030303030303030303030303030303030303030303" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0303030303030303030303030303030303030303030303030303030303030303" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "finalized_at" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "players" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + } + }, + { + "key": { + "symbol": "report1_reporter" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "symbol": "report1_score" + }, + "val": { + "i64": "1" + } + }, + { + "key": { + "symbol": "report2_reporter" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "report2_score" + }, + "val": { + "i64": "1" + } + }, + { + "key": { + "symbol": "stake_amount" + }, + "val": { + "i128": "1000" + } + }, + { + "key": { + "symbol": "stake_asset" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "symbol": "state" + }, + "val": { + "u32": 3 + } + }, + { + "key": { + "symbol": "winner" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/match-lifecycle/test_snapshots/test/test_finalize_match_as_participant.1.json b/contracts/match-lifecycle/test_snapshots/test/test_finalize_match_as_participant.1.json new file mode 100644 index 0000000..16fd661 --- /dev/null +++ b/contracts/match-lifecycle/test_snapshots/test/test_finalize_match_as_participant.1.json @@ -0,0 +1,455 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "submit_result", + "args": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "i64": "0" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "submit_result", + "args": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "i64": "0" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "finalize_match", + "args": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 12345, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": "12345" + } + }, + { + "key": { + "symbol": "finalized_at" + }, + "val": { + "u64": "12345" + } + }, + { + "key": { + "symbol": "players" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + } + }, + { + "key": { + "symbol": "report1_reporter" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "symbol": "report1_score" + }, + "val": { + "i64": "0" + } + }, + { + "key": { + "symbol": "report2_reporter" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "report2_score" + }, + "val": { + "i64": "0" + } + }, + { + "key": { + "symbol": "stake_amount" + }, + "val": { + "i128": "1000" + } + }, + { + "key": { + "symbol": "stake_asset" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "symbol": "state" + }, + "val": { + "u32": 3 + } + }, + { + "key": { + "symbol": "winner" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "4837995959683129791" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "4837995959683129791" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/match-lifecycle/test_snapshots/test/test_match_exists.1.json b/contracts/match-lifecycle/test_snapshots/test/test_match_exists.1.json new file mode 100644 index 0000000..0967c21 --- /dev/null +++ b/contracts/match-lifecycle/test_snapshots/test/test_match_exists.1.json @@ -0,0 +1,275 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [], + [], + [], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 12345, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": "12345" + } + }, + { + "key": { + "symbol": "finalized_at" + }, + "val": "void" + }, + { + "key": { + "symbol": "players" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + } + }, + { + "key": { + "symbol": "report1_reporter" + }, + "val": "void" + }, + { + "key": { + "symbol": "report1_score" + }, + "val": "void" + }, + { + "key": { + "symbol": "report2_reporter" + }, + "val": "void" + }, + { + "key": { + "symbol": "report2_score" + }, + "val": "void" + }, + { + "key": { + "symbol": "stake_amount" + }, + "val": { + "i128": "1000" + } + }, + { + "key": { + "symbol": "stake_asset" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "symbol": "state" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "winner" + }, + "val": "void" + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/match-lifecycle/test_snapshots/test/test_submit_result_dual_reporting_agree.1.json b/contracts/match-lifecycle/test_snapshots/test/test_submit_result_dual_reporting_agree.1.json new file mode 100644 index 0000000..482d494 --- /dev/null +++ b/contracts/match-lifecycle/test_snapshots/test/test_submit_result_dual_reporting_agree.1.json @@ -0,0 +1,397 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "submit_result", + "args": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "i64": "0" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "submit_result", + "args": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "i64": "0" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 12345, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": "12345" + } + }, + { + "key": { + "symbol": "finalized_at" + }, + "val": "void" + }, + { + "key": { + "symbol": "players" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + } + }, + { + "key": { + "symbol": "report1_reporter" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "symbol": "report1_score" + }, + "val": { + "i64": "0" + } + }, + { + "key": { + "symbol": "report2_reporter" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "report2_score" + }, + "val": { + "i64": "0" + } + }, + { + "key": { + "symbol": "stake_amount" + }, + "val": { + "i128": "1000" + } + }, + { + "key": { + "symbol": "stake_asset" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "symbol": "state" + }, + "val": { + "u32": 2 + } + }, + { + "key": { + "symbol": "winner" + }, + "val": "void" + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/match-lifecycle/test_snapshots/test/test_submit_result_dual_reporting_dispute.1.json b/contracts/match-lifecycle/test_snapshots/test/test_submit_result_dual_reporting_dispute.1.json new file mode 100644 index 0000000..b077057 --- /dev/null +++ b/contracts/match-lifecycle/test_snapshots/test/test_submit_result_dual_reporting_dispute.1.json @@ -0,0 +1,396 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "submit_result", + "args": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "i64": "0" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "submit_result", + "args": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "i64": "1" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 12345, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": "12345" + } + }, + { + "key": { + "symbol": "finalized_at" + }, + "val": "void" + }, + { + "key": { + "symbol": "players" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + } + }, + { + "key": { + "symbol": "report1_reporter" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "symbol": "report1_score" + }, + "val": { + "i64": "0" + } + }, + { + "key": { + "symbol": "report2_reporter" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "report2_score" + }, + "val": { + "i64": "1" + } + }, + { + "key": { + "symbol": "stake_amount" + }, + "val": { + "i128": "1000" + } + }, + { + "key": { + "symbol": "stake_asset" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "symbol": "state" + }, + "val": { + "u32": 4 + } + }, + { + "key": { + "symbol": "winner" + }, + "val": "void" + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/match-lifecycle/test_snapshots/test/test_submit_result_non_participant_fails.1.json b/contracts/match-lifecycle/test_snapshots/test/test_submit_result_non_participant_fails.1.json new file mode 100644 index 0000000..edec318 --- /dev/null +++ b/contracts/match-lifecycle/test_snapshots/test/test_submit_result_non_participant_fails.1.json @@ -0,0 +1,272 @@ +{ + "generators": { + "address": 6, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 12345, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": "12345" + } + }, + { + "key": { + "symbol": "finalized_at" + }, + "val": "void" + }, + { + "key": { + "symbol": "players" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + } + }, + { + "key": { + "symbol": "report1_reporter" + }, + "val": "void" + }, + { + "key": { + "symbol": "report1_score" + }, + "val": "void" + }, + { + "key": { + "symbol": "report2_reporter" + }, + "val": "void" + }, + { + "key": { + "symbol": "report2_score" + }, + "val": "void" + }, + { + "key": { + "symbol": "stake_amount" + }, + "val": { + "i128": "1000" + } + }, + { + "key": { + "symbol": "stake_asset" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "symbol": "state" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "winner" + }, + "val": "void" + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/match-lifecycle/test_snapshots/test/test_submit_result_same_reporter_twice_fails.1.json b/contracts/match-lifecycle/test_snapshots/test/test_submit_result_same_reporter_twice_fails.1.json new file mode 100644 index 0000000..a73ef4c --- /dev/null +++ b/contracts/match-lifecycle/test_snapshots/test/test_submit_result_same_reporter_twice_fails.1.json @@ -0,0 +1,334 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "submit_result", + "args": [ + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "i64": "0" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 12345, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Match" + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": "12345" + } + }, + { + "key": { + "symbol": "finalized_at" + }, + "val": "void" + }, + { + "key": { + "symbol": "players" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + } + }, + { + "key": { + "symbol": "report1_reporter" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "symbol": "report1_score" + }, + "val": { + "i64": "0" + } + }, + { + "key": { + "symbol": "report2_reporter" + }, + "val": "void" + }, + { + "key": { + "symbol": "report2_score" + }, + "val": "void" + }, + { + "key": { + "symbol": "stake_amount" + }, + "val": { + "i128": "1000" + } + }, + { + "key": { + "symbol": "stake_asset" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "symbol": "state" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "winner" + }, + "val": "void" + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/reputation-index/Cargo.toml b/contracts/reputation-index/Cargo.toml new file mode 100644 index 0000000..35f7898 --- /dev/null +++ b/contracts/reputation-index/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "arenax-reputation-index" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/reputation-index/src/events.rs b/contracts/reputation-index/src/events.rs new file mode 100644 index 0000000..88e9745 --- /dev/null +++ b/contracts/reputation-index/src/events.rs @@ -0,0 +1,16 @@ +use soroban_sdk::{contractevent, Address}; +// events +#[contractevent] +pub struct ReputationChanged { + pub player: Address, + pub skill_delta: i128, + pub fair_play_delta: i128, + pub match_id: u64, +} + +#[contractevent] +pub struct ReputationDecayed { + pub player: Address, + pub skill_decayed: i128, + pub fair_play_decayed: i128, +} diff --git a/contracts/reputation-index/src/lib.rs b/contracts/reputation-index/src/lib.rs new file mode 100644 index 0000000..13f7253 --- /dev/null +++ b/contracts/reputation-index/src/lib.rs @@ -0,0 +1,209 @@ +#![no_std] +use soroban_sdk::{ + contract, contractimpl, contracttype, Address, Env, Vec, +}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Reputation { + pub skill: i128, + pub fair_play: i128, + pub last_update_ts: u64, +} + +#[contracttype] +pub enum DataKey { + Reputation(Address), + Admin, + AuthorizedMatchContract, + AuthorizedAntiCheatOracle, + DecayRate, // points per day (as i128) +} + +#[contract] +pub struct ReputationIndex; + +mod events; + +#[contractimpl] +impl ReputationIndex { + /// Initialize the contract + pub fn initialize(env: Env, admin: Address, match_contract: Address, decay_rate: i128) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::AuthorizedMatchContract, &match_contract); + env.storage().instance().set(&DataKey::DecayRate, &decay_rate); + } + + /// Update reputation after a match outcome is finalized. + /// outcome: skill delta for each player corresponding to the players list. + pub fn update_on_match(env: Env, match_id: u64, players: Vec
, outcome: Vec) { + let match_contract: Address = env + .storage() + .instance() + .get(&DataKey::AuthorizedMatchContract) + .expect("match contract not set"); + + match_contract.require_auth(); + + if players.len() != outcome.len() { + panic!("players and outcome length mismatch"); + } + + let now = env.ledger().timestamp(); + + for i in 0..players.len() { + let player = players.get(i).unwrap(); + let skill_delta = outcome.get(i).unwrap(); + + let mut rep = Self::get_reputation(env.clone(), player.clone()); + + // Apply decay before updating + rep = Self::internal_apply_decay(&env, rep, now); + + let fair_play_delta = 1i128; // Completion bonus + + rep.skill = rep.skill.saturating_add(skill_delta).max(0); + rep.fair_play = rep.fair_play.saturating_add(fair_play_delta).max(0); + rep.last_update_ts = now; + + env.storage().persistent().set(&DataKey::Reputation(player.clone()), &rep); + + // Emit reputation_changed event + events::ReputationChanged { + player: player.clone(), + skill_delta, + fair_play_delta, + match_id, + } + .publish(&env); + } + } + + /// Explicitly apply decay to a player's reputation based on a timestamp. + pub fn apply_decay(env: Env, addr: Address, now_ts: u64) { + let mut rep = Self::get_reputation(env.clone(), addr.clone()); + let old_skill = rep.skill; + let old_fair_play = rep.fair_play; + + rep = Self::internal_apply_decay(&env, rep, now_ts); + env.storage().persistent().set(&DataKey::Reputation(addr.clone()), &rep); + + // Emit decay event + events::ReputationDecayed { + player: addr, + skill_decayed: old_skill - rep.skill, + fair_play_decayed: old_fair_play - rep.fair_play, + } + .publish(&env); + } + + /// Get current reputation for a player. + pub fn get_reputation(env: Env, addr: Address) -> Reputation { + env.storage() + .persistent() + .get(&DataKey::Reputation(addr)) + .unwrap_or(Reputation { + skill: 1000, + fair_play: 100, + last_update_ts: env.ledger().timestamp(), + }) + } + + fn internal_apply_decay(env: &Env, mut rep: Reputation, now: u64) -> Reputation { + let elapsed = now.saturating_sub(rep.last_update_ts); + if elapsed == 0 { + return rep; + } + + let decay_rate: i128 = env.storage().instance().get(&DataKey::DecayRate).unwrap_or(0); + if decay_rate == 0 { + return rep; + } + + // decay_rate is points per day (86400 seconds) + let decay_amount = (elapsed as i128 * decay_rate) / 86400; + + if decay_amount > 0 { + rep.skill = rep.skill.saturating_sub(decay_amount).max(0); + rep.fair_play = rep.fair_play.saturating_sub(decay_amount).max(0); + rep.last_update_ts = now; + } + + rep + } + + pub fn set_decay_rate(env: Env, admin: Address, new_rate: i128) { + let saved_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + if admin != saved_admin { + panic!("not admin"); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::DecayRate, &new_rate); + } + + /// Set the authorized anti-cheat oracle contract (admin only). That contract may call + /// apply_anticheat_penalty to apply bounded fair_play penalties. + pub fn set_authorized_anticheat_oracle(env: Env, admin: Address, oracle: Address) { + let saved_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + if admin != saved_admin { + panic!("not admin"); + } + admin.require_auth(); + env.storage() + .instance() + .set(&DataKey::AuthorizedAntiCheatOracle, &oracle); + } + + /// Apply a bounded anti-cheat penalty to a player's fair_play score. + /// Callable only by the authorized anti-cheat oracle contract. Penalty is capped and + /// fair_play cannot underflow (floor at 0). + pub fn apply_anticheat_penalty( + env: Env, + oracle: Address, + player: Address, + match_id: u64, + penalty: i128, + ) { + oracle.require_auth(); + let authorized: Address = env + .storage() + .instance() + .get(&DataKey::AuthorizedAntiCheatOracle) + .expect("anticheat oracle not set"); + if oracle != authorized { + panic!("not authorized anticheat oracle"); + } + // Cap penalty at a maximum (e.g. 100 per call) to keep penalties bounded + const MAX_PENALTY_PER_FLAG: i128 = 100; + let capped = if penalty > MAX_PENALTY_PER_FLAG { + MAX_PENALTY_PER_FLAG + } else if penalty < 0 { + 0 + } else { + penalty + }; + if capped == 0 { + return; + } + let now = env.ledger().timestamp(); + let mut rep = Self::get_reputation(env.clone(), player.clone()); + rep = Self::internal_apply_decay(&env, rep, now); + rep.fair_play = rep.fair_play.saturating_sub(capped).max(0); + rep.last_update_ts = now; + env.storage() + .persistent() + .set(&DataKey::Reputation(player.clone()), &rep); + events::ReputationChanged { + player, + skill_delta: 0, + fair_play_delta: -(capped as i128), + match_id, + } + .publish(&env); + } +} + +mod test; diff --git a/contracts/reputation-index/src/test.rs b/contracts/reputation-index/src/test.rs new file mode 100644 index 0000000..24978b8 --- /dev/null +++ b/contracts/reputation-index/src/test.rs @@ -0,0 +1,45 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, vec, Address, Env}; + +#[test] +fn test_reputation_index() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let match_contract = Address::generate(&env); + let player1 = Address::generate(&env); + + let contract_id = env.register(ReputationIndex, ()); + let client = ReputationIndexClient::new(&env, &contract_id); + + // Initialize with 10 points decay per day + client.initialize(&admin, &match_contract, &10); + + // Initial reputation (default) + let rep = client.get_reputation(&player1); + assert_eq!(rep.skill, 1000); + assert_eq!(rep.fair_play, 100); + + // Update match outcome + let players = vec![&env, player1.clone()]; + let outcomes = vec![&env, 25i128]; // +25 skill + client.update_on_match(&1, &players, &outcomes); + + let rep = client.get_reputation(&player1); + assert_eq!(rep.skill, 1025); + assert_eq!(rep.fair_play, 101); + + // Test decay after 1 day (86400 seconds) + let one_day_later = env.ledger().timestamp() + 86400; + client.apply_decay(&player1, &one_day_later); + + let rep = client.get_reputation(&player1); + // 1025 - 10 = 1015 + // 101 - 10 = 91 + assert_eq!(rep.skill, 1015); + assert_eq!(rep.fair_play, 91); + assert_eq!(rep.last_update_ts, one_day_later); +} diff --git a/contracts/reputation-index/test_snapshots/test/test_reputation_index.1.json b/contracts/reputation-index/test_snapshots/test/test_reputation_index.1.json new file mode 100644 index 0000000..2cc4fe9 --- /dev/null +++ b/contracts/reputation-index/test_snapshots/test/test_reputation_index.1.json @@ -0,0 +1,254 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "update_on_match", + "args": [ + { + "u64": "1" + }, + { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + { + "vec": [ + { + "i128": "25" + } + ] + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "vec": [ + { + "symbol": "Reputation" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "vec": [ + { + "symbol": "Reputation" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "fair_play" + }, + "val": { + "i128": "91" + } + }, + { + "key": { + "symbol": "last_update_ts" + }, + "val": { + "u64": "86400" + } + }, + { + "key": { + "symbol": "skill" + }, + "val": { + "i128": "1015" + } + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AuthorizedMatchContract" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "DecayRate" + } + ] + }, + "val": { + "i128": "10" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/staking-manager/Cargo.toml b/contracts/staking-manager/Cargo.toml new file mode 100644 index 0000000..a8b2647 --- /dev/null +++ b/contracts/staking-manager/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "staking-manager" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "ArenaX Tournament Staking Manager - AX token staking for tournaments" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk.workspace = true + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/staking-manager/src/lib.rs b/contracts/staking-manager/src/lib.rs new file mode 100644 index 0000000..14375cb --- /dev/null +++ b/contracts/staking-manager/src/lib.rs @@ -0,0 +1,723 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractevent, contractimpl, contracttype, token, Address, BytesN, Env, Map, Symbol, +}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + Admin, + AxToken, + TournamentContract, + DisputeContract, + Stake(BytesN<32>, Address), // tournament_id, user_address + TournamentInfo(BytesN<32>), + UserStakeInfo(Address), + Paused, +} + +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum TournamentState { + NotStarted = 0, + Active = 1, + Completed = 2, + Cancelled = 3, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StakeInfo { + pub user: Address, + pub tournament_id: BytesN<32>, + pub amount: i128, + pub staked_at: u64, + pub is_locked: bool, + pub can_withdraw: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TournamentInfo { + pub tournament_id: BytesN<32>, + pub state: u32, + pub stake_requirement: i128, + pub total_staked: i128, + pub participant_count: u32, + pub created_at: u64, + pub completed_at: Option, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserStakeInfo { + pub user: Address, + pub total_staked: i128, + pub total_slashed: i128, + pub active_tournaments: u32, + pub completed_tournaments: u32, +} + +#[contractevent] +pub struct Initialized { + pub admin: Address, + pub ax_token: Address, +} + +#[contractevent] +pub struct TokenSet { + pub token: Address, +} + +#[contractevent] +pub struct TournamentContractSet { + pub contract: Address, +} + +#[contractevent] +pub struct DisputeContractSet { + pub contract: Address, +} + +#[contractevent] +pub struct Staked { + pub user: Address, + pub tournament_id: BytesN<32>, + pub amount: i128, +} + +#[contractevent] +pub struct Withdrawn { + pub user: Address, + pub tournament_id: BytesN<32>, + pub amount: i128, +} + +#[contractevent] +pub struct Slashed { + pub user: Address, + pub tournament_id: BytesN<32>, + pub amount: i128, + pub slashed_by: Address, +} + +#[contractevent] +pub struct TournamentCreated { + pub tournament_id: BytesN<32>, + pub stake_requirement: i128, +} + +#[contractevent] +pub struct TournamentUpdated { + pub tournament_id: BytesN<32>, + pub state: u32, +} + +#[contractevent] +pub struct ContractPaused { + pub paused: bool, + pub paused_by: Address, +} + +#[contract] +pub struct StakingManager; + +#[contractimpl] +impl StakingManager { + /// Initialize the staking manager with admin and AX token address + /// + /// # Arguments + /// * `admin` - The admin address + /// * `ax_token` - The AX token contract address + /// + /// # Panics + /// * If contract is already initialized + pub fn initialize(env: Env, admin: Address, ax_token: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::AxToken, &ax_token); + env.storage().instance().set(&DataKey::Paused, &false); + + Initialized { admin, ax_token }.publish(&env); + } + + /// Set the AX token address + /// + /// # Arguments + /// * `ax_token` - The AX token contract address + /// + /// # Panics + /// * If caller is not admin + pub fn set_ax_token(env: Env, ax_token: Address) { + Self::require_admin(&env); + env.storage().instance().set(&DataKey::AxToken, &ax_token); + + TokenSet { token: ax_token }.publish(&env); + } + + /// Set the tournament contract address + /// + /// # Arguments + /// * `tournament_contract` - The tournament contract address + /// + /// # Panics + /// * If caller is not admin + pub fn set_tournament_contract(env: Env, tournament_contract: Address) { + Self::require_admin(&env); + env.storage() + .instance() + .set(&DataKey::TournamentContract, &tournament_contract); + + TournamentContractSet { + contract: tournament_contract, + } + .publish(&env); + } + + /// Set the dispute contract address + /// + /// # Arguments + /// * `dispute_contract` - The dispute contract address + /// + /// # Panics + /// * If caller is not admin + pub fn set_dispute_contract(env: Env, dispute_contract: Address) { + Self::require_admin(&env); + env.storage() + .instance() + .set(&DataKey::DisputeContract, &dispute_contract); + + DisputeContractSet { + contract: dispute_contract, + } + .publish(&env); + } + + /// Create a new tournament + /// + /// # Arguments + /// * `tournament_id` - Unique tournament identifier + /// * `stake_requirement` - Required stake amount for participation + /// + /// # Panics + /// * If contract is paused + /// * If caller is not admin or tournament contract + /// * If tournament already exists + /// * If stake requirement is not positive + pub fn create_tournament(env: Env, tournament_id: BytesN<32>, stake_requirement: i128) { + Self::require_not_paused(&env); + Self::require_admin_or_tournament_contract(&env); + + if stake_requirement <= 0 { + panic!("stake requirement must be positive"); + } + + if env + .storage() + .persistent() + .has(&DataKey::TournamentInfo(tournament_id.clone())) + { + panic!("tournament already exists"); + } + + let tournament_info = TournamentInfo { + tournament_id: tournament_id.clone(), + state: TournamentState::NotStarted as u32, + stake_requirement, + total_staked: 0, + participant_count: 0, + created_at: env.ledger().timestamp(), + completed_at: None, + }; + + env.storage() + .persistent() + .set(&DataKey::TournamentInfo(tournament_id.clone()), &tournament_info); + + TournamentCreated { + tournament_id, + stake_requirement, + } + .publish(&env); + } + + /// Update tournament state + /// + /// # Arguments + /// * `tournament_id` - Tournament identifier + /// * `state` - New tournament state + /// + /// # Panics + /// * If contract is paused + /// * If caller is not admin or tournament contract + /// * If tournament doesn't exist + pub fn update_tournament_state(env: Env, tournament_id: BytesN<32>, state: u32) { + Self::require_not_paused(&env); + Self::require_admin_or_tournament_contract(&env); + + let mut tournament_info: TournamentInfo = env + .storage() + .persistent() + .get(&DataKey::TournamentInfo(tournament_id.clone())) + .expect("tournament not found"); + + let old_state = tournament_info.state; + tournament_info.state = state; + + if state == TournamentState::Completed as u32 || state == TournamentState::Cancelled as u32 { + tournament_info.completed_at = Some(env.ledger().timestamp()); + + // Unlock all stakes for this tournament + Self::unlock_tournament_stakes(&env, &tournament_id); + } + + env.storage() + .persistent() + .set(&DataKey::TournamentInfo(tournament_id.clone()), &tournament_info); + + TournamentUpdated { + tournament_id, + state, + } + .publish(&env); + } + + /// Stake AX tokens for tournament participation + /// + /// # Arguments + /// * `user` - The user staking tokens + /// * `tournament_id` - Tournament identifier + /// * `amount` - Amount to stake + /// + /// # Panics + /// * If contract is paused + /// * If tournament doesn't exist or is not active + /// * If amount doesn't meet stake requirement + /// * If user has already staked for this tournament + pub fn stake(env: Env, user: Address, tournament_id: BytesN<32>, amount: i128) { + Self::require_not_paused(&env); + user.require_auth(); + + if amount <= 0 { + panic!("amount must be positive"); + } + + let tournament_info: TournamentInfo = env + .storage() + .persistent() + .get(&DataKey::TournamentInfo(tournament_id.clone())) + .expect("tournament not found"); + + if tournament_info.state != TournamentState::Active as u32 { + panic!("tournament is not active"); + } + + if amount < tournament_info.stake_requirement { + panic!("amount below stake requirement"); + } + + let stake_key = DataKey::Stake(tournament_id.clone(), user.clone()); + if env.storage().persistent().has(&stake_key) { + panic!("user already staked for this tournament"); + } + + // Transfer AX tokens to contract + let ax_token = Self::get_ax_token(&env); + let contract_address = env.current_contract_address(); + let token_client = token::Client::new(&env, &ax_token); + token_client.transfer(&user, &contract_address, &amount); + + // Create stake record + let stake_info = StakeInfo { + user: user.clone(), + tournament_id: tournament_id.clone(), + amount, + staked_at: env.ledger().timestamp(), + is_locked: true, + can_withdraw: false, + }; + + env.storage() + .persistent() + .set(&stake_key, &stake_info); + + // Update tournament info + let mut updated_tournament_info = tournament_info; + updated_tournament_info.total_staked += amount; + updated_tournament_info.participant_count += 1; + env.storage() + .persistent() + .set(&DataKey::TournamentInfo(tournament_id.clone()), &updated_tournament_info); + + // Update user stake info + Self::update_user_stake_info(&env, &user, amount, 0, 1, 0); + + Staked { + user, + tournament_id, + amount, + } + .publish(&env); + } + + /// Withdraw staked tokens after tournament completion + /// + /// # Arguments + /// * `user` - The user withdrawing tokens + /// * `tournament_id` - Tournament identifier + /// + /// # Panics + /// * If contract is paused + /// * If user has no stake for this tournament + /// * If stake is still locked + pub fn withdraw(env: Env, user: Address, tournament_id: BytesN<32>) { + Self::require_not_paused(&env); + user.require_auth(); + + let stake_key = DataKey::Stake(tournament_id.clone(), user.clone()); + let mut stake_info: StakeInfo = env + .storage() + .persistent() + .get(&stake_key) + .expect("no stake found"); + + if !stake_info.can_withdraw { + panic!("stake is not withdrawable"); + } + + // Transfer tokens back to user + let ax_token = Self::get_ax_token(&env); + let contract_address = env.current_contract_address(); + let token_client = token::Client::new(&env, &ax_token); + token_client.transfer(&contract_address, &user, &stake_info.amount); + + // Remove stake record + env.storage().persistent().remove(&stake_key); + + // Update user stake info + Self::update_user_stake_info(&env, &user, -stake_info.amount, 0, -1, 1); + + Withdrawn { + user, + tournament_id, + amount: stake_info.amount, + } + .publish(&env); + } + + /// Slash user's stake based on dispute resolution + /// + /// # Arguments + /// * `user` - The user being slashed + /// * `tournament_id` - Tournament identifier + /// * `amount` - Amount to slash + /// * `slashed_by` - Address authorizing the slash + /// + /// # Panics + /// * If contract is paused + /// * If caller is not authorized (dispute contract or admin) + /// * If user has no stake for this tournament + /// * If amount exceeds staked amount + pub fn slash(env: Env, user: Address, tournament_id: BytesN<32>, amount: i128, slashed_by: Address) { + Self::require_not_paused(&env); + Self::require_dispute_contract_or_admin(&env, &slashed_by); + + if amount <= 0 { + panic!("amount must be positive"); + } + + let stake_key = DataKey::Stake(tournament_id.clone(), user.clone()); + let mut stake_info: StakeInfo = env + .storage() + .persistent() + .get(&stake_key) + .expect("no stake found"); + + if amount > stake_info.amount { + panic!("slash amount exceeds staked amount"); + } + + // Transfer slashed amount to treasury (or burn) + let ax_token = Self::get_ax_token(&env); + let contract_address = env.current_contract_address(); + let token_client = token::Client::new(&env, &ax_token); + + // For now, we'll burn the slashed tokens by sending to a dead address + // In production, this might go to a treasury address + let dead_address = Address::from_contract_id(&BytesN::from_array(&env, &[0u8; 32])); + token_client.transfer(&contract_address, &dead_address, &amount); + + // Update stake info + stake_info.amount -= amount; + if stake_info.amount == 0 { + // Remove stake record if fully slashed + env.storage().persistent().remove(&stake_key); + } else { + env.storage() + .persistent() + .set(&stake_key, &stake_info); + } + + // Update tournament info + let mut tournament_info: TournamentInfo = env + .storage() + .persistent() + .get(&DataKey::TournamentInfo(tournament_id.clone())) + .expect("tournament not found"); + tournament_info.total_staked -= amount; + if stake_info.amount == 0 { + tournament_info.participant_count -= 1; + } + env.storage() + .persistent() + .set(&DataKey::TournamentInfo(tournament_id.clone()), &tournament_info); + + // Update user stake info + Self::update_user_stake_info(&env, &user, 0, amount, 0, 0); + + Slashed { + user, + tournament_id, + amount, + slashed_by, + } + .publish(&env); + } + + /// Get user's stake information for a tournament + /// + /// # Arguments + /// * `user` - User address + /// * `tournament_id` - Tournament identifier + /// + /// # Returns + /// Stake information or panics if not found + pub fn get_stake(env: Env, user: Address, tournament_id: BytesN<32>) -> StakeInfo { + env.storage() + .persistent() + .get(&DataKey::Stake(tournament_id, user)) + .expect("stake not found") + } + + /// Get tournament information + /// + /// # Arguments + /// * `tournament_id` - Tournament identifier + /// + /// # Returns + /// Tournament information or panics if not found + pub fn get_tournament_info(env: Env, tournament_id: BytesN<32>) -> TournamentInfo { + env.storage() + .persistent() + .get(&DataKey::TournamentInfo(tournament_id)) + .expect("tournament not found") + } + + /// Get user's overall stake information + /// + /// # Arguments + /// * `user` - User address + /// + /// # Returns + /// User's stake information + pub fn get_user_stake_info(env: Env, user: Address) -> UserStakeInfo { + env.storage() + .instance() + .get(&DataKey::UserStakeInfo(user)) + .unwrap_or(UserStakeInfo { + user, + total_staked: 0, + total_slashed: 0, + active_tournaments: 0, + completed_tournaments: 0, + }) + } + + /// Check if user can withdraw stake from tournament + /// + /// # Arguments + /// * `user` - User address + /// * `tournament_id` - Tournament identifier + /// + /// # Returns + /// True if withdrawal is allowed + pub fn can_withdraw(env: Env, user: Address, tournament_id: BytesN<32>) -> bool { + if let Some(stake_info) = env + .storage() + .persistent() + .get::(&DataKey::Stake(tournament_id, user)) + { + stake_info.can_withdraw + } else { + false + } + } + + /// Get total staked amount for a tournament + /// + /// # Arguments + /// * `tournament_id` - Tournament identifier + /// + /// # Returns + /// Total staked amount + pub fn get_total_staked(env: Env, tournament_id: BytesN<32>) -> i128 { + let tournament_info: TournamentInfo = env + .storage() + .persistent() + .get(&DataKey::TournamentInfo(tournament_id)) + .expect("tournament not found"); + tournament_info.total_staked + } + + /// Pause/unpause the contract + /// + /// # Arguments + /// * `paused` - Whether to pause the contract + /// + /// # Panics + /// * If caller is not admin + pub fn set_paused(env: Env, paused: bool) { + Self::require_admin(&env); + let admin = env.current_contract_address(); + + env.storage().instance().set(&DataKey::Paused, &paused); + + ContractPaused { + paused, + paused_by: admin, + } + .publish(&env); + } + + /// Get the admin address + /// + /// # Returns + /// The admin address + /// + /// # Panics + /// * If contract is not initialized + pub fn get_admin(env: Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized") + } + + /// Get the AX token address + /// + /// # Returns + /// The AX token address + /// + /// # Panics + /// * If token is not set + pub fn get_ax_token(env: Env) -> Address { + env.storage() + .instance() + .get(&DataKey::AxToken) + .expect("AX token not set") + } + + /// Check if the contract is paused + /// + /// # Returns + /// True if the contract is paused, false otherwise + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + // Helper functions for internal use + + fn unlock_tournament_stakes(env: &Env, tournament_id: &BytesN<32>) { + // This would need to iterate through all stakes for the tournament + // For now, this is a placeholder - in a real implementation, + // you'd need a way to efficiently find all stakes for a tournament + } + + fn update_user_stake_info( + env: &Env, + user: &Address, + staked_amount: i128, + slashed_amount: i128, + active_delta: i32, + completed_delta: i32, + ) { + let mut user_info: UserStakeInfo = env + .storage() + .instance() + .get(&DataKey::UserStakeInfo(user.clone())) + .unwrap_or(UserStakeInfo { + user: user.clone(), + total_staked: 0, + total_slashed: 0, + active_tournaments: 0, + completed_tournaments: 0, + }); + + user_info.total_staked += staked_amount; + user_info.total_slashed += slashed_amount; + user_info.active_tournaments = (user_info.active_tournaments as i32 + active_delta) as u32; + user_info.completed_tournaments = + (user_info.completed_tournaments as i32 + completed_delta) as u32; + + env.storage() + .instance() + .set(&DataKey::UserStakeInfo(user.clone()), &user_info); + } + + fn require_admin(env: &Env) { + let admin = Self::get_admin(env.clone()); + admin.require_auth(); + } + + fn require_not_paused(env: &Env) { + let paused = Self::is_paused(env.clone()); + if paused { + panic!("contract is paused"); + } + } + + fn require_admin_or_tournament_contract(env: &Env) { + let admin = Self::get_admin(env.clone()); + + if let Some(tournament_contract) = env + .storage() + .instance() + .get::(&DataKey::TournamentContract) + { + // Check if caller is admin or tournament contract + // This is simplified - in practice, you'd check the actual caller + admin.require_auth(); + } else { + admin.require_auth(); + } + } + + fn require_dispute_contract_or_admin(env: &Env, caller: &Address) { + let admin = Self::get_admin(env.clone()); + + if caller == &admin { + return; + } + + if let Some(dispute_contract) = env + .storage() + .instance() + .get::(&DataKey::DisputeContract) + { + if caller == &dispute_contract { + return; + } + } + + panic!("caller not authorized"); + } +} diff --git a/contracts/staking-manager/src/test.rs b/contracts/staking-manager/src/test.rs new file mode 100644 index 0000000..c0ba101 --- /dev/null +++ b/contracts/staking-manager/src/test.rs @@ -0,0 +1,638 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + token::{StellarAssetClient, TokenClient as SdkTokenClient}, + Address, BytesN, Env, +}; + +fn create_test_env() -> (Env, Address, Address, Address) { + let env = Env::default(); + let admin = Address::generate(&env); + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + (env, admin, user1, user2) +} + +fn initialize_contract(env: &Env, admin: &Address) -> Address { + let contract_id = Address::generate(env); + env.register_contract(&contract_id, StakingManager); + let client = StakingManagerClient::new(env, &contract_id); + + let ax_token = create_ax_token(env, admin); + + env.mock_all_auths(); + client.initialize(admin, &ax_token); + + contract_id +} + +fn create_ax_token(env: &Env, admin: &Address) -> Address { + let token_address = env.register_stellar_asset_contract_v2(admin.clone()); + token_address.address() +} + +fn mint_ax_tokens(env: &Env, token: &Address, admin: &Address, to: &Address, amount: i128) { + let stellar_client = StellarAssetClient::new(env, token); + stellar_client.mint(to, &amount); +} + +fn generate_tournament_id(env: &Env, seed: u32) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[0..4].copy_from_slice(&seed.to_be_bytes()); + BytesN::from_array(env, &bytes) +} + +#[test] +fn test_initialization() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + assert_eq!(client.get_admin(), admin); + assert!(!client.is_paused()); + + let ax_token = create_ax_token(&env, &admin); + client.set_ax_token(&ax_token); + assert_eq!(client.get_ax_token(), ax_token); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_double_initialization() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let ax_token = create_ax_token(&env, &admin); + client.initialize(&admin, &ax_token); +} + +#[test] +fn test_create_tournament() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + let stake_requirement = 1000i128; + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &stake_requirement); + + let tournament_info = client.get_tournament_info(&tournament_id); + assert_eq!(tournament_info.tournament_id, tournament_id); + assert_eq!(tournament_info.stake_requirement, stake_requirement); + assert_eq!(tournament_info.state, TournamentState::NotStarted as u32); + assert_eq!(tournament_info.total_staked, 0); + assert_eq!(tournament_info.participant_count, 0); +} + +#[test] +#[should_panic(expected = "stake requirement must be positive")] +fn test_create_tournament_zero_requirement_fails() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &0); +} + +#[test] +#[should_panic(expected = "tournament already exists")] +fn test_create_duplicate_tournament_fails() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + client.create_tournament(&tournament_id, &1000); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_create_tournament_unauthorized() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + client.create_tournament(&tournament_id, &1000); +} + +#[test] +fn test_update_tournament_state() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + + client.update_tournament_state(&tournament_id, &(TournamentState::Active as u32)); + let tournament_info = client.get_tournament_info(&tournament_id); + assert_eq!(tournament_info.state, TournamentState::Active as u32); + + client.update_tournament_state(&tournament_id, &(TournamentState::Completed as u32)); + let updated_info = client.get_tournament_info(&tournament_id); + assert_eq!(updated_info.state, TournamentState::Completed as u32); + assert!(updated_info.completed_at.is_some()); +} + +#[test] +fn test_stake() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + let stake_amount = 1000i128; + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + client.update_tournament_state(&tournament_id, &(TournamentState::Active as u32)); + + let ax_token = client.get_ax_token(); + mint_ax_tokens(&env, &ax_token, &admin, &user1, stake_amount * 2); + + client.stake(&user1, &tournament_id, &stake_amount); + + let stake_info = client.get_stake(&user1, &tournament_id); + assert_eq!(stake_info.user, user1); + assert_eq!(stake_info.tournament_id, tournament_id); + assert_eq!(stake_info.amount, stake_amount); + assert!(stake_info.is_locked); + assert!(!stake_info.can_withdraw); + + let tournament_info = client.get_tournament_info(&tournament_id); + assert_eq!(tournament_info.total_staked, stake_amount); + assert_eq!(tournament_info.participant_count, 1); + + let user_info = client.get_user_stake_info(&user1); + assert_eq!(user_info.total_staked, stake_amount); + assert_eq!(user_info.active_tournaments, 1); +} + +#[test] +#[should_panic(expected = "amount must be positive")] +fn test_stake_zero_amount_fails() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + client.update_tournament_state(&tournament_id, &(TournamentState::Active as u32)); + + client.stake(&user1, &tournament_id, &0); +} + +#[test] +#[should_panic(expected = "tournament is not active")] +fn test_stake_inactive_tournament_fails() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + + let ax_token = client.get_ax_token(); + mint_ax_tokens(&env, &ax_token, &admin, &user1, 1000); + + client.stake(&user1, &tournament_id, &1000); +} + +#[test] +#[should_panic(expected = "amount below stake requirement")] +fn test_stake_below_requirement_fails() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + client.update_tournament_state(&tournament_id, &(TournamentState::Active as u32)); + + let ax_token = client.get_ax_token(); + mint_ax_tokens(&env, &ax_token, &admin, &user1, 500); + + client.stake(&user1, &tournament_id, &500); +} + +#[test] +#[should_panic(expected = "user already staked for this tournament")] +fn test_stake_twice_fails() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + client.update_tournament_state(&tournament_id, &(TournamentState::Active as u32)); + + let ax_token = client.get_ax_token(); + mint_ax_tokens(&env, &ax_token, &admin, &user1, 2000); + + client.stake(&user1, &tournament_id, &1000); + client.stake(&user1, &tournament_id, &1000); +} + +#[test] +fn test_withdraw() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + let stake_amount = 1000i128; + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + client.update_tournament_state(&tournament_id, &(TournamentState::Active as u32)); + + let ax_token = client.get_ax_token(); + let token_client = SdkTokenClient::new(&env, &ax_token); + + mint_ax_tokens(&env, &ax_token, &admin, &user1, stake_amount * 2); + let initial_balance = token_client.balance(&user1); + + client.stake(&user1, &tournament_id, &stake_amount); + assert_eq!(token_client.balance(&user1), initial_balance - stake_amount); + + client.update_tournament_state(&tournament_id, &(TournamentState::Completed as u32)); + + client.withdraw(&user1, &tournament_id); + assert_eq!(token_client.balance(&user1), initial_balance); + + let user_info = client.get_user_stake_info(&user1); + assert_eq!(user_info.total_staked, 0); + assert_eq!(user_info.active_tournaments, 0); + assert_eq!(user_info.completed_tournaments, 1); +} + +#[test] +#[should_panic(expected = "no stake found")] +fn test_withdraw_no_stake_fails() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + client.update_tournament_state(&tournament_id, &(TournamentState::Completed as u32)); + + client.withdraw(&user1, &tournament_id); +} + +#[test] +#[should_panic(expected = "stake is not withdrawable")] +fn test_withdraw_locked_fails() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + client.update_tournament_state(&tournament_id, &(TournamentState::Active as u32)); + + let ax_token = client.get_ax_token(); + mint_ax_tokens(&env, &ax_token, &admin, &user1, 1000); + + client.stake(&user1, &tournament_id, &1000); + client.withdraw(&user1, &tournament_id); +} + +#[test] +fn test_slash() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + let stake_amount = 1000i128; + let slash_amount = 300i128; + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + client.update_tournament_state(&tournament_id, &(TournamentState::Active as u32)); + + let ax_token = client.get_ax_token(); + mint_ax_tokens(&env, &ax_token, &admin, &user1, stake_amount * 2); + + client.stake(&user1, &tournament_id, &stake_amount); + + let dispute_contract = Address::generate(&env); + client.set_dispute_contract(&dispute_contract); + + client.slash(&user1, &tournament_id, &slash_amount, &dispute_contract); + + let stake_info = client.get_stake(&user1, &tournament_id); + assert_eq!(stake_info.amount, stake_amount - slash_amount); + + let tournament_info = client.get_tournament_info(&tournament_id); + assert_eq!(tournament_info.total_staked, stake_amount - slash_amount); + + let user_info = client.get_user_stake_info(&user1); + assert_eq!(user_info.total_slashed, slash_amount); +} + +#[test] +#[should_panic(expected = "slash amount exceeds staked amount")] +fn test_slash_exceeds_stake_fails() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + client.update_tournament_state(&tournament_id, &(TournamentState::Active as u32)); + + let ax_token = client.get_ax_token(); + mint_ax_tokens(&env, &ax_token, &admin, &user1, 1000); + + client.stake(&user1, &tournament_id, &1000); + + let dispute_contract = Address::generate(&env); + client.set_dispute_contract(&dispute_contract); + + client.slash(&user1, &tournament_id, &1500, &dispute_contract); +} + +#[test] +#[should_panic(expected = "amount must be positive")] +fn test_slash_zero_amount_fails() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + client.update_tournament_state(&tournament_id, &(TournamentState::Active as u32)); + + let ax_token = client.get_ax_token(); + mint_ax_tokens(&env, &ax_token, &admin, &user1, 1000); + + client.stake(&user1, &tournament_id, &1000); + + let dispute_contract = Address::generate(&env); + client.set_dispute_contract(&dispute_contract); + + client.slash(&user1, &tournament_id, &0, &dispute_contract); +} + +#[test] +#[should_panic(expected = "caller not authorized")] +fn test_slash_unauthorized_fails() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + client.update_tournament_state(&tournament_id, &(TournamentState::Active as u32)); + + let ax_token = client.get_ax_token(); + mint_ax_tokens(&env, &ax_token, &admin, &user1, 1000); + + client.stake(&user1, &tournament_id, &1000); + + let random_address = Address::generate(&env); + client.slash(&user1, &tournament_id, &300, &random_address); +} + +#[test] +fn test_pause_contract() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + env.mock_all_auths(); + + assert!(!client.is_paused()); + client.set_paused(&true); + assert!(client.is_paused()); + client.set_paused(&false); + assert!(!client.is_paused()); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_pause_contract_unauthorized() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + client.set_paused(&true); +} + +#[test] +#[should_panic(expected = "contract is paused")] +fn test_operations_when_paused() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.set_paused(&true); + + client.create_tournament(&tournament_id, &1000); +} + +#[test] +fn test_get_total_staked() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + client.update_tournament_state(&tournament_id, &(TournamentState::Active as u32)); + + let ax_token = client.get_ax_token(); + mint_ax_tokens(&env, &ax_token, &admin, &user1, 1000); + mint_ax_tokens(&env, &ax_token, &admin, &user2, 1000); + + assert_eq!(client.get_total_staked(&tournament_id), 0); + + client.stake(&user1, &tournament_id, &1000); + assert_eq!(client.get_total_staked(&tournament_id), 1000); + + client.stake(&user2, &tournament_id, &1000); + assert_eq!(client.get_total_staked(&tournament_id), 2000); +} + +#[test] +fn test_can_withdraw() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + client.update_tournament_state(&tournament_id, &(TournamentState::Active as u32)); + + let ax_token = client.get_ax_token(); + mint_ax_tokens(&env, &ax_token, &admin, &user1, 1000); + + assert!(!client.can_withdraw(&user1, &tournament_id)); + + client.stake(&user1, &tournament_id, &1000); + assert!(!client.can_withdraw(&user1, &tournament_id)); + + client.update_tournament_state(&tournament_id, &(TournamentState::Completed as u32)); + assert!(client.can_withdraw(&user1, &tournament_id)); + + client.withdraw(&user1, &tournament_id); + assert!(!client.can_withdraw(&user1, &tournament_id)); +} + +#[test] +fn test_full_staking_lifecycle() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + let stake_amount = 1000i128; + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &stake_amount); + client.update_tournament_state(&tournament_id, &(TournamentState::Active as u32)); + + let ax_token = client.get_ax_token(); + let token_client = SdkTokenClient::new(&env, &ax_token); + + mint_ax_tokens(&env, &ax_token, &admin, &user1, stake_amount * 2); + let initial_balance = token_client.balance(&user1); + + client.stake(&user1, &tournament_id, &stake_amount); + assert_eq!(token_client.balance(&user1), initial_balance - stake_amount); + + let dispute_contract = Address::generate(&env); + client.set_dispute_contract(&dispute_contract); + + client.slash(&user1, &tournament_id, &(stake_amount / 2), &dispute_contract); + let stake_info = client.get_stake(&user1, &tournament_id); + assert_eq!(stake_info.amount, stake_amount / 2); + + client.update_tournament_state(&tournament_id, &(TournamentState::Completed as u32)); + assert!(client.can_withdraw(&user1, &tournament_id)); + + client.withdraw(&user1, &tournament_id); + assert_eq!(token_client.balance(&user1), initial_balance - (stake_amount / 2)); + + let user_info = client.get_user_stake_info(&user1); + assert_eq!(user_info.total_staked, stake_amount); + assert_eq!(user_info.total_slashed, stake_amount / 2); + assert_eq!(user_info.active_tournaments, 0); + assert_eq!(user_info.completed_tournaments, 1); +} + +#[test] +fn test_multiple_users_staking() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + env.mock_all_auths(); + client.create_tournament(&tournament_id, &1000); + client.update_tournament_state(&tournament_id, &(TournamentState::Active as u32)); + + let ax_token = client.get_ax_token(); + mint_ax_tokens(&env, &ax_token, &admin, &user1, 1000); + mint_ax_tokens(&env, &ax_token, &admin, &user2, 1000); + + client.stake(&user1, &tournament_id, &1000); + client.stake(&user2, &tournament_id, &1000); + + let tournament_info = client.get_tournament_info(&tournament_id); + assert_eq!(tournament_info.total_staked, 2000); + assert_eq!(tournament_info.participant_count, 2); + + let user1_info = client.get_user_stake_info(&user1); + let user2_info = client.get_user_stake_info(&user2); + assert_eq!(user1_info.active_tournaments, 1); + assert_eq!(user2_info.active_tournaments, 1); + + client.update_tournament_state(&tournament_id, &(TournamentState::Completed as u32)); + + client.withdraw(&user1, &tournament_id); + client.withdraw(&user2, &tournament_id); + + let final_user1_info = client.get_user_stake_info(&user1); + let final_user2_info = client.get_user_stake_info(&user2); + assert_eq!(final_user1_info.active_tournaments, 0); + assert_eq!(final_user1_info.completed_tournaments, 1); + assert_eq!(final_user2_info.active_tournaments, 0); + assert_eq!(final_user2_info.completed_tournaments, 1); +} + +#[test] +fn test_contract_configuration() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_contract = Address::generate(&env); + let dispute_contract = Address::generate(&env); + + env.mock_all_auths(); + client.set_tournament_contract(&tournament_contract); + client.set_dispute_contract(&dispute_contract); + + let ax_token = create_ax_token(&env, &admin); + client.set_ax_token(&ax_token); +} + +#[test] +fn test_edge_cases() { + let (env, admin, user1, user2) = create_test_env(); + let contract_id = initialize_contract(&env, &admin); + let client = StakingManagerClient::new(&env, &contract_id); + + let tournament_id = generate_tournament_id(&env, 1); + + let user_info = client.get_user_stake_info(&user1); + assert_eq!(user_info.total_staked, 0); + assert_eq!(user_info.total_slashed, 0); + assert_eq!(user_info.active_tournaments, 0); + assert_eq!(user_info.completed_tournaments, 0); + + assert!(!client.can_withdraw(&user1, &tournament_id)); +} diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000..e54411d --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1 @@ +{"extends": "next/core-web-vitals"} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 19b777d..a0204a2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,13 +8,17 @@ "name": "arenax-frontend", "version": "0.1.0", "dependencies": { + "@albedo-link/intent": "^0.13.0", "@radix-ui/react-slot": "^1.0.2", + "@stellar/freighter-api": "^6.0.1", + "@stellar/stellar-sdk": "^14.5.0", + "@tanstack/react-query": "^5.90.21", "autoprefixer": "^10.4.16", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "eslint": "^8.55.0", "eslint-config-next": "14.0.0", - "framer-motion": "^11.0.0", + "framer-motion": "^11.18.2", "lucide-react": "^0.294.0", "next": "14.0.0", "next-themes": "^0.4.6", @@ -34,6 +38,7 @@ "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "jest": "^29.7.0", + "jest-environment-jsdom": "^30.2.0", "typescript": "^5.3.0" } }, @@ -44,6 +49,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@albedo-link/intent": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@albedo-link/intent/-/intent-0.13.0.tgz", + "integrity": "sha512-A8CBXqGQEBMXhwxNXj5inC6HLjyx5Do7jW99NOFeecYd1nPUq8gfM0tvoNoR8H8JQ11aTl9tyQBuu/+l3xeBnQ==", + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -56,6 +67,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", @@ -582,6 +614,121 @@ "dev": true, "license": "MIT" }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -941,6 +1088,235 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.2.0.tgz", + "integrity": "sha512-kazxw2L9IPuZpQ0mEt9lu9Z98SqR74xcagANmMBU16X0lS23yPc0+S6hGLUz8kVRlomZEs/5S/Zlpqwf5yu6OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/environment": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/fake-timers": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinclair/typebox": { + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@jest/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", @@ -1002,6 +1378,30 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -1368,6 +1768,33 @@ "node": ">= 10" } }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1532,6 +1959,83 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@stellar/freighter-api": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@stellar/freighter-api/-/freighter-api-6.0.1.tgz", + "integrity": "sha512-eqwakEqSg+zoLuPpSbKyrX0pG8DQFzL/J5GtbfuMCmJI+h+oiC9pQ5C6QLc80xopZQKdGt8dUAFCmDMNdAG95w==", + "license": "Apache-2.0", + "dependencies": { + "buffer": "6.0.3", + "semver": "7.7.1" + } + }, + "node_modules/@stellar/freighter-api/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@stellar/js-xdr": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", + "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", + "license": "Apache-2.0" + }, + "node_modules/@stellar/stellar-base": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.0.4.tgz", + "integrity": "sha512-UbNW6zbdOBXJwLAV2mMak0bIC9nw3IZVlQXkv2w2dk1jgCbJjy3oRVC943zeGE5JAm0Z9PHxrIjmkpGhayY7kw==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.9.6", + "@stellar/js-xdr": "^3.1.2", + "base32.js": "^0.1.0", + "bignumber.js": "^9.3.1", + "buffer": "^6.0.3", + "sha.js": "^2.4.12" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stellar/stellar-sdk": { + "version": "14.5.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.5.0.tgz", + "integrity": "sha512-Uzjq+An/hUA+Q5ERAYPtT0+MMiwWnYYWMwozmZMjxjdL2MmSjucBDF8Q04db6K/ekU4B5cHuOfsdlrfaxQYblw==", + "license": "Apache-2.0", + "dependencies": { + "@stellar/stellar-base": "^14.0.4", + "axios": "^1.13.3", + "bignumber.js": "^9.3.1", + "commander": "^14.0.2", + "eventsource": "^2.0.2", + "feaxios": "^0.0.23", + "randombytes": "^2.1.0", + "toml": "^3.0.0", + "urijs": "^1.19.1" + }, + "bin": { + "stellar-js": "bin/stellar-js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@swc/helpers": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", @@ -1541,6 +2045,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "9.3.4", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", @@ -1779,6 +2309,18 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -1830,6 +2372,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -2257,6 +2806,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2542,6 +3101,12 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.23", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", @@ -2602,6 +3167,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2743,6 +3319,35 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base32.js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.17", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz", @@ -2752,6 +3357,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2829,6 +3443,30 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3105,6 +3743,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -3182,6 +3832,20 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3316,6 +3980,20 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3384,6 +4062,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -3488,6 +4173,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3590,6 +4284,19 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4264,6 +4971,15 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -4379,6 +5095,15 @@ "bser": "2.1.1" } }, + "node_modules/feaxios": { + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", + "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", + "license": "MIT", + "dependencies": { + "is-retry-allowed": "^3.0.0" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -4439,6 +5164,26 @@ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -4454,6 +5199,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -4871,6 +5632,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4878,6 +5652,34 @@ "dev": true, "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4888,6 +5690,39 @@ "node": ">=10.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5307,6 +6142,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -5325,6 +6167,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-retry-allowed": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", + "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-set": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", @@ -5803,93 +6657,319 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-diff/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-jsdom": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.2.0.tgz", + "integrity": "sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/environment-jsdom-abstract": "30.2.0", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jsdom": "^26.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/environment": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@sinclair/typebox": { + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/jest-diff/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/jest-environment-jsdom/node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-diff/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "node_modules/jest-environment-jsdom/node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "dev": true, "license": "MIT", "dependencies": { - "detect-newline": "^3.0.0" + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "node_modules/jest-environment-jsdom/node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-each/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/jest-environment-jsdom/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/jest-each/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/jest-environment-jsdom/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-each/node_modules/react-is": { + "node_modules/jest-environment-jsdom/node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", @@ -6481,6 +7561,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6751,6 +7871,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -6983,6 +8124,13 @@ "node": ">=8" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7254,6 +8402,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7634,6 +8795,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7680,6 +8847,15 @@ ], "license": "MIT" }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -7969,6 +9145,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8011,6 +9194,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -8044,6 +9247,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -8111,6 +9334,26 @@ "node": ">= 0.4" } }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8583,6 +9826,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", @@ -8732,6 +9982,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -8739,6 +10009,20 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8751,6 +10035,38 @@ "node": ">=8.0" } }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -9027,6 +10343,12 @@ "punycode": "^2.1.0" } }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -9079,6 +10401,19 @@ "d3-timer": "^3.0.1" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -9102,6 +10437,54 @@ "node": ">=10.13.0" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9249,6 +10632,45 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 300470d..5a51afb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,13 +11,17 @@ "test:watch": "jest --watch" }, "dependencies": { + "@albedo-link/intent": "^0.13.0", "@radix-ui/react-slot": "^1.0.2", + "@stellar/freighter-api": "^6.0.1", + "@stellar/stellar-sdk": "^14.5.0", + "@tanstack/react-query": "^5.90.21", "autoprefixer": "^10.4.16", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "eslint": "^8.55.0", "eslint-config-next": "14.0.0", - "framer-motion": "^11.0.0", + "framer-motion": "^11.18.2", "lucide-react": "^0.294.0", "next": "14.0.0", "next-themes": "^0.4.6", @@ -37,6 +41,7 @@ "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "jest": "^29.7.0", + "jest-environment-jsdom": "^30.2.0", "typescript": "^5.3.0" } } diff --git a/frontend/src/app/about/page.tsx b/frontend/src/app/about/page.tsx index 53ebd19..6986d88 100644 --- a/frontend/src/app/about/page.tsx +++ b/frontend/src/app/about/page.tsx @@ -1,4 +1,5 @@ import { Metadata } from "next"; +import Image from "next/image"; import { Card, CardContent } from "@/components/ui/Card"; import { Shield, Zap, Globe, Users } from "lucide-react"; @@ -87,8 +88,8 @@ export default function AboutPage() { are delivered instantly, transparently, and globally.

- By leveraging Stellar's lightning-fast blockchain and Soroban's smart contract - capabilities, we've built a tournament platform that eliminates the middlemen + By leveraging Stellar's lightning-fast blockchain and Soroban's smart contract + capabilities, we've built a tournament platform that eliminates the middlemen and puts players first.

@@ -96,7 +97,7 @@ export default function AboutPage() {

Our Vision

- To become the world's most trusted competitive gaming platform by making every + To become the world's most trusted competitive gaming platform by making every match verifiable, every prize instant, and every player empowered.

@@ -182,10 +183,11 @@ export default function AboutPage() { {team.map((member) => (
- {member.name}
diff --git a/frontend/src/app/contact/ContactForm.tsx b/frontend/src/app/contact/ContactForm.tsx index 55a556f..7b81b4c 100644 --- a/frontend/src/app/contact/ContactForm.tsx +++ b/frontend/src/app/contact/ContactForm.tsx @@ -49,7 +49,7 @@ export function ContactForm() { Send us a Message - Fill out the form below and we'll get back to you within 24 hours. + Fill out the form below and we'll get back to you within 24 hours. @@ -64,7 +64,7 @@ export function ContactForm() {

Message Sent!

- Thank you for reaching out. We'll respond to your inquiry soon. + Thank you for reaching out. We'll respond to your inquiry soon.

) : ( diff --git a/frontend/src/app/contact/page.tsx b/frontend/src/app/contact/page.tsx index cf4f03b..2d86853 100644 --- a/frontend/src/app/contact/page.tsx +++ b/frontend/src/app/contact/page.tsx @@ -19,7 +19,7 @@ export default function ContactPage() { Contact & Support

- Have questions? We're here to help. Reach out to our team or check our FAQs. + Have questions? We're here to help. Reach out to our team or check our FAQs.

diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 2f76dff..100fbd7 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -31,16 +31,13 @@ export default function RootLayout({ - {children} + + {children} + - - - {children} - - diff --git a/frontend/src/app/matches/[id]/page.tsx b/frontend/src/app/matches/[id]/page.tsx new file mode 100644 index 0000000..61cf36e --- /dev/null +++ b/frontend/src/app/matches/[id]/page.tsx @@ -0,0 +1,464 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Button } from "@/components/ui/Button"; +import { useMatchWebSocket, useMatchScoreReporting } from "@/hooks/useMatchWebSocket"; +import { ArrowLeft, RefreshCw, Wifi, WifiOff, AlertTriangle, CheckCircle, User, Trophy } from "lucide-react"; +import Link from "next/link"; + +// Mock match data +interface MatchDetail { + id: string; + tournamentId: string; + tournamentName: string; + player1Id: string; + player1Username: string; + player2Id: string; + player2Username: string; + gameType: string; + status: "pending" | "in_progress" | "completed" | "disputed" | "cancelled"; + scorePlayer1: number; + scorePlayer2: number; + winnerId?: string; + startedAt?: string; + completedAt?: string; +} + +// Mock data for demonstration +const mockMatchDetails: Record = { + "match-1": { + id: "match-1", + tournamentId: "1", + tournamentName: "CS2 Pro League 2026", + player1Id: "user-123", + player1Username: "ProGamer99", + player2Id: "user-456", + player2Username: "ShadowNinja", + gameType: "Counter-Strike 2", + status: "in_progress", + scorePlayer1: 8, + scorePlayer2: 7, + startedAt: "2026-02-24T18:00:00Z", + }, + "match-2": { + id: "match-2", + tournamentId: "1", + tournamentName: "CS2 Pro League 2026", + player1Id: "user-789", + player1Username: "EliteSniper", + player2Id: "user-101", + player2Username: "DragonSlayer", + gameType: "Counter-Strike 2", + status: "in_progress", + scorePlayer1: 5, + scorePlayer2: 3, + startedAt: "2026-02-24T18:30:00Z", + }, +}; + +export default function MatchHubPage() { + const params = useParams(); + const router = useRouter(); + const matchId = params.id as string; + + // Get current user (mock) + const currentUserId = "user-123"; // Would come from auth context + + // Local match state (simulated real-time updates) + const [match, setMatch] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Load match data + useEffect(() => { + const loadMatch = async () => { + setIsLoading(true); + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 500)); + + const matchData = mockMatchDetails[matchId]; + if (matchData) { + setMatch(matchData); + } + setIsLoading(false); + }; + + loadMatch(); + }, [matchId]); + + // WebSocket connection for real-time updates + const { isConnected, lastUpdate, connectionError, reconnect } = useMatchWebSocket({ + matchId, + enabled: match?.status === "in_progress", + }); + + // Score reporting hook + const { + reportScore, + isReporting, + conflictDetected, + conflictingReport, + clearConflict + } = useMatchScoreReporting(); + + // Score input states + const [player1Score, setPlayer1Score] = useState(0); + const [player2Score, setPlayer2Score] = useState(0); + const [submitted, setSubmitted] = useState(false); + + // Check if current user is a participant + const isParticipant = useMemo(() => { + if (!match) return false; + return match.player1Id === currentUserId || match.player2Id === currentUserId; + }, [match, currentUserId]); + + // Check if current user is the reporter + const isReporter = isParticipant; + + // Update local state when match loads + useEffect(() => { + if (match) { + setPlayer1Score(match.scorePlayer1); + setPlayer2Score(match.scorePlayer2); + } + }, [match]); + + // Apply real-time score updates + useEffect(() => { + if (lastUpdate && match) { + setMatch((prevMatch) => { + if (!prevMatch) return prevMatch; + return { + ...prevMatch, + scorePlayer1: lastUpdate.scorePlayer1 ?? prevMatch.scorePlayer1, + scorePlayer2: lastUpdate.scorePlayer2 ?? prevMatch.scorePlayer2, + }; + }); + setPlayer1Score(lastUpdate.scorePlayer1 ?? match.scorePlayer1); + setPlayer2Score(lastUpdate.scorePlayer2 ?? match.scorePlayer2); + } + }, [lastUpdate, match]); + + const handleSubmitScore = async () => { + if (!match) return; + + const success = await reportScore({ + matchId: match.id, + player1Score, + player2Score, + reporterId: currentUserId, + }); + + if (success) { + setSubmitted(true); + // Reset after 2 seconds + setTimeout(() => setSubmitted(false), 2000); + } + }; + + const handleResolveConflict = () => { + // In a real app, this would open a dispute resolution flow + clearConflict(); + }; + + if (isLoading) { + return ( +
+
+ +

Loading match...

+
+
+ ); + } + + if (!match) { + return ( +
+
+

Match Not Found

+

+ The match you're looking for doesn't exist. +

+ +
+
+ ); + } + + const isLive = match.status === "in_progress"; + + return ( +
+ {/* Back Button */} +
+ +
+ +
+ {/* Match Header */} +
+
+
+

Match Hub

+

{match.tournamentName}

+

{match.gameType}

+
+
+ {/* Connection Status */} + {isLive && ( +
+ {isConnected ? ( + <> + + Live + + ) : ( + <> + + Disconnected + + )} +
+ )} + + {/* Match Status */} +
+ {isLive ? "In Progress" : match.status.charAt(0).toUpperCase() + match.status.slice(1)} +
+
+
+ + {/* Connection Error */} + {connectionError && ( +
+
+

{connectionError}

+ +
+
+ )} +
+ + {/* Score Display */} +
+
+ {/* Player 1 */} +
+
+ +
+

{match.player1Username}

+ {match.player1Id === currentUserId && ( + (You) + )} +

+ {match.scorePlayer1} +

+
+ + {/* VS */} +
+

VS

+
+ + {/* Player 2 */} +
+
+ +
+

{match.player2Username}

+ {match.player2Id === currentUserId && ( + (You) + )} +

+ {match.scorePlayer2} +

+
+
+ + {/* Winner Display */} + {match.status === "completed" && match.winnerId && ( +
+
+ + + Winner: {match.winnerId === match.player1Id ? match.player1Username : match.player2Username} + +
+
+ )} +
+ + {/* Score Reporting Form (only for participants) */} + {isReporter && isLive && ( +
+

+ Report Match Score +

+ + {/* Conflict Alert */} + {conflictDetected && ( +
+
+ +
+

+ Score Conflict Detected! +

+

+ The other player reported a different score. Please resolve this discrepancy. +

+ {conflictingReport && ( +
+

Their report:

+

+ {match.player1Username}: {conflictingReport.player1Score} - {match.player2Username}: {conflictingReport.player2Score} +

+
+ )} +
+ + +
+
+
+
+ )} + + {/* Score Input */} +
+
+ + setPlayer1Score(parseInt(e.target.value) || 0)} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
+ +
+ - +
+ +
+ + setPlayer2Score(parseInt(e.target.value) || 0)} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
+
+ + {/* Submit Button */} +
+ +
+ +

+ Both players must report the same score for the match to be confirmed. +

+
+ )} + + {/* Not a participant */} + {!isReporter && isLive && ( +
+

+ Only participants can report scores for this match. +

+
+ )} + + {/* Match Info */} +
+

Match Information

+
+
+

Match ID

+

{match.id}

+
+
+

Tournament

+ + {match.tournamentName} + +
+ {match.startedAt && ( +
+

Started

+

+ {new Date(match.startedAt).toLocaleString()} +

+
+ )} + {match.completedAt && ( +
+

Completed

+

+ {new Date(match.completedAt).toLocaleString()} +

+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/privacy/page.tsx b/frontend/src/app/privacy/page.tsx index 87f66a6..1854416 100644 --- a/frontend/src/app/privacy/page.tsx +++ b/frontend/src/app/privacy/page.tsx @@ -173,7 +173,7 @@ export default function PrivacyPage() {
-

10. Children's Privacy

+

10. Children's Privacy

ArenaX is not intended for children under 18. We do not knowingly collect personal information from children under 18. If you become aware that a child @@ -204,7 +204,7 @@ export default function PrivacyPage() {

We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the - "Last Updated" date. You are advised to review this Privacy Policy periodically + "Last Updated" date. You are advised to review this Privacy Policy periodically for any changes.

diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 47561d7..6fe2e71 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState } from "react"; +import Image from "next/image"; import { EloChart } from "@/components/profile/EloChart"; import { MatchHistory } from "@/components/profile/MatchHistory"; import { ProfileBio } from "@/components/profile/ProfileBio"; @@ -22,7 +23,7 @@ export default function ProfilePage() {
{user.avatar ? ( - {user.username} + {user.username} ) : ( {user.username.charAt(0)} diff --git a/frontend/src/app/terms/page.tsx b/frontend/src/app/terms/page.tsx index 929b883..df3f9a7 100644 --- a/frontend/src/app/terms/page.tsx +++ b/frontend/src/app/terms/page.tsx @@ -22,7 +22,7 @@ export default function TermsPage() {

1. Acceptance of Terms

- By accessing and using ArenaX ("the Platform"), you accept and agree to be bound + By accessing and using ArenaX ("the Platform"), you accept and agree to be bound by the terms and provision of this agreement. Additionally, when using ArenaX services, you shall be subject to any posted guidelines or rules applicable to such services.

@@ -148,7 +148,7 @@ export default function TermsPage() {

ArenaX reserves the right to modify these terms at any time. We will provide notice of material changes by posting the updated terms on the Platform and - updating the "Last Updated" date. Your continued use of the Platform after + updating the "Last Updated" date. Your continued use of the Platform after such modifications constitutes your acceptance of the new terms.

diff --git a/frontend/src/app/tournaments/[id]/page.tsx b/frontend/src/app/tournaments/[id]/page.tsx index c5705ca..333a224 100644 --- a/frontend/src/app/tournaments/[id]/page.tsx +++ b/frontend/src/app/tournaments/[id]/page.tsx @@ -6,19 +6,32 @@ import { TournamentHeader } from "@/components/tournaments/TournamentHeader"; import { TournamentRules } from "@/components/tournaments/TournamentRules"; import { TournamentParticipants } from "@/components/tournaments/TournamentParticipants"; import { JoinTournamentButton } from "@/components/tournaments/JoinTournamentButton"; +import { SingleEliminationBracket } from "@/components/bracket/SingleEliminationBracket"; import { mockTournaments } from "@/data/mockTournaments"; +import { generateMockBracket } from "@/data/mockBracket"; import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/Button"; +import { useAuth } from "@/hooks/useAuth"; export default function TournamentDetailsPage() { const params = useParams(); const router = useRouter(); + const { user } = useAuth(); const tournamentId = params.id as string; const tournament = useMemo(() => { return mockTournaments.find((t) => t.id === tournamentId); }, [tournamentId]); + // Generate bracket data for tournaments that are in progress or completed + const bracketData = useMemo(() => { + if (!tournament) return null; + if (tournament.status === "in_progress" || tournament.status === "completed") { + return generateMockBracket(tournamentId); + } + return null; + }, [tournament, tournamentId]); + // If not found, show minimal page if (!tournament) { return ( @@ -28,7 +41,7 @@ export default function TournamentDetailsPage() { Tournament Not Found

- The tournament you're looking for doesn't exist. + The tournament you're looking for doesn't exist.

+ +
+
+ + {/* Filters Section */} +
+
+ +

Filters & Sort

+
+ + {/* Search */} +
+
+ + setSearchValue(e.target.value)} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pl-10 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" + /> +
+
+ + {/* Filter Row */} +
+ {/* Status Filter */} +
+ + +
+ + {/* Game Type Filter */} +
+ + +
+ + {/* Entry Fee Filter */} +
+ + +
+ + {/* Sort */} +
+ +
+ + +
+
+
+ + {/* Reset Button */} + {(searchValue || selectedStatus || selectedGameType || entryFeeFilter !== "all") && ( +
+ +
+ )} +
{/* Results Count */} -
+

{filteredTournaments.length} tournament {filteredTournaments.length !== 1 ? "s" : ""} found + {activeTab === "joined" ? " (joined)" : " (available)"}

@@ -74,20 +307,28 @@ export default function TournamentsPage() { {filteredTournaments.length > 0 ? (
{filteredTournaments.map((tournament) => ( - + ))}
) : (
+

No tournaments found

- {searchValue || selectedStatus || selectedGameType - ? "Try adjusting your search or filters" - : "No tournaments are currently available"} + {activeTab === "joined" + ? "You haven't joined any tournaments yet. Browse available tournaments to join!" + : searchValue || selectedStatus || selectedGameType || entryFeeFilter !== "all" + ? "Try adjusting your search or filters" + : "No tournaments are currently available"}

- {(searchValue || selectedStatus || selectedGameType) && ( + {(searchValue || selectedStatus || selectedGameType || entryFeeFilter !== "all") && ( + )}
)}
diff --git a/frontend/src/components/bracket/BracketMatchModal.tsx b/frontend/src/components/bracket/BracketMatchModal.tsx new file mode 100644 index 0000000..0e1e84d --- /dev/null +++ b/frontend/src/components/bracket/BracketMatchModal.tsx @@ -0,0 +1,193 @@ +"use client"; + +import React from "react"; +import { BracketMatch, PrizeDistribution } from "@/types/bracket"; +import { Button } from "@/components/ui/Button"; +import { X, Trophy, Medal, User, TrendingUp, Zap } from "lucide-react"; + +interface BracketMatchModalProps { + match: BracketMatch; + isOpen: boolean; + onClose: () => void; + prizeDistribution?: PrizeDistribution[]; +} + +export function BracketMatchModal({ + match, + isOpen, + onClose, + prizeDistribution, +}: BracketMatchModalProps) { + if (!isOpen) return null; + + const getStatusLabel = () => { + switch (match.status) { + case "pending": + return { label: "Pending", color: "text-gray-600", bg: "bg-gray-100" }; + case "in_progress": + return { label: "In Progress", color: "text-blue-600", bg: "bg-blue-100" }; + case "completed": + return { label: "Completed", color: "text-green-600", bg: "bg-green-100" }; + case "disputed": + return { label: "Disputed", color: "text-red-600", bg: "bg-red-100" }; + } + }; + + const status = getStatusLabel(); + const hasWinner = match.status === "completed" && match.winnerId; + + return ( +
+
+ {/* Header */} +
+
+ +

Match Details

+
+ +
+ + {/* Body */} +
+ {/* Match Status */} +
+ + {status.label} + +
+ + {/* Players */} +
+ {/* Player 1 */} +
+
+
+
+ +
+
+

+ {match.player1?.username || "TBD"} + {match.player1?.isCurrentUser && ( + (You) + )} +

+ {match.player1 && ( +

+ + ELO: {match.player1.elo} +

+ )} +
+
+ {match.status === "completed" && ( +
+ {match.scorePlayer1 ?? "-"} +
+ )} +
+
+ + {/* VS */} +
+ VS +
+ + {/* Player 2 */} +
+
+
+
+ +
+
+

+ {match.player2?.username || "TBD"} + {match.player2?.isCurrentUser && ( + (You) + )} +

+ {match.player2 && ( +

+ + ELO: {match.player2.elo} +

+ )} +
+
+ {match.status === "completed" && ( +
+ {match.scorePlayer2 ?? "-"} +
+ )} +
+
+
+ + {/* Prize Distribution */} + {prizeDistribution && prizeDistribution.length > 0 && ( +
+

+ + Prize Distribution +

+
+ {prizeDistribution.slice(0, 4).map((prize) => ( +
+ + {prize.position === 1 && } + {prize.position === 2 && } + {prize.position === 3 && } + {prize.position > 3 && `${prize.position}${getOrdinalSuffix(prize.position)}`} + + + {prize.amount !== undefined + ? `$${prize.amount.toLocaleString()}` + : `${prize.percentage}%`} + +
+ ))} +
+
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ); +} + +function getOrdinalSuffix(n: number): string { + const s = ["th", "st", "nd", "rd"]; + const v = n % 100; + return s[(v - 20) % 10] || s[v] || s[0]; +} diff --git a/frontend/src/components/bracket/SingleEliminationBracket.tsx b/frontend/src/components/bracket/SingleEliminationBracket.tsx new file mode 100644 index 0000000..e77f2da --- /dev/null +++ b/frontend/src/components/bracket/SingleEliminationBracket.tsx @@ -0,0 +1,269 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { BracketData, BracketMatch, BracketMatchStatus } from "@/types/bracket"; +import { BracketMatchModal } from "./BracketMatchModal"; +import { Button } from "@/components/ui/Button"; +import { User, Trophy, Clock, Zap, ChevronRight } from "lucide-react"; + +interface SingleEliminationBracketProps { + bracketData: BracketData; + currentUserId?: string; +} + +export function SingleEliminationBracket({ + bracketData, + currentUserId, +}: SingleEliminationBracketProps) { + const [selectedMatch, setSelectedMatch] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + + // Mark current user's matches + const processedBracketData = useMemo(() => { + if (!currentUserId) return bracketData; + + return { + ...bracketData, + rounds: bracketData.rounds.map((round) => ({ + ...round, + matches: round.matches.map((match) => ({ + ...match, + player1: match.player1 + ? { ...match.player1, isCurrentUser: match.player1.id === currentUserId } + : null, + player2: match.player2 + ? { ...match.player2, isCurrentUser: match.player2.id === currentUserId } + : null, + })), + })), + }; + }, [bracketData, currentUserId]); + + const handleMatchClick = (match: BracketMatch) => { + setSelectedMatch(match); + setModalOpen(true); + }; + + const handleCloseModal = () => { + setModalOpen(false); + setSelectedMatch(null); + }; + + const getMatchStatusStyles = (match: BracketMatch) => { + const isUserMatch = + match.player1?.isCurrentUser || match.player2?.isCurrentUser; + + if (isUserMatch) { + return { + border: "border-blue-500 dark:border-blue-400", + bg: "bg-blue-50 dark:bg-blue-950/30", + badge: "bg-blue-500", + }; + } + + switch (match.status) { + case "in_progress": + return { + border: "border-green-500 dark:border-green-400", + bg: "bg-green-50 dark:bg-green-950/30", + badge: "bg-green-500", + }; + case "completed": + return { + border: "border-gray-300 dark:border-gray-600", + bg: "bg-muted/30", + badge: "bg-gray-400", + }; + case "disputed": + return { + border: "border-red-500 dark:border-red-400", + bg: "bg-red-50 dark:bg-red-950/30", + badge: "bg-red-500", + }; + default: + return { + border: "border-muted", + bg: "bg-card", + badge: "bg-muted-foreground/30", + }; + } + }; + + return ( +
+ {/* Bracket Container */} +
+ {processedBracketData.rounds.map((round, roundIndex) => ( +
+ {/* Round Header */} +
+

{round.roundName}

+

+ {round.matches.length} matches +

+
+ + {/* Matches Container */} +
+ {round.matches.map((match, matchIndex) => { + const styles = getMatchStatusStyles(match); + const isUserMatch = + match.player1?.isCurrentUser || match.player2?.isCurrentUser; + + return ( +
+ {/* Match Card */} + + + {/* Connector Line (except for last round) */} + {roundIndex < processedBracketData.rounds.length - 1 && ( +
+ +
+ )} +
+ ); + })} +
+
+ ))} +
+ + {/* Prize Distribution Legend */} + {bracketData.prizeDistribution && bracketData.prizeDistribution.length > 0 && ( +
+

+ + Prize Distribution +

+
+ {bracketData.prizeDistribution.map((prize) => ( +
+ + + {prize.position} + {prize.position === 1 + ? "st" + : prize.position === 2 + ? "nd" + : prize.position === 3 + ? "rd" + : "th"} + :{" "} + {prize.amount !== undefined + ? `$${prize.amount.toLocaleString()}` + : `${prize.percentage}%`} + +
+ ))} +
+
+ )} + + {/* Match Details Modal */} + {selectedMatch && ( + + )} +
+ ); +} diff --git a/frontend/src/components/layout/UserMenu.tsx b/frontend/src/components/layout/UserMenu.tsx index ef953f0..0eb506b 100644 --- a/frontend/src/components/layout/UserMenu.tsx +++ b/frontend/src/components/layout/UserMenu.tsx @@ -2,6 +2,7 @@ import { useRef, useEffect, useState } from "react"; import Link from "next/link"; +import Image from "next/image"; import { usePathname } from "next/navigation"; import { LogOut, User, Wallet } from "lucide-react"; import { useAuth } from "@/hooks/useAuth"; @@ -44,10 +45,12 @@ export function UserMenu() { aria-label="User menu" > {user.avatar ? ( - ) : ( {initial} diff --git a/frontend/src/components/profile/ProfileBio.tsx b/frontend/src/components/profile/ProfileBio.tsx index c06edcf..93e30a7 100644 --- a/frontend/src/components/profile/ProfileBio.tsx +++ b/frontend/src/components/profile/ProfileBio.tsx @@ -81,7 +81,7 @@ export function ProfileBio({ user, onSave }: ProfileBioProps) { ) : ( <>

- "{user.bio || "No bio set yet."}" + {'"'}{user.bio || 'No bio set yet.'}{'"'}

{user.socialLinks?.twitter && ( diff --git a/frontend/src/components/tournaments/QuickJoinModal.tsx b/frontend/src/components/tournaments/QuickJoinModal.tsx new file mode 100644 index 0000000..8b09bb3 --- /dev/null +++ b/frontend/src/components/tournaments/QuickJoinModal.tsx @@ -0,0 +1,249 @@ +"use client"; + +import React, { useState } from "react"; +import { Tournament } from "@/types/tournament"; +import { Button } from "@/components/ui/Button"; +import { X, Zap, Users, Trophy, Clock, CheckCircle } from "lucide-react"; +import { useNotifications } from "@/contexts/NotificationContext"; + +interface QuickJoinModalProps { + tournament: Tournament; + isOpen: boolean; + onClose: () => void; + onJoinSuccess?: (tournamentId: string) => void; +} + +type JoinStatus = "idle" | "confirming" | "success" | "error"; + +export function QuickJoinModal({ + tournament, + isOpen, + onClose, + onJoinSuccess, +}: QuickJoinModalProps) { + const { notify } = useNotifications(); + const [joinStatus, setJoinStatus] = useState("idle"); + const [error, setError] = useState(null); + + if (!isOpen) return null; + + const handleConfirmJoin = async () => { + setJoinStatus("confirming"); + setError(null); + + try { + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 1500)); + + setJoinStatus("success"); + + notify({ + type: "success", + title: "Tournament Joined", + message: `You've successfully joined ${tournament.name}!`, + toast: true, + toastDuration: 5000, + }); + + onJoinSuccess?.(tournament.id); + + // Auto-close after 2 seconds + setTimeout(() => { + setJoinStatus("idle"); + onClose(); + }, 2000); + } catch (err) { + setJoinStatus("error"); + setError(err instanceof Error ? err.message : "Failed to join tournament"); + } + }; + + const handleClose = () => { + if (joinStatus !== "confirming") { + setJoinStatus("idle"); + setError(null); + onClose(); + } + }; + + return ( +
+
+ {/* Header */} +
+
+ {joinStatus === "success" && ( + + )} + {joinStatus === "confirming" && ( + + )} + {joinStatus === "idle" && ( + + )} + {joinStatus === "error" && ( + + )} +

+ {joinStatus === "success" + ? "Successfully Joined!" + : joinStatus === "error" + ? "Join Failed" + : "Confirm Join Tournament"} +

+
+ {joinStatus !== "confirming" && ( + + )} +
+ + {/* Body */} +
+ {joinStatus === "idle" && ( + <> +

+ You are about to join the following tournament: +

+
+
+ + + {tournament.name} + +
+
+ + + Entry Fee:{" "} + + {tournament.entryFee === 0 + ? "Free" + : `$${tournament.entryFee}`} + + +
+
+ + + Players: {tournament.currentParticipants}/ + {tournament.maxParticipants} + +
+
+ {tournament.entryFee > 0 && ( +
+

+ Note: You will be + charged ${tournament.entryFee} upon confirmation. +

+
+ )} + + )} + + {joinStatus === "confirming" && ( + <> +

+ Processing your tournament registration... +

+
+
+ Tournament + + {tournament.name} + +
+
+ Entry Fee + + {tournament.entryFee === 0 + ? "Free" + : `$${tournament.entryFee}`} + +
+
+ + )} + + {joinStatus === "success" && ( + <> +

+ Congratulations! You have successfully joined the tournament. +

+
+

+ Tournament starts on{" "} + + {new Date(tournament.startTime).toLocaleDateString()} + + . Check in 15 minutes before your first match. +

+
+ + )} + + {joinStatus === "error" && ( + <> +

{error}

+
+

+ There was an error joining the tournament. Please try again. +

+
+ + )} +
+ + {/* Footer */} +
+ {joinStatus === "idle" && ( + <> + + + + )} + + {joinStatus === "confirming" && ( + + )} + + {joinStatus === "success" && ( + + )} + + {joinStatus === "error" && ( + <> + + + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/tournaments/TournamentCardWithQuickJoin.tsx b/frontend/src/components/tournaments/TournamentCardWithQuickJoin.tsx new file mode 100644 index 0000000..760f99e --- /dev/null +++ b/frontend/src/components/tournaments/TournamentCardWithQuickJoin.tsx @@ -0,0 +1,228 @@ +"use client"; + +import React, { useState } from "react"; +import Link from "next/link"; +import { Tournament, TournamentStatus } from "@/types/tournament"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { QuickJoinModal } from "./QuickJoinModal"; +import { Users, Trophy, Clock, Zap } from "lucide-react"; + +interface TournamentCardProps { + tournament: Tournament; + isJoined?: boolean; + onJoinSuccess?: (tournamentId: string) => void; +} + +const statusConfig: Record< + TournamentStatus, + { label: string; color: string; bgColor: string } +> = { + draft: { + label: "Draft", + color: "text-gray-600", + bgColor: "bg-gray-100 dark:bg-gray-800", + }, + registration_open: { + label: "Registration Open", + color: "text-green-600", + bgColor: "bg-green-100 dark:bg-green-900", + }, + registration_closed: { + label: "Registration Closed", + color: "text-orange-600", + bgColor: "bg-orange-100 dark:bg-orange-900", + }, + in_progress: { + label: "Ongoing", + color: "text-blue-600", + bgColor: "bg-blue-100 dark:bg-blue-900", + }, + completed: { + label: "Completed", + color: "text-purple-600", + bgColor: "bg-purple-100 dark:bg-purple-900", + }, + cancelled: { + label: "Cancelled", + color: "text-red-600", + bgColor: "bg-red-100 dark:bg-red-900", + }, +}; + +export function TournamentCardWithQuickJoin({ + tournament, + isJoined = false, + onJoinSuccess, +}: TournamentCardProps) { + const [showQuickJoin, setShowQuickJoin] = useState(false); + + const status = statusConfig[tournament.status]; + const participantPercentage = Math.round( + (tournament.currentParticipants / tournament.maxParticipants) * 100, + ); + const isFull = tournament.currentParticipants >= tournament.maxParticipants; + const canJoin = + tournament.status === "registration_open" && !isFull && !isJoined; + + const handleJoinClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setShowQuickJoin(true); + }; + + const handleJoinSuccess = (tournamentId: string) => { + onJoinSuccess?.(tournamentId); + }; + + return ( + <> + + {/* Header with Status */} +
+
+

+ {tournament.name} +

+

+ {tournament.gameType} +

+
+ + {status.label} + +
+ + {/* Details */} +
+ {/* Description */} + {tournament.description && ( +

+ {tournament.description} +

+ )} + + {/* Stats Grid */} +
+ {/* Entry Fee */} +
+ +
+

Entry Fee

+

+ {tournament.entryFee === 0 ? "Free" : `$${tournament.entryFee}`} +

+
+
+ + {/* Prize Pool */} +
+ +
+

Prize Pool

+

+ ${tournament.prizePool.toLocaleString()} +

+
+
+ + {/* Start Time */} +
+ +
+

Starts

+

+ {new Date(tournament.startTime).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} +

+
+
+ + {/* Participants */} +
+ +
+

Players

+

+ {tournament.currentParticipants}/{tournament.maxParticipants} +

+
+
+
+ + {/* Progress Bar */} +
+
+

Filled

+

+ {participantPercentage}% +

+
+
+
+
+
+ + {/* Joined Badge */} + {isJoined && ( +
+

+ ✓ You have joined this tournament +

+
+ )} +
+ + {/* Footer with Button */} +
+ {canJoin ? ( + + ) : ( + + + + )} +
+ + + {/* Quick Join Modal */} + setShowQuickJoin(false)} + onJoinSuccess={handleJoinSuccess} + /> + + ); +} diff --git a/frontend/src/components/tournaments/TournamentRules.tsx b/frontend/src/components/tournaments/TournamentRules.tsx index 974d087..b236d36 100644 --- a/frontend/src/components/tournaments/TournamentRules.tsx +++ b/frontend/src/components/tournaments/TournamentRules.tsx @@ -135,7 +135,7 @@ export function TournamentRules({ tournament }: TournamentRulesProps) { of conduct
  • - • In case of disputes, tournament organizers' decisions are + • In case of disputes, tournament organizers' decisions are final
  • diff --git a/frontend/src/data/mockBracket.ts b/frontend/src/data/mockBracket.ts new file mode 100644 index 0000000..e20b59e --- /dev/null +++ b/frontend/src/data/mockBracket.ts @@ -0,0 +1,283 @@ +import { BracketData, BracketMatch, BracketRound, BracketPlayer, calculatePrizeDistribution } from "@/types/bracket"; + +// Mock players for bracket +const mockPlayers: BracketPlayer[] = [ + { id: "user-1", username: "ProGamer99", elo: 1850 }, + { id: "user-2", username: "ShadowNinja", elo: 1720 }, + { id: "user-3", username: "EliteSniper", elo: 1680 }, + { id: "user-4", username: "DragonSlayer", elo: 1650 }, + { id: "user-5", username: "NightWalker", elo: 1590 }, + { id: "user-6", username: "SpeedRunner", elo: 1540 }, + { id: "user-7", username: "CyberPunk", elo: 1510 }, + { id: "user-8", username: "IronWolf", elo: 1480 }, +]; + +// Prize pool for the tournament +const prizePool = 50000; +const prizeDistribution = calculatePrizeDistribution(prizePool); + +// Generate mock matches for a single elimination bracket +export function generateMockBracket(tournamentId: string): BracketData { + const rounds: BracketRound[] = []; + const totalRounds = 3; // 8 players = 3 rounds (Quarterfinals, Semifinals, Finals) + + // Round 1: Quarterfinals (4 matches) + const round1Matches: BracketMatch[] = [ + { + id: `${tournamentId}-match-1`, + round: 1, + matchNumber: 1, + player1: mockPlayers[0], + player2: mockPlayers[7], + status: "completed", + winnerId: mockPlayers[0].id, + scorePlayer1: 2, + scorePlayer2: 0, + nextMatchId: `${tournamentId}-match-5`, + }, + { + id: `${tournamentId}-match-2`, + round: 1, + matchNumber: 2, + player1: mockPlayers[1], + player2: mockPlayers[6], + status: "completed", + winnerId: mockPlayers[1].id, + scorePlayer1: 2, + scorePlayer2: 1, + nextMatchId: `${tournamentId}-match-5`, + }, + { + id: `${tournamentId}-match-3`, + round: 1, + matchNumber: 3, + player1: mockPlayers[2], + player2: mockPlayers[5], + status: "completed", + winnerId: mockPlayers[2].id, + scorePlayer1: 2, + scorePlayer2: 0, + nextMatchId: `${tournamentId}-match-6`, + }, + { + id: `${tournamentId}-match-4`, + round: 1, + matchNumber: 4, + player1: mockPlayers[3], + player2: mockPlayers[4], + status: "completed", + winnerId: mockPlayers[3].id, + scorePlayer1: 2, + scorePlayer2: 1, + nextMatchId: `${tournamentId}-match-6`, + }, + ]; + + // Round 2: Semifinals (2 matches) + const round2Matches: BracketMatch[] = [ + { + id: `${tournamentId}-match-5`, + round: 2, + matchNumber: 5, + player1: mockPlayers[0], + player2: mockPlayers[1], + status: "completed", + winnerId: mockPlayers[0].id, + scorePlayer1: 2, + scorePlayer2: 1, + nextMatchId: `${tournamentId}-match-7`, + }, + { + id: `${tournamentId}-match-6`, + round: 2, + matchNumber: 6, + player1: mockPlayers[2], + player2: mockPlayers[3], + status: "completed", + winnerId: mockPlayers[3].id, + scorePlayer1: 1, + scorePlayer2: 2, + nextMatchId: `${tournamentId}-match-7`, + }, + ]; + + // Round 3: Finals (1 match) + const round3Matches: BracketMatch[] = [ + { + id: `${tournamentId}-match-7`, + round: 3, + matchNumber: 7, + player1: mockPlayers[0], + player2: mockPlayers[3], + status: "completed", + winnerId: mockPlayers[0].id, + scorePlayer1: 3, + scorePlayer2: 2, + }, + ]; + + rounds.push({ + roundNumber: 1, + roundName: "Quarterfinals", + matches: round1Matches, + }); + + rounds.push({ + roundNumber: 2, + roundName: "Semifinals", + matches: round2Matches, + }); + + rounds.push({ + roundNumber: 3, + roundName: "Finals", + matches: round3Matches, + }); + + return { + tournamentId, + tournamentName: "CS2 Pro League 2026", + rounds, + totalRounds, + prizeDistribution, + }; +} + +// Generate a bracket with a match where the current user is playing +export function generateMockBracketWithCurrentUser( + tournamentId: string, + currentUserId: string +): BracketData { + const bracket = generateMockBracket(tournamentId); + + // Add current user to the first match as player 1 + if (bracket.rounds[0]?.matches[0]) { + bracket.rounds[0].matches[0].player1 = { + id: currentUserId, + username: "CurrentUser", + elo: 1700, + isCurrentUser: true, + }; + bracket.rounds[0].matches[0].player2 = mockPlayers[7]; + } + + return bracket; +} + +// Generate a bracket with some in-progress matches +export function generateInProgressBracket(tournamentId: string): BracketData { + const bracket = generateMockBracket(tournamentId); + + // Make the semifinals in progress + if (bracket.rounds[1]?.matches[0]) { + bracket.rounds[1].matches[0].status = "in_progress"; + } + + return bracket; +} + +// Generate a bracket with pending matches (for upcoming tournaments) +export function generatePendingBracket(tournamentId: string): BracketData { + const rounds: BracketRound[] = []; + const totalRounds = 3; + + // Round 1: Quarterfinals - all pending + const round1Matches: BracketMatch[] = [ + { + id: `${tournamentId}-match-1`, + round: 1, + matchNumber: 1, + player1: mockPlayers[0], + player2: mockPlayers[7], + status: "pending", + nextMatchId: `${tournamentId}-match-5`, + }, + { + id: `${tournamentId}-match-2`, + round: 1, + matchNumber: 2, + player1: mockPlayers[1], + player2: mockPlayers[6], + status: "pending", + nextMatchId: `${tournamentId}-match-5`, + }, + { + id: `${tournamentId}-match-3`, + round: 1, + matchNumber: 3, + player1: mockPlayers[2], + player2: mockPlayers[5], + status: "pending", + nextMatchId: `${tournamentId}-match-6`, + }, + { + id: `${tournamentId}-match-4`, + round: 1, + matchNumber: 4, + player1: mockPlayers[3], + player2: mockPlayers[4], + status: "pending", + nextMatchId: `${tournamentId}-match-6`, + }, + ]; + + // Round 2: Semifinals - no players yet + const round2Matches: BracketMatch[] = [ + { + id: `${tournamentId}-match-5`, + round: 2, + matchNumber: 5, + player1: null, + player2: null, + status: "pending", + nextMatchId: `${tournamentId}-match-7`, + }, + { + id: `${tournamentId}-match-6`, + round: 2, + matchNumber: 6, + player1: null, + player2: null, + status: "pending", + nextMatchId: `${tournamentId}-match-7`, + }, + ]; + + // Round 3: Finals - no players yet + const round3Matches: BracketMatch[] = [ + { + id: `${tournamentId}-match-7`, + round: 3, + matchNumber: 7, + player1: null, + player2: null, + status: "pending", + }, + ]; + + rounds.push({ + roundNumber: 1, + roundName: "Quarterfinals", + matches: round1Matches, + }); + + rounds.push({ + roundNumber: 2, + roundName: "Semifinals", + matches: round2Matches, + }); + + rounds.push({ + roundNumber: 3, + roundName: "Finals", + matches: round3Matches, + }); + + return { + tournamentId, + tournamentName: "Upcoming Tournament", + rounds, + totalRounds, + prizeDistribution, + }; +} diff --git a/frontend/src/hooks/useMatchWebSocket.ts b/frontend/src/hooks/useMatchWebSocket.ts new file mode 100644 index 0000000..640a809 --- /dev/null +++ b/frontend/src/hooks/useMatchWebSocket.ts @@ -0,0 +1,194 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { Match } from "@/types/match"; + +export interface MatchUpdate { + matchId: string; + scorePlayer1?: number; + scorePlayer2?: number; + status?: Match["status"]; + winnerId?: string; + timestamp: number; +} + +interface UseMatchWebSocketOptions { + matchId: string; + enabled?: boolean; +} + +interface UseMatchWebSocketReturn { + isConnected: boolean; + lastUpdate: MatchUpdate | null; + connectionError: string | null; + reconnect: () => void; + disconnect: () => void; +} + +// Simulated WebSocket for match updates +// In a real application, this would connect to an actual WebSocket server +export function useMatchWebSocket({ + matchId, + enabled = true, +}: UseMatchWebSocketOptions): UseMatchWebSocketReturn { + const [isConnected, setIsConnected] = useState(false); + const [lastUpdate, setLastUpdate] = useState(null); + const [connectionError, setConnectionError] = useState(null); + const intervalRef = useRef | null>(null); + const reconnectTimeoutRef = useRef | null>(null); + + const disconnect = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + setIsConnected(false); + }, []); + + const connect = useCallback(() => { + // Simulate connection delay + setConnectionError(null); + + // Simulate WebSocket connection + const connectTimeout = setTimeout(() => { + setIsConnected(true); + + // Simulate receiving periodic updates (every 2-5 seconds) + // In a real app, this would be actual WebSocket messages + if (enabled) { + intervalRef.current = setInterval(() => { + // Simulate random score updates for demo purposes + // In production, this would receive actual match updates from the server + const randomUpdate: MatchUpdate = { + matchId, + timestamp: Date.now(), + // Simulate occasional score changes + scorePlayer1: Math.floor(Math.random() * 16), + scorePlayer2: Math.floor(Math.random() * 16), + }; + + setLastUpdate(randomUpdate); + }, 3000 + Math.random() * 2000); + } + }, 500); + + return () => clearTimeout(connectTimeout); + }, [matchId, enabled]); + + const reconnect = useCallback(() => { + disconnect(); + connect(); + }, [disconnect, connect]); + + useEffect(() => { + if (enabled && matchId) { + connect(); + } + + return () => { + disconnect(); + }; + }, [matchId, enabled, connect, disconnect]); + + // Simulate occasional disconnections for realism + useEffect(() => { + if (!isConnected || !enabled) return; + + // Random disconnection simulation (1% chance every 10 seconds) + const disconnectChance = setInterval(() => { + if (Math.random() < 0.01) { + setIsConnected(false); + setConnectionError("Connection lost. Reconnecting..."); + + // Auto-reconnect after 2 seconds + reconnectTimeoutRef.current = setTimeout(() => { + reconnect(); + }, 2000); + } + }, 10000); + + return () => clearInterval(disconnectChance); + }, [isConnected, enabled, reconnect]); + + return { + isConnected, + lastUpdate, + connectionError, + reconnect, + disconnect, + }; +} + +// Hook for reporting match scores +interface ScoreReport { + matchId: string; + player1Score: number; + player2Score: number; + reporterId: string; +} + +interface UseMatchScoreReportingReturn { + reportScore: (report: ScoreReport) => Promise; + pendingReport: ScoreReport | null; + isReporting: boolean; + conflictDetected: boolean; + conflictingReport: ScoreReport | null; + clearConflict: () => void; +} + +// Simulated score reporting with conflict detection +export function useMatchScoreReporting(): UseMatchScoreReportingReturn { + const [isReporting, setIsReporting] = useState(false); + const [pendingReport, setPendingReport] = useState(null); + const [conflictDetected, setConflictDetected] = useState(false); + const [conflictingReport, setConflictingReport] = useState(null); + + const reportScore = useCallback(async (report: ScoreReport): Promise => { + setIsReporting(true); + setPendingReport(report); + + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Simulate conflict detection (30% chance of conflict for demo) + const hasConflict = Math.random() < 0.3; + + if (hasConflict) { + // Create conflicting report + const conflict: ScoreReport = { + ...report, + player1Score: report.player1Score + (Math.random() > 0.5 ? 1 : -1), + player2Score: report.player2Score + (Math.random() > 0.5 ? 1 : -1), + }; + + setConflictDetected(true); + setConflictingReport(conflict); + setIsReporting(false); + return false; + } + + // Success + setPendingReport(null); + setIsReporting(false); + return true; + }, []); + + const clearConflict = useCallback(() => { + setConflictDetected(false); + setConflictingReport(null); + setPendingReport(null); + }, []); + + return { + reportScore, + pendingReport, + isReporting, + conflictDetected, + conflictingReport, + clearConflict, + }; +} diff --git a/frontend/src/types/bracket.ts b/frontend/src/types/bracket.ts new file mode 100644 index 0000000..bd7cc91 --- /dev/null +++ b/frontend/src/types/bracket.ts @@ -0,0 +1,59 @@ +// Bracket-related types for tournament visualization + +export interface BracketPlayer { + id: string; + username: string; + avatar?: string; + elo: number; + isCurrentUser?: boolean; +} + +export interface BracketMatch { + id: string; + round: number; + matchNumber: number; + player1: BracketPlayer | null; + player2: BracketPlayer | null; + winnerId?: string; + status: BracketMatchStatus; + scorePlayer1?: number; + scorePlayer2?: number; + prizePool?: number; + nextMatchId?: string; // For progression +} + +export type BracketMatchStatus = + | "pending" + | "in_progress" + | "completed" + | "disputed"; + +export interface BracketRound { + roundNumber: number; + roundName: string; + matches: BracketMatch[]; +} + +export interface BracketData { + tournamentId: string; + tournamentName: string; + rounds: BracketRound[]; + totalRounds: number; + prizeDistribution: PrizeDistribution[]; +} + +export interface PrizeDistribution { + position: number; + percentage: number; + amount?: number; +} + +// Helper function to calculate prize pool distribution +export function calculatePrizeDistribution(totalPrizePool: number): PrizeDistribution[] { + return [ + { position: 1, percentage: 50, amount: totalPrizePool * 0.5 }, + { position: 2, percentage: 25, amount: totalPrizePool * 0.25 }, + { position: 3, percentage: 12.5, amount: totalPrizePool * 0.125 }, + { position: 4, percentage: 12.5, amount: totalPrizePool * 0.125 }, + ]; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 15b2bf8..17932d7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -3,6 +3,7 @@ export * from './user'; export * from './tournament'; export * from './match'; export * from './notification'; +export * from './bracket'; // Common API response types export interface ApiResponse { diff --git a/server/package-lock.json b/server/package-lock.json index 94fb52e..5b75fe0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,7 +8,7 @@ "name": "arenax-server", "version": "0.1.0", "dependencies": { - "@prisma/client": "^5.10.2", + "@prisma/client": "^5.22.0", "@sentry/node": "^9.18.0", "@stellar/stellar-sdk": "^11.3.0", "bcrypt": "^5.1.1", @@ -17,6 +17,7 @@ "express": "^4.18.3", "express-rate-limit": "^7.5.0", "helmet": "^7.1.0", + "ioredis": "^5.9.3", "jsonwebtoken": "^9.0.2", "uuid": "^9.0.1", "winston": "^3.18.3", @@ -27,12 +28,13 @@ "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/ioredis": "^4.28.10", "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20.11.24", "@types/passport": "^1.0.17", "@types/passport-jwt": "^4.0.1", "@types/uuid": "^9.0.8", - "prisma": "^5.10.2", + "prisma": "^5.22.0", "ts-node-dev": "^2.0.0", "typescript": "^5.3.3" } @@ -70,6 +72,12 @@ "kuler": "^2.0.0" } }, + "node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "license": "MIT" + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -813,42 +821,6 @@ "@opentelemetry/semantic-conventions": "^1.34.0" } }, - "node_modules/@sentry/node/node_modules/balanced-match": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", - "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@sentry/node/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@sentry/node/node_modules/minimatch": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", - "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@sentry/opentelemetry": { "version": "9.47.1", "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-9.47.1.tgz", @@ -1017,6 +989,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ioredis": { + "version": "4.28.10", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", + "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", @@ -1384,10 +1366,13 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/bare-addon-resolve": { "version": "1.10.0", @@ -1523,13 +1508,15 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -1678,6 +1665,15 @@ "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "license": "MIT" }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", @@ -1858,6 +1854,15 @@ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "license": "MIT" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2371,6 +2376,34 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", + "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2566,6 +2599,53 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.3.tgz", + "integrity": "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2745,12 +2825,24 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -2911,15 +3003,18 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -3368,6 +3463,27 @@ "node": ">=8.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-addon": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz", @@ -3719,6 +3835,12 @@ "node": "*" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/server/package.json b/server/package.json index 9d03e2f..1819130 100644 --- a/server/package.json +++ b/server/package.json @@ -13,8 +13,9 @@ "lint": "tsc --noEmit" }, "dependencies": { + "@sentry/node": "^9.18.0", - "@prisma/client": "^5.10.2", + "@prisma/client": "^5.22.0", "@stellar/stellar-sdk": "^11.3.0", "bcrypt": "^5.1.1", "cors": "^2.8.5", @@ -22,6 +23,7 @@ "express-rate-limit": "^7.5.0", "express": "^4.18.3", "helmet": "^7.1.0", + "ioredis": "^5.9.3", "jsonwebtoken": "^9.0.2", "winston": "^3.18.3", "winston-daily-rotate-file": "^5.0.0", @@ -32,12 +34,13 @@ "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/ioredis": "^4.28.10", "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20.11.24", "@types/passport": "^1.0.17", "@types/passport-jwt": "^4.0.1", "@types/uuid": "^9.0.8", - "prisma": "^5.10.2", + "prisma": "^5.22.0", "ts-node-dev": "^2.0.0", "typescript": "^5.3.3" } diff --git a/server/prisma/migrations/20260222111851_init_gaming_engine/migration.sql b/server/prisma/migrations/20260222111851_init_gaming_engine/migration.sql new file mode 100644 index 0000000..3882d5f --- /dev/null +++ b/server/prisma/migrations/20260222111851_init_gaming_engine/migration.sql @@ -0,0 +1,111 @@ +-- CreateEnum +CREATE TYPE "MatchState" AS ENUM ('PENDING', 'ACTIVE', 'SETTLED', 'FORFEIT'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "walletAddress" TEXT, + "elo" INTEGER NOT NULL DEFAULT 1200, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "ownerId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Payment" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "fromAddress" TEXT NOT NULL, + "toAddress" TEXT NOT NULL, + "amount" TEXT NOT NULL, + "asset" TEXT NOT NULL DEFAULT 'USDC', + "status" TEXT NOT NULL DEFAULT 'PENDING', + "txHash" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Payment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BlockchainEvent" ( + "id" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "contractId" TEXT NOT NULL, + "txHash" TEXT NOT NULL, + "ledger" INTEGER NOT NULL, + "data" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BlockchainEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Match" ( + "id" TEXT NOT NULL, + "player1Id" TEXT NOT NULL, + "player2Id" TEXT NOT NULL, + "state" "MatchState" NOT NULL DEFAULT 'PENDING', + "winnerId" TEXT, + "player1Report" JSONB, + "player2Report" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Match_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EloHistory" ( + "id" TEXT NOT NULL, + "matchId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "oldRating" INTEGER NOT NULL, + "newRating" INTEGER NOT NULL, + "delta" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "EloHistory_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_walletAddress_key" ON "User"("walletAddress"); + +-- CreateIndex +CREATE UNIQUE INDEX "Payment_txHash_key" ON "Payment"("txHash"); + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Match" ADD CONSTRAINT "Match_player1Id_fkey" FOREIGN KEY ("player1Id") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Match" ADD CONSTRAINT "Match_player2Id_fkey" FOREIGN KEY ("player2Id") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EloHistory" ADD CONSTRAINT "EloHistory_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "Match"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EloHistory" ADD CONSTRAINT "EloHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 3006580..1b2b0b7 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -8,18 +8,27 @@ datasource db { } model User { - id String @id @default(uuid()) - email String @unique - username String @unique + id String @id @default(uuid()) + email String @unique + username String @unique passwordHash String - role String @default("USER") + role String @default("USER") bio String? socials Json? - walletAddress String? @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - projects Project[] - wallet UserWallet? + walletAddress String? @unique + + elo Int @default(1200) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + projects Project[] + + wallet UserWallet? + matchesAsP1 Match[] @relation("Player1") + matchesAsP2 Match[] @relation("Player2") + eloHistory EloHistory[] + refreshTokens RefreshToken[] } @@ -36,30 +45,30 @@ model UserWallet { } model RefreshToken { - id String @id @default(uuid()) + id String @id @default(uuid()) userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) familyId String - tokenHash String @unique + tokenHash String @unique expiresAt DateTime revokedAt DateTime? parentTokenId String? replacedByTokenId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId]) @@index([familyId]) } model Project { - id String @id @default(uuid()) + id String @id @default(uuid()) name String description String? ownerId String - owner User @relation(fields: [ownerId], references: [id]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + owner User @relation(fields: [ownerId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt payments Payment[] } @@ -87,7 +96,7 @@ model BlockchainTransaction { id String @id @default(uuid()) userId String? txHash String @unique - type String // e.g., "WALLET_REGISTRATION", "CONTRACT_INVOKE" + type String // e.g., "WALLET_REGISTRATION", "CONTRACT_INVOKE" status TxStatus @default(PENDING) payload Json? error String? @@ -96,11 +105,56 @@ model BlockchainTransaction { } model BlockchainEvent { - id String @id @default(uuid()) - eventType String // e.g., PAY_DONE - contractId String - txHash String - ledger Int - data Json - createdAt DateTime @default(now()) + id String @id @default(uuid()) + eventType String // e.g., PAY_DONE + contractId String + txHash String + ledger Int + data Json + createdAt DateTime @default(now()) +} + +model Match { + id String @id @default(uuid()) + + player1Id String + player2Id String + + player1 User @relation("Player1", fields: [player1Id], references: [id]) + player2 User @relation("Player2", fields: [player2Id], references: [id]) + + state MatchState @default(PENDING) + + winnerId String? + + player1Report Json? + player2Report Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + eloHistory EloHistory[] +} + +model EloHistory { + id String @id @default(uuid()) + + matchId String + userId String + + match Match @relation(fields: [matchId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + oldRating Int + newRating Int + delta Int + + createdAt DateTime @default(now()) +} + +enum MatchState { + PENDING + ACTIVE + SETTLED + FORFEIT } diff --git a/server/src/app.ts b/server/src/app.ts index 58a0cec..2c80f21 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -10,6 +10,13 @@ import { requestIdMiddleware } from './middleware/request-id.middleware'; import { logger } from './services/logger.service'; import { initializeTelemetry } from './services/telemetry.service'; +import Redis from "ioredis"; +import { MatchMakingWorker } from './workers/matchmaking.worker'; +import {ReaperWorker} from "./workers/reaper.worker"; + +const redis = new Redis(); + + dotenv.config(); initializeTelemetry(); @@ -90,4 +97,7 @@ app.listen(port, () => { logger.info('Server started', { url: `http://localhost:${port}` }); }); +new MatchMakingWorker(redis).start(); +new ReaperWorker().start(); + export default app; diff --git a/server/src/controllers/match.controller.ts b/server/src/controllers/match.controller.ts new file mode 100644 index 0000000..1f2ddd0 --- /dev/null +++ b/server/src/controllers/match.controller.ts @@ -0,0 +1,76 @@ +import {Request, Response} from "express"; +import { PrismaClient } from "@prisma/client"; +import {EloService} from "../services/elo.service"; + +const prisma = new PrismaClient(); +const eloService = new EloService(); + +export class MatchController { + async reportResult(req: Request, res: Response) { + const {id} = req.params; + const { winnerId } = req.body; + + const match = await prisma.match.findUnique({ + where: {id}, + include: { + player1: true, + player2: true, + }, + }); + if(!match) return res.status(404).json({error: "Not found"}); + + if(match.state !== "ACTIVE") + return res.status(400).json({error: "Invalid state"}); + + const outcome = + winnerId === match.player1Id ? 1 : + winnerId === match.player2Id ? 0 : + 0.5; + const rating = eloService.calculateRating( + match.player1.elo, + match.player2.elo, + outcome + ); + await prisma.$transaction([ + prisma.user.update({ + where: { id: match.player1Id}, + data: { + elo: rating.newRatingA, + }, + }), + prisma.user.update({ + where: { id: match.player2Id}, + data: { + elo: rating.newRatingB, + }, + }), + prisma.eloHistory.create({ + data: { + matchId: match.id, + userId: match.player1Id, + oldRating: match.player1.elo, + newRating: rating.newRatingA, + delta: rating.deltaA, + }, + }), + prisma.eloHistory.create({ + data: { + matchId: match.id, + userId: match.player2Id, + oldRating: match.player2.elo, + newRating: rating.newRatingB, + delta: rating.newRatingB, + }, + }), + prisma.match.update({ + where: { id}, + data: { + state: "SETTLED", + winnerId, + }, + }), + + ]); + return res.json({rating}); + } +} diff --git a/server/src/redis/redis.client.ts b/server/src/redis/redis.client.ts new file mode 100644 index 0000000..e69de29 diff --git a/server/src/routes/match.routes.ts b/server/src/routes/match.routes.ts new file mode 100644 index 0000000..a57b10b --- /dev/null +++ b/server/src/routes/match.routes.ts @@ -0,0 +1,10 @@ +import {Router} from "express"; +import {MatchController} from "../controllers/match.controller"; + +const router = Router(); +const controller = new MatchController(); + +router.post("/matches/:id/report",(req, res) => + controller.reportResult(req, res) +); +export default router; \ No newline at end of file diff --git a/server/src/services/elo.service.ts b/server/src/services/elo.service.ts new file mode 100644 index 0000000..3423961 --- /dev/null +++ b/server/src/services/elo.service.ts @@ -0,0 +1,33 @@ +export class EloService { + private readonly K= 32; + /** + * Calculate new ratings for two players + * outcome: 1 = A wins, 0= B wins, 0.5 = draw + */ + calculateRating( + ratingA: number, + ratingB: number, + outcome: 1| 0 | 0.5 + ) { + const expectedA = + 1 / (1+ Math.pow(10, (ratingB - ratingA) /400)); + const expectedB = + 1 / (1+ Math.pow(10, (ratingA - ratingB) /400)); + const scoreA = outcome; + const scoreB = outcome === 0.5 ? 0.5 : outcome === 1 ? 0 : 1; + const newRatingA = Math.round( + ratingA + this.K * (scoreA - expectedA) + ); + const newRatingB = Math.round( + ratingB + this.K * (scoreB - expectedB) + ); + + return { + newRatingA, + newRatingB, + deltaA: newRatingA - ratingA, + deltaB: newRatingB - ratingB, + }; + + } +} \ No newline at end of file diff --git a/server/src/services/match.service.ts b/server/src/services/match.service.ts index 80dc0a4..d3c95ff 100644 --- a/server/src/services/match.service.ts +++ b/server/src/services/match.service.ts @@ -1,9 +1,88 @@ +import Redis from "ioredis"; + export class MatchService { + private redis: Redis; + + constructor(redisClient: Redis) { + this.redis = redisClient; + } + + /** + * Create if a player is already queued. + */ + async isQueued(playerId: string): Promise { + const exists = await this.redis.sismember("queue:active", playerId); + return exists === 1; + } /** - * Create a new match between players. + * Add a player to matchmaking queue. */ + async joinQueue(playerId: string, elo: number, group: string) { + const alreadyQueued = await this.isQueued(playerId); + + if(alreadyQueued) { + throw new Error("Player already in matchmaking queue"); + } + const queueKey = `queue:elo:${group}`; + const metaKey = `queue:meta:${playerId}`; + const now = Date.now(); + + const multi = this.redis.multi(); + + //Add to sorted set(score = elo) + multi.zadd(queueKey, elo, playerId); + + //Store metadata + multi.hset(metaKey, { + elo: elo.toString(), + joinedAt: now.toString(), + group, + }); + //Add to active set + multi.sadd("queue:active", playerId); + await multi.exec(); + return {success: true}; + + } + /** + * Remove player from matchmaking queue. + */ + async leaveQueue(playerId: string) { + const metaKey = `queue:meta:${playerId}`; + const metadata = await this.redis.hgetall(metaKey); + + if (!metadata || !metadata.group) { + throw new Error("Player is not in queue"); + } + const queueKey = `queue:elo:${metadata.group}`; + const multi = this.redis.multi(); + + //remove from sorted set + multi.zrem(queueKey, playerId); + + //delete metadata + multi.del(metaKey); + + //remove from active set + multi.srem("queue:active", playerId); + + await multi.exec(); + + return {success: true}; + + + } async createMatch(player1Id: string, player2Id: string, matchType: string) { // Placeholder for match creation + console.log(`Creating match ${player1Id} vs ${player2Id}`); + + //TODO: Save in DB + return { + player1Id, + player2Id, + state: "PENDING", + matchType, + } } /** diff --git a/server/src/workers/matchmaking.worker.ts b/server/src/workers/matchmaking.worker.ts new file mode 100644 index 0000000..c528dec --- /dev/null +++ b/server/src/workers/matchmaking.worker.ts @@ -0,0 +1,101 @@ +import Redis from "ioredis"; +import {MatchService} from "../services/match.service"; + +export class MatchMakingWorker { + private redis: Redis; + private matchService: MatchService; + private readonly BASE_DELTA = 50; + private readonly STALE_TIMEOUT = 5*60*1000; + private readonly GROUPS = ["solo"]; + + constructor(redis: Redis) { + this.redis = redis; + this.matchService = new MatchService(redis); + + } + start() { + console.log("Matchmaking worker started"); + + setInterval(() => { + this.tick(); + },3000); //every 3 seconds + } + private async tick() { + for(const group of this.GROUPS) { + await this.processGroup(group); + } + } + private async processGroup(group: string) { + await this.cleanupStale(group); + await this.tryMatch(group); + } + /** + * Remove players who waited too long(stale) + */ + private async cleanupStale(group: string){ + const queueKey = `queue:elo:${group}`; + const now = Date.now(); + + const players = await this.redis.zrange(queueKey, 0, 50); + for (const playerId of players) { + const meta = await this.redis.hgetall(`queue:meta:${playerId}`); + if(!meta.joinedAt) continue; + + const joinedAt = parseInt(meta.joinedAt); + const waitTime = now- joinedAt; + + if(waitTime > this.STALE_TIMEOUT) { + console.log(`Removing stale player ${playerId}`); + await this.matchService.leaveQueue(playerId); + } + } + } + /** + * Try to match players + */ + private async tryMatch(group: string) { + const queueKey = `queue:elo:${group}`; + const players = await this.redis.zrange(queueKey, 0, 20); + + for (const playerId of players) { + const meta = await this.redis.hgetall(`queue:meta:${playerId}`); + if(!meta.elo) continue; + + const elo = parseInt(meta.elo); + const joinedAt = parseInt(meta.joinedAt); + const waitTime = Date.now() - joinedAt; + + //dynamic radius expansion + const dynamicDelta = + this.BASE_DELTA + Math.floor(waitTime / 10000) *20; + const min = elo - dynamicDelta; + const max = elo + dynamicDelta; + + const candidates = await this.redis.zrangebyscore( + queueKey, + min, + max, + "LIMIT", + 0, + 5, + ); + const opponent = candidates.find(p=>p!==playerId); + if(opponent) { + await this.createMatch(playerId, opponent, group); + return; //process next tick + } + } + } + private async createMatch( + player1: string, + player2: string, + group: string + ) { + console.log(`Match found: ${player1} vs ${player2}`); + + await this.matchService.leaveQueue(player1); + await this.matchService.leaveQueue(player2); + + await this.matchService.createMatch(player1, player2, group); + } +} \ No newline at end of file diff --git a/server/src/workers/reaper.worker.ts b/server/src/workers/reaper.worker.ts new file mode 100644 index 0000000..58ccc9d --- /dev/null +++ b/server/src/workers/reaper.worker.ts @@ -0,0 +1,33 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export class ReaperWorker { + start() { + console.log("Reaper worker started"); + + setInterval(()=>{ + this.checkExpiredMatches(); + }, 60 * 60 *1000); // every 1 hour + } + private async checkExpiredMatches() { + const cutoff = new Date(Date.now() - 24*60*60*1000); + const expired = await prisma.match.findMany({ + where: { + state: "ACTIVE", + updatedAt: { + lt: cutoff, + }, + }, + }); + for(const match of expired) { + await prisma.match.update({ + where: {id: match.id}, + data: { + state: "FORFEIT", + }, + }); + console.log(`Match ${match.id} auto-forefeited`); + } + } +} \ No newline at end of file diff --git a/server/tsconfig.json b/server/tsconfig.json index d0ac2e8..ddb8776 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "CommonJS", + "moduleResolution": "Node", "outDir": "./dist", "rootDir": "./src", "strict": true,