diff --git a/src/Qubic.vcxproj b/src/Qubic.vcxproj
index 78634bba1..99cb3d7e2 100644
--- a/src/Qubic.vcxproj
+++ b/src/Qubic.vcxproj
@@ -27,6 +27,7 @@
+
diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters
index 2c0da5fb5..30c12fca8 100644
--- a/src/Qubic.vcxproj.filters
+++ b/src/Qubic.vcxproj.filters
@@ -303,6 +303,9 @@
contracts
+
+ contracts
+
contract_core
diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h
index 65139e477..a1ef1e24e 100644
--- a/src/contract_core/contract_def.h
+++ b/src/contract_core/contract_def.h
@@ -211,6 +211,16 @@
#define CONTRACT_STATE2_TYPE QRWA2
#include "contracts/qRWA.h"
+#undef CONTRACT_INDEX
+#undef CONTRACT_STATE_TYPE
+#undef CONTRACT_STATE2_TYPE
+
+#define QDUEL_CONTRACT_INDEX 21
+#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
@@ -319,6 +329,7 @@ constexpr struct ContractDescription
{"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
{"QRWA", 197, 10000, sizeof(QRWA)}, // proposal in epoch 195, IPO in 196, construction and first use in 197
+ {"QDUEL", 198, 10000, sizeof(QDUEL)}, // proposal in epoch 196, IPO in 197, construction and first use in 198
// new contracts should be added above this line
#ifdef INCLUDE_CONTRACT_TEST_EXAMPLES
{"TESTEXA", 138, 10000, sizeof(TESTEXA)},
@@ -435,6 +446,7 @@ static void initializeContracts()
REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QIP);
REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QRAFFLE);
REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QRWA);
+ 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..44f20da99
--- /dev/null
+++ b/src/contracts/QDuel.h
@@ -0,0 +1,1089 @@
+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 = 55; // 0.55% * QDUEL_PERCENT_SCALE
+constexpr uint8 QDUEL_PERCENT_SCALE = 1000;
+constexpr uint8 QDUEL_TTL_HOURS = 3;
+constexpr uint8 QDUEL_TICK_UPDATE_PERIOD = 100; // Process TICK logic once per this many ticks
+constexpr uint64 QDUEL_RANDOM_LOTTERY_ASSET_NAME = 19538; // RL
+constexpr uint64 QDUEL_ROOMS_REMOVAL_THRESHOLD_PERCENT = 75;
+
+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,
+ ACCESS_DENIED,
+ INVALID_VALUE,
+ USER_ALREADY_EXISTS,
+ USER_NOT_FOUND,
+ INSUFFICIENT_FREE_DEPOSIT,
+
+ // Room
+ ROOM_INSUFFICIENT_DUEL_AMOUNT,
+ ROOM_NOT_FOUND,
+ ROOM_FULL,
+ ROOM_FAILED_CREATE,
+ ROOM_FAILED_GET_WINNER,
+ ROOM_ACCESS_DENIED,
+ ROOM_FAILED_CALCULATE_REVENUE,
+
+ STATE_LOCKED,
+
+ UNKNOWN_ERROR = UINT8_MAX
+ };
+
+ static constexpr uint8 toReturnCode(EReturnCode code) { return static_cast(code); }
+
+ struct RoomInfo
+ {
+ id roomId;
+ id owner;
+ id allowedPlayer; // If zero, anyone can join
+ uint64 amount;
+ uint64 closeTimer;
+ DateAndTime lastUpdate;
+ };
+
+ struct UserData
+ {
+ id userId;
+ id roomId;
+ id allowedPlayer;
+ uint64 depositedAmount;
+ uint64 locked;
+ uint64 stake;
+ uint64 raiseStep;
+ uint64 maxStake;
+ };
+
+ struct AddUserData_input
+ {
+ id userId;
+ id roomId;
+ id allowedPlayer;
+ uint64 depositedAmount;
+ uint64 stake;
+ uint64 raiseStep;
+ uint64 maxStake;
+ };
+
+ struct AddUserData_output
+ {
+ uint8 returnCode;
+ };
+
+ struct AddUserData_locals
+ {
+ UserData newUserData;
+ };
+
+ struct CreateRoom_input
+ {
+ id allowedPlayer; // If zero, anyone can join
+ uint64 stake;
+ uint64 raiseStep;
+ uint64 maxStake;
+ };
+
+ struct CreateRoom_output
+ {
+ uint8 returnCode;
+ };
+
+ struct CreateRoomRecord_input
+ {
+ id owner;
+ id allowedPlayer;
+ uint64 amount;
+ };
+
+ struct CreateRoomRecord_output
+ {
+ id roomId;
+ uint8 returnCode;
+ };
+
+ struct CreateRoomRecord_locals
+ {
+ RoomInfo newRoom;
+ id roomId;
+ uint64 attempt;
+ };
+
+ struct ComputeNextStake_input
+ {
+ uint64 stake;
+ uint64 raiseStep;
+ uint64 maxStake;
+ };
+
+ struct ComputeNextStake_output
+ {
+ uint64 nextStake;
+ uint8 returnCode;
+ };
+
+ struct CreateRoom_locals
+ {
+ AddUserData_input addUserInput;
+ AddUserData_output addUserOutput;
+ CreateRoomRecord_input createRoomInput;
+ CreateRoomRecord_output createRoomOutput;
+ };
+
+ 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 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 FinalizeRoom_input
+ {
+ id roomId;
+ id owner;
+ uint64 roomAmount;
+ bit includeLocked;
+ };
+
+ struct FinalizeRoom_output
+ {
+ uint8 returnCode;
+ };
+
+ struct FinalizeRoom_locals
+ {
+ UserData userData;
+ uint64 availableDeposit;
+ CreateRoomRecord_input createRoomInput;
+ CreateRoomRecord_output createRoomOutput;
+ ComputeNextStake_input nextStakeInput;
+ ComputeNextStake_output nextStakeOutput;
+ };
+
+ 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;
+ TransferToShareholders_input transferToShareholders_input;
+ TransferToShareholders_output transferToShareholders_output;
+ FinalizeRoom_input finalizeInput;
+ FinalizeRoom_output finalizeOutput;
+ id winner;
+ uint64 returnAmount;
+ uint64 amount;
+ bit failedGetWinner;
+ };
+
+ 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 GetUserProfile_input
+ {
+ };
+
+ struct GetUserProfile_output
+ {
+ id roomId;
+ uint64 depositedAmount;
+ uint64 locked;
+ uint64 stake;
+ uint64 raiseStep;
+ uint64 maxStake;
+ uint8 returnCode;
+ };
+
+ struct GetUserProfile_locals
+ {
+ UserData userData;
+ };
+
+ struct Deposit_input
+ {
+ };
+
+ struct Deposit_output
+ {
+ uint8 returnCode;
+ };
+
+ struct Deposit_locals
+ {
+ UserData userData;
+ };
+
+ struct Withdraw_input
+ {
+ uint64 amount;
+ };
+
+ struct Withdraw_output
+ {
+ uint8 returnCode;
+ };
+
+ struct Withdraw_locals
+ {
+ UserData userData;
+ uint64 freeAmount;
+ };
+
+ struct END_TICK_locals
+ {
+ UserData userData;
+ RoomInfo room;
+ DateAndTime now;
+ sint64 roomIndex;
+ uint32 currentTimestamp;
+ uint64 elapsedSeconds;
+ FinalizeRoom_input finalizeInput;
+ FinalizeRoom_output finalizeOutput;
+ };
+
+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_PROCEDURE(Deposit, 5);
+ REGISTER_USER_PROCEDURE(Withdraw, 6);
+
+ REGISTER_USER_FUNCTION(GetPercentFees, 1);
+ REGISTER_USER_FUNCTION(GetRooms, 2);
+ REGISTER_USER_FUNCTION(GetTTLHours, 3);
+ REGISTER_USER_FUNCTION(GetUserProfile, 4);
+ }
+
+ 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;
+ }
+
+ BEGIN_EPOCH()
+ {
+ state.firstTick = true;
+ state.currentState = EState::LOCKED;
+ }
+
+ END_EPOCH()
+ {
+ state.rooms.cleanup();
+ state.users.cleanup();
+ }
+
+ END_TICK_WITH_LOCALS()
+ {
+ if (mod(qpi.tick(), QDUEL_TICK_UPDATE_PERIOD) != 0)
+ {
+ 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);
+ }
+ }
+
+ if ((state.currentState & EState::LOCKED) != EState::NONE)
+ {
+ return;
+ }
+
+ locals.roomIndex = state.rooms.nextElementIndex(NULL_INDEX);
+ while (locals.roomIndex != NULL_INDEX)
+ {
+ locals.room = state.rooms.value(locals.roomIndex);
+ locals.now = qpi.now();
+
+ /**
+ * The interval between the end of the epoch and the first valid tick can be large.
+ * To do this, we restore the time before the room was closed.
+ */
+ if (state.firstTick)
+ {
+ locals.room.lastUpdate = locals.now;
+ }
+
+ locals.elapsedSeconds = div(locals.room.lastUpdate.durationMicrosec(locals.now), 1000000ULL);
+ if (locals.elapsedSeconds >= locals.room.closeTimer)
+ {
+ locals.finalizeInput.roomId = locals.room.roomId;
+ locals.finalizeInput.owner = locals.room.owner;
+ locals.finalizeInput.roomAmount = locals.room.amount;
+ locals.finalizeInput.includeLocked = true;
+ CALL(FinalizeRoom, locals.finalizeInput, locals.finalizeOutput);
+ }
+ else
+ {
+ locals.room.closeTimer = usubSatu64(locals.room.closeTimer, locals.elapsedSeconds);
+ locals.room.lastUpdate = locals.now;
+ state.rooms.set(locals.room.roomId, locals.room);
+ }
+
+ locals.roomIndex = state.rooms.nextElementIndex(locals.roomIndex);
+ }
+
+ state.firstTick = false;
+ }
+
+ 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());
+
+ output.returnCode = toReturnCode(EReturnCode::ROOM_INSUFFICIENT_DUEL_AMOUNT); // insufficient duel amount
+ return;
+ }
+
+ if (input.stake < state.minimumDuelAmount || (input.maxStake > 0 && input.maxStake < input.stake))
+ {
+ qpi.transfer(qpi.invocator(), qpi.invocationReward());
+
+ output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE);
+ return;
+ }
+
+ if (qpi.invocationReward() < input.stake)
+ {
+ qpi.transfer(qpi.invocator(), qpi.invocationReward());
+
+ output.returnCode = toReturnCode(EReturnCode::ROOM_INSUFFICIENT_DUEL_AMOUNT);
+ return;
+ }
+
+ if (state.users.contains(qpi.invocator()))
+ {
+ qpi.transfer(qpi.invocator(), qpi.invocationReward());
+
+ output.returnCode = toReturnCode(EReturnCode::USER_ALREADY_EXISTS);
+ return;
+ }
+
+ locals.createRoomInput.owner = qpi.invocator();
+ locals.createRoomInput.allowedPlayer = input.allowedPlayer;
+ locals.createRoomInput.amount = input.stake;
+ CALL(CreateRoomRecord, locals.createRoomInput, locals.createRoomOutput);
+ if (locals.createRoomOutput.returnCode != toReturnCode(EReturnCode::SUCCESS))
+ {
+ qpi.transfer(qpi.invocator(), qpi.invocationReward());
+ output.returnCode = locals.createRoomOutput.returnCode;
+ return;
+ }
+
+ locals.addUserInput.userId = qpi.invocator();
+ locals.addUserInput.roomId = locals.createRoomOutput.roomId;
+ locals.addUserInput.allowedPlayer = input.allowedPlayer;
+ if (qpi.invocationReward() > input.stake)
+ {
+ locals.addUserInput.depositedAmount = qpi.invocationReward() - input.stake;
+ }
+ else
+ {
+ locals.addUserInput.depositedAmount = 0;
+ }
+ locals.addUserInput.stake = input.stake;
+ locals.addUserInput.raiseStep = input.raiseStep;
+ locals.addUserInput.maxStake = input.maxStake;
+
+ CALL(AddUserData, locals.addUserInput, locals.addUserOutput);
+ if (locals.addUserOutput.returnCode != toReturnCode(EReturnCode::SUCCESS))
+ {
+ state.rooms.removeByKey(locals.createRoomOutput.roomId);
+ qpi.transfer(qpi.invocator(), qpi.invocationReward());
+
+ output.returnCode = locals.addUserOutput.returnCode;
+ return;
+ }
+
+ output.returnCode = toReturnCode(EReturnCode::SUCCESS);
+ }
+
+ 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());
+
+ 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.owner;
+ 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);
+
+ state.rooms.removeByKey(input.roomId);
+ locals.amount = 0;
+ locals.failedGetWinner = true;
+ locals.room.amount = 0;
+ }
+
+ if (locals.amount > 0)
+ {
+ 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 if (!locals.failedGetWinner)
+ {
+ // 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);
+
+ state.rooms.removeByKey(input.roomId);
+ }
+
+ 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)
+ {
+ 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);
+ }
+ }
+
+ locals.finalizeInput.roomId = input.roomId;
+ locals.finalizeInput.owner = locals.room.owner;
+ locals.finalizeInput.roomAmount = 0;
+ locals.finalizeInput.includeLocked = false;
+ CALL(FinalizeRoom, locals.finalizeInput, locals.finalizeOutput);
+ output.returnCode = locals.finalizeOutput.returnCode;
+ }
+
+ 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);
+ }
+
+ PUBLIC_FUNCTION_WITH_LOCALS(GetUserProfile)
+ {
+ if (!state.users.get(qpi.invocator(), locals.userData))
+ {
+ output.returnCode = toReturnCode(EReturnCode::USER_NOT_FOUND);
+ return;
+ }
+
+ output.roomId = locals.userData.roomId;
+ output.depositedAmount = locals.userData.depositedAmount;
+ output.locked = locals.userData.locked;
+ output.stake = locals.userData.stake;
+ output.raiseStep = locals.userData.raiseStep;
+ output.maxStake = locals.userData.maxStake;
+ output.returnCode = toReturnCode(EReturnCode::SUCCESS);
+ }
+
+ PUBLIC_PROCEDURE_WITH_LOCALS(Deposit)
+ {
+ if (qpi.invocationReward() == 0)
+ {
+ output.returnCode = toReturnCode(EReturnCode::INVALID_VALUE);
+ return;
+ }
+
+ if (!state.users.get(qpi.invocator(), locals.userData))
+ {
+ qpi.transfer(qpi.invocator(), qpi.invocationReward());
+ output.returnCode = toReturnCode(EReturnCode::USER_NOT_FOUND);
+ return;
+ }
+
+ locals.userData.depositedAmount += qpi.invocationReward();
+ state.users.set(locals.userData.userId, locals.userData);
+ output.returnCode = toReturnCode(EReturnCode::SUCCESS);
+ }
+
+ PUBLIC_PROCEDURE_WITH_LOCALS(Withdraw)
+ {
+ if (qpi.invocationReward() > 0)
+ {
+ qpi.transfer(qpi.invocator(), qpi.invocationReward());
+ }
+
+ if (!state.users.get(qpi.invocator(), locals.userData))
+ {
+ output.returnCode = toReturnCode(EReturnCode::USER_NOT_FOUND);
+ return;
+ }
+
+ locals.freeAmount = locals.userData.depositedAmount;
+
+ if (input.amount == 0 || input.amount > locals.freeAmount)
+ {
+ output.returnCode = toReturnCode(EReturnCode::INSUFFICIENT_FREE_DEPOSIT);
+ return;
+ }
+
+ locals.userData.depositedAmount -= input.amount;
+ state.users.set(locals.userData.userId, locals.userData);
+ qpi.transfer(qpi.invocator(), input.amount);
+ output.returnCode = toReturnCode(EReturnCode::SUCCESS);
+ }
+
+protected:
+ HashMap rooms;
+ HashMap users;
+ id teamAddress;
+ uint64 minimumDuelAmount;
+ uint8 devFeePercentBps;
+ uint8 burnFeePercentBps;
+ uint8 shareholdersFeePercentBps;
+ uint8 ttlHours;
+ uint8 firstTick;
+ 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 computeNextStake(const ComputeNextStake_input& input, ComputeNextStake_output& output)
+ {
+ output.nextStake = input.stake;
+
+ if (input.raiseStep > 1)
+ {
+ if (input.maxStake > 0 && input.stake > 0 && input.raiseStep > div(input.maxStake, input.stake))
+ {
+ output.nextStake = input.maxStake;
+ }
+ else
+ {
+ output.nextStake = smul(input.stake, input.raiseStep);
+ }
+ }
+
+ if (input.maxStake > 0 && output.nextStake > input.maxStake)
+ {
+ output.nextStake = input.maxStake;
+ }
+
+ output.returnCode = toReturnCode(EReturnCode::SUCCESS);
+ }
+
+ static uint64_t usubSatu64(uint64 a, uint64 b) { return (a < b) ? 0 : (a - b); }
+
+private:
+ PRIVATE_PROCEDURE_WITH_LOCALS(CreateRoomRecord)
+ {
+ if (state.rooms.population() >= state.rooms.capacity())
+ {
+ output.returnCode = toReturnCode(EReturnCode::ROOM_FULL);
+ output.roomId = id::zero();
+ return;
+ }
+
+ locals.attempt = 0;
+ while (locals.attempt < 8)
+ {
+ locals.roomId = qpi.K12(m256i(qpi.tick() ^ state.rooms.population() ^ input.owner.u64._0 ^ locals.attempt,
+ input.owner.u64._1 ^ input.allowedPlayer.u64._0 ^ (locals.attempt << 1),
+ input.owner.u64._2 ^ input.allowedPlayer.u64._1 ^ (locals.attempt << 2),
+ input.owner.u64._3 ^ input.amount ^ (locals.attempt << 3)));
+ if (!state.rooms.contains(locals.roomId))
+ {
+ break;
+ }
+ ++locals.attempt;
+ }
+ if (locals.attempt >= 8)
+ {
+ output.returnCode = toReturnCode(EReturnCode::ROOM_FAILED_CREATE);
+ output.roomId = id::zero();
+ return;
+ }
+
+ locals.newRoom.roomId = locals.roomId;
+ locals.newRoom.owner = input.owner;
+ locals.newRoom.allowedPlayer = input.allowedPlayer;
+ locals.newRoom.amount = input.amount;
+ locals.newRoom.closeTimer = static_cast(state.ttlHours) * 3600U;
+ locals.newRoom.lastUpdate = qpi.now();
+
+ if (state.rooms.set(locals.roomId, locals.newRoom) == NULL_INDEX)
+ {
+ output.returnCode = toReturnCode(EReturnCode::ROOM_FAILED_CREATE);
+ output.roomId = id::zero();
+ return;
+ }
+
+ output.roomId = locals.roomId;
+ output.returnCode = toReturnCode(EReturnCode::SUCCESS);
+ }
+
+ PRIVATE_PROCEDURE_WITH_LOCALS(FinalizeRoom)
+ {
+ state.rooms.removeByKey(input.roomId);
+
+ if (!state.users.get(input.owner, locals.userData))
+ {
+ if (input.roomAmount > 0)
+ {
+ qpi.transfer(input.owner, input.roomAmount);
+ }
+ output.returnCode = toReturnCode(EReturnCode::SUCCESS);
+ return;
+ }
+
+ locals.availableDeposit = locals.userData.depositedAmount;
+ if (input.includeLocked)
+ {
+ locals.availableDeposit += locals.userData.locked;
+ }
+
+ if (locals.availableDeposit == 0)
+ {
+ state.users.removeByKey(locals.userData.userId);
+ output.returnCode = toReturnCode(EReturnCode::SUCCESS);
+ return;
+ }
+
+ locals.nextStakeInput.stake = locals.userData.stake;
+ locals.nextStakeInput.raiseStep = locals.userData.raiseStep;
+ locals.nextStakeInput.maxStake = locals.userData.maxStake;
+ computeNextStake(locals.nextStakeInput, locals.nextStakeOutput);
+ if (locals.nextStakeOutput.returnCode != toReturnCode(EReturnCode::SUCCESS))
+ {
+ qpi.transfer(locals.userData.userId, locals.availableDeposit);
+ state.users.removeByKey(locals.userData.userId);
+ output.returnCode = locals.nextStakeOutput.returnCode;
+ return;
+ }
+
+ if (locals.nextStakeOutput.nextStake > locals.availableDeposit)
+ {
+ locals.nextStakeOutput.nextStake = locals.availableDeposit;
+ }
+
+ if (locals.nextStakeOutput.nextStake < state.minimumDuelAmount)
+ {
+ qpi.transfer(locals.userData.userId, locals.availableDeposit);
+ state.users.removeByKey(locals.userData.userId);
+ output.returnCode = toReturnCode(EReturnCode::SUCCESS);
+ return;
+ }
+
+ locals.createRoomInput.owner = locals.userData.userId;
+ locals.createRoomInput.allowedPlayer = locals.userData.allowedPlayer;
+ locals.createRoomInput.amount = locals.nextStakeOutput.nextStake;
+ CALL(CreateRoomRecord, locals.createRoomInput, locals.createRoomOutput);
+ if (locals.createRoomOutput.returnCode != toReturnCode(EReturnCode::SUCCESS))
+ {
+ qpi.transfer(locals.userData.userId, locals.availableDeposit);
+ state.users.removeByKey(locals.userData.userId);
+ output.returnCode = toReturnCode(EReturnCode::SUCCESS);
+ return;
+ }
+
+ locals.userData.roomId = locals.createRoomOutput.roomId;
+ locals.userData.depositedAmount = locals.availableDeposit - locals.nextStakeOutput.nextStake;
+ locals.userData.locked = locals.nextStakeOutput.nextStake;
+ locals.userData.stake = locals.nextStakeOutput.nextStake;
+ state.users.set(locals.userData.userId, locals.userData);
+
+ output.returnCode = toReturnCode(EReturnCode::SUCCESS);
+
+ state.rooms.cleanupIfNeeded(QDUEL_ROOMS_REMOVAL_THRESHOLD_PERCENT);
+ state.users.cleanupIfNeeded(QDUEL_ROOMS_REMOVAL_THRESHOLD_PERCENT);
+ }
+
+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);
+ }
+
+ 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;
+ }
+
+ PRIVATE_PROCEDURE_WITH_LOCALS(AddUserData)
+ {
+ // Already Exist
+ if (state.users.contains(input.userId))
+ {
+ output.returnCode = toReturnCode(EReturnCode::USER_ALREADY_EXISTS);
+ return;
+ }
+
+ locals.newUserData.userId = input.userId;
+ locals.newUserData.roomId = input.roomId;
+ locals.newUserData.allowedPlayer = input.allowedPlayer;
+ locals.newUserData.depositedAmount = input.depositedAmount;
+ locals.newUserData.locked = input.stake;
+ locals.newUserData.stake = input.stake;
+ locals.newUserData.raiseStep = input.raiseStep;
+ locals.newUserData.maxStake = input.maxStake;
+ if (state.users.set(input.userId, locals.newUserData) == NULL_INDEX)
+ {
+ output.returnCode = toReturnCode(EReturnCode::ROOM_FAILED_CREATE);
+ return;
+ }
+ output.returnCode = toReturnCode(EReturnCode::SUCCESS);
+ }
+};
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
new file mode 100644
index 000000000..d36856662
--- /dev/null
+++ b/test/contract_qduel.cpp
@@ -0,0 +1,1122 @@
+#define NO_UEFI
+#define _ALLOW_KEYWORD_MACROS
+#define private protected
+#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 PROCEDURE_INDEX_DEPOSIT = 5;
+constexpr uint16 PROCEDURE_INDEX_WITHDRAW = 6;
+constexpr uint16 FUNCTION_INDEX_GET_PERCENT_FEES = 1;
+constexpr uint16 FUNCTION_INDEX_GET_ROOMS = 2;
+constexpr uint16 FUNCTION_INDEX_GET_TTL_HOURS = 3;
+constexpr uint16 FUNCTION_INDEX_GET_USER_PROFILE = 4;
+
+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);
+
+class QpiContextUserFunctionCallWithInvocator : public QpiContextFunctionCall
+{
+public:
+ QpiContextUserFunctionCallWithInvocator(unsigned int contractIndex, const id& invocator)
+ : QpiContextFunctionCall(contractIndex, invocator, 0, USER_FUNCTION_CALL)
+ {}
+};
+
+class QDuelChecker : public QDUEL
+{
+public:
+ // Expose read-only accessors for internal state so tests can assert without
+ // modifying contract storage directly.
+ 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; }
+ void setState(EState newState) { currentState = newState; }
+ EState getState() const { return currentState; }
+ // Helper to fetch user record without exposing contract internals.
+ bool getUserData(const id& user, UserData& data) const { return users.get(user, data); }
+ // Directly set a user record to simulate edge-case storage edits.
+ void setUserData(const UserData& data) { users.set(data.userId, data); }
+
+ RoomInfo firstRoom() const
+ {
+ // Map storage can be sparse; walk to first element.
+ 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
+ {
+ // Run the same winner function as the contract to keep tests deterministic.
+ 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);
+
+ // Contract helpers require zeroed outputs and locals.
+ output = {};
+ CalculateRevenue_input revenueInput{amount};
+ CalculateRevenue_locals revenueLocals{};
+ CalculateRevenue(qpi, *this, revenueInput, output, revenueLocals);
+ }
+
+ GetUserProfile_output getUserProfileFor(const id& user) const
+ {
+ QpiContextUserFunctionCallWithInvocator qpi(QDUEL_CONTRACT_INDEX, user);
+ GetUserProfile_input input{};
+ GetUserProfile_output output{};
+ GetUserProfile_locals locals{};
+ GetUserProfile(qpi, *this, input, output, locals);
+ return output;
+ }
+};
+
+class ContractTestingQDuel : protected ContractTesting
+{
+public:
+ ContractTestingQDuel()
+ {
+ // Build an empty chain state and deploy the contract under test.
+ initEmptySpectrum();
+ initEmptyUniverse();
+ INIT_CONTRACT(QDUEL);
+ system.epoch = contractDescriptions[QDUEL_CONTRACT_INDEX].constructionEpoch;
+ callSystemProcedure(QDUEL_CONTRACT_INDEX, INITIALIZE);
+ }
+
+ // Access helper for the underlying contract state.
+ QDuelChecker* state() { return reinterpret_cast(contractStates[QDUEL_CONTRACT_INDEX]); }
+
+ QDUEL::CreateRoom_output createRoom(const id& user, const id& allowedPlayer, uint64 stake, uint64 raiseStep, uint64 maxStake, sint64 reward)
+ {
+ QDUEL::CreateRoom_input input{allowedPlayer, stake, raiseStep, maxStake};
+ QDUEL::CreateRoom_output output;
+ // Route through contract procedure to keep call path identical to production.
+ 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;
+ // Call the user procedure so validation and state updates are exercised.
+ 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;
+ // System procedures are tested via normal user invocation.
+ 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;
+ // Ensure contract state gets updated through procedure validation.
+ 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;
+ // Read-only function call for fee snapshot.
+ 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;
+ // Read-only function call for rooms snapshot.
+ callFunction(QDUEL_CONTRACT_INDEX, FUNCTION_INDEX_GET_ROOMS, input, output);
+ return output;
+ }
+
+ QDUEL::GetTTLHours_output getTtlHours()
+ {
+ QDUEL::GetTTLHours_input input{};
+ QDUEL::GetTTLHours_output output;
+ // Read-only function call for TTL configuration.
+ callFunction(QDUEL_CONTRACT_INDEX, FUNCTION_INDEX_GET_TTL_HOURS, input, output);
+ return output;
+ }
+
+ QDUEL::GetUserProfile_output getUserProfile()
+ {
+ QDUEL::GetUserProfile_input input{};
+ QDUEL::GetUserProfile_output output;
+ // Read-only function call for caller profile.
+ callFunction(QDUEL_CONTRACT_INDEX, FUNCTION_INDEX_GET_USER_PROFILE, input, output);
+ return output;
+ }
+
+ QDUEL::Deposit_output deposit(const id& user, sint64 reward)
+ {
+ QDUEL::Deposit_input input{};
+ QDUEL::Deposit_output output;
+ // Deposit is a user procedure that mutates balance and state.
+ if (!invokeUserProcedure(QDUEL_CONTRACT_INDEX, PROCEDURE_INDEX_DEPOSIT, input, output, user, reward))
+ {
+ output.returnCode = QDUEL::toReturnCode(QDUEL::EReturnCode::UNKNOWN_ERROR);
+ }
+ return output;
+ }
+
+ QDUEL::Withdraw_output withdraw(const id& user, uint64 amount, sint64 reward = 0)
+ {
+ QDUEL::Withdraw_input input{amount};
+ QDUEL::Withdraw_output output;
+ // Withdraw uses user procedure to enforce validations and limits.
+ if (!invokeUserProcedure(QDUEL_CONTRACT_INDEX, PROCEDURE_INDEX_WITHDRAW, input, output, user, reward))
+ {
+ output.returnCode = QDUEL::toReturnCode(QDUEL::EReturnCode::UNKNOWN_ERROR);
+ }
+ return output;
+ }
+
+ // Helpers that dispatch system procedures during lifecycle tests.
+ void endTick() { callSystemProcedure(QDUEL_CONTRACT_INDEX, END_TICK); }
+
+ void endEpoch() { callSystemProcedure(QDUEL_CONTRACT_INDEX, END_EPOCH); }
+
+ void beginEpoch() { callSystemProcedure(QDUEL_CONTRACT_INDEX, BEGIN_EPOCH); }
+
+ // Control time and tick for deterministic tests.
+ void setTick(uint32 tick) { system.tick = tick; }
+ uint32 getTick() const { return system.tick; }
+
+ void forceEndTick()
+ {
+ // Align tick to update period so END_TICK work executes.
+ 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)
+ {
+ // Set a fixed time and reset etalon tick so tests are stable.
+ 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();
+ }
+};
+
+namespace
+{
+ bool findPlayersForWinner(ContractTestingQDuel& qduel, bool wantPlayer1Win, id& player1, id& player2)
+ {
+ // Brute-force deterministic ids until winner matches desired side.
+ for (uint64 i = 1; i < 10000; ++i)
+ {
+ const id candidate1(i, 0, 0, 0);
+ const id candidate2(i + 1, 0, 0, 0);
+ const id winner = qduel.state()->computeWinner(candidate1, candidate2);
+ if (winner == (wantPlayer1Win ? candidate1 : candidate2))
+ {
+ player1 = candidate1;
+ player2 = candidate2;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ void runFullGameCycleWithFees(ContractTestingQDuel& qduel, const id& player1, const id& player2, const id& expectedWinner)
+ {
+ // Setup shareholders so revenue distribution can be validated.
+ const id shareholder1 = id::randomValue();
+ const id shareholder2 = id::randomValue();
+ constexpr unsigned int rlSharesOwner1 = 100;
+ constexpr unsigned int rlSharesOwner2 = 576;
+ std::vector> rlShares{
+ {shareholder1, rlSharesOwner1},
+ {shareholder2, rlSharesOwner2},
+ };
+ issueContractShares(RL_CONTRACT_INDEX, rlShares);
+
+ // Set fees as the team address (contract owner).
+ constexpr uint8 devFee = 15;
+ constexpr uint8 burnFee = 30;
+ constexpr uint8 shareholdersFee = 55;
+ increaseEnergy(qduel.state()->team(), 1);
+ EXPECT_EQ(qduel.setPercentFees(qduel.state()->team(), devFee, burnFee, shareholdersFee).returnCode,
+ QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ // Setup: give both players enough balance to cover the duel.
+ constexpr uint64 duelAmount = 100000ULL;
+ increaseEnergy(player1, duelAmount);
+ increaseEnergy(player2, duelAmount);
+ const uint64 player1Before = getBalance(player1);
+ const uint64 player2Before = getBalance(player2);
+
+ const uint64 teamBefore = getBalance(qduel.state()->team());
+ const uint64 shareholder1Before = getBalance(shareholder1);
+ const uint64 shareholder2Before = getBalance(shareholder2);
+
+ // Create room and keep initial balance snapshots for payout assertions.
+ EXPECT_EQ(qduel.createRoom(player1, NULL_ID, duelAmount, 1, duelAmount, duelAmount).returnCode,
+ QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+ const uint64 player1AfterCreateRoom = getBalance(player1);
+
+ const id winner = qduel.state()->computeWinner(player1, player2);
+ EXPECT_EQ(winner, expectedWinner);
+
+ // Calculate expected revenue distribution for fees and winner.
+ QDUEL::CalculateRevenue_output revenueOutput{};
+ qduel.state()->calculateRevenue(duelAmount * 2, revenueOutput);
+
+ // Player 2 joins and triggers finalize logic.
+ EXPECT_EQ(qduel.connectToRoom(player2, qduel.state()->firstRoom().roomId, duelAmount).returnCode,
+ QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+ const uint64 player2AfterConnectToRoom = getBalance(player2);
+
+ // Check fee distribution for team and shareholders.
+ EXPECT_EQ(getBalance(qduel.state()->team()), teamBefore + revenueOutput.devFee);
+
+ // Check shareholder dividends across the full set of computors.
+ const uint64 dividendPerShare = revenueOutput.shareholdersFee / NUMBER_OF_COMPUTORS;
+ EXPECT_EQ(getBalance(shareholder1), shareholder1Before + dividendPerShare * rlSharesOwner1);
+ EXPECT_EQ(getBalance(shareholder2), shareholder2Before + dividendPerShare * rlSharesOwner2);
+
+ // Check winner receives the remainder and loser only pays entry.
+ if (winner == player1)
+ {
+ EXPECT_EQ(getBalance(player1), player1AfterCreateRoom + revenueOutput.winner);
+ EXPECT_EQ(getBalance(player2), player2Before - duelAmount);
+ }
+ else
+ {
+ EXPECT_EQ(getBalance(player1), player1Before - duelAmount);
+ EXPECT_EQ(getBalance(player2), (player2Before - duelAmount) + revenueOutput.winner);
+ }
+ }
+} // namespace
+
+TEST(ContractQDuel, EndEpochKeepsDepositWhileRoomsRecreatedEachEpoch)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+ qduel.setDeterministicTime(2025, 1, 1, 0);
+
+ const id owner(40, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ const uint64 epochs = 3;
+ const uint64 reward = stake + (stake * epochs);
+ increaseEnergy(owner, reward);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, reward).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ QDUEL::UserData ownerData{};
+ ASSERT_TRUE(qduel.state()->getUserData(owner, ownerData));
+ uint64 expectedDeposit = ownerData.depositedAmount;
+ id currentRoomId = ownerData.roomId;
+
+ for (uint32 epoch = 0; epoch < epochs; ++epoch)
+ {
+ qduel.beginEpoch();
+ qduel.endEpoch();
+ qduel.setTick(qduel.getTick() + 1);
+
+ QDUEL::UserData afterEndEpoch{};
+ ASSERT_TRUE(qduel.state()->getUserData(owner, afterEndEpoch));
+ EXPECT_EQ(afterEndEpoch.depositedAmount, expectedDeposit);
+ EXPECT_EQ(afterEndEpoch.roomId, currentRoomId);
+
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ const id opponent(200 + epoch, 0, 0, 0);
+ increaseEnergy(opponent, stake);
+ EXPECT_EQ(qduel.connectToRoom(opponent, currentRoomId, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ ASSERT_TRUE(qduel.state()->getUserData(owner, ownerData));
+ EXPECT_NE(ownerData.roomId, currentRoomId);
+ EXPECT_EQ(ownerData.locked, stake);
+ expectedDeposit -= stake;
+ EXPECT_EQ(ownerData.depositedAmount, expectedDeposit);
+ currentRoomId = ownerData.roomId;
+ }
+}
+
+TEST(ContractQDuel, BeginEpochKeepsRoomsAndUsers)
+{
+ ContractTestingQDuel qduel;
+ // Start from a deterministic time and unlocked state.
+ qduel.state()->setState(QDUEL::EState::NONE);
+ qduel.setDeterministicTime(2022, 4, 13, 0);
+
+ const id owner(1, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ // Give the owner enough balance to create a room.
+ increaseEnergy(owner, stake);
+
+ // Create a room and verify it survives epoch transition.
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ const QDUEL::RoomInfo roomBefore = qduel.state()->firstRoom();
+ QDUEL::UserData userBefore{};
+ EXPECT_TRUE(qduel.state()->getUserData(owner, userBefore));
+
+ // Begin epoch should not wipe persistent data.
+ qduel.beginEpoch();
+
+ // Room and user record should still exist after epoch transition.
+ EXPECT_TRUE(qduel.state()->hasRoom(roomBefore.roomId));
+ QDUEL::UserData userAfter{};
+ EXPECT_TRUE(qduel.state()->getUserData(owner, userAfter));
+ EXPECT_EQ(userAfter.roomId, roomBefore.roomId);
+}
+
+TEST(ContractQDuel, FirstTickAfterUnlockResetsTimerStart)
+{
+ ContractTestingQDuel qduel;
+ // Start from a deterministic time and unlocked state.
+ qduel.state()->setState(QDUEL::EState::NONE);
+ qduel.setDeterministicTime(2022, 4, 13, 0);
+
+ const id owner(2, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ // Fund owner so the room creation succeeds.
+ increaseEnergy(owner, stake);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ const QDUEL::RoomInfo roomBefore = qduel.state()->firstRoom();
+ const uint64 initialCloseTimer = roomBefore.closeTimer;
+ const DateAndTime initialLastUpdate = roomBefore.lastUpdate;
+
+ // Locking occurs at epoch start; timers should not advance while locked.
+ qduel.beginEpoch();
+
+ // Still locked: no timer or lastUpdate changes.
+ qduel.setDeterministicTime(2022, 4, 13, 1);
+ qduel.forceEndTick();
+
+ const QDUEL::RoomInfo lockedRoom = qduel.state()->firstRoom();
+ EXPECT_EQ(lockedRoom.closeTimer, initialCloseTimer);
+ EXPECT_EQ(lockedRoom.lastUpdate, initialLastUpdate);
+
+ // First unlocked tick: reset lastUpdate to "now" without reducing timer.
+ qduel.setDeterministicTime(2022, 4, 14, 2);
+ qduel.forceEndTick();
+
+ const QDUEL::RoomInfo unlockedRoom = qduel.state()->firstRoom();
+ EXPECT_EQ(unlockedRoom.closeTimer, initialCloseTimer);
+ const DateAndTime expectedNow(2022, 4, 14, 2, 0, 0);
+ EXPECT_EQ(unlockedRoom.lastUpdate, expectedNow);
+}
+
+TEST(ContractQDuel, EndTickExpiresRoomCreatesNewWhenDepositAvailable)
+{
+ ContractTestingQDuel qduel;
+ // Start from a deterministic time and unlocked state.
+ qduel.state()->setState(QDUEL::EState::NONE);
+ qduel.setDeterministicTime(2025, 1, 1, 0);
+
+ const id owner(3, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ // Fund owner with enough to re-create room after finalize.
+ increaseEnergy(owner, stake);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ const QDUEL::RoomInfo roomBefore = qduel.state()->firstRoom();
+ EXPECT_EQ(qduel.state()->roomCount(), 1ULL);
+
+ // Advance beyond TTL to trigger finalize and auto room creation.
+ qduel.setDeterministicTime(2025, 1, 1, 3);
+ qduel.forceEndTick();
+
+ // A new room should replace the expired one.
+ EXPECT_EQ(qduel.state()->roomCount(), 1ULL);
+ const QDUEL::RoomInfo roomAfter = qduel.state()->firstRoom();
+ EXPECT_NE(roomAfter.roomId, roomBefore.roomId);
+
+ QDUEL::UserData userAfter{};
+ EXPECT_TRUE(qduel.state()->getUserData(owner, userAfter));
+ // User should be re-bound to the new room with locked stake.
+ EXPECT_EQ(userAfter.roomId, roomAfter.roomId);
+ EXPECT_EQ(userAfter.locked, stake);
+}
+
+TEST(ContractQDuel, EndTickExpiresRoomWithoutAvailableDepositRemovesUser)
+{
+ ContractTestingQDuel qduel;
+ // Start from a deterministic time and unlocked state.
+ qduel.state()->setState(QDUEL::EState::NONE);
+ qduel.setDeterministicTime(2025, 1, 1, 0);
+
+ const id owner(4, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ // Fund owner just enough to create the initial room.
+ increaseEnergy(owner, stake);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ QDUEL::UserData userData{};
+ ASSERT_TRUE(qduel.state()->getUserData(owner, userData));
+ // Remove available balance so finalize cannot recreate the room.
+ userData.depositedAmount = 0;
+ userData.locked = 0;
+ qduel.state()->setUserData(userData);
+
+ // Expire room and expect cleanup.
+ qduel.setDeterministicTime(2025, 1, 1, 3);
+ qduel.forceEndTick();
+
+ // Room and user data should be removed when deposit is insufficient.
+ EXPECT_EQ(qduel.state()->roomCount(), 0ULL);
+ QDUEL::UserData userAfter{};
+ EXPECT_FALSE(qduel.state()->getUserData(owner, userAfter));
+}
+
+TEST(ContractQDuel, EndTickSkipsNonPeriodTicks)
+{
+ ContractTestingQDuel qduel;
+ // Start from a deterministic time and unlocked state.
+ qduel.state()->setState(QDUEL::EState::NONE);
+ qduel.setDeterministicTime(2025, 1, 1, 0);
+
+ const id owner(5, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ // Fund owner to create a room.
+ increaseEnergy(owner, stake);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ const QDUEL::RoomInfo roomBefore = qduel.state()->firstRoom();
+ qduel.setDeterministicTime(2025, 1, 1, 1);
+ qduel.setTick(1);
+ // Non-period tick: no updates expected.
+ qduel.endTick();
+
+ const QDUEL::RoomInfo roomAfterSkipped = qduel.state()->firstRoom();
+ EXPECT_EQ(roomAfterSkipped.closeTimer, roomBefore.closeTimer);
+ EXPECT_EQ(roomAfterSkipped.lastUpdate, roomBefore.lastUpdate);
+
+ // Period tick: updates should apply.
+ qduel.setTick(QDUEL_TICK_UPDATE_PERIOD);
+ qduel.endTick();
+
+ const QDUEL::RoomInfo roomAfterProcessed = qduel.state()->firstRoom();
+ // Close timer should have decreased by one hour and lastUpdate bumped.
+ EXPECT_EQ(roomAfterProcessed.closeTimer, roomBefore.closeTimer - 3600ULL);
+ const DateAndTime expectedNow(2025, 1, 1, 1, 0, 0);
+ EXPECT_EQ(roomAfterProcessed.lastUpdate, expectedNow);
+}
+
+TEST(ContractQDuel, LockedStateBlocksCreateAndConnect)
+{
+ ContractTestingQDuel qduel;
+ // Start from a deterministic time and unlocked state.
+ qduel.state()->setState(QDUEL::EState::NONE);
+ qduel.setDeterministicTime(2025, 1, 1, 0);
+
+ const id owner(6, 0, 0, 0);
+ const id other(7, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ // Fund owner to create the baseline room.
+ increaseEnergy(owner, stake);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+ const QDUEL::RoomInfo roomBefore = qduel.state()->firstRoom();
+
+ // Lock contract and verify user procedures are blocked.
+ qduel.state()->setState(QDUEL::EState::LOCKED);
+ // Fund the other user so only the lock gate can fail.
+ increaseEnergy(other, stake);
+
+ EXPECT_EQ(qduel.createRoom(other, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::STATE_LOCKED));
+ EXPECT_EQ(qduel.connectToRoom(other, roomBefore.roomId, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::STATE_LOCKED));
+ // Existing room should remain unchanged.
+ EXPECT_TRUE(qduel.state()->hasRoom(roomBefore.roomId));
+}
+
+TEST(ContractQDuel, EndTickRecreatesRoomWithUpdatedStake)
+{
+ ContractTestingQDuel qduel;
+ // Start from a deterministic time and unlocked state.
+ qduel.state()->setState(QDUEL::EState::NONE);
+ qduel.setDeterministicTime(2025, 1, 1, 0);
+
+ const id owner(8, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ // Fund owner so next stake can be doubled.
+ increaseEnergy(owner, stake * 2);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 2, 0, stake * 2).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+ const QDUEL::RoomInfo roomBefore = qduel.state()->firstRoom();
+
+ // Expire the room and expect a new one using computed next stake.
+ qduel.setDeterministicTime(2025, 1, 1, 3);
+ qduel.forceEndTick();
+
+ const QDUEL::RoomInfo roomAfter = qduel.state()->firstRoom();
+ EXPECT_NE(roomAfter.roomId, roomBefore.roomId);
+ // Amount should reflect the raiseStep applied to the original stake.
+ EXPECT_EQ(roomAfter.amount, stake * 2);
+
+ QDUEL::UserData userAfter{};
+ EXPECT_TRUE(qduel.state()->getUserData(owner, userAfter));
+ // User should be locked into the new room with the updated stake.
+ EXPECT_EQ(userAfter.roomId, roomAfter.roomId);
+ EXPECT_EQ(userAfter.locked, stake * 2);
+ EXPECT_EQ(userAfter.depositedAmount, 0ULL);
+}
+
+TEST(ContractQDuel, ConnectFinalizeIgnoresLockedAmount)
+{
+ ContractTestingQDuel qduel;
+ // Start from a deterministic time and unlocked state.
+ qduel.state()->setState(QDUEL::EState::NONE);
+ qduel.setDeterministicTime(2025, 1, 1, 0);
+
+ const id owner(9, 0, 0, 0);
+ const id opponent(10, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ // Fund both players so creation and join can proceed.
+ increaseEnergy(owner, stake);
+ increaseEnergy(opponent, stake);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+ const QDUEL::RoomInfo roomBefore = qduel.state()->firstRoom();
+
+ // On connect, finalize uses includeLocked=false, so owner data is cleared.
+ EXPECT_EQ(qduel.connectToRoom(opponent, roomBefore.roomId, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ // Room is removed and owner record should be purged after finalize.
+ EXPECT_FALSE(qduel.state()->hasRoom(roomBefore.roomId));
+ QDUEL::UserData ownerAfter{};
+ EXPECT_FALSE(qduel.state()->getUserData(owner, ownerAfter));
+}
+
+TEST(ContractQDuel, InitializeDefaults)
+{
+ ContractTestingQDuel qduel;
+
+ EXPECT_EQ(qduel.state()->team(), QDUEL_TEAM_ADDRESS);
+ EXPECT_EQ(qduel.state()->minDuelAmount(), QDUEL_MINIMUM_DUEL_AMOUNT);
+ 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()->getState(), QDUEL::EState::NONE);
+ EXPECT_EQ(qduel.state()->roomCount(), 0ULL);
+}
+
+TEST(ContractQDuel, CreateRoomStoresRoomAndUser)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+ qduel.setDeterministicTime(2025, 1, 1, 0);
+
+ const id owner(11, 0, 0, 0);
+ const id allowed(12, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ const uint64 reward = stake + 5000;
+ increaseEnergy(owner, reward);
+
+ EXPECT_EQ(qduel.createRoom(owner, allowed, stake, 2, stake * 3, reward).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ EXPECT_EQ(qduel.state()->roomCount(), 1ULL);
+ const QDUEL::RoomInfo room = qduel.state()->firstRoom();
+ EXPECT_EQ(room.owner, owner);
+ EXPECT_EQ(room.allowedPlayer, allowed);
+ EXPECT_EQ(room.amount, stake);
+ EXPECT_EQ(room.closeTimer, static_cast(qduel.state()->ttl()) * 3600ULL);
+ const DateAndTime expectedNow(2025, 1, 1, 0, 0, 0);
+ EXPECT_EQ(room.lastUpdate, expectedNow);
+
+ QDUEL::UserData user{};
+ EXPECT_TRUE(qduel.state()->getUserData(owner, user));
+ EXPECT_EQ(user.roomId, room.roomId);
+ EXPECT_EQ(user.allowedPlayer, allowed);
+ EXPECT_EQ(user.depositedAmount, reward - stake);
+ EXPECT_EQ(user.locked, stake);
+ EXPECT_EQ(user.stake, stake);
+ EXPECT_EQ(user.raiseStep, 2ULL);
+ EXPECT_EQ(user.maxStake, stake * 3);
+}
+
+TEST(ContractQDuel, CreateRoomRejectsStakeBelowMinimum)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ const id owner(13, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount() - 1;
+ const uint64 reward = qduel.state()->minDuelAmount();
+ increaseEnergy(owner, reward);
+ const uint64 balanceBefore = getBalance(owner);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, reward).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::INVALID_VALUE));
+ EXPECT_EQ(getBalance(owner), balanceBefore);
+ EXPECT_EQ(qduel.state()->roomCount(), 0ULL);
+}
+
+TEST(ContractQDuel, CreateRoomRejectsMaxStakeBelowStake)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ const id owner(14, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ const uint64 reward = stake;
+ increaseEnergy(owner, reward);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake - 1, reward).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::INVALID_VALUE));
+ EXPECT_EQ(qduel.state()->roomCount(), 0ULL);
+}
+
+TEST(ContractQDuel, CreateRoomRejectsRewardBelowMinimum)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ const id owner(15, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ const uint64 reward = qduel.state()->minDuelAmount() - 1;
+ increaseEnergy(owner, reward);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, reward).returnCode,
+ QDUEL::toReturnCode(QDUEL::EReturnCode::ROOM_INSUFFICIENT_DUEL_AMOUNT));
+ EXPECT_EQ(qduel.state()->roomCount(), 0ULL);
+}
+
+TEST(ContractQDuel, CreateRoomRejectsRewardBelowStake)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ const id owner(16, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount() + 1000;
+ const uint64 reward = stake - 1;
+ increaseEnergy(owner, reward);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, reward).returnCode,
+ QDUEL::toReturnCode(QDUEL::EReturnCode::ROOM_INSUFFICIENT_DUEL_AMOUNT));
+ EXPECT_EQ(qduel.state()->roomCount(), 0ULL);
+}
+
+TEST(ContractQDuel, CreateRoomRejectsDuplicateUser)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ const id owner(17, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ increaseEnergy(owner, stake * 2);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::USER_ALREADY_EXISTS));
+ EXPECT_EQ(qduel.state()->roomCount(), 1ULL);
+}
+
+TEST(ContractQDuel, CreateRoomRejectsWhenRoomsFull)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ const uint64 stake = qduel.state()->minDuelAmount();
+ for (uint32 i = 0; i < QDUEL_MAX_NUMBER_OF_ROOMS; ++i)
+ {
+ const id owner(100 + i, 0, 0, 0);
+ qduel.setTick(i);
+ increaseEnergy(owner, stake);
+ const auto output = qduel.createRoom(owner, NULL_ID, stake, 1, stake, stake);
+ EXPECT_EQ(output.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS)) << "at[" << i << "]";
+ }
+
+ EXPECT_EQ(qduel.state()->roomCount(), static_cast(QDUEL_MAX_NUMBER_OF_ROOMS));
+
+ const id extraOwner(9999, 0, 0, 0);
+ increaseEnergy(extraOwner, stake);
+ EXPECT_EQ(qduel.createRoom(extraOwner, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::ROOM_FULL));
+}
+
+TEST(ContractQDuel, ConnectToRoomRejectsMissingRoom)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ const id player(18, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ increaseEnergy(player, stake);
+
+ EXPECT_EQ(qduel.connectToRoom(player, id(999, 0, 0, 0), stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::ROOM_NOT_FOUND));
+}
+
+TEST(ContractQDuel, ConnectToRoomRejectsNotAllowedPlayer)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ const id owner(19, 0, 0, 0);
+ const id allowed(20, 0, 0, 0);
+ const id other(21, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ increaseEnergy(owner, stake);
+ increaseEnergy(other, stake);
+
+ EXPECT_EQ(qduel.createRoom(owner, allowed, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+ const QDUEL::RoomInfo room = qduel.state()->firstRoom();
+
+ EXPECT_EQ(qduel.connectToRoom(other, room.roomId, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::ROOM_ACCESS_DENIED));
+}
+
+TEST(ContractQDuel, ConnectToRoomRejectsInsufficientReward)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ const id owner(22, 0, 0, 0);
+ const id opponent(23, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ increaseEnergy(owner, stake);
+ increaseEnergy(opponent, stake - 1);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+ const QDUEL::RoomInfo room = qduel.state()->firstRoom();
+
+ EXPECT_EQ(qduel.connectToRoom(opponent, room.roomId, stake - 1).returnCode,
+ QDUEL::toReturnCode(QDUEL::EReturnCode::ROOM_INSUFFICIENT_DUEL_AMOUNT));
+}
+
+TEST(ContractQDuel, ConnectToRoomRefundsExcessRewardForLoser)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ id owner;
+ id opponent;
+ ASSERT_TRUE(findPlayersForWinner(qduel, true, owner, opponent));
+
+ const uint64 stake = qduel.state()->minDuelAmount();
+ const uint64 reward = stake + 5000;
+ increaseEnergy(owner, stake);
+ increaseEnergy(opponent, reward);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+ const uint64 opponentBefore = getBalance(opponent);
+
+ EXPECT_EQ(qduel.connectToRoom(opponent, qduel.state()->firstRoom().roomId, reward).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+ EXPECT_EQ(getBalance(opponent), opponentBefore - stake);
+}
+
+TEST(ContractQDuel, ConnectFinalizeCreatesRoomFromDeposit)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ const id owner(24, 0, 0, 0);
+ const id opponent(25, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ increaseEnergy(owner, stake * 2);
+ increaseEnergy(opponent, stake);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, 0, stake * 2).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+ const QDUEL::RoomInfo roomBefore = qduel.state()->firstRoom();
+
+ qduel.setTick(10);
+
+ EXPECT_EQ(qduel.connectToRoom(opponent, roomBefore.roomId, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ EXPECT_EQ(qduel.state()->roomCount(), 1ULL);
+ const QDUEL::RoomInfo roomAfter = qduel.state()->firstRoom();
+ EXPECT_NE(roomAfter.roomId, roomBefore.roomId);
+ EXPECT_EQ(roomAfter.owner, owner);
+ EXPECT_EQ(roomAfter.amount, stake);
+
+ QDUEL::UserData userAfter{};
+ EXPECT_TRUE(qduel.state()->getUserData(owner, userAfter));
+ EXPECT_EQ(userAfter.roomId, roomAfter.roomId);
+ EXPECT_EQ(userAfter.locked, stake);
+ EXPECT_EQ(userAfter.depositedAmount, 0ULL);
+}
+
+TEST(ContractQDuel, GetWinnerPlayerIsOrderInvariant)
+{
+ ContractTestingQDuel qduel;
+ qduel.setTick(1234);
+
+ const id player1(26, 0, 0, 0);
+ const id player2(27, 0, 0, 0);
+
+ const id winnerForward = qduel.state()->computeWinner(player1, player2);
+ const id winnerReverse = qduel.state()->computeWinner(player2, player1);
+ EXPECT_EQ(winnerForward, winnerReverse);
+ EXPECT_TRUE(winnerForward == player1 || winnerForward == player2);
+}
+
+TEST(ContractQDuel, CalculateRevenueMatchesExpectedSplits)
+{
+ ContractTestingQDuel qduel;
+
+ constexpr uint64 amount = 1000000ULL;
+ QDUEL::CalculateRevenue_output output{};
+ qduel.state()->calculateRevenue(amount, output);
+
+ const uint64 expectedDev = (amount * qduel.state()->devFee()) / QDUEL_PERCENT_SCALE;
+ const uint64 expectedBurn = (amount * qduel.state()->burnFee()) / QDUEL_PERCENT_SCALE;
+ const uint64 expectedShareholders = ((amount * qduel.state()->shareholdersFee()) / QDUEL_PERCENT_SCALE) / 676ULL * 676ULL;
+ const uint64 expectedWinner = amount - (expectedDev + expectedBurn + expectedShareholders);
+
+ EXPECT_EQ(output.devFee, expectedDev);
+ EXPECT_EQ(output.burnFee, expectedBurn);
+ EXPECT_EQ(output.shareholdersFee, expectedShareholders);
+ EXPECT_EQ(output.winner, expectedWinner);
+}
+
+TEST(ContractQDuel, SetPercentFeesAccessDeniedAndGetPercentFees)
+{
+ ContractTestingQDuel qduel;
+ const auto before = qduel.getPercentFees();
+
+ const id user(28, 0, 0, 0);
+ increaseEnergy(user, 10);
+ const uint64 balanceBefore = getBalance(user);
+
+ EXPECT_EQ(qduel.setPercentFees(user, 1, 2, 3, 10).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::ACCESS_DENIED));
+ EXPECT_EQ(getBalance(user), balanceBefore);
+
+ const auto after = qduel.getPercentFees();
+ EXPECT_EQ(after.devFeePercentBps, before.devFeePercentBps);
+ EXPECT_EQ(after.burnFeePercentBps, before.burnFeePercentBps);
+ EXPECT_EQ(after.shareholdersFeePercentBps, before.shareholdersFeePercentBps);
+ EXPECT_EQ(after.percentScale, before.percentScale);
+}
+
+TEST(ContractQDuel, SetPercentFeesUpdatesState)
+{
+ ContractTestingQDuel qduel;
+
+ increaseEnergy(qduel.state()->team(), 1);
+ EXPECT_EQ(qduel.setPercentFees(qduel.state()->team(), 1, 2, 3, 1).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ const auto output = qduel.getPercentFees();
+ EXPECT_EQ(output.devFeePercentBps, 1);
+ EXPECT_EQ(output.burnFeePercentBps, 2);
+ EXPECT_EQ(output.shareholdersFeePercentBps, 3);
+ EXPECT_EQ(output.percentScale, QDUEL_PERCENT_SCALE);
+ EXPECT_EQ(output.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+}
+
+TEST(ContractQDuel, SetTTLHoursAccessDeniedAndInvalid)
+{
+ ContractTestingQDuel qduel;
+ const uint8 ttlBefore = qduel.state()->ttl();
+
+ const id user(29, 0, 0, 0);
+ increaseEnergy(user, 5);
+ const uint64 balanceBefore = getBalance(user);
+
+ EXPECT_EQ(qduel.setTtlHours(user, 5, 5).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::ACCESS_DENIED));
+ EXPECT_EQ(getBalance(user), balanceBefore);
+ EXPECT_EQ(qduel.state()->ttl(), ttlBefore);
+
+ increaseEnergy(qduel.state()->team(), 1);
+ EXPECT_EQ(qduel.setTtlHours(qduel.state()->team(), 0, 1).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::INVALID_VALUE));
+ EXPECT_EQ(qduel.state()->ttl(), ttlBefore);
+}
+
+TEST(ContractQDuel, SetTTLHoursUpdatesState)
+{
+ ContractTestingQDuel qduel;
+ increaseEnergy(qduel.state()->team(), 1);
+
+ EXPECT_EQ(qduel.setTtlHours(qduel.state()->team(), 6, 1).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+ const auto output = qduel.getTtlHours();
+ EXPECT_EQ(output.ttlHours, 6);
+ EXPECT_EQ(output.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+}
+
+TEST(ContractQDuel, GetRoomsReturnsActiveRooms)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ const id owner1(30, 0, 0, 0);
+ const id owner2(31, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ increaseEnergy(owner1, stake);
+ increaseEnergy(owner2, stake);
+
+ EXPECT_EQ(qduel.createRoom(owner1, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+ EXPECT_EQ(qduel.createRoom(owner2, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ const auto output = qduel.getRooms();
+ EXPECT_EQ(output.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ uint64 count = 0;
+ bool foundOwner1 = false;
+ bool foundOwner2 = false;
+ for (uint32 i = 0; i < QDUEL_MAX_NUMBER_OF_ROOMS; ++i)
+ {
+ const QDUEL::RoomInfo room = output.rooms.get(i);
+ if (room.roomId != id::zero())
+ {
+ ++count;
+ EXPECT_TRUE(qduel.state()->hasRoom(room.roomId));
+ if (room.owner == owner1)
+ {
+ foundOwner1 = true;
+ }
+ if (room.owner == owner2)
+ {
+ foundOwner2 = true;
+ }
+ }
+ }
+ EXPECT_EQ(count, qduel.state()->roomCount());
+ EXPECT_TRUE(foundOwner1);
+ EXPECT_TRUE(foundOwner2);
+}
+
+TEST(ContractQDuel, GetUserProfileReportsUserData)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ const QDUEL::GetUserProfile_output& missing = qduel.getUserProfile();
+ EXPECT_EQ(missing.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::USER_NOT_FOUND));
+
+ const id owner(32, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ increaseEnergy(owner, stake + 200);
+
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 2, stake * 2, stake + 200).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ const auto profile = qduel.state()->getUserProfileFor(owner);
+ EXPECT_EQ(profile.returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+ EXPECT_EQ(profile.depositedAmount, 200ULL);
+ EXPECT_EQ(profile.locked, stake);
+ EXPECT_EQ(profile.stake, stake);
+ EXPECT_EQ(profile.raiseStep, 2ULL);
+ EXPECT_EQ(profile.maxStake, stake * 2);
+ EXPECT_NE(profile.roomId, id::zero());
+}
+
+TEST(ContractQDuel, DepositValidationsAndUpdatesBalance)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ const id missingUser(33, 0, 0, 0);
+ increaseEnergy(missingUser, 1);
+ EXPECT_EQ(qduel.deposit(missingUser, 0).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::INVALID_VALUE));
+
+ increaseEnergy(missingUser, 100);
+ const uint64 missingBefore = getBalance(missingUser);
+ EXPECT_EQ(qduel.deposit(missingUser, 100).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::USER_NOT_FOUND));
+ EXPECT_EQ(getBalance(missingUser), missingBefore);
+
+ const id owner(34, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ increaseEnergy(owner, stake);
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, stake).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ QDUEL::UserData before{};
+ ASSERT_TRUE(qduel.state()->getUserData(owner, before));
+ increaseEnergy(owner, 500);
+ EXPECT_EQ(qduel.deposit(owner, 500).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ QDUEL::UserData after{};
+ ASSERT_TRUE(qduel.state()->getUserData(owner, after));
+ EXPECT_EQ(after.depositedAmount, before.depositedAmount + 500);
+}
+
+TEST(ContractQDuel, WithdrawValidationsAndTransfers)
+{
+ ContractTestingQDuel qduel;
+ qduel.state()->setState(QDUEL::EState::NONE);
+
+ const id missingUser(35, 0, 0, 0);
+ increaseEnergy(missingUser, 1);
+ EXPECT_EQ(qduel.withdraw(missingUser, 1).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::USER_NOT_FOUND));
+
+ const id owner(36, 0, 0, 0);
+ const uint64 stake = qduel.state()->minDuelAmount();
+ increaseEnergy(owner, stake + 1000);
+ EXPECT_EQ(qduel.createRoom(owner, NULL_ID, stake, 1, stake, stake + 1000).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+
+ EXPECT_EQ(qduel.withdraw(owner, 0).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::INSUFFICIENT_FREE_DEPOSIT));
+ EXPECT_EQ(qduel.withdraw(owner, 2000).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::INSUFFICIENT_FREE_DEPOSIT));
+
+ const uint64 balanceBefore = getBalance(owner);
+ EXPECT_EQ(qduel.withdraw(owner, 500).returnCode, QDUEL::toReturnCode(QDUEL::EReturnCode::SUCCESS));
+ EXPECT_EQ(getBalance(owner), balanceBefore + 500);
+
+ QDUEL::UserData userAfter{};
+ ASSERT_TRUE(qduel.state()->getUserData(owner, userAfter));
+ EXPECT_EQ(userAfter.depositedAmount, 500ULL);
+}
+
+TEST(ContractQDuel, ConnectToRoomDistributesFeesPlayer1Wins)
+{
+ ContractTestingQDuel qduel;
+
+ id player1;
+ id player2;
+ ASSERT_TRUE(findPlayersForWinner(qduel, true, player1, player2));
+ runFullGameCycleWithFees(qduel, player1, player2, player1);
+}
+
+TEST(ContractQDuel, ConnectToRoomDistributesFeesPlayer2Wins)
+{
+ ContractTestingQDuel qduel;
+
+ id player1;
+ id player2;
+ ASSERT_TRUE(findPlayersForWinner(qduel, false, player1, player2));
+ runFullGameCycleWithFees(qduel, player1, player2, player2);
+}
diff --git a/test/test.vcxproj b/test/test.vcxproj
index 5132f62d4..d1548175e 100644
--- a/test/test.vcxproj
+++ b/test/test.vcxproj
@@ -141,6 +141,7 @@
+
@@ -192,4 +193,4 @@
-
\ No newline at end of file
+
diff --git a/test/test.vcxproj.filters b/test/test.vcxproj.filters
index 725c90fc9..5a2bbb435 100644
--- a/test/test.vcxproj.filters
+++ b/test/test.vcxproj.filters
@@ -28,6 +28,7 @@
+