Skip to content

Sistema de Saque via Waxpeer (Skin Withdrawal)

Documentacao completa do sistema de saque de skins via integracao com a API P2P da Waxpeer. Este documento cobre cada detalhe do fluxo: desde o clique do usuario ate a entrega da skin no Steam, incluindo todos os edge cases, status codes, eventos WebSocket, endpoints HTTP, protecao de preco, rastreamento dual (WebSocket + Polling), validacoes, locks distribuidos e integracao com o frontend.


Indice

  1. Visao Geral
  2. Schema Completo (Prisma)
  3. Transicoes de Status (Maquina de Estados)
  4. Waxpeer Status Codes (0-6)
  5. Happy Path Completo (15 Passos)
  6. Edge Cases
  7. Protecao de Preco (Price Protection)
  8. Sistema Dual de Rastreamento
  9. Eventos WebSocket
  10. Endpoints HTTP
  11. Validacao de Trade URL
  12. Distributed Locking (Redlock)
  13. Integracao Frontend
  14. Codigo Morto (Dead Code)
  15. Debugging

1. Visao Geral

O sistema de saque permite que o usuario retire skins do inventario da plataforma e as receba diretamente no Steam. A plataforma atua como compradora no marketplace P2P da Waxpeer: quando o usuario solicita um saque, nos compramos a skin de um vendedor na Waxpeer usando a API buy-one-p2p-name, e o vendedor envia a skin diretamente para o Steam do usuario via trade offer.

Fluxo Resumido

Usuario clica "Sacar"
    |
    v
Backend verifica preco na Waxpeer (/v1/prices)
    |
    v
Preco Waxpeer <= maxPayCents (valueCents / margem)?
    |
   SIM --> Compra na Waxpeer (/v1/buy-one-p2p-name)
    |         |
    |         v
    |       Vendedor envia trade offer pro usuario
    |         |
    |         v
    |       Usuario aceita no Steam
    |         |
    |         v
    |       Skin entregue (COMPLETED)
    |
   NAO --> Retorna canWithdraw=false, sugere SWAP

Arquivos Envolvidos

ArquivoResponsabilidade
src/application/use-cases/inventory/request-withdraw-item.use-case.tsPonto de entrada principal, ~355 linhas. Valida item, consulta preco, executa compra
src/infrastructure/external-apis/waxpeer.service.tsIntegracao HTTP com API Waxpeer, ~430 linhas
src/infrastructure/external-apis/waxpeer-websocket.service.tsRastreamento real-time via WebSocket Waxpeer, ~350 linhas
src/application/services/waxpeer-withdrawal-tracker.service.tsPolling fallback a cada 30s, ~200 linhas
src/infrastructure/database/repositories/waxpeer-withdrawal.repository.tsRepositorio Prisma
src/domain/repositories/waxpeer-withdrawal.repository.interface.tsInterface do repositorio
src/infrastructure/websocket/websocket.gateway.tsEmissao de eventos WebSocket para o frontend
src/infrastructure/locks/lock.service.tsLock distribuido via Redlock
src/presentation/controllers/inventory.controller.tsController HTTP (4 endpoints de withdrawal)
client/app/lib/use-withdrawal-status.tsxHook React para consumir eventos WebSocket

2. Schema Completo (Prisma)

Enum WaxpeerWithdrawalStatus

prisma
enum WaxpeerWithdrawalStatus {
  PENDING_APPROVAL
  PENDING
  WAITING_SELLER
  TRADE_SENT
  COMPLETED
  CANCELLED
  FAILED
}

PENDING_APPROVAL e o estado inicial dos saques que nao passam na auto-aprovacao e ficam aguardando revisao de um admin (ver secao 5). Saques auto-aprovados nascem em PENDING.

Model WaxpeerWithdrawal

prisma
model WaxpeerWithdrawal {
  id                BigInt                  @id
  userId            BigInt                  @map("user_id")
  userInventoryId   BigInt                  @map("user_inventory_id")
  itemId            BigInt                  @map("item_id")
  waxpeerId         String?                 @unique @map("waxpeer_id")
  projectId         String                  @unique @map("project_id")
  tradeId           String?                 @map("trade_id")
  pricePaidCents    BigInt                  @map("price_paid_cents")
  skinValueUsdCents BigInt                  @default(0) @map("skin_value_usd_cents")
  status            WaxpeerWithdrawalStatus @default(PENDING)
  autoApproved      Boolean                 @default(false) @map("auto_approved")
  waxpeerStatus     Int?                    @map("waxpeer_status")
  failureReason     String?                 @map("failure_reason")
  metadata          Json?
  sendUntil         DateTime?               @map("send_until")
  createdAt         DateTime                @default(now()) @map("created_at")
  updatedAt         DateTime                @updatedAt @map("updated_at")
  completedAt       DateTime?               @map("completed_at")

  user              User                    @relation(fields: [userId], references: [id])
  userInventory     UserInventory           @relation(fields: [userInventoryId], references: [id])
  item              Item                    @relation(fields: [itemId], references: [id])

  @@map("waxpeer_withdrawals")
  @@index([userId, status])
  @@index([status, createdAt])
  @@index([waxpeerId])
  @@index([projectId])
  @@index([userInventoryId])
  @@index([userId, autoApproved, createdAt])
}

Detalhamento dos Campos

CampoTipoDescricao
idBigIntSnowflake ID gerado internamente
userIdBigIntFK para User. Dono do item
userInventoryIdBigIntFK para UserInventory. Item sendo sacado
itemIdBigIntFK para Item. Dados do item (nome, preco, etc.)
waxpeerIdString? (unique)ID retornado pela Waxpeer apos buy-one-p2p-name. Usado para rastreamento via check-many-steam. Pode ser null se a compra falhar antes de receber o ID
projectIdString (unique)ID gerado pelo nosso sistema no formato wd_{inventoryItemId}_{timestamp}. Usado para rastreamento via check-many-project-id
tradeIdString?Trade offer ID do Steam. Preenchido quando o vendedor envia a trade
pricePaidCentsBigIntPreco pago em centavos USD (convertido de Waxpeer coins: coins / 10)
skinValueUsdCentsBigIntSnapshot do valor da skin em centavos USD (item.valueCents) no momento do saque. Base do limite diario de auto-aprovacao
statusWaxpeerWithdrawalStatusStatus interno do nosso sistema
autoApprovedBooleantrue se o saque passou na auto-aprovacao (nasce PENDING); false se foi para revisao admin (PENDING_APPROVAL)
waxpeerStatusInt?Ultimo status numerico recebido da Waxpeer (0-6)
failureReasonString?Motivo da falha/cancelamento
metadataJson?Dados brutos da Waxpeer (resposta de compra, dados de trade, etc.)
sendUntilDateTime?Deadline para o vendedor enviar a trade. Se expirar, Waxpeer cancela
createdAtDateTimeQuando o registro foi criado
updatedAtDateTimeUltima atualizacao (auto-managed pelo Prisma)
completedAtDateTime?Quando a trade foi concluida com sucesso

Indices

  1. @@index([userId, status]) - Busca de withdrawals ativos por usuario
  2. @@index([status, createdAt]) - Polling de withdrawals pendentes (tracker)
  3. @@index([waxpeerId]) - Lookup por ID da Waxpeer (WebSocket events)
  4. @@index([projectId]) - Lookup por project ID
  5. @@index([userInventoryId]) - Verificar se item ja tem withdrawal ativo

Constraints Unique

  • waxpeerId (unique) - Garante 1:1 entre withdrawal e trade na Waxpeer
  • projectId (unique) - Garante unicidade do nosso tracking ID

3. Transicoes de Status (Maquina de Estados)

       canWithdraw = true  (item -> LOCKED)
                    |
         +----------+-----------+
         |                      |
   auto-aprovado           nao elegivel
   (eligible)              (admin review)
         |                      |
         v                      v
    +--------+          +------------------+
    | PENDING |          | PENDING_APPROVAL |
    +---+----+          +---------+--------+
        |                        |
        |                +-------+--------+
        |                |                |
        |          admin aprova     admin rejeita
        |                |                |
        |                v                v
        |         (compra Waxpeer)   +-----------+
        |                |           | CANCELLED |
        +-------+--------+           +-----------+
                |                   (item -> AVAILABLE)
      +---------+----------+
      |                    |
 Buy API OK           Buy API FAIL
      |                    |
      v                    v
      +-------+--------+     +-----+---+
      | WAITING_SELLER  |     | FAILED  |
      +-------+---------+     +---------+
              |                (item volta AVAILABLE)
     +--------+--------+
     |                  |
  Seller sends     Seller cancels /
  trade offer       timeout sendUntil
     |                  |
     v                  v
  +--+--------+    +----+------+
  | TRADE_SENT |    | CANCELLED |
  +--+---------+    +-----------+
     |              (item volta AVAILABLE)
     |
  +--+--------+---------+
  |           |          |
