Skip to content

Cases - Abertura de Caixas

A abertura de caixas e a mecanica principal da plataforma. O jogador seleciona uma caixa, paga o preco em BRL (centavos) e recebe um item aleatorio determinado pelo sistema Provably Fair (HMAC-SHA256). O sistema suporta abertura unica e multipla (ate 100 por vez), com sistema FLIP integrado para itens raros.


Indice

  1. Modelo de Dados (Prisma)
  2. Fluxo Completo de Abertura
  3. Calculo de Probabilidades e Hash Ranges
  4. Calculo do Roll (Provably Fair)
  5. Calculo do Nonce
  6. Selecao do Item pelo Roll
  7. Calculo de Lucro
  8. Abertura Multipla
  9. Preparacao de Itens para Roleta
  10. Eventos WebSocket
  11. Live Drop Delay
  12. Endpoints da API
  13. DTOs
  14. Verificacao Provably Fair
  15. Casos de Erro
  16. Debugging

Modelo de Dados (Prisma)

Enums

prisma
enum CaseStatus {
  ACTIVE
  INACTIVE
  COMING_SOON
}

enum RiskLevel {
  LOW
  MEDIUM
  HIGH
  MAXIMUM
}

enum Rarity {
  CONSUMER
  INDUSTRIAL
  MIL_SPEC
  RESTRICTED
  CLASSIFIED
  COVERT
  EXTRAORDINARY
}

enum WeaponCategory {
  PISTOL
  RIFLE
  SMG
  SNIPER
  SHOTGUN
  HEAVY
  KNIFE
  GLOVE
  OTHER
}

enum ItemExterior {
  FACTORY_NEW
  MINIMAL_WEAR
  FIELD_TESTED
  WELL_WORN
  BATTLE_SCARRED
  NOT_PAINTED
}

enum InventoryItemStatus {
  AVAILABLE
  LOCKED
  SOLD
  WITHDRAWN
  USED_IN_UPGRADE
  USED_IN_BATTLE
  IN_SWAP
  IN_CRASH
  USED_IN_CRASH
  IN_MINES
  USED_IN_MINES
}

CaseCategory

prisma
model CaseCategory {
  id          BigInt      @id
  name        String      @unique
  slug        String      @unique
  description String?
  order       Int         @default(0)
  status      CaseStatus  @default(ACTIVE)
  createdAt   DateTime    @default(now()) @map("created_at")
  updatedAt   DateTime    @updatedAt @map("updated_at")

  cases       Case[]

  @@map("case_categories")
  @@index([status, order])
}

Case

prisma
model Case {
  id              BigInt       @id
  name            String
  slug            String       @unique
  description     String?
  imageUrl        String       @map("image_url")
  bgColor         String?      @map("bg_color")
  priceCents      BigInt       @map("price_cents")
  riskLevel       RiskLevel    @map("risk_level")
  categoryId      BigInt       @map("category_id")
  isHot           Boolean      @default(false) @map("is_hot")
  isNew           Boolean      @default(false) @map("is_new")
  order           Int          @default(0)
  flipThreshold   Int?         @map("flip_threshold")
  flipDisplayRareSpecial Boolean @default(false) @map("flip_display_rare_special")
  status          CaseStatus   @default(ACTIVE)
  targetRtpPercent Decimal?    @db.Decimal(5, 2) @map("target_rtp_percent")
  rtpAlertActive  Boolean      @default(false) @map("rtp_alert_active")
  createdAt       DateTime     @default(now()) @map("created_at")
  updatedAt       DateTime     @updatedAt @map("updated_at")

  category        CaseCategory @relation(fields: [categoryId], references: [id])
  items           CaseItem[]
  openings        CaseOpening[]
  battleCases     BattleCase[]
  battleRounds    BattleRound[]
  freeCredits     FreeOpeningCredit[]

  @@map("cases")
  @@index([status, categoryId, order])
  @@index([isHot])
  @@index([isNew])
  @@index([riskLevel])
}

Campos importantes:

  • priceCents: Preco em centavos de BRL. Exemplo: 1590 = R$ 15,90
  • riskLevel: LOW, MEDIUM, HIGH ou MAXIMUM
  • status: Apenas caixas com status ACTIVE podem ser abertas
  • isHot / isNew: Flags para destaque visual no frontend
  • flipThreshold: Limiar do FLIP (nullable). Se null, a caixa nao tem FLIP; caso contrario, roll >= flipThreshold ativa a segunda roleta (ver FLIP)
  • targetRtpPercent / rtpAlertActive: RTP alvo e flag de alerta usados pelo monitoramento de RTP apos sync de precos

Item

prisma
model Item {
  id                  BigInt          @id
  name                String
  marketHashName      String          @unique @map("market_hash_name")
  description         String?
  imageUrl            String          @map("image_url")
  rarity              Rarity
  valueCents          BigInt          @map("value_cents")
  valueCentsBrl       BigInt          @default(0) @map("value_cents_brl")
  originalPriceCents  BigInt          @default(0) @map("original_price_cents")
  marginPercent       Float           @default(20) @map("margin_percent")
  weaponCategory      WeaponCategory  @map("weapon_category")
  collection          String?
  exterior            ItemExterior?
  isStatTrak          Boolean         @default(false) @map("is_stattrak")
  createdAt           DateTime        @default(now()) @map("created_at")
  updatedAt           DateTime        @updatedAt @map("updated_at")

  @@map("items")
  @@index([rarity])
  @@index([valueCents])
  @@index([valueCentsBrl])
  @@index([weaponCategory])
}

REGRA CRITICA: O campo valueCentsBrl (BRL) e o utilizado em TODOS os calculos internos (RTP, lucro, probabilidades, exibicao). O campo valueCents (USD) serve apenas como referencia do preco original.

CaseItem

prisma
model CaseItem {
  id              BigInt   @id
  caseId          BigInt   @map("case_id")
  itemId          BigInt   @map("item_id")
  weight          Int
  hashRangeStart  Int      @map("hash_range_start")
  hashRangeEnd    Int      @map("hash_range_end")
  dropChance      Float    @map("drop_chance")
  isRare          Boolean  @default(false) @map("is_rare")

  case            Case     @relation(fields: [caseId], references: [id], onDelete: Cascade)
  item            Item     @relation(fields: [itemId], references: [id])

  @@unique([caseId, itemId])
  @@map("case_items")
  @@index([caseId])
  @@index([itemId])
}

Campos importantes:

  • weight: Peso relativo do item. Quanto maior, mais provavel de dropar.
  • hashRangeStart / hashRangeEnd: Faixa de 0 a 9.999.999 (HASH_MAX - 1). Se o roll cair nessa faixa, o item e selecionado.
  • dropChance: Porcentagem real de drop (calculada a partir do weight).
  • isRare: Flag legada no schema. NAO e usada pelo FlipService — a participacao no FLIP e decidida por hashRangeStart >= Case.flipThreshold (ver FLIP), nao por este campo.

CaseOpening

