Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/modules/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ st_core = { path = "../st_core", features = ["api"] }
# For Redis Pub/Sub in WebSocket
redis = { version = "0.24", features = ["tokio-comp", "json"] }

# For IPFS/Arweave archival
reqwest = { version = "0.11", features = ["json", "multipart"] }
sha2 = "0.10"
hex = "0.4"

[dev-dependencies]
tokio = { version = "1", features = ["full"] }
actix-rt = "2.9"
Expand Down
30 changes: 27 additions & 3 deletions backend/modules/api/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::env;
use uuid::Uuid;

use dto::auth::{RegisterRequest, LoginRequest, AuthResponse, ErrorResponse, RefreshTokenRequest, RefreshResponse, LogoutResponse};
use security::{JwtService, TokenService, TokenServiceError};
use security::{JwtService, TokenService, TokenServiceError, SessionService};
use sea_orm::DatabaseConnection;

/// Register a new user
Expand Down Expand Up @@ -58,6 +58,7 @@ pub async fn register(
#[post("/login")]
pub async fn login(
db: web::Data<DatabaseConnection>,
req: HttpRequest,
payload: web::Json<LoginRequest>,
jwt_service: web::Data<JwtService>,
) -> HttpResponse {
Expand All @@ -73,8 +74,31 @@ pub async fn login(
let user_id = 1;
let username = payload.username.clone();

// Generate access token
let access_token = match jwt_service.generate_token(user_id, &username) {
// Extract IP address and user agent for session tracking
let ip_address = req.connection_info().real_ip_remote_addr().map(|s| s.to_string());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

actix-web ConnectionInfo real_ip_remote_addr spoofing X-Forwarded-For trusted proxy

💡 Result:

actix-web ConnectionInfo does not provide a built-in “trusted proxy” allowlist; it will read client IP information from spoofable headers by default unless you control who can set them (e.g., by stripping/overwriting at a reverse proxy). 1) What ConnectionInfo uses for real_ip_remote_addr - ConnectionInfo::realip_remote_addr resolves the “real IP” (remote address of the client) in this order: - Forwarded header (RFC 7239) - X-Forwarded-For header - peer address from the opened socket (same as peer_addr) This behavior is defined in actix-web’s ConnectionInfo docs/source. 2) Spoofing risk (no trust model) - The docs explicitly warn: do not use realip_remote_addr for security purposes unless you can be sure the Forwarded and X-Forwarded-For headers cannot be spoofed by the client. If you’re running without a proxy, obtaining the peer address would be more appropriate. - actix-web also notes that Forwarded/X-Forwarded-* values expose privacy-sensitive info (like client IP) and must be considered when deploying. 3) “trusted proxy” guidance for X-Forwarded-For - Because actix-web’s ConnectionInfo simply parses these headers, your application needs a deployment-level strategy to ensure only your reverse proxy can influence them. Typical approach: - Configure your reverse proxy to overwrite X-Forwarded-For (and Forwarded if used) and to remove any incoming copies from the client. - Ensure the actix-web server is not directly reachable from untrusted networks (only through the reverse proxy / internal network). - Then, it is safe to treat realip_remote_addr as the proxy-supplied client IP for logging/diagnostics; for security decisions, still prefer using peer_addr or implement explicit trust logic yourself. 4) actix-web-related misconception: “trusted proxy” doesn’t exist in ConnectionInfo - There is an open discussion/issue specifically highlighting that realip_remote_addr behavior can lead to insecure code when apps use it unconditionally. 5) Practical options if you need explicit trust - actix-web itself: no built-in trusted-proxy config for ConnectionInfo. - If you need “trusted proxy” behavior, you generally implement it via: - middleware that extracts IP only when the request comes from known proxy IPs, or - custom header parsing with an allowlist of proxy addresses (not shown in actix-web ConnectionInfo docs). Example takeaway: If you want “X-Forwarded-For trusted proxy” semantics, you must enforce them outside ConnectionInfo (reverse proxy configuration + network isolation, or custom middleware/logic).