User       User       Trade
accepts   declines    fails
  |           |          |
  v           v          v
+----+   +---+-----+ +--+----+
|DONE|   |CANCELLED| |FAILED |
+----+   +---------+ +-------+
(COMPLETED)
(item = WITHDRAWN)

Tabela Completa de Transicoes

DeParaGatilhoEfeito no UserInventory
(criacao)PENDINGcanWithdraw + auto-aprovacao elegivelLOCKED
(criacao)PENDING_APPROVALcanWithdraw + auto-aprovacao nao elegivelLOCKED
PENDING_APPROVALPENDINGAdmin aprova (revalida preco e executa compra)Permanece LOCKED
PENDING_APPROVALCANCELLEDAdmin rejeita (exige reason)Volta para AVAILABLE
PENDINGWAITING_SELLERAPI buy-one-p2p-name retorna success: truePermanece LOCKED
PENDINGFAILEDAPI buy-one-p2p-name retorna success: falseVolta para AVAILABLE
WAITING_SELLERTRADE_SENTWaxpeer status muda para 2, 3 ou 4 (vendedor enviou trade)Permanece LOCKED
WAITING_SELLERCANCELLEDWaxpeer status muda para 6 / timeout de sendUntilVolta para AVAILABLE
TRADE_SENTCOMPLETEDWaxpeer status = 5, is_released = true, done = true ou stage = 9Muda para WITHDRAWN
TRADE_SENTCANCELLEDWaxpeer status = 6 com reason contendo "cancel"/"declined"Volta para AVAILABLE
TRADE_SENTFAILEDWaxpeer status = 6 com reason genericaVolta para AVAILABLE

Efeitos Colaterais por Status Final

COMPLETED:

  • UserInventory.status = WITHDRAWN
  • WaxpeerWithdrawal.completedAt = new Date()
  • Evento WebSocket withdrawal:completed emitido

CANCELLED / FAILED:

  • UserInventory.status = AVAILABLE (item retorna ao inventario)
  • WaxpeerWithdrawal.failureReason preenchido
  • Eventos WebSocket withdrawal:cancelled ou withdrawal:failed emitidos

4. Waxpeer Status Codes (0-6)

A Waxpeer usa codigos numericos inteiros para representar o estado de uma trade. Estes codigos sao recebidos tanto via WebSocket (steamTrade event) quanto via polling HTTP (check-many-steam).

CodigoSignificado WaxpeerMapeamento Interno
0Aguardando vendedor aceitarWAITING_SELLER
1Vendedor preparando envioWAITING_SELLER
2Trade offer enviada (aguardando usuario aceitar)TRADE_SENT
3Trade offer aceita (processando)TRADE_SENT
4Item em periodo de retencao / confirmacaoTRADE_SENT (ou COMPLETED se release_date presente no Tracker)
5Trade concluida com sucessoCOMPLETED
6Trade cancelada / falhouCANCELLED ou FAILED

Diferencas de Mapeamento: WebSocket vs Tracker

Existe uma diferenca sutil entre como o WebSocket e o Tracker mapeiam o status 4:

WebSocket (waxpeer-websocket.service.ts):

typescript
private mapWaxpeerStatusToOurStatus(
  waxpeerStatus: number,
  done: boolean,
  isReleased?: boolean,
  stage?: number,
): WaxpeerWithdrawalStatus {
  if (waxpeerStatus === 6) {
    return WaxpeerWithdrawalStatus.CANCELLED;
  }
  if (isReleased || waxpeerStatus === 5) {
    return WaxpeerWithdrawalStatus.COMPLETED;
  }
  if (stage === 9) {
    return WaxpeerWithdrawalStatus.COMPLETED;
  }
  if (done && waxpeerStatus !== 6) {
    return WaxpeerWithdrawalStatus.COMPLETED;
  }
  switch (waxpeerStatus) {
    case 0:
    case 1:
      return WaxpeerWithdrawalStatus.WAITING_SELLER;
    case 2:
    case 3:
    case 4:
      return WaxpeerWithdrawalStatus.TRADE_SENT;
    default:
      return WaxpeerWithdrawalStatus.PENDING;
  }
}

Tracker (waxpeer-withdrawal-tracker.service.ts):

typescript
private mapWaxpeerStatusToOurStatus(trade: WaxpeerTradeInfo): WaxpeerWithdrawalStatus {
  const waxpeerStatus = trade.status;

  if (waxpeerStatus === 6) {
    if (
      trade.reason?.toLowerCase().includes('cancel') ||
      trade.reason?.toLowerCase().includes('failed') ||
      trade.reason?.toLowerCase().includes('declined')
    ) {
      return 'CANCELLED';
    }
    return 'FAILED';
  }
  if (trade.is_released || waxpeerStatus === 5) {
    return 'COMPLETED';
  }
  if (waxpeerStatus === 4 && trade.release_date) {
    return 'COMPLETED';
  }
  if (trade.done && waxpeerStatus !== 6) {
    return 'COMPLETED';
  }
  if (waxpeerStatus === 2 || waxpeerStatus === 3 || waxpeerStatus === 4) {
    return 'TRADE_SENT';
  }
  if (waxpeerStatus === 0 || waxpeerStatus === 1) {
    return 'WAITING_SELLER';
  }
  return 'PENDING';
}

Diferencas Criticas entre os Dois Mapeamentos

SituacaoWebSocketTracker
Status 6 (cancelado)Sempre CANCELLEDCANCELLED se reason contem "cancel"/"failed"/"declined", senao FAILED
Status 4 com release_dateTRADE_SENTCOMPLETED
stage === 9COMPLETEDNao verifica stage (campo nao existe em WaxpeerTradeInfo)

Nota: O WebSocket recebe dados no formato WaxpeerSteamTradeData (com stage), enquanto o Tracker recebe WaxpeerTradeInfo (com release_date e reason). Estruturas diferentes da mesma API.


5. Happy Path Completo (15 Passos)

Passo 1: Usuario Clica "Sacar" no Frontend

O frontend faz POST /api/inventory/:id/withdraw onde :id e o userInventoryId.

Passo 2: Redlock Adquirido

O RequestWithdrawItemUseCase.execute() adquire um lock distribuido via Redlock:

typescript
async execute(userId: bigint, inventoryItemId: bigint): Promise<RequestWithdrawResult> {
  try {
    return await this.lockService.withWithdrawalLock(inventoryItemId, async () => {
      return await this.executeWithdrawal(userId, inventoryItemId);
    });
  } catch (error) {
    if (error.name === 'ExecutionError') {
      throw new ConflictException('Este item ja esta sendo processado. Por favor, aguarde.');
    }
    throw error;
  }
}

Lock key: lock:withdrawal:item:{inventoryItemId}, TTL: 30 segundos.

Passo 3: Validacoes

Executadas em sequencia:

  1. Item existe no inventario: userInventoryRepository.findById(inventoryItemId) - se nao, NotFoundException
  2. Item pertence ao usuario: inventoryItem.userId !== userId - se nao, BadRequestException
  3. Item nao e restrito a jogos: inventoryItem.restrictedToGames - itens da Loja/Shop nao podem ser sacados para a Steam, BadRequestException
  4. Status e AVAILABLE: inventoryItem.status !== 'AVAILABLE' - se nao, BadRequestException
  5. Nao existe withdrawal ativo: waxpeerWithdrawalRepository.findActiveByUserInventoryId() verifica status in: ['PENDING_APPROVAL', 'PENDING', 'WAITING_SELLER', 'TRADE_SENT', 'COMPLETED'] - se existe, BadRequestException
  6. Usuario existe: userRepository.findById(userId) - se nao, NotFoundException
  7. Trade URL cadastrada: user.tradeUrl deve existir - se nao, BadRequestException com mensagem "Cadastre seu Trade URL do Steam no perfil antes de retirar itens."
  8. Trade URL valida: Regex TRADE_URL_REGEX - se nao, BadRequestException com mensagem "Trade URL invalido. Atualize no perfil."

