From 00812803284ddef01a1177ad62f13fc1c567f944 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 14 Jun 2026 19:30:17 +0200 Subject: [PATCH] fix(catalog): claim vouchers before rewards Move voucher exhaustion checks and history persistence behind a synchronized per-voucher claim path. Rewards are now applied only after the history row is inserted successfully, preventing duplicate or failed-claim redemption from granting credits, points, or catalog items. Adds a contract test for claim ordering. Maven verification was attempted but blocked by sandbox network/plugin resolution after escalation usage was exhausted; diff --check passes. --- .../habbohotel/catalog/CatalogManager.java | 24 +++++---- .../eu/habbo/habbohotel/catalog/Voucher.java | 31 ++++++++++-- .../catalog/VoucherClaimContractTest.java | 50 +++++++++++++++++++ 3 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/VoucherClaimContractTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java index 0349184f..29e6ff11 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java @@ -711,18 +711,22 @@ public class CatalogManager { return; } - if (voucher.isExhausted()) { - client.sendResponse(new RedeemVoucherErrorComposer(Emulator.getGameEnvironment().getCatalogManager().deleteVoucher(voucher) ? RedeemVoucherErrorComposer.INVALID_CODE : RedeemVoucherErrorComposer.TECHNICAL_ERROR)); - return; + Voucher.ClaimResult claimResult = voucher.claimForUser(habbo.getHabboInfo().getId()); + switch (claimResult) { + case CLAIMED: + break; + case EXHAUSTED: + client.sendResponse(new RedeemVoucherErrorComposer(Emulator.getGameEnvironment().getCatalogManager().deleteVoucher(voucher) ? RedeemVoucherErrorComposer.INVALID_CODE : RedeemVoucherErrorComposer.TECHNICAL_ERROR)); + return; + case USER_LIMIT: + client.sendResponse(new ModToolIssueHandledComposer("You have exceeded the limit for redeeming this voucher.")); + return; + case FAILED: + default: + client.sendResponse(new RedeemVoucherErrorComposer(RedeemVoucherErrorComposer.TECHNICAL_ERROR)); + return; } - if (voucher.hasUserExhausted(habbo.getHabboInfo().getId())) { - client.sendResponse(new ModToolIssueHandledComposer("You have exceeded the limit for redeeming this voucher.")); - return; - } - - voucher.addHistoryEntry(habbo.getHabboInfo().getId()); - if (voucher.points > 0) { client.getHabbo().givePoints(voucher.pointsType, voucher.points); } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/Voucher.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/Voucher.java index 468e2a2b..59cf0b5f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/Voucher.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/Voucher.java @@ -14,6 +14,13 @@ import java.util.List; public class Voucher { private static final Logger LOGGER = LoggerFactory.getLogger(Voucher.class); + public enum ClaimResult { + CLAIMED, + EXHAUSTED, + USER_LIMIT, + FAILED + } + public final int id; public final String code; public final int credits; @@ -58,18 +65,34 @@ public class Voucher { return this.amount > 0 && this.history.size() >= this.amount; } - public void addHistoryEntry(int userId) { - int timestamp = Emulator.getIntUnixTimestamp(); - this.history.add(new VoucherHistoryEntry(this.id, userId, timestamp)); + public synchronized ClaimResult claimForUser(int userId) { + if (this.isExhausted()) { + return ClaimResult.EXHAUSTED; + } + if (this.hasUserExhausted(userId)) { + return ClaimResult.USER_LIMIT; + } + + int timestamp = Emulator.getIntUnixTimestamp(); + if (!this.insertHistoryEntry(userId, timestamp)) { + return ClaimResult.FAILED; + } + + this.history.add(new VoucherHistoryEntry(this.id, userId, timestamp)); + return ClaimResult.CLAIMED; + } + + private boolean insertHistoryEntry(int userId, int timestamp) { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO voucher_history (`voucher_id`, `user_id`, `timestamp`) VALUES (?, ?, ?)")) { statement.setInt(1, this.id); statement.setInt(2, userId); statement.setInt(3, timestamp); - statement.execute(); + return statement.executeUpdate() > 0; } catch (SQLException e) { LOGGER.error("Caught SQL exception", e); + return false; } } } diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/VoucherClaimContractTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/VoucherClaimContractTest.java new file mode 100644 index 00000000..c3639ec6 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/VoucherClaimContractTest.java @@ -0,0 +1,50 @@ +package com.eu.habbo.habbohotel.catalog; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +class VoucherClaimContractTest { + private static String voucherSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/catalog/Voucher.java")); + } + + private static String catalogManagerSource() throws Exception { + return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java")); + } + + @Test + void voucherClaimIsSynchronizedAndPersistsBeforeRewardEligibility() throws Exception { + String source = voucherSource(); + + assertTrue(source.contains("public synchronized ClaimResult claimForUser(int userId)"), + "voucher claim should check limits and persist history under a per-voucher lock"); + assertTrue(source.contains("private boolean insertHistoryEntry"), + "history insert should report database failure to the caller"); + + int insertCall = source.indexOf("insertHistoryEntry(userId, timestamp)"); + int memoryAppend = source.indexOf("this.history.add(new VoucherHistoryEntry(this.id, userId, timestamp))"); + + assertTrue(insertCall > -1, "claimForUser must persist the history row"); + assertTrue(memoryAppend > insertCall, + "in-memory history must only be updated after the database insert succeeds"); + } + + @Test + void catalogRewardsOnlyAfterVoucherClaimSucceeds() throws Exception { + String source = catalogManagerSource(); + + int claim = source.indexOf("Voucher.ClaimResult claimResult = voucher.claimForUser"); + int claimedGuard = source.indexOf("case CLAIMED", claim); + int pointsGrant = source.indexOf("client.getHabbo().givePoints", claim); + int creditsGrant = source.indexOf("client.getHabbo().giveCredits", claim); + + assertTrue(claim > -1, "CatalogManager must claim the voucher before applying rewards"); + assertTrue(claimedGuard > claim, "voucher rewards should only continue for a CLAIMED result"); + assertTrue(pointsGrant > claimedGuard, "points must be granted only after CLAIMED"); + assertTrue(creditsGrant > claimedGuard, "credits must be granted only after CLAIMED"); + } +}