prisma
model CaseOpening {
  id              BigInt   @id
  userId          BigInt   @map("user_id")
  caseId          BigInt   @map("case_id")
  itemId          BigInt   @map("item_id")
  pricePaidCents  BigInt   @map("price_paid_cents")
  itemValueCents  BigInt   @map("item_value_cents")
  profitCents     BigInt   @map("profit_cents")

  serverSeed      String   @map("server_seed")
  serverSeedHash  String   @map("server_seed_hash")
  clientSeed      String   @map("client_seed")
  nonce           Int
  roll            Int

  isFlip          Boolean  @default(false) @map("is_flip")
  flipServerSeed  String?  @map("flip_server_seed")
  flipClientSeed  String?  @map("flip_client_seed")
  flipNonce       Int?     @map("flip_nonce")
  flipRoll        Int?     @map("flip_roll")

  createdAt       DateTime @default(now()) @map("created_at")

  user            User     @relation(fields: [userId], references: [id])
  case            Case     @relation(fields: [caseId], references: [id])
  item            Item     @relation(fields: [itemId], references: [id])

  @@map("case_openings")
  @@index([userId, createdAt])
  @@index([caseId, createdAt])
  @@index([isFlip])
}

UserInventory

prisma
model UserInventory {
  id           BigInt              @id
  userId       BigInt              @map("user_id")
  itemId       BigInt              @map("item_id")
  status       InventoryItemStatus @default(AVAILABLE)
  acquiredAt   DateTime            @default(now()) @map("acquired_at")
  acquiredFrom       String?             @map("acquired_from")
  acquiredValueCents BigInt              @default(0) @map("acquired_value_cents")
  restrictedToGames  Boolean             @default(false) @map("restricted_to_games")
  lockedUntil        DateTime?           @map("locked_until")

  user               User                 @relation(fields: [userId], references: [id])
  item               Item                 @relation(fields: [itemId], references: [id])
  swapItems          SwapItem[]
  siteSwapItems      SiteSwapItem[]
  waxpeerWithdrawals WaxpeerWithdrawal[]
  crashBetSkins      CrashBetSkin[]

  @@map("user_inventory")
  @@index([userId, status])
  @@index([itemId])
}

Fluxo Completo de Abertura

O fluxo e executado no OpenCaseUseCase.execute() em src/application/use-cases/case-opening/open-case.use-case.ts.

Passo a Passo

1. Buscar caixa por ID (cache)             -> caseRepository.findByIdCached(caseId)
2. Validar status da caixa                 -> caseData.status === 'ACTIVE'
3. Adquirir lock distribuido               -> lockService.withUserBalanceLock(userId)
4. Buscar usuario                          -> userRepository.findById(userId)
5. Validar saldo REAL                      -> transactionService.getUserBalance(userId) >= caseData.priceCents
6. Gerar server seed (32 bytes hex)        -> provablyFairService.generateServerSeed()
7. Gerar hash do server seed               -> provablyFairService.hashServerSeed(serverSeed)
8. Resolver client seed                    -> provablyFairService.generateClientSeed(clientSeed)
9. Obter nonce (Redis INCR por usuario)    -> nonceService.getNextNonce(userId)
10. Calcular roll (1 a 10.000.000)         -> provablyFairService.calculateRoll(serverSeed, clientSeed, nonce)
11. Ler flip threshold da caixa            -> caseData.flipThreshold
12. Verificar se ativou FLIP               -> flipService.shouldTriggerFlip(roll, flipThreshold)
13. Separar itens normais/raros            -> filter por hashRangeStart vs flipThreshold
14. Preparar itens para roleta             -> prepareFlipItemsForRoulette()
15. Selecionar item ganho pelo roll        -> selectFlipWinner (FLIP) ou getItemByRoll (normal)
16. Buff STREAMER (opcional)               -> reroll determinístico se role === 'STREAMER'
17. Calcular lucro                         -> wonItem.valueCentsBrl - caseData.priceCents
18. Iniciar transacao Prisma (15000ms)     -> prisma.$transaction()
19.   Debitar saldo (double-entry)         -> transactionService.debit(userId, priceCents, ..., tx)
20.   Criar registro CaseOpening           -> caseOpeningRepository.create() (campos flip = null)
21.   Adicionar item ao inventario         -> userInventoryRepository.create() (acquiredValueCents)
22.   Marcar firstBetAt + totalWageredCents-> tx.user.updateMany / update (atomicos)
23. Commit da transacao
24. Buscar saldo REAL (pos-commit)         -> transactionService.getUserBalance(userId)
25. Emitir saldo via WebSocket             -> webSocketGateway.emitBalanceUpdate()
26. Auditar abertura                       -> auditService.logUserAction('CASE_OPENED', ...)
27. Agendar live drop com delay            -> setTimeout(emitLiveDrop, delay)

Codigo Real do Use Case

typescript
// src/application/use-cases/case-opening/open-case.use-case.ts