Passo 4: Consulta Preco na Waxpeer

typescript
waxpeerPrice = await this.waxpeerService.getItemPrice(item.marketHashName);

Chama GET /v1/prices?api={key}&game=csgo&search={marketHashName} com timeout de 15 segundos.

A resposta retorna uma lista de itens. O servico busca exact match pelo name:

typescript
const exactMatch = response.data.items.find((item) => item.name === marketHashName);

Passo 5: Comparacao de Preco

O teto de saque e calculado a partir do valor do item (USD, com margem) dividido pela margem dinamica:

typescript
const valueCentsUsd = Number(item.valueCents);
const priceMargin = await this.priceMarginService.getCurrent();
const maxPayCents = Math.ceil(valueCentsUsd / priceMargin);

const hasStock = waxpeerCount >= 1;
const isPriceValid = waxpeerMinPriceCents <= maxPayCents;
const canWithdraw = waxpeerPrice.isExactMatch && hasStock && isPriceValid;
  • isExactMatch: Nome do item encontrado exatamente (nao apenas similar)
  • hasStock: Pelo menos 1 unidade disponivel na Waxpeer
  • isPriceValid: Preco minimo na Waxpeer e menor ou igual ao maxPayCents (teto = valueCents / priceMargin, em USD)

Se qualquer condicao falhar, canWithdraw = false.

Passo 6: Lock de Auto-Aprovacao + ProjectId

Quando canWithdraw === true, a criacao do registro roda dentro de um lock por-usuario (withAutoApprovalLock(userId)) para serializar a avaliacao do limite diario e evitar race de dois saques simultaneos estourando o teto:

typescript
const skinValueUsdCents = BigInt(valueCentsUsd);

const decision = await this.lockService.withAutoApprovalLock(userId, async () => {
  const evaluation = await this.autoApprovalEvaluator.evaluate(userId, skinValueUsdCents);
  const projectId = `wd_${inventoryItemId}_${Date.now()}`;
  await this.userInventoryRepository.updateStatus(inventoryItem.id, 'LOCKED');
  // ... cria o withdrawal (Passo 8)
});

Formato do projectId: wd_{snowflakeId}_{timestampMs}. Exemplo: wd_1234567890123_1707654321000.

Passo 7: Item Trancado (LOCKED)

Dentro do lock, o item vai para LOCKED (nao pode mais ser vendido, trocado (swap) ou usado em upgrade/batalha), independente de auto-aprovacao ou revisao admin.

Passo 8: Registro de Withdrawal Criado (branch auto vs admin)

A elegibilidade (AutoApprovalEvaluatorService.evaluate) decide o status inicial e o flag autoApproved:

typescript
if (evaluation.eligible) {
  // AUTO-APROVADO
  await this.waxpeerWithdrawalRepository.create({
    userId,
    userInventoryId: inventoryItem.id,
    itemId: item.id,
    projectId,
    pricePaidCents: BigInt(waxpeerPrice.priceCentsUsd),
    skinValueUsdCents,       // snapshot do valor da skin (item.valueCents)
    status: 'PENDING',
    autoApproved: true,
  });
} else {
  // REVISAO ADMIN
  await this.waxpeerWithdrawalRepository.create({
    ...,
    status: 'PENDING_APPROVAL',
    autoApproved: false,
  });
}

O id e um Snowflake ID gerado pelo SnowflakeService.

Passo 9: Branch de Decisao (Auto-Aprovado vs Admin)

Fora do lock:

  • Auto-aprovado (autoApproved: true): chama runAutoPurchase() que delega ao WaxpeerPurchaseExecutorService.execute({ trigger: { kind: 'AUTO' } }) (segue para o Passo 10). A compra roda fora do lock de usuario (e lenta) — o registro ja criado garante que a proxima request veja o total diario atualizado.
  • Revisao admin (autoApproved: false): NAO compra agora. Emite withdrawal:pending_approval e audita SKIN_WITHDRAWAL_REQUESTED. A compra so acontece quando um admin aprovar (revalidando o preco ao vivo).
typescript
// caminho admin
this.webSocketGateway.emitWithdrawalPendingApproval(userId, {
  inventoryItemId: inventoryItem.id.toString(),
  itemName: item.name,
  projectId: decision.projectId,
  withdrawalId,
});

Nota: o evento withdrawal:pending NAO e mais emitido no fluxo atual (metodo emitWithdrawalPending sem callers — ver secao 14).

Passo 10: Compra na Waxpeer (via WaxpeerPurchaseExecutorService)

O executor seta o withdrawal para PENDING e chama:

typescript
const buyResult = await this.waxpeerService.buyItemByName(
  item.marketHashName,
  waxpeerPrice.priceWaxpeerCoins,
  projectId,
  tradePartner,
  tradeToken,
);

Chama GET /v1/buy-one-p2p-name com parametros:

  • api: API key
  • name: marketHashName do item
  • token: trade token do usuario (extraido da Trade URL)
  • partner: trade partner ID do usuario (extraido da Trade URL)
  • price: preco em Waxpeer coins (1000 coins = $1.00)
  • game: csgo
  • project_id: nosso projectId

Timeout: 30 segundos. Este mesmo executor e compartilhado pelo fluxo de aprovacao manual do admin.

Passo 11: Atualizacao para WAITING_SELLER

Se buyResult.success === true:

typescript
await this.waxpeerWithdrawalRepository.updateStatus(withdrawal.id, 'WAITING_SELLER', {
  waxpeerId: buyResult.id?.toString(),
  waxpeerStatus: 0,
  sendUntil: buyResult.send_until ? new Date(buyResult.send_until * 1000) : undefined,
  metadata: buyResult,
});

O waxpeerId e o ID retornado pela Waxpeer (identificador da trade no sistema deles). O sendUntil e um timestamp Unix que indica ate quando o vendedor tem para enviar a trade offer. Falha na compra → FAILED, item volta AVAILABLE, emite withdrawal:failed.

Passo 12: Evento WebSocket WAITING_SELLER Emitido

typescript
this.webSocketGateway.emitWithdrawalWaitingSeller(userId, {
  inventoryItemId: inventoryItem.id.toString(),
  sellerName: undefined,
  sendUntil: buyResult.send_until,
});

Nota: sellerName e undefined neste ponto. O nome do vendedor so chega via WebSocket da Waxpeer posteriormente.

Passo 13: Vendedor Envia Trade Offer (Monitorado por WebSocket + Polling)

O Waxpeer WebSocket emite evento steamTrade com item.status = 2 (trade offer enviada). O WaxpeerWebSocketService processa e atualiza para TRADE_SENT. Paralelamente, o WaxpeerWithdrawalTrackerService verifica a cada 30 segundos via check-many-steam.

Passo 14: Usuario Aceita no Steam

Quando o usuario aceita a trade offer no Steam, o Waxpeer detecta e atualiza o status. O item.status muda para 5 ou done = true ou is_released = true.

Passo 15: Finalizacao (COMPLETED)

typescript
await this.waxpeerWithdrawalRepository.updateStatus(withdrawal.id, newStatus, {
  waxpeerStatus: item.status,
  tradeId: item.trade_id || undefined,
  completedAt: new Date(),
  metadata: { ... },
});

await this.userInventoryRepository.updateStatus(
  withdrawal.userInventoryId,
  InventoryItemStatus.WITHDRAWN,
);

O inventario do item muda de LOCKED para WITHDRAWN (estado final, irreversivel). Evento withdrawal:completed e emitido via WebSocket.


6. Edge Cases

6.1 Preco na Waxpeer MAIOR que nosso maxPayCents

Cenario: Teto de saque do item e $10.00 (maxPayCents = 1000, calculado de valueCents / priceMargin), mas na Waxpeer o mais barato esta $12.00.

Comportamento:

typescript
const isPriceValid = waxpeerMinPriceCents <= maxPayCents; // false
const canWithdraw = waxpeerPrice.isExactMatch && hasStock && isPriceValid; // false

Resultado: canWithdraw = false. Se isExactMatch = true, entao canSwap = true e o frontend exibe:

"Item indisponivel para saque. Voce pode trocar no SWAP."

O item permanece AVAILABLE. Nenhum registro de withdrawal e criado. O usuario pode usar o item no SWAP.

