diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index cf19016c..f17a0777 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -24,7 +24,7 @@ import org.qortal.account.PublicKeyAccount; import org.qortal.asset.Asset; import org.qortal.at.AT; import org.qortal.block.BlockChain.BlockTimingByHeight; -import org.qortal.block.BlockChain.ShareByLevel; +import org.qortal.block.BlockChain.AccountLevelShareBin; import org.qortal.controller.Controller; import org.qortal.crypto.Crypto; import org.qortal.data.account.AccountBalanceData; @@ -126,8 +126,13 @@ public class Block { /** Locally-generated AT fees */ protected long ourAtFees; // Generated locally + @FunctionalInterface + private interface BlockRewardDistributor { + long distribute(long amount, Map balanceChanges) throws DataException; + } + /** Lazy-instantiated expanded info on block's online accounts. */ - static class ExpandedAccount { + private static class ExpandedAccount { private final RewardShareData rewardShareData; private final int sharePercent; private final boolean isRecipientAlsoMinter; @@ -166,28 +171,29 @@ public class Block { * This is a method, not a final variable, because account's level can change between construction and call, * e.g. during Block.process() where account levels are bumped right before Block.distributeBlockReward(). * - * @return share "bin" (index into BlockShareByLevel blockchain config, so 0+), or -1 if no bin found + * @return account-level share "bin" from blockchain config, or null if founder / none found */ - int getShareBin() { + public AccountLevelShareBin getShareBin() { if (this.isMinterFounder) - return -1; + return null; - final List sharesByLevel = BlockChain.getInstance().getBlockSharesByLevel(); final int accountLevel = this.mintingAccountData.getLevel(); + if (accountLevel <= 0) + return null; - for (int s = 0; s < sharesByLevel.size(); ++s) - if (sharesByLevel.get(s).levels.contains(accountLevel)) - return s; + final AccountLevelShareBin[] shareBinsByLevel = BlockChain.getInstance().getShareBinsByAccountLevel(); + if (accountLevel > shareBinsByLevel.length) + return null; - return -1; + return shareBinsByLevel[accountLevel]; } - void distribute(long accountAmount) throws DataException { + public long distribute(long accountAmount, Map balanceChanges) { if (this.isRecipientAlsoMinter) { // minter & recipient the same - simpler case LOGGER.trace(() -> String.format("Minter/recipient account %s share: %s", this.mintingAccount.getAddress(), Amounts.prettyAmount(accountAmount))); if (accountAmount != 0) - this.mintingAccount.modifyAssetBalance(Asset.QORT, accountAmount); + balanceChanges.merge(this.mintingAccount, accountAmount, Long::sum); } else { // minter & recipient different - extra work needed long recipientAmount = (accountAmount * this.sharePercent) / 100L / 100L; // because scaled by 2dp and 'percent' means "per 100" @@ -195,12 +201,15 @@ public class Block { LOGGER.trace(() -> String.format("Minter account %s share: %s", this.mintingAccount.getAddress(), Amounts.prettyAmount(minterAmount))); if (minterAmount != 0) - this.mintingAccount.modifyAssetBalance(Asset.QORT, minterAmount); + balanceChanges.merge(this.mintingAccount, minterAmount, Long::sum); LOGGER.trace(() -> String.format("Recipient account %s share: %s", this.recipientAccount.getAddress(), Amounts.prettyAmount(recipientAmount))); if (recipientAmount != 0) - this.recipientAccount.modifyAssetBalance(Asset.QORT, recipientAmount); + balanceChanges.merge(this.recipientAccount, recipientAmount, Long::sum); } + + // We always distribute all of the amount + return accountAmount; } } /** Always use getExpandedAccounts() to access this, as it's lazy-instantiated. */ @@ -1229,11 +1238,8 @@ public class Block { // Increase account levels increaseAccountLevels(); - // Block rewards go before transactions processed + // Distribute block rewards, including transaction fees, before transactions processed processBlockRewards(); - - // Give transaction fees to minter/reward-share account(s) - rewardTransactionFees(); } // We're about to (test-)process a batch of transactions, @@ -1307,10 +1313,13 @@ public class Block { } protected void processBlockRewards() throws DataException { + // General block reward long reward = BlockChain.getInstance().getRewardAtHeight(this.blockData.getHeight()); + // Add transaction fees + reward += this.blockData.getTotalFees(); - // No reward for our height? - if (reward == 0) + // Nothing to reward? + if (reward <= 0) return; distributeBlockReward(reward); @@ -1389,16 +1398,6 @@ public class Block { } } - protected void rewardTransactionFees() throws DataException { - long blockFees = this.blockData.getTotalFees(); - - // No transaction fees? - if (blockFees <= 0) - return; - - distributeBlockReward(blockFees); - } - protected void processAtFeesAndStates() throws DataException { ATRepository atRepository = this.repository.getATRepository(); @@ -1463,10 +1462,7 @@ public class Block { // Invalidate expandedAccounts as they may have changed due to orphaning TRANSFER_PRIVS transactions, etc. this.cachedExpandedAccounts = null; - // Deduct any transaction fees from minter/reward-share account(s) - deductTransactionFees(); - - // Block rewards removed after transactions undone + // Block rewards, including transaction fees, removed after transactions undone orphanBlockRewards(); // Decrease account levels @@ -1541,25 +1537,18 @@ public class Block { } protected void orphanBlockRewards() throws DataException { + // General block reward long reward = BlockChain.getInstance().getRewardAtHeight(this.blockData.getHeight()); + // Add transaction fees + reward += this.blockData.getTotalFees(); - // No reward for our height? - if (reward == 0) + // Nothing to reward? + if (reward <= 0) return; distributeBlockReward(- reward); } - protected void deductTransactionFees() throws DataException { - long blockFees = this.blockData.getTotalFees(); - - // No transaction fees? - if (blockFees <= 0) - return; - - distributeBlockReward(- blockFees); - } - protected void orphanAtFeesAndStates() throws DataException { ATRepository atRepository = this.repository.getATRepository(); for (ATStateData atStateData : this.getATStates()) { @@ -1615,62 +1604,218 @@ public class Block { } } - protected void distributeBlockReward(long totalAmount) throws DataException { - LOGGER.trace(() -> String.format("Distributing: %s", Amounts.prettyAmount(totalAmount))); + private static class BlockRewardCandidate { + public final String description; + public long share; + public final BlockRewardDistributor distributionMethod; - // Distribute according to account level - long sharedByLevelAmount = distributeBlockRewardByLevel(totalAmount); - LOGGER.trace(() -> String.format("Shared %s of %s based on account levels", Amounts.prettyAmount(sharedByLevelAmount), Amounts.prettyAmount(totalAmount))); + public BlockRewardCandidate(String description, long share, BlockRewardDistributor distributionMethod) { + this.description = description; + this.share = share; + this.distributionMethod = distributionMethod; + } - // Distribute amongst legacy QORA holders - long sharedByQoraHoldersAmount = distributeBlockRewardToQoraHolders(totalAmount); - LOGGER.trace(() -> String.format("Shared %s of %s to legacy QORA holders", Amounts.prettyAmount(sharedByQoraHoldersAmount), Amounts.prettyAmount(totalAmount))); - - // Spread remainder across founder accounts - long foundersAmount = totalAmount - sharedByLevelAmount - sharedByQoraHoldersAmount; - distributeBlockRewardToFounders(foundersAmount); + public long distribute(long distibutionAmount, Map balanceChanges) throws DataException { + return this.distributionMethod.distribute(distibutionAmount, balanceChanges); + } } - private long distributeBlockRewardByLevel(long totalAmount) throws DataException { - List expandedAccounts = this.getExpandedAccounts(); - List sharesByLevel = BlockChain.getInstance().getBlockSharesByLevel(); + protected void distributeBlockReward(long totalAmount) throws DataException { + final long totalAmountForLogging = totalAmount; + LOGGER.trace(() -> String.format("Distributing: %s", Amounts.prettyAmount(totalAmountForLogging))); - // Distribute amount across bins - long sharedAmount = 0; - for (int s = 0; s < sharesByLevel.size(); ++s) { - final int binIndex = s; + final boolean isProcessingNotOrphaning = totalAmount >= 0; - long binAmount = Amounts.roundDownScaledMultiply(totalAmount, sharesByLevel.get(binIndex).share); - LOGGER.trace(() -> String.format("Bin %d share of %s: %s", binIndex, Amounts.prettyAmount(totalAmount), Amounts.prettyAmount(binAmount))); + // How to distribute reward among groups, with ratio, IN ORDER + List rewardCandidates = determineBlockRewardCandidates(isProcessingNotOrphaning); - // Spread across all accounts in bin. getShareBin() returns -1 for minter accounts that are also founders, so they are effectively filtered out. - List binnedAccounts = expandedAccounts.stream().filter(accountInfo -> accountInfo.getShareBin() == binIndex).collect(Collectors.toList()); + // Now distribute to candidates + + // Collate all balance changes and then apply in one final step + Map balanceChanges = new HashMap<>(); + + long remainingAmount = totalAmount; + for (int r = 0; r < rewardCandidates.size(); ++r) { + BlockRewardCandidate rewardCandidate = rewardCandidates.get(r); + + // Distribute to these reward candidate accounts + final long distributionAmount = Amounts.roundDownScaledMultiply(totalAmount, rewardCandidate.share); + + long sharedAmount = rewardCandidate.distribute(distributionAmount, balanceChanges); + remainingAmount -= sharedAmount; + + // Reallocate any amount we didn't distribute, e.g. from maxxed legacy QORA holders + if (sharedAmount != distributionAmount) + totalAmount += Amounts.scaledDivide(distributionAmount - sharedAmount, 1_00000000 - rewardCandidate.share); + + final long remainingAmountForLogging = remainingAmount; + LOGGER.trace(() -> String.format("%s share: %s. Actually shared: %s. Remaining: %s", + rewardCandidate.description, + Amounts.prettyAmount(distributionAmount), + Amounts.prettyAmount(sharedAmount), + Amounts.prettyAmount(remainingAmountForLogging))); + } + + // Apply balance changes + for (Map.Entry balanceChange : balanceChanges.entrySet()) + balanceChange.getKey().modifyAssetBalance(Asset.QORT, balanceChange.getValue()); + } + + protected List determineBlockRewardCandidates(boolean isProcessingNotOrphaning) throws DataException { + // How to distribute reward among groups, with ratio, IN ORDER + List rewardCandidates = new ArrayList<>(); + + // All online accounts + final List expandedAccounts = this.getExpandedAccounts(); + + /* + * Distribution rules: + * + * Distribution is based on the minting account of 'online' reward-shares. + * + * If ANY founders are online, then they receive the leftover non-distributed reward. + * If NO founders are online, then account-level-based rewards are scaled up so 100% of reward is allocated. + * + * If ANY non-maxxed legacy QORA holders exist then they are always allocated their fixed share (e.g. 20%). + * + * There has to be either at least one 'online' account for blocks to be minted + * so there is always either one account-level-based or founder reward candidate. + * + * Examples: + * + * With at least one founder online: + * Level 1/2 accounts: 5% + * Legacy QORA holders: 20% + * Founders: ~75% + * + * No online founders: + * Level 1/2 accounts: 5% + * Level 5/6 accounts: 15% + * Legacy QORA holders: 20% + * Total: 40% + * + * After scaling account-level-based shares to fill 100%: + * Level 1/2 accounts: 20% + * Level 5/6 accounts: 60% + * Legacy QORA holders: 20% + * Total: 100% + */ + long totalShares = 0; + + // Determine whether we have any online founders + final List onlineFounderAccounts = expandedAccounts.stream().filter(expandedAccount -> expandedAccount.isMinterFounder).collect(Collectors.toList()); + final boolean haveFounders = !onlineFounderAccounts.isEmpty(); + + // Determine reward candidates based on account level + List accountLevelShareBins = BlockChain.getInstance().getAccountLevelShareBins(); + for (int binIndex = 0; binIndex < accountLevelShareBins.size(); ++binIndex) { + // Find all accounts in share bin. getShareBin() returns null for minter accounts that are also founders, so they are effectively filtered out. + AccountLevelShareBin accountLevelShareBin = accountLevelShareBins.get(binIndex); + // Object reference compare is OK as all references are read-only from blockchain config. + List binnedAccounts = expandedAccounts.stream().filter(accountInfo -> accountInfo.getShareBin() == accountLevelShareBin).collect(Collectors.toList()); + + // No online accounts in this bin? Skip to next one if (binnedAccounts.isEmpty()) continue; - long perAccountAmount = binAmount / binnedAccounts.size(); + String description = String.format("Bin %d", binIndex); + BlockRewardDistributor accountLevelBinDistributor = (distributionAmount, balanceChanges) -> distributeBlockRewardShare(distributionAmount, binnedAccounts, balanceChanges); - for (int a = 0; a < binnedAccounts.size(); ++a) { - ExpandedAccount expandedAccount = binnedAccounts.get(a); - expandedAccount.distribute(perAccountAmount); - sharedAmount += perAccountAmount; - } + BlockRewardCandidate rewardCandidate = new BlockRewardCandidate(description, accountLevelShareBin.share, accountLevelBinDistributor); + rewardCandidates.add(rewardCandidate); + + totalShares += rewardCandidate.share; + } + + // Fetch list of legacy QORA holders who haven't reached their cap of QORT reward. + List qoraHolders = this.repository.getAccountRepository().getEligibleLegacyQoraHolders(isProcessingNotOrphaning ? null : this.blockData.getHeight()); + final boolean haveQoraHolders = !qoraHolders.isEmpty(); + final long qoraHoldersShare = BlockChain.getInstance().getQoraHoldersShare(); + + // Perform account-level-based reward scaling if appropriate + if (!haveFounders) { + // Recalculate distribution ratios based on candidates + + // Nothing shared? This shouldn't happen + if (totalShares == 0) + throw new DataException("Unexpected lack of block reward candidates?"); + + // Re-scale individual reward candidate's share as if total shared was 100% - legacy QORA holders' share + long scalingFactor; + if (haveQoraHolders) + scalingFactor = Amounts.scaledDivide(totalShares, 1_00000000 - qoraHoldersShare); + else + scalingFactor = totalShares; + + for (BlockRewardCandidate rewardCandidate : rewardCandidates) + rewardCandidate.share = Amounts.scaledDivide(rewardCandidate.share, scalingFactor); + } + + // Add legacy QORA holders as reward candidate with fixed share (if appropriate) + if (haveQoraHolders) { + // Yes: add to reward candidates list + BlockRewardDistributor legacyQoraHoldersDistributor = (distributionAmount, balanceChanges) -> distributeBlockRewardToQoraHolders(distributionAmount, qoraHolders, balanceChanges, this); + + BlockRewardCandidate rewardCandidate = new BlockRewardCandidate("Legacy QORA holders", qoraHoldersShare, legacyQoraHoldersDistributor); + + if (haveFounders) + // We have founders, so distribute legacy QORA holders just before founders so founders get any non-distributed + rewardCandidates.add(rewardCandidate); + else + // No founder, so distribute legacy QORA holders first, so all account-level-based rewards get share of any non-distributed + rewardCandidates.add(0, rewardCandidate); + + totalShares += rewardCandidate.share; + } + + // Add founders as reward candidate if appropriate + if (haveFounders) { + // Yes: add to reward candidates list + BlockRewardDistributor founderDistributor = (distributionAmount, balanceChanges) -> distributeBlockRewardShare(distributionAmount, onlineFounderAccounts, balanceChanges); + + final long foundersShare = 1_00000000 - totalShares; + BlockRewardCandidate rewardCandidate = new BlockRewardCandidate("Founders", foundersShare, founderDistributor); + rewardCandidates.add(rewardCandidate); + } + + return rewardCandidates; + } + + private static long distributeBlockRewardShare(long distributionAmount, List accounts, Map balanceChanges) { + // Collate all expanded accounts by minting account + Map> accountsByMinter = new HashMap<>(); + + for (ExpandedAccount expandedAccount : accounts) + accountsByMinter.compute(expandedAccount.mintingAccount.getAddress(), (minterAddress, otherAccounts) -> { + if (otherAccounts == null) { + return new ArrayList<>(Arrays.asList(expandedAccount)); + } else { + otherAccounts.add(expandedAccount); + return otherAccounts; + } + }); + + // Divide distribution amount by number of *minting* accounts + long perMintingAccountAmount = distributionAmount / accountsByMinter.keySet().size(); + + // Distribute, reducing totalAmount by how much was actually distributed + long sharedAmount = 0; + for (List recipientAccounts : accountsByMinter.values()) { + long perRecipientAccountAmount = perMintingAccountAmount / recipientAccounts.size(); + + for (ExpandedAccount expandedAccount : recipientAccounts) + sharedAmount += expandedAccount.distribute(perRecipientAccountAmount, balanceChanges); } return sharedAmount; } - private long distributeBlockRewardToQoraHolders(long totalAmount) throws DataException { - long qoraHoldersAmount = Amounts.roundDownScaledMultiply(totalAmount, BlockChain.getInstance().getQoraHoldersShare()); - LOGGER.trace(() -> String.format("Legacy QORA holders share of %s: %s", Amounts.prettyAmount(totalAmount), Amounts.prettyAmount(qoraHoldersAmount))); - - final boolean isProcessingNotOrphaning = totalAmount >= 0; + private static long distributeBlockRewardToQoraHolders(long qoraHoldersAmount, List qoraHolders, Map balanceChanges, Block block) throws DataException { + final boolean isProcessingNotOrphaning = qoraHoldersAmount >= 0; long qoraPerQortReward = BlockChain.getInstance().getQoraPerQortReward(); BigInteger qoraPerQortRewardBI = BigInteger.valueOf(qoraPerQortReward); - List qoraHolders = this.repository.getAccountRepository().getEligibleLegacyQoraHolders(isProcessingNotOrphaning ? null : this.blockData.getHeight()); - long totalQoraHeld = 0; for (int i = 0; i < qoraHolders.size(); ++i) totalQoraHeld += qoraHolders.get(i).getBalance(); @@ -1678,14 +1823,14 @@ public class Block { long finalTotalQoraHeld = totalQoraHeld; LOGGER.trace(() -> String.format("Total legacy QORA held: %s", Amounts.prettyAmount(finalTotalQoraHeld))); - long sharedAmount = 0; if (totalQoraHeld <= 0) - return sharedAmount; + return 0; // Could do with a faster 128bit integer library, but until then... BigInteger qoraHoldersAmountBI = BigInteger.valueOf(qoraHoldersAmount); BigInteger totalQoraHeldBI = BigInteger.valueOf(totalQoraHeld); + long sharedAmount = 0; for (int h = 0; h < qoraHolders.size(); ++h) { AccountBalanceData qoraHolder = qoraHolders.get(h); BigInteger qoraHolderBalanceBI = BigInteger.valueOf(qoraHolder.getBalance()); @@ -1694,15 +1839,15 @@ public class Block { // long holderReward = (qoraHoldersAmount * qoraHolder.getBalance()) / totalQoraHeld; long holderReward = qoraHoldersAmountBI.multiply(qoraHolderBalanceBI).divide(totalQoraHeldBI).longValue(); - long finalHolderReward = holderReward; + final long holderRewardForLogging = holderReward; LOGGER.trace(() -> String.format("QORA holder %s has %s / %s QORA so share: %s", - qoraHolder.getAddress(), Amounts.prettyAmount(qoraHolder.getBalance()), finalTotalQoraHeld, Amounts.prettyAmount(finalHolderReward))); + qoraHolder.getAddress(), Amounts.prettyAmount(qoraHolder.getBalance()), finalTotalQoraHeld, Amounts.prettyAmount(holderRewardForLogging))); // Too small to register this time? if (holderReward == 0) continue; - Account qoraHolderAccount = new Account(repository, qoraHolder.getAddress()); + Account qoraHolderAccount = new Account(block.repository, qoraHolder.getAddress()); long newQortFromQoraBalance = qoraHolderAccount.getConfirmedBalance(Asset.QORT_FROM_QORA) + holderReward; @@ -1718,16 +1863,16 @@ public class Block { newQortFromQoraBalance -= adjustment; // This is also the QORA holder's final QORT-from-QORA block - QortFromQoraData qortFromQoraData = new QortFromQoraData(qoraHolder.getAddress(), holderReward, this.blockData.getHeight()); - this.repository.getAccountRepository().save(qortFromQoraData); + QortFromQoraData qortFromQoraData = new QortFromQoraData(qoraHolder.getAddress(), holderReward, block.blockData.getHeight()); + block.repository.getAccountRepository().save(qortFromQoraData); long finalAdjustedHolderReward = holderReward; LOGGER.trace(() -> String.format("QORA holder %s final share %s at height %d", - qoraHolder.getAddress(), Amounts.prettyAmount(finalAdjustedHolderReward), this.blockData.getHeight())); + qoraHolder.getAddress(), Amounts.prettyAmount(finalAdjustedHolderReward), block.blockData.getHeight())); } } else { // Orphaning - QortFromQoraData qortFromQoraData = this.repository.getAccountRepository().getQortFromQoraInfo(qoraHolder.getAddress()); + QortFromQoraData qortFromQoraData = block.repository.getAccountRepository().getQortFromQoraInfo(qoraHolder.getAddress()); if (qortFromQoraData != null) { // Final QORT-from-QORA amount from repository was stored during processing, and hence positive. // So we use + here as qortFromQora is negative during orphaning. @@ -1737,15 +1882,15 @@ public class Block { holderReward -= adjustment; newQortFromQoraBalance -= adjustment; - this.repository.getAccountRepository().deleteQortFromQoraInfo(qoraHolder.getAddress()); + block.repository.getAccountRepository().deleteQortFromQoraInfo(qoraHolder.getAddress()); long finalAdjustedHolderReward = holderReward; LOGGER.trace(() -> String.format("QORA holder %s final share %s was at height %d", - qoraHolder.getAddress(), Amounts.prettyAmount(finalAdjustedHolderReward), this.blockData.getHeight())); + qoraHolder.getAddress(), Amounts.prettyAmount(finalAdjustedHolderReward), block.blockData.getHeight())); } } - qoraHolderAccount.modifyAssetBalance(Asset.QORT, holderReward); + balanceChanges.merge(qoraHolderAccount, holderReward, Long::sum); if (newQortFromQoraBalance > 0) qoraHolderAccount.setConfirmedBalance(Asset.QORT_FROM_QORA, newQortFromQoraBalance); @@ -1759,41 +1904,6 @@ public class Block { return sharedAmount; } - private void distributeBlockRewardToFounders(long foundersAmount) throws DataException { - // Remaining reward portion is spread across all founders, online or not - List founderAccounts = this.repository.getAccountRepository().getFlaggedAccounts(Account.FOUNDER_FLAG); - - long foundersCount = founderAccounts.size(); - long perFounderAmount = foundersAmount / foundersCount; - - LOGGER.trace(() -> String.format("Sharing remaining %s to %d founder%s, %s each", - Amounts.prettyAmount(foundersAmount), - founderAccounts.size(), (founderAccounts.size() != 1 ? "s" : ""), - Amounts.prettyAmount(perFounderAmount))); - - List expandedAccounts = this.getExpandedAccounts(); - for (int a = 0; a < founderAccounts.size(); ++a) { - Account founderAccount = new Account(this.repository, founderAccounts.get(a).getAddress()); - - // If founder is minter in any online reward-shares then founder's amount is spread across these, otherwise founder gets whole amount. - List founderExpandedAccounts = expandedAccounts.stream().filter( - accountInfo -> accountInfo.isMinterFounder && - accountInfo.mintingAccountData.getAddress().equals(founderAccount.getAddress()) - ).collect(Collectors.toList()); - - if (founderExpandedAccounts.isEmpty()) { - // Simple case: no founder-as-minter reward-shares online so founder gets whole amount. - founderAccount.modifyAssetBalance(Asset.QORT, perFounderAmount); - } else { - // Distribute over reward-shares - long perFounderRewardShareAmount = perFounderAmount / founderExpandedAccounts.size(); - - for (int fea = 0; fea < founderExpandedAccounts.size(); ++fea) - founderExpandedAccounts.get(fea).distribute(perFounderRewardShareAmount); - } - } - } - /** Opportunity to tidy repository, etc. after block process/orphan. */ private void postBlockTidy() throws DataException { this.repository.getAccountRepository().tidy(); diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 16f1f57d..7f21f014 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -89,15 +89,17 @@ public class BlockChain { @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long reward; } - List rewardsByHeight; + private List rewardsByHeight; /** Share of block reward/fees by account level */ - public static class ShareByLevel { + public static class AccountLevelShareBin { public List levels; @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long share; } - List sharesByLevel; + private List sharesByLevel; + /** Generated lookup of share-bin by account level */ + private AccountLevelShareBin[] shareBinsByLevel; /** Share of block reward/fees to legacy QORA coin holders */ @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) @@ -116,7 +118,7 @@ public class BlockChain { * Example: if blocksNeededByLevel[3] is 200,
* then level 3 accounts need to mint 200 blocks to reach level 4. */ - List blocksNeededByLevel; + private List blocksNeededByLevel; /** * Cumulative number of minted blocks required to reach next level from scratch. @@ -130,7 +132,7 @@ public class BlockChain { *

* Should NOT be present in blockchain config file! */ - List cumulativeBlocksByLevel; + private List cumulativeBlocksByLevel; /** Block times by block height */ public static class BlockTimingByHeight { @@ -139,7 +141,7 @@ public class BlockChain { public long deviation; // ms public double power; } - List blockTimingsByHeight; + private List blockTimingsByHeight; private int minAccountLevelToMint = 1; private int minAccountLevelToRewardShare; @@ -316,10 +318,14 @@ public class BlockChain { return this.rewardsByHeight; } - public List getBlockSharesByLevel() { + public List getAccountLevelShareBins() { return this.sharesByLevel; } + public AccountLevelShareBin[] getShareBinsByAccountLevel() { + return this.shareBinsByLevel; + } + public List getBlocksNeededByLevel() { return this.blocksNeededByLevel; } @@ -433,6 +439,15 @@ public class BlockChain { for (FeatureTrigger featureTrigger : FeatureTrigger.values()) if (!this.featureTriggers.containsKey(featureTrigger.name())) Settings.throwValidationError(String.format("Missing feature trigger \"%s\" in blockchain config", featureTrigger.name())); + + // Check block reward share bounds + long totalShare = this.qoraHoldersShare; + // Add share percents for account-level-based rewards + for (AccountLevelShareBin accountLevelShareBin : this.sharesByLevel) + totalShare += accountLevelShareBin.share; + + if (totalShare < 0 || totalShare > 1_00000000L) + Settings.throwValidationError("Total non-founder share out of bounds (0> initialBalances = AccountUtils.getBalances(repository, Asset.QORT, Asset.LEGACY_QORA, Asset.QORT_FROM_QORA); + + long blockReward = BlockUtils.getNextBlockReward(repository); + + BlockMinter.mintTestingBlock(repository, dilbertSelfShareAccount); + + /* + * Dilbert is only account 'online'. + * No founders online. + * Some legacy QORA holders. + * + * So Dilbert should receive 100% - legacy QORA holder's share. + */ + + final long qoraHoldersShare = BlockChain.getInstance().getQoraHoldersShare(); + final long remainingShare = 1_00000000 - qoraHoldersShare; + + long dilbertExpectedBalance = initialBalances.get("dilbert").get(Asset.QORT); + dilbertExpectedBalance += Amounts.roundDownScaledMultiply(blockReward, remainingShare); + + AccountUtils.assertBalance(repository, "dilbert", Asset.QORT, dilbertExpectedBalance); + + // After several blocks, the legacy QORA holder should be maxxed out + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + + // Now Dilbert should be receiving 100% of block reward + blockReward = BlockUtils.getNextBlockReward(repository); + + BlockMinter.mintTestingBlock(repository, dilbertSelfShareAccount); + + AccountUtils.assertBalance(repository, "dilbert", Asset.QORT, dilbertExpectedBalance + blockReward); + } + } + + /** Check leftover legacy QORA reward goes to online founders. */ + @Test + public void testLeftoverReward() throws DataException { + Common.useSettings("test-settings-v2-leftover-reward.json"); + + try (final Repository repository = RepositoryManager.getRepository()) { + Map> initialBalances = AccountUtils.getBalances(repository, Asset.QORT, Asset.LEGACY_QORA, Asset.QORT_FROM_QORA); + + long blockReward = BlockUtils.getNextBlockReward(repository); + + BlockUtils.mintBlock(repository); // Block minted by Alice self-share + + // Chloe maxxes out her legacy QORA reward so some is leftover to reward to Alice. + + TestAccount chloe = Common.getTestAccount(repository, "chloe"); + final long chloeQortFromQora = chloe.getConfirmedBalance(Asset.QORT_FROM_QORA); + + long expectedBalance = initialBalances.get("alice").get(Asset.QORT) + blockReward - chloeQortFromQora; + AccountUtils.assertBalance(repository, "alice", Asset.QORT, expectedBalance); + } + } + } \ No newline at end of file diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json new file mode 100644 index 00000000..d402aa95 --- /dev/null +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -0,0 +1,70 @@ +{ + "isTestChain": true, + "blockTimestampMargin": 500, + "transactionExpiryPeriod": 86400000, + "maxBlockSize": 2097152, + "maxBytesPerUnitFee": 1024, + "unitFee": "0.1", + "requireGroupForApproval": false, + "minAccountLevelToRewardShare": 5, + "maxRewardSharesPerMintingAccount": 20, + "founderEffectiveMintingLevel": 10, + "onlineAccountSignaturesMinLifetime": 3600000, + "onlineAccountSignaturesMaxLifetime": 86400000, + "rewardsByHeight": [ + { "height": 1, "reward": 100 }, + { "height": 11, "reward": 10 }, + { "height": 21, "reward": 1 } + ], + "sharesByLevel": [ + { "levels": [ 1, 2 ], "share": 0.05 }, + { "levels": [ 3, 4 ], "share": 0.10 }, + { "levels": [ 5, 6 ], "share": 0.15 }, + { "levels": [ 7, 8 ], "share": 0.20 }, + { "levels": [ 9, 10 ], "share": 0.25 } + ], + "qoraHoldersShare": 0.20, + "qoraPerQortReward": 250, + "blocksNeededByLevel": [ 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 ], + "blockTimingsByHeight": [ + { "height": 1, "target": 60000, "deviation": 30000, "power": 0.2 } + ], + "ciyamAtSettings": { + "feePerStep": "0.0001", + "maxStepsPerRound": 500, + "stepsPerFunctionCall": 10, + "minutesPerBlock": 1 + }, + "featureTriggers": { + "messageHeight": 0, + "atHeight": 0, + "assetsTimestamp": 0, + "votingTimestamp": 0, + "arbitraryTimestamp": 0, + "powfixTimestamp": 0, + "qortalTimestamp": 0, + "newAssetPricingTimestamp": 0, + "groupApprovalTimestamp": 0 + }, + "genesisInfo": { + "version": 4, + "timestamp": 0, + "transactions": [ + { "type": "ISSUE_ASSET", "assetName": "QORT", "description": "QORT native coin", "data": "", "quantity": 0, "isDivisible": true, "fee": 0 }, + { "type": "ISSUE_ASSET", "assetName": "Legacy-QORA", "description": "Representative legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true }, + { "type": "ISSUE_ASSET", "assetName": "QORT-from-QORA", "description": "QORT gained from holding legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true }, + + { "type": "GENESIS", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "amount": "1000000000" }, + { "type": "GENESIS", "recipient": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "amount": "1000000" }, + { "type": "GENESIS", "recipient": "QaUpHNhT3Ygx6avRiKobuLdusppR5biXjL", "amount": "1000000" }, + { "type": "GENESIS", "recipient": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "amount": "1000000" }, + + { "type": "GENESIS", "recipient": "QaUpHNhT3Ygx6avRiKobuLdusppR5biXjL", "amount": "4000", "assetId": 1 }, + + { "type": "ACCOUNT_FLAGS", "target": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "rewardSharePublicKey": "7PpfnvLSG7y4HPh8hE7KoqAjLCkv7Ui6xw4mKAkbZtox", "sharePercent": 100 }, + + { "type": "ACCOUNT_LEVEL", "target": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "level": 8 } + ] + } +} diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json new file mode 100644 index 00000000..e454d8e7 --- /dev/null +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -0,0 +1,70 @@ +{ + "isTestChain": true, + "blockTimestampMargin": 500, + "transactionExpiryPeriod": 86400000, + "maxBlockSize": 2097152, + "maxBytesPerUnitFee": 1024, + "unitFee": "0.1", + "requireGroupForApproval": false, + "minAccountLevelToRewardShare": 5, + "maxRewardSharesPerMintingAccount": 20, + "founderEffectiveMintingLevel": 10, + "onlineAccountSignaturesMinLifetime": 3600000, + "onlineAccountSignaturesMaxLifetime": 86400000, + "rewardsByHeight": [ + { "height": 1, "reward": 100 }, + { "height": 11, "reward": 10 }, + { "height": 21, "reward": 1 } + ], + "sharesByLevel": [ + { "levels": [ 1, 2 ], "share": 0.05 }, + { "levels": [ 3, 4 ], "share": 0.10 }, + { "levels": [ 5, 6 ], "share": 0.15 }, + { "levels": [ 7, 8 ], "share": 0.20 }, + { "levels": [ 9, 10 ], "share": 0.25 } + ], + "qoraHoldersShare": 0.20, + "qoraPerQortReward": 250, + "blocksNeededByLevel": [ 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 ], + "blockTimingsByHeight": [ + { "height": 1, "target": 60000, "deviation": 30000, "power": 0.2 } + ], + "ciyamAtSettings": { + "feePerStep": "0.0001", + "maxStepsPerRound": 500, + "stepsPerFunctionCall": 10, + "minutesPerBlock": 1 + }, + "featureTriggers": { + "messageHeight": 0, + "atHeight": 0, + "assetsTimestamp": 0, + "votingTimestamp": 0, + "arbitraryTimestamp": 0, + "powfixTimestamp": 0, + "qortalTimestamp": 0, + "newAssetPricingTimestamp": 0, + "groupApprovalTimestamp": 0 + }, + "genesisInfo": { + "version": 4, + "timestamp": 0, + "transactions": [ + { "type": "ISSUE_ASSET", "assetName": "QORT", "description": "QORT native coin", "data": "", "quantity": 0, "isDivisible": true, "fee": 0 }, + { "type": "ISSUE_ASSET", "assetName": "Legacy-QORA", "description": "Representative legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true }, + { "type": "ISSUE_ASSET", "assetName": "QORT-from-QORA", "description": "QORT gained from holding legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true }, + + { "type": "GENESIS", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "amount": "1000000000" }, + { "type": "GENESIS", "recipient": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "amount": "1000000" }, + { "type": "GENESIS", "recipient": "QaUpHNhT3Ygx6avRiKobuLdusppR5biXjL", "amount": "1000000" }, + { "type": "GENESIS", "recipient": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "amount": "1000000" }, + + { "type": "GENESIS", "recipient": "QaUpHNhT3Ygx6avRiKobuLdusppR5biXjL", "amount": "40000", "assetId": 1 }, + + { "type": "ACCOUNT_FLAGS", "target": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "rewardSharePublicKey": "7PpfnvLSG7y4HPh8hE7KoqAjLCkv7Ui6xw4mKAkbZtox", "sharePercent": 100 }, + + { "type": "ACCOUNT_LEVEL", "target": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "level": 8 } + ] + } +} diff --git a/src/test/resources/test-settings-v2-leftover-reward.json b/src/test/resources/test-settings-v2-leftover-reward.json new file mode 100644 index 00000000..bdbc1d52 --- /dev/null +++ b/src/test/resources/test-settings-v2-leftover-reward.json @@ -0,0 +1,7 @@ +{ + "restrictedApi": false, + "blockchainConfig": "src/test/resources/test-chain-v2-leftover-reward.json", + "wipeUnconfirmedOnStart": false, + "testNtpOffset": 0, + "minPeers": 0 +} diff --git a/src/test/resources/test-settings-v2-qora-holder-extremes.json b/src/test/resources/test-settings-v2-qora-holder-extremes.json index 83b23287..b311fbf2 100644 --- a/src/test/resources/test-settings-v2-qora-holder-extremes.json +++ b/src/test/resources/test-settings-v2-qora-holder-extremes.json @@ -1,6 +1,6 @@ { "restrictedApi": false, - "blockchainConfig": "src/test/resources/test-chain-v2-qora-holder.json", + "blockchainConfig": "src/test/resources/test-chain-v2-qora-holder-extremes.json", "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2-reward-scaling.json b/src/test/resources/test-settings-v2-reward-scaling.json new file mode 100644 index 00000000..262938b7 --- /dev/null +++ b/src/test/resources/test-settings-v2-reward-scaling.json @@ -0,0 +1,7 @@ +{ + "restrictedApi": false, + "blockchainConfig": "src/test/resources/test-chain-v2-reward-scaling.json", + "wipeUnconfirmedOnStart": false, + "testNtpOffset": 0, + "minPeers": 0 +}