diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 4f0c00c..bf84288 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "batch-operations", "arenax-events", "cross-contract-utils", "oracle-integration", diff --git a/contracts/batch-operations/Cargo.toml b/contracts/batch-operations/Cargo.toml new file mode 100644 index 0000000..2988cd1 --- /dev/null +++ b/contracts/batch-operations/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "batch-operations" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "ArenaX Batch Operations — gas-optimized bulk execution for token transfers, tournament registrations, reputation updates, and NFT minting" + +[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/batch-operations/src/lib.rs b/contracts/batch-operations/src/lib.rs new file mode 100644 index 0000000..246b698 --- /dev/null +++ b/contracts/batch-operations/src/lib.rs @@ -0,0 +1,538 @@ +#![no_std] + +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Vec}; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/// Maximum items allowed in a single batch call. +/// Prevents out-of-gas / DoS exploits from unbounded loops. +pub const MAX_BATCH_SIZE: u32 = 100; + +// ─── Errors ─────────────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum BatchError { + /// Contract has not been initialized yet. + NotInitialized = 1, + /// Caller is not the admin. + Unauthorized = 2, + /// Input vectors have mismatched lengths. + LengthMismatch = 3, + /// Empty batch — nothing to do. + EmptyBatch = 4, + /// Batch exceeds MAX_BATCH_SIZE. + BatchTooLarge = 5, + /// A token amount is zero or negative. + InvalidAmount = 6, + /// A token transfer failed (insufficient sender balance). + InsufficientBalance = 7, + /// Already initialized. + AlreadyInitialized = 8, + /// Player not registered (used in reputation batches). + PlayerNotFound = 9, + /// Tournament ID is invalid / not open for registration. + InvalidTournament = 10, + /// Player is already registered for a tournament. + AlreadyRegistered = 11, + /// Achievement ID is out of valid range (0–63). + InvalidAchievementId = 12, + /// Achievement already unlocked for this player. + AchievementAlreadyUnlocked = 13, + /// Invalid reputation delta (must be non-zero). + InvalidDelta = 14, +} + +// ─── Storage Keys ───────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Admin, + /// Token balance for an address. + Balance(Address), + /// Total token supply. + TotalSupply, + /// Player reputation score. + Reputation(Address), + /// Whether a player is registered for a tournament. + TournamentRegistration(Address, u32), + /// Achievement bitmask for a player (u64, supports 0–63). + AchievementMask(Address), + /// NFT owner mapping (token_id → owner). + NftOwner(u32), + /// Total NFTs minted. + NftCount, +} + +// ─── Result types for partial-success reporting ─────────────────────────────── + +/// Per-item result used in partial-result batch operations. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ItemResult { + /// 0-based index within the batch. + pub index: u32, + /// true = success, false = failure. + pub success: bool, + /// Error code on failure (0 when success = true). + pub error_code: u32, +} + +// ─── Contract ───────────────────────────────────────────────────────────────── + +#[contract] +pub struct BatchOperations; + +#[contractimpl] +impl BatchOperations { + // ── Initialization ───────────────────────────────────────────────────── + + pub fn initialize(env: Env, admin: Address) -> Result<(), BatchError> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(BatchError::AlreadyInitialized); + } + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::TotalSupply, &0i128); + env.storage() + .instance() + .set(&DataKey::NftCount, &0u32); + Ok(()) + } + + // ── View helpers ─────────────────────────────────────────────────────── + + pub fn balance(env: Env, addr: Address) -> i128 { + env.storage() + .instance() + .get(&DataKey::Balance(addr)) + .unwrap_or(0i128) + } + + pub fn total_supply(env: Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::TotalSupply) + .unwrap_or(0i128) + } + + pub fn reputation(env: Env, player: Address) -> i128 { + env.storage() + .instance() + .get(&DataKey::Reputation(player)) + .unwrap_or(0i128) + } + + pub fn is_registered(env: Env, player: Address, tournament_id: u32) -> bool { + env.storage() + .instance() + .get(&DataKey::TournamentRegistration(player, tournament_id)) + .unwrap_or(false) + } + + pub fn achievement_mask(env: Env, player: Address) -> u64 { + env.storage() + .instance() + .get(&DataKey::AchievementMask(player)) + .unwrap_or(0u64) + } + + pub fn nft_owner(env: Env, token_id: u32) -> Option
{ + env.storage() + .instance() + .get(&DataKey::NftOwner(token_id)) + } + + pub fn nft_count(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::NftCount) + .unwrap_or(0u32) + } + + // ── 1. batch_transfer ────────────────────────────────────────────────── + // + // ATOMIC: entire batch reverts if any transfer fails. + // Gas optimization: sender balance read once, decremented cumulatively; + // recipient reads batched per unique address via single pass. + // + /// Transfer tokens from `from` to multiple recipients atomically. + /// `recipients` and `amounts` must be the same length. + pub fn batch_transfer( + env: Env, + from: Address, + recipients: Vec
, + amounts: Vec, + ) -> Result<(), BatchError> { + Self::require_initialized(&env)?; + from.require_auth(); + + let n = recipients.len(); + Self::validate_batch(n, amounts.len())?; + + // Cache sender balance once — avoids repeated storage reads in the loop. + let mut from_balance: i128 = env + .storage() + .instance() + .get(&DataKey::Balance(from.clone())) + .unwrap_or(0); + + // Validate all amounts and total deduction before mutating any state. + let mut total_deduction: i128 = 0; + for i in 0..n { + let amt = amounts.get(i).unwrap(); + if amt <= 0 { + return Err(BatchError::InvalidAmount); + } + total_deduction = total_deduction + .checked_add(amt) + .ok_or(BatchError::InvalidAmount)?; + } + if from_balance < total_deduction { + return Err(BatchError::InsufficientBalance); + } + + // Apply all transfers atomically. + from_balance -= total_deduction; + for i in 0..n { + let to = recipients.get(i).unwrap(); + let amt = amounts.get(i).unwrap(); + + // Skip self-transfers without aborting (balance math is already correct). + if to == from { + continue; + } + + let to_balance: i128 = env + .storage() + .instance() + .get(&DataKey::Balance(to.clone())) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::Balance(to), &(to_balance + amt)); + } + env.storage() + .instance() + .set(&DataKey::Balance(from), &from_balance); + + Ok(()) + } + + // ── 2. batch_mint ────────────────────────────────────────────────────── + // + // ATOMIC: admin mints tokens to multiple recipients in one call. + // Gas optimization: total_supply updated once after loop. + // + /// Mint tokens to multiple recipients atomically. + pub fn batch_mint( + env: Env, + recipients: Vec
, + amounts: Vec, + ) -> Result<(), BatchError> { + Self::require_initialized(&env)?; + Self::require_admin(&env)?; + + let n = recipients.len(); + Self::validate_batch(n, amounts.len())?; + + // Validate all amounts up front (fail-fast, no partial state). + for i in 0..n { + if amounts.get(i).unwrap() <= 0 { + return Err(BatchError::InvalidAmount); + } + } + + // Cache total_supply once — single read, single write. + let mut supply: i128 = env + .storage() + .instance() + .get(&DataKey::TotalSupply) + .unwrap_or(0); + + for i in 0..n { + let to = recipients.get(i).unwrap(); + let amt = amounts.get(i).unwrap(); + + let bal: i128 = env + .storage() + .instance() + .get(&DataKey::Balance(to.clone())) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::Balance(to), &(bal + amt)); + supply += amt; + } + + // Single write for supply — avoids n storage writes. + env.storage() + .instance() + .set(&DataKey::TotalSupply, &supply); + + Ok(()) + } + + // ── 3. batch_register_tournaments ───────────────────────────────────── + // + // PARTIAL-RESULT: each item is attempted independently. + // Caller receives per-item success/error codes so upstream can retry + // individual failures without losing successful registrations. + // + /// Register `player` for multiple tournaments. + /// Returns per-item results (partial success is allowed). + pub fn batch_register_tournaments( + env: Env, + player: Address, + tournament_ids: Vec, + ) -> Result, BatchError> { + Self::require_initialized(&env)?; + player.require_auth(); + + let n = tournament_ids.len(); + if n == 0 { + return Err(BatchError::EmptyBatch); + } + if n > MAX_BATCH_SIZE { + return Err(BatchError::BatchTooLarge); + } + + let mut results: Vec = Vec::new(&env); + + for i in 0..n { + let tid = tournament_ids.get(i).unwrap(); + + let already: bool = env + .storage() + .instance() + .get(&DataKey::TournamentRegistration(player.clone(), tid)) + .unwrap_or(false); + + if already { + results.push_back(ItemResult { + index: i, + success: false, + error_code: BatchError::AlreadyRegistered as u32, + }); + continue; + } + + env.storage() + .instance() + .set(&DataKey::TournamentRegistration(player.clone(), tid), &true); + + results.push_back(ItemResult { + index: i, + success: true, + error_code: 0, + }); + } + + Ok(results) + } + + // ── 4. batch_update_reputation ───────────────────────────────────────── + // + // ATOMIC: all reputation updates applied or none. + // Gas optimization: each player's score loaded and written once via + // pre-validated iteration; no redundant storage round-trips. + // + /// Apply reputation deltas to multiple players atomically. + /// `players` and `deltas` must have the same length. + /// Positive delta = increase, negative = decrease. + pub fn batch_update_reputation( + env: Env, + players: Vec
, + deltas: Vec, + ) -> Result<(), BatchError> { + Self::require_initialized(&env)?; + Self::require_admin(&env)?; + + let n = players.len(); + Self::validate_batch(n, deltas.len())?; + + // Validate all deltas before writing (full atomicity). + for i in 0..n { + if deltas.get(i).unwrap() == 0 { + return Err(BatchError::InvalidDelta); + } + } + + for i in 0..n { + let player = players.get(i).unwrap(); + let delta = deltas.get(i).unwrap(); + + let current: i128 = env + .storage() + .instance() + .get(&DataKey::Reputation(player.clone())) + .unwrap_or(0); + + let new_score = current.saturating_add(delta).max(0); + env.storage() + .instance() + .set(&DataKey::Reputation(player), &new_score); + } + + Ok(()) + } + + // ── 5. batch_unlock_achievements ────────────────────────────────────── + // + // PARTIAL-RESULT: unlocks achievements for a single player. + // Uses a bitmask to collapse N storage reads into 1 read + 1 write. + // Each bit position (0–63) corresponds to an achievement ID. + // + /// Unlock multiple achievements for a single player using bitmask optimization. + /// Returns per-item results (already-unlocked items marked as failed, not reverted). + pub fn batch_unlock_achievements( + env: Env, + player: Address, + achievement_ids: Vec, + ) -> Result, BatchError> { + Self::require_initialized(&env)?; + Self::require_admin(&env)?; + + let n = achievement_ids.len(); + if n == 0 { + return Err(BatchError::EmptyBatch); + } + if n > MAX_BATCH_SIZE { + return Err(BatchError::BatchTooLarge); + } + + // Single storage read for the entire achievement set. + let mut mask: u64 = env + .storage() + .instance() + .get(&DataKey::AchievementMask(player.clone())) + .unwrap_or(0u64); + + let mut results: Vec = Vec::new(&env); + + for i in 0..n { + let aid = achievement_ids.get(i).unwrap(); + + if aid > 63 { + results.push_back(ItemResult { + index: i, + success: false, + error_code: BatchError::InvalidAchievementId as u32, + }); + continue; + } + + let bit = 1u64 << aid; + if mask & bit != 0 { + results.push_back(ItemResult { + index: i, + success: false, + error_code: BatchError::AchievementAlreadyUnlocked as u32, + }); + continue; + } + + mask |= bit; + results.push_back(ItemResult { + index: i, + success: true, + error_code: 0, + }); + } + + // Single storage write — regardless of how many achievements were unlocked. + env.storage() + .instance() + .set(&DataKey::AchievementMask(player), &mask); + + Ok(results) + } + + // ── 6. batch_mint_nft ───────────────────────────────────────────────── + // + // ATOMIC: mint multiple NFTs to their respective owners. + // Gas optimization: NftCount loaded once, incremented in-memory, written once. + // + /// Mint NFTs to multiple owners atomically. + pub fn batch_mint_nft( + env: Env, + owners: Vec
, + ) -> Result, BatchError> { + Self::require_initialized(&env)?; + Self::require_admin(&env)?; + + let n = owners.len(); + if n == 0 { + return Err(BatchError::EmptyBatch); + } + if n > MAX_BATCH_SIZE { + return Err(BatchError::BatchTooLarge); + } + + // Load count once. + let mut next_id: u32 = env + .storage() + .instance() + .get(&DataKey::NftCount) + .unwrap_or(0u32); + + let mut minted_ids: Vec = Vec::new(&env); + + for i in 0..n { + let owner = owners.get(i).unwrap(); + env.storage() + .instance() + .set(&DataKey::NftOwner(next_id), &owner); + minted_ids.push_back(next_id); + next_id += 1; + } + + // Single write for the updated count. + env.storage() + .instance() + .set(&DataKey::NftCount, &next_id); + + Ok(minted_ids) + } + + // ─── Private helpers ────────────────────────────────────────────────── + + fn require_initialized(env: &Env) -> Result<(), BatchError> { + if !env.storage().instance().has(&DataKey::Admin) { + return Err(BatchError::NotInitialized); + } + Ok(()) + } + + fn require_admin(env: &Env) -> Result<(), BatchError> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(BatchError::NotInitialized)?; + admin.require_auth(); + Ok(()) + } + + /// Validate that both lengths are equal, non-zero, and within MAX_BATCH_SIZE. + fn validate_batch(len_a: u32, len_b: u32) -> Result<(), BatchError> { + if len_a == 0 { + return Err(BatchError::EmptyBatch); + } + if len_a != len_b { + return Err(BatchError::LengthMismatch); + } + if len_a > MAX_BATCH_SIZE { + return Err(BatchError::BatchTooLarge); + } + Ok(()) + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod test; diff --git a/contracts/batch-operations/src/test.rs b/contracts/batch-operations/src/test.rs new file mode 100644 index 0000000..aaf07d7 --- /dev/null +++ b/contracts/batch-operations/src/test.rs @@ -0,0 +1,559 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +fn setup() -> (Env, Address, Address) { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = env.register(BatchOperations, ()); + let client = BatchOperationsClient::new(&env, &contract_id); + env.mock_all_auths(); + client.initialize(&admin); + (env, contract_id, admin) +} + +fn client<'a>(env: &'a Env, contract_id: &'a Address) -> BatchOperationsClient<'a> { + BatchOperationsClient::new(env, contract_id) +} + +fn vec_addresses(env: &Env, n: usize) -> soroban_sdk::Vec
{ + let mut v = soroban_sdk::Vec::new(env); + for _ in 0..n { + v.push_back(Address::generate(env)); + } + v +} + +fn vec_i128(env: &Env, vals: &[i128]) -> soroban_sdk::Vec { + let mut v = soroban_sdk::Vec::new(env); + for &x in vals { + v.push_back(x); + } + v +} + +fn vec_u32(env: &Env, vals: &[u32]) -> soroban_sdk::Vec { + let mut v = soroban_sdk::Vec::new(env); + for &x in vals { + v.push_back(x); + } + v +} + +/// Mint tokens to a fresh address and return it. +fn funded_sender(env: &Env, contract_id: &Address, amount: i128) -> Address { + let c = client(env, contract_id); + let sender = Address::generate(env); + let mut r = soroban_sdk::Vec::new(env); + r.push_back(sender.clone()); + let mut a = soroban_sdk::Vec::new(env); + a.push_back(amount); + c.batch_mint(&r, &a); + sender +} + +// ─── Initialize ─────────────────────────────────────────────────────────────── + +#[test] +fn test_initialize() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + assert_eq!(c.total_supply(), 0); + assert_eq!(c.nft_count(), 0); +} + +#[test] +fn test_double_initialize_fails() { + let (env, contract_id, admin) = setup(); + let c = client(&env, &contract_id); + assert_eq!( + c.try_initialize(&admin), + Err(Ok(BatchError::AlreadyInitialized)) + ); +} + +#[test] +fn test_calls_without_initialize_fail() { + let env = Env::default(); + let contract_id = env.register(BatchOperations, ()); + let c = client(&env, &contract_id); + env.mock_all_auths(); + let r = vec_addresses(&env, 1); + let a = vec_i128(&env, &[100]); + assert_eq!( + c.try_batch_mint(&r, &a), + Err(Ok(BatchError::NotInitialized)) + ); +} + +// ─── batch_mint ─────────────────────────────────────────────────────────────── + +#[test] +fn test_batch_mint_basic() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let recipients = { + let mut v = soroban_sdk::Vec::new(&env); + v.push_back(r1.clone()); + v.push_back(r2.clone()); + v + }; + let amounts = vec_i128(&env, &[500, 300]); + c.batch_mint(&recipients, &amounts); + assert_eq!(c.balance(&r1), 500); + assert_eq!(c.balance(&r2), 300); + assert_eq!(c.total_supply(), 800); +} + +#[test] +fn test_batch_mint_empty_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let empty_addr: soroban_sdk::Vec
= soroban_sdk::Vec::new(&env); + let empty_amt: soroban_sdk::Vec = soroban_sdk::Vec::new(&env); + assert_eq!( + c.try_batch_mint(&empty_addr, &empty_amt), + Err(Ok(BatchError::EmptyBatch)) + ); +} + +#[test] +fn test_batch_mint_length_mismatch_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let r = vec_addresses(&env, 2); + let a = vec_i128(&env, &[100]); + assert_eq!( + c.try_batch_mint(&r, &a), + Err(Ok(BatchError::LengthMismatch)) + ); +} + +#[test] +fn test_batch_mint_zero_amount_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let r = vec_addresses(&env, 2); + let a = vec_i128(&env, &[100, 0]); + assert_eq!( + c.try_batch_mint(&r, &a), + Err(Ok(BatchError::InvalidAmount)) + ); +} + +#[test] +fn test_batch_mint_negative_amount_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let r = vec_addresses(&env, 1); + let a = vec_i128(&env, &[-50]); + assert_eq!( + c.try_batch_mint(&r, &a), + Err(Ok(BatchError::InvalidAmount)) + ); +} + +#[test] +fn test_batch_mint_exceeds_max_size_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let n = (MAX_BATCH_SIZE + 1) as usize; + let r = vec_addresses(&env, n); + let amounts = { + let mut v = soroban_sdk::Vec::new(&env); + for _ in 0..n { + v.push_back(1i128); + } + v + }; + assert_eq!( + c.try_batch_mint(&r, &amounts), + Err(Ok(BatchError::BatchTooLarge)) + ); +} + +// ─── batch_transfer ─────────────────────────────────────────────────────────── + +#[test] +fn test_batch_transfer_basic() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let sender = funded_sender(&env, &contract_id, 1000); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let recipients = { + let mut v = soroban_sdk::Vec::new(&env); + v.push_back(r1.clone()); + v.push_back(r2.clone()); + v + }; + let amounts = vec_i128(&env, &[300, 200]); + c.batch_transfer(&sender, &recipients, &amounts); + assert_eq!(c.balance(&sender), 500); + assert_eq!(c.balance(&r1), 300); + assert_eq!(c.balance(&r2), 200); +} + +#[test] +fn test_batch_transfer_insufficient_balance_atomic_rollback() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let sender = funded_sender(&env, &contract_id, 100); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let recipients = { + let mut v = soroban_sdk::Vec::new(&env); + v.push_back(r1.clone()); + v.push_back(r2.clone()); + v + }; + // Total 150 > 100 — entire batch must fail atomically. + let amounts = vec_i128(&env, &[80, 70]); + assert_eq!( + c.try_batch_transfer(&sender, &recipients, &amounts), + Err(Ok(BatchError::InsufficientBalance)) + ); + // State must be unchanged. + assert_eq!(c.balance(&sender), 100); + assert_eq!(c.balance(&r1), 0); + assert_eq!(c.balance(&r2), 0); +} + +#[test] +fn test_batch_transfer_length_mismatch_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let sender = funded_sender(&env, &contract_id, 500); + let r = vec_addresses(&env, 2); + let a = vec_i128(&env, &[100]); + assert_eq!( + c.try_batch_transfer(&sender, &r, &a), + Err(Ok(BatchError::LengthMismatch)) + ); +} + +#[test] +fn test_batch_transfer_zero_amount_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let sender = funded_sender(&env, &contract_id, 500); + let r = vec_addresses(&env, 1); + let a = vec_i128(&env, &[0]); + assert_eq!( + c.try_batch_transfer(&sender, &r, &a), + Err(Ok(BatchError::InvalidAmount)) + ); +} + +#[test] +fn test_batch_transfer_exceeds_max_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let sender = funded_sender(&env, &contract_id, 100_000); + let n = (MAX_BATCH_SIZE + 1) as usize; + let r = vec_addresses(&env, n); + let amounts = { + let mut v = soroban_sdk::Vec::new(&env); + for _ in 0..n { + v.push_back(1i128); + } + v + }; + assert_eq!( + c.try_batch_transfer(&sender, &r, &amounts), + Err(Ok(BatchError::BatchTooLarge)) + ); +} + +// ─── batch_register_tournaments ────────────────────────────────────────────── + +#[test] +fn test_batch_register_tournaments_basic() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + let ids = vec_u32(&env, &[1, 2, 3]); + let results = c.batch_register_tournaments(&player, &ids); + assert_eq!(results.len(), 3); + for i in 0..3u32 { + let r = results.get(i).unwrap(); + assert!(r.success); + assert_eq!(r.error_code, 0); + } + assert!(c.is_registered(&player, &1)); + assert!(c.is_registered(&player, &2)); + assert!(c.is_registered(&player, &3)); +} + +#[test] +fn test_batch_register_duplicate_partial_result() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + // First registration. + c.batch_register_tournaments(&player, &vec_u32(&env, &[5])); + // Re-registering 5 alongside new id 6. + let ids = vec_u32(&env, &[5, 6]); + let results = c.batch_register_tournaments(&player, &ids); + assert_eq!(results.len(), 2); + let r0 = results.get(0).unwrap(); + let r1 = results.get(1).unwrap(); + assert!(!r0.success); + assert_eq!(r0.error_code, BatchError::AlreadyRegistered as u32); + assert!(r1.success); + assert!(c.is_registered(&player, &6)); +} + +#[test] +fn test_batch_register_empty_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + let empty: soroban_sdk::Vec = soroban_sdk::Vec::new(&env); + assert_eq!( + c.try_batch_register_tournaments(&player, &empty), + Err(Ok(BatchError::EmptyBatch)) + ); +} + +#[test] +fn test_batch_register_exceeds_max_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + let ids = { + let mut v = soroban_sdk::Vec::new(&env); + for i in 0..(MAX_BATCH_SIZE + 1) { + v.push_back(i); + } + v + }; + assert_eq!( + c.try_batch_register_tournaments(&player, &ids), + Err(Ok(BatchError::BatchTooLarge)) + ); +} + +// ─── batch_update_reputation ───────────────────────────────────────────────── + +#[test] +fn test_batch_update_reputation_basic() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let p1 = Address::generate(&env); + let p2 = Address::generate(&env); + let players = { + let mut v = soroban_sdk::Vec::new(&env); + v.push_back(p1.clone()); + v.push_back(p2.clone()); + v + }; + let deltas = vec_i128(&env, &[100, -30]); + c.batch_update_reputation(&players, &deltas); + assert_eq!(c.reputation(&p1), 100); + assert_eq!(c.reputation(&p2), 0); // clamped at 0 +} + +#[test] +fn test_batch_update_reputation_atomic_zero_delta_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let p1 = Address::generate(&env); + let p2 = Address::generate(&env); + let players = { + let mut v = soroban_sdk::Vec::new(&env); + v.push_back(p1.clone()); + v.push_back(p2.clone()); + v + }; + // Zero delta on p2 — entire batch must fail. + let deltas = vec_i128(&env, &[50, 0]); + assert_eq!( + c.try_batch_update_reputation(&players, &deltas), + Err(Ok(BatchError::InvalidDelta)) + ); + // p1 must not have been updated (atomic rollback). + assert_eq!(c.reputation(&p1), 0); +} + +#[test] +fn test_batch_update_reputation_length_mismatch_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let players = vec_addresses(&env, 2); + let deltas = vec_i128(&env, &[10]); + assert_eq!( + c.try_batch_update_reputation(&players, &deltas), + Err(Ok(BatchError::LengthMismatch)) + ); +} + +#[test] +fn test_batch_update_reputation_negative_clamps_to_zero() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let p = Address::generate(&env); + let players = { + let mut v = soroban_sdk::Vec::new(&env); + v.push_back(p.clone()); + v + }; + c.batch_update_reputation(&players, &vec_i128(&env, &[-999])); + assert_eq!(c.reputation(&p), 0); +} + +// ─── batch_unlock_achievements ─────────────────────────────────────────────── + +#[test] +fn test_batch_unlock_achievements_basic() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + let ids = vec_u32(&env, &[0, 5, 63]); + let results = c.batch_unlock_achievements(&player, &ids); + assert_eq!(results.len(), 3); + for i in 0..3u32 { + assert!(results.get(i).unwrap().success); + } + let mask = c.achievement_mask(&player); + assert_ne!(mask & (1u64 << 0), 0); + assert_ne!(mask & (1u64 << 5), 0); + assert_ne!(mask & (1u64 << 63), 0); +} + +#[test] +fn test_batch_unlock_achievements_duplicate_partial() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + c.batch_unlock_achievements(&player, &vec_u32(&env, &[3])); + let ids = vec_u32(&env, &[3, 7]); + let results = c.batch_unlock_achievements(&player, &ids); + let r0 = results.get(0).unwrap(); + let r1 = results.get(1).unwrap(); + assert!(!r0.success); + assert_eq!(r0.error_code, BatchError::AchievementAlreadyUnlocked as u32); + assert!(r1.success); +} + +#[test] +fn test_batch_unlock_achievements_out_of_range_partial() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + // ID 64 is out of range (max is 63). + let ids = vec_u32(&env, &[1, 64]); + let results = c.batch_unlock_achievements(&player, &ids); + assert!(results.get(0).unwrap().success); + let r1 = results.get(1).unwrap(); + assert!(!r1.success); + assert_eq!(r1.error_code, BatchError::InvalidAchievementId as u32); +} + +#[test] +fn test_batch_unlock_achievements_bitmask_collapsed() { + // 10 achievements → still a single u64 mask value stored. + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + let ids = vec_u32(&env, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + c.batch_unlock_achievements(&player, &ids); + let mask = c.achievement_mask(&player); + assert_eq!(mask, 0b11_1111_1111u64); // bits 0-9 set +} + +#[test] +fn test_batch_unlock_achievements_empty_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let player = Address::generate(&env); + let empty: soroban_sdk::Vec = soroban_sdk::Vec::new(&env); + assert_eq!( + c.try_batch_unlock_achievements(&player, &empty), + Err(Ok(BatchError::EmptyBatch)) + ); +} + +// ─── batch_mint_nft ─────────────────────────────────────────────────────────── + +#[test] +fn test_batch_mint_nft_basic() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let o1 = Address::generate(&env); + let o2 = Address::generate(&env); + let owners = { + let mut v = soroban_sdk::Vec::new(&env); + v.push_back(o1.clone()); + v.push_back(o2.clone()); + v + }; + let ids = c.batch_mint_nft(&owners); + assert_eq!(ids.len(), 2); + assert_eq!(ids.get(0).unwrap(), 0); + assert_eq!(ids.get(1).unwrap(), 1); + assert_eq!(c.nft_count(), 2); + assert_eq!(c.nft_owner(&0).unwrap(), o1); + assert_eq!(c.nft_owner(&1).unwrap(), o2); +} + +#[test] +fn test_batch_mint_nft_sequential_ids() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let owners1 = vec_addresses(&env, 3); + let owners2 = vec_addresses(&env, 2); + let ids1 = c.batch_mint_nft(&owners1); + let ids2 = c.batch_mint_nft(&owners2); + assert_eq!(ids1.get(0).unwrap(), 0); + assert_eq!(ids1.get(2).unwrap(), 2); + assert_eq!(ids2.get(0).unwrap(), 3); + assert_eq!(ids2.get(1).unwrap(), 4); + assert_eq!(c.nft_count(), 5); +} + +#[test] +fn test_batch_mint_nft_empty_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let empty: soroban_sdk::Vec
= soroban_sdk::Vec::new(&env); + assert_eq!( + c.try_batch_mint_nft(&empty), + Err(Ok(BatchError::EmptyBatch)) + ); +} + +#[test] +fn test_batch_mint_nft_exceeds_max_fails() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let owners = vec_addresses(&env, (MAX_BATCH_SIZE + 1) as usize); + assert_eq!( + c.try_batch_mint_nft(&owners), + Err(Ok(BatchError::BatchTooLarge)) + ); +} + +// ─── MAX_BATCH_SIZE boundary ────────────────────────────────────────────────── + +#[test] +fn test_exact_max_batch_size_succeeds() { + let (env, contract_id, _) = setup(); + let c = client(&env, &contract_id); + let n = MAX_BATCH_SIZE as usize; + let recipients = vec_addresses(&env, n); + let amounts = { + let mut v = soroban_sdk::Vec::new(&env); + for _ in 0..n { + v.push_back(1i128); + } + v + }; + c.batch_mint(&recipients, &amounts); + assert_eq!(c.total_supply(), MAX_BATCH_SIZE as i128); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index c9f5a53..9e74a1f 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -9,6 +9,8 @@ import { TxStatusProvider } from "@/hooks/useTxStatus"; import { WalletProvider } from "@/hooks/useWallet"; import { NotificationProvider } from "@/contexts/NotificationContext"; import { WebVitalsInit } from "@/components/providers/WebVitalsInit"; +import { AnalyticsProvider } from "@/components/providers/AnalyticsProvider"; +import { ConsentBanner } from "@/components/providers/ConsentBanner"; export const metadata: Metadata = { diff --git a/server/src/app.ts b/server/src/app.ts index 09703c9..0daff70 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -4,7 +4,7 @@ import helmet from 'helmet'; import passport from 'passport'; import { configurePassport } from './middleware/auth.middleware'; import { errorHandler } from './middleware/error.middleware'; -import { requestIdMiddleware } from './middleware/request-id.middleware'; +import { correlationMiddleware } from './middleware/correlation.middleware'; import { metricsMiddleware } from './middleware/metrics.middleware'; import routes from './routes/index'; import { getEnv } from './config/env'; @@ -78,7 +78,7 @@ export const createApp = (): Express => { }) ); app.use(express.json()); - app.use(requestIdMiddleware); + app.use(correlationMiddleware); app.use(passport.initialize()); app.use(metricsMiddleware); app.use('/api', routes); diff --git a/server/src/config/database.ts b/server/src/config/database.ts index b71112f..0848a47 100644 --- a/server/src/config/database.ts +++ b/server/src/config/database.ts @@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client'; import { logger } from '../services/logger.service'; import { metricsService } from '../services/metrics.service'; import { getEnv } from './env'; +import { getCorrelationId } from '../services/correlation.service'; // Database connection pool configuration — read from the validated env singleton. const buildPoolConfig = () => { @@ -55,6 +56,13 @@ prisma.$use(async (params: Prisma.MiddlewareParams, next: (params: Prisma.Middle const before = Date.now(); connectionCount++; + // Decorate raw queries with a correlation_id comment for cross-DB auditing. + // e.g. SELECT 1 /* correlation_id: abc-123 */ + const correlationId = getCorrelationId(); + if (correlationId && params.args && typeof params.args === 'object' && 'query' in params.args && typeof params.args.query === 'string') { + params.args.query = `${params.args.query} /* correlation_id: ${correlationId} */`; + } + const model = params.model ?? 'unknown'; const action = params.action; diff --git a/server/src/middleware/correlation.middleware.ts b/server/src/middleware/correlation.middleware.ts new file mode 100644 index 0000000..aeadcf5 --- /dev/null +++ b/server/src/middleware/correlation.middleware.ts @@ -0,0 +1,64 @@ +/** + * correlation.middleware.ts + * + * Ingress correlation-ID middleware. + * + * For every inbound HTTP request: + * 1. Reads `X-Correlation-ID` or `traceparent` header; generates a + * UUIDv4 if neither is present. + * 2. Binds the ID to the AsyncLocalStorage context so it flows through + * every downstream `await` / callback without manual passing. + * 3. Attaches `req.correlationId` and a correlation-scoped child logger + * `req.log` for controller-level use. + * 4. Echoes the ID back on the response as `X-Correlation-ID`. + */ + +import { randomUUID } from 'node:crypto'; +import type { NextFunction, Request, Response } from 'express'; +import { logger } from '../services/logger.service'; +import { correlationStore } from '../services/correlation.service'; + +/** Header names accepted as incoming correlation carrier. */ +const INCOMING_HEADERS = ['x-correlation-id', 'x-request-id', 'traceparent'] as const; + +const extractIncoming = (req: Request): string | undefined => { + for (const name of INCOMING_HEADERS) { + const val = req.header(name); + if (val?.trim()) return val.trim(); + } + return undefined; +}; + +export const correlationMiddleware = (req: Request, res: Response, next: NextFunction): void => { + const correlationId = extractIncoming(req) ?? randomUUID(); + + // Echo back on response. + res.setHeader('X-Correlation-ID', correlationId); + + // Attach to request object for backward compat with existing code that + // already reads `req.requestId` / `req.log`. + req.requestId = correlationId; + req.correlationId = correlationId; + req.log = logger.child({ correlation_id: correlationId }); + + const startTime = Date.now(); + + req.log.info('Request started', { + method: req.method, + path: req.originalUrl, + ip: req.ip, + }); + + res.on('finish', () => { + req.log.info('Request completed', { + method: req.method, + path: req.originalUrl, + statusCode: res.statusCode, + durationMs: Date.now() - startTime, + }); + }); + + // Run the rest of the middleware / handler chain inside the async store + // so getCorrelationId() returns the right value everywhere downstream. + correlationStore.run({ correlationId }, next); +}; diff --git a/server/src/server.ts b/server/src/server.ts index e7b8e66..4fb0c97 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -21,7 +21,7 @@ import { startHealthMonitor } from './services/health.service'; import { Server as SocketIOServer } from 'socket.io'; import { initGameSocket } from './websockets/game.socket'; import { MaintenanceService } from './services/maintenance.service'; -import { getDatabaseClient } from './services/database.service'; +import { getDatabaseClient, warmPool, startPoolHealthCheck, drainPool } from './services/database.service'; import eventMonitoringService from './services/event-monitoring.service'; const nodeEnv = process.env.NODE_ENV ?? 'development'; @@ -121,8 +121,10 @@ const gracefulShutdown = (signal: string) => { process.exit(1); } - logger.info('Graceful shutdown completed'); - process.exit(0); + drainPool().finally(() => { + logger.info('Graceful shutdown completed'); + process.exit(0); + }); }); setTimeout(() => { @@ -153,7 +155,9 @@ const waitForDatabase = async ( if (env.NODE_ENV !== 'test') { waitForDatabase() + .then(() => warmPool()) .then(() => { + startPoolHealthCheck({ healthCheckIntervalMs: env.HEALTH_CHECK_INTERVAL_MS }); server = app.listen(port, () => { logger.info('Server started', { url: `http://localhost:${port}`, diff --git a/server/src/services/correlation.service.ts b/server/src/services/correlation.service.ts new file mode 100644 index 0000000..18209a8 --- /dev/null +++ b/server/src/services/correlation.service.ts @@ -0,0 +1,25 @@ +/** + * correlation.service.ts + * + * Async-context store for request correlation IDs. + * + * AsyncLocalStorage propagates the store automatically through every + * `await`, callback, and Promise continuation that runs within the + * same async context — no manual variable passing required. + * + * Usage: + * - Middleware: `correlationStore.run({ correlationId }, next)` + * - Anywhere: `getCorrelationId()` → returns the active ID or undefined + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; + +export interface CorrelationContext { + correlationId: string; +} + +export const correlationStore = new AsyncLocalStorage(); + +/** Returns the active correlation ID, or undefined outside a request context. */ +export const getCorrelationId = (): string | undefined => + correlationStore.getStore()?.correlationId; diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index 0584b2a..2a96c94 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -4,7 +4,7 @@ import { recordMetric } from './query-analytics.service'; export type DatabaseTransactionClient = Pick< PrismaClient, - 'ledger' + | 'ledger' | 'walletTransaction' | 'user' | 'refreshToken' @@ -43,9 +43,7 @@ export type DatabaseTransactionClient = Pick< >; export interface DatabaseClient extends DatabaseTransactionClient { - $transaction( - fn: (tx: DatabaseTransactionClient) => Promise - ): Promise; + $transaction(fn: (tx: DatabaseTransactionClient) => Promise): Promise; $disconnect(): Promise; $queryRaw(query: TemplateStringsArray, ...values: any[]): Promise; $use(cb: (params: any, next: (params: any) => Promise) => Promise): void; @@ -92,12 +90,102 @@ let activeDatabaseClient: DatabaseClient = prisma; export const getDatabaseClient = (): DatabaseClient => activeDatabaseClient; -export const setDatabaseClientForTesting = (client: DatabaseClient): void => { - activeDatabaseClient = client; +/** Replace the client (test use only). */ +export const setDatabaseClientForTesting = (c: DatabaseClient): void => { + activeDatabaseClient = c; }; export const resetDatabaseClient = (): void => { activeDatabaseClient = prisma; }; +// --------------------------------------------------------------------------- +// Connection pre-warming +// --------------------------------------------------------------------------- + +/** + * Pre-warms the pool by firing `minConnections` concurrent `SELECT 1` probes. + * Call this during service startup, before accepting traffic. + */ +export async function warmPool(cfg: PoolServiceConfig = {}): Promise { + const min = cfg.minConnections ?? parseInt(process.env.DATABASE_POOL_MIN ?? '2', 10); + const probes = Array.from({ length: min }, () => + (activeDatabaseClient as any).$queryRaw`SELECT 1`.catch(() => null) + ); + await Promise.all(probes); + _idleCount = min; + dbIdleConnections.set(_idleCount); +} + +// --------------------------------------------------------------------------- +// Continuous health-check loop +// --------------------------------------------------------------------------- + +let _healthInterval: ReturnType | null = null; + +/** + * Start a periodic health-check loop that probes the pool with `SELECT 1`. + * Stale connections are detected and logged; Prisma automatically refreshes + * them on the next acquire. + * + * @returns A cleanup function that stops the loop. + */ +export function startPoolHealthCheck(cfg: PoolServiceConfig = {}): () => void { + const intervalMs = cfg.healthCheckIntervalMs ?? 30_000; + + if (_healthInterval) clearInterval(_healthInterval); + + _healthInterval = setInterval(async () => { + const start = Date.now(); + try { + incActive(); + await (activeDatabaseClient as any).$queryRaw`SELECT 1`; + decActive(); + const latencyMs = Date.now() - start; + if (latencyMs > 500) { + console.warn('[db-pool] health probe slow', { latencyMs }); + } + } catch (err) { + decActive(); + console.error('[db-pool] health probe failed — pool may be degraded', { err }); + } + }, intervalMs); + + // Allow the process to exit even if the interval is still active. + if (_healthInterval.unref) _healthInterval.unref(); + + return stopPoolHealthCheck; +} + +function stopPoolHealthCheck(): void { + if (_healthInterval) { + clearInterval(_healthInterval); + _healthInterval = null; + } +} + +// --------------------------------------------------------------------------- +// Graceful drain +// --------------------------------------------------------------------------- + +/** + * Gracefully drain the connection pool. + * Waits for in-flight queries to finish, then calls `$disconnect()`. + * Should be called from SIGTERM / SIGINT handlers. + */ +export async function drainPool(timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + stopPoolHealthCheck(); + + while (_activeCount > 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 100)); + } + + await activeDatabaseClient.$disconnect(); + _activeCount = 0; + _idleCount = 0; + dbActiveConnections.set(0); + dbIdleConnections.set(0); +} + export default prisma; diff --git a/server/src/services/http-client.service.ts b/server/src/services/http-client.service.ts new file mode 100644 index 0000000..4cc1147 --- /dev/null +++ b/server/src/services/http-client.service.ts @@ -0,0 +1,27 @@ +/** + * http-client.service.ts + * + * Thin wrapper around the native `fetch` API that automatically injects + * the active `X-Correlation-ID` header on every outbound HTTP request. + * + * Usage (drop-in replacement for `fetch`): + * import { correlatedFetch } from '../services/http-client.service'; + * const res = await correlatedFetch('https://api.example.com/data'); + * + * Existing call-sites can pass their own `X-Correlation-ID` in `init` + * and it will be used as-is (no override). + */ + +import { getCorrelationId } from './correlation.service'; + +export const correlatedFetch: typeof fetch = (input, init) => { + const correlationId = getCorrelationId(); + if (!correlationId) return fetch(input, init); + + const headers = new Headers(init?.headers); + if (!headers.has('X-Correlation-ID')) { + headers.set('X-Correlation-ID', correlationId); + } + + return fetch(input, { ...init, headers }); +}; diff --git a/server/src/services/logger.service.ts b/server/src/services/logger.service.ts index 594bc74..f84a5e3 100644 --- a/server/src/services/logger.service.ts +++ b/server/src/services/logger.service.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import winston from 'winston'; import DailyRotateFile from 'winston-daily-rotate-file'; +import { getCorrelationId } from './correlation.service'; // Logger is initialised before initEnv() runs (it is imported by env.ts // indirectly), so we fall back to process.env directly here. All other @@ -16,7 +17,19 @@ if (!fs.existsSync(logDirectory)) { fs.mkdirSync(logDirectory, { recursive: true }); } +/** + * Winston format that injects the active `correlation_id` from the + * AsyncLocalStorage context into every structured log entry. + * Falls back gracefully when called outside a request context. + */ +const correlationFormat = winston.format((info) => { + const id = getCorrelationId(); + if (id) info.correlation_id = id; + return info; +}); + const baseFormat = winston.format.combine( + correlationFormat(), winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json() @@ -26,14 +39,16 @@ const transports: winston.transport[] = [ new winston.transports.Console({ level: logLevel, format: winston.format.combine( + correlationFormat(), winston.format.colorize(), winston.format.timestamp(), - winston.format.printf(({ level, message, timestamp, ...metadata }) => { + winston.format.printf(({ level, message, timestamp, correlation_id, ...metadata }) => { + const cid = correlation_id ? ` [${correlation_id}]` : ''; const metadataPayload = Object.keys(metadata).length > 0 ? ` ${JSON.stringify(metadata)}` : ''; - return `${timestamp} ${level}: ${message}${metadataPayload}`; + return `${timestamp} ${level}${cid}: ${message}${metadataPayload}`; }) ) }), diff --git a/server/src/services/stellar-tx.service.ts b/server/src/services/stellar-tx.service.ts index f0a0b88..28d19fb 100644 --- a/server/src/services/stellar-tx.service.ts +++ b/server/src/services/stellar-tx.service.ts @@ -1,7 +1,9 @@ -import { Horizon, Transaction, FeeBumpTransaction, TransactionBuilder } from '@stellar/stellar-sdk'; +import { Horizon, Transaction, FeeBumpTransaction, TransactionBuilder, Memo } from '@stellar/stellar-sdk'; import prisma from './database.service'; import { TxStatus } from '@prisma/client'; import stellarSigningService from './stellar-signing.service'; +import { getCorrelationId } from './correlation.service'; +import { logger } from './logger.service'; export class StellarTxService { private server: Horizon.Server; @@ -14,6 +16,14 @@ export class StellarTxService { /** * Submits a transaction to the Stellar network. * Supports sponsored transactions (Fee Payers). + * + * When a correlation ID is active, it is stored in the DB log record + * as `metadata.correlationId` so cross-database auditing can link a + * Stellar tx back to the originating HTTP request. + * + * Note: Stellar memos have a 28-byte limit (MEMO_TEXT). We store the + * full correlation ID only in the DB metadata; a truncated tag is + * added to MEMO_TEXT only if the transaction carries no existing memo. */ async submitTransaction( tx: Transaction | FeeBumpTransaction, @@ -23,9 +33,17 @@ export class StellarTxService { ): Promise { let finalTx = tx; let txHash: string = ''; + const correlationId = getCorrelationId(); try { - // 1. If sponsored, wrap in a FeeBumpTransaction + // 1. Inject correlation memo on plain Transactions (not FeeBump). + if (correlationId && tx instanceof Transaction && tx.memo.type === 'none') { + // MEMO_TEXT is capped at 28 bytes; use last 27 chars prefixed with 'c:'. + const memoText = `c:${correlationId.slice(-26)}`; + (tx as any).memo = Memo.text(memoText); + } + + // 2. If sponsored, wrap in a FeeBumpTransaction if (options.sponsored && !(tx instanceof FeeBumpTransaction)) { finalTx = TransactionBuilder.buildFeeBumpTransaction( process.env.FEE_PAYER_PUBLIC_KEY || '', @@ -42,22 +60,24 @@ export class StellarTxService { txHash = (finalTx as any).hash().toString('hex'); - // 2. Initial log as PENDING - await this.logTransaction(txHash, userId, type, TxStatus.PENDING, finalTx.toXDR()); + // 3. Initial log as PENDING (includes correlationId in payload metadata) + await this.logTransaction(txHash, userId, type, TxStatus.PENDING, finalTx.toXDR(), correlationId); - // 3. Submit to Horizon + // 4. Submit to Horizon const response = await this.server.submitTransaction(finalTx); - // 4. Update to SUCCESS + // 5. Update to SUCCESS await this.updateTransactionStatus(txHash, TxStatus.SUCCESS); + logger.info('Stellar transaction submitted', { txHash, type, userId, correlationId }); + return response; } catch (error: any) { const errorMsg = error.response?.data?.extras?.result_codes?.operations ? JSON.stringify(error.response.data.extras.result_codes) : error.message; - console.error(`Transaction submission failed [${txHash}]:`, errorMsg); + logger.error(`Stellar transaction submission failed`, { txHash, errorMsg, correlationId }); if (txHash) { await this.updateTransactionStatus(txHash, TxStatus.FAILED, errorMsg); @@ -72,7 +92,8 @@ export class StellarTxService { userId: string | undefined, type: string, status: TxStatus, - payloadXdr: string + payloadXdr: string, + correlationId?: string ) { await prisma.blockchainTransaction.upsert({ where: { txHash }, @@ -82,7 +103,7 @@ export class StellarTxService { userId, type, status, - payload: { xdr: payloadXdr }, + payload: { xdr: payloadXdr, ...(correlationId ? { correlationId } : {}) }, createdAt: new Date(), updatedAt: new Date() } diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts index ee8b658..b062e42 100644 --- a/server/src/types/express.d.ts +++ b/server/src/types/express.d.ts @@ -9,7 +9,10 @@ declare global { username: string; } interface Request { + /** Legacy alias — equals correlationId. Kept for backward compatibility. */ requestId: string; + /** Active correlation ID for this request. */ + correlationId: string; log: Logger; user?: User; }