6.2 Sem Estoque na Waxpeer

Cenario: Item encontrado pelo nome, mas count = 0.

Comportamento:

typescript
const hasStock = waxpeerCount >= 1; // false
const canWithdraw = false;

Resultado: canWithdraw = false, canSwap = true (se nome exato encontrado). Mensagem:

"Item indisponivel para saque. Voce pode trocar no SWAP."

6.3 Nome Nao Encontrado Exatamente

Cenario: marketHashName retorna resultados similares mas nao exatos na busca /v1/prices.

Comportamento: O servico retorna o primeiro item encontrado mas marca isExactMatch = false:

typescript
if (!exactMatch) {
  const firstItem = response.data.items[0];
  return {
    name: firstItem.name,
    // ...
    isExactMatch: false,
    allResults: response.data.items,
  };
}

Resultado: canWithdraw = false, canSwap = false. Mensagem:

"Item indisponivel para saque no momento."

6.4 API de Compra Falha

Cenario: buy-one-p2p-name retorna success: false (preco mudou, item vendido, saldo Waxpeer insuficiente, etc.).

Comportamento:

typescript
if (!buyResult.success) {
  await this.waxpeerWithdrawalRepository.updateStatus(withdrawal.id, 'FAILED', {
    failureReason: buyResult.error,
  });
  await this.userInventoryRepository.updateStatus(inventoryItem.id, 'AVAILABLE');
  this.webSocketGateway.emitWithdrawalFailed(userId, {
    inventoryItemId: inventoryItem.id.toString(),
    reason: buyResult.error || 'Erro desconhecido ao processar compra',
  });
}

Resultado: Withdrawal marcado como FAILED. Item volta para AVAILABLE. Evento withdrawal:failed emitido. O registro permanece no banco para historico. O usuario pode tentar novamente.

6.5 Vendedor Cancela ou Timeout (sendUntil)

Cenario: O vendedor nao envia a trade offer dentro do prazo sendUntil, ou cancela a trade.

Comportamento: A Waxpeer atualiza o status para 6. Nosso sistema detecta via WebSocket ou Polling:

WebSocket:

typescript
if (waxpeerStatus === 6) {
  return WaxpeerWithdrawalStatus.CANCELLED;
}

Tracker (com analise de reason):

typescript
if (waxpeerStatus === 6) {
  if (trade.reason?.toLowerCase().includes('cancel') || ...) {
    return 'CANCELLED';
  }
  return 'FAILED';
}

Resultado: Item volta para AVAILABLE. Evento withdrawal:cancelled ou withdrawal:failed emitido.

6.6 Usuario Recusa Trade Offer no Steam

Cenario: O vendedor enviou a trade, mas o usuario clicou "Recusar" no Steam.

Comportamento: Waxpeer detecta a recusa e muda status para 6 com reason "declined". O Tracker diferencia:

typescript
if (trade.reason?.toLowerCase().includes('declined')) {
  return 'CANCELLED';
}

Resultado: Item volta para AVAILABLE. O usuario pode solicitar saque novamente.

6.7 WebSocket da Waxpeer Desconecta

Cenario: A conexao WebSocket com wss://waxpeer.com cai.

Comportamento: Reconexao com backoff linear:

typescript
private scheduleReconnect() {
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
    this.logger.error('[WaxpeerWS] Max reconnection attempts reached');
    return;
  }
  this.reconnectAttempts++;
  const delay = this.reconnectDelay * this.reconnectAttempts;
  setTimeout(() => { this.connect(); }, delay);
}

Configuracao:

  • reconnectDelay: 5000ms (5 segundos base)
  • maxReconnectAttempts: 10
  • Delay real: 5000 * attempt (5s, 10s, 15s, 20s, ..., 50s)
  • Delay maximo: 50 segundos (tentativa 10)

Nota: Este e um backoff linear, nao exponencial. O delay cresce em 5000ms por tentativa.

Mitigacao: O WaxpeerWithdrawalTrackerService (polling a cada 30s) serve como fallback. Se o WebSocket cair, o polling continua rastreando todos os withdrawals pendentes.

6.8 Item Preso em LOCKED sem waxpeerId (Bug Conhecido)

Cenario: O item foi trancado (LOCKED) e o registro de withdrawal criado com status PENDING, mas a chamada buy-one-p2p-name lanca uma excecao nao tratada (timeout de rede, crash do processo, etc.) ANTES de atualizar o status para WAITING_SELLER ou FAILED.

Consequencia:

  • UserInventory.status = LOCKED
  • WaxpeerWithdrawal.status = PENDING
  • WaxpeerWithdrawal.waxpeerId = null

O Tracker ignora withdrawals sem waxpeerId:

typescript
const waxpeerIds = pendingWithdrawals
  .filter((w) => w.waxpeerId)
  .map((w) => w.waxpeerId as string);

if (waxpeerIds.length === 0) {
  this.logger.debug('No withdrawals with waxpeerId to check');
  return;
}

Resultado: O item fica permanentemente LOCKED e o withdrawal fica PENDING para sempre. O usuario nao consegue vender, trocar ou sacar o item novamente.

Solucao manual: Atualizar o withdrawal para FAILED e o inventory item para AVAILABLE via banco de dados ou endpoint admin.

6.9 Requisicoes Concorrentes no Mesmo Item (Redlock)

Cenario: Usuario clica "Sacar" duas vezes rapidamente (double-click ou requisicao duplicada).

Comportamento: O Redlock impede execucao concorrente:

typescript
return await this.lockService.withWithdrawalLock(inventoryItemId, async () => {
  return await this.executeWithdrawal(userId, inventoryItemId);
});

A segunda requisicao tenta adquirir o lock lock:withdrawal:item:{id} que ja esta adquirido. Apos 10 tentativas com retry de 200ms + jitter, o Redlock lanca ExecutionError:

typescript
if (error.name === 'ExecutionError') {
  throw new ConflictException('Este item ja esta sendo processado. Por favor, aguarde.');
}

Resultado: HTTP 409 Conflict para a segunda requisicao.

6.10 API Waxpeer Retorna Erro ou Timeout ao Consultar Preco

Cenario: A chamada getItemPrice() falha (timeout 15s, erro HTTP, etc.).

Comportamento: O erro e capturado e priceComparison e construido com valores zerados:

typescript
catch (error) {
  priceComparison = {
    ourPriceCentsUsd: valueCentsUsd,
    ourPriceUsd: `$${(valueCentsUsd / 100).toFixed(2)}`,
    maxPayCents,
    maxPayUsd: `$${(maxPayCents / 100).toFixed(2)}`,
    waxpeerMinPriceCents: 0,
    waxpeerMinPriceUsd: '$0.00',
    waxpeerCount: 0,
    hasStock: false,
    isPriceValid: false,
    canWithdraw: false,
    canSwap: false,
    failedConditions: [`Erro ao consultar Waxpeer: ${error.message}`],
    reason: `❌ NÃO PODE RETIRAR - Erro ao consultar Waxpeer: ${error.message}`,
    userMessage: 'Item indisponível para saque no momento.',
  };
}

Resultado: canWithdraw = false. Nenhum registro criado. Item permanece AVAILABLE. A resposta HTTP contem o erro para o frontend exibir.


7. Protecao de Preco (Price Protection)

maxPayCents como Teto

O teto de saque e derivado do valor do item (USD, com margem) e da margem dinamica: maxPayCents = ceil(valueCents / priceMargin). E o preco maximo que estamos dispostos a pagar na Waxpeer — na pratica, o preco bruto do item sem a margem da casa.

typescript
const valueCentsUsd = Number(item.valueCents);
const priceMargin = await this.priceMarginService.getCurrent();
const maxPayCents = Math.ceil(valueCentsUsd / priceMargin);
const isPriceValid = waxpeerMinPriceCents <= maxPayCents;

Conversao de Moedas Waxpeer

A Waxpeer usa "coins" como unidade monetaria. A conversao e:

1000 Waxpeer coins = $1.00 USD = 100 cents USD

Portanto:

typescript
priceCentsUsd: Math.round(exactMatch.min / 10),

Exemplo: Item com min: 13464 (Waxpeer coins) = $13.46 USD = 1346 cents USD.

Oportunidade de Arbitragem

Se o preco na Waxpeer e menor que nosso maxPayCents, a diferenca e lucro para a plataforma:

