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
- Visao Geral
- Schema Completo (Prisma)
- Transicoes de Status (Maquina de Estados)
- Waxpeer Status Codes (0-6)
- Happy Path Completo (15 Passos)
- Edge Cases
- Protecao de Preco (Price Protection)
- Sistema Dual de Rastreamento
- Eventos WebSocket
- Endpoints HTTP
- Validacao de Trade URL
- Distributed Locking (Redlock)
- Integracao Frontend
- Codigo Morto (Dead Code)
- 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 SWAPArquivos Envolvidos
| Arquivo | Responsabilidade |
|---|---|
src/application/use-cases/inventory/request-withdraw-item.use-case.ts | Ponto de entrada principal, ~355 linhas. Valida item, consulta preco, executa compra |
src/infrastructure/external-apis/waxpeer.service.ts | Integracao HTTP com API Waxpeer, ~430 linhas |
src/infrastructure/external-apis/waxpeer-websocket.service.ts | Rastreamento real-time via WebSocket Waxpeer, ~350 linhas |
src/application/services/waxpeer-withdrawal-tracker.service.ts | Polling fallback a cada 30s, ~200 linhas |
src/infrastructure/database/repositories/waxpeer-withdrawal.repository.ts | Repositorio Prisma |
src/domain/repositories/waxpeer-withdrawal.repository.interface.ts | Interface do repositorio |
src/infrastructure/websocket/websocket.gateway.ts | Emissao de eventos WebSocket para o frontend |
src/infrastructure/locks/lock.service.ts | Lock distribuido via Redlock |
src/presentation/controllers/inventory.controller.ts | Controller HTTP (4 endpoints de withdrawal) |
client/app/lib/use-withdrawal-status.tsx | Hook React para consumir eventos WebSocket |
2. Schema Completo (Prisma)
Enum WaxpeerWithdrawalStatus
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
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
| Campo | Tipo | Descricao |
|---|---|---|
id | BigInt | Snowflake ID gerado internamente |
userId | BigInt | FK para User. Dono do item |
userInventoryId | BigInt | FK para UserInventory. Item sendo sacado |
itemId | BigInt | FK para Item. Dados do item (nome, preco, etc.) |
waxpeerId | String? (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 |
projectId | String (unique) | ID gerado pelo nosso sistema no formato wd_{inventoryItemId}_{timestamp}. Usado para rastreamento via check-many-project-id |
tradeId | String? | Trade offer ID do Steam. Preenchido quando o vendedor envia a trade |
pricePaidCents | BigInt | Preco pago em centavos USD (convertido de Waxpeer coins: coins / 10) |
skinValueUsdCents | BigInt | Snapshot do valor da skin em centavos USD (item.valueCents) no momento do saque. Base do limite diario de auto-aprovacao |
status | WaxpeerWithdrawalStatus | Status interno do nosso sistema |
autoApproved | Boolean | true se o saque passou na auto-aprovacao (nasce PENDING); false se foi para revisao admin (PENDING_APPROVAL) |
waxpeerStatus | Int? | Ultimo status numerico recebido da Waxpeer (0-6) |
failureReason | String? | Motivo da falha/cancelamento |
metadata | Json? | Dados brutos da Waxpeer (resposta de compra, dados de trade, etc.) |
sendUntil | DateTime? | Deadline para o vendedor enviar a trade. Se expirar, Waxpeer cancela |
createdAt | DateTime | Quando o registro foi criado |
updatedAt | DateTime | Ultima atualizacao (auto-managed pelo Prisma) |
completedAt | DateTime? | Quando a trade foi concluida com sucesso |
Indices
@@index([userId, status])- Busca de withdrawals ativos por usuario@@index([status, createdAt])- Polling de withdrawals pendentes (tracker)@@index([waxpeerId])- Lookup por ID da Waxpeer (WebSocket events)@@index([projectId])- Lookup por project ID@@index([userInventoryId])- Verificar se item ja tem withdrawal ativo
Constraints Unique
waxpeerId(unique) - Garante 1:1 entre withdrawal e trade na WaxpeerprojectId(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
| De | Para | Gatilho | Efeito no UserInventory |
|---|---|---|---|
| (criacao) | PENDING | canWithdraw + auto-aprovacao elegivel | LOCKED |
| (criacao) | PENDING_APPROVAL | canWithdraw + auto-aprovacao nao elegivel | LOCKED |
PENDING_APPROVAL | PENDING | Admin aprova (revalida preco e executa compra) | Permanece LOCKED |
PENDING_APPROVAL | CANCELLED | Admin rejeita (exige reason) | Volta para AVAILABLE |
PENDING | WAITING_SELLER | API buy-one-p2p-name retorna success: true | Permanece LOCKED |
PENDING | FAILED | API buy-one-p2p-name retorna success: false | Volta para AVAILABLE |
WAITING_SELLER | TRADE_SENT | Waxpeer status muda para 2, 3 ou 4 (vendedor enviou trade) | Permanece LOCKED |
WAITING_SELLER | CANCELLED | Waxpeer status muda para 6 / timeout de sendUntil | Volta para AVAILABLE |
TRADE_SENT | COMPLETED | Waxpeer status = 5, is_released = true, done = true ou stage = 9 | Muda para WITHDRAWN |
TRADE_SENT | CANCELLED | Waxpeer status = 6 com reason contendo "cancel"/"declined" | Volta para AVAILABLE |
TRADE_SENT | FAILED | Waxpeer status = 6 com reason generica | Volta para AVAILABLE |
Efeitos Colaterais por Status Final
COMPLETED:
UserInventory.status=WITHDRAWNWaxpeerWithdrawal.completedAt=new Date()- Evento WebSocket
withdrawal:completedemitido
CANCELLED / FAILED:
UserInventory.status=AVAILABLE(item retorna ao inventario)WaxpeerWithdrawal.failureReasonpreenchido- Eventos WebSocket
withdrawal:cancelledouwithdrawal:failedemitidos
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).
| Codigo | Significado Waxpeer | Mapeamento Interno |
|---|---|---|
| 0 | Aguardando vendedor aceitar | WAITING_SELLER |
| 1 | Vendedor preparando envio | WAITING_SELLER |
| 2 | Trade offer enviada (aguardando usuario aceitar) | TRADE_SENT |
| 3 | Trade offer aceita (processando) | TRADE_SENT |
| 4 | Item em periodo de retencao / confirmacao | TRADE_SENT (ou COMPLETED se release_date presente no Tracker) |
| 5 | Trade concluida com sucesso | COMPLETED |
| 6 | Trade cancelada / falhou | CANCELLED 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):
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):
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
| Situacao | WebSocket | Tracker |
|---|---|---|
| Status 6 (cancelado) | Sempre CANCELLED | CANCELLED se reason contem "cancel"/"failed"/"declined", senao FAILED |
Status 4 com release_date | TRADE_SENT | COMPLETED |
stage === 9 | COMPLETED | Nao 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:
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:
- Item existe no inventario:
userInventoryRepository.findById(inventoryItemId)- se nao,NotFoundException - Item pertence ao usuario:
inventoryItem.userId !== userId- se nao,BadRequestException - Item nao e restrito a jogos:
inventoryItem.restrictedToGames- itens da Loja/Shop nao podem ser sacados para a Steam,BadRequestException - Status e AVAILABLE:
inventoryItem.status !== 'AVAILABLE'- se nao,BadRequestException - Nao existe withdrawal ativo:
waxpeerWithdrawalRepository.findActiveByUserInventoryId()verifica statusin: ['PENDING_APPROVAL', 'PENDING', 'WAITING_SELLER', 'TRADE_SENT', 'COMPLETED']- se existe,BadRequestException - Usuario existe:
userRepository.findById(userId)- se nao,NotFoundException - Trade URL cadastrada:
user.tradeUrldeve existir - se nao,BadRequestExceptioncom mensagem "Cadastre seu Trade URL do Steam no perfil antes de retirar itens." - Trade URL valida: Regex TRADE_URL_REGEX - se nao,
BadRequestExceptioncom mensagem "Trade URL invalido. Atualize no perfil."
Passo 4: Consulta Preco na Waxpeer
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:
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:
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 WaxpeerisPriceValid: Preco minimo na Waxpeer e menor ou igual aomaxPayCents(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:
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:
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): chamarunAutoPurchase()que delega aoWaxpeerPurchaseExecutorService.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. Emitewithdrawal:pending_approvale auditaSKIN_WITHDRAWAL_REQUESTED. A compra so acontece quando um admin aprovar (revalidando o preco ao vivo).
// caminho admin
this.webSocketGateway.emitWithdrawalPendingApproval(userId, {
inventoryItemId: inventoryItem.id.toString(),
itemName: item.name,
projectId: decision.projectId,
withdrawalId,
});Nota: o evento
withdrawal:pendingNAO e mais emitido no fluxo atual (metodoemitWithdrawalPendingsem callers — ver secao 14).
Passo 10: Compra na Waxpeer (via WaxpeerPurchaseExecutorService)
O executor seta o withdrawal para PENDING e chama:
const buyResult = await this.waxpeerService.buyItemByName(
item.marketHashName,
waxpeerPrice.priceWaxpeerCoins,
projectId,
tradePartner,
tradeToken,
);Chama GET /v1/buy-one-p2p-name com parametros:
api: API keyname: marketHashName do itemtoken: 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: csgoproject_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:
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
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)
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:
const isPriceValid = waxpeerMinPriceCents <= maxPayCents; // false
const canWithdraw = waxpeerPrice.isExactMatch && hasStock && isPriceValid; // falseResultado: 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:
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:
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:
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:
if (waxpeerStatus === 6) {
return WaxpeerWithdrawalStatus.CANCELLED;
}Tracker (com analise de reason):
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:
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:
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 = LOCKEDWaxpeerWithdrawal.status = PENDINGWaxpeerWithdrawal.waxpeerId = null
O Tracker ignora withdrawals sem waxpeerId:
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:
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:
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:
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.
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 USDPortanto:
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 saqueO 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:
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:
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:
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:
- Recebe evento
steamTrade - Itera sobre
data.items[] - Para cada item, busca withdrawal por
waxpeerId = item.id.toString() - Mapeia status Waxpeer para nosso status
- Atualiza banco se houve mudanca
- Atualiza
UserInventoryse necessario - Emite evento WebSocket para o frontend
Lifecycle:
onModuleInit(): Conecta automaticamente seWAXPEER_API_KEYestiver configuradaonModuleDestroy(): 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:
- Busca ate 100 withdrawals com status
in: ['PENDING', 'WAITING_SELLER', 'TRADE_SENT'] - Filtra apenas os que tem
waxpeerId(ignora PENDING sem waxpeerId) - Chama
GET /v1/check-many-steam?api={key}&id={id1}&id={id2}&...(maximo 100 IDs) - Para cada trade retornada, compara status e atualiza se mudou
- Emite eventos WebSocket para o frontend
Guard de concorrencia:
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:
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
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:
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:
const dbUnchanged = oldStatus === newStatus && withdrawal.waxpeerStatus === item.status;
if (!dbUnchanged) { /* atualiza */ }Tracker:
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:
{
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:
{
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:
{
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:
{
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:
{
inventoryItemId: string;
tradeId?: string; // Steam trade offer ID
}9.5 withdrawal:completed
Emitido por: WaxpeerWebSocketService, WaxpeerWithdrawalTrackerService
Payload:
{
inventoryItemId: string;
itemName: string; // Nome do item entregue
}9.6 withdrawal:cancelled
Emitido por: WaxpeerWebSocketService, WaxpeerWithdrawalTrackerService
Payload:
{
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:
{
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):
{
"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):
{
"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 inventario400 Bad Request- Item nao pertence ao usuario / Status nao e AVAILABLE / Withdrawal ativo existe / Trade URL nao cadastrada / Trade URL invalida409 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:
{
"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:
{
"id": "1111111111111",
"status": "TRADE_SENT",
"waxpeerStatus": 2,
"tradeId": "5432109876"
}Erros:
Error: Withdrawal not found- ID invalido ou pertence a outro usuarioError: 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 registrosoffset(opcional, default: 0) - Paginacao
Response:
{
"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
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=AbCdEfGhExtracao de Dados
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 + 76561197960265728Exemplo: Partner 123456789 = SteamID64 76561198083722517.
Validacao via API Waxpeer
Alem da regex local, existe um metodo checkTradeLink() que valida a trade URL diretamente na Waxpeer:
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
// lock.service.ts
this.redlock = new Redlock([client], {
driftFactor: 0.01,
retryCount: 10,
retryDelay: 200,
retryJitter: 200,
automaticExtensionThreshold: 500,
});| Parametro | Valor | Descricao |
|---|---|---|
driftFactor | 0.01 | Fator de drift para calculo de TTL efetivo |
retryCount | 10 | Tentativas para adquirir o lock |
retryDelay | 200ms | Delay base entre tentativas |
retryJitter | 200ms | Jitter aleatorio adicionado ao delay |
automaticExtensionThreshold | 500ms | Estende lock automaticamente se restam < 500ms |
Lock de Withdrawal
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:
- Validacoes de banco (~50ms)
- Consulta preco Waxpeer (~1-5s, timeout 15s)
- Compra Waxpeer (~1-10s, timeout 30s)
- 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:
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:
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:
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
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
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} tradesWaxpeerWebSocketService:
[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):
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:
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:
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
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:
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"
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.
