diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index 2aa6e074..d2e0f91b 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -580,7 +580,11 @@ struct ContractStateChangeInfo // When enabling, replace both lines below, e.g.: //constexpr ContractStateChangeInfo contractStateChangeInfos[] = { { DUMMY_CONTRACT_INDEX, MIGRATE, 219 } }; //constexpr unsigned int contractStateChangeCount = sizeof(contractStateChangeInfos) / sizeof(contractStateChangeInfos[0]); -constexpr ContractStateChangeInfo contractStateChangeInfos[] = { { QIP_CONTRACT_INDEX, RESET, 224 }, { RANDOM_CONTRACT_INDEX, PADDING, 224 } }; +constexpr ContractStateChangeInfo contractStateChangeInfos[] = { + { QIP_CONTRACT_INDEX, RESET, 224 }, + { RANDOM_CONTRACT_INDEX, PADDING, 224 }, + { QEARN_CONTRACT_INDEX, PADDING, QEARN_V2_ACTIVATION_EPOCH }, +}; constexpr unsigned int contractStateChangeCount = sizeof(contractStateChangeInfos) / sizeof(contractStateChangeInfos[0]); diff --git a/src/contracts/Qearn.h b/src/contracts/Qearn.h index 0f8eec02..5410dfa0 100644 --- a/src/contracts/Qearn.h +++ b/src/contracts/Qearn.h @@ -7,6 +7,28 @@ constexpr uint64 QEARN_MAX_USERS = 131072; constexpr uint64 QEARN_MAX_LOCK_AMOUNT = 1000000000000ULL; constexpr uint64 QEARN_MAX_BONUS_AMOUNT = 1000000000000ULL; constexpr uint64 QEARN_INITIAL_EPOCH = 138; +// V2 release epoch. This must match the PADDING epoch in contract_def.h, +// and the upgraded binary must be active before BEGIN_EPOCH of this epoch. +constexpr uint64 QEARN_V2_ACTIVATION_EPOCH = 227; + +constexpr uint32 QEARN_V2_LOCK_PERIOD_13 = 13; +constexpr uint32 QEARN_V2_LOCK_PERIOD_26 = 26; +constexpr uint32 QEARN_V2_LOCK_PERIOD_52 = 52; +constexpr uint64 QEARN_V2_LAST_LOCK_EPOCH = + QEARN_MAX_EPOCHS - QEARN_V2_LOCK_PERIOD_52 - 1; +constexpr uint32 QEARN_V2_TERM_INDEX_13 = 0; +constexpr uint32 QEARN_V2_TERM_INDEX_26 = 1; +constexpr uint32 QEARN_V2_TERM_INDEX_52 = 2; +constexpr uint32 QEARN_V2_NUMBER_OF_TERMS = 3; +constexpr uint32 QEARN_V2_TERM_ARRAY_CAPACITY = 4; +constexpr uint64 QEARN_V2_MAX_FULLY_UNLOCK_RECORDS = QEARN_MAX_USERS * 4; + +constexpr uint64 QEARN_V2_ALLOCATION_PERCENT_13 = 10; +constexpr uint64 QEARN_V2_ALLOCATION_PERCENT_26 = 20; +constexpr uint64 QEARN_V2_RETURN_PERCENT_13 = 3; +constexpr uint64 QEARN_V2_RETURN_PERCENT_26 = 6; +constexpr uint64 QEARN_V2_RETURN_PERCENT_52 = 18; +constexpr uint64 QEARN_V2_RETURN_SCALE = 10000000ULL; constexpr uint64 QEARN_EARLY_UNLOCKING_PERCENT_0_3 = 0; constexpr uint64 QEARN_EARLY_UNLOCKING_PERCENT_4_7 = 5; @@ -44,6 +66,10 @@ constexpr sint32 QEARN_EMPTY_LOCKED = 4; constexpr sint32 QEARN_UNLOCK_SUCCESS = 5; constexpr sint32 QEARN_OVERFLOW_USER = 6; constexpr sint32 QEARN_LIMIT_LOCK = 7; +constexpr sint32 QEARN_INVALID_LOCK_PERIOD = 8; +constexpr sint32 QEARN_V2_NOT_ACTIVE = 9; +constexpr sint32 QEARN_V2_POSITION_MATURED = 10; +constexpr sint32 QEARN_TRANSFER_FAILED = 11; enum QEARNLogInfo { QearnSuccessLocking = 0, @@ -109,6 +135,29 @@ struct QEARN : public ContractBase }; + struct V2TermInfo + { + uint64 initialLockedAmount; + uint64 currentLockedAmount; + uint64 earlyUnlockedAmount; + uint64 initialRewardPool; + uint64 currentRewardPool; + uint64 rewardedAmount; + uint64 forfeitedClaimAmount; + }; + + struct V2EpochInfo + { + Array terms; + uint64 bonusAmount; + uint64 rewardedAmount; + uint64 forfeitedClaimAmount; + uint64 transferredTo52Amount; + // Unique V2 reward funds transferred out of QEarn to CCF. + uint64 transferredToCCFAmount; + uint32 finalized; + }; + // State data struct StateData { @@ -121,6 +170,15 @@ struct QEARN : public ContractBase uint32 _earlyUnlockedCnt; uint32 _fullyUnlockedCnt; Array statsInfo; + + // Appended V2 state. Keeping every V1 field above byte-for-byte unchanged + // allows the activation upgrade to use PADDING and preserve all V1 positions. + Array lockerLockPeriods; + Array v2EpochInfo; + // A user may have one maturity in each V2 term during the same epoch. + // Keep these records separate from the fixed-size V1 history array. + Array v2FullyUnlocker; + uint32 v2FullyUnlockedCnt; }; public: @@ -129,11 +187,12 @@ struct QEARN : public ContractBase }; struct getLockInfoPerEpoch_output { - uint64 lockedAmount; /* initial total locked amount in epoch */ - uint64 bonusAmount; /* initial bonus amount in epoch*/ - uint64 currentLockedAmount; /* total locked amount in epoch. exactly the amount excluding the amount unlocked early*/ - uint64 currentBonusAmount; /* bonus amount in epoch excluding the early unlocking */ - uint64 yield; /* Yield calculated by 10000000 multiple*/ + // For V2 epochs this legacy endpoint reports the 52-epoch term only. + uint64 lockedAmount; /* initial locked amount */ + uint64 bonusAmount; /* initial bonus amount */ + uint64 currentLockedAmount; /* amount excluding early unlocks */ + uint64 currentBonusAmount; /* bonus excluding early unlock effects */ + uint64 yield; /* V1 yield / V2 52-term return, scaled by 10000000 (not APY) */ }; struct getUserLockedInfo_input { @@ -230,6 +289,76 @@ struct QEARN : public ContractBase sint32 returnCode; }; + struct lockV2_input + { + // One of 13, 26, or 52. Each user/epoch/term is one position. + uint32 lockPeriod; + }; + + struct lockV2_output + { + sint32 returnCode; + }; + + struct unlockV2_input + { + uint32 lockedEpoch; + uint32 lockPeriod; + }; + + struct unlockV2_output + { + sint32 returnCode; + }; + + struct getV2LockInfoPerEpoch_input + { + uint32 epoch; + uint32 lockPeriod; + }; + + struct getV2LockInfoPerEpoch_output + { + uint64 initialLockedAmount; + uint64 currentLockedAmount; + uint64 termEarlyUnlockedAmount; + uint64 initialRewardPool; + uint64 currentRewardPool; + uint64 termRewardedAmount; + // Cumulative abandoned reward claims. For the 52-epoch term, retained + // rewards can be abandoned again and this is not a unique-funds counter. + uint64 termForfeitedClaimAmount; + // Total term return scaled by 10,000,000. This is not APY. + uint64 currentTermReturn; + uint64 maturityEpoch; + uint64 epochBonusAmount; + uint64 epochRewardedAmount; + // Cumulative abandoned reward claims across the three terms. + uint64 epochForfeitedClaimAmount; + uint64 epochTransferredTo52Amount; + // Initial/dynamic cap surplus and final 52-term settlement remainder. + uint64 epochTransferredToCCFAmount; + // 0 while the lock epoch is open; 1 after its pools are allocated. + uint32 finalized; + // 0 = future, 1 = open/running, 2 = matured. + uint32 state; + sint32 returnCode; + }; + + struct getV2UserLockedInfo_input + { + id user; + uint32 epoch; + uint32 lockPeriod; + }; + + struct getV2UserLockedInfo_output + { + uint64 lockedAmount; + uint64 maturityEpoch; + sint32 returnCode; + }; + struct getStatsPerEpoch_input { uint32 epoch; }; @@ -239,7 +368,7 @@ struct QEARN : public ContractBase uint64 earlyUnlockedAmount; uint64 earlyUnlockedPercent; uint64 totalLockedAmount; - uint64 averageAPY; + uint64 averageAPY; // Legacy name: V2 reports average active 52-term return, not APY. }; @@ -287,9 +416,11 @@ struct QEARN : public ContractBase { EpochIndexInfo tmpEpochIndex; LockInfo INITIALIZE_USER; - uint32 _t; - sint32 st; - sint32 en; + uint32 epoch; + uint32 readIndex; + uint32 writeIndex; + uint32 oldStartIndex; + uint32 oldEndIndex; uint32 startEpoch; }; @@ -301,62 +432,54 @@ struct QEARN : public ContractBase { locals.startEpoch = QEARN_INITIAL_EPOCH; } + locals.INITIALIZE_USER.ID = NULL_ID; + locals.INITIALIZE_USER._lockedAmount = 0; + locals.INITIALIZE_USER._lockedEpoch = 0; // Remove all gaps in Locker array and update epochIndex locals.tmpEpochIndex.startIndex = 0; - for(locals._t = locals.startEpoch; locals._t <= qpi.epoch(); locals._t++) + for (locals.epoch = locals.startEpoch; locals.epoch <= qpi.epoch(); locals.epoch++) { - // This loop iteration moves all elements of one epoch to the start of its range in the Locker array. - // The startIndex is given by the end of the range of the previous epoch, the new endIndex is found in the - // gap removal process. - locals.st = locals.tmpEpochIndex.startIndex; - locals.en = state.get()._epochIndex.get(locals._t).endIndex; - ASSERT(locals.st <= locals.en); - - while(locals.st < locals.en) + locals.oldStartIndex = state.get()._epochIndex.get(locals.epoch).startIndex; + locals.oldEndIndex = state.get()._epochIndex.get(locals.epoch).endIndex; + locals.writeIndex = locals.tmpEpochIndex.startIndex; + ASSERT(locals.oldStartIndex <= locals.oldEndIndex); + ASSERT(locals.writeIndex <= locals.oldStartIndex); + + for (locals.readIndex = locals.oldStartIndex; + locals.readIndex < locals.oldEndIndex; + locals.readIndex++) { - // try to set locals.st to first empty slot - while (state.get().locker.get(locals.st)._lockedAmount && locals.st < locals.en) + if (!state.get().locker.get(locals.readIndex)._lockedAmount) { - locals.st++; + state.mut().lockerLockPeriods.set(locals.readIndex, 0); + continue; } - // try set locals.en to last non-empty slot in epoch - --locals.en; - while (!state.get().locker.get(locals.en)._lockedAmount && locals.st < locals.en) + if (locals.writeIndex != locals.readIndex) { - locals.en--; - } + state.mut().locker.set(locals.writeIndex, state.get().locker.get(locals.readIndex)); + state.mut().lockerLockPeriods.set( + locals.writeIndex, + state.get().lockerLockPeriods.get(locals.readIndex)); - // if st and en meet, there are no gaps to be closed by moving in this epoch range - if (locals.st >= locals.en) - { - // make locals.en point behind last element again - ++locals.en; - break; + state.mut().locker.set(locals.readIndex, locals.INITIALIZE_USER); + state.mut().lockerLockPeriods.set(locals.readIndex, 0); } - - // move entry from locals.en to locals.st - state.mut().locker.set(locals.st, state.get().locker.get(locals.en)); - - // make locals.en slot empty -> locals.en points behind last element again - locals.INITIALIZE_USER.ID = NULL_ID; - locals.INITIALIZE_USER._lockedAmount = 0; - locals.INITIALIZE_USER._lockedEpoch = 0; - state.mut().locker.set(locals.en, locals.INITIALIZE_USER); + locals.writeIndex++; } - // update epoch index - locals.tmpEpochIndex.endIndex = locals.en; - state.mut()._epochIndex.set(locals._t, locals.tmpEpochIndex); + locals.tmpEpochIndex.endIndex = locals.writeIndex; + state.mut()._epochIndex.set(locals.epoch, locals.tmpEpochIndex); - // set start index of next epoch to end index of current epoch locals.tmpEpochIndex.startIndex = locals.tmpEpochIndex.endIndex; } - // Set end index for next epoch - locals.tmpEpochIndex.endIndex = locals.tmpEpochIndex.startIndex; - state.mut()._epochIndex.set(qpi.epoch() + 1, locals.tmpEpochIndex); + if (qpi.epoch() + 1 < QEARN_MAX_EPOCHS) + { + locals.tmpEpochIndex.endIndex = locals.tmpEpochIndex.startIndex; + state.mut()._epochIndex.set(qpi.epoch() + 1, locals.tmpEpochIndex); + } } struct getStateOfRound_locals { @@ -385,8 +508,49 @@ struct QEARN : public ContractBase } } - PUBLIC_FUNCTION(getLockInfoPerEpoch) + struct getLockInfoPerEpoch_locals { + V2TermInfo termInfo; + }; + + PUBLIC_FUNCTION_WITH_LOCALS(getLockInfoPerEpoch) + { + if (input.Epoch >= QEARN_MAX_EPOCHS) + { + return; + } + if (input.Epoch >= QEARN_V2_ACTIVATION_EPOCH) + { + // Procedure 1 becomes a 52-epoch V2 lock after activation. Keep its + // legacy query paired with that term for QVAULT/QBOND compatibility. + locals.termInfo = state.get().v2EpochInfo.get(input.Epoch).terms.get(QEARN_V2_TERM_INDEX_52); + output.bonusAmount = locals.termInfo.initialRewardPool; + output.lockedAmount = locals.termInfo.initialLockedAmount; + output.currentBonusAmount = locals.termInfo.currentRewardPool; + output.currentLockedAmount = locals.termInfo.currentLockedAmount; + if (!output.currentLockedAmount + && input.Epoch + QEARN_V2_LOCK_PERIOD_52 <= qpi.epoch()) + { + ASSERT(locals.termInfo.initialLockedAmount >= locals.termInfo.earlyUnlockedAmount); + output.currentLockedAmount = + locals.termInfo.initialLockedAmount - locals.termInfo.earlyUnlockedAmount; + output.currentBonusAmount = locals.termInfo.rewardedAmount; + } + if (output.currentLockedAmount) + { + output.yield = div(output.currentBonusAmount * QEARN_V2_RETURN_SCALE, output.currentLockedAmount); + if (output.yield > QEARN_V2_RETURN_PERCENT_52 * 100000ULL) + { + output.yield = QEARN_V2_RETURN_PERCENT_52 * 100000ULL; + } + } + else + { + output.yield = 0; + } + return; + } + output.bonusAmount = state.get()._initialRoundInfo.get(input.Epoch)._epochBonusAmount; output.lockedAmount = state.get()._initialRoundInfo.get(input.Epoch)._totalLockedAmount; output.currentBonusAmount = state.get()._currentRoundInfo.get(input.Epoch)._epochBonusAmount; @@ -404,17 +568,50 @@ struct QEARN : public ContractBase struct getStatsPerEpoch_locals { Entity entity; - uint32 cnt, _t; + V2EpochInfo epochInfo; + V2TermInfo termInfo; + uint64 initialLockedAmount; + uint32 cnt; + uint32 termIndex; + uint32 _t; }; PUBLIC_FUNCTION_WITH_LOCALS(getStatsPerEpoch) { - output.earlyUnlockedAmount = state.get()._initialRoundInfo.get(input.epoch)._totalLockedAmount - state.get()._currentRoundInfo.get(input.epoch)._totalLockedAmount; - output.earlyUnlockedPercent = div(output.earlyUnlockedAmount * 10000ULL, state.get()._initialRoundInfo.get(input.epoch)._totalLockedAmount); + if (input.epoch >= QEARN_MAX_EPOCHS) + { + return; + } + if (input.epoch >= QEARN_V2_ACTIVATION_EPOCH) + { + locals.epochInfo = state.get().v2EpochInfo.get(input.epoch); + for (locals.termIndex = 0; + locals.termIndex < QEARN_V2_NUMBER_OF_TERMS; + locals.termIndex++) + { + locals.termInfo = locals.epochInfo.terms.get(locals.termIndex); + locals.initialLockedAmount += locals.termInfo.initialLockedAmount; + output.earlyUnlockedAmount += locals.termInfo.earlyUnlockedAmount; + } + } + else + { + locals.initialLockedAmount = + state.get()._initialRoundInfo.get(input.epoch)._totalLockedAmount; + output.earlyUnlockedAmount = locals.initialLockedAmount + - state.get()._currentRoundInfo.get(input.epoch)._totalLockedAmount; + } + if (locals.initialLockedAmount) + { + output.earlyUnlockedPercent = + div(output.earlyUnlockedAmount * 10000ULL, locals.initialLockedAmount); + } qpi.getEntity(SELF, locals.entity); output.totalLockedAmount = locals.entity.incomingAmount - locals.entity.outgoingAmount; + // This field is retained for ABI compatibility. For V2 rounds it is the + // average active 52-epoch term return, scaled by 10,000,000, not APY. output.averageAPY = 0; locals.cnt = 0; @@ -424,17 +621,34 @@ struct QEARN : public ContractBase { break; } - if(state.get()._currentRoundInfo.get(locals._t)._totalLockedAmount == 0) + if (locals._t >= QEARN_V2_ACTIVATION_EPOCH) { - continue; + locals.termInfo = state.get().v2EpochInfo.get(locals._t).terms.get(QEARN_V2_TERM_INDEX_52); + if (!locals.termInfo.currentLockedAmount) + { + continue; + } + output.averageAPY += div( + locals.termInfo.currentRewardPool * QEARN_V2_RETURN_SCALE, + locals.termInfo.currentLockedAmount); + } + else + { + if (!state.get()._currentRoundInfo.get(locals._t)._totalLockedAmount) + { + continue; + } + output.averageAPY += div( + state.get()._currentRoundInfo.get(locals._t)._epochBonusAmount * QEARN_V2_RETURN_SCALE, + state.get()._currentRoundInfo.get(locals._t)._totalLockedAmount); } - locals.cnt++; - output.averageAPY += div(state.get()._currentRoundInfo.get(locals._t)._epochBonusAmount * 10000000ULL, state.get()._currentRoundInfo.get(locals._t)._totalLockedAmount); } - output.averageAPY = div(output.averageAPY, locals.cnt * 1ULL); - + if (locals.cnt) + { + output.averageAPY = div(output.averageAPY, locals.cnt * 1ULL); + } } struct getBurnedAndBoostedStats_locals @@ -488,12 +702,18 @@ struct QEARN : public ContractBase PUBLIC_FUNCTION_WITH_LOCALS(getUserLockedInfo) { + if (input.epoch >= QEARN_MAX_EPOCHS) + { + return; + } locals.startIndex = state.get()._epochIndex.get(input.epoch).startIndex; locals.endIndex = state.get()._epochIndex.get(input.epoch).endIndex; for(locals._t = locals.startIndex; locals._t < locals.endIndex; locals._t++) { - if(state.get().locker.get(locals._t).ID == input.user) + if(state.get().locker.get(locals._t).ID == input.user + && (input.epoch < QEARN_V2_ACTIVATION_EPOCH + || state.get().lockerLockPeriods.get(locals._t) == QEARN_V2_LOCK_PERIOD_52)) { output.lockedAmount = state.get().locker.get(locals._t)._lockedAmount; return; @@ -501,6 +721,143 @@ struct QEARN : public ContractBase } } + struct getV2LockInfoPerEpoch_locals + { + V2EpochInfo epochInfo; + V2TermInfo termInfo; + uint32 termIndex; + uint64 returnCap; + uint64 maturedLockedAmount; + }; + + PUBLIC_FUNCTION_WITH_LOCALS(getV2LockInfoPerEpoch) + { + output.returnCode = QEARN_INVALID_LOCK_PERIOD; + if (input.lockPeriod == QEARN_V2_LOCK_PERIOD_13) + { + locals.termIndex = QEARN_V2_TERM_INDEX_13; + locals.returnCap = QEARN_V2_RETURN_PERCENT_13 * 100000ULL; + } + else if (input.lockPeriod == QEARN_V2_LOCK_PERIOD_26) + { + locals.termIndex = QEARN_V2_TERM_INDEX_26; + locals.returnCap = QEARN_V2_RETURN_PERCENT_26 * 100000ULL; + } + else if (input.lockPeriod == QEARN_V2_LOCK_PERIOD_52) + { + locals.termIndex = QEARN_V2_TERM_INDEX_52; + locals.returnCap = QEARN_V2_RETURN_PERCENT_52 * 100000ULL; + } + else + { + return; + } + + if (input.epoch < QEARN_V2_ACTIVATION_EPOCH || input.epoch >= QEARN_MAX_EPOCHS) + { + output.returnCode = QEARN_INVALID_INPUT_LOCKED_EPOCH; + return; + } + + locals.epochInfo = state.get().v2EpochInfo.get(input.epoch); + locals.termInfo = locals.epochInfo.terms.get(locals.termIndex); + output.initialLockedAmount = locals.termInfo.initialLockedAmount; + output.currentLockedAmount = locals.termInfo.currentLockedAmount; + output.termEarlyUnlockedAmount = locals.termInfo.earlyUnlockedAmount; + output.initialRewardPool = locals.termInfo.initialRewardPool; + output.currentRewardPool = locals.termInfo.currentRewardPool; + output.termRewardedAmount = locals.termInfo.rewardedAmount; + output.termForfeitedClaimAmount = locals.termInfo.forfeitedClaimAmount; + output.maturityEpoch = input.epoch + input.lockPeriod; + output.epochBonusAmount = locals.epochInfo.bonusAmount; + output.epochRewardedAmount = locals.epochInfo.rewardedAmount; + output.epochForfeitedClaimAmount = locals.epochInfo.forfeitedClaimAmount; + output.epochTransferredTo52Amount = locals.epochInfo.transferredTo52Amount; + output.epochTransferredToCCFAmount = locals.epochInfo.transferredToCCFAmount; + output.finalized = locals.epochInfo.finalized; + + if (output.currentLockedAmount) + { + if (input.lockPeriod == QEARN_V2_LOCK_PERIOD_52) + { + output.currentTermReturn = div(output.currentRewardPool * QEARN_V2_RETURN_SCALE, output.currentLockedAmount); + } + else if (output.initialLockedAmount) + { + output.currentTermReturn = div(output.initialRewardPool * QEARN_V2_RETURN_SCALE, output.initialLockedAmount); + } + } + else if (qpi.epoch() >= output.maturityEpoch) + { + ASSERT(output.initialLockedAmount >= output.termEarlyUnlockedAmount); + locals.maturedLockedAmount = + output.initialLockedAmount - output.termEarlyUnlockedAmount; + if (locals.maturedLockedAmount) + { + output.currentTermReturn = div( + output.termRewardedAmount * QEARN_V2_RETURN_SCALE, + locals.maturedLockedAmount); + } + } + + if (output.currentTermReturn > locals.returnCap) + { + output.currentTermReturn = locals.returnCap; + } + + if (input.epoch > qpi.epoch()) + { + output.state = 0; + } + else if (qpi.epoch() < output.maturityEpoch + || (qpi.epoch() == output.maturityEpoch && output.currentLockedAmount)) + { + output.state = 1; + } + else + { + output.state = 2; + } + output.returnCode = QEARN_LOCK_SUCCESS; + } + + struct getV2UserLockedInfo_locals + { + uint32 t; + uint32 startIndex; + uint32 endIndex; + }; + + PUBLIC_FUNCTION_WITH_LOCALS(getV2UserLockedInfo) + { + output.returnCode = QEARN_INVALID_LOCK_PERIOD; + if (input.lockPeriod != QEARN_V2_LOCK_PERIOD_13 + && input.lockPeriod != QEARN_V2_LOCK_PERIOD_26 + && input.lockPeriod != QEARN_V2_LOCK_PERIOD_52) + { + return; + } + if (input.epoch < QEARN_V2_ACTIVATION_EPOCH || input.epoch >= QEARN_MAX_EPOCHS) + { + output.returnCode = QEARN_INVALID_INPUT_LOCKED_EPOCH; + return; + } + + output.maturityEpoch = input.epoch + input.lockPeriod; + locals.startIndex = state.get()._epochIndex.get(input.epoch).startIndex; + locals.endIndex = state.get()._epochIndex.get(input.epoch).endIndex; + for (locals.t = locals.startIndex; locals.t < locals.endIndex; locals.t++) + { + if (state.get().locker.get(locals.t).ID == input.user + && state.get().lockerLockPeriods.get(locals.t) == input.lockPeriod) + { + output.lockedAmount = state.get().locker.get(locals.t)._lockedAmount; + break; + } + } + output.returnCode = QEARN_LOCK_SUCCESS; + } + struct getUserLockStatus_locals { uint64 bn; uint32 _t; @@ -522,7 +879,7 @@ struct QEARN : public ContractBase locals.lockedWeeks = qpi.epoch() - state.get().locker.get(locals._t)._lockedEpoch; locals.bn = 1ULL< 0) + locals.termIndex = QEARN_V2_TERM_INDEX_13; + } + else if (input.lockPeriod == QEARN_V2_LOCK_PERIOD_26) + { + locals.termIndex = QEARN_V2_TERM_INDEX_26; + } + else if (input.lockPeriod == QEARN_V2_LOCK_PERIOD_52) + { + locals.termIndex = QEARN_V2_TERM_INDEX_52; + } + else + { + if (qpi.invocationReward() > 0) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } return; } - locals.endIndex = state.get()._epochIndex.get(qpi.epoch()).endIndex; + if (qpi.epoch() < QEARN_V2_ACTIVATION_EPOCH + || qpi.epoch() > QEARN_V2_LAST_LOCK_EPOCH) + { + output.returnCode = QEARN_V2_NOT_ACTIVE; + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + return; + } - for(locals.t = state.get()._epochIndex.get(qpi.epoch()).startIndex ; locals.t < locals.endIndex; locals.t++) + if (qpi.invocationReward() < sint64(QEARN_MINIMUM_LOCKING_AMOUNT)) { + output.returnCode = QEARN_INVALID_INPUT_AMOUNT; + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + return; + } + locals.amount = uint64(qpi.invocationReward()); - if(state.get().locker.get(locals.t).ID == qpi.invocator()) - { // the case to be locked several times at one epoch, at that time, this address already located in state.Locker array, the amount will be increased as current locking amount. - if(state.get().locker.get(locals.t)._lockedAmount + qpi.invocationReward() > QEARN_MAX_LOCK_AMOUNT) + locals.endIndex = state.get()._epochIndex.get(qpi.epoch()).endIndex; + for (locals.t = state.get()._epochIndex.get(qpi.epoch()).startIndex; locals.t < locals.endIndex; locals.t++) + { + if (state.get().locker.get(locals.t).ID == qpi.invocator() + && state.get().lockerLockPeriods.get(locals.t) == input.lockPeriod) + { + if (state.get().locker.get(locals.t)._lockedAmount + locals.amount > QEARN_MAX_LOCK_AMOUNT) { output.returnCode = QEARN_LIMIT_LOCK; - - locals.log = {QEARN_CONTRACT_INDEX, SELF, qpi.invocator(), qpi.invocationReward(), QearnLimitLocking, 0}; - LOG_INFO(locals.log); - - if(qpi.invocationReward() > 0) + if (qpi.invocationReward() > 0) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } return; } - locals.newLocker._lockedAmount = state.get().locker.get(locals.t)._lockedAmount + qpi.invocationReward(); - locals.newLocker._lockedEpoch = qpi.epoch(); - locals.newLocker.ID = qpi.invocator(); - + locals.newLocker = state.get().locker.get(locals.t); + locals.newLocker._lockedAmount += locals.amount; state.mut().locker.set(locals.t, locals.newLocker); - locals.updatedRoundInfo._totalLockedAmount = state.get()._initialRoundInfo.get(qpi.epoch())._totalLockedAmount + qpi.invocationReward(); - locals.updatedRoundInfo._epochBonusAmount = state.get()._initialRoundInfo.get(qpi.epoch())._epochBonusAmount; - state.mut()._initialRoundInfo.set(qpi.epoch(), locals.updatedRoundInfo); + locals.epochInfo = state.get().v2EpochInfo.get(qpi.epoch()); + locals.termInfo = locals.epochInfo.terms.get(locals.termIndex); + locals.termInfo.initialLockedAmount += locals.amount; + locals.termInfo.currentLockedAmount += locals.amount; + locals.epochInfo.terms.set(locals.termIndex, locals.termInfo); + state.mut().v2EpochInfo.set(qpi.epoch(), locals.epochInfo); - locals.updatedRoundInfo._totalLockedAmount = state.get()._currentRoundInfo.get(qpi.epoch())._totalLockedAmount + qpi.invocationReward(); - locals.updatedRoundInfo._epochBonusAmount = state.get()._currentRoundInfo.get(qpi.epoch())._epochBonusAmount; + locals.updatedRoundInfo = state.get()._initialRoundInfo.get(qpi.epoch()); + locals.updatedRoundInfo._totalLockedAmount += locals.amount; + state.mut()._initialRoundInfo.set(qpi.epoch(), locals.updatedRoundInfo); + locals.updatedRoundInfo = state.get()._currentRoundInfo.get(qpi.epoch()); + locals.updatedRoundInfo._totalLockedAmount += locals.amount; state.mut()._currentRoundInfo.set(qpi.epoch(), locals.updatedRoundInfo); - output.returnCode = QEARN_LOCK_SUCCESS; // additional locking of this epoch is succeed - + output.returnCode = QEARN_LOCK_SUCCESS; locals.log = {QEARN_CONTRACT_INDEX, qpi.invocator(), SELF, qpi.invocationReward(), QearnSuccessLocking, 0}; LOG_INFO(locals.log); - return ; + return; } - } - if(locals.endIndex >= QEARN_MAX_LOCKS - 1) + if (locals.endIndex >= QEARN_MAX_LOCKS - 1) { - // Remove gaps in locker array to free up memory slots CALL(_RemoveGapsInLockerArray, locals.gapRemovalInput, locals.gapRemovalOutput); - - // Re-check if there's space after gap removal locals.endIndex = state.get()._epochIndex.get(qpi.epoch()).endIndex; - - if(locals.endIndex >= QEARN_MAX_LOCKS - 1) + if (locals.endIndex >= QEARN_MAX_LOCKS - 1) { output.returnCode = QEARN_OVERFLOW_USER; - - locals.log = {QEARN_CONTRACT_INDEX, SELF, qpi.invocator(), qpi.invocationReward(), QearnOverflowUser, 0}; - LOG_INFO(locals.log); - - if(qpi.invocationReward() > 0) + if (qpi.invocationReward() > 0) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); } - return ; // overflow users in Qearn after gap removal + return; } } - if(qpi.invocationReward() > QEARN_MAX_LOCK_AMOUNT) + if (locals.amount > QEARN_MAX_LOCK_AMOUNT) { output.returnCode = QEARN_LIMIT_LOCK; + if (qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + return; + } + + locals.newLocker.ID = qpi.invocator(); + locals.newLocker._lockedAmount = locals.amount; + locals.newLocker._lockedEpoch = qpi.epoch(); + state.mut().locker.set(locals.endIndex, locals.newLocker); + state.mut().lockerLockPeriods.set(locals.endIndex, uint8(input.lockPeriod)); + + locals.tmpIndex = state.get()._epochIndex.get(qpi.epoch()); + locals.tmpIndex.endIndex = locals.endIndex + 1; + state.mut()._epochIndex.set(qpi.epoch(), locals.tmpIndex); + + locals.epochInfo = state.get().v2EpochInfo.get(qpi.epoch()); + locals.termInfo = locals.epochInfo.terms.get(locals.termIndex); + locals.termInfo.initialLockedAmount += locals.amount; + locals.termInfo.currentLockedAmount += locals.amount; + locals.epochInfo.terms.set(locals.termIndex, locals.termInfo); + state.mut().v2EpochInfo.set(qpi.epoch(), locals.epochInfo); + + locals.updatedRoundInfo = state.get()._initialRoundInfo.get(qpi.epoch()); + locals.updatedRoundInfo._totalLockedAmount += locals.amount; + state.mut()._initialRoundInfo.set(qpi.epoch(), locals.updatedRoundInfo); + locals.updatedRoundInfo = state.get()._currentRoundInfo.get(qpi.epoch()); + locals.updatedRoundInfo._totalLockedAmount += locals.amount; + state.mut()._currentRoundInfo.set(qpi.epoch(), locals.updatedRoundInfo); + + output.returnCode = QEARN_LOCK_SUCCESS; + locals.log = {QEARN_CONTRACT_INDEX, qpi.invocator(), SELF, qpi.invocationReward(), QearnSuccessLocking, 0}; + LOG_INFO(locals.log); + } + + struct lockV2_locals + { + _LockV2_input lockInput; + _LockV2_output lockOutput; + }; + + PUBLIC_PROCEDURE_WITH_LOCALS(lockV2) + { + locals.lockInput.lockPeriod = input.lockPeriod; + CALL(_LockV2, locals.lockInput, locals.lockOutput); + output.returnCode = locals.lockOutput.returnCode; + } + + struct lock_locals { + + LockInfo newLocker; + RoundInfo updatedRoundInfo; + EpochIndexInfo tmpIndex; + QEARNLogger log; + uint32 t; + uint32 endIndex; + _RemoveGapsInLockerArray_input gapRemovalInput; + _RemoveGapsInLockerArray_output gapRemovalOutput; + _LockV2_input lockV2Input; + _LockV2_output lockV2Output; + }; + + PUBLIC_PROCEDURE_WITH_LOCALS(lock) + { + if (qpi.epoch() >= QEARN_V2_ACTIVATION_EPOCH) + { + locals.lockV2Input.lockPeriod = QEARN_V2_LOCK_PERIOD_52; + CALL(_LockV2, locals.lockV2Input, locals.lockV2Output); + output.returnCode = locals.lockV2Output.returnCode; + return; + } + + if (qpi.invocationReward() < QEARN_MINIMUM_LOCKING_AMOUNT || qpi.epoch() < QEARN_INITIAL_EPOCH) + { + output.returnCode = QEARN_INVALID_INPUT_AMOUNT; // if the amount of locking is less than 10M, it should be failed to lock. + + locals.log = {QEARN_CONTRACT_INDEX, SELF, qpi.invocator(), qpi.invocationReward(), QearnInvalidInput, 0}; + LOG_INFO(locals.log); + if(qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + return; + } + + locals.endIndex = state.get()._epochIndex.get(qpi.epoch()).endIndex; + + for(locals.t = state.get()._epochIndex.get(qpi.epoch()).startIndex ; locals.t < locals.endIndex; locals.t++) + { + + if(state.get().locker.get(locals.t).ID == qpi.invocator()) + { // the case to be locked several times at one epoch, at that time, this address already located in state.Locker array, the amount will be increased as current locking amount. + if(state.get().locker.get(locals.t)._lockedAmount + qpi.invocationReward() > QEARN_MAX_LOCK_AMOUNT) + { + output.returnCode = QEARN_LIMIT_LOCK; + + locals.log = {QEARN_CONTRACT_INDEX, SELF, qpi.invocator(), qpi.invocationReward(), QearnLimitLocking, 0}; + LOG_INFO(locals.log); + + if(qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + return; + } + + locals.newLocker._lockedAmount = state.get().locker.get(locals.t)._lockedAmount + qpi.invocationReward(); + locals.newLocker._lockedEpoch = qpi.epoch(); + locals.newLocker.ID = qpi.invocator(); + + state.mut().locker.set(locals.t, locals.newLocker); + state.mut().lockerLockPeriods.set(locals.t, 0); + + locals.updatedRoundInfo._totalLockedAmount = state.get()._initialRoundInfo.get(qpi.epoch())._totalLockedAmount + qpi.invocationReward(); + locals.updatedRoundInfo._epochBonusAmount = state.get()._initialRoundInfo.get(qpi.epoch())._epochBonusAmount; + state.mut()._initialRoundInfo.set(qpi.epoch(), locals.updatedRoundInfo); + + locals.updatedRoundInfo._totalLockedAmount = state.get()._currentRoundInfo.get(qpi.epoch())._totalLockedAmount + qpi.invocationReward(); + locals.updatedRoundInfo._epochBonusAmount = state.get()._currentRoundInfo.get(qpi.epoch())._epochBonusAmount; + state.mut()._currentRoundInfo.set(qpi.epoch(), locals.updatedRoundInfo); + + output.returnCode = QEARN_LOCK_SUCCESS; // additional locking of this epoch is succeed + + locals.log = {QEARN_CONTRACT_INDEX, qpi.invocator(), SELF, qpi.invocationReward(), QearnSuccessLocking, 0}; + LOG_INFO(locals.log); + return ; + } + + } + + if(locals.endIndex >= QEARN_MAX_LOCKS - 1) + { + // Remove gaps in locker array to free up memory slots + CALL(_RemoveGapsInLockerArray, locals.gapRemovalInput, locals.gapRemovalOutput); + + // Re-check if there's space after gap removal + locals.endIndex = state.get()._epochIndex.get(qpi.epoch()).endIndex; + + if(locals.endIndex >= QEARN_MAX_LOCKS - 1) + { + output.returnCode = QEARN_OVERFLOW_USER; + + locals.log = {QEARN_CONTRACT_INDEX, SELF, qpi.invocator(), qpi.invocationReward(), QearnOverflowUser, 0}; + LOG_INFO(locals.log); + + if(qpi.invocationReward() > 0) + { + qpi.transfer(qpi.invocator(), qpi.invocationReward()); + } + return ; // overflow users in Qearn after gap removal + } + } + + if(qpi.invocationReward() > QEARN_MAX_LOCK_AMOUNT) + { + output.returnCode = QEARN_LIMIT_LOCK; + + locals.log = {QEARN_CONTRACT_INDEX, SELF, qpi.invocator(), qpi.invocationReward(), QearnLimitLocking, 0}; + LOG_INFO(locals.log); - locals.log = {QEARN_CONTRACT_INDEX, SELF, qpi.invocator(), qpi.invocationReward(), QearnLimitLocking, 0}; - LOG_INFO(locals.log); - if(qpi.invocationReward() > 0) { qpi.transfer(qpi.invocator(), qpi.invocationReward()); @@ -675,6 +1233,7 @@ struct QEARN : public ContractBase locals.newLocker._lockedEpoch = qpi.epoch(); state.mut().locker.set(locals.endIndex, locals.newLocker); + state.mut().lockerLockPeriods.set(locals.endIndex, 0); locals.tmpIndex.startIndex = state.get()._epochIndex.get(qpi.epoch()).startIndex; locals.tmpIndex.endIndex = locals.endIndex + 1; @@ -694,6 +1253,332 @@ struct QEARN : public ContractBase LOG_INFO(locals.log); } + struct _TransferV2SurplusToCCF_input + { + uint32 lockedEpoch; + uint64 amount; + }; + + struct _TransferV2SurplusToCCF_output + { + }; + + struct _TransferV2SurplusToCCF_locals + { + V2EpochInfo epochInfo; + sint64 transferResult; + }; + + PRIVATE_PROCEDURE_WITH_LOCALS(_TransferV2SurplusToCCF) + { + if (!input.amount) + { + return; + } + + locals.transferResult = qpi.transfer( + id(CCF_CONTRACT_INDEX, 0, 0, 0), + sint64(input.amount)); + if (locals.transferResult < 0) + { + // Callers only pass surplus already backed by QEarn's balance. + // Do not report a CCF transfer if that invariant is ever violated. + ASSERT(locals.transferResult >= 0); + return; + } + + locals.epochInfo = state.get().v2EpochInfo.get(input.lockedEpoch); + locals.epochInfo.transferredToCCFAmount += input.amount; + state.mut().v2EpochInfo.set(input.lockedEpoch, locals.epochInfo); + } + + struct _FundV2LongPool_input + { + uint32 lockedEpoch; + uint64 amount; + }; + + struct _FundV2LongPool_output + { + uint64 fundedAmount; + uint64 surplusAmount; + }; + + struct _FundV2LongPool_locals + { + V2EpochInfo epochInfo; + V2TermInfo longTermInfo; + RoundInfo roundInfo; + StatsInfo stats; + _TransferV2SurplusToCCF_input ccfInput; + _TransferV2SurplusToCCF_output ccfOutput; + uint128 calculation; + uint64 cap; + uint64 availableAmount; + uint64 previousPool; + }; + + PRIVATE_PROCEDURE_WITH_LOCALS(_FundV2LongPool) + { + locals.epochInfo = state.get().v2EpochInfo.get(input.lockedEpoch); + locals.longTermInfo = locals.epochInfo.terms.get(QEARN_V2_TERM_INDEX_52); + locals.previousPool = locals.longTermInfo.currentRewardPool; + locals.calculation = div( + uint128(locals.longTermInfo.currentLockedAmount) * uint128(QEARN_V2_RETURN_PERCENT_52), + uint128(100)); + ASSERT(locals.calculation.high == 0); + locals.cap = locals.calculation.low; + locals.availableAmount = locals.previousPool + input.amount; + + if (locals.availableAmount > locals.cap) + { + locals.longTermInfo.currentRewardPool = locals.cap; + output.surplusAmount = locals.availableAmount - locals.cap; + } + else + { + locals.longTermInfo.currentRewardPool = locals.availableAmount; + } + + if (locals.longTermInfo.currentRewardPool > locals.previousPool) + { + output.fundedAmount = locals.longTermInfo.currentRewardPool - locals.previousPool; + } + locals.epochInfo.transferredTo52Amount += output.fundedAmount; + locals.epochInfo.terms.set(QEARN_V2_TERM_INDEX_52, locals.longTermInfo); + state.mut().v2EpochInfo.set(input.lockedEpoch, locals.epochInfo); + + if (output.surplusAmount) + { + locals.roundInfo = state.get()._currentRoundInfo.get(input.lockedEpoch); + ASSERT(locals.roundInfo._epochBonusAmount >= output.surplusAmount); + locals.roundInfo._epochBonusAmount -= output.surplusAmount; + state.mut()._currentRoundInfo.set(input.lockedEpoch, locals.roundInfo); + } + + if (output.fundedAmount) + { + locals.stats = state.get().statsInfo.get(input.lockedEpoch); + locals.stats.boostedAmount += output.fundedAmount; + state.mut().statsInfo.set(input.lockedEpoch, locals.stats); + } + + if (output.surplusAmount) + { + locals.ccfInput.lockedEpoch = input.lockedEpoch; + locals.ccfInput.amount = output.surplusAmount; + CALL(_TransferV2SurplusToCCF, locals.ccfInput, locals.ccfOutput); + } + } + + struct _UnlockV2_input + { + uint32 lockedEpoch; + uint32 lockPeriod; + uint64 requestedAmount; + uint32 validateRequestedAmount; + }; + + struct _UnlockV2_output + { + sint32 returnCode; + }; + + struct _UnlockV2_locals + { + V2EpochInfo epochInfo; + V2TermInfo termInfo; + RoundInfo roundInfo; + LockInfo emptyLocker; + HistoryInfo historyInfo; + QEARNLogger log; + _FundV2LongPool_input fundInput; + _FundV2LongPool_output fundOutput; + uint128 calculation; + uint32 termIndex; + uint32 t; + uint32 index; + uint32 startIndex; + uint32 endIndex; + uint64 amount; + uint64 forfeitedAmount; + uint64 amountToLongPool; + sint64 transferAmount; + sint64 transferResult; + }; + + PRIVATE_PROCEDURE_WITH_LOCALS(_UnlockV2) + { + output.returnCode = QEARN_INVALID_LOCK_PERIOD; + if (input.lockPeriod == QEARN_V2_LOCK_PERIOD_13) + { + locals.termIndex = QEARN_V2_TERM_INDEX_13; + } + else if (input.lockPeriod == QEARN_V2_LOCK_PERIOD_26) + { + locals.termIndex = QEARN_V2_TERM_INDEX_26; + } + else if (input.lockPeriod == QEARN_V2_LOCK_PERIOD_52) + { + locals.termIndex = QEARN_V2_TERM_INDEX_52; + } + else + { + return; + } + + if (input.lockedEpoch < QEARN_V2_ACTIVATION_EPOCH + || input.lockedEpoch >= QEARN_MAX_EPOCHS + || input.lockedEpoch > qpi.epoch()) + { + output.returnCode = QEARN_INVALID_INPUT_LOCKED_EPOCH; + return; + } + if (input.lockedEpoch == qpi.epoch()) + { + output.returnCode = QEARN_INVALID_INPUT_LOCKED_EPOCH; + return; + } + if (qpi.epoch() >= input.lockedEpoch + input.lockPeriod) + { + output.returnCode = QEARN_V2_POSITION_MATURED; + return; + } + + locals.index = QEARN_MAX_LOCKS; + locals.startIndex = state.get()._epochIndex.get(input.lockedEpoch).startIndex; + locals.endIndex = state.get()._epochIndex.get(input.lockedEpoch).endIndex; + for (locals.t = locals.startIndex; locals.t < locals.endIndex; locals.t++) + { + if (state.get().locker.get(locals.t).ID == qpi.invocator() + && state.get().lockerLockPeriods.get(locals.t) == input.lockPeriod) + { + locals.index = locals.t; + break; + } + } + if (locals.index == QEARN_MAX_LOCKS) + { + output.returnCode = QEARN_EMPTY_LOCKED; + return; + } + + locals.amount = state.get().locker.get(locals.index)._lockedAmount; + if (input.validateRequestedAmount && input.requestedAmount != locals.amount) + { + output.returnCode = QEARN_INVALID_INPUT_UNLOCK_AMOUNT; + return; + } + + locals.epochInfo = state.get().v2EpochInfo.get(input.lockedEpoch); + locals.termInfo = locals.epochInfo.terms.get(locals.termIndex); + ASSERT(locals.termInfo.currentLockedAmount >= locals.amount); + if (locals.termIndex == QEARN_V2_TERM_INDEX_52) + { + if (locals.termInfo.currentLockedAmount) + { + locals.calculation = div( + uint128(locals.termInfo.currentRewardPool) * uint128(locals.amount), + uint128(locals.termInfo.currentLockedAmount)); + ASSERT(locals.calculation.high == 0); + locals.forfeitedAmount = locals.calculation.low; + } + } + else if (locals.termInfo.initialLockedAmount) + { + locals.calculation = div( + uint128(locals.termInfo.initialRewardPool) * uint128(locals.amount), + uint128(locals.termInfo.initialLockedAmount)); + ASSERT(locals.calculation.high == 0); + locals.forfeitedAmount = locals.calculation.low; + if (locals.forfeitedAmount > locals.termInfo.currentRewardPool) + { + locals.forfeitedAmount = locals.termInfo.currentRewardPool; + } + locals.termInfo.currentRewardPool -= locals.forfeitedAmount; + locals.amountToLongPool = locals.forfeitedAmount; + } + + // A transfer to a contract is rejected while an incoming-transfer + // callback is running. Do not close the position unless its principal + // has actually been returned. + locals.transferAmount = sint64(locals.amount); + locals.transferResult = qpi.transfer(qpi.invocator(), locals.transferAmount); + if (locals.transferResult < 0) + { + output.returnCode = QEARN_TRANSFER_FAILED; + locals.log = { + QEARN_CONTRACT_INDEX, + SELF, + qpi.invocator(), + locals.transferAmount, + QearnFailedTransfer, + 0 + }; + LOG_INFO(locals.log); + return; + } + + locals.termInfo.currentLockedAmount -= locals.amount; + locals.termInfo.earlyUnlockedAmount += locals.amount; + locals.termInfo.forfeitedClaimAmount += locals.forfeitedAmount; + locals.epochInfo.forfeitedClaimAmount += locals.forfeitedAmount; + if (locals.termIndex != QEARN_V2_TERM_INDEX_52 && !locals.termInfo.currentLockedAmount) + { + locals.amountToLongPool += locals.termInfo.currentRewardPool; + locals.termInfo.currentRewardPool = 0; + } + locals.epochInfo.terms.set(locals.termIndex, locals.termInfo); + state.mut().v2EpochInfo.set(input.lockedEpoch, locals.epochInfo); + + locals.roundInfo = state.get()._currentRoundInfo.get(input.lockedEpoch); + ASSERT(locals.roundInfo._totalLockedAmount >= locals.amount); + locals.roundInfo._totalLockedAmount -= locals.amount; + state.mut()._currentRoundInfo.set(input.lockedEpoch, locals.roundInfo); + + state.mut().locker.set(locals.index, locals.emptyLocker); + state.mut().lockerLockPeriods.set(locals.index, 0); + locals.log = {QEARN_CONTRACT_INDEX, SELF, qpi.invocator(), locals.transferAmount, QearnSuccessEarlyUnlocking, 0}; + LOG_INFO(locals.log); + + locals.historyInfo._unlockedID = qpi.invocator(); + locals.historyInfo._unlockedAmount = locals.amount; + for (locals.t = 0; locals.t < state.get()._earlyUnlockedCnt; locals.t++) + { + if (state.get().earlyUnlocker.get(locals.t)._unlockedID == qpi.invocator()) + { + locals.historyInfo._unlockedAmount += state.get().earlyUnlocker.get(locals.t)._unlockedAmount; + locals.historyInfo._rewardedAmount += state.get().earlyUnlocker.get(locals.t)._rewardedAmount; + state.mut().earlyUnlocker.set(locals.t, locals.historyInfo); + break; + } + } + if (locals.t == state.get()._earlyUnlockedCnt && state.get()._earlyUnlockedCnt < QEARN_MAX_USERS) + { + state.mut().earlyUnlocker.set(locals.t, locals.historyInfo); + state.mut()._earlyUnlockedCnt++; + } + + locals.fundInput.lockedEpoch = input.lockedEpoch; + locals.fundInput.amount = locals.amountToLongPool; + CALL(_FundV2LongPool, locals.fundInput, locals.fundOutput); + output.returnCode = QEARN_UNLOCK_SUCCESS; + } + + struct unlockV2_locals + { + _UnlockV2_input unlockInput; + _UnlockV2_output unlockOutput; + }; + + PUBLIC_PROCEDURE_WITH_LOCALS(unlockV2) + { + locals.unlockInput.lockedEpoch = input.lockedEpoch; + locals.unlockInput.lockPeriod = input.lockPeriod; + CALL(_UnlockV2, locals.unlockInput, locals.unlockOutput); + output.returnCode = locals.unlockOutput.returnCode; + } + struct unlock_locals { RoundInfo updatedRoundInfo; @@ -707,7 +1592,8 @@ struct QEARN : public ContractBase uint64 amountOfburn; uint64 rewardPercent; sint64 transferAmount; - sint64 divCalcu; + sint64 transferResult; + uint64 divCalcu; uint32 earlyUnlockingPercent; uint32 burnPercent; uint32 indexOfinvocator; @@ -716,12 +1602,14 @@ struct QEARN : public ContractBase uint32 countOfLockedEpochs; uint32 startIndex; uint32 endIndex; + _UnlockV2_input unlockV2Input; + _UnlockV2_output unlockV2Output; }; PUBLIC_PROCEDURE_WITH_LOCALS(unlock) { - if (input.lockedEpoch > QEARN_MAX_EPOCHS || input.lockedEpoch < QEARN_INITIAL_EPOCH) + if (input.lockedEpoch >= QEARN_MAX_EPOCHS || input.lockedEpoch < QEARN_INITIAL_EPOCH) { output.returnCode = QEARN_INVALID_INPUT_LOCKED_EPOCH; // if user try to unlock with wrong locked epoch, it should be failed to unlock. @@ -745,6 +1633,17 @@ struct QEARN : public ContractBase } + if (input.lockedEpoch >= QEARN_V2_ACTIVATION_EPOCH) + { + locals.unlockV2Input.lockedEpoch = input.lockedEpoch; + locals.unlockV2Input.lockPeriod = QEARN_V2_LOCK_PERIOD_52; + locals.unlockV2Input.requestedAmount = input.amount; + locals.unlockV2Input.validateRequestedAmount = 1; + CALL(_UnlockV2, locals.unlockV2Input, locals.unlockV2Output); + output.returnCode = locals.unlockV2Output.returnCode; + return; + } + locals.indexOfinvocator = QEARN_MAX_LOCKS; locals.startIndex = state.get()._epochIndex.get(input.lockedEpoch).startIndex; locals.endIndex = state.get()._epochIndex.get(input.lockedEpoch).endIndex; @@ -877,8 +1776,22 @@ struct QEARN : public ContractBase locals.amountOfReward = div(locals.divCalcu * locals.earlyUnlockingPercent * 1ULL , 10000000ULL); locals.amountOfburn = div(locals.divCalcu * locals.burnPercent * 1ULL, 10000000ULL); - qpi.transfer(qpi.invocator(), locals.amountOfUnlocking + locals.amountOfReward); locals.transferAmount = locals.amountOfUnlocking + locals.amountOfReward; + locals.transferResult = qpi.transfer(qpi.invocator(), locals.transferAmount); + if (locals.transferResult < 0) + { + output.returnCode = QEARN_TRANSFER_FAILED; + locals.log = { + QEARN_CONTRACT_INDEX, + SELF, + qpi.invocator(), + locals.transferAmount, + QearnFailedTransfer, + 0 + }; + LOG_INFO(locals.log); + return; + } locals.log = {QEARN_CONTRACT_INDEX, SELF, qpi.invocator(), locals.transferAmount, QearnSuccessEarlyUnlocking, 0}; LOG_INFO(locals.log); @@ -968,6 +1881,278 @@ struct QEARN : public ContractBase output.returnCode = QEARN_UNLOCK_SUCCESS; // unlock is succeed } + struct _FinalizeV2Round_input + { + uint32 lockedEpoch; + }; + + struct _FinalizeV2Round_output + { + }; + + struct _FinalizeV2Round_locals + { + V2EpochInfo epochInfo; + V2TermInfo term13; + V2TermInfo term26; + V2TermInfo term52; + RoundInfo roundInfo; + _TransferV2SurplusToCCF_input ccfInput; + _TransferV2SurplusToCCF_output ccfOutput; + uint128 calculation; + uint64 bonusAmount; + uint64 allocation13; + uint64 allocation26; + uint64 cap13; + uint64 cap26; + uint64 cap52; + uint64 available52; + uint64 surplusAmount; + }; + + PRIVATE_PROCEDURE_WITH_LOCALS(_FinalizeV2Round) + { + if (input.lockedEpoch < QEARN_V2_ACTIVATION_EPOCH || input.lockedEpoch >= QEARN_MAX_EPOCHS) + { + return; + } + + locals.epochInfo = state.get().v2EpochInfo.get(input.lockedEpoch); + if (locals.epochInfo.finalized) + { + return; + } + + locals.term13 = locals.epochInfo.terms.get(QEARN_V2_TERM_INDEX_13); + locals.term26 = locals.epochInfo.terms.get(QEARN_V2_TERM_INDEX_26); + locals.term52 = locals.epochInfo.terms.get(QEARN_V2_TERM_INDEX_52); + locals.bonusAmount = state.get()._initialRoundInfo.get(input.lockedEpoch)._epochBonusAmount; + + locals.calculation = div( + uint128(locals.bonusAmount) * uint128(QEARN_V2_ALLOCATION_PERCENT_13), + uint128(100)); + ASSERT(locals.calculation.high == 0); + locals.allocation13 = locals.calculation.low; + locals.calculation = div( + uint128(locals.term13.initialLockedAmount) * uint128(QEARN_V2_RETURN_PERCENT_13), + uint128(100)); + ASSERT(locals.calculation.high == 0); + locals.cap13 = locals.calculation.low; + if (locals.allocation13 > locals.cap13) + { + locals.allocation13 = locals.cap13; + } + + locals.calculation = div( + uint128(locals.bonusAmount) * uint128(QEARN_V2_ALLOCATION_PERCENT_26), + uint128(100)); + ASSERT(locals.calculation.high == 0); + locals.allocation26 = locals.calculation.low; + locals.calculation = div( + uint128(locals.term26.initialLockedAmount) * uint128(QEARN_V2_RETURN_PERCENT_26), + uint128(100)); + ASSERT(locals.calculation.high == 0); + locals.cap26 = locals.calculation.low; + if (locals.allocation26 > locals.cap26) + { + locals.allocation26 = locals.cap26; + } + + ASSERT(locals.bonusAmount >= locals.allocation13 + locals.allocation26); + locals.available52 = locals.bonusAmount - locals.allocation13 - locals.allocation26; + locals.calculation = div( + uint128(locals.term52.initialLockedAmount) * uint128(QEARN_V2_RETURN_PERCENT_52), + uint128(100)); + ASSERT(locals.calculation.high == 0); + locals.cap52 = locals.calculation.low; + + locals.term13.initialRewardPool = locals.allocation13; + locals.term13.currentRewardPool = locals.allocation13; + locals.term26.initialRewardPool = locals.allocation26; + locals.term26.currentRewardPool = locals.allocation26; + if (locals.available52 > locals.cap52) + { + locals.term52.initialRewardPool = locals.cap52; + locals.term52.currentRewardPool = locals.cap52; + locals.surplusAmount = locals.available52 - locals.cap52; + } + else + { + locals.term52.initialRewardPool = locals.available52; + locals.term52.currentRewardPool = locals.available52; + } + + locals.epochInfo.terms.set(QEARN_V2_TERM_INDEX_13, locals.term13); + locals.epochInfo.terms.set(QEARN_V2_TERM_INDEX_26, locals.term26); + locals.epochInfo.terms.set(QEARN_V2_TERM_INDEX_52, locals.term52); + locals.epochInfo.bonusAmount = locals.bonusAmount; + locals.epochInfo.finalized = 1; + state.mut().v2EpochInfo.set(input.lockedEpoch, locals.epochInfo); + + locals.roundInfo = state.get()._currentRoundInfo.get(input.lockedEpoch); + locals.roundInfo._epochBonusAmount = locals.allocation13 + locals.allocation26 + locals.term52.currentRewardPool; + state.mut()._currentRoundInfo.set(input.lockedEpoch, locals.roundInfo); + + if (locals.surplusAmount) + { + locals.ccfInput.lockedEpoch = input.lockedEpoch; + locals.ccfInput.amount = locals.surplusAmount; + CALL(_TransferV2SurplusToCCF, locals.ccfInput, locals.ccfOutput); + } + } + + struct _PayoutV2Term_input + { + uint32 lockedEpoch; + uint32 lockPeriod; + uint32 termIndex; + }; + + struct _PayoutV2Term_output + { + }; + + struct _PayoutV2Term_locals + { + V2EpochInfo epochInfo; + V2TermInfo termInfo; + RoundInfo roundInfo; + StatsInfo stats; + LockInfo emptyLocker; + HistoryInfo historyInfo; + QEARNLogger log; + _FundV2LongPool_input fundInput; + _FundV2LongPool_output fundOutput; + _TransferV2SurplusToCCF_input ccfInput; + _TransferV2SurplusToCCF_output ccfOutput; + uint128 calculation; + uint32 t; + uint32 startIndex; + uint32 endIndex; + uint64 denominator; + uint64 numerator; + uint64 rewardAmount; + uint64 totalRewardedAmount; + uint64 totalUnlockedAmount; + uint64 remainingRewardPool; + sint64 transferAmount; + }; + + PRIVATE_PROCEDURE_WITH_LOCALS(_PayoutV2Term) + { + if (input.lockedEpoch < QEARN_V2_ACTIVATION_EPOCH || input.lockedEpoch >= QEARN_MAX_EPOCHS) + { + return; + } + locals.epochInfo = state.get().v2EpochInfo.get(input.lockedEpoch); + if (!locals.epochInfo.finalized) + { + return; + } + locals.termInfo = locals.epochInfo.terms.get(input.termIndex); + locals.remainingRewardPool = locals.termInfo.currentRewardPool; + if (input.termIndex == QEARN_V2_TERM_INDEX_52) + { + locals.numerator = locals.termInfo.currentRewardPool; + locals.denominator = locals.termInfo.currentLockedAmount; + } + else + { + locals.numerator = locals.termInfo.initialRewardPool; + locals.denominator = locals.termInfo.initialLockedAmount; + } + + locals.startIndex = state.get()._epochIndex.get(input.lockedEpoch).startIndex; + locals.endIndex = state.get()._epochIndex.get(input.lockedEpoch).endIndex; + for (locals.t = locals.startIndex; locals.t < locals.endIndex; locals.t++) + { + if (!state.get().locker.get(locals.t)._lockedAmount + || state.get().lockerLockPeriods.get(locals.t) != input.lockPeriod) + { + continue; + } + + if (locals.denominator) + { + locals.calculation = div( + uint128(locals.numerator) * uint128(state.get().locker.get(locals.t)._lockedAmount), + uint128(locals.denominator)); + ASSERT(locals.calculation.high == 0); + locals.rewardAmount = locals.calculation.low; + if (locals.rewardAmount > locals.remainingRewardPool) + { + locals.rewardAmount = locals.remainingRewardPool; + } + } + else + { + locals.rewardAmount = 0; + } + + locals.transferAmount = sint64(state.get().locker.get(locals.t)._lockedAmount + locals.rewardAmount); + qpi.transfer(state.get().locker.get(locals.t).ID, locals.transferAmount); + locals.log = {QEARN_CONTRACT_INDEX, SELF, state.get().locker.get(locals.t).ID, locals.transferAmount, QearnSuccessFullyUnlocking, 0}; + LOG_INFO(locals.log); + + locals.historyInfo._unlockedID = state.get().locker.get(locals.t).ID; + locals.historyInfo._unlockedAmount = state.get().locker.get(locals.t)._lockedAmount; + locals.historyInfo._rewardedAmount = locals.rewardAmount; + if (state.get().v2FullyUnlockedCnt < QEARN_V2_MAX_FULLY_UNLOCK_RECORDS) + { + state.mut().v2FullyUnlocker.set( + state.get().v2FullyUnlockedCnt, + locals.historyInfo); + state.mut().v2FullyUnlockedCnt++; + } + + locals.totalUnlockedAmount += state.get().locker.get(locals.t)._lockedAmount; + locals.totalRewardedAmount += locals.rewardAmount; + locals.remainingRewardPool -= locals.rewardAmount; + state.mut().locker.set(locals.t, locals.emptyLocker); + state.mut().lockerLockPeriods.set(locals.t, 0); + } + + ASSERT(locals.termInfo.currentLockedAmount >= locals.totalUnlockedAmount); + locals.termInfo.currentLockedAmount -= locals.totalUnlockedAmount; + ASSERT(locals.termInfo.currentRewardPool >= locals.totalRewardedAmount); + locals.termInfo.currentRewardPool = 0; + locals.termInfo.rewardedAmount += locals.totalRewardedAmount; + locals.epochInfo.terms.set(input.termIndex, locals.termInfo); + locals.epochInfo.rewardedAmount += locals.totalRewardedAmount; + state.mut().v2EpochInfo.set(input.lockedEpoch, locals.epochInfo); + + locals.roundInfo = state.get()._currentRoundInfo.get(input.lockedEpoch); + ASSERT(locals.roundInfo._totalLockedAmount >= locals.totalUnlockedAmount); + ASSERT(locals.roundInfo._epochBonusAmount >= locals.totalRewardedAmount); + locals.roundInfo._totalLockedAmount -= locals.totalUnlockedAmount; + locals.roundInfo._epochBonusAmount -= locals.totalRewardedAmount; + state.mut()._currentRoundInfo.set(input.lockedEpoch, locals.roundInfo); + + locals.stats = state.get().statsInfo.get(input.lockedEpoch); + locals.stats.rewardedAmount += locals.totalRewardedAmount; + state.mut().statsInfo.set(input.lockedEpoch, locals.stats); + + if (input.termIndex == QEARN_V2_TERM_INDEX_52) + { + if (locals.remainingRewardPool) + { + locals.roundInfo = state.get()._currentRoundInfo.get(input.lockedEpoch); + ASSERT(locals.roundInfo._epochBonusAmount >= locals.remainingRewardPool); + locals.roundInfo._epochBonusAmount -= locals.remainingRewardPool; + state.mut()._currentRoundInfo.set(input.lockedEpoch, locals.roundInfo); + locals.ccfInput.lockedEpoch = input.lockedEpoch; + locals.ccfInput.amount = locals.remainingRewardPool; + CALL(_TransferV2SurplusToCCF, locals.ccfInput, locals.ccfOutput); + } + } + else + { + locals.fundInput.lockedEpoch = input.lockedEpoch; + locals.fundInput.amount = locals.remainingRewardPool; + CALL(_FundV2LongPool, locals.fundInput, locals.fundOutput); + } + } + REGISTER_USER_FUNCTIONS_AND_PROCEDURES() { REGISTER_USER_FUNCTION(getLockInfoPerEpoch, 1); @@ -978,12 +2163,44 @@ struct QEARN : public ContractBase REGISTER_USER_FUNCTION(getStatsPerEpoch, 6); REGISTER_USER_FUNCTION(getBurnedAndBoostedStats, 7); REGISTER_USER_FUNCTION(getBurnedAndBoostedStatsPerEpoch, 8); + REGISTER_USER_FUNCTION(getV2LockInfoPerEpoch, 9); + REGISTER_USER_FUNCTION(getV2UserLockedInfo, 10); REGISTER_USER_PROCEDURE(lock, 1); REGISTER_USER_PROCEDURE(unlock, 2); + REGISTER_USER_PROCEDURE(lockV2, 3); + REGISTER_USER_PROCEDURE(unlockV2, 4); } + struct POST_INCOMING_TRANSFER_locals + { + sint64 transferResult; + QEARNLogger log; + }; + + POST_INCOMING_TRANSFER_WITH_LOCALS() + { + if (input.type != TransferType::standardTransaction || input.amount <= 0) + { + return; + } + + locals.transferResult = qpi.transfer(input.sourceId, input.amount); + if (locals.transferResult < 0) + { + locals.log = { + QEARN_CONTRACT_INDEX, + SELF, + input.sourceId, + input.amount, + QearnFailedTransfer, + 0 + }; + LOG_INFO(locals.log); + } + } + struct BEGIN_EPOCH_locals { HistoryInfo INITIALIZE_HISTORY; @@ -1059,6 +2276,14 @@ struct QEARN : public ContractBase QEARNLogger log; _RemoveGapsInLockerArray_input gapRemovalInput; _RemoveGapsInLockerArray_output gapRemovalOutput; + _PayoutV2Term_input payout13Input; + _PayoutV2Term_output payout13Output; + _PayoutV2Term_input payout26Input; + _PayoutV2Term_output payout26Output; + _PayoutV2Term_input payout52Input; + _PayoutV2Term_output payout52Output; + _FinalizeV2Round_input finalizeInput; + _FinalizeV2Round_output finalizeOutput; uint64 _rewardPercent; uint64 _rewardAmount; @@ -1074,64 +2299,93 @@ struct QEARN : public ContractBase { state.mut()._earlyUnlockedCnt = 0; state.mut()._fullyUnlockedCnt = 0; + state.mut().v2FullyUnlockedCnt = 0; + + if (qpi.epoch() >= QEARN_V2_ACTIVATION_EPOCH + QEARN_V2_LOCK_PERIOD_13) + { + locals.payout13Input.lockedEpoch = qpi.epoch() - QEARN_V2_LOCK_PERIOD_13; + locals.payout13Input.lockPeriod = QEARN_V2_LOCK_PERIOD_13; + locals.payout13Input.termIndex = QEARN_V2_TERM_INDEX_13; + CALL(_PayoutV2Term, locals.payout13Input, locals.payout13Output); + } + if (qpi.epoch() >= QEARN_V2_ACTIVATION_EPOCH + QEARN_V2_LOCK_PERIOD_26) + { + locals.payout26Input.lockedEpoch = qpi.epoch() - QEARN_V2_LOCK_PERIOD_26; + locals.payout26Input.lockPeriod = QEARN_V2_LOCK_PERIOD_26; + locals.payout26Input.termIndex = QEARN_V2_TERM_INDEX_26; + CALL(_PayoutV2Term, locals.payout26Input, locals.payout26Output); + } + locals.lockedEpoch = qpi.epoch() - 52; locals.endIndex = state.get()._epochIndex.get(locals.lockedEpoch).endIndex; - locals._burnAmount = state.get()._currentRoundInfo.get(locals.lockedEpoch)._epochBonusAmount; + if (locals.lockedEpoch < QEARN_V2_ACTIVATION_EPOCH) + { + locals._burnAmount = state.get()._currentRoundInfo.get(locals.lockedEpoch)._epochBonusAmount; - locals._rewardPercent = div(state.get()._currentRoundInfo.get(locals.lockedEpoch)._epochBonusAmount * 10000000ULL, state.get()._currentRoundInfo.get(locals.lockedEpoch)._totalLockedAmount); - locals.tmpStats.rewardedAmount = state.get().statsInfo.get(locals.lockedEpoch).rewardedAmount; + locals._rewardPercent = div(state.get()._currentRoundInfo.get(locals.lockedEpoch)._epochBonusAmount * 10000000ULL, state.get()._currentRoundInfo.get(locals.lockedEpoch)._totalLockedAmount); + locals.tmpStats.rewardedAmount = state.get().statsInfo.get(locals.lockedEpoch).rewardedAmount; - for(locals._t = state.get()._epochIndex.get(locals.lockedEpoch).startIndex; locals._t < locals.endIndex; locals._t++) - { - if(state.get().locker.get(locals._t)._lockedAmount == 0) + for(locals._t = state.get()._epochIndex.get(locals.lockedEpoch).startIndex; locals._t < locals.endIndex; locals._t++) { - continue; - } - - ASSERT(state.get().locker.get(locals._t)._lockedEpoch == locals.lockedEpoch); + if(state.get().locker.get(locals._t)._lockedAmount == 0) + { + continue; + } - locals._rewardAmount = div(state.get().locker.get(locals._t)._lockedAmount * locals._rewardPercent, 10000000ULL); - qpi.transfer(state.get().locker.get(locals._t).ID, locals._rewardAmount + state.get().locker.get(locals._t)._lockedAmount); + ASSERT(state.get().locker.get(locals._t)._lockedEpoch == locals.lockedEpoch); - locals.transferAmount = locals._rewardAmount + state.get().locker.get(locals._t)._lockedAmount; - locals.log = {QEARN_CONTRACT_INDEX, SELF, qpi.invocator(), locals.transferAmount, QearnSuccessFullyUnlocking, 0}; - LOG_INFO(locals.log); + locals._rewardAmount = div(state.get().locker.get(locals._t)._lockedAmount * locals._rewardPercent, 10000000ULL); + qpi.transfer(state.get().locker.get(locals._t).ID, locals._rewardAmount + state.get().locker.get(locals._t)._lockedAmount); - if(state.get()._fullyUnlockedCnt < QEARN_MAX_USERS) - { + locals.transferAmount = locals._rewardAmount + state.get().locker.get(locals._t)._lockedAmount; + locals.log = {QEARN_CONTRACT_INDEX, SELF, qpi.invocator(), locals.transferAmount, QearnSuccessFullyUnlocking, 0}; + LOG_INFO(locals.log); locals.INITIALIZE_HISTORY._unlockedID = state.get().locker.get(locals._t).ID; locals.INITIALIZE_HISTORY._unlockedAmount = state.get().locker.get(locals._t)._lockedAmount; locals.INITIALIZE_HISTORY._rewardedAmount = locals._rewardAmount; + if (state.get()._fullyUnlockedCnt < QEARN_MAX_USERS) + { + state.mut().fullyUnlocker.set(state.get()._fullyUnlockedCnt, locals.INITIALIZE_HISTORY); + state.mut()._fullyUnlockedCnt++; + } + + locals.INITIALIZE_USER.ID = NULL_ID; + locals.INITIALIZE_USER._lockedAmount = 0; + locals.INITIALIZE_USER._lockedEpoch = 0; - state.mut().fullyUnlocker.set(state.get()._fullyUnlockedCnt, locals.INITIALIZE_HISTORY); + state.mut().locker.set(locals._t, locals.INITIALIZE_USER); + state.mut().lockerLockPeriods.set(locals._t, 0); - state.mut()._fullyUnlockedCnt++; + locals._burnAmount -= locals._rewardAmount; + locals.tmpStats.rewardedAmount += locals._rewardAmount; } - locals.INITIALIZE_USER.ID = NULL_ID; - locals.INITIALIZE_USER._lockedAmount = 0; - locals.INITIALIZE_USER._lockedEpoch = 0; + qpi.burn(locals._burnAmount); - state.mut().locker.set(locals._t, locals.INITIALIZE_USER); + locals.tmpStats.boostedAmount = state.get().statsInfo.get(locals.lockedEpoch).boostedAmount; + locals.tmpStats.burnedAmount = state.get().statsInfo.get(locals.lockedEpoch).burnedAmount + locals._burnAmount; - locals._burnAmount -= locals._rewardAmount; - locals.tmpStats.rewardedAmount += locals._rewardAmount; + state.mut().statsInfo.set(locals.lockedEpoch, locals.tmpStats); + } + else + { + locals.payout52Input.lockedEpoch = locals.lockedEpoch; + locals.payout52Input.lockPeriod = QEARN_V2_LOCK_PERIOD_52; + locals.payout52Input.termIndex = QEARN_V2_TERM_INDEX_52; + CALL(_PayoutV2Term, locals.payout52Input, locals.payout52Output); + } + + if (qpi.epoch() >= QEARN_V2_ACTIVATION_EPOCH) + { + locals.finalizeInput.lockedEpoch = qpi.epoch(); + CALL(_FinalizeV2Round, locals.finalizeInput, locals.finalizeOutput); } locals.tmpEpochIndex.startIndex = 0; locals.tmpEpochIndex.endIndex = 0; state.mut()._epochIndex.set(locals.lockedEpoch, locals.tmpEpochIndex); - - // remove all gaps in Locker array (from beginning) and update epochIndex CALL(_RemoveGapsInLockerArray, locals.gapRemovalInput, locals.gapRemovalOutput); - - qpi.burn(locals._burnAmount); - - locals.tmpStats.boostedAmount = state.get().statsInfo.get(locals.lockedEpoch).boostedAmount; - locals.tmpStats.burnedAmount = state.get().statsInfo.get(locals.lockedEpoch).burnedAmount + locals._burnAmount; - - state.mut().statsInfo.set(locals.lockedEpoch, locals.tmpStats); - } + } }; diff --git a/test/contract_qbond.cpp b/test/contract_qbond.cpp index 5faa2982..1051c7ea 100644 --- a/test/contract_qbond.cpp +++ b/test/contract_qbond.cpp @@ -1,7 +1,21 @@ #define NO_UEFI +#include + #include "contract_testing.h" +static_assert(sizeof(QEARN::lock_input) == 1); +static_assert(sizeof(QEARN::lock_output) == 4); +static_assert(offsetof(QEARN::lock_output, returnCode) == 0); +static_assert(sizeof(QEARN::getLockInfoPerEpoch_input) == 4); +static_assert(offsetof(QEARN::getLockInfoPerEpoch_input, Epoch) == 0); +static_assert(sizeof(QEARN::getLockInfoPerEpoch_output) == 40); +static_assert(offsetof(QEARN::getLockInfoPerEpoch_output, lockedAmount) == 0); +static_assert(offsetof(QEARN::getLockInfoPerEpoch_output, bonusAmount) == 8); +static_assert(offsetof(QEARN::getLockInfoPerEpoch_output, currentLockedAmount) == 16); +static_assert(offsetof(QEARN::getLockInfoPerEpoch_output, currentBonusAmount) == 24); +static_assert(offsetof(QEARN::getLockInfoPerEpoch_output, yield) == 32); + std::string assetNameFromInt64(uint64 assetName); std::string getCurrentMbondIndex(uint16_t epoch) { @@ -56,6 +70,40 @@ class ContractTestingQBond : protected ContractTesting callSystemProcedure(QBOND_CONTRACT_INDEX, END_EPOCH, expectSuccess); } + void qearnBeginEpoch(bool expectSuccess = true) + { + callSystemProcedure(QEARN_CONTRACT_INDEX, BEGIN_EPOCH, expectSuccess); + } + + void qearnEndEpoch(bool expectSuccess = true) + { + callSystemProcedure(QEARN_CONTRACT_INDEX, END_EPOCH, expectSuccess); + } + + QEARN::getLockInfoPerEpoch_output getQearnLockInfoPerEpoch(uint32 epoch) const + { + QEARN::getLockInfoPerEpoch_input input{ epoch }; + QEARN::getLockInfoPerEpoch_output output; + callFunction(QEARN_CONTRACT_INDEX, 1, input, output); + return output; + } + + QEARN::getV2LockInfoPerEpoch_output getQearnV2LockInfoPerEpoch(uint32 epoch, uint32 lockPeriod) const + { + QEARN::getV2LockInfoPerEpoch_input input{ epoch, lockPeriod }; + QEARN::getV2LockInfoPerEpoch_output output; + callFunction(QEARN_CONTRACT_INDEX, 9, input, output); + return output; + } + + QEARN::getV2UserLockedInfo_output getQearnV2UserLockedInfo(const id& user, uint32 epoch, uint32 lockPeriod) const + { + QEARN::getV2UserLockedInfo_input input{ user, epoch, lockPeriod }; + QEARN::getV2UserLockedInfo_output output; + callFunction(QEARN_CONTRACT_INDEX, 10, input, output); + return output; + } + void stake(const id& staker, const int64_t& quMillions, const int64_t& quAmount) { QBOND::Stake_input input{ quMillions }; @@ -176,6 +224,113 @@ class ContractTestingQBond : protected ContractTesting } }; +TEST(ContractQBond, LegacyQEarnInterfaceUsesV2LongTermAfterActivation) +{ + constexpr uint16 lockEpoch = static_cast(QEARN_V2_ACTIVATION_EPOCH); + constexpr sint64 mbondAmount = 100; + constexpr sint64 principal = + mbondAmount * static_cast(QBOND_MBOND_PRICE); + constexpr sint64 reward = + principal * static_cast(QEARN_V2_RETURN_PERCENT_52) / 100; + constexpr sint64 stakeFee = + principal * static_cast(QBOND_STAKE_FEE_PERCENT) / 10000; + constexpr uint64 termReturn = QEARN_V2_RETURN_PERCENT_52 * 100000ULL; + const id qbondContractId(QBOND_CONTRACT_INDEX, 0, 0, 0); + const id qearnContractId(QEARN_CONTRACT_INDEX, 0, 0, 0); + + // Initialize the cyclic MBond assets at their normal starting epoch and + // advance QBond to the QEarn V2 activation epoch. + system.epoch = QBOND_CYCLIC_START_EPOCH; + ContractTestingQBond qbond; + while (system.epoch < lockEpoch) + { + qbond.beginEpoch(); + system.epoch++; + } + + increaseEnergy(qearnContractId, reward); + qbond.qearnBeginEpoch(); + qbond.beginEpoch(); + + increaseEnergy(testAddress1, principal + stakeFee); + qbond.stake(testAddress1, mbondAmount, principal + stakeFee); + qbond.qearnEndEpoch(); + + const uint64 mbondName = assetNameFromString( + std::string("MBND").append(getCurrentMbondIndex(lockEpoch)).c_str()); + EXPECT_EQ(numberOfPossessedShares( + mbondName, + qbondContractId, + testAddress1, + testAddress1, + QBOND_CONTRACT_INDEX, + QBOND_CONTRACT_INDEX), mbondAmount); + + // QBond still calls legacy procedure 1 and function 1. After activation, + // both must represent the V2 52-epoch position without changing their ABI. + const auto legacyInfo = qbond.getQearnLockInfoPerEpoch(lockEpoch); + EXPECT_EQ(legacyInfo.lockedAmount, principal); + EXPECT_EQ(legacyInfo.currentLockedAmount, principal); + EXPECT_EQ(legacyInfo.bonusAmount, reward); + EXPECT_EQ(legacyInfo.currentBonusAmount, reward); + EXPECT_EQ(legacyInfo.yield, termReturn); + + const auto qbondInfo = qbond.getInfoPerEpoch(lockEpoch); + EXPECT_EQ(qbondInfo.stakersAmount, 1); + EXPECT_EQ(qbondInfo.totalStaked, mbondAmount); + EXPECT_EQ(qbondInfo.apy, termReturn); + + const auto v2Info = qbond.getQearnV2LockInfoPerEpoch( + lockEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(v2Info.returnCode, QEARN_LOCK_SUCCESS); + EXPECT_EQ(v2Info.initialLockedAmount, principal); + EXPECT_EQ(v2Info.currentLockedAmount, principal); + EXPECT_EQ(v2Info.initialRewardPool, reward); + EXPECT_EQ(v2Info.currentRewardPool, reward); + EXPECT_EQ(v2Info.currentTermReturn, termReturn); + EXPECT_EQ(v2Info.maturityEpoch, lockEpoch + QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(v2Info.finalized, 1); + + const auto v2UserInfo = qbond.getQearnV2UserLockedInfo( + qbondContractId, lockEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(v2UserInfo.returnCode, QEARN_LOCK_SUCCESS); + EXPECT_EQ(v2UserInfo.lockedAmount, principal); + EXPECT_EQ(v2UserInfo.maturityEpoch, lockEpoch + QEARN_V2_LOCK_PERIOD_52); + + for (system.epoch = lockEpoch + 1; + system.epoch < lockEpoch + QEARN_V2_LOCK_PERIOD_52; + system.epoch++) + { + qbond.qearnBeginEpoch(); + qbond.qearnEndEpoch(); + } + + EXPECT_EQ(getBalance(testAddress1), 0); + EXPECT_EQ(getBalance(qbondContractId), stakeFee); + EXPECT_EQ(getBalance(qearnContractId), principal + reward); + + // QEarn pays at END_EPOCH of lockEpoch + 52. QBond intentionally waits + // until its next BEGIN_EPOCH before distributing to MBond holders. + qbond.qearnBeginEpoch(); + EXPECT_EQ(getBalance(testAddress1), 0); + qbond.qearnEndEpoch(); + EXPECT_EQ(getBalance(testAddress1), 0); + EXPECT_EQ(getBalance(qbondContractId), principal + reward + stakeFee); + EXPECT_EQ(getBalance(qearnContractId), 0); + + system.epoch++; + qbond.beginEpoch(); + EXPECT_EQ(getBalance(testAddress1), principal + reward); + EXPECT_EQ(getBalance(qbondContractId), stakeFee); + EXPECT_EQ(numberOfPossessedShares( + mbondName, + qbondContractId, + testAddress1, + testAddress1, + QBOND_CONTRACT_INDEX, + QBOND_CONTRACT_INDEX), 0); +} + TEST(ContractQBond, Stake) { system.epoch = QBOND_CYCLIC_START_EPOCH; diff --git a/test/contract_qearn.cpp b/test/contract_qearn.cpp index c01030fa..765d844f 100644 --- a/test/contract_qearn.cpp +++ b/test/contract_qearn.cpp @@ -1,5 +1,7 @@ #define NO_UEFI +#include +#include #include #include @@ -17,6 +19,7 @@ static const id QEARN_CONTRACT_ID(QEARN_CONTRACT_INDEX, 0, 0, 0); +static const id CCF_CONTRACT_ID(CCF_CONTRACT_INDEX, 0, 0, 0); static std::mt19937_64 rand64; @@ -26,6 +29,45 @@ static unsigned long long random(unsigned long long maxValue); static std::vector fullyUnlockedAmount; static std::vector fullyUnlockedUser; +struct QearnV1StateLayout +{ + QPI::Array initialRoundInfo; + QPI::Array currentRoundInfo; + QPI::Array epochIndex; + QPI::Array locker; + QPI::Array earlyUnlocker; + QPI::Array fullyUnlocker; + uint32 earlyUnlockedCnt; + uint32 fullyUnlockedCnt; + QPI::Array statsInfo; +}; + +static_assert( + sizeof(QearnV1StateLayout) == offsetof(QEARN::StateData, lockerLockPeriods), + "QEarn V2 PADDING must preserve the exact V1 state prefix"); +static_assert( + sizeof(QearnV1StateLayout) == 214171656ULL, + "QEarn V1 deployed state size changed; PADDING is no longer safe"); +static_assert( + sizeof(QEARN::StateData) == 244645904ULL, + "QEarn V2 state layout changed; review the epoch-227 deployment plan"); + +static_assert(sizeof(QEARN::lockV2_input) == 4); +static_assert(sizeof(QEARN::lockV2_output) == 4); +static_assert(sizeof(QEARN::unlockV2_input) == 8); +static_assert(sizeof(QEARN::unlockV2_output) == 4); +static_assert(sizeof(QEARN::getV2LockInfoPerEpoch_input) == 8); +static_assert(sizeof(QEARN::getV2LockInfoPerEpoch_output) == 128); +static_assert(offsetof(QEARN::getV2LockInfoPerEpoch_output, finalized) == 112); +static_assert(offsetof(QEARN::getV2LockInfoPerEpoch_output, state) == 116); +static_assert(offsetof(QEARN::getV2LockInfoPerEpoch_output, returnCode) == 120); +static_assert(sizeof(QEARN::getV2UserLockedInfo_input) == 40); +static_assert(offsetof(QEARN::getV2UserLockedInfo_input, epoch) == 32); +static_assert(offsetof(QEARN::getV2UserLockedInfo_input, lockPeriod) == 36); +static_assert(sizeof(QEARN::getV2UserLockedInfo_output) == 24); +static_assert(offsetof(QEARN::getV2UserLockedInfo_output, maturityEpoch) == 8); +static_assert(offsetof(QEARN::getV2UserLockedInfo_output, returnCode) == 16); + class QearnChecker : public QEARN, public QEARN::StateData { @@ -295,6 +337,27 @@ class ContractTestingQearn : protected ContractTesting return output; } + QEARN::getV2LockInfoPerEpoch_output getV2LockInfoPerEpoch(uint32 epoch, uint32 lockPeriod) const + { + QEARN::getV2LockInfoPerEpoch_input input; + input.epoch = epoch; + input.lockPeriod = lockPeriod; + QEARN::getV2LockInfoPerEpoch_output output; + callFunction(QEARN_CONTRACT_INDEX, 9, input, output); + return output; + } + + QEARN::getV2UserLockedInfo_output getV2UserLockedInfo(uint32 epoch, const id& user, uint32 lockPeriod) const + { + QEARN::getV2UserLockedInfo_input input; + input.user = user; + input.epoch = epoch; + input.lockPeriod = lockPeriod; + QEARN::getV2UserLockedInfo_output output; + callFunction(QEARN_CONTRACT_INDEX, 10, input, output); + return output; + } + sint32 lock(const id& user, long long amount, bool expectSuccess = true) { QEARN::lock_input input; @@ -313,6 +376,25 @@ class ContractTestingQearn : protected ContractTesting return output.returnCode; } + sint32 lockV2(const id& user, long long amount, uint32 lockPeriod, bool expectSuccess = true) + { + QEARN::lockV2_input input; + input.lockPeriod = lockPeriod; + QEARN::lockV2_output output; + EXPECT_EQ(invokeUserProcedure(QEARN_CONTRACT_INDEX, 3, input, output, user, amount), expectSuccess); + return output.returnCode; + } + + sint32 unlockV2(const id& user, uint32 lockedEpoch, uint32 lockPeriod, bool expectSuccess = true) + { + QEARN::unlockV2_input input; + input.lockedEpoch = lockedEpoch; + input.lockPeriod = lockPeriod; + QEARN::unlockV2_output output; + EXPECT_EQ(invokeUserProcedure(QEARN_CONTRACT_INDEX, 4, input, output, user, 0), expectSuccess); + return output.returnCode; + } + struct UserData { std::map locked; @@ -703,6 +785,92 @@ static std::vector getRandomUsers(unsigned int totalUsers, unsigned int maxN } +TEST(TestContractQearn, StandardTransferIsRefundedWithoutCreatingPosition) +{ + ContractTestingQearn qearn; + system.epoch = QEARN_V2_ACTIVATION_EPOCH; + qearn.beginEpoch(); + + const id sender(1091, 9, 1, 1); + constexpr sint64 amount = 100000000LL; + increaseEnergy(sender, amount); + + const sint64 senderBalanceBefore = getBalance(sender); + const sint64 qearnBalanceBefore = getBalance(QEARN_CONTRACT_ID); + const int senderIndex = spectrumIndex(sender); + ASSERT_GE(senderIndex, 0); + ASSERT_TRUE(decreaseEnergy(senderIndex, amount)); + increaseEnergy(QEARN_CONTRACT_ID, amount); + ASSERT_EQ(getBalance(sender), senderBalanceBefore - amount); + ASSERT_EQ(getBalance(QEARN_CONTRACT_ID), qearnBalanceBefore + amount); + + notifyContractOfIncomingTransfer( + sender, + QEARN_CONTRACT_ID, + amount, + QPI::TransferType::standardTransaction); + + EXPECT_EQ(getBalance(sender), senderBalanceBefore); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), qearnBalanceBefore); + EXPECT_EQ( + qearn.getV2UserLockedInfo(system.epoch, sender, QEARN_V2_LOCK_PERIOD_13).lockedAmount, + 0ULL); + EXPECT_EQ( + qearn.getV2UserLockedInfo(system.epoch, sender, QEARN_V2_LOCK_PERIOD_26).lockedAmount, + 0ULL); + EXPECT_EQ( + qearn.getV2UserLockedInfo(system.epoch, sender, QEARN_V2_LOCK_PERIOD_52).lockedAmount, + 0ULL); +} + +TEST(TestContractQearn, RevenueDonationIsRetained) +{ + ContractTestingQearn qearn; + system.epoch = QEARN_V2_ACTIVATION_EPOCH; + qearn.beginEpoch(); + + const id donor(1093, 9, 3, 3); + constexpr sint64 amount = 100000000LL; + increaseEnergy(donor, amount); + + const sint64 qearnBalanceBefore = getBalance(QEARN_CONTRACT_ID); + const int donorIndex = spectrumIndex(donor); + ASSERT_GE(donorIndex, 0); + ASSERT_TRUE(decreaseEnergy(donorIndex, amount)); + increaseEnergy(QEARN_CONTRACT_ID, amount); + + notifyContractOfIncomingTransfer( + donor, + QEARN_CONTRACT_ID, + amount, + QPI::TransferType::revenueDonation); + + EXPECT_EQ(getBalance(donor), 0); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), qearnBalanceBefore + amount); +} + +TEST(TestContractQearn, ProcedureTransactionStillFundsLockV2) +{ + ContractTestingQearn qearn; + system.epoch = QEARN_V2_ACTIVATION_EPOCH; + qearn.beginEpoch(); + + const id sender(1092, 9, 2, 2); + constexpr sint64 amount = 100000000LL; + increaseEnergy(sender, amount); + const sint64 qearnBalanceBefore = getBalance(QEARN_CONTRACT_ID); + + EXPECT_EQ( + qearn.lockV2(sender, amount, QEARN_V2_LOCK_PERIOD_13), + QEARN_LOCK_SUCCESS); + + EXPECT_EQ(getBalance(sender), 0); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), qearnBalanceBefore + amount); + EXPECT_EQ( + qearn.getV2UserLockedInfo(system.epoch, sender, QEARN_V2_LOCK_PERIOD_13).lockedAmount, + uint64(amount)); +} + TEST(TestContractQearn, ErrorChecking) { ContractTestingQearn qearn; @@ -788,6 +956,1393 @@ TEST(TestContractQearn, ErrorChecking) EXPECT_EQ(qearn.unlock(otherUser, QEARN_MINIMUM_LOCKING_AMOUNT, system.epoch), QEARN_UNLOCK_SUCCESS); } +TEST(TestContractQearn, V2ActivationAndLegacyWrappersRemainCompatible) +{ + ContractTestingQearn qearn; + const uint32 legacyEpoch = QEARN_V2_ACTIVATION_EPOCH - 1; + const uint32 v2Epoch = QEARN_V2_ACTIVATION_EPOCH; + const uint64 principal = 100000000ULL; + const id legacyUser(1001, 1, 1, 1); + const id inactiveV2User(1002, 2, 2, 2); + const id wrapperUser(1003, 3, 3, 3); + const id shortTermUser(1004, 4, 4, 4); + + system.epoch = legacyEpoch; + qearn.beginEpoch(); + + increaseEnergy(legacyUser, principal); + EXPECT_EQ(qearn.lock(legacyUser, principal), QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.getUserLockedInfo(legacyEpoch, legacyUser), principal); + + increaseEnergy(inactiveV2User, principal); + const sint64 inactiveBalance = getBalance(inactiveV2User); + EXPECT_EQ(qearn.lockV2(inactiveV2User, principal, QEARN_V2_LOCK_PERIOD_13), QEARN_V2_NOT_ACTIVE); + EXPECT_EQ(getBalance(inactiveV2User), inactiveBalance); + EXPECT_EQ(qearn.getV2LockInfoPerEpoch(legacyEpoch, QEARN_V2_LOCK_PERIOD_13).returnCode, + QEARN_INVALID_INPUT_LOCKED_EPOCH); + EXPECT_EQ(qearn.getV2UserLockedInfo(legacyEpoch, inactiveV2User, QEARN_V2_LOCK_PERIOD_13).returnCode, + QEARN_INVALID_INPUT_LOCKED_EPOCH); + qearn.endEpoch(); + + system.epoch = v2Epoch; + qearn.beginEpoch(); + + increaseEnergy(wrapperUser, principal); + increaseEnergy(shortTermUser, principal); + EXPECT_EQ(qearn.lock(wrapperUser, principal), QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2(shortTermUser, principal, QEARN_V2_LOCK_PERIOD_13), QEARN_LOCK_SUCCESS); + + // The V1 lock procedure remains a compatibility wrapper for a V2 52-epoch lock. + EXPECT_EQ(qearn.getUserLockedInfo(v2Epoch, wrapperUser), principal); + EXPECT_EQ(qearn.getV2UserLockedInfo(v2Epoch, wrapperUser, QEARN_V2_LOCK_PERIOD_52).lockedAmount, principal); + EXPECT_EQ(qearn.getUserLockedInfo(v2Epoch, shortTermUser), 0ULL); + EXPECT_EQ(qearn.getV2UserLockedInfo(v2Epoch, shortTermUser, QEARN_V2_LOCK_PERIOD_13).lockedAmount, principal); + + // Neither the new procedure nor the legacy 52-epoch wrapper may unlock in the lock epoch. + EXPECT_EQ(qearn.unlockV2(shortTermUser, v2Epoch, QEARN_V2_LOCK_PERIOD_13), + QEARN_INVALID_INPUT_LOCKED_EPOCH); + EXPECT_EQ(qearn.unlock(wrapperUser, QEARN_MINIMUM_LOCKING_AMOUNT, v2Epoch), + QEARN_INVALID_INPUT_LOCKED_EPOCH); + EXPECT_EQ(qearn.getV2UserLockedInfo(v2Epoch, shortTermUser, QEARN_V2_LOCK_PERIOD_13).lockedAmount, principal); + EXPECT_EQ(qearn.getV2UserLockedInfo(v2Epoch, wrapperUser, QEARN_V2_LOCK_PERIOD_52).lockedAmount, principal); + + // A pre-activation position continues to use the original V1 unlock path after activation. + const sint64 legacyBalance = getBalance(legacyUser); + EXPECT_EQ(qearn.unlock(legacyUser, principal, legacyEpoch), QEARN_UNLOCK_SUCCESS); + EXPECT_EQ(getBalance(legacyUser), legacyBalance + principal); + EXPECT_EQ(qearn.getUserLockedInfo(legacyEpoch, legacyUser), 0ULL); + qearn.endEpoch(); + + system.epoch = v2Epoch + 1; + qearn.beginEpoch(); + + // The legacy wrapper requires an exact full-position amount, preventing an + // old partial-unlock UI from silently closing the complete V2 position. + const sint64 wrapperBalance = getBalance(wrapperUser); + EXPECT_EQ(qearn.unlock(wrapperUser, QEARN_MINIMUM_LOCKING_AMOUNT, v2Epoch), + QEARN_INVALID_INPUT_UNLOCK_AMOUNT); + EXPECT_EQ(getBalance(wrapperUser), wrapperBalance); + EXPECT_EQ(qearn.getV2UserLockedInfo( + v2Epoch, wrapperUser, QEARN_V2_LOCK_PERIOD_52).lockedAmount, principal); + EXPECT_EQ(qearn.unlock(wrapperUser, principal, v2Epoch), QEARN_UNLOCK_SUCCESS); + EXPECT_EQ(getBalance(wrapperUser), wrapperBalance + principal); + EXPECT_EQ(qearn.getV2UserLockedInfo(v2Epoch, wrapperUser, QEARN_V2_LOCK_PERIOD_52).lockedAmount, 0ULL); + + const sint64 shortTermBalance = getBalance(shortTermUser); + EXPECT_EQ(qearn.unlockV2(shortTermUser, v2Epoch, QEARN_V2_LOCK_PERIOD_13), QEARN_UNLOCK_SUCCESS); + EXPECT_EQ(getBalance(shortTermUser), shortTermBalance + principal); + EXPECT_EQ(qearn.getV2UserLockedInfo(v2Epoch, shortTermUser, QEARN_V2_LOCK_PERIOD_13).lockedAmount, 0ULL); + EXPECT_EQ(qearn.getEndedStatus(wrapperUser).earlyRewardedAmount, 0ULL); + EXPECT_EQ(qearn.getEndedStatus(shortTermUser).earlyRewardedAmount, 0ULL); +} + +TEST(TestContractQearn, V2FailedPrincipalTransferDoesNotClosePosition) +{ + ContractTestingQearn qearn; + const uint32 lockedEpoch = QEARN_V2_ACTIVATION_EPOCH; + const uint64 principal = 100000000ULL; + const id contractUser(QVAULT_CONTRACT_INDEX, 0, 0, 0); + + system.epoch = lockedEpoch; + qearn.beginEpoch(); + increaseEnergy(contractUser, principal); + EXPECT_EQ(qearn.lockV2(contractUser, principal, QEARN_V2_LOCK_PERIOD_13), + QEARN_LOCK_SUCCESS); + qearn.endEpoch(); + + system.epoch = lockedEpoch + 1; + qearn.beginEpoch(); + const sint64 userBalanceBefore = getBalance(contractUser); + const sint64 qearnBalanceBefore = getBalance(QEARN_CONTRACT_ID); + + // QPI rejects transfers to contracts while an incoming-transfer callback + // is active. A failed repayment must leave the position fully intact. + contractCallbacksRunning = ContractCallbackPostIncomingTransfer; + EXPECT_EQ(qearn.unlockV2(contractUser, lockedEpoch, QEARN_V2_LOCK_PERIOD_13), + QEARN_TRANSFER_FAILED); + contractCallbacksRunning = NoContractCallback; + + EXPECT_EQ(getBalance(contractUser), userBalanceBefore); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), qearnBalanceBefore); + EXPECT_EQ(qearn.getV2UserLockedInfo( + lockedEpoch, contractUser, QEARN_V2_LOCK_PERIOD_13).lockedAmount, principal); + auto term13 = + qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_13); + EXPECT_EQ(term13.currentLockedAmount, principal); + EXPECT_EQ(term13.termEarlyUnlockedAmount, 0ULL); + EXPECT_EQ(term13.termForfeitedClaimAmount, 0ULL); + + EXPECT_EQ(qearn.unlockV2(contractUser, lockedEpoch, QEARN_V2_LOCK_PERIOD_13), + QEARN_UNLOCK_SUCCESS); + EXPECT_EQ(getBalance(contractUser), userBalanceBefore + static_cast(principal)); + EXPECT_EQ(qearn.getV2UserLockedInfo( + lockedEpoch, contractUser, QEARN_V2_LOCK_PERIOD_13).lockedAmount, 0ULL); +} + +TEST(TestContractQearn, V2ValidatesTermsAmountsAndLockEpochRange) +{ + ContractTestingQearn qearn; + const uint32 lockedEpoch = QEARN_V2_ACTIVATION_EPOCH; + const id user(1051, 1, 1, 1); + + system.epoch = lockedEpoch; + qearn.beginEpoch(); + + increaseEnergy(user, QEARN_MINIMUM_LOCKING_AMOUNT); + const sint64 balanceBeforeInvalidPeriod = getBalance(user); + EXPECT_EQ(qearn.lockV2(user, QEARN_MINIMUM_LOCKING_AMOUNT, 12), + QEARN_INVALID_LOCK_PERIOD); + EXPECT_EQ(getBalance(user), balanceBeforeInvalidPeriod); + EXPECT_EQ(qearn.getV2LockInfoPerEpoch(lockedEpoch, 12).returnCode, + QEARN_INVALID_LOCK_PERIOD); + EXPECT_EQ(qearn.getV2UserLockedInfo(lockedEpoch, user, 12).returnCode, + QEARN_INVALID_LOCK_PERIOD); + + increaseEnergy(user, QEARN_MINIMUM_LOCKING_AMOUNT - 1); + const sint64 balanceBeforeSmallLock = getBalance(user); + EXPECT_EQ(qearn.lockV2( + user, QEARN_MINIMUM_LOCKING_AMOUNT - 1, QEARN_V2_LOCK_PERIOD_52), + QEARN_INVALID_INPUT_AMOUNT); + EXPECT_EQ(getBalance(user), balanceBeforeSmallLock); + + increaseEnergy(user, QEARN_MAX_LOCK_AMOUNT + QEARN_MINIMUM_LOCKING_AMOUNT); + EXPECT_EQ(qearn.lockV2(user, QEARN_MAX_LOCK_AMOUNT, QEARN_V2_LOCK_PERIOD_52), + QEARN_LOCK_SUCCESS); + const sint64 balanceBeforeLimit = getBalance(user); + EXPECT_EQ(qearn.lockV2(user, QEARN_MINIMUM_LOCKING_AMOUNT, QEARN_V2_LOCK_PERIOD_52), + QEARN_LIMIT_LOCK); + EXPECT_EQ(getBalance(user), balanceBeforeLimit); + EXPECT_EQ(qearn.getV2UserLockedInfo( + lockedEpoch, user, QEARN_V2_LOCK_PERIOD_52).lockedAmount, + QEARN_MAX_LOCK_AMOUNT); +} + +TEST(TestContractQearn, V2HonorsLastLockEpoch) +{ + ContractTestingQearn qearn; + const id lastEpochUser(1052, 2, 2, 2); + const id lateUser(1053, 3, 3, 3); + + system.epoch = QEARN_V2_LAST_LOCK_EPOCH; + qearn.beginEpoch(); + increaseEnergy(lastEpochUser, QEARN_MINIMUM_LOCKING_AMOUNT); + EXPECT_EQ(qearn.lockV2( + lastEpochUser, QEARN_MINIMUM_LOCKING_AMOUNT, QEARN_V2_LOCK_PERIOD_13), + QEARN_LOCK_SUCCESS); + + system.epoch = QEARN_V2_LAST_LOCK_EPOCH + 1; + increaseEnergy(lateUser, QEARN_MINIMUM_LOCKING_AMOUNT); + const sint64 lateBalance = getBalance(lateUser); + EXPECT_EQ(qearn.lockV2( + lateUser, QEARN_MINIMUM_LOCKING_AMOUNT, QEARN_V2_LOCK_PERIOD_13), + QEARN_V2_NOT_ACTIVE); + EXPECT_EQ(getBalance(lateUser), lateBalance); +} + +TEST(TestContractQearn, V2KeepsThreeTermsSeparateForOneAddress) +{ + ContractTestingQearn qearn; + const uint32 lockedEpoch = QEARN_V2_ACTIVATION_EPOCH; + const uint64 principal = 100000000ULL; + const id user(1061, 13, 26, 52); + + system.epoch = lockedEpoch; + qearn.beginEpoch(); + increaseEnergy(user, principal * 3); + EXPECT_EQ(qearn.lockV2(user, principal, QEARN_V2_LOCK_PERIOD_13), + QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2(user, principal, QEARN_V2_LOCK_PERIOD_26), + QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2(user, principal, QEARN_V2_LOCK_PERIOD_52), + QEARN_LOCK_SUCCESS); + + EXPECT_EQ(qearn.getV2UserLockedInfo( + lockedEpoch, user, QEARN_V2_LOCK_PERIOD_13).lockedAmount, principal); + EXPECT_EQ(qearn.getV2UserLockedInfo( + lockedEpoch, user, QEARN_V2_LOCK_PERIOD_26).lockedAmount, principal); + EXPECT_EQ(qearn.getV2UserLockedInfo( + lockedEpoch, user, QEARN_V2_LOCK_PERIOD_52).lockedAmount, principal); + EXPECT_EQ(qearn.getUserLockedInfo(lockedEpoch, user), principal); + EXPECT_EQ(qearn.getUserLockStatus(user), 1ULL); +} + +TEST(TestContractQearn, V1MaturityIsPreservedAlongsideLiveV2Cohorts) +{ + ContractTestingQearn qearn; + const uint32 v1Epoch = QEARN_V2_ACTIVATION_EPOCH - 1; + const uint32 v2Epoch = QEARN_V2_ACTIVATION_EPOCH; + const uint64 bonus = 100000000ULL; + const uint64 principal = 1000000000ULL; + const id v1User(1101, 1, 1, 1); + const id v2ShortUser(1102, 13, 2, 2); + const id v2LongUser(1103, 52, 3, 3); + + system.epoch = v1Epoch; + increaseEnergy(QEARN_CONTRACT_ID, bonus); + qearn.beginEpoch(); + increaseEnergy(v1User, principal); + EXPECT_EQ(qearn.lock(v1User, principal), QEARN_LOCK_SUCCESS); + qearn.endEpoch(); + + system.epoch = v2Epoch; + increaseEnergy(QEARN_CONTRACT_ID, bonus); + qearn.beginEpoch(); + increaseEnergy(v2ShortUser, principal); + increaseEnergy(v2LongUser, principal); + EXPECT_EQ(qearn.lockV2(v2ShortUser, principal, QEARN_V2_LOCK_PERIOD_13), + QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2(v2LongUser, principal, QEARN_V2_LOCK_PERIOD_52), + QEARN_LOCK_SUCCESS); + qearn.endEpoch(); + + for (system.epoch = v2Epoch + 1; + system.epoch <= v2Epoch + QEARN_V2_LOCK_PERIOD_52; + ++system.epoch) + { + qearn.beginEpoch(); + qearn.endEpoch(); + + if (system.epoch == v1Epoch + QEARN_V2_LOCK_PERIOD_52) + { + EXPECT_EQ(getBalance(v1User), principal + bonus); + EXPECT_EQ(qearn.getUserLockedInfo(v1Epoch, v1User), 0ULL); + EXPECT_EQ(getBalance(v2LongUser), 0); + } + } + + EXPECT_EQ(getBalance(v2ShortUser), principal + bonus * 10 / 100); + EXPECT_EQ(getBalance(v2LongUser), principal + bonus * 90 / 100); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), 0); + + const auto v1Round = qearn.getLockInfoPerEpoch(v1Epoch); + EXPECT_EQ(v1Round.lockedAmount, principal); + EXPECT_EQ(v1Round.bonusAmount, bonus); + EXPECT_EQ(v1Round.yield, 1000000ULL); + const auto v2LongRound = + qearn.getV2LockInfoPerEpoch(v2Epoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(v2LongRound.termRewardedAmount, bonus * 90 / 100); + EXPECT_EQ(v2LongRound.currentTermReturn, 900000ULL); +} + +TEST(TestContractQearn, V2AllocatesBonusAcrossAllTerms) +{ + ContractTestingQearn qearn; + const uint32 lockedEpoch = QEARN_V2_ACTIVATION_EPOCH; + const uint64 bonus = 100000000ULL; + const uint64 principal = 1000000000ULL; + const id user13(2001, 13, 13, 13); + const id user26(2002, 26, 26, 26); + const id user52(2003, 52, 52, 52); + + system.epoch = lockedEpoch; + increaseEnergy(QEARN_CONTRACT_ID, bonus); + qearn.beginEpoch(); + + increaseEnergy(user13, principal); + increaseEnergy(user26, principal); + increaseEnergy(user52, principal); + EXPECT_EQ(qearn.lockV2(user13, principal, QEARN_V2_LOCK_PERIOD_13), QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2(user26, principal, QEARN_V2_LOCK_PERIOD_26), QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2(user52, principal, QEARN_V2_LOCK_PERIOD_52), QEARN_LOCK_SUCCESS); + qearn.endEpoch(); + + const auto term13 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_13); + const auto term26 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_26); + const auto term52 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + + EXPECT_EQ(term13.returnCode, QEARN_LOCK_SUCCESS); + EXPECT_EQ(term26.returnCode, QEARN_LOCK_SUCCESS); + EXPECT_EQ(term52.returnCode, QEARN_LOCK_SUCCESS); + EXPECT_EQ(term13.initialLockedAmount, principal); + EXPECT_EQ(term26.initialLockedAmount, principal); + EXPECT_EQ(term52.initialLockedAmount, principal); + EXPECT_EQ(term13.initialRewardPool, bonus * 10 / 100); + EXPECT_EQ(term26.initialRewardPool, bonus * 20 / 100); + EXPECT_EQ(term52.initialRewardPool, bonus * 70 / 100); + EXPECT_EQ(term13.currentRewardPool, term13.initialRewardPool); + EXPECT_EQ(term26.currentRewardPool, term26.initialRewardPool); + EXPECT_EQ(term52.currentRewardPool, term52.initialRewardPool); + EXPECT_EQ(term13.currentTermReturn, 100000ULL); + EXPECT_EQ(term26.currentTermReturn, 200000ULL); + EXPECT_EQ(term52.currentTermReturn, 700000ULL); + EXPECT_EQ(term13.maturityEpoch, lockedEpoch + QEARN_V2_LOCK_PERIOD_13); + EXPECT_EQ(term26.maturityEpoch, lockedEpoch + QEARN_V2_LOCK_PERIOD_26); + EXPECT_EQ(term52.maturityEpoch, lockedEpoch + QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term52.epochBonusAmount, bonus); + EXPECT_EQ(term52.epochTransferredToCCFAmount, 0ULL); + EXPECT_EQ(term52.finalized, 1U); + EXPECT_EQ(bonus, + term13.currentRewardPool + term26.currentRewardPool + term52.currentRewardPool + + term52.epochRewardedAmount + term52.epochTransferredToCCFAmount); + + // The original read APIs expose the compatibility 52-epoch term only. + const auto legacyRoundInfo = qearn.getLockInfoPerEpoch(lockedEpoch); + EXPECT_EQ(legacyRoundInfo.lockedAmount, principal); + EXPECT_EQ(legacyRoundInfo.bonusAmount, bonus * 70 / 100); + EXPECT_EQ(legacyRoundInfo.currentLockedAmount, principal); + EXPECT_EQ(legacyRoundInfo.currentBonusAmount, bonus * 70 / 100); + EXPECT_EQ(legacyRoundInfo.yield, 700000ULL); + EXPECT_EQ(qearn.getUserLockedInfo(lockedEpoch, user13), 0ULL); + EXPECT_EQ(qearn.getUserLockedInfo(lockedEpoch, user26), 0ULL); + EXPECT_EQ(qearn.getUserLockedInfo(lockedEpoch, user52), principal); +} + +TEST(TestContractQearn, V2EarlyExitReturnsPrincipalAndRoutesForfeitureToCohort52) +{ + ContractTestingQearn qearn; + const uint32 lockedEpoch = QEARN_V2_ACTIVATION_EPOCH; + const uint64 bonus = 100000000ULL; + const uint64 firstLock = 400000000ULL; + const uint64 secondLock = 600000000ULL; + const uint64 principal = firstLock + secondLock; + const id shortTermUser(3001, 13, 1, 1); + const id longTermUser(3002, 52, 2, 2); + + system.epoch = lockedEpoch; + increaseEnergy(QEARN_CONTRACT_ID, bonus); + qearn.beginEpoch(); + + increaseEnergy(shortTermUser, principal); + increaseEnergy(longTermUser, principal); + EXPECT_EQ(qearn.lockV2(shortTermUser, firstLock, QEARN_V2_LOCK_PERIOD_13), QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2(shortTermUser, secondLock, QEARN_V2_LOCK_PERIOD_13), QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2(longTermUser, principal, QEARN_V2_LOCK_PERIOD_52), QEARN_LOCK_SUCCESS); + + const sint64 sameEpochBalance = getBalance(shortTermUser); + EXPECT_EQ(qearn.unlockV2(shortTermUser, lockedEpoch, QEARN_V2_LOCK_PERIOD_13), + QEARN_INVALID_INPUT_LOCKED_EPOCH); + EXPECT_EQ(getBalance(shortTermUser), sameEpochBalance); + EXPECT_EQ(qearn.getV2UserLockedInfo(lockedEpoch, shortTermUser, QEARN_V2_LOCK_PERIOD_13).lockedAmount, + principal); + qearn.endEpoch(); + + auto term13 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_13); + auto term52 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term13.initialRewardPool, 10000000ULL); + EXPECT_EQ(term52.initialRewardPool, 90000000ULL); + + system.epoch = lockedEpoch + 1; + qearn.beginEpoch(); + const sint64 userBalanceBefore = getBalance(shortTermUser); + const sint64 contractBalanceBefore = getBalance(QEARN_CONTRACT_ID); + EXPECT_EQ(qearn.unlockV2(shortTermUser, lockedEpoch, QEARN_V2_LOCK_PERIOD_13), QEARN_UNLOCK_SUCCESS); + + // The two deposits formed one position: it is closed in full and pays no early reward. + EXPECT_EQ(getBalance(shortTermUser), userBalanceBefore + principal); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), contractBalanceBefore - principal); + EXPECT_EQ(qearn.getV2UserLockedInfo(lockedEpoch, shortTermUser, QEARN_V2_LOCK_PERIOD_13).lockedAmount, + 0ULL); + const auto endedStatus = qearn.getEndedStatus(shortTermUser); + EXPECT_EQ(endedStatus.earlyUnlockedAmount, principal); + EXPECT_EQ(endedStatus.earlyRewardedAmount, 0ULL); + + term13 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_13); + term52 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term13.currentLockedAmount, 0ULL); + EXPECT_EQ(term13.currentRewardPool, 0ULL); + EXPECT_EQ(term13.epochForfeitedClaimAmount, 10000000ULL); + EXPECT_EQ(term52.initialRewardPool, 90000000ULL); + EXPECT_EQ(term52.currentRewardPool, 100000000ULL); + EXPECT_EQ(term52.epochTransferredTo52Amount, 10000000ULL); + EXPECT_EQ(term52.epochTransferredToCCFAmount, 0ULL); + EXPECT_EQ(bonus, + term13.currentRewardPool + term52.currentRewardPool + + term52.epochRewardedAmount + term52.epochTransferredToCCFAmount); + EXPECT_EQ(qearn.getV2UserLockedInfo(lockedEpoch, longTermUser, QEARN_V2_LOCK_PERIOD_52).lockedAmount, + principal); + + const auto stats = qearn.getBurnedAndBoostedStatsPerEpoch(lockedEpoch); + EXPECT_EQ(stats.boostedAmount, 10000000ULL); + EXPECT_EQ(stats.burnedAmount, 0ULL); +} + +TEST(TestContractQearn, V2PaysEachTermAtItsMaturity) +{ + ContractTestingQearn qearn; + const uint32 lockedEpoch = QEARN_V2_ACTIVATION_EPOCH; + const uint64 bonus = 100000000ULL; + const uint64 principal = 1000000000ULL; + const uint64 reward13 = 10000000ULL; + const uint64 reward26 = 20000000ULL; + const uint64 reward52 = 70000000ULL; + const id user13(4001, 13, 1, 1); + const id user26(4002, 26, 2, 2); + const id user52(4003, 52, 3, 3); + + system.epoch = lockedEpoch; + increaseEnergy(QEARN_CONTRACT_ID, bonus); + qearn.beginEpoch(); + increaseEnergy(user13, principal); + increaseEnergy(user26, principal); + increaseEnergy(user52, principal); + EXPECT_EQ(qearn.lockV2(user13, principal, QEARN_V2_LOCK_PERIOD_13), QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2(user26, principal, QEARN_V2_LOCK_PERIOD_26), QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2(user52, principal, QEARN_V2_LOCK_PERIOD_52), QEARN_LOCK_SUCCESS); + qearn.endEpoch(); + + for (system.epoch = lockedEpoch + 1; + system.epoch <= lockedEpoch + QEARN_V2_LOCK_PERIOD_52; + ++system.epoch) + { + qearn.beginEpoch(); + + if (system.epoch == lockedEpoch + QEARN_V2_LOCK_PERIOD_13) + { + EXPECT_EQ(qearn.unlockV2(user13, lockedEpoch, QEARN_V2_LOCK_PERIOD_13), + QEARN_V2_POSITION_MATURED); + EXPECT_EQ(getBalance(user13), 0); + } + if (system.epoch == lockedEpoch + QEARN_V2_LOCK_PERIOD_26) + { + EXPECT_EQ(qearn.unlockV2(user26, lockedEpoch, QEARN_V2_LOCK_PERIOD_26), + QEARN_V2_POSITION_MATURED); + EXPECT_EQ(getBalance(user26), 0); + } + if (system.epoch == lockedEpoch + QEARN_V2_LOCK_PERIOD_52) + { + EXPECT_EQ(qearn.unlockV2(user52, lockedEpoch, QEARN_V2_LOCK_PERIOD_52), + QEARN_V2_POSITION_MATURED); + EXPECT_EQ(getBalance(user52), 0); + } + + qearn.endEpoch(); + + if (system.epoch == lockedEpoch + QEARN_V2_LOCK_PERIOD_13) + { + EXPECT_EQ(getBalance(user13), principal + reward13); + EXPECT_EQ(qearn.getV2UserLockedInfo( + lockedEpoch, user13, QEARN_V2_LOCK_PERIOD_13).lockedAmount, 0ULL); + const auto endedStatus = qearn.getEndedStatus(user13); + EXPECT_EQ(endedStatus.fullyUnlockedAmount, principal); + EXPECT_EQ(endedStatus.fullyRewardedAmount, reward13); + } + if (system.epoch == lockedEpoch + QEARN_V2_LOCK_PERIOD_26) + { + EXPECT_EQ(getBalance(user26), principal + reward26); + EXPECT_EQ(qearn.getV2UserLockedInfo( + lockedEpoch, user26, QEARN_V2_LOCK_PERIOD_26).lockedAmount, 0ULL); + const auto endedStatus = qearn.getEndedStatus(user26); + EXPECT_EQ(endedStatus.fullyUnlockedAmount, principal); + EXPECT_EQ(endedStatus.fullyRewardedAmount, reward26); + } + if (system.epoch == lockedEpoch + QEARN_V2_LOCK_PERIOD_52) + { + EXPECT_EQ(getBalance(user52), principal + reward52); + EXPECT_EQ(qearn.getV2UserLockedInfo( + lockedEpoch, user52, QEARN_V2_LOCK_PERIOD_52).lockedAmount, 0ULL); + const auto endedStatus = qearn.getEndedStatus(user52); + EXPECT_EQ(endedStatus.fullyUnlockedAmount, principal); + EXPECT_EQ(endedStatus.fullyRewardedAmount, reward52); + } + } + + const auto term52 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term52.epochRewardedAmount, bonus); + EXPECT_EQ(term52.epochTransferredToCCFAmount, 0ULL); + EXPECT_EQ(term52.currentTermReturn, 700000ULL); + EXPECT_EQ(bonus, + term52.currentRewardPool + term52.epochRewardedAmount + + term52.epochTransferredToCCFAmount); + EXPECT_EQ(qearn.getStatsPerEpoch(lockedEpoch).earlyUnlockedAmount, 0ULL); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), 0); +} + +TEST(TestContractQearn, V2CapsReturnsAndTransfersLongPoolSurplusToCCF) +{ + ContractTestingQearn qearn; + const uint32 lockedEpoch = QEARN_V2_ACTIVATION_EPOCH; + const uint64 bonus = 100000000ULL; + const uint64 principal = 100000000ULL; + const id user13(5001, 13, 1, 1); + const id user26(5002, 26, 2, 2); + const id user52(5003, 52, 3, 3); + + system.epoch = lockedEpoch; + increaseEnergy(QEARN_CONTRACT_ID, bonus); + qearn.beginEpoch(); + increaseEnergy(user13, principal); + increaseEnergy(user26, principal); + increaseEnergy(user52, principal); + EXPECT_EQ(qearn.lockV2(user13, principal, QEARN_V2_LOCK_PERIOD_13), QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2(user26, principal, QEARN_V2_LOCK_PERIOD_26), QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2(user52, principal, QEARN_V2_LOCK_PERIOD_52), QEARN_LOCK_SUCCESS); + + const sint64 contractBalanceBeforeFinalization = getBalance(QEARN_CONTRACT_ID); + const sint64 ccfBalanceBeforeFinalization = getBalance(CCF_CONTRACT_ID); + qearn.endEpoch(); + + auto term13 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_13); + const auto term26 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_26); + auto term52 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term13.initialRewardPool, 3000000ULL); + EXPECT_EQ(term26.initialRewardPool, 6000000ULL); + EXPECT_EQ(term52.initialRewardPool, 18000000ULL); + EXPECT_EQ(term13.currentTermReturn, 300000ULL); + EXPECT_EQ(term26.currentTermReturn, 600000ULL); + EXPECT_EQ(term52.currentTermReturn, 1800000ULL); + EXPECT_EQ(term52.epochTransferredToCCFAmount, 73000000ULL); + EXPECT_EQ(bonus, + term13.currentRewardPool + term26.currentRewardPool + term52.currentRewardPool + + term52.epochRewardedAmount + term52.epochTransferredToCCFAmount); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), contractBalanceBeforeFinalization - 73000000LL); + EXPECT_EQ(getBalance(CCF_CONTRACT_ID), ccfBalanceBeforeFinalization + 73000000LL); + EXPECT_EQ(qearn.getBurnedAndBoostedStatsPerEpoch(lockedEpoch).burnedAmount, 0ULL); + + // With the 52-epoch pool already at 18%, a forfeited short-term reward is surplus. + system.epoch = lockedEpoch + 1; + qearn.beginEpoch(); + const sint64 contractBalanceBeforeExit = getBalance(QEARN_CONTRACT_ID); + const sint64 ccfBalanceBeforeExit = getBalance(CCF_CONTRACT_ID); + EXPECT_EQ(qearn.unlockV2(user13, lockedEpoch, QEARN_V2_LOCK_PERIOD_13), QEARN_UNLOCK_SUCCESS); + + term13 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_13); + term52 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term13.epochForfeitedClaimAmount, 3000000ULL); + EXPECT_EQ(term52.currentRewardPool, 18000000ULL); + EXPECT_EQ(term52.epochTransferredTo52Amount, 0ULL); + EXPECT_EQ(term52.epochTransferredToCCFAmount, 76000000ULL); + EXPECT_EQ(bonus, + term13.currentRewardPool + term26.currentRewardPool + term52.currentRewardPool + + term52.epochRewardedAmount + term52.epochTransferredToCCFAmount); + EXPECT_EQ(qearn.getBurnedAndBoostedStatsPerEpoch(lockedEpoch).burnedAmount, 0ULL); + EXPECT_EQ(getBalance(user13), principal); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), + contractBalanceBeforeExit - static_cast(principal) - 3000000LL); + EXPECT_EQ(getBalance(CCF_CONTRACT_ID), ccfBalanceBeforeExit + 3000000LL); +} + +TEST(TestContractQearn, V2ShortOnlyCohortTransfersUnusedAndForfeitedRewardToCCF) +{ + ContractTestingQearn qearn; + const uint32 lockedEpoch = QEARN_V2_ACTIVATION_EPOCH; + const uint64 bonus = 100000000ULL; + const uint64 principal = 100000000ULL; + const uint64 shortRewardCap = 3000000ULL; + const id shortUser(5101, 13, 1, 1); + + system.epoch = lockedEpoch; + increaseEnergy(QEARN_CONTRACT_ID, bonus); + qearn.beginEpoch(); + increaseEnergy(shortUser, principal); + EXPECT_EQ(qearn.lockV2(shortUser, principal, QEARN_V2_LOCK_PERIOD_13), + QEARN_LOCK_SUCCESS); + const sint64 ccfBalanceBefore = getBalance(CCF_CONTRACT_ID); + qearn.endEpoch(); + + auto term13 = + qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_13); + auto term52 = + qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term13.currentRewardPool, shortRewardCap); + EXPECT_EQ(term52.initialLockedAmount, 0ULL); + EXPECT_EQ(term52.currentRewardPool, 0ULL); + EXPECT_EQ(term52.epochTransferredToCCFAmount, bonus - shortRewardCap); + EXPECT_EQ(getBalance(CCF_CONTRACT_ID), + ccfBalanceBefore + static_cast(bonus - shortRewardCap)); + EXPECT_EQ(bonus, + term13.currentRewardPool + term52.currentRewardPool + + term52.epochRewardedAmount + term52.epochTransferredToCCFAmount); + + system.epoch = lockedEpoch + 1; + qearn.beginEpoch(); + EXPECT_EQ(qearn.unlockV2(shortUser, lockedEpoch, QEARN_V2_LOCK_PERIOD_13), + QEARN_UNLOCK_SUCCESS); + + term13 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_13); + term52 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term13.currentRewardPool, 0ULL); + EXPECT_EQ(term13.termForfeitedClaimAmount, shortRewardCap); + EXPECT_EQ(term52.currentRewardPool, 0ULL); + EXPECT_EQ(term52.epochTransferredTo52Amount, 0ULL); + EXPECT_EQ(term52.epochTransferredToCCFAmount, bonus); + EXPECT_EQ(getBalance(shortUser), static_cast(principal)); + EXPECT_EQ(getBalance(CCF_CONTRACT_ID), + ccfBalanceBefore + static_cast(bonus)); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), 0); +} + +TEST(TestContractQearn, V2LongEarlyExitShrinksCapAndPreservesSurvivorReturn) +{ + ContractTestingQearn qearn; + const uint32 lockedEpoch = QEARN_V2_ACTIVATION_EPOCH; + const uint64 bonus = 100000000ULL; + const uint64 principal = 100000000ULL; + const uint64 initialPool = 36000000ULL; + const uint64 initialCCFTransfer = 64000000ULL; + const uint64 exitingClaim = 18000000ULL; + const uint64 survivorReward = 18000000ULL; + const id exitingUser(6001, 52, 1, 1); + const id survivingUser(6002, 52, 2, 2); + + system.epoch = lockedEpoch; + increaseEnergy(QEARN_CONTRACT_ID, bonus); + qearn.beginEpoch(); + increaseEnergy(exitingUser, principal); + increaseEnergy(survivingUser, principal); + EXPECT_EQ(qearn.lockV2(exitingUser, principal, QEARN_V2_LOCK_PERIOD_52), QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2(survivingUser, principal, QEARN_V2_LOCK_PERIOD_52), QEARN_LOCK_SUCCESS); + const sint64 ccfBalanceBeforeFinalization = getBalance(CCF_CONTRACT_ID); + qearn.endEpoch(); + + auto term52 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term52.initialLockedAmount, principal * 2); + EXPECT_EQ(term52.currentLockedAmount, principal * 2); + EXPECT_EQ(term52.initialRewardPool, initialPool); + EXPECT_EQ(term52.currentRewardPool, initialPool); + EXPECT_EQ(term52.currentTermReturn, 1800000ULL); + EXPECT_EQ(term52.epochTransferredToCCFAmount, initialCCFTransfer); + EXPECT_EQ(exitingClaim, initialPool * principal / (principal * 2)); + EXPECT_EQ(bonus, term52.currentRewardPool + term52.epochTransferredToCCFAmount); + EXPECT_EQ(getBalance(CCF_CONTRACT_ID), + ccfBalanceBeforeFinalization + static_cast(initialCCFTransfer)); + + system.epoch = lockedEpoch + 1; + qearn.beginEpoch(); + const sint64 exitingBalanceBefore = getBalance(exitingUser); + const sint64 contractBalanceBefore = getBalance(QEARN_CONTRACT_ID); + const sint64 ccfBalanceBeforeExit = getBalance(CCF_CONTRACT_ID); + EXPECT_EQ(qearn.unlockV2(exitingUser, lockedEpoch, QEARN_V2_LOCK_PERIOD_52), + QEARN_UNLOCK_SUCCESS); + + // The exit returns principal only. Its abandoned 18% claim stays in the + // long pool until the smaller survivor-only cap sends that surplus to CCF. + EXPECT_EQ(getBalance(exitingUser), exitingBalanceBefore + principal); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), + contractBalanceBefore - static_cast(principal + exitingClaim)); + EXPECT_EQ(getBalance(CCF_CONTRACT_ID), + ccfBalanceBeforeExit + static_cast(exitingClaim)); + const auto exitingStatus = qearn.getEndedStatus(exitingUser); + EXPECT_EQ(exitingStatus.earlyUnlockedAmount, principal); + EXPECT_EQ(exitingStatus.earlyRewardedAmount, 0ULL); + + term52 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term52.currentLockedAmount, principal); + EXPECT_EQ(term52.termEarlyUnlockedAmount, principal); + EXPECT_EQ(term52.initialRewardPool, initialPool); + EXPECT_EQ(term52.currentRewardPool, survivorReward); + EXPECT_EQ(term52.termForfeitedClaimAmount, exitingClaim); + EXPECT_EQ(term52.epochForfeitedClaimAmount, exitingClaim); + EXPECT_EQ(term52.epochTransferredTo52Amount, 0ULL); + EXPECT_EQ(term52.epochTransferredToCCFAmount, initialCCFTransfer + exitingClaim); + EXPECT_EQ(term52.currentTermReturn, 1800000ULL); + EXPECT_EQ(qearn.getV2UserLockedInfo( + lockedEpoch, survivingUser, QEARN_V2_LOCK_PERIOD_52).lockedAmount, principal); + EXPECT_EQ(bonus, + term52.currentRewardPool + term52.epochRewardedAmount + + term52.epochTransferredToCCFAmount); + EXPECT_EQ(qearn.getBurnedAndBoostedStatsPerEpoch(lockedEpoch).burnedAmount, 0ULL); + qearn.endEpoch(); + + for (system.epoch = lockedEpoch + 2; + system.epoch <= lockedEpoch + QEARN_V2_LOCK_PERIOD_52; + ++system.epoch) + { + qearn.beginEpoch(); + qearn.endEpoch(); + } + + EXPECT_EQ(getBalance(exitingUser), principal); + EXPECT_EQ(getBalance(survivingUser), principal + survivorReward); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), 0); + const auto survivingStatus = qearn.getEndedStatus(survivingUser); + EXPECT_EQ(survivingStatus.fullyUnlockedAmount, principal); + EXPECT_EQ(survivingStatus.fullyRewardedAmount, survivorReward); + + term52 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term52.currentLockedAmount, 0ULL); + EXPECT_EQ(term52.currentRewardPool, 0ULL); + EXPECT_EQ(term52.termEarlyUnlockedAmount, principal); + EXPECT_EQ(term52.termRewardedAmount, survivorReward); + EXPECT_EQ(term52.termForfeitedClaimAmount, exitingClaim); + EXPECT_EQ(term52.epochRewardedAmount, survivorReward); + EXPECT_EQ(term52.epochTransferredToCCFAmount, initialCCFTransfer + exitingClaim); + EXPECT_EQ(term52.currentTermReturn, 1800000ULL); + EXPECT_EQ(term52.state, 2U); + EXPECT_EQ(bonus, + term52.epochRewardedAmount + term52.epochTransferredToCCFAmount); +} + +TEST(TestContractQearn, V2FinalLongEarlyExitTransfersTheEntireRewardPoolToCCF) +{ + ContractTestingQearn qearn; + const uint32 lockedEpoch = QEARN_V2_ACTIVATION_EPOCH; + const uint64 bonus = 18000000ULL; + const uint64 principal = 100000000ULL; + const id longUser(6101, 52, 1, 1); + + system.epoch = lockedEpoch; + increaseEnergy(QEARN_CONTRACT_ID, bonus); + qearn.beginEpoch(); + increaseEnergy(longUser, principal); + EXPECT_EQ(qearn.lockV2(longUser, principal, QEARN_V2_LOCK_PERIOD_52), + QEARN_LOCK_SUCCESS); + const sint64 ccfBalanceBefore = getBalance(CCF_CONTRACT_ID); + qearn.endEpoch(); + + auto term52 = + qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term52.currentRewardPool, bonus); + EXPECT_EQ(term52.currentTermReturn, 1800000ULL); + EXPECT_EQ(term52.epochTransferredToCCFAmount, 0ULL); + + system.epoch = lockedEpoch + 1; + qearn.beginEpoch(); + EXPECT_EQ(qearn.unlockV2(longUser, lockedEpoch, QEARN_V2_LOCK_PERIOD_52), + QEARN_UNLOCK_SUCCESS); + + term52 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term52.currentLockedAmount, 0ULL); + EXPECT_EQ(term52.currentRewardPool, 0ULL); + EXPECT_EQ(term52.termEarlyUnlockedAmount, principal); + EXPECT_EQ(term52.termForfeitedClaimAmount, bonus); + EXPECT_EQ(term52.epochTransferredToCCFAmount, bonus); + EXPECT_EQ(getBalance(longUser), static_cast(principal)); + EXPECT_EQ(getBalance(CCF_CONTRACT_ID), + ccfBalanceBefore + static_cast(bonus)); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), 0); + EXPECT_EQ(bonus, + term52.epochRewardedAmount + term52.epochTransferredToCCFAmount); +} + +TEST(TestContractQearn, V2ShortFixedEntitlementsRouteRoundingDustTo52) +{ + ContractTestingQearn qearn; + const uint32 lockedEpoch = QEARN_V2_ACTIVATION_EPOCH; + const uint64 bonus = 101ULL; + const uint64 exitingPrincipal = 10000000ULL; + const uint64 survivingPrincipal = 20000000ULL; + const uint64 longPrincipal = 10000000ULL; + const uint64 shortPool = 10ULL; + const uint64 exitingClaim = 3ULL; + const uint64 survivorReward = 6ULL; + const uint64 roundingDust = 1ULL; + const uint64 initialLongPool = 91ULL; + const id exitingUser(7001, 13, 1, 1); + const id survivingUser(7002, 13, 2, 2); + const id longUser(7003, 52, 3, 3); + + system.epoch = lockedEpoch; + increaseEnergy(QEARN_CONTRACT_ID, bonus); + qearn.beginEpoch(); + increaseEnergy(exitingUser, exitingPrincipal); + increaseEnergy(survivingUser, survivingPrincipal); + increaseEnergy(longUser, longPrincipal); + EXPECT_EQ(qearn.lockV2( + exitingUser, exitingPrincipal, QEARN_V2_LOCK_PERIOD_13), QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2( + survivingUser, survivingPrincipal, QEARN_V2_LOCK_PERIOD_13), QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2( + longUser, longPrincipal, QEARN_V2_LOCK_PERIOD_52), QEARN_LOCK_SUCCESS); + qearn.endEpoch(); + + auto term13 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_13); + auto term52 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term13.initialLockedAmount, exitingPrincipal + survivingPrincipal); + EXPECT_EQ(term13.initialRewardPool, shortPool); + EXPECT_EQ(term13.currentRewardPool, shortPool); + EXPECT_EQ(term52.initialRewardPool, initialLongPool); + EXPECT_EQ(term52.currentRewardPool, initialLongPool); + EXPECT_EQ(exitingClaim, + shortPool * exitingPrincipal / (exitingPrincipal + survivingPrincipal)); + EXPECT_EQ(survivorReward, + shortPool * survivingPrincipal / (exitingPrincipal + survivingPrincipal)); + EXPECT_EQ(roundingDust, shortPool - exitingClaim - survivorReward); + EXPECT_EQ(bonus, term13.currentRewardPool + term52.currentRewardPool); + + system.epoch = lockedEpoch + 1; + qearn.beginEpoch(); + const sint64 contractBalanceBefore = getBalance(QEARN_CONTRACT_ID); + EXPECT_EQ(qearn.unlockV2(exitingUser, lockedEpoch, QEARN_V2_LOCK_PERIOD_13), + QEARN_UNLOCK_SUCCESS); + EXPECT_EQ(getBalance(exitingUser), exitingPrincipal); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), + contractBalanceBefore - static_cast(exitingPrincipal)); + + term13 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_13); + term52 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term13.currentLockedAmount, survivingPrincipal); + EXPECT_EQ(term13.termEarlyUnlockedAmount, exitingPrincipal); + EXPECT_EQ(term13.currentRewardPool, shortPool - exitingClaim); + EXPECT_EQ(term13.termForfeitedClaimAmount, exitingClaim); + EXPECT_EQ(term13.epochForfeitedClaimAmount, exitingClaim); + EXPECT_EQ(term52.currentRewardPool, initialLongPool + exitingClaim); + EXPECT_EQ(term52.epochTransferredTo52Amount, exitingClaim); + EXPECT_EQ(term52.epochTransferredToCCFAmount, 0ULL); + EXPECT_EQ(bonus, term13.currentRewardPool + term52.currentRewardPool); + qearn.endEpoch(); + + for (system.epoch = lockedEpoch + 2; + system.epoch <= lockedEpoch + QEARN_V2_LOCK_PERIOD_13; + ++system.epoch) + { + qearn.beginEpoch(); + qearn.endEpoch(); + } + + // Entitlements always use the original pool and original total principal: + // floor(10 * 10M / 30M) = 3 and floor(10 * 20M / 30M) = 6. + // The unclaimable final qu is routed to this cohort's 52-epoch pool. + EXPECT_EQ(shortPool, exitingClaim + survivorReward + roundingDust); + EXPECT_EQ(getBalance(exitingUser), exitingPrincipal); + EXPECT_EQ(getBalance(survivingUser), survivingPrincipal + survivorReward); + EXPECT_EQ(getBalance(longUser), 0); + const auto survivingStatus = qearn.getEndedStatus(survivingUser); + EXPECT_EQ(survivingStatus.fullyUnlockedAmount, survivingPrincipal); + EXPECT_EQ(survivingStatus.fullyRewardedAmount, survivorReward); + + term13 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_13); + term52 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term13.currentLockedAmount, 0ULL); + EXPECT_EQ(term13.currentRewardPool, 0ULL); + EXPECT_EQ(term13.termEarlyUnlockedAmount, exitingPrincipal); + EXPECT_EQ(term13.termRewardedAmount, survivorReward); + EXPECT_EQ(term13.termForfeitedClaimAmount, exitingClaim); + EXPECT_EQ(term13.epochRewardedAmount, survivorReward); + EXPECT_EQ(term52.currentRewardPool, initialLongPool + exitingClaim + roundingDust); + EXPECT_EQ(term52.epochTransferredTo52Amount, exitingClaim + roundingDust); + EXPECT_EQ(term52.epochTransferredToCCFAmount, 0ULL); + EXPECT_EQ(qearn.getBurnedAndBoostedStatsPerEpoch(lockedEpoch).boostedAmount, + exitingClaim + roundingDust); + EXPECT_EQ(bonus, + term13.currentRewardPool + term52.currentRewardPool + + term52.epochRewardedAmount + term52.epochTransferredToCCFAmount); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), + static_cast(longPrincipal + term52.currentRewardPool)); +} + +TEST(TestContractQearn, V2TransfersLongMaturityRoundingRemainderToCCF) +{ + ContractTestingQearn qearn; + const uint32 lockedEpoch = QEARN_V2_ACTIVATION_EPOCH; + const uint64 bonus = 101ULL; + const uint64 firstPrincipal = 10000000ULL; + const uint64 secondPrincipal = 20000000ULL; + const uint64 firstReward = 33ULL; + const uint64 secondReward = 67ULL; + const uint64 roundingRemainder = 1ULL; + const id firstUser(7101, 52, 1, 1); + const id secondUser(7102, 52, 2, 2); + + system.epoch = lockedEpoch; + increaseEnergy(QEARN_CONTRACT_ID, bonus); + qearn.beginEpoch(); + increaseEnergy(firstUser, firstPrincipal); + increaseEnergy(secondUser, secondPrincipal); + EXPECT_EQ(qearn.lockV2( + firstUser, firstPrincipal, QEARN_V2_LOCK_PERIOD_52), QEARN_LOCK_SUCCESS); + EXPECT_EQ(qearn.lockV2( + secondUser, secondPrincipal, QEARN_V2_LOCK_PERIOD_52), QEARN_LOCK_SUCCESS); + const sint64 ccfBalanceBefore = getBalance(CCF_CONTRACT_ID); + qearn.endEpoch(); + + auto term52 = + qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term52.initialRewardPool, bonus); + EXPECT_EQ(term52.epochTransferredToCCFAmount, 0ULL); + + for (system.epoch = lockedEpoch + 1; + system.epoch <= lockedEpoch + QEARN_V2_LOCK_PERIOD_52; + ++system.epoch) + { + qearn.beginEpoch(); + qearn.endEpoch(); + } + + EXPECT_EQ(getBalance(firstUser), firstPrincipal + firstReward); + EXPECT_EQ(getBalance(secondUser), secondPrincipal + secondReward); + EXPECT_EQ(getBalance(CCF_CONTRACT_ID), + ccfBalanceBefore + static_cast(roundingRemainder)); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), 0); + + term52 = qearn.getV2LockInfoPerEpoch(lockedEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(term52.currentRewardPool, 0ULL); + EXPECT_EQ(term52.termRewardedAmount, firstReward + secondReward); + EXPECT_EQ(term52.epochRewardedAmount, firstReward + secondReward); + EXPECT_EQ(term52.epochTransferredToCCFAmount, roundingRemainder); + EXPECT_EQ(bonus, + term52.epochRewardedAmount + term52.epochTransferredToCCFAmount); + EXPECT_EQ(qearn.getBurnedAndBoostedStatsPerEpoch(lockedEpoch).burnedAmount, 0ULL); +} + +TEST(TestContractQearn, V2RandomizedMixedTermsPreserveAccountingThroughExitsAndMaturities) +{ + struct PropertyPosition + { + id user; + uint32 termIndex; + uint32 lockPeriod; + uint32 exitEpochOffset; + uint64 principal; + bool active; + }; + + struct PropertyTermModel + { + uint64 initialLockedAmount; + uint64 currentLockedAmount; + uint64 earlyUnlockedAmount; + uint64 initialRewardPool; + uint64 currentRewardPool; + uint64 rewardedAmount; + uint64 forfeitedClaimAmount; + }; + + struct PropertyEpochModel + { + PropertyTermModel terms[QEARN_V2_NUMBER_OF_TERMS]; + uint64 rewardedAmount; + uint64 forfeitedClaimAmount; + uint64 transferredTo52Amount; + uint64 transferredToCCFAmount; + }; + + ContractTestingQearn qearn; + const uint32 lockedEpoch = QEARN_V2_ACTIVATION_EPOCH; + const uint64 bonus = 250000123ULL; + const uint32 walletCount = 18; + const uint32 lockPeriods[QEARN_V2_NUMBER_OF_TERMS] = { + QEARN_V2_LOCK_PERIOD_13, + QEARN_V2_LOCK_PERIOD_26, + QEARN_V2_LOCK_PERIOD_52, + }; + const uint32 exitOffsets[QEARN_V2_NUMBER_OF_TERMS][6] = { + {1, 2, 4, 7, 10, 12}, + {1, 3, 8, 13, 19, 25}, + {1, 5, 12, 20, 33, 51}, + }; + const uint64 seed = 0x514541524E5632ULL; + std::mt19937_64 rng(seed); + std::vector positions; + std::map expectedWalletBalances; + PropertyEpochModel model = {}; + SCOPED_TRACE(::testing::Message() << "seed=" << seed); + + // Every wallet owns all three independent terms. The first three wallets + // remain through maturity; the others create deterministic, seeded exits. + // All long positions except those first three exit, which guarantees both + // dynamic-cap CCF routing and live long-term maturity recipients. + for (uint32 walletIndex = 0; walletIndex < walletCount; ++walletIndex) + { + const id user( + 8000 + walletIndex, + 0xA000 + walletIndex, + 0xB000 + walletIndex, + 0xC000 + walletIndex); + for (uint32 termIndex = 0; + termIndex < QEARN_V2_NUMBER_OF_TERMS; + ++termIndex) + { + const uint64 principal = 80000001ULL + (rng() % 80000000ULL); + uint32 exitEpochOffset = 0; + if (walletIndex >= 3) + { + bool exitsEarly = false; + if (termIndex == QEARN_V2_TERM_INDEX_13) + { + exitsEarly = walletIndex == 3 || (rng() % 100ULL) < 45ULL; + } + else if (termIndex == QEARN_V2_TERM_INDEX_26) + { + exitsEarly = walletIndex == 3 || (rng() % 100ULL) < 50ULL; + } + else + { + exitsEarly = true; + } + + if (exitsEarly) + { + exitEpochOffset = + exitOffsets[termIndex][rng() % 6ULL]; + if (walletIndex == 3) + { + // Force mixed-term exits in one epoch. Their order is + // shuffled below, exercising order-dependent counters. + exitEpochOffset = 1; + } + } + } + + positions.push_back(PropertyPosition{ + user, + termIndex, + lockPeriods[termIndex], + exitEpochOffset, + principal, + true, + }); + model.terms[termIndex].initialLockedAmount += principal; + model.terms[termIndex].currentLockedAmount += principal; + expectedWalletBalances[user] += principal; + } + } + + system.epoch = lockedEpoch; + increaseEnergy(QEARN_CONTRACT_ID, bonus); + qearn.beginEpoch(); + for (const auto& wallet : expectedWalletBalances) + { + increaseEnergy(wallet.first, wallet.second); + } + for (const auto& position : positions) + { + EXPECT_EQ(qearn.lockV2( + position.user, + position.principal, + position.lockPeriod), + QEARN_LOCK_SUCCESS); + } + for (auto& wallet : expectedWalletBalances) + { + EXPECT_EQ(getBalance(wallet.first), 0); + wallet.second = 0; + } + + const sint64 initialCCFBalance = getBalance(CCF_CONTRACT_ID); + model.terms[QEARN_V2_TERM_INDEX_13].initialRewardPool = + std::min( + bonus * QEARN_V2_ALLOCATION_PERCENT_13 / 100ULL, + model.terms[QEARN_V2_TERM_INDEX_13].initialLockedAmount + * QEARN_V2_RETURN_PERCENT_13 / 100ULL); + model.terms[QEARN_V2_TERM_INDEX_13].currentRewardPool = + model.terms[QEARN_V2_TERM_INDEX_13].initialRewardPool; + model.terms[QEARN_V2_TERM_INDEX_26].initialRewardPool = + std::min( + bonus * QEARN_V2_ALLOCATION_PERCENT_26 / 100ULL, + model.terms[QEARN_V2_TERM_INDEX_26].initialLockedAmount + * QEARN_V2_RETURN_PERCENT_26 / 100ULL); + model.terms[QEARN_V2_TERM_INDEX_26].currentRewardPool = + model.terms[QEARN_V2_TERM_INDEX_26].initialRewardPool; + + const uint64 availableLongReward = + bonus + - model.terms[QEARN_V2_TERM_INDEX_13].initialRewardPool + - model.terms[QEARN_V2_TERM_INDEX_26].initialRewardPool; + const uint64 initialLongCap = + model.terms[QEARN_V2_TERM_INDEX_52].initialLockedAmount + * QEARN_V2_RETURN_PERCENT_52 / 100ULL; + model.terms[QEARN_V2_TERM_INDEX_52].initialRewardPool = + std::min(availableLongReward, initialLongCap); + model.terms[QEARN_V2_TERM_INDEX_52].currentRewardPool = + model.terms[QEARN_V2_TERM_INDEX_52].initialRewardPool; + model.transferredToCCFAmount = + availableLongReward + - model.terms[QEARN_V2_TERM_INDEX_52].initialRewardPool; + + auto fundLongPool = [&](uint64 amount) + { + PropertyTermModel& longTerm = + model.terms[QEARN_V2_TERM_INDEX_52]; + const uint64 previousPool = longTerm.currentRewardPool; + const uint64 availableAmount = previousPool + amount; + const uint64 currentCap = + longTerm.currentLockedAmount + * QEARN_V2_RETURN_PERCENT_52 / 100ULL; + longTerm.currentRewardPool = + std::min(availableAmount, currentCap); + if (longTerm.currentRewardPool > previousPool) + { + model.transferredTo52Amount += + longTerm.currentRewardPool - previousPool; + } + model.transferredToCCFAmount += + availableAmount - longTerm.currentRewardPool; + }; + + auto checkAccounting = [&]() + { + uint64 activePrincipal = 0; + uint64 currentRewardPools = 0; + for (const auto& position : positions) + { + if (position.active) + { + activePrincipal += position.principal; + } + } + + for (uint32 termIndex = 0; + termIndex < QEARN_V2_NUMBER_OF_TERMS; + ++termIndex) + { + const PropertyTermModel& expectedTerm = model.terms[termIndex]; + const auto actualTerm = qearn.getV2LockInfoPerEpoch( + lockedEpoch, + lockPeriods[termIndex]); + EXPECT_EQ(actualTerm.returnCode, QEARN_LOCK_SUCCESS); + EXPECT_EQ(actualTerm.epochBonusAmount, bonus); + EXPECT_EQ(actualTerm.finalized, 1U); + EXPECT_EQ(actualTerm.initialLockedAmount, + expectedTerm.initialLockedAmount); + EXPECT_EQ(actualTerm.currentLockedAmount, + expectedTerm.currentLockedAmount); + EXPECT_EQ(actualTerm.termEarlyUnlockedAmount, + expectedTerm.earlyUnlockedAmount); + EXPECT_EQ(actualTerm.initialRewardPool, + expectedTerm.initialRewardPool); + EXPECT_EQ(actualTerm.currentRewardPool, + expectedTerm.currentRewardPool); + EXPECT_EQ(actualTerm.termRewardedAmount, + expectedTerm.rewardedAmount); + EXPECT_EQ(actualTerm.termForfeitedClaimAmount, + expectedTerm.forfeitedClaimAmount); + EXPECT_EQ(actualTerm.epochRewardedAmount, + model.rewardedAmount); + EXPECT_EQ(actualTerm.epochForfeitedClaimAmount, + model.forfeitedClaimAmount); + EXPECT_EQ(actualTerm.epochTransferredTo52Amount, + model.transferredTo52Amount); + EXPECT_EQ(actualTerm.epochTransferredToCCFAmount, + model.transferredToCCFAmount); + currentRewardPools += expectedTerm.currentRewardPool; + } + + EXPECT_EQ(bonus, + currentRewardPools + + model.rewardedAmount + + model.transferredToCCFAmount); + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), + static_cast(activePrincipal + currentRewardPools)); + EXPECT_EQ(getBalance(CCF_CONTRACT_ID), + initialCCFBalance + + static_cast(model.transferredToCCFAmount)); + + const auto stats = + qearn.getBurnedAndBoostedStatsPerEpoch(lockedEpoch); + EXPECT_EQ(stats.burnedAmount, 0ULL); + EXPECT_EQ(stats.boostedAmount, model.transferredTo52Amount); + EXPECT_EQ(stats.rewardedAmount, model.rewardedAmount); + for (const auto& wallet : expectedWalletBalances) + { + EXPECT_EQ(getBalance(wallet.first), + static_cast(wallet.second)); + } + }; + + auto checkCompactedLockerRange = [&]() + { + uint32 expectedActivePositions = 0; + for (const auto& position : positions) + { + const uint64 expectedLockedAmount = + position.active ? position.principal : 0ULL; + EXPECT_EQ(qearn.getV2UserLockedInfo( + lockedEpoch, + position.user, + position.lockPeriod).lockedAmount, + expectedLockedAmount); + if (position.active) + { + expectedActivePositions++; + } + } + + const QearnChecker* contractState = qearn.getState(); + const QEARN::EpochIndexInfo range = + contractState->_epochIndex.get(lockedEpoch); + EXPECT_EQ(range.endIndex - range.startIndex, + expectedActivePositions); + for (uint32 lockerIndex = range.startIndex; + lockerIndex < range.endIndex; + ++lockerIndex) + { + const QEARN::LockInfo& actualLock = + contractState->locker.get(lockerIndex); + const uint32 actualLockPeriod = + contractState->lockerLockPeriods.get(lockerIndex); + EXPECT_GT(actualLock._lockedAmount, 0ULL); + EXPECT_EQ(actualLock._lockedEpoch, lockedEpoch); + + uint32 matchingPositions = 0; + for (const auto& position : positions) + { + if (position.active + && position.user == actualLock.ID + && position.lockPeriod == actualLockPeriod + && position.principal == actualLock._lockedAmount) + { + matchingPositions++; + } + } + EXPECT_EQ(matchingPositions, 1U); + } + }; + + auto exitPosition = [&](size_t positionIndex) + { + PropertyPosition& position = positions[positionIndex]; + PropertyTermModel& term = model.terms[position.termIndex]; + SCOPED_TRACE( + ::testing::Message() + << "exit position=" << positionIndex + << ", term=" << position.lockPeriod + << ", epochOffset=" << position.exitEpochOffset); + EXPECT_TRUE(position.active); + + uint64 forfeitedAmount = 0; + uint64 amountToLongPool = 0; + if (position.termIndex == QEARN_V2_TERM_INDEX_52) + { + if (term.currentLockedAmount) + { + forfeitedAmount = + term.currentRewardPool * position.principal + / term.currentLockedAmount; + } + } + else if (term.initialLockedAmount) + { + forfeitedAmount = + term.initialRewardPool * position.principal + / term.initialLockedAmount; + forfeitedAmount = + std::min(forfeitedAmount, term.currentRewardPool); + amountToLongPool = forfeitedAmount; + } + + EXPECT_EQ(qearn.unlockV2( + position.user, + lockedEpoch, + position.lockPeriod), + QEARN_UNLOCK_SUCCESS); + + if (position.termIndex != QEARN_V2_TERM_INDEX_52) + { + term.currentRewardPool -= forfeitedAmount; + } + term.currentLockedAmount -= position.principal; + term.earlyUnlockedAmount += position.principal; + term.forfeitedClaimAmount += forfeitedAmount; + model.forfeitedClaimAmount += forfeitedAmount; + if (position.termIndex != QEARN_V2_TERM_INDEX_52 + && !term.currentLockedAmount) + { + amountToLongPool += term.currentRewardPool; + term.currentRewardPool = 0; + } + position.active = false; + expectedWalletBalances[position.user] += position.principal; + fundLongPool(amountToLongPool); + checkAccounting(); + }; + + auto matureTerm = [&](uint32 termIndex) + { + PropertyTermModel& term = model.terms[termIndex]; + const uint64 numerator = + termIndex == QEARN_V2_TERM_INDEX_52 + ? term.currentRewardPool + : term.initialRewardPool; + const uint64 denominator = + termIndex == QEARN_V2_TERM_INDEX_52 + ? term.currentLockedAmount + : term.initialLockedAmount; + uint64 remainingRewardPool = term.currentRewardPool; + uint64 totalRewardedAmount = 0; + uint64 totalUnlockedAmount = 0; + + // Stable compaction preserves lock order, matching the contract's + // deterministic floor-and-remainder settlement. + for (auto& position : positions) + { + if (!position.active || position.termIndex != termIndex) + { + continue; + } + + uint64 rewardAmount = 0; + if (denominator) + { + rewardAmount = + numerator * position.principal / denominator; + rewardAmount = + std::min(rewardAmount, remainingRewardPool); + } + remainingRewardPool -= rewardAmount; + totalRewardedAmount += rewardAmount; + totalUnlockedAmount += position.principal; + expectedWalletBalances[position.user] += + position.principal + rewardAmount; + position.active = false; + } + + EXPECT_EQ(term.currentLockedAmount, totalUnlockedAmount); + term.currentLockedAmount -= totalUnlockedAmount; + term.currentRewardPool = 0; + term.rewardedAmount += totalRewardedAmount; + model.rewardedAmount += totalRewardedAmount; + if (termIndex == QEARN_V2_TERM_INDEX_52) + { + model.transferredToCCFAmount += remainingRewardPool; + } + else + { + fundLongPool(remainingRewardPool); + } + }; + + qearn.endEpoch(); + checkAccounting(); + checkCompactedLockerRange(); + + for (uint32 epochOffset = 1; + epochOffset <= QEARN_V2_LOCK_PERIOD_52; + ++epochOffset) + { + system.epoch = lockedEpoch + epochOffset; + SCOPED_TRACE( + ::testing::Message() + << "seed=" << seed + << ", epochOffset=" << epochOffset); + qearn.beginEpoch(); + + std::vector scheduledExits; + for (size_t positionIndex = 0; + positionIndex < positions.size(); + ++positionIndex) + { + if (positions[positionIndex].active + && positions[positionIndex].exitEpochOffset == epochOffset) + { + scheduledExits.push_back(positionIndex); + } + } + std::shuffle(scheduledExits.begin(), scheduledExits.end(), rng); + for (const size_t positionIndex : scheduledExits) + { + exitPosition(positionIndex); + } + + if (epochOffset == QEARN_V2_LOCK_PERIOD_13) + { + matureTerm(QEARN_V2_TERM_INDEX_13); + } + if (epochOffset == QEARN_V2_LOCK_PERIOD_26) + { + matureTerm(QEARN_V2_TERM_INDEX_26); + } + if (epochOffset == QEARN_V2_LOCK_PERIOD_52) + { + matureTerm(QEARN_V2_TERM_INDEX_52); + } + + qearn.endEpoch(); + checkAccounting(); + checkCompactedLockerRange(); + } + + EXPECT_GT(model.transferredTo52Amount, 0ULL); + EXPECT_GT(model.transferredToCCFAmount, 0ULL); + EXPECT_GT(model.rewardedAmount, 0ULL); + for (uint32 termIndex = 0; + termIndex < QEARN_V2_NUMBER_OF_TERMS; + ++termIndex) + { + EXPECT_GT(model.terms[termIndex].earlyUnlockedAmount, 0ULL); + } + EXPECT_EQ(getBalance(QEARN_CONTRACT_ID), 0); +} + // Test case for gap removal logic in overflow check (lines 635-656 in Qearn.h) // This test verifies that when the locker array is near capacity and contains gaps, // attempting to lock triggers gap removal, allowing the lock to succeed. @@ -885,11 +2440,15 @@ TEST(TestContractQearn, GapRemovalOnOverflow) void testRandomLockWithoutUnlock(const uint16 numEpochs, const unsigned int totalUsers, const unsigned int maxUserLocking) { - std::cout << "random test without early unlock for " << numEpochs << " epochs with " << totalUsers << " total users and up to " << maxUserLocking << " lock calls per epoch" << std::endl; ContractTestingQearn qearn; const uint16 firstEpoch = contractDescriptions[QEARN_CONTRACT_INDEX].constructionEpoch; - const uint16 lastEpoch = firstEpoch + numEpochs; + // This model checks the original 52-epoch economics. V2 behavior is covered by + // focused tests above, so do not carry the V1 model across the activation boundary. + const uint16 v1NumEpochs = std::min( + numEpochs, QEARN_V2_ACTIVATION_EPOCH - firstEpoch - 1); + const uint16 lastEpoch = firstEpoch + v1NumEpochs; + std::cout << "random V1 test without early unlock for " << v1NumEpochs << " epochs with " << totalUsers << " total users and up to " << maxUserLocking << " lock calls per epoch" << std::endl; // first epoch is without donation/bonus for (system.epoch = firstEpoch; system.epoch <= lastEpoch; ++system.epoch) @@ -934,11 +2493,15 @@ TEST(TestContractQearn, RandomLockWithoutUnlock) void testRandomLockWithUnlock(const uint16 numEpochs, const unsigned int totalUsers, const unsigned int maxUserLocking, const unsigned int maxUserUnlocking) { - std::cout << "random test with early unlock for " << numEpochs << " epochs with " << totalUsers << " total users, up to " << maxUserLocking << " lock calls (per epoch), and up to " << maxUserUnlocking << " unlock calls (per running round)" << std::endl; ContractTestingQearn qearn; const uint16 firstEpoch = contractDescriptions[QEARN_CONTRACT_INDEX].constructionEpoch; - const uint16 lastEpoch = firstEpoch + numEpochs; + // This model checks V1 partial unlock rewards and burns. Keep it entirely + // before activation because V2 early unlocks intentionally close full positions. + const uint16 v1NumEpochs = std::min( + numEpochs, QEARN_V2_ACTIVATION_EPOCH - firstEpoch - 1); + const uint16 lastEpoch = firstEpoch + v1NumEpochs; + std::cout << "random V1 test with early unlock for " << v1NumEpochs << " epochs with " << totalUsers << " total users, up to " << maxUserLocking << " lock calls (per epoch), and up to " << maxUserUnlocking << " unlock calls (per running round)" << std::endl; for (system.epoch = firstEpoch; system.epoch <= lastEpoch; ++system.epoch) { diff --git a/test/contract_qvault.cpp b/test/contract_qvault.cpp index 636c5b08..b7cc512e 100644 --- a/test/contract_qvault.cpp +++ b/test/contract_qvault.cpp @@ -98,6 +98,19 @@ class QVAULTChecker : public QVAULT, public QVAULT::StateData EXPECT_EQ(QEarnP.get(index).numberOfEpoch, numberOfEpoch); } + void seedPassedQEarnProposal(uint32 proposedEpoch, uint64 amountPerEpoch) + { + QEarnPInfo proposal; + setMemory(proposal, 0); + proposal.amountOfInvestPerEpoch = amountPerEpoch; + proposal.assignedFundPerEpoch = amountPerEpoch; + proposal.proposedEpoch = proposedEpoch; + proposal.numberOfEpoch = 1; + proposal.result = QVAULT_PROPOSAL_PASSED; + QEarnP.set(0, proposal); + numberOfQEarnP = 1; + } + void submitFundPChecker(uint32 index, id proposer, uint32 amountOfQcap, uint64 pricePerOneQcap) { EXPECT_EQ(FundP.get(index).currentQuorumPercent, 670); @@ -213,7 +226,7 @@ class QXChecker : public QX, public QX::StateData class ContractTestingQvault : protected ContractTesting { public: - ContractTestingQvault() + explicit ContractTestingQvault(bool initializeQearn = false) { initEmptySpectrum(); initEmptyUniverse(); @@ -221,6 +234,11 @@ class ContractTestingQvault : protected ContractTesting callSystemProcedure(QVAULT_CONTRACT_INDEX, INITIALIZE); INIT_CONTRACT(QX); callSystemProcedure(QX_CONTRACT_INDEX, INITIALIZE); + if (initializeQearn) + { + INIT_CONTRACT(QEARN); + callSystemProcedure(QEARN_CONTRACT_INDEX, INITIALIZE); + } } QVAULTChecker* getState() @@ -243,6 +261,40 @@ class ContractTestingQvault : protected ContractTesting callSystemProcedure(QVAULT_CONTRACT_INDEX, BEGIN_EPOCH, expectSuccess); } + void qearnBeginEpoch(bool expectSuccess = true) + { + callSystemProcedure(QEARN_CONTRACT_INDEX, BEGIN_EPOCH, expectSuccess); + } + + void qearnEndEpoch(bool expectSuccess = true) + { + callSystemProcedure(QEARN_CONTRACT_INDEX, END_EPOCH, expectSuccess); + } + + QEARN::getLockInfoPerEpoch_output getQearnLockInfoPerEpoch(uint32 epoch) const + { + QEARN::getLockInfoPerEpoch_input input{ epoch }; + QEARN::getLockInfoPerEpoch_output output; + callFunction(QEARN_CONTRACT_INDEX, 1, input, output); + return output; + } + + QEARN::getV2LockInfoPerEpoch_output getQearnV2LockInfoPerEpoch(uint32 epoch, uint32 lockPeriod) const + { + QEARN::getV2LockInfoPerEpoch_input input{ epoch, lockPeriod }; + QEARN::getV2LockInfoPerEpoch_output output; + callFunction(QEARN_CONTRACT_INDEX, 9, input, output); + return output; + } + + QEARN::getV2UserLockedInfo_output getQearnV2UserLockedInfo(const id& user, uint32 epoch, uint32 lockPeriod) const + { + QEARN::getV2UserLockedInfo_input input{ user, epoch, lockPeriod }; + QEARN::getV2UserLockedInfo_output output; + callFunction(QEARN_CONTRACT_INDEX, 10, input, output); + return output; + } + void qxEndTick(bool expectSuccess = true) { callSystemProcedure(QX_CONTRACT_INDEX, END_TICK, expectSuccess); @@ -935,4 +987,78 @@ TEST(TestContractQvault, testingAllProceduresAndFunctions) uint64 tmpAmount = numberOfPossessedShares(qcapAssetName, QVAULT_QCAP_ISSUER, stakers[3], stakers[3], QVAULT_CONTRACT_INDEX, QVAULT_CONTRACT_INDEX); QvaultV2.buyQcap(stakers[3], 100, 10000000); EXPECT_EQ(numberOfPossessedShares(qcapAssetName, QVAULT_QCAP_ISSUER, stakers[3], stakers[3], QVAULT_CONTRACT_INDEX, QVAULT_CONTRACT_INDEX), tmpAmount + 100); -} \ No newline at end of file +} + +TEST(TestContractQvault, LegacyQEarnInterfaceUsesV2LongTermAfterActivation) +{ + constexpr uint16 lockEpoch = static_cast(QEARN_V2_ACTIVATION_EPOCH); + constexpr uint32 proposalEpoch = lockEpoch - 2; + constexpr sint64 principal = 100000000LL; + constexpr sint64 reward = + principal * static_cast(QEARN_V2_RETURN_PERCENT_52) / 100; + constexpr uint64 termReturn = QEARN_V2_RETURN_PERCENT_52 * 100000ULL; + const id qearnContractId(QEARN_CONTRACT_INDEX, 0, 0, 0); + + system.epoch = lockEpoch; + ContractTestingQvault qvault(true); + qvault.getState()->seedPassedQEarnProposal( + proposalEpoch, static_cast(principal)); + + increaseEnergy(qearnContractId, reward); + increaseEnergy(QVAULT_CONTRACT_ID, principal); + qvault.qearnBeginEpoch(); + qvault.beginEpoch(); + qvault.qearnEndEpoch(); + + // QVault still invokes QEarn procedure 1. At epoch 227 it must create the + // V2 52-epoch position reported by the unchanged legacy function 1. + const auto legacyInfo = qvault.getQearnLockInfoPerEpoch(lockEpoch); + EXPECT_EQ(legacyInfo.lockedAmount, principal); + EXPECT_EQ(legacyInfo.currentLockedAmount, principal); + EXPECT_EQ(legacyInfo.bonusAmount, reward); + EXPECT_EQ(legacyInfo.currentBonusAmount, reward); + EXPECT_EQ(legacyInfo.yield, termReturn); + + const auto v2Info = qvault.getQearnV2LockInfoPerEpoch( + lockEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(v2Info.returnCode, QEARN_LOCK_SUCCESS); + EXPECT_EQ(v2Info.initialLockedAmount, principal); + EXPECT_EQ(v2Info.currentLockedAmount, principal); + EXPECT_EQ(v2Info.initialRewardPool, reward); + EXPECT_EQ(v2Info.currentRewardPool, reward); + EXPECT_EQ(v2Info.currentTermReturn, termReturn); + EXPECT_EQ(v2Info.maturityEpoch, lockEpoch + QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(v2Info.finalized, 1); + + const auto v2UserInfo = qvault.getQearnV2UserLockedInfo( + QVAULT_CONTRACT_ID, lockEpoch, QEARN_V2_LOCK_PERIOD_52); + EXPECT_EQ(v2UserInfo.returnCode, QEARN_LOCK_SUCCESS); + EXPECT_EQ(v2UserInfo.lockedAmount, principal); + EXPECT_EQ(v2UserInfo.maturityEpoch, lockEpoch + QEARN_V2_LOCK_PERIOD_52); + + for (system.epoch = lockEpoch + 1; + system.epoch < lockEpoch + QEARN_V2_LOCK_PERIOD_52; + system.epoch++) + { + qvault.qearnBeginEpoch(); + qvault.qearnEndEpoch(); + } + + auto qvaultData = qvault.getData(); + EXPECT_EQ(qvaultData.reinvestingFund, 0); + EXPECT_EQ(qvaultData.totalEpochRevenue, 0); + EXPECT_EQ(qvaultData.revenueByQearn, 0); + EXPECT_EQ(getBalance(QVAULT_CONTRACT_ID), 0); + EXPECT_EQ(getBalance(qearnContractId), principal + reward); + + // The QEarn transfer and QVault callback both occur at END_EPOCH 279. + qvault.qearnBeginEpoch(); + qvault.qearnEndEpoch(); + + qvaultData = qvault.getData(); + EXPECT_EQ(qvaultData.reinvestingFund, principal); + EXPECT_EQ(qvaultData.totalEpochRevenue, reward); + EXPECT_EQ(qvaultData.revenueByQearn, reward); + EXPECT_EQ(getBalance(QVAULT_CONTRACT_ID), principal + reward); + EXPECT_EQ(getBalance(qearnContractId), 0); +}