async execute(
  userId: bigint,
  caseId: bigint,
  clientSeed?: string,
): Promise<{
  opening: any;
  item: any;
  inventoryItemId: bigint;
  serverSeedHash: string;
  clientSeed: string;
  nonce: number;
  roll: number;
  isFlip: boolean;
  flipRoll?: number;
  flipItems?: any[];
  normalItems?: any[];
}> {
  const caseData = await this.caseRepository.findByIdCached(caseId);
  if (!caseData) {
    throw new NotFoundException(`Case with ID ${caseId} not found`);
  }

  if (caseData.status !== 'ACTIVE') {
    throw new BadRequestException('Case is not available');
  }

  return this.lockService.withUserBalanceLock(userId, async () => {
    const user = await this.userRepository.findById(userId);
    if (!user) {
      throw new NotFoundException('User not found');
    }

    // Saldo REAL (source of truth), NAO o cache user.balanceCents
    const realBalance = await this.transactionService.getUserBalance(userId);
    if (realBalance < caseData.priceCents) {
      throw new BadRequestException('Insufficient balance');
    }

    const serverSeed = this.provablyFairService.generateServerSeed();
    const serverSeedHash = this.provablyFairService.hashServerSeed(serverSeed);
    const finalClientSeed = this.provablyFairService.generateClientSeed(clientSeed);

    // Nonce monotonico por usuario (redis.incr('nonce:{userId}'))
    const nonce = await this.nonceService.getNextNonce(userId);

    const roll = this.provablyFairService.calculateRoll(serverSeed, finalClientSeed, nonce);

    const flipThreshold = caseData.flipThreshold;
    const allItems = caseData.items;

    const isFlip = this.flipService.shouldTriggerFlip(roll, flipThreshold);

    const normalItems =
      flipThreshold === null ? allItems : allItems.filter((ci) => ci.hashRangeStart < flipThreshold);
    const normalItemsData = this.prepareFlipItemsForRoulette(normalItems);

    const flipItems =
      flipThreshold === null ? [] : allItems.filter((ci) => ci.hashRangeStart >= flipThreshold);
    const flipItemsData = this.prepareFlipItemsForRoulette(flipItems);

    // Selecao do item: FLIP primeiro (selectFlipWinner), fallback getItemByRoll
    let wonItem;
    let actualIsFlip = false;
    if (isFlip && flipItems.length > 0) {
      const flipWinner = this.flipService.selectFlipWinner(flipItems, roll);
      if (flipWinner) {
        wonItem = flipWinner;
        actualIsFlip = true;
      }
    }
    if (!wonItem) {
      wonItem = this.flipService.getItemByRoll(allItems, roll);
    }

    // Buff STREAMER: reroll determinístico (sufixos _buff_check e _buff)
    if (user.role === 'STREAMER' && user.affiliateBuffPercentage > 0) {
      const buffCheckRoll = this.provablyFairService.calculateRoll(
        serverSeed + '_buff_check', finalClientSeed, nonce,
      ) % 100;
      if (buffCheckRoll < user.affiliateBuffPercentage) {
        const buffRoll = this.provablyFairService.calculateRoll(
          serverSeed + '_buff', finalClientSeed, nonce,
        );
        let buffItem;
        if (actualIsFlip && flipItems.length > 0) {
          buffItem = this.flipService.selectFlipWinner(flipItems, buffRoll);
        }
        if (!buffItem) {
          buffItem = this.flipService.getItemByRoll(allItems, buffRoll);
        }
        if (buffItem && buffItem.item.valueCentsBrl > wonItem.item.valueCentsBrl) {
          wonItem = buffItem;
        }
      }
    }

    const profitCents = wonItem.item.valueCentsBrl - caseData.priceCents;

    // Tudo que muda estado numa unica transacao atomica (timeout 15000ms)
    const result = await this.prisma.$transaction(async (tx) => {
      await this.transactionService.debit(
        userId,
        caseData.priceCents,
        `Case opening: ${caseData.name}`,
        { action: 'CASE_OPEN', caseId: caseData.id.toString(), isFlip: actualIsFlip },
        tx,
      );

      const opening = await this.caseOpeningRepository.create({
        userId,
        caseId,
        itemId: wonItem.item.id,
        pricePaidCents: caseData.priceCents,
        itemValueCents: wonItem.item.valueCentsBrl,
        profitCents,
        serverSeed,
        serverSeedHash,
        clientSeed: finalClientSeed,
        nonce,
        roll,
        isFlip: actualIsFlip,
        flipServerSeed: null,
        flipClientSeed: null,
        flipNonce: null,
        flipRoll: null,
      }, tx);

      const inventoryItem = await this.userInventoryRepository.create({
        userId,
        itemId: wonItem.item.id,
        status: 'AVAILABLE',
        acquiredFrom: `Caixa - ${caseData.name}`,
        acquiredValueCents: wonItem.item.valueCentsBrl,
      }, tx);

      // FTB (firstBetAt) + wagered atomicos no mesmo row/tx
      await tx.user.updateMany({
        where: { id: userId, firstBetAt: null },
        data: { firstBetAt: new Date() },
      });
      await tx.user.update({
        where: { id: userId },
        data: { totalWageredCents: { increment: caseData.priceCents } },
      });

      return {
        opening,
        item: wonItem.item,
        dropChance: wonItem.dropChance,
        inventoryItemId: inventoryItem.id,
        serverSeedHash,
        clientSeed: finalClientSeed,
        nonce,
        roll,
        isFlip: actualIsFlip,
        flipRoll: actualIsFlip ? roll : undefined,
        flipItems: actualIsFlip ? flipItemsData : undefined,
        normalItems: normalItemsData,
      };
    }, { timeout: 15000 });

    // Saldo REAL pos-commit -> WebSocket
    const realBalanceCents = await this.transactionService.getUserBalance(userId);
    this.webSocketGateway.emitBalanceUpdate(userId, realBalanceCents);

    await this.auditService.logUserAction(
      userId, 'CASE_OPENED', 'CaseOpening', result.opening.id.toString(),
      { caseId: caseId.toString(), itemWon: result.item.name, roll: result.roll, isFlip: result.isFlip },
    );

    // Live drop com delay (12s normal / 30s FLIP)
    const liveDropDelay = result.isFlip ? 30000 : 12000;
    setTimeout(() => {
      this.webSocketGateway.emitLiveDrop({
        userName: user.username,
        userAvatar: user.avatarUrl || undefined,
        itemName: result.item.name,
        itemImage: result.item.imageUrl,
        itemValueCents: result.item.valueCentsBrl,
        rarity: result.item.rarity,
        source: 'case',
        timestamp: new Date(),
        dropChance: result.dropChance,
        caseImage: caseData.imageUrl || undefined,
        caseName: caseData.name || undefined,
      });
    }, liveDropDelay);

    return result;
  });
}

Lock Distribuido (Redlock)

O lock e adquirido via LockService.withUserBalanceLock() usando Redlock:

typescript
// src/infrastructure/locks/lock.service.ts

async withUserBalanceLock<T>(userId: bigint, callback: () => Promise<T>): Promise<T> {
  return this.withLock(`user:balance:${userId}`, callback, 10000);
}

async withLock<T>(resource: string, callback: () => Promise<T>, ttl: number = 5000): Promise<T> {
  const lock = await this.acquireLock(resource, ttl);
  try {
    return await callback();
  } finally {
    await this.releaseLock(lock);
  }
}

Parametros do Redlock:

  • driftFactor: 0.01
  • retryCount: 10
  • retryDelay: 200ms
  • retryJitter: 200ms
  • automaticExtensionThreshold: 500ms
  • TTL do lock de saldo: 10.000ms (10 segundos)

Debito (Double-Entry Bookkeeping)

O debito e feito via TransactionService.debit() que internamente chama createDoubleEntry():

typescript
// src/application/services/transaction.service.ts

async debit(
  userId: bigint,
  amountCents: bigint,
  reason: string,
  metadata?: any,
  externalTx?: PrismaTransactionClient,
): Promise<any> {
  return this.createDoubleEntry(
    userId,
    this.SYSTEM_USER_ID, // BigInt(0)
    amountCents,
    reason,
    metadata,
    externalTx,
  );
}

Dentro de createDoubleEntry:

  1. Valida que amountCents > 0
  2. Busca o usuario de debito
  3. Busca saldo REAL via transactionRepository.getUserBalance(debitUserId)
  4. Valida currentBalance >= amountCents
  5. Cria transacao DEBIT (usuario -> sistema)
  6. Cria transacao CREDIT (sistema -> usuario)
  7. Vincula as duas transacoes via relatedTransactionId
  8. Atualiza cache user.balanceCents para usuarios reais (nao SYSTEM_USER_ID = 0)

Calculo de Probabilidades e Hash Ranges

Implementado em src/application/services/probability.service.ts.

Constantes

typescript
const HASH_MAX = 10000000; // Range: 0 a 9.999.999

Calculo de Probabilidades

typescript
// src/application/services/probability.service.ts

async calculateProbabilities(caseId: bigint) {
  const caseItems = await this.caseRepository.getCaseItems(caseId);
  const totalWeight = caseItems.reduce((sum, item) => sum + item.weight, 0);

  if (totalWeight === 0) {
    throw new Error('Total weight cannot be zero');
  }

  const probabilities = caseItems.map((caseItem) => ({
    caseItemId: caseItem.id,
    itemId: caseItem.itemId,
    itemName: caseItem.item.name,
    weight: caseItem.weight,
    dropChance: (caseItem.weight / totalWeight) * 100,
  }));

  const totalProbability = probabilities.reduce((sum, p) => sum + p.dropChance, 0);
  if (Math.abs(totalProbability - 100) > 0.01) {
    throw new Error(`Total probability must be 100%, got ${totalProbability}%`);
  }

  return probabilities;
}

Atribuicao de Hash Ranges

typescript
// src/application/services/probability.service.ts

