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..e259dbe9f
--- /dev/null
+++ b/src/contracts/QDuel.h
@@ -0,0 +1,642 @@
+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 = 100;
+constexpr uint8 QDUEL_TTL_HOURS = 2;
+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
+{
+};
+
+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,
+
+ // 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 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 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;
+ };
+
+ 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;
+ 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;
+ uint32 currentTimestamp;
+ };
+
+ 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;
+
+ state.currentState = EState::LOCKED;
+ }
+
+ 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);
+ }
+ }
+
+ 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);
+ }
+
+ 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());
+
+ 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.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.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)
+ {
+ 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);
+ }
+
+ 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;
+ 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)
+ {
+ 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;
+ }
+};
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..fbeffc034
--- /dev/null
+++ b/test/contract_qduel.cpp
@@ -0,0 +1,613 @@
+#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 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);
+
+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; }
+ void setState(EState newState) { currentState = newState; }
+ EState getState() const { return currentState; }
+
+ 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); }
+
+ 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)
+{
+ 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;
+
+ qduel.setDeterministicTime();
+ qduel.forceEndTick();
+
+ 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, 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);
+
+ 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;
+
+ qduel.setDeterministicTime();
+ qduel.forceEndTick();
+
+ 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, 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);
+ 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;
+
+ 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 * 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, ConnectToRoomPaysRLDividendsToShareholders)
+{
+ ContractTestingQDuel qduel;
+
+ qduel.setDeterministicTime();
+ qduel.forceEndTick();
+
+ 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, 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, shareholdersFeePercentBps).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{};
+ constexpr uint64 duelPayoutAmount = duelAmount * 2;
+ qduel.state()->calculateRevenue(duelPayoutAmount, 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;
+
+ const id host = id::randomValue();
+ increaseEnergy(host, QDUEL_MINIMUM_DUEL_AMOUNT * 2);
+
+ qduel.setDeterministicTime();
+ qduel.forceEndTick();
+
+ 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.forceEndTick();
+ EXPECT_EQ(qduel.state()->roomCount(), 1ull);
+ EXPECT_EQ(getBalance(host), balanceBefore - QDUEL_MINIMUM_DUEL_AMOUNT);
+
+ qduel.setDeterministicTime(2025, 1, 1, QDUEL_TTL_HOURS + 1);
+ qduel.forceEndTick();
+ EXPECT_EQ(qduel.state()->roomCount(), 0ull);
+ EXPECT_EQ(getBalance(host), balanceBefore);
+}
+
+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);
+ 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..a29532213 100644
--- a/test/test.vcxproj
+++ b/test/test.vcxproj
@@ -140,6 +140,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 @@
+