From deb0868dfb643b6e39eb35bfbb88152cfff85581 Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 25 Dec 2025 21:19:10 +0300 Subject: [PATCH 1/6] QDuel --- src/Qubic.vcxproj | 1 + src/Qubic.vcxproj.filters | 3 + src/contract_core/contract_def.h | 12 + src/contracts/QDuel.h | 519 +++++++++++++++++++++++++++++++ test/contract_qduel.cpp | 464 +++++++++++++++++++++++++++ test/test.vcxproj | 3 +- test/test.vcxproj.filters | 1 + 7 files changed, 1002 insertions(+), 1 deletion(-) create mode 100644 src/contracts/QDuel.h create mode 100644 test/contract_qduel.cpp diff --git a/src/Qubic.vcxproj b/src/Qubic.vcxproj index fbba3cdb9..ddf5a4c23 100644 --- a/src/Qubic.vcxproj +++ b/src/Qubic.vcxproj @@ -26,6 +26,7 @@ + diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters index 6d8af4091..af657a3e4 100644 --- a/src/Qubic.vcxproj.filters +++ b/src/Qubic.vcxproj.filters @@ -300,6 +300,9 @@ contracts + + contracts + contract_core diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index 12a13b5c9..c6ea1c612 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -201,6 +201,16 @@ #define CONTRACT_STATE2_TYPE QRAFFLE2 #include "contracts/QRaffle.h" +#undef CONTRACT_INDEX +#undef CONTRACT_STATE_TYPE +#undef CONTRACT_STATE2_TYPE + +#define QDUEL_CONTRACT_INDEX 22 +#define CONTRACT_INDEX QDUEL_CONTRACT_INDEX +#define CONTRACT_STATE_TYPE QDUEL +#define CONTRACT_STATE2_TYPE QDUEL2 +#include "contracts/QDuel.h" + // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES @@ -308,6 +318,7 @@ constexpr struct ContractDescription {"QBOND", 182, 10000, sizeof(QBOND)}, // proposal in epoch 180, IPO in 181, construction and first use in 182 {"QIP", 189, 10000, sizeof(QIP)}, // proposal in epoch 187, IPO in 188, construction and first use in 189 {"QRAFFLE", 192, 10000, sizeof(QRAFFLE)}, // proposal in epoch 190, IPO in 191, construction and first use in 192 + {"QDUEL", 197, 10000, sizeof(QDUEL)}, // proposal in epoch 195, IPO in 196, construction and first use in 197 // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES {"TESTEXA", 138, 10000, sizeof(TESTEXA)}, @@ -423,6 +434,7 @@ static void initializeContracts() REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QBOND); REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QIP); REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QRAFFLE); + REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QDUEL); // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(TESTEXA); diff --git a/src/contracts/QDuel.h b/src/contracts/QDuel.h new file mode 100644 index 000000000..29c244834 --- /dev/null +++ b/src/contracts/QDuel.h @@ -0,0 +1,519 @@ +using namespace QPI; + +constexpr uint32 QDUEL_MAX_NUMBER_OF_ROOMS = 512; +constexpr uint64 QDUEL_MINIMUM_DUEL_AMOUNT = 10000; +constexpr uint8 QDUEL_DEV_FEE_PERCENT_BPS = 15; // 0.15% * QDUEL_PERCENT_SCALE +constexpr uint8 QDUEL_BURN_FEE_PERCENT_BPS = 30; // 0.3% * QDUEL_PERCENT_SCALE +constexpr uint8 QDUEL_SHAREHOLDERS_FEE_PERCENT_BPS = 25; // 0.25% * QDUEL_PERCENT_SCALE +constexpr uint8 QDUEL_PERCENT_SCALE = 100; +constexpr uint8 QDUEL_TTL_HOURS = 2; +constexpr uint8 QDUEL_TICK_UPDATE_PERIOD = 10; // Process TICK logic once per this many ticks + +struct QDUEL2 +{ +}; + +struct QDUEL : public ContractBase +{ +public: + enum class EReturnCode : uint8 + { + SUCCESS, + ACCESS_DENIED, + INVALID_VALUE, + + // Room + ROOM_INSUFFICIENT_DUEL_AMOUNT, + ROOM_NOT_FOUND, + ROOM_FULL, + ROOM_FAILED_CREATE, + ROOM_FAILED_GET_WINNER, + ROOM_ACCESS_DENIED, + ROOM_FAILED_CALCULATE_REVENUE, + + UNKNOWN_ERROR = UINT8_MAX + }; + + static constexpr uint8 toReturnCode(EReturnCode code) { return static_cast(code); } + + struct RoomInfo + { + id roomId; + id player1; + id allowedPlayer; // If zero, anyone can join + uint64 amount; + DateAndTime creationTimestamp; + }; + + struct CreateRoom_input + { + id allowedPlayer; // If zero, anyone can join + }; + + struct CreateRoom_output + { + uint8 returnCode; + }; + + struct CreateRoom_locals + { + RoomInfo newRoom; + id roomId; + }; + + struct GetWinnerPlayer_input + { + id player1; + id player2; + }; + + struct GetWinnerPlayer_output + { + id winner; + }; + + struct GetWinnerPlayer_locals + { + m256i randomValue; + m256i minPlayerId; + m256i maxPlayerId; + }; + + struct CalculateRevenue_input + { + uint64 amount; + }; + + struct CalculateRevenue_output + { + uint64 devFee; + uint64 burnFee; + uint64 shareholdersFee; + uint64 winner; + }; + + struct ConnectToRoom_input + { + id roomId; + }; + + struct ConnectToRoom_output + { + uint8 returnCode; + }; + + struct ConnectToRoom_locals + { + RoomInfo room; + GetWinnerPlayer_input getWinnerPlayer_input; + GetWinnerPlayer_output getWinnerPlayer_output; + CalculateRevenue_input calculateRevenue_input; + CalculateRevenue_output calculateRevenue_output; + id winner; + uint64 returnAmount; + uint64 amount; + }; + + struct GetPercentFees_input + { + }; + + struct GetPercentFees_output + { + uint8 devFeePercentBps; + uint8 burnFeePercentBps; + uint8 shareholdersFeePercentBps; + uint8 percentScale; + uint64 returnCode; + }; + + struct SetPercentFees_input + { + uint8 devFeePercentBps; + uint8 burnFeePercentBps; + uint8 shareholdersFeePercentBps; + }; + + struct SetPercentFees_output + { + uint8 returnCode; + }; + + struct SetPercentFees_locals + { + uint16 totalPercent; + }; + + struct GetRooms_input + { + }; + + struct GetRooms_output + { + Array rooms; + + uint8 returnCode; + }; + + struct GetRooms_locals + { + sint64 hashSetIndex; + uint64 arrayIndex; + }; + + struct SetTTLHours_input + { + uint8 ttlHours; + }; + + struct SetTTLHours_output + { + uint8 returnCode; + }; + + struct GetTTLHours_input + { + }; + + struct GetTTLHours_output + { + uint8 ttlHours; + uint8 returnCode; + }; + + struct END_TICK_locals + { + sint64 roomIndex; + RoomInfo room; + DateAndTime threshold; + }; + + struct END_EPOCH_locals + { + sint64 roomIndex; + RoomInfo room; + }; + +public: + REGISTER_USER_FUNCTIONS_AND_PROCEDURES() + { + REGISTER_USER_PROCEDURE(CreateRoom, 1); + REGISTER_USER_PROCEDURE(ConnectToRoom, 2); + REGISTER_USER_PROCEDURE(SetPercentFees, 3); + REGISTER_USER_PROCEDURE(SetTTLHours, 4); + + REGISTER_USER_FUNCTION(GetPercentFees, 1); + REGISTER_USER_FUNCTION(GetRooms, 2); + REGISTER_USER_FUNCTION(GetTTLHours, 3); + } + + INITIALIZE() + { + state.teamAddress = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, + _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); + + state.minimumDuelAmount = QDUEL_MINIMUM_DUEL_AMOUNT; + + // Fee + state.devFeePercentBps = QDUEL_DEV_FEE_PERCENT_BPS; + state.burnFeePercentBps = QDUEL_BURN_FEE_PERCENT_BPS; + state.shareholdersFeePercentBps = QDUEL_SHAREHOLDERS_FEE_PERCENT_BPS; + + state.ttlHours = QDUEL_TTL_HOURS; + } + + END_TICK_WITH_LOCALS() + { + if (mod(qpi.tick(), QDUEL_TICK_UPDATE_PERIOD) != 0) + { + return; + } + + locals.roomIndex = state.rooms.nextElementIndex(NULL_INDEX); + while (locals.roomIndex != NULL_INDEX) + { + locals.room = state.rooms.value(locals.roomIndex); + locals.threshold = locals.room.creationTimestamp; + locals.threshold.add(0, 0, 0, state.ttlHours, 0, 0); + + if (locals.threshold < qpi.now() || locals.threshold == qpi.now()) + { + qpi.transfer(locals.room.player1, locals.room.amount); + state.rooms.removeByIndex(locals.roomIndex); + } + + locals.roomIndex = state.rooms.nextElementIndex(locals.roomIndex); + } + } + + END_EPOCH_WITH_LOCALS() + { + locals.roomIndex = state.rooms.nextElementIndex(NULL_INDEX); + while (locals.roomIndex != NULL_INDEX) + { + locals.room = state.rooms.value(locals.roomIndex); + qpi.transfer(locals.room.player1, locals.room.amount); + + locals.roomIndex = state.rooms.nextElementIndex(locals.roomIndex); + } + + state.rooms.reset(); + } + + PUBLIC_PROCEDURE_WITH_LOCALS(CreateRoom) + { + if (qpi.invocationReward() < state.minimumDuelAmount) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + + output.returnCode = toReturnCode(EReturnCode::ROOM_INSUFFICIENT_DUEL_AMOUNT); // insufficient duel amount + return; + } + + if (state.rooms.population() >= state.rooms.capacity()) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + + output.returnCode = toReturnCode(EReturnCode::ROOM_FULL); // no more rooms available + return; + } + + locals.roomId = qpi.K12(qpi.tick() ^ state.rooms.population() ^ qpi.invocator().u64._0); + if (state.rooms.contains(locals.roomId)) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + output.returnCode = toReturnCode(EReturnCode::ROOM_FAILED_CREATE); // room creation failed + + return; + } + + locals.newRoom.roomId = locals.roomId; + locals.newRoom.player1 = qpi.invocator(); + locals.newRoom.allowedPlayer = input.allowedPlayer; + locals.newRoom.amount = qpi.invocationReward(); + locals.newRoom.creationTimestamp = qpi.now(); + + if (state.rooms.set(locals.roomId, locals.newRoom) == NULL_INDEX) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + + output.returnCode = toReturnCode(EReturnCode::ROOM_FAILED_CREATE); + return; + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PUBLIC_PROCEDURE_WITH_LOCALS(ConnectToRoom) + { + if (!state.rooms.get(input.roomId, locals.room)) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + + output.returnCode = toReturnCode(EReturnCode::ROOM_NOT_FOUND); + + return; + } + + if (locals.room.allowedPlayer != NULL_ID) + { + if (locals.room.allowedPlayer != qpi.invocator()) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + + output.returnCode = toReturnCode(EReturnCode::ROOM_ACCESS_DENIED); + return; + } + } + + if (qpi.invocationReward() < locals.room.amount) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + + output.returnCode = toReturnCode(EReturnCode::ROOM_INSUFFICIENT_DUEL_AMOUNT); + return; + } + + if (qpi.invocationReward() > locals.room.amount) + { + locals.returnAmount = qpi.invocationReward() - locals.room.amount; + qpi.transfer(qpi.invocator(), locals.returnAmount); + } + + locals.amount = (qpi.invocationReward() - locals.returnAmount) + locals.room.amount; + + locals.getWinnerPlayer_input.player1 = locals.room.player1; + locals.getWinnerPlayer_input.player2 = qpi.invocator(); + + CALL(GetWinnerPlayer, locals.getWinnerPlayer_input, locals.getWinnerPlayer_output); + locals.winner = locals.getWinnerPlayer_output.winner; + + if (locals.winner == id::zero() || + (locals.winner != locals.getWinnerPlayer_input.player1 && locals.winner != locals.getWinnerPlayer_input.player2)) + { + // Return fund to player1 + qpi.transfer(locals.getWinnerPlayer_input.player1, locals.room.amount); + // Return fund to player2 + qpi.transfer(locals.getWinnerPlayer_input.player2, locals.room.amount); + + output.returnCode = toReturnCode(EReturnCode::ROOM_FAILED_GET_WINNER); + return; + } + + locals.calculateRevenue_input.amount = locals.amount; + CALL(CalculateRevenue, locals.calculateRevenue_input, locals.calculateRevenue_output); + + if (locals.calculateRevenue_output.winner > 0) + { + qpi.transfer(locals.winner, locals.calculateRevenue_output.winner); + } + else + { + // Return fund to player1 + qpi.transfer(locals.getWinnerPlayer_input.player1, locals.room.amount); + // Return fund to player2 + qpi.transfer(locals.getWinnerPlayer_input.player2, locals.room.amount); + + output.returnCode = toReturnCode(EReturnCode::ROOM_FAILED_CALCULATE_REVENUE); + return; + } + + if (locals.calculateRevenue_output.devFee > 0) + { + qpi.transfer(state.teamAddress, locals.calculateRevenue_output.devFee); + } + if (locals.calculateRevenue_output.burnFee > 0) + { + qpi.burn(locals.calculateRevenue_output.burnFee); + } + if (locals.calculateRevenue_output.shareholdersFee > 0) + { + qpi.distributeDividends(div(locals.calculateRevenue_output.shareholdersFee, 676ULL)); + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PUBLIC_PROCEDURE_WITH_LOCALS(SetPercentFees) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (qpi.invocator() != state.teamAddress) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + locals.totalPercent = static_cast(input.devFeePercentBps) + static_cast(input.burnFeePercentBps) + + static_cast(input.shareholdersFeePercentBps); + locals.totalPercent = div(locals.totalPercent, static_cast(QDUEL_PERCENT_SCALE)); + + if (locals.totalPercent >= 100) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + state.devFeePercentBps = input.devFeePercentBps; + state.burnFeePercentBps = input.burnFeePercentBps; + state.shareholdersFeePercentBps = input.shareholdersFeePercentBps; + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PUBLIC_PROCEDURE(SetTTLHours) + { + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + + if (qpi.invocator() != state.teamAddress) + { + output.returnCode = toReturnCode(EReturnCode::ACCESS_DENIED); + return; + } + + if (input.ttlHours == 0) + { + output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE); + return; + } + + state.ttlHours = input.ttlHours; + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PUBLIC_FUNCTION(GetPercentFees) + { + output.devFeePercentBps = state.devFeePercentBps; + output.burnFeePercentBps = state.burnFeePercentBps; + output.shareholdersFeePercentBps = state.shareholdersFeePercentBps; + output.percentScale = QDUEL_PERCENT_SCALE; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PUBLIC_FUNCTION_WITH_LOCALS(GetRooms) + { + locals.hashSetIndex = state.rooms.nextElementIndex(NULL_INDEX); + while (locals.hashSetIndex != NULL_INDEX) + { + output.rooms.set(locals.arrayIndex++, state.rooms.value(locals.hashSetIndex)); + + locals.hashSetIndex = state.rooms.nextElementIndex(locals.hashSetIndex); + } + + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + + PUBLIC_FUNCTION(GetTTLHours) + { + output.ttlHours = state.ttlHours; + output.returnCode = toReturnCode(EReturnCode::SUCCESS); + } + +protected: + HashMap rooms; + id teamAddress; + uint64 minimumDuelAmount; + uint8 devFeePercentBps; + uint8 burnFeePercentBps; + uint8 shareholdersFeePercentBps; + uint8 ttlHours; + +protected: + template static constexpr const T& min(const T& a, const T& b) { return (a < b) ? a : b; } + template static constexpr const T& max(const T& a, const T& b) { return (a > b) ? a : b; } + static constexpr const m256i& max(const m256i& a, const m256i& b) { return (a < b) ? b : a; } + +private: + PRIVATE_FUNCTION_WITH_LOCALS(GetWinnerPlayer) + { + locals.minPlayerId = min(input.player1, input.player2); + locals.maxPlayerId = max(input.player1, input.player2); + + locals.randomValue = qpi.getPrevSpectrumDigest(); + + locals.randomValue.u64._0 ^= locals.minPlayerId.u64._0 ^ locals.maxPlayerId.u64._0 ^ qpi.tick(); + locals.randomValue.u64._1 ^= locals.minPlayerId.u64._1 ^ locals.maxPlayerId.u64._1; + locals.randomValue.u64._2 ^= locals.minPlayerId.u64._2 ^ locals.maxPlayerId.u64._2; + locals.randomValue.u64._3 ^= locals.minPlayerId.u64._3 ^ locals.maxPlayerId.u64._3; + + locals.randomValue = qpi.K12(locals.randomValue); + + output.winner = locals.randomValue.u64._0 & 1 ? locals.maxPlayerId : locals.minPlayerId; + } + + PRIVATE_FUNCTION(CalculateRevenue) + { + output.devFee = div(smul(input.amount, static_cast(state.devFeePercentBps)), QDUEL_PERCENT_SCALE); + output.burnFee = div(smul(input.amount, static_cast(state.burnFeePercentBps)), QDUEL_PERCENT_SCALE); + output.shareholdersFee = + smul(div(div(smul(input.amount, static_cast(state.shareholdersFeePercentBps)), QDUEL_PERCENT_SCALE), 676ULL), 676ULL); + output.winner = input.amount - (output.devFee + output.burnFee + output.shareholdersFee); + } +}; diff --git a/test/contract_qduel.cpp b/test/contract_qduel.cpp new file mode 100644 index 000000000..eaa6d2f77 --- /dev/null +++ b/test/contract_qduel.cpp @@ -0,0 +1,464 @@ +#define NO_UEFI +#define _ALLOW_KEYWORD_MACROS +#define private public +#include "contract_testing.h" +#undef private +#undef _ALLOW_KEYWORD_MACROS + +constexpr uint16 PROCEDURE_INDEX_CREATE_ROOM = 1; +constexpr uint16 PROCEDURE_INDEX_CONNECT_ROOM = 2; +constexpr uint16 PROCEDURE_INDEX_SET_PERCENT_FEES = 3; +constexpr uint16 PROCEDURE_INDEX_SET_TTL_HOURS = 4; +constexpr uint16 FUNCTION_INDEX_GET_PERCENT_FEES = 1; +constexpr uint16 FUNCTION_INDEX_GET_ROOMS = 2; +constexpr uint16 FUNCTION_INDEX_GET_TTL_HOURS = 3; + +static const id QDUEL_TEAM_ADDRESS = + ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, _W, _E, _T, _H, _N, _G, + _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); + +static void setDeterministicTimeAndTick(uint32 tick = QDUEL_TICK_UPDATE_PERIOD, uint16 year = 2025, uint8 month = 1, uint8 day = 1, uint8 hour = 0) +{ + setMemory(utcTime, 0); + utcTime.Year = year; + utcTime.Month = month; + utcTime.Day = day; + utcTime.Hour = hour; + utcTime.Minute = 0; + utcTime.Second = 0; + utcTime.Nanosecond = 0; + updateQpiTime(); + system.tick = tick; + etalonTick.prevSpectrumDigest = m256i::zero(); +} + +class QDuelChecker : public QDUEL +{ +public: + uint64 roomCount() const { return rooms.population(); } + id team() const { return teamAddress; } + uint8 ttl() const { return ttlHours; } + uint8 devFee() const { return devFeePercentBps; } + uint8 burnFee() const { return burnFeePercentBps; } + uint8 shareholdersFee() const { return shareholdersFeePercentBps; } + uint64 minDuelAmount() const { return minimumDuelAmount; } + + RoomInfo firstRoom() const + { + const sint64 index = rooms.nextElementIndex(NULL_INDEX); + if (index == NULL_INDEX) + { + return RoomInfo{}; + } + return rooms.value(index); + } + + bool hasRoom(const id& roomId) const { return rooms.contains(roomId); } + + id computeWinner(const id& player1, const id& player2) const + { + QpiContextUserFunctionCall qpi(QDUEL_CONTRACT_INDEX); + GetWinnerPlayer_input input{player1, player2}; + GetWinnerPlayer_output output{}; + GetWinnerPlayer_locals locals{}; + GetWinnerPlayer(qpi, *this, input, output, locals); + return output.winner; + } + + void calculateRevenue(uint64 amount, CalculateRevenue_output& output) const + { + QpiContextUserFunctionCall qpi(QDUEL_CONTRACT_INDEX); + + output = {}; + CalculateRevenue_input revenueInput{amount}; + CalculateRevenue_locals revenueLocals{}; + CalculateRevenue(qpi, *this, revenueInput, output, revenueLocals); + } +}; + +class ContractTestingQDuel : protected ContractTesting +{ +public: + ContractTestingQDuel() + { + initEmptySpectrum(); + initEmptyUniverse(); + INIT_CONTRACT(QDUEL); + system.epoch = contractDescriptions[QDUEL_CONTRACT_INDEX].constructionEpoch; + callSystemProcedure(QDUEL_CONTRACT_INDEX, INITIALIZE); + } + + QDuelChecker* state() { return reinterpret_cast(contractStates[QDUEL_CONTRACT_INDEX]); } + + QDUEL::CreateRoom_output createRoom(const id& user, const id& allowedPlayer, sint64 reward) + { + QDUEL::CreateRoom_input input{allowedPlayer}; + QDUEL::CreateRoom_output output; + if (!invokeUserProcedure(QDUEL_CONTRACT_INDEX, PROCEDURE_INDEX_CREATE_ROOM, input, output, user, reward)) + { + output.returnCode = QDUEL::toReturnCode(QDUEL::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + QDUEL::ConnectToRoom_output connectToRoom(const id& user, const id& roomId, sint64 reward) + { + QDUEL::ConnectToRoom_input input{roomId}; + QDUEL::ConnectToRoom_output output; + if (!invokeUserProcedure(QDUEL_CONTRACT_INDEX, PROCEDURE_INDEX_CONNECT_ROOM, input, output, user, reward)) + { + output.returnCode = QDUEL::toReturnCode(QDUEL::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + QDUEL::SetPercentFees_output setPercentFees(const id& user, uint8 devFee, uint8 burnFee, uint8 shareholdersFee, sint64 reward = 0) + { + QDUEL::SetPercentFees_input input{devFee, burnFee, shareholdersFee}; + QDUEL::SetPercentFees_output output; + if (!invokeUserProcedure(QDUEL_CONTRACT_INDEX, PROCEDURE_INDEX_SET_PERCENT_FEES, input, output, user, reward)) + { + output.returnCode = QDUEL::toReturnCode(QDUEL::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + QDUEL::SetTTLHours_output setTtlHours(const id& user, uint8 ttlHours, sint64 reward = 0) + { + QDUEL::SetTTLHours_input input{ttlHours}; + QDUEL::SetTTLHours_output output; + if (!invokeUserProcedure(QDUEL_CONTRACT_INDEX, PROCEDURE_INDEX_SET_TTL_HOURS, input, output, user, reward)) + { + output.returnCode = QDUEL::toReturnCode(QDUEL::EReturnCode::UNKNOWN_ERROR); + } + return output; + } + + QDUEL::GetPercentFees_output getPercentFees() + { + QDUEL::GetPercentFees_input input{}; + QDUEL::GetPercentFees_output output; + callFunction(QDUEL_CONTRACT_INDEX, FUNCTION_INDEX_GET_PERCENT_FEES, input, output); + return output; + } + + QDUEL::GetRooms_output getRooms() + { + QDUEL::GetRooms_input input{}; + QDUEL::GetRooms_output output; + callFunction(QDUEL_CONTRACT_INDEX, FUNCTION_INDEX_GET_ROOMS, input, output); + return output; + } + + QDUEL::GetTTLHours_output getTtlHours() + { + QDUEL::GetTTLHours_input input{}; + QDUEL::GetTTLHours_output output; + callFunction(QDUEL_CONTRACT_INDEX, FUNCTION_INDEX_GET_TTL_HOURS, input, output); + return output; + } + + void endTick() { callSystemProcedure(QDUEL_CONTRACT_INDEX, END_TICK); } + + void endEpoch() { callSystemProcedure(QDUEL_CONTRACT_INDEX, END_EPOCH); } +}; + +TEST(ContractQDuel, InitializeDefaults) +{ + ContractTestingQDuel qduel; + + EXPECT_EQ(qduel.state()->team(), QDUEL_TEAM_ADDRESS); + EXPECT_EQ(qduel.state()->devFee(), QDUEL_DEV_FEE_PERCENT_BPS); + EXPECT_EQ(qduel.state()->burnFee(), QDUEL_BURN_FEE_PERCENT_BPS); + EXPECT_EQ(qduel.state()->shareholdersFee(), QDUEL_SHAREHOLDERS_FEE_PERCENT_BPS); + EXPECT_EQ(qduel.state()->ttl(), QDUEL_TTL_HOURS); + EXPECT_EQ(qduel.state()->minDuelAmount(), QDUEL_MINIMUM_DUEL_AMOUNT); +} + +TEST(ContractQDuel, CreateRoomRejectsInsufficientAmount) +{ + ContractTestingQDuel qduel; + + const id host = id::randomValue(); + increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT); + + const uint64 balanceBefore = getBalance(host); + const QDUEL::CreateRoom_output& createOut = qduel.createRoom(host, NULL_ID, QDUEL_MINIMUM_DUEL_AMOUNT - 1); + EXPECT_EQ(createOut.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::ROOM_INSUFFICIENT_DUEL_AMOUNT)); + EXPECT_EQ(getBalance(host), balanceBefore); + EXPECT_EQ(qduel.state()->roomCount(), 0ull); +} + +TEST(ContractQDuel, CreateRoomListedInGetRooms) +{ + ContractTestingQDuel qduel; + + const id host = id::randomValue(); + const id allowed = id::randomValue(); + increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT * 2); + + constexpr sint64 reward = QDUEL_MINIMUM_DUEL_AMOUNT; + const uint64 balanceBefore = getBalance(host); + const QDUEL::CreateRoom_output& createOut = qduel.createRoom(host, allowed, reward); + EXPECT_EQ(createOut.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + + const QDUEL::RoomInfo room = qduel.state()->firstRoom(); + EXPECT_EQ(room.player1, host); + EXPECT_EQ(room.allowedPlayer, allowed); + EXPECT_EQ(room.amount, QDUEL_MINIMUM_DUEL_AMOUNT); + EXPECT_EQ(getBalance(host), balanceBefore - QDUEL_MINIMUM_DUEL_AMOUNT); + + const QDUEL::GetRooms_output& roomsOut = qduel.getRooms(); + EXPECT_EQ(roomsOut.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + EXPECT_NE(roomsOut.rooms.get(0).roomId, NULL_ID); + + uint64 found = 0; + for (uint64 i = 0; i < roomsOut.rooms.capacity(); ++i) + { + const QDUEL::RoomInfo& listed = roomsOut.rooms.get(i); + if (listed.roomId == NULL_ID) + { + continue; + } + EXPECT_EQ(listed.roomId, room.roomId); + EXPECT_EQ(listed.player1, room.player1); + EXPECT_EQ(listed.allowedPlayer, room.allowedPlayer); + EXPECT_EQ(listed.amount, room.amount); + ++found; + } + EXPECT_EQ(found, 1ull); +} + +TEST(ContractQDuel, SetPercentFeesAccessDeniedAndSuccess) +{ + ContractTestingQDuel qduel; + + const id attacker = id::randomValue(); + increaseEnergy(attacker, 1); + + const uint64 balanceBefore = getBalance(attacker); + const QDUEL::SetPercentFees_output& denied = qduel.setPercentFees(attacker, 1, 1, 1, 1); + EXPECT_EQ(denied.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(getBalance(attacker), balanceBefore); + + increaseEnergy(qduel.state()->team(), 1); + const QDUEL::SetPercentFees_output& applied = qduel.setPercentFees(qduel.state()->team(), 2, 3, 4); + EXPECT_EQ(applied.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + + const QDUEL::GetPercentFees_output& fees = qduel.getPercentFees(); + EXPECT_EQ(fees.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + EXPECT_EQ(fees.devFeePercentBps, 2); + EXPECT_EQ(fees.burnFeePercentBps, 3); + EXPECT_EQ(fees.shareholdersFeePercentBps, 4); + EXPECT_EQ(fees.percentScale, QDUEL_PERCENT_SCALE); +} + +TEST(ContractQDuel, SetTTLHoursAccessDeniedInvalidAndSuccess) +{ + ContractTestingQDuel qduel; + + const id attacker = id::randomValue(); + increaseEnergy(attacker, 1); + + const uint64 balanceBefore = getBalance(attacker); + const QDUEL::SetTTLHours_output& denied = qduel.setTtlHours(attacker, 1, 1); + EXPECT_EQ(denied.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::ACCESS_DENIED)); + EXPECT_EQ(getBalance(attacker), balanceBefore); + + increaseEnergy(qduel.state()->team(), 1); + const QDUEL::SetTTLHours_output& invalid = qduel.setTtlHours(qduel.state()->team(), 0); + EXPECT_EQ(invalid.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::INVALID_VALUE)); + + const QDUEL::SetTTLHours_output& applied = qduel.setTtlHours(qduel.state()->team(), 5); + EXPECT_EQ(applied.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + EXPECT_EQ(qduel.getTtlHours().ttlHours, 5); +} + +TEST(ContractQDuel, ConnectToRoomRejectsInvalidRequests) +{ + ContractTestingQDuel qduel; + + const id host = id::randomValue(); + const id intruder = id::randomValue(); + increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT * 2); + increaseEnergy(intruder, QDUEL_MINIMUM_DUEL_AMOUNT * 2); + + const QDUEL::CreateRoom_output& createOut = qduel.createRoom(host, host, QDUEL_MINIMUM_DUEL_AMOUNT); + EXPECT_EQ(createOut.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + const id& roomId = qduel.state()->firstRoom().roomId; + + const uint64 intruderBalance = getBalance(intruder); + const QDUEL::ConnectToRoom_output& denied = qduel.connectToRoom(intruder, roomId, QDUEL_MINIMUM_DUEL_AMOUNT); + EXPECT_EQ(denied.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::ROOM_ACCESS_DENIED)); + EXPECT_EQ(getBalance(intruder), intruderBalance); + + const QDUEL::ConnectToRoom_output& notFound = qduel.connectToRoom(host, id::randomValue(), QDUEL_MINIMUM_DUEL_AMOUNT); + EXPECT_EQ(notFound.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::ROOM_NOT_FOUND)); + + const uint64 hostBalance = getBalance(host); + const QDUEL::ConnectToRoom_output& insufficient = qduel.connectToRoom(host, roomId, QDUEL_MINIMUM_DUEL_AMOUNT - 1); + EXPECT_EQ(insufficient.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::ROOM_INSUFFICIENT_DUEL_AMOUNT)); + EXPECT_EQ(getBalance(host), hostBalance); +} + +TEST(ContractQDuel, ConnectToRoomSuccessPaysWinner) +{ + ContractTestingQDuel qduel; + + const id host = id::randomValue(); + const id joiner = id::randomValue(); + increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT * 2); + increaseEnergy(joiner, QDUEL_MINIMUM_DUEL_AMOUNT * 2); + + increaseEnergy(qduel.state()->team(), 1); + EXPECT_EQ(qduel.setPercentFees(qduel.state()->team(), 0, 0, 0).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + + const QDUEL::CreateRoom_output& createOut = qduel.createRoom(host, NULL_ID, QDUEL_MINIMUM_DUEL_AMOUNT); + EXPECT_EQ(createOut.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + const QDUEL::RoomInfo& room = qduel.state()->firstRoom(); + + const id winner = qduel.state()->computeWinner(host, joiner); + + QDUEL::CalculateRevenue_output revenueOutput{}; + qduel.state()->calculateRevenue(QDUEL_MINIMUM_DUEL_AMOUNT * 2, revenueOutput); + + const uint64 hostBefore = getBalance(host); + const uint64 joinerBefore = getBalance(joiner); + const QDUEL::ConnectToRoom_output& connectOut = qduel.connectToRoom(joiner, room.roomId, QDUEL_MINIMUM_DUEL_AMOUNT); + EXPECT_EQ(connectOut.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + + EXPECT_TRUE(winner == host || winner == joiner); + + const uint64 hostAfter = getBalance(host); + const uint64 joinerAfter = getBalance(joiner); + if (winner == host) + { + EXPECT_EQ(hostAfter, hostBefore + revenueOutput.winner); + EXPECT_EQ(joinerAfter, joinerBefore - QDUEL_MINIMUM_DUEL_AMOUNT); + } + else + { + EXPECT_EQ(joinerAfter, joinerBefore - QDUEL_MINIMUM_DUEL_AMOUNT + revenueOutput.winner); + EXPECT_EQ(hostAfter, hostBefore); + } +} + +TEST(ContractQDuel, ConnectToRoomRefundsOverpayment) +{ + ContractTestingQDuel qduel; + + const id host = id::randomValue(); + const id joiner = id::randomValue(); + increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT * 2); + increaseEnergy(joiner, QDUEL_MINIMUM_DUEL_AMOUNT * 3); + + increaseEnergy(qduel.state()->team(), 1); + EXPECT_EQ(qduel.setPercentFees(qduel.state()->team(), 0, 0, 0).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + + const QDUEL::CreateRoom_output& createOut = qduel.createRoom(host, NULL_ID, QDUEL_MINIMUM_DUEL_AMOUNT); + EXPECT_EQ(createOut.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + const QDUEL::RoomInfo& room = qduel.state()->firstRoom(); + + const id& winner = qduel.state()->computeWinner(host, joiner); + + QDUEL::CalculateRevenue_output revenueOutput{}; + qduel.state()->calculateRevenue(QDUEL_MINIMUM_DUEL_AMOUNT * 2, revenueOutput); + + const uint64 joinerBefore = getBalance(joiner); + const uint64 hostBefore = getBalance(host); + constexpr uint64 overpay = QDUEL_MINIMUM_DUEL_AMOUNT + 123; + const QDUEL::ConnectToRoom_output& connectOut = qduel.connectToRoom(joiner, room.roomId, overpay); + EXPECT_EQ(connectOut.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + + const uint64 joinerAfter = getBalance(joiner); + const uint64 hostAfter = getBalance(host); + if (winner == joiner) + { + EXPECT_EQ(joinerAfter, joinerBefore - QDUEL_MINIMUM_DUEL_AMOUNT + revenueOutput.winner); + EXPECT_EQ(hostAfter, hostBefore); + } + else + { + EXPECT_EQ(joinerAfter, joinerBefore - QDUEL_MINIMUM_DUEL_AMOUNT); + EXPECT_EQ(hostAfter, hostBefore + revenueOutput.winner); + } +} + +TEST(ContractQDuel, EndTickRefundsOnlyAfterTtl) +{ + ContractTestingQDuel qduel; + + const id host = id::randomValue(); + increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT * 2); + + setDeterministicTimeAndTick(); + + const uint64 balanceBefore = getBalance(host); + const QDUEL::CreateRoom_output& createOut = qduel.createRoom(host, NULL_ID, QDUEL_MINIMUM_DUEL_AMOUNT); + EXPECT_EQ(createOut.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + EXPECT_EQ(qduel.state()->roomCount(), 1ull); + EXPECT_EQ(getBalance(host), balanceBefore - QDUEL_MINIMUM_DUEL_AMOUNT); + + qduel.endTick(); + EXPECT_EQ(qduel.state()->roomCount(), 1ull); + EXPECT_EQ(getBalance(host), balanceBefore - QDUEL_MINIMUM_DUEL_AMOUNT); + + setDeterministicTimeAndTick(20, 2025, 1, 1, QDUEL_TTL_HOURS + 1); + qduel.endTick(); + EXPECT_EQ(qduel.state()->roomCount(), 0ull); + EXPECT_EQ(getBalance(host), balanceBefore); +} + +TEST(ContractQDuel, EndEpochRefundsAllRooms) +{ + ContractTestingQDuel qduel; + + const id host1 = id::randomValue(); + const id host2 = id::randomValue(); + increaseEnergy(host1, QDUEL_MINIMUM_DUEL_AMOUNT * 2); + increaseEnergy(host2, QDUEL_MINIMUM_DUEL_AMOUNT * 2); + + const uint64 host1Before = getBalance(host1); + const uint64 host2Before = getBalance(host2); + EXPECT_EQ(qduel.createRoom(host1, NULL_ID, QDUEL_MINIMUM_DUEL_AMOUNT).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + EXPECT_EQ(qduel.createRoom(host2, NULL_ID, QDUEL_MINIMUM_DUEL_AMOUNT).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + EXPECT_EQ(qduel.state()->roomCount(), 2ull); + + qduel.endEpoch(); + EXPECT_EQ(qduel.state()->roomCount(), 0ull); + EXPECT_EQ(getBalance(host1), host1Before); + EXPECT_EQ(getBalance(host2), host2Before); +} + +TEST(ContractQDuel, PrivateFunctionGetWinnerPlayerDeterministic) +{ + ContractTestingQDuel qduel; + + const id player1 = id::randomValue(); + const id player2 = id::randomValue(); + const id winner1 = qduel.state()->computeWinner(player1, player2); + const id winner2 = qduel.state()->computeWinner(player1, player2); + EXPECT_EQ(winner1, winner2); + EXPECT_TRUE(winner1 == player1 || winner1 == player2); +} + +TEST(ContractQDuel, PrivateFunctionCalculateRevenueComputesFees) +{ + ContractTestingQDuel qduel; + + increaseEnergy(qduel.state()->team(), 1); + EXPECT_EQ(qduel.setPercentFees(qduel.state()->team(), 2, 3, 4).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + + QDUEL::CalculateRevenue_output revenueOutput{}; + qduel.state()->calculateRevenue(1000000ull, revenueOutput); + + constexpr uint64 expectedDev = 1000000ull * 2 / QDUEL_PERCENT_SCALE; + constexpr uint64 expectedBurn = 1000000ull * 3 / QDUEL_PERCENT_SCALE; + constexpr uint64 expectedShare = 1000000ull * 4 / QDUEL_PERCENT_SCALE / 676ULL * 676ULL; + constexpr uint64 expectedWinner = 1000000ull - (expectedDev + expectedBurn + expectedShare); + + EXPECT_EQ(revenueOutput.devFee, expectedDev); + EXPECT_EQ(revenueOutput.burnFee, expectedBurn); + EXPECT_EQ(revenueOutput.shareholdersFee, expectedShare); + EXPECT_EQ(revenueOutput.winner, expectedWinner); +} diff --git a/test/test.vcxproj b/test/test.vcxproj index 5979b640b..ebb4a9985 100644 --- a/test/test.vcxproj +++ b/test/test.vcxproj @@ -120,6 +120,7 @@ + @@ -190,4 +191,4 @@ - \ No newline at end of file + diff --git a/test/test.vcxproj.filters b/test/test.vcxproj.filters index 05f9b9622..370ba0237 100644 --- a/test/test.vcxproj.filters +++ b/test/test.vcxproj.filters @@ -28,6 +28,7 @@ + From b7d07f5539f629be76f4bf9c3507c3188f758893 Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 25 Dec 2025 21:57:25 +0300 Subject: [PATCH 2/6] transfer dividends to RL shareholders --- src/contracts/QDuel.h | 73 +++++++++++++++++++++++++++++++++++++++-- test/contract_qduel.cpp | 41 +++++++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/contracts/QDuel.h b/src/contracts/QDuel.h index 29c244834..381ee22f4 100644 --- a/src/contracts/QDuel.h +++ b/src/contracts/QDuel.h @@ -7,7 +7,8 @@ constexpr uint8 QDUEL_BURN_FEE_PERCENT_BPS = 30; // 0.3% * QDUEL_PERCENT constexpr uint8 QDUEL_SHAREHOLDERS_FEE_PERCENT_BPS = 25; // 0.25% * QDUEL_PERCENT_SCALE constexpr uint8 QDUEL_PERCENT_SCALE = 100; constexpr uint8 QDUEL_TTL_HOURS = 2; -constexpr uint8 QDUEL_TICK_UPDATE_PERIOD = 10; // Process TICK logic once per this many ticks +constexpr uint8 QDUEL_TICK_UPDATE_PERIOD = 10; // Process TICK logic once per this many ticks +constexpr uint64 QDUEL_RANDOM_LOTTERY_ASSET_NAME = 19538; // RL struct QDUEL2 { @@ -92,6 +93,31 @@ struct QDUEL : public ContractBase uint64 winner; }; + struct TransferToShareholders_input + { + uint64 amount; + }; + + struct TransferToShareholders_output + { + uint64 remainder; + }; + + struct TransferToShareholders_locals + { + Entity entity; + uint64 shareholdersCount; + uint64 perShareholderAmount; + uint64 remainder; + sint64 index; + Asset rlAsset; + uint64 dividendPerShare; + AssetPossessionIterator rlIter; + uint64 rlShares; + uint64 transferredAmount; + sint64 toTransfer; + }; + struct ConnectToRoom_input { id roomId; @@ -109,6 +135,8 @@ struct QDUEL : public ContractBase GetWinnerPlayer_output getWinnerPlayer_output; CalculateRevenue_input calculateRevenue_input; CalculateRevenue_output calculateRevenue_output; + TransferToShareholders_input transferToShareholders_input; + TransferToShareholders_output transferToShareholders_output; id winner; uint64 returnAmount; uint64 amount; @@ -388,7 +416,14 @@ struct QDUEL : public ContractBase } if (locals.calculateRevenue_output.shareholdersFee > 0) { - qpi.distributeDividends(div(locals.calculateRevenue_output.shareholdersFee, 676ULL)); + locals.transferToShareholders_input.amount = locals.calculateRevenue_output.shareholdersFee; + + CALL(TransferToShareholders, locals.transferToShareholders_input, locals.transferToShareholders_output); + + if (locals.transferToShareholders_output.remainder > 0) + { + qpi.burn(locals.transferToShareholders_output.remainder); + } } output.returnCode = toReturnCode(EReturnCode::SUCCESS); @@ -516,4 +551,38 @@ struct QDUEL : public ContractBase smul(div(div(smul(input.amount, static_cast(state.shareholdersFeePercentBps)), QDUEL_PERCENT_SCALE), 676ULL), 676ULL); output.winner = input.amount - (output.devFee + output.burnFee + output.shareholdersFee); } + + PRIVATE_PROCEDURE_WITH_LOCALS(TransferToShareholders) + { + if (input.amount == 0) + { + return; + } + + locals.rlAsset.issuer = id::zero(); + locals.rlAsset.assetName = QDUEL_RANDOM_LOTTERY_ASSET_NAME; + + locals.dividendPerShare = div(input.amount, NUMBER_OF_COMPUTORS); + if (locals.dividendPerShare == 0) + { + return; + } + + locals.rlIter.begin(locals.rlAsset); + while (!locals.rlIter.reachedEnd()) + { + locals.rlShares = static_cast(locals.rlIter.numberOfPossessedShares()); + if (locals.rlShares > 0) + { + locals.toTransfer = static_cast(smul(locals.rlShares, locals.dividendPerShare)); + if (qpi.transfer(locals.rlIter.possessor(), locals.toTransfer) >= 0) + { + locals.transferredAmount += locals.toTransfer; + } + } + locals.rlIter.next(); + } + + output.remainder = input.amount - locals.transferredAmount; + } }; diff --git a/test/contract_qduel.cpp b/test/contract_qduel.cpp index eaa6d2f77..4d0afc64b 100644 --- a/test/contract_qduel.cpp +++ b/test/contract_qduel.cpp @@ -384,6 +384,47 @@ TEST(ContractQDuel, ConnectToRoomRefundsOverpayment) } } +TEST(ContractQDuel, ConnectToRoomPaysRLDividendsToShareholders) +{ + ContractTestingQDuel qduel; + + const id shareholder1 = id::randomValue(); + const id shareholder2 = id::randomValue(); + const id shareholder3 = id::randomValue(); + std::vector> rlShares{ + {shareholder1, 100}, + {shareholder2, 200}, + {shareholder3, 376}, + }; + issueContractShares(RL_CONTRACT_INDEX, rlShares); + + increaseEnergy(qduel.state()->team(), 1); + EXPECT_EQ(qduel.setPercentFees(qduel.state()->team(), 0, 0, 10).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + + const id host = id::randomValue(); + const id joiner = id::randomValue(); + constexpr uint64 duelAmount = 67600ULL; + increaseEnergy(host, duelAmount); + increaseEnergy(joiner, duelAmount); + + EXPECT_EQ(qduel.createRoom(host, NULL_ID, duelAmount).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + + QDUEL::CalculateRevenue_output revenueOutput{}; + qduel.state()->calculateRevenue(duelAmount * 2, revenueOutput); + const uint64 dividendPerShare = revenueOutput.shareholdersFee / NUMBER_OF_COMPUTORS; + + const uint64 shareholder1Before = getBalance(shareholder1); + const uint64 shareholder2Before = getBalance(shareholder2); + const uint64 shareholder3Before = getBalance(shareholder3); + + EXPECT_EQ(qduel.connectToRoom(joiner, qduel.state()->firstRoom().roomId, duelAmount).returnCode, + QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + + EXPECT_EQ(getBalance(shareholder1), shareholder1Before + dividendPerShare * rlShares[0].second); + EXPECT_EQ(getBalance(shareholder2), shareholder2Before + dividendPerShare * rlShares[1].second); + EXPECT_EQ(getBalance(shareholder3), shareholder3Before + dividendPerShare * rlShares[2].second); +} + TEST(ContractQDuel, EndTickRefundsOnlyAfterTtl) { ContractTestingQDuel qduel; From d8b83f17c2de33382f49d295df2543e4c047b3d2 Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 25 Dec 2025 22:13:43 +0300 Subject: [PATCH 3/6] Remove magic numbers --- test/contract_qduel.cpp | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/test/contract_qduel.cpp b/test/contract_qduel.cpp index 4d0afc64b..3fea333d9 100644 --- a/test/contract_qduel.cpp +++ b/test/contract_qduel.cpp @@ -391,15 +391,20 @@ TEST(ContractQDuel, ConnectToRoomPaysRLDividendsToShareholders) const id shareholder1 = id::randomValue(); const id shareholder2 = id::randomValue(); const id shareholder3 = id::randomValue(); + constexpr unsigned int rlSharesOwner1 = 100; + constexpr unsigned int rlSharesOwner2 = 200; + constexpr unsigned int rlSharesOwner3 = 376; std::vector> rlShares{ - {shareholder1, 100}, - {shareholder2, 200}, - {shareholder3, 376}, + {shareholder1, rlSharesOwner1}, + {shareholder2, rlSharesOwner2}, + {shareholder3, rlSharesOwner3}, }; issueContractShares(RL_CONTRACT_INDEX, rlShares); + constexpr uint8 shareholdersFeePercentBps = 10; increaseEnergy(qduel.state()->team(), 1); - EXPECT_EQ(qduel.setPercentFees(qduel.state()->team(), 0, 0, 10).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + EXPECT_EQ(qduel.setPercentFees(qduel.state()->team(), 0, 0, shareholdersFeePercentBps).returnCode, + QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); const id host = id::randomValue(); const id joiner = id::randomValue(); @@ -410,7 +415,8 @@ TEST(ContractQDuel, ConnectToRoomPaysRLDividendsToShareholders) EXPECT_EQ(qduel.createRoom(host, NULL_ID, duelAmount).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); QDUEL::CalculateRevenue_output revenueOutput{}; - qduel.state()->calculateRevenue(duelAmount * 2, revenueOutput); + constexpr uint64 duelPayoutAmount = duelAmount * 2; + qduel.state()->calculateRevenue(duelPayoutAmount, revenueOutput); const uint64 dividendPerShare = revenueOutput.shareholdersFee / NUMBER_OF_COMPUTORS; const uint64 shareholder1Before = getBalance(shareholder1); From 9d9153d248f0acd1322f1112ec403fe1c988f872 Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 25 Dec 2025 22:50:46 +0300 Subject: [PATCH 4/6] Move test file --- test/test.vcxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test.vcxproj b/test/test.vcxproj index ebb4a9985..a29532213 100644 --- a/test/test.vcxproj +++ b/test/test.vcxproj @@ -120,7 +120,6 @@ - @@ -141,6 +140,7 @@ + From baa2d1fddbd350168a8b1fca06e559379b353807 Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 25 Dec 2025 23:28:21 +0300 Subject: [PATCH 5/6] Changes public to protected --- test/contract_qduel.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/contract_qduel.cpp b/test/contract_qduel.cpp index 3fea333d9..de5c4a2d6 100644 --- a/test/contract_qduel.cpp +++ b/test/contract_qduel.cpp @@ -1,6 +1,6 @@ #define NO_UEFI #define _ALLOW_KEYWORD_MACROS -#define private public +#define private protected #include "contract_testing.h" #undef private #undef _ALLOW_KEYWORD_MACROS From bf8922756e8ae03a87966bc24d846a6d8bbdac51 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 26 Dec 2025 12:42:08 +0300 Subject: [PATCH 6/6] Adds lock mechanism --- src/contracts/QDuel.h | 58 +++++++++++++- src/contracts/RandomLottery.h | 6 +- test/contract_qduel.cpp | 140 +++++++++++++++++++++++++++++----- 3 files changed, 180 insertions(+), 24 deletions(-) diff --git a/src/contracts/QDuel.h b/src/contracts/QDuel.h index 381ee22f4..e259dbe9f 100644 --- a/src/contracts/QDuel.h +++ b/src/contracts/QDuel.h @@ -4,7 +4,7 @@ constexpr uint32 QDUEL_MAX_NUMBER_OF_ROOMS = 512; constexpr uint64 QDUEL_MINIMUM_DUEL_AMOUNT = 10000; constexpr uint8 QDUEL_DEV_FEE_PERCENT_BPS = 15; // 0.15% * QDUEL_PERCENT_SCALE constexpr uint8 QDUEL_BURN_FEE_PERCENT_BPS = 30; // 0.3% * QDUEL_PERCENT_SCALE -constexpr uint8 QDUEL_SHAREHOLDERS_FEE_PERCENT_BPS = 25; // 0.25% * QDUEL_PERCENT_SCALE +constexpr uint8 QDUEL_SHAREHOLDERS_FEE_PERCENT_BPS = 55; // 0.55% * QDUEL_PERCENT_SCALE constexpr uint8 QDUEL_PERCENT_SCALE = 100; constexpr uint8 QDUEL_TTL_HOURS = 2; constexpr uint8 QDUEL_TICK_UPDATE_PERIOD = 10; // Process TICK logic once per this many ticks @@ -17,6 +17,23 @@ struct QDUEL2 struct QDUEL : public ContractBase { public: + enum class EState : uint8 + { + NONE = 0, + WAIT_TIME = 1 << 0, + + LOCKED = WAIT_TIME + }; + + friend EState operator|(const EState& a, const EState& b) { return static_cast(static_cast(a) | static_cast(b)); } + friend EState operator&(const EState& a, const EState& b) { return static_cast(static_cast(a) & static_cast(b)); } + friend EState operator~(const EState& a) { return static_cast(~static_cast(a)); } + template friend bool operator==(const EState& a, const T& b) { return static_cast(a) == b; } + template friend bool operator!=(const EState& a, const T& b) { return !(a == b); } + + static EState removeStateFlag(EState state, EState flag) { return state & ~flag; } + static EState addStateFlag(EState state, EState flag) { return state | flag; } + enum class EReturnCode : uint8 { SUCCESS, @@ -32,6 +49,8 @@ struct QDUEL : public ContractBase ROOM_ACCESS_DENIED, ROOM_FAILED_CALCULATE_REVENUE, + STATE_LOCKED, + UNKNOWN_ERROR = UINT8_MAX }; @@ -214,6 +233,7 @@ struct QDUEL : public ContractBase sint64 roomIndex; RoomInfo room; DateAndTime threshold; + uint32 currentTimestamp; }; struct END_EPOCH_locals @@ -248,6 +268,8 @@ struct QDUEL : public ContractBase state.shareholdersFeePercentBps = QDUEL_SHAREHOLDERS_FEE_PERCENT_BPS; state.ttlHours = QDUEL_TTL_HOURS; + + state.currentState = EState::LOCKED; } END_TICK_WITH_LOCALS() @@ -257,6 +279,15 @@ struct QDUEL : public ContractBase return; } + if ((state.currentState & EState::WAIT_TIME) != EState::NONE) + { + RL::makeDateStamp(qpi.year(), qpi.month(), qpi.day(), locals.currentTimestamp); + if (RL_DEFAULT_INIT_TIME < locals.currentTimestamp) + { + state.currentState = removeStateFlag(state.currentState, EState::WAIT_TIME); + } + } + locals.roomIndex = state.rooms.nextElementIndex(NULL_INDEX); while (locals.roomIndex != NULL_INDEX) { @@ -285,11 +316,19 @@ struct QDUEL : public ContractBase locals.roomIndex = state.rooms.nextElementIndex(locals.roomIndex); } - state.rooms.reset(); + clearState(state); } PUBLIC_PROCEDURE_WITH_LOCALS(CreateRoom) { + if ((state.currentState & EState::LOCKED) != EState::NONE) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + + output.returnCode = toReturnCode(EReturnCode::STATE_LOCKED); + return; + } + if (qpi.invocationReward() < state.minimumDuelAmount) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); @@ -334,6 +373,14 @@ struct QDUEL : public ContractBase PUBLIC_PROCEDURE_WITH_LOCALS(ConnectToRoom) { + if ((state.currentState & EState::LOCKED) != EState::NONE) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + + output.returnCode = toReturnCode(EReturnCode::STATE_LOCKED); + return; + } + if (!state.rooms.get(input.roomId, locals.room)) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); @@ -519,12 +566,19 @@ struct QDUEL : public ContractBase uint8 burnFeePercentBps; uint8 shareholdersFeePercentBps; uint8 ttlHours; + EState currentState; protected: template static constexpr const T& min(const T& a, const T& b) { return (a < b) ? a : b; } template static constexpr const T& max(const T& a, const T& b) { return (a > b) ? a : b; } static constexpr const m256i& max(const m256i& a, const m256i& b) { return (a < b) ? b : a; } + static void clearState(QDUEL& state) + { + state.currentState = EState::LOCKED; + state.rooms.reset(); + } + private: PRIVATE_FUNCTION_WITH_LOCALS(GetWinnerPlayer) { diff --git a/src/contracts/RandomLottery.h b/src/contracts/RandomLottery.h index 2765a4537..7de4979c4 100644 --- a/src/contracts/RandomLottery.h +++ b/src/contracts/RandomLottery.h @@ -778,6 +778,9 @@ struct RL : public ContractBase output.returnCode = toReturnCode(EReturnCode::SUCCESS); } + // Packs current date into a compact stamp (Y/M/D) used to ensure a single action per calendar day. + static void makeDateStamp(uint8 year, uint8 month, uint8 day, uint32& res) { res = static_cast(year << 9 | month << 5 | day); } + private: /** * @brief Internal: records a winner into the cyclic winners array. @@ -952,9 +955,6 @@ struct RL : public ContractBase static void getWinnerCounter(const RL& state, uint64& outCounter) { outCounter = mod(state.winnersCounter, state.winners.capacity()); } - // Packs current date into a compact stamp (Y/M/D) used to ensure a single action per calendar day. - static void makeDateStamp(uint8 year, uint8 month, uint8 day, uint32& res) { res = static_cast(year << 9 | month << 5 | day); } - // Reads current net on-chain balance of SELF (incoming - outgoing). static void getSCRevenue(const Entity& entity, uint64& revenue) { revenue = entity.incomingAmount - entity.outgoingAmount; } diff --git a/test/contract_qduel.cpp b/test/contract_qduel.cpp index de5c4a2d6..fbeffc034 100644 --- a/test/contract_qduel.cpp +++ b/test/contract_qduel.cpp @@ -17,21 +17,6 @@ static const id QDUEL_TEAM_ADDRESS = ID(_Z, _T, _Z, _E, _A, _Q, _G, _U, _P, _I, _K, _T, _X, _F, _Y, _X, _Y, _E, _I, _T, _L, _A, _K, _F, _T, _D, _X, _C, _R, _L, _W, _E, _T, _H, _N, _G, _H, _D, _Y, _U, _W, _E, _Y, _Q, _N, _Q, _S, _R, _H, _O, _W, _M, _U, _J, _L, _E); -static void setDeterministicTimeAndTick(uint32 tick = QDUEL_TICK_UPDATE_PERIOD, uint16 year = 2025, uint8 month = 1, uint8 day = 1, uint8 hour = 0) -{ - setMemory(utcTime, 0); - utcTime.Year = year; - utcTime.Month = month; - utcTime.Day = day; - utcTime.Hour = hour; - utcTime.Minute = 0; - utcTime.Second = 0; - utcTime.Nanosecond = 0; - updateQpiTime(); - system.tick = tick; - etalonTick.prevSpectrumDigest = m256i::zero(); -} - class QDuelChecker : public QDUEL { public: @@ -42,6 +27,8 @@ class QDuelChecker : public QDUEL uint8 burnFee() const { return burnFeePercentBps; } uint8 shareholdersFee() const { return shareholdersFeePercentBps; } uint64 minDuelAmount() const { return minimumDuelAmount; } + void setState(EState newState) { currentState = newState; } + EState getState() const { return currentState; } RoomInfo firstRoom() const { @@ -161,6 +148,27 @@ class ContractTestingQDuel : protected ContractTesting void endTick() { callSystemProcedure(QDUEL_CONTRACT_INDEX, END_TICK); } void endEpoch() { callSystemProcedure(QDUEL_CONTRACT_INDEX, END_EPOCH); } + + void forceEndTick() + { + system.tick = system.tick + (QDUEL_TICK_UPDATE_PERIOD - mod(system.tick, static_cast(QDUEL_TICK_UPDATE_PERIOD))); + + endTick(); + } + + void setDeterministicTime(uint16 year = 2025, uint8 month = 1, uint8 day = 1, uint8 hour = 0) + { + setMemory(utcTime, 0); + utcTime.Year = year; + utcTime.Month = month; + utcTime.Day = day; + utcTime.Hour = hour; + utcTime.Minute = 0; + utcTime.Second = 0; + utcTime.Nanosecond = 0; + updateQpiTime(); + etalonTick.prevSpectrumDigest = m256i::zero(); + } }; TEST(ContractQDuel, InitializeDefaults) @@ -179,6 +187,9 @@ TEST(ContractQDuel, CreateRoomRejectsInsufficientAmount) { ContractTestingQDuel qduel; + qduel.setDeterministicTime(); + qduel.forceEndTick(); + const id host = id::randomValue(); increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT); @@ -189,10 +200,61 @@ TEST(ContractQDuel, CreateRoomRejectsInsufficientAmount) EXPECT_EQ(qduel.state()->roomCount(), 0ull); } +TEST(ContractQDuel, CreateRoomBlockedWhenLocked) +{ + ContractTestingQDuel qduel; + + const id host = id::randomValue(); + increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT); + qduel.state()->setState(QDUEL::EState::LOCKED); + + const uint64 balanceBefore = getBalance(host); + const QDUEL::CreateRoom_output& createOut = qduel.createRoom(host, NULL_ID, QDUEL_MINIMUM_DUEL_AMOUNT); + EXPECT_EQ(createOut.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::STATE_LOCKED)); + EXPECT_EQ(getBalance(host), balanceBefore); + EXPECT_EQ(qduel.state()->roomCount(), 0ull); +} + +TEST(ContractQDuel, CreateRoomLockedUntilValidTime) +{ + ContractTestingQDuel qduel; + + // Set default time + qduel.setDeterministicTime(2022, 4, 13); + qduel.forceEndTick(); + + const id host = id::randomValue(); + const id joiner = id::randomValue(); + increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT * 2); + increaseEnergy(joiner, QDUEL_MINIMUM_DUEL_AMOUNT * 2); + + EXPECT_EQ(qduel.state()->getState() & QDUEL::EState::WAIT_TIME, QDUEL::EState::WAIT_TIME); + + const uint64 hostBalance = getBalance(host); + const QDUEL::CreateRoom_output& lockedOut = qduel.createRoom(host, NULL_ID, QDUEL_MINIMUM_DUEL_AMOUNT); + EXPECT_EQ(lockedOut.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::STATE_LOCKED)); + EXPECT_EQ(getBalance(host), hostBalance); + EXPECT_EQ(qduel.state()->roomCount(), 0ull); + + qduel.setDeterministicTime(2025, 5, 1, 0); + qduel.forceEndTick(); + EXPECT_EQ(qduel.state()->getState() & QDUEL::EState::WAIT_TIME, QDUEL::EState::NONE); + + const QDUEL::CreateRoom_output& createOut = qduel.createRoom(host, NULL_ID, QDUEL_MINIMUM_DUEL_AMOUNT); + EXPECT_EQ(createOut.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + EXPECT_EQ(qduel.state()->roomCount(), 1ull); + + const QDUEL::ConnectToRoom_output& connectOut = qduel.connectToRoom(joiner, qduel.state()->firstRoom().roomId, QDUEL_MINIMUM_DUEL_AMOUNT); + EXPECT_EQ(connectOut.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); +} + TEST(ContractQDuel, CreateRoomListedInGetRooms) { ContractTestingQDuel qduel; + qduel.setDeterministicTime(); + qduel.forceEndTick(); + const id host = id::randomValue(); const id allowed = id::randomValue(); increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT * 2); @@ -278,6 +340,9 @@ TEST(ContractQDuel, ConnectToRoomRejectsInvalidRequests) { ContractTestingQDuel qduel; + qduel.setDeterministicTime(); + qduel.forceEndTick(); + const id host = id::randomValue(); const id intruder = id::randomValue(); increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT * 2); @@ -301,10 +366,37 @@ TEST(ContractQDuel, ConnectToRoomRejectsInvalidRequests) EXPECT_EQ(getBalance(host), hostBalance); } +TEST(ContractQDuel, ConnectToRoomBlockedWhenLocked) +{ + ContractTestingQDuel qduel; + + qduel.setDeterministicTime(); + qduel.forceEndTick(); + + const id host = id::randomValue(); + const id joiner = id::randomValue(); + increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT * 2); + increaseEnergy(joiner, QDUEL_MINIMUM_DUEL_AMOUNT * 2); + + EXPECT_EQ(qduel.createRoom(host, NULL_ID, QDUEL_MINIMUM_DUEL_AMOUNT).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)); + + const id& roomId = qduel.state()->firstRoom().roomId; + qduel.state()->setState(QDUEL::EState::LOCKED); + + const uint64 joinerBalance = getBalance(joiner); + const QDUEL::ConnectToRoom_output& connectOut = qduel.connectToRoom(joiner, roomId, QDUEL_MINIMUM_DUEL_AMOUNT); + EXPECT_EQ(connectOut.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::STATE_LOCKED)); + EXPECT_EQ(getBalance(joiner), joinerBalance); + EXPECT_TRUE(qduel.state()->hasRoom(roomId)); +} + TEST(ContractQDuel, ConnectToRoomSuccessPaysWinner) { ContractTestingQDuel qduel; + qduel.setDeterministicTime(); + qduel.forceEndTick(); + const id host = id::randomValue(); const id joiner = id::randomValue(); increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT * 2); @@ -347,6 +439,9 @@ TEST(ContractQDuel, ConnectToRoomRefundsOverpayment) { ContractTestingQDuel qduel; + qduel.setDeterministicTime(); + qduel.forceEndTick(); + const id host = id::randomValue(); const id joiner = id::randomValue(); increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT * 2); @@ -388,6 +483,9 @@ TEST(ContractQDuel, ConnectToRoomPaysRLDividendsToShareholders) { ContractTestingQDuel qduel; + qduel.setDeterministicTime(); + qduel.forceEndTick(); + const id shareholder1 = id::randomValue(); const id shareholder2 = id::randomValue(); const id shareholder3 = id::randomValue(); @@ -438,7 +536,8 @@ TEST(ContractQDuel, EndTickRefundsOnlyAfterTtl) const id host = id::randomValue(); increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT * 2); - setDeterministicTimeAndTick(); + qduel.setDeterministicTime(); + qduel.forceEndTick(); const uint64 balanceBefore = getBalance(host); const QDUEL::CreateRoom_output& createOut = qduel.createRoom(host, NULL_ID, QDUEL_MINIMUM_DUEL_AMOUNT); @@ -446,12 +545,12 @@ TEST(ContractQDuel, EndTickRefundsOnlyAfterTtl) EXPECT_EQ(qduel.state()->roomCount(), 1ull); EXPECT_EQ(getBalance(host), balanceBefore - QDUEL_MINIMUM_DUEL_AMOUNT); - qduel.endTick(); + qduel.forceEndTick(); EXPECT_EQ(qduel.state()->roomCount(), 1ull); EXPECT_EQ(getBalance(host), balanceBefore - QDUEL_MINIMUM_DUEL_AMOUNT); - setDeterministicTimeAndTick(20, 2025, 1, 1, QDUEL_TTL_HOURS + 1); - qduel.endTick(); + qduel.setDeterministicTime(2025, 1, 1, QDUEL_TTL_HOURS + 1); + qduel.forceEndTick(); EXPECT_EQ(qduel.state()->roomCount(), 0ull); EXPECT_EQ(getBalance(host), balanceBefore); } @@ -460,6 +559,9 @@ TEST(ContractQDuel, EndEpochRefundsAllRooms) { ContractTestingQDuel qduel; + qduel.setDeterministicTime(); + qduel.forceEndTick(); + const id host1 = id::randomValue(); const id host2 = id::randomValue(); increaseEnergy(host1, QDUEL_MINIMUM_DUEL_AMOUNT * 2);