async assignHashRanges(caseId: bigint) {
  const probabilities = await this.calculateProbabilities(caseId);

  const HASH_MAX = 10000000;
  let currentStart = 0;

  const ranges = probabilities.map((prob) => {
    const range = Math.floor((prob.dropChance / 100) * HASH_MAX);
    const hashRangeStart = currentStart;
    const hashRangeEnd = currentStart + range - 1;
    currentStart += range;

    return {
      caseItemId: prob.caseItemId,
      hashRangeStart,
      hashRangeEnd,
      dropChance: prob.dropChance,
    };
  });

  // Garante que o ultimo item cubra ate o final do range
  if (ranges.length > 0) {
    ranges[ranges.length - 1].hashRangeEnd = HASH_MAX - 1;
  }

  // Persiste no banco
  await Promise.all(
    ranges.map((range) =>
      this.caseRepository.updateCaseItem(range.caseItemId, {
        hashRangeStart: range.hashRangeStart,
        hashRangeEnd: range.hashRangeEnd,
        dropChance: range.dropChance,
      }),
    ),
  );

  return ranges;
}

Validacao

typescript
async validateProbabilities(caseId: bigint): Promise<boolean> {
  const probabilities = await this.calculateProbabilities(caseId);
  const total = probabilities.reduce((sum, p) => sum + p.dropChance, 0);
  return Math.abs(total - 100) < 0.01;
}

Exemplo de Hash Ranges

Supondo uma caixa com 5 itens e os seguintes pesos:

ItemWeightDrop ChanceHash Range StartHash Range End
Consumer (Barato)799279.92%07.991.999
Industrial159815.98%7.992.0009.589.999
Mil-Spec3203.20%9.590.0009.909.999
Restricted640.64%9.910.0009.973.999
Classified (Raro)260.26%9.974.0009.999.999

Regras:

  • O ultimo item SEMPRE tem hashRangeEnd = HASH_MAX - 1 (9.999.999) para evitar gaps
  • Itens baratos ficam nos ranges baixos (alta probabilidade)
  • Itens caros/raros ficam nos ranges altos (baixa probabilidade)
  • A soma de todos os ranges DEVE cobrir exatamente 0 a 9.999.999

Calculo do Roll (Provably Fair)

Implementado em src/application/services/provably-fair.service.ts.

Geracao do Server Seed

typescript
generateServerSeed(): string {
  return randomBytes(32).toString('hex');
}

Resultado: String hexadecimal de 64 caracteres (256 bits).

Hash do Server Seed

typescript
hashServerSeed(seed: string): string {
  return createHmac('sha256', seed).digest('hex');
}

O hash e enviado ao usuario ANTES da abertura. O server seed real so e revelado na verificacao.

Geracao/Resolucao do Client Seed

typescript
generateClientSeed(userInput?: string): string {
  if (userInput && userInput.length > 0) {
    return userInput;
  }
  return randomBytes(32).toString('hex');
}

Se o usuario fornece um client seed customizado, ele e usado diretamente. Caso contrario, e gerado automaticamente com 32 bytes aleatorios (64 caracteres hex).

Calculo do Roll

typescript
calculateRoll(
  serverSeed: string,
  clientSeed: string,
  nonce: number,
  max: number = 10000000,
): number {
  const hmac = createHmac('sha256', serverSeed);
  hmac.update(`${clientSeed}:${nonce}`);
  const hash = hmac.digest('hex');

  const hashPrefix = hash.substring(0, 13);
  const decimal = parseInt(hashPrefix, 16);

  const maxSafeValue = Math.floor(Number.MAX_SAFE_INTEGER / max) * max;

  let result = decimal;
  if (result >= maxSafeValue) {
    const hmac2 = createHmac('sha256', serverSeed);
    hmac2.update(`${clientSeed}:${nonce}:retry`);
    const hash2 = hmac2.digest('hex');
    result = parseInt(hash2.substring(0, 13), 16);
  }

  const roll = (result % max) + 1;
  return roll;
}

Detalhes do algoritmo:

  1. Cria HMAC-SHA256 usando serverSeed como chave
  2. Atualiza com ${clientSeed}:${nonce} como mensagem
  3. Gera hash hexadecimal (64 caracteres)
  4. Extrai os primeiros 13 caracteres do hash
  5. Converte para decimal (parseInt(hashPrefix, 16))
  6. Verifica overflow de seguranca: se decimal >= maxSafeValue, recalcula com suffix :retry
  7. Aplica modulo: (result % 10000000) + 1
  8. Resultado final: numero de 1 a 10.000.000

Importante: O roll retornado e de 1 a 10.000.000, mas os hash ranges no banco sao de 0 a 9.999.999. A selecao do item usa roll >= hashRangeStart && roll <= hashRangeEnd, entao o roll precisa ser ajustado ou os ranges consideram esse offset. No codigo atual, getItemByRoll do FlipService faz a comparacao direta.


Calculo do Nonce

typescript
const nonce = await this.nonceService.getNextNonce(userId);
// nonce.service.ts -> redis.incr('nonce:{userId}')

Fonte: NonceService.getNextNonce(userId) executa redis.incr('nonce:{userId}').

  • Contador monotonico por usuario, persistido no Redis
  • Compartilhado por todos os jogos que usam o NonceService (case, battle, upgrade)
  • Cada abertura (unica ou multipla) puxa um valor unico e crescente, sem colisao
  • Como e sequencial, o mesmo usuario nunca repete um nonce

Na abertura multipla: executeMultiple chama getNextNonce(userId) para cada draw dentro do loop, antes de abrir a transacao unica.


Selecao do Item pelo Roll

Implementado em src/application/services/flip.service.ts. Quando o FLIP ativa, o OpenCaseUseCase chama primeiro selectFlipWinner(flipItems, roll) (busca so no tier raro); o fallback abaixo, getItemByRoll(allItems, roll), cobre todos os itens da caixa:

typescript
getItemByRoll(items: any[], roll: number) {
  const matchedItem = items.find(
    (item) => roll >= item.hashRangeStart && roll <= item.hashRangeEnd,
  );

  if (!matchedItem) {
    throw new Error(`No item found for roll ${roll}`);
  }

  return matchedItem;
}

Tambem existe a versao em ProbabilityService.getItemByRoll():

typescript
async getItemByRoll(caseId: bigint, roll: number) {
  const caseItems = await this.caseRepository.getCaseItems(caseId);
  const matchedItem = caseItems.find(
    (item) => roll >= item.hashRangeStart && roll <= item.hashRangeEnd,
  );

  if (!matchedItem) {
    throw new Error(`No item found for roll ${roll} in case ${caseId}`);
  }

  return matchedItem;
}

Se nenhum item for encontrado para o roll, uma excecao e lancada. Isso indica que os hash ranges nao cobrem todo o espectro (bug de configuracao).


Calculo de Lucro

typescript
const profitCents = wonItem.item.valueCentsBrl - caseData.priceCents;
  • Lucro positivo: Jogador ganhou item mais valioso que o preco da caixa
  • Lucro negativo: Jogador ganhou item menos valioso que o preco da caixa
  • Ambos os valores sao BigInt em centavos de BRL

O lucro e salvo no campo profitCents do CaseOpening e retornado formatado na response da API via MoneyService.format().

MoneyService

typescript
// src/application/services/money.service.ts

format(cents: bigint, locale = 'pt-BR', currency = 'BRL'): string {
  const reais = Number(cents) / 100;
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
  }).format(reais);
}

Exemplo: format(BigInt(-1410)) retorna "-R$ 14,10".