Nosso teto: $10.00 (maxPayCents = valueCents / priceMargin)
Preco Waxpeer: $8.50 (850 cents)
Economia: $1.50 por saque

O sistema sempre compra pelo preco minimo disponivel na Waxpeer (min field), que pode ser significativamente menor que o nosso teto.

Campos de Preco na Resposta da API

A comparacao de precos e retornada ao frontend na propriedade priceComparison:

typescript
interface PriceComparison {
  ourPriceCentsUsd: number;        // valueCents do item (USD, com margem)
  ourPriceUsd: string;             // Formatado: "$10.50"
  maxPayCents: number;             // Teto = ceil(valueCents / priceMargin)
  maxPayUsd: string;               // Formatado: "$10.00"
  waxpeerMinPriceCents: number;    // Preco minimo Waxpeer em cents USD
  waxpeerMinPriceUsd: string;      // Formatado: "$8.50"
  marginPercent?: number;          // Margem efetiva ((1 - 1/priceMargin) * 100)
  waxpeerCount: number;            // Unidades disponiveis
  hasStock: boolean;               // count >= 1
  isPriceValid: boolean;           // waxpeerMin <= maxPayCents
  canWithdraw: boolean;            // exactMatch && hasStock && isPriceValid
  canSwap: boolean;                // exactMatch (independente de preco/estoque)
  failedConditions: string[];      // Lista de condicoes que falharam
  reason: string;                  // Explicacao completa
  userMessage: string;             // Mensagem para o usuario
}

8. Sistema Dual de Rastreamento

O sistema utiliza duas estrategias complementares para rastrear o status de withdrawals:

8.1 WebSocket Real-Time (Primario)

Arquivo: src/infrastructure/external-apis/waxpeer-websocket.service.ts

Conexao:

typescript
this.socket = io('wss://waxpeer.com', {
  path: '/socket.io/',
  transports: ['websocket'],
  extraHeaders: {
    authorization: this.apiKey,
  },
  reconnection: false, // Reconexao manual
});

Evento escutado: steamTrade

Estrutura do evento:

typescript
interface WaxpeerSteamTradeEvent {
  type: 'create' | 'update';
  data: WaxpeerSteamTradeData;
}

interface WaxpeerSteamTradeData {
  id: string;
  costum_id?: string;       // Nota: typo da Waxpeer, nao e "custom_id"
  stage: number;
  send_until: string;
  release_date?: string;
  is_released?: boolean;
  last_updated: string;
  done: boolean;
  boxed: string;
  creator: string;
  now: string;
  user: {
    id: string;
    name: string;
    avatar: string;
    can_p2p: boolean;
    kyc_status: string;
  };
  seller: {
    id: string;
    name: string;
    avatar: string;
    can_p2p: boolean;
    kyc_status: string | null;
    shop: string;
    auto: boolean;
    success_trades: number;
    failed_trades: number;
  };
  items: WaxpeerTradeItem[];
}

interface WaxpeerTradeItem {
  id: number;
  price: number;
  name: string;
  status: number;        // 0-6
  merchant: string;
  item_id: number;
  trade_id: string | null;
  image: string;
  sent_time: string | null;
}

Fluxo de processamento:

  1. Recebe evento steamTrade
  2. Itera sobre data.items[]
  3. Para cada item, busca withdrawal por waxpeerId = item.id.toString()
  4. Mapeia status Waxpeer para nosso status
  5. Atualiza banco se houve mudanca
  6. Atualiza UserInventory se necessario
  7. Emite evento WebSocket para o frontend

Lifecycle:

  • onModuleInit(): Conecta automaticamente se WAXPEER_API_KEY estiver configurada
  • onModuleDestroy(): Desconecta

8.2 Polling Cron (Fallback)

Arquivo: src/application/services/waxpeer-withdrawal-tracker.service.ts

Frequencia: A cada 30 segundos (@Cron(CronExpression.EVERY_30_SECONDS))

Fluxo:

  1. Busca ate 100 withdrawals com status in: ['PENDING', 'WAITING_SELLER', 'TRADE_SENT']
  2. Filtra apenas os que tem waxpeerId (ignora PENDING sem waxpeerId)
  3. Chama GET /v1/check-many-steam?api={key}&id={id1}&id={id2}&... (maximo 100 IDs)
  4. Para cada trade retornada, compara status e atualiza se mudou
  5. Emite eventos WebSocket para o frontend

Guard de concorrencia:

typescript
private isProcessing = false;

@Cron(CronExpression.EVERY_30_SECONDS)
async checkPendingWithdrawals() {
  if (this.isProcessing) {
    this.logger.debug('Already processing, skipping...');
    return;
  }
  this.isProcessing = true;
  try { ... }
  finally { this.isProcessing = false; }
}

Force Check: Existe um metodo para verificacao manual sob demanda:

typescript
async forceCheckWithdrawal(withdrawalId: bigint): Promise<void> {
  const withdrawal = await this.waxpeerWithdrawalRepository.findById(withdrawalId);
  if (!withdrawal) throw new Error('Withdrawal not found');
  if (!withdrawal.waxpeerId) throw new Error('Withdrawal has no waxpeerId');
  const result = await this.waxpeerService.checkTradeStatus([withdrawal.waxpeerId]);
  if (!result.success || result.trades.length === 0) throw new Error('Failed to check trade status');
  await this.processTradeUpdate(result.trades[0], [withdrawal]);
}

8.3 API Waxpeer para Polling

Endpoint principal usado: GET /v1/check-many-steam

typescript
async checkTradeStatus(waxpeerIds: string[]): Promise<WaxpeerCheckTradesResult> {
  // Limita a 100 IDs por chamada
  if (waxpeerIds.length > 100) {
    waxpeerIds = waxpeerIds.slice(0, 100);
  }
  const queryParams = new URLSearchParams();
  queryParams.append('api', this.apiKey);
  waxpeerIds.forEach((id) => queryParams.append('id', id));
  const url = `${this.baseUrl}/check-many-steam?${queryParams.toString()}`;
  // timeout: 30000ms
}

Resposta:

typescript
interface WaxpeerTradeInfo {
  id: number;
  price: number;
  name: string;
  status: number;          // 0-6
  project_id?: string;
  custom_id?: string;
  trade_id?: string;
  done: boolean;
  for_steamid64?: string;
  reason?: string;
  seller_name?: string;
  seller_avatar?: string;
  seller_steam_joined?: number;
  seller_steam_level?: number;
  send_until?: number;
  last_updated?: number;
  counter?: number;
  is_released?: boolean;
  release_date?: string;
  penalties?: {
    rollback_fee?: number;
    rollback_penalty?: number;
    total_penalty?: number;
  };
}

8.4 Porque Dual Tracking

O WebSocket fornece atualizacoes em tempo real (latencia de milissegundos), mas pode desconectar. O Polling garante que nenhuma mudanca seja perdida, mesmo que o WebSocket fique offline por minutos. As duas fontes convergem para o mesmo estado no banco de dados, e ambas verificam se o status realmente mudou antes de atualizar:

WebSocket:

typescript
const dbUnchanged = oldStatus === newStatus && withdrawal.waxpeerStatus === item.status;
if (!dbUnchanged) { /* atualiza */ }

Tracker:

typescript
if (newStatus === oldStatus && trade.status === withdrawal.waxpeerStatus) {
  return; // Skip, nada mudou
}

9. Eventos WebSocket

Todos os eventos de withdrawal sao emitidos para a sala privada user:{userId}. Apenas o usuario autenticado recebe.

9.1 withdrawal:pending

Emitido por: nenhum caller no fluxo atual. O metodo emitWithdrawalPending existe no gateway mas nao e mais chamado (ver secao 14) — o saque auto-aprovado vai direto para withdrawal:waiting_seller e o saque nao elegivel vai para withdrawal:pending_approval.

Payload:

typescript
{
  inventoryItemId: string;  // ID do UserInventory
  itemName: string;         // Nome do item
  projectId: string;        // Nosso tracking ID (ex: "wd_123_1707654321000")
}

9.1b withdrawal:pending_approval

Emitido por: RequestWithdrawItemUseCase (quando o saque nao passa na auto-aprovacao e vai para revisao do admin)

Payload:

typescript
{
  inventoryItemId: string;  // ID do UserInventory
  itemName: string;         // Nome do item
  projectId: string;        // Nosso tracking ID
  withdrawalId: string;     // ID do WaxpeerWithdrawal (status PENDING_APPROVAL)
}

