Shop: branch OPEN_CHECKOUT by payment rail + add license-request handler
Build (Windows x64) / build (push) Canceled after 0s
Build (Windows x64) / linux-build (push) Canceled after 0s

This commit is contained in:
luis
2026-08-19 22:24:01 -03:00
parent 3547681277
commit 03794c3ea9
7 changed files with 181 additions and 10 deletions
@@ -436,6 +436,24 @@ int32 BattlePayMgr::GetCharacterBoostType()
return int32(sWorld->getIntConfig(CONFIG_SHOP_CHARACTER_BOOST_TYPE)); return int32(sWorld->getIntConfig(CONFIG_SHOP_CHARACTER_BOOST_TYPE));
} }
ShopRealMoneyMode BattlePayMgr::GetRealMoneyMode()
{
// Read the string directly from the config: World has no string-config array, and this is queried
// only on OPEN_CHECKOUT of a real-money product (rare), so a per-call lookup is fine. Default
// "instant" so a fresh test realm can buy real-money products for QA without a web backend.
std::string const mode = sConfigMgr->GetStringDefault("Shop.RealMoney.Mode", "instant");
if (mode == "web")
return SHOP_REAL_MONEY_WEB;
if (mode == "disabled")
return SHOP_REAL_MONEY_DISABLED;
if (mode == "instant")
return SHOP_REAL_MONEY_INSTANT;
TC_LOG_ERROR("server.loading", "BattlePay: Shop.RealMoney.Mode = '{}' is not one of web|instant|disabled; "
"using 'instant'.", mode);
return SHOP_REAL_MONEY_INSTANT;
}
bool BattlePayMgr::IsCharacterBoostProduct(ShopProduct const& product) bool BattlePayMgr::IsCharacterBoostProduct(ShopProduct const& product)
{ {
return GetServiceType(product) == SHOP_SERVICE_CHARACTER_BOOST; return GetServiceType(product) == SHOP_SERVICE_CHARACTER_BOOST;
+29 -1
View File
@@ -29,6 +29,29 @@
class Player; class Player;
// Payment method of a shop product (ShopProduct::Currency). Distinct from the client's CurrencyTypes.db2
// ids - this is how the SERVER charges. REAL_MONEY is the Rail A marker: such a product is never charged
// on the wire; its checkout goes to the shop2 web overlay (or is granted instantly for QA) per
// Shop.RealMoney.Mode. The COMMERCE_MASTER_PLAN's "CurrencyTypesID == 0 (real money)" maps to this value,
// because value 0 here already means "free".
enum ShopCurrency : uint8
{
SHOP_CURRENCY_FREE = 0, // no cost
SHOP_CURRENCY_GOLD = 1, // copper
SHOP_CURRENCY_ITEM_TOKEN = 2, // a token item (PriceItemId x PriceItemCount)
SHOP_CURRENCY_CUSTOM = 3, // custom currency amount
SHOP_CURRENCY_REAL_MONEY = 4 // real money -> Rail A (web checkout); never charged on the wire
};
// Server behaviour when a real-money (Rail A) product's checkout is opened. Read from the
// Shop.RealMoney.Mode config string; see BattlePayMgr::GetRealMoneyMode.
enum ShopRealMoneyMode : uint8
{
SHOP_REAL_MONEY_WEB = 0, // let the client web-checkout; the realm sends no game response
SHOP_REAL_MONEY_INSTANT = 1, // grant server-side immediately with no charge (private-realm QA)
SHOP_REAL_MONEY_DISABLED = 2 // refuse: no SSO token, no grant
};
// A single deliverable payload of a shop product (>1 per product = a bundle). // A single deliverable payload of a shop product (>1 per product = a bundle).
struct ShopDeliverable struct ShopDeliverable
{ {
@@ -106,7 +129,7 @@ struct ShopProduct
bool Enabled = true; bool Enabled = true;
std::string Name; std::string Name;
std::string Description; std::string Description;
uint8 Currency = 1; // 0 free | 1 gold(copper) | 2 item-token | 3 custom-currency uint8 Currency = 1; // ShopCurrency: 0 free | 1 gold | 2 item-token | 3 custom | 4 real-money(web)
uint64 Price = 0; // copper (currency 1) or currency amount (3) uint64 Price = 0; // copper (currency 1) or currency amount (3)
uint32 PriceItemId = 0; // currency 2: token item uint32 PriceItemId = 0; // currency 2: token item
uint32 PriceItemCount = 0; uint32 PriceItemCount = 0;
@@ -194,6 +217,11 @@ public:
// The VAS service type a product's type-5 deliverable names, or 0 if it has none. // The VAS service type a product's type-5 deliverable names, or 0 if it has none.
static uint8 GetServiceType(ShopProduct const& product); static uint8 GetServiceType(ShopProduct const& product);
// How the realm treats a real-money (Rail A) checkout, from the Shop.RealMoney.Mode config string
// ("web" | "instant" | "disabled"; unknown values fall back to the default). See the OPEN_CHECKOUT
// rail branch in HandleBattlePayOpenCheckout.
static ShopRealMoneyMode GetRealMoneyMode();
// Allocates the next DistributionID (realm-namespaced, seeded from the store at startup). // Allocates the next DistributionID (realm-namespaced, seeded from the store at startup).
uint64 GenerateDistributionID() { return ++_distributionCounter; } uint64 GenerateDistributionID() { return ++_distributionCounter; }
+97 -5
View File
@@ -591,11 +591,103 @@ void WorldSession::HandleBattlePayOpenCheckout(WorldPackets::BattlePay::OpenChec
if (!sWorld->getBoolConfig(CONFIG_SHOP_ENABLED)) if (!sWorld->getBoolConfig(CONFIG_SHOP_ENABLED))
return; return;
// Retail answers CMSG_BATTLE_PAY_OPEN_CHECKOUT with SMSG_GENERATE_SSO_TOKEN_RESPONSE as a strict // Two payment rails, decided by the checked-out product (resolve the advertised productID -> catalog):
// 1:1 echo of the request's ClientToken (proven in all 8 captures: checkout #N -> response #N with //
// the same u32). Answering from here - rather than pushing the token unsolicited at login - is what // Rail B - IN-GAME CURRENCY. A product we route whose Currency is anything but real money is bought on
// lets checkouts #2+ get a reply. See COMMERCE_AUDIT C-09 / WOW_TOKEN_RE_68275.md. // the wire. Keep the proven handshake: SMSG_GENERATE_SSO_TOKEN_RESPONSE echoing the request's
SendGenerateSsoToken(openCheckout.ClientToken); // ClientToken 1:1 (COMMERCE_AUDIT C-09 / WOW_TOKEN_RE_68275.md). Answering from here - not from an
// unsolicited push at login - is what lets checkouts #2+ get a reply.
//
// Rail A - REAL MONEY. Either a product explicitly flagged Currency 4 (real money), OR an un-reskinned
// retail catalog card we do not route to a shop_product (e.g. the Midnight expansion, ProductID
// 0x417070). The 12.1.0.69382 sniff proves the client gets NO game-packet response for such a
// product and opens the shop2 web overlay itself; the old 1:1 SSO echo here was only ever correct for
// in-game-currency items. Behaviour is governed by Shop.RealMoney.Mode.
ShopProduct const* product = sBattlePayMgr->GetProductByAdvertisedId(openCheckout.ProductID);
if (product && product->Currency != SHOP_CURRENCY_REAL_MONEY)
{
SendGenerateSsoToken(openCheckout.ClientToken);
return;
}
switch (BattlePayMgr::GetRealMoneyMode())
{
case SHOP_REAL_MONEY_INSTANT:
{
// Grant the product server-side with no charge (private-realm QA default). Reuse the proven
// delivery path: a real-money product carries Currency 4, which the charge checks (== gold,
// == item token) never match, so nothing is deducted. An unrouted retail card has no
// shop_product to grant - honest no-op rather than a faked success.
if (!product)
{
TC_LOG_INFO("network", "BattlePay: {} opened a real-money checkout for unrouted product {} with "
"Shop.RealMoney.Mode=instant, but no shop_product is seeded for it - nothing to grant.",
GetPlayerInfo(), openCheckout.ProductID);
return;
}
// The client resends OPEN_CHECKOUT on each web-payment failure (7 retries in the capture);
// throttle identical rapid retries so instant mode grants exactly once, reusing the same
// per-session guard as the StartPurchase path.
static constexpr uint32 BATTLEPAY_INSTANT_THROTTLE_MS = 2000;
uint32 const now = getMSTime();
if (_battlePayPurchaseInFlight ||
(_lastBattlePayPurchaseMSTime && getMSTimeDiff(_lastBattlePayPurchaseMSTime, now) < BATTLEPAY_INSTANT_THROTTLE_MS))
{
TC_LOG_DEBUG("network", "BattlePay: throttled duplicate real-money instant checkout from {} (product {}).",
GetPlayerInfo(), openCheckout.ProductID);
return;
}
_lastBattlePayPurchaseMSTime = now;
TC_LOG_INFO("network", "BattlePay: {} real-money checkout for product {} granted instantly "
"(Shop.RealMoney.Mode=instant, no charge).", GetPlayerInfo(), openCheckout.ProductID);
_battlePayPurchaseInFlight = true;
BattlePayProcessPurchase(openCheckout.ProductID);
_battlePayPurchaseInFlight = false;
return;
}
case SHOP_REAL_MONEY_DISABLED:
// Refuse politely: no SSO token, no grant. The client opened its own web overlay on
// OPEN_CHECKOUT and the realm cannot recall that, but it neither authenticates the web session
// nor delivers anything, so the purchase cannot complete. Logged for the operator.
TC_LOG_INFO("network", "BattlePay: {} attempted a real-money checkout for product {} but "
"Shop.RealMoney.Mode=disabled - refused (no SSO, no grant).", GetPlayerInfo(), openCheckout.ProductID);
return;
case SHOP_REAL_MONEY_WEB:
default:
// Sniff-accurate: the client already opened the shop2 HTTPS overlay from OPEN_CHECKOUT, so the
// game connection sends NO response. Completion, if any, arrives from the web backend (Rail A3).
TC_LOG_DEBUG("network", "BattlePay: {} opened a real-money web checkout for product {} (client token {}); "
"no game response (Shop.RealMoney.Mode=web).", GetPlayerInfo(), openCheckout.ProductID, openCheckout.ClientToken);
return;
}
}
// CMSG_CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST. The client sends this mid-checkout carrying its own
// license / game data (variable body: 876/140/52/36/24 bytes in the 12.1.0.69382 capture). Retail answers
// on SMSG opcode 0x4202C1 - which our opcode map currently mis-names SMSG_VAS_GET_QUEUE_MINUTES_RESPONSE
// (the sniff's 0x4502C1 is that opcode plus the sniff's fixed 0x030000 server-opcode bias) - with an
// 11465/1025/423/403/40-byte payload.
//
// That response is a nested reflection bitstream whose FIELD LAYOUT is not recovered - the capture gives
// only the sizes, not the member types. Emitting a guessed layout on that opcode would risk the client's
// reflection parser rejecting it and disconnecting, which is exactly the "do not invent wire" hazard. So
// this handler does the honest, safe thing: it makes the opcode an EXPECTED, real handler (no more
// STATUS_UNHANDLED / Handle_NULL), logs the body size so a future capture can be matched to a variant, and
// sends nothing. The 69382 capture shows the checkout proceeds (the client drives its own web overlay from
// OPEN_CHECKOUT) without the realm answering this, so withholding the un-modeled response does not stall
// the client. When the response wire is recovered, this is where it is sent.
void WorldSession::HandleCatalogShopLicenseGameDataRequest(WorldPackets::BattlePay::CatalogShopLicenseGameDataRequest& request)
{
if (!sWorld->getBoolConfig(CONFIG_SHOP_ENABLED))
return;
TC_LOG_INFO("network", "BattlePay: CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST from {} ({} body bytes). The paired "
"response (SMSG 0x4202C1) is an un-modeled reflection payload and is deliberately not answered - see the "
"handler note. If the client stalls waiting for it, capture the response bytes and model them here.",
GetPlayerInfo(), request.Data.size());
} }
namespace namespace
@@ -178,6 +178,18 @@ namespace WorldPackets::BattlePay
void OpenCheckout::Read() void OpenCheckout::Read()
{ {
_worldPacket >> ClientToken; _worldPacket >> ClientToken;
_worldPacket >> ProductID;
}
void CatalogShopLicenseGameDataRequest::Read()
{
// The response wire is not yet modeled, so this body is retained for diagnostics only (its size
// identifies which of the captured request variants it is). Consume the whole body so the packet
// is not reported as under-read.
size_t const len = _worldPacket.size();
Data.resize(len);
if (len)
_worldPacket.read(Data.data(), len);
} }
WorldPacket const* StartPurchaseResponse::Write() WorldPacket const* StartPurchaseResponse::Write()
@@ -47,6 +47,23 @@ namespace WorldPackets
void Read() override {} void Read() override {}
}; };
// CMSG_CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST. Sent by the client mid-checkout carrying its own
// license / game data. The body is variable (876/140/52/36/24 bytes across the 12.1.0.69382
// capture). We keep the whole body so the handler can log its size for future response modeling;
// the paired response wire (SMSG 0x4202C1) is not yet recovered - see
// WorldSession::HandleCatalogShopLicenseGameDataRequest.
class CatalogShopLicenseGameDataRequest final : public ClientPacket
{
public:
explicit CatalogShopLicenseGameDataRequest(WorldPacket&& packet)
: ClientPacket(CMSG_CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST, std::move(packet)) {
}
void Read() override;
std::vector<uint8> Data; // raw request body, retained for diagnostics only
};
// The 12.0.7 catalog is a nested reflection bitstream that cannot be re-serialized field-by-field // The 12.0.7 catalog is a nested reflection bitstream that cannot be re-serialized field-by-field
// offline (see docs). For P0 we replay a byte-exact, client-validated catalog blob captured from a // offline (see docs). For P0 we replay a byte-exact, client-validated catalog blob captured from a
// real 68275 session, so the shop opens and displays real products. RawData is the message BODY // real 68275 session, so the shop opens and displays real products. RawData is the message BODY
@@ -403,9 +420,10 @@ namespace WorldPackets
bool Flag = false; bool Flag = false;
}; };
// Client opens the checkout. The u32 is the ClientToken the server must echo back verbatim in // Client opens the checkout. Body is 8 bytes: { u32 ClientToken, u32 ProductID } (12.1.0.69382
// SMSG_GENERATE_SSO_TOKEN_RESPONSE (proven 1:1 in all 8 captures - checkout #N -> response #N // capture, Midnight ProductID 0x417070 in all 7 attempts). ClientToken is the token the SSO/token
// with the same u32). It is not a distributionID. See COMMERCE_AUDIT C-09. // handshake echoes back verbatim for an in-game-currency product (COMMERCE_AUDIT C-09); ProductID
// selects the product and decides the payment rail - see HandleBattlePayOpenCheckout.
class OpenCheckout final : public ClientPacket class OpenCheckout final : public ClientPacket
{ {
public: public:
@@ -414,6 +432,7 @@ namespace WorldPackets
void Read() override; void Read() override;
uint32 ClientToken = 0; uint32 ClientToken = 0;
uint32 ProductID = 0;
}; };
// Server-driven purchase confirmation prompt (retail interposes this between StartPurchase and // Server-driven purchase confirmation prompt (retail interposes this between StartPurchase and
+1 -1
View File
@@ -285,7 +285,7 @@ void OpcodeTable::InitializeClientOpcodes()
DEFINE_HANDLER(CMSG_CAN_DUEL, STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleCanDuel); DEFINE_HANDLER(CMSG_CAN_DUEL, STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleCanDuel);
DEFINE_HANDLER(CMSG_CAN_REDEEM_TOKEN_FOR_BALANCE, STATUS_AUTHED, PROCESS_THREADUNSAFE, &WorldSession::HandleCanRedeemTokenForBalance); DEFINE_HANDLER(CMSG_CAN_REDEEM_TOKEN_FOR_BALANCE, STATUS_AUTHED, PROCESS_THREADUNSAFE, &WorldSession::HandleCanRedeemTokenForBalance);
DEFINE_HANDLER(CMSG_CAST_SPELL, STATUS_LOGGEDIN, PROCESS_THREADSAFE, &WorldSession::HandleCastSpellOpcode); DEFINE_HANDLER(CMSG_CAST_SPELL, STATUS_LOGGEDIN, PROCESS_THREADSAFE, &WorldSession::HandleCastSpellOpcode);
DEFINE_HANDLER(CMSG_CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST, STATUS_UNHANDLED, PROCESS_THREADUNSAFE, &WorldSession::Handle_NULL); DEFINE_HANDLER(CMSG_CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST, STATUS_AUTHED, PROCESS_THREADUNSAFE, &WorldSession::HandleCatalogShopLicenseGameDataRequest);
DEFINE_HANDLER(CMSG_CHALLENGE_MODE_REQUEST_LEADERS, STATUS_LOGGEDIN, PROCESS_INPLACE, &WorldSession::HandleChallengeModeRequestLeaders); DEFINE_HANDLER(CMSG_CHALLENGE_MODE_REQUEST_LEADERS, STATUS_LOGGEDIN, PROCESS_INPLACE, &WorldSession::HandleChallengeModeRequestLeaders);
DEFINE_HANDLER(CMSG_CHANGE_BAG_SLOT_FLAG, STATUS_LOGGEDIN, PROCESS_INPLACE, &WorldSession::HandleChangeBagSlotFlag); DEFINE_HANDLER(CMSG_CHANGE_BAG_SLOT_FLAG, STATUS_LOGGEDIN, PROCESS_INPLACE, &WorldSession::HandleChangeBagSlotFlag);
DEFINE_HANDLER(CMSG_CHANGE_BANK_BAG_SLOT_FLAG, STATUS_LOGGEDIN, PROCESS_INPLACE, &WorldSession::Handle_NULL); DEFINE_HANDLER(CMSG_CHANGE_BANK_BAG_SLOT_FLAG, STATUS_LOGGEDIN, PROCESS_INPLACE, &WorldSession::Handle_NULL);
+2
View File
@@ -135,6 +135,7 @@ namespace WorldPackets
class GetPurchaseList; class GetPurchaseList;
class StartPurchase; class StartPurchase;
class OpenCheckout; class OpenCheckout;
class CatalogShopLicenseGameDataRequest;
class ConfirmPurchaseResponse; class ConfirmPurchaseResponse;
class DistributionAssignToTarget; class DistributionAssignToTarget;
class CharacterUpgradeStart; class CharacterUpgradeStart;
@@ -2396,6 +2397,7 @@ public:
void HandleBattlePayGetPurchaseList(WorldPackets::BattlePay::GetPurchaseList& getPurchaseList); void HandleBattlePayGetPurchaseList(WorldPackets::BattlePay::GetPurchaseList& getPurchaseList);
void HandleBattlePayStartPurchase(WorldPackets::BattlePay::StartPurchase& startPurchase); void HandleBattlePayStartPurchase(WorldPackets::BattlePay::StartPurchase& startPurchase);
void HandleBattlePayOpenCheckout(WorldPackets::BattlePay::OpenCheckout & openCheckout); void HandleBattlePayOpenCheckout(WorldPackets::BattlePay::OpenCheckout & openCheckout);
void HandleCatalogShopLicenseGameDataRequest(WorldPackets::BattlePay::CatalogShopLicenseGameDataRequest& request);
void BattlePayProcessPurchase(uint32 productID); void BattlePayProcessPurchase(uint32 productID);
void HandleBattlePayConfirmPurchaseResponse(WorldPackets::BattlePay::ConfirmPurchaseResponse& confirmPurchaseResponse); void HandleBattlePayConfirmPurchaseResponse(WorldPackets::BattlePay::ConfirmPurchaseResponse& confirmPurchaseResponse);
void SendBattlePayDistributionList(); void SendBattlePayDistributionList();