Abertura Multipla

Implementada em src/application/use-cases/case-opening/open-multiple-cases.use-case.ts.

typescript
// open-multiple-cases.use-case.ts apenas DELEGA:
async execute(userId: bigint, caseId: bigint, quantity: number, clientSeed?: string) {
  return this.openCaseUseCase.executeMultiple(userId, caseId, quantity, clientSeed);
}

// A logica real fica em open-case.use-case.ts -> executeMultiple():
async executeMultiple(userId: bigint, caseId: bigint, quantity: number, clientSeed?: string) {
  if (quantity < 1 || quantity > 100) {
    throw new BadRequestException('Quantity must be between 1 and 100');
  }

  const caseData = await this.caseRepository.findByIdCached(caseId);
  if (!caseData || caseData.status !== 'ACTIVE') {
    throw new BadRequestException('Case is not available');
  }

  return this.lockService.withUserBalanceLock(userId, async () => {
    const user = await this.userRepository.findById(userId);
    if (!user) throw new NotFoundException('User not found');

    const totalCost = caseData.priceCents * BigInt(quantity);
    const realBalance = await this.transactionService.getUserBalance(userId);
    if (realBalance < totalCost) {
      throw new BadRequestException('Insufficient balance');
    }

    // 1) Gera TODOS os draws (seeds proprios + nonce sequencial + item) FORA da tx
    const draws = [];
    for (let i = 0; i < quantity; i++) {
      const serverSeed = this.provablyFairService.generateServerSeed();
      const finalClientSeed = this.provablyFairService.generateClientSeed(clientSeed);
      const nonce = await this.nonceService.getNextNonce(userId);
      const roll = this.provablyFairService.calculateRoll(serverSeed, finalClientSeed, nonce);
      // ... shouldTriggerFlip + selectFlipWinner/getItemByRoll + buff STREAMER ...
      draws.push({ serverSeed, finalClientSeed, nonce, roll, wonItem, actualIsFlip });
    }

    // 2) Persiste todos os draws numa UNICA transacao atomica (timeout 20000ms)
    const created = await this.prisma.$transaction(async (tx) => {
      for (const d of draws) {
        await this.transactionService.debit(userId, caseData.priceCents, /* ... */ tx);
        // create CaseOpening (flip fields null) + create UserInventory (acquiredValueCents)
      }
      // firstBetAt + totalWageredCents (increment totalCost) atomicos
      return out;
    }, { timeout: 20000 });

    // 3) Pos-commit: emit saldo, audit e live drops (delay por draw)
    return created.map(/* ... */);
  });
}

Comportamento:

  • Quantidade minima: 1, maxima: 100
  • OpenMultipleCasesUseCase apenas delega para OpenCaseUseCase.executeMultiple()
  • Um unico lock por usuario (withUserBalanceLock) cobre toda a operacao
  • Validacao de saldo total (REAL, via getUserBalance) feita ANTES de gerar os draws (priceCents * quantity)
  • Todos os draws (seeds proprios + nonce sequencial + item) sao gerados antes da transacao
  • Uma unica transacao Prisma (timeout 20000ms) persiste TODOS os draws (debit + opening + inventory + firstBetAt/wagered)
  • Se qualquer draw falhar, a transacao inteira sofre rollback (tudo ou nada)
  • Cada draw gera seu proprio live drop com delay independente

Preparacao de Itens para Roleta

O metodo prepareFlipItemsForRoulette formata os itens para consumo do frontend:

typescript
private prepareFlipItemsForRoulette(items: any[]): any[] {
  const sortedItems = [...items].sort(
    (a, b) => Number(b.item.valueCentsBrl) - Number(a.item.valueCentsBrl),
  );

  return sortedItems.map((item) => ({
    id: item.id.toString(),
    itemId: item.item.id.toString(),
    itemName: item.item.name,
    itemImage: item.item.imageUrl,
    rarity: item.item.rarity,
    valueCents: item.item.valueCentsBrl.toString(),
    valueFormatted: (Number(item.item.valueCentsBrl) / 100).toFixed(2),
    dropChance: item.dropChance,
    weight: item.weight,
    weaponCategory: item.item.weaponCategory,
    exterior: item.item.exterior ?? undefined,
    isStatTrak: item.item.isStatTrak,
  }));
}

Comportamento:

  • Ordena itens por valueCentsBrl descendente (mais caros primeiro)
  • Retorna TODOS os itens (nao limita a 15)
  • Converte BigInt para string (necessario para serializacao JSON)
  • valueCents no retorno e na verdade valueCentsBrl (BRL, nao USD)
  • valueFormatted e o valor em reais com 2 casas decimais (ex: "1863.21")
  • Usado tanto para normalItems quanto para flipItems

Eventos WebSocket

balance:updated (Privado - sala user:{userId})

Emitido apos o commit da transacao:

typescript
this.webSocketGateway.emitBalanceUpdate(userId, realBalanceCents);
typescript
// Implementacao no WebSocketGatewayService
emitBalanceUpdate(userId: bigint, balanceCents: bigint) {
  const roomName = `user:${userId}`;
  this.server.to(roomName).emit('balance:updated', {
    balanceCents: balanceCents.toString()
  });
}

Payload:

json
{
  "balanceCents": "186321"
}

REGRA: balanceCents e SEMPRE uma string representando centavos inteiros. Frontend faz parseInt(data.balanceCents, 10).

live:drop (Global - broadcast para todos)

Emitido com delay apos a abertura:

typescript
emitLiveDrop(drop: {
  userName: string;
  userAvatar?: string;
  itemName: string;
  itemImage: string;
  itemValueCents: bigint | string;
  rarity: string;
  source: 'case' | 'upgrade' | 'swap' | 'battle' | 'event';
  timestamp: Date;
  dropChance: number;
  caseImage?: string;
}) {
  const valueCents = typeof drop.itemValueCents === 'bigint'
    ? drop.itemValueCents
    : BigInt(drop.itemValueCents);
  const isTopDrop = valueCents >= BigInt(100000); // >= R$ 1.000,00

  this.server.emit('live:drop', {
    userName: drop.userName,
    userAvatar: drop.userAvatar,
    itemName: drop.itemName,
    itemImage: drop.itemImage,
    itemValueCents: valueCents.toString(),
    rarity: drop.rarity,
    source: drop.source,
    timestamp: drop.timestamp,
    dropChance: drop.dropChance,
    isTopDrop,
    caseImage: drop.caseImage,
  });
}

Payload:

json
{
  "userName": "Player123",
  "userAvatar": "https://...",
  "itemName": "AWP | Dragon Lore",
  "itemImage": "https://...",
  "itemValueCents": "70765",
  "rarity": "EXTRAORDINARY",
  "source": "case",
  "timestamp": "2026-02-11T10:30:00.000Z",
  "dropChance": 0.05,
  "isTopDrop": false,
  "caseImage": "https://..."
}

isTopDrop: true quando valueCents >= 100000 (R$ 1.000,00 ou mais). Usado pelo frontend para animacoes especiais.

case:opened (Global - broadcast para todos)