9.2 withdrawal:processing

Emitido por: emitWithdrawalProcessing() (metodo existe mas NAO e chamado no fluxo atual - ver secao 14)

Payload:

typescript
{
  inventoryItemId: string;
  waxpeerId?: string;
  sendUntil?: number;      // Unix timestamp
}

9.3 withdrawal:waiting_seller

Emitido por: RequestWithdrawItemUseCase (apos compra bem-sucedida), WaxpeerWebSocketService (update de status), WaxpeerWithdrawalTrackerService (polling)

Payload:

typescript
{
  inventoryItemId: string;
  sellerName?: string;      // Nome do vendedor na Waxpeer (pode ser undefined)
  sendUntil?: number;       // Unix timestamp - deadline para envio
}

9.4 withdrawal:trade_sent

Emitido por: WaxpeerWebSocketService, WaxpeerWithdrawalTrackerService

Payload:

typescript
{
  inventoryItemId: string;
  tradeId?: string;         // Steam trade offer ID
}

9.5 withdrawal:completed

Emitido por: WaxpeerWebSocketService, WaxpeerWithdrawalTrackerService

Payload:

typescript
{
  inventoryItemId: string;
  itemName: string;         // Nome do item entregue
}

9.6 withdrawal:cancelled

Emitido por: WaxpeerWebSocketService, WaxpeerWithdrawalTrackerService

Payload:

typescript
{
  inventoryItemId: string;
  reason: string;           // Ex: "Trade cancelled by Waxpeer", "Cancelado pelo vendedor ou timeout"
}

9.7 withdrawal:failed

Emitido por: RequestWithdrawItemUseCase (compra falhou), WaxpeerWebSocketService, WaxpeerWithdrawalTrackerService

Payload:

typescript
{
  inventoryItemId: string;
  reason: string;           // Ex: "Erro desconhecido ao processar compra", "Trade failed"
}

10. Endpoints HTTP

Todos os endpoints ficam sob o controller InventoryController, prefixo /inventory, e requerem autenticacao (@UseGuards(AuthGuard)).

10.1 POST /inventory/:id/withdraw

Descricao: Solicita saque de um item do inventario.

Parametros:

  • :id (path) - ID do UserInventory (BigInt como string)

Headers:

  • Cookie: sessionId=...

Response (sucesso na compra):

json
{
  "success": true,
  "canWithdraw": true,
  "inventoryItem": {
    "id": "1234567890123",
    "userId": "9876543210",
    "itemId": "5555555555",
    "status": "LOCKED",
    "acquiredAt": "2024-01-15T10:30:00.000Z",
    "acquiredFrom": "CASE_OPENING"
  },
  "item": {
    "id": "5555555555",
    "name": "AK-47 | Redline (Field-Tested)",
    "marketHashName": "AK-47 | Redline (Field-Tested)",
    "imageUrl": "https://...",
    "rarity": "CLASSIFIED",
    "valueCentsBrl": "4500",
    "originalPriceCents": "850",
    "marginPercent": 15,
    "weaponCategory": "RIFLE",
    "exterior": "FT",
    "isStatTrak": false
  },
  "waxpeerPrice": {
    "name": "AK-47 | Redline (Field-Tested)",
    "count": 142,
    "priceWaxpeerCoins": 7500,
    "priceCentsUsd": 750,
    "steamPriceWaxpeerCoins": 9000,
    "imageUrl": "https://...",
    "isExactMatch": true,
    "allResults": [...]
  },
  "priceComparison": {
    "ourPriceCentsUsd": 1000,
    "ourPriceUsd": "$10.00",
    "maxPayCents": 850,
    "maxPayUsd": "$8.50",
    "waxpeerMinPriceCents": 750,
    "waxpeerMinPriceUsd": "$7.50",
    "marginPercent": 15.0,
    "waxpeerCount": 142,
    "hasStock": true,
    "isPriceValid": true,
    "canWithdraw": true,
    "canSwap": true,
    "failedConditions": [],
    "reason": "✅ PRONTO PARA RETIRADA - Todas as condicoes atendidas",
    "userMessage": ""
  },
  "purchase": {
    "success": true,
    "waxpeerId": "98765432",
    "projectId": "wd_1234567890123_1707654321000",
    "pricePaid": 7500,
    "pricePaidUsd": "$7.50",
    "name": "AK-47 | Redline (Field-Tested)",
    "sendUntil": 1707740721,
    "averageTime": 180
  },
  "withdrawalId": "1111111111111"
}

Response (nao pode sacar):

json
{
  "success": false,
  "canWithdraw": false,
  "inventoryItem": { ... },
  "item": { ... },
  "waxpeerPrice": { ... },
  "priceComparison": {
    "canWithdraw": false,
    "canSwap": true,
    "failedConditions": ["Preço Waxpeer ($12.00) excede o máximo ($8.50) — nosso preço: $10.00 com margem 15%"],
    "userMessage": "Item indisponivel para saque. Voce pode trocar no SWAP."
  },
  "purchase": null,
  "withdrawalId": undefined
}

Erros:

  • 404 Not Found - Item nao encontrado no inventario
  • 400 Bad Request - Item nao pertence ao usuario / Status nao e AVAILABLE / Withdrawal ativo existe / Trade URL nao cadastrada / Trade URL invalida
  • 409 Conflict - Item ja sendo processado (Redlock)

10.2 GET /inventory/withdrawals/active

Descricao: Retorna todos os withdrawals ativos do usuario (status PENDING, WAITING_SELLER ou TRADE_SENT).

Response:

json
{
  "withdrawals": [
    {
      "id": "1111111111111",
      "inventoryItemId": "1234567890123",
      "itemId": "5555555555",
      "itemName": "AK-47 | Redline (Field-Tested)",
      "itemImage": "https://...",
      "itemRarity": "CLASSIFIED",
      "itemValueCents": "4500",
      "projectId": "wd_1234567890123_1707654321000",
      "waxpeerId": "98765432",
      "tradeId": null,
      "status": "WAITING_SELLER",
      "waxpeerStatus": 1,
      "pricePaidCents": "750",
      "sendUntil": "2024-02-12T10:30:00.000Z",
      "createdAt": "2024-02-11T10:30:00.000Z"
    }
  ]
}

10.3 POST /inventory/withdrawals/:id/refresh

Descricao: Forca uma verificacao imediata do status de um withdrawal via API Waxpeer (bypass do cron de 30s).

Parametros:

  • :id (path) - ID do WaxpeerWithdrawal

Response:

json
{
  "id": "1111111111111",
  "status": "TRADE_SENT",
  "waxpeerStatus": 2,
  "tradeId": "5432109876"
}

Erros:

  • Error: Withdrawal not found - ID invalido ou pertence a outro usuario
  • Error: Withdrawal has no waxpeerId - Withdrawal sem ID Waxpeer (bug 6.8)
  • Error: Failed to check trade status - API Waxpeer falhou

10.4 GET /inventory/withdrawals/history

Descricao: Retorna historico completo de withdrawals do usuario (todos os status).

Query params:

  • limit (opcional, default: 50) - Maximo de registros
  • offset (opcional, default: 0) - Paginacao

Response:

json
{
  "withdrawals": [
    {
      "id": "1111111111111",
      "inventoryItemId": "1234567890123",
      "itemId": "5555555555",
      "itemName": "AK-47 | Redline (Field-Tested)",
      "itemImage": "https://...",
      "itemRarity": "CLASSIFIED",
      "itemValueCents": "4500",
      "projectId": "wd_1234567890123_1707654321000",
      "waxpeerId": "98765432",
      "tradeId": "5432109876",
      "status": "COMPLETED",
      "waxpeerStatus": 5,
      "pricePaidCents": "750",
      "failureReason": null,
      "sendUntil": "2024-02-12T10:30:00.000Z",
      "createdAt": "2024-02-11T10:30:00.000Z",
      "completedAt": "2024-02-11T10:35:00.000Z"
    }
  ]
}

11. Validacao de Trade URL

Regex

typescript
const TRADE_URL_REGEX =
  /^https:\/\/steamcommunity\.com\/tradeoffer\/new\/\?partner=(\d+)&token=([a-zA-Z0-9_-]+)$/;

Formato Esperado

https://steamcommunity.com/tradeoffer/new/?partner=123456789&token=AbCdEfGh

