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;
}