typescript
emitCaseOpening(opening: any) {
  this.server.emit('case:opened', {
    id: opening.id.toString(),
    userId: opening.userId.toString(),
    username: opening.username,
    userAvatar: opening.userAvatar,
    caseName: opening.caseName,
    caseImage: opening.caseImage,
    itemName: opening.itemName,
    itemImage: opening.itemImage,
    itemRarity: opening.itemRarity,
    itemValue: opening.itemValue,
    bgColor: opening.bgColor,
    bottomColor: opening.bottomColor,
    isFlip: opening.isFlip,
    timestamp: opening.timestamp,
  });
}

Live Drop Delay

O live drop e emitido com delay para sincronizar com a animacao do frontend:

typescript
const liveDropDelay = result.isFlip ? 30000 : 12000;
TipoDelayMotivo
Normal12.000ms (12s)Tempo da animacao da roleta + pull
FLIP30.000ms (30s)Primeira roleta (10s) + animacao faca (~8s) + segunda roleta (10s) + pull

O setTimeout e executado FORA da transacao, apos o commit. Se o processo reiniciar antes do delay, o live drop nao sera emitido (aceitavel - nao afeta integridade).


Endpoints da API

Todos os endpoints estao nos controllers:

  • src/presentation/controllers/case.controller.ts - CRUD de caixas (publico)
  • src/presentation/controllers/case-opening.controller.ts - Abertura e historico

GET /cases

Descricao: Lista todas as caixas com filtros opcionais. Autenticacao: Publica (@Public()) Controller: CaseController.getCases()

Query Parameters (GetCasesQueryDto):

ParametroTipoObrigatorioValores
categoryIdstringNaoID da categoria (BigInt como string)
riskLevelenumNaoLOW, MEDIUM, HIGH, MAXIMUM
statusenumNaoACTIVE, INACTIVE, COMING_SOON
isHotbooleanNaotrue ou false
isNewbooleanNaotrue ou false

Response (CaseResponseDto[]):

json
[
  {
    "id": "123456789",
    "name": "Snow Case",
    "slug": "snow-case",
    "description": "Caixa com skins de inverno",
    "imageUrl": "https://cdn.example.com/cases/snow.png",
    "bgColor": "#1a2b3c",
    "priceCents": "1590",
    "priceFormatted": "R$ 15,90",
    "riskLevel": "MEDIUM",
    "category": {
      "id": "1",
      "name": "Populares",
      "slug": "populares"
    },
    "isHot": true,
    "isNew": false,
    "status": "ACTIVE",
    "itemCount": 15,
    "bestItem": {
      "id": "999",
      "name": "Butterfly Knife | Emerald",
      "imageUrl": "https://...",
      "rarity": "EXTRAORDINARY",
      "valueCents": "70765",
      "valueFormatted": "R$ 707,65"
    }
  }
]

Logica do bestItem: E o item com maior valueCentsBrl dentro da caixa:

typescript
const bestItem = c.items?.length
  ? c.items.reduce((best, current) =>
      current.item.valueCentsBrl > best.item.valueCentsBrl ? current : best,
    )
  : null;

GET /cases/categories

Descricao: Lista todas as categorias de caixas. Autenticacao: Publica (@Public()) Controller: CaseController.getCategories()

Response (CaseCategoryResponseDto[]):

json
[
  {
    "id": "1",
    "name": "Populares",
    "slug": "populares",
    "description": "As caixas mais abertas",
    "order": 1,
    "status": "ACTIVE"
  }
]

GET /cases/:id

Descricao: Detalhes completos de uma caixa com todos os itens. Autenticacao: Publica (@Public()) Controller: CaseController.getCaseById()

Response (CaseResponseDto):

json
{
  "id": "123456789",
  "name": "Snow Case",
  "slug": "snow-case",
  "description": "Caixa com skins de inverno",
  "imageUrl": "https://cdn.example.com/cases/snow.png",
  "bgColor": "#1a2b3c",
  "priceCents": "1590",
  "priceFormatted": "R$ 15,90",
  "riskLevel": "MEDIUM",
  "category": {
    "id": "1",
    "name": "Populares",
    "slug": "populares"
  },
  "isHot": true,
  "isNew": false,
  "status": "ACTIVE",
  "items": [
    {
      "id": "1",
      "itemId": "456",
      "itemName": "MAG-7 | Wildwood",
      "itemImage": "https://...",
      "rarity": "CONSUMER",
      "valueCents": "37",
      "valueFormatted": "R$ 0,37",
      "dropChance": 66.82,
      "weight": 6682,
      "weaponCategory": "SHOTGUN",
      "isStatTrak": false
    },
    {
      "id": "15",
      "itemId": "999",
      "itemName": "Butterfly Knife | Emerald",
      "itemImage": "https://...",
      "rarity": "EXTRAORDINARY",
      "valueCents": "70765",
      "valueFormatted": "R$ 707,65",
      "dropChance": 0.01,
      "weight": 1,
      "weaponCategory": "KNIFE",
      "exterior": "FACTORY_NEW",
      "isStatTrak": false
    }
  ]
}

Nota: O campo valueCents na response dos items e na verdade valueCentsBrl (BRL). O mapeamento e feito no controller:

typescript
valueCents: ci.item.valueCentsBrl.toString(),
valueFormatted: this.moneyService.format(ci.item.valueCentsBrl),

GET /cases/:id/items

Descricao: Lista apenas os itens de uma caixa. Autenticacao: Publica (@Public()) Controller: CaseController.getCaseItems()

Response (CaseItemResponseDto[]):

json
[
  {
    "id": "1",
    "itemId": "456",
    "itemName": "MAG-7 | Wildwood",
    "itemImage": "https://...",
    "rarity": "CONSUMER",
    "valueCents": "37",
    "valueFormatted": "R$ 0,37",
    "dropChance": 66.82,
    "weight": 6682,
    "weaponCategory": "SHOTGUN",
    "isStatTrak": false
  }
]

GET /cases/:id/probabilities

Descricao: Retorna as probabilidades e hash ranges de cada item da caixa. Autenticacao: Publica (@Public()) Controller: CaseController.getCaseProbabilities()

Response (CaseProbabilityResponseDto[]):

json
[
  {
    "itemId": "456",
    "itemName": "MAG-7 | Wildwood",
    "rarity": "CONSUMER",
    "valueCents": "37",
    "valueFormatted": "R$ 0,37",
    "dropChance": 66.82,
    "weight": 6682,
    "hashRangeStart": 0,
    "hashRangeEnd": 6681999
  },
  {
    "itemId": "999",
    "itemName": "Butterfly Knife | Emerald",
    "rarity": "EXTRAORDINARY",
    "valueCents": "70765",
    "valueFormatted": "R$ 707,65",
    "dropChance": 0.01,
    "weight": 1,
    "hashRangeStart": 9999899,
    "hashRangeEnd": 9999999
  }
]

POST /cases/:id/open

Descricao: Abre uma ou mais caixas. Autenticacao: Requerida (@UseGuards(AuthGuard)) Controller: CaseOpeningController.openCase()

Request Body (OpenCaseDto):

json
{
  "quantity": 1,
  "clientSeed": "minha-seed-customizada"
}
CampoTipoObrigatorioValidacaoDefault
quantitynumberNao@Min(1) @Max(100)1
clientSeedstringNao@IsString()Gerado automaticamente

Response (quantity = 1) (CaseOpeningResponseDto):