Extracao de Dados

typescript
const tradeUrlMatch = user.tradeUrl.match(TRADE_URL_REGEX);
const tradePartner = tradeUrlMatch[1]; // Grupo 1: partner ID (string numerica)
const tradeToken = tradeUrlMatch[2];   // Grupo 2: token (alfanumerico + _ -)

O tradePartner e o SteamID32 do usuario. Junto com o tradeToken, sao enviados para a API Waxpeer buy-one-p2p-name como parametros partner e token.

Calculo SteamID64 a partir do Partner

O partner ID (SteamID32) pode ser convertido para SteamID64:

SteamID64 = SteamID32 + 76561197960265728

Exemplo: Partner 123456789 = SteamID64 76561198083722517.

Validacao via API Waxpeer

Alem da regex local, existe um metodo checkTradeLink() que valida a trade URL diretamente na Waxpeer:

typescript
async checkTradeLink(tradelink: string): Promise<WaxpeerCheckTradeLinkResult> {
  const url = `${this.baseUrl}/check-tradelink?api=${this.apiKey}`;
  const response = await firstValueFrom(
    this.httpService.post<WaxpeerCheckTradeLinkResponse>(
      url,
      { tradelink },
      { timeout: 15000 },
    ),
  );
  return {
    success: response.data.success,
    steamid64: response.data.steamid64,
    steamid32: response.data.steamid32,
  };
}

Nota: Este metodo nao e chamado durante o fluxo de saque. Ele existe para validacao avulsa (ex: quando o usuario cadastra a trade URL no perfil).


12. Distributed Locking (Redlock)

Configuracao

typescript
// lock.service.ts
this.redlock = new Redlock([client], {
  driftFactor: 0.01,
  retryCount: 10,
  retryDelay: 200,
  retryJitter: 200,
  automaticExtensionThreshold: 500,
});
ParametroValorDescricao
driftFactor0.01Fator de drift para calculo de TTL efetivo
retryCount10Tentativas para adquirir o lock
retryDelay200msDelay base entre tentativas
retryJitter200msJitter aleatorio adicionado ao delay
automaticExtensionThreshold500msEstende lock automaticamente se restam < 500ms

Lock de Withdrawal

typescript
async withWithdrawalLock<T>(inventoryItemId: bigint, callback: () => Promise<T>): Promise<T> {
  return this.withLock(`withdrawal:item:${inventoryItemId}`, callback, 30000);
}
  • Key: lock:withdrawal:item:{inventoryItemId}
  • TTL: 30000ms (30 segundos)
  • Tempo maximo de espera: ~10 * (200 + 200) = ~4 segundos (10 retries * ~400ms delay)

Tempo Total Possivel

O fluxo dentro do lock inclui:

  1. Validacoes de banco (~50ms)
  2. Consulta preco Waxpeer (~1-5s, timeout 15s)
  3. Compra Waxpeer (~1-10s, timeout 30s)
  4. Updates de banco (~50ms)

No pior caso, a operacao pode levar ate ~15s (timeout de preco) + ~30s (timeout de compra) = 45s, que excede o TTL de 30s. Porem, o automaticExtensionThreshold de 500ms estende o lock automaticamente enquanto o callback estiver executando.


13. Integracao Frontend

Hook useWithdrawalStatus

Arquivo: client/app/lib/use-withdrawal-status.tsx

Tipos:

typescript
type WithdrawalStatus =
  | 'pending'
  | 'processing'
  | 'waiting_seller'
  | 'trade_sent'
  | 'completed'
  | 'cancelled'
  | 'failed';

interface WithdrawalEvent {
  inventoryItemId: string;
  itemName?: string;
  projectId?: string;
  waxpeerId?: string;
  sendUntil?: number;
  sellerName?: string;
  tradeId?: string;
  reason?: string;
}

Uso:

typescript
function InventoryPage() {
  const { isConnected } = useWithdrawalStatus({
    onPending: (data) => {
      // Item comecou processo de saque
      updateItemStatus(data.inventoryItemId, 'pending');
    },
    onWaitingSeller: (data) => {
      // Compra feita, aguardando vendedor
      updateItemStatus(data.inventoryItemId, 'waiting_seller');
      if (data.sendUntil) {
        showCountdown(data.sendUntil);
      }
    },
    onTradeSent: (data) => {
      // Vendedor enviou trade offer
      updateItemStatus(data.inventoryItemId, 'trade_sent');
      if (data.tradeId) {
        showTradeLink(data.tradeId);
      }
    },
    onCompleted: (data) => {
      // Entregue com sucesso
      removeFromInventory(data.inventoryItemId);
      showSuccessToast(`${data.itemName} sacado com sucesso!`);
    },
    onCancelled: (data) => {
      // Cancelado
      updateItemStatus(data.inventoryItemId, 'available');
      showWarningToast(data.reason);
    },
    onFailed: (data) => {
      // Falhou
      updateItemStatus(data.inventoryItemId, 'available');
      showErrorToast(data.reason);
    },
    onStatusChange: (status, data) => {
      // Callback generico para qualquer mudanca
      console.log(`Withdrawal ${data.inventoryItemId}: ${status}`);
    },
  });
}

Eventos WebSocket Escutados:

typescript
socket.on('withdrawal:pending', handlePending);
socket.on('withdrawal:processing', handleProcessing);
socket.on('withdrawal:waiting_seller', handleWaitingSeller);
socket.on('withdrawal:trade_sent', handleTradeSent);
socket.on('withdrawal:completed', handleCompleted);
socket.on('withdrawal:cancelled', handleCancelled);
socket.on('withdrawal:failed', handleFailed);

Cleanup: Os listeners sao removidos no return do useEffect quando o componente desmonta ou as dependencias mudam.


14. Codigo Morto (Dead Code)

14.1 emitWithdrawalProcessing()

Arquivo: src/infrastructure/websocket/websocket.gateway.ts

typescript
emitWithdrawalProcessing(
  userId: bigint,
  data: {
    inventoryItemId: string;
    waxpeerId?: string;
    sendUntil?: number;
  },
) {
  const roomName = `user:${userId}`;
  this.logger.log(`[Withdrawal] Processing for user ${userId}, waxpeerId: ${data.waxpeerId}`);
  this.server.to(roomName).emit('withdrawal:processing', data);
}

Este metodo existe no gateway mas nao e chamado por nenhum servico. O fluxo vai direto de PENDING para WAITING_SELLER. O evento withdrawal:processing nunca e emitido no backend atual.

No frontend, o hook useWithdrawalStatus escuta withdrawal:processing e tem um callback onProcessing, mas ele nunca sera acionado com o codigo atual do backend.

14.2 checkTradeStatusByProjectId()

Arquivo: src/infrastructure/external-apis/waxpeer.service.ts

typescript
async checkTradeStatusByProjectId(projectIds: string[]): Promise<WaxpeerCheckTradesResult> {
  // Chama GET /v1/check-many-project-id
  // ...
}

Este metodo existe no WaxpeerService mas nao e chamado por nenhum servico. O Tracker usa checkTradeStatus() (por waxpeerId), nao checkTradeStatusByProjectId().

O metodo poderia ser util para buscar trades de withdrawals que ficaram em PENDING sem waxpeerId (bug 6.8), mas atualmente nao e utilizado.


15. Debugging

Logs Criticos para Monitorar

RequestWithdrawItemUseCase:

[RequestWithdrawItemUseCase] User {userId} requesting withdraw for inventory item {inventoryItemId}
[RequestWithdrawItemUseCase] Item found: {name}
[RequestWithdrawItemUseCase]   - Original Price: {cents} cents
[RequestWithdrawItemUseCase]   - Margin: {percent}%
[RequestWithdrawItemUseCase] Waxpeer check: CAN WITHDRAW / CANNOT WITHDRAW
[RequestWithdrawItemUseCase] Failed conditions: {conditions}
[RequestWithdrawItemUseCase] Creating withdrawal record with projectId: {projectId}
[RequestWithdrawItemUseCase] Executing purchase on Waxpeer...
[RequestWithdrawItemUseCase] PURCHASE SUCCESSFUL - Waxpeer ID: {waxpeerId}
[RequestWithdrawItemUseCase] PURCHASE FAILED: {error}

WaxpeerService:

