diff --git a/common/src/main/java/bisq/common/config/Config.java b/common/src/main/java/bisq/common/config/Config.java index 985f578428..414d65fef8 100644 --- a/common/src/main/java/bisq/common/config/Config.java +++ b/common/src/main/java/bisq/common/config/Config.java @@ -129,6 +129,9 @@ public class Config { public static final String BYPASS_MEMPOOL_VALIDATION = "bypassMempoolValidation"; public static final String DAO_NODE_API_URL = "daoNodeApiUrl"; public static final String DAO_NODE_API_PORT = "daoNodeApiPort"; + public static final String IS_BM_FULL_NODE = "isBmFullNode"; + public static final String BM_ORACLE_NODE_PUB_KEY = "bmOracleNodePubKey"; + public static final String BM_ORACLE_NODE_PRIV_KEY = "bmOracleNodePrivKey"; public static final String SEED_NODE_REPORTING_SERVER_URL = "seedNodeReportingServerUrl"; // Default values for certain options @@ -221,6 +224,9 @@ public class Config { public final boolean bypassMempoolValidation; public final String daoNodeApiUrl; public final int daoNodeApiPort; + public final boolean isBmFullNode; + public final String bmOracleNodePubKey; + public final String bmOracleNodePrivKey; public final String seedNodeReportingServerUrl; // Properties derived from options but not exposed as options themselves @@ -681,6 +687,23 @@ public class Config { .withRequiredArg() .ofType(Integer.class) .defaultsTo(8082); + + ArgumentAcceptingOptionSpec isBmFullNode = + parser.accepts(IS_BM_FULL_NODE, "Run as Burningman full node") + .withRequiredArg() + .ofType(boolean.class) + .defaultsTo(false); + + ArgumentAcceptingOptionSpec bmOracleNodePubKey = + parser.accepts(BM_ORACLE_NODE_PUB_KEY, "Burningman oracle node public key") + .withRequiredArg() + .defaultsTo(""); + + ArgumentAcceptingOptionSpec bmOracleNodePrivKey = + parser.accepts(BM_ORACLE_NODE_PRIV_KEY, "Burningman oracle node private key") + .withRequiredArg() + .defaultsTo(""); + ArgumentAcceptingOptionSpec seedNodeReportingServerUrlOpt = parser.accepts(SEED_NODE_REPORTING_SERVER_URL, "URL of seed node reporting server") .withRequiredArg() @@ -806,6 +829,9 @@ public class Config { this.bypassMempoolValidation = options.valueOf(bypassMempoolValidationOpt); this.daoNodeApiUrl = options.valueOf(daoNodeApiUrlOpt); this.daoNodeApiPort = options.valueOf(daoNodeApiPortOpt); + this.isBmFullNode = options.valueOf(isBmFullNode); + this.bmOracleNodePubKey = options.valueOf(bmOracleNodePubKey); + this.bmOracleNodePrivKey = options.valueOf(bmOracleNodePrivKey); this.seedNodeReportingServerUrl = options.valueOf(seedNodeReportingServerUrlOpt); } catch (OptionException ex) { throw new ConfigException("problem parsing option '%s': %s", diff --git a/common/src/main/java/bisq/common/util/DateUtil.java b/common/src/main/java/bisq/common/util/DateUtil.java new file mode 100644 index 0000000000..15199e826b --- /dev/null +++ b/common/src/main/java/bisq/common/util/DateUtil.java @@ -0,0 +1,60 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.common.util; + +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; + +public class DateUtil { + /** + * + * @param date The date which should be reset to first day of month + * @return First day in given date with time set to zero. + */ + public static Date getStartOfMonth(Date date) { + Calendar calendar = new GregorianCalendar(); + calendar.setTime(date); + calendar.set(Calendar.DAY_OF_MONTH, 1); + calendar.set(Calendar.HOUR_OF_DAY, 0); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + return calendar.getTime(); + } + + /** + * + * @param year The year + * @param month The month starts with 0 for January + * @return First day in given month with time set to zero. + */ + public static Date getStartOfMonth(int year, int month) { + Calendar calendar = new GregorianCalendar(); + calendar.setTime(new Date()); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month); + calendar.set(Calendar.DAY_OF_MONTH, 1); + calendar.set(Calendar.HOUR_OF_DAY, 0); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + Date time = calendar.getTime(); + return time; + } +} diff --git a/common/src/main/java/bisq/common/util/Hex.java b/common/src/main/java/bisq/common/util/Hex.java index 5edcce6602..4088d0f632 100644 --- a/common/src/main/java/bisq/common/util/Hex.java +++ b/common/src/main/java/bisq/common/util/Hex.java @@ -28,4 +28,8 @@ public class Hex { public static String encode(byte[] bytes) { return BaseEncoding.base16().lowerCase().encode(bytes); } + + public static byte[] decodeLast4Bytes(String hex) { + return decode(hex.substring(hex.length() - 8)); + } } diff --git a/core/src/main/java/bisq/core/btc/wallet/BsqWalletService.java b/core/src/main/java/bisq/core/btc/wallet/BsqWalletService.java index 273def00b0..f07ea8cf97 100644 --- a/core/src/main/java/bisq/core/btc/wallet/BsqWalletService.java +++ b/core/src/main/java/bisq/core/btc/wallet/BsqWalletService.java @@ -591,7 +591,7 @@ public class BsqWalletService extends WalletService implements DaoStateListener coinSelector.setUtxoCandidates(null); // We reuse the selectors. Reset the transactionOutputCandidates field return tx; } catch (InsufficientMoneyException e) { - log.error("getPreparedSendTx: tx={}", tx.toString()); + log.error("getPreparedSendTx: tx={}", tx); log.error(e.toString()); throw new InsufficientBsqException(e.missing); } diff --git a/core/src/main/java/bisq/core/btc/wallet/TradeWalletService.java b/core/src/main/java/bisq/core/btc/wallet/TradeWalletService.java index 2df405287d..29bda0b41e 100644 --- a/core/src/main/java/bisq/core/btc/wallet/TradeWalletService.java +++ b/core/src/main/java/bisq/core/btc/wallet/TradeWalletService.java @@ -76,7 +76,7 @@ import static com.google.common.base.Preconditions.checkNotNull; public class TradeWalletService { private static final Logger log = LoggerFactory.getLogger(TradeWalletService.class); - private static final Coin MIN_DELAYED_PAYOUT_TX_FEE = Coin.valueOf(1000); + public static final Coin MIN_DELAYED_PAYOUT_TX_FEE = Coin.valueOf(1000); private final WalletsSetup walletsSetup; private final Preferences preferences; @@ -696,16 +696,31 @@ public class TradeWalletService { // Delayed payout tx /////////////////////////////////////////////////////////////////////////////////////////// + public Transaction createDelayedUnsignedPayoutTx(Transaction depositTx, + List> receivers, + long lockTime) + throws AddressFormatException, TransactionVerificationException { + TransactionOutput depositTxOutput = depositTx.getOutput(0); + Transaction delayedPayoutTx = new Transaction(params); + delayedPayoutTx.addInput(depositTxOutput); + applyLockTime(lockTime, delayedPayoutTx); + checkArgument(!receivers.isEmpty(), "receivers must not be empty"); + receivers.forEach(receiver -> delayedPayoutTx.addOutput(Coin.valueOf(receiver.first), Address.fromString(params, receiver.second))); + WalletService.printTx("Unsigned delayedPayoutTx ToDonationAddress", delayedPayoutTx); + WalletService.verifyTransaction(delayedPayoutTx); + return delayedPayoutTx; + } + public Transaction createDelayedUnsignedPayoutTx(Transaction depositTx, String donationAddressString, Coin minerFee, long lockTime) throws AddressFormatException, TransactionVerificationException { - TransactionOutput hashedMultiSigOutput = depositTx.getOutput(0); + TransactionOutput depositTxOutput = depositTx.getOutput(0); Transaction delayedPayoutTx = new Transaction(params); - delayedPayoutTx.addInput(hashedMultiSigOutput); + delayedPayoutTx.addInput(depositTxOutput); applyLockTime(lockTime, delayedPayoutTx); - Coin outputAmount = hashedMultiSigOutput.getValue().subtract(minerFee); + Coin outputAmount = depositTxOutput.getValue().subtract(minerFee); delayedPayoutTx.addOutput(outputAmount, Address.fromString(params, donationAddressString)); WalletService.printTx("Unsigned delayedPayoutTx ToDonationAddress", delayedPayoutTx); WalletService.verifyTransaction(delayedPayoutTx); diff --git a/core/src/main/java/bisq/core/dao/CyclesInDaoStateService.java b/core/src/main/java/bisq/core/dao/CyclesInDaoStateService.java new file mode 100644 index 0000000000..753d6ad936 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/CyclesInDaoStateService.java @@ -0,0 +1,127 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao; + +import bisq.core.dao.governance.period.CycleService; +import bisq.core.dao.state.DaoStateService; +import bisq.core.dao.state.model.governance.Cycle; +import bisq.core.dao.state.model.governance.DaoPhase; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import lombok.extern.slf4j.Slf4j; + +/** + * Utility methods for Cycle related methods. + * As they might be called often we use caching. + */ +@Slf4j +@Singleton +public class CyclesInDaoStateService { + private final DaoStateService daoStateService; + private final CycleService cycleService; + + // Cached results + private final Map cyclesByHeight = new HashMap<>(); + private final Map indexByCycle = new HashMap<>(); + private final Map cyclesByIndex = new HashMap<>(); + + @Inject + public CyclesInDaoStateService(DaoStateService daoStateService, CycleService cycleService) { + this.daoStateService = daoStateService; + this.cycleService = cycleService; + } + + public int getCycleIndexAtChainHeight(int chainHeight) { + return findCycleAtHeight(chainHeight) + .map(cycleService::getCycleIndex) + .orElse(-1); + } + + public int getHeightOfFirstBlockOfResultPhaseOfPastCycle(int chainHeight, int numPastCycles) { + return findCycleAtHeight(chainHeight) + .map(cycle -> { + int cycleIndex = getIndexForCycle(cycle); + int targetIndex = Math.max(0, (cycleIndex - numPastCycles)); + return getCycleAtIndex(targetIndex); + }) + .map(cycle -> cycle.getFirstBlockOfPhase(DaoPhase.Phase.RESULT)) + .orElse(daoStateService.getGenesisBlockHeight()); + } + + /** + * + * @param chainHeight Chain height from where we start + * @param numPastCycles Number of past cycles + * @return The height at the same offset from the first block of the cycle as in the current cycle minus the past cycles. + */ + public int getChainHeightOfPastCycle(int chainHeight, int numPastCycles) { + int firstBlockOfPastCycle = getHeightOfFirstBlockOfPastCycle(chainHeight, numPastCycles); + if (firstBlockOfPastCycle == daoStateService.getGenesisBlockHeight()) { + return firstBlockOfPastCycle; + } + return firstBlockOfPastCycle + getOffsetFromFirstBlockInCycle(chainHeight); + } + + public Integer getOffsetFromFirstBlockInCycle(int chainHeight) { + return daoStateService.getCycle(chainHeight) + .map(c -> chainHeight - c.getHeightOfFirstBlock()) + .orElse(0); + } + + public int getHeightOfFirstBlockOfPastCycle(int chainHeight, int numPastCycles) { + return findCycleAtHeight(chainHeight) + .map(cycle -> getIndexForCycle(cycle) - numPastCycles) + .filter(targetIndex -> targetIndex > 0) + .map(targetIndex -> getCycleAtIndex(targetIndex).getHeightOfFirstBlock()) + .orElse(daoStateService.getGenesisBlockHeight()); + } + + public Cycle getCycleAtIndex(int index) { + int cycleIndex = Math.max(0, index); + return Optional.ofNullable(cyclesByIndex.get(cycleIndex)) + .orElseGet(() -> { + Cycle cycle = daoStateService.getCycleAtIndex(cycleIndex); + cyclesByIndex.put(cycleIndex, cycle); + return cycle; + }); + } + + public int getIndexForCycle(Cycle cycle) { + return Optional.ofNullable(indexByCycle.get(cycle)) + .orElseGet(() -> { + int index = cycleService.getCycleIndex(cycle); + indexByCycle.put(cycle, index); + return index; + }); + } + + public Optional findCycleAtHeight(int chainHeight) { + return Optional.ofNullable(cyclesByHeight.get(chainHeight)) + .or(() -> { + Optional optionalCycle = daoStateService.getCycle(chainHeight); + optionalCycle.ifPresent(cycle -> cyclesByHeight.put(chainHeight, cycle)); + return optionalCycle; + }); + } +} diff --git a/core/src/main/java/bisq/core/dao/DaoFacade.java b/core/src/main/java/bisq/core/dao/DaoFacade.java index e739c356ba..b4729179d2 100644 --- a/core/src/main/java/bisq/core/dao/DaoFacade.java +++ b/core/src/main/java/bisq/core/dao/DaoFacade.java @@ -252,11 +252,13 @@ public class DaoFacade implements DaoSetupService { // Creation of Proposal and proposalTransaction public ProposalWithTransaction getCompensationProposalWithTransaction(String name, String link, - Coin requestedBsq) + Coin requestedBsq, + Optional burningManReceiverAddress) throws ProposalValidationException, InsufficientMoneyException, TxException { return compensationProposalFactory.createProposalWithTransaction(name, link, - requestedBsq); + requestedBsq, + burningManReceiverAddress); } public ProposalWithTransaction getReimbursementProposalWithTransaction(String name, diff --git a/core/src/main/java/bisq/core/dao/DaoModule.java b/core/src/main/java/bisq/core/dao/DaoModule.java index 1bcd45e140..3d10caedb8 100644 --- a/core/src/main/java/bisq/core/dao/DaoModule.java +++ b/core/src/main/java/bisq/core/dao/DaoModule.java @@ -222,6 +222,9 @@ public class DaoModule extends AppModule { bindConstant().annotatedWith(named(Config.RPC_BLOCK_NOTIFICATION_HOST)).to(config.rpcBlockNotificationHost); bindConstant().annotatedWith(named(Config.DUMP_BLOCKCHAIN_DATA)).to(config.dumpBlockchainData); bindConstant().annotatedWith(named(Config.FULL_DAO_NODE)).to(config.fullDaoNode); + bindConstant().annotatedWith(named(Config.IS_BM_FULL_NODE)).to(config.isBmFullNode); + bindConstant().annotatedWith(named(Config.BM_ORACLE_NODE_PUB_KEY)).to(config.bmOracleNodePubKey); + bindConstant().annotatedWith(named(Config.BM_ORACLE_NODE_PRIV_KEY)).to(config.bmOracleNodePrivKey); } } diff --git a/core/src/main/java/bisq/core/dao/DaoSetup.java b/core/src/main/java/bisq/core/dao/DaoSetup.java index 728f94108a..98ebdb86d5 100644 --- a/core/src/main/java/bisq/core/dao/DaoSetup.java +++ b/core/src/main/java/bisq/core/dao/DaoSetup.java @@ -17,6 +17,9 @@ package bisq.core.dao; +import bisq.core.dao.burningman.accounting.BurningManAccountingService; +import bisq.core.dao.burningman.accounting.node.AccountingNode; +import bisq.core.dao.burningman.accounting.node.AccountingNodeProvider; import bisq.core.dao.governance.asset.AssetService; import bisq.core.dao.governance.ballot.BallotListService; import bisq.core.dao.governance.blindvote.BlindVoteListService; @@ -54,9 +57,11 @@ import java.util.function.Consumer; public class DaoSetup { private final BsqNode bsqNode; private final List daoSetupServices = new ArrayList<>(); + private final AccountingNode accountingNode; @Inject public DaoSetup(BsqNodeProvider bsqNodeProvider, + AccountingNodeProvider accountingNodeProvider, DaoStateService daoStateService, CycleService cycleService, BallotListService ballotListService, @@ -79,9 +84,11 @@ public class DaoSetup { DaoStateMonitoringService daoStateMonitoringService, ProposalStateMonitoringService proposalStateMonitoringService, BlindVoteStateMonitoringService blindVoteStateMonitoringService, - DaoStateSnapshotService daoStateSnapshotService) { + DaoStateSnapshotService daoStateSnapshotService, + BurningManAccountingService burningManAccountingService) { bsqNode = bsqNodeProvider.getBsqNode(); + accountingNode = accountingNodeProvider.getAccountingNode(); // We need to take care of order of execution. daoSetupServices.add(daoStateService); @@ -107,8 +114,10 @@ public class DaoSetup { daoSetupServices.add(proposalStateMonitoringService); daoSetupServices.add(blindVoteStateMonitoringService); daoSetupServices.add(daoStateSnapshotService); + daoSetupServices.add(burningManAccountingService); - daoSetupServices.add(bsqNodeProvider.getBsqNode()); + daoSetupServices.add(bsqNode); + daoSetupServices.add(accountingNode); } public void onAllServicesInitialized(Consumer errorMessageHandler, @@ -116,6 +125,9 @@ public class DaoSetup { bsqNode.setErrorMessageHandler(errorMessageHandler); bsqNode.setWarnMessageHandler(warnMessageHandler); + accountingNode.setErrorMessageHandler(errorMessageHandler); + accountingNode.setWarnMessageHandler(warnMessageHandler); + // We add first all listeners at all services and then call the start methods. // Some services are listening on others so we need to make sure that the // listeners are set before we call start as that might trigger state change @@ -126,5 +138,6 @@ public class DaoSetup { public void shutDown() { bsqNode.shutDown(); + accountingNode.shutDown(); } } diff --git a/core/src/main/java/bisq/core/dao/burningman/BtcFeeReceiverService.java b/core/src/main/java/bisq/core/dao/burningman/BtcFeeReceiverService.java new file mode 100644 index 0000000000..8e042d7edb --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/BtcFeeReceiverService.java @@ -0,0 +1,132 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman; + +import bisq.core.dao.burningman.model.BurningManCandidate; +import bisq.core.dao.state.DaoStateListener; +import bisq.core.dao.state.DaoStateService; +import bisq.core.dao.state.model.blockchain.Block; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.stream.Collectors; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Singleton +public class BtcFeeReceiverService implements DaoStateListener { + private final BurningManService burningManService; + + private int currentChainHeight; + + @Inject + public BtcFeeReceiverService(DaoStateService daoStateService, BurningManService burningManService) { + this.burningManService = burningManService; + + daoStateService.addDaoStateListener(this); + daoStateService.getLastBlock().ifPresent(this::applyBlock); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // DaoStateListener + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void onParseBlockCompleteAfterBatchProcessing(Block block) { + applyBlock(block); + } + + private void applyBlock(Block block) { + currentChainHeight = block.getHeight(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + public String getAddress() { + if (!BurningManService.isActivated()) { + // Before activation, we fall back to the current fee receiver address + return BurningManPresentationService.LEGACY_BURNING_MAN_BTC_FEES_ADDRESS; + } + + Map burningManCandidatesByName = burningManService.getBurningManCandidatesByName(currentChainHeight); + if (burningManCandidatesByName.isEmpty()) { + // If there are no compensation requests (e.g. at dev testing) we fall back to the default address + return burningManService.getLegacyBurningManAddress(currentChainHeight); + } + + // It might be that we do not reach 100% if some entries had a cappedBurnAmountShare. + // In that case we fill up the gap to 100% with the legacy BM. + // cappedBurnAmountShare is a % value represented as double. Smallest supported value is 0.01% -> 0.0001. + // By multiplying it with 10000 and using Math.floor we limit the candidate to 0.01%. + // Entries with 0 will be ignored in the selection method, so we do not need to filter them out. + List burningManCandidates = new ArrayList<>(burningManCandidatesByName.values()); + int ceiling = 10000; + List amountList = burningManCandidates.stream() + .map(BurningManCandidate::getCappedBurnAmountShare) + .map(cappedBurnAmountShare -> (long) Math.floor(cappedBurnAmountShare * ceiling)) + .collect(Collectors.toList()); + long sum = amountList.stream().mapToLong(e -> e).sum(); + // If we have not reached the 100% we fill the missing gap with the legacy BM + if (sum < ceiling) { + amountList.add(ceiling - sum); + } + + int winnerIndex = getRandomIndex(amountList, new Random()); + if (winnerIndex == burningManCandidates.size()) { + // If we have filled up the missing gap to 100% with the legacy BM we would get an index out of bounds of + // the burningManCandidates as we added for the legacy BM an entry at the end. + return burningManService.getLegacyBurningManAddress(currentChainHeight); + } + return burningManCandidates.get(winnerIndex).getMostRecentAddress() + .orElse(burningManService.getLegacyBurningManAddress(currentChainHeight)); + } + + @VisibleForTesting + static int getRandomIndex(List weights, Random random) { + long sum = weights.stream().mapToLong(n -> n).sum(); + if (sum == 0) { + return -1; + } + long target = random.longs(0, sum).findFirst().orElseThrow() + 1; + return findIndex(weights, target); + } + + @VisibleForTesting + static int findIndex(List weights, long target) { + int currentRange = 0; + for (int i = 0; i < weights.size(); i++) { + currentRange += weights.get(i); + if (currentRange >= target) { + return i; + } + } + return 0; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/BurnTargetService.java b/core/src/main/java/bisq/core/dao/burningman/BurnTargetService.java new file mode 100644 index 0000000000..c770d4ddda --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/BurnTargetService.java @@ -0,0 +1,252 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman; + +import bisq.core.dao.CyclesInDaoStateService; +import bisq.core.dao.burningman.model.BurnOutputModel; +import bisq.core.dao.burningman.model.BurningManCandidate; +import bisq.core.dao.burningman.model.ReimbursementModel; +import bisq.core.dao.governance.param.Param; +import bisq.core.dao.governance.proposal.ProposalService; +import bisq.core.dao.governance.proposal.storage.appendonly.ProposalPayload; +import bisq.core.dao.state.DaoStateService; +import bisq.core.dao.state.model.blockchain.Tx; +import bisq.core.dao.state.model.governance.Cycle; +import bisq.core.dao.state.model.governance.Issuance; +import bisq.core.dao.state.model.governance.IssuanceType; +import bisq.core.dao.state.model.governance.ReimbursementProposal; + +import bisq.common.config.Config; +import bisq.common.util.Hex; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import lombok.extern.slf4j.Slf4j; + +/** + * Burn target related API. Not touching trade protocol aspects and parameters can be changed here without risking to + * break trade protocol validations. + */ +@Slf4j +@Singleton +class BurnTargetService { + // Number of cycles for accumulating reimbursement amounts. Used for the burn target. + private static final int NUM_CYCLES_BURN_TARGET = 12; + private static final int NUM_CYCLES_AVERAGE_DISTRIBUTION = 3; + + // Default value for the estimated BTC trade fees per month as BSQ sat value (100 sat = 1 BSQ). + // Default is roughly average of last 12 months at Nov 2022. + // Can be changed with DAO parameter voting. + private static final long DEFAULT_ESTIMATED_BTC_TRADE_FEE_REVENUE_PER_CYCLE = Config.baseCurrencyNetwork().isRegtest() ? 1000000 : 6200000; + + private final DaoStateService daoStateService; + private final CyclesInDaoStateService cyclesInDaoStateService; + private final ProposalService proposalService; + + @Inject + public BurnTargetService(DaoStateService daoStateService, + CyclesInDaoStateService cyclesInDaoStateService, + ProposalService proposalService) { + this.daoStateService = daoStateService; + this.cyclesInDaoStateService = cyclesInDaoStateService; + this.proposalService = proposalService; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + Set getReimbursements(int chainHeight) { + Set reimbursements = new HashSet<>(); + daoStateService.getIssuanceSetForType(IssuanceType.REIMBURSEMENT).stream() + .filter(issuance -> issuance.getChainHeight() <= chainHeight) + .forEach(issuance -> getReimbursementProposalsForIssuance(issuance) + .forEach(reimbursementProposal -> { + int issuanceHeight = issuance.getChainHeight(); + long issuanceAmount = issuance.getAmount(); + long issuanceDate = daoStateService.getBlockTime(issuanceHeight); + int cycleIndex = cyclesInDaoStateService.getCycleIndexAtChainHeight(issuanceHeight); + reimbursements.add(new ReimbursementModel( + issuanceAmount, + issuanceHeight, + issuanceDate, + cycleIndex, + reimbursementProposal.getTxId())); + })); + return reimbursements; + } + + long getBurnTarget(int chainHeight, Collection burningManCandidates) { + // Reimbursements are taken into account at result vote block + int chainHeightOfPastCycle = cyclesInDaoStateService.getChainHeightOfPastCycle(chainHeight, NUM_CYCLES_BURN_TARGET); + long accumulatedReimbursements = getAccumulatedReimbursements(chainHeight, chainHeightOfPastCycle); + + // Param changes are taken into account at first block at next cycle after voting + int heightOfFirstBlockOfPastCycle = cyclesInDaoStateService.getHeightOfFirstBlockOfPastCycle(chainHeight, NUM_CYCLES_BURN_TARGET - 1); + long accumulatedEstimatedBtcTradeFees = getAccumulatedEstimatedBtcTradeFees(chainHeight, heightOfFirstBlockOfPastCycle); + + // Legacy BurningMan + Set proofOfBurnTxs = getProofOfBurnTxs(chainHeight, chainHeightOfPastCycle); + long burnedAmountFromLegacyBurningManDPT = getBurnedAmountFromLegacyBurningManDPT(proofOfBurnTxs, chainHeight, chainHeightOfPastCycle); + long burnedAmountFromLegacyBurningMansBtcFees = getBurnedAmountFromLegacyBurningMansBtcFees(proofOfBurnTxs, chainHeight, chainHeightOfPastCycle); + + // Distributed BurningMen + long burnedAmountFromBurningMen = getBurnedAmountFromBurningMen(burningManCandidates, chainHeight, chainHeightOfPastCycle); + + long burnTarget = accumulatedReimbursements + + accumulatedEstimatedBtcTradeFees + - burnedAmountFromLegacyBurningManDPT + - burnedAmountFromLegacyBurningMansBtcFees + - burnedAmountFromBurningMen; + + log.info("accumulatedReimbursements: {}\n" + + "+ accumulatedEstimatedBtcTradeFees: {}\n" + + "- burnedAmountFromLegacyBurningManDPT: {}\n" + + "- burnedAmountFromLegacyBurningMansBtcFees: {}\n" + + "- burnedAmountFromBurningMen: {}\n" + + "= burnTarget: {}\n", + accumulatedReimbursements, + accumulatedEstimatedBtcTradeFees, + burnedAmountFromLegacyBurningManDPT, + burnedAmountFromLegacyBurningMansBtcFees, + burnedAmountFromBurningMen, + burnTarget); + return burnTarget; + } + + long getAverageDistributionPerCycle(int chainHeight) { + // Reimbursements are taken into account at result vote block + int chainHeightOfPastCycle = cyclesInDaoStateService.getChainHeightOfPastCycle(chainHeight, NUM_CYCLES_AVERAGE_DISTRIBUTION); + long reimbursements = getAccumulatedReimbursements(chainHeight, chainHeightOfPastCycle); + + // Param changes are taken into account at first block at next cycle after voting + int firstBlockOfPastCycle = cyclesInDaoStateService.getHeightOfFirstBlockOfPastCycle(chainHeight, NUM_CYCLES_AVERAGE_DISTRIBUTION - 1); + long btcTradeFees = getAccumulatedEstimatedBtcTradeFees(chainHeight, firstBlockOfPastCycle); + + return Math.round((reimbursements + btcTradeFees) / (double) NUM_CYCLES_AVERAGE_DISTRIBUTION); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Private + /////////////////////////////////////////////////////////////////////////////////////////// + + private Stream getReimbursementProposalsForIssuance(Issuance issuance) { + return proposalService.getProposalPayloads().stream() + .map(ProposalPayload::getProposal) + .filter(proposal -> issuance.getTxId().equals(proposal.getTxId())) + .filter(proposal -> proposal instanceof ReimbursementProposal) + .map(proposal -> (ReimbursementProposal) proposal); + } + + private long getAccumulatedReimbursements(int chainHeight, int fromBlock) { + return getReimbursements(chainHeight).stream() + .filter(reimbursementModel -> reimbursementModel.getHeight() > fromBlock) + .filter(reimbursementModel -> reimbursementModel.getHeight() <= chainHeight) + .mapToLong(ReimbursementModel::getAmount) + .sum(); + } + + // The BTC fees are set by parameter and becomes active at first block of the next cycle after voting. + private long getAccumulatedEstimatedBtcTradeFees(int chainHeight, int fromBlock) { + return daoStateService.getCycles().stream() + .filter(cycle -> cycle.getHeightOfFirstBlock() >= fromBlock) + .filter(cycle -> cycle.getHeightOfFirstBlock() <= chainHeight) + .mapToLong(this::getBtcTradeFeeFromParam) + .sum(); + } + + private long getBtcTradeFeeFromParam(Cycle cycle) { + int value = daoStateService.getParamValueAsBlock(Param.LOCK_TIME_TRADE_PAYOUT, cycle.getHeightOfFirstBlock()); + // Ignore default value (4320) + return value != 4320 ? value : DEFAULT_ESTIMATED_BTC_TRADE_FEE_REVENUE_PER_CYCLE; + } + + private Set getProofOfBurnTxs(int chainHeight, int fromBlock) { + return daoStateService.getProofOfBurnTxs().stream() + .filter(tx -> tx.getBlockHeight() > fromBlock) + .filter(tx -> tx.getBlockHeight() <= chainHeight) + .collect(Collectors.toSet()); + } + + + private long getBurnedAmountFromLegacyBurningManDPT(Set proofOfBurnTxs, int chainHeight, int fromBlock) { + // Legacy burningman use those opReturn data to mark their burn transactions from delayed payout transaction cases. + // opReturn data from delayed payout txs when BM traded with the refund agent: 1701e47e5d8030f444c182b5e243871ebbaeadb5e82f + // opReturn data from delayed payout txs when BM traded with traders who got reimbursed by the DAO: 1701293c488822f98e70e047012f46f5f1647f37deb7 + return proofOfBurnTxs.stream() + .filter(tx -> tx.getBlockHeight() > fromBlock) + .filter(tx -> tx.getBlockHeight() <= chainHeight) + .filter(tx -> { + String hash = Hex.encode(tx.getLastTxOutput().getOpReturnData()); + return "1701e47e5d8030f444c182b5e243871ebbaeadb5e82f".equals(hash) || + "1701293c488822f98e70e047012f46f5f1647f37deb7".equals(hash); + }) + .mapToLong(Tx::getBurntBsq) + .sum(); + } + + private long getBurnedAmountFromLegacyBurningMansBtcFees(Set proofOfBurnTxs, int chainHeight, int fromBlock) { + // Legacy burningman use the below opReturn data to mark their burn transactions from Btc trade fees. + return proofOfBurnTxs.stream() + .filter(tx -> tx.getBlockHeight() > fromBlock) + .filter(tx -> tx.getBlockHeight() <= chainHeight) + .filter(tx -> "1701721206fe6b40777763de1c741f4fd2706d94775d".equals(Hex.encode(tx.getLastTxOutput().getOpReturnData()))) + .mapToLong(Tx::getBurntBsq) + .sum(); + } + + private long getBurnedAmountFromBurningMen(Collection burningManCandidates, + int chainHeight, + int fromBlock) { + return burningManCandidates.stream() + .map(burningManCandidate -> burningManCandidate.getBurnOutputModels().stream() + .filter(burnOutputModel -> burnOutputModel.getHeight() > fromBlock) + .filter(burnOutputModel -> burnOutputModel.getHeight() <= chainHeight) + .mapToLong(BurnOutputModel::getAmount) + .sum()) + .mapToLong(e -> e) + .sum(); + } + + long getAccumulatedDecayedBurnedAmount(Collection burningManCandidates, int chainHeight) { + int fromBlock = cyclesInDaoStateService.getChainHeightOfPastCycle(chainHeight, NUM_CYCLES_BURN_TARGET); + return getAccumulatedDecayedBurnedAmount(burningManCandidates, chainHeight, fromBlock); + } + + private long getAccumulatedDecayedBurnedAmount(Collection burningManCandidates, + int chainHeight, + int fromBlock) { + return burningManCandidates.stream() + .map(burningManCandidate -> burningManCandidate.getBurnOutputModels().stream() + .filter(burnOutputModel -> burnOutputModel.getHeight() > fromBlock) + .filter(burnOutputModel -> burnOutputModel.getHeight() <= chainHeight) + .mapToLong(BurnOutputModel::getDecayedAmount) + .sum()) + .mapToLong(e -> e) + .sum(); + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/BurningManPresentationService.java b/core/src/main/java/bisq/core/dao/burningman/BurningManPresentationService.java new file mode 100644 index 0000000000..d019eb73c4 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/BurningManPresentationService.java @@ -0,0 +1,366 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman; + +import bisq.core.btc.wallet.BsqWalletService; +import bisq.core.dao.CyclesInDaoStateService; +import bisq.core.dao.burningman.model.BurnOutputModel; +import bisq.core.dao.burningman.model.BurningManCandidate; +import bisq.core.dao.burningman.model.LegacyBurningMan; +import bisq.core.dao.burningman.model.ReimbursementModel; +import bisq.core.dao.governance.proposal.MyProposalListService; +import bisq.core.dao.state.DaoStateListener; +import bisq.core.dao.state.DaoStateService; +import bisq.core.dao.state.model.blockchain.BaseTx; +import bisq.core.dao.state.model.blockchain.Block; +import bisq.core.dao.state.model.blockchain.Tx; +import bisq.core.dao.state.model.blockchain.TxOutput; +import bisq.core.dao.state.model.governance.CompensationProposal; +import bisq.core.dao.state.model.governance.Proposal; + +import bisq.network.p2p.storage.P2PDataStorage; + +import bisq.common.config.Config; +import bisq.common.util.Hex; +import bisq.common.util.Tuple2; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.annotations.VisibleForTesting; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import lombok.extern.slf4j.Slf4j; + +/** + * Provides APIs for burningman data representation in the UI. + */ +@Slf4j +@Singleton +public class BurningManPresentationService implements DaoStateListener { + // Burn target gets increased by that amount to give more flexibility. + // Burn target is calculated from reimbursements + estimated BTC fees - burned amounts. + private static final long BURN_TARGET_BOOST_AMOUNT = Config.baseCurrencyNetwork().isRegtest() ? 1000000 : 10000000; + // To avoid that the BM get locked in small total burn amounts we allow to burn up to 1000 BSQ more than the + // calculation to not exceed the cap would suggest. + private static final long MAX_BURN_TARGET_LOWER_FLOOR = 100000; + public static final String LEGACY_BURNING_MAN_DPT_NAME = "Legacy Burningman (DPT)"; + public static final String LEGACY_BURNING_MAN_BTC_FEES_NAME = "Legacy Burningman (BTC fees)"; + static final String LEGACY_BURNING_MAN_BTC_FEES_ADDRESS = "38bZBj5peYS3Husdz7AH3gEUiUbYRD951t"; + // Those are the opReturn data used by legacy BM for burning BTC received from DPT. + // For regtest testing burn bsq and use the pre-image `dpt` which has the hash 14af04ea7e34bd7378b034ddf90da53b7c27a277. + // The opReturn data gets additionally prefixed with 1701 + private static final Set OP_RETURN_DATA_LEGACY_BM_DPT = Config.baseCurrencyNetwork().isRegtest() ? + Set.of("170114af04ea7e34bd7378b034ddf90da53b7c27a277") : + Set.of("1701e47e5d8030f444c182b5e243871ebbaeadb5e82f", + "1701293c488822f98e70e047012f46f5f1647f37deb7"); + // The opReturn data used by legacy BM for burning BTC received from BTC trade fees. + // For regtest testing burn bsq and use the pre-image `fee` which has the hash b3253b7b92bb7f0916b05f10d4fa92be8e48f5e6. + // The opReturn data gets additionally prefixed with 1701 + private static final Set OP_RETURN_DATA_LEGACY_BM_FEES = Config.baseCurrencyNetwork().isRegtest() ? + Set.of("1701b3253b7b92bb7f0916b05f10d4fa92be8e48f5e6") : + Set.of("1701721206fe6b40777763de1c741f4fd2706d94775d"); + + private final DaoStateService daoStateService; + private final CyclesInDaoStateService cyclesInDaoStateService; + private final MyProposalListService myProposalListService; + private final BsqWalletService bsqWalletService; + private final BurningManService burningManService; + private final BurnTargetService burnTargetService; + + private int currentChainHeight; + private Optional burnTarget = Optional.empty(); + private final Map burningManCandidatesByName = new HashMap<>(); + private final Set reimbursements = new HashSet<>(); + private Optional averageDistributionPerCycle = Optional.empty(); + private Set myCompensationRequestNames = null; + @SuppressWarnings("OptionalAssignedToNull") + private Optional> myGenesisOutputNames = null; + private Optional legacyBurningManDPT = Optional.empty(); + private Optional legacyBurningManBtcFees = Optional.empty(); + private final Map> proofOfBurnOpReturnTxOutputByHash = new HashMap<>(); + private final Map burningManNameByAddress = new HashMap<>(); + + @Inject + public BurningManPresentationService(DaoStateService daoStateService, + CyclesInDaoStateService cyclesInDaoStateService, + MyProposalListService myProposalListService, + BsqWalletService bsqWalletService, + BurningManService burningManService, + BurnTargetService burnTargetService) { + this.daoStateService = daoStateService; + this.cyclesInDaoStateService = cyclesInDaoStateService; + this.myProposalListService = myProposalListService; + this.bsqWalletService = bsqWalletService; + this.burningManService = burningManService; + this.burnTargetService = burnTargetService; + + daoStateService.addDaoStateListener(this); + daoStateService.getLastBlock().ifPresent(this::applyBlock); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // DaoStateListener + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void onParseBlockCompleteAfterBatchProcessing(Block block) { + applyBlock(block); + } + + private void applyBlock(Block block) { + currentChainHeight = block.getHeight(); + burningManCandidatesByName.clear(); + reimbursements.clear(); + burnTarget = Optional.empty(); + myCompensationRequestNames = null; + averageDistributionPerCycle = Optional.empty(); + legacyBurningManDPT = Optional.empty(); + legacyBurningManBtcFees = Optional.empty(); + proofOfBurnOpReturnTxOutputByHash.clear(); + burningManNameByAddress.clear(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + public long getBurnTarget() { + if (burnTarget.isPresent()) { + return burnTarget.get(); + } + + burnTarget = Optional.of(burnTargetService.getBurnTarget(currentChainHeight, getBurningManCandidatesByName().values())); + return burnTarget.get(); + } + + public long getBoostedBurnTarget() { + return getBurnTarget() + BURN_TARGET_BOOST_AMOUNT; + } + + public long getAverageDistributionPerCycle() { + if (averageDistributionPerCycle.isPresent()) { + return averageDistributionPerCycle.get(); + } + + averageDistributionPerCycle = Optional.of(burnTargetService.getAverageDistributionPerCycle(currentChainHeight)); + return averageDistributionPerCycle.get(); + } + + public long getExpectedRevenue(BurningManCandidate burningManCandidate) { + return Math.round(burningManCandidate.getCappedBurnAmountShare() * getAverageDistributionPerCycle()); + } + + public Tuple2 getCandidateBurnTarget(BurningManCandidate burningManCandidate) { + long burnTarget = getBurnTarget(); + double compensationShare = burningManCandidate.getCompensationShare(); + if (burnTarget == 0 || compensationShare == 0) { + return new Tuple2<>(0L, 0L); + } + + double maxCompensationShare = Math.min(BurningManService.MAX_BURN_SHARE, compensationShare); + long lowerBaseTarget = Math.round(burnTarget * maxCompensationShare); + long boostedBurnAmount = burnTarget + BURN_TARGET_BOOST_AMOUNT; + double maxBoostedCompensationShare = Math.min(BurningManService.MAX_BURN_SHARE, compensationShare * BurningManService.ISSUANCE_BOOST_FACTOR); + long upperBaseTarget = Math.round(boostedBurnAmount * maxBoostedCompensationShare); + long totalBurnedAmount = burnTargetService.getAccumulatedDecayedBurnedAmount(getBurningManCandidatesByName().values(), currentChainHeight); + + if (totalBurnedAmount == 0) { + return new Tuple2<>(lowerBaseTarget, upperBaseTarget); + } + + double burnAmountShare = burningManCandidate.getBurnAmountShare(); + long candidatesBurnAmount = burningManCandidate.getAccumulatedDecayedBurnAmount(); + if (burnAmountShare < maxBoostedCompensationShare) { + long myBurnAmount = getMissingAmountToReachTargetShare(totalBurnedAmount, candidatesBurnAmount, maxCompensationShare); + long myMaxBurnAmount = getMissingAmountToReachTargetShare(totalBurnedAmount, candidatesBurnAmount, maxBoostedCompensationShare); + + // We limit to base targets + myBurnAmount = Math.min(myBurnAmount, lowerBaseTarget); + myMaxBurnAmount = Math.min(myMaxBurnAmount, upperBaseTarget); + + // We allow at least MAX_BURN_TARGET_LOWER_FLOOR (1000 BSQ) to burn, even if that means to hit the cap to give more flexibility + // when low amounts are burned and the 11% cap would lock in BM to small increments per burn iteration. + myMaxBurnAmount = Math.max(myMaxBurnAmount, MAX_BURN_TARGET_LOWER_FLOOR); + + // If below dust we set value to 0 + myBurnAmount = myBurnAmount < 546 ? 0 : myBurnAmount; + return new Tuple2<>(myBurnAmount, myMaxBurnAmount); + } else { + // We have reached our cap. + return new Tuple2<>(0L, MAX_BURN_TARGET_LOWER_FLOOR); + } + } + + @VisibleForTesting + static long getMissingAmountToReachTargetShare(long total, long myAmount, double myTargetShare) { + long others = total - myAmount; + double shareTargetOthers = 1 - myTargetShare; + double targetAmount = shareTargetOthers > 0 ? myTargetShare / shareTargetOthers * others : 0; + return Math.round(targetAmount) - myAmount; + } + + public Set getReimbursements() { + if (!reimbursements.isEmpty()) { + return reimbursements; + } + + reimbursements.addAll(burnTargetService.getReimbursements(currentChainHeight)); + return reimbursements; + } + + public Optional> findMyGenesisOutputNames() { + // Optional.empty is valid case, so we use null to detect if it was set. + // As it does not change at new blocks its only set once. + //noinspection OptionalAssignedToNull + if (myGenesisOutputNames != null) { + return myGenesisOutputNames; + } + + myGenesisOutputNames = daoStateService.getGenesisTx() + .flatMap(tx -> Optional.ofNullable(bsqWalletService.getTransaction(tx.getId())) + .map(genesisTransaction -> genesisTransaction.getOutputs().stream() + .filter(transactionOutput -> transactionOutput.isMine(bsqWalletService.getWallet())) + .map(transactionOutput -> BurningManService.GENESIS_OUTPUT_PREFIX + transactionOutput.getIndex()) + .collect(Collectors.toSet()) + ) + ); + return myGenesisOutputNames; + } + + public Set getMyCompensationRequestNames() { + // Can be empty, so we compare with null and reset to null at new block + if (myCompensationRequestNames != null) { + return myCompensationRequestNames; + } + myCompensationRequestNames = myProposalListService.getList().stream() + .filter(proposal -> proposal instanceof CompensationProposal) + .map(Proposal::getName) + .collect(Collectors.toSet()); + return myCompensationRequestNames; + } + + public Map getBurningManCandidatesByName() { + // Cached value is only used for currentChainHeight + if (!burningManCandidatesByName.isEmpty()) { + return burningManCandidatesByName; + } + + burningManCandidatesByName.putAll(burningManService.getBurningManCandidatesByName(currentChainHeight)); + return burningManCandidatesByName; + } + + public LegacyBurningMan getLegacyBurningManForDPT() { + if (legacyBurningManDPT.isPresent()) { + return legacyBurningManDPT.get(); + } + + // We do not add the legacy burningman to the list but keep it as class field only to avoid that it + // interferes with usage of the burningManCandidatesByName map. + LegacyBurningMan legacyBurningManDPT = getLegacyBurningMan(burningManService.getLegacyBurningManAddress(currentChainHeight), OP_RETURN_DATA_LEGACY_BM_DPT); + this.legacyBurningManDPT = Optional.of(legacyBurningManDPT); + return legacyBurningManDPT; + } + + public LegacyBurningMan getLegacyBurningManForBtcFees() { + if (legacyBurningManBtcFees.isPresent()) { + return legacyBurningManBtcFees.get(); + } + + // We do not add the legacy burningman to the list but keep it as class field only to avoid that it + // interferes with usage of the burningManCandidatesByName map. + LegacyBurningMan legacyBurningManBtcFees = getLegacyBurningMan(LEGACY_BURNING_MAN_BTC_FEES_ADDRESS, OP_RETURN_DATA_LEGACY_BM_FEES); + + this.legacyBurningManBtcFees = Optional.of(legacyBurningManBtcFees); + return legacyBurningManBtcFees; + } + + private LegacyBurningMan getLegacyBurningMan(String address, Set opReturnData) { + LegacyBurningMan legacyBurningMan = new LegacyBurningMan(address); + // The opReturnData used by legacy BM at burning BSQ. + getProofOfBurnOpReturnTxOutputByHash().values().stream() + .flatMap(txOutputs -> txOutputs.stream() + .filter(txOutput -> { + String opReturnAsHex = Hex.encode(txOutput.getOpReturnData()); + return opReturnData.stream().anyMatch(e -> e.equals(opReturnAsHex)); + })) + .forEach(burnOutput -> { + int burnOutputHeight = burnOutput.getBlockHeight(); + Optional optionalTx = daoStateService.getTx(burnOutput.getTxId()); + long burnOutputAmount = optionalTx.map(Tx::getBurntBsq).orElse(0L); + long date = optionalTx.map(BaseTx::getTime).orElse(0L); + int cycleIndex = cyclesInDaoStateService.getCycleIndexAtChainHeight(burnOutputHeight); + legacyBurningMan.addBurnOutputModel(new BurnOutputModel(burnOutputAmount, + burnOutputAmount, + burnOutputHeight, + burnOutput.getTxId(), + date, + cycleIndex)); + }); + // Set remaining share if the sum of all capped shares does not reach 100%. + double burnAmountShareOfOthers = getBurningManCandidatesByName().values().stream() + .mapToDouble(BurningManCandidate::getCappedBurnAmountShare) + .sum(); + legacyBurningMan.applyBurnAmountShare(1 - burnAmountShareOfOthers); + return legacyBurningMan; + } + + public Map getBurningManNameByAddress() { + if (!burningManNameByAddress.isEmpty()) { + return burningManNameByAddress; + } + // clone to not alter source map. We do not store legacy BM in the source map. + Map burningManCandidatesByName = new HashMap<>(getBurningManCandidatesByName()); + burningManCandidatesByName.put(LEGACY_BURNING_MAN_DPT_NAME, getLegacyBurningManForDPT()); + burningManCandidatesByName.put(LEGACY_BURNING_MAN_BTC_FEES_NAME, getLegacyBurningManForBtcFees()); + + Map> receiverAddressesByBurningManName = new HashMap<>(); + burningManCandidatesByName.forEach((name, burningManCandidate) -> { + receiverAddressesByBurningManName.putIfAbsent(name, new HashSet<>()); + receiverAddressesByBurningManName.get(name).addAll(burningManCandidate.getAllAddresses()); + }); + + + Map map = new HashMap<>(); + receiverAddressesByBurningManName + .forEach((name, addresses) -> addresses + .forEach(address -> map.putIfAbsent(address, name))); + burningManNameByAddress.putAll(map); + return burningManNameByAddress; + } + + public String getGenesisTxId() { + return daoStateService.getGenesisTxId(); + } + + private Map> getProofOfBurnOpReturnTxOutputByHash() { + if (!proofOfBurnOpReturnTxOutputByHash.isEmpty()) { + return proofOfBurnOpReturnTxOutputByHash; + } + + proofOfBurnOpReturnTxOutputByHash.putAll(burningManService.getProofOfBurnOpReturnTxOutputByHash(currentChainHeight)); + return proofOfBurnOpReturnTxOutputByHash; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/BurningManService.java b/core/src/main/java/bisq/core/dao/burningman/BurningManService.java new file mode 100644 index 0000000000..306a388bad --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/BurningManService.java @@ -0,0 +1,330 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman; + +import bisq.core.dao.CyclesInDaoStateService; +import bisq.core.dao.burningman.model.BurnOutputModel; +import bisq.core.dao.burningman.model.BurningManCandidate; +import bisq.core.dao.burningman.model.CompensationModel; +import bisq.core.dao.governance.param.Param; +import bisq.core.dao.governance.proofofburn.ProofOfBurnConsensus; +import bisq.core.dao.governance.proposal.ProposalService; +import bisq.core.dao.governance.proposal.storage.appendonly.ProposalPayload; +import bisq.core.dao.state.DaoStateService; +import bisq.core.dao.state.model.blockchain.BaseTx; +import bisq.core.dao.state.model.blockchain.Tx; +import bisq.core.dao.state.model.blockchain.TxOutput; +import bisq.core.dao.state.model.governance.CompensationProposal; +import bisq.core.dao.state.model.governance.Issuance; +import bisq.core.dao.state.model.governance.IssuanceType; + +import bisq.network.p2p.storage.P2PDataStorage; + +import bisq.common.config.Config; +import bisq.common.util.Utilities; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableList; + +import java.util.Collection; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; + +import lombok.extern.slf4j.Slf4j; + +/** + * Methods are used by the DelayedPayoutTxReceiverService, which is used in the trade protocol for creating and + * verifying the delayed payout transaction. As verification is done by trade peer it requires data to be deterministic. + * Parameters listed here must not be changed as they could break verification of the peers + * delayed payout transaction in case not both traders are using the same version. + */ +@Slf4j +@Singleton +public class BurningManService { + private static final Date ACTIVATION_DATE = Utilities.getUTCDate(2023, GregorianCalendar.JANUARY, 1); + + public static boolean isActivated() { + return Config.baseCurrencyNetwork().isRegtest() || new Date().after(ACTIVATION_DATE); + } + + // Parameters + // Cannot be changed after release as it would break trade protocol verification of DPT receivers. + + // Prefix for generic names for the genesis outputs. Appended with output index. + // Used as pre-image for burning. + static final String GENESIS_OUTPUT_PREFIX = "Bisq co-founder "; + + // Factor for weighting the genesis output amounts. + private static final double GENESIS_OUTPUT_AMOUNT_FACTOR = 0.05; + + // The number of cycles we go back for the decay function used for compensation request amounts. + private static final int NUM_CYCLES_COMP_REQUEST_DECAY = 24; + + // The number of cycles we go back for the decay function used for burned amounts. + private static final int NUM_CYCLES_BURN_AMOUNT_DECAY = 12; + + // Factor for boosting the issuance share (issuance is compensation requests + genesis output). + // This will be used for increasing the allowed burn amount. The factor gives more flexibility + // and compensates for those who do not burn. The burn share is capped by that factor as well. + // E.g. a contributor with 10% issuance share will be able to receive max 20% of the BTC fees or DPT output + // even if they had burned more and had a higher burn share than 20%. + public static final double ISSUANCE_BOOST_FACTOR = 3; + + // The max amount the burn share can reach. This value is derived from the min. security deposit in a trade and + // ensures that an attack where a BM would take all sell offers cannot be economically profitable as they would + // lose their deposit and cannot gain more than 11% of the DPT payout. As the total amount in a trade is 2 times + // that deposit plus the trade amount the limiting factor here is 11% (0.15 / 1.3). + public static final double MAX_BURN_SHARE = 0.11; + + + private final DaoStateService daoStateService; + private final CyclesInDaoStateService cyclesInDaoStateService; + private final ProposalService proposalService; + + @Inject + public BurningManService(DaoStateService daoStateService, + CyclesInDaoStateService cyclesInDaoStateService, + ProposalService proposalService) { + this.daoStateService = daoStateService; + this.cyclesInDaoStateService = cyclesInDaoStateService; + this.proposalService = proposalService; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Package scope API + /////////////////////////////////////////////////////////////////////////////////////////// + + Map getBurningManCandidatesByName(int chainHeight) { + Map burningManCandidatesByName = new HashMap<>(); + Map> proofOfBurnOpReturnTxOutputByHash = getProofOfBurnOpReturnTxOutputByHash(chainHeight); + + // Add contributors who made a compensation request + daoStateService.getIssuanceSetForType(IssuanceType.COMPENSATION).stream() + .filter(issuance -> issuance.getChainHeight() <= chainHeight) + .forEach(issuance -> { + getCompensationProposalsForIssuance(issuance).forEach(compensationProposal -> { + String name = compensationProposal.getName(); + burningManCandidatesByName.putIfAbsent(name, new BurningManCandidate()); + BurningManCandidate candidate = burningManCandidatesByName.get(name); + + // Issuance + compensationProposal.getBurningManReceiverAddress() + .or(() -> daoStateService.getTx(compensationProposal.getTxId()) + .map(this::getAddressFromCompensationRequest)) + .ifPresent(address -> { + int issuanceHeight = issuance.getChainHeight(); + long issuanceAmount = getIssuanceAmountForCompensationRequest(issuance); + int cycleIndex = cyclesInDaoStateService.getCycleIndexAtChainHeight(issuanceHeight); + if (isValidCompensationRequest(name, cycleIndex, issuanceAmount)) { + long decayedIssuanceAmount = getDecayedCompensationAmount(issuanceAmount, issuanceHeight, chainHeight); + long issuanceDate = daoStateService.getBlockTime(issuanceHeight); + candidate.addCompensationModel(CompensationModel.fromCompensationRequest(address, + issuanceAmount, + decayedIssuanceAmount, + issuanceHeight, + issuance.getTxId(), + issuanceDate, + cycleIndex)); + } + }); + + addBurnOutputModel(chainHeight, proofOfBurnOpReturnTxOutputByHash, name, candidate); + }); + } + ); + + // Add output receivers of genesis transaction + daoStateService.getGenesisTx() + .ifPresent(tx -> tx.getTxOutputs().forEach(txOutput -> { + String name = GENESIS_OUTPUT_PREFIX + txOutput.getIndex(); + burningManCandidatesByName.putIfAbsent(name, new BurningManCandidate()); + BurningManCandidate candidate = burningManCandidatesByName.get(name); + + // Issuance + int issuanceHeight = txOutput.getBlockHeight(); + long issuanceAmount = txOutput.getValue(); + long decayedAmount = getDecayedGenesisOutputAmount(issuanceAmount); + long issuanceDate = daoStateService.getBlockTime(issuanceHeight); + candidate.addCompensationModel(CompensationModel.fromGenesisOutput(txOutput.getAddress(), + issuanceAmount, + decayedAmount, + issuanceHeight, + txOutput.getTxId(), + txOutput.getIndex(), + issuanceDate)); + addBurnOutputModel(chainHeight, proofOfBurnOpReturnTxOutputByHash, name, candidate); + })); + + Collection burningManCandidates = burningManCandidatesByName.values(); + double totalDecayedCompensationAmounts = burningManCandidates.stream() + .mapToDouble(BurningManCandidate::getAccumulatedDecayedCompensationAmount) + .sum(); + double totalDecayedBurnAmounts = burningManCandidates.stream() + .mapToDouble(BurningManCandidate::getAccumulatedDecayedBurnAmount) + .sum(); + burningManCandidates.forEach(candidate -> candidate.calculateShares(totalDecayedCompensationAmounts, totalDecayedBurnAmounts)); + return burningManCandidatesByName; + } + + String getLegacyBurningManAddress(int chainHeight) { + return daoStateService.getParamValue(Param.RECIPIENT_BTC_ADDRESS, chainHeight); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Private + /////////////////////////////////////////////////////////////////////////////////////////// + + Map> getProofOfBurnOpReturnTxOutputByHash(int chainHeight) { + Map> map = new HashMap<>(); + daoStateService.getProofOfBurnOpReturnTxOutputs().stream() + .filter(txOutput -> txOutput.getBlockHeight() <= chainHeight) + .forEach(txOutput -> { + P2PDataStorage.ByteArray key = new P2PDataStorage.ByteArray(ProofOfBurnConsensus.getHashFromOpReturnData(txOutput.getOpReturnData())); + map.putIfAbsent(key, new HashSet<>()); + map.get(key).add(txOutput); + }); + return map; + } + + private Stream getCompensationProposalsForIssuance(Issuance issuance) { + return proposalService.getProposalPayloads().stream() + .map(ProposalPayload::getProposal) + .filter(proposal -> issuance.getTxId().equals(proposal.getTxId())) + .filter(proposal -> proposal instanceof CompensationProposal) + .map(proposal -> (CompensationProposal) proposal); + } + + + private String getAddressFromCompensationRequest(Tx tx) { + ImmutableList txOutputs = tx.getTxOutputs(); + // The compensation request tx has usually 4 outputs. If there is no BTC change its 3 outputs. + // BTC change output is at index 2 if present otherwise + // we use the BSQ address of the compensation candidate output at index 1. + // See https://docs.bisq.network/dao-technical-overview.html#compensation-request-txreimbursement-request-tx + if (txOutputs.size() == 4) { + return txOutputs.get(2).getAddress(); + } else { + return txOutputs.get(1).getAddress(); + } + } + + private long getIssuanceAmountForCompensationRequest(Issuance issuance) { + // There was a reimbursement for a conference sponsorship with 44776 BSQ. We remove that as well. + // See https://github.com/bisq-network/compensation/issues/498 + if (issuance.getTxId().equals("01455fc4c88fca0665a5f56a90ff03fb9e3e88c3430ffc5217246e32d180aa64")) { + return 119400; // That was the compensation part + } else { + return issuance.getAmount(); + } + } + + private boolean isValidCompensationRequest(String name, int cycleIndex, long issuanceAmount) { + // Up to cycle 15 the RefundAgent made reimbursement requests as compensation requests. We filter out those entries. + // As it is mixed with RefundAgents real compensation requests we take out all above 3500 BSQ. + boolean isReimbursementOfRefundAgent = name.equals("RefundAgent") && cycleIndex <= 15 && issuanceAmount > 350000; + return !isReimbursementOfRefundAgent; + } + + private long getDecayedCompensationAmount(long amount, int issuanceHeight, int chainHeight) { + int chainHeightOfPastCycle = cyclesInDaoStateService.getChainHeightOfPastCycle(chainHeight, NUM_CYCLES_COMP_REQUEST_DECAY); + return getDecayedAmount(amount, issuanceHeight, chainHeight, chainHeightOfPastCycle); + } + + // Linear decay between currentBlockHeight (100% of amount) and issuanceHeight + // chainHeightOfPastCycle is currentBlockHeight - numCycles*cycleDuration. It changes with each block and + // distance to currentBlockHeight is the same if cycle durations have not changed (possible via DAo voting but never done). + @VisibleForTesting + static long getDecayedAmount(long amount, + int issuanceHeight, + int currentBlockHeight, + int chainHeightOfPastCycle) { + if (issuanceHeight > currentBlockHeight) + throw new IllegalArgumentException("issuanceHeight must not be larger than currentBlockHeight. issuanceHeight=" + issuanceHeight + "; currentBlockHeight=" + currentBlockHeight); + if (currentBlockHeight < 0) + throw new IllegalArgumentException("currentBlockHeight must not be negative. currentBlockHeight=" + currentBlockHeight); + if (amount < 0) + throw new IllegalArgumentException("amount must not be negative. amount" + amount); + if (issuanceHeight < 0) + throw new IllegalArgumentException("issuanceHeight must not be negative. issuanceHeight=" + issuanceHeight); + + if (currentBlockHeight <= chainHeightOfPastCycle) { + return amount; + } + + double factor = Math.max(0, (issuanceHeight - chainHeightOfPastCycle) / (double) (currentBlockHeight - chainHeightOfPastCycle)); + long weighted = Math.round(amount * factor); + return Math.max(0, weighted); + } + + private void addBurnOutputModel(int chainHeight, + Map> proofOfBurnOpReturnTxOutputByHash, + String name, + BurningManCandidate candidate) { + getProofOfBurnOpReturnTxOutputSetForName(proofOfBurnOpReturnTxOutputByHash, name) + .forEach(burnOutput -> { + int burnOutputHeight = burnOutput.getBlockHeight(); + Optional optionalTx = daoStateService.getTx(burnOutput.getTxId()); + long burnOutputAmount = optionalTx.map(Tx::getBurntBsq).orElse(0L); + long decayedBurnOutputAmount = getDecayedBurnedAmount(burnOutputAmount, burnOutputHeight, chainHeight); + long date = optionalTx.map(BaseTx::getTime).orElse(0L); + int cycleIndex = cyclesInDaoStateService.getCycleIndexAtChainHeight(burnOutputHeight); + candidate.addBurnOutputModel(new BurnOutputModel(burnOutputAmount, + decayedBurnOutputAmount, + burnOutputHeight, + burnOutput.getTxId(), + date, + cycleIndex)); + }); + } + + private static Set getProofOfBurnOpReturnTxOutputSetForName(Map> proofOfBurnOpReturnTxOutputByHash, + String name) { + byte[] preImage = name.getBytes(Charsets.UTF_8); + byte[] hash = ProofOfBurnConsensus.getHash(preImage); + P2PDataStorage.ByteArray key = new P2PDataStorage.ByteArray(hash); + if (proofOfBurnOpReturnTxOutputByHash.containsKey(key)) { + return proofOfBurnOpReturnTxOutputByHash.get(key); + } else { + return new HashSet<>(); + } + } + + private long getDecayedBurnedAmount(long amount, int issuanceHeight, int chainHeight) { + int chainHeightOfPastCycle = cyclesInDaoStateService.getChainHeightOfPastCycle(chainHeight, NUM_CYCLES_BURN_AMOUNT_DECAY); + return getDecayedAmount(amount, + issuanceHeight, + chainHeight, + chainHeightOfPastCycle); + } + + private long getDecayedGenesisOutputAmount(long amount) { + return Math.round(amount * GENESIS_OUTPUT_AMOUNT_FACTOR); + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/DelayedPayoutTxReceiverService.java b/core/src/main/java/bisq/core/dao/burningman/DelayedPayoutTxReceiverService.java new file mode 100644 index 0000000000..288b8f5bb6 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/DelayedPayoutTxReceiverService.java @@ -0,0 +1,188 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman; + +import bisq.core.btc.wallet.TradeWalletService; +import bisq.core.dao.burningman.model.BurningManCandidate; +import bisq.core.dao.state.DaoStateListener; +import bisq.core.dao.state.DaoStateService; +import bisq.core.dao.state.model.blockchain.Block; + +import bisq.common.util.Tuple2; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.annotations.VisibleForTesting; + +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import lombok.extern.slf4j.Slf4j; + +/** + * Used in the trade protocol for creating and verifying the delayed payout transaction. + * Requires to be deterministic. + * Changes in the parameters related to the receivers list could break verification of the peers + * delayed payout transaction in case not both are using the same version. + */ +@Slf4j +@Singleton +public class DelayedPayoutTxReceiverService implements DaoStateListener { + // One part of the limit for the min. amount to be included in the DPT outputs. + // The miner fee rate multiplied by 2 times the output size is the other factor. + // The higher one of both is used. 1000 sat is about 2 USD @ 20k price. + private static final long DPT_MIN_OUTPUT_AMOUNT = 1000; + + // If at DPT there is some leftover amount due to capping of some receivers (burn share is + // max. ISSUANCE_BOOST_FACTOR times the issuance share) we send it to legacy BM if it is larger + // than DPT_MIN_REMAINDER_TO_LEGACY_BM, otherwise we spend it as miner fee. + // 50000 sat is about 10 USD @ 20k price. We use a rather high value as we want to avoid that the legacy BM + // gets still payouts. + private static final long DPT_MIN_REMAINDER_TO_LEGACY_BM = 50000; + + // Min. fee rate for DPT. If fee rate used at take offer time was higher we use that. + // We prefer a rather high fee rate to not risk that the DPT gets stuck if required fee rate would + // spike when opening arbitration. + private static final long DPT_MIN_TX_FEE_RATE = 10; + + + private final DaoStateService daoStateService; + private final BurningManService burningManService; + private int currentChainHeight; + + @Inject + public DelayedPayoutTxReceiverService(DaoStateService daoStateService, BurningManService burningManService) { + this.daoStateService = daoStateService; + this.burningManService = burningManService; + + daoStateService.addDaoStateListener(this); + daoStateService.getLastBlock().ifPresent(this::applyBlock); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // DaoStateListener + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void onParseBlockCompleteAfterBatchProcessing(Block block) { + applyBlock(block); + } + + private void applyBlock(Block block) { + currentChainHeight = block.getHeight(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + // We use a snapshot blockHeight to avoid failed trades in case maker and taker have different block heights. + // The selection is deterministic based on DAO data. + // The block height is the last mod(10) height from the range of the last 10-20 blocks (139 -> 120; 140 -> 130, 141 -> 130). + // We do not have the latest dao state by that but can ensure maker and taker have the same block. + public int getBurningManSelectionHeight() { + return getSnapshotHeight(daoStateService.getGenesisBlockHeight(), currentChainHeight, 10); + } + + public List> getReceivers(int burningManSelectionHeight, + long inputAmount, + long tradeTxFee) { + Collection burningManCandidates = burningManService.getBurningManCandidatesByName(burningManSelectionHeight).values(); + if (burningManCandidates.isEmpty()) { + // If there are no compensation requests (e.g. at dev testing) we fall back to the legacy BM + return List.of(new Tuple2<>(inputAmount, burningManService.getLegacyBurningManAddress(burningManSelectionHeight))); + } + + // We need to use the same txFeePerVbyte value for both traders. + // We use the tradeTxFee value which is calculated from the average of taker fee tx size and deposit tx size. + // Otherwise, we would need to sync the fee rate of both traders. + // In case of very large taker fee tx we would get a too high fee, but as fee rate is anyway rather + // arbitrary and volatile we are on the safer side. The delayed payout tx is published long after the + // take offer event and the recommended fee at that moment might be very different to actual + // recommended fee. To avoid that the delayed payout tx would get stuck due too low fees we use a + // min. fee rate of 10 sat/vByte. + + // Deposit tx has a clearly defined structure, so we know the size. It is only one optional output if range amount offer was taken. + // Smallest tx size is 246. With additional change output we add 32. To be safe we use the largest expected size. + double txSize = 278; + long txFeePerVbyte = Math.max(DPT_MIN_TX_FEE_RATE, Math.round(tradeTxFee / txSize)); + long spendableAmount = getSpendableAmount(burningManCandidates.size(), inputAmount, txFeePerVbyte); + // We only use outputs > 1000 sat or at least 2 times the cost for the output (32 bytes). + // If we remove outputs it will be spent as miner fee. + long minOutputAmount = Math.max(DPT_MIN_OUTPUT_AMOUNT, txFeePerVbyte * 32 * 2); + // We accumulate small amounts which gets filtered out and subtract it from 1 to get an adjustment factor + // used later to be applied to the remaining burningmen share. + double adjustment = 1 - burningManCandidates.stream() + .filter(candidate -> candidate.getMostRecentAddress().isPresent()) + .mapToDouble(candidate -> { + double cappedBurnAmountShare = candidate.getCappedBurnAmountShare(); + long amount = Math.round(cappedBurnAmountShare * spendableAmount); + return amount < minOutputAmount ? cappedBurnAmountShare : 0d; + }) + .sum(); + + List> receivers = burningManCandidates.stream() + .filter(candidate -> candidate.getMostRecentAddress().isPresent()) + .map(candidate -> { + double cappedBurnAmountShare = candidate.getCappedBurnAmountShare() / adjustment; + return new Tuple2<>(Math.round(cappedBurnAmountShare * spendableAmount), + candidate.getMostRecentAddress().get()); + }) + .filter(tuple -> tuple.first >= minOutputAmount) + .sorted(Comparator., Long>comparing(tuple -> tuple.first) + .thenComparing(tuple -> tuple.second)) + .collect(Collectors.toList()); + long totalOutputValue = receivers.stream().mapToLong(e -> e.first).sum(); + if (totalOutputValue < spendableAmount) { + long available = spendableAmount - totalOutputValue; + // If the available is larger than DPT_MIN_REMAINDER_TO_LEGACY_BM we send it to legacy BM + // Otherwise we use it as miner fee + if (available > DPT_MIN_REMAINDER_TO_LEGACY_BM) { + receivers.add(new Tuple2<>(available, burningManService.getLegacyBurningManAddress(burningManSelectionHeight))); + } + } + return receivers; + } + + private static long getSpendableAmount(int numOutputs, long inputAmount, long txFeePerVbyte) { + // Output size: 32 bytes + // Tx size without outputs: 51 bytes + int txSize = 51 + numOutputs * 32; // min value: txSize=83 + long minerFee = txFeePerVbyte * txSize; // min value: minerFee=830 + // We need to make sure we have at least 1000 sat as defined in TradeWalletService + minerFee = Math.max(TradeWalletService.MIN_DELAYED_PAYOUT_TX_FEE.value, minerFee); + return inputAmount - minerFee; + } + + // Borrowed from DaoStateSnapshotService. We prefer to not reuse to avoid dependency to an unrelated domain. + @VisibleForTesting + static int getSnapshotHeight(int genesisHeight, int height, int grid) { + int minSnapshotHeight = genesisHeight + 3 * grid; + if (height > minSnapshotHeight) { + int ratio = (int) Math.round(height / (double) grid); + return ratio * grid - grid; + } else { + return genesisHeight; + } + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/BurningManAccountingService.java b/core/src/main/java/bisq/core/dao/burningman/accounting/BurningManAccountingService.java new file mode 100644 index 0000000000..46b61a5092 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/BurningManAccountingService.java @@ -0,0 +1,257 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting; + +import bisq.core.dao.DaoSetupService; +import bisq.core.dao.burningman.BurningManPresentationService; +import bisq.core.dao.burningman.accounting.balance.BalanceEntry; +import bisq.core.dao.burningman.accounting.balance.BalanceModel; +import bisq.core.dao.burningman.accounting.balance.ReceivedBtcBalanceEntry; +import bisq.core.dao.burningman.accounting.blockchain.AccountingBlock; +import bisq.core.dao.burningman.accounting.blockchain.AccountingTx; +import bisq.core.dao.burningman.accounting.exceptions.BlockHashNotConnectingException; +import bisq.core.dao.burningman.accounting.exceptions.BlockHeightNotConnectingException; +import bisq.core.dao.burningman.accounting.storage.BurningManAccountingStoreService; +import bisq.core.monetary.Price; +import bisq.core.trade.statistics.TradeStatisticsManager; +import bisq.core.user.Preferences; +import bisq.core.util.AveragePriceUtil; + +import bisq.common.UserThread; +import bisq.common.config.Config; +import bisq.common.util.DateUtil; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import java.util.Arrays; +import java.util.Calendar; +import java.util.Comparator; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Provides APIs for the accounting related aspects of burningmen. + * Combines the received funds from BTC trade fees and DPT payouts and the burned BSQ. + */ +@Slf4j +@Singleton +public class BurningManAccountingService implements DaoSetupService { + // now 763195 -> 107159 blocks takes about 14h + // First tx at BM address 656036 Sun Nov 08 19:02:18 EST 2020 + // 2 months ago 754555 + public static final int EARLIEST_BLOCK_HEIGHT = Config.baseCurrencyNetwork().isRegtest() ? 111 : 656035; + public static final int EARLIEST_DATE_YEAR = 2020; + public static final int EARLIEST_DATE_MONTH = 10; + + private final BurningManAccountingStoreService burningManAccountingStoreService; + private final BurningManPresentationService burningManPresentationService; + private final TradeStatisticsManager tradeStatisticsManager; + private final Preferences preferences; + + @Getter + private final Map averageBsqPriceByMonth = new HashMap<>(getHistoricalAverageBsqPriceByMonth()); + @Getter + private final Map balanceModelByBurningManName = new HashMap<>(); + + @Inject + public BurningManAccountingService(BurningManAccountingStoreService burningManAccountingStoreService, + BurningManPresentationService burningManPresentationService, + TradeStatisticsManager tradeStatisticsManager, + Preferences preferences) { + this.burningManAccountingStoreService = burningManAccountingStoreService; + this.burningManPresentationService = burningManPresentationService; + this.tradeStatisticsManager = tradeStatisticsManager; + this.preferences = preferences; + + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // DaoSetupService + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void addListeners() { + } + + @Override + public void start() { + // Create the map from now back to the last entry of the historical data (April 2019-Nov. 2022). + averageBsqPriceByMonth.putAll(getAverageBsqPriceByMonth(new Date(), 2022, 10)); + + updateBalanceModelByAddress(); + CompletableFuture.runAsync(() -> { + Map map = new HashMap<>(); + // addAccountingBlockToBalanceModel takes about 500ms for 100k items, so we run it in a non UI thread. + getBlocks().forEach(block -> addAccountingBlockToBalanceModel(map, block)); + UserThread.execute(() -> balanceModelByBurningManName.putAll(map)); + }); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + public void onInitialBlockRequestsComplete() { + updateBalanceModelByAddress(); + getBlocks().forEach(this::addAccountingBlockToBalanceModel); + } + + public void onNewBlockReceived(AccountingBlock accountingBlock) { + updateBalanceModelByAddress(); + addAccountingBlockToBalanceModel(accountingBlock); + } + + public void addBlock(AccountingBlock block) throws BlockHashNotConnectingException, BlockHeightNotConnectingException { + if (!getBlocks().contains(block)) { + Optional optionalLastBlock = getLastBlock(); + if (optionalLastBlock.isPresent()) { + AccountingBlock lastBlock = optionalLastBlock.get(); + if (block.getHeight() != lastBlock.getHeight() + 1) { + throw new BlockHeightNotConnectingException(); + } + if (!Arrays.equals(block.getTruncatedPreviousBlockHash(), lastBlock.getTruncatedHash())) { + throw new BlockHashNotConnectingException(); + } + } else if (block.getHeight() != EARLIEST_BLOCK_HEIGHT) { + throw new BlockHeightNotConnectingException(); + } + log.info("Add new accountingBlock at height {} at {} with {} txs", block.getHeight(), + new Date(block.getDate()), block.getTxs().size()); + burningManAccountingStoreService.addBlock(block); + } else { + log.info("We have that block already. Height: {}", block.getHeight()); + } + } + + public int getBlockHeightOfLastBlock() { + return getLastBlock().map(AccountingBlock::getHeight).orElse(BurningManAccountingService.EARLIEST_BLOCK_HEIGHT - 1); + } + + public Optional getLastBlock() { + return getBlocks().stream().max(Comparator.comparing(AccountingBlock::getHeight)); + } + + public Optional getBlockAtHeight(int height) { + return getBlocks().stream().filter(block -> block.getHeight() == height).findAny(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Delegates + /////////////////////////////////////////////////////////////////////////////////////////// + + public List getBlocks() { + return burningManAccountingStoreService.getBlocks(); + } + + public Map getBurningManNameByAddress() { + return burningManPresentationService.getBurningManNameByAddress(); + } + + public String getGenesisTxId() { + return burningManPresentationService.getGenesisTxId(); + } + + public void purgeLastTenBlocks() { + burningManAccountingStoreService.purgeLastTenBlocks(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Private + /////////////////////////////////////////////////////////////////////////////////////////// + + private void updateBalanceModelByAddress() { + burningManPresentationService.getBurningManCandidatesByName().keySet() + .forEach(key -> balanceModelByBurningManName.putIfAbsent(key, new BalanceModel())); + } + + private void addAccountingBlockToBalanceModel(AccountingBlock accountingBlock) { + addAccountingBlockToBalanceModel(balanceModelByBurningManName, accountingBlock); + } + + private void addAccountingBlockToBalanceModel(Map balanceModelByBurningManName, + AccountingBlock accountingBlock) { + accountingBlock.getTxs().forEach(tx -> { + tx.getOutputs().forEach(txOutput -> { + String name = txOutput.getName(); + balanceModelByBurningManName.putIfAbsent(name, new BalanceModel()); + balanceModelByBurningManName.get(name).addReceivedBtcBalanceEntry(new ReceivedBtcBalanceEntry(tx.getTruncatedTxId(), + txOutput.getValue(), + new Date(accountingBlock.getDate()), + toBalanceEntryType(tx.getType()))); + }); + }); + } + + private Map getAverageBsqPriceByMonth(Date from, int toYear, int toMonth) { + Map averageBsqPriceByMonth = new HashMap<>(); + Calendar calendar = new GregorianCalendar(); + calendar.setTime(from); + int year = calendar.get(Calendar.YEAR); + int month = calendar.get(Calendar.MONTH); + do { + for (; month >= 0; month--) { + if (year == toYear && month == toMonth) { + break; + } + Date date = DateUtil.getStartOfMonth(year, month); + Price averageBsqPrice = AveragePriceUtil.getAveragePriceTuple(preferences, tradeStatisticsManager, 30, date).second; + averageBsqPriceByMonth.put(date, averageBsqPrice); + } + year--; + month = 11; + } while (year >= toYear); + return averageBsqPriceByMonth; + } + + private static BalanceEntry.Type toBalanceEntryType(AccountingTx.Type type) { + return type == AccountingTx.Type.BTC_TRADE_FEE_TX ? + BalanceEntry.Type.BTC_TRADE_FEE_TX : + BalanceEntry.Type.DPT_TX; + } + + @SuppressWarnings("CommentedOutCode") + private static Map getHistoricalAverageBsqPriceByMonth() { + // We use the average 30 day BSQ price from the first day of a month back 30 days. So for 1.Nov 2022 we take the average during October 2022. + // Filling the map takes a bit of computation time (about 5 sec), so we use for historical data a pre-calculated list. + // Average price from 1. May 2019 (April average) - 1. Nov 2022 (Oct average) + String historical = "1648789200000=2735, 1630472400000=3376, 1612155600000=6235, 1559365200000=13139, 1659330000000=3609, 1633064400000=3196, 1583038800000=7578, 1622523600000=3918, 1625115600000=3791, 1667278800000=3794, 1561957200000=10882, 1593579600000=6153, 1577854800000=9034, 1596258000000=6514, 1604206800000=5642, 1643691600000=3021, 1606798800000=4946, 1569906000000=10445, 1567314000000=9885, 1614574800000=5052, 1656651600000=3311, 1638334800000=3015, 1564635600000=8788, 1635742800000=3065, 1654059600000=3207, 1646110800000=2824, 1609477200000=4199, 1664600400000=3820, 1662008400000=3756, 1556686800000=24094, 1588309200000=7986, 1585717200000=7994, 1627794000000=3465, 1580533200000=5094, 1590987600000=7411, 1619845200000=3956, 1617253200000=4024, 1575176400000=9571, 1572584400000=9058, 1641013200000=3052, 1601528400000=5648, 1651381200000=2908, 1598936400000=6032"; + + // Create historical data as string + /* log.info("averageBsqPriceByMonth=" + getAverageBsqPriceByMonth(new Date(), 2019, 3).entrySet().stream() + .map(e -> e.getKey().getTime() + "=" + e.getValue().getValue()) + .collect(Collectors.toList())); + */ + return Arrays.stream(historical.split(", ")) + .map(chunk -> chunk.split("=")) + .collect(Collectors.toMap(tuple -> new Date(Long.parseLong(tuple[0])), + tuple -> Price.valueOf("BSQ", Long.parseLong(tuple[1])))); + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/balance/BalanceEntry.java b/core/src/main/java/bisq/core/dao/burningman/accounting/balance/BalanceEntry.java new file mode 100644 index 0000000000..5786efde9a --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/balance/BalanceEntry.java @@ -0,0 +1,32 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.balance; + +import java.util.Date; + +public interface BalanceEntry { + Date getDate(); + + Date getMonth(); + + enum Type { + BTC_TRADE_FEE_TX, + DPT_TX, + BURN_TX + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/balance/BalanceModel.java b/core/src/main/java/bisq/core/dao/burningman/accounting/balance/BalanceModel.java new file mode 100644 index 0000000000..e85fec5b64 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/balance/BalanceModel.java @@ -0,0 +1,124 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.balance; + +import bisq.core.dao.burningman.model.BurnOutputModel; +import bisq.core.dao.burningman.model.BurningManCandidate; + +import bisq.common.util.DateUtil; + +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import lombok.EqualsAndHashCode; +import lombok.extern.slf4j.Slf4j; + +import static bisq.core.dao.burningman.accounting.BurningManAccountingService.EARLIEST_DATE_MONTH; +import static bisq.core.dao.burningman.accounting.BurningManAccountingService.EARLIEST_DATE_YEAR; + +@Slf4j +@EqualsAndHashCode +public class BalanceModel { + private final Set receivedBtcBalanceEntries = new HashSet<>(); + private final Map> receivedBtcBalanceEntriesByMonth = new HashMap<>(); + + public BalanceModel() { + } + + public void addReceivedBtcBalanceEntry(ReceivedBtcBalanceEntry balanceEntry) { + receivedBtcBalanceEntries.add(balanceEntry); + + Date month = balanceEntry.getMonth(); + receivedBtcBalanceEntriesByMonth.putIfAbsent(month, new HashSet<>()); + receivedBtcBalanceEntriesByMonth.get(month).add(balanceEntry); + } + + public Set getReceivedBtcBalanceEntries() { + return receivedBtcBalanceEntries; + } + + public Stream getBurnedBsqBalanceEntries(Set burnOutputModels) { + return burnOutputModels.stream() + .map(burnOutputModel -> new BurnedBsqBalanceEntry(burnOutputModel.getTxId(), + burnOutputModel.getAmount(), + new Date(burnOutputModel.getDate()))); + } + + public List getMonthlyBalanceEntries(BurningManCandidate burningManCandidate, + Predicate predicate) { + Map> burnOutputModelsByMonth = burningManCandidate.getBurnOutputModelsByMonth(); + Set months = getMonths(new Date(), EARLIEST_DATE_YEAR, EARLIEST_DATE_MONTH); + return months.stream() + .map(date -> { + long sumBurnedBsq = 0; + Set types = new HashSet<>(); + if (burnOutputModelsByMonth.containsKey(date)) { + Set burnOutputModels = burnOutputModelsByMonth.get(date); + Set monthlyBurnedBsqBalanceEntries = burnOutputModels.stream() + .map(burnOutputModel -> new MonthlyBurnedBsqBalanceEntry(burnOutputModel.getTxId(), + burnOutputModel.getAmount(), + date)) + .collect(Collectors.toSet()); + sumBurnedBsq = monthlyBurnedBsqBalanceEntries.stream() + .filter(predicate) + .peek(e -> types.add(e.getType())) + .mapToLong(MonthlyBurnedBsqBalanceEntry::getAmount) + .sum(); + } + long sumReceivedBtc = 0; + if (receivedBtcBalanceEntriesByMonth.containsKey(date)) { + sumReceivedBtc = receivedBtcBalanceEntriesByMonth.get(date).stream() + .filter(predicate) + .peek(e -> types.add(e.getType())) + .mapToLong(BaseBalanceEntry::getAmount) + .sum(); + } + return new MonthlyBalanceEntry(sumReceivedBtc, sumBurnedBsq, date, types); + }) + .filter(balanceEntry -> balanceEntry.getBurnedBsq() > 0 || balanceEntry.getReceivedBtc() > 0) + .collect(Collectors.toList()); + } + + private Set getMonths(Date from, int toYear, int toMonth) { + Set map = new HashSet<>(); + Calendar calendar = new GregorianCalendar(); + calendar.setTime(from); + int year = calendar.get(Calendar.YEAR); + int month = calendar.get(Calendar.MONTH); + do { + for (; month >= 0; month--) { + if (year == toYear && month == toMonth) { + break; + } + map.add(DateUtil.getStartOfMonth(year, month)); + } + year--; + month = 11; + } while (year >= toYear); + return map; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/balance/BaseBalanceEntry.java b/core/src/main/java/bisq/core/dao/burningman/accounting/balance/BaseBalanceEntry.java new file mode 100644 index 0000000000..19de0b8100 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/balance/BaseBalanceEntry.java @@ -0,0 +1,56 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.balance; + +import bisq.common.util.DateUtil; + +import java.util.Date; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@EqualsAndHashCode +@Getter +public abstract class BaseBalanceEntry implements BalanceEntry { + private final String txId; + private final long amount; + private final Date date; + private final Date month; + private final Type type; + + protected BaseBalanceEntry(String txId, long amount, Date date, Type type) { + this.txId = txId; + this.amount = amount; + this.date = date; + month = DateUtil.getStartOfMonth(date); + this.type = type; + } + + @Override + public String toString() { + return "BaseBalanceEntry{" + + "\r\n txId=" + txId + + "\r\n amount=" + amount + + ",\r\n date=" + date + + ",\r\n month=" + month + + ",\r\n type=" + type + + "\r\n}"; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/balance/BurnedBsqBalanceEntry.java b/core/src/main/java/bisq/core/dao/burningman/accounting/balance/BurnedBsqBalanceEntry.java new file mode 100644 index 0000000000..e1c166405b --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/balance/BurnedBsqBalanceEntry.java @@ -0,0 +1,31 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.balance; + +import java.util.Date; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@EqualsAndHashCode(callSuper = true) +@Getter +public class BurnedBsqBalanceEntry extends BaseBalanceEntry { + public BurnedBsqBalanceEntry(String txId, long amount, Date date) { + super(txId, amount, date, Type.BURN_TX); + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/balance/MonthlyBalanceEntry.java b/core/src/main/java/bisq/core/dao/burningman/accounting/balance/MonthlyBalanceEntry.java new file mode 100644 index 0000000000..130641a7ce --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/balance/MonthlyBalanceEntry.java @@ -0,0 +1,44 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.balance; + +import bisq.common.util.DateUtil; + +import java.util.Date; +import java.util.Set; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@EqualsAndHashCode +@Getter +public class MonthlyBalanceEntry implements BalanceEntry { + private final long receivedBtc; + private final long burnedBsq; + private final Date date; + private final Date month; + private final Set types; + + public MonthlyBalanceEntry(long receivedBtc, long burnedBsq, Date date, Set types) { + this.receivedBtc = receivedBtc; + this.burnedBsq = burnedBsq; + this.date = date; + month = DateUtil.getStartOfMonth(date); + this.types = types; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/balance/MonthlyBurnedBsqBalanceEntry.java b/core/src/main/java/bisq/core/dao/burningman/accounting/balance/MonthlyBurnedBsqBalanceEntry.java new file mode 100644 index 0000000000..8d404bf1d3 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/balance/MonthlyBurnedBsqBalanceEntry.java @@ -0,0 +1,31 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.balance; + +import java.util.Date; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@EqualsAndHashCode(callSuper = true) +@Getter +public class MonthlyBurnedBsqBalanceEntry extends BurnedBsqBalanceEntry { + public MonthlyBurnedBsqBalanceEntry(String txId, long amount, Date date) { + super(txId, amount, date); + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/balance/ReceivedBtcBalanceEntry.java b/core/src/main/java/bisq/core/dao/burningman/accounting/balance/ReceivedBtcBalanceEntry.java new file mode 100644 index 0000000000..78e27e74f0 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/balance/ReceivedBtcBalanceEntry.java @@ -0,0 +1,34 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.balance; + +import bisq.common.util.Hex; + +import java.util.Date; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@EqualsAndHashCode(callSuper = true) +@Getter +public class ReceivedBtcBalanceEntry extends BaseBalanceEntry { + // We store only last 4 bytes in the AccountTx which is used to create a ReceivedBtcBalanceEntry instance. + public ReceivedBtcBalanceEntry(byte[] truncatedTxId, long amount, Date date, BalanceEntry.Type type) { + super(Hex.encode(truncatedTxId), amount, date, type); + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/AccountingBlock.java b/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/AccountingBlock.java new file mode 100644 index 0000000000..e77341576f --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/AccountingBlock.java @@ -0,0 +1,112 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.blockchain; + +import bisq.common.proto.network.NetworkPayload; +import bisq.common.util.Hex; + +import com.google.protobuf.ByteString; + +import java.util.List; +import java.util.stream.Collectors; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +// Block data is aggressively optimized for minimal size. +// Block has 21 bytes base cost +// Tx has 2 byte base cost. +// TxOutput has variable byte size depending on name length, usually about 10-20 bytes. +// Some extra overhead of a few bytes is present depending on lists filled or not. +// Example fee tx (1 output) has about 40 bytes +// Example DPT tx with 2 outputs has about 60 bytes, typical DPT with 15-20 outputs might have 500 bytes. +// 2 year legacy BM had about 100k fee txs and 1000 DPTs. Would be about 4MB for fee txs and 500kB for DPT. +// As most blocks have at least 1 tx we might not have empty blocks. +// With above estimates we can expect about 2 MB growth per year. +@Slf4j +@EqualsAndHashCode + +public final class AccountingBlock implements NetworkPayload { + @Getter + private final int height; + private final int timeInSec; + // We use only last 4 bytes of 32 byte hash to save space. + // We use a byte array for flexibility if we would need to change the length of the hash later. + @Getter + private final byte[] truncatedHash; + @Getter + private final byte[] truncatedPreviousBlockHash; + @Getter + private final List txs; + + public AccountingBlock(int height, + int timeInSec, + byte[] truncatedHash, + byte[] truncatedPreviousBlockHash, + List txs) { + this.height = height; + this.timeInSec = timeInSec; + this.truncatedHash = truncatedHash; + this.truncatedPreviousBlockHash = truncatedPreviousBlockHash; + this.txs = txs; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public protobuf.AccountingBlock toProtoMessage() { + return protobuf.AccountingBlock.newBuilder() + .setHeight(height) + .setTimeInSec(timeInSec) + .setTruncatedHash(ByteString.copyFrom(truncatedHash)) + .setTruncatedPreviousBlockHash(ByteString.copyFrom(truncatedPreviousBlockHash)) + .addAllTxs(txs.stream().map(AccountingTx::toProtoMessage).collect(Collectors.toList())) + .build(); + } + + public static AccountingBlock fromProto(protobuf.AccountingBlock proto) { + List txs = proto.getTxsList().stream() + .map(AccountingTx::fromProto) + .collect(Collectors.toList()); + // log.error("AccountingBlock.getSerializedSize {}, txs.size={}", proto.getSerializedSize(), txs.size()); + return new AccountingBlock(proto.getHeight(), + proto.getTimeInSec(), + proto.getTruncatedHash().toByteArray(), + proto.getTruncatedPreviousBlockHash().toByteArray(), + txs); + } + + public long getDate() { + return timeInSec * 1000L; + } + + @Override + public String toString() { + return "AccountingBlock{" + + "\r\n height=" + height + + ",\r\n timeInSec=" + timeInSec + + ",\r\n truncatedHash=" + Hex.encode(truncatedHash) + + ",\r\n truncatedPreviousBlockHash=" + Hex.encode(truncatedPreviousBlockHash) + + ",\r\n txs=" + txs + + "\r\n}"; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/AccountingTx.java b/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/AccountingTx.java new file mode 100644 index 0000000000..3c4c13af99 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/AccountingTx.java @@ -0,0 +1,87 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.blockchain; + +import bisq.common.proto.network.NetworkPayload; +import bisq.common.util.Hex; + +import com.google.protobuf.ByteString; + +import java.util.List; +import java.util.stream.Collectors; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@EqualsAndHashCode +@Getter +public final class AccountingTx implements NetworkPayload { + public enum Type { + BTC_TRADE_FEE_TX, + DPT_TX + } + + private final Type type; + private final List outputs; + // We store only last 4 bytes to have a unique ID. Chance for collusion is very low, and we take that risk that + // one object might get overridden in a hashset by the colluding truncatedTxId and all other data being the same as well. + private final byte[] truncatedTxId; + + public AccountingTx(Type type, List outputs, String txId) { + this(type, outputs, Hex.decodeLast4Bytes(txId)); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + private AccountingTx(Type type, List outputs, byte[] truncatedTxId) { + this.type = type; + this.outputs = outputs; + this.truncatedTxId = truncatedTxId; + } + + @Override + public protobuf.AccountingTx toProtoMessage() { + return protobuf.AccountingTx.newBuilder() + .setType(type.ordinal()) + .addAllOutputs(outputs.stream().map(AccountingTxOutput::toProtoMessage).collect(Collectors.toList())) + .setTruncatedTxId(ByteString.copyFrom(truncatedTxId)).build(); + } + + public static AccountingTx fromProto(protobuf.AccountingTx proto) { + List outputs = proto.getOutputsList().stream() + .map(AccountingTxOutput::fromProto) + .collect(Collectors.toList()); + return new AccountingTx(Type.values()[proto.getType()], + outputs, + proto.getTruncatedTxId().toByteArray()); + } + + @Override + public String toString() { + return "AccountingTx{" + + ",\n type='" + type + '\'' + + ",\n outputs=" + outputs + + ",\n truncatedTxId=" + Hex.encode(truncatedTxId) + + "\n }"; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/AccountingTxOutput.java b/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/AccountingTxOutput.java new file mode 100644 index 0000000000..9b9780cd28 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/AccountingTxOutput.java @@ -0,0 +1,93 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.blockchain; + +import bisq.core.dao.burningman.BurningManPresentationService; + +import bisq.common.proto.network.NetworkPayload; + +import org.bitcoinj.core.Coin; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import static com.google.common.base.Preconditions.checkArgument; + +// Outputs get pruned to required number of outputs depending on tx type. +// We store value as integer in protobuf as we do not support larger amounts than 21.47483647 BTC for our tx types. +// Name is burningman candidate name. For legacy Burningman we shorten to safe space. +@Slf4j +@EqualsAndHashCode +public final class AccountingTxOutput implements NetworkPayload { + private static final String LEGACY_BM_FEES_SHORT = "LBMF"; + private static final String LEGACY_BM_DPT_SHORT = "LBMD"; + + @Getter + private final long value; + private final String name; + + public AccountingTxOutput(long value, String name) { + this.value = value; + this.name = maybeShortenLBM(name); + } + + private String maybeShortenLBM(String name) { + return name.equals(BurningManPresentationService.LEGACY_BURNING_MAN_BTC_FEES_NAME) ? + LEGACY_BM_FEES_SHORT : + name.equals(BurningManPresentationService.LEGACY_BURNING_MAN_DPT_NAME) ? + LEGACY_BM_DPT_SHORT : + name; + } + + public String getName() { + return name.equals(LEGACY_BM_FEES_SHORT) ? + BurningManPresentationService.LEGACY_BURNING_MAN_BTC_FEES_NAME : + name.equals(LEGACY_BM_DPT_SHORT) ? + BurningManPresentationService.LEGACY_BURNING_MAN_DPT_NAME : + name; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public protobuf.AccountingTxOutput toProtoMessage() { + checkArgument(value < Coin.valueOf(2147483647).getValue(), + "We only support integer value in protobuf storage for the amount and it need to be below 21.47483647 BTC"); + return protobuf.AccountingTxOutput.newBuilder() + .setValue((int) value) + .setName(name).build(); + } + + public static AccountingTxOutput fromProto(protobuf.AccountingTxOutput proto) { + int intValue = proto.getValue(); + checkArgument(intValue >= 0, "Value must not be negative"); + return new AccountingTxOutput(intValue, proto.getName()); + } + + @Override + public String toString() { + return "AccountingTxOutput{" + + ",\r\n value=" + value + + ",\r\n name='" + name + '\'' + + "\r\n}"; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/temp/TempAccountingTx.java b/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/temp/TempAccountingTx.java new file mode 100644 index 0000000000..b2ab9ddbbe --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/temp/TempAccountingTx.java @@ -0,0 +1,75 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.blockchain.temp; + +import bisq.core.dao.node.full.rpc.dto.DtoPubKeyScript; +import bisq.core.dao.node.full.rpc.dto.RawDtoTransaction; +import bisq.core.dao.state.model.blockchain.ScriptType; + +import java.math.BigDecimal; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@EqualsAndHashCode +@Getter +public final class TempAccountingTx { + private final String txId; + private final boolean isValidDptLockTime; + private final List inputs; + private final List outputs; + + public TempAccountingTx(RawDtoTransaction tx) { + txId = tx.getTxId(); + + // If lockTime is < 500000000 it is interpreted as block height, otherwise as unix time. We use block height. + // We only handle blocks from EARLIEST_BLOCK_HEIGHT on + //todo for dev testing + isValidDptLockTime = /*tx.getLockTime() >= BurningManAccountingService.EARLIEST_BLOCK_HEIGHT &&*/ tx.getLockTime() < 500000000; + + inputs = tx.getVIn().stream() + .map(input -> { + List txInWitness = input.getTxInWitness() != null ? input.getTxInWitness() : new ArrayList<>(); + return new TempAccountingTxInput(input.getSequence(), txInWitness); + }) + .collect(Collectors.toList()); + + outputs = tx.getVOut().stream() + .map(output -> { + long value = BigDecimal.valueOf(output.getValue()).movePointRight(8).longValueExact(); + // We use a non-null field for address as in the final object we require that the address is available + String address = ""; + DtoPubKeyScript scriptPubKey = output.getScriptPubKey(); + if (scriptPubKey != null) { + List addresses = scriptPubKey.getAddresses(); + if (addresses != null && addresses.size() == 1) { + address = addresses.get(0); + } + } + ScriptType scriptType = output.getScriptPubKey() != null ? output.getScriptPubKey().getType() : null; + return new TempAccountingTxOutput(value, address, scriptType); + }) + .collect(Collectors.toList()); + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/temp/TempAccountingTxInput.java b/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/temp/TempAccountingTxInput.java new file mode 100644 index 0000000000..ea0cd2907d --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/temp/TempAccountingTxInput.java @@ -0,0 +1,37 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.blockchain.temp; + +import java.util.List; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@EqualsAndHashCode +@Getter +public final class TempAccountingTxInput { + private final long sequence; + private final List txInWitness; + + public TempAccountingTxInput(long sequence, List txInWitness) { + this.sequence = sequence; + this.txInWitness = txInWitness; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/temp/TempAccountingTxOutput.java b/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/temp/TempAccountingTxOutput.java new file mode 100644 index 0000000000..55fbbc1cc1 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/blockchain/temp/TempAccountingTxOutput.java @@ -0,0 +1,37 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.blockchain.temp; + +import bisq.core.dao.state.model.blockchain.ScriptType; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@EqualsAndHashCode +@Getter +public final class TempAccountingTxOutput { + private final long value; + private final String address; + private final ScriptType scriptType; + + public TempAccountingTxOutput(long value, String address, ScriptType scriptType) { + this.value = value; + this.address = address; + this.scriptType = scriptType; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/exceptions/BlockHashNotConnectingException.java b/core/src/main/java/bisq/core/dao/burningman/accounting/exceptions/BlockHashNotConnectingException.java new file mode 100644 index 0000000000..a3e84b4c79 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/exceptions/BlockHashNotConnectingException.java @@ -0,0 +1,26 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.exceptions; + +import lombok.Getter; + +@Getter +public class BlockHashNotConnectingException extends Exception { + public BlockHashNotConnectingException() { + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/exceptions/BlockHeightNotConnectingException.java b/core/src/main/java/bisq/core/dao/burningman/accounting/exceptions/BlockHeightNotConnectingException.java new file mode 100644 index 0000000000..042b50839a --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/exceptions/BlockHeightNotConnectingException.java @@ -0,0 +1,26 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.exceptions; + +import lombok.Getter; + +@Getter +public class BlockHeightNotConnectingException extends Exception { + public BlockHeightNotConnectingException() { + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/node/AccountingNode.java b/core/src/main/java/bisq/core/dao/burningman/accounting/node/AccountingNode.java new file mode 100644 index 0000000000..4572938e45 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/node/AccountingNode.java @@ -0,0 +1,237 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.node; + +import bisq.core.dao.DaoSetupService; +import bisq.core.dao.burningman.accounting.BurningManAccountingService; +import bisq.core.dao.burningman.accounting.blockchain.AccountingBlock; +import bisq.core.dao.burningman.accounting.node.full.AccountingBlockParser; +import bisq.core.dao.state.DaoStateListener; +import bisq.core.dao.state.DaoStateService; + +import bisq.network.p2p.BootstrapListener; +import bisq.network.p2p.P2PService; + +import bisq.common.UserThread; +import bisq.common.app.DevEnv; + +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.Sha256Hash; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import java.util.Collection; +import java.util.Set; +import java.util.function.Consumer; + +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; + +import static org.bitcoinj.core.Utils.HEX; + +@Slf4j +public abstract class AccountingNode implements DaoSetupService, DaoStateListener { + public static final Set PERMITTED_PUB_KEYS = Set.of("034527b1c2b644283c19c180efbcc9ba51258fbe5c5ce0c95b522f1c9b07896e48", + "02a06121632518eef4400419a3aaf944534e8cf357138dd82bba3ad78ce5902f27", + "029ff0da89aa03507dfe0529eb74a53bc65fbee7663ceba04f014b0ee4520973b5", + "0205c992604969bc70914fc89a6daa43cd5157ac886224a8c0901dd1dc6dd1df45", + "023e699281b3ee41f35991f064a5c12cb1b61286dda22c8220b3f707aa21235efb"); + + /////////////////////////////////////////////////////////////////////////////////////////// + // Oracle verification + /////////////////////////////////////////////////////////////////////////////////////////// + + public static Sha256Hash getSha256Hash(AccountingBlock block) { + return Sha256Hash.of(block.toProtoMessage().toByteArray()); + } + + @Nullable + public static Sha256Hash getSha256Hash(Collection blocks) { + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + for (AccountingBlock accountingBlock : blocks) { + outputStream.write(accountingBlock.toProtoMessage().toByteArray()); + } + return Sha256Hash.of(outputStream.toByteArray()); + } catch (IOException e) { + return null; + } + } + + public static byte[] getSignature(Sha256Hash sha256Hash, ECKey privKey) { + ECKey.ECDSASignature ecdsaSignature = privKey.sign(sha256Hash); + return ecdsaSignature.encodeToDER(); + } + + public static boolean isValidPubKeyAndSignature(Sha256Hash sha256Hash, + String pubKey, + byte[] signature, + boolean useDevPrivilegeKeys) { + if (!getPermittedPubKeys(useDevPrivilegeKeys).contains(pubKey)) { + log.warn("PubKey is not in supported key set. pubKey={}", pubKey); + return false; + } + + try { + ECKey.ECDSASignature ecdsaSignature = ECKey.ECDSASignature.decodeFromDER(signature); + ECKey ecPubKey = ECKey.fromPublicOnly(HEX.decode(pubKey)); + return ecPubKey.verify(sha256Hash, ecdsaSignature); + } catch (Throwable e) { + log.warn("Signature verification failed."); + return false; + } + } + + public static boolean isPermittedPubKey(boolean useDevPrivilegeKeys, String pubKey) { + return getPermittedPubKeys(useDevPrivilegeKeys).contains(pubKey); + } + + private static Set getPermittedPubKeys(boolean useDevPrivilegeKeys) { + return useDevPrivilegeKeys ? Set.of(DevEnv.DEV_PRIVILEGE_PUB_KEY) : PERMITTED_PUB_KEYS; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Class fields + /////////////////////////////////////////////////////////////////////////////////////////// + + protected final P2PService p2PService; + private final DaoStateService daoStateService; + protected final BurningManAccountingService burningManAccountingService; + protected final AccountingBlockParser accountingBlockParser; + + @Nullable + protected Consumer errorMessageHandler; + @Nullable + protected Consumer warnMessageHandler; + protected BootstrapListener bootstrapListener; + protected int tryReorgCounter; + protected boolean p2pNetworkReady; + protected boolean initialBlockRequestsComplete; + + public AccountingNode(P2PService p2PService, + DaoStateService daoStateService, + BurningManAccountingService burningManAccountingService, + AccountingBlockParser accountingBlockParser) { + this.p2PService = p2PService; + this.daoStateService = daoStateService; + this.burningManAccountingService = burningManAccountingService; + this.accountingBlockParser = accountingBlockParser; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // DaoStateListener + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void onParseBlockChainComplete() { + onInitialDaoBlockParsingComplete(); + + // We get called onParseBlockChainComplete at each new block arriving but we want to react only after initial + // parsing is done, so we remove after getting called ourself as listener. + daoStateService.removeDaoStateListener(this); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // DaoSetupService + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void addListeners() { + if (daoStateService.isParseBlockChainComplete()) { + log.info("daoStateService.isParseBlockChainComplete is already true, " + + "we call onInitialDaoBlockParsingComplete directly"); + onInitialDaoBlockParsingComplete(); + } else { + daoStateService.addDaoStateListener(this); + } + + bootstrapListener = new BootstrapListener() { + @Override + public void onNoSeedNodeAvailable() { + onP2PNetworkReady(); + } + + @Override + public void onUpdatedDataReceived() { + onP2PNetworkReady(); + } + }; + } + + @Override + public abstract void start(); + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + public void setErrorMessageHandler(@SuppressWarnings("NullableProblems") Consumer errorMessageHandler) { + this.errorMessageHandler = errorMessageHandler; + } + + public void setWarnMessageHandler(@SuppressWarnings("NullableProblems") Consumer warnMessageHandler) { + this.warnMessageHandler = warnMessageHandler; + } + + public abstract void shutDown(); + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Protected + /////////////////////////////////////////////////////////////////////////////////////////// + + protected abstract void onInitialDaoBlockParsingComplete(); + + protected void onInitialized() { + if (p2PService.isBootstrapped()) { + log.info("p2PService.isBootstrapped is already true, we call onP2PNetworkReady directly."); + onP2PNetworkReady(); + } else { + p2PService.addP2PServiceListener(bootstrapListener); + } + } + + protected void onP2PNetworkReady() { + p2pNetworkReady = true; + p2PService.removeP2PServiceListener(bootstrapListener); + } + + protected abstract void startRequestBlocks(); + + protected void onInitialBlockRequestsComplete() { + initialBlockRequestsComplete = true; + burningManAccountingService.onInitialBlockRequestsComplete(); + } + + protected void applyReOrg() { + log.warn("applyReOrg called"); + tryReorgCounter++; + if (tryReorgCounter < 5) { + burningManAccountingService.purgeLastTenBlocks(); + // Increase delay at each retry + UserThread.runAfter(this::startRequestBlocks, (long) tryReorgCounter * tryReorgCounter); + } else { + log.warn("We tried {} times to request blocks again after a reorg signal but it is still failing.", tryReorgCounter); + } + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/node/AccountingNodeProvider.java b/core/src/main/java/bisq/core/dao/burningman/accounting/node/AccountingNodeProvider.java new file mode 100644 index 0000000000..0022aa7b77 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/node/AccountingNodeProvider.java @@ -0,0 +1,57 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.node; + +import bisq.core.dao.burningman.BurningManService; +import bisq.core.dao.burningman.accounting.node.full.AccountingFullNode; +import bisq.core.dao.burningman.accounting.node.lite.AccountingLiteNode; +import bisq.core.user.Preferences; + +import bisq.common.config.Config; + +import com.google.inject.Inject; + +import javax.inject.Named; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class AccountingNodeProvider { + @Getter + private final AccountingNode accountingNode; + + @Inject + public AccountingNodeProvider(AccountingLiteNode liteNode, + AccountingFullNode fullNode, + InActiveAccountingNode inActiveAccountingNode, + @Named(Config.IS_BM_FULL_NODE) boolean isBmFullNode, + Preferences preferences) { + + boolean rpcDataSet = preferences.getRpcUser() != null && + !preferences.getRpcUser().isEmpty() + && preferences.getRpcPw() != null && + !preferences.getRpcPw().isEmpty() && + preferences.getBlockNotifyPort() > 0; + if (BurningManService.isActivated()) { + accountingNode = isBmFullNode && rpcDataSet ? fullNode : liteNode; + } else { + accountingNode = inActiveAccountingNode; + } + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/node/InActiveAccountingNode.java b/core/src/main/java/bisq/core/dao/burningman/accounting/node/InActiveAccountingNode.java new file mode 100644 index 0000000000..a5c2604b34 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/node/InActiveAccountingNode.java @@ -0,0 +1,60 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.node; + +import bisq.core.dao.burningman.accounting.BurningManAccountingService; +import bisq.core.dao.burningman.accounting.node.full.AccountingBlockParser; +import bisq.core.dao.state.DaoStateService; + +import bisq.network.p2p.P2PService; + +import com.google.inject.Inject; + +import javax.inject.Singleton; + +// Dummy implementation for a do-nothing AccountingNode. Used for the time before the burningman domain gets activated. +@Singleton +class InActiveAccountingNode extends AccountingNode { + @Inject + public InActiveAccountingNode(P2PService p2PService, + DaoStateService daoStateService, + BurningManAccountingService burningManAccountingService, + AccountingBlockParser accountingBlockParser) { + super(p2PService, daoStateService, burningManAccountingService, accountingBlockParser); + } + + @Override + public void addListeners() { + } + + @Override + public void start() { + } + + @Override + public void shutDown() { + } + + @Override + protected void onInitialDaoBlockParsingComplete() { + } + + @Override + protected void startRequestBlocks() { + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/node/full/AccountingBlockParser.java b/core/src/main/java/bisq/core/dao/burningman/accounting/node/full/AccountingBlockParser.java new file mode 100644 index 0000000000..6fba586b16 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/node/full/AccountingBlockParser.java @@ -0,0 +1,210 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.node.full; + +import bisq.core.dao.burningman.accounting.BurningManAccountingService; +import bisq.core.dao.burningman.accounting.blockchain.AccountingBlock; +import bisq.core.dao.burningman.accounting.blockchain.AccountingTx; +import bisq.core.dao.burningman.accounting.blockchain.AccountingTxOutput; +import bisq.core.dao.burningman.accounting.blockchain.temp.TempAccountingTx; +import bisq.core.dao.burningman.accounting.blockchain.temp.TempAccountingTxInput; +import bisq.core.dao.burningman.accounting.blockchain.temp.TempAccountingTxOutput; +import bisq.core.dao.node.full.rpc.dto.RawDtoBlock; +import bisq.core.dao.state.model.blockchain.ScriptType; + +import bisq.common.util.Hex; + +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.TransactionInput; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Singleton +public class AccountingBlockParser { + private final BurningManAccountingService burningManAccountingService; + + @Inject + public AccountingBlockParser(BurningManAccountingService burningManAccountingService) { + this.burningManAccountingService = burningManAccountingService; + } + + public AccountingBlock parse(RawDtoBlock rawDtoBlock) { + Map burningManNameByAddress = burningManAccountingService.getBurningManNameByAddress(); + String genesisTxId = burningManAccountingService.getGenesisTxId(); + + // We filter early for first output address match. DPT txs have multiple outputs which need to match and will be checked later. + Set receiverAddresses = burningManNameByAddress.keySet(); + List txs = rawDtoBlock.getTx().stream() + .map(TempAccountingTx::new) + .filter(tempAccountingTx -> receiverAddresses.contains(tempAccountingTx.getOutputs().get(0).getAddress())) + .map(tempAccountingTx -> toAccountingTx(tempAccountingTx, burningManNameByAddress, genesisTxId)) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + // Time in rawDtoBlock is in seconds + int timeInSec = rawDtoBlock.getTime().intValue(); + byte[] truncatedHash = Hex.decodeLast4Bytes(rawDtoBlock.getHash()); + byte[] truncatedPreviousBlockHash = Hex.decodeLast4Bytes(rawDtoBlock.getPreviousBlockHash()); + return new AccountingBlock(rawDtoBlock.getHeight(), + timeInSec, + truncatedHash, + truncatedPreviousBlockHash, + txs); + } + + // We cannot know for sure if it's a DPT or BTC fee tx as we do not have the spending tx output with more + // data for verification as we do not keep the full blockchain data for lookup unspent tx outputs. + // The DPT can be very narrowly detected. The BTC fee txs might have false positives. + private Optional toAccountingTx(TempAccountingTx tempAccountingTx, + Map burningManNameByAddress, + String genesisTxId) { + if (genesisTxId.equals(tempAccountingTx.getTxId())) { + return Optional.empty(); + } + + Set receiverAddresses = burningManNameByAddress.keySet(); + // DPT has 1 input from P2WSH with lock time and sequence number set. + // We only use native segwit P2SH, so we expect a txInWitness + List inputs = tempAccountingTx.getInputs(); + List outputs = tempAccountingTx.getOutputs(); + // Max DPT output amount is currently 4 BTC. Max. security deposit is 50% of trade amount. + // We give some extra headroom to cover potential future changes. + // We store value as integer in protobuf to safe space, so max. possible value is 21.47483647 BTC. + long maxDptValue = 6 * Coin.COIN.getValue(); + if (inputs.size() == 1 && + isValidTimeLock(tempAccountingTx, inputs.get(0)) && + doAllOutputsMatchReceivers(outputs, receiverAddresses) && + isWitnessDataWP2SH(inputs.get(0).getTxInWitness()) && + outputs.stream().allMatch(txOutput -> isExpectedScriptType(txOutput, tempAccountingTx)) && + outputs.stream().allMatch(txOutput -> txOutput.getValue() <= maxDptValue)) { + + List accountingTxOutputs = outputs.stream() + .map(output -> new AccountingTxOutput(output.getValue(), burningManNameByAddress.get(output.getAddress()))) + .collect(Collectors.toList()); + return Optional.of(new AccountingTx(AccountingTx.Type.DPT_TX, accountingTxOutputs, tempAccountingTx.getTxId())); + } + + // BTC trade fee tx has 2 or 3 outputs. + // First output to receiver, second reserved for trade and an optional 3rd for change. + // Address check is done in parse method above already. + // Amounts are in a certain range but as fee can change with DAO param changes we cannot set hard limits. + // The min. trade fee in Nov. 2022 is 5000 sat. We use 2500 as lower bound. + // The largest taker fee for a 2 BTC trade is about 0.0059976 BTC (599_760 sat). We use 1_000_000 sat as upper bound. + // Inputs are not constrained. + // Max fees amount is currently 0.0176 BTC. + // We give some extra headroom to cover potential future fee changes. + // We store value as integer in protobuf to safe space, so max. possible value is 21.47483647 BTC. + long maxTradeFeeValue = Coin.parseCoin("0.1").getValue(); + TempAccountingTxOutput firstOutput = outputs.get(0); + if (outputs.size() >= 2 && + outputs.size() <= 3 && + firstOutput.getValue() > 2500 && + firstOutput.getValue() < 1_000_000 && + isExpectedScriptType(firstOutput, tempAccountingTx) && + firstOutput.getValue() <= maxTradeFeeValue) { + // We only keep first output. + String name = burningManNameByAddress.get(firstOutput.getAddress()); + return Optional.of(new AccountingTx(AccountingTx.Type.BTC_TRADE_FEE_TX, + List.of(new AccountingTxOutput(firstOutput.getValue(), name)), + tempAccountingTx.getTxId())); + } + + return Optional.empty(); + } + + // TODO not sure if other ScriptType are to be expected + private boolean isExpectedScriptType(TempAccountingTxOutput txOutput, TempAccountingTx accountingTx) { + boolean result = txOutput.getScriptType() != null && + (txOutput.getScriptType() == ScriptType.PUB_KEY_HASH || + txOutput.getScriptType() == ScriptType.SCRIPT_HASH || + txOutput.getScriptType() == ScriptType.WITNESS_V0_KEYHASH); + if (!result) { + log.error("isExpectedScriptType txOutput.getScriptType()={}, txIf={}", txOutput.getScriptType(), accountingTx.getTxId()); + } + return result; + } + + // All outputs need to be to receiver addresses (incl. legacy BM) + private boolean doAllOutputsMatchReceivers(List outputs, Set receiverAddresses) { + return outputs.stream().allMatch(output -> receiverAddresses.contains(output.getAddress())); + } + + private boolean isValidTimeLock(TempAccountingTx accountingTx, TempAccountingTxInput firstInput) { + // Need to be 0xfffffffe + return accountingTx.isValidDptLockTime() && firstInput.getSequence() == TransactionInput.NO_SEQUENCE - 1; + } + + /* + Example txInWitness: [, 304502210098fcec3ac1c1383d40159587b42e8b79cb3e793004d6ccb080bfb93f02c15f93022039f014eb933c59f988d68a61aa7a1c787f08d94bd0b222104718792798e43e3c01, 304402201f8b37f3b8b5b9944ca88f18f6bb888c5a48dc5183edf204ae6d3781032122e102204dc6397538055d94de1ab683315aac7d87289be0c014569f7b3fa465bf70b6d401, 5221027f6da96ede171617ce79ec305a76871ecce21ad737517b667fc9735f2dc342342102d8d93e02fb0833201274b47a302b47ff81c0b3510508eb0444cb1674d0d8a6d052ae] + */ + private boolean isWitnessDataWP2SH(List txInWitness) { + // txInWitness from the 2of2 multiSig has 4 chunks. + // 0 byte, sig1, sig2, redeemScript + if (txInWitness.size() != 4) { + log.error("txInWitness chunks size not 4 .txInWitness={}", txInWitness); + return false; + } + // First chunk is o byte (empty string) + if (!txInWitness.get(0).isEmpty()) { + log.error("txInWitness.get(0) not empty .txInWitness={}", txInWitness); + return false; + } + + // The 2 signatures are 70 - 73 bytes + int minSigLength = 140; + int maxSigLength = 146; + int fistSigLength = txInWitness.get(1).length(); + if (fistSigLength < minSigLength || fistSigLength > maxSigLength) { + log.error("fistSigLength wrong .txInWitness={}", txInWitness); + return false; + } + int secondSigLength = txInWitness.get(2).length(); + if (secondSigLength < minSigLength || secondSigLength > maxSigLength) { + log.error("secondSigLength wrong .txInWitness={}", txInWitness); + return false; + } + + String redeemScript = txInWitness.get(3); + if (redeemScript.length() != 142) { + log.error("redeemScript not valid length .txInWitness={}", txInWitness); + return false; + } + + // OP_2 pub1 pub2 OP_2 OP_CHECKMULTISIG + // In hex: "5221" + PUB_KEY_1 + "21" + PUB_KEY_2 + "52ae"; + // PubKeys are 33 bytes -> length 66 in hex + String separator = redeemScript.substring(70, 72); + boolean result = redeemScript.startsWith("5221") && + redeemScript.endsWith("52ae") && + separator.equals("21"); + if (!result) { + log.error("redeemScript not valid .txInWitness={}", txInWitness); + } + return result; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/node/full/AccountingFullNode.java b/core/src/main/java/bisq/core/dao/burningman/accounting/node/full/AccountingFullNode.java new file mode 100644 index 0000000000..3ee9cbef46 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/node/full/AccountingFullNode.java @@ -0,0 +1,323 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.node.full; + +import bisq.core.dao.burningman.accounting.BurningManAccountingService; +import bisq.core.dao.burningman.accounting.blockchain.AccountingBlock; +import bisq.core.dao.burningman.accounting.exceptions.BlockHashNotConnectingException; +import bisq.core.dao.burningman.accounting.exceptions.BlockHeightNotConnectingException; +import bisq.core.dao.burningman.accounting.node.AccountingNode; +import bisq.core.dao.burningman.accounting.node.full.network.AccountingFullNodeNetworkService; +import bisq.core.dao.node.full.RpcException; +import bisq.core.dao.node.full.RpcService; +import bisq.core.dao.node.full.rpc.NotificationHandlerException; +import bisq.core.dao.node.full.rpc.dto.RawDtoBlock; +import bisq.core.dao.state.DaoStateService; + +import bisq.network.p2p.P2PService; + +import bisq.common.UserThread; +import bisq.common.handlers.ResultHandler; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import java.net.ConnectException; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Singleton +public class AccountingFullNode extends AccountingNode { + private final RpcService rpcService; + private final AccountingFullNodeNetworkService accountingFullNodeNetworkService; + + private boolean addBlockHandlerAdded; + private int batchedBlocks; + private long batchStartTime; + private final List pendingRawDtoBlocks = new ArrayList<>(); + private int requestBlocksUpToHeadHeightCounter; + + @Inject + public AccountingFullNode(P2PService p2PService, + DaoStateService daoStateService, + BurningManAccountingService burningManAccountingService, + AccountingBlockParser accountingBlockParser, + AccountingFullNodeNetworkService accountingFullNodeNetworkService, + RpcService rpcService) { + super(p2PService, daoStateService, burningManAccountingService, accountingBlockParser); + + this.rpcService = rpcService; + this.accountingFullNodeNetworkService = accountingFullNodeNetworkService; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Public methods + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void start() { + // We do not start yes but wait until DAO block parsing is complete to not interfere with + // that higher priority activity. + } + + @Override + public void shutDown() { + accountingFullNodeNetworkService.shutDown(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Protected + /////////////////////////////////////////////////////////////////////////////////////////// + + // We start after initial DAO parsing is complete + @Override + protected void onInitialDaoBlockParsingComplete() { + log.info("onInitialDaoBlockParsingComplete"); + rpcService.setup(() -> { + accountingFullNodeNetworkService.addListeners(); + super.onInitialized(); + startRequestBlocks(); + }, + this::handleError); + } + + @Override + protected void onP2PNetworkReady() { + super.onP2PNetworkReady(); + + if (initialBlockRequestsComplete) { + addBlockHandler(); + int heightOfLastBlock = burningManAccountingService.getBlockHeightOfLastBlock(); + log.info("onP2PNetworkReady: We run requestBlocksIfNewBlockAvailable with latest block height {}.", heightOfLastBlock); + requestBlocksIfNewBlockAvailable(heightOfLastBlock); + } + } + + @Override + protected void startRequestBlocks() { + int heightOfLastBlock = burningManAccountingService.getBlockHeightOfLastBlock(); + log.info("startRequestBlocks: heightOfLastBlock={}", heightOfLastBlock); + rpcService.requestChainHeadHeight(headHeight -> { + // If our persisted block is equal to the chain height we have heightOfLastBlock 1 block higher, + // so we do not call parseBlocksOnHeadHeight + log.info("rpcService.requestChainHeadHeight: headHeight={}", headHeight); + requestBlocksUpToHeadHeight(heightOfLastBlock, headHeight); + }, + this::handleError); + } + + @Override + protected void onInitialBlockRequestsComplete() { + super.onInitialBlockRequestsComplete(); + + if (p2pNetworkReady) { + addBlockHandler(); + } else { + log.info("onParseBlockChainComplete but P2P network is not ready yet."); + } + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Private + /////////////////////////////////////////////////////////////////////////////////////////// + + private void addBlockHandler() { + if (!addBlockHandlerAdded) { + addBlockHandlerAdded = true; + rpcService.addNewRawDtoBlockHandler(rawDtoBlock -> { + parseBlock(rawDtoBlock).ifPresent(accountingBlock -> { + maybePublishAccountingBlock(accountingBlock); + burningManAccountingService.onNewBlockReceived(accountingBlock); + }); + }, + this::handleError); + } + } + + private void maybePublishAccountingBlock(AccountingBlock accountingBlock) { + if (p2pNetworkReady && initialBlockRequestsComplete) { + accountingFullNodeNetworkService.publishAccountingBlock(accountingBlock); + } + } + + private void requestBlocksIfNewBlockAvailable(int heightOfLastBlock) { + rpcService.requestChainHeadHeight(headHeight -> { + if (headHeight > heightOfLastBlock) { + log.info("During requests new blocks have arrived. We request again to get the missing blocks." + + "heightOfLastBlock={}, headHeight={}", heightOfLastBlock, headHeight); + requestBlocksUpToHeadHeight(heightOfLastBlock, headHeight); + } else { + log.info("requestChainHeadHeight did not result in a new block, so we complete."); + log.info("Requesting {} blocks took {} seconds", batchedBlocks, (System.currentTimeMillis() - batchStartTime) / 1000d); + if (!initialBlockRequestsComplete) { + onInitialBlockRequestsComplete(); + } + } + }, + this::handleError); + } + + private void requestBlocksUpToHeadHeight(int heightOfLastBlock, int headHeight) { + if (heightOfLastBlock == headHeight) { + log.info("Out heightOfLastBlock is same as headHeight of Bitcoin Core."); + if (!initialBlockRequestsComplete) { + onInitialBlockRequestsComplete(); + } + } else if (heightOfLastBlock < headHeight) { + batchedBlocks = headHeight - heightOfLastBlock; + batchStartTime = System.currentTimeMillis(); + log.info("Request {} blocks from {} to {}", batchedBlocks, heightOfLastBlock, headHeight); + requestBlockRecursively(heightOfLastBlock, + headHeight, + () -> { + // We are done, but it might be that new blocks have arrived in the meantime, + // so we try again with heightOfLastBlock set to current headHeight + requestBlocksIfNewBlockAvailable(headHeight); + }, this::handleError); + } else { + requestBlocksUpToHeadHeightCounter++; + log.warn("We are trying to start with a block which is above the chain height of Bitcoin Core. " + + "We need probably wait longer until Bitcoin Core has fully synced. " + + "We try again after a delay of {} min.", requestBlocksUpToHeadHeightCounter * requestBlocksUpToHeadHeightCounter); + if (requestBlocksUpToHeadHeightCounter <= 5) { + UserThread.runAfter(() -> rpcService.requestChainHeadHeight(height -> + requestBlocksUpToHeadHeight(heightOfLastBlock, height), + this::handleError), requestBlocksUpToHeadHeightCounter * requestBlocksUpToHeadHeightCounter * 60L); + } else { + log.warn("We tried {} times to start with startBlockHeight {} which is above the chain height {} of Bitcoin Core. " + + "It might be that Bitcoin Core has not fully synced. We give up now.", + requestBlocksUpToHeadHeightCounter, heightOfLastBlock, headHeight); + } + } + } + + private void requestBlockRecursively(int heightOfLastBlock, + int headHeight, + ResultHandler completeHandler, + Consumer errorHandler) { + int requestHeight = heightOfLastBlock + 1; + rpcService.requestRawDtoBlock(requestHeight, + rawDtoBlock -> { + parseBlock(rawDtoBlock).ifPresent(this::maybePublishAccountingBlock); + + // Increment heightOfLastBlock and recursively call requestBlockRecursively until we reach headHeight + int newHeightOfLastBlock = burningManAccountingService.getBlockHeightOfLastBlock(); + if (requestHeight < headHeight) { + requestBlockRecursively(newHeightOfLastBlock, headHeight, completeHandler, errorHandler); + } else { + // We are done + completeHandler.handleResult(); + } + }, + errorHandler); + } + + private Optional parseBlock(RawDtoBlock rawDtoBlock) { + // We check if we have a block with that height. If so we return. We do not use the chainHeight as with the earliest + // height we have no block but chainHeight is initially set to the earliest height (bad design ;-( but a bit tricky + // to change now as it used in many areas.) + if (burningManAccountingService.getBlockAtHeight(rawDtoBlock.getHeight()).isPresent()) { + log.info("We have already a block with the height of the new block. Height of new block={}", rawDtoBlock.getHeight()); + return Optional.empty(); + } + + pendingRawDtoBlocks.remove(rawDtoBlock); + + try { + AccountingBlock accountingBlock = accountingBlockParser.parse(rawDtoBlock); + burningManAccountingService.addBlock(accountingBlock); + + // After parsing we check if we have pending future blocks. + // As we successfully added a new block a pending block might fit as next block. + if (!pendingRawDtoBlocks.isEmpty()) { + // We take only first element after sorting (so it is the accountingBlock with the next height) to avoid that + // we would repeat calls in recursions in case we would iterate the list. + pendingRawDtoBlocks.sort(Comparator.comparing(RawDtoBlock::getHeight)); + RawDtoBlock nextPending = pendingRawDtoBlocks.get(0); + if (nextPending.getHeight() == burningManAccountingService.getBlockHeightOfLastBlock() + 1) { + parseBlock(nextPending); + } + } + return Optional.of(accountingBlock); + } catch (BlockHeightNotConnectingException e) { + // If height of rawDtoBlock is not at expected heightForNextBlock but further in the future we add it to pendingRawDtoBlocks + int heightForNextBlock = burningManAccountingService.getBlockHeightOfLastBlock() + 1; + if (rawDtoBlock.getHeight() > heightForNextBlock && !pendingRawDtoBlocks.contains(rawDtoBlock)) { + pendingRawDtoBlocks.add(rawDtoBlock); + log.info("We received a block with a future block height. We store it as pending and try to apply it at the next block. " + + "heightForNextBlock={}, rawDtoBlock: height/hash={}/{}", heightForNextBlock, rawDtoBlock.getHeight(), rawDtoBlock.getHash()); + } + } catch (BlockHashNotConnectingException throwable) { + Optional lastBlock = burningManAccountingService.getLastBlock(); + log.warn("Block not connecting:\n" + + "New block height={}; hash={}, previousBlockHash={}, latest block height={}; hash={}", + rawDtoBlock.getHeight(), + rawDtoBlock.getHash(), + rawDtoBlock.getPreviousBlockHash(), + lastBlock.isPresent() ? lastBlock.get().getHeight() : "lastBlock not present", + lastBlock.isPresent() ? lastBlock.get().getTruncatedHash() : "lastBlock not present"); + + pendingRawDtoBlocks.clear(); + applyReOrg(); + } + return Optional.empty(); + } + + private void handleError(Throwable throwable) { + if (throwable instanceof BlockHashNotConnectingException || throwable instanceof BlockHeightNotConnectingException) { + // We do not escalate that exception as it is handled with the snapshot manager to recover its state. + log.warn(throwable.toString()); + } else { + String errorMessage = "An error occurred: Error=" + throwable.toString(); + log.error(errorMessage); + throwable.printStackTrace(); + + if (throwable instanceof RpcException) { + Throwable cause = throwable.getCause(); + if (cause != null) { + if (cause instanceof ConnectException) { + if (warnMessageHandler != null) + warnMessageHandler.accept("You have configured Bisq to run as BM full node but there is no " + + "localhost Bitcoin Core node detected. You need to have Bitcoin Core started and synced before " + + "starting Bisq. Please restart Bisq with proper BM full node setup or switch to lite node mode."); + return; + } else if (cause instanceof NotificationHandlerException) { + log.error("Error from within block notification daemon: {}", cause.getCause().toString()); + applyReOrg(); + return; + } else if (cause instanceof Error) { + throw (Error) cause; + } + } + } + + if (errorMessageHandler != null) + errorMessageHandler.accept(errorMessage); + } + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/node/full/network/AccountingFullNodeNetworkService.java b/core/src/main/java/bisq/core/dao/burningman/accounting/node/full/network/AccountingFullNodeNetworkService.java new file mode 100644 index 0000000000..1936c35305 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/node/full/network/AccountingFullNodeNetworkService.java @@ -0,0 +1,230 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.node.full.network; + +import bisq.core.dao.burningman.accounting.BurningManAccountingService; +import bisq.core.dao.burningman.accounting.blockchain.AccountingBlock; +import bisq.core.dao.burningman.accounting.node.AccountingNode; +import bisq.core.dao.burningman.accounting.node.messages.GetAccountingBlocksRequest; +import bisq.core.dao.burningman.accounting.node.messages.NewAccountingBlockBroadcastMessage; + +import bisq.network.p2p.network.Connection; +import bisq.network.p2p.network.MessageListener; +import bisq.network.p2p.network.NetworkNode; +import bisq.network.p2p.peers.Broadcaster; +import bisq.network.p2p.peers.PeerManager; + +import bisq.common.UserThread; +import bisq.common.app.DevEnv; +import bisq.common.config.Config; +import bisq.common.proto.network.NetworkEnvelope; + +import org.bitcoinj.core.ECKey; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import java.math.BigInteger; + +import java.util.HashMap; +import java.util.Map; + +import lombok.extern.slf4j.Slf4j; + +import org.jetbrains.annotations.Nullable; + +import static com.google.common.base.Preconditions.checkArgument; +import static org.bitcoinj.core.Utils.HEX; + +// Taken from FullNodeNetworkService +@Singleton +@Slf4j +public class AccountingFullNodeNetworkService implements MessageListener, PeerManager.Listener { + private static final long CLEANUP_TIMER = 120; + + private final NetworkNode networkNode; + private final PeerManager peerManager; + private final Broadcaster broadcaster; + private final BurningManAccountingService burningManAccountingService; + private final boolean useDevPrivilegeKeys; + @Nullable + private final String bmOracleNodePubKey; + @Nullable + private final ECKey bmOracleNodePrivKey; + + // Key is connection UID + private final Map getBlocksRequestHandlers = new HashMap<>(); + private boolean stopped; + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Constructor + /////////////////////////////////////////////////////////////////////////////////////////// + + @Inject + public AccountingFullNodeNetworkService(NetworkNode networkNode, + PeerManager peerManager, + Broadcaster broadcaster, + BurningManAccountingService burningManAccountingService, + @Named(Config.USE_DEV_PRIVILEGE_KEYS) boolean useDevPrivilegeKeys, + @Named(Config.BM_ORACLE_NODE_PUB_KEY) String bmOracleNodePubKey, + @Named(Config.BM_ORACLE_NODE_PRIV_KEY) String bmOracleNodePrivKey) { + this.networkNode = networkNode; + this.peerManager = peerManager; + this.broadcaster = broadcaster; + this.burningManAccountingService = burningManAccountingService; + this.useDevPrivilegeKeys = useDevPrivilegeKeys; + + if (useDevPrivilegeKeys) { + bmOracleNodePubKey = DevEnv.DEV_PRIVILEGE_PUB_KEY; + bmOracleNodePrivKey = DevEnv.DEV_PRIVILEGE_PRIV_KEY; + } + this.bmOracleNodePubKey = bmOracleNodePubKey.isEmpty() ? null : bmOracleNodePubKey; + this.bmOracleNodePrivKey = bmOracleNodePrivKey.isEmpty() ? null : toEcKey(bmOracleNodePrivKey); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + public void addListeners() { + networkNode.addMessageListener(this); + peerManager.addListener(this); + } + + public void shutDown() { + stopped = true; + networkNode.removeMessageListener(this); + peerManager.removeListener(this); + } + + + public void publishAccountingBlock(AccountingBlock block) { + if (bmOracleNodePubKey == null || bmOracleNodePrivKey == null) { + log.warn("Ignore publishNewBlock call. bmOracleNodePubKey or bmOracleNodePrivKey are not set up"); + return; + } + + checkArgument(AccountingNode.isPermittedPubKey(useDevPrivilegeKeys, bmOracleNodePubKey), + "The bmOracleNodePubKey must be included in the hard coded list of supported pub keys"); + + log.info("Publish new block at height={}", block.getHeight()); + byte[] signature = AccountingNode.getSignature(AccountingNode.getSha256Hash(block), bmOracleNodePrivKey); + NewAccountingBlockBroadcastMessage message = new NewAccountingBlockBroadcastMessage(block, bmOracleNodePubKey, signature); + broadcaster.broadcast(message, networkNode.getNodeAddress()); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // PeerManager.Listener implementation + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void onAllConnectionsLost() { + stopped = true; + } + + @Override + public void onNewConnectionAfterAllConnectionsLost() { + stopped = false; + } + + @Override + public void onAwakeFromStandby() { + stopped = false; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // MessageListener implementation + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void onMessage(NetworkEnvelope networkEnvelope, Connection connection) { + if (networkEnvelope instanceof GetAccountingBlocksRequest) { + handleGetBlocksRequest((GetAccountingBlocksRequest) networkEnvelope, connection); + } + } + + private void handleGetBlocksRequest(GetAccountingBlocksRequest getBlocksRequest, Connection connection) { + if (bmOracleNodePubKey == null || bmOracleNodePrivKey == null) { + log.warn("Ignore handleGetBlocksRequest call. bmOracleNodePubKey or bmOracleNodePrivKey are not set up"); + return; + } + + checkArgument(AccountingNode.isPermittedPubKey(useDevPrivilegeKeys, bmOracleNodePubKey), + "The bmOracleNodePubKey must be included in the hard coded list of supported pub keys"); + + if (stopped) { + log.warn("We have stopped already. We ignore that onMessage call."); + return; + } + + String uid = connection.getUid(); + if (getBlocksRequestHandlers.containsKey(uid)) { + log.warn("We have already a GetDataRequestHandler for that connection started. " + + "We start a cleanup timer if the handler has not closed by itself in between 2 minutes."); + + UserThread.runAfter(() -> { + if (getBlocksRequestHandlers.containsKey(uid)) { + GetAccountingBlocksRequestHandler handler = getBlocksRequestHandlers.get(uid); + handler.stop(); + getBlocksRequestHandlers.remove(uid); + } + }, CLEANUP_TIMER); + return; + } + + GetAccountingBlocksRequestHandler requestHandler = new GetAccountingBlocksRequestHandler(networkNode, + burningManAccountingService, + bmOracleNodePrivKey, + bmOracleNodePubKey, + new GetAccountingBlocksRequestHandler.Listener() { + @Override + public void onComplete() { + getBlocksRequestHandlers.remove(uid); + } + + @Override + public void onFault(String errorMessage, @Nullable Connection connection) { + getBlocksRequestHandlers.remove(uid); + if (!stopped) { + log.trace("GetDataRequestHandler failed.\n\tConnection={}\n\t" + + "ErrorMessage={}", connection, errorMessage); + if (connection != null) { + peerManager.handleConnectionFault(connection); + } + } else { + log.warn("We have stopped already. We ignore that getDataRequestHandler.handle.onFault call."); + } + } + }); + getBlocksRequestHandlers.put(uid, requestHandler); + requestHandler.onGetBlocksRequest(getBlocksRequest, connection); + } + + private ECKey toEcKey(String bmOracleNodePrivKey) { + try { + return ECKey.fromPrivate(new BigInteger(1, HEX.decode(bmOracleNodePrivKey))); + } catch (Throwable t) { + log.error("Error at creating EC key out of bmOracleNodePrivKey", t); + return null; + } + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/node/full/network/GetAccountingBlocksRequestHandler.java b/core/src/main/java/bisq/core/dao/burningman/accounting/node/full/network/GetAccountingBlocksRequestHandler.java new file mode 100644 index 0000000000..6e1148a4b9 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/node/full/network/GetAccountingBlocksRequestHandler.java @@ -0,0 +1,171 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.node.full.network; + +import bisq.core.dao.burningman.accounting.BurningManAccountingService; +import bisq.core.dao.burningman.accounting.blockchain.AccountingBlock; +import bisq.core.dao.burningman.accounting.node.AccountingNode; +import bisq.core.dao.burningman.accounting.node.messages.GetAccountingBlocksRequest; +import bisq.core.dao.burningman.accounting.node.messages.GetAccountingBlocksResponse; + +import bisq.network.p2p.network.CloseConnectionReason; +import bisq.network.p2p.network.Connection; +import bisq.network.p2p.network.NetworkNode; + +import bisq.common.Timer; +import bisq.common.UserThread; + +import org.bitcoinj.core.ECKey; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import lombok.extern.slf4j.Slf4j; + +import org.jetbrains.annotations.NotNull; + +// Taken from GetBlocksRequestHandler +@Slf4j +class GetAccountingBlocksRequestHandler { + private static final long TIMEOUT_MIN = 3; + + /////////////////////////////////////////////////////////////////////////////////////////// + // Listener + /////////////////////////////////////////////////////////////////////////////////////////// + + public interface Listener { + void onComplete(); + + void onFault(String errorMessage, Connection connection); + } + + private final NetworkNode networkNode; + private final BurningManAccountingService burningManAccountingService; + private final ECKey bmOracleNodePrivKey; + private final String bmOracleNodePubKey; + private final Listener listener; + private Timer timeoutTimer; + private boolean stopped; + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Constructor + /////////////////////////////////////////////////////////////////////////////////////////// + + GetAccountingBlocksRequestHandler(NetworkNode networkNode, + BurningManAccountingService burningManAccountingService, + ECKey bmOracleNodePrivKey, + String bmOracleNodePubKey, + Listener listener) { + this.networkNode = networkNode; + this.burningManAccountingService = burningManAccountingService; + this.bmOracleNodePrivKey = bmOracleNodePrivKey; + this.bmOracleNodePubKey = bmOracleNodePubKey; + this.listener = listener; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + public void onGetBlocksRequest(GetAccountingBlocksRequest request, Connection connection) { + long ts = System.currentTimeMillis(); + List blocks = burningManAccountingService.getBlocks().stream() + .filter(block -> block.getHeight() >= request.getFromBlockHeight()) + .collect(Collectors.toList()); + byte[] signature = AccountingNode.getSignature(AccountingNode.getSha256Hash(blocks), bmOracleNodePrivKey); + GetAccountingBlocksResponse getBlocksResponse = new GetAccountingBlocksResponse(blocks, request.getNonce(), bmOracleNodePubKey, signature); + log.info("Received GetAccountingBlocksRequest from {} for blocks from height {}. " + + "Building GetAccountingBlocksResponse with {} blocks took {} ms.", + connection.getPeersNodeAddressOptional(), request.getFromBlockHeight(), + blocks.size(), System.currentTimeMillis() - ts); + + if (timeoutTimer != null) { + timeoutTimer.stop(); + log.warn("Timeout was already running. We stopped it."); + } + timeoutTimer = UserThread.runAfter(() -> { // setup before sending to avoid race conditions + String errorMessage = "A timeout occurred for getBlocksResponse.requestNonce:" + + getBlocksResponse.getRequestNonce() + + " on connection: " + connection; + handleFault(errorMessage, CloseConnectionReason.SEND_MSG_TIMEOUT, connection); + }, + TIMEOUT_MIN, TimeUnit.MINUTES); + + SettableFuture future = networkNode.sendMessage(connection, getBlocksResponse); + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(Connection connection) { + if (!stopped) { + log.info("Send DataResponse to {} succeeded. getBlocksResponse.getBlocks().size()={}", + connection.getPeersNodeAddressOptional(), getBlocksResponse.getBlocks().size()); + cleanup(); + listener.onComplete(); + } else { + log.trace("We have stopped already. We ignore that networkNode.sendMessage.onSuccess call."); + } + } + + @Override + public void onFailure(@NotNull Throwable throwable) { + if (!stopped) { + String errorMessage = "Sending getBlocksResponse to " + connection + + " failed. That is expected if the peer is offline. getBlocksResponse=" + getBlocksResponse + "." + + "Exception: " + throwable.getMessage(); + handleFault(errorMessage, CloseConnectionReason.SEND_MSG_FAILURE, connection); + } else { + log.trace("We have stopped already. We ignore that networkNode.sendMessage.onFailure call."); + } + } + }, MoreExecutors.directExecutor()); + } + + public void stop() { + cleanup(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Private + /////////////////////////////////////////////////////////////////////////////////////////// + + private void handleFault(String errorMessage, CloseConnectionReason closeConnectionReason, Connection connection) { + if (!stopped) { + log.warn("{}, closeConnectionReason={}", errorMessage, closeConnectionReason); + cleanup(); + listener.onFault(errorMessage, connection); + } else { + log.warn("We have already stopped (handleFault)"); + } + } + + private void cleanup() { + stopped = true; + if (timeoutTimer != null) { + timeoutTimer.stop(); + timeoutTimer = null; + } + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/node/lite/AccountingLiteNode.java b/core/src/main/java/bisq/core/dao/burningman/accounting/node/lite/AccountingLiteNode.java new file mode 100644 index 0000000000..d6c437682c --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/node/lite/AccountingLiteNode.java @@ -0,0 +1,310 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.node.lite; + +import bisq.core.btc.setup.WalletsSetup; +import bisq.core.btc.wallet.BsqWalletService; +import bisq.core.dao.burningman.accounting.BurningManAccountingService; +import bisq.core.dao.burningman.accounting.blockchain.AccountingBlock; +import bisq.core.dao.burningman.accounting.exceptions.BlockHashNotConnectingException; +import bisq.core.dao.burningman.accounting.exceptions.BlockHeightNotConnectingException; +import bisq.core.dao.burningman.accounting.node.AccountingNode; +import bisq.core.dao.burningman.accounting.node.full.AccountingBlockParser; +import bisq.core.dao.burningman.accounting.node.lite.network.AccountingLiteNodeNetworkService; +import bisq.core.dao.burningman.accounting.node.messages.GetAccountingBlocksResponse; +import bisq.core.dao.burningman.accounting.node.messages.NewAccountingBlockBroadcastMessage; +import bisq.core.dao.state.DaoStateService; + +import bisq.network.p2p.P2PService; +import bisq.network.p2p.network.ConnectionState; + +import bisq.common.Timer; +import bisq.common.UserThread; +import bisq.common.config.Config; +import bisq.common.util.Hex; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import javafx.beans.value.ChangeListener; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Singleton +public class AccountingLiteNode extends AccountingNode implements AccountingLiteNodeNetworkService.Listener { + private static final int CHECK_FOR_BLOCK_RECEIVED_DELAY_SEC = 10; + + private final WalletsSetup walletsSetup; + private final BsqWalletService bsqWalletService; + private final AccountingLiteNodeNetworkService accountingLiteNodeNetworkService; + private final boolean useDevPrivilegeKeys; + + private final List pendingAccountingBlocks = new ArrayList<>(); + private final ChangeListener blockDownloadListener; + private Timer checkForBlockReceivedTimer; + private int requestBlocksCounter; + + @Inject + public AccountingLiteNode(P2PService p2PService, + DaoStateService daoStateService, + BurningManAccountingService burningManAccountingService, + AccountingBlockParser accountingBlockParser, + WalletsSetup walletsSetup, + BsqWalletService bsqWalletService, + AccountingLiteNodeNetworkService accountingLiteNodeNetworkService, + @Named(Config.USE_DEV_PRIVILEGE_KEYS) boolean useDevPrivilegeKeys) { + super(p2PService, daoStateService, burningManAccountingService, accountingBlockParser); + + this.walletsSetup = walletsSetup; + this.bsqWalletService = bsqWalletService; + this.accountingLiteNodeNetworkService = accountingLiteNodeNetworkService; + this.useDevPrivilegeKeys = useDevPrivilegeKeys; + + blockDownloadListener = (observable, oldValue, newValue) -> { + if ((double) newValue == 1) { + setupWalletBestBlockListener(); + } + }; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Public methods + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void start() { + // We do not start yes but wait until DAO block parsing is complete to not interfere with + // that higher priority activity. + } + + @Override + public void shutDown() { + accountingLiteNodeNetworkService.shutDown(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // AccountingLiteNodeNetworkService.Listener + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void onRequestedBlocksReceived(GetAccountingBlocksResponse getBlocksResponse) { + List blocks = getBlocksResponse.getBlocks(); + if (!blocks.isEmpty() && isValidPubKeyAndSignature(AccountingNode.getSha256Hash(blocks), + getBlocksResponse.getPubKey(), + getBlocksResponse.getSignature(), + useDevPrivilegeKeys)) { + processAccountingBlocks(blocks); + } + } + + @Override + public void onNewBlockReceived(NewAccountingBlockBroadcastMessage message) { + AccountingBlock accountingBlock = message.getBlock(); + if (isValidPubKeyAndSignature(AccountingNode.getSha256Hash(accountingBlock), + message.getPubKey(), + message.getSignature(), + useDevPrivilegeKeys)) { + processNewAccountingBlock(accountingBlock); + } + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Protected methods + /////////////////////////////////////////////////////////////////////////////////////////// + + // We start after initial DAO parsing is complete + @Override + protected void onInitialDaoBlockParsingComplete() { + accountingLiteNodeNetworkService.addListeners(); + + // We wait until the wallet is synced before using it for triggering requests + if (walletsSetup.isDownloadComplete()) { + setupWalletBestBlockListener(); + } else { + walletsSetup.downloadPercentageProperty().addListener(blockDownloadListener); + } + + super.onInitialized(); + } + + @Override + protected void onP2PNetworkReady() { + super.onP2PNetworkReady(); + + accountingLiteNodeNetworkService.addListener(this); + + if (!initialBlockRequestsComplete) { + startRequestBlocks(); + } + } + + @Override + protected void startRequestBlocks() { + int heightOfLastBlock = burningManAccountingService.getBlockHeightOfLastBlock(); + if (walletsSetup.isDownloadComplete() && heightOfLastBlock == bsqWalletService.getBestChainHeight()) { + log.info("No block request needed as we have already the most recent block. " + + "heightOfLastBlock={}, bsqWalletService.getBestChainHeight()={}", + heightOfLastBlock, bsqWalletService.getBestChainHeight()); + onInitialBlockRequestsComplete(); + return; + } + + ConnectionState.incrementExpectedInitialDataResponses(); + accountingLiteNodeNetworkService.requestBlocks(heightOfLastBlock + 1); + } + + @Override + protected void applyReOrg() { + pendingAccountingBlocks.clear(); + accountingLiteNodeNetworkService.reset(); + super.applyReOrg(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Private + /////////////////////////////////////////////////////////////////////////////////////////// + + private void processAccountingBlocks(List blocks) { + log.info("We received blocks from height {} to {}", + blocks.get(0).getHeight(), + blocks.get(blocks.size() - 1).getHeight()); + + boolean requiresReOrg = false; + for (AccountingBlock block : blocks) { + try { + burningManAccountingService.addBlock(block); + } catch (BlockHeightNotConnectingException e) { + log.info("Height not connecting. This could happen if we received multiple responses and had already applied a previous one. {}", e.toString()); + } catch (BlockHashNotConnectingException e) { + log.warn("Interrupt loop because a reorg is required. {}", e.toString()); + requiresReOrg = true; + break; + } + } + if (requiresReOrg) { + applyReOrg(); + return; + } + + int heightOfLastBlock = burningManAccountingService.getBlockHeightOfLastBlock(); + if (walletsSetup.isDownloadComplete() && heightOfLastBlock < bsqWalletService.getBestChainHeight()) { + accountingLiteNodeNetworkService.requestBlocks(heightOfLastBlock + 1); + } else { + if (!initialBlockRequestsComplete) { + onInitialBlockRequestsComplete(); + } + } + } + + private void processNewAccountingBlock(AccountingBlock accountingBlock) { + int blockHeight = accountingBlock.getHeight(); + log.info("onNewBlockReceived: accountingBlock at height {}", blockHeight); + + pendingAccountingBlocks.remove(accountingBlock); + try { + burningManAccountingService.addBlock(accountingBlock); + burningManAccountingService.onNewBlockReceived(accountingBlock); + + // After parsing we check if we have pending blocks we might have received earlier but which have been + // not connecting from the latest height we had. The list is sorted by height + if (!pendingAccountingBlocks.isEmpty()) { + // We take only first element after sorting (so it is the accountingBlock with the next height) to avoid that + // we would repeat calls in recursions in case we would iterate the list. + pendingAccountingBlocks.sort(Comparator.comparing(AccountingBlock::getHeight)); + AccountingBlock nextPending = pendingAccountingBlocks.get(0); + if (nextPending.getHeight() == burningManAccountingService.getBlockHeightOfLastBlock() + 1) { + processNewAccountingBlock(nextPending); + } + } + } catch (BlockHeightNotConnectingException e) { + // If height of rawDtoBlock is not at expected heightForNextBlock but further in the future we add it to pendingRawDtoBlocks + int heightForNextBlock = burningManAccountingService.getBlockHeightOfLastBlock() + 1; + if (accountingBlock.getHeight() > heightForNextBlock && !pendingAccountingBlocks.contains(accountingBlock)) { + pendingAccountingBlocks.add(accountingBlock); + log.info("We received a accountingBlock with a future accountingBlock height. We store it as pending and try to apply it at the next accountingBlock. " + + "heightForNextBlock={}, accountingBlock: height/truncatedHash={}/{}", heightForNextBlock, accountingBlock.getHeight(), accountingBlock.getTruncatedHash()); + + requestBlocksCounter++; + log.warn("We are trying to call requestBlocks with heightForNextBlock {} after a delay of {} min.", + heightForNextBlock, requestBlocksCounter * requestBlocksCounter); + if (requestBlocksCounter <= 5) { + UserThread.runAfter(() -> { + pendingAccountingBlocks.clear(); + accountingLiteNodeNetworkService.requestBlocks(heightForNextBlock); + }, + requestBlocksCounter * requestBlocksCounter * 60L); + } else { + log.warn("We tried {} times to call requestBlocks with heightForNextBlock {}.", + requestBlocksCounter, heightForNextBlock); + } + } + } catch (BlockHashNotConnectingException e) { + Optional lastBlock = burningManAccountingService.getLastBlock(); + log.warn("Block not connecting:\n" + + "New block height/hash/previousBlockHash={}/{}/{}, latest block height/hash={}/{}", + accountingBlock.getHeight(), + Hex.encode(accountingBlock.getTruncatedHash()), + Hex.encode(accountingBlock.getTruncatedPreviousBlockHash()), + lastBlock.isPresent() ? lastBlock.get().getHeight() : "lastBlock not present", + lastBlock.isPresent() ? Hex.encode(lastBlock.get().getTruncatedHash()) : "lastBlock not present"); + + applyReOrg(); + } + } + + private void setupWalletBestBlockListener() { + walletsSetup.downloadPercentageProperty().removeListener(blockDownloadListener); + + bsqWalletService.addNewBestBlockListener(blockFromWallet -> { + // If we are not completed with initial block requests we return + if (!initialBlockRequestsComplete) + return; + + if (checkForBlockReceivedTimer != null) { + // In case we received a new block before out timer gets called we stop the old timer + checkForBlockReceivedTimer.stop(); + } + + int walletBlockHeight = blockFromWallet.getHeight(); + log.info("New block at height {} from bsqWalletService", walletBlockHeight); + + // We expect to receive the new BSQ block from the network shortly after BitcoinJ has been aware of it. + // If we don't receive it we request it manually from seed nodes + checkForBlockReceivedTimer = UserThread.runAfter(() -> { + int heightOfLastBlock = burningManAccountingService.getBlockHeightOfLastBlock(); + if (heightOfLastBlock < walletBlockHeight) { + log.warn("We did not receive a block from the network {} seconds after we saw the new block in BitcoinJ. " + + "We request from our seed nodes missing blocks from block height {}.", + CHECK_FOR_BLOCK_RECEIVED_DELAY_SEC, heightOfLastBlock + 1); + accountingLiteNodeNetworkService.requestBlocks(heightOfLastBlock + 1); + } + }, CHECK_FOR_BLOCK_RECEIVED_DELAY_SEC); + }); + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/node/lite/network/AccountingLiteNodeNetworkService.java b/core/src/main/java/bisq/core/dao/burningman/accounting/node/lite/network/AccountingLiteNodeNetworkService.java new file mode 100644 index 0000000000..d0c7f92c04 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/node/lite/network/AccountingLiteNodeNetworkService.java @@ -0,0 +1,392 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.node.lite.network; + +import bisq.core.dao.burningman.accounting.node.messages.GetAccountingBlocksResponse; +import bisq.core.dao.burningman.accounting.node.messages.NewAccountingBlockBroadcastMessage; + +import bisq.network.p2p.NodeAddress; +import bisq.network.p2p.network.CloseConnectionReason; +import bisq.network.p2p.network.Connection; +import bisq.network.p2p.network.ConnectionListener; +import bisq.network.p2p.network.MessageListener; +import bisq.network.p2p.network.NetworkNode; +import bisq.network.p2p.peers.Broadcaster; +import bisq.network.p2p.peers.PeerManager; +import bisq.network.p2p.seed.SeedNodeRepository; +import bisq.network.p2p.storage.P2PDataStorage; + +import bisq.common.Timer; +import bisq.common.UserThread; +import bisq.common.app.DevEnv; +import bisq.common.proto.network.NetworkEnvelope; +import bisq.common.util.Tuple2; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +import lombok.extern.slf4j.Slf4j; + +import org.jetbrains.annotations.Nullable; + +// Taken from LiteNodeNetworkService +@Slf4j +@Singleton +public class AccountingLiteNodeNetworkService implements MessageListener, ConnectionListener, PeerManager.Listener { + private static final long RETRY_DELAY_SEC = 10; + private static final long CLEANUP_TIMER = 120; + private static final int MAX_RETRY = 12; + + private int retryCounter = 0; + private int lastRequestedBlockHeight; + private int lastReceivedBlockHeight; + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Listener + /////////////////////////////////////////////////////////////////////////////////////////// + + public interface Listener { + void onRequestedBlocksReceived(GetAccountingBlocksResponse getBlocksResponse); + + void onNewBlockReceived(NewAccountingBlockBroadcastMessage newBlockBroadcastMessage); + } + + private final NetworkNode networkNode; + private final PeerManager peerManager; + private final Broadcaster broadcaster; + private final Collection seedNodeAddresses; + + private final List listeners = new CopyOnWriteArrayList<>(); + + // Key is tuple of seedNode address and requested blockHeight + private final Map, RequestAccountingBlocksHandler> requestBlocksHandlerMap = new HashMap<>(); + private Timer retryTimer; + private boolean stopped; + private final Set receivedBlockMessages = new HashSet<>(); + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Constructor + /////////////////////////////////////////////////////////////////////////////////////////// + + @Inject + public AccountingLiteNodeNetworkService(NetworkNode networkNode, + PeerManager peerManager, + Broadcaster broadcaster, + SeedNodeRepository seedNodesRepository) { + this.networkNode = networkNode; + this.peerManager = peerManager; + this.broadcaster = broadcaster; + // seedNodeAddresses can be empty (in case there is only 1 seed node, the seed node starting up has no other seed nodes) + this.seedNodeAddresses = new HashSet<>(seedNodesRepository.getSeedNodeAddresses()); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + public void addListeners() { + networkNode.addMessageListener(this); + networkNode.addConnectionListener(this); + peerManager.addListener(this); + } + + public void shutDown() { + stopped = true; + stopRetryTimer(); + networkNode.removeMessageListener(this); + networkNode.removeConnectionListener(this); + peerManager.removeListener(this); + closeAllHandlers(); + } + + public void addListener(Listener listener) { + listeners.add(listener); + } + + public void requestBlocks(int startBlockHeight) { + lastRequestedBlockHeight = startBlockHeight; + Optional connectionToSeedNodeOptional = networkNode.getConfirmedConnections().stream() + .filter(peerManager::isSeedNode) + .findAny(); + + connectionToSeedNodeOptional.flatMap(Connection::getPeersNodeAddressOptional) + .ifPresentOrElse(candidate -> { + seedNodeAddresses.remove(candidate); + requestBlocks(candidate, startBlockHeight); + }, () -> { + tryWithNewSeedNode(startBlockHeight); + }); + } + + public void reset() { + lastRequestedBlockHeight = 0; + lastReceivedBlockHeight = 0; + retryCounter = 0; + requestBlocksHandlerMap.values().forEach(RequestAccountingBlocksHandler::terminate); + requestBlocksHandlerMap.clear(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // ConnectionListener implementation + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void onConnection(Connection connection) { + } + + @Override + public void onDisconnect(CloseConnectionReason closeConnectionReason, Connection connection) { + closeHandler(connection); + + if (peerManager.isPeerBanned(closeConnectionReason, connection)) { + connection.getPeersNodeAddressOptional().ifPresent(nodeAddress -> { + seedNodeAddresses.remove(nodeAddress); + removeFromRequestBlocksHandlerMap(nodeAddress); + }); + } + } + + @Override + public void onError(Throwable throwable) { + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // PeerManager.Listener implementation + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void onAllConnectionsLost() { + log.info("onAllConnectionsLost"); + closeAllHandlers(); + stopRetryTimer(); + stopped = true; + tryWithNewSeedNode(lastRequestedBlockHeight); + } + + @Override + public void onNewConnectionAfterAllConnectionsLost() { + log.info("onNewConnectionAfterAllConnectionsLost"); + closeAllHandlers(); + stopped = false; + tryWithNewSeedNode(lastRequestedBlockHeight); + } + + @Override + public void onAwakeFromStandby() { + log.info("onAwakeFromStandby"); + closeAllHandlers(); + stopped = false; + tryWithNewSeedNode(lastRequestedBlockHeight); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // MessageListener implementation + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void onMessage(NetworkEnvelope networkEnvelope, Connection connection) { + if (networkEnvelope instanceof NewAccountingBlockBroadcastMessage) { + NewAccountingBlockBroadcastMessage newBlockBroadcastMessage = (NewAccountingBlockBroadcastMessage) networkEnvelope; + P2PDataStorage.ByteArray truncatedHash = new P2PDataStorage.ByteArray(newBlockBroadcastMessage.getBlock().getTruncatedHash()); + if (receivedBlockMessages.contains(truncatedHash)) { + log.debug("We had that message already and do not further broadcast it. hash={}", truncatedHash); + return; + } + + log.info("We received a NewAccountingBlockBroadcastMessage from peer {} and broadcast it to our peers. height={} truncatedHash={}", + connection.getPeersNodeAddressOptional().orElse(null), newBlockBroadcastMessage.getBlock().getHeight(), truncatedHash); + receivedBlockMessages.add(truncatedHash); + broadcaster.broadcast(newBlockBroadcastMessage, connection.getPeersNodeAddressOptional().orElse(null)); + listeners.forEach(listener -> listener.onNewBlockReceived(newBlockBroadcastMessage)); + } + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // RequestData + /////////////////////////////////////////////////////////////////////////////////////////// + + private void requestBlocks(NodeAddress peersNodeAddress, int startBlockHeight) { + if (stopped) { + log.warn("We have stopped already. We ignore that requestData call."); + return; + } + + Tuple2 key = new Tuple2<>(peersNodeAddress, startBlockHeight); + if (requestBlocksHandlerMap.containsKey(key)) { + log.warn("We have started already a requestDataHandshake for startBlockHeight {} to peer. nodeAddress={}\n" + + "We start a cleanup timer if the handler has not closed by itself in between 2 minutes.", + peersNodeAddress, startBlockHeight); + + UserThread.runAfter(() -> { + if (requestBlocksHandlerMap.containsKey(key)) { + RequestAccountingBlocksHandler handler = requestBlocksHandlerMap.get(key); + handler.terminate(); + requestBlocksHandlerMap.remove(key); + } + }, CLEANUP_TIMER); + return; + } + + if (startBlockHeight < lastReceivedBlockHeight) { + log.warn("startBlockHeight must not be smaller than lastReceivedBlockHeight. That should never happen." + + "startBlockHeight={},lastReceivedBlockHeight={}", startBlockHeight, lastReceivedBlockHeight); + DevEnv.logErrorAndThrowIfDevMode("startBlockHeight must be larger than lastReceivedBlockHeight. startBlockHeight=" + + startBlockHeight + " / lastReceivedBlockHeight=" + lastReceivedBlockHeight); + return; + } + + RequestAccountingBlocksHandler requestAccountingBlocksHandler = new RequestAccountingBlocksHandler(networkNode, + peerManager, + peersNodeAddress, + startBlockHeight, + new RequestAccountingBlocksHandler.Listener() { + @Override + public void onComplete(GetAccountingBlocksResponse getBlocksResponse) { + log.info("requestBlocksHandler to {} completed", peersNodeAddress); + stopRetryTimer(); + + // need to remove before listeners are notified as they cause the update call + requestBlocksHandlerMap.remove(key); + // we only notify if our request was latest + if (startBlockHeight > lastReceivedBlockHeight) { + lastReceivedBlockHeight = startBlockHeight; + + listeners.forEach(listener -> listener.onRequestedBlocksReceived(getBlocksResponse)); + } else { + log.warn("We got a response which is already obsolete because we received a " + + "response from a request with same or higher block height."); + } + } + + @Override + public void onFault(String errorMessage, @Nullable Connection connection) { + log.warn("requestBlocksHandler with outbound connection failed.\n\tnodeAddress={}\n\t" + + "ErrorMessage={}", peersNodeAddress, errorMessage); + + peerManager.handleConnectionFault(peersNodeAddress); + requestBlocksHandlerMap.remove(key); + + tryWithNewSeedNode(startBlockHeight); + } + }); + requestBlocksHandlerMap.put(key, requestAccountingBlocksHandler); + requestAccountingBlocksHandler.requestBlocks(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Utils + /////////////////////////////////////////////////////////////////////////////////////////// + + private void tryWithNewSeedNode(int startBlockHeight) { + if (networkNode.getAllConnections().isEmpty()) { + return; + } + + if (lastRequestedBlockHeight == 0) { + return; + } + + if (stopped) { + return; + } + + if (retryTimer != null) { + log.warn("We have a retry timer already running."); + return; + } + + retryCounter++; + + if (retryCounter > MAX_RETRY) { + log.warn("We tried {} times but could not connect to a seed node.", retryCounter); + return; + } + + retryTimer = UserThread.runAfter(() -> { + stopped = false; + + stopRetryTimer(); + + List list = seedNodeAddresses.stream() + .filter(e -> peerManager.isSeedNode(e) && !peerManager.isSelf(e)) + .collect(Collectors.toList()); + Collections.shuffle(list); + + if (!list.isEmpty()) { + NodeAddress nextCandidate = list.get(0); + seedNodeAddresses.remove(nextCandidate); + log.info("We try requestBlocks from {} with startBlockHeight={}", nextCandidate, startBlockHeight); + requestBlocks(nextCandidate, startBlockHeight); + } else { + log.warn("No more seed nodes available we could try."); + } + }, + RETRY_DELAY_SEC); + } + + private void stopRetryTimer() { + if (retryTimer != null) { + retryTimer.stop(); + retryTimer = null; + } + } + + private void closeHandler(Connection connection) { + Optional peersNodeAddressOptional = connection.getPeersNodeAddressOptional(); + if (peersNodeAddressOptional.isPresent()) { + NodeAddress nodeAddress = peersNodeAddressOptional.get(); + removeFromRequestBlocksHandlerMap(nodeAddress); + } else { + log.trace("closeHandler: nodeAddress not set in connection {}", connection); + } + } + + private void removeFromRequestBlocksHandlerMap(NodeAddress nodeAddress) { + requestBlocksHandlerMap.entrySet().stream() + .filter(e -> e.getKey().first.equals(nodeAddress)) + .findAny() + .ifPresent(e -> { + e.getValue().terminate(); + requestBlocksHandlerMap.remove(e.getKey()); + }); + } + + private void closeAllHandlers() { + requestBlocksHandlerMap.values().forEach(RequestAccountingBlocksHandler::terminate); + requestBlocksHandlerMap.clear(); + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/node/lite/network/RequestAccountingBlocksHandler.java b/core/src/main/java/bisq/core/dao/burningman/accounting/node/lite/network/RequestAccountingBlocksHandler.java new file mode 100644 index 0000000000..303b0bda3e --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/node/lite/network/RequestAccountingBlocksHandler.java @@ -0,0 +1,219 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.node.lite.network; + + +import bisq.core.dao.burningman.accounting.node.messages.GetAccountingBlocksRequest; +import bisq.core.dao.burningman.accounting.node.messages.GetAccountingBlocksResponse; + +import bisq.network.p2p.NodeAddress; +import bisq.network.p2p.network.CloseConnectionReason; +import bisq.network.p2p.network.Connection; +import bisq.network.p2p.network.MessageListener; +import bisq.network.p2p.network.NetworkNode; +import bisq.network.p2p.peers.PeerManager; + +import bisq.common.Timer; +import bisq.common.UserThread; +import bisq.common.proto.network.NetworkEnvelope; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; + +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +// Taken from RequestBlocksHandler +@Slf4j +public class RequestAccountingBlocksHandler implements MessageListener { + private static final long TIMEOUT_MIN = 3; + + /////////////////////////////////////////////////////////////////////////////////////////// + // Listener + /////////////////////////////////////////////////////////////////////////////////////////// + + public interface Listener { + void onComplete(GetAccountingBlocksResponse getBlocksResponse); + + @SuppressWarnings("UnusedParameters") + void onFault(String errorMessage, @SuppressWarnings("SameParameterValue") @Nullable Connection connection); + } + + + private final NetworkNode networkNode; + private final PeerManager peerManager; + @Getter + private final NodeAddress nodeAddress; + @Getter + private final int startBlockHeight; + private final Listener listener; + private Timer timeoutTimer; + private final int nonce = new Random().nextInt(); + private boolean stopped; + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Constructor + /////////////////////////////////////////////////////////////////////////////////////////// + + public RequestAccountingBlocksHandler(NetworkNode networkNode, + PeerManager peerManager, + NodeAddress nodeAddress, + int startBlockHeight, + Listener listener) { + this.networkNode = networkNode; + this.peerManager = peerManager; + this.nodeAddress = nodeAddress; + this.startBlockHeight = startBlockHeight; + this.listener = listener; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + public void requestBlocks() { + if (stopped) { + log.warn("We have stopped already. We ignore that requestData call."); + return; + } + + GetAccountingBlocksRequest getBlocksRequest = new GetAccountingBlocksRequest(startBlockHeight, nonce, networkNode.getNodeAddress()); + + if (timeoutTimer != null) { + log.warn("We had a timer already running and stop it."); + timeoutTimer.stop(); + } + timeoutTimer = UserThread.runAfter(() -> { // setup before sending to avoid race conditions + if (!stopped) { + String errorMessage = "A timeout occurred when sending getBlocksRequest:" + getBlocksRequest + + " on peersNodeAddress:" + nodeAddress; + log.debug("{} / RequestDataHandler={}", errorMessage, RequestAccountingBlocksHandler.this); + handleFault(errorMessage, nodeAddress, CloseConnectionReason.SEND_MSG_TIMEOUT); + } else { + log.warn("We have stopped already. We ignore that timeoutTimer.run call. " + + "Might be caused by a previous networkNode.sendMessage.onFailure."); + } + }, + TIMEOUT_MIN, TimeUnit.MINUTES); + + log.info("We request blocks from peer {} from block height {}.", nodeAddress, getBlocksRequest.getFromBlockHeight()); + + networkNode.addMessageListener(this); + + SettableFuture future = networkNode.sendMessage(nodeAddress, getBlocksRequest); + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(Connection connection) { + log.info("Sending of GetAccountingBlocksRequest message to peer {} succeeded.", nodeAddress.getFullAddress()); + } + + @Override + public void onFailure(@NotNull Throwable throwable) { + if (!stopped) { + String errorMessage = "Sending getBlocksRequest to " + nodeAddress + + " failed. That is expected if the peer is offline.\n\t" + + "getBlocksRequest=" + getBlocksRequest + "." + + "\n\tException=" + throwable.getMessage(); + log.error(errorMessage); + handleFault(errorMessage, nodeAddress, CloseConnectionReason.SEND_MSG_FAILURE); + } else { + log.warn("We have stopped already. We ignore that networkNode.sendMessage.onFailure call."); + } + } + }, MoreExecutors.directExecutor()); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // MessageListener implementation + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void onMessage(NetworkEnvelope networkEnvelope, Connection connection) { + if (networkEnvelope instanceof GetAccountingBlocksResponse) { + if (stopped) { + log.warn("We have stopped already. We ignore that onDataRequest call."); + return; + } + + Optional optionalNodeAddress = connection.getPeersNodeAddressOptional(); + if (optionalNodeAddress.isEmpty()) { + log.warn("Peers node address is not present, that is not expected."); + // We do not return here as in case the connection has been created from the peers side we might not + // have the address set. As we check the nonce later we do not care that much for the check if the + // connection address is the same as the one we used. + } else if (!optionalNodeAddress.get().equals(nodeAddress)) { + log.warn("Peers node address is not the same we used for the request. This is not expected. We ignore that message."); + return; + } + + GetAccountingBlocksResponse getBlocksResponse = (GetAccountingBlocksResponse) networkEnvelope; + if (getBlocksResponse.getRequestNonce() != nonce) { + log.warn("Nonce not matching. That can happen rarely if we get a response after a canceled " + + "handshake (timeout causes connection close but peer might have sent a msg before " + + "connection was closed).\n\t" + + "We drop that message. nonce={} / requestNonce={}", + nonce, getBlocksResponse.getRequestNonce()); + return; + } + + terminate(); + log.info("We received from peer {} a BlocksResponse with {} blocks", + nodeAddress.getFullAddress(), getBlocksResponse.getBlocks().size()); + listener.onComplete(getBlocksResponse); + } + } + + public void terminate() { + stopped = true; + networkNode.removeMessageListener(this); + stopTimeoutTimer(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Private + /////////////////////////////////////////////////////////////////////////////////////////// + + @SuppressWarnings("UnusedParameters") + private void handleFault(String errorMessage, + NodeAddress nodeAddress, + CloseConnectionReason closeConnectionReason) { + terminate(); + peerManager.handleConnectionFault(nodeAddress); + listener.onFault(errorMessage, null); + } + + private void stopTimeoutTimer() { + if (timeoutTimer != null) { + timeoutTimer.stop(); + timeoutTimer = null; + } + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/node/messages/GetAccountingBlocksRequest.java b/core/src/main/java/bisq/core/dao/burningman/accounting/node/messages/GetAccountingBlocksRequest.java new file mode 100644 index 0000000000..364b169a94 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/node/messages/GetAccountingBlocksRequest.java @@ -0,0 +1,111 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.node.messages; + +import bisq.network.p2p.DirectMessage; +import bisq.network.p2p.InitialDataRequest; +import bisq.network.p2p.NodeAddress; +import bisq.network.p2p.SendersNodeAddressMessage; +import bisq.network.p2p.SupportedCapabilitiesMessage; + +import bisq.common.app.Capabilities; +import bisq.common.app.Version; +import bisq.common.proto.network.NetworkEnvelope; + +import java.util.Optional; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +// Taken from GetBlocksRequest +@EqualsAndHashCode(callSuper = true) +@Getter +@Slf4j +public final class GetAccountingBlocksRequest extends NetworkEnvelope implements DirectMessage, SendersNodeAddressMessage, + SupportedCapabilitiesMessage, InitialDataRequest { + private final int fromBlockHeight; + private final int nonce; + + private final NodeAddress senderNodeAddress; + private final Capabilities supportedCapabilities; + + public GetAccountingBlocksRequest(int fromBlockHeight, + int nonce, + NodeAddress senderNodeAddress) { + this(fromBlockHeight, + nonce, + senderNodeAddress, + Capabilities.app, + Version.getP2PMessageVersion()); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + private GetAccountingBlocksRequest(int fromBlockHeight, + int nonce, + NodeAddress senderNodeAddress, + Capabilities supportedCapabilities, + int messageVersion) { + super(messageVersion); + this.fromBlockHeight = fromBlockHeight; + this.nonce = nonce; + this.senderNodeAddress = senderNodeAddress; + this.supportedCapabilities = supportedCapabilities; + } + + //todo + @Override + public protobuf.NetworkEnvelope toProtoNetworkEnvelope() { + protobuf.GetAccountingBlocksRequest.Builder builder = protobuf.GetAccountingBlocksRequest.newBuilder() + .setFromBlockHeight(fromBlockHeight) + .setNonce(nonce); + Optional.ofNullable(senderNodeAddress).ifPresent(e -> builder.setSenderNodeAddress(e.toProtoMessage())); + Optional.ofNullable(supportedCapabilities).ifPresent(e -> builder.addAllSupportedCapabilities(Capabilities.toIntList(supportedCapabilities))); + return getNetworkEnvelopeBuilder().setGetAccountingBlocksRequest(builder).build(); + } + + public static NetworkEnvelope fromProto(protobuf.GetAccountingBlocksRequest proto, int messageVersion) { + protobuf.NodeAddress protoNodeAddress = proto.getSenderNodeAddress(); + NodeAddress senderNodeAddress = protoNodeAddress.getHostName().isEmpty() ? + null : + NodeAddress.fromProto(protoNodeAddress); + Capabilities supportedCapabilities = proto.getSupportedCapabilitiesList().isEmpty() ? + null : + Capabilities.fromIntList(proto.getSupportedCapabilitiesList()); + return new GetAccountingBlocksRequest(proto.getFromBlockHeight(), + proto.getNonce(), + senderNodeAddress, + supportedCapabilities, + messageVersion); + } + + + @Override + public String toString() { + return "GetAccountingBlocksRequest{" + + "\n fromBlockHeight=" + fromBlockHeight + + ",\n nonce=" + nonce + + ",\n senderNodeAddress=" + senderNodeAddress + + ",\n supportedCapabilities=" + supportedCapabilities + + "\n} " + super.toString(); + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/node/messages/GetAccountingBlocksResponse.java b/core/src/main/java/bisq/core/dao/burningman/accounting/node/messages/GetAccountingBlocksResponse.java new file mode 100644 index 0000000000..81f7f48be2 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/node/messages/GetAccountingBlocksResponse.java @@ -0,0 +1,110 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.node.messages; + + +import bisq.core.dao.burningman.accounting.blockchain.AccountingBlock; + +import bisq.network.p2p.DirectMessage; +import bisq.network.p2p.ExtendedDataSizePermission; +import bisq.network.p2p.InitialDataRequest; +import bisq.network.p2p.InitialDataResponse; + +import bisq.common.app.Version; +import bisq.common.proto.network.NetworkEnvelope; + +import com.google.protobuf.ByteString; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +// Taken from GetBlocksResponse +@EqualsAndHashCode(callSuper = true) +@Getter +@Slf4j +public final class GetAccountingBlocksResponse extends NetworkEnvelope implements DirectMessage, + ExtendedDataSizePermission, InitialDataResponse { + private final List blocks; + private final int requestNonce; + private final String pubKey; + private final byte[] signature; + + public GetAccountingBlocksResponse(List blocks, + int requestNonce, + String pubKey, + byte[] signature) { + this(blocks, requestNonce, pubKey, signature, Version.getP2PMessageVersion()); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + private GetAccountingBlocksResponse(List blocks, + int requestNonce, + String pubKey, + byte[] signature, + int messageVersion) { + super(messageVersion); + + this.blocks = blocks; + this.requestNonce = requestNonce; + this.pubKey = pubKey; + this.signature = signature; + } + + @Override + public protobuf.NetworkEnvelope toProtoNetworkEnvelope() { + protobuf.NetworkEnvelope proto = getNetworkEnvelopeBuilder() + .setGetAccountingBlocksResponse(protobuf.GetAccountingBlocksResponse.newBuilder() + .addAllBlocks(blocks.stream() + .map(AccountingBlock::toProtoMessage) + .collect(Collectors.toList())) + .setRequestNonce(requestNonce) + .setPubKey(pubKey) + .setSignature(ByteString.copyFrom(signature))) + .build(); + log.info("Sending a GetBlocksResponse with {} kB", proto.getSerializedSize() / 1000d); + return proto; + } + + public static NetworkEnvelope fromProto(protobuf.GetAccountingBlocksResponse proto, int messageVersion) { + List list = proto.getBlocksList().stream() + .map(AccountingBlock::fromProto) + .collect(Collectors.toList()); + log.info("Received a GetBlocksResponse with {} blocks and {} kB size", list.size(), proto.getSerializedSize() / 1000d); + return new GetAccountingBlocksResponse(proto.getBlocksList().isEmpty() ? + new ArrayList<>() : + list, + proto.getRequestNonce(), + proto.getPubKey(), + proto.getSignature().toByteArray(), + messageVersion); + } + + @Override + public Class associatedRequest() { + return GetAccountingBlocksRequest.class; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/node/messages/NewAccountingBlockBroadcastMessage.java b/core/src/main/java/bisq/core/dao/burningman/accounting/node/messages/NewAccountingBlockBroadcastMessage.java new file mode 100644 index 0000000000..c50da6666a --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/node/messages/NewAccountingBlockBroadcastMessage.java @@ -0,0 +1,78 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.node.messages; + + +import bisq.core.dao.burningman.accounting.blockchain.AccountingBlock; + +import bisq.network.p2p.storage.messages.BroadcastMessage; + +import bisq.common.app.Version; +import bisq.common.proto.network.NetworkEnvelope; + +import com.google.protobuf.ByteString; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@EqualsAndHashCode(callSuper = true) +@Getter +public final class NewAccountingBlockBroadcastMessage extends BroadcastMessage { + private final AccountingBlock block; + private final String pubKey; + private final byte[] signature; + + public NewAccountingBlockBroadcastMessage(AccountingBlock block, String pubKey, byte[] signature) { + this(block, pubKey, signature, Version.getP2PMessageVersion()); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + private NewAccountingBlockBroadcastMessage(AccountingBlock block, + String pubKey, + byte[] signature, + int messageVersion) { + super(messageVersion); + this.block = block; + this.pubKey = pubKey; + this.signature = signature; + } + + @Override + public protobuf.NetworkEnvelope toProtoNetworkEnvelope() { + return getNetworkEnvelopeBuilder() + .setNewAccountingBlockBroadcastMessage(protobuf.NewAccountingBlockBroadcastMessage.newBuilder() + .setBlock(block.toProtoMessage()) + .setPubKey(pubKey) + .setSignature(ByteString.copyFrom(signature)) + ) + .build(); + } + + public static NetworkEnvelope fromProto(protobuf.NewAccountingBlockBroadcastMessage proto, int messageVersion) { + return new NewAccountingBlockBroadcastMessage(AccountingBlock.fromProto(proto.getBlock()), + proto.getPubKey(), + proto.getSignature().toByteArray(), + messageVersion); + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/storage/BurningManAccountingStore.java b/core/src/main/java/bisq/core/dao/burningman/accounting/storage/BurningManAccountingStore.java new file mode 100644 index 0000000000..cd31b12db9 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/storage/BurningManAccountingStore.java @@ -0,0 +1,57 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.storage; + + +import bisq.core.dao.burningman.accounting.blockchain.AccountingBlock; + +import bisq.common.proto.persistable.PersistableEnvelope; + +import com.google.protobuf.Message; + +import java.util.LinkedList; +import java.util.List; +import java.util.stream.Collectors; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Getter +public class BurningManAccountingStore implements PersistableEnvelope { + private final LinkedList blocks = new LinkedList<>(); + + public BurningManAccountingStore(List blocks) { + this.blocks.addAll(blocks); + } + + public Message toProtoMessage() { + return protobuf.PersistableEnvelope.newBuilder() + .setBurningManAccountingStore(protobuf.BurningManAccountingStore.newBuilder() + .addAllBlocks(blocks.stream() + .map(AccountingBlock::toProtoMessage) + .collect(Collectors.toList()))) + .build(); + } + + public static BurningManAccountingStore fromProto(protobuf.BurningManAccountingStore proto) { + return new BurningManAccountingStore(proto.getBlocksList().stream() + .map(AccountingBlock::fromProto) + .collect(Collectors.toList())); + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/storage/BurningManAccountingStoreService.java b/core/src/main/java/bisq/core/dao/burningman/accounting/storage/BurningManAccountingStoreService.java new file mode 100644 index 0000000000..79a58fb07c --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/storage/BurningManAccountingStoreService.java @@ -0,0 +1,105 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.accounting.storage; + +import bisq.core.dao.burningman.accounting.blockchain.AccountingBlock; + +import bisq.network.p2p.storage.persistence.ResourceDataStoreService; +import bisq.network.p2p.storage.persistence.StoreService; + +import bisq.common.config.Config; +import bisq.common.persistence.PersistenceManager; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import java.io.File; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Singleton +public class BurningManAccountingStoreService extends StoreService { + private static final String FILE_NAME = "BurningManAccountingStore"; + + @Inject + public BurningManAccountingStoreService(ResourceDataStoreService resourceDataStoreService, + @Named(Config.STORAGE_DIR) File storageDir, + PersistenceManager persistenceManager) { + super(storageDir, persistenceManager); + + resourceDataStoreService.addService(this); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + public void requestPersistence() { + persistenceManager.requestPersistence(); + } + + public List getBlocks() { + return Collections.unmodifiableList(store.getBlocks()); + } + + public void addBlock(AccountingBlock block) { + store.getBlocks().add(block); + requestPersistence(); + } + + public void purgeLastTenBlocks() { + List blocks = store.getBlocks(); + if (blocks.size() <= 10) { + blocks.clear(); + requestPersistence(); + return; + } + + List purged = new ArrayList<>(blocks.subList(0, blocks.size() - 10)); + blocks.clear(); + blocks.addAll(purged); + requestPersistence(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Protected + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + protected BurningManAccountingStore createStore() { + return new BurningManAccountingStore(new ArrayList<>()); + } + + @Override + protected void initializePersistenceManager() { + persistenceManager.initialize(store, PersistenceManager.Source.NETWORK); + } + + @Override + public String getFileName() { + return FILE_NAME; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/model/BurnOutputModel.java b/core/src/main/java/bisq/core/dao/burningman/model/BurnOutputModel.java new file mode 100644 index 0000000000..4de2436dc0 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/model/BurnOutputModel.java @@ -0,0 +1,61 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.model; + +import java.util.Date; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public final class BurnOutputModel { + private final long amount; + private final long decayedAmount; + private final int height; + private final String txId; + private final long date; + private final int cycleIndex; + + public BurnOutputModel(long amount, + long decayedAmount, + int height, + String txId, + long date, + int cycleIndex) { + + this.amount = amount; + this.decayedAmount = decayedAmount; + this.height = height; + this.txId = txId; + this.date = date; + this.cycleIndex = cycleIndex; + } + + @Override + public String toString() { + return "\n BurnOutputModel{" + + "\r\n amount=" + amount + + ",\r\n decayedAmount=" + decayedAmount + + ",\r\n height=" + height + + ",\r\n txId='" + txId + '\'' + + ",\r\n date=" + new Date(date) + + ",\r\n cycleIndex=" + cycleIndex + + "\r\n }"; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/model/BurningManCandidate.java b/core/src/main/java/bisq/core/dao/burningman/model/BurningManCandidate.java new file mode 100644 index 0000000000..32f35e91bb --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/model/BurningManCandidate.java @@ -0,0 +1,117 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.model; + +import bisq.core.dao.burningman.BurningManService; + +import bisq.common.util.DateUtil; + +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Contains all relevant data for a burningman candidate (any contributor who has made a compensation request or was + * a receiver of a genesis output). + */ +@Slf4j +@Getter +@EqualsAndHashCode +public class BurningManCandidate { + private final Set compensationModels = new HashSet<>(); + private long accumulatedCompensationAmount; + private long accumulatedDecayedCompensationAmount; + private double compensationShare; // Share of accumulated decayed compensation amounts in relation to total issued amounts + protected Optional mostRecentAddress = Optional.empty(); + + private final Set burnOutputModels = new HashSet<>(); + private final Map> burnOutputModelsByMonth = new HashMap<>(); + private long accumulatedBurnAmount; + private long accumulatedDecayedBurnAmount; + protected double burnAmountShare; // Share of accumulated decayed burn amounts in relation to total burned amounts + protected double cappedBurnAmountShare; // Capped burnAmountShare. Cannot be larger than boostedCompensationShare + + public BurningManCandidate() { + } + + public void addBurnOutputModel(BurnOutputModel burnOutputModel) { + if (burnOutputModels.contains(burnOutputModel)) { + return; + } + burnOutputModels.add(burnOutputModel); + + Date month = DateUtil.getStartOfMonth(new Date(burnOutputModel.getDate())); + burnOutputModelsByMonth.putIfAbsent(month, new HashSet<>()); + burnOutputModelsByMonth.get(month).add(burnOutputModel); + + accumulatedDecayedBurnAmount += burnOutputModel.getDecayedAmount(); + accumulatedBurnAmount += burnOutputModel.getAmount(); + } + + public void addCompensationModel(CompensationModel compensationModel) { + if (compensationModels.contains(compensationModel)) { + return; + } + + compensationModels.add(compensationModel); + + accumulatedDecayedCompensationAmount += compensationModel.getDecayedAmount(); + accumulatedCompensationAmount += compensationModel.getAmount(); + + mostRecentAddress = compensationModels.stream() + .max(Comparator.comparing(CompensationModel::getHeight)) + .map(CompensationModel::getAddress); + } + + public Set getAllAddresses() { + return compensationModels.stream().map(CompensationModel::getAddress).collect(Collectors.toSet()); + } + + public void calculateShares(double totalDecayedCompensationAmounts, double totalDecayedBurnAmounts) { + compensationShare = totalDecayedCompensationAmounts > 0 ? accumulatedDecayedCompensationAmount / totalDecayedCompensationAmounts : 0; + double maxBoostedCompensationShare = Math.min(BurningManService.MAX_BURN_SHARE, compensationShare * BurningManService.ISSUANCE_BOOST_FACTOR); + + burnAmountShare = totalDecayedBurnAmounts > 0 ? accumulatedDecayedBurnAmount / totalDecayedBurnAmounts : 0; + cappedBurnAmountShare = Math.min(maxBoostedCompensationShare, burnAmountShare); + } + + @Override + public String toString() { + return "BurningManCandidate{" + + "\r\n compensationModels=" + compensationModels + + ",\r\n accumulatedCompensationAmount=" + accumulatedCompensationAmount + + ",\r\n accumulatedDecayedCompensationAmount=" + accumulatedDecayedCompensationAmount + + ",\r\n compensationShare=" + compensationShare + + ",\r\n mostRecentAddress=" + mostRecentAddress + + ",\r\n burnOutputModels=" + burnOutputModels + + ",\r\n accumulatedBurnAmount=" + accumulatedBurnAmount + + ",\r\n accumulatedDecayedBurnAmount=" + accumulatedDecayedBurnAmount + + ",\r\n burnAmountShare=" + burnAmountShare + + ",\r\n cappedBurnAmountShare=" + cappedBurnAmountShare + + "\r\n}"; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/model/CompensationModel.java b/core/src/main/java/bisq/core/dao/burningman/model/CompensationModel.java new file mode 100644 index 0000000000..7425446d9d --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/model/CompensationModel.java @@ -0,0 +1,104 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.model; + +import java.util.Date; +import java.util.Optional; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public final class CompensationModel { + public static CompensationModel fromCompensationRequest(String address, + long amount, + long decayedAmount, + int height, + String txId, + long date, + int cycleIndex) { + return new CompensationModel(address, + amount, + decayedAmount, + height, + txId, + Optional.empty(), + date, + cycleIndex); + } + + public static CompensationModel fromGenesisOutput(String address, + long amount, + long decayedAmount, + int height, + String txId, + int outputIndex, + long date) { + return new CompensationModel(address, + amount, + decayedAmount, + height, + txId, + Optional.of(outputIndex), + date, + 0); + } + + + private final String address; + private final long amount; + private final long decayedAmount; + private final int height; + private final String txId; + private final Optional outputIndex; // Only set for genesis tx outputs as needed for sorting + private final long date; + private final int cycleIndex; + + private CompensationModel(String address, + long amount, + long decayedAmount, + int height, + String txId, + Optional outputIndex, + long date, + int cycleIndex) { + this.address = address; + this.amount = amount; + this.decayedAmount = decayedAmount; + this.height = height; + this.txId = txId; + this.outputIndex = outputIndex; + this.date = date; + this.cycleIndex = cycleIndex; + } + + @Override + public String toString() { + return "\n CompensationModel{" + + "\r\n address='" + address + '\'' + + ",\r\n amount=" + amount + + ",\r\n decayedAmount=" + decayedAmount + + ",\r\n height=" + height + + ",\r\n txId='" + txId + '\'' + + ",\r\n outputIndex=" + outputIndex + + ",\r\n date=" + new Date(date) + + ",\r\n cycleIndex=" + cycleIndex + + "\r\n }"; + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/model/LegacyBurningMan.java b/core/src/main/java/bisq/core/dao/burningman/model/LegacyBurningMan.java new file mode 100644 index 0000000000..46244b362b --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/model/LegacyBurningMan.java @@ -0,0 +1,50 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.model; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Getter +@EqualsAndHashCode(callSuper = true) +public final class LegacyBurningMan extends BurningManCandidate { + public LegacyBurningMan(String address) { + mostRecentAddress = Optional.of(address); + } + + public void applyBurnAmountShare(double burnAmountShare) { + this.burnAmountShare = burnAmountShare; + this.cappedBurnAmountShare = burnAmountShare; + } + + @Override + public void calculateShares(double totalDecayedCompensationAmounts, double totalDecayedBurnAmounts) { + // do nothing + } + + @Override + public Set getAllAddresses() { + return mostRecentAddress.map(Set::of).orElse(new HashSet<>()); + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/model/ReimbursementModel.java b/core/src/main/java/bisq/core/dao/burningman/model/ReimbursementModel.java new file mode 100644 index 0000000000..acc5a07772 --- /dev/null +++ b/core/src/main/java/bisq/core/dao/burningman/model/ReimbursementModel.java @@ -0,0 +1,56 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman.model; + +import java.util.Date; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public final class ReimbursementModel { + private final long amount; + private final int height; + private final long date; + private final int cycleIndex; + private final String txId; + + public ReimbursementModel(long amount, + int height, + long date, + int cycleIndex, + String txId) { + this.amount = amount; + this.height = height; + this.date = date; + this.cycleIndex = cycleIndex; + this.txId = txId; + } + + @Override + public String toString() { + return "\n ReimbursementModel{" + + ",\r\n amount=" + amount + + ",\r\n height=" + height + + ",\r\n date=" + new Date(date) + + ",\r\n cycleIndex=" + cycleIndex + + ",\r\n txId=" + txId + + "\r\n }"; + } +} diff --git a/core/src/main/java/bisq/core/dao/governance/param/Param.java b/core/src/main/java/bisq/core/dao/governance/param/Param.java index 7202c414aa..8ba5258edc 100644 --- a/core/src/main/java/bisq/core/dao/governance/param/Param.java +++ b/core/src/main/java/bisq/core/dao/governance/param/Param.java @@ -117,8 +117,14 @@ public enum Param { // Min required trade volume to not get de-listed. Check starts after trial period and use trial period afterwards to look back for trade activity. ASSET_MIN_VOLUME("0.01", ParamType.BTC, 10, 10), + // LOCK_TIME_TRADE_PAYOUT was never used. + // We re-purpose it as value for BTC fee revenue per cycle. This can be added as oracle data by DAO voting. + // We cannot change the ParamType as that would break consensus LOCK_TIME_TRADE_PAYOUT("4320", ParamType.BLOCK), // 30 days, can be disabled by setting to 0 + + // Not used ARBITRATOR_FEE("0", ParamType.PERCENT), // % of trade. For new trade protocol. Arbitration will become optional and we can apply a fee to it. Initially we start with no fee. + MAX_TRADE_LIMIT("2", ParamType.BTC, 2, 2), // max trade limit for lowest risk payment method. Others will get derived from that. // The base factor to multiply the bonded role amount. E.g. If Twitter admin has 20 as amount and BONDED_ROLE_FACTOR is 1000 we get 20000 BSQ as required bond. diff --git a/core/src/main/java/bisq/core/dao/governance/proposal/compensation/CompensationProposalFactory.java b/core/src/main/java/bisq/core/dao/governance/proposal/compensation/CompensationProposalFactory.java index bd307f55a0..fe582c1ec2 100644 --- a/core/src/main/java/bisq/core/dao/governance/proposal/compensation/CompensationProposalFactory.java +++ b/core/src/main/java/bisq/core/dao/governance/proposal/compensation/CompensationProposalFactory.java @@ -40,6 +40,8 @@ import org.bitcoinj.core.Transaction; import javax.inject.Inject; import java.util.HashMap; +import java.util.Map; +import java.util.Optional; import lombok.extern.slf4j.Slf4j; @@ -48,9 +50,9 @@ import lombok.extern.slf4j.Slf4j; */ @Slf4j public class CompensationProposalFactory extends BaseProposalFactory { - private Coin requestedBsq; private String bsqAddress; + private Optional burningManReceiverAddress; @Inject public CompensationProposalFactory(BsqWalletService bsqWalletService, @@ -65,9 +67,11 @@ public class CompensationProposalFactory extends BaseProposalFactory burningManReceiverAddress) throws ProposalValidationException, InsufficientMoneyException, TxException { this.requestedBsq = requestedBsq; + this.burningManReceiverAddress = burningManReceiverAddress; this.bsqAddress = bsqWalletService.getUnusedBsqAddressAsString(); return super.createProposalWithTransaction(name, link); @@ -75,12 +79,17 @@ public class CompensationProposalFactory extends BaseProposalFactory extraDataMap = null; + if (burningManReceiverAddress.isPresent()) { + extraDataMap = new HashMap<>(); + extraDataMap.put(CompensationProposal.BURNING_MAN_RECEIVER_ADDRESS, burningManReceiverAddress.get()); + } return new CompensationProposal( name, link, requestedBsq, bsqAddress, - new HashMap<>()); + extraDataMap); } @Override diff --git a/core/src/main/java/bisq/core/dao/node/full/RpcException.java b/core/src/main/java/bisq/core/dao/node/full/RpcException.java index 92defd15c5..32987e2349 100644 --- a/core/src/main/java/bisq/core/dao/node/full/RpcException.java +++ b/core/src/main/java/bisq/core/dao/node/full/RpcException.java @@ -17,7 +17,7 @@ package bisq.core.dao.node.full; -class RpcException extends Exception { +public class RpcException extends Exception { RpcException(String message, Throwable cause) { super(message, cause); } diff --git a/core/src/main/java/bisq/core/dao/node/full/RpcService.java b/core/src/main/java/bisq/core/dao/node/full/RpcService.java index 48aebf84b9..d7e557db38 100644 --- a/core/src/main/java/bisq/core/dao/node/full/RpcService.java +++ b/core/src/main/java/bisq/core/dao/node/full/RpcService.java @@ -56,6 +56,8 @@ import java.io.IOException; import java.math.BigDecimal; import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; @@ -91,6 +93,14 @@ public class RpcService { // Keep that for optimization after measuring performance differences private final ListeningExecutorService executor = Utilities.getSingleThreadListeningExecutor("RpcService"); private volatile boolean isShutDown; + private final Set setupResultHandlers = new CopyOnWriteArraySet<>(); + private final Set> setupErrorHandlers = new CopyOnWriteArraySet<>(); + private volatile boolean setupComplete; + private Consumer rawDtoBlockHandler; + private Consumer rawDtoBlockErrorHandler; + private Consumer rawBlockHandler; + private Consumer rawBlockErrorHandler; + private volatile boolean isBlockHandlerSet; /////////////////////////////////////////////////////////////////////////////////////////// @@ -139,7 +149,20 @@ public class RpcService { executor.shutdown(); } - void setup(ResultHandler resultHandler, Consumer errorHandler) { + public void setup(ResultHandler resultHandler, Consumer errorHandler) { + if (setupComplete) { + // Setup got already called and has finished. + resultHandler.handleResult(); + return; + } else { + setupResultHandlers.add(resultHandler); + setupErrorHandlers.add(errorHandler); + if (setupResultHandlers.size() > 1) { + // Setup got already called but has not finished yet. + return; + } + } + try { ListenableFuture future = executor.submit(() -> { try { @@ -159,7 +182,11 @@ public class RpcService { daemon = new BitcoindDaemon(rpcBlockHost, rpcBlockPort, throwable -> { log.error(throwable.toString()); throwable.printStackTrace(); - UserThread.execute(() -> errorHandler.accept(new RpcException(throwable))); + UserThread.execute(() -> { + setupErrorHandlers.forEach(handler -> handler.accept(new RpcException(throwable))); + setupErrorHandlers.clear(); + setupResultHandlers.clear(); + }); }); log.info("Setup took {} ms", System.currentTimeMillis() - startTs); @@ -173,11 +200,20 @@ public class RpcService { Futures.addCallback(future, new FutureCallback<>() { public void onSuccess(Void ignore) { - UserThread.execute(resultHandler::handleResult); + setupComplete = true; + UserThread.execute(() -> { + setupResultHandlers.forEach(ResultHandler::handleResult); + setupResultHandlers.clear(); + setupErrorHandlers.clear(); + }); } public void onFailure(@NotNull Throwable throwable) { - UserThread.execute(() -> errorHandler.accept(throwable)); + UserThread.execute(() -> { + setupErrorHandlers.forEach(handler -> handler.accept(throwable)); + setupErrorHandlers.clear(); + setupResultHandlers.clear(); + }); } }, MoreExecutors.directExecutor()); } catch (Exception e) { @@ -219,21 +255,49 @@ public class RpcService { void addNewDtoBlockHandler(Consumer dtoBlockHandler, Consumer errorHandler) { + this.rawBlockHandler = dtoBlockHandler; + this.rawBlockErrorHandler = errorHandler; + if (!isBlockHandlerSet) { + setupBlockHandler(); + } + } + + public void addNewRawDtoBlockHandler(Consumer rawDtoBlockHandler, + Consumer errorHandler) { + this.rawDtoBlockHandler = rawDtoBlockHandler; + this.rawDtoBlockErrorHandler = errorHandler; + if (!isBlockHandlerSet) { + setupBlockHandler(); + } + } + + private void setupBlockHandler() { + isBlockHandlerSet = true; daemon.setBlockListener(blockHash -> { try { - var rawDtoBlock = client.getBlock(blockHash, 2); - log.info("New block received: height={}, id={}", rawDtoBlock.getHeight(), rawDtoBlock.getHash()); + RawDtoBlock rawDtoBlock = client.getBlock(blockHash, 2); + log.info("New rawDtoBlock received: height={}, hash={}", rawDtoBlock.getHeight(), rawDtoBlock.getHash()); - var block = getBlockFromRawDtoBlock(rawDtoBlock); - UserThread.execute(() -> dtoBlockHandler.accept(block)); + if (rawBlockHandler != null) { + RawBlock rawBlock = getRawBlockFromRawDtoBlock(rawDtoBlock); + UserThread.execute(() -> rawBlockHandler.accept(rawBlock)); + } + if (rawDtoBlockHandler != null) { + UserThread.execute(() -> rawDtoBlockHandler.accept(rawDtoBlock)); + } } catch (Throwable throwable) { log.error("Error at BlockHandler", throwable); - errorHandler.accept(throwable); + if (rawBlockErrorHandler != null) { + rawBlockErrorHandler.accept(throwable); + } + if (rawDtoBlockErrorHandler != null) { + rawDtoBlockErrorHandler.accept(throwable); + } } }); } - void requestChainHeadHeight(Consumer resultHandler, Consumer errorHandler) { + public void requestChainHeadHeight(Consumer resultHandler, Consumer errorHandler) { try { ListenableFuture future = executor.submit(client::getBlockCount); Futures.addCallback(future, new FutureCallback<>() { @@ -262,7 +326,7 @@ public class RpcService { long startTs = System.currentTimeMillis(); String blockHash = client.getBlockHash(blockHeight); var rawDtoBlock = client.getBlock(blockHash, 2); - var block = getBlockFromRawDtoBlock(rawDtoBlock); + var block = getRawBlockFromRawDtoBlock(rawDtoBlock); log.info("requestDtoBlock from bitcoind at blockHeight {} with {} txs took {} ms", blockHeight, block.getRawTxs().size(), System.currentTimeMillis() - startTs); return block; @@ -289,12 +353,47 @@ public class RpcService { } } + public void requestRawDtoBlock(int blockHeight, + Consumer rawDtoBlockHandler, + Consumer errorHandler) { + try { + ListenableFuture future = executor.submit(() -> { + long startTs = System.currentTimeMillis(); + String blockHash = client.getBlockHash(blockHeight); + + // getBlock with about 2000 tx takes about 500 ms on a 4GHz Intel Core i7 CPU. + var rawDtoBlock = client.getBlock(blockHash, 2); + log.info("requestRawDtoBlock from bitcoind at blockHeight {} with {} txs took {} ms", + blockHeight, rawDtoBlock.getTx().size(), System.currentTimeMillis() - startTs); + return rawDtoBlock; + }); + + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(RawDtoBlock rawDtoBlock) { + UserThread.execute(() -> rawDtoBlockHandler.accept(rawDtoBlock)); + } + + @Override + public void onFailure(@NotNull Throwable throwable) { + log.error("Error at requestRawDtoBlock: blockHeight={}", blockHeight); + UserThread.execute(() -> errorHandler.accept(throwable)); + } + }, MoreExecutors.directExecutor()); + } catch (Exception e) { + if (!isShutDown || !(e instanceof RejectedExecutionException)) { + log.warn(e.toString(), e); + throw e; + } + } + } + /////////////////////////////////////////////////////////////////////////////////////////// // Private /////////////////////////////////////////////////////////////////////////////////////////// - private static RawBlock getBlockFromRawDtoBlock(RawDtoBlock rawDtoBlock) { + private static RawBlock getRawBlockFromRawDtoBlock(RawDtoBlock rawDtoBlock) { List txList = rawDtoBlock.getTx().stream() .map(e -> getTxFromRawTransaction(e, rawDtoBlock)) .collect(Collectors.toList()); diff --git a/core/src/main/java/bisq/core/dao/state/DaoStateService.java b/core/src/main/java/bisq/core/dao/state/DaoStateService.java index 2cb1d29206..e71ca09b6c 100644 --- a/core/src/main/java/bisq/core/dao/state/DaoStateService.java +++ b/core/src/main/java/bisq/core/dao/state/DaoStateService.java @@ -201,6 +201,32 @@ public class DaoStateService implements DaoSetupService { return getCycle(blockHeight).map(cycle -> cycle.getHeightOfFirstBlock()); } + public Optional getNextCycle(Cycle cycle) { + return getCycle(cycle.getHeightOfLastBlock() + 1); + } + + public Optional getPreviousCycle(Cycle cycle) { + return getCycle(cycle.getHeightOfFirstBlock() - 1); + } + + public Optional getPastCycle(Cycle cycle, int numPastCycles) { + Optional previous = Optional.empty(); + Cycle current = cycle; + for (int i = 0; i < numPastCycles; i++) { + previous = getPreviousCycle(current); + if (previous.isPresent()) { + current = previous.get(); + } else { + break; + } + } + return previous; + } + + public Cycle getCycleAtIndex(int index) { + return getCycles().get(index); + } + /////////////////////////////////////////////////////////////////////////////////////////// // Block @@ -440,7 +466,7 @@ public class DaoStateService implements DaoSetupService { // TxOutput /////////////////////////////////////////////////////////////////////////////////////////// - private Stream getUnorderedTxOutputStream() { + public Stream getUnorderedTxOutputStream() { return getUnorderedTxStream() .flatMap(tx -> tx.getTxOutputs().stream()); } diff --git a/core/src/main/java/bisq/core/dao/state/model/governance/CompensationProposal.java b/core/src/main/java/bisq/core/dao/state/model/governance/CompensationProposal.java index 4ed48d694b..2eb35f6fb4 100644 --- a/core/src/main/java/bisq/core/dao/state/model/governance/CompensationProposal.java +++ b/core/src/main/java/bisq/core/dao/state/model/governance/CompensationProposal.java @@ -30,11 +30,13 @@ import org.bitcoinj.core.Coin; import java.util.Date; import java.util.Map; +import java.util.Optional; import lombok.EqualsAndHashCode; import lombok.Value; import lombok.extern.slf4j.Slf4j; +import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; @Immutable @@ -42,6 +44,9 @@ import javax.annotation.concurrent.Immutable; @EqualsAndHashCode(callSuper = true) @Value public final class CompensationProposal extends Proposal implements IssuanceProposal, ImmutableDaoStateModel { + // Keys for extra map + public static final String BURNING_MAN_RECEIVER_ADDRESS = "burningManReceiverAddress"; + private final long requestedBsq; private final String bsqAddress; @@ -49,7 +54,7 @@ public final class CompensationProposal extends Proposal implements IssuanceProp String link, Coin requestedBsq, String bsqAddress, - Map extraDataMap) { + @Nullable Map extraDataMap) { this(name, link, bsqAddress, @@ -72,7 +77,7 @@ public final class CompensationProposal extends Proposal implements IssuanceProp byte version, long creationDate, String txId, - Map extraDataMap) { + @Nullable Map extraDataMap) { super(name, link, version, @@ -135,6 +140,11 @@ public final class CompensationProposal extends Proposal implements IssuanceProp return TxType.COMPENSATION_REQUEST; } + // Added at v.1.9.7 + public Optional getBurningManReceiverAddress() { + return Optional.ofNullable(extraDataMap).flatMap(map -> Optional.ofNullable(map.get(BURNING_MAN_RECEIVER_ADDRESS))); + } + @Override public Proposal cloneProposalAndAddTxId(String txId) { return new CompensationProposal(getName(), @@ -152,6 +162,7 @@ public final class CompensationProposal extends Proposal implements IssuanceProp return "CompensationProposal{" + "\n requestedBsq=" + requestedBsq + ",\n bsqAddress='" + bsqAddress + '\'' + + ",\n burningManReceiverAddress='" + getBurningManReceiverAddress() + '\'' + "\n} " + super.toString(); } } diff --git a/core/src/main/java/bisq/core/dao/state/model/governance/Proposal.java b/core/src/main/java/bisq/core/dao/state/model/governance/Proposal.java index 585ffd9bc8..4fcbe09ebb 100644 --- a/core/src/main/java/bisq/core/dao/state/model/governance/Proposal.java +++ b/core/src/main/java/bisq/core/dao/state/model/governance/Proposal.java @@ -145,7 +145,7 @@ public abstract class Proposal implements PersistablePayload, NetworkPayload, Co "\n txId='" + txId + '\'' + ",\n name='" + name + '\'' + ",\n link='" + link + '\'' + - ",\n txId='" + txId + '\'' + + ",\n extraDataMap='" + extraDataMap + '\'' + ",\n version=" + version + ",\n creationDate=" + new Date(creationDate) + "\n}"; diff --git a/core/src/main/java/bisq/core/offer/OpenOfferManager.java b/core/src/main/java/bisq/core/offer/OpenOfferManager.java index e2b270f690..c3ab839966 100644 --- a/core/src/main/java/bisq/core/offer/OpenOfferManager.java +++ b/core/src/main/java/bisq/core/offer/OpenOfferManager.java @@ -22,6 +22,9 @@ import bisq.core.btc.wallet.BsqWalletService; import bisq.core.btc.wallet.BtcWalletService; import bisq.core.btc.wallet.TradeWalletService; import bisq.core.dao.DaoFacade; +import bisq.core.dao.burningman.BtcFeeReceiverService; +import bisq.core.dao.burningman.BurningManService; +import bisq.core.dao.burningman.DelayedPayoutTxReceiverService; import bisq.core.exceptions.TradePriceOutOfToleranceException; import bisq.core.filter.FilterManager; import bisq.core.locale.Res; @@ -122,6 +125,8 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe private final RefundAgentManager refundAgentManager; private final DaoFacade daoFacade; private final FilterManager filterManager; + private final BtcFeeReceiverService btcFeeReceiverService; + private final DelayedPayoutTxReceiverService delayedPayoutTxReceiverService; private final Broadcaster broadcaster; private final PersistenceManager> persistenceManager; private final Map offersToBeEdited = new HashMap<>(); @@ -155,6 +160,8 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe RefundAgentManager refundAgentManager, DaoFacade daoFacade, FilterManager filterManager, + BtcFeeReceiverService btcFeeReceiverService, + DelayedPayoutTxReceiverService delayedPayoutTxReceiverService, Broadcaster broadcaster, PersistenceManager> persistenceManager) { this.coreContext = coreContext; @@ -175,6 +182,8 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe this.refundAgentManager = refundAgentManager; this.daoFacade = daoFacade; this.filterManager = filterManager; + this.btcFeeReceiverService = btcFeeReceiverService; + this.delayedPayoutTxReceiverService = delayedPayoutTxReceiverService; this.broadcaster = broadcaster; this.persistenceManager = persistenceManager; @@ -389,6 +398,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe arbitratorManager, tradeStatisticsManager, daoFacade, + btcFeeReceiverService, user, filterManager); PlaceOfferProtocol placeOfferProtocol = new PlaceOfferProtocol( @@ -663,6 +673,22 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe return; } + if (BurningManService.isActivated()) { + try { + int takersBurningManSelectionHeight = request.getBurningManSelectionHeight(); + checkArgument(takersBurningManSelectionHeight > 0, "takersBurningManSelectionHeight must not be 0"); + + int makersBurningManSelectionHeight = delayedPayoutTxReceiverService.getBurningManSelectionHeight(); + checkArgument(takersBurningManSelectionHeight == makersBurningManSelectionHeight, + "takersBurningManSelectionHeight does no match makersBurningManSelectionHeight"); + } catch (Throwable t) { + errorMessage = "Message validation failed. Error=" + t + ", Message=" + request; + log.warn(errorMessage); + sendAckMessage(request, peer, false, errorMessage); + return; + } + } + try { Optional openOfferOptional = getOpenOfferById(request.offerId); AvailabilityResult availabilityResult; diff --git a/core/src/main/java/bisq/core/offer/availability/OfferAvailabilityModel.java b/core/src/main/java/bisq/core/offer/availability/OfferAvailabilityModel.java index 6b75fda67d..3e93f82a54 100644 --- a/core/src/main/java/bisq/core/offer/availability/OfferAvailabilityModel.java +++ b/core/src/main/java/bisq/core/offer/availability/OfferAvailabilityModel.java @@ -17,6 +17,7 @@ package bisq.core.offer.availability; +import bisq.core.dao.burningman.DelayedPayoutTxReceiverService; import bisq.core.offer.Offer; import bisq.core.offer.availability.messages.OfferAvailabilityResponse; import bisq.core.support.dispute.mediation.mediator.MediatorManager; @@ -70,12 +71,17 @@ public class OfferAvailabilityModel implements Model { @Getter private final boolean isTakerApiUser; + // Added in v 1.9.7 + @Getter + private final DelayedPayoutTxReceiverService delayedPayoutTxReceiverService; + public OfferAvailabilityModel(Offer offer, PubKeyRing pubKeyRing, P2PService p2PService, User user, MediatorManager mediatorManager, TradeStatisticsManager tradeStatisticsManager, + DelayedPayoutTxReceiverService delayedPayoutTxReceiverService, boolean isTakerApiUser) { this.offer = offer; this.pubKeyRing = pubKeyRing; @@ -83,6 +89,7 @@ public class OfferAvailabilityModel implements Model { this.user = user; this.mediatorManager = mediatorManager; this.tradeStatisticsManager = tradeStatisticsManager; + this.delayedPayoutTxReceiverService = delayedPayoutTxReceiverService; this.isTakerApiUser = isTakerApiUser; } diff --git a/core/src/main/java/bisq/core/offer/availability/messages/OfferAvailabilityRequest.java b/core/src/main/java/bisq/core/offer/availability/messages/OfferAvailabilityRequest.java index 2b2d412b45..baea9d0fcd 100644 --- a/core/src/main/java/bisq/core/offer/availability/messages/OfferAvailabilityRequest.java +++ b/core/src/main/java/bisq/core/offer/availability/messages/OfferAvailabilityRequest.java @@ -44,17 +44,23 @@ public final class OfferAvailabilityRequest extends OfferMessage implements Supp private final Capabilities supportedCapabilities; private final boolean isTakerApiUser; + // Added in v 1.9.7 + private final int burningManSelectionHeight; + public OfferAvailabilityRequest(String offerId, PubKeyRing pubKeyRing, long takersTradePrice, - boolean isTakerApiUser) { + boolean isTakerApiUser, + int burningManSelectionHeight) { this(offerId, pubKeyRing, takersTradePrice, isTakerApiUser, + burningManSelectionHeight, Capabilities.app, Version.getP2PMessageVersion(), - UUID.randomUUID().toString()); + UUID.randomUUID().toString() + ); } @@ -66,6 +72,7 @@ public final class OfferAvailabilityRequest extends OfferMessage implements Supp PubKeyRing pubKeyRing, long takersTradePrice, boolean isTakerApiUser, + int burningManSelectionHeight, @Nullable Capabilities supportedCapabilities, int messageVersion, @Nullable String uid) { @@ -73,6 +80,7 @@ public final class OfferAvailabilityRequest extends OfferMessage implements Supp this.pubKeyRing = pubKeyRing; this.takersTradePrice = takersTradePrice; this.isTakerApiUser = isTakerApiUser; + this.burningManSelectionHeight = burningManSelectionHeight; this.supportedCapabilities = supportedCapabilities; } @@ -82,7 +90,8 @@ public final class OfferAvailabilityRequest extends OfferMessage implements Supp .setOfferId(offerId) .setPubKeyRing(pubKeyRing.toProtoMessage()) .setTakersTradePrice(takersTradePrice) - .setIsTakerApiUser(isTakerApiUser); + .setIsTakerApiUser(isTakerApiUser) + .setBurningManSelectionHeight(burningManSelectionHeight); Optional.ofNullable(supportedCapabilities).ifPresent(e -> builder.addAllSupportedCapabilities(Capabilities.toIntList(supportedCapabilities))); Optional.ofNullable(uid).ifPresent(e -> builder.setUid(uid)); @@ -97,6 +106,7 @@ public final class OfferAvailabilityRequest extends OfferMessage implements Supp PubKeyRing.fromProto(proto.getPubKeyRing()), proto.getTakersTradePrice(), proto.getIsTakerApiUser(), + proto.getBurningManSelectionHeight(), Capabilities.fromIntList(proto.getSupportedCapabilitiesList()), messageVersion, proto.getUid().isEmpty() ? null : proto.getUid()); diff --git a/core/src/main/java/bisq/core/offer/availability/tasks/SendOfferAvailabilityRequest.java b/core/src/main/java/bisq/core/offer/availability/tasks/SendOfferAvailabilityRequest.java index 6c0c11f40e..bbe2c8ea74 100644 --- a/core/src/main/java/bisq/core/offer/availability/tasks/SendOfferAvailabilityRequest.java +++ b/core/src/main/java/bisq/core/offer/availability/tasks/SendOfferAvailabilityRequest.java @@ -39,8 +39,12 @@ public class SendOfferAvailabilityRequest extends Task { try { runInterceptHook(); + int burningManSelectionHeight = model.getDelayedPayoutTxReceiverService().getBurningManSelectionHeight(); OfferAvailabilityRequest message = new OfferAvailabilityRequest(model.getOffer().getId(), - model.getPubKeyRing(), model.getTakersTradePrice(), model.isTakerApiUser()); + model.getPubKeyRing(), + model.getTakersTradePrice(), + model.isTakerApiUser(), + burningManSelectionHeight); log.info("Send {} with offerId {} and uid {} to peer {}", message.getClass().getSimpleName(), message.getOfferId(), message.getUid(), model.getPeerNodeAddress()); diff --git a/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/PlaceOfferModel.java b/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/PlaceOfferModel.java index e90e4dcc00..0b44a8b649 100644 --- a/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/PlaceOfferModel.java +++ b/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/PlaceOfferModel.java @@ -21,6 +21,7 @@ import bisq.core.btc.wallet.BsqWalletService; import bisq.core.btc.wallet.BtcWalletService; import bisq.core.btc.wallet.TradeWalletService; import bisq.core.dao.DaoFacade; +import bisq.core.dao.burningman.BtcFeeReceiverService; import bisq.core.filter.FilterManager; import bisq.core.offer.Offer; import bisq.core.offer.OfferBookService; @@ -51,6 +52,7 @@ public class PlaceOfferModel implements Model { private final ArbitratorManager arbitratorManager; private final TradeStatisticsManager tradeStatisticsManager; private final DaoFacade daoFacade; + private final BtcFeeReceiverService btcFeeReceiverService; private final User user; @Getter private final FilterManager filterManager; @@ -71,6 +73,7 @@ public class PlaceOfferModel implements Model { ArbitratorManager arbitratorManager, TradeStatisticsManager tradeStatisticsManager, DaoFacade daoFacade, + BtcFeeReceiverService btcFeeReceiverService, User user, FilterManager filterManager) { this.offer = offer; @@ -83,6 +86,7 @@ public class PlaceOfferModel implements Model { this.arbitratorManager = arbitratorManager; this.tradeStatisticsManager = tradeStatisticsManager; this.daoFacade = daoFacade; + this.btcFeeReceiverService = btcFeeReceiverService; this.user = user; this.filterManager = filterManager; } diff --git a/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/tasks/CreateMakerFeeTx.java b/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/tasks/CreateMakerFeeTx.java index 471593a3d2..637fea6ab6 100644 --- a/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/tasks/CreateMakerFeeTx.java +++ b/core/src/main/java/bisq/core/offer/placeoffer/bisq_v1/tasks/CreateMakerFeeTx.java @@ -28,7 +28,6 @@ import bisq.core.dao.exceptions.DaoDisabledException; import bisq.core.dao.state.model.blockchain.TxType; import bisq.core.offer.Offer; import bisq.core.offer.placeoffer.bisq_v1.PlaceOfferModel; -import bisq.core.util.FeeReceiverSelector; import bisq.common.UserThread; import bisq.common.taskrunner.Task; @@ -66,9 +65,8 @@ public class CreateMakerFeeTx extends Task { TradeWalletService tradeWalletService = model.getTradeWalletService(); - String feeReceiver = FeeReceiverSelector.getAddress(model.getFilterManager()); - if (offer.isCurrencyForMakerFeeBtc()) { + String feeReceiver = model.getBtcFeeReceiverService().getAddress(); tradeWalletService.createBtcTradingFeeTx( fundingAddress, reservedForTradeAddress, diff --git a/core/src/main/java/bisq/core/proto/network/CoreNetworkProtoResolver.java b/core/src/main/java/bisq/core/proto/network/CoreNetworkProtoResolver.java index 98f4ad3d3e..0169503094 100644 --- a/core/src/main/java/bisq/core/proto/network/CoreNetworkProtoResolver.java +++ b/core/src/main/java/bisq/core/proto/network/CoreNetworkProtoResolver.java @@ -19,6 +19,9 @@ package bisq.core.proto.network; import bisq.core.alert.Alert; import bisq.core.alert.PrivateNotificationMessage; +import bisq.core.dao.burningman.accounting.node.messages.GetAccountingBlocksRequest; +import bisq.core.dao.burningman.accounting.node.messages.GetAccountingBlocksResponse; +import bisq.core.dao.burningman.accounting.node.messages.NewAccountingBlockBroadcastMessage; import bisq.core.dao.governance.blindvote.network.messages.RepublishGovernanceDataRequest; import bisq.core.dao.governance.proposal.storage.temp.TempProposalPayload; import bisq.core.dao.monitoring.network.messages.GetBlindVoteStateHashesRequest; @@ -254,6 +257,13 @@ public class CoreNetworkProtoResolver extends CoreProtoResolver implements Netwo case GET_INVENTORY_RESPONSE: return GetInventoryResponse.fromProto(proto.getGetInventoryResponse(), messageVersion); + case GET_ACCOUNTING_BLOCKS_REQUEST: + return GetAccountingBlocksRequest.fromProto(proto.getGetAccountingBlocksRequest(), messageVersion); + case GET_ACCOUNTING_BLOCKS_RESPONSE: + return GetAccountingBlocksResponse.fromProto(proto.getGetAccountingBlocksResponse(), messageVersion); + case NEW_ACCOUNTING_BLOCK_BROADCAST_MESSAGE: + return NewAccountingBlockBroadcastMessage.fromProto(proto.getNewAccountingBlockBroadcastMessage(), messageVersion); + default: throw new ProtobufferException("Unknown proto message case (PB.NetworkEnvelope). messageCase=" + proto.getMessageCase() + "; proto raw data=" + proto.toString()); diff --git a/core/src/main/java/bisq/core/proto/persistable/CorePersistenceProtoResolver.java b/core/src/main/java/bisq/core/proto/persistable/CorePersistenceProtoResolver.java index bb63cbb065..7e291d6cdc 100644 --- a/core/src/main/java/bisq/core/proto/persistable/CorePersistenceProtoResolver.java +++ b/core/src/main/java/bisq/core/proto/persistable/CorePersistenceProtoResolver.java @@ -21,6 +21,7 @@ import bisq.core.account.sign.SignedWitnessStore; import bisq.core.account.witness.AccountAgeWitnessStore; import bisq.core.btc.model.AddressEntryList; import bisq.core.btc.wallet.BtcWalletService; +import bisq.core.dao.burningman.accounting.storage.BurningManAccountingStore; import bisq.core.dao.governance.blindvote.MyBlindVoteList; import bisq.core.dao.governance.blindvote.storage.BlindVoteStore; import bisq.core.dao.governance.bond.reputation.MyReputationList; @@ -141,6 +142,8 @@ public class CorePersistenceProtoResolver extends CoreProtoResolver implements P return RemovedPayloadsMap.fromProto(proto.getRemovedPayloadsMap()); case BSQ_BLOCK_STORE: return BsqBlockStore.fromProto(proto.getBsqBlockStore()); + case BURNING_MAN_ACCOUNTING_STORE: + return BurningManAccountingStore.fromProto(proto.getBurningManAccountingStore()); default: throw new ProtobufferRuntimeException("Unknown proto message case(PB.PersistableEnvelope). " + "messageCase=" + proto.getMessageCase() + "; proto raw data=" + proto.toString()); diff --git a/core/src/main/java/bisq/core/support/dispute/Dispute.java b/core/src/main/java/bisq/core/support/dispute/Dispute.java index c93510371e..dfd676ca2e 100644 --- a/core/src/main/java/bisq/core/support/dispute/Dispute.java +++ b/core/src/main/java/bisq/core/support/dispute/Dispute.java @@ -77,7 +77,6 @@ import javax.annotation.Nullable; @EqualsAndHashCode @Getter public final class Dispute implements NetworkPayload, PersistablePayload { - public enum State { NEEDS_UPGRADE, NEW, @@ -146,6 +145,13 @@ public final class Dispute implements NetworkPayload, PersistablePayload { // Added at v1.6.0 private Dispute.State disputeState = State.NEW; + // Added in v 1.9.7 + @Setter + private int burningManSelectionHeight; + @Setter + private long tradeTxFee; + + // Should be only used in emergency case if we need to add data but do not want to break backward compatibility // at the P2P network storage checks. The hash of the object will be used to verify if the data is valid. Any new // field in a class would break that hash and therefore break the storage mechanism. @@ -263,7 +269,9 @@ public final class Dispute implements NetworkPayload, PersistablePayload { .setIsClosed(this.isClosed()) .setOpeningDate(openingDate) .setState(Dispute.State.toProtoMessage(disputeState)) - .setId(id); + .setId(id) + .setBurningManSelectionHeight(burningManSelectionHeight) + .setTradeTxFee(tradeTxFee); Optional.ofNullable(contractHash).ifPresent(e -> builder.setContractHash(ByteString.copyFrom(e))); Optional.ofNullable(depositTxSerialized).ifPresent(e -> builder.setDepositTxSerialized(ByteString.copyFrom(e))); @@ -330,6 +338,9 @@ public final class Dispute implements NetworkPayload, PersistablePayload { dispute.setDonationAddressOfDelayedPayoutTx(donationAddressOfDelayedPayoutTx); } + dispute.setBurningManSelectionHeight(proto.getBurningManSelectionHeight()); + dispute.setTradeTxFee(proto.getTradeTxFee()); + if (Dispute.State.fromProto(proto.getState()) == State.NEEDS_UPGRADE) { // old disputes did not have a state field, so choose an appropriate state: dispute.setState(proto.getIsClosed() ? State.CLOSED : State.OPEN); @@ -516,6 +527,13 @@ public final class Dispute implements NetworkPayload, PersistablePayload { return cachedDepositTx; } + // Dispute agents might receive disputes created before activation date. + // By checking if burningManSelectionHeight is > 0 we can detect if the trade was created with + // the new burningmen receivers or with legacy BM. + public boolean isUsingLegacyBurningMan() { + return burningManSelectionHeight == 0; + } + @Override public String toString() { return "Dispute{" + @@ -550,6 +568,8 @@ public final class Dispute implements NetworkPayload, PersistablePayload { ",\n delayedPayoutTxId='" + delayedPayoutTxId + '\'' + ",\n donationAddressOfDelayedPayoutTx='" + donationAddressOfDelayedPayoutTx + '\'' + ",\n cachedDepositTx='" + cachedDepositTx + '\'' + + ",\n burningManSelectionHeight='" + burningManSelectionHeight + '\'' + + ",\n tradeTxFee='" + tradeTxFee + '\'' + "\n}"; } } diff --git a/core/src/main/java/bisq/core/support/dispute/DisputeManager.java b/core/src/main/java/bisq/core/support/dispute/DisputeManager.java index 6163c70039..e64ac9a78d 100644 --- a/core/src/main/java/bisq/core/support/dispute/DisputeManager.java +++ b/core/src/main/java/bisq/core/support/dispute/DisputeManager.java @@ -273,8 +273,10 @@ public abstract class DisputeManager> extends Sup List disputes = getDisputeList().getList(); disputes.forEach(dispute -> { try { - DisputeValidation.validateDonationAddressMatchesAnyPastParamValues(dispute, dispute.getDonationAddressOfDelayedPayoutTx(), daoFacade); DisputeValidation.validateNodeAddresses(dispute, config); + if (dispute.isUsingLegacyBurningMan()) { + DisputeValidation.validateDonationAddressMatchesAnyPastParamValues(dispute, dispute.getDonationAddressOfDelayedPayoutTx(), daoFacade); + } } catch (DisputeValidation.AddressException | DisputeValidation.NodeAddressException e) { log.error(e.toString()); validationExceptions.add(e); @@ -371,8 +373,10 @@ public abstract class DisputeManager> extends Sup DisputeValidation.validateDisputeData(dispute, btcWalletService); DisputeValidation.validateNodeAddresses(dispute, config); DisputeValidation.validateSenderNodeAddress(dispute, openNewDisputeMessage.getSenderNodeAddress()); - DisputeValidation.validateDonationAddressMatchesAnyPastParamValues(dispute, dispute.getDonationAddressOfDelayedPayoutTx(), daoFacade); DisputeValidation.testIfDisputeTriesReplay(dispute, disputeList.getList()); + if (dispute.isUsingLegacyBurningMan()) { + DisputeValidation.validateDonationAddressMatchesAnyPastParamValues(dispute, dispute.getDonationAddressOfDelayedPayoutTx(), daoFacade); + } } catch (DisputeValidation.ValidationException e) { log.error(e.toString()); validationExceptions.add(e); @@ -401,13 +405,15 @@ public abstract class DisputeManager> extends Sup DisputeValidation.validateDisputeData(dispute, btcWalletService); DisputeValidation.validateNodeAddresses(dispute, config); DisputeValidation.validateTradeAndDispute(dispute, trade); - DisputeValidation.validateDonationAddress(dispute, - Objects.requireNonNull(trade.getDelayedPayoutTx()), - btcWalletService.getParams(), - daoFacade); TradeDataValidation.validateDelayedPayoutTx(trade, trade.getDelayedPayoutTx(), btcWalletService); + if (dispute.isUsingLegacyBurningMan()) { + DisputeValidation.validateDonationAddress(dispute, + Objects.requireNonNull(trade.getDelayedPayoutTx()), + btcWalletService.getParams()); + DisputeValidation.validateDonationAddressMatchesAnyPastParamValues(dispute, dispute.getDonationAddressOfDelayedPayoutTx(), daoFacade); + } } catch (TradeDataValidation.ValidationException | DisputeValidation.ValidationException e) { // The peer sent us an invalid donation address. We do not return here as we don't want to break // mediation/arbitration and log only the issue. The dispute agent will run validation as well and will get @@ -618,6 +624,8 @@ public abstract class DisputeManager> extends Sup dispute.setExtraDataMap(disputeFromOpener.getExtraDataMap()); dispute.setDelayedPayoutTxId(disputeFromOpener.getDelayedPayoutTxId()); dispute.setDonationAddressOfDelayedPayoutTx(disputeFromOpener.getDonationAddressOfDelayedPayoutTx()); + dispute.setBurningManSelectionHeight(disputeFromOpener.getBurningManSelectionHeight()); + dispute.setTradeTxFee(disputeFromOpener.getTradeTxFee()); Optional storedDisputeOptional = findDispute(dispute); diff --git a/core/src/main/java/bisq/core/support/dispute/DisputeValidation.java b/core/src/main/java/bisq/core/support/dispute/DisputeValidation.java index f8161f94e1..bbb4ac0cb3 100644 --- a/core/src/main/java/bisq/core/support/dispute/DisputeValidation.java +++ b/core/src/main/java/bisq/core/support/dispute/DisputeValidation.java @@ -115,6 +115,7 @@ public class DisputeValidation { } } + public static void validateSenderNodeAddress(Dispute dispute, NodeAddress senderNodeAddress) throws NodeAddressException { if (!senderNodeAddress.equals(dispute.getContract().getBuyerNodeAddress()) @@ -156,8 +157,7 @@ public class DisputeValidation { public static void validateDonationAddress(Dispute dispute, Transaction delayedPayoutTx, - NetworkParameters params, - DaoFacade daoFacade) + NetworkParameters params) throws AddressException { TransactionOutput output = delayedPayoutTx.getOutput(0); Address address = output.getScriptPubKey().getToAddress(params); @@ -167,12 +167,13 @@ public class DisputeValidation { log.error(delayedPayoutTx.toString()); throw new DisputeValidation.AddressException(dispute, errorMsg); } - String delayedPayoutTxOutputAddress = address.toString(); - validateDonationAddressMatchesAnyPastParamValues(dispute, delayedPayoutTxOutputAddress, daoFacade); // Verify that address in the dispute matches the one in the trade. + String delayedPayoutTxOutputAddress = address.toString(); checkArgument(delayedPayoutTxOutputAddress.equals(dispute.getDonationAddressOfDelayedPayoutTx()), - "donationAddressOfDelayedPayoutTx from dispute does not match address from delayed payout tx"); + "donationAddressOfDelayedPayoutTx from dispute does not match address from delayed payout tx. " + + "delayedPayoutTxOutputAddress=" + delayedPayoutTxOutputAddress + + "; dispute.getDonationAddressOfDelayedPayoutTx()=" + dispute.getDonationAddressOfDelayedPayoutTx()); } public static void testIfAnyDisputeTriedReplay(List disputeList, @@ -244,7 +245,6 @@ public class DisputeValidation { Map> disputesPerDelayedPayoutTxId, Map> disputesPerDepositTxId) throws DisputeReplayException { - try { String disputeToTestTradeId = disputeToTest.getTradeId(); String disputeToTestDelayedPayoutTxId = disputeToTest.getDelayedPayoutTxId(); @@ -312,6 +312,7 @@ public class DisputeValidation { } } + public static class AddressException extends ValidationException { AddressException(Dispute dispute, String msg) { super(dispute, msg); diff --git a/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java b/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java index 4e52c51398..06fdc0bdfb 100644 --- a/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java +++ b/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java @@ -21,6 +21,7 @@ import bisq.core.btc.setup.WalletsSetup; import bisq.core.btc.wallet.BtcWalletService; import bisq.core.btc.wallet.TradeWalletService; import bisq.core.dao.DaoFacade; +import bisq.core.dao.burningman.DelayedPayoutTxReceiverService; import bisq.core.locale.Res; import bisq.core.offer.OpenOffer; import bisq.core.offer.OpenOfferManager; @@ -50,11 +51,13 @@ import bisq.common.app.Version; import bisq.common.config.Config; import bisq.common.crypto.KeyRing; import bisq.common.util.Hex; +import bisq.common.util.Tuple2; import org.bitcoinj.core.NetworkParameters; import org.bitcoinj.core.Transaction; import org.bitcoinj.core.TransactionInput; import org.bitcoinj.core.TransactionOutPoint; +import org.bitcoinj.core.TransactionOutput; import com.google.inject.Inject; import com.google.inject.Singleton; @@ -74,8 +77,10 @@ import static com.google.common.base.Preconditions.checkNotNull; @Slf4j @Singleton public final class RefundManager extends DisputeManager { + private final DelayedPayoutTxReceiverService delayedPayoutTxReceiverService; private final MempoolService mempoolService; + /////////////////////////////////////////////////////////////////////////////////////////// // Constructor /////////////////////////////////////////////////////////////////////////////////////////// @@ -89,6 +94,7 @@ public final class RefundManager extends DisputeManager { ClosedTradableManager closedTradableManager, OpenOfferManager openOfferManager, DaoFacade daoFacade, + DelayedPayoutTxReceiverService delayedPayoutTxReceiverService, KeyRing keyRing, RefundDisputeListService refundDisputeListService, Config config, @@ -96,6 +102,7 @@ public final class RefundManager extends DisputeManager { MempoolService mempoolService) { super(p2PService, tradeWalletService, walletService, walletsSetup, tradeManager, closedTradableManager, openOfferManager, daoFacade, keyRing, refundDisputeListService, config, priceFeedService); + this.delayedPayoutTxReceiverService = delayedPayoutTxReceiverService; this.mempoolService = mempoolService; } @@ -308,4 +315,27 @@ public final class RefundManager extends DisputeManager { checkArgument(fundingTxId.equals(depositTx.getTxId().toString()), "First input at delayedPayoutTx does not connect to depositTx"); } + + public void verifyDelayedPayoutTxReceivers(Transaction delayedPayoutTx, Dispute dispute) { + Transaction depositTx = dispute.findDepositTx(btcWalletService).orElseThrow(); + long inputAmount = depositTx.getOutput(0).getValue().value; + int selectionHeight = dispute.getBurningManSelectionHeight(); + List> delayedPayoutTxReceivers = delayedPayoutTxReceiverService.getReceivers( + selectionHeight, + inputAmount, + dispute.getTradeTxFee()); + log.info("Verify delayedPayoutTx using selectionHeight {} and receivers {}", selectionHeight, delayedPayoutTxReceivers); + checkArgument(delayedPayoutTx.getOutputs().size() == delayedPayoutTxReceivers.size(), + "Size of outputs and delayedPayoutTxReceivers must be the same"); + + NetworkParameters params = btcWalletService.getParams(); + for (int i = 0; i < delayedPayoutTx.getOutputs().size(); i++) { + TransactionOutput transactionOutput = delayedPayoutTx.getOutputs().get(i); + Tuple2 receiverTuple = delayedPayoutTxReceivers.get(0); + checkArgument(transactionOutput.getScriptPubKey().getToAddress(params).toString().equals(receiverTuple.second), + "output address does not match delayedPayoutTxReceivers address. transactionOutput=" + transactionOutput); + checkArgument(transactionOutput.getValue().value == receiverTuple.first, + "output value does not match delayedPayoutTxReceivers value. transactionOutput=" + transactionOutput); + } + } } diff --git a/core/src/main/java/bisq/core/trade/TradeManager.java b/core/src/main/java/bisq/core/trade/TradeManager.java index 21d1615110..0384187bf1 100644 --- a/core/src/main/java/bisq/core/trade/TradeManager.java +++ b/core/src/main/java/bisq/core/trade/TradeManager.java @@ -77,6 +77,7 @@ import bisq.network.p2p.P2PService; import bisq.network.p2p.network.TorNetworkNode; import bisq.common.ClockWatcher; +import bisq.common.app.DevEnv; import bisq.common.config.Config; import bisq.common.crypto.KeyRing; import bisq.common.handlers.ErrorMessageHandler; @@ -644,6 +645,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi user, mediatorManager, tradeStatisticsManager, + provider.getDelayedPayoutTxReceiverService(), isTakerApiUser); } @@ -729,6 +731,9 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi clockWatcher.addListener(new ClockWatcher.Listener() { @Override public void onSecondTick() { + if (DevEnv.isDevMode()) { + updateTradePeriodState(); + } } @Override @@ -743,6 +748,12 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi if (!trade.isPayoutPublished()) { Date maxTradePeriodDate = trade.getMaxTradePeriodDate(); Date halfTradePeriodDate = trade.getHalfTradePeriodDate(); + if (DevEnv.isDevMode()) { + TransactionConfidence confidenceForTxId = btcWalletService.getConfidenceForTxId(trade.getDepositTxId()); + if (confidenceForTxId != null && confidenceForTxId.getDepthInBlocks() > 4) { + trade.setTradePeriodState(Trade.TradePeriodState.TRADE_PERIOD_OVER); + } + } if (maxTradePeriodDate != null && halfTradePeriodDate != null) { Date now = new Date(); if (now.after(maxTradePeriodDate)) { diff --git a/core/src/main/java/bisq/core/trade/bisq_v1/TradeDataValidation.java b/core/src/main/java/bisq/core/trade/bisq_v1/TradeDataValidation.java index 77e5bb1166..1404dcc820 100644 --- a/core/src/main/java/bisq/core/trade/bisq_v1/TradeDataValidation.java +++ b/core/src/main/java/bisq/core/trade/bisq_v1/TradeDataValidation.java @@ -71,12 +71,6 @@ public class TradeDataValidation { throw new InvalidTxException(errorMsg); } - if (delayedPayoutTx.getOutputs().size() != 1) { - errorMsg = "Number of delayedPayoutTx outputs must be 1"; - log.error(errorMsg); - log.error(delayedPayoutTx.toString()); - throw new InvalidTxException(errorMsg); - } // connectedOutput is null and input.getValue() is null at that point as the tx is not committed to the wallet // yet. So we cannot check that the input matches but we did the amount check earlier in the trade protocol. @@ -97,24 +91,33 @@ public class TradeDataValidation { throw new InvalidLockTimeException(errorMsg); } - // Check amount - TransactionOutput output = delayedPayoutTx.getOutput(0); - Offer offer = checkNotNull(trade.getOffer()); - Coin msOutputAmount = offer.getBuyerSecurityDeposit() - .add(offer.getSellerSecurityDeposit()) - .add(checkNotNull(trade.getAmount())); + if (trade.isUsingLegacyBurningMan()) { + if (delayedPayoutTx.getOutputs().size() != 1) { + errorMsg = "Number of delayedPayoutTx outputs must be 1"; + log.error(errorMsg); + log.error(delayedPayoutTx.toString()); + throw new InvalidTxException(errorMsg); + } - if (!output.getValue().equals(msOutputAmount)) { - errorMsg = "Output value of deposit tx and delayed payout tx is not matching. Output: " + output + " / msOutputAmount: " + msOutputAmount; - log.error(errorMsg); - log.error(delayedPayoutTx.toString()); - throw new InvalidAmountException(errorMsg); - } + // Check amount + TransactionOutput output = delayedPayoutTx.getOutput(0); + Offer offer = checkNotNull(trade.getOffer()); + Coin msOutputAmount = offer.getBuyerSecurityDeposit() + .add(offer.getSellerSecurityDeposit()) + .add(checkNotNull(trade.getAmount())); - NetworkParameters params = btcWalletService.getParams(); - String delayedPayoutTxOutputAddress = output.getScriptPubKey().getToAddress(params).toString(); - if (addressConsumer != null) { - addressConsumer.accept(delayedPayoutTxOutputAddress); + if (!output.getValue().equals(msOutputAmount)) { + errorMsg = "Output value of deposit tx and delayed payout tx is not matching. Output: " + output + " / msOutputAmount: " + msOutputAmount; + log.error(errorMsg); + log.error(delayedPayoutTx.toString()); + throw new InvalidAmountException(errorMsg); + } + + NetworkParameters params = btcWalletService.getParams(); + if (addressConsumer != null) { + String delayedPayoutTxOutputAddress = output.getScriptPubKey().getToAddress(params).toString(); + addressConsumer.accept(delayedPayoutTxOutputAddress); + } } } diff --git a/core/src/main/java/bisq/core/trade/model/bisq_v1/Trade.java b/core/src/main/java/bisq/core/trade/model/bisq_v1/Trade.java index 35b6048ecc..97048c035d 100644 --- a/core/src/main/java/bisq/core/trade/model/bisq_v1/Trade.java +++ b/core/src/main/java/bisq/core/trade/model/bisq_v1/Trade.java @@ -1067,6 +1067,12 @@ public abstract class Trade extends TradeModel { return offer != null && offer.isBsqSwapOffer(); } + // By checking if burningManSelectionHeight is 0 we can detect if the trade was created with + // the new burningmen receivers or with legacy BM. + public boolean isUsingLegacyBurningMan() { + return processModel.getBurningManSelectionHeight() == 0; + } + /////////////////////////////////////////////////////////////////////////////////////////// // Private diff --git a/core/src/main/java/bisq/core/trade/protocol/Provider.java b/core/src/main/java/bisq/core/trade/protocol/Provider.java index 273285efe1..d745e1868f 100644 --- a/core/src/main/java/bisq/core/trade/protocol/Provider.java +++ b/core/src/main/java/bisq/core/trade/protocol/Provider.java @@ -23,6 +23,8 @@ import bisq.core.btc.wallet.BtcWalletService; import bisq.core.btc.wallet.TradeWalletService; import bisq.core.btc.wallet.WalletsManager; import bisq.core.dao.DaoFacade; +import bisq.core.dao.burningman.BtcFeeReceiverService; +import bisq.core.dao.burningman.DelayedPayoutTxReceiverService; import bisq.core.filter.FilterManager; import bisq.core.offer.OpenOfferManager; import bisq.core.provider.fee.FeeService; @@ -60,6 +62,8 @@ public class Provider { private final RefundAgentManager refundAgentManager; private final KeyRing keyRing; private final FeeService feeService; + private final BtcFeeReceiverService btcFeeReceiverService; + private final DelayedPayoutTxReceiverService delayedPayoutTxReceiverService; @Inject public Provider(OpenOfferManager openOfferManager, @@ -78,7 +82,9 @@ public class Provider { MediatorManager mediatorManager, RefundAgentManager refundAgentManager, KeyRing keyRing, - FeeService feeService) { + FeeService feeService, + BtcFeeReceiverService btcFeeReceiverService, + DelayedPayoutTxReceiverService delayedPayoutTxReceiverService) { this.openOfferManager = openOfferManager; this.p2PService = p2PService; @@ -97,5 +103,7 @@ public class Provider { this.refundAgentManager = refundAgentManager; this.keyRing = keyRing; this.feeService = feeService; + this.btcFeeReceiverService = btcFeeReceiverService; + this.delayedPayoutTxReceiverService = delayedPayoutTxReceiverService; } } diff --git a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/messages/InputsForDepositTxRequest.java b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/messages/InputsForDepositTxRequest.java index d6cf92ab3d..7725a66b81 100644 --- a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/messages/InputsForDepositTxRequest.java +++ b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/messages/InputsForDepositTxRequest.java @@ -80,6 +80,9 @@ public final class InputsForDepositTxRequest extends TradeMessage implements Dir @Nullable private final String takersPaymentMethodId; + // Added in v 1.9.7 + private final int burningManSelectionHeight; + public InputsForDepositTxRequest(String tradeId, NodeAddress senderNodeAddress, long tradeAmount, @@ -107,7 +110,8 @@ public final class InputsForDepositTxRequest extends TradeMessage implements Dir byte[] accountAgeWitnessSignatureOfOfferId, long currentDate, @Nullable byte[] hashOfTakersPaymentAccountPayload, - @Nullable String takersPaymentMethodId) { + @Nullable String takersPaymentMethodId, + int burningManSelectionHeight) { super(messageVersion, tradeId, uid); this.senderNodeAddress = senderNodeAddress; this.tradeAmount = tradeAmount; @@ -134,6 +138,7 @@ public final class InputsForDepositTxRequest extends TradeMessage implements Dir this.currentDate = currentDate; this.hashOfTakersPaymentAccountPayload = hashOfTakersPaymentAccountPayload; this.takersPaymentMethodId = takersPaymentMethodId; + this.burningManSelectionHeight = burningManSelectionHeight; } @@ -169,7 +174,8 @@ public final class InputsForDepositTxRequest extends TradeMessage implements Dir .setRefundAgentNodeAddress(refundAgentNodeAddress.toProtoMessage()) .setUid(uid) .setAccountAgeWitnessSignatureOfOfferId(ByteString.copyFrom(accountAgeWitnessSignatureOfOfferId)) - .setCurrentDate(currentDate); + .setCurrentDate(currentDate) + .setBurningManSelectionHeight(burningManSelectionHeight); Optional.ofNullable(changeOutputAddress).ifPresent(builder::setChangeOutputAddress); Optional.ofNullable(arbitratorNodeAddress).ifPresent(e -> builder.setArbitratorNodeAddress(arbitratorNodeAddress.toProtoMessage())); @@ -223,7 +229,8 @@ public final class InputsForDepositTxRequest extends TradeMessage implements Dir ProtoUtil.byteArrayOrNullFromProto(proto.getAccountAgeWitnessSignatureOfOfferId()), proto.getCurrentDate(), hashOfTakersPaymentAccountPayload, - ProtoUtil.stringOrNullFromProto(proto.getTakersPayoutMethodId())); + ProtoUtil.stringOrNullFromProto(proto.getTakersPayoutMethodId()), + proto.getBurningManSelectionHeight()); } @Override @@ -253,6 +260,7 @@ public final class InputsForDepositTxRequest extends TradeMessage implements Dir ",\n currentDate=" + currentDate + ",\n hashOfTakersPaymentAccountPayload=" + Utilities.bytesAsHexString(hashOfTakersPaymentAccountPayload) + ",\n takersPaymentMethodId=" + takersPaymentMethodId + + ",\n burningManSelectionHeight=" + burningManSelectionHeight + "\n} " + super.toString(); } } diff --git a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/model/ProcessModel.java b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/model/ProcessModel.java index a7ad26a85a..3436c09595 100644 --- a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/model/ProcessModel.java +++ b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/model/ProcessModel.java @@ -23,6 +23,8 @@ import bisq.core.btc.wallet.BsqWalletService; import bisq.core.btc.wallet.BtcWalletService; import bisq.core.btc.wallet.TradeWalletService; import bisq.core.dao.DaoFacade; +import bisq.core.dao.burningman.BtcFeeReceiverService; +import bisq.core.dao.burningman.DelayedPayoutTxReceiverService; import bisq.core.filter.FilterManager; import bisq.core.network.MessageState; import bisq.core.offer.Offer; @@ -176,6 +178,11 @@ public class ProcessModel implements ProtocolModel { @Setter private ObjectProperty paymentStartedMessageStateProperty = new SimpleObjectProperty<>(MessageState.UNDEFINED); + // Added in v 1.9.7 + @Setter + @Getter + private int burningManSelectionHeight; + public ProcessModel(String offerId, String accountId, PubKeyRing pubKeyRing) { this(offerId, accountId, pubKeyRing, new TradingPeer()); } @@ -217,7 +224,8 @@ public class ProcessModel implements ProtocolModel { .setFundsNeededForTradeAsLong(fundsNeededForTradeAsLong) .setPaymentStartedMessageState(paymentStartedMessageStateProperty.get().name()) .setBuyerPayoutAmountFromMediation(buyerPayoutAmountFromMediation) - .setSellerPayoutAmountFromMediation(sellerPayoutAmountFromMediation); + .setSellerPayoutAmountFromMediation(sellerPayoutAmountFromMediation) + .setBurningManSelectionHeight(burningManSelectionHeight); Optional.ofNullable(takeOfferFeeTxId).ifPresent(builder::setTakeOfferFeeTxId); Optional.ofNullable(payoutTxSignature).ifPresent(e -> builder.setPayoutTxSignature(ByteString.copyFrom(payoutTxSignature))); @@ -259,6 +267,7 @@ public class ProcessModel implements ProtocolModel { String paymentStartedMessageStateString = ProtoUtil.stringOrNullFromProto(proto.getPaymentStartedMessageState()); MessageState paymentStartedMessageState = ProtoUtil.enumFromProto(MessageState.class, paymentStartedMessageStateString); processModel.setPaymentStartedMessageState(paymentStartedMessageState); + processModel.setBurningManSelectionHeight(proto.getBurningManSelectionHeight()); if (proto.hasPaymentAccount()) { processModel.setPaymentAccount(PaymentAccount.fromProto(proto.getPaymentAccount(), coreProtoResolver)); @@ -377,6 +386,14 @@ public class ProcessModel implements ProtocolModel { return provider.getTradeWalletService(); } + public BtcFeeReceiverService getBtcFeeReceiverService() { + return provider.getBtcFeeReceiverService(); + } + + public DelayedPayoutTxReceiverService getDelayedPayoutTxReceiverService() { + return provider.getDelayedPayoutTxReceiverService(); + } + public User getUser() { return provider.getUser(); } diff --git a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/buyer/BuyerVerifiesFinalDelayedPayoutTx.java b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/buyer/BuyerVerifiesFinalDelayedPayoutTx.java index 6b5f6e0fae..ba05dacec9 100644 --- a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/buyer/BuyerVerifiesFinalDelayedPayoutTx.java +++ b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/buyer/BuyerVerifiesFinalDelayedPayoutTx.java @@ -17,16 +17,22 @@ package bisq.core.trade.protocol.bisq_v1.tasks.buyer; +import bisq.core.btc.wallet.BtcWalletService; +import bisq.core.dao.burningman.BurningManService; import bisq.core.trade.bisq_v1.TradeDataValidation; import bisq.core.trade.model.bisq_v1.Trade; import bisq.core.trade.protocol.bisq_v1.tasks.TradeTask; import bisq.common.taskrunner.TaskRunner; +import bisq.common.util.Tuple2; import org.bitcoinj.core.Transaction; +import java.util.List; + import lombok.extern.slf4j.Slf4j; +import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; @Slf4j @@ -40,17 +46,37 @@ public class BuyerVerifiesFinalDelayedPayoutTx extends TradeTask { try { runInterceptHook(); - Transaction delayedPayoutTx = trade.getDelayedPayoutTx(); - checkNotNull(delayedPayoutTx, "trade.getDelayedPayoutTx() must not be null"); + BtcWalletService btcWalletService = processModel.getBtcWalletService(); + Transaction finalDelayedPayoutTx = trade.getDelayedPayoutTx(); + checkNotNull(finalDelayedPayoutTx, "trade.getDelayedPayoutTx() must not be null"); + // Check again tx TradeDataValidation.validateDelayedPayoutTx(trade, - delayedPayoutTx, - processModel.getBtcWalletService()); + finalDelayedPayoutTx, + btcWalletService); - // Now as we know the deposit tx we can also verify the input Transaction depositTx = trade.getDepositTx(); checkNotNull(depositTx, "trade.getDepositTx() must not be null"); - TradeDataValidation.validatePayoutTxInput(depositTx, delayedPayoutTx); + // Now as we know the deposit tx we can also verify the input + TradeDataValidation.validatePayoutTxInput(depositTx, finalDelayedPayoutTx); + + if (BurningManService.isActivated()) { + long inputAmount = depositTx.getOutput(0).getValue().value; + long tradeTxFeeAsLong = trade.getTradeTxFeeAsLong(); + int selectionHeight = processModel.getBurningManSelectionHeight(); + List> delayedPayoutTxReceivers = processModel.getDelayedPayoutTxReceiverService().getReceivers( + selectionHeight, + inputAmount, + tradeTxFeeAsLong); + log.info("Verify delayedPayoutTx using selectionHeight {} and receivers {}", selectionHeight, delayedPayoutTxReceivers); + long lockTime = trade.getLockTime(); + Transaction buyersDelayedPayoutTx = processModel.getTradeWalletService().createDelayedUnsignedPayoutTx( + depositTx, + delayedPayoutTxReceivers, + lockTime); + checkArgument(buyersDelayedPayoutTx.getTxId().equals(finalDelayedPayoutTx.getTxId()), + "TxIds of buyersDelayedPayoutTx and finalDelayedPayoutTx must be the same"); + } complete(); } catch (TradeDataValidation.ValidationException e) { diff --git a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/buyer/BuyerVerifiesPreparedDelayedPayoutTx.java b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/buyer/BuyerVerifiesPreparedDelayedPayoutTx.java index 77f04f4757..9f7e9c0d5f 100644 --- a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/buyer/BuyerVerifiesPreparedDelayedPayoutTx.java +++ b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/buyer/BuyerVerifiesPreparedDelayedPayoutTx.java @@ -17,14 +17,22 @@ package bisq.core.trade.protocol.bisq_v1.tasks.buyer; +import bisq.core.btc.wallet.BtcWalletService; +import bisq.core.dao.burningman.BurningManService; import bisq.core.trade.bisq_v1.TradeDataValidation; import bisq.core.trade.model.bisq_v1.Trade; import bisq.core.trade.protocol.bisq_v1.tasks.TradeTask; import bisq.common.taskrunner.TaskRunner; +import bisq.common.util.Tuple2; + +import org.bitcoinj.core.Transaction; + +import java.util.List; import lombok.extern.slf4j.Slf4j; +import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; @Slf4j @@ -38,19 +46,36 @@ public class BuyerVerifiesPreparedDelayedPayoutTx extends TradeTask { try { runInterceptHook(); - var preparedDelayedPayoutTx = processModel.getPreparedDelayedPayoutTx(); + Transaction sellersPreparedDelayedPayoutTx = checkNotNull(processModel.getPreparedDelayedPayoutTx()); + BtcWalletService btcWalletService = processModel.getBtcWalletService(); TradeDataValidation.validateDelayedPayoutTx(trade, - preparedDelayedPayoutTx, - processModel.getBtcWalletService()); + sellersPreparedDelayedPayoutTx, + btcWalletService); + + Transaction preparedDepositTx = btcWalletService.getTxFromSerializedTx(processModel.getPreparedDepositTx()); + if (BurningManService.isActivated()) { + long inputAmount = preparedDepositTx.getOutput(0).getValue().value; + long tradeTxFeeAsLong = trade.getTradeTxFeeAsLong(); + List> delayedPayoutTxReceivers = processModel.getDelayedPayoutTxReceiverService().getReceivers( + processModel.getBurningManSelectionHeight(), + inputAmount, + tradeTxFeeAsLong); + + long lockTime = trade.getLockTime(); + Transaction buyersPreparedDelayedPayoutTx = processModel.getTradeWalletService().createDelayedUnsignedPayoutTx( + preparedDepositTx, + delayedPayoutTxReceivers, + lockTime); + checkArgument(buyersPreparedDelayedPayoutTx.getTxId().equals(sellersPreparedDelayedPayoutTx.getTxId()), + "TxIds of buyersPreparedDelayedPayoutTx and sellersPreparedDelayedPayoutTx must be the same"); + } // If the deposit tx is non-malleable, we already know its final ID, so should check that now // before sending any further data to the seller, to provide extra protection for the buyer. if (isDepositTxNonMalleable()) { - var preparedDepositTx = processModel.getBtcWalletService().getTxFromSerializedTx( - processModel.getPreparedDepositTx()); - TradeDataValidation.validatePayoutTxInput(preparedDepositTx, checkNotNull(preparedDelayedPayoutTx)); + TradeDataValidation.validatePayoutTxInput(preparedDepositTx, sellersPreparedDelayedPayoutTx); } else { - log.info("Deposit tx is malleable, so we skip preparedDelayedPayoutTx input validation."); + log.info("Deposit tx is malleable, so we skip sellersPreparedDelayedPayoutTx input validation."); } complete(); diff --git a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/maker/MakerProcessesInputsForDepositTxRequest.java b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/maker/MakerProcessesInputsForDepositTxRequest.java index c49c2338e2..aefe9f609a 100644 --- a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/maker/MakerProcessesInputsForDepositTxRequest.java +++ b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/maker/MakerProcessesInputsForDepositTxRequest.java @@ -17,6 +17,7 @@ package bisq.core.trade.protocol.bisq_v1.tasks.maker; +import bisq.core.dao.burningman.BurningManService; import bisq.core.exceptions.TradePriceOutOfToleranceException; import bisq.core.offer.Offer; import bisq.core.support.dispute.mediation.mediator.Mediator; @@ -79,6 +80,16 @@ public class MakerProcessesInputsForDepositTxRequest extends TradeTask { tradingPeer.setAccountId(nonEmptyStringOf(request.getTakerAccountId())); + if (BurningManService.isActivated()) { + int takersBurningManSelectionHeight = request.getBurningManSelectionHeight(); + checkArgument(takersBurningManSelectionHeight > 0, "takersBurningManSelectionHeight must not be 0"); + + int makersBurningManSelectionHeight = processModel.getDelayedPayoutTxReceiverService().getBurningManSelectionHeight(); + checkArgument(takersBurningManSelectionHeight == makersBurningManSelectionHeight, + "takersBurningManSelectionHeight does no match makersBurningManSelectionHeight"); + processModel.setBurningManSelectionHeight(makersBurningManSelectionHeight); + } + // We set the taker fee only in the processModel yet not in the trade as the tx was only created but not // published yet. Once it was published we move it to trade. The takerFeeTx should be sent in a later // message but that cannot be changed due backward compatibility issues. It is a left over from the diff --git a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/seller/SellerCreatesDelayedPayoutTx.java b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/seller/SellerCreatesDelayedPayoutTx.java index 5e7f76b2ad..9a0346fab1 100644 --- a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/seller/SellerCreatesDelayedPayoutTx.java +++ b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/seller/SellerCreatesDelayedPayoutTx.java @@ -18,16 +18,20 @@ package bisq.core.trade.protocol.bisq_v1.tasks.seller; import bisq.core.btc.wallet.TradeWalletService; +import bisq.core.dao.burningman.BurningManService; import bisq.core.dao.governance.param.Param; import bisq.core.trade.bisq_v1.TradeDataValidation; import bisq.core.trade.model.bisq_v1.Trade; import bisq.core.trade.protocol.bisq_v1.tasks.TradeTask; import bisq.common.taskrunner.TaskRunner; +import bisq.common.util.Tuple2; import org.bitcoinj.core.Coin; import org.bitcoinj.core.Transaction; +import java.util.List; + import lombok.extern.slf4j.Slf4j; import static com.google.common.base.Preconditions.checkNotNull; @@ -44,16 +48,33 @@ public class SellerCreatesDelayedPayoutTx extends TradeTask { try { runInterceptHook(); - String donationAddressString = processModel.getDaoFacade().getParamValue(Param.RECIPIENT_BTC_ADDRESS); - Coin minerFee = trade.getTradeTxFee(); TradeWalletService tradeWalletService = processModel.getTradeWalletService(); Transaction depositTx = checkNotNull(processModel.getDepositTx()); + Transaction preparedDelayedPayoutTx; + if (BurningManService.isActivated()) { + long inputAmount = depositTx.getOutput(0).getValue().value; + long tradeTxFeeAsLong = trade.getTradeTxFeeAsLong(); + int selectionHeight = processModel.getBurningManSelectionHeight(); + List> delayedPayoutTxReceivers = processModel.getDelayedPayoutTxReceiverService().getReceivers( + selectionHeight, + inputAmount, + tradeTxFeeAsLong); + log.info("Verify delayedPayoutTx using selectionHeight {} and receivers {}", selectionHeight, delayedPayoutTxReceivers); + long lockTime = trade.getLockTime(); + preparedDelayedPayoutTx = tradeWalletService.createDelayedUnsignedPayoutTx( + depositTx, + delayedPayoutTxReceivers, + lockTime); + } else { + String donationAddressString = processModel.getDaoFacade().getParamValue(Param.RECIPIENT_BTC_ADDRESS); + Coin minerFee = trade.getTradeTxFee(); + long lockTime = trade.getLockTime(); + preparedDelayedPayoutTx = tradeWalletService.createDelayedUnsignedPayoutTx(depositTx, + donationAddressString, + minerFee, + lockTime); + } - long lockTime = trade.getLockTime(); - Transaction preparedDelayedPayoutTx = tradeWalletService.createDelayedUnsignedPayoutTx(depositTx, - donationAddressString, - minerFee, - lockTime); TradeDataValidation.validateDelayedPayoutTx(trade, preparedDelayedPayoutTx, processModel.getBtcWalletService()); diff --git a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/taker/CreateTakerFeeTx.java b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/taker/CreateTakerFeeTx.java index 1371df8616..4b25c1199a 100644 --- a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/taker/CreateTakerFeeTx.java +++ b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/taker/CreateTakerFeeTx.java @@ -24,7 +24,6 @@ import bisq.core.btc.wallet.WalletService; import bisq.core.dao.exceptions.DaoDisabledException; import bisq.core.trade.model.bisq_v1.Trade; import bisq.core.trade.protocol.bisq_v1.tasks.TradeTask; -import bisq.core.util.FeeReceiverSelector; import bisq.common.taskrunner.TaskRunner; @@ -66,7 +65,7 @@ public class CreateTakerFeeTx extends TradeTask { Transaction transaction; if (trade.isCurrencyForTakerFeeBtc()) { - String feeReceiver = FeeReceiverSelector.getAddress(processModel.getFilterManager()); + String feeReceiver = processModel.getBtcFeeReceiverService().getAddress(); transaction = tradeWalletService.createBtcTradingFeeTx( fundingAddress, reservedForTradeAddress, diff --git a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/taker/TakerSendInputsForDepositTxRequest.java b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/taker/TakerSendInputsForDepositTxRequest.java index 75cfa53d7c..a5df2116a9 100644 --- a/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/taker/TakerSendInputsForDepositTxRequest.java +++ b/core/src/main/java/bisq/core/trade/protocol/bisq_v1/tasks/taker/TakerSendInputsForDepositTxRequest.java @@ -104,6 +104,9 @@ public class TakerSendInputsForDepositTxRequest extends TradeTask { byte[] signatureOfNonce = Sig.sign(processModel.getKeyRing().getSignatureKeyPair().getPrivate(), offerId.getBytes(Charsets.UTF_8)); + int burningManSelectionHeight = processModel.getDelayedPayoutTxReceiverService().getBurningManSelectionHeight(); + processModel.setBurningManSelectionHeight(burningManSelectionHeight); + String takersPaymentMethodId = checkNotNull(processModel.getPaymentAccountPayload(trade)).getPaymentMethodId(); InputsForDepositTxRequest request = new InputsForDepositTxRequest( offerId, @@ -133,7 +136,8 @@ public class TakerSendInputsForDepositTxRequest extends TradeTask { signatureOfNonce, new Date().getTime(), hashOfTakersPaymentAccountPayload, - takersPaymentMethodId); + takersPaymentMethodId, + burningManSelectionHeight); log.info("Send {} with offerId {} and uid {} to peer {}", request.getClass().getSimpleName(), request.getTradeId(), request.getUid(), trade.getTradingPeerNodeAddress()); diff --git a/core/src/main/java/bisq/core/util/AveragePriceUtil.java b/core/src/main/java/bisq/core/util/AveragePriceUtil.java index beb320e0e2..fd1f8e65df 100644 --- a/core/src/main/java/bisq/core/util/AveragePriceUtil.java +++ b/core/src/main/java/bisq/core/util/AveragePriceUtil.java @@ -42,11 +42,26 @@ public class AveragePriceUtil { public static Tuple2 getAveragePriceTuple(Preferences preferences, TradeStatisticsManager tradeStatisticsManager, int days) { + return getAveragePriceTuple(preferences, tradeStatisticsManager, days, new Date()); + } + + public static Tuple2 getAveragePriceTuple(Preferences preferences, + TradeStatisticsManager tradeStatisticsManager, + int days, + Date date) { + Date pastXDays = getPastDate(days, date); + return getAveragePriceTuple(preferences, tradeStatisticsManager, pastXDays, date); + } + + public static Tuple2 getAveragePriceTuple(Preferences preferences, + TradeStatisticsManager tradeStatisticsManager, + Date pastXDays, + Date date) { double percentToTrim = Math.max(0, Math.min(49, preferences.getBsqAverageTrimThreshold() * 100)); - Date pastXDays = getPastDate(days); List bsqAllTradePastXDays = tradeStatisticsManager.getObservableTradeStatisticsSet().stream() .filter(e -> e.getCurrency().equals("BSQ")) .filter(e -> e.getDate().after(pastXDays)) + .filter(e -> e.getDate().before(date)) .collect(Collectors.toList()); List bsqTradePastXDays = percentToTrim > 0 ? removeOutliers(bsqAllTradePastXDays, percentToTrim) : @@ -55,6 +70,7 @@ public class AveragePriceUtil { List usdAllTradePastXDays = tradeStatisticsManager.getObservableTradeStatisticsSet().stream() .filter(e -> e.getCurrency().equals("USD")) .filter(e -> e.getDate().after(pastXDays)) + .filter(e -> e.getDate().before(date)) .collect(Collectors.toList()); List usdTradePastXDays = percentToTrim > 0 ? removeOutliers(usdAllTradePastXDays, percentToTrim) : @@ -103,7 +119,7 @@ public class AveragePriceUtil { var usdBTCPrice = 10000d; // Default to 10000 USD per BTC if there is no USD feed at all for (TradeStatistics3 item : bsqList) { - // Find usdprice for trade item + // Find usd price for trade item usdBTCPrice = usdList.stream() .filter(usd -> usd.getDateAsLong() > item.getDateAsLong()) .map(usd -> MathUtils.scaleDownByPowerOf10((double) usd.getTradePrice().getValue(), @@ -130,9 +146,9 @@ public class AveragePriceUtil { return averagePrice; } - private static Date getPastDate(int days) { + private static Date getPastDate(int days, Date date) { Calendar cal = new GregorianCalendar(); - cal.setTime(new Date()); + cal.setTime(date); cal.add(Calendar.DAY_OF_MONTH, -1 * days); return cal.getTime(); } diff --git a/core/src/main/java/bisq/core/util/FeeReceiverSelector.java b/core/src/main/java/bisq/core/util/FeeReceiverSelector.java deleted file mode 100644 index a7f32e13cd..0000000000 --- a/core/src/main/java/bisq/core/util/FeeReceiverSelector.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * This file is part of Bisq. - * - * Bisq is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or (at - * your option) any later version. - * - * Bisq is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public - * License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Bisq. If not, see . - */ - -package bisq.core.util; - -import bisq.core.filter.FilterManager; - -import bisq.common.config.Config; - -import org.bitcoinj.core.Coin; - -import com.google.common.annotations.VisibleForTesting; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.Random; - -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public class FeeReceiverSelector { - public static final String BTC_FEE_RECEIVER_ADDRESS = "38bZBj5peYS3Husdz7AH3gEUiUbYRD951t"; - - public static String getMostRecentAddress() { - return Config.baseCurrencyNetwork().isMainnet() ? BTC_FEE_RECEIVER_ADDRESS : - Config.baseCurrencyNetwork().isTestnet() ? "2N4mVTpUZAnhm9phnxB7VrHB4aBhnWrcUrV" : - "2MzBNTJDjjXgViKBGnatDU3yWkJ8pJkEg9w"; - } - - public static String getAddress(FilterManager filterManager) { - return getAddress(filterManager, new Random()); - } - - @VisibleForTesting - static String getAddress(FilterManager filterManager, Random rnd) { - List feeReceivers = Optional.ofNullable(filterManager.getFilter()) - .flatMap(f -> Optional.ofNullable(f.getBtcFeeReceiverAddresses())) - .orElse(List.of()); - - List amountList = new ArrayList<>(); - List receiverAddressList = new ArrayList<>(); - - feeReceivers.forEach(e -> { - try { - String[] tokens = e.split("#"); - amountList.add(Coin.parseCoin(tokens[1]).longValue()); // total amount the victim should receive - receiverAddressList.add(tokens[0]); // victim's receiver address - } catch (RuntimeException ignore) { - // If input format is not as expected we ignore entry - } - }); - - if (!amountList.isEmpty()) { - return receiverAddressList.get(weightedSelection(amountList, rnd)); - } - - // If no fee address receiver is defined via filter we use the hard coded recent address - return getMostRecentAddress(); - } - - @VisibleForTesting - static int weightedSelection(List weights, Random rnd) { - long sum = weights.stream().mapToLong(n -> n).sum(); - long target = rnd.longs(0, sum).findFirst().orElseThrow(); - int i; - for (i = 0; i < weights.size() && target >= 0; i++) { - target -= weights.get(i); - } - return i - 1; - } -} diff --git a/core/src/main/java/bisq/core/util/coin/BsqFormatter.java b/core/src/main/java/bisq/core/util/coin/BsqFormatter.java index cd0accdb97..7e1545eccd 100644 --- a/core/src/main/java/bisq/core/util/coin/BsqFormatter.java +++ b/core/src/main/java/bisq/core/util/coin/BsqFormatter.java @@ -221,6 +221,10 @@ public class BsqFormatter implements CoinFormatter { } } + public String formatCoin(long value) { + return formatCoin(Coin.valueOf(value)); + } + public String formatCoin(Coin coin) { return formatCoin(coin, false); } diff --git a/core/src/main/java/bisq/core/util/coin/CoinFormatter.java b/core/src/main/java/bisq/core/util/coin/CoinFormatter.java index 2b65460e6b..c6c4c31786 100644 --- a/core/src/main/java/bisq/core/util/coin/CoinFormatter.java +++ b/core/src/main/java/bisq/core/util/coin/CoinFormatter.java @@ -5,6 +5,8 @@ import org.bitcoinj.core.Coin; public interface CoinFormatter { String formatCoin(Coin coin); + String formatCoin(long value); + String formatCoin(Coin coin, boolean appendCode); String formatCoin(Coin coin, int decimalPlaces); diff --git a/core/src/main/java/bisq/core/util/coin/ImmutableCoinFormatter.java b/core/src/main/java/bisq/core/util/coin/ImmutableCoinFormatter.java index 80a9d7ef86..07116a71bc 100644 --- a/core/src/main/java/bisq/core/util/coin/ImmutableCoinFormatter.java +++ b/core/src/main/java/bisq/core/util/coin/ImmutableCoinFormatter.java @@ -53,6 +53,11 @@ public class ImmutableCoinFormatter implements CoinFormatter { return formatCoin(coin, -1); } + @Override + public String formatCoin(long value) { + return formatCoin(Coin.valueOf(value)); + } + @Override public String formatCoin(Coin coin, boolean appendCode) { return appendCode ? formatCoinWithCode(coin) : formatCoin(coin); diff --git a/core/src/main/java/bisq/core/util/validation/BtcAddressValidator.java b/core/src/main/java/bisq/core/util/validation/BtcAddressValidator.java index 38eda4094c..2820d5d81a 100644 --- a/core/src/main/java/bisq/core/util/validation/BtcAddressValidator.java +++ b/core/src/main/java/bisq/core/util/validation/BtcAddressValidator.java @@ -43,6 +43,9 @@ public final class BtcAddressValidator extends InputValidator { } private ValidationResult validateBtcAddress(String input) { + if (allowEmpty && (input == null || input.trim().isEmpty())) { + return new ValidationResult(true); + } try { Address.fromString(Config.baseCurrencyNetworkParameters(), input); return new ValidationResult(true); diff --git a/core/src/main/java/bisq/core/util/validation/InputValidator.java b/core/src/main/java/bisq/core/util/validation/InputValidator.java index 7ce3d68a5e..8be6d60626 100644 --- a/core/src/main/java/bisq/core/util/validation/InputValidator.java +++ b/core/src/main/java/bisq/core/util/validation/InputValidator.java @@ -24,7 +24,11 @@ import java.math.BigInteger; import java.util.Objects; import java.util.function.Function; +import lombok.Setter; + public class InputValidator { + @Setter + public boolean allowEmpty; public ValidationResult validate(String input) { return validateIfNotEmpty(input); @@ -32,10 +36,13 @@ public class InputValidator { protected ValidationResult validateIfNotEmpty(String input) { //trim added to avoid empty input - if (input == null || input.trim().length() == 0) - return new ValidationResult(false, Res.get("validation.empty")); - else + if (allowEmpty) { return new ValidationResult(true); + } else if (input == null || input.trim().length() == 0) { + return new ValidationResult(false, Res.get("validation.empty")); + } else { + return new ValidationResult(true); + } } public static class ValidationResult { diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index e50348262f..ffbb9e3912 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -975,14 +975,15 @@ portfolio.pending.mediationResult.popup.info=The mediator has suggested the foll Your trading peer receives: {1}\n\n\ You can accept or reject this suggested payout.\n\n\ By accepting, you sign the proposed payout transaction. \ + Mediation is expected to be the optimal resolution for both traders. \ If your trading peer also accepts and signs, the payout will be completed, and the trade will be closed.\n\n\ - If one or both of you reject the suggestion, you will have to wait until {2} (block {3}) to open a \ - second-round dispute with an arbitrator who will investigate the case again and do a payout based on their findings.\n\n\ - The arbitrator may charge a small fee (fee maximum: the trader''s security deposit) as compensation for their work. \ - Both traders agreeing to the mediator''s suggestion is the happy path—requesting arbitration is meant for \ - exceptional circumstances, such as if a trader is sure the mediator did not make a fair payout suggestion \ - (or if the other peer is unresponsive).\n\n\ - More details about the new arbitration model: [HYPERLINK:https://bisq.wiki/Dispute_resolution#Level_3:_Arbitration] + If one or both of you reject the suggestion, you will have to wait until {2} (block {3}) to reject mediation suggestion, \ + which will open a second-round dispute with an arbitrator who will investigate the case again and do a payout based on their findings.\n\n\ + If the trade goes to arbitration the arbitrator will pay out the trade amount plus one peer's security deposit. \ + This means the total arbitration payout will be less than the mediation payout. \ + Requesting arbitration is meant for exceptional circumstances. such as; \ + one peer not responding, or disputing the mediator made a fair payout suggestion. \n\n\ + More details about the arbitration model: [HYPERLINK:https://bisq.wiki/Dispute_resolution#Level_3:_Arbitration] portfolio.pending.mediationResult.popup.selfAccepted.lockTimeOver=You have accepted the mediator''s suggested payout \ but it seems that your trading peer has not accepted it.\n\n\ Once the lock time is over on {0} (block {1}), you can open a second-round dispute with an arbitrator who will \ @@ -1967,7 +1968,7 @@ dao.tab.factsAndFigures=Facts & Figures dao.tab.bsqWallet=BSQ wallet dao.tab.proposals=Governance dao.tab.bonding=Bonding -dao.tab.proofOfBurn=Asset listing fee/Proof of burn +dao.tab.proofOfBurn=Proof of burn dao.tab.monitor=Network monitor dao.tab.news=News @@ -2097,7 +2098,8 @@ dao.param.ASSET_LISTING_FEE_PER_DAY=Asset listing fee per day dao.param.ASSET_MIN_VOLUME=Min. trade volume for assets # suppress inspection "UnusedProperty" -dao.param.LOCK_TIME_TRADE_PAYOUT=Lock time for alternative trade payout tx +## Was never used and got re-purposed for expected BTC fee revenue per cycle +dao.param.LOCK_TIME_TRADE_PAYOUT=BTC fee revenue / cycle as BSQ Satoshi value # suppress inspection "UnusedProperty" dao.param.ARBITRATOR_FEE=Arbitrator fee in BTC @@ -2261,6 +2263,7 @@ dao.bond.bondedRoleType.BTC_DONATION_ADDRESS_OWNER=BTC donation address owner dao.burnBsq.assetFee=Asset listing dao.burnBsq.menuItem.assetFee=Asset listing fee dao.burnBsq.menuItem.proofOfBurn=Proof of burn +dao.burnBsq.menuItem.burningMan=Burningmen dao.burnBsq.header=Fee for asset listing dao.burnBsq.selectAsset=Select Asset dao.burnBsq.fee=Fee @@ -2275,6 +2278,59 @@ dao.burnBsq.assets.trialFee=Fee for trial period dao.burnBsq.assets.totalFee=Total fees paid dao.burnBsq.assets.days={0} days dao.burnBsq.assets.toFewDays=The asset fee is too low. The min. amount of days for the trial period is {0}. +dao.burningman.target.header=Burn target +dao.burningman.burn.header=Burn BSQ +dao.burningman.amount.prompt=Amount to burn +dao.burningman.amount.prompt.max=Amount to burn: {0} - {1} BSQ +dao.burningman.candidates.table.header=Burningmen candidates +dao.burningman.burnOutput.table.header=Burn outputs +dao.burningman.balanceEntry.table.header=Revenue +dao.burningman.balanceEntry.table.showMonthlyToggle=By month +dao.burningman.balanceEntry.table.radio.all=All +dao.burningman.balanceEntry.table.radio.fee=BTC fees only +dao.burningman.balanceEntry.table.radio.dpt=DPT only +dao.burningman.balanceEntry.table.radio.burn=Burned BSQ + +dao.burningman.compensations.table.header=Compensations +dao.burningman.reimbursement.table.header=Reimbursement requests +dao.burningman.expectedRevenue=Past 3-month average distribution +dao.burningman.burnTarget.label=Amount of BSQ to burn +dao.burningman.burnTarget.fromTo={0} - {1} BSQ +dao.burningman.filter=Filter burningman candidates +dao.burningman.toggle=Show only active burningmen +dao.burningman.contributorsComboBox.prompt=Select any of my contributors +dao.burningman.selectedContributor=Selected contributor +dao.burningman.selectedContributorName=Contributor name +dao.burningman.selectedContributorAddress=Receiver address +dao.burningman.shared.table.height=Block height +dao.burningman.shared.table.cycle=Cycle +dao.burningman.shared.table.date=Date +dao.burningman.table.name=Contributor name +dao.burningman.table.burnTarget=Burn target +dao.burningman.table.expectedRevenue=Expected to receive +dao.burningman.table.burnAmount=Burned amount +dao.burningman.table.decayedBurnAmount=Decayed burned amount +dao.burningman.table.burnAmountShare.label=Receiver share +dao.burningman.table.burnAmountShare.capped={0} (capped from {1}) +dao.burningman.table.numBurnOutputs=Num. Burns +dao.burningman.table.issuanceAmount=Issued amount +dao.burningman.table.decayedIssuanceAmount=Decayed issued amount +dao.burningman.table.issuanceShare=Issuance share +dao.burningman.table.numIssuances=Num. issuances +dao.burningman.table.reimbursedAmount=Reimbursed amount +dao.burningman.table.balanceEntry.date=Date +dao.burningman.table.balanceEntry.receivedBtc=Received BTC +dao.burningman.table.balanceEntry.receivedBtcAsBsq=Received BTC as BSQ +dao.burningman.table.balanceEntry.burnedBsq=Burned BSQ +dao.burningman.table.balanceEntry.revenue=Revenue +dao.burningman.table.balanceEntry.price=BSQ/BTC price +dao.burningman.table.balanceEntry.type=Type + +# From BalanceEntry.Type enum names +dao.burningman.balanceEntry.type.UNDEFINED=Undefined +dao.burningman.balanceEntry.type.BTC_TRADE_FEE_TX=Trade fee +dao.burningman.balanceEntry.type.DPT_TX=DPT +dao.burningman.balanceEntry.type.BURN_TX=Burned BSQ # suppress inspection "UnusedProperty" dao.assetState.UNDEFINED=Undefined @@ -2400,6 +2456,7 @@ dao.proposal.display.name=Exact GitHub username dao.proposal.display.link=Link to detailed info dao.proposal.display.link.prompt=Link to proposal dao.proposal.display.requestedBsq=Requested amount in BSQ +dao.proposal.display.burningManReceiverAddress=Burningman receiver address (optional) dao.proposal.display.txId=Proposal transaction ID dao.proposal.display.proposalFee=Proposal fee dao.proposal.display.myVote=My vote @@ -2459,6 +2516,10 @@ dao.wallet.send.receiverBtcAddress=Receiver's BTC address dao.wallet.send.setDestinationAddress=Fill in your destination address dao.wallet.send.send=Send BSQ funds dao.wallet.send.inputControl=Select inputs +dao.wallet.send.addOpReturn=Add data +dao.wallet.send.preImage=Pre-image +dao.wallet.send.opReturnAsHex=Op-Return data Hex encoded +dao.wallet.send.opReturnAsHash=Hash of Op-Return data dao.wallet.send.sendBtc=Send BTC funds dao.wallet.send.sendFunds.headline=Confirm withdrawal request dao.wallet.send.sendFunds.details=Sending: {0}\nTo receiving address: {1}.\nRequired mining fee is: {2} ({3} satoshis/vbyte)\nTransaction vsize: {4} vKb\n\nThe recipient will receive: {5}\n\nAre you sure you want to withdraw that amount? @@ -2758,7 +2819,7 @@ disputeSummaryWindow.requestingTxs=Requesting blockchain transactions from block disputeSummaryWindow.requestTransactionsError=Requesting the 4 trade transactions failed. Error message: {0}.\n\n\ Please verify the transactions manually before closing the dispute. disputeSummaryWindow.delayedPayoutTxVerificationFailed=Verification of the delayed payout transaction failed. Error message: {0}.\n\n\ - Please do not make the payout but get in touch with developers to clearify the case. + Please do not make the payout but get in touch with developers to clarify the case. # dynamic values are not recognized by IntelliJ # suppress inspection "UnusedProperty" diff --git a/core/src/test/java/bisq/core/dao/burningman/BtcFeeReceiverServiceTest.java b/core/src/test/java/bisq/core/dao/burningman/BtcFeeReceiverServiceTest.java new file mode 100644 index 0000000000..04ae86c415 --- /dev/null +++ b/core/src/test/java/bisq/core/dao/burningman/BtcFeeReceiverServiceTest.java @@ -0,0 +1,80 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman; + + +import com.google.common.primitives.Longs; + +import java.util.List; +import java.util.Random; + +import org.mockito.junit.MockitoJUnitRunner; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertEquals; + +@RunWith(MockitoJUnitRunner.class) +public class BtcFeeReceiverServiceTest { + @Test + public void testGetRandomIndex() { + Random rnd = new Random(456); + assertEquals(4, BtcFeeReceiverService.getRandomIndex(Longs.asList(0, 0, 0, 3, 3), rnd)); + assertEquals(3, BtcFeeReceiverService.getRandomIndex(Longs.asList(0, 0, 0, 6, 0, 0, 0, 0, 0), rnd)); + + assertEquals(-1, BtcFeeReceiverService.getRandomIndex(Longs.asList(), rnd)); + assertEquals(-1, BtcFeeReceiverService.getRandomIndex(Longs.asList(0), rnd)); + assertEquals(-1, BtcFeeReceiverService.getRandomIndex(Longs.asList(0, 0), rnd)); + + int[] selections = new int[3]; + for (int i = 0; i < 6000; i++) { + selections[BtcFeeReceiverService.getRandomIndex(Longs.asList(1, 2, 3), rnd)]++; + } + // selections with new Random(456) are: [986, 1981, 3033] + assertEquals(1000.0, selections[0], 100); + assertEquals(2000.0, selections[1], 100); + assertEquals(3000.0, selections[2], 100); + } + + @Test + public void testFindIndex() { + List weights = Longs.asList(1, 2, 3); + assertEquals(0, BtcFeeReceiverService.findIndex(weights, 1)); + assertEquals(1, BtcFeeReceiverService.findIndex(weights, 2)); + assertEquals(1, BtcFeeReceiverService.findIndex(weights, 3)); + assertEquals(2, BtcFeeReceiverService.findIndex(weights, 4)); + assertEquals(2, BtcFeeReceiverService.findIndex(weights, 5)); + assertEquals(2, BtcFeeReceiverService.findIndex(weights, 6)); + + // invalid values return index 0 + assertEquals(0, BtcFeeReceiverService.findIndex(weights, 0)); + assertEquals(0, BtcFeeReceiverService.findIndex(weights, 7)); + + assertEquals(0, BtcFeeReceiverService.findIndex(Longs.asList(0, 1, 2, 3), 0)); + assertEquals(0, BtcFeeReceiverService.findIndex(Longs.asList(1, 2, 3), 0)); + assertEquals(0, BtcFeeReceiverService.findIndex(Longs.asList(1, 2, 3), 1)); + assertEquals(1, BtcFeeReceiverService.findIndex(Longs.asList(0, 1, 2, 3), 1)); + assertEquals(2, BtcFeeReceiverService.findIndex(Longs.asList(0, 1, 2, 3), 2)); + assertEquals(1, BtcFeeReceiverService.findIndex(Longs.asList(0, 1, 0, 2, 3), 1)); + assertEquals(3, BtcFeeReceiverService.findIndex(Longs.asList(0, 1, 0, 2, 3), 2)); + assertEquals(3, BtcFeeReceiverService.findIndex(Longs.asList(0, 0, 0, 1, 2, 3), 1)); + assertEquals(4, BtcFeeReceiverService.findIndex(Longs.asList(0, 0, 0, 1, 2, 3), 2)); + assertEquals(6, BtcFeeReceiverService.findIndex(Longs.asList(0, 0, 0, 1, 0, 0, 2, 3), 2)); + } +} diff --git a/core/src/test/java/bisq/core/dao/burningman/BurningManPresentationServiceTest.java b/core/src/test/java/bisq/core/dao/burningman/BurningManPresentationServiceTest.java new file mode 100644 index 0000000000..f1e445c945 --- /dev/null +++ b/core/src/test/java/bisq/core/dao/burningman/BurningManPresentationServiceTest.java @@ -0,0 +1,135 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman; + + +import org.mockito.junit.MockitoJUnitRunner; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertEquals; + +@RunWith(MockitoJUnitRunner.class) +public class BurningManPresentationServiceTest { + @Test + public void testGetRandomIndex() { + long total = 100; + long myAmount = 40; + double myTargetShare = 0.75; + // Initial state: + // Mine: 40 + // Others: 60 + // Total: 100 + // Current myShare: 0.4 + + // Target state: + // Mine: 40 + 140 = 180 + // Others: 60 + // Total: 240 + // Target myShare: 0.75 + + assertEquals(140, BurningManPresentationService.getMissingAmountToReachTargetShare(total, myAmount, myTargetShare)); + + total = 60; + myAmount = 0; + myTargetShare = 0.4; + // Initial state: + // Mine: 0 + // Others: 60 + // Total: 60 + // Current myShare: 0 + + // Target state: + // Mine: 0 + 40 = 40 + // Others: 60 + // Total: 100 + // Target myShare: 0.4 + + assertEquals(40, BurningManPresentationService.getMissingAmountToReachTargetShare(total, myAmount, myTargetShare)); + + total = 40; + myAmount = 40; + myTargetShare = 1; + // Initial state: + // Mine: 40 + // Others: 0 + // Total: 40 + // Current myShare: 1 + + // Target state: + // Mine: 40 -40 = 0 + // Others: 0 + // Total: 0 + // Target myShare: 1 + + assertEquals(-40, BurningManPresentationService.getMissingAmountToReachTargetShare(total, myAmount, myTargetShare)); + + total = 100; + myAmount = 99; + myTargetShare = 0; + // Initial state: + // Mine: 99 + // Others: 1 + // Total: 100 + // Current myShare: 0.99 + + // Target state: + // Mine: 99 - 99 = 0 + // Others: 1 + // Total: 100 + // Target myShare: 0 + + assertEquals(-99, BurningManPresentationService.getMissingAmountToReachTargetShare(total, myAmount, myTargetShare)); + + total = 100; + myAmount = 1; + myTargetShare = 0.5; + // Initial state: + // Mine: 1 + // Others: 99 + // Total: 100 + // Current myShare: 0.01 + + // Target state: + // Mine: 1 + 98 = 99 + // Others: 99 + // Total: 198 + // Target myShare: 0.5 + + assertEquals(98, BurningManPresentationService.getMissingAmountToReachTargetShare(total, myAmount, myTargetShare)); + + + total = 110; + myAmount = 0; + myTargetShare = 0.6; + // Initial state: + // Mine: 0 + // Others: 110 + // Total: 110 + // Current myShare: 0 + + // Target state: + // Mine: 165 + // Others: 110 + // Total: 275 + // Target myShare: 0.6 + + assertEquals(165, BurningManPresentationService.getMissingAmountToReachTargetShare(total, myAmount, myTargetShare)); + } +} diff --git a/core/src/test/java/bisq/core/dao/burningman/BurningManServiceTest.java b/core/src/test/java/bisq/core/dao/burningman/BurningManServiceTest.java new file mode 100644 index 0000000000..09c68527f4 --- /dev/null +++ b/core/src/test/java/bisq/core/dao/burningman/BurningManServiceTest.java @@ -0,0 +1,52 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman; + + +import org.mockito.junit.MockitoJUnitRunner; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertEquals; + +@RunWith(MockitoJUnitRunner.class) +public class BurningManServiceTest { + @Test + public void testGetDecayedAmount() { + long amount = 100; + int currentBlockHeight = 1400; + int fromBlockHeight = 1000; + assertEquals(0, BurningManService.getDecayedAmount(amount, 1000, currentBlockHeight, fromBlockHeight)); + assertEquals(25, BurningManService.getDecayedAmount(amount, 1100, currentBlockHeight, fromBlockHeight)); + assertEquals(50, BurningManService.getDecayedAmount(amount, 1200, currentBlockHeight, fromBlockHeight)); + assertEquals(75, BurningManService.getDecayedAmount(amount, 1300, currentBlockHeight, fromBlockHeight)); + + // cycles with 100 blocks, issuance at block 20, look-back period 3 cycles + assertEquals(40, BurningManService.getDecayedAmount(amount, 120, 300, 0)); + assertEquals(33, BurningManService.getDecayedAmount(amount, 120, 320, 20)); + assertEquals(27, BurningManService.getDecayedAmount(amount, 120, 340, 40)); + assertEquals(20, BurningManService.getDecayedAmount(amount, 120, 360, 60)); + assertEquals(13, BurningManService.getDecayedAmount(amount, 120, 380, 80)); + assertEquals(7, BurningManService.getDecayedAmount(amount, 120, 399, 99)); + assertEquals(7, BurningManService.getDecayedAmount(amount, 120, 400, 100)); + assertEquals(3, BurningManService.getDecayedAmount(amount, 120, 410, 110)); + assertEquals(40, BurningManService.getDecayedAmount(amount, 220, 400, 100)); + + } +} diff --git a/core/src/test/java/bisq/core/dao/burningman/DelayedPayoutTxReceiverServiceTest.java b/core/src/test/java/bisq/core/dao/burningman/DelayedPayoutTxReceiverServiceTest.java new file mode 100644 index 0000000000..669d9c92b8 --- /dev/null +++ b/core/src/test/java/bisq/core/dao/burningman/DelayedPayoutTxReceiverServiceTest.java @@ -0,0 +1,60 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.dao.burningman; + + +import org.mockito.junit.MockitoJUnitRunner; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertEquals; + +@RunWith(MockitoJUnitRunner.class) +public class DelayedPayoutTxReceiverServiceTest { + @Test + public void testGetSnapshotHeight() { + // up to genesis + 3* grid we use genesis + assertEquals(102, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 0, 10)); + assertEquals(102, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 100, 10)); + assertEquals(102, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 102, 10)); + assertEquals(102, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 119, 10)); + assertEquals(102, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 120, 10)); + assertEquals(102, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 121, 10)); + assertEquals(102, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 130, 10)); + assertEquals(102, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 131, 10)); + assertEquals(102, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 132, 10)); + + assertEquals(120, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 133, 10)); + assertEquals(120, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 134, 10)); + + assertEquals(130, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 135, 10)); + assertEquals(130, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 136, 10)); + assertEquals(130, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 139, 10)); + assertEquals(130, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 140, 10)); + assertEquals(130, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 141, 10)); + + assertEquals(140, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 149, 10)); + assertEquals(140, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 150, 10)); + assertEquals(140, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 151, 10)); + + assertEquals(150, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 159, 10)); + + assertEquals(990, DelayedPayoutTxReceiverService.getSnapshotHeight(102, 1000, 10)); + } +} diff --git a/core/src/test/java/bisq/core/offer/OpenOfferManagerTest.java b/core/src/test/java/bisq/core/offer/OpenOfferManagerTest.java index d961f6c256..f662ef8aa9 100644 --- a/core/src/test/java/bisq/core/offer/OpenOfferManagerTest.java +++ b/core/src/test/java/bisq/core/offer/OpenOfferManagerTest.java @@ -67,6 +67,8 @@ public class OpenOfferManagerTest { null, null, null, + null, + null, persistenceManager ); @@ -114,6 +116,8 @@ public class OpenOfferManagerTest { null, null, null, + null, + null, persistenceManager ); @@ -153,6 +157,8 @@ public class OpenOfferManagerTest { null, null, null, + null, + null, persistenceManager ); diff --git a/core/src/test/java/bisq/core/provider/mempool/TxValidatorTest.java b/core/src/test/java/bisq/core/provider/mempool/TxValidatorTest.java index 793941e35a..412e282478 100644 --- a/core/src/test/java/bisq/core/provider/mempool/TxValidatorTest.java +++ b/core/src/test/java/bisq/core/provider/mempool/TxValidatorTest.java @@ -22,7 +22,6 @@ import bisq.core.dao.state.DaoStateService; import bisq.core.filter.Filter; import bisq.core.filter.FilterManager; import bisq.core.trade.DelayedPayoutAddressProvider; -import bisq.core.util.FeeReceiverSelector; import bisq.core.util.ParsingUtils; import bisq.core.util.coin.BsqFormatter; @@ -65,7 +64,7 @@ public class TxValidatorTest { btcFeeReceivers.add("13sxMq8mTw7CTSqgGiMPfwo6ZDsVYrHLmR"); btcFeeReceivers.add("19qA2BVPoyXDfHKVMovKG7SoxGY7xrBV8c"); btcFeeReceivers.add("19BNi5EpZhgBBWAt5ka7xWpJpX2ZWJEYyq"); - btcFeeReceivers.add(FeeReceiverSelector.BTC_FEE_RECEIVER_ADDRESS); + btcFeeReceivers.add("38bZBj5peYS3Husdz7AH3gEUiUbYRD951t"); btcFeeReceivers.add(DelayedPayoutAddressProvider.BM2019_ADDRESS); btcFeeReceivers.add("1BVxNn3T12veSK6DgqwU4Hdn7QHcDDRag7"); btcFeeReceivers.add("3A8Zc1XioE2HRzYfbb5P8iemCS72M6vRJV"); diff --git a/core/src/test/java/bisq/core/util/FeeReceiverSelectorTest.java b/core/src/test/java/bisq/core/util/FeeReceiverSelectorTest.java deleted file mode 100644 index 8ddf70cb15..0000000000 --- a/core/src/test/java/bisq/core/util/FeeReceiverSelectorTest.java +++ /dev/null @@ -1,142 +0,0 @@ -/* - * This file is part of Bisq. - * - * Bisq is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or (at - * your option) any later version. - * - * Bisq is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public - * License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Bisq. If not, see . - */ - -package bisq.core.util; - -import bisq.core.filter.Filter; -import bisq.core.filter.FilterManager; - -import com.google.common.collect.Lists; -import com.google.common.primitives.Longs; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Random; - -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.assertEquals; -import static org.mockito.Mockito.when; - -@RunWith(MockitoJUnitRunner.class) -public class FeeReceiverSelectorTest { - @Mock - private FilterManager filterManager; - - @Test - public void testGetAddress() { - Random rnd = new Random(123); - when(filterManager.getFilter()).thenReturn(filterWithReceivers( - List.of("", "foo#0.001", "ill-formed", "bar#0.002", "baz#0.001", "partial#bad"))); - - Map selectionCounts = new HashMap<>(); - for (int i = 0; i < 400; i++) { - String address = FeeReceiverSelector.getAddress(filterManager, rnd); - selectionCounts.compute(address, (k, n) -> n != null ? n + 1 : 1); - } - - assertEquals(3, selectionCounts.size()); - - // Check within 2 std. of the expected values (95% confidence each): - assertEquals(100.0, selectionCounts.get("foo"), 18); - assertEquals(200.0, selectionCounts.get("bar"), 20); - assertEquals(100.0, selectionCounts.get("baz"), 18); - } - - @Test - public void testGetAddress_noValidReceivers_nullFilter() { - when(filterManager.getFilter()).thenReturn(null); - assertEquals(FeeReceiverSelector.getMostRecentAddress(), FeeReceiverSelector.getAddress(filterManager)); - } - - @Test - public void testGetAddress_noValidReceivers_filterWithNullList() { - when(filterManager.getFilter()).thenReturn(filterWithReceivers(null)); - assertEquals(FeeReceiverSelector.getMostRecentAddress(), FeeReceiverSelector.getAddress(filterManager)); - } - - @Test - public void testGetAddress_noValidReceivers_filterWithEmptyList() { - when(filterManager.getFilter()).thenReturn(filterWithReceivers(List.of())); - assertEquals(FeeReceiverSelector.getMostRecentAddress(), FeeReceiverSelector.getAddress(filterManager)); - } - - @Test - public void testGetAddress_noValidReceivers_filterWithIllFormedList() { - when(filterManager.getFilter()).thenReturn(filterWithReceivers(List.of("ill-formed"))); - assertEquals(FeeReceiverSelector.getMostRecentAddress(), FeeReceiverSelector.getAddress(filterManager)); - } - - @Test - public void testWeightedSelection() { - Random rnd = new Random(456); - - int[] selections = new int[3]; - for (int i = 0; i < 6000; i++) { - selections[FeeReceiverSelector.weightedSelection(Longs.asList(1, 2, 3), rnd)]++; - } - - // Check within 2 std. of the expected values (95% confidence each): - assertEquals(1000.0, selections[0], 58); - assertEquals(2000.0, selections[1], 74); - assertEquals(3000.0, selections[2], 78); - } - - private static Filter filterWithReceivers(List btcFeeReceiverAddresses) { - return new Filter(Lists.newArrayList(), - Lists.newArrayList(), - Lists.newArrayList(), - Lists.newArrayList(), - Lists.newArrayList(), - Lists.newArrayList(), - Lists.newArrayList(), - Lists.newArrayList(), - false, - Lists.newArrayList(), - false, - null, - null, - Lists.newArrayList(), - Lists.newArrayList(), - Lists.newArrayList(), - btcFeeReceiverAddresses, - null, - 0, - null, - null, - null, - null, - false, - Lists.newArrayList(), - new HashSet<>(), - false, - false, - false, - 0, - Lists.newArrayList(), - 0, - 0, - 0, - 0); - } -} diff --git a/desktop/src/main/java/bisq/desktop/main/dao/bonding/bonds/BondsView.java b/desktop/src/main/java/bisq/desktop/main/dao/bonding/bonds/BondsView.java index 704a14f8df..e78e6cc37f 100644 --- a/desktop/src/main/java/bisq/desktop/main/dao/bonding/bonds/BondsView.java +++ b/desktop/src/main/java/bisq/desktop/main/dao/bonding/bonds/BondsView.java @@ -93,7 +93,8 @@ public class BondsView extends ActivatableView { @Override public void initialize() { - tableView = FormBuilder.addTableViewWithHeader(root, ++gridRow, Res.get("dao.bond.allBonds.header"), "last"); + tableView = FormBuilder.addTableViewWithHeader(root, ++gridRow, + Res.get("dao.bond.allBonds.header"), "last").first; tableView.setItems(sortedList); GridPane.setVgrow(tableView, Priority.ALWAYS); addColumns(); diff --git a/desktop/src/main/java/bisq/desktop/main/dao/bonding/reputation/MyReputationView.java b/desktop/src/main/java/bisq/desktop/main/dao/bonding/reputation/MyReputationView.java index 5510e195e8..964c436be3 100644 --- a/desktop/src/main/java/bisq/desktop/main/dao/bonding/reputation/MyReputationView.java +++ b/desktop/src/main/java/bisq/desktop/main/dao/bonding/reputation/MyReputationView.java @@ -141,7 +141,8 @@ public class MyReputationView extends ActivatableView implements lockupButton = addButtonAfterGroup(root, ++gridRow, Res.get("dao.bond.reputation.lockupButton")); - tableView = FormBuilder.addTableViewWithHeader(root, ++gridRow, Res.get("dao.bond.reputation.table.header"), 20, "last"); + tableView = FormBuilder.addTableViewWithHeader(root, ++gridRow, + Res.get("dao.bond.reputation.table.header"), 20, "last").first; createColumns(); tableView.setItems(sortedList); GridPane.setVgrow(tableView, Priority.ALWAYS); diff --git a/desktop/src/main/java/bisq/desktop/main/dao/bonding/roles/RolesView.java b/desktop/src/main/java/bisq/desktop/main/dao/bonding/roles/RolesView.java index a06d8c8c54..64209846e2 100644 --- a/desktop/src/main/java/bisq/desktop/main/dao/bonding/roles/RolesView.java +++ b/desktop/src/main/java/bisq/desktop/main/dao/bonding/roles/RolesView.java @@ -23,9 +23,9 @@ import bisq.desktop.components.AutoTooltipButton; import bisq.desktop.components.AutoTooltipTableColumn; import bisq.desktop.components.ExternalHyperlink; import bisq.desktop.components.HyperlinkWithIcon; -import bisq.desktop.main.dao.bonding.BondingViewUtils; import bisq.desktop.main.dao.MessageSignatureWindow; import bisq.desktop.main.dao.MessageVerificationWindow; +import bisq.desktop.main.dao.bonding.BondingViewUtils; import bisq.desktop.util.FormBuilder; import bisq.desktop.util.GUIUtil; @@ -92,7 +92,8 @@ public class RolesView extends ActivatableView { @Override public void initialize() { int gridRow = 0; - tableView = FormBuilder.addTableViewWithHeader(root, gridRow, Res.get("dao.bond.bondedRoles"), "last"); + tableView = FormBuilder.addTableViewWithHeader(root, gridRow, + Res.get("dao.bond.bondedRoles"), "last").first; createColumns(); tableView.setItems(sortedList); GridPane.setVgrow(tableView, Priority.ALWAYS); @@ -294,6 +295,7 @@ public class RolesView extends ActivatableView { public TableCell call(TableColumn column) { return new TableCell<>() { HBox hbox; + @Override public void updateItem(final RolesListItem item, boolean empty) { super.updateItem(item, empty); @@ -315,13 +317,15 @@ public class RolesView extends ActivatableView { if (item.isLockupButtonVisible()) { AutoTooltipButton buttonLockup = new AutoTooltipButton(Res.get("dao.bond.table.button.lockup")); buttonLockup.setMinWidth(70); - buttonLockup.setOnAction(e -> bondingViewUtils.lockupBondForBondedRole(item.getRole(), txId -> {})); + buttonLockup.setOnAction(e -> bondingViewUtils.lockupBondForBondedRole(item.getRole(), txId -> { + })); hbox.getChildren().add(buttonLockup); } if (item.isRevokeButtonVisible()) { AutoTooltipButton buttonRevoke = new AutoTooltipButton(Res.get("dao.bond.table.button.revoke")); buttonRevoke.setMinWidth(70); - buttonRevoke.setOnAction(e -> bondingViewUtils.unLock(item.getLockupTxId(), txId -> {})); + buttonRevoke.setOnAction(e -> bondingViewUtils.unLock(item.getLockupTxId(), txId -> { + })); hbox.getChildren().add(buttonRevoke); } hbox.setMinWidth(hbox.getChildren().size() * 70); diff --git a/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/BurnBsqView.java b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/BurnBsqView.java index 882da4aedf..4204c763eb 100644 --- a/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/BurnBsqView.java +++ b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/BurnBsqView.java @@ -28,6 +28,7 @@ import bisq.desktop.components.MenuItem; import bisq.desktop.main.MainView; import bisq.desktop.main.dao.DaoView; import bisq.desktop.main.dao.burnbsq.assetfee.AssetFeeView; +import bisq.desktop.main.dao.burnbsq.burningman.BurningManView; import bisq.desktop.main.dao.burnbsq.proofofburn.ProofOfBurnView; import bisq.core.locale.Res; @@ -49,7 +50,7 @@ public class BurnBsqView extends ActivatableView { private final ViewLoader viewLoader; private final Navigation navigation; - private MenuItem assetFee, proofOfBurn; + private MenuItem proofOfBurn, burningMan, assetFee; private Navigation.Listener listener; @FXML @@ -77,26 +78,28 @@ public class BurnBsqView extends ActivatableView { }; toggleGroup = new ToggleGroup(); - final List> baseNavPath = Arrays.asList(MainView.class, DaoView.class, BurnBsqView.class); - assetFee = new MenuItem(navigation, toggleGroup, Res.get("dao.burnBsq.menuItem.assetFee"), - AssetFeeView.class, baseNavPath); + List> baseNavPath = Arrays.asList(MainView.class, DaoView.class, BurnBsqView.class); proofOfBurn = new MenuItem(navigation, toggleGroup, Res.get("dao.burnBsq.menuItem.proofOfBurn"), ProofOfBurnView.class, baseNavPath); - - leftVBox.getChildren().addAll(assetFee, proofOfBurn); + burningMan = new MenuItem(navigation, toggleGroup, Res.get("dao.burnBsq.menuItem.burningMan"), + BurningManView.class, baseNavPath); + assetFee = new MenuItem(navigation, toggleGroup, Res.get("dao.burnBsq.menuItem.assetFee"), + AssetFeeView.class, baseNavPath); + leftVBox.getChildren().addAll(burningMan, proofOfBurn, assetFee); } @Override protected void activate() { - assetFee.activate(); proofOfBurn.activate(); + burningMan.activate(); + assetFee.activate(); navigation.addListener(listener); ViewPath viewPath = navigation.getCurrentPath(); if (viewPath.size() == 3 && viewPath.indexOf(BurnBsqView.class) == 2 || viewPath.size() == 2 && viewPath.indexOf(DaoView.class) == 1) { if (selectedViewClass == null) - selectedViewClass = AssetFeeView.class; + selectedViewClass = BurningManView.class; loadView(selectedViewClass); @@ -111,15 +114,17 @@ public class BurnBsqView extends ActivatableView { protected void deactivate() { navigation.removeListener(listener); - assetFee.deactivate(); proofOfBurn.deactivate(); + burningMan.deactivate(); + assetFee.deactivate(); } private void loadView(Class viewClass) { View view = viewLoader.load(viewClass); content.getChildren().setAll(view.getRoot()); - if (view instanceof AssetFeeView) toggleGroup.selectToggle(assetFee); - else if (view instanceof ProofOfBurnView) toggleGroup.selectToggle(proofOfBurn); + if (view instanceof ProofOfBurnView) toggleGroup.selectToggle(proofOfBurn); + else if (view instanceof BurningManView) toggleGroup.selectToggle(burningMan); + else if (view instanceof AssetFeeView) toggleGroup.selectToggle(assetFee); } } diff --git a/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/assetfee/AssetFeeView.java b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/assetfee/AssetFeeView.java index e0c4ec30be..dac67dfb0a 100644 --- a/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/assetfee/AssetFeeView.java +++ b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/assetfee/AssetFeeView.java @@ -151,7 +151,8 @@ public class AssetFeeView extends ActivatableView implements Bsq payFeeButton = addButtonAfterGroup(root, ++gridRow, Res.get("dao.burnBsq.payFee")); - tableView = FormBuilder.addTableViewWithHeader(root, ++gridRow, Res.get("dao.burnBsq.allAssets"), 20, "last"); + tableView = FormBuilder.addTableViewWithHeader(root, ++gridRow, + Res.get("dao.burnBsq.allAssets"), 20, "last").first; createColumns(); tableView.setItems(sortedList); diff --git a/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/BalanceEntryItem.java b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/BalanceEntryItem.java new file mode 100644 index 0000000000..84b0eba5eb --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/BalanceEntryItem.java @@ -0,0 +1,219 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.desktop.main.dao.burnbsq.burningman; + +import bisq.desktop.util.DisplayUtils; + +import bisq.core.dao.burningman.accounting.balance.BalanceEntry; +import bisq.core.dao.burningman.accounting.balance.BaseBalanceEntry; +import bisq.core.dao.burningman.accounting.balance.BurnedBsqBalanceEntry; +import bisq.core.dao.burningman.accounting.balance.MonthlyBalanceEntry; +import bisq.core.locale.Res; +import bisq.core.monetary.Price; +import bisq.core.util.coin.BsqFormatter; +import bisq.core.util.coin.CoinFormatter; + +import bisq.common.util.MathUtils; + +import org.bitcoinj.core.Coin; + +import com.google.common.base.Joiner; + +import java.text.SimpleDateFormat; + +import java.util.ArrayList; +import java.util.Date; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j + +@EqualsAndHashCode +class BalanceEntryItem { + private final BalanceEntry balanceEntry; + private final BsqFormatter bsqFormatter; + private final CoinFormatter btcFormatter; + @Getter + private final Date date; + @Getter + private final Date month; + @Getter + private final Optional price; + @Getter + private final Optional type; + @Getter + private final Optional receivedBtc; + @Getter + private final Optional receivedBtcAsBsq; + @Getter + private final Optional burnedBsq; + @Getter + private final Optional revenue; + + // We create the strings on demand and cache them. For large data sets it would be a bit slow otherwise. + private String monthAsString, dateAsString, receivedBtcAsString, receivedBtcAsBsqAsString, burnedBsqAsString, revenueAsString, + priceAsString, typeAsString; + + BalanceEntryItem(BalanceEntry balanceEntry, + Map averageBsqPriceByMonth, + BsqFormatter bsqFormatter, + CoinFormatter btcFormatter) { + this.balanceEntry = balanceEntry; + this.bsqFormatter = bsqFormatter; + this.btcFormatter = btcFormatter; + + date = balanceEntry.getDate(); + month = balanceEntry.getMonth(); + price = Optional.ofNullable(averageBsqPriceByMonth.get(month)); + if (balanceEntry instanceof MonthlyBalanceEntry) { + MonthlyBalanceEntry monthlyBalanceEntry = (MonthlyBalanceEntry) balanceEntry; + receivedBtc = Optional.of(monthlyBalanceEntry.getReceivedBtc()); + burnedBsq = Optional.of(-monthlyBalanceEntry.getBurnedBsq()); + type = monthlyBalanceEntry.getTypes().size() == 1 ? + Optional.of(new ArrayList<>(monthlyBalanceEntry.getTypes()).get(0)) : + Optional.empty(); + } else if (balanceEntry instanceof BurnedBsqBalanceEntry) { + BurnedBsqBalanceEntry burnedBsqBalanceEntry = (BurnedBsqBalanceEntry) balanceEntry; + receivedBtc = Optional.empty(); + burnedBsq = Optional.of(-burnedBsqBalanceEntry.getAmount()); + type = Optional.of(burnedBsqBalanceEntry.getType()); + } else { + BaseBalanceEntry baseBalanceEntry = (BaseBalanceEntry) balanceEntry; + receivedBtc = Optional.of(baseBalanceEntry.getAmount()); + burnedBsq = Optional.empty(); + type = Optional.of(baseBalanceEntry.getType()); + } + + if (price.isEmpty() || receivedBtc.isEmpty()) { + receivedBtcAsBsq = Optional.empty(); + } else { + long volume = price.get().getVolumeByAmount(Coin.valueOf(receivedBtc.get())).getValue(); + receivedBtcAsBsq = Optional.of(MathUtils.roundDoubleToLong(MathUtils.scaleDownByPowerOf10(volume, 6))); + } + + if (balanceEntry instanceof MonthlyBalanceEntry) { + revenue = Optional.of(receivedBtcAsBsq.orElse(0L) + burnedBsq.get()); + } else { + revenue = Optional.empty(); + } + } + + String getMonthAsString() { + if (monthAsString != null) { + return monthAsString; + } + + monthAsString = new SimpleDateFormat("MMM-yyyy").format(month); + return monthAsString; + } + + String getDateAsString() { + if (dateAsString != null) { + return dateAsString; + } + + dateAsString = DisplayUtils.formatDateTime(date); + return dateAsString; + } + + String getReceivedBtcAsString() { + if (receivedBtcAsString != null) { + return receivedBtcAsString; + } + + receivedBtcAsString = receivedBtc.filter(e -> e != 0).map(btcFormatter::formatCoin).orElse(""); + return receivedBtcAsString; + } + + String getReceivedBtcAsBsqAsString() { + if (receivedBtcAsBsqAsString != null) { + return receivedBtcAsBsqAsString; + } + + receivedBtcAsBsqAsString = receivedBtcAsBsq.filter(e -> e != 0).map(bsqFormatter::formatCoin).orElse(""); + return receivedBtcAsBsqAsString; + } + + String getBurnedBsqAsString() { + if (burnedBsqAsString != null) { + return burnedBsqAsString; + } + + burnedBsqAsString = burnedBsq.filter(e -> e != 0).map(bsqFormatter::formatCoin).orElse(""); + return burnedBsqAsString; + } + + String getRevenueAsString() { + if (revenueAsString != null) { + return revenueAsString; + } + + revenueAsString = revenue.filter(e -> e != 0).map(bsqFormatter::formatCoin).orElse(""); + return revenueAsString; + } + + String getPriceAsString() { + if (priceAsString != null) { + return priceAsString; + } + + priceAsString = price.map(Price::toString).orElse(""); + return priceAsString; + } + + String getTypeAsString() { + if (typeAsString != null) { + return typeAsString; + } + + if (balanceEntry instanceof MonthlyBalanceEntry) { + MonthlyBalanceEntry monthlyBalanceEntry = (MonthlyBalanceEntry) balanceEntry; + typeAsString = type.map(type -> Res.get("dao.burningman.balanceEntry.type." + type.name())) + .orElse(Joiner.on(", ") + .join(monthlyBalanceEntry.getTypes().stream() + .map(type -> Res.get("dao.burningman.balanceEntry.type." + type.name())) + .sorted() + .collect(Collectors.toList()))); + } else { + typeAsString = type.map(type -> Res.get("dao.burningman.balanceEntry.type." + type.name())).orElse(""); + } + return typeAsString; + } + + // Dummy for CSV export + @SuppressWarnings("OptionalAssignedToNull") + BalanceEntryItem() { + balanceEntry = null; + bsqFormatter = null; + btcFormatter = null; + + date = null; + type = null; + price = null; + month = null; + receivedBtc = null; + receivedBtcAsBsq = null; + burnedBsq = null; + revenue = null; + } +} diff --git a/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/BurnOutputListItem.java b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/BurnOutputListItem.java new file mode 100644 index 0000000000..7dc1aa3c91 --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/BurnOutputListItem.java @@ -0,0 +1,47 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.desktop.main.dao.burnbsq.burningman; + +import bisq.desktop.util.DisplayUtils; + +import bisq.core.dao.burningman.model.BurnOutputModel; +import bisq.core.util.coin.BsqFormatter; + +import java.util.Date; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +class BurnOutputListItem { + private final String dateAsString, amountAsString, decayedAmountAsString; + private final long date, amount, decayedAmount; + private final int cycleIndex, height; + + BurnOutputListItem(BurnOutputModel model, BsqFormatter bsqFormatter, boolean isLegacyBurningMan) { + height = model.getHeight(); + cycleIndex = model.getCycleIndex(); + date = model.getDate(); + dateAsString = DisplayUtils.formatDateTime(new Date(date)); + amount = model.getAmount(); + amountAsString = bsqFormatter.formatCoinWithCode(amount); + decayedAmount = isLegacyBurningMan ? 0 : model.getDecayedAmount(); + decayedAmountAsString = isLegacyBurningMan ? "" : bsqFormatter.formatCoinWithCode(decayedAmount); + } +} diff --git a/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/BurningManListItem.java b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/BurningManListItem.java new file mode 100644 index 0000000000..aad6dd6d5c --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/BurningManListItem.java @@ -0,0 +1,121 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.desktop.main.dao.burnbsq.burningman; + +import bisq.core.dao.burningman.BurningManPresentationService; +import bisq.core.dao.burningman.model.BurningManCandidate; +import bisq.core.dao.burningman.model.LegacyBurningMan; +import bisq.core.locale.Res; +import bisq.core.util.FormattingUtils; +import bisq.core.util.coin.BsqFormatter; + +import bisq.common.util.Tuple2; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +class BurningManListItem { + private final BurningManCandidate burningManCandidate; + private final String name, address, cappedBurnAmountShareAsString, compensationShareAsString, + accumulatedDecayedBurnAmountAsBsq, burnTargetAsBsq, accumulatedBurnAmountAsBsq, + accumulatedDecayedCompensationAmountAsBsq, accumulatedCompensationAmountAsBsq, expectedRevenueAsBsq, numIssuancesAsString; + private final long burnTarget, maxBurnTarget, accumulatedDecayedBurnAmount, accumulatedBurnAmount, + accumulatedDecayedCompensationAmount, accumulatedCompensationAmount, expectedRevenue; + private final int numBurnOutputs, numIssuances; + private final double cappedBurnAmountShare, burnAmountShare, compensationShare; + + BurningManListItem(BurningManPresentationService burningManPresentationService, + String name, + BurningManCandidate burningManCandidate, + BsqFormatter bsqFormatter) { + this.burningManCandidate = burningManCandidate; + + this.name = name; + address = burningManCandidate.getMostRecentAddress().orElse(Res.get("shared.na")); + + if (burningManCandidate instanceof LegacyBurningMan) { + // Burn + burnTarget = 0; + burnTargetAsBsq = ""; + maxBurnTarget = 0; + accumulatedBurnAmount = burningManCandidate.getAccumulatedBurnAmount(); + accumulatedBurnAmountAsBsq = bsqFormatter.formatCoinWithCode(accumulatedBurnAmount); + accumulatedDecayedBurnAmount = 0; + accumulatedDecayedBurnAmountAsBsq = ""; + + burnAmountShare = burningManCandidate.getBurnAmountShare(); + + cappedBurnAmountShare = burningManCandidate.getCappedBurnAmountShare(); + + // LegacyBurningManForDPT is the one defined by DAO voting, so only that would receive BTC if new BM do not cover 100%. + if (burningManPresentationService.getLegacyBurningManForDPT().equals(burningManCandidate)) { + expectedRevenue = burningManPresentationService.getExpectedRevenue(burningManCandidate); + cappedBurnAmountShareAsString = FormattingUtils.formatToPercentWithSymbol(burnAmountShare); + } else { + expectedRevenue = 0; + cappedBurnAmountShareAsString = FormattingUtils.formatToPercentWithSymbol(0); + } + expectedRevenueAsBsq = bsqFormatter.formatCoinWithCode(expectedRevenue); + numBurnOutputs = burningManCandidate.getBurnOutputModels().size(); + + // There is no issuance for legacy BM + accumulatedCompensationAmount = 0; + accumulatedCompensationAmountAsBsq = ""; + accumulatedDecayedCompensationAmount = 0; + accumulatedDecayedCompensationAmountAsBsq = ""; + compensationShare = 0; + compensationShareAsString = ""; + numIssuances = 0; + numIssuancesAsString = ""; + } else { + // Burn + Tuple2 burnTargetTuple = burningManPresentationService.getCandidateBurnTarget(burningManCandidate); + burnTarget = burnTargetTuple.first; + maxBurnTarget = burnTargetTuple.second; + burnTargetAsBsq = Res.get("dao.burningman.burnTarget.fromTo", bsqFormatter.formatCoin(burnTarget), bsqFormatter.formatCoin(maxBurnTarget)); + accumulatedBurnAmount = burningManCandidate.getAccumulatedBurnAmount(); + accumulatedBurnAmountAsBsq = bsqFormatter.formatCoinWithCode(accumulatedBurnAmount); + accumulatedDecayedBurnAmount = burningManCandidate.getAccumulatedDecayedBurnAmount(); + accumulatedDecayedBurnAmountAsBsq = bsqFormatter.formatCoinWithCode(accumulatedDecayedBurnAmount); + burnAmountShare = burningManCandidate.getBurnAmountShare(); + cappedBurnAmountShare = burningManCandidate.getCappedBurnAmountShare(); + if (burnAmountShare != cappedBurnAmountShare) { + cappedBurnAmountShareAsString = Res.get("dao.burningman.table.burnAmountShare.capped", + FormattingUtils.formatToPercentWithSymbol(cappedBurnAmountShare), + FormattingUtils.formatToPercentWithSymbol(burnAmountShare)); + } else { + cappedBurnAmountShareAsString = FormattingUtils.formatToPercentWithSymbol(cappedBurnAmountShare); + } + expectedRevenue = burningManPresentationService.getExpectedRevenue(burningManCandidate); + expectedRevenueAsBsq = bsqFormatter.formatCoinWithCode(expectedRevenue); + numBurnOutputs = burningManCandidate.getBurnOutputModels().size(); + + // Issuance + accumulatedCompensationAmount = burningManCandidate.getAccumulatedCompensationAmount(); + accumulatedCompensationAmountAsBsq = bsqFormatter.formatCoinWithCode(accumulatedCompensationAmount); + accumulatedDecayedCompensationAmount = burningManCandidate.getAccumulatedDecayedCompensationAmount(); + accumulatedDecayedCompensationAmountAsBsq = bsqFormatter.formatCoinWithCode(accumulatedDecayedCompensationAmount); + compensationShare = burningManCandidate.getCompensationShare(); + compensationShareAsString = FormattingUtils.formatToPercentWithSymbol(compensationShare); + numIssuances = burningManCandidate.getCompensationModels().size(); + numIssuancesAsString = String.valueOf(numIssuances); + } + } +} diff --git a/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/BurningManView.fxml b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/BurningManView.fxml new file mode 100644 index 0000000000..f15441148b --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/BurningManView.fxml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/BurningManView.java b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/BurningManView.java new file mode 100644 index 0000000000..d0752e3abd --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/BurningManView.java @@ -0,0 +1,1540 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.desktop.main.dao.burnbsq.burningman; + +import bisq.desktop.common.view.ActivatableView; +import bisq.desktop.common.view.FxmlView; +import bisq.desktop.components.AutoTooltipLabel; +import bisq.desktop.components.AutoTooltipRadioButton; +import bisq.desktop.components.AutoTooltipSlideToggleButton; +import bisq.desktop.components.AutoTooltipTableColumn; +import bisq.desktop.components.InputTextField; +import bisq.desktop.components.TitledGroupBg; +import bisq.desktop.main.overlays.popups.Popup; +import bisq.desktop.util.FormBuilder; +import bisq.desktop.util.GUIUtil; +import bisq.desktop.util.Layout; +import bisq.desktop.util.validation.BsqValidator; + +import bisq.core.btc.listeners.BsqBalanceListener; +import bisq.core.btc.wallet.BsqWalletService; +import bisq.core.dao.DaoFacade; +import bisq.core.dao.burningman.BurningManPresentationService; +import bisq.core.dao.burningman.accounting.BurningManAccountingService; +import bisq.core.dao.burningman.accounting.balance.BalanceEntry; +import bisq.core.dao.burningman.accounting.balance.BalanceModel; +import bisq.core.dao.burningman.accounting.balance.BaseBalanceEntry; +import bisq.core.dao.burningman.accounting.balance.BurnedBsqBalanceEntry; +import bisq.core.dao.burningman.accounting.balance.ReceivedBtcBalanceEntry; +import bisq.core.dao.burningman.model.BurningManCandidate; +import bisq.core.dao.burningman.model.LegacyBurningMan; +import bisq.core.dao.governance.proofofburn.ProofOfBurnService; +import bisq.core.dao.governance.proposal.TxException; +import bisq.core.dao.state.DaoStateListener; +import bisq.core.dao.state.model.blockchain.Block; +import bisq.core.locale.Res; +import bisq.core.monetary.Price; +import bisq.core.util.FormattingUtils; +import bisq.core.util.ParsingUtils; +import bisq.core.util.coin.BsqFormatter; +import bisq.core.util.coin.CoinFormatter; + +import bisq.common.app.DevEnv; +import bisq.common.util.Tuple2; +import bisq.common.util.Tuple3; + +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.InsufficientMoneyException; +import org.bitcoinj.core.Transaction; + +import com.googlecode.jcsv.writer.CSVEntryConverter; + +import javax.inject.Inject; +import javax.inject.Named; + +import javafx.stage.Stage; + +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.RadioButton; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.control.Toggle; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; + +import javafx.geometry.HPos; +import javafx.geometry.Insets; + +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.value.ChangeListener; + +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.collections.transformation.FilteredList; +import javafx.collections.transformation.SortedList; + +import javafx.util.Callback; +import javafx.util.StringConverter; + +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.annotation.Nullable; + +import static bisq.desktop.util.FormBuilder.*; + +@FxmlView +public class BurningManView extends ActivatableView implements DaoStateListener, BsqBalanceListener { + private final DaoFacade daoFacade; + private final BurningManPresentationService burningManPresentationService; + private final BurningManAccountingService burningManAccountingService; + private final ProofOfBurnService proofOfBurnService; + private final BsqWalletService bsqWalletService; + private final BsqFormatter bsqFormatter; + private final CoinFormatter btcFormatter; + private final BsqValidator bsqValidator; + + private InputTextField amountInputTextField, burningmenFilterField; + private ComboBox contributorComboBox; + private Button burnButton, exportBalanceEntriesButton; + private TitledGroupBg burnOutputsTitledGroupBg, compensationsTitledGroupBg, selectedContributorTitledGroupBg; + private AutoTooltipSlideToggleButton showOnlyActiveBurningmenToggle, showMonthlyBalanceEntryToggle; + private TextField expectedRevenueField, selectedContributorNameField, selectedContributorAddressField, burnTargetField; + private ToggleGroup balanceEntryToggleGroup; + private HBox balanceEntryHBox; + private VBox selectedContributorNameBox, selectedContributorAddressBox; + private TableView burningManTableView; + private TableView balanceEntryTableView; + private TableView burnOutputsTableView; + private TableView compensationsTableView; + private TableView reimbursementsTableView; + + + private final ObservableList burningManObservableList = FXCollections.observableArrayList(); + private final FilteredList burningManFilteredList = new FilteredList<>(burningManObservableList); + private final SortedList burningManSortedList = new SortedList<>(burningManFilteredList); + private final ObservableList burnOutputsObservableList = FXCollections.observableArrayList(); + private final SortedList burnOutputsSortedList = new SortedList<>(burnOutputsObservableList); + private final ObservableList compensationObservableList = FXCollections.observableArrayList(); + private final SortedList compensationSortedList = new SortedList<>(compensationObservableList); + private final ObservableList reimbursementObservableList = FXCollections.observableArrayList(); + private final SortedList reimbursementSortedList = new SortedList<>(reimbursementObservableList); + private final ObservableList balanceEntryObservableList = FXCollections.observableArrayList(); + private final FilteredList balanceEntryFilteredList = new FilteredList<>(balanceEntryObservableList); + private final SortedList balanceEntrySortedList = new SortedList<>(balanceEntryFilteredList); + + private final ChangeListener amountFocusOutListener; + private final ChangeListener amountInputTextFieldListener; + private final ChangeListener burningmenSelectionListener; + private final ChangeListener filterListener; + private final ChangeListener contributorsListener; + private final ChangeListener balanceEntryToggleListener; + + private int gridRow = 0; + private boolean showMonthlyBalanceEntries = true; + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Constructor, lifecycle + /////////////////////////////////////////////////////////////////////////////////////////// + + @Inject + private BurningManView(DaoFacade daoFacade, + BurningManPresentationService burningManPresentationService, + BurningManAccountingService burningManAccountingService, + ProofOfBurnService proofOfBurnService, + BsqWalletService bsqWalletService, + BsqFormatter bsqFormatter, + @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter, + BsqValidator bsqValidator) { + this.daoFacade = daoFacade; + this.burningManPresentationService = burningManPresentationService; + this.burningManAccountingService = burningManAccountingService; + this.proofOfBurnService = proofOfBurnService; + this.bsqWalletService = bsqWalletService; + this.bsqFormatter = bsqFormatter; + this.btcFormatter = btcFormatter; + this.bsqValidator = bsqValidator; + + amountFocusOutListener = (observable, oldValue, newValue) -> { + if (!newValue) { + updateButtonState(); + } + }; + + amountInputTextFieldListener = (observable, oldValue, newValue) -> { + updateButtonState(); + }; + + burningmenSelectionListener = (observable, oldValue, newValue) -> { + boolean isValueSet = newValue != null; + burnOutputsTableView.setVisible(isValueSet); + burnOutputsTableView.setManaged(isValueSet); + burnOutputsTitledGroupBg.setVisible(isValueSet); + burnOutputsTitledGroupBg.setManaged(isValueSet); + balanceEntryTableView.setVisible(isValueSet); + balanceEntryTableView.setManaged(isValueSet); + balanceEntryHBox.setVisible(isValueSet); + balanceEntryHBox.setManaged(isValueSet); + exportBalanceEntriesButton.setVisible(isValueSet); + exportBalanceEntriesButton.setManaged(isValueSet); + + compensationsTableView.setVisible(isValueSet); + compensationsTableView.setManaged(isValueSet); + compensationsTitledGroupBg.setVisible(isValueSet); + compensationsTitledGroupBg.setManaged(isValueSet); + selectedContributorTitledGroupBg.setManaged(isValueSet); + selectedContributorTitledGroupBg.setVisible(isValueSet); + selectedContributorNameBox.setManaged(isValueSet); + selectedContributorNameBox.setVisible(isValueSet); + selectedContributorAddressBox.setManaged(isValueSet); + selectedContributorAddressBox.setVisible(isValueSet); + if (isValueSet) { + onBurningManSelected(newValue); + } else { + selectedContributorNameField.clear(); + selectedContributorAddressField.clear(); + } + }; + + filterListener = (observable, oldValue, newValue) -> updateBurningmenPredicate(); + + contributorsListener = (observable, oldValue, newValue) -> { + if (newValue != null) { + bsqValidator.setMaxValue(Coin.valueOf(newValue.getMaxBurnTarget())); + amountInputTextField.clear(); + amountInputTextField.resetValidation(); + String burnTarget = bsqFormatter.formatCoin(newValue.getBurnTarget()); + String maxBurnTarget = bsqFormatter.formatCoin(newValue.getMaxBurnTarget()); + amountInputTextField.setPromptText(Res.get("dao.burningman.amount.prompt.max", burnTarget, maxBurnTarget)); + updateButtonState(); + } + }; + balanceEntryToggleListener = (observable, oldValue, newValue) -> onTypeChanged(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Constructor, lifecycle + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void initialize() { + GridPane gridPane = new GridPane(); + gridPane.setHgap(5); + gridPane.setVgap(5); + gridPane.setPadding(new Insets(0, 20, 0, 10)); + ColumnConstraints columnConstraints1 = new ColumnConstraints(); + columnConstraints1.setPercentWidth(50); + ColumnConstraints columnConstraints2 = new ColumnConstraints(); + columnConstraints2.setPercentWidth(50); + gridPane.getColumnConstraints().addAll(columnConstraints1, columnConstraints2); + + root.setContent(gridPane); + root.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + root.setFitToWidth(true); + + // Burn target + TitledGroupBg targetTitledGroupBg = addTitledGroupBg(gridPane, gridRow, 2, Res.get("dao.burningman.target.header")); + GridPane.setColumnSpan(targetTitledGroupBg, 2); + burnTargetField = addCompactTopLabelTextField(gridPane, ++gridRow, + Res.get("dao.burningman.burnTarget.label"), "", Layout.FLOATING_LABEL_DISTANCE).second; + Tuple3 currentBlockHeightTuple = addCompactTopLabelTextField(gridPane, gridRow, + Res.get("dao.burningman.expectedRevenue"), "", Layout.FLOATING_LABEL_DISTANCE); + expectedRevenueField = currentBlockHeightTuple.second; + GridPane.setColumnIndex(currentBlockHeightTuple.third, 1); + + // Burn inputs + addTitledGroupBg(gridPane, ++gridRow, 4, Res.get("dao.burningman.burn.header"), Layout.COMPACT_GROUP_DISTANCE); + contributorComboBox = addComboBox(gridPane, gridRow, Res.get("dao.burningman.contributorsComboBox.prompt"), Layout.COMPACT_FIRST_ROW_AND_GROUP_DISTANCE); + contributorComboBox.setMaxWidth(300); + contributorComboBox.setConverter(new StringConverter<>() { + @Override + public String toString(BurningManListItem item) { + return item.getName(); + } + + @Override + public BurningManListItem fromString(String string) { + return null; + } + }); + amountInputTextField = addInputTextField(gridPane, ++gridRow, Res.get("dao.burningman.amount.prompt")); + burnButton = addButtonAfterGroup(gridPane, ++gridRow, Res.get("dao.proofOfBurn.burn")); + + // Burningmen candidates + Tuple3, HBox> burningmenTuple = addTableViewWithHeaderAndFilterField(gridPane, + ++gridRow, + Res.get("dao.burningman.candidates.table.header"), + Res.get("dao.burningman.filter"), + 30); + burningmenFilterField = burningmenTuple.first; + burningManTableView = burningmenTuple.second; + GridPane.setColumnSpan(burningManTableView, 2); + createBurningmenColumns(); + burningManTableView.setItems(burningManSortedList); + burningManTableView.setTableMenuButtonVisible(true); + HBox hBox = burningmenTuple.third; + GridPane.setColumnSpan(hBox, 2); + showOnlyActiveBurningmenToggle = new AutoTooltipSlideToggleButton(); + showOnlyActiveBurningmenToggle.setText(Res.get("dao.burningman.toggle")); + HBox.setMargin(showOnlyActiveBurningmenToggle, new Insets(-21, 0, 0, 0)); + hBox.getChildren().add(2, showOnlyActiveBurningmenToggle); + + // Selected contributor + selectedContributorTitledGroupBg = addTitledGroupBg(gridPane, ++gridRow, 4, + Res.get("dao.burningman.selectedContributor"), Layout.COMPACT_GROUP_DISTANCE); + selectedContributorTitledGroupBg.setManaged(false); + selectedContributorTitledGroupBg.setVisible(false); + Tuple3 nameTuple = addCompactTopLabelTextField(gridPane, ++gridRow, + Res.get("dao.burningman.selectedContributorName"), "", + Layout.COMPACT_GROUP_DISTANCE + Layout.FLOATING_LABEL_DISTANCE); + selectedContributorNameField = nameTuple.second; + selectedContributorNameBox = nameTuple.third; + selectedContributorNameBox.setManaged(false); + selectedContributorNameBox.setVisible(false); + + Tuple3 addressTuple = addCompactTopLabelTextField(gridPane, gridRow, + Res.get("dao.burningman.selectedContributorAddress"), "", + Layout.COMPACT_GROUP_DISTANCE + Layout.FLOATING_LABEL_DISTANCE); + selectedContributorAddressField = addressTuple.second; + selectedContributorAddressBox = addressTuple.third; + GridPane.setColumnSpan(selectedContributorAddressBox, 2); + GridPane.setColumnIndex(selectedContributorAddressBox, 1); + selectedContributorAddressBox.setManaged(false); + selectedContributorAddressBox.setVisible(false); + + // BalanceEntry + TitledGroupBg balanceEntryTitledGroupBg = new TitledGroupBg(); + balanceEntryTitledGroupBg.setText(Res.get("dao.burningman.balanceEntry.table.header")); + + showMonthlyBalanceEntryToggle = new AutoTooltipSlideToggleButton(); + showMonthlyBalanceEntryToggle.setText(Res.get("dao.burningman.balanceEntry.table.showMonthlyToggle")); + HBox.setMargin(showMonthlyBalanceEntryToggle, new Insets(-21, 0, 0, 0)); + showMonthlyBalanceEntryToggle.setSelected(true); + + balanceEntryToggleGroup = new ToggleGroup(); + RadioButton balanceEntryShowAllRadioButton = getRadioButton(Res.get("dao.burningman.balanceEntry.table.radio.all"), null); + RadioButton balanceEntryShowFeeRadioButton = getRadioButton(Res.get("dao.burningman.balanceEntry.table.radio.fee"), BalanceEntry.Type.BTC_TRADE_FEE_TX); + RadioButton balanceEntryShowDptRadioButton = getRadioButton(Res.get("dao.burningman.balanceEntry.table.radio.dpt"), BalanceEntry.Type.DPT_TX); + RadioButton balanceEntryShowBurnRadioButton = getRadioButton(Res.get("dao.burningman.balanceEntry.table.radio.burn"), BalanceEntry.Type.BURN_TX); + balanceEntryToggleGroup.selectToggle(balanceEntryShowAllRadioButton); + + Region spacer = new Region(); + balanceEntryHBox = new HBox(20, + balanceEntryTitledGroupBg, + spacer, + showMonthlyBalanceEntryToggle, + balanceEntryShowAllRadioButton, + balanceEntryShowFeeRadioButton, + balanceEntryShowDptRadioButton, + balanceEntryShowBurnRadioButton); + HBox.setHgrow(spacer, Priority.ALWAYS); + balanceEntryHBox.setVisible(false); + balanceEntryHBox.setManaged(false); + balanceEntryHBox.prefWidthProperty().bind(gridPane.widthProperty()); + GridPane.setRowIndex(balanceEntryHBox, ++gridRow); + GridPane.setColumnSpan(balanceEntryHBox, 2); + GridPane.setMargin(balanceEntryHBox, new Insets(38, -10, -12, -10)); + gridPane.getChildren().add(balanceEntryHBox); + + balanceEntryTableView = new TableView<>(); + GridPane.setColumnSpan(balanceEntryTableView, 2); + GridPane.setRowIndex(balanceEntryTableView, gridRow); + GridPane.setMargin(balanceEntryTableView, new Insets(60, -10, 5, -10)); + gridPane.getChildren().add(balanceEntryTableView); + balanceEntryTableView.setPlaceholder(new AutoTooltipLabel(Res.get("table.placeholder.noData"))); + balanceEntryTableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); + createBalanceEntryColumns(); + balanceEntryTableView.setItems(balanceEntrySortedList); + balanceEntryTableView.setVisible(false); + balanceEntryTableView.setManaged(false); + + exportBalanceEntriesButton = addButton(gridPane, ++gridRow, Res.get("shared.exportCSV")); + GridPane.setColumnIndex(exportBalanceEntriesButton, 1); + GridPane.setHalignment(exportBalanceEntriesButton, HPos.RIGHT); + exportBalanceEntriesButton.setVisible(false); + exportBalanceEntriesButton.setManaged(false); + + // BurnOutputs + Tuple2, TitledGroupBg> burnOutputTuple = addTableViewWithHeader(gridPane, ++gridRow, + Res.get("dao.burningman.burnOutput.table.header"), 30); + burnOutputsTableView = burnOutputTuple.first; + GridPane.setMargin(burnOutputsTableView, new Insets(60, 0, 5, -10)); + createBurnOutputsColumns(); + burnOutputsTableView.setItems(burnOutputsSortedList); + burnOutputsTableView.setVisible(false); + burnOutputsTableView.setManaged(false); + burnOutputsTitledGroupBg = burnOutputTuple.second; + burnOutputsTitledGroupBg.setVisible(false); + burnOutputsTitledGroupBg.setManaged(false); + + // Compensations + Tuple2, TitledGroupBg> compensationTuple = addTableViewWithHeader(gridPane, gridRow, + Res.get("dao.burningman.compensations.table.header"), 30); + compensationsTableView = compensationTuple.first; + GridPane.setMargin(compensationsTableView, new Insets(60, -10, 5, 0)); + GridPane.setColumnIndex(compensationsTableView, 1); + createCompensationColumns(); + compensationsTableView.setItems(compensationSortedList); + compensationsTableView.setVisible(false); + compensationsTableView.setManaged(false); + compensationsTitledGroupBg = compensationTuple.second; + GridPane.setColumnIndex(compensationsTitledGroupBg, 1); + compensationsTitledGroupBg.setVisible(false); + compensationsTitledGroupBg.setManaged(false); + + // Reimbursements + reimbursementsTableView = FormBuilder.addTableViewWithHeader(gridPane, ++gridRow, + Res.get("dao.burningman.reimbursement.table.header"), 30).first; + GridPane.setColumnSpan(reimbursementsTableView, 2); + createReimbursementColumns(); + reimbursementsTableView.setItems(reimbursementSortedList); + } + + private RadioButton getRadioButton(String title, @Nullable BalanceEntry.Type type) { + AutoTooltipRadioButton radioButton = new AutoTooltipRadioButton(title); + radioButton.setToggleGroup(balanceEntryToggleGroup); + radioButton.setUserData(type); + HBox.setMargin(radioButton, new Insets(-12, 2, 0, 0)); + return radioButton; + } + + @Override + protected void activate() { + GUIUtil.setFitToRowsForTableView(burningManTableView, 36, 28, 10, 10); + GUIUtil.setFitToRowsForTableView(reimbursementsTableView, 36, 28, 3, 6); + + daoFacade.addBsqStateListener(this); + bsqWalletService.addBsqBalanceListener(this); + + amountInputTextField.textProperty().addListener(amountInputTextFieldListener); + amountInputTextField.focusedProperty().addListener(amountFocusOutListener); + + burningmenFilterField.textProperty().addListener(filterListener); + burningManTableView.getSelectionModel().selectedItemProperty().addListener(burningmenSelectionListener); + + contributorComboBox.getSelectionModel().selectedItemProperty().addListener(contributorsListener); + + balanceEntryToggleGroup.selectedToggleProperty().addListener(balanceEntryToggleListener); + + burningManSortedList.comparatorProperty().bind(burningManTableView.comparatorProperty()); + burnOutputsSortedList.comparatorProperty().bind(burnOutputsTableView.comparatorProperty()); + balanceEntrySortedList.comparatorProperty().bind(balanceEntryTableView.comparatorProperty()); + compensationSortedList.comparatorProperty().bind(compensationsTableView.comparatorProperty()); + reimbursementSortedList.comparatorProperty().bind(reimbursementsTableView.comparatorProperty()); + + burnButton.setOnAction(e -> onBurn()); + + exportBalanceEntriesButton.setOnAction(event -> onExportBalanceEntries()); + showOnlyActiveBurningmenToggle.setOnAction(e -> updateBurningmenPredicate()); + showMonthlyBalanceEntryToggle.setOnAction(e -> onShowMonthly(showMonthlyBalanceEntryToggle.isSelected())); + + amountInputTextField.setValidator(bsqValidator); + + if (daoFacade.isParseBlockChainComplete()) { + updateData(); + } + + onUpdateAvailableBalance(bsqWalletService.getAvailableBalance()); + + updateButtonState(); + updateBurningmenPredicate(); + } + + @Override + protected void deactivate() { + daoFacade.removeBsqStateListener(this); + bsqWalletService.removeBsqBalanceListener(this); + + amountInputTextField.textProperty().removeListener(amountInputTextFieldListener); + amountInputTextField.focusedProperty().removeListener(amountFocusOutListener); + + burningmenFilterField.textProperty().removeListener(filterListener); + burningManTableView.getSelectionModel().selectedItemProperty().removeListener(burningmenSelectionListener); + + contributorComboBox.getSelectionModel().selectedItemProperty().removeListener(contributorsListener); + + balanceEntryToggleGroup.selectedToggleProperty().removeListener(balanceEntryToggleListener); + + burningManSortedList.comparatorProperty().unbind(); + burnOutputsSortedList.comparatorProperty().unbind(); + balanceEntrySortedList.comparatorProperty().unbind(); + compensationSortedList.comparatorProperty().unbind(); + reimbursementSortedList.comparatorProperty().unbind(); + + burnButton.setOnAction(null); + exportBalanceEntriesButton.setOnAction(null); + showOnlyActiveBurningmenToggle.setOnAction(null); + showMonthlyBalanceEntryToggle.setOnAction(null); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // DaoStateListener + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void onParseBlockCompleteAfterBatchProcessing(Block block) { + updateData(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // BsqBalanceListener + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void onUpdateBalances(Coin availableBalance, + Coin availableNonBsqBalance, + Coin unverifiedBalance, + Coin unconfirmedChangeBalance, + Coin lockedForVotingBalance, + Coin lockupBondsBalance, + Coin unlockingBondsBalance) { + onUpdateAvailableBalance(availableBalance); + } + + private void onUpdateAvailableBalance(Coin availableBalance) { + bsqValidator.setAvailableBalance(availableBalance); + updateButtonState(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Private + /////////////////////////////////////////////////////////////////////////////////////////// + + private void updateData() { + burningManObservableList.setAll(burningManPresentationService.getBurningManCandidatesByName().entrySet().stream() + .map(entry -> new BurningManListItem(burningManPresentationService, entry.getKey(), entry.getValue(), bsqFormatter)) + .collect(Collectors.toList())); + burningManObservableList.add(new BurningManListItem(burningManPresentationService, + BurningManPresentationService.LEGACY_BURNING_MAN_DPT_NAME, + burningManPresentationService.getLegacyBurningManForDPT(), + bsqFormatter)); + burningManObservableList.add(new BurningManListItem(burningManPresentationService, + BurningManPresentationService.LEGACY_BURNING_MAN_BTC_FEES_NAME, + burningManPresentationService.getLegacyBurningManForBtcFees(), + bsqFormatter)); + reimbursementObservableList.setAll(burningManPresentationService.getReimbursements().stream() + .map(reimbursementModel -> new ReimbursementListItem(reimbursementModel, bsqFormatter)) + .collect(Collectors.toList())); + + expectedRevenueField.setText(bsqFormatter.formatCoinWithCode(burningManPresentationService.getAverageDistributionPerCycle())); + + String burnTarget = bsqFormatter.formatCoin(burningManPresentationService.getBurnTarget()); + String boostedBurnTarget = bsqFormatter.formatCoin(burningManPresentationService.getBoostedBurnTarget()); + burnTargetField.setText(Res.get("dao.burningman.burnTarget.fromTo", burnTarget, boostedBurnTarget)); + + if (daoFacade.isParseBlockChainComplete()) { + Set myContributorNames = burningManPresentationService.getMyCompensationRequestNames(); + burningManPresentationService.findMyGenesisOutputNames().ifPresent(myContributorNames::addAll); + Map burningmenListItemByName = burningManObservableList.stream() + .collect(Collectors.toMap(BurningManListItem::getName, e -> e)); + List myBurningManListItems = myContributorNames.stream() + .filter(burningmenListItemByName::containsKey) + .map(burningmenListItemByName::get) + .sorted(Comparator.comparing(BurningManListItem::getName)) + .collect(Collectors.toList()); + contributorComboBox.setItems(FXCollections.observableArrayList(myBurningManListItems)); + } + } + + private void onShowMonthly(boolean value) { + showMonthlyBalanceEntries = value; + BurningManListItem selectedItem = burningManTableView.getSelectionModel().getSelectedItem(); + if (selectedItem != null) { + onBurningManSelected(selectedItem); + } + } + + private void updateBurningmenPredicate() { + burningManFilteredList.setPredicate(burningManListItem -> { + boolean showOnlyActiveBurningmen = showOnlyActiveBurningmenToggle.isSelected(); + String filterText = burningmenFilterField.getText(); + boolean activeBurnerOrShowAll = !showOnlyActiveBurningmen || burningManListItem.getCappedBurnAmountShare() > 0; + if (filterText == null || filterText.trim().isEmpty()) { + return activeBurnerOrShowAll; + } else { + return activeBurnerOrShowAll && burningManListItem.getName().toLowerCase().contains(filterText.toLowerCase()); + } + }); + } + + private void onTypeChanged() { + if (showMonthlyBalanceEntries) { + BurningManListItem selectedItem = burningManTableView.getSelectionModel().getSelectedItem(); + if (selectedItem != null) { + onBurningManSelected(selectedItem); + } + } + balanceEntryFilteredList.setPredicate(balanceEntryItem -> { + BalanceEntry.Type userData = (BalanceEntry.Type) balanceEntryToggleGroup.getSelectedToggle().getUserData(); + return showMonthlyBalanceEntries || userData == null || balanceEntryItem.getType().orElse(null) == userData; + }); + } + + private void onBurningManSelected(BurningManListItem burningManListItem) { + String name = burningManListItem.getName(); + selectedContributorNameField.setText(name); + selectedContributorAddressField.setText(burningManListItem.getAddress()); + BurningManCandidate burningManCandidate = burningManListItem.getBurningManCandidate(); + + boolean isLegacyBurningMan = burningManCandidate instanceof LegacyBurningMan; + burnOutputsObservableList.setAll(burningManCandidate.getBurnOutputModels().stream() + .map(burnOutputModel -> new BurnOutputListItem(burnOutputModel, bsqFormatter, isLegacyBurningMan)) + .collect(Collectors.toList())); + GUIUtil.setFitToRowsForTableView(burnOutputsTableView, 36, 28, 4, 6); + + if (burningManAccountingService.getBalanceModelByBurningManName().containsKey(name)) { + BalanceModel balanceModel = burningManAccountingService.getBalanceModelByBurningManName().get(name); + List balanceEntries; + if (showMonthlyBalanceEntries) { + Predicate predicate = balanceEntry -> { + BalanceEntry.Type selectedType = (BalanceEntry.Type) balanceEntryToggleGroup.getSelectedToggle().getUserData(); + return selectedType == null || + selectedType.equals(balanceEntry.getType()); + }; + balanceEntries = balanceModel.getMonthlyBalanceEntries(burningManCandidate, predicate); + } else { + Stream receivedBtcBalanceEntries = balanceModel.getReceivedBtcBalanceEntries().stream(); + Stream burnedBsqBalanceEntries = balanceModel.getBurnedBsqBalanceEntries(burningManCandidate.getBurnOutputModels()); + balanceEntries = Stream.concat(receivedBtcBalanceEntries, burnedBsqBalanceEntries).collect(Collectors.toList()); + } + + Map averageBsqPriceByMonth = burningManAccountingService.getAverageBsqPriceByMonth(); + long ts = System.currentTimeMillis(); + balanceEntryObservableList.setAll(balanceEntries.stream() + .map(balanceEntry -> new BalanceEntryItem(balanceEntry, averageBsqPriceByMonth, bsqFormatter, btcFormatter)) + .collect(Collectors.toList())); + // 108869: 617 - 1878, 640-1531 + log.error("balanceEntryObservableList setAll took {} ms size={}", System.currentTimeMillis() - ts, balanceEntryObservableList.size()); + } else { + balanceEntryObservableList.clear(); + } + GUIUtil.setFitToRowsForTableView(balanceEntryTableView, 36, 28, 4, 6); + + compensationObservableList.setAll(burningManCandidate.getCompensationModels().stream() + .map(compensationModel -> new CompensationListItem(compensationModel, bsqFormatter)) + .collect(Collectors.toList())); + GUIUtil.setFitToRowsForTableView(compensationsTableView, 36, 28, 4, 6); + } + + private void updateButtonState() { + boolean isValid = bsqValidator.validate(amountInputTextField.getText()).isValid && + contributorComboBox.getSelectionModel().getSelectedItem() != null; + burnButton.setDisable(!isValid); + } + + private void onBurn() { + BurningManListItem selectedItem = contributorComboBox.getSelectionModel().getSelectedItem(); + if (selectedItem != null) { + Coin amount = getAmountFee(); + String name = selectedItem.getName(); + try { + Transaction transaction = proofOfBurnService.burn(name, amount.value); + Coin miningFee = transaction.getFee(); + int txVsize = transaction.getVsize(); + + if (!DevEnv.isDevMode()) { + GUIUtil.showBsqFeeInfoPopup(amount, miningFee, txVsize, bsqFormatter, btcFormatter, + Res.get("dao.proofOfBurn.header"), () -> doPublishFeeTx(transaction, name)); + } else { + doPublishFeeTx(transaction, name); + } + } catch (InsufficientMoneyException | TxException e) { + e.printStackTrace(); + new Popup().error(e.toString()).show(); + } + } + } + + private Coin getAmountFee() { + return ParsingUtils.parseToCoin(amountInputTextField.getText(), bsqFormatter); + } + + private void doPublishFeeTx(Transaction transaction, String preImageAsString) { + proofOfBurnService.publishTransaction(transaction, preImageAsString, + () -> { + if (!DevEnv.isDevMode()) + new Popup().confirmation(Res.get("dao.tx.published.success")).show(); + }, + errorMessage -> new Popup().warning(errorMessage).show()); + + amountInputTextField.clear(); + amountInputTextField.setPromptText(Res.get("dao.burningman.amount.prompt")); + amountInputTextField.resetValidation(); + contributorComboBox.getSelectionModel().clearSelection(); + } + + private void onExportBalanceEntries() { + CSVEntryConverter headerConverter = item -> { + ObservableList> tableColumns = balanceEntryTableView.getColumns(); + String[] columns = new String[tableColumns.size()]; + for (int i = 0; i < tableColumns.size(); i++) { + columns[i] = ((AutoTooltipLabel) tableColumns.get(i).getGraphic()).getText(); + } + return columns; + }; + CSVEntryConverter contentConverter = item -> { + String[] columns = new String[7]; + columns[0] = item.getDateAsString(); + columns[1] = item.getReceivedBtcAsString(); + columns[2] = item.getPriceAsString(); + columns[3] = item.getReceivedBtcAsBsqAsString(); + columns[4] = item.getBurnedBsqAsString(); + columns[5] = item.getRevenueAsString(); + columns[6] = item.getTypeAsString(); + return columns; + }; + + GUIUtil.exportCSV("burningman_revenue.csv", headerConverter, contentConverter, + new BalanceEntryItem(), balanceEntryObservableList, (Stage) root.getScene().getWindow()); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Table columns + /////////////////////////////////////////////////////////////////////////////////////////// + + private void createBurningmenColumns() { + TableColumn column; + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.name")); + column.setMinWidth(190); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.getStyleClass().add("first-column"); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurningManListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setMinHeight(36); + setText(item.getName()); + } else + setText(""); + } + }; + } + }); + burningManTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(e -> e.getName().toLowerCase())); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.burnTarget")); + column.setMinWidth(200); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurningManListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getBurnTargetAsBsq()); + } else + setText(""); + } + }; + } + }); + burningManTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(BurningManListItem::getBurnTarget)); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.expectedRevenue")); + column.setMinWidth(140); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurningManListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getExpectedRevenueAsBsq()); + } else + setText(""); + } + }; + } + }); + burningManTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(BurningManListItem::getExpectedRevenue)); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.burnAmountShare.label")); + column.setMinWidth(230); + column.setMaxWidth(column.getMinWidth()); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurningManListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getCappedBurnAmountShareAsString()); + } else + setText(""); + } + }; + } + }); + burningManTableView.getColumns().add(column); + column.setSortType(TableColumn.SortType.DESCENDING); + column.setComparator(Comparator.comparing(BurningManListItem::getCappedBurnAmountShare)); + burningManTableView.getSortOrder().add(column); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.decayedBurnAmount")); + column.setMinWidth(160); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurningManListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getAccumulatedDecayedBurnAmountAsBsq()); + } else + setText(""); + } + }; + } + }); + burningManTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(BurningManListItem::getAccumulatedDecayedBurnAmount)); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.burnAmount")); + column.setMinWidth(130); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurningManListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getAccumulatedBurnAmountAsBsq()); + } else + setText(""); + } + }; + } + }); + burningManTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(BurningManListItem::getAccumulatedBurnAmount)); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.numBurnOutputs")); + column.setMinWidth(90); + column.setMaxWidth(column.getMinWidth()); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurningManListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(String.valueOf(item.getNumBurnOutputs())); + } else + setText(""); + } + }; + } + }); + burningManTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(BurningManListItem::getNumBurnOutputs)); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.issuanceShare")); + column.setMinWidth(110); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurningManListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getCompensationShareAsString()); + } else + setText(""); + } + }; + } + }); + burningManTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(BurningManListItem::getCompensationShare)); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.decayedIssuanceAmount")); + column.setMinWidth(140); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurningManListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getAccumulatedDecayedCompensationAmountAsBsq()); + } else + setText(""); + } + }; + } + }); + burningManTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(BurningManListItem::getAccumulatedDecayedCompensationAmount)); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.issuanceAmount")); + column.setMinWidth(120); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurningManListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getAccumulatedCompensationAmountAsBsq()); + } else + setText(""); + } + }; + } + }); + burningManTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(BurningManListItem::getAccumulatedCompensationAmount)); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.numIssuances")); + column.setMinWidth(110); + column.setMaxWidth(column.getMinWidth()); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurningManListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getNumIssuancesAsString()); + } else + setText(""); + } + }; + } + }); + burningManTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(BurningManListItem::getNumIssuances)); + column.setSortType(TableColumn.SortType.DESCENDING); + } + + private void createBurnOutputsColumns() { + TableColumn column; + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.shared.table.date")); + column.setMinWidth(160); + column.setMaxWidth(column.getMinWidth()); + column.getStyleClass().add("first-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurnOutputListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setMinHeight(36); + setText(item.getDateAsString()); + } else + setText(""); + } + }; + } + }); + burnOutputsTableView.getColumns().add(column); + column.setSortType(TableColumn.SortType.DESCENDING); + column.setComparator(Comparator.comparing(BurnOutputListItem::getDate)); + burnOutputsTableView.getSortOrder().add(column); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.shared.table.cycle")); + column.setMinWidth(60); + column.setMaxWidth(column.getMinWidth()); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurnOutputListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(String.valueOf(item.getCycleIndex() + 1)); + } else + setText(""); + } + }; + } + }); + burnOutputsTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(BurnOutputListItem::getCycleIndex)); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.shared.table.height")); + column.setMinWidth(90); + column.setMaxWidth(column.getMinWidth()); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurnOutputListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(String.valueOf(item.getHeight())); + } else + setText(""); + } + }; + } + }); + burnOutputsTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(BurnOutputListItem::getHeight)); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.decayedBurnAmount")); + column.setMinWidth(160); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurnOutputListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getDecayedAmountAsString()); + } else + setText(""); + } + }; + } + }); + burnOutputsTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(BurnOutputListItem::getDecayedAmount)); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.burnAmount")); + column.setMinWidth(140); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BurnOutputListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getAmountAsString()); + } else + setText(""); + } + }; + } + }); + burnOutputsTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(BurnOutputListItem::getAmount)); + column.setSortType(TableColumn.SortType.DESCENDING); + } + + private void createBalanceEntryColumns() { + TableColumn column; + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.balanceEntry.date")); + column.setMinWidth(160); + column.getStyleClass().add("first-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BalanceEntryItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setMinHeight(36); + setText(showMonthlyBalanceEntries ? item.getMonthAsString() : item.getDateAsString()); + } else + setText(""); + } + }; + } + }); + balanceEntryTableView.getColumns().add(column); + column.setSortType(TableColumn.SortType.DESCENDING); + column.setComparator(Comparator.comparing(BalanceEntryItem::getDate)); + balanceEntryTableView.getSortOrder().add(column); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.balanceEntry.receivedBtc")); + column.setMinWidth(100); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BalanceEntryItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getReceivedBtcAsString()); + } else + setText(""); + } + }; + } + }); + balanceEntryTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(e -> e.getReceivedBtc().orElse(null))); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.balanceEntry.price")); + column.setMinWidth(160); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BalanceEntryItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getPriceAsString()); + } else + setText(""); + } + }; + } + }); + balanceEntryTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(e -> e.getPrice().orElse(null))); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.balanceEntry.receivedBtcAsBsq")); + column.setMinWidth(100); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BalanceEntryItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(String.valueOf(item.getReceivedBtcAsBsqAsString())); + } else + setText(""); + } + }; + } + }); + balanceEntryTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(e -> e.getReceivedBtcAsBsq().orElse(null))); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.balanceEntry.burnedBsq")); + column.setMinWidth(100); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BalanceEntryItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(String.valueOf(item.getBurnedBsqAsString())); + } else + setText(""); + } + }; + } + }); + balanceEntryTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(e -> e.getBurnedBsq().orElse(null))); + column.setSortType(TableColumn.SortType.DESCENDING); + + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.balanceEntry.revenue")); + column.setMinWidth(100); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BalanceEntryItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(String.valueOf(item.getRevenueAsString())); + } else + setText(""); + } + }; + } + }); + balanceEntryTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(e -> e.getRevenue().orElse(null))); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.balanceEntry.type")); + column.setMinWidth(140); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final BalanceEntryItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getTypeAsString()); + } else + setText(""); + } + }; + } + }); + balanceEntryTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(e -> e.getType().orElse(null))); + column.setSortType(TableColumn.SortType.DESCENDING); + } + + private void createCompensationColumns() { + TableColumn column; + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.shared.table.date")); + column.setMinWidth(160); + column.setMaxWidth(column.getMinWidth()); + column.getStyleClass().add("first-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final CompensationListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setMinHeight(36); + setText(item.getDateAsString()); + } else + setText(""); + } + }; + } + }); + compensationsTableView.getColumns().add(column); + column.setSortType(TableColumn.SortType.DESCENDING); + column.setComparator(Comparator.comparing(CompensationListItem::getDate)); + compensationsTableView.getSortOrder().add(column); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.shared.table.cycle")); + column.setMinWidth(60); + column.setMaxWidth(column.getMinWidth()); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final CompensationListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(String.valueOf(item.getCycleIndex() + 1)); + } else + setText(""); + } + }; + } + }); + compensationsTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(CompensationListItem::getCycleIndex)); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.shared.table.height")); + column.setMinWidth(90); + column.setMaxWidth(column.getMinWidth()); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final CompensationListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(String.valueOf(item.getHeight())); + } else + setText(""); + } + }; + } + }); + compensationsTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(CompensationListItem::getHeight)); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.decayedIssuanceAmount")); + column.setMinWidth(160); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final CompensationListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getDecayedAmountAsString()); + } else + setText(""); + } + }; + } + }); + compensationsTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(CompensationListItem::getDecayedAmount)); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.issuanceAmount")); + column.setMinWidth(140); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final CompensationListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getAmountAsString()); + } else + setText(""); + } + }; + } + }); + compensationsTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(CompensationListItem::getAmount)); + column.setSortType(TableColumn.SortType.DESCENDING); + } + + private void createReimbursementColumns() { + TableColumn column; + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.shared.table.date")); + column.setMinWidth(160); + column.setMaxWidth(column.getMinWidth()); + column.getStyleClass().add("first-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final ReimbursementListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setMinHeight(36); + setText(item.getDateAsString()); + } else + setText(""); + } + }; + } + }); + reimbursementsTableView.getColumns().add(column); + column.setSortType(TableColumn.SortType.DESCENDING); + column.setComparator(Comparator.comparing(ReimbursementListItem::getDate)); + reimbursementsTableView.getSortOrder().add(column); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.shared.table.height")); + column.setMinWidth(90); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final ReimbursementListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(String.valueOf(item.getHeight())); + } else + setText(""); + } + }; + } + }); + reimbursementsTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(ReimbursementListItem::getHeight)); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.shared.table.cycle")); + column.setMinWidth(60); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final ReimbursementListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(String.valueOf(item.getCycleIndex() + 1)); + } else + setText(""); + } + }; + } + }); + reimbursementsTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(ReimbursementListItem::getCycleIndex)); + column.setSortType(TableColumn.SortType.DESCENDING); + + column = new AutoTooltipTableColumn<>(Res.get("dao.burningman.table.reimbursedAmount")); + column.setMinWidth(140); + column.getStyleClass().add("last-column"); + column.setCellValueFactory((item) -> new ReadOnlyObjectWrapper<>(item.getValue())); + column.setCellFactory(new Callback<>() { + @Override + public TableCell call(TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final ReimbursementListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + setText(item.getAmountAsString()); + } else + setText(""); + } + }; + } + }); + reimbursementsTableView.getColumns().add(column); + column.setComparator(Comparator.comparing(ReimbursementListItem::getAmount)); + column.setSortType(TableColumn.SortType.DESCENDING); + } +} diff --git a/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/CompensationListItem.java b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/CompensationListItem.java new file mode 100644 index 0000000000..90d943e249 --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/CompensationListItem.java @@ -0,0 +1,48 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.desktop.main.dao.burnbsq.burningman; + +import bisq.desktop.util.DisplayUtils; + +import bisq.core.dao.burningman.model.CompensationModel; +import bisq.core.util.coin.BsqFormatter; + +import java.util.Date; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +class CompensationListItem { + private final String amountAsString, decayedAmountAsString, dateAsString; + private final long amount, decayedAmount, date; + private final int height, cycleIndex; + + CompensationListItem(CompensationModel model, BsqFormatter bsqFormatter) { + + height = model.getHeight(); + cycleIndex = model.getCycleIndex(); + date = model.getDate(); + dateAsString = DisplayUtils.formatDateTime(new Date(date)); + amount = model.getAmount(); + amountAsString = bsqFormatter.formatCoinWithCode(amount); + decayedAmount = model.getDecayedAmount(); + decayedAmountAsString = bsqFormatter.formatCoinWithCode(decayedAmount); + } +} diff --git a/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/ReimbursementListItem.java b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/ReimbursementListItem.java new file mode 100644 index 0000000000..d6d444ae1c --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/burningman/ReimbursementListItem.java @@ -0,0 +1,45 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.desktop.main.dao.burnbsq.burningman; + +import bisq.desktop.util.DisplayUtils; + +import bisq.core.dao.burningman.model.ReimbursementModel; +import bisq.core.util.coin.BsqFormatter; + +import java.util.Date; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +class ReimbursementListItem { + private final String amountAsString, dateAsString; + private final long amount, date; + private final int height, cycleIndex; + + ReimbursementListItem(ReimbursementModel model, BsqFormatter bsqFormatter) { + height = model.getHeight(); + cycleIndex = model.getCycleIndex(); + date = model.getDate(); + dateAsString = DisplayUtils.formatDateTime(new Date(date)); + amount = model.getAmount(); + amountAsString = bsqFormatter.formatCoinWithCode(amount); + } +} diff --git a/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/proofofburn/ProofOfBurnView.java b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/proofofburn/ProofOfBurnView.java index 92f1ea8c9e..dc506d2538 100644 --- a/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/proofofburn/ProofOfBurnView.java +++ b/desktop/src/main/java/bisq/desktop/main/dao/burnbsq/proofofburn/ProofOfBurnView.java @@ -143,11 +143,13 @@ public class ProofOfBurnView extends ActivatableView implements hashTextField = addTopLabelTextField(root, ++gridRow, Res.get("dao.proofOfBurn.hash")).second; burnButton = addButtonAfterGroup(root, ++gridRow, Res.get("dao.proofOfBurn.burn")); - myItemsTableView = FormBuilder.addTableViewWithHeader(root, ++gridRow, Res.get("dao.proofOfBurn.myItems"), 30); + myItemsTableView = FormBuilder.addTableViewWithHeader(root, ++gridRow, + Res.get("dao.proofOfBurn.myItems"), 30).first; createColumnsForMyItems(); myItemsTableView.setItems(myItemsSortedList); - allTxsTableView = FormBuilder.addTableViewWithHeader(root, ++gridRow, Res.get("dao.proofOfBurn.allTxs"), 30, "last"); + allTxsTableView = FormBuilder.addTableViewWithHeader(root, ++gridRow, + Res.get("dao.proofOfBurn.allTxs"), 30, "last").first; createColumnsForAllTxs(); allTxsTableView.setItems(allItemsSortedList); @@ -295,6 +297,8 @@ public class ProofOfBurnView extends ActivatableView implements amountInputTextField.clear(); preImageTextField.clear(); + amountInputTextField.resetValidation(); + preImageTextField.resetValidation(); } diff --git a/desktop/src/main/java/bisq/desktop/main/dao/governance/ProposalDisplay.java b/desktop/src/main/java/bisq/desktop/main/dao/governance/ProposalDisplay.java index 08c3b83f6f..abeef70dc1 100644 --- a/desktop/src/main/java/bisq/desktop/main/dao/governance/ProposalDisplay.java +++ b/desktop/src/main/java/bisq/desktop/main/dao/governance/ProposalDisplay.java @@ -27,7 +27,6 @@ import bisq.desktop.main.MainView; import bisq.desktop.main.dao.DaoView; import bisq.desktop.main.dao.bonding.BondingView; import bisq.desktop.main.dao.bonding.bonds.BondsView; -import bisq.desktop.util.FormBuilder; import bisq.desktop.util.GUIUtil; import bisq.desktop.util.Layout; import bisq.desktop.util.validation.BsqValidator; @@ -58,6 +57,7 @@ import bisq.core.dao.state.model.governance.Vote; import bisq.core.locale.CurrencyUtil; import bisq.core.locale.Res; import bisq.core.util.coin.BsqFormatter; +import bisq.core.util.validation.BtcAddressValidator; import bisq.core.util.validation.InputValidator; import bisq.core.util.validation.RegexValidator; @@ -116,7 +116,7 @@ public class ProposalDisplay { public InputTextField nameTextField; public InputTextField linkInputTextField; @Nullable - public InputTextField requestedBsqTextField, paramValueTextField; + public InputTextField requestedBsqTextField, burningManReceiverAddressTextField, paramValueTextField; @Nullable public ComboBox paramComboBox; @Nullable @@ -133,9 +133,9 @@ public class ProposalDisplay { private int gridRowStartIndex; private final List inputChangedListeners = new ArrayList<>(); @Getter - private List inputControls = new ArrayList<>(); + private final List inputControls = new ArrayList<>(); @Getter - private List> comboBoxes = new ArrayList<>(); + private final List> comboBoxes = new ArrayList<>(); private final ChangeListener focusOutListener; private final ChangeListener inputListener; private ChangeListener paramChangeListener; @@ -143,7 +143,6 @@ public class ProposalDisplay { private TitledGroupBg myVoteTitledGroup; private VBox linkWithIconContainer, comboBoxValueContainer, myVoteBox, voteResultBox; private int votingBoxRowSpan; - private Optional navigateHandlerOptional = Optional.empty(); public ProposalDisplay(GridPane gridPane, @@ -188,6 +187,8 @@ public class ProposalDisplay { switch (proposalType) { case COMPENSATION_REQUEST: + titledGroupBgRowSpan = 6; + break; case REIMBURSEMENT_REQUEST: case CONFISCATE_BOND: case REMOVE_ASSET: @@ -266,7 +267,6 @@ public class ProposalDisplay { case REIMBURSEMENT_REQUEST: requestedBsqTextField = addInputTextField(gridPane, ++gridRow, Res.get("dao.proposal.display.requestedBsq")); - checkNotNull(requestedBsqTextField, "requestedBsqTextField must not be null"); inputControls.add(requestedBsqTextField); if (isMakeProposalScreen) { @@ -280,10 +280,19 @@ public class ProposalDisplay { } requestedBsqTextField.setValidator(bsqValidator); } + + if (proposalType == ProposalType.COMPENSATION_REQUEST) { + burningManReceiverAddressTextField = addInputTextField(gridPane, ++gridRow, + Res.get("dao.proposal.display.burningManReceiverAddress")); + BtcAddressValidator btcAddressValidator = new BtcAddressValidator(); + btcAddressValidator.setAllowEmpty(true); + burningManReceiverAddressTextField.setValidator(btcAddressValidator); + inputControls.add(burningManReceiverAddressTextField); + } break; case CHANGE_PARAM: checkNotNull(gridPane, "gridPane must not be null"); - paramComboBox = FormBuilder.addComboBox(gridPane, ++gridRow, + paramComboBox = addComboBox(gridPane, ++gridRow, Res.get("dao.proposal.display.paramComboBox.label")); comboBoxValueTextFieldIndex = gridRow; checkNotNull(paramComboBox, "paramComboBox must not be null"); @@ -322,7 +331,7 @@ public class ProposalDisplay { paramComboBox.getSelectionModel().selectedItemProperty().addListener(paramChangeListener); break; case BONDED_ROLE: - bondedRoleTypeComboBox = FormBuilder.addComboBox(gridPane, ++gridRow, + bondedRoleTypeComboBox = addComboBox(gridPane, ++gridRow, Res.get("dao.proposal.display.bondedRoleComboBox.label")); comboBoxValueTextFieldIndex = gridRow; checkNotNull(bondedRoleTypeComboBox, "bondedRoleTypeComboBox must not be null"); @@ -354,7 +363,7 @@ public class ProposalDisplay { break; case CONFISCATE_BOND: - confiscateBondComboBox = FormBuilder.addComboBox(gridPane, ++gridRow, + confiscateBondComboBox = addComboBox(gridPane, ++gridRow, Res.get("dao.proposal.display.confiscateBondComboBox.label")); comboBoxValueTextFieldIndex = gridRow; checkNotNull(confiscateBondComboBox, "confiscateBondComboBox must not be null"); @@ -381,7 +390,7 @@ public class ProposalDisplay { case GENERIC: break; case REMOVE_ASSET: - assetComboBox = FormBuilder.addComboBox(gridPane, ++gridRow, + assetComboBox = addComboBox(gridPane, ++gridRow, Res.get("dao.proposal.display.assetComboBox.label")); comboBoxValueTextFieldIndex = gridRow; checkNotNull(assetComboBox, "assetComboBox must not be null"); @@ -530,6 +539,16 @@ public class ProposalDisplay { CompensationProposal compensationProposal = (CompensationProposal) proposal; checkNotNull(requestedBsqTextField, "requestedBsqTextField must not be null"); requestedBsqTextField.setText(bsqFormatter.formatCoinWithCode(compensationProposal.getRequestedBsq())); + if (burningManReceiverAddressTextField != null) { + Optional burningManReceiverAddress = compensationProposal.getBurningManReceiverAddress(); + boolean isPresent = burningManReceiverAddress.isPresent(); + burningManReceiverAddressTextField.setVisible(isPresent); + burningManReceiverAddressTextField.setManaged(isPresent); + if (isPresent) { + burningManReceiverAddressTextField.setText(burningManReceiverAddress.get()); + } + } + } else if (proposal instanceof ReimbursementProposal) { ReimbursementProposal reimbursementProposal = (ReimbursementProposal) proposal; checkNotNull(requestedBsqTextField, "requestedBsqTextField must not be null"); @@ -591,9 +610,9 @@ public class ProposalDisplay { private void addListeners() { inputControls.stream() .filter(Objects::nonNull).forEach(inputControl -> { - inputControl.textProperty().addListener(inputListener); - inputControl.focusedProperty().addListener(focusOutListener); - }); + inputControl.textProperty().addListener(inputListener); + inputControl.focusedProperty().addListener(focusOutListener); + }); comboBoxes.stream() .filter(Objects::nonNull) .forEach(comboBox -> comboBox.getSelectionModel().selectedItemProperty().addListener(inputListener)); @@ -602,9 +621,9 @@ public class ProposalDisplay { public void removeListeners() { inputControls.stream() .filter(Objects::nonNull).forEach(inputControl -> { - inputControl.textProperty().removeListener(inputListener); - inputControl.focusedProperty().removeListener(focusOutListener); - }); + inputControl.textProperty().removeListener(inputListener); + inputControl.focusedProperty().removeListener(focusOutListener); + }); comboBoxes.stream() .filter(Objects::nonNull) .forEach(comboBox -> comboBox.getSelectionModel().selectedItemProperty().removeListener(inputListener)); diff --git a/desktop/src/main/java/bisq/desktop/main/dao/governance/make/MakeProposalView.java b/desktop/src/main/java/bisq/desktop/main/dao/governance/make/MakeProposalView.java index 343a9ee51f..8a4656938e 100644 --- a/desktop/src/main/java/bisq/desktop/main/dao/governance/make/MakeProposalView.java +++ b/desktop/src/main/java/bisq/desktop/main/dao/governance/make/MakeProposalView.java @@ -296,7 +296,7 @@ public class MakeProposalView extends ActivatableView implements if (requiredBond > availableBalance) { long missing = requiredBond - availableBalance; new Popup().warning(Res.get("dao.proposal.create.missingBsqFundsForBond", - bsqFormatter.formatCoinWithCode(missing))) + bsqFormatter.formatCoinWithCode(missing))) .actionButtonText(Res.get("dao.proposal.create.publish")) .onAction(() -> showFeeInfoAndPublishMyProposal(proposal, transaction, miningFee, txVsize, fee)) .show(); @@ -340,7 +340,11 @@ public class MakeProposalView extends ActivatableView implements } } - private void showFeeInfoAndPublishMyProposal(Proposal proposal, Transaction transaction, Coin miningFee, int txVsize, Coin fee) { + private void showFeeInfoAndPublishMyProposal(Proposal proposal, + Transaction transaction, + Coin miningFee, + int txVsize, + Coin fee) { if (!DevEnv.isDevMode()) { Coin btcForIssuance = null; @@ -394,9 +398,19 @@ public class MakeProposalView extends ActivatableView implements case COMPENSATION_REQUEST: checkNotNull(proposalDisplay.requestedBsqTextField, "proposalDisplay.requestedBsqTextField must not be null"); + + Optional burningManReceiverAddress = Optional.empty(); + if (proposalDisplay.burningManReceiverAddressTextField != null && + proposalDisplay.burningManReceiverAddressTextField.getText() != null && + !proposalDisplay.burningManReceiverAddressTextField.getText().trim().isEmpty()) { + burningManReceiverAddress = Optional.of(proposalDisplay.burningManReceiverAddressTextField.getText()); + } + + Coin requestedBsq = ParsingUtils.parseToCoin(proposalDisplay.requestedBsqTextField.getText(), bsqFormatter); return daoFacade.getCompensationProposalWithTransaction(name, link, - ParsingUtils.parseToCoin(proposalDisplay.requestedBsqTextField.getText(), bsqFormatter)); + requestedBsq, + burningManReceiverAddress); case REIMBURSEMENT_REQUEST: checkNotNull(proposalDisplay.requestedBsqTextField, "proposalDisplay.requestedBsqTextField must not be null"); @@ -504,16 +518,16 @@ public class MakeProposalView extends ActivatableView implements if (proposalDisplay != null) { proposalDisplay.getInputControls().stream() .filter(Objects::nonNull).forEach(e -> { - if (e instanceof InputTextField) { - InputTextField inputTextField = (InputTextField) e; - inputsValid.set(inputsValid.get() && - inputTextField.getValidator() != null && - inputTextField.getValidator().validate(e.getText()).isValid); - } - }); + if (e instanceof InputTextField) { + InputTextField inputTextField = (InputTextField) e; + inputsValid.set(inputsValid.get() && + inputTextField.getValidator() != null && + inputTextField.getValidator().validate(e.getText()).isValid); + } + }); proposalDisplay.getComboBoxes().stream() .filter(Objects::nonNull).forEach(comboBox -> inputsValid.set(inputsValid.get() && - comboBox.getSelectionModel().getSelectedItem() != null)); + comboBox.getSelectionModel().getSelectedItem() != null)); InputTextField linkInputTextField = proposalDisplay.linkInputTextField; inputsValid.set(inputsValid.get() && diff --git a/desktop/src/main/java/bisq/desktop/main/dao/wallet/send/BsqSendView.java b/desktop/src/main/java/bisq/desktop/main/dao/wallet/send/BsqSendView.java index 010b58ea96..c9caa5e0be 100644 --- a/desktop/src/main/java/bisq/desktop/main/dao/wallet/send/BsqSendView.java +++ b/desktop/src/main/java/bisq/desktop/main/dao/wallet/send/BsqSendView.java @@ -61,8 +61,11 @@ import bisq.core.util.validation.BtcAddressValidator; import bisq.network.p2p.P2PService; import bisq.common.UserThread; +import bisq.common.crypto.Hash; import bisq.common.handlers.ResultHandler; +import bisq.common.util.Hex; import bisq.common.util.Tuple2; +import bisq.common.util.Tuple3; import org.bitcoinj.core.Coin; import org.bitcoinj.core.InsufficientMoneyException; @@ -72,8 +75,13 @@ import org.bitcoinj.core.TransactionOutput; import javax.inject.Inject; import javax.inject.Named; +import com.google.common.base.Charsets; + import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; import javafx.scene.layout.GridPane; +import javafx.scene.layout.VBox; import javafx.beans.value.ChangeListener; @@ -87,6 +95,7 @@ import javax.annotation.Nullable; import static bisq.desktop.util.FormBuilder.addInputTextField; import static bisq.desktop.util.FormBuilder.addTitledGroupBg; +import static bisq.desktop.util.FormBuilder.addTopLabelTextField; @FxmlView public class BsqSendView extends ActivatableView implements BsqBalanceListener { @@ -106,12 +115,15 @@ public class BsqSendView extends ActivatableView implements BsqB private final WalletPasswordWindow walletPasswordWindow; private int gridRow = 0; - private InputTextField amountInputTextField, btcAmountInputTextField; - private Button sendBsqButton, sendBtcButton, bsqInputControlButton, btcInputControlButton; + private InputTextField amountInputTextField, btcAmountInputTextField, preImageTextField; + private TextField opReturnDataAsHexTextField; + private VBox opReturnDataAsHexBox; + private Label opReturnDataAsHexLabel; + private Button sendBsqButton, sendBtcButton, bsqInputControlButton, btcInputControlButton, btcOpReturnButton; private InputTextField receiversAddressInputTextField, receiversBtcAddressInputTextField; private ChangeListener focusOutListener; private TitledGroupBg btcTitledGroupBg; - private ChangeListener inputTextFieldListener; + private ChangeListener inputTextFieldListener, preImageInputTextFieldListener; @Nullable private Set bsqUtxoCandidates; @Nullable @@ -166,6 +178,8 @@ public class BsqSendView extends ActivatableView implements BsqB }; inputTextFieldListener = (observable, oldValue, newValue) -> onUpdateBalances(); + preImageInputTextFieldListener = (observable, oldValue, newValue) -> opReturnDataAsHexTextField.setText(getOpReturnDataAsHexFromPreImage(newValue)); + setSendBtcGroupVisibleState(false); } @@ -183,6 +197,7 @@ public class BsqSendView extends ActivatableView implements BsqB bsqInputControlButton.setOnAction((event) -> onBsqInputControl()); sendBtcButton.setOnAction((event) -> onSendBtc()); btcInputControlButton.setOnAction((event) -> onBtcInputControl()); + btcOpReturnButton.setOnAction((event) -> onShowPreImageField()); receiversAddressInputTextField.focusedProperty().addListener(focusOutListener); amountInputTextField.focusedProperty().addListener(focusOutListener); @@ -193,6 +208,7 @@ public class BsqSendView extends ActivatableView implements BsqB amountInputTextField.textProperty().addListener(inputTextFieldListener); receiversBtcAddressInputTextField.textProperty().addListener(inputTextFieldListener); btcAmountInputTextField.textProperty().addListener(inputTextFieldListener); + preImageTextField.textProperty().addListener(preImageInputTextFieldListener); bsqWalletService.addBsqBalanceListener(this); @@ -228,11 +244,13 @@ public class BsqSendView extends ActivatableView implements BsqB amountInputTextField.textProperty().removeListener(inputTextFieldListener); receiversBtcAddressInputTextField.textProperty().removeListener(inputTextFieldListener); btcAmountInputTextField.textProperty().removeListener(inputTextFieldListener); + preImageTextField.textProperty().removeListener(preImageInputTextFieldListener); bsqWalletService.removeBsqBalanceListener(this); sendBsqButton.setOnAction(null); btcInputControlButton.setOnAction(null); + btcOpReturnButton.setOnAction(null); sendBtcButton.setOnAction(null); bsqInputControlButton.setOnAction(null); } @@ -367,12 +385,14 @@ public class BsqSendView extends ActivatableView implements BsqB btcAmountInputTextField.setVisible(visible); sendBtcButton.setVisible(visible); btcInputControlButton.setVisible(visible); + btcOpReturnButton.setVisible(visible); btcTitledGroupBg.setManaged(visible); receiversBtcAddressInputTextField.setManaged(visible); btcAmountInputTextField.setManaged(visible); sendBtcButton.setManaged(visible); btcInputControlButton.setManaged(visible); + btcOpReturnButton.setManaged(visible); } private void addSendBtcGroup() { @@ -387,10 +407,24 @@ public class BsqSendView extends ActivatableView implements BsqB btcAmountInputTextField.setValidator(btcValidator); GridPane.setColumnSpan(btcAmountInputTextField, 3); - Tuple2 tuple = FormBuilder.add2ButtonsAfterGroup(root, ++gridRow, - Res.get("dao.wallet.send.sendBtc"), Res.get("dao.wallet.send.inputControl")); + preImageTextField = addInputTextField(root, ++gridRow, Res.get("dao.wallet.send.preImage")); + GridPane.setColumnSpan(preImageTextField, 3); + preImageTextField.setVisible(false); + preImageTextField.setManaged(false); + + Tuple3 opReturnDataAsHexTuple = addTopLabelTextField(root, ++gridRow, Res.get("dao.wallet.send.opReturnAsHex"), -10); + opReturnDataAsHexLabel = opReturnDataAsHexTuple.first; + opReturnDataAsHexTextField = opReturnDataAsHexTuple.second; + opReturnDataAsHexBox = opReturnDataAsHexTuple.third; + GridPane.setColumnSpan(opReturnDataAsHexBox, 3); + opReturnDataAsHexBox.setVisible(false); + opReturnDataAsHexBox.setManaged(false); + + Tuple3 tuple = FormBuilder.add3ButtonsAfterGroup(root, ++gridRow, + Res.get("dao.wallet.send.sendBtc"), Res.get("dao.wallet.send.inputControl"), Res.get("dao.wallet.send.addOpReturn")); sendBtcButton = tuple.first; btcInputControlButton = tuple.second; + btcOpReturnButton = tuple.third; } private void onBtcInputControl() { @@ -410,6 +444,15 @@ public class BsqSendView extends ActivatableView implements BsqB show(); } + private void onShowPreImageField() { + btcOpReturnButton.setDisable(true); + preImageTextField.setManaged(true); + preImageTextField.setVisible(true); + opReturnDataAsHexBox.setManaged(true); + opReturnDataAsHexBox.setVisible(true); + GridPane.setRowSpan(btcTitledGroupBg, 4); + } + private void setBtcUtxoCandidates(Set candidates) { this.btcUtxoCandidates = candidates; updateBtcValidator(getSpendableBtcBalance()); @@ -430,8 +473,12 @@ public class BsqSendView extends ActivatableView implements BsqB String receiversAddressString = receiversBtcAddressInputTextField.getText(); Coin receiverAmount = bsqFormatter.parseToBTC(btcAmountInputTextField.getText()); try { + byte[] opReturnData = null; + if (preImageTextField.isVisible() && !preImageTextField.getText().trim().isEmpty()) { + opReturnData = getOpReturnData(preImageTextField.getText()); + } Transaction preparedSendTx = bsqWalletService.getPreparedSendBtcTx(receiversAddressString, receiverAmount, btcUtxoCandidates); - Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx); + Transaction txWithBtcFee = btcWalletService.completePreparedBsqTx(preparedSendTx, opReturnData); Transaction signedTx = bsqWalletService.signTxAndVerifyNoDustOutputs(txWithBtcFee); Coin miningFee = signedTx.getFee(); @@ -449,6 +496,7 @@ public class BsqSendView extends ActivatableView implements BsqB () -> { receiversBtcAddressInputTextField.setText(""); btcAmountInputTextField.setText(""); + preImageTextField.clear(); receiversBtcAddressInputTextField.resetValidation(); btcAmountInputTextField.resetValidation(); @@ -463,6 +511,30 @@ public class BsqSendView extends ActivatableView implements BsqB } } + private byte[] getOpReturnData(String preImageAsString) { + byte[] opReturnData; + try { + // If preImage is hex encoded we use it directly + opReturnData = Hex.decode(preImageAsString); + } catch (Throwable ignore) { + opReturnData = preImageAsString.getBytes(Charsets.UTF_8); + } + + // If too long for OpReturn we hash it + if (opReturnData.length > 80) { + opReturnData = Hash.getSha256Ripemd160hash(opReturnData); + opReturnDataAsHexLabel.setText(Res.get("dao.wallet.send.opReturnAsHash")); + } else { + opReturnDataAsHexLabel.setText(Res.get("dao.wallet.send.opReturnAsHex")); + } + + return opReturnData; + } + + private String getOpReturnDataAsHexFromPreImage(String preImage) { + return Hex.encode(getOpReturnData(preImage)); + } + private void handleError(Throwable t) { if (t instanceof InsufficientMoneyException) { final Coin missingCoin = ((InsufficientMoneyException) t).missing; diff --git a/desktop/src/main/java/bisq/desktop/main/overlays/Overlay.java b/desktop/src/main/java/bisq/desktop/main/overlays/Overlay.java index 764e0eda5c..77796da6b6 100644 --- a/desktop/src/main/java/bisq/desktop/main/overlays/Overlay.java +++ b/desktop/src/main/java/bisq/desktop/main/overlays/Overlay.java @@ -762,7 +762,7 @@ public abstract class Overlay> { copyIcon.getStyleClass().add("popup-icon-information"); copyIcon.setManaged(true); copyIcon.setVisible(true); - FormBuilder.getIconForLabel(AwesomeIcon.COPY, copyIcon, "1.5em"); + FormBuilder.getIconForLabel(AwesomeIcon.COPY, copyIcon, "1.1em"); copyIcon.addEventHandler(MOUSE_CLICKED, mouseEvent -> { if (message != null) { String forClipboard = headLineLabel.getText() + System.lineSeparator() + message diff --git a/desktop/src/main/java/bisq/desktop/main/overlays/windows/DisputeSummaryWindow.java b/desktop/src/main/java/bisq/desktop/main/overlays/windows/DisputeSummaryWindow.java index 15fa5102f5..55cfebac1d 100644 --- a/desktop/src/main/java/bisq/desktop/main/overlays/windows/DisputeSummaryWindow.java +++ b/desktop/src/main/java/bisq/desktop/main/overlays/windows/DisputeSummaryWindow.java @@ -844,7 +844,14 @@ public class DisputeSummaryWindow extends Overlay { if (throwable == null) { try { refundManager.verifyTradeTxChain(txList); - doCloseIfValid(closeTicketButton); + + if (!dispute.isUsingLegacyBurningMan()) { + Transaction delayedPayoutTx = txList.get(3); + refundManager.verifyDelayedPayoutTxReceivers(delayedPayoutTx, dispute); + doCloseIfValid(closeTicketButton); + } else { + doCloseIfValid(closeTicketButton); + } } catch (Throwable error) { UserThread.runAfter(() -> new Popup().warning(Res.get("disputeSummaryWindow.delayedPayoutTxVerificationFailed", error.getMessage())) @@ -870,8 +877,10 @@ public class DisputeSummaryWindow extends Overlay { private void doCloseIfValid(Button closeTicketButton) { var disputeManager = checkNotNull(getDisputeManager(dispute)); try { - DisputeValidation.validateDonationAddressMatchesAnyPastParamValues(dispute, dispute.getDonationAddressOfDelayedPayoutTx(), daoFacade); DisputeValidation.testIfDisputeTriesReplay(dispute, disputeManager.getDisputesAsObservableList()); + if (dispute.isUsingLegacyBurningMan()) { + DisputeValidation.validateDonationAddressMatchesAnyPastParamValues(dispute, dispute.getDonationAddressOfDelayedPayoutTx(), daoFacade); + } doClose(closeTicketButton); } catch (DisputeValidation.AddressException exception) { String addressAsString = dispute.getDonationAddressOfDelayedPayoutTx(); diff --git a/desktop/src/main/java/bisq/desktop/main/overlays/windows/TacWindow.java b/desktop/src/main/java/bisq/desktop/main/overlays/windows/TacWindow.java index 57443a1ed8..dda1a08906 100644 --- a/desktop/src/main/java/bisq/desktop/main/overlays/windows/TacWindow.java +++ b/desktop/src/main/java/bisq/desktop/main/overlays/windows/TacWindow.java @@ -18,8 +18,11 @@ package bisq.desktop.main.overlays.windows; import bisq.desktop.app.BisqApp; +import bisq.desktop.components.AutoTooltipLabel; +import bisq.desktop.components.ExternalHyperlink; import bisq.desktop.components.HyperlinkWithIcon; import bisq.desktop.main.overlays.Overlay; +import bisq.desktop.util.GUIUtil; import bisq.core.locale.Res; @@ -27,15 +30,15 @@ import com.google.inject.Inject; import javafx.stage.Screen; +import javafx.scene.control.Label; import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; import javafx.geometry.Insets; import javafx.geometry.Rectangle2D; import lombok.extern.slf4j.Slf4j; -import static bisq.desktop.util.FormBuilder.addHyperlinkWithIcon; - @Slf4j public class TacWindow extends Overlay { @@ -50,9 +53,9 @@ public class TacWindow extends Overlay { smallScreen = primaryScreenBoundsWidth < 1024; if (smallScreen) { this.width = primaryScreenBoundsWidth * 0.8; - log.warn("Very small screen: primaryScreenBounds=" + primaryScreenBounds.toString()); + log.warn("Very small screen: primaryScreenBounds=" + primaryScreenBounds); } else { - width = 1100; + width = 1250; } } @@ -72,7 +75,7 @@ public class TacWindow extends Overlay { "2. The user is responsible for using the software in compliance with local laws. Don't use the software if using it is not legal in your jurisdiction.\n\n" + - "3. Any " + Res.getBaseCurrencyName() + " market prices, network fee estimates, or other data obtained from servers operated by the Bisq DAO is provided on an 'as is, as available' basis without representation or warranty of any kind. It is your responsibility to verify any data provided in regards to inaccuracies or omissions.\n\n" + + "3. Any market prices, network fee estimates, or other data obtained from servers operated by the Bisq DAO is provided on an 'as is, as available' basis without representation or warranty of any kind. It is your responsibility to verify any data provided in regards to inaccuracies or omissions.\n\n" + "4. Any Fiat payment method carries a potential risk for bank chargeback. By accepting the \"User Agreement\" the user confirms " + "to be aware of those risks and in no case will claim legal responsibility to the authors or copyright holders of the software.\n\n" + @@ -82,19 +85,19 @@ public class TacWindow extends Overlay { "The language to be used in the arbitration proceedings shall be English if not otherwise stated.\n\n" + "6. The user confirms that they have read and agreed to the rules regarding the dispute process:\n" + - " - You must complete trades within the maximum duration specified for each payment method.\n" + - " - Leave the \"reason for payment\" field empty. DO NOT put the trade ID or any other text like 'bitcoin', 'BTC', or 'Bisq'.\n" + - " - If the bank of the fiat sender charges fees, the sender (" + Res.getBaseCurrencyCode() + " buyer) has to cover the fees.\n" + - " - You must cooperate with the mediator during the mediation process, and respond to each mediator message within 48 hours.\n" + - " - If either (or both) traders do not accept the mediator's suggested payout, traders can open a refund request from an arbitrator after 10 days in case of altcoin trades\n" + - " and 20 days for fiat trades.\n" + - " - You should only open a refund request from an arbitrator if you think the mediator's suggested payout is unfair, or if your trading peer is unresponsive.\n" + - " - Opening a refund request from an arbitrator triggers the delayed payout transaction, sending all funds from the deposit transaction to the Bisq DAO receiver\n" + - " address ('collateral for refund to avoid scamming the refund process'). At this point, the arbitrator will re-investigate the case and personally refund \n" + - " (at their discretion) the trader who requested arbitration.\n" + - " - The arbitrator may charge a small fee (max. the traders security deposit) as compensation for their work.\n" + - " - The arbitrator will then make a reimbursement request to the Bisq DAO to get reimbursed for the refund they paid to the trader.\n\n" + - "For more details and a general overview please read the full documentation about dispute resolution."; + " - You must complete trades within the trading window.\n" + + " - By default, leave the \"reason for payment\" field empty. NEVER put the trade ID or any other text like 'bitcoin', 'BTC', or 'Bisq'.\n" + + " - If the bank of the fiat sender charges fees, the sender (BTC buyer) has to cover the fees.\n" + + " - In case of mediation, you must cooperate with the mediator and respond to each message within 48 hours.\n" + + " - If either (or both) traders do not accept the mediator's suggested payout, traders can open arbitration after 10 days in case of altcoin trades and after 20 days for fiat trades.\n" + + " - You should only open arbitration if you think the mediator's suggested payout is unfair, or if your trading peer is unresponsive.\n" + + " - By opening arbitration the trader publishes the delayed payout transaction, sending all funds from the deposit transaction to the distributed Bisq Burningmen who have burned BSQ upfront\n" + + " which authorizes them for that role.\n" + + " - The arbitrator will re-investigate the case and decide how the trader(s) should be refunded.\n" + + " - The refund agent will take the payout suggestion from the arbitrator and refund the trader(s) from their own pocket to avoid the extra effort for the traders to do the reimbursement request at\n" + + " the Bisq DAO. The refund agent will make the reimbursement request on behalf of those traders who got refunded directly by them. It is up to the discretion of the refund agent to which\n" + + " amounts they provide this service.\n" + + " - In case a trader is not satisfied with the arbitration result or if the refund agent could not refund directly the trader, they can make a reimbursement request to the Bisq DAO by themself.\n"; message(text); actionButtonText(Res.get("tacWindow.agree")); closeButtonText(Res.get("tacWindow.disagree")); @@ -109,10 +112,18 @@ public class TacWindow extends Overlay { String fontStyleClass = smallScreen ? "small-text" : "normal-text"; messageLabel.getStyleClass().add(fontStyleClass); - HyperlinkWithIcon hyperlinkWithIcon = addHyperlinkWithIcon(gridPane, ++rowIndex, Res.get("tacWindow.arbitrationSystem"), - "https://bisq.wiki/Dispute_resolution"); + Label label = new AutoTooltipLabel(" - For more details and a general overview please read the full documentation about "); + label.getStyleClass().add(fontStyleClass); + + HyperlinkWithIcon hyperlinkWithIcon = new ExternalHyperlink(Res.get("tacWindow.arbitrationSystem").toLowerCase() + "."); + hyperlinkWithIcon.setOnAction(e -> GUIUtil.openWebPage("https://bisq.wiki/Dispute_resolution")); hyperlinkWithIcon.getStyleClass().add(fontStyleClass); - GridPane.setMargin(hyperlinkWithIcon, new Insets(-6, 0, -20, -4)); + HBox.setMargin(hyperlinkWithIcon, new Insets(-0.5, 0, 0, 0)); + + HBox hBox = new HBox(label, hyperlinkWithIcon); + GridPane.setRowIndex(hBox, ++rowIndex); + GridPane.setMargin(hBox, new Insets(-5, 0, -20, 0)); + gridPane.getChildren().add(hBox); } @Override diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesDataModel.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesDataModel.java index 69c0f5bfdd..519dba1295 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesDataModel.java @@ -511,7 +511,7 @@ public class PendingTradesDataModel extends ActivatableDataModel { boolean useRefundAgent = disputeState == Trade.DisputeState.MEDIATION_CLOSED || disputeState == Trade.DisputeState.REFUND_REQUESTED || remainingLockTime <= 0; - AtomicReference donationAddressString = new AtomicReference<>(""); + AtomicReference donationAddressString = new AtomicReference<>(null); Transaction delayedPayoutTx = trade.getDelayedPayoutTx(); try { TradeDataValidation.validateDelayedPayoutTx(trade, @@ -568,6 +568,9 @@ public class PendingTradesDataModel extends ActivatableDataModel { dispute.setDelayedPayoutTxId(delayedPayoutTx.getTxId().toString()); } + dispute.setBurningManSelectionHeight(trade.getProcessModel().getBurningManSelectionHeight()); + dispute.setTradeTxFee(trade.getTradeTxFeeAsLong()); + trade.setDisputeState(Trade.DisputeState.MEDIATION_REQUESTED); sendOpenDisputeMessage(disputeManager, resultHandler, dispute); tradeManager.requestPersistence(); @@ -641,6 +644,9 @@ public class PendingTradesDataModel extends ActivatableDataModel { dispute.setDelayedPayoutTxId(delayedPayoutTx.getTxId().toString()); trade.setDisputeState(Trade.DisputeState.REFUND_REQUESTED); + dispute.setBurningManSelectionHeight(trade.getProcessModel().getBurningManSelectionHeight()); + dispute.setTradeTxFee(trade.getTradeTxFeeAsLong()); + ((DisputeProtocol) tradeManager.getTradeProtocol(trade)).onPublishDelayedPayoutTx(() -> { log.info("DelayedPayoutTx published and message sent to peer"); sendOpenDisputeMessage(disputeManager, resultHandler, dispute); diff --git a/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/DisputeAgentView.java b/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/DisputeAgentView.java index ff5dca4fec..62217a98d2 100644 --- a/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/DisputeAgentView.java +++ b/desktop/src/main/java/bisq/desktop/main/support/dispute/agent/DisputeAgentView.java @@ -151,9 +151,8 @@ public abstract class DisputeAgentView extends DisputeView implements MultipleHo } private String getValidationExceptionMessage(DisputeValidation.ValidationException exception) { - Dispute dispute = exception.getDispute(); if (exception instanceof DisputeValidation.AddressException) { - return getAddressExceptionMessage(dispute); + return getAddressExceptionMessage(exception.getDispute()); } else if (exception.getMessage() != null && !exception.getMessage().isEmpty()) { return exception.getMessage(); } else { diff --git a/desktop/src/main/java/bisq/desktop/util/FormBuilder.java b/desktop/src/main/java/bisq/desktop/util/FormBuilder.java index 3d006b7854..33544b5369 100644 --- a/desktop/src/main/java/bisq/desktop/util/FormBuilder.java +++ b/desktop/src/main/java/bisq/desktop/util/FormBuilder.java @@ -195,7 +195,10 @@ public class FormBuilder { return addSimpleMarkdownLabel(gridPane, rowIndex, null, 0); } - public static SimpleMarkdownLabel addSimpleMarkdownLabel(GridPane gridPane, int rowIndex, String markdown, double top) { + public static SimpleMarkdownLabel addSimpleMarkdownLabel(GridPane gridPane, + int rowIndex, + String markdown, + double top) { SimpleMarkdownLabel label = new SimpleMarkdownLabel(markdown); GridPane.setRowIndex(label, rowIndex); @@ -903,7 +906,6 @@ public class FormBuilder { } - /////////////////////////////////////////////////////////////////////////////////////////// // Label + InputTextField + Button /////////////////////////////////////////////////////////////////////////////////////////// @@ -2384,26 +2386,31 @@ public class FormBuilder { } } - public static TableView addTableViewWithHeader(GridPane gridPane, int rowIndex, String headerText) { + public static Tuple2, TitledGroupBg> addTableViewWithHeader(GridPane gridPane, + int rowIndex, + String headerText) { return addTableViewWithHeader(gridPane, rowIndex, headerText, 0, null); } - public static TableView addTableViewWithHeader(GridPane gridPane, - int rowIndex, - String headerText, - String groupStyle) { + public static Tuple2, TitledGroupBg> addTableViewWithHeader(GridPane gridPane, + int rowIndex, + String headerText, + String groupStyle) { return addTableViewWithHeader(gridPane, rowIndex, headerText, 0, groupStyle); } - public static TableView addTableViewWithHeader(GridPane gridPane, int rowIndex, String headerText, int top) { + public static Tuple2, TitledGroupBg> addTableViewWithHeader(GridPane gridPane, + int rowIndex, + String headerText, + int top) { return addTableViewWithHeader(gridPane, rowIndex, headerText, top, null); } - public static TableView addTableViewWithHeader(GridPane gridPane, - int rowIndex, - String headerText, - int top, - String groupStyle) { + public static Tuple2, TitledGroupBg> addTableViewWithHeader(GridPane gridPane, + int rowIndex, + String headerText, + int top, + String groupStyle) { TitledGroupBg titledGroupBg = addTitledGroupBg(gridPane, rowIndex, 1, headerText, top); if (groupStyle != null) titledGroupBg.getStyleClass().add(groupStyle); @@ -2414,7 +2421,40 @@ public class FormBuilder { gridPane.getChildren().add(tableView); tableView.setPlaceholder(new AutoTooltipLabel(Res.get("table.placeholder.noData"))); tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); - return tableView; + return new Tuple2<>(tableView, titledGroupBg); + } + + public static Tuple3, HBox> addTableViewWithHeaderAndFilterField(GridPane gridPane, + int rowIndex, + String headerText, + String filterPromptText, + int top) { + TitledGroupBg titledGroupBg = new TitledGroupBg(); + titledGroupBg.setText(headerText); + + InputTextField filterField = new InputTextField(); + filterField.setLabelFloat(true); + filterField.setPromptText(filterPromptText); + filterField.setMinWidth(200); + + Region spacer = new Region(); + HBox hBox = new HBox(20, titledGroupBg, spacer, filterField); + HBox.setHgrow(spacer, Priority.ALWAYS); + HBox.setMargin(filterField, new Insets(-12, 2, 0, 0)); + + hBox.prefWidthProperty().bind(gridPane.widthProperty()); + GridPane.setRowIndex(hBox, rowIndex); + GridPane.setRowSpan(hBox, 1); + GridPane.setMargin(hBox, new Insets(top + 8, -10, -12, -10)); + gridPane.getChildren().add(hBox); + + TableView tableView = new TableView<>(); + GridPane.setRowIndex(tableView, rowIndex); + GridPane.setMargin(tableView, new Insets(top + 30, -10, 5, -10)); + gridPane.getChildren().add(tableView); + tableView.setPlaceholder(new AutoTooltipLabel(Res.get("table.placeholder.noData"))); + tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); + return new Tuple3<>(filterField, tableView, hBox); } } diff --git a/p2p/src/main/resources/BurningManAccountingStore_BTC_MAINNET b/p2p/src/main/resources/BurningManAccountingStore_BTC_MAINNET new file mode 100644 index 0000000000..ab31521895 Binary files /dev/null and b/p2p/src/main/resources/BurningManAccountingStore_BTC_MAINNET differ diff --git a/proto/src/main/proto/pb.proto b/proto/src/main/proto/pb.proto index a3f13e7101..3e73d5a3c2 100644 --- a/proto/src/main/proto/pb.proto +++ b/proto/src/main/proto/pb.proto @@ -98,6 +98,10 @@ message NetworkEnvelope { BsqSwapFinalizedTxMessage bsq_swap_finalized_tx_message = 59; FileTransferPart file_transfer_part = 60; + + GetAccountingBlocksRequest get_accounting_blocks_request = 61; + GetAccountingBlocksResponse get_accounting_blocks_response = 62; + NewAccountingBlockBroadcastMessage new_accounting_block_broadcast_message = 63; } } @@ -174,6 +178,7 @@ message OfferAvailabilityRequest { repeated int32 supported_capabilities = 4; string uid = 5; bool is_taker_api_user = 6; + int32 burning_man_selection_height = 7; // Added in v 1.9.7 } message OfferAvailabilityResponse { @@ -260,6 +265,7 @@ message InputsForDepositTxRequest { NodeAddress refund_agent_node_address = 25; bytes hash_of_takers_payment_account_payload = 26; string takers_payout_method_id = 27; + int32 burning_man_selection_height = 28; // Added in v 1.9.7 } message InputsForDepositTxResponse { @@ -934,6 +940,8 @@ message Dispute { State state = 28; int64 trade_period_end = 29; map extra_data = 30; + int32 burning_man_selection_height = 31; // Added in v 1.9.7 + int64 trade_tx_fee = 32; // Added in v 1.9.7 } message Attachment { @@ -1488,6 +1496,7 @@ message PersistableEnvelope { IgnoredMailboxMap ignored_mailbox_map = 33; RemovedPayloadsMap removed_payloads_map = 34; BsqBlockStore bsq_block_store = 35; + BurningManAccountingStore burning_man_accounting_store = 36; } } @@ -1810,6 +1819,7 @@ message ProcessModel { int64 buyer_payout_amount_from_mediation = 19; int64 seller_payout_amount_from_mediation = 20; PaymentAccount payment_account = 21; + int32 burning_man_selection_height = 22; // Added in v 1.9.7 } message TradingPeer { @@ -1994,6 +2004,51 @@ message SubAccountMapEntry { repeated PaymentAccount value = 2; } +// Burningman accounting data +message AccountingTxOutput { + uint32 value = 1; + string name = 2; +} + +message AccountingTx { + uint32 type = 1; + repeated AccountingTxOutput outputs = 2; + bytes truncated_tx_id = 3; +} + +message AccountingBlock { + uint32 height = 1; + uint32 time_in_sec = 2; + bytes truncated_hash = 3; + bytes truncated_previous_block_hash = 4; + repeated AccountingTx txs = 5; +} + +message BurningManAccountingStore { + repeated AccountingBlock blocks = 1; +} + +message GetAccountingBlocksRequest { + int32 from_block_height = 1; + int32 nonce = 2; + NodeAddress sender_node_address = 3; + repeated int32 supported_capabilities = 4; +} + +message GetAccountingBlocksResponse { + repeated AccountingBlock blocks = 1; + int32 request_nonce = 2; + string pub_key = 3; + bytes signature = 4; +} + +message NewAccountingBlockBroadcastMessage { + AccountingBlock block = 1; + string pub_key = 2; + bytes signature = 3; +} + +// DAO message BaseBlock { int32 height = 1; int64 time = 2;