json
{
  "id": "789012345",
  "inventoryItemId": "111222333",
  "userId": "100",
  "caseId": "123456789",
  "caseName": "",
  "caseImage": "",
  "itemId": "456",
  "itemName": "AK-47 | Redline",
  "itemImage": "https://...",
  "itemRarity": "CLASSIFIED",
  "itemValueCents": "15000",
  "itemValueFormatted": "R$ 150,00",
  "pricePaidCents": "1590",
  "pricePaidFormatted": "R$ 15,90",
  "profitCents": "13410",
  "profitFormatted": "R$ 134,10",
  "serverSeedHash": "a1b2c3d4e5f6...",
  "clientSeed": "minha-seed-customizada",
  "nonce": 456789,
  "roll": 9985432,
  "isFlip": false,
  "normalItems": [
    {
      "id": "1",
      "itemId": "456",
      "itemName": "AK-47 | Redline",
      "itemImage": "https://...",
      "rarity": "CLASSIFIED",
      "valueCents": "15000",
      "valueFormatted": "150.00",
      "dropChance": 0.26,
      "weight": 26,
      "weaponCategory": "RIFLE",
      "isStatTrak": false
    }
  ],
  "createdAt": "2026-02-11T10:30:00.000Z"
}

Observacoes sobre a response:

  • caseName e caseImage sao retornados como string vazia ("") na abertura unica
  • flipRoll so e retornado quando o FLIP ativa (isFlip = true), igual ao roll; ausente quando isFlip = false
  • flipItems e undefined quando isFlip = false, presente quando isFlip = true
  • normalItems e SEMPRE retornado (itens do tier normal, hashRangeStart < flipThreshold, ordenados por valor desc)
  • Todos os valores BigInt sao convertidos para string

Response (quantity > 1) (CaseOpeningResponseDto[]):

Retorna um array com o mesmo formato acima, um elemento por abertura.

GET /cases/openings

Descricao: Historico de aberturas do usuario autenticado. Autenticacao: Requerida (@UseGuards(AuthGuard)) Controller: CaseOpeningController.getOpeningHistory()

Query Parameters:

ParametroTipoDefault
limitnumber50
offsetnumber0

Response (CaseOpeningResponseDto[]):

json
[
  {
    "id": "789012345",
    "userId": "100",
    "caseId": "123456789",
    "caseName": "Snow Case",
    "caseImage": "https://...",
    "itemId": "456",
    "itemName": "AK-47 | Redline",
    "itemImage": "https://...",
    "itemRarity": "CLASSIFIED",
    "itemValueCents": "15000",
    "itemValueFormatted": "R$ 150,00",
    "pricePaidCents": "1590",
    "pricePaidFormatted": "R$ 15,90",
    "profitCents": "13410",
    "profitFormatted": "R$ 134,10",
    "serverSeedHash": "a1b2c3d4e5f6...",
    "clientSeed": "minha-seed-customizada",
    "nonce": 456789,
    "roll": 9985432,
    "isFlip": false,
    "createdAt": "2026-02-11T10:30:00.000Z"
  }
]

POST /cases/openings/:id/verify

Descricao: Verifica a integridade de uma abertura via Provably Fair. Autenticacao: Requerida (@UseGuards(AuthGuard)) Controller: CaseOpeningController.verifyOpening()

Response (VerifyOpeningResponseDto):

json
{
  "isValid": true,
  "isServerSeedValid": true,
  "isRollValid": true,
  "opening": {
    "id": "789012345",
    "serverSeed": "a1b2c3d4e5f6789...",
    "serverSeedHash": "hash-do-server-seed...",
    "clientSeed": "minha-seed-customizada",
    "nonce": 456789,
    "roll": 9985432,
    "isFlip": false
  }
}

Logica de verificacao (VerifyCaseOpeningUseCase):

typescript
async execute(openingId: bigint) {
  const opening = await this.caseOpeningRepository.findById(openingId);
  if (!opening) {
    throw new NotFoundException('Opening not found');
  }

  const isServerSeedValid = this.provablyFairService.verifyServerSeedHash(
    opening.serverSeed,
    opening.serverSeedHash,
  );

  const isRollValid = this.provablyFairService.verifyResult(
    opening.serverSeed,
    opening.clientSeed,
    opening.nonce,
    opening.roll,
  );

  return {
    isValid: isServerSeedValid && isRollValid,
    isServerSeedValid,
    isRollValid,
    opening: {
      id: opening.id,
      serverSeed: opening.serverSeed,
      serverSeedHash: opening.serverSeedHash,
      clientSeed: opening.clientSeed,
      nonce: opening.nonce,
      roll: opening.roll,
      isFlip: opening.isFlip,
    },
  };
}

Duas verificacoes sao feitas:

  1. isServerSeedValid: HMAC-SHA256(serverSeed) === serverSeedHash armazenado
  2. isRollValid: calculateRoll(serverSeed, clientSeed, nonce) === roll armazenado

GET /cases/openings/recent

Descricao: Retorna aberturas recentes de todos os usuarios (live drops publicos). Autenticacao: Publica (@Public()) Controller: CaseOpeningController.getRecentOpenings()

Query Parameters:

ParametroTipoDefaultMax
limitnumber50100

Response (RecentOpeningDto[]):

json
[
  {
    "id": "789012345",
    "username": "Player123",
    "userAvatar": "https://...",
    "caseName": "Snow Case",
    "caseImage": "https://...",
    "itemName": "AK-47 | Redline",
    "itemImage": "https://...",
    "itemRarity": "CLASSIFIED",
    "itemValueCents": "15000",
    "itemValueFormatted": "R$ 150,00",
    "isFlip": false,
    "createdAt": "2026-02-11T10:30:00.000Z"
  }
]

DTOs

OpenCaseDto

typescript
export class OpenCaseDto {
  @IsOptional()
  @IsNumber()
  @Min(1)
  @Max(100)
  @Type(() => Number)
  quantity?: number = 1;

  @IsOptional()
  @IsString()
  clientSeed?: string;
}

CaseOpeningResponseDto

typescript
export class CaseOpeningResponseDto {
  id: string;
  inventoryItemId?: string;
  userId: string;
  caseId: string;
  caseName: string;
  caseImage: string;
  itemId: string;
  itemName: string;
  itemImage: string;
  itemRarity: string;
  itemValueCents: string;
  itemValueFormatted: string;
  pricePaidCents: string;
  pricePaidFormatted: string;
  profitCents: string;
  profitFormatted: string;
  serverSeedHash: string;
  clientSeed: string;
  nonce: number;
  roll: number;
  isFlip: boolean;
  flipRoll?: number;
  flipItems?: FlipItemDto[];
  normalItems?: FlipItemDto[];
  createdAt: Date;
}

FlipItemDto

typescript
export class FlipItemDto {
  id: string;
  itemId: string;
  itemName: string;
  itemImage: string;
  rarity: string;
  valueCents: string;
  valueFormatted: string;
  dropChance: number;
  weight: number;
  weaponCategory: string;
  exterior?: string;
  isStatTrak: boolean;
}

VerifyOpeningResponseDto

typescript
export class VerifyOpeningResponseDto {
  isValid: boolean;
  isServerSeedValid: boolean;
  isRollValid: boolean;
  opening: {
    id: string;
    serverSeed: string;
    serverSeedHash: string;
    clientSeed: string;
    nonce: number;
    roll: number;
    isFlip: boolean;
  };
}

RecentOpeningDto