[WaxpeerService] Fetching price from Waxpeer /v1/prices for: {marketHashName}
[WaxpeerService] No items found for: {marketHashName}
[WaxpeerService] Exact match not found for: {marketHashName}. Found {n} similar items.
[WaxpeerService] [Waxpeer] Buying item: {name} for {coins} coins
[WaxpeerService] [Waxpeer] Project ID: {projectId}
[WaxpeerService] [Waxpeer] Buy response: {json}
[WaxpeerService] [Waxpeer] Buy failed: {msg}
[WaxpeerService] [Waxpeer] Checking {n} trades

WaxpeerWebSocketService:

[WaxpeerWebSocketService] [WaxpeerWS] Connected to Waxpeer WebSocket
[WaxpeerWebSocketService] [WaxpeerWS] Disconnected: {reason}
[WaxpeerWebSocketService] [WaxpeerWS] Connection error: {error}
[WaxpeerWebSocketService] [WaxpeerWS] Scheduling reconnect attempt {n}/{max} in {delay}ms
[WaxpeerWebSocketService] [WaxpeerWS] Max reconnection attempts reached
[WaxpeerWebSocketService] [WaxpeerWS] ========== STEAM TRADE EVENT ==========
[WaxpeerWebSocketService] [WaxpeerWS] Event type: {create|update}
[WaxpeerWebSocketService] [WaxpeerWS] Trade ID: {id}
[WaxpeerWebSocketService] [WaxpeerWS] Stage: {stage}
[WaxpeerWebSocketService] [WaxpeerWS] Done: {boolean}
[WaxpeerWebSocketService] [WaxpeerWS] Items count: {n}
[WaxpeerWebSocketService] [WaxpeerWS] Item: id={id}, name={name}, status={status}, trade_id={tradeId}
[WaxpeerWebSocketService] [WaxpeerWS] Trade {waxpeerId}: Waxpeer status {status}, stage {stage} -> Our status {newStatus} (was {oldStatus})
[WaxpeerWebSocketService] [WaxpeerWS] No withdrawal found for waxpeerId: {id}

WaxpeerWithdrawalTrackerService:

[WaxpeerWithdrawalTrackerService] Checking {n} pending withdrawals...
[WaxpeerWithdrawalTrackerService] No pending withdrawals to check
[WaxpeerWithdrawalTrackerService] No withdrawals with waxpeerId to check
[WaxpeerWithdrawalTrackerService] [Tracker] Received {n} trades from Waxpeer API
[WaxpeerWithdrawalTrackerService] [Tracker] Trade {id}: status={status}, done={done}, is_released={isReleased}, release_date={date}, reason={reason}
[WaxpeerWithdrawalTrackerService] Withdrawal {id}: {oldStatus} -> {newStatus} (Waxpeer status: {status})
[WaxpeerWithdrawalTrackerService] Failed to check trade status: {error}

WebSocketGatewayService:

[WebSocketGatewayService] [Withdrawal] Pending for user {userId}: {itemName}
[WebSocketGatewayService] [Withdrawal] Processing for user {userId}, waxpeerId: {waxpeerId}
[WebSocketGatewayService] [Withdrawal] Waiting seller for user {userId}
[WebSocketGatewayService] [Withdrawal] Trade sent for user {userId}, tradeId: {tradeId}
[WebSocketGatewayService] [Withdrawal] Completed for user {userId}: {itemName}
[WebSocketGatewayService] [Withdrawal] Cancelled for user {userId}: {reason}
[WebSocketGatewayService] [Withdrawal] Failed for user {userId}: {reason}

Queries Uteis para Debug

Encontrar withdrawals presos (bug 6.8):

sql
SELECT w.id, w.status, w.waxpeer_id, w.project_id, w.created_at, ui.status as inventory_status
FROM waxpeer_withdrawals w
JOIN user_inventory ui ON ui.id = w.user_inventory_id
WHERE w.status = 'PENDING'
  AND w.waxpeer_id IS NULL
  AND w.created_at < NOW() - INTERVAL '5 minutes';

Verificar divergencia entre withdrawal status e inventory status:

sql
SELECT w.id, w.status as withdrawal_status, ui.status as inventory_status
FROM waxpeer_withdrawals w
JOIN user_inventory ui ON ui.id = w.user_inventory_id
WHERE (w.status IN ('FAILED', 'CANCELLED') AND ui.status = 'LOCKED')
   OR (w.status = 'COMPLETED' AND ui.status != 'WITHDRAWN');

Withdrawals ativos sem atualizacao recente:

sql
SELECT w.id, w.status, w.waxpeer_id, w.waxpeer_status, w.updated_at
FROM waxpeer_withdrawals w
WHERE w.status IN ('PENDING', 'WAITING_SELLER', 'TRADE_SENT')
  AND w.updated_at < NOW() - INTERVAL '10 minutes';

Apendice: Interfaces Completas do Repositorio

typescript
interface IWaxpeerWithdrawalRepository {
  create(data: CreateWaxpeerWithdrawalData): Promise<WaxpeerWithdrawal>;
  findById(id: bigint): Promise<WaxpeerWithdrawal | null>;
  findByProjectId(projectId: string): Promise<WaxpeerWithdrawal | null>;
  findByWaxpeerId(waxpeerId: string): Promise<WaxpeerWithdrawal | null>;
  findByUserInventoryId(userInventoryId: bigint): Promise<WaxpeerWithdrawal | null>;
  findActiveByUserInventoryId(userInventoryId: bigint): Promise<WaxpeerWithdrawal | null>;
  findByUserId(userId: bigint, limit?: number, offset?: number): Promise<WaxpeerWithdrawalWithItem[]>;
  findPending(limit?: number): Promise<WaxpeerWithdrawal[]>;
  findActiveByUserId(userId: bigint): Promise<WaxpeerWithdrawalWithItem[]>;
  updateStatus(id: bigint, status: WaxpeerWithdrawalStatus, data?: UpdateWaxpeerWithdrawalData): Promise<WaxpeerWithdrawal>;
  updateWaxpeerData(id: bigint, data: UpdateWaxpeerDataInput): Promise<WaxpeerWithdrawal>;
  delete(id: bigint): Promise<void>;
}

interface CreateWaxpeerWithdrawalData {
  userId: bigint;
  userInventoryId: bigint;
  itemId: bigint;
  projectId: string;
  pricePaidCents: bigint;
}

interface UpdateWaxpeerWithdrawalData {
  waxpeerId?: string;
  tradeId?: string;
  waxpeerStatus?: number;
  failureReason?: string;
  sendUntil?: Date;
  completedAt?: Date;
  metadata?: any;
}

interface UpdateWaxpeerDataInput {
  waxpeerId?: string;
  tradeId?: string;
  waxpeerStatus?: number;
  status?: WaxpeerWithdrawalStatus;
  sendUntil?: Date;
  metadata?: any;
}

interface WaxpeerWithdrawalWithItem extends WaxpeerWithdrawal {
  item: {
    id: bigint;
    name: string;
    marketHashName: string;
    imageUrl: string;
    rarity: string;
    valueCentsBrl: bigint;
  };
}

findActiveByUserInventoryId - Detalhe Importante

A query que verifica se existe um withdrawal ativo inclui COMPLETED no filtro:

typescript
async findActiveByUserInventoryId(userInventoryId: bigint): Promise<WaxpeerWithdrawal | null> {
  return this.prisma.waxpeerWithdrawal.findFirst({
    where: {
      userInventoryId,
      status: { in: ['PENDING_APPROVAL', 'PENDING', 'WAITING_SELLER', 'TRADE_SENT', 'COMPLETED'] },
    },
    orderBy: { createdAt: 'desc' },
  });
}

Isso significa que um item que ja foi sacado com sucesso (COMPLETED) nao pode ter um novo withdrawal criado. Isso faz sentido porque o item ja esta com status WITHDRAWN no inventario e nao passaria na validacao de status !== 'AVAILABLE' de qualquer forma.

findPending - Quais Status Sao Considerados "Pendentes"

typescript
async findPending(limit: number = 100): Promise<WaxpeerWithdrawal[]> {
  return this.prisma.waxpeerWithdrawal.findMany({
    where: {
      status: { in: ['PENDING', 'WAITING_SELLER', 'TRADE_SENT'] },
    },
    orderBy: { createdAt: 'asc' },
    take: limit,
  });
}

O Tracker processa todos os withdrawals que ainda estao em andamento, ordenados do mais antigo para o mais recente.

Documentação Técnica - ProCases