Citations:


🏁 Script executed:

# First, let's examine the exact code at line 78 with surrounding context
sed -n '70,90p' backend/modules/api/src/auth.rs

Repository: NOVUS-X/XLMate

Length of output: 793


🏁 Script executed:

# Search for session.ip_address usage to understand the impact scope
rg 'session.*ip_address|ip_address.*session' -i -A 2 -B 2

Repository: NOVUS-X/XLMate

Length of output: 405


🏁 Script executed:

# Search for IP binding or trusted proxy validation patterns
rg 'ip.*bind|trusted.*proxy|proxy.*ip|X-Forwarded-For' -i -A 2 -B 2

Repository: NOVUS-X/XLMate

Length of output: 742


🏁 Script executed:

# Search for other uses of real_ip_remote_addr or peer_addr in the codebase
rg 'real_ip_remote_addr|peer_addr|connection_info' -i -A 2

Repository: NOVUS-X/XLMate

Length of output: 1657


🏁 Script executed:

# Find and examine SessionService implementation
fd 'SessionService|session.*service' -i --type f | head -20

Repository: NOVUS-X/XLMate

Length of output: 106


🏁 Script executed:

# Look for the create_session method implementation
rg 'fn create_session|impl SessionService' -A 15 -B 2

Repository: NOVUS-X/XLMate

Length of output: 1671


🏁 Script executed:

# Check if IP is validated on session validation/refresh
rg 'validate.*session|refresh.*session|session.*valid' -i -A 5 -B 2

Repository: NOVUS-X/XLMate

Length of output: 808


🏁 Script executed:

# Search for proxy configuration or middleware
rg 'proxy|middleware|X-Forwarded|Forwarded' -i --type f

Repository: NOVUS-X/XLMate

Length of output: 84


🏁 Script executed:

# Look for proxy or middleware configuration without the invalid --type flag
rg 'proxy|middleware|X-Forwarded|Forwarded' -i -A 2 -B 2

Repository: NOVUS-X/XLMate

Length of output: 11672


🏁 Script executed:

# Get the full validate_session implementation
sed -n '1,200p' backend/modules/security/src/session_service.rs | grep -A 30 'fn validate_session'

Repository: NOVUS-X/XLMate

Length of output: 1004


🏁 Script executed:

# Check if IP validation happens during request processing
rg 'ip_address.*validate|validate.*ip|ip.*compare|ip.*mismatch' -i -A 3 -B 2

Repository: NOVUS-X/XLMate

Length of output: 513


🏁 Script executed:

# Search for generate_token_with_session to see if IP binding is actually used
rg 'generate_token_with_session|ip_hash' -A 10 -B 2

Repository: NOVUS-X/XLMate

Length of output: 4085


Remove real_ip_remote_addr() or validate it through a trusted proxy.

ConnectionInfo::real_ip_remote_addr() reads the leftmost X-Forwarded-For or Forwarded header without validation. Any direct client can send X-Forwarded-For: 8.8.8.8 and poison the stored ip_address. This is amplified by the incomplete IP binding feature—generate_token_with_session() currently passes ip_hash: None, so the session IP is stored but never validated on refresh/validate, leaving spoofing undetected.

Use peer_addr() for direct deployments, or if behind a proxy, validate that the request's peer_addr matches a configured trusted proxy CIDR before accepting X-Forwarded-For. Also implement consistent IP validation across login, refresh, and validate endpoints (currently missing).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/auth.rs` at line 78, The current use of
req.connection_info().real_ip_remote_addr() can be spoofed; replace it by using
peer_addr() for direct deployments or, if behind a trusted proxy, validate that
peer_addr() is within configured trusted proxy CIDRs before extracting and
trusting X-Forwarded-For, and only then parse the leftmost address; update the
call site that sets ip_address (currently calling
req.connection_info().real_ip_remote_addr()) and ensure
generate_token_with_session() receives a validated ip_hash (not None) so
sessions store a concrete IP hash, and apply the same validation/assignment
logic consistently in the login, refresh, and validate handlers so IP-based
session checks work end-to-end.