typescript
export class RecentOpeningDto {
  id: string;
  username: string;
  userAvatar: string;
  caseName: string;
  caseImage: string;
  itemName: string;
  itemImage: string;
  itemRarity: string;
  itemValueCents: string;
  itemValueFormatted: string;
  isFlip: boolean;
  createdAt: Date;
}

GetCasesQueryDto

typescript
export class GetCasesQueryDto {
  @IsOptional()
  @IsString()
  categoryId?: string;

  @IsOptional()
  @IsEnum(['LOW', 'MEDIUM', 'HIGH', 'MAXIMUM'])
  riskLevel?: 'LOW' | 'MEDIUM' | 'HIGH' | 'MAXIMUM';

  @IsOptional()
  @IsEnum(['ACTIVE', 'INACTIVE', 'COMING_SOON'])
  status?: 'ACTIVE' | 'INACTIVE' | 'COMING_SOON';

  @IsOptional()
  @Type(() => Boolean)
  @IsBoolean()
  isHot?: boolean;

  @IsOptional()
  @Type(() => Boolean)
  @IsBoolean()
  isNew?: boolean;
}

CaseProbabilityResponseDto

typescript
export class CaseProbabilityResponseDto {
  itemId: string;
  itemName: string;
  rarity: string;
  valueCents: string;
  valueFormatted: string;
  dropChance: number;
  weight: number;
  hashRangeStart: number;
  hashRangeEnd: number;
}

Verificacao Provably Fair

O sistema permite que o usuario verifique a integridade de qualquer abertura:

typescript
// src/application/services/provably-fair.service.ts

verifyResult(
  serverSeed: string,
  clientSeed: string,
  nonce: number,
  expectedRoll: number,
  max: number = 10000000,
): boolean {
  const calculatedRoll = this.calculateRoll(serverSeed, clientSeed, nonce, max);
  return calculatedRoll === expectedRoll;
}

verifyServerSeedHash(serverSeed: string, serverSeedHash: string): boolean {
  const calculatedHash = this.hashServerSeed(serverSeed);
  return calculatedHash === serverSeedHash;
}

Fluxo de verificacao:

  1. Usuario recebe serverSeedHash no momento da abertura (ANTES do resultado)
  2. Apos o resultado, usuario acessa POST /cases/openings/:id/verify
  3. Backend revela o serverSeed original
  4. Backend recalcula: HMAC-SHA256(serverSeed) === serverSeedHash salvo
  5. Backend recalcula: calculateRoll(serverSeed, clientSeed, nonce) === roll salvo
  6. Se ambos batem, a abertura e valida e nao foi manipulada

Casos de Erro

ErroHTTP StatusMensagemCausa
Caixa nao encontrada404Case with ID {id} not foundID invalido ou inexistente
Caixa inativa400Case is not availablestatus !== 'ACTIVE'
Usuario nao encontrado404User not foundSessao invalida ou usuario deletado
Saldo insuficiente400Insufficient balancetransactionService.getUserBalance(userId) < priceCents
Quantidade invalida400Quantity must be between 1 and 100`quantity < 1
Saldo insuficiente (multipla)400Insufficient balance. Required: X cents, Available: Y centsbalanceCents < priceCents * quantity
Roll sem item500No item found for roll {roll}Hash ranges nao cobrem todo o espectro
Abertura nao encontrada (verify)404Opening not foundID de abertura invalido
Lock timeout500Redlock timeoutOutra operacao em andamento para o mesmo usuario
Weight zero500Total weight cannot be zeroCaixa sem itens ou todos com weight 0

Debugging

Backend

Logs ja implementados no OpenCaseUseCase:

[OpenCaseUseCase] Finding case with ID: 123456789
[OpenCaseUseCase] Case found: Snow Case Price: 1590
[OpenCaseUseCase] Finding user with ID: 100
[OpenCaseUseCase] User found. Balance BEFORE: 186321 cents = 1863.21 reais
[OpenCaseUseCase] Case price: 1590 cents = 15.90 reais
[OpenCaseUseCase] Balance OK, proceeding...
[OpenCaseUseCase] Debiting user...
[OpenCaseUseCase] Debit successful
[OpenCaseUseCase] Generating provably fair seeds...
[OpenCaseUseCase] Seeds generated
[OpenCaseUseCase] Roll generated: 9985432
[OpenCaseUseCase] Flip threshold: 9974000
[OpenCaseUseCase] Is FLIP: true (roll >= threshold)
[OpenCaseUseCase] Won item: AWP | Dragon Lore (tier FLIP)
[OpenCaseUseCase] Transaction committed. Fetching REAL balance from transactions for user 100...
[OpenCaseUseCase] Real balance from transactions: 184731 cents
[OpenCaseUseCase] Case price was: 1590 cents = 15.90 reais

Logs do WebSocket:

[Balance Update] User: 100, BalanceCents: 184731, Room: user:100
[Balance Update] Clients in room user:100: 1
[Balance Update] Event emitted to room user:100
Emitting live drop: Player123 got AWP | Dragon Lore (0.01%) - isTopDrop: false

Verificacao de Integridade de Ranges

Para verificar se os hash ranges de uma caixa cobrem todo o espectro:

typescript
const caseItems = await caseRepository.getCaseItems(caseId);
const totalCoverage = caseItems.reduce(
  (sum, item) => sum + (item.hashRangeEnd - item.hashRangeStart + 1),
  0,
);
// totalCoverage DEVE ser exatamente 10.000.000

Divergencia de Saldo

Se houver divergencia entre o saldo exibido e o real:

  1. O saldo REAL sempre vem de transactionService.getUserBalance(userId) (soma de transacoes)
  2. user.balanceCents e um CACHE atualizado apos cada transacao
  3. O WebSocket emite o saldo REAL (pos-commit) via emitBalanceUpdate(userId, realBalanceCents)

Arquivos Relacionados

ArquivoResponsabilidade
src/application/use-cases/case-opening/open-case.use-case.tsLogica principal de abertura
src/application/use-cases/case-opening/open-multiple-cases.use-case.tsAbertura multipla (loop)
src/application/use-cases/case-opening/verify-case-opening.use-case.tsVerificacao Provably Fair
src/application/use-cases/case-opening/get-case-opening-history.use-case.tsHistorico do usuario
src/application/use-cases/case-opening/get-recent-openings.use-case.tsAberturas recentes (publico)
src/application/services/provably-fair.service.tsCalculo de roll e verificacao
src/application/services/nonce.service.tsNonce monotonico por usuario (redis.incr('nonce:{userId}'))
src/application/services/probability.service.tsCalculo de probabilidades e hash ranges
src/application/services/flip.service.tsSistema FLIP (threshold, itens raros)
src/application/services/transaction.service.tsDouble-entry bookkeeping
src/application/services/money.service.tsFormatacao de valores monetarios
src/infrastructure/locks/lock.service.tsDistributed locks (Redlock)
src/infrastructure/websocket/websocket.gateway.tsEmissao de eventos WebSocket
src/presentation/controllers/case.controller.tsEndpoints CRUD de caixas
src/presentation/controllers/case-opening.controller.tsEndpoints de abertura
src/application/dto/case.dto.tsDTOs de caixas
src/application/dto/case-opening.dto.tsDTOs de abertura
prisma/schema.prismaModelos Case, CaseItem, CaseOpening, UserInventory

Documentação Técnica - ProCases