let user_agent = req.headers().get("User-Agent")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());

// Create a new session
let session_ttl = env::var("SESSION_TTL_HOURS")
.unwrap_or_else(|_| "168".to_string()) // 7 days default
.parse::<i64>()
.unwrap_or(168);

let session_id = match SessionService::create_session(&db, user_id, ip_address, user_agent, session_ttl).await {
Ok(sid) => sid,
Err(e) => {
log::error!("Failed to create session: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse {
message: "Failed to create session".to_string(),
code: "SESSION_ERROR".to_string(),
});
}
};

// Generate access token with session ID
let access_token = match jwt_service.generate_token_with_session(user_id, &username, Some(session_id.to_string()), None) {
Comment on lines +77 to +101

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ip_hash is never computed — the advertised "IP binding" in JWT is a no-op.

The PR objective explicitly promises "IP binding to mitigate token theft", and Claims now carries ip_hash, but the login handler passes None. Even the ip_address you just extracted on line 78 is not being hashed into the token. No downstream validator can enforce binding if the claim is never populated.

Hash the IP (e.g., SHA-256 with a server-side pepper to avoid exposing raw IPs in logs/tokens) and thread it into generate_token_with_session. You'll also need middleware that cross-checks the incoming request IP's hash against claims.ip_hash — otherwise this remains decorative.

🔧 Proposed fix (login handler)
     // Extract IP address and user agent for session tracking
     let ip_address = req.connection_info().real_ip_remote_addr().map(|s| s.to_string());
+    let ip_hash = ip_address.as_deref().map(|ip| {
+        use sha2::{Digest, Sha256};
+        let pepper = env::var("IP_HASH_PEPPER").unwrap_or_default();
+        let mut hasher = Sha256::new();
+        hasher.update(pepper.as_bytes());
+        hasher.update(ip.as_bytes());
+        format!("{:x}", hasher.finalize())
+    });
     let user_agent = req.headers().get("User-Agent")
         ...

-    let access_token = match jwt_service.generate_token_with_session(user_id, &username, Some(session_id.to_string()), None) {
+    let access_token = match jwt_service.generate_token_with_session(user_id, &username, Some(session_id.to_string()), ip_hash) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/auth.rs` around lines 77 - 101, The login handler
extracts ip_address but never computes or passes ip_hash to the token, so
compute a server-side peppered SHA-256 (e.g., sha256(pepper + ip)) using a
secret from env (e.g., JWT_IP_PEPPER), convert to hex/base64 and pass that value
into jwt_service.generate_token_with_session (where Claims.ip_hash is expected)
instead of None; ensure SessionService::create_session still gets the raw or
stored IP as needed, and implement middleware in your JWT validation path that
recomputes the same peppered hash from
req.connection_info().real_ip_remote_addr() and compares it to claims.ip_hash,
rejecting requests when they differ.

Ok(t) => t,
Err(_) => {
return HttpResponse::InternalServerError().json(ErrorResponse {
Expand Down
2 changes: 2 additions & 0 deletions backend/modules/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ pub mod config;
pub mod server;
pub mod players;
pub mod games;
pub mod pgn_archive;
pub mod pgn_archive_api;

// External modules
extern crate challenge;
Expand Down
223 changes: 223 additions & 0 deletions backend/modules/api/src/pgn_archive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
use serde::{Deserialize, Serialize};
use std::env;
use std::fmt;

/// Errors that can occur during PGN archival
#[derive(Debug)]
pub enum PgnArchiveError {
IpfsError(String),
ArweaveError(String),
SerializationError(String),
NetworkError(String),
}

impl fmt::Display for PgnArchiveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::IpfsError(e) => write!(f, "IPFS error: {}", e),
Self::ArweaveError(e) => write!(f, "Arweave error: {}", e),
Self::SerializationError(e) => write!(f, "Serialization error: {}", e),
Self::NetworkError(e) => write!(f, "Network error: {}", e),
}
}
}

impl std::error::Error for PgnArchiveError {}

/// Response from IPFS pinning
#[derive(Debug, Serialize, Deserialize)]
pub struct IpfsResponse {
#[serde(rename = "IpfsHash")]
pub ipfs_hash: String,
#[serde(rename = "PinSize")]
pub pin_size: usize,
#[serde(rename = "Timestamp")]
pub timestamp: String,
}

/// Response from Arweave upload
#[derive(Debug, Serialize, Deserialize)]
pub struct ArweaveResponse {
pub id: String,
pub status: u16,
}

/// Archive result containing both IPFS and Arweave identifiers
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PgnArchiveResult {
pub game_id: String,
pub ipfs_cid: Option<String>,
pub arweave_tx_id: Option<String>,
pub pgn_hash: String,
pub archived_at: String,
}

/// PGN Archival Service for storing game data on decentralized storage
#[derive(Clone, Debug)]
pub struct PgnArchiveService {
client: reqwest::Client,
}

impl PgnArchiveService {
pub fn new() -> Self {
PgnArchiveService {
client: reqwest::Client::new(),
}
}
Comment on lines +62 to +66

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add HTTP timeouts to the reqwest::Client.

The default reqwest::Client::new() has no connect or request timeout. Because archive_pgn is invoked on the Actix request thread, a slow/hung IPFS or Arweave bundler can block the handler until the TCP stack gives up (minutes), tying up worker threads and degrading the whole API. This is the classic blocking-on-request-thread hazard for external calls.

🛠️ Suggested fix
-impl PgnArchiveService {
-    pub fn new() -> Self {
-        PgnArchiveService {
-            client: reqwest::Client::new(),
-        }
-    }
+impl PgnArchiveService {
+    pub fn new() -> Self {
+        let client = reqwest::Client::builder()
+            .connect_timeout(std::time::Duration::from_secs(5))
+            .timeout(std::time::Duration::from_secs(30))
+            .build()
+            .expect("failed to build reqwest client");
+        PgnArchiveService { client }
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn new() -> Self {
PgnArchiveService {
client: reqwest::Client::new(),
}
}
pub fn new() -> Self {
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("failed to build reqwest client");
PgnArchiveService { client }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/pgn_archive.rs` around lines 62 - 66, The
PgnArchiveService currently creates a reqwest::Client with Client::new() which
has no connect or overall request timeouts; update PgnArchiveService::new to
build the client via reqwest::Client::builder() and set sensible connect_timeout
and timeout values (e.g., a few seconds for connect_timeout and a shorter
overall request timeout) before calling .build(), then assign that client to the
client field so archive_pgn calls cannot block Actix threads indefinitely.


/// Archive PGN to IPFS
pub async fn archive_to_ipfs(&self, pgn_content: &str) -> Result<String, PgnArchiveError> {
let ipfs_url = env::var("IPFS_API_URL")
.unwrap_or_else(|_| "https://api.pinata.cloud/pinning/pinJSONToIPFS".to_string());

let ipfs_api_key = env::var("IPFS_API_KEY")
.unwrap_or_else(|_| "".to_string());

let ipfs_secret_key = env::var("IPFS_SECRET_KEY")
.unwrap_or_else(|_| "".to_string());

let payload = serde_json::json!({
"pinataContent": {
"pgn": pgn_content,
"type": "chess_game",
"archived_at": chrono::Utc::now().to_rfc3339()
},
"pinataMetadata": {
"name": format!("xlmate_game_{}.pgn", chrono::Utc::now().timestamp())
}
});

let response = self.client
.post(&ipfs_url)
.header("Content-Type", "application/json")
.header("pinata_api_key", &ipfs_api_key)
.header("pinata_secret_api_key", &ipfs_secret_key)
.json(&payload)
.send()
.await
.map_err(|e| PgnArchiveError::NetworkError(e.to_string()))?;

if !response.status().is_success() {
return Err(PgnArchiveError::IpfsError(
format!("IPFS upload failed with status: {}", response.status())
));
}

let ipfs_response: IpfsResponse = response
.json()
.await
.map_err(|e| PgnArchiveError::SerializationError(e.to_string()))?;

Ok(ipfs_response.ipfs_hash)
}

/// Archive PGN to Arweave
pub async fn archive_to_arweave(&self, pgn_content: &str) -> Result<String, PgnArchiveError> {
let arweave_url = env::var("ARWEAVE_GATEWAY_URL")
.unwrap_or_else(|_| "https://arweave.net".to_string());

// For simplicity, we're using a bundler service
// In production, you'd use the Arweave SDK with a wallet
let bundler_url = env::var("ARWEAVE_BUNDLER_URL")
.unwrap_or_else(|_| "https://node2.bundlr.network".to_string());

// Calculate SHA-256 hash of PGN for integrity
let pgn_hash = self.calculate_hash(pgn_content);

let payload = serde_json::json!({
"data": pgn_content,
"tags": [
{ "name": "Content-Type", "value": "application/x-chess-pgn" },
{ "name": "App", "value": "XLMate" },
{ "name": "PGN-Hash", "value": &pgn_hash },
{ "name": "Archived-At", "value": &chrono::Utc::now().to_rfc3339() }
]
});

let response = self.client
.post(&format!("{}/tx", bundler_url))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await
.map_err(|e| PgnArchiveError::NetworkError(e.to_string()))?;

if !response.status().is_success() {
return Err(PgnArchiveError::ArweaveError(
format!("Arweave upload failed with status: {}", response.status())
));
}

let arweave_response: ArweaveResponse = response
.json()
.await
.map_err(|e| PgnArchiveError::SerializationError(e.to_string()))?;

Ok(arweave_response.id)
}
Comment on lines +115 to +157

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Bundlr Irys node /tx endpoint request format signed dataitem ANS-104

💡 Result:

The /tx endpoint on Bundlr/Irys nodes (e.g., https://node1.irys.xyz/tx or https://upload.ardrive.io/v1/tx) accepts POST requests with a signed ANS-104 DataItem as the raw binary request body. Request format: - Method: POST - URL: /{token}/tx or /tx or /v1/tx (token-specific path optional) - Headers: Content-Type: application/octet-stream - Body: Raw bytes of the fully signed DataItem (binary format per ANS-104) The signed DataItem is a binary structure: - Signature type (2 bytes) - Signature (variable) - Owner (variable) - Target (32 bytes + presence byte, optional) - Anchor (32 bytes + presence byte, optional) - Number of tags (8 bytes) - Number of tag bytes (8 bytes) - Tags (binary Avro array) - Data (variable binary payload) To create: Use libraries like @irys/bundles or arbundles to build, sign (with owner key), serialize to raw bytes via dataItem.getRaw, then POST as body. Example (from ArConnect docs): fetch(https://upload.ardrive.io/v1/tx, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: dataItem.getRaw, // signed DataItem binary }) Response: Typically JSON with tx ID { id: "txid" }. Nodes verify signature, deduct fees from pre-funded balance, bundle into Arweave tx, return ID.

Citations:


🏁 Script executed:

# Check the file exists and examine the lines in question
head -c 5000 backend/modules/api/src/pgn_archive.rs | tail -c 3000

Repository: NOVUS-X/XLMate

Length of output: 3058


🏁 Script executed:

# Get more context around lines 115-157
sed -n '110,160p' backend/modules/api/src/pgn_archive.rs

Repository: NOVUS-X/XLMate

Length of output: 1924


🏁 Script executed:

# Search for any signing-related code in the file
rg -n "sign|Sign|bundler|ANS-104|dataitem" backend/modules/api/src/pgn_archive.rs

Repository: NOVUS-X/XLMate

Length of output: 235


🏁 Script executed:

# Find the archive_pgn method to see the fallback logic
sed -n '160,220p' backend/modules/api/src/pgn_archive.rs

Repository: NOVUS-X/XLMate

Length of output: 2062


🏁 Script executed:

# Search for where archive_to_arweave is called and how errors are handled
rg -n "archive_to_arweave|Arweave" backend/modules/api/src/pgn_archive.rs -A 3

Repository: NOVUS-X/XLMate

Length of output: 2232


Arweave bundler request is unsigned — will 4xx in production.

Bundlr/Irys /tx endpoint requires a signed ANS-104 DataItem as raw binary (Content-Type: application/octet-stream), not a JSON payload with application/json. The code at lines 121–157 sends an unsigned JSON request that will fail with a 4xx error on any real bundler. The inline comment (lines 119–120) acknowledges this is not production-ready, but the function returns Result<String, PgnArchiveError> as if it succeeds; in practice, the error is caught in archive_pgn() and silently degraded to IPFS-only archival.

Either:

  • Integrate a proper Arweave SDK with wallet signing (e.g., using libraries with ANS-104 support) before enabling this endpoint, or
  • Feature-gate the Arweave path until signing is implemented, so callers are aware the feature is unavailable.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/modules/api/src/pgn_archive.rs` around lines 115 - 157, The
archive_to_arweave function currently posts an unsigned JSON payload to the
bundler /tx endpoint which will 4xx in production; fix by either implementing
proper ANS-104 signing and sending a binary DataItem with Content-Type:
application/octet-stream (use an Arweave/Bundlr SDK to create/sign the DataItem
before POST) inside archive_to_arweave, or feature‑gate the Arweave path (e.g.,
check an env var like ARWEAVE_ENABLED in archive_to_arweave or the caller
archive_pgn) and immediately return a clear PgnArchiveError variant (e.g.,
NotSupported/Disabled) if not implemented; also ensure archive_pgn no longer
silently degrades by propagating the explicit error from archive_to_arweave
instead of hiding it.


/// Archive PGN to both IPFS and Arweave
pub async fn archive_pgn(
&self,
game_id: &str,
pgn_content: &str,
) -> Result<PgnArchiveResult, PgnArchiveError> {
let pgn_hash = self.calculate_hash(pgn_content);

// Archive to both services in parallel
let ipfs_future = self.archive_to_ipfs(pgn_content);
let arweave_future = self.archive_to_arweave(pgn_content);

let (ipfs_result, arweave_result) = tokio::join!(ipfs_future, arweave_future);

let ipfs_cid = match ipfs_result {
Ok(cid) => {
log::info!("PGN archived to IPFS: {}", cid);
Some(cid)
}
Err(e) => {
log::warn!("Failed to archive to IPFS: {}", e);
None
}
};

let arweave_tx_id = match arweave_result {
Ok(tx_id) => {
log::info!("PGN archived to Arweave: {}", tx_id);
Some(tx_id)
}
Err(e) => {
log::warn!("Failed to archive to Arweave: {}", e);
None
}
};

if ipfs_cid.is_none() && arweave_tx_id.is_none() {
return Err(PgnArchiveError::NetworkError(
"Failed to archive PGN to any storage service".to_string()
));
}

Ok(PgnArchiveResult {
game_id: game_id.to_string(),
ipfs_cid,
arweave_tx_id,
pgn_hash,
archived_at: chrono::Utc::now().to_rfc3339(),
})
}

/// Calculate SHA-256 hash of PGN content
fn calculate_hash(&self, content: &str) -> String {
use sha2::{Sha256, Digest};
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
hex::encode(hasher.finalize())
}

/// Verify PGN integrity by comparing hash
pub fn verify_pgn_integrity(&self, pgn_content: &str, expected_hash: &str) -> bool {
let actual_hash = self.calculate_hash(pgn_content);
actual_hash == expected_hash
}
}
Loading
Loading