From 3253d9d3fbd1ce3e0d6f3f02926d37bf9f51d3b1 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 30 Sep 2020 15:07:53 +0100 Subject: [PATCH 001/231] WIP: initial implementation of AT sleep-until-message (untested) --- src/main/java/org/qortal/at/AT.java | 43 ++++++++++++-- src/main/java/org/qortal/at/QortalATAPI.java | 56 +++++++++++++++++- .../org/qortal/at/QortalFunctionCode.java | 37 ++++++++++++ src/main/java/org/qortal/block/Block.java | 7 ++- src/main/java/org/qortal/data/at/ATData.java | 13 ++++- .../java/org/qortal/data/at/ATStateData.java | 29 ++++++---- .../org/qortal/repository/ATRepository.java | 2 +- .../repository/hsqldb/HSQLDBATRepository.java | 58 ++++++++++++++----- .../hsqldb/HSQLDBDatabaseUpdates.java | 6 ++ .../org/qortal/test/at/AtRepositoryTests.java | 3 +- 10 files changed, 214 insertions(+), 40 deletions(-) diff --git a/src/main/java/org/qortal/at/AT.java b/src/main/java/org/qortal/at/AT.java index e82ab14e..0a5246af 100644 --- a/src/main/java/org/qortal/at/AT.java +++ b/src/main/java/org/qortal/at/AT.java @@ -1,5 +1,7 @@ package org.qortal.at; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import org.ciyam.at.MachineState; @@ -56,12 +58,12 @@ public class AT { this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, codeBytes, codeHash, machineState.isSleeping(), machineState.getSleepUntilHeight(), machineState.isFinished(), machineState.hadFatalError(), - machineState.isFrozen(), machineState.getFrozenBalance()); + machineState.isFrozen(), machineState.getFrozenBalance(), null); byte[] stateData = machineState.toBytes(); byte[] stateHash = Crypto.digest(stateData); - this.atStateData = new ATStateData(atAddress, height, stateData, stateHash, 0L, true); + this.atStateData = new ATStateData(atAddress, height, stateData, stateHash, 0L, true, null); } // Getters / setters @@ -84,13 +86,27 @@ public class AT { this.repository.getATRepository().delete(this.atData.getATAddress()); } + /** + * Potentially execute AT. + *

+ * Note that sleep-until-message support might set/reset + * sleep-related flags/values. + *

+ * {@link #getATStateData()} will return null if nothing happened. + *

+ * @param blockHeight + * @param blockTimestamp + * @return AT-generated transactions, possibly empty + * @throws DataException + */ public List run(int blockHeight, long blockTimestamp) throws DataException { String atAddress = this.atData.getATAddress(); QortalATAPI api = new QortalATAPI(repository, this.atData, blockTimestamp); QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); - byte[] codeBytes = this.atData.getCodeBytes(); + if (!api.willExecute(blockHeight)) + return Collections.emptyList(); // Fetch latest ATStateData for this AT ATStateData latestAtStateData = this.repository.getATRepository().getLatestATState(atAddress); @@ -100,8 +116,10 @@ public class AT { throw new IllegalStateException("No previous AT state data found"); // [Re]create AT machine state using AT state data or from scratch as applicable + byte[] codeBytes = this.atData.getCodeBytes(); MachineState state = MachineState.fromBytes(api, loggerFactory, latestAtStateData.getStateData(), codeBytes); try { + api.preExecute(state); state.execute(); } catch (Exception e) { throw new DataException(String.format("Uncaught exception while running AT '%s'", atAddress), e); @@ -109,9 +127,16 @@ public class AT { byte[] stateData = state.toBytes(); byte[] stateHash = Crypto.digest(stateData); - long atFees = api.calcFinalFees(state); - this.atStateData = new ATStateData(atAddress, blockHeight, stateData, stateHash, atFees, false); + // Nothing happened? + if (state.getSteps() == 0 && Arrays.equals(stateHash, latestAtStateData.getStateHash())) + // this.atStateData will be null + return Collections.emptyList(); + + long atFees = api.calcFinalFees(state); + Long sleepUntilMessageTimestamp = this.atData.getSleepUntilMessageTimestamp(); + + this.atStateData = new ATStateData(atAddress, blockHeight, stateData, stateHash, atFees, false, sleepUntilMessageTimestamp); return api.getTransactions(); } @@ -130,6 +155,10 @@ public class AT { this.atData.setHadFatalError(state.hadFatalError()); this.atData.setIsFrozen(state.isFrozen()); this.atData.setFrozenBalance(state.getFrozenBalance()); + + // Special sleep-until-message support + this.atData.setSleepUntilMessageTimestamp(this.atStateData.getSleepUntilMessageTimestamp()); + this.repository.getATRepository().save(this.atData); } @@ -157,6 +186,10 @@ public class AT { this.atData.setHadFatalError(state.hadFatalError()); this.atData.setIsFrozen(state.isFrozen()); this.atData.setFrozenBalance(state.getFrozenBalance()); + + // Special sleep-until-message support + this.atData.setSleepUntilMessageTimestamp(previousStateData.getSleepUntilMessageTimestamp()); + this.repository.getATRepository().save(this.atData); } diff --git a/src/main/java/org/qortal/at/QortalATAPI.java b/src/main/java/org/qortal/at/QortalATAPI.java index 582b44e2..d70ac9ba 100644 --- a/src/main/java/org/qortal/at/QortalATAPI.java +++ b/src/main/java/org/qortal/at/QortalATAPI.java @@ -32,6 +32,7 @@ import org.qortal.group.Group; import org.qortal.repository.ATRepository; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.ATRepository.NextTransactionInfo; import org.qortal.transaction.AtTransaction; import org.qortal.transaction.Transaction.TransactionType; import org.qortal.utils.Base58; @@ -74,8 +75,46 @@ public class QortalATAPI extends API { return this.transactions; } - public long calcFinalFees(MachineState state) { - return state.getSteps() * this.ciyamAtSettings.feePerStep; + public boolean willExecute(int blockHeight) throws DataException { + // Sleep-until-message/height checking + Long sleepUntilMessageTimestamp = this.atData.getSleepUntilMessageTimestamp(); + + if (sleepUntilMessageTimestamp != null) { + // Quicker to check height, if sleep-until-height also active + Integer sleepUntilHeight = this.atData.getSleepUntilHeight(); + + boolean wakeDueToHeight = sleepUntilHeight != null && blockHeight >= sleepUntilHeight; + + boolean wakeDueToMessage = false; + if (!wakeDueToHeight) { + // No avoiding asking repository + Timestamp previousTxTimestamp = new Timestamp(sleepUntilMessageTimestamp); + NextTransactionInfo nextTransactionInfo = this.repository.getATRepository().findNextTransaction(this.atData.getATAddress(), + previousTxTimestamp.blockHeight, + previousTxTimestamp.transactionSequence); + + wakeDueToMessage = nextTransactionInfo != null; + } + + // Can we skip? + if (!wakeDueToHeight && !wakeDueToMessage) + // this.atStateData will be null + return false; + } + + return true; + } + + public void preExecute(MachineState state) { + // Sleep-until-message/height checking + Long sleepUntilMessageTimestamp = this.atData.getSleepUntilMessageTimestamp(); + + if (sleepUntilMessageTimestamp != null) { + // We've passed checks, so clear sleep-related flags/values + this.setIsSleeping(state, false); + this.setSleepUntilHeight(state, null); + this.atData.setSleepUntilMessageTimestamp(null); + } } // Inherited methods from CIYAM AT API @@ -408,6 +447,10 @@ public class QortalATAPI extends API { // Utility methods + public long calcFinalFees(MachineState state) { + return state.getSteps() * this.ciyamAtSettings.feePerStep; + } + /** Returns partial transaction signature, used to verify we're operating on the same transaction and not naively using block height & sequence. */ public static byte[] partialSignature(byte[] fullSignature) { return Arrays.copyOfRange(fullSignature, 8, 32); @@ -456,6 +499,15 @@ public class QortalATAPI extends API { } } + /*package*/ void sleepUntilMessageOrHeight(MachineState state, long txTimestamp, Long sleepUntilHeight) { + this.setIsSleeping(state, true); + + this.atData.setSleepUntilMessageTimestamp(txTimestamp); + + if (sleepUntilHeight != null) + this.setSleepUntilHeight(state, new Timestamp(sleepUntilHeight).blockHeight); + } + /** Returns AT's account */ /* package */ Account getATAccount() { return new Account(this.repository, this.atData.getATAddress()); diff --git a/src/main/java/org/qortal/at/QortalFunctionCode.java b/src/main/java/org/qortal/at/QortalFunctionCode.java index 67ab5b98..eb407450 100644 --- a/src/main/java/org/qortal/at/QortalFunctionCode.java +++ b/src/main/java/org/qortal/at/QortalFunctionCode.java @@ -84,6 +84,43 @@ public enum QortalFunctionCode { api.setB(state, bBytes); } }, + /** + * Sleep AT until a new message arrives after 'tx-timestamp'.
+ * 0x0503 tx-timestamp + */ + SLEEP_UNTIL_MESSAGE(0x0503, 1, false) { + @Override + protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException { + if (functionData.value1 <= 0) + return; + + long txTimestamp = functionData.value1; + + QortalATAPI api = (QortalATAPI) state.getAPI(); + api.sleepUntilMessageOrHeight(state, txTimestamp, null); + } + }, + /** + * Sleep AT until a new message arrives, after 'tx-timestamp', or height reached.
+ * 0x0504 tx-timestamp height + */ + SLEEP_UNTIL_MESSAGE_OR_HEIGHT(0x0504, 2, false) { + @Override + protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException { + if (functionData.value1 <= 0) + return; + + long txTimestamp = functionData.value1; + + if (functionData.value2 <= 0) + return; + + long sleepUntilHeight = functionData.value2; + + QortalATAPI api = (QortalATAPI) state.getAPI(); + api.sleepUntilMessageOrHeight(state, txTimestamp, sleepUntilHeight); + } + }, /** * Convert address in B to 20-byte value in LSB of B1, and all of B2 & B3.
* 0x0510 diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index b977a613..93441582 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1246,12 +1246,13 @@ public class Block { for (ATData atData : executableATs) { AT at = new AT(this.repository, atData); List atTransactions = at.run(this.blockData.getHeight(), this.blockData.getTimestamp()); + ATStateData atStateData = at.getATStateData(); + // Didn't execute? (e.g. sleeping) + if (atStateData == null) + continue; allAtTransactions.addAll(atTransactions); - - ATStateData atStateData = at.getATStateData(); this.ourAtStates.add(atStateData); - this.ourAtFees += atStateData.getFees(); } diff --git a/src/main/java/org/qortal/data/at/ATData.java b/src/main/java/org/qortal/data/at/ATData.java index 02f79f84..9e977acf 100644 --- a/src/main/java/org/qortal/data/at/ATData.java +++ b/src/main/java/org/qortal/data/at/ATData.java @@ -23,6 +23,7 @@ public class ATData { private boolean isFrozen; @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) private Long frozenBalance; + private Long sleepUntilMessageTimestamp; // Constructors @@ -31,7 +32,8 @@ public class ATData { } public ATData(String ATAddress, byte[] creatorPublicKey, long creation, int version, long assetId, byte[] codeBytes, byte[] codeHash, - boolean isSleeping, Integer sleepUntilHeight, boolean isFinished, boolean hadFatalError, boolean isFrozen, Long frozenBalance) { + boolean isSleeping, Integer sleepUntilHeight, boolean isFinished, boolean hadFatalError, boolean isFrozen, Long frozenBalance, + Long sleepUntilMessageTimestamp) { this.ATAddress = ATAddress; this.creatorPublicKey = creatorPublicKey; this.creation = creation; @@ -45,6 +47,7 @@ public class ATData { this.hadFatalError = hadFatalError; this.isFrozen = isFrozen; this.frozenBalance = frozenBalance; + this.sleepUntilMessageTimestamp = sleepUntilMessageTimestamp; } /** For constructing skeleton ATData with bare minimum info. */ @@ -133,4 +136,12 @@ public class ATData { this.frozenBalance = frozenBalance; } + public Long getSleepUntilMessageTimestamp() { + return this.sleepUntilMessageTimestamp; + } + + public void setSleepUntilMessageTimestamp(Long sleepUntilMessageTimestamp) { + this.sleepUntilMessageTimestamp = sleepUntilMessageTimestamp; + } + } diff --git a/src/main/java/org/qortal/data/at/ATStateData.java b/src/main/java/org/qortal/data/at/ATStateData.java index e689f5ae..ddace8e3 100644 --- a/src/main/java/org/qortal/data/at/ATStateData.java +++ b/src/main/java/org/qortal/data/at/ATStateData.java @@ -10,35 +10,32 @@ public class ATStateData { private Long fees; private boolean isInitial; + // Qortal-AT-specific + private Long sleepUntilMessageTimestamp; + // Constructors /** Create new ATStateData */ - public ATStateData(String ATAddress, Integer height, byte[] stateData, byte[] stateHash, Long fees, boolean isInitial) { + public ATStateData(String ATAddress, Integer height, byte[] stateData, byte[] stateHash, Long fees, + boolean isInitial, Long sleepUntilMessageTimestamp) { this.ATAddress = ATAddress; this.height = height; this.stateData = stateData; this.stateHash = stateHash; this.fees = fees; this.isInitial = isInitial; + this.sleepUntilMessageTimestamp = sleepUntilMessageTimestamp; } /** For recreating per-block ATStateData from repository where not all info is needed */ public ATStateData(String ATAddress, int height, byte[] stateHash, Long fees, boolean isInitial) { - this(ATAddress, height, null, stateHash, fees, isInitial); - } - - /** For creating ATStateData from serialized bytes when we don't have all the info */ - public ATStateData(String ATAddress, byte[] stateHash) { - // This won't ever be initial AT state from deployment as that's never serialized over the network, - // but generated when the DeployAtTransaction is processed locally. - this(ATAddress, null, null, stateHash, null, false); + this(ATAddress, height, null, stateHash, fees, isInitial, null); } /** For creating ATStateData from serialized bytes when we don't have all the info */ public ATStateData(String ATAddress, byte[] stateHash, Long fees) { - // This won't ever be initial AT state from deployment as that's never serialized over the network, - // but generated when the DeployAtTransaction is processed locally. - this(ATAddress, null, null, stateHash, fees, false); + // This won't ever be initial AT state from deployment, as that's never serialized over the network. + this(ATAddress, null, null, stateHash, fees, false, null); } // Getters / setters @@ -72,4 +69,12 @@ public class ATStateData { return this.isInitial; } + public Long getSleepUntilMessageTimestamp() { + return this.sleepUntilMessageTimestamp; + } + + public void setSleepUntilMessageTimestamp(Long sleepUntilMessageTimestamp) { + this.sleepUntilMessageTimestamp = sleepUntilMessageTimestamp; + } + } diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index b21a4909..9209b29e 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -78,7 +78,7 @@ public interface ATRepository { /** * Returns all ATStateData for a given block height. *

- * Unlike getATState, only returns ATStateData saved at the given height. + * Unlike getATState, only returns partial ATStateData saved at the given height. * * @param height * - block height diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index f49da36d..cd7474ed 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -26,7 +26,7 @@ public class HSQLDBATRepository implements ATRepository { public ATData fromATAddress(String atAddress) throws DataException { String sql = "SELECT creator, created_when, version, asset_id, code_bytes, code_hash, " + "is_sleeping, sleep_until_height, is_finished, had_fatal_error, " - + "is_frozen, frozen_balance " + + "is_frozen, frozen_balance, sleep_until_message_timestamp " + "FROM ATs " + "WHERE AT_address = ? LIMIT 1"; @@ -54,8 +54,13 @@ public class HSQLDBATRepository implements ATRepository { if (frozenBalance == 0 && resultSet.wasNull()) frozenBalance = null; + Long sleepUntilMessageTimestamp = resultSet.getLong(12); + if (sleepUntilMessageTimestamp == 0 && resultSet.wasNull()) + sleepUntilMessageTimestamp = null; + return new ATData(atAddress, creatorPublicKey, created, version, assetId, codeBytes, codeHash, - isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance); + isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance, + sleepUntilMessageTimestamp); } catch (SQLException e) { throw new DataException("Unable to fetch AT from repository", e); } @@ -88,7 +93,7 @@ public class HSQLDBATRepository implements ATRepository { public List getAllExecutableATs() throws DataException { String sql = "SELECT AT_address, creator, created_when, version, asset_id, code_bytes, code_hash, " + "is_sleeping, sleep_until_height, had_fatal_error, " - + "is_frozen, frozen_balance " + + "is_frozen, frozen_balance, sleep_until_message_timestamp " + "FROM ATs " + "WHERE is_finished = false " + "ORDER BY created_when ASC"; @@ -122,8 +127,13 @@ public class HSQLDBATRepository implements ATRepository { if (frozenBalance == 0 && resultSet.wasNull()) frozenBalance = null; + Long sleepUntilMessageTimestamp = resultSet.getLong(12); + if (sleepUntilMessageTimestamp == 0 && resultSet.wasNull()) + sleepUntilMessageTimestamp = null; + ATData atData = new ATData(atAddress, creatorPublicKey, created, version, assetId, codeBytes, codeHash, - isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance); + isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance, + sleepUntilMessageTimestamp); executableATs.add(atData); } while (resultSet.next()); @@ -141,7 +151,7 @@ public class HSQLDBATRepository implements ATRepository { sql.append("SELECT AT_address, creator, created_when, version, asset_id, code_bytes, ") .append("is_sleeping, sleep_until_height, is_finished, had_fatal_error, ") - .append("is_frozen, frozen_balance ") + .append("is_frozen, frozen_balance, sleep_until_message_timestamp ") .append("FROM ATs ") .append("WHERE code_hash = ? "); bindParams.add(codeHash); @@ -185,8 +195,13 @@ public class HSQLDBATRepository implements ATRepository { if (frozenBalance == 0 && resultSet.wasNull()) frozenBalance = null; + Long sleepUntilMessageTimestamp = resultSet.getLong(13); + if (sleepUntilMessageTimestamp == 0 && resultSet.wasNull()) + sleepUntilMessageTimestamp = null; + ATData atData = new ATData(atAddress, creatorPublicKey, created, version, assetId, codeBytes, codeHash, - isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance); + isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance, + sleepUntilMessageTimestamp); matchingATs.add(atData); } while (resultSet.next()); @@ -225,7 +240,7 @@ public class HSQLDBATRepository implements ATRepository { .bind("code_bytes", atData.getCodeBytes()).bind("code_hash", atData.getCodeHash()) .bind("is_sleeping", atData.getIsSleeping()).bind("sleep_until_height", atData.getSleepUntilHeight()) .bind("is_finished", atData.getIsFinished()).bind("had_fatal_error", atData.getHadFatalError()).bind("is_frozen", atData.getIsFrozen()) - .bind("frozen_balance", atData.getFrozenBalance()); + .bind("frozen_balance", atData.getFrozenBalance()).bind("sleep_until_message_timestamp", atData.getSleepUntilMessageTimestamp()); try { saveHelper.execute(this.repository); @@ -248,7 +263,7 @@ public class HSQLDBATRepository implements ATRepository { @Override public ATStateData getATStateAtHeight(String atAddress, int height) throws DataException { - String sql = "SELECT state_data, state_hash, fees, is_initial " + String sql = "SELECT state_data, state_hash, fees, is_initial, sleep_until_message_timestamp " + "FROM ATStates " + "LEFT OUTER JOIN ATStatesData USING (AT_address, height) " + "WHERE ATStates.AT_address = ? AND ATStates.height = ? " @@ -263,7 +278,11 @@ public class HSQLDBATRepository implements ATRepository { long fees = resultSet.getLong(3); boolean isInitial = resultSet.getBoolean(4); - return new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial); + Long sleepUntilMessageTimestamp = resultSet.getLong(5); + if (sleepUntilMessageTimestamp == 0 && resultSet.wasNull()) + sleepUntilMessageTimestamp = null; + + return new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial, sleepUntilMessageTimestamp); } catch (SQLException e) { throw new DataException("Unable to fetch AT state from repository", e); } @@ -271,7 +290,7 @@ public class HSQLDBATRepository implements ATRepository { @Override public ATStateData getLatestATState(String atAddress) throws DataException { - String sql = "SELECT height, state_data, state_hash, fees, is_initial " + String sql = "SELECT height, state_data, state_hash, fees, is_initial, sleep_until_message_timestamp " + "FROM ATStates " + "JOIN ATStatesData USING (AT_address, height) " + "WHERE ATStates.AT_address = ? " @@ -290,7 +309,11 @@ public class HSQLDBATRepository implements ATRepository { long fees = resultSet.getLong(4); boolean isInitial = resultSet.getBoolean(5); - return new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial); + Long sleepUntilMessageTimestamp = resultSet.getLong(6); + if (sleepUntilMessageTimestamp == 0 && resultSet.wasNull()) + sleepUntilMessageTimestamp = null; + + return new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial, sleepUntilMessageTimestamp); } catch (SQLException e) { throw new DataException("Unable to fetch latest AT state from repository", e); } @@ -303,10 +326,10 @@ public class HSQLDBATRepository implements ATRepository { StringBuilder sql = new StringBuilder(1024); List bindParams = new ArrayList<>(); - sql.append("SELECT AT_address, height, state_data, state_hash, fees, is_initial " + sql.append("SELECT AT_address, height, state_data, state_hash, fees, is_initial, FinalATStates.sleep_until_message_timestamp " + "FROM ATs " + "CROSS JOIN LATERAL(" - + "SELECT height, state_data, state_hash, fees, is_initial " + + "SELECT height, state_data, state_hash, fees, is_initial, sleep_until_message_timestamp " + "FROM ATStates " + "JOIN ATStatesData USING (AT_address, height) " + "WHERE ATStates.AT_address = ATs.AT_address "); @@ -360,7 +383,11 @@ public class HSQLDBATRepository implements ATRepository { long fees = resultSet.getLong(5); boolean isInitial = resultSet.getBoolean(6); - ATStateData atStateData = new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial); + Long sleepUntilMessageTimestamp = resultSet.getLong(7); + if (sleepUntilMessageTimestamp == 0 && resultSet.wasNull()) + sleepUntilMessageTimestamp = null; + + ATStateData atStateData = new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial, sleepUntilMessageTimestamp); atStates.add(atStateData); } while (resultSet.next()); @@ -494,7 +521,8 @@ public class HSQLDBATRepository implements ATRepository { atStatesSaver.bind("AT_address", atStateData.getATAddress()).bind("height", atStateData.getHeight()) .bind("state_hash", atStateData.getStateHash()) - .bind("fees", atStateData.getFees()).bind("is_initial", atStateData.isInitial()); + .bind("fees", atStateData.getFees()).bind("is_initial", atStateData.isInitial()) + .bind("sleep_until_message_timestamp", atStateData.getSleepUntilMessageTimestamp()); try { atStatesSaver.execute(this.repository); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index e60616d6..e29d6b44 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -771,6 +771,12 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CHECKPOINT"); break; + case 31: + // AT sleep-until-message support + stmt.execute("ALTER TABLE ATs ADD sleep_until_message_timestamp BIGINT"); + stmt.execute("ALTER TABLE ATStates ADD sleep_until_message_timestamp BIGINT"); + break; + default: // nothing to do return false; diff --git a/src/test/java/org/qortal/test/at/AtRepositoryTests.java b/src/test/java/org/qortal/test/at/AtRepositoryTests.java index 9d19f0eb..d3d477a3 100644 --- a/src/test/java/org/qortal/test/at/AtRepositoryTests.java +++ b/src/test/java/org/qortal/test/at/AtRepositoryTests.java @@ -351,7 +351,8 @@ public class AtRepositoryTests extends Common { /*StateData*/ null, atStateData.getStateHash(), atStateData.getFees(), - atStateData.isInitial()); + atStateData.isInitial(), + atStateData.getSleepUntilMessageTimestamp()); repository.getATRepository().save(newAtStateData); atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); From 7a40c3526f3b9dadd59517c165e4e479d04990dd Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 10 Nov 2020 15:30:54 +0000 Subject: [PATCH 002/231] Bugfixes and tests for SLEEP_UNTIL_MESSAGE --- src/main/java/org/qortal/at/AT.java | 1 + src/main/java/org/qortal/at/QortalATAPI.java | 3 +- .../repository/hsqldb/HSQLDBATRepository.java | 4 +- .../at/SleepUntilMessageOrHeightTests.java | 365 ++++++++++++++++++ .../test/at/SleepUntilMessageTests.java | 311 +++++++++++++++ 5 files changed, 680 insertions(+), 4 deletions(-) create mode 100644 src/test/java/org/qortal/test/at/SleepUntilMessageOrHeightTests.java create mode 100644 src/test/java/org/qortal/test/at/SleepUntilMessageTests.java diff --git a/src/main/java/org/qortal/at/AT.java b/src/main/java/org/qortal/at/AT.java index 0a5246af..99ae57d5 100644 --- a/src/main/java/org/qortal/at/AT.java +++ b/src/main/java/org/qortal/at/AT.java @@ -106,6 +106,7 @@ public class AT { QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); if (!api.willExecute(blockHeight)) + // this.atStateData will be null return Collections.emptyList(); // Fetch latest ATStateData for this AT diff --git a/src/main/java/org/qortal/at/QortalATAPI.java b/src/main/java/org/qortal/at/QortalATAPI.java index d70ac9ba..bb8942cb 100644 --- a/src/main/java/org/qortal/at/QortalATAPI.java +++ b/src/main/java/org/qortal/at/QortalATAPI.java @@ -98,7 +98,6 @@ public class QortalATAPI extends API { // Can we skip? if (!wakeDueToHeight && !wakeDueToMessage) - // this.atStateData will be null return false; } @@ -505,7 +504,7 @@ public class QortalATAPI extends API { this.atData.setSleepUntilMessageTimestamp(txTimestamp); if (sleepUntilHeight != null) - this.setSleepUntilHeight(state, new Timestamp(sleepUntilHeight).blockHeight); + this.setSleepUntilHeight(state, sleepUntilHeight.intValue()); } /** Returns AT's account */ diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index cd7474ed..e45e4794 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -54,7 +54,7 @@ public class HSQLDBATRepository implements ATRepository { if (frozenBalance == 0 && resultSet.wasNull()) frozenBalance = null; - Long sleepUntilMessageTimestamp = resultSet.getLong(12); + Long sleepUntilMessageTimestamp = resultSet.getLong(13); if (sleepUntilMessageTimestamp == 0 && resultSet.wasNull()) sleepUntilMessageTimestamp = null; @@ -127,7 +127,7 @@ public class HSQLDBATRepository implements ATRepository { if (frozenBalance == 0 && resultSet.wasNull()) frozenBalance = null; - Long sleepUntilMessageTimestamp = resultSet.getLong(12); + Long sleepUntilMessageTimestamp = resultSet.getLong(13); if (sleepUntilMessageTimestamp == 0 && resultSet.wasNull()) sleepUntilMessageTimestamp = null; diff --git a/src/test/java/org/qortal/test/at/SleepUntilMessageOrHeightTests.java b/src/test/java/org/qortal/test/at/SleepUntilMessageOrHeightTests.java new file mode 100644 index 00000000..7ac952d2 --- /dev/null +++ b/src/test/java/org/qortal/test/at/SleepUntilMessageOrHeightTests.java @@ -0,0 +1,365 @@ +package org.qortal.test.at; + +import static org.junit.Assert.*; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; + +import org.ciyam.at.CompilationException; +import org.ciyam.at.FunctionCode; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.ciyam.at.Timestamp; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.Account; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.at.QortalFunctionCode; +import org.qortal.block.Block; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.utils.BitTwiddling; + +public class SleepUntilMessageOrHeightTests extends Common { + + private static final byte[] messageData = new byte[] { 0x44 }; + private static final byte[] creationBytes = buildSleepUntilMessageOrHeightAT(); + private static final long fundingAmount = 1_00000000L; + private static final long WAKE_HEIGHT = 10L; + + private Repository repository = null; + private PrivateKeyAccount deployer; + private DeployAtTransaction deployAtTransaction; + private Account atAccount; + private String atAddress; + private byte[] rawNextTimestamp = new byte[32]; + private Transaction transaction; + + @Before + public void before() throws DataException { + Common.useDefaultSettings(); + + this.repository = RepositoryManager.getRepository(); + this.deployer = Common.getTestAccount(repository, "alice"); + + this.deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + this.atAccount = deployAtTransaction.getATAccount(); + this.atAddress = deployAtTransaction.getATAccount().getAddress(); + } + + @After + public void after() throws DataException { + if (this.repository != null) + this.repository.close(); + + this.repository = null; + } + + @Test + public void testDeploy() throws DataException { + // Confirm initial value is zero + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + assertArrayEquals(new byte[32], rawNextTimestamp); + } + + @Test + public void testFeelessSleep() throws DataException { + // Mint block to allow AT to initialize and call SLEEP_UNTIL_MESSAGE + BlockUtils.mintBlock(repository); + + // Fetch AT's balance for this height + long preMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + // Mint block + BlockUtils.mintBlock(repository); + + // Fetch new AT balance + long postMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + assertEquals(preMintBalance, postMintBalance); + } + + @Test + public void testFeelessSleep2() throws DataException { + // Mint block to allow AT to initialize and call SLEEP_UNTIL_MESSAGE + BlockUtils.mintBlock(repository); + + // Fetch AT's balance for this height + long preMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + // Mint several blocks + for (int i = 0; i < 5; ++i) + BlockUtils.mintBlock(repository); + + // Fetch new AT balance + long postMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + assertEquals(preMintBalance, postMintBalance); + } + + @Test + public void testSleepUntilMessage() throws DataException { + // Mint block to allow AT to initialize and call SLEEP_UNTIL_MESSAGE_OR_HEIGHT + BlockUtils.mintBlock(repository); + + // Send message to AT + transaction = sendMessage(repository, deployer, messageData, atAddress); + BlockUtils.mintBlock(repository); + + // Mint block so AT executes and finds message + BlockUtils.mintBlock(repository); + + // Confirm AT finds message + assertTimestamp(repository, atAddress, transaction); + } + + @Test + public void testSleepUntilHeight() throws DataException { + // AT deployment in block 2 + + // Mint block to allow AT to initialize and call SLEEP_UNTIL_MESSAGE_OR_HEIGHT + BlockUtils.mintBlock(repository); // height now 3 + + // Fetch AT's balance for this height + long preMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + // Mint several blocks + for (int i = 3; i < WAKE_HEIGHT; ++i) + BlockUtils.mintBlock(repository); + + // We should now be at WAKE_HEIGHT + long height = repository.getBlockRepository().getBlockchainHeight(); + assertEquals(WAKE_HEIGHT, height); + + // AT should have woken and run at this height so balance should have changed + + // Fetch new AT balance + long postMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + assertNotSame(preMintBalance, postMintBalance); + + // Confirm AT has no message + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + assertArrayEquals(new byte[32], rawNextTimestamp); + + // Mint yet another block + BlockUtils.mintBlock(repository); + + // AT should also have woken and run at this height so balance should have changed + + // Fetch new AT balance + long postMint2Balance = atAccount.getConfirmedBalance(Asset.QORT); + + assertNotSame(postMintBalance, postMint2Balance); + + // Confirm AT still has no message + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + assertArrayEquals(new byte[32], rawNextTimestamp); + + } + + private static byte[] buildSleepUntilMessageOrHeightAT() { + // Labels for data segment addresses + int addrCounter = 0; + + // Beginning of data segment for easy extraction + final int addrNextTx = addrCounter; + addrCounter += 4; + + final int addrNextTxIndex = addrCounter++; + + final int addrLastTxTimestamp = addrCounter++; + + final int addrWakeHeight = addrCounter++; + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + // skip addrNextTx + dataByteBuffer.position(dataByteBuffer.position() + 4 * MachineState.VALUE_SIZE); + + // Store pointer to addrNextTx at addrNextTxIndex + dataByteBuffer.putLong(addrNextTx); + + // skip addrLastTxTimestamp + dataByteBuffer.position(dataByteBuffer.position() + MachineState.VALUE_SIZE); + + // Store fixed wake height (block 10) + dataByteBuffer.putLong(WAKE_HEIGHT); + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + /* Initialization */ + + // Use AT creation 'timestamp' as starting point for finding transactions sent to AT + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for message to AT */ + + /* Sleep until message arrives */ + codeByteBuffer.put(OpCode.EXT_FUN_DAT_2.compile(QortalFunctionCode.SLEEP_UNTIL_MESSAGE_OR_HEIGHT.value, addrLastTxTimestamp, addrWakeHeight)); + + // Find next transaction to this AT since the last one (if any) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); + + // Copy A to data segment, starting at addrNextTx (as pointed to by addrNextTxIndex) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_A_IND, addrNextTxIndex)); + + // Stop if timestamp part of A is zero + codeByteBuffer.put(OpCode.STZ_DAT.compile(addrNextTx)); + + // Update our 'last found transaction's timestamp' using 'timestamp' from transaction + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp)); + + // We're done + codeByteBuffer.put(OpCode.FIN_IMD.compile()); + + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile AT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "Test AT"; + String description = "Test AT"; + String atType = "Test"; + String tags = "TEST"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private void extractNextTxTimestamp(Repository repository, String atAddress, byte[] rawNextTimestamp) throws DataException { + // Check AT result + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + byte[] stateData = atStateData.getStateData(); + + byte[] dataBytes = MachineState.extractDataBytes(stateData); + + System.arraycopy(dataBytes, 0, rawNextTimestamp, 0, rawNextTimestamp.length); + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndImportValid(repository, messageTransactionData, sender); + + return messageTransaction; + } + + private void assertTimestamp(Repository repository, String atAddress, Transaction transaction) throws DataException { + int height = transaction.getHeight(); + byte[] transactionSignature = transaction.getTransactionData().getSignature(); + + BlockData blockData = repository.getBlockRepository().fromHeight(height); + assertNotNull(blockData); + + Block block = new Block(repository, blockData); + + List blockTransactions = block.getTransactions(); + int sequence; + for (sequence = blockTransactions.size() - 1; sequence >= 0; --sequence) + if (Arrays.equals(blockTransactions.get(sequence).getTransactionData().getSignature(), transactionSignature)) + break; + + assertNotSame(-1, sequence); + + byte[] rawNextTimestamp = new byte[32]; + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + + Timestamp expectedTimestamp = new Timestamp(height, sequence); + Timestamp actualTimestamp = new Timestamp(BitTwiddling.longFromBEBytes(rawNextTimestamp, 0)); + + assertEquals(String.format("Expected height %d, seq %d but was height %d, seq %d", + height, sequence, + actualTimestamp.blockHeight, actualTimestamp.transactionSequence + ), + expectedTimestamp.longValue(), + actualTimestamp.longValue()); + + byte[] expectedPartialSignature = new byte[24]; + System.arraycopy(transactionSignature, 8, expectedPartialSignature, 0, expectedPartialSignature.length); + + byte[] actualPartialSignature = new byte[24]; + System.arraycopy(rawNextTimestamp, 8, actualPartialSignature, 0, actualPartialSignature.length); + + assertArrayEquals(expectedPartialSignature, actualPartialSignature); + } + +} diff --git a/src/test/java/org/qortal/test/at/SleepUntilMessageTests.java b/src/test/java/org/qortal/test/at/SleepUntilMessageTests.java new file mode 100644 index 00000000..290f973a --- /dev/null +++ b/src/test/java/org/qortal/test/at/SleepUntilMessageTests.java @@ -0,0 +1,311 @@ +package org.qortal.test.at; + +import static org.junit.Assert.*; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; + +import org.ciyam.at.CompilationException; +import org.ciyam.at.FunctionCode; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.ciyam.at.Timestamp; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.Account; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.at.QortalFunctionCode; +import org.qortal.block.Block; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.utils.BitTwiddling; + +public class SleepUntilMessageTests extends Common { + + private static final byte[] messageData = new byte[] { 0x44 }; + private static final byte[] creationBytes = buildSleepUntilMessageAT(); + private static final long fundingAmount = 1_00000000L; + + private Repository repository = null; + private PrivateKeyAccount deployer; + private DeployAtTransaction deployAtTransaction; + private Account atAccount; + private String atAddress; + private byte[] rawNextTimestamp = new byte[32]; + private Transaction transaction; + + @Before + public void before() throws DataException { + Common.useDefaultSettings(); + + this.repository = RepositoryManager.getRepository(); + this.deployer = Common.getTestAccount(repository, "alice"); + + this.deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + this.atAccount = deployAtTransaction.getATAccount(); + this.atAddress = deployAtTransaction.getATAccount().getAddress(); + } + + @After + public void after() throws DataException { + if (this.repository != null) + this.repository.close(); + + this.repository = null; + } + + @Test + public void testDeploy() throws DataException { + // Confirm initial value is zero + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + assertArrayEquals(new byte[32], rawNextTimestamp); + } + + @Test + public void testFeelessSleep() throws DataException { + // Mint block to allow AT to initialize and call SLEEP_UNTIL_MESSAGE + BlockUtils.mintBlock(repository); + + // Fetch AT's balance for this height + long preMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + // Mint block + BlockUtils.mintBlock(repository); + + // Fetch new AT balance + long postMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + assertEquals(preMintBalance, postMintBalance); + } + + @Test + public void testFeelessSleep2() throws DataException { + // Mint block to allow AT to initialize and call SLEEP_UNTIL_MESSAGE + BlockUtils.mintBlock(repository); + + // Fetch AT's balance for this height + long preMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + // Mint several blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + + // Fetch new AT balance + long postMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + assertEquals(preMintBalance, postMintBalance); + } + + @Test + public void testSleepUntilMessage() throws DataException { + // Mint block to allow AT to initialize and call SLEEP_UNTIL_MESSAGE + BlockUtils.mintBlock(repository); + + // Send message to AT + transaction = sendMessage(repository, deployer, messageData, atAddress); + BlockUtils.mintBlock(repository); + + // Mint block so AT executes and finds message + BlockUtils.mintBlock(repository); + + // Confirm AT finds message + assertTimestamp(repository, atAddress, transaction); + } + + private static byte[] buildSleepUntilMessageAT() { + // Labels for data segment addresses + int addrCounter = 0; + + // Beginning of data segment for easy extraction + final int addrNextTx = addrCounter; + addrCounter += 4; + + final int addrNextTxIndex = addrCounter++; + + final int addrLastTxTimestamp = addrCounter++; + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + // skip addrNextTx + dataByteBuffer.position(dataByteBuffer.position() + 4 * MachineState.VALUE_SIZE); + + // Store pointer to addrNextTx at addrNextTxIndex + dataByteBuffer.putLong(addrNextTx); + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + /* Initialization */ + + // Use AT creation 'timestamp' as starting point for finding transactions sent to AT + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for message to AT */ + + /* Sleep until message arrives */ + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.SLEEP_UNTIL_MESSAGE.value, addrLastTxTimestamp)); + + // Find next transaction to this AT since the last one (if any) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); + + // Copy A to data segment, starting at addrNextTx (as pointed to by addrNextTxIndex) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_A_IND, addrNextTxIndex)); + + // Stop if timestamp part of A is zero + codeByteBuffer.put(OpCode.STZ_DAT.compile(addrNextTx)); + + // Update our 'last found transaction's timestamp' using 'timestamp' from transaction + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp)); + + // We're done + codeByteBuffer.put(OpCode.FIN_IMD.compile()); + + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile AT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "Test AT"; + String description = "Test AT"; + String atType = "Test"; + String tags = "TEST"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private void extractNextTxTimestamp(Repository repository, String atAddress, byte[] rawNextTimestamp) throws DataException { + // Check AT result + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + byte[] stateData = atStateData.getStateData(); + + byte[] dataBytes = MachineState.extractDataBytes(stateData); + + System.arraycopy(dataBytes, 0, rawNextTimestamp, 0, rawNextTimestamp.length); + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndImportValid(repository, messageTransactionData, sender); + + return messageTransaction; + } + + private void assertTimestamp(Repository repository, String atAddress, Transaction transaction) throws DataException { + int height = transaction.getHeight(); + byte[] transactionSignature = transaction.getTransactionData().getSignature(); + + BlockData blockData = repository.getBlockRepository().fromHeight(height); + assertNotNull(blockData); + + Block block = new Block(repository, blockData); + + List blockTransactions = block.getTransactions(); + int sequence; + for (sequence = blockTransactions.size() - 1; sequence >= 0; --sequence) + if (Arrays.equals(blockTransactions.get(sequence).getTransactionData().getSignature(), transactionSignature)) + break; + + assertNotSame(-1, sequence); + + byte[] rawNextTimestamp = new byte[32]; + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + + Timestamp expectedTimestamp = new Timestamp(height, sequence); + Timestamp actualTimestamp = new Timestamp(BitTwiddling.longFromBEBytes(rawNextTimestamp, 0)); + + assertEquals(String.format("Expected height %d, seq %d but was height %d, seq %d", + height, sequence, + actualTimestamp.blockHeight, actualTimestamp.transactionSequence + ), + expectedTimestamp.longValue(), + actualTimestamp.longValue()); + + byte[] expectedPartialSignature = new byte[24]; + System.arraycopy(transactionSignature, 8, expectedPartialSignature, 0, expectedPartialSignature.length); + + byte[] actualPartialSignature = new byte[24]; + System.arraycopy(rawNextTimestamp, 8, actualPartialSignature, 0, actualPartialSignature.length); + + assertArrayEquals(expectedPartialSignature, actualPartialSignature); + } + +} From a9c7142d7b069013719533ffdd80a0cf49c8654e Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 10 Nov 2020 16:46:07 +0000 Subject: [PATCH 003/231] Speed up AT states reshape --- .../hsqldb/HSQLDBDatabaseUpdates.java | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index e29d6b44..a0cc4b85 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -695,7 +695,7 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CHECKPOINT"); break; - case 30: + case 30: { // Split AT state data off to new table for better performance/management. if (!wasPristine && !"mem".equals(HSQLDBRepository.getDbPathname(connection.getMetaData().getURL()))) { @@ -770,12 +770,42 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE ATStatesNew RENAME TO ATStates"); stmt.execute("CHECKPOINT"); break; + } - case 31: + case 31: { // AT sleep-until-message support + LOGGER.info("Altering AT table in repository - this might take a while... (approx. 20 seconds on high-spec)"); stmt.execute("ALTER TABLE ATs ADD sleep_until_message_timestamp BIGINT"); - stmt.execute("ALTER TABLE ATStates ADD sleep_until_message_timestamp BIGINT"); + + // Create new AT-states table with new column + stmt.execute("CREATE TABLE ATStatesNew (" + + "AT_address QortalAddress, height INTEGER NOT NULL, state_hash ATStateHash NOT NULL, " + + "fees QortalAmount NOT NULL, is_initial BOOLEAN NOT NULL, sleep_until_message_timestamp BIGINT, " + + "PRIMARY KEY (AT_address, height), " + + "FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); + stmt.execute("SET TABLE ATStatesNew NEW SPACE"); + stmt.execute("CHECKPOINT"); + + ResultSet resultSet = stmt.executeQuery("SELECT height FROM Blocks ORDER BY height DESC LIMIT 1"); + final int blockchainHeight = resultSet.next() ? resultSet.getInt(1) : 0; + final int heightStep = 100; + + LOGGER.info("Altering AT states table in repository - this might take a while... (approx. 3 mins on high-spec)"); + for (int minHeight = 1; minHeight < blockchainHeight; minHeight += heightStep) { + stmt.execute("INSERT INTO ATStatesNew (" + + "SELECT AT_address, height, state_hash, fees, is_initial, NULL " + + "FROM ATStates " + + "WHERE height BETWEEN " + minHeight + " AND " + (minHeight + heightStep - 1) + + ")"); + stmt.execute("COMMIT"); + } + stmt.execute("CHECKPOINT"); + + stmt.execute("DROP TABLE ATStates"); + stmt.execute("ALTER TABLE ATStatesNew RENAME TO ATStates"); + stmt.execute("CHECKPOINT"); break; + } default: // nothing to do From 744deaed8d46bf3d550e6c21adec6af761fffc09 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 1 Aug 2021 10:39:34 +0100 Subject: [PATCH 004/231] Fixed merge issue due to differing db schemas. --- .../hsqldb/HSQLDBDatabaseUpdates.java | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 12e425c3..6dfef623 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -776,41 +776,6 @@ public class HSQLDBDatabaseUpdates { break; } - case 31: { - // AT sleep-until-message support - LOGGER.info("Altering AT table in repository - this might take a while... (approx. 20 seconds on high-spec)"); - stmt.execute("ALTER TABLE ATs ADD sleep_until_message_timestamp BIGINT"); - - // Create new AT-states table with new column - stmt.execute("CREATE TABLE ATStatesNew (" - + "AT_address QortalAddress, height INTEGER NOT NULL, state_hash ATStateHash NOT NULL, " - + "fees QortalAmount NOT NULL, is_initial BOOLEAN NOT NULL, sleep_until_message_timestamp BIGINT, " - + "PRIMARY KEY (AT_address, height), " - + "FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); - stmt.execute("SET TABLE ATStatesNew NEW SPACE"); - stmt.execute("CHECKPOINT"); - - ResultSet resultSet = stmt.executeQuery("SELECT height FROM Blocks ORDER BY height DESC LIMIT 1"); - final int blockchainHeight = resultSet.next() ? resultSet.getInt(1) : 0; - final int heightStep = 100; - - LOGGER.info("Altering AT states table in repository - this might take a while... (approx. 3 mins on high-spec)"); - for (int minHeight = 1; minHeight < blockchainHeight; minHeight += heightStep) { - stmt.execute("INSERT INTO ATStatesNew (" - + "SELECT AT_address, height, state_hash, fees, is_initial, NULL " - + "FROM ATStates " - + "WHERE height BETWEEN " + minHeight + " AND " + (minHeight + heightStep - 1) - + ")"); - stmt.execute("COMMIT"); - } - stmt.execute("CHECKPOINT"); - - stmt.execute("DROP TABLE ATStates"); - stmt.execute("ALTER TABLE ATStatesNew RENAME TO ATStates"); - stmt.execute("CHECKPOINT"); - break; - } - case 31: // Fix latest AT state cache which was previous created as TEMPORARY stmt.execute("DROP TABLE IF EXISTS LatestATStates"); @@ -858,6 +823,41 @@ public class HSQLDBDatabaseUpdates { + "timestamp_signature Signature NOT NULL, " + TRANSACTION_KEYS + ")"); break; + case 34: { + // AT sleep-until-message support + LOGGER.info("Altering AT table in repository - this might take a while... (approx. 20 seconds on high-spec)"); + stmt.execute("ALTER TABLE ATs ADD sleep_until_message_timestamp BIGINT"); + + // Create new AT-states table with new column + stmt.execute("CREATE TABLE ATStatesNew (" + + "AT_address QortalAddress, height INTEGER NOT NULL, state_hash ATStateHash NOT NULL, " + + "fees QortalAmount NOT NULL, is_initial BOOLEAN NOT NULL, sleep_until_message_timestamp BIGINT, " + + "PRIMARY KEY (AT_address, height), " + + "FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); + stmt.execute("SET TABLE ATStatesNew NEW SPACE"); + stmt.execute("CHECKPOINT"); + + ResultSet resultSet = stmt.executeQuery("SELECT height FROM Blocks ORDER BY height DESC LIMIT 1"); + final int blockchainHeight = resultSet.next() ? resultSet.getInt(1) : 0; + final int heightStep = 100; + + LOGGER.info("Altering AT states table in repository - this might take a while... (approx. 3 mins on high-spec)"); + for (int minHeight = 1; minHeight < blockchainHeight; minHeight += heightStep) { + stmt.execute("INSERT INTO ATStatesNew (" + + "SELECT AT_address, height, state_hash, fees, is_initial, NULL " + + "FROM ATStates " + + "WHERE height BETWEEN " + minHeight + " AND " + (minHeight + heightStep - 1) + + ")"); + stmt.execute("COMMIT"); + } + stmt.execute("CHECKPOINT"); + + stmt.execute("DROP TABLE ATStates"); + stmt.execute("ALTER TABLE ATStatesNew RENAME TO ATStates"); + stmt.execute("CHECKPOINT"); + break; + } + default: // nothing to do return false; From dde47bc1fc0a3d2e8cdbdab8deafe2613251d020 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 1 Aug 2021 18:05:58 +0100 Subject: [PATCH 005/231] Fixed build errors by adding sleepUntilMessageTimestamp to recent method additions. --- .../qortal/repository/hsqldb/HSQLDBATRepository.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 2fd66469..d2461466 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -225,7 +225,7 @@ public class HSQLDBATRepository implements ATRepository { sql.append("SELECT AT_address, creator, created_when, version, asset_id, code_bytes, ") .append("is_sleeping, sleep_until_height, is_finished, had_fatal_error, ") - .append("is_frozen, frozen_balance, code_hash ") + .append("is_frozen, frozen_balance, code_hash, sleep_until_message_timestamp ") .append("FROM "); // (VALUES (?), (?), ...) AS ATCodeHashes (code_hash) @@ -279,9 +279,10 @@ public class HSQLDBATRepository implements ATRepository { frozenBalance = null; byte[] codeHash = resultSet.getBytes(13); + Long sleepUntilMessageTimestamp = resultSet.getLong(14); ATData atData = new ATData(atAddress, creatorPublicKey, created, version, assetId, codeBytes, codeHash, - isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance); + isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance, sleepUntilMessageTimestamp); matchingATs.add(atData); } while (resultSet.next()); @@ -498,7 +499,7 @@ public class HSQLDBATRepository implements ATRepository { StringBuilder sql = new StringBuilder(1024); List bindParams = new ArrayList<>(); - sql.append("SELECT AT_address, height, state_data, state_hash, fees, is_initial " + sql.append("SELECT AT_address, height, state_data, state_hash, fees, is_initial, sleep_until_message_timestamp " + "FROM ATs " + "CROSS JOIN LATERAL(" + "SELECT height, state_data, state_hash, fees, is_initial " @@ -553,8 +554,10 @@ public class HSQLDBATRepository implements ATRepository { byte[] stateHash = resultSet.getBytes(4); long fees = resultSet.getLong(5); boolean isInitial = resultSet.getBoolean(6); + Long sleepUntilMessageTimestamp = resultSet.getLong(7); - ATStateData atStateData = new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial); + ATStateData atStateData = new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial, + sleepUntilMessageTimestamp); atStates.add(atStateData); } while (resultSet.next()); From 68190c8c76df3abdf2a549ed9e69fd176ad66627 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 1 Aug 2021 18:07:19 +0100 Subject: [PATCH 006/231] Fixed a build error relating to using an int rather than Integer in the CIYAM AT library. Solved for now by using 0 instead of null, but will review this again before release. --- src/main/java/org/qortal/at/QortalATAPI.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/at/QortalATAPI.java b/src/main/java/org/qortal/at/QortalATAPI.java index 4cd09e46..0e2ab389 100644 --- a/src/main/java/org/qortal/at/QortalATAPI.java +++ b/src/main/java/org/qortal/at/QortalATAPI.java @@ -111,7 +111,7 @@ public class QortalATAPI extends API { if (sleepUntilMessageTimestamp != null) { // We've passed checks, so clear sleep-related flags/values this.setIsSleeping(state, false); - this.setSleepUntilHeight(state, null); + this.setSleepUntilHeight(state, 0); this.atData.setSleepUntilMessageTimestamp(null); } } From 96a82381d1078d3cfce3089dc600b71e521895e7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 1 Aug 2021 18:19:59 +0100 Subject: [PATCH 007/231] Bump version to 1.6.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 651b7974..4aeb5182 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 1.5.6 + 1.6.0 jar true From 4956c3328c91e0fe1b2e2de12c48330172a05e54 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 1 Aug 2021 19:14:55 +0100 Subject: [PATCH 008/231] Updated AdvancedInstaller project for v1.6.0 --- WindowsInstaller/Qortal.aip | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index 7d69ffb9..fab3d4df 100755 --- a/WindowsInstaller/Qortal.aip +++ b/WindowsInstaller/Qortal.aip @@ -17,10 +17,10 @@ - + - + @@ -212,7 +212,7 @@ - + From 711ad638b848705959bd3f20d362408b3d8d23f6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 2 Aug 2021 09:24:38 +0100 Subject: [PATCH 009/231] Renamed Chinese translation files. zh_SC renamed to zh_CN, and zh_TC renamed to zh_TW. This is necessary for the localization library to locate the files correctly. --- .../i18n/{SysTray_zh_SC.properties => SysTray_zh_CN.properties} | 0 .../i18n/{SysTray_zh_TC.properties => SysTray_zh_TW.properties} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/i18n/{SysTray_zh_SC.properties => SysTray_zh_CN.properties} (100%) rename src/main/resources/i18n/{SysTray_zh_TC.properties => SysTray_zh_TW.properties} (100%) diff --git a/src/main/resources/i18n/SysTray_zh_SC.properties b/src/main/resources/i18n/SysTray_zh_CN.properties similarity index 100% rename from src/main/resources/i18n/SysTray_zh_SC.properties rename to src/main/resources/i18n/SysTray_zh_CN.properties diff --git a/src/main/resources/i18n/SysTray_zh_TC.properties b/src/main/resources/i18n/SysTray_zh_TW.properties similarity index 100% rename from src/main/resources/i18n/SysTray_zh_TC.properties rename to src/main/resources/i18n/SysTray_zh_TW.properties From 797dff475228b2ed3801258c5ef53f59ee247217 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 2 Aug 2021 20:07:34 +0100 Subject: [PATCH 010/231] Added DogecoinACCTv2 and DogecoinACCTv2TradeBot --- .../tradebot/DogecoinACCTv2TradeBot.java | 883 ++++++++++++++++++ .../qortal/controller/tradebot/TradeBot.java | 1 + .../org/qortal/crosschain/DogecoinACCTv2.java | 855 +++++++++++++++++ .../crosschain/SupportedBlockchain.java | 5 +- 4 files changed, 1742 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/qortal/controller/tradebot/DogecoinACCTv2TradeBot.java create mode 100644 src/main/java/org/qortal/crosschain/DogecoinACCTv2.java diff --git a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv2TradeBot.java b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv2TradeBot.java new file mode 100644 index 00000000..a85f0be1 --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv2TradeBot.java @@ -0,0 +1,883 @@ +package org.qortal.controller.tradebot; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bitcoinj.core.*; +import org.bitcoinj.script.Script.ScriptType; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.account.PublicKeyAccount; +import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.asset.Asset; +import org.qortal.crosschain.*; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.DeployAtTransactionTransformer; +import org.qortal.utils.Base58; +import org.qortal.utils.NTP; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toMap; + +/** + * Performing cross-chain trading steps on behalf of user. + *

+ * We deal with three different independent state-spaces here: + *

    + *
  • Qortal blockchain
  • + *
  • Foreign blockchain
  • + *
  • Trade-bot entries
  • + *
+ */ +public class DogecoinACCTv2TradeBot implements AcctTradeBot { + + private static final Logger LOGGER = LogManager.getLogger(DogecoinACCTv2TradeBot.class); + + public enum State implements TradeBot.StateNameAndValueSupplier { + BOB_WAITING_FOR_AT_CONFIRM(10, false, false), + BOB_WAITING_FOR_MESSAGE(15, true, true), + BOB_WAITING_FOR_AT_REDEEM(25, true, true), + BOB_DONE(30, false, false), + BOB_REFUNDED(35, false, false), + + ALICE_WAITING_FOR_AT_LOCK(85, true, true), + ALICE_DONE(95, false, false), + ALICE_REFUNDING_A(105, true, true), + ALICE_REFUNDED(110, false, false); + + private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); + + public final int value; + public final boolean requiresAtData; + public final boolean requiresTradeData; + + State(int value, boolean requiresAtData, boolean requiresTradeData) { + this.value = value; + this.requiresAtData = requiresAtData; + this.requiresTradeData = requiresTradeData; + } + + public static State valueOf(int value) { + return map.get(value); + } + + @Override + public String getState() { + return this.name(); + } + + @Override + public int getStateValue() { + return this.value; + } + } + + /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */ + private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms + + private static DogecoinACCTv2TradeBot instance; + + private final List endStates = Arrays.asList(State.BOB_DONE, State.BOB_REFUNDED, State.ALICE_DONE, State.ALICE_REFUNDING_A, State.ALICE_REFUNDED).stream() + .map(State::name) + .collect(Collectors.toUnmodifiableList()); + + private DogecoinACCTv2TradeBot() { + } + + public static synchronized DogecoinACCTv2TradeBot getInstance() { + if (instance == null) + instance = new DogecoinACCTv2TradeBot(); + + return instance; + } + + @Override + public List getEndStates() { + return this.endStates; + } + + /** + * Creates a new trade-bot entry from the "Bob" viewpoint, i.e. OFFERing QORT in exchange for DOGE. + *

+ * Generates: + *

    + *
  • new 'trade' private key
  • + *
+ * Derives: + *
    + *
  • 'native' (as in Qortal) public key, public key hash, address (starting with Q)
  • + *
  • 'foreign' (as in Dogecoin) public key, public key hash
  • + *
+ * A Qortal AT is then constructed including the following as constants in the 'data segment': + *
    + *
  • 'native'/Qortal 'trade' address - used as a MESSAGE contact
  • + *
  • 'foreign'/Dogecoin public key hash - used by Alice's P2SH scripts to allow redeem
  • + *
  • QORT amount on offer by Bob
  • + *
  • DOGE amount expected in return by Bob (from Alice)
  • + *
  • trading timeout, in case things go wrong and everyone needs to refund
  • + *
+ * Returns a DEPLOY_AT transaction that needs to be signed and broadcast to the Qortal network. + *

+ * Trade-bot will wait for Bob's AT to be deployed before taking next step. + *

+ * @param repository + * @param tradeBotCreateRequest + * @return raw, unsigned DEPLOY_AT transaction + * @throws DataException + */ + public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { + byte[] tradePrivateKey = TradeBot.generateTradePrivateKey(); + + byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); + + byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + + // Convert Dogecoin receiving address into public key hash (we only support P2PKH at this time) + Address dogecoinReceivingAddress; + try { + dogecoinReceivingAddress = Address.fromString(Dogecoin.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress); + } catch (AddressFormatException e) { + throw new DataException("Unsupported Dogecoin receiving address: " + tradeBotCreateRequest.receivingAddress); + } + if (dogecoinReceivingAddress.getOutputScriptType() != ScriptType.P2PKH) + throw new DataException("Unsupported Dogecoin receiving address: " + tradeBotCreateRequest.receivingAddress); + + byte[] dogecoinReceivingAccountInfo = dogecoinReceivingAddress.getHash(); + + PublicKeyAccount creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey); + + // Deploy AT + long timestamp = NTP.getTime(); + byte[] reference = creator.getLastReference(); + long fee = 0L; + byte[] signature = null; + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, creator.getPublicKey(), fee, signature); + + String name = "QORT/DOGE ACCT"; + String description = "QORT/DOGE cross-chain trade"; + String aTType = "ACCT"; + String tags = "ACCT QORT DOGE"; + byte[] creationBytes = DogecoinACCTv2.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, tradeBotCreateRequest.qortAmount, + tradeBotCreateRequest.foreignAmount, tradeBotCreateRequest.tradeTimeout); + long amount = tradeBotCreateRequest.fundingQortAmount; + + DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + DeployAtTransaction.ensureATAddress(deployAtTransactionData); + String atAddress = deployAtTransactionData.getAtAddress(); + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, DogecoinACCTv2.NAME, + State.BOB_WAITING_FOR_AT_CONFIRM.name(), State.BOB_WAITING_FOR_AT_CONFIRM.value, + creator.getAddress(), atAddress, timestamp, tradeBotCreateRequest.qortAmount, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + null, null, + SupportedBlockchain.DOGECOIN.name(), + tradeForeignPublicKey, tradeForeignPublicKeyHash, + tradeBotCreateRequest.foreignAmount, null, null, null, dogecoinReceivingAccountInfo); + + TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Built AT %s. Waiting for deployment", atAddress)); + + // Attempt to backup the trade bot data + TradeBot.backupTradeBotData(repository); + + // Return to user for signing and broadcast as we don't have their Qortal private key + try { + return DeployAtTransactionTransformer.toBytes(deployAtTransactionData); + } catch (TransformationException e) { + throw new DataException("Failed to transform DEPLOY_AT transaction?", e); + } + } + + /** + * Creates a trade-bot entry from the 'Alice' viewpoint, i.e. matching DOGE to an existing offer. + *

+ * Requires a chosen trade offer from Bob, passed by crossChainTradeData + * and access to a Dogecoin wallet via xprv58. + *

+ * The crossChainTradeData contains the current trade offer state + * as extracted from the AT's data segment. + *

+ * Access to a funded wallet is via a Dogecoin BIP32 hierarchical deterministic key, + * passed via xprv58. + * This key will be stored in your node's database + * to allow trade-bot to create/fund the necessary P2SH transactions! + * However, due to the nature of BIP32 keys, it is possible to give the trade-bot + * only a subset of wallet access (see BIP32 for more details). + *

+ * As an example, the xprv58 can be extract from a legacy, password-less + * Electrum wallet by going to the console tab and entering:
+ * wallet.keystore.xprv
+ * which should result in a base58 string starting with either 'xprv' (for Dogecoin main-net) + * or 'tprv' for (Dogecoin test-net). + *

+ * It is envisaged that the value in xprv58 will actually come from a Qortal-UI-managed wallet. + *

+ * If sufficient funds are available, this method will actually fund the P2SH-A + * with the Dogecoin amount expected by 'Bob'. + *

+ * If the Dogecoin transaction is successfully broadcast to the network then + * we also send a MESSAGE to Bob's trade-bot to let them know. + *

+ * The trade-bot entry is saved to the repository and the cross-chain trading process commences. + *

+ * @param repository + * @param crossChainTradeData chosen trade OFFER that Alice wants to match + * @param xprv58 funded wallet xprv in base58 + * @return true if P2SH-A funding transaction successfully broadcast to Dogecoin network, false otherwise + * @throws DataException + */ + public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException { + byte[] tradePrivateKey = TradeBot.generateTradePrivateKey(); + byte[] secretA = TradeBot.generateSecret(); + byte[] hashOfSecretA = Crypto.hash160(secretA); + + byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); + + byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + byte[] receivingPublicKeyHash = Base58.decode(receivingAddress); // Actually the whole address, not just PKH + + // We need to generate lockTime-A: add tradeTimeout to now + long now = NTP.getTime(); + int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (now / 1000L); + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, DogecoinACCTv2.NAME, + State.ALICE_WAITING_FOR_AT_LOCK.name(), State.ALICE_WAITING_FOR_AT_LOCK.value, + receivingAddress, crossChainTradeData.qortalAtAddress, now, crossChainTradeData.qortAmount, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secretA, hashOfSecretA, + SupportedBlockchain.DOGECOIN.name(), + tradeForeignPublicKey, tradeForeignPublicKeyHash, + crossChainTradeData.expectedForeignAmount, xprv58, null, lockTimeA, receivingPublicKeyHash); + + // Attempt to backup the trade bot data + TradeBot.backupTradeBotData(repository); + + // Check we have enough funds via xprv58 to fund P2SH to cover expectedForeignAmount + long p2shFee; + try { + p2shFee = Dogecoin.getInstance().getP2shFee(now); + } catch (ForeignBlockchainException e) { + LOGGER.debug("Couldn't estimate Dogecoin fees?"); + return ResponseResult.NETWORK_ISSUE; + } + + // Fee for redeem/refund is subtracted from P2SH-A balance. + // Do not include fee for funding transaction as this is covered by buildSpend() + long amountA = crossChainTradeData.expectedForeignAmount + p2shFee /*redeeming/refunding P2SH-A*/; + + // P2SH-A to be funded + byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorForeignPKH, hashOfSecretA); + String p2shAddress = Dogecoin.getInstance().deriveP2shAddress(redeemScriptBytes); + + // Build transaction for funding P2SH-A + Transaction p2shFundingTransaction = Dogecoin.getInstance().buildSpend(tradeBotData.getForeignKey(), p2shAddress, amountA); + if (p2shFundingTransaction == null) { + LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?"); + return ResponseResult.BALANCE_ISSUE; + } + + try { + Dogecoin.getInstance().broadcastTransaction(p2shFundingTransaction); + } catch (ForeignBlockchainException e) { + // We couldn't fund P2SH-A at this time + LOGGER.debug("Couldn't broadcast P2SH-A funding transaction?"); + return ResponseResult.NETWORK_ISSUE; + } + + // Attempt to send MESSAGE to Bob's Qortal trade address + byte[] messageData = DogecoinACCTv2.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; + + boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); + if (!isMessageAlreadySent) { + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + + messageTransaction.computeNonce(); + messageTransaction.sign(sender); + + // reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + return ResponseResult.NETWORK_ISSUE; + } + } + + TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); + + return ResponseResult.OK; + } + + @Override + public boolean canDelete(Repository repository, TradeBotData tradeBotData) throws DataException { + State tradeBotState = State.valueOf(tradeBotData.getStateValue()); + if (tradeBotState == null) + return true; + + // If the AT doesn't exist then we might as well let the user tidy up + if (!repository.getATRepository().exists(tradeBotData.getAtAddress())) + return true; + + switch (tradeBotState) { + case BOB_WAITING_FOR_AT_CONFIRM: + case ALICE_DONE: + case BOB_DONE: + case ALICE_REFUNDED: + case BOB_REFUNDED: + return true; + + default: + return false; + } + } + + @Override + public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { + State tradeBotState = State.valueOf(tradeBotData.getStateValue()); + if (tradeBotState == null) { + LOGGER.info(() -> String.format("Trade-bot entry for AT %s has invalid state?", tradeBotData.getAtAddress())); + return; + } + + ATData atData = null; + CrossChainTradeData tradeData = null; + + if (tradeBotState.requiresAtData) { + // Attempt to fetch AT data + atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.debug(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + + if (tradeBotState.requiresTradeData) { + tradeData = DogecoinACCTv2.getInstance().populateTradeData(repository, atData); + if (tradeData == null) { + LOGGER.warn(() -> String.format("Unable to fetch ACCT trade data for AT %s from repository", tradeBotData.getAtAddress())); + return; + } + } + } + + switch (tradeBotState) { + case BOB_WAITING_FOR_AT_CONFIRM: + handleBobWaitingForAtConfirm(repository, tradeBotData); + break; + + case BOB_WAITING_FOR_MESSAGE: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleBobWaitingForMessage(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_WAITING_FOR_AT_LOCK: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleAliceWaitingForAtLock(repository, tradeBotData, atData, tradeData); + break; + + case BOB_WAITING_FOR_AT_REDEEM: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleBobWaitingForAtRedeem(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_DONE: + case BOB_DONE: + break; + + case ALICE_REFUNDING_A: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleAliceRefundingP2shA(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_REFUNDED: + case BOB_REFUNDED: + break; + } + } + + /** + * Trade-bot is waiting for Bob's AT to deploy. + *

+ * If AT is deployed, then trade-bot's next step is to wait for MESSAGE from Alice. + */ + private void handleBobWaitingForAtConfirm(Repository repository, TradeBotData tradeBotData) throws DataException { + if (!repository.getATRepository().exists(tradeBotData.getAtAddress())) { + if (NTP.getTime() - tradeBotData.getTimestamp() <= MAX_AT_CONFIRMATION_PERIOD) + return; + + // We've waited ages for AT to be confirmed into a block but something has gone awry. + // After this long we assume transaction loss so give up with trade-bot entry too. + tradeBotData.setState(State.BOB_REFUNDED.name()); + tradeBotData.setStateValue(State.BOB_REFUNDED.value); + tradeBotData.setTimestamp(NTP.getTime()); + // We delete trade-bot entry here instead of saving, hence not using updateTradeBotState() + repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s never confirmed. Giving up on trade", tradeBotData.getAtAddress())); + TradeBot.notifyStateChange(tradeBotData); + return; + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_MESSAGE, + () -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress())); + } + + /** + * Trade-bot is waiting for MESSAGE from Alice's trade-bot, containing Alice's trade info. + *

+ * It's possible Bob has cancelling his trade offer, receiving an automatic QORT refund, + * in which case trade-bot is done with this specific trade and finalizes on refunded state. + *

+ * Assuming trade is still on offer, trade-bot checks the contents of MESSAGE from Alice's trade-bot. + *

+ * Details from Alice are used to derive P2SH-A address and this is checked for funding balance. + *

+ * Assuming P2SH-A has at least expected Dogecoin balance, + * Bob's trade-bot constructs a zero-fee, PoW MESSAGE to send to Bob's AT with more trade details. + *

+ * On processing this MESSAGE, Bob's AT should switch into 'TRADE' mode and only trade with Alice. + *

+ * Trade-bot's next step is to wait for Alice to redeem the AT, which will allow Bob to + * extract secret-A needed to redeem Alice's P2SH. + * @throws ForeignBlockchainException + */ + private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + // If AT has finished then Bob likely cancelled his trade offer + if (atData.getIsFinished()) { + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, + () -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress())); + return; + } + + Dogecoin dogecoin = Dogecoin.getInstance(); + + String address = tradeBotData.getTradeNativeAddress(); + List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, address, null, null, null); + + for (MessageTransactionData messageTransactionData : messageTransactionsData) { + if (messageTransactionData.isText()) + continue; + + // We're expecting: HASH160(secret-A), Alice's Dogecoin pubkeyhash and lockTime-A + byte[] messageData = messageTransactionData.getData(); + DogecoinACCTv2.OfferMessageData offerMessageData = DogecoinACCTv2.extractOfferMessageData(messageData); + if (offerMessageData == null) + continue; + + byte[] aliceForeignPublicKeyHash = offerMessageData.partnerDogecoinPKH; + byte[] hashOfSecretA = offerMessageData.hashOfSecretA; + int lockTimeA = (int) offerMessageData.lockTimeA; + long messageTimestamp = messageTransactionData.getTimestamp(); + int refundTimeout = DogecoinACCTv2.calcRefundTimeout(messageTimestamp, lockTimeA); + + // Determine P2SH-A address and confirm funded + byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA); + String p2shAddressA = dogecoin.deriveP2shAddress(redeemScriptA); + + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp); + final long minimumAmountA = tradeBotData.getForeignAmount() + p2shFee; + + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // There might be another MESSAGE from someone else with an actually funded P2SH-A... + continue; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // We've already redeemed this? + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE, + () -> String.format("P2SH-A %s already spent? Assuming trade complete", p2shAddressA)); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + // This P2SH-A is burnt, but there might be another MESSAGE from someone else with an actually funded P2SH-A... + continue; + + case FUNDED: + // Fall-through out of switch... + break; + } + + // Good to go - send MESSAGE to AT + + String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey()); + + // Build outgoing message, padding each part to 32 bytes to make it easier for AT to consume + byte[] outgoingMessageData = DogecoinACCTv2.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + String messageRecipient = tradeBotData.getAtAddress(); + + boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, outgoingMessageData); + if (!isMessageAlreadySent) { + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + + outgoingMessageTransaction.computeNonce(); + outgoingMessageTransaction.sign(sender); + + // reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_AT_REDEEM, + () -> String.format("Locked AT %s to %s. Waiting for AT redeem", tradeBotData.getAtAddress(), aliceNativeAddress)); + + return; + } + } + + /** + * Trade-bot is waiting for Bob's AT to switch to TRADE mode and lock trade to Alice only. + *

+ * It's possible that Bob has cancelled his trade offer in the mean time, or that somehow + * this process has taken so long that we've reached P2SH-A's locktime, or that someone else + * has managed to trade with Bob. In any of these cases, trade-bot switches to begin the refunding process. + *

+ * Assuming Bob's AT is locked to Alice, trade-bot checks AT's state data to make sure it is correct. + *

+ * If all is well, trade-bot then redeems AT using Alice's secret-A, releasing Bob's QORT to Alice. + *

+ * In revealing a valid secret-A, Bob can then redeem the DOGE funds from P2SH-A. + *

+ * @throws ForeignBlockchainException + */ + private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData)) + return; + + Dogecoin dogecoin = Dogecoin.getInstance(); + int lockTimeA = tradeBotData.getLockTimeA(); + + // Refund P2SH-A if we've passed lockTime-A + if (NTP.getTime() >= lockTimeA * 1000L) { + byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = dogecoin.deriveP2shAddress(redeemScriptA); + + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + case FUNDED: + break; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // Already redeemed? + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, + () -> String.format("P2SH-A %s already spent? Assuming trade completed", p2shAddressA)); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED, + () -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA)); + return; + + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, + () -> atData.getIsFinished() + ? String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddressA) + : String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddressA)); + + return; + } + + // We're waiting for AT to be in TRADE mode + if (crossChainTradeData.mode != AcctMode.TRADING) + return; + + // AT is in TRADE mode and locked to us as checked by aliceUnexpectedState() above + + // Find our MESSAGE to AT from previous state + List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(tradeBotData.getTradeNativePublicKey(), + crossChainTradeData.qortalCreatorTradeAddress, null, null, null); + if (messageTransactionsData == null || messageTransactionsData.isEmpty()) { + LOGGER.warn(() -> String.format("Unable to find our message to trade creator %s?", crossChainTradeData.qortalCreatorTradeAddress)); + return; + } + + long recipientMessageTimestamp = messageTransactionsData.get(0).getTimestamp(); + int refundTimeout = DogecoinACCTv2.calcRefundTimeout(recipientMessageTimestamp, lockTimeA); + + // Our calculated refundTimeout should match AT's refundTimeout + if (refundTimeout != crossChainTradeData.refundTimeout) { + LOGGER.debug(() -> String.format("Trade AT refundTimeout '%d' doesn't match our refundTimeout '%d'", crossChainTradeData.refundTimeout, refundTimeout)); + // We'll eventually refund + return; + } + + // We're good to redeem AT + + // Send 'redeem' MESSAGE to AT using both secret + byte[] secretA = tradeBotData.getSecret(); + String qortalReceivingAddress = Base58.encode(tradeBotData.getReceivingAccountInfo()); // Actually contains whole address, not just PKH + byte[] messageData = DogecoinACCTv2.buildRedeemMessage(secretA, qortalReceivingAddress); + String messageRecipient = tradeBotData.getAtAddress(); + + boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); + if (!isMessageAlreadySent) { + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + + messageTransaction.computeNonce(); + messageTransaction.sign(sender); + + // Reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, + () -> String.format("Redeeming AT %s. Funds should arrive at %s", + tradeBotData.getAtAddress(), qortalReceivingAddress)); + } + + /** + * Trade-bot is waiting for Alice to redeem Bob's AT, thus revealing secret-A which is required to spend the DOGE funds from P2SH-A. + *

+ * It's possible that Bob's AT has reached its trading timeout and automatically refunded QORT back to Bob. In which case, + * trade-bot is done with this specific trade and finalizes in refunded state. + *

+ * Assuming trade-bot can extract a valid secret-A from Alice's MESSAGE then trade-bot uses that to redeem the DOGE funds from P2SH-A + * to Bob's 'foreign'/Dogecoin trade legacy-format address, as derived from trade private key. + *

+ * (This could potentially be 'improved' to send DOGE to any address of Bob's choosing by changing the transaction output). + *

+ * If trade-bot successfully broadcasts the transaction, then this specific trade is done. + * @throws ForeignBlockchainException + */ + private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + // AT should be 'finished' once Alice has redeemed QORT funds + if (!atData.getIsFinished()) + // Not finished yet + return; + + // If AT is REFUNDED or CANCELLED then something has gone wrong + if (crossChainTradeData.mode == AcctMode.REFUNDED || crossChainTradeData.mode == AcctMode.CANCELLED) { + // Alice hasn't redeemed the QORT, so there is no point in trying to redeem the DOGE + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, + () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); + + return; + } + + byte[] secretA = DogecoinACCTv2.getInstance().findSecretA(repository, crossChainTradeData); + if (secretA == null) { + LOGGER.debug(() -> String.format("Unable to find secret-A from redeem message to AT %s?", tradeBotData.getAtAddress())); + return; + } + + // Use secret-A to redeem P2SH-A + + Dogecoin dogecoin = Dogecoin.getInstance(); + + byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + int lockTimeA = crossChainTradeData.lockTimeA; + byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTimeA, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA); + String p2shAddressA = dogecoin.deriveP2shAddress(redeemScriptA); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // P2SH-A suddenly not funded? Our best bet at this point is to hope for AT auto-refund + return; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // Double-check that we have redeemed P2SH-A... + break; + + case REFUND_IN_PROGRESS: + case REFUNDED: + // Wait for AT to auto-refund + return; + + case FUNDED: { + Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); + ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = dogecoin.getUnspentOutputs(p2shAddressA); + + Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(dogecoin.getNetworkParameters(), redeemAmount, redeemKey, + fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); + + dogecoin.broadcastTransaction(p2shRedeemTransaction); + break; + } + } + + String receivingAddress = dogecoin.pkhToAddress(receivingAccountInfo); + + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE, + () -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress)); + } + + /** + * Trade-bot is attempting to refund P2SH-A. + * @throws ForeignBlockchainException + */ + private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + int lockTimeA = tradeBotData.getLockTimeA(); + + // We can't refund P2SH-A until lockTime-A has passed + if (NTP.getTime() <= lockTimeA * 1000L) + return; + + Dogecoin dogecoin = Dogecoin.getInstance(); + + // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113) + int medianBlockTime = dogecoin.getMedianBlockTime(); + if (medianBlockTime <= lockTimeA) + return; + + byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = dogecoin.deriveP2shAddress(redeemScriptA); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // Still waiting for P2SH-A to be funded... + return; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // Too late! + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, + () -> String.format("P2SH-A %s already spent!", p2shAddressA)); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + break; + + case FUNDED:{ + Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = dogecoin.getUnspentOutputs(p2shAddressA); + + // Determine receive address for refund + String receiveAddress = dogecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + Address receiving = Address.fromString(dogecoin.getNetworkParameters(), receiveAddress); + + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(dogecoin.getNetworkParameters(), refundAmount, refundKey, + fundingOutputs, redeemScriptA, lockTimeA, receiving.getHash()); + + dogecoin.broadcastTransaction(p2shRefundTransaction); + break; + } + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED, + () -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddressA)); + } + + /** + * Returns true if Alice finds AT unexpectedly cancelled, refunded, redeemed or locked to someone else. + *

+ * Will automatically update trade-bot state to ALICE_REFUNDING_A or ALICE_DONE as necessary. + * + * @throws DataException + * @throws ForeignBlockchainException + */ + private boolean aliceUnexpectedState(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + // This is OK + if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.OFFERING) + return false; + + boolean isAtLockedToUs = tradeBotData.getTradeNativeAddress().equals(crossChainTradeData.qortalPartnerAddress); + + if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.TRADING) + if (isAtLockedToUs) { + // AT is trading with us - OK + return false; + } else { + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, + () -> String.format("AT %s trading with someone else: %s. Refunding & aborting trade", tradeBotData.getAtAddress(), crossChainTradeData.qortalPartnerAddress)); + + return true; + } + + if (atData.getIsFinished() && crossChainTradeData.mode == AcctMode.REDEEMED && isAtLockedToUs) { + // We've redeemed already? + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, + () -> String.format("AT %s already redeemed by us. Trade completed", tradeBotData.getAtAddress())); + } else { + // Any other state is not good, so start defensive refund + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, + () -> String.format("AT %s cancelled/refunded/redeemed by someone else/invalid state. Refunding & aborting trade", tradeBotData.getAtAddress())); + } + + return true; + } + + private long calcFeeTimestamp(int lockTimeA, int tradeTimeout) { + return (lockTimeA - tradeTimeout * 60) * 1000L; + } + +} diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index 6e9d1474..47dbe164 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -77,6 +77,7 @@ public class TradeBot implements Listener { acctTradeBotSuppliers.put(BitcoinACCTv1.class, BitcoinACCTv1TradeBot::getInstance); acctTradeBotSuppliers.put(LitecoinACCTv1.class, LitecoinACCTv1TradeBot::getInstance); acctTradeBotSuppliers.put(DogecoinACCTv1.class, DogecoinACCTv1TradeBot::getInstance); + acctTradeBotSuppliers.put(DogecoinACCTv2.class, DogecoinACCTv2TradeBot::getInstance); } private static TradeBot instance; diff --git a/src/main/java/org/qortal/crosschain/DogecoinACCTv2.java b/src/main/java/org/qortal/crosschain/DogecoinACCTv2.java new file mode 100644 index 00000000..d2dc8fcb --- /dev/null +++ b/src/main/java/org/qortal/crosschain/DogecoinACCTv2.java @@ -0,0 +1,855 @@ +package org.qortal.crosschain; + +import com.google.common.hash.HashCode; +import com.google.common.primitives.Bytes; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.ciyam.at.*; +import org.qortal.account.Account; +import org.qortal.asset.Asset; +import org.qortal.at.QortalFunctionCode; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.at.ATStateData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.utils.Base58; +import org.qortal.utils.BitTwiddling; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; + +import static org.ciyam.at.OpCode.calcOffset; + +/** + * Cross-chain trade AT + * + *

+ *

    + *
  • Bob generates Dogecoin & Qortal 'trade' keys + *
      + *
    • private key required to sign P2SH redeem tx
    • + *
    • private key could be used to create 'secret' (e.g. double-SHA256)
    • + *
    • encrypted private key could be stored in Qortal AT for access by Bob from any node
    • + *
    + *
  • + *
  • Bob deploys Qortal AT + *
      + *
    + *
  • + *
  • Alice finds Qortal AT and wants to trade + *
      + *
    • Alice generates Dogecoin & Qortal 'trade' keys
    • + *
    • Alice funds Dogecoin P2SH-A
    • + *
    • Alice sends 'offer' MESSAGE to Bob from her Qortal trade address, containing: + *
        + *
      • hash-of-secret-A
      • + *
      • her 'trade' Dogecoin PKH
      • + *
      + *
    • + *
    + *
  • + *
  • Bob receives "offer" MESSAGE + *
      + *
    • Checks Alice's P2SH-A
    • + *
    • Sends 'trade' MESSAGE to Qortal AT from his trade address, containing: + *
        + *
      • Alice's trade Qortal address
      • + *
      • Alice's trade Dogecoin PKH
      • + *
      • hash-of-secret-A
      • + *
      + *
    • + *
    + *
  • + *
  • Alice checks Qortal AT to confirm it's locked to her + *
      + *
    • Alice sends 'redeem' MESSAGE to Qortal AT from her trade address, containing: + *
        + *
      • secret-A
      • + *
      • Qortal receiving address of her chosing
      • + *
      + *
    • + *
    • AT's QORT funds are sent to Qortal receiving address
    • + *
    + *
  • + *
  • Bob checks AT, extracts secret-A + *
      + *
    • Bob redeems P2SH-A using his Dogecoin trade key and secret-A
    • + *
    • P2SH-A DOGE funds end up at Dogecoin address determined by redeem transaction output(s)
    • + *
    + *
  • + *
+ */ +public class DogecoinACCTv2 implements ACCT { + + private static final Logger LOGGER = LogManager.getLogger(DogecoinACCTv2.class); + + public static final String NAME = DogecoinACCTv2.class.getSimpleName(); + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("0eb49b0313ff3855a29d860c2a8203faa2ef62e28ea30459321f176079cfa3a6").asBytes(); // SHA256 of AT code bytes + + public static final int SECRET_LENGTH = 32; + + /** Value offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */ + private static final int MODE_VALUE_OFFSET = 61; + /** Byte offset into AT state data where 'mode' variable (long) is stored. */ + public static final int MODE_BYTE_OFFSET = MachineState.HEADER_LENGTH + (MODE_VALUE_OFFSET * MachineState.VALUE_SIZE); + + public static class OfferMessageData { + public byte[] partnerDogecoinPKH; + public byte[] hashOfSecretA; + public long lockTimeA; + } + public static final int OFFER_MESSAGE_LENGTH = 20 /*partnerDogecoinPKH*/ + 20 /*hashOfSecretA*/ + 8 /*lockTimeA*/; + public static final int TRADE_MESSAGE_LENGTH = 32 /*partner's Qortal trade address (padded from 25 to 32)*/ + + 24 /*partner's Dogecoin PKH (padded from 20 to 24)*/ + + 8 /*AT trade timeout (minutes)*/ + + 24 /*hash of secret-A (padded from 20 to 24)*/ + + 8 /*lockTimeA*/; + public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret-A*/ + 32 /*partner's Qortal receiving address padded from 25 to 32*/; + public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/; + + private static DogecoinACCTv2 instance; + + private DogecoinACCTv2() { + } + + public static synchronized DogecoinACCTv2 getInstance() { + if (instance == null) + instance = new DogecoinACCTv2(); + + return instance; + } + + @Override + public byte[] getCodeBytesHash() { + return CODE_BYTES_HASH; + } + + @Override + public int getModeByteOffset() { + return MODE_BYTE_OFFSET; + } + + @Override + public ForeignBlockchain getBlockchain() { + return Dogecoin.getInstance(); + } + + /** + * Returns Qortal AT creation bytes for cross-chain trading AT. + *

+ * tradeTimeout (minutes) is the time window for the trade partner to send the + * 32-byte secret to the AT, before the AT automatically refunds the AT's creator. + * + * @param creatorTradeAddress AT creator's trade Qortal address + * @param dogecoinPublicKeyHash 20-byte HASH160 of creator's trade Dogecoin public key + * @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT + * @param dogecoinAmount how much DOGE the AT creator is expecting to trade + * @param tradeTimeout suggested timeout for entire trade + */ + public static byte[] buildQortalAT(String creatorTradeAddress, byte[] dogecoinPublicKeyHash, long qortAmount, long dogecoinAmount, int tradeTimeout) { + if (dogecoinPublicKeyHash.length != 20) + throw new IllegalArgumentException("Dogecoin public key hash should be 20 bytes"); + + // Labels for data segment addresses + int addrCounter = 0; + + // Constants (with corresponding dataByteBuffer.put*() calls below) + + final int addrCreatorTradeAddress1 = addrCounter++; + final int addrCreatorTradeAddress2 = addrCounter++; + final int addrCreatorTradeAddress3 = addrCounter++; + final int addrCreatorTradeAddress4 = addrCounter++; + + final int addrDogecoinPublicKeyHash = addrCounter; + addrCounter += 4; + + final int addrQortAmount = addrCounter++; + final int addrDogecoinAmount = addrCounter++; + final int addrTradeTimeout = addrCounter++; + + final int addrMessageTxnType = addrCounter++; + final int addrExpectedTradeMessageLength = addrCounter++; + final int addrExpectedRedeemMessageLength = addrCounter++; + + final int addrCreatorAddressPointer = addrCounter++; + final int addrQortalPartnerAddressPointer = addrCounter++; + final int addrMessageSenderPointer = addrCounter++; + + final int addrTradeMessagePartnerDogecoinPKHOffset = addrCounter++; + final int addrPartnerDogecoinPKHPointer = addrCounter++; + final int addrTradeMessageHashOfSecretAOffset = addrCounter++; + final int addrHashOfSecretAPointer = addrCounter++; + + final int addrRedeemMessageReceivingAddressOffset = addrCounter++; + + final int addrMessageDataPointer = addrCounter++; + final int addrMessageDataLength = addrCounter++; + + final int addrPartnerReceivingAddressPointer = addrCounter++; + + final int addrEndOfConstants = addrCounter; + + // Variables + + final int addrCreatorAddress1 = addrCounter++; + final int addrCreatorAddress2 = addrCounter++; + final int addrCreatorAddress3 = addrCounter++; + final int addrCreatorAddress4 = addrCounter++; + + final int addrQortalPartnerAddress1 = addrCounter++; + final int addrQortalPartnerAddress2 = addrCounter++; + final int addrQortalPartnerAddress3 = addrCounter++; + final int addrQortalPartnerAddress4 = addrCounter++; + + final int addrLockTimeA = addrCounter++; + final int addrRefundTimeout = addrCounter++; + final int addrRefundTimestamp = addrCounter++; + final int addrLastTxnTimestamp = addrCounter++; + final int addrBlockTimestamp = addrCounter++; + final int addrTxnType = addrCounter++; + final int addrResult = addrCounter++; + + final int addrMessageSender1 = addrCounter++; + final int addrMessageSender2 = addrCounter++; + final int addrMessageSender3 = addrCounter++; + final int addrMessageSender4 = addrCounter++; + + final int addrMessageLength = addrCounter++; + + final int addrMessageData = addrCounter; + addrCounter += 4; + + final int addrHashOfSecretA = addrCounter; + addrCounter += 4; + + final int addrPartnerDogecoinPKH = addrCounter; + addrCounter += 4; + + final int addrPartnerReceivingAddress = addrCounter; + addrCounter += 4; + + final int addrMode = addrCounter++; + assert addrMode == MODE_VALUE_OFFSET : String.format("addrMode %d does not match MODE_VALUE_OFFSET %d", addrMode, MODE_VALUE_OFFSET); + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + // AT creator's trade Qortal address, decoded from Base58 + assert dataByteBuffer.position() == addrCreatorTradeAddress1 * MachineState.VALUE_SIZE : "addrCreatorTradeAddress1 incorrect"; + byte[] creatorTradeAddressBytes = Base58.decode(creatorTradeAddress); + dataByteBuffer.put(Bytes.ensureCapacity(creatorTradeAddressBytes, 32, 0)); + + // Dogecoin public key hash + assert dataByteBuffer.position() == addrDogecoinPublicKeyHash * MachineState.VALUE_SIZE : "addrDogecoinPublicKeyHash incorrect"; + dataByteBuffer.put(Bytes.ensureCapacity(dogecoinPublicKeyHash, 32, 0)); + + // Redeem Qort amount + assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect"; + dataByteBuffer.putLong(qortAmount); + + // Expected Dogecoin amount + assert dataByteBuffer.position() == addrDogecoinAmount * MachineState.VALUE_SIZE : "addrDogecoinAmount incorrect"; + dataByteBuffer.putLong(dogecoinAmount); + + // Suggested trade timeout (minutes) + assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect"; + dataByteBuffer.putLong(tradeTimeout); + + // We're only interested in MESSAGE transactions + assert dataByteBuffer.position() == addrMessageTxnType * MachineState.VALUE_SIZE : "addrMessageTxnType incorrect"; + dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value); + + // Expected length of 'trade' MESSAGE data from AT creator + assert dataByteBuffer.position() == addrExpectedTradeMessageLength * MachineState.VALUE_SIZE : "addrExpectedTradeMessageLength incorrect"; + dataByteBuffer.putLong(TRADE_MESSAGE_LENGTH); + + // Expected length of 'redeem' MESSAGE data from trade partner + assert dataByteBuffer.position() == addrExpectedRedeemMessageLength * MachineState.VALUE_SIZE : "addrExpectedRedeemMessageLength incorrect"; + dataByteBuffer.putLong(REDEEM_MESSAGE_LENGTH); + + // Index into data segment of AT creator's address, used by GET_B_IND + assert dataByteBuffer.position() == addrCreatorAddressPointer * MachineState.VALUE_SIZE : "addrCreatorAddressPointer incorrect"; + dataByteBuffer.putLong(addrCreatorAddress1); + + // Index into data segment of partner's Qortal address, used by SET_B_IND + assert dataByteBuffer.position() == addrQortalPartnerAddressPointer * MachineState.VALUE_SIZE : "addrQortalPartnerAddressPointer incorrect"; + dataByteBuffer.putLong(addrQortalPartnerAddress1); + + // Index into data segment of (temporary) transaction's sender's address, used by GET_B_IND + assert dataByteBuffer.position() == addrMessageSenderPointer * MachineState.VALUE_SIZE : "addrMessageSenderPointer incorrect"; + dataByteBuffer.putLong(addrMessageSender1); + + // Offset into 'trade' MESSAGE data payload for extracting partner's Dogecoin PKH + assert dataByteBuffer.position() == addrTradeMessagePartnerDogecoinPKHOffset * MachineState.VALUE_SIZE : "addrTradeMessagePartnerDogecoinPKHOffset incorrect"; + dataByteBuffer.putLong(32L); + + // Index into data segment of partner's Dogecoin PKH, used by GET_B_IND + assert dataByteBuffer.position() == addrPartnerDogecoinPKHPointer * MachineState.VALUE_SIZE : "addrPartnerDogecoinPKHPointer incorrect"; + dataByteBuffer.putLong(addrPartnerDogecoinPKH); + + // Offset into 'trade' MESSAGE data payload for extracting hash-of-secret-A + assert dataByteBuffer.position() == addrTradeMessageHashOfSecretAOffset * MachineState.VALUE_SIZE : "addrTradeMessageHashOfSecretAOffset incorrect"; + dataByteBuffer.putLong(64L); + + // Index into data segment to hash of secret A, used by GET_B_IND + assert dataByteBuffer.position() == addrHashOfSecretAPointer * MachineState.VALUE_SIZE : "addrHashOfSecretAPointer incorrect"; + dataByteBuffer.putLong(addrHashOfSecretA); + + // Offset into 'redeem' MESSAGE data payload for extracting Qortal receiving address + assert dataByteBuffer.position() == addrRedeemMessageReceivingAddressOffset * MachineState.VALUE_SIZE : "addrRedeemMessageReceivingAddressOffset incorrect"; + dataByteBuffer.putLong(32L); + + // Source location and length for hashing any passed secret + assert dataByteBuffer.position() == addrMessageDataPointer * MachineState.VALUE_SIZE : "addrMessageDataPointer incorrect"; + dataByteBuffer.putLong(addrMessageData); + assert dataByteBuffer.position() == addrMessageDataLength * MachineState.VALUE_SIZE : "addrMessageDataLength incorrect"; + dataByteBuffer.putLong(32L); + + // Pointer into data segment of where to save partner's receiving Qortal address, used by GET_B_IND + assert dataByteBuffer.position() == addrPartnerReceivingAddressPointer * MachineState.VALUE_SIZE : "addrPartnerReceivingAddressPointer incorrect"; + dataByteBuffer.putLong(addrPartnerReceivingAddress); + + assert dataByteBuffer.position() == addrEndOfConstants * MachineState.VALUE_SIZE : "dataByteBuffer position not at end of constants"; + + // Code labels + Integer labelRefund = null; + + Integer labelTradeTxnLoop = null; + Integer labelCheckTradeTxn = null; + Integer labelCheckCancelTxn = null; + Integer labelNotTradeNorCancelTxn = null; + Integer labelCheckNonRefundTradeTxn = null; + Integer labelTradeTxnExtract = null; + Integer labelRedeemTxnLoop = null; + Integer labelCheckRedeemTxn = null; + Integer labelCheckRedeemTxnSender = null; + Integer labelPayout = null; + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(768); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + /* Initialization */ + + /* NOP - to ensure DOGECOIN ACCT is unique */ + codeByteBuffer.put(OpCode.NOP.compile()); + + // Use AT creation 'timestamp' as starting point for finding transactions sent to AT + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxnTimestamp)); + + // Load B register with AT creator's address so we can save it into addrCreatorAddress1-4 + codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B)); + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrCreatorAddressPointer)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for message from AT creator's trade address containing trade partner details, or AT owner's address to cancel offer */ + + /* Transaction processing loop */ + labelTradeTxnLoop = codeByteBuffer.position(); + + // Find next transaction (if any) to this AT since the last one (referenced by addrLastTxnTimestamp) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp)); + // If no transaction found, A will be zero. If A is zero, set addrResult to 1, otherwise 0. + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult)); + // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction + codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckTradeTxn))); + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + + /* Check transaction */ + labelCheckTradeTxn = codeByteBuffer.position(); + + // Update our 'last found transaction's timestamp' using 'timestamp' from transaction + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxnTimestamp)); + // Extract transaction type (message/payment) from transaction and save type in addrTxnType + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType)); + // If transaction type is not MESSAGE type then go look for another transaction + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelTradeTxnLoop))); + + /* Check transaction's sender. We're expecting AT creator's trade address for 'trade' message, or AT creator's own address for 'cancel' message. */ + + // Extract sender address from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B)); + // Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer)); + // Compare each part of message sender's address with AT creator's trade address. If they don't match, check for cancel situation. + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorTradeAddress1, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorTradeAddress2, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorTradeAddress3, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorTradeAddress4, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + // Message sender's address matches AT creator's trade address so go process 'trade' message + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelCheckNonRefundTradeTxn == null ? 0 : labelCheckNonRefundTradeTxn)); + + /* Checking message sender for possible cancel message */ + labelCheckCancelTxn = codeByteBuffer.position(); + + // Compare each part of message sender's address with AT creator's address. If they don't match, look for another transaction. + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorAddress1, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorAddress2, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorAddress3, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + // Partner address is AT creator's address, so cancel offer and finish. + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.CANCELLED.value)); + // We're finished forever (finishing auto-refunds remaining balance to AT creator) + codeByteBuffer.put(OpCode.FIN_IMD.compile()); + + /* Not trade nor cancel message */ + labelNotTradeNorCancelTxn = codeByteBuffer.position(); + + // Loop to find another transaction + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop)); + + /* Possible switch-to-trade-mode message */ + labelCheckNonRefundTradeTxn = codeByteBuffer.position(); + + // Check 'trade' message we received has expected number of message bytes + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength)); + // If message length matches, branch to info extraction code + codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedTradeMessageLength, calcOffset(codeByteBuffer, labelTradeTxnExtract))); + // Message length didn't match - go back to finding another 'trade' MESSAGE transaction + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop)); + + /* Extracting info from 'trade' MESSAGE transaction */ + labelTradeTxnExtract = codeByteBuffer.position(); + + // Extract message from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B)); + // Save B register into data segment starting at addrQortalPartnerAddress1 (as pointed to by addrQortalPartnerAddressPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalPartnerAddressPointer)); + + // Extract trade partner's Dogecoin public key hash (PKH) from message into B + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessagePartnerDogecoinPKHOffset)); + // Store partner's Dogecoin PKH (we only really use values from B1-B3) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerDogecoinPKHPointer)); + // Extract AT trade timeout (minutes) (from B4) + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrRefundTimeout)); + + // Grab next 32 bytes + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessageHashOfSecretAOffset)); + + // Extract hash-of-secret-A (we only really use values from B1-B3) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrHashOfSecretAPointer)); + // Extract lockTime-A (from B4) + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrLockTimeA)); + + // Calculate trade timeout refund 'timestamp' by adding addrRefundTimeout minutes to this transaction's 'timestamp', then save into addrRefundTimestamp + codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxnTimestamp, addrRefundTimeout)); + + /* We are in 'trade mode' */ + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.TRADING.value)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for trade timeout or 'redeem' MESSAGE from Qortal trade partner */ + + // Fetch current block 'timestamp' + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_BLOCK_TIMESTAMP, addrBlockTimestamp)); + // If we're not past refund 'timestamp' then look for next transaction + codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrRefundTimestamp, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + // We're past refund 'timestamp' so go refund everything back to AT creator + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund)); + + /* Transaction processing loop */ + labelRedeemTxnLoop = codeByteBuffer.position(); + + // Find next transaction to this AT since the last one (if any) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp)); + // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0. + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult)); + // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction + codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckRedeemTxn))); + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + + /* Check transaction */ + labelCheckRedeemTxn = codeByteBuffer.position(); + + // Update our 'last found transaction's timestamp' using 'timestamp' from transaction + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxnTimestamp)); + // Extract transaction type (message/payment) from transaction and save type in addrTxnType + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType)); + // If transaction type is not MESSAGE type then go look for another transaction + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + + /* Check message payload length */ + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength)); + // If message length matches, branch to sender checking code + codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedRedeemMessageLength, calcOffset(codeByteBuffer, labelCheckRedeemTxnSender))); + // Message length didn't match - go back to finding another 'redeem' MESSAGE transaction + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); + + /* Check transaction's sender */ + labelCheckRedeemTxnSender = codeByteBuffer.position(); + + // Extract sender address from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B)); + // Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer)); + // Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction. + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalPartnerAddress1, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalPartnerAddress2, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalPartnerAddress3, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalPartnerAddress4, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + + /* Check 'secret-A' in transaction's message */ + + // Extract secret-A from first 32 bytes of message from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B)); + // Save B register into data segment starting at addrMessageData (as pointed to by addrMessageDataPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageDataPointer)); + // Load B register with expected hash result (as pointed to by addrHashOfSecretAPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrHashOfSecretAPointer)); + // Perform HASH160 using source data at addrMessageData. (Location and length specified via addrMessageDataPointer and addrMessageDataLength). + // Save the equality result (1 if they match, 0 otherwise) into addrResult. + codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrResult, addrMessageDataPointer, addrMessageDataLength)); + // If hashes don't match, addrResult will be zero so go find another transaction + codeByteBuffer.put(OpCode.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelPayout))); + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); + + /* Success! Pay arranged amount to receiving address */ + labelPayout = codeByteBuffer.position(); + + // Extract Qortal receiving address from next 32 bytes of message from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageReceivingAddressOffset)); + // Save B register into data segment starting at addrPartnerReceivingAddress (as pointed to by addrPartnerReceivingAddressPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerReceivingAddressPointer)); + // Pay AT's balance to receiving address + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount)); + // Set redeemed mode + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REDEEMED.value)); + // We're finished forever (finishing auto-refunds remaining balance to AT creator) + codeByteBuffer.put(OpCode.FIN_IMD.compile()); + + // Fall-through to refunding any remaining balance back to AT creator + + /* Refund balance back to AT creator */ + labelRefund = codeByteBuffer.position(); + + // Set refunded mode + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REFUNDED.value)); + // We're finished forever (finishing auto-refunds remaining balance to AT creator) + codeByteBuffer.put(OpCode.FIN_IMD.compile()); + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile DOGE-QORT ACCT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + assert Arrays.equals(Crypto.digest(codeBytes), DogecoinACCTv2.CODE_BYTES_HASH) + : String.format("BTCACCT.CODE_BYTES_HASH mismatch: expected %s, actual %s", HashCode.fromBytes(CODE_BYTES_HASH), HashCode.fromBytes(Crypto.digest(codeBytes))); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + /** + * Returns CrossChainTradeData with useful info extracted from AT. + */ + @Override + public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { + ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress()); + return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); + } + + /** + * Returns CrossChainTradeData with useful info extracted from AT. + */ + @Override + public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atStateData.getATAddress()); + return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); + } + + /** + * Returns CrossChainTradeData with useful info extracted from AT. + */ + public CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, long creationTimestamp, ATStateData atStateData) throws DataException { + byte[] addressBytes = new byte[25]; // for general use + String atAddress = atStateData.getATAddress(); + + CrossChainTradeData tradeData = new CrossChainTradeData(); + + tradeData.foreignBlockchain = SupportedBlockchain.DOGECOIN.name(); + tradeData.acctName = NAME; + + tradeData.qortalAtAddress = atAddress; + tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey); + tradeData.creationTimestamp = creationTimestamp; + + Account atAccount = new Account(repository, atAddress); + tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT); + + byte[] stateData = atStateData.getStateData(); + ByteBuffer dataByteBuffer = ByteBuffer.wrap(stateData); + dataByteBuffer.position(MachineState.HEADER_LENGTH); + + /* Constants */ + + // Skip creator's trade address + dataByteBuffer.get(addressBytes); + tradeData.qortalCreatorTradeAddress = Base58.encode(addressBytes); + dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length); + + // Creator's Dogecoin/foreign public key hash + tradeData.creatorForeignPKH = new byte[20]; + dataByteBuffer.get(tradeData.creatorForeignPKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorForeignPKH.length); // skip to 32 bytes + + // We don't use secret-B + tradeData.hashOfSecretB = null; + + // Redeem payout + tradeData.qortAmount = dataByteBuffer.getLong(); + + // Expected DOGE amount + tradeData.expectedForeignAmount = dataByteBuffer.getLong(); + + // Trade timeout + tradeData.tradeTimeout = (int) dataByteBuffer.getLong(); + + // Skip MESSAGE transaction type + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip expected 'trade' message length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip expected 'redeem' message length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to creator's address + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to partner's Qortal trade address + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to message sender + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip 'trade' message data offset for partner's Dogecoin PKH + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to partner's Dogecoin PKH + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip 'trade' message data offset for hash-of-secret-A + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to hash-of-secret-A + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip 'redeem' message data offset for partner's Qortal receiving address + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to message data + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip message data length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to partner's receiving address + dataByteBuffer.position(dataByteBuffer.position() + 8); + + /* End of constants / begin variables */ + + // Skip AT creator's address + dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); + + // Partner's trade address (if present) + dataByteBuffer.get(addressBytes); + String qortalRecipient = Base58.encode(addressBytes); + dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length); + + // Potential lockTimeA (if in trade mode) + int lockTimeA = (int) dataByteBuffer.getLong(); + + // AT refund timeout (probably only useful for debugging) + int refundTimeout = (int) dataByteBuffer.getLong(); + + // Trade-mode refund timestamp (AT 'timestamp' converted to Qortal block height) + long tradeRefundTimestamp = dataByteBuffer.getLong(); + + // Skip last transaction timestamp + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip block timestamp + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip transaction type + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip temporary result + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip temporary message sender + dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); + + // Skip message length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip temporary message data + dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); + + // Potential hash160 of secret A + byte[] hashOfSecretA = new byte[20]; + dataByteBuffer.get(hashOfSecretA); + dataByteBuffer.position(dataByteBuffer.position() + 32 - hashOfSecretA.length); // skip to 32 bytes + + // Potential partner's Dogecoin PKH + byte[] partnerDogecoinPKH = new byte[20]; + dataByteBuffer.get(partnerDogecoinPKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerDogecoinPKH.length); // skip to 32 bytes + + // Partner's receiving address (if present) + byte[] partnerReceivingAddress = new byte[25]; + dataByteBuffer.get(partnerReceivingAddress); + dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerReceivingAddress.length); // skip to 32 bytes + + // Trade AT's 'mode' + long modeValue = dataByteBuffer.getLong(); + AcctMode mode = AcctMode.valueOf((int) (modeValue & 0xffL)); + + /* End of variables */ + + if (mode != null && mode != AcctMode.OFFERING) { + tradeData.mode = mode; + tradeData.refundTimeout = refundTimeout; + tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight; + tradeData.qortalPartnerAddress = qortalRecipient; + tradeData.hashOfSecretA = hashOfSecretA; + tradeData.partnerForeignPKH = partnerDogecoinPKH; + tradeData.lockTimeA = lockTimeA; + + if (mode == AcctMode.REDEEMED) + tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress); + } else { + tradeData.mode = AcctMode.OFFERING; + } + + tradeData.duplicateDeprecated(); + + return tradeData; + } + + /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ + public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { + byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); + return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); + } + + /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ + public static OfferMessageData extractOfferMessageData(byte[] messageData) { + if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH) + return null; + + OfferMessageData offerMessageData = new OfferMessageData(); + offerMessageData.partnerDogecoinPKH = Arrays.copyOfRange(messageData, 0, 20); + offerMessageData.hashOfSecretA = Arrays.copyOfRange(messageData, 20, 40); + offerMessageData.lockTimeA = BitTwiddling.longFromBEBytes(messageData, 40); + + return offerMessageData; + } + + /** Returns 'trade' MESSAGE payload for AT creator to send to AT. */ + public static byte[] buildTradeMessage(String partnerQortalTradeAddress, byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + byte[] data = new byte[TRADE_MESSAGE_LENGTH]; + byte[] partnerQortalAddressBytes = Base58.decode(partnerQortalTradeAddress); + byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); + byte[] refundTimeoutBytes = BitTwiddling.toBEByteArray((long) refundTimeout); + + System.arraycopy(partnerQortalAddressBytes, 0, data, 0, partnerQortalAddressBytes.length); + System.arraycopy(partnerBitcoinPKH, 0, data, 32, partnerBitcoinPKH.length); + System.arraycopy(refundTimeoutBytes, 0, data, 56, refundTimeoutBytes.length); + System.arraycopy(hashOfSecretA, 0, data, 64, hashOfSecretA.length); + System.arraycopy(lockTimeABytes, 0, data, 88, lockTimeABytes.length); + + return data; + } + + /** Returns 'cancel' MESSAGE payload for AT creator to cancel trade AT. */ + @Override + public byte[] buildCancelMessage(String creatorQortalAddress) { + byte[] data = new byte[CANCEL_MESSAGE_LENGTH]; + byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress); + + System.arraycopy(creatorQortalAddressBytes, 0, data, 0, creatorQortalAddressBytes.length); + + return data; + } + + /** Returns 'redeem' MESSAGE payload for trade partner to send to AT. */ + public static byte[] buildRedeemMessage(byte[] secretA, String qortalReceivingAddress) { + byte[] data = new byte[REDEEM_MESSAGE_LENGTH]; + byte[] qortalReceivingAddressBytes = Base58.decode(qortalReceivingAddress); + + System.arraycopy(secretA, 0, data, 0, secretA.length); + System.arraycopy(qortalReceivingAddressBytes, 0, data, 32, qortalReceivingAddressBytes.length); + + return data; + } + + /** Returns refund timeout (minutes) based on trade partner's 'offer' MESSAGE timestamp and P2SH-A locktime. */ + public static int calcRefundTimeout(long offerMessageTimestamp, int lockTimeA) { + // refund should be triggered halfway between offerMessageTimestamp and lockTimeA + return (int) ((lockTimeA - (offerMessageTimestamp / 1000L)) / 2L / 60L); + } + + @Override + public byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { + String atAddress = crossChainTradeData.qortalAtAddress; + String redeemerAddress = crossChainTradeData.qortalPartnerAddress; + + // We don't have partner's public key so we check every message to AT + List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, atAddress, null, null, null); + if (messageTransactionsData == null) + return null; + + // Find 'redeem' message + for (MessageTransactionData messageTransactionData : messageTransactionsData) { + // Check message payload type/encryption + if (messageTransactionData.isText() || messageTransactionData.isEncrypted()) + continue; + + // Check message payload size + byte[] messageData = messageTransactionData.getData(); + if (messageData.length != REDEEM_MESSAGE_LENGTH) + // Wrong payload length + continue; + + // Check sender + if (!Crypto.toAddress(messageTransactionData.getSenderPublicKey()).equals(redeemerAddress)) + // Wrong sender; + continue; + + // Extract secretA + byte[] secretA = new byte[32]; + System.arraycopy(messageData, 0, secretA, 0, secretA.length); + + byte[] hashOfSecretA = Crypto.hash160(secretA); + if (!Arrays.equals(hashOfSecretA, crossChainTradeData.hashOfSecretA)) + continue; + + return secretA; + } + + return null; + } + +} diff --git a/src/main/java/org/qortal/crosschain/SupportedBlockchain.java b/src/main/java/org/qortal/crosschain/SupportedBlockchain.java index 1fc8d149..8fa919d3 100644 --- a/src/main/java/org/qortal/crosschain/SupportedBlockchain.java +++ b/src/main/java/org/qortal/crosschain/SupportedBlockchain.java @@ -42,7 +42,8 @@ public enum SupportedBlockchain { }, DOGECOIN(Arrays.asList( - Triple.valueOf(DogecoinACCTv1.NAME, DogecoinACCTv1.CODE_BYTES_HASH, DogecoinACCTv1::getInstance) + Triple.valueOf(DogecoinACCTv1.NAME, DogecoinACCTv1.CODE_BYTES_HASH, DogecoinACCTv1::getInstance), + Triple.valueOf(DogecoinACCTv2.NAME, DogecoinACCTv2.CODE_BYTES_HASH, DogecoinACCTv2::getInstance) )) { @Override public ForeignBlockchain getInstance() { @@ -51,7 +52,7 @@ public enum SupportedBlockchain { @Override public ACCT getLatestAcct() { - return DogecoinACCTv1.getInstance(); + return DogecoinACCTv2.getInstance(); } }; From a1c61a1146455c7dafb0e9afeb11cc93c25fed03 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 2 Aug 2021 20:08:53 +0100 Subject: [PATCH 011/231] Initial attempt at adding "sleep until message" functionality to DOGE ACCTv2. --- src/main/java/org/qortal/crosschain/DogecoinACCTv2.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/crosschain/DogecoinACCTv2.java b/src/main/java/org/qortal/crosschain/DogecoinACCTv2.java index d2dc8fcb..c4b0edb3 100644 --- a/src/main/java/org/qortal/crosschain/DogecoinACCTv2.java +++ b/src/main/java/org/qortal/crosschain/DogecoinACCTv2.java @@ -88,7 +88,7 @@ public class DogecoinACCTv2 implements ACCT { private static final Logger LOGGER = LogManager.getLogger(DogecoinACCTv2.class); public static final String NAME = DogecoinACCTv2.class.getSimpleName(); - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("0eb49b0313ff3855a29d860c2a8203faa2ef62e28ea30459321f176079cfa3a6").asBytes(); // SHA256 of AT code bytes + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("6fff38d6eeb06568a9c879c5628527730319844aa0de53f5f4ffab5506efe885").asBytes(); // SHA256 of AT code bytes public static final int SECRET_LENGTH = 32; @@ -356,6 +356,9 @@ public class DogecoinACCTv2 implements ACCT { /* Transaction processing loop */ labelTradeTxnLoop = codeByteBuffer.position(); + /* Sleep until message arrives */ + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.SLEEP_UNTIL_MESSAGE.value, addrLastTxnTimestamp)); + // Find next transaction (if any) to this AT since the last one (referenced by addrLastTxnTimestamp) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp)); // If no transaction found, A will be zero. If A is zero, set addrResult to 1, otherwise 0. @@ -462,6 +465,9 @@ public class DogecoinACCTv2 implements ACCT { /* Transaction processing loop */ labelRedeemTxnLoop = codeByteBuffer.position(); + /* Sleep until message arrives */ + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.SLEEP_UNTIL_MESSAGE.value, addrLastTxnTimestamp)); + // Find next transaction to this AT since the last one (if any) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp)); // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0. From 961c5ea962d2879e218169aac091390e1f04ae7c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 4 Aug 2021 09:22:17 +0100 Subject: [PATCH 012/231] Treat zero as null in sleepUntilHeight AT data. This is needed because we are unable to call setSleepUntilHeight() with a null value due to the datatype used in the CIYAM AT library. An alternate option would be to fork the AT library and use an Integer or Long rather than an int, but since we don't have a block zero, this is still a valid thing to check even when using that approach. --- src/main/java/org/qortal/at/QortalATAPI.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/at/QortalATAPI.java b/src/main/java/org/qortal/at/QortalATAPI.java index 0e2ab389..c393a684 100644 --- a/src/main/java/org/qortal/at/QortalATAPI.java +++ b/src/main/java/org/qortal/at/QortalATAPI.java @@ -83,7 +83,7 @@ public class QortalATAPI extends API { // Quicker to check height, if sleep-until-height also active Integer sleepUntilHeight = this.atData.getSleepUntilHeight(); - boolean wakeDueToHeight = sleepUntilHeight != null && blockHeight >= sleepUntilHeight; + boolean wakeDueToHeight = sleepUntilHeight != null && sleepUntilHeight != 0 && blockHeight >= sleepUntilHeight; boolean wakeDueToMessage = false; if (!wakeDueToHeight) { From f669e3f6c4ba143894f46e0ad5b5bf1c2f7930a9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 4 Aug 2021 18:48:59 +0100 Subject: [PATCH 013/231] Fixed Dogecoin tests. --- .../org/qortal/test/crosschain/DogecoinTests.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/test/java/org/qortal/test/crosschain/DogecoinTests.java b/src/test/java/org/qortal/test/crosschain/DogecoinTests.java index b6d21315..2b0410c3 100644 --- a/src/test/java/org/qortal/test/crosschain/DogecoinTests.java +++ b/src/test/java/org/qortal/test/crosschain/DogecoinTests.java @@ -35,10 +35,10 @@ public class DogecoinTests extends Common { @Test public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { long before = System.currentTimeMillis(); - System.out.println(String.format("Bitcoin median blocktime: %d", dogecoin.getMedianBlockTime())); + System.out.println(String.format("Dogecoin median blocktime: %d", dogecoin.getMedianBlockTime())); long afterFirst = System.currentTimeMillis(); - System.out.println(String.format("Bitcoin median blocktime: %d", dogecoin.getMedianBlockTime())); + System.out.println(String.format("Dogecoin median blocktime: %d", dogecoin.getMedianBlockTime())); long afterSecond = System.currentTimeMillis(); long firstPeriod = afterFirst - before; @@ -64,10 +64,11 @@ public class DogecoinTests extends Common { } @Test + @Ignore(value = "No testnet nodes available, so we can't regularly test buildSpend yet") public void testBuildSpend() { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + String xprv58 = "dgpv51eADS3spNJh9drNeW1Tc1P9z2LyaQRXPBortsq6yice1k47C2u2Prvgxycr2ihNBWzKZ2LthcBBGiYkWZ69KUTVkcLVbnjq7pD8mnApEru"; - String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + String recipient = "DP1iFao33xdEPa5vaArpj7sykfzKNeiJeX"; long amount = 1000L; Transaction transaction = dogecoin.buildSpend(xprv58, recipient, amount); @@ -81,7 +82,7 @@ public class DogecoinTests extends Common { @Test public void testGetWalletBalance() { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + String xprv58 = "dgpv51eADS3spNJh9drNeW1Tc1P9z2LyaQRXPBortsq6yice1k47C2u2Prvgxycr2ihNBWzKZ2LthcBBGiYkWZ69KUTVkcLVbnjq7pD8mnApEru"; Long balance = dogecoin.getWalletBalance(xprv58); @@ -102,7 +103,7 @@ public class DogecoinTests extends Common { @Test public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + String xprv58 = "dgpv51eADS3spNJh9drNeW1Tc1P9z2LyaQRXPBortsq6yice1k47C2u2Prvgxycr2ihNBWzKZ2LthcBBGiYkWZ69KUTVkcLVbnjq7pD8mnApEru"; String address = dogecoin.getUnusedReceiveAddress(xprv58); From f09677d37622ada0a70b01c3e57d16cbc72f27c6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 4 Aug 2021 18:54:36 +0100 Subject: [PATCH 014/231] Added inputs, outputs and feeAmount to /crosschain//walletbalance endpoints The inputs and outputs contain a simpler version than the ones in the raw transaction, consisting of `address`, `amount`, and `addressInWallet`. The latter of the three is to know whether the address is one that is derived from the supplied xpub master public key. --- .../java/org/qortal/crosschain/Bitcoiny.java | 19 ++++- .../qortal/crosschain/SimpleTransaction.java | 81 ++++++++++++++++++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java index d4693818..3665f4ba 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoiny.java +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -406,14 +406,24 @@ public abstract class Bitcoiny implements ForeignBlockchain { protected SimpleTransaction convertToSimpleTransaction(BitcoinyTransaction t, Set keySet) { long amount = 0; long total = 0L; + long totalInputAmount = 0L; + long totalOutputAmount = 0L; + List inputs = new ArrayList<>(); + List outputs = new ArrayList<>(); + for (BitcoinyTransaction.Input input : t.inputs) { try { BitcoinyTransaction t2 = getTransaction(input.outputTxHash); List senders = t2.outputs.get(input.outputVout).addresses; + long inputAmount = t2.outputs.get(input.outputVout).value; + totalInputAmount += inputAmount; for (String sender : senders) { + boolean addressInWallet = false; if (keySet.contains(sender)) { - total += t2.outputs.get(input.outputVout).value; + total += inputAmount; + addressInWallet = true; } + inputs.add(new SimpleTransaction.Input(sender, inputAmount, addressInWallet)); } } catch (ForeignBlockchainException e) { LOGGER.trace("Failed to retrieve transaction information {}", input.outputTxHash); @@ -422,17 +432,22 @@ public abstract class Bitcoiny implements ForeignBlockchain { if (t.outputs != null && !t.outputs.isEmpty()) { for (BitcoinyTransaction.Output output : t.outputs) { for (String address : output.addresses) { + boolean addressInWallet = false; if (keySet.contains(address)) { if (total > 0L) { amount -= (total - output.value); } else { amount += output.value; } + addressInWallet = true; } + outputs.add(new SimpleTransaction.Output(address, output.value, addressInWallet)); } + totalOutputAmount += output.value; } } - return new SimpleTransaction(t.txHash, t.timestamp, amount); + long fee = totalInputAmount - totalOutputAmount; + return new SimpleTransaction(t.txHash, t.timestamp, amount, fee, inputs, outputs); } /** diff --git a/src/main/java/org/qortal/crosschain/SimpleTransaction.java b/src/main/java/org/qortal/crosschain/SimpleTransaction.java index 0fae20a5..27c9f9e3 100644 --- a/src/main/java/org/qortal/crosschain/SimpleTransaction.java +++ b/src/main/java/org/qortal/crosschain/SimpleTransaction.java @@ -2,20 +2,85 @@ package org.qortal.crosschain; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import java.util.List; @XmlAccessorType(XmlAccessType.FIELD) public class SimpleTransaction { private String txHash; private Integer timestamp; private long totalAmount; + private long feeAmount; + private List inputs; + private List outputs; + + + @XmlAccessorType(XmlAccessType.FIELD) + public static class Input { + private String address; + private long amount; + private boolean addressInWallet; + + public Input() { + } + + public Input(String address, long amount, boolean addressInWallet) { + this.address = address; + this.amount = amount; + this.addressInWallet = addressInWallet; + } + + public String getAddress() { + return address; + } + + public long getAmount() { + return amount; + } + + public boolean getAddressInWallet() { + return addressInWallet; + } + } + + @XmlAccessorType(XmlAccessType.FIELD) + public static class Output { + private String address; + private long amount; + private boolean addressInWallet; + + public Output() { + } + + public Output(String address, long amount, boolean addressInWallet) { + this.address = address; + this.amount = amount; + this.addressInWallet = addressInWallet; + } + + public String getAddress() { + return address; + } + + public long getAmount() { + return amount; + } + + public boolean getAddressInWallet() { + return addressInWallet; + } + } + public SimpleTransaction() { } - public SimpleTransaction(String txHash, Integer timestamp, long totalAmount) { + public SimpleTransaction(String txHash, Integer timestamp, long totalAmount, long feeAmount, List inputs, List outputs) { this.txHash = txHash; this.timestamp = timestamp; this.totalAmount = totalAmount; + this.feeAmount = feeAmount; + this.inputs = inputs; + this.outputs = outputs; } public String getTxHash() { @@ -29,4 +94,16 @@ public class SimpleTransaction { public long getTotalAmount() { return totalAmount; } -} \ No newline at end of file + + public long getFeeAmount() { + return feeAmount; + } + + public List getInputs() { + return this.inputs; + } + + public List getOutputs() { + return this.outputs; + } +} From bd4b9a9fd38ab49b4508a342ef824e24fe19cb40 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 4 Aug 2021 18:56:16 +0100 Subject: [PATCH 015/231] Modified .gitignore to allow multiple testnets to exist by adding a number or other suffix. --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 890f8cb2..005ab005 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,8 @@ /settings.json /testnet* /settings*.json -/testchain.json -/run-testnet.sh +/testchain*.json +/run-testnet*.sh /.idea /qortal.iml .DS_Store From 8ae78703cade1320ceb0586e1a7c1fba317ea9c4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 4 Aug 2021 18:59:30 +0100 Subject: [PATCH 016/231] Revert "Initial attempt at adding "sleep until message" functionality to DOGE ACCTv2." This reverts commit a1c61a1146455c7dafb0e9afeb11cc93c25fed03. --- src/main/java/org/qortal/crosschain/DogecoinACCTv2.java | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/main/java/org/qortal/crosschain/DogecoinACCTv2.java b/src/main/java/org/qortal/crosschain/DogecoinACCTv2.java index c4b0edb3..d2dc8fcb 100644 --- a/src/main/java/org/qortal/crosschain/DogecoinACCTv2.java +++ b/src/main/java/org/qortal/crosschain/DogecoinACCTv2.java @@ -88,7 +88,7 @@ public class DogecoinACCTv2 implements ACCT { private static final Logger LOGGER = LogManager.getLogger(DogecoinACCTv2.class); public static final String NAME = DogecoinACCTv2.class.getSimpleName(); - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("6fff38d6eeb06568a9c879c5628527730319844aa0de53f5f4ffab5506efe885").asBytes(); // SHA256 of AT code bytes + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("0eb49b0313ff3855a29d860c2a8203faa2ef62e28ea30459321f176079cfa3a6").asBytes(); // SHA256 of AT code bytes public static final int SECRET_LENGTH = 32; @@ -356,9 +356,6 @@ public class DogecoinACCTv2 implements ACCT { /* Transaction processing loop */ labelTradeTxnLoop = codeByteBuffer.position(); - /* Sleep until message arrives */ - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.SLEEP_UNTIL_MESSAGE.value, addrLastTxnTimestamp)); - // Find next transaction (if any) to this AT since the last one (referenced by addrLastTxnTimestamp) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp)); // If no transaction found, A will be zero. If A is zero, set addrResult to 1, otherwise 0. @@ -465,9 +462,6 @@ public class DogecoinACCTv2 implements ACCT { /* Transaction processing loop */ labelRedeemTxnLoop = codeByteBuffer.position(); - /* Sleep until message arrives */ - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.SLEEP_UNTIL_MESSAGE.value, addrLastTxnTimestamp)); - // Find next transaction to this AT since the last one (if any) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp)); // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0. From 1d65e34fe54ad1ee1eb3905d84ffc4b2708b59e5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 4 Aug 2021 18:59:36 +0100 Subject: [PATCH 017/231] Revert "Added DogecoinACCTv2 and DogecoinACCTv2TradeBot" This reverts commit 797dff475228b2ed3801258c5ef53f59ee247217. --- .../tradebot/DogecoinACCTv2TradeBot.java | 883 ------------------ .../qortal/controller/tradebot/TradeBot.java | 1 - .../org/qortal/crosschain/DogecoinACCTv2.java | 855 ----------------- .../crosschain/SupportedBlockchain.java | 5 +- 4 files changed, 2 insertions(+), 1742 deletions(-) delete mode 100644 src/main/java/org/qortal/controller/tradebot/DogecoinACCTv2TradeBot.java delete mode 100644 src/main/java/org/qortal/crosschain/DogecoinACCTv2.java diff --git a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv2TradeBot.java b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv2TradeBot.java deleted file mode 100644 index a85f0be1..00000000 --- a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv2TradeBot.java +++ /dev/null @@ -1,883 +0,0 @@ -package org.qortal.controller.tradebot; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.bitcoinj.core.*; -import org.bitcoinj.script.Script.ScriptType; -import org.qortal.account.PrivateKeyAccount; -import org.qortal.account.PublicKeyAccount; -import org.qortal.api.model.crosschain.TradeBotCreateRequest; -import org.qortal.asset.Asset; -import org.qortal.crosschain.*; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.crosschain.TradeBotData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; -import org.qortal.data.transaction.MessageTransactionData; -import org.qortal.group.Group; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.transaction.DeployAtTransaction; -import org.qortal.transaction.MessageTransaction; -import org.qortal.transaction.Transaction.ValidationResult; -import org.qortal.transform.TransformationException; -import org.qortal.transform.transaction.DeployAtTransactionTransformer; -import org.qortal.utils.Base58; -import org.qortal.utils.NTP; - -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import static java.util.Arrays.stream; -import static java.util.stream.Collectors.toMap; - -/** - * Performing cross-chain trading steps on behalf of user. - *

- * We deal with three different independent state-spaces here: - *

    - *
  • Qortal blockchain
  • - *
  • Foreign blockchain
  • - *
  • Trade-bot entries
  • - *
- */ -public class DogecoinACCTv2TradeBot implements AcctTradeBot { - - private static final Logger LOGGER = LogManager.getLogger(DogecoinACCTv2TradeBot.class); - - public enum State implements TradeBot.StateNameAndValueSupplier { - BOB_WAITING_FOR_AT_CONFIRM(10, false, false), - BOB_WAITING_FOR_MESSAGE(15, true, true), - BOB_WAITING_FOR_AT_REDEEM(25, true, true), - BOB_DONE(30, false, false), - BOB_REFUNDED(35, false, false), - - ALICE_WAITING_FOR_AT_LOCK(85, true, true), - ALICE_DONE(95, false, false), - ALICE_REFUNDING_A(105, true, true), - ALICE_REFUNDED(110, false, false); - - private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); - - public final int value; - public final boolean requiresAtData; - public final boolean requiresTradeData; - - State(int value, boolean requiresAtData, boolean requiresTradeData) { - this.value = value; - this.requiresAtData = requiresAtData; - this.requiresTradeData = requiresTradeData; - } - - public static State valueOf(int value) { - return map.get(value); - } - - @Override - public String getState() { - return this.name(); - } - - @Override - public int getStateValue() { - return this.value; - } - } - - /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */ - private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms - - private static DogecoinACCTv2TradeBot instance; - - private final List endStates = Arrays.asList(State.BOB_DONE, State.BOB_REFUNDED, State.ALICE_DONE, State.ALICE_REFUNDING_A, State.ALICE_REFUNDED).stream() - .map(State::name) - .collect(Collectors.toUnmodifiableList()); - - private DogecoinACCTv2TradeBot() { - } - - public static synchronized DogecoinACCTv2TradeBot getInstance() { - if (instance == null) - instance = new DogecoinACCTv2TradeBot(); - - return instance; - } - - @Override - public List getEndStates() { - return this.endStates; - } - - /** - * Creates a new trade-bot entry from the "Bob" viewpoint, i.e. OFFERing QORT in exchange for DOGE. - *

- * Generates: - *

    - *
  • new 'trade' private key
  • - *
- * Derives: - *
    - *
  • 'native' (as in Qortal) public key, public key hash, address (starting with Q)
  • - *
  • 'foreign' (as in Dogecoin) public key, public key hash
  • - *
- * A Qortal AT is then constructed including the following as constants in the 'data segment': - *
    - *
  • 'native'/Qortal 'trade' address - used as a MESSAGE contact
  • - *
  • 'foreign'/Dogecoin public key hash - used by Alice's P2SH scripts to allow redeem
  • - *
  • QORT amount on offer by Bob
  • - *
  • DOGE amount expected in return by Bob (from Alice)
  • - *
  • trading timeout, in case things go wrong and everyone needs to refund
  • - *
- * Returns a DEPLOY_AT transaction that needs to be signed and broadcast to the Qortal network. - *

- * Trade-bot will wait for Bob's AT to be deployed before taking next step. - *

- * @param repository - * @param tradeBotCreateRequest - * @return raw, unsigned DEPLOY_AT transaction - * @throws DataException - */ - public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { - byte[] tradePrivateKey = TradeBot.generateTradePrivateKey(); - - byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey); - byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); - String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); - - byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey); - byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); - - // Convert Dogecoin receiving address into public key hash (we only support P2PKH at this time) - Address dogecoinReceivingAddress; - try { - dogecoinReceivingAddress = Address.fromString(Dogecoin.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress); - } catch (AddressFormatException e) { - throw new DataException("Unsupported Dogecoin receiving address: " + tradeBotCreateRequest.receivingAddress); - } - if (dogecoinReceivingAddress.getOutputScriptType() != ScriptType.P2PKH) - throw new DataException("Unsupported Dogecoin receiving address: " + tradeBotCreateRequest.receivingAddress); - - byte[] dogecoinReceivingAccountInfo = dogecoinReceivingAddress.getHash(); - - PublicKeyAccount creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey); - - // Deploy AT - long timestamp = NTP.getTime(); - byte[] reference = creator.getLastReference(); - long fee = 0L; - byte[] signature = null; - BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, creator.getPublicKey(), fee, signature); - - String name = "QORT/DOGE ACCT"; - String description = "QORT/DOGE cross-chain trade"; - String aTType = "ACCT"; - String tags = "ACCT QORT DOGE"; - byte[] creationBytes = DogecoinACCTv2.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, tradeBotCreateRequest.qortAmount, - tradeBotCreateRequest.foreignAmount, tradeBotCreateRequest.tradeTimeout); - long amount = tradeBotCreateRequest.fundingQortAmount; - - DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - DeployAtTransaction.ensureATAddress(deployAtTransactionData); - String atAddress = deployAtTransactionData.getAtAddress(); - - TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, DogecoinACCTv2.NAME, - State.BOB_WAITING_FOR_AT_CONFIRM.name(), State.BOB_WAITING_FOR_AT_CONFIRM.value, - creator.getAddress(), atAddress, timestamp, tradeBotCreateRequest.qortAmount, - tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, - null, null, - SupportedBlockchain.DOGECOIN.name(), - tradeForeignPublicKey, tradeForeignPublicKeyHash, - tradeBotCreateRequest.foreignAmount, null, null, null, dogecoinReceivingAccountInfo); - - TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Built AT %s. Waiting for deployment", atAddress)); - - // Attempt to backup the trade bot data - TradeBot.backupTradeBotData(repository); - - // Return to user for signing and broadcast as we don't have their Qortal private key - try { - return DeployAtTransactionTransformer.toBytes(deployAtTransactionData); - } catch (TransformationException e) { - throw new DataException("Failed to transform DEPLOY_AT transaction?", e); - } - } - - /** - * Creates a trade-bot entry from the 'Alice' viewpoint, i.e. matching DOGE to an existing offer. - *

- * Requires a chosen trade offer from Bob, passed by crossChainTradeData - * and access to a Dogecoin wallet via xprv58. - *

- * The crossChainTradeData contains the current trade offer state - * as extracted from the AT's data segment. - *

- * Access to a funded wallet is via a Dogecoin BIP32 hierarchical deterministic key, - * passed via xprv58. - * This key will be stored in your node's database - * to allow trade-bot to create/fund the necessary P2SH transactions! - * However, due to the nature of BIP32 keys, it is possible to give the trade-bot - * only a subset of wallet access (see BIP32 for more details). - *

- * As an example, the xprv58 can be extract from a legacy, password-less - * Electrum wallet by going to the console tab and entering:
- * wallet.keystore.xprv
- * which should result in a base58 string starting with either 'xprv' (for Dogecoin main-net) - * or 'tprv' for (Dogecoin test-net). - *

- * It is envisaged that the value in xprv58 will actually come from a Qortal-UI-managed wallet. - *

- * If sufficient funds are available, this method will actually fund the P2SH-A - * with the Dogecoin amount expected by 'Bob'. - *

- * If the Dogecoin transaction is successfully broadcast to the network then - * we also send a MESSAGE to Bob's trade-bot to let them know. - *

- * The trade-bot entry is saved to the repository and the cross-chain trading process commences. - *

- * @param repository - * @param crossChainTradeData chosen trade OFFER that Alice wants to match - * @param xprv58 funded wallet xprv in base58 - * @return true if P2SH-A funding transaction successfully broadcast to Dogecoin network, false otherwise - * @throws DataException - */ - public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException { - byte[] tradePrivateKey = TradeBot.generateTradePrivateKey(); - byte[] secretA = TradeBot.generateSecret(); - byte[] hashOfSecretA = Crypto.hash160(secretA); - - byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey); - byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); - String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); - - byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey); - byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); - byte[] receivingPublicKeyHash = Base58.decode(receivingAddress); // Actually the whole address, not just PKH - - // We need to generate lockTime-A: add tradeTimeout to now - long now = NTP.getTime(); - int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (now / 1000L); - - TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, DogecoinACCTv2.NAME, - State.ALICE_WAITING_FOR_AT_LOCK.name(), State.ALICE_WAITING_FOR_AT_LOCK.value, - receivingAddress, crossChainTradeData.qortalAtAddress, now, crossChainTradeData.qortAmount, - tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, - secretA, hashOfSecretA, - SupportedBlockchain.DOGECOIN.name(), - tradeForeignPublicKey, tradeForeignPublicKeyHash, - crossChainTradeData.expectedForeignAmount, xprv58, null, lockTimeA, receivingPublicKeyHash); - - // Attempt to backup the trade bot data - TradeBot.backupTradeBotData(repository); - - // Check we have enough funds via xprv58 to fund P2SH to cover expectedForeignAmount - long p2shFee; - try { - p2shFee = Dogecoin.getInstance().getP2shFee(now); - } catch (ForeignBlockchainException e) { - LOGGER.debug("Couldn't estimate Dogecoin fees?"); - return ResponseResult.NETWORK_ISSUE; - } - - // Fee for redeem/refund is subtracted from P2SH-A balance. - // Do not include fee for funding transaction as this is covered by buildSpend() - long amountA = crossChainTradeData.expectedForeignAmount + p2shFee /*redeeming/refunding P2SH-A*/; - - // P2SH-A to be funded - byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorForeignPKH, hashOfSecretA); - String p2shAddress = Dogecoin.getInstance().deriveP2shAddress(redeemScriptBytes); - - // Build transaction for funding P2SH-A - Transaction p2shFundingTransaction = Dogecoin.getInstance().buildSpend(tradeBotData.getForeignKey(), p2shAddress, amountA); - if (p2shFundingTransaction == null) { - LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?"); - return ResponseResult.BALANCE_ISSUE; - } - - try { - Dogecoin.getInstance().broadcastTransaction(p2shFundingTransaction); - } catch (ForeignBlockchainException e) { - // We couldn't fund P2SH-A at this time - LOGGER.debug("Couldn't broadcast P2SH-A funding transaction?"); - return ResponseResult.NETWORK_ISSUE; - } - - // Attempt to send MESSAGE to Bob's Qortal trade address - byte[] messageData = DogecoinACCTv2.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); - String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; - - boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); - if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - - messageTransaction.computeNonce(); - messageTransaction.sign(sender); - - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } - } - - TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); - - return ResponseResult.OK; - } - - @Override - public boolean canDelete(Repository repository, TradeBotData tradeBotData) throws DataException { - State tradeBotState = State.valueOf(tradeBotData.getStateValue()); - if (tradeBotState == null) - return true; - - // If the AT doesn't exist then we might as well let the user tidy up - if (!repository.getATRepository().exists(tradeBotData.getAtAddress())) - return true; - - switch (tradeBotState) { - case BOB_WAITING_FOR_AT_CONFIRM: - case ALICE_DONE: - case BOB_DONE: - case ALICE_REFUNDED: - case BOB_REFUNDED: - return true; - - default: - return false; - } - } - - @Override - public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { - State tradeBotState = State.valueOf(tradeBotData.getStateValue()); - if (tradeBotState == null) { - LOGGER.info(() -> String.format("Trade-bot entry for AT %s has invalid state?", tradeBotData.getAtAddress())); - return; - } - - ATData atData = null; - CrossChainTradeData tradeData = null; - - if (tradeBotState.requiresAtData) { - // Attempt to fetch AT data - atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); - if (atData == null) { - LOGGER.debug(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); - return; - } - - if (tradeBotState.requiresTradeData) { - tradeData = DogecoinACCTv2.getInstance().populateTradeData(repository, atData); - if (tradeData == null) { - LOGGER.warn(() -> String.format("Unable to fetch ACCT trade data for AT %s from repository", tradeBotData.getAtAddress())); - return; - } - } - } - - switch (tradeBotState) { - case BOB_WAITING_FOR_AT_CONFIRM: - handleBobWaitingForAtConfirm(repository, tradeBotData); - break; - - case BOB_WAITING_FOR_MESSAGE: - TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); - handleBobWaitingForMessage(repository, tradeBotData, atData, tradeData); - break; - - case ALICE_WAITING_FOR_AT_LOCK: - TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); - handleAliceWaitingForAtLock(repository, tradeBotData, atData, tradeData); - break; - - case BOB_WAITING_FOR_AT_REDEEM: - TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); - handleBobWaitingForAtRedeem(repository, tradeBotData, atData, tradeData); - break; - - case ALICE_DONE: - case BOB_DONE: - break; - - case ALICE_REFUNDING_A: - TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); - handleAliceRefundingP2shA(repository, tradeBotData, atData, tradeData); - break; - - case ALICE_REFUNDED: - case BOB_REFUNDED: - break; - } - } - - /** - * Trade-bot is waiting for Bob's AT to deploy. - *

- * If AT is deployed, then trade-bot's next step is to wait for MESSAGE from Alice. - */ - private void handleBobWaitingForAtConfirm(Repository repository, TradeBotData tradeBotData) throws DataException { - if (!repository.getATRepository().exists(tradeBotData.getAtAddress())) { - if (NTP.getTime() - tradeBotData.getTimestamp() <= MAX_AT_CONFIRMATION_PERIOD) - return; - - // We've waited ages for AT to be confirmed into a block but something has gone awry. - // After this long we assume transaction loss so give up with trade-bot entry too. - tradeBotData.setState(State.BOB_REFUNDED.name()); - tradeBotData.setStateValue(State.BOB_REFUNDED.value); - tradeBotData.setTimestamp(NTP.getTime()); - // We delete trade-bot entry here instead of saving, hence not using updateTradeBotState() - repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); - repository.saveChanges(); - - LOGGER.info(() -> String.format("AT %s never confirmed. Giving up on trade", tradeBotData.getAtAddress())); - TradeBot.notifyStateChange(tradeBotData); - return; - } - - TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_MESSAGE, - () -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress())); - } - - /** - * Trade-bot is waiting for MESSAGE from Alice's trade-bot, containing Alice's trade info. - *

- * It's possible Bob has cancelling his trade offer, receiving an automatic QORT refund, - * in which case trade-bot is done with this specific trade and finalizes on refunded state. - *

- * Assuming trade is still on offer, trade-bot checks the contents of MESSAGE from Alice's trade-bot. - *

- * Details from Alice are used to derive P2SH-A address and this is checked for funding balance. - *

- * Assuming P2SH-A has at least expected Dogecoin balance, - * Bob's trade-bot constructs a zero-fee, PoW MESSAGE to send to Bob's AT with more trade details. - *

- * On processing this MESSAGE, Bob's AT should switch into 'TRADE' mode and only trade with Alice. - *

- * Trade-bot's next step is to wait for Alice to redeem the AT, which will allow Bob to - * extract secret-A needed to redeem Alice's P2SH. - * @throws ForeignBlockchainException - */ - private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData, - ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { - // If AT has finished then Bob likely cancelled his trade offer - if (atData.getIsFinished()) { - TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, - () -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress())); - return; - } - - Dogecoin dogecoin = Dogecoin.getInstance(); - - String address = tradeBotData.getTradeNativeAddress(); - List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, address, null, null, null); - - for (MessageTransactionData messageTransactionData : messageTransactionsData) { - if (messageTransactionData.isText()) - continue; - - // We're expecting: HASH160(secret-A), Alice's Dogecoin pubkeyhash and lockTime-A - byte[] messageData = messageTransactionData.getData(); - DogecoinACCTv2.OfferMessageData offerMessageData = DogecoinACCTv2.extractOfferMessageData(messageData); - if (offerMessageData == null) - continue; - - byte[] aliceForeignPublicKeyHash = offerMessageData.partnerDogecoinPKH; - byte[] hashOfSecretA = offerMessageData.hashOfSecretA; - int lockTimeA = (int) offerMessageData.lockTimeA; - long messageTimestamp = messageTransactionData.getTimestamp(); - int refundTimeout = DogecoinACCTv2.calcRefundTimeout(messageTimestamp, lockTimeA); - - // Determine P2SH-A address and confirm funded - byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA); - String p2shAddressA = dogecoin.deriveP2shAddress(redeemScriptA); - - long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); - long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp); - final long minimumAmountA = tradeBotData.getForeignAmount() + p2shFee; - - BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); - - switch (htlcStatusA) { - case UNFUNDED: - case FUNDING_IN_PROGRESS: - // There might be another MESSAGE from someone else with an actually funded P2SH-A... - continue; - - case REDEEM_IN_PROGRESS: - case REDEEMED: - // We've already redeemed this? - TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE, - () -> String.format("P2SH-A %s already spent? Assuming trade complete", p2shAddressA)); - return; - - case REFUND_IN_PROGRESS: - case REFUNDED: - // This P2SH-A is burnt, but there might be another MESSAGE from someone else with an actually funded P2SH-A... - continue; - - case FUNDED: - // Fall-through out of switch... - break; - } - - // Good to go - send MESSAGE to AT - - String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey()); - - // Build outgoing message, padding each part to 32 bytes to make it easier for AT to consume - byte[] outgoingMessageData = DogecoinACCTv2.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); - String messageRecipient = tradeBotData.getAtAddress(); - - boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, outgoingMessageData); - if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); - - outgoingMessageTransaction.computeNonce(); - outgoingMessageTransaction.sign(sender); - - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); - return; - } - } - - TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_AT_REDEEM, - () -> String.format("Locked AT %s to %s. Waiting for AT redeem", tradeBotData.getAtAddress(), aliceNativeAddress)); - - return; - } - } - - /** - * Trade-bot is waiting for Bob's AT to switch to TRADE mode and lock trade to Alice only. - *

- * It's possible that Bob has cancelled his trade offer in the mean time, or that somehow - * this process has taken so long that we've reached P2SH-A's locktime, or that someone else - * has managed to trade with Bob. In any of these cases, trade-bot switches to begin the refunding process. - *

- * Assuming Bob's AT is locked to Alice, trade-bot checks AT's state data to make sure it is correct. - *

- * If all is well, trade-bot then redeems AT using Alice's secret-A, releasing Bob's QORT to Alice. - *

- * In revealing a valid secret-A, Bob can then redeem the DOGE funds from P2SH-A. - *

- * @throws ForeignBlockchainException - */ - private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData, - ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { - if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData)) - return; - - Dogecoin dogecoin = Dogecoin.getInstance(); - int lockTimeA = tradeBotData.getLockTimeA(); - - // Refund P2SH-A if we've passed lockTime-A - if (NTP.getTime() >= lockTimeA * 1000L) { - byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); - String p2shAddressA = dogecoin.deriveP2shAddress(redeemScriptA); - - long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); - long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp); - long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; - - BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); - - switch (htlcStatusA) { - case UNFUNDED: - case FUNDING_IN_PROGRESS: - case FUNDED: - break; - - case REDEEM_IN_PROGRESS: - case REDEEMED: - // Already redeemed? - TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, - () -> String.format("P2SH-A %s already spent? Assuming trade completed", p2shAddressA)); - return; - - case REFUND_IN_PROGRESS: - case REFUNDED: - TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED, - () -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA)); - return; - - } - - TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, - () -> atData.getIsFinished() - ? String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddressA) - : String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddressA)); - - return; - } - - // We're waiting for AT to be in TRADE mode - if (crossChainTradeData.mode != AcctMode.TRADING) - return; - - // AT is in TRADE mode and locked to us as checked by aliceUnexpectedState() above - - // Find our MESSAGE to AT from previous state - List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(tradeBotData.getTradeNativePublicKey(), - crossChainTradeData.qortalCreatorTradeAddress, null, null, null); - if (messageTransactionsData == null || messageTransactionsData.isEmpty()) { - LOGGER.warn(() -> String.format("Unable to find our message to trade creator %s?", crossChainTradeData.qortalCreatorTradeAddress)); - return; - } - - long recipientMessageTimestamp = messageTransactionsData.get(0).getTimestamp(); - int refundTimeout = DogecoinACCTv2.calcRefundTimeout(recipientMessageTimestamp, lockTimeA); - - // Our calculated refundTimeout should match AT's refundTimeout - if (refundTimeout != crossChainTradeData.refundTimeout) { - LOGGER.debug(() -> String.format("Trade AT refundTimeout '%d' doesn't match our refundTimeout '%d'", crossChainTradeData.refundTimeout, refundTimeout)); - // We'll eventually refund - return; - } - - // We're good to redeem AT - - // Send 'redeem' MESSAGE to AT using both secret - byte[] secretA = tradeBotData.getSecret(); - String qortalReceivingAddress = Base58.encode(tradeBotData.getReceivingAccountInfo()); // Actually contains whole address, not just PKH - byte[] messageData = DogecoinACCTv2.buildRedeemMessage(secretA, qortalReceivingAddress); - String messageRecipient = tradeBotData.getAtAddress(); - - boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); - if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - - messageTransaction.computeNonce(); - messageTransaction.sign(sender); - - // Reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); - - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); - return; - } - } - - TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, - () -> String.format("Redeeming AT %s. Funds should arrive at %s", - tradeBotData.getAtAddress(), qortalReceivingAddress)); - } - - /** - * Trade-bot is waiting for Alice to redeem Bob's AT, thus revealing secret-A which is required to spend the DOGE funds from P2SH-A. - *

- * It's possible that Bob's AT has reached its trading timeout and automatically refunded QORT back to Bob. In which case, - * trade-bot is done with this specific trade and finalizes in refunded state. - *

- * Assuming trade-bot can extract a valid secret-A from Alice's MESSAGE then trade-bot uses that to redeem the DOGE funds from P2SH-A - * to Bob's 'foreign'/Dogecoin trade legacy-format address, as derived from trade private key. - *

- * (This could potentially be 'improved' to send DOGE to any address of Bob's choosing by changing the transaction output). - *

- * If trade-bot successfully broadcasts the transaction, then this specific trade is done. - * @throws ForeignBlockchainException - */ - private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData, - ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { - // AT should be 'finished' once Alice has redeemed QORT funds - if (!atData.getIsFinished()) - // Not finished yet - return; - - // If AT is REFUNDED or CANCELLED then something has gone wrong - if (crossChainTradeData.mode == AcctMode.REFUNDED || crossChainTradeData.mode == AcctMode.CANCELLED) { - // Alice hasn't redeemed the QORT, so there is no point in trying to redeem the DOGE - TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, - () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); - - return; - } - - byte[] secretA = DogecoinACCTv2.getInstance().findSecretA(repository, crossChainTradeData); - if (secretA == null) { - LOGGER.debug(() -> String.format("Unable to find secret-A from redeem message to AT %s?", tradeBotData.getAtAddress())); - return; - } - - // Use secret-A to redeem P2SH-A - - Dogecoin dogecoin = Dogecoin.getInstance(); - - byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); - int lockTimeA = crossChainTradeData.lockTimeA; - byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTimeA, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA); - String p2shAddressA = dogecoin.deriveP2shAddress(redeemScriptA); - - // Fee for redeem/refund is subtracted from P2SH-A balance. - long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); - long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp); - long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; - BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); - - switch (htlcStatusA) { - case UNFUNDED: - case FUNDING_IN_PROGRESS: - // P2SH-A suddenly not funded? Our best bet at this point is to hope for AT auto-refund - return; - - case REDEEM_IN_PROGRESS: - case REDEEMED: - // Double-check that we have redeemed P2SH-A... - break; - - case REFUND_IN_PROGRESS: - case REFUNDED: - // Wait for AT to auto-refund - return; - - case FUNDED: { - Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); - ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = dogecoin.getUnspentOutputs(p2shAddressA); - - Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(dogecoin.getNetworkParameters(), redeemAmount, redeemKey, - fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); - - dogecoin.broadcastTransaction(p2shRedeemTransaction); - break; - } - } - - String receivingAddress = dogecoin.pkhToAddress(receivingAccountInfo); - - TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE, - () -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress)); - } - - /** - * Trade-bot is attempting to refund P2SH-A. - * @throws ForeignBlockchainException - */ - private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData, - ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { - int lockTimeA = tradeBotData.getLockTimeA(); - - // We can't refund P2SH-A until lockTime-A has passed - if (NTP.getTime() <= lockTimeA * 1000L) - return; - - Dogecoin dogecoin = Dogecoin.getInstance(); - - // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113) - int medianBlockTime = dogecoin.getMedianBlockTime(); - if (medianBlockTime <= lockTimeA) - return; - - byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); - String p2shAddressA = dogecoin.deriveP2shAddress(redeemScriptA); - - // Fee for redeem/refund is subtracted from P2SH-A balance. - long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); - long p2shFee = Dogecoin.getInstance().getP2shFee(feeTimestamp); - long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; - BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(dogecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); - - switch (htlcStatusA) { - case UNFUNDED: - case FUNDING_IN_PROGRESS: - // Still waiting for P2SH-A to be funded... - return; - - case REDEEM_IN_PROGRESS: - case REDEEMED: - // Too late! - TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, - () -> String.format("P2SH-A %s already spent!", p2shAddressA)); - return; - - case REFUND_IN_PROGRESS: - case REFUNDED: - break; - - case FUNDED:{ - Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); - ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = dogecoin.getUnspentOutputs(p2shAddressA); - - // Determine receive address for refund - String receiveAddress = dogecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); - Address receiving = Address.fromString(dogecoin.getNetworkParameters(), receiveAddress); - - Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(dogecoin.getNetworkParameters(), refundAmount, refundKey, - fundingOutputs, redeemScriptA, lockTimeA, receiving.getHash()); - - dogecoin.broadcastTransaction(p2shRefundTransaction); - break; - } - } - - TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED, - () -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddressA)); - } - - /** - * Returns true if Alice finds AT unexpectedly cancelled, refunded, redeemed or locked to someone else. - *

- * Will automatically update trade-bot state to ALICE_REFUNDING_A or ALICE_DONE as necessary. - * - * @throws DataException - * @throws ForeignBlockchainException - */ - private boolean aliceUnexpectedState(Repository repository, TradeBotData tradeBotData, - ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { - // This is OK - if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.OFFERING) - return false; - - boolean isAtLockedToUs = tradeBotData.getTradeNativeAddress().equals(crossChainTradeData.qortalPartnerAddress); - - if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.TRADING) - if (isAtLockedToUs) { - // AT is trading with us - OK - return false; - } else { - TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, - () -> String.format("AT %s trading with someone else: %s. Refunding & aborting trade", tradeBotData.getAtAddress(), crossChainTradeData.qortalPartnerAddress)); - - return true; - } - - if (atData.getIsFinished() && crossChainTradeData.mode == AcctMode.REDEEMED && isAtLockedToUs) { - // We've redeemed already? - TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, - () -> String.format("AT %s already redeemed by us. Trade completed", tradeBotData.getAtAddress())); - } else { - // Any other state is not good, so start defensive refund - TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, - () -> String.format("AT %s cancelled/refunded/redeemed by someone else/invalid state. Refunding & aborting trade", tradeBotData.getAtAddress())); - } - - return true; - } - - private long calcFeeTimestamp(int lockTimeA, int tradeTimeout) { - return (lockTimeA - tradeTimeout * 60) * 1000L; - } - -} diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index 47dbe164..6e9d1474 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -77,7 +77,6 @@ public class TradeBot implements Listener { acctTradeBotSuppliers.put(BitcoinACCTv1.class, BitcoinACCTv1TradeBot::getInstance); acctTradeBotSuppliers.put(LitecoinACCTv1.class, LitecoinACCTv1TradeBot::getInstance); acctTradeBotSuppliers.put(DogecoinACCTv1.class, DogecoinACCTv1TradeBot::getInstance); - acctTradeBotSuppliers.put(DogecoinACCTv2.class, DogecoinACCTv2TradeBot::getInstance); } private static TradeBot instance; diff --git a/src/main/java/org/qortal/crosschain/DogecoinACCTv2.java b/src/main/java/org/qortal/crosschain/DogecoinACCTv2.java deleted file mode 100644 index d2dc8fcb..00000000 --- a/src/main/java/org/qortal/crosschain/DogecoinACCTv2.java +++ /dev/null @@ -1,855 +0,0 @@ -package org.qortal.crosschain; - -import com.google.common.hash.HashCode; -import com.google.common.primitives.Bytes; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.ciyam.at.*; -import org.qortal.account.Account; -import org.qortal.asset.Asset; -import org.qortal.at.QortalFunctionCode; -import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; -import org.qortal.data.at.ATStateData; -import org.qortal.data.crosschain.CrossChainTradeData; -import org.qortal.data.transaction.MessageTransactionData; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.utils.Base58; -import org.qortal.utils.BitTwiddling; - -import java.nio.ByteBuffer; -import java.util.Arrays; -import java.util.List; - -import static org.ciyam.at.OpCode.calcOffset; - -/** - * Cross-chain trade AT - * - *

- *

    - *
  • Bob generates Dogecoin & Qortal 'trade' keys - *
      - *
    • private key required to sign P2SH redeem tx
    • - *
    • private key could be used to create 'secret' (e.g. double-SHA256)
    • - *
    • encrypted private key could be stored in Qortal AT for access by Bob from any node
    • - *
    - *
  • - *
  • Bob deploys Qortal AT - *
      - *
    - *
  • - *
  • Alice finds Qortal AT and wants to trade - *
      - *
    • Alice generates Dogecoin & Qortal 'trade' keys
    • - *
    • Alice funds Dogecoin P2SH-A
    • - *
    • Alice sends 'offer' MESSAGE to Bob from her Qortal trade address, containing: - *
        - *
      • hash-of-secret-A
      • - *
      • her 'trade' Dogecoin PKH
      • - *
      - *
    • - *
    - *
  • - *
  • Bob receives "offer" MESSAGE - *
      - *
    • Checks Alice's P2SH-A
    • - *
    • Sends 'trade' MESSAGE to Qortal AT from his trade address, containing: - *
        - *
      • Alice's trade Qortal address
      • - *
      • Alice's trade Dogecoin PKH
      • - *
      • hash-of-secret-A
      • - *
      - *
    • - *
    - *
  • - *
  • Alice checks Qortal AT to confirm it's locked to her - *
      - *
    • Alice sends 'redeem' MESSAGE to Qortal AT from her trade address, containing: - *
        - *
      • secret-A
      • - *
      • Qortal receiving address of her chosing
      • - *
      - *
    • - *
    • AT's QORT funds are sent to Qortal receiving address
    • - *
    - *
  • - *
  • Bob checks AT, extracts secret-A - *
      - *
    • Bob redeems P2SH-A using his Dogecoin trade key and secret-A
    • - *
    • P2SH-A DOGE funds end up at Dogecoin address determined by redeem transaction output(s)
    • - *
    - *
  • - *
- */ -public class DogecoinACCTv2 implements ACCT { - - private static final Logger LOGGER = LogManager.getLogger(DogecoinACCTv2.class); - - public static final String NAME = DogecoinACCTv2.class.getSimpleName(); - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("0eb49b0313ff3855a29d860c2a8203faa2ef62e28ea30459321f176079cfa3a6").asBytes(); // SHA256 of AT code bytes - - public static final int SECRET_LENGTH = 32; - - /** Value offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */ - private static final int MODE_VALUE_OFFSET = 61; - /** Byte offset into AT state data where 'mode' variable (long) is stored. */ - public static final int MODE_BYTE_OFFSET = MachineState.HEADER_LENGTH + (MODE_VALUE_OFFSET * MachineState.VALUE_SIZE); - - public static class OfferMessageData { - public byte[] partnerDogecoinPKH; - public byte[] hashOfSecretA; - public long lockTimeA; - } - public static final int OFFER_MESSAGE_LENGTH = 20 /*partnerDogecoinPKH*/ + 20 /*hashOfSecretA*/ + 8 /*lockTimeA*/; - public static final int TRADE_MESSAGE_LENGTH = 32 /*partner's Qortal trade address (padded from 25 to 32)*/ - + 24 /*partner's Dogecoin PKH (padded from 20 to 24)*/ - + 8 /*AT trade timeout (minutes)*/ - + 24 /*hash of secret-A (padded from 20 to 24)*/ - + 8 /*lockTimeA*/; - public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret-A*/ + 32 /*partner's Qortal receiving address padded from 25 to 32*/; - public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/; - - private static DogecoinACCTv2 instance; - - private DogecoinACCTv2() { - } - - public static synchronized DogecoinACCTv2 getInstance() { - if (instance == null) - instance = new DogecoinACCTv2(); - - return instance; - } - - @Override - public byte[] getCodeBytesHash() { - return CODE_BYTES_HASH; - } - - @Override - public int getModeByteOffset() { - return MODE_BYTE_OFFSET; - } - - @Override - public ForeignBlockchain getBlockchain() { - return Dogecoin.getInstance(); - } - - /** - * Returns Qortal AT creation bytes for cross-chain trading AT. - *

- * tradeTimeout (minutes) is the time window for the trade partner to send the - * 32-byte secret to the AT, before the AT automatically refunds the AT's creator. - * - * @param creatorTradeAddress AT creator's trade Qortal address - * @param dogecoinPublicKeyHash 20-byte HASH160 of creator's trade Dogecoin public key - * @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT - * @param dogecoinAmount how much DOGE the AT creator is expecting to trade - * @param tradeTimeout suggested timeout for entire trade - */ - public static byte[] buildQortalAT(String creatorTradeAddress, byte[] dogecoinPublicKeyHash, long qortAmount, long dogecoinAmount, int tradeTimeout) { - if (dogecoinPublicKeyHash.length != 20) - throw new IllegalArgumentException("Dogecoin public key hash should be 20 bytes"); - - // Labels for data segment addresses - int addrCounter = 0; - - // Constants (with corresponding dataByteBuffer.put*() calls below) - - final int addrCreatorTradeAddress1 = addrCounter++; - final int addrCreatorTradeAddress2 = addrCounter++; - final int addrCreatorTradeAddress3 = addrCounter++; - final int addrCreatorTradeAddress4 = addrCounter++; - - final int addrDogecoinPublicKeyHash = addrCounter; - addrCounter += 4; - - final int addrQortAmount = addrCounter++; - final int addrDogecoinAmount = addrCounter++; - final int addrTradeTimeout = addrCounter++; - - final int addrMessageTxnType = addrCounter++; - final int addrExpectedTradeMessageLength = addrCounter++; - final int addrExpectedRedeemMessageLength = addrCounter++; - - final int addrCreatorAddressPointer = addrCounter++; - final int addrQortalPartnerAddressPointer = addrCounter++; - final int addrMessageSenderPointer = addrCounter++; - - final int addrTradeMessagePartnerDogecoinPKHOffset = addrCounter++; - final int addrPartnerDogecoinPKHPointer = addrCounter++; - final int addrTradeMessageHashOfSecretAOffset = addrCounter++; - final int addrHashOfSecretAPointer = addrCounter++; - - final int addrRedeemMessageReceivingAddressOffset = addrCounter++; - - final int addrMessageDataPointer = addrCounter++; - final int addrMessageDataLength = addrCounter++; - - final int addrPartnerReceivingAddressPointer = addrCounter++; - - final int addrEndOfConstants = addrCounter; - - // Variables - - final int addrCreatorAddress1 = addrCounter++; - final int addrCreatorAddress2 = addrCounter++; - final int addrCreatorAddress3 = addrCounter++; - final int addrCreatorAddress4 = addrCounter++; - - final int addrQortalPartnerAddress1 = addrCounter++; - final int addrQortalPartnerAddress2 = addrCounter++; - final int addrQortalPartnerAddress3 = addrCounter++; - final int addrQortalPartnerAddress4 = addrCounter++; - - final int addrLockTimeA = addrCounter++; - final int addrRefundTimeout = addrCounter++; - final int addrRefundTimestamp = addrCounter++; - final int addrLastTxnTimestamp = addrCounter++; - final int addrBlockTimestamp = addrCounter++; - final int addrTxnType = addrCounter++; - final int addrResult = addrCounter++; - - final int addrMessageSender1 = addrCounter++; - final int addrMessageSender2 = addrCounter++; - final int addrMessageSender3 = addrCounter++; - final int addrMessageSender4 = addrCounter++; - - final int addrMessageLength = addrCounter++; - - final int addrMessageData = addrCounter; - addrCounter += 4; - - final int addrHashOfSecretA = addrCounter; - addrCounter += 4; - - final int addrPartnerDogecoinPKH = addrCounter; - addrCounter += 4; - - final int addrPartnerReceivingAddress = addrCounter; - addrCounter += 4; - - final int addrMode = addrCounter++; - assert addrMode == MODE_VALUE_OFFSET : String.format("addrMode %d does not match MODE_VALUE_OFFSET %d", addrMode, MODE_VALUE_OFFSET); - - // Data segment - ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); - - // AT creator's trade Qortal address, decoded from Base58 - assert dataByteBuffer.position() == addrCreatorTradeAddress1 * MachineState.VALUE_SIZE : "addrCreatorTradeAddress1 incorrect"; - byte[] creatorTradeAddressBytes = Base58.decode(creatorTradeAddress); - dataByteBuffer.put(Bytes.ensureCapacity(creatorTradeAddressBytes, 32, 0)); - - // Dogecoin public key hash - assert dataByteBuffer.position() == addrDogecoinPublicKeyHash * MachineState.VALUE_SIZE : "addrDogecoinPublicKeyHash incorrect"; - dataByteBuffer.put(Bytes.ensureCapacity(dogecoinPublicKeyHash, 32, 0)); - - // Redeem Qort amount - assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect"; - dataByteBuffer.putLong(qortAmount); - - // Expected Dogecoin amount - assert dataByteBuffer.position() == addrDogecoinAmount * MachineState.VALUE_SIZE : "addrDogecoinAmount incorrect"; - dataByteBuffer.putLong(dogecoinAmount); - - // Suggested trade timeout (minutes) - assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect"; - dataByteBuffer.putLong(tradeTimeout); - - // We're only interested in MESSAGE transactions - assert dataByteBuffer.position() == addrMessageTxnType * MachineState.VALUE_SIZE : "addrMessageTxnType incorrect"; - dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value); - - // Expected length of 'trade' MESSAGE data from AT creator - assert dataByteBuffer.position() == addrExpectedTradeMessageLength * MachineState.VALUE_SIZE : "addrExpectedTradeMessageLength incorrect"; - dataByteBuffer.putLong(TRADE_MESSAGE_LENGTH); - - // Expected length of 'redeem' MESSAGE data from trade partner - assert dataByteBuffer.position() == addrExpectedRedeemMessageLength * MachineState.VALUE_SIZE : "addrExpectedRedeemMessageLength incorrect"; - dataByteBuffer.putLong(REDEEM_MESSAGE_LENGTH); - - // Index into data segment of AT creator's address, used by GET_B_IND - assert dataByteBuffer.position() == addrCreatorAddressPointer * MachineState.VALUE_SIZE : "addrCreatorAddressPointer incorrect"; - dataByteBuffer.putLong(addrCreatorAddress1); - - // Index into data segment of partner's Qortal address, used by SET_B_IND - assert dataByteBuffer.position() == addrQortalPartnerAddressPointer * MachineState.VALUE_SIZE : "addrQortalPartnerAddressPointer incorrect"; - dataByteBuffer.putLong(addrQortalPartnerAddress1); - - // Index into data segment of (temporary) transaction's sender's address, used by GET_B_IND - assert dataByteBuffer.position() == addrMessageSenderPointer * MachineState.VALUE_SIZE : "addrMessageSenderPointer incorrect"; - dataByteBuffer.putLong(addrMessageSender1); - - // Offset into 'trade' MESSAGE data payload for extracting partner's Dogecoin PKH - assert dataByteBuffer.position() == addrTradeMessagePartnerDogecoinPKHOffset * MachineState.VALUE_SIZE : "addrTradeMessagePartnerDogecoinPKHOffset incorrect"; - dataByteBuffer.putLong(32L); - - // Index into data segment of partner's Dogecoin PKH, used by GET_B_IND - assert dataByteBuffer.position() == addrPartnerDogecoinPKHPointer * MachineState.VALUE_SIZE : "addrPartnerDogecoinPKHPointer incorrect"; - dataByteBuffer.putLong(addrPartnerDogecoinPKH); - - // Offset into 'trade' MESSAGE data payload for extracting hash-of-secret-A - assert dataByteBuffer.position() == addrTradeMessageHashOfSecretAOffset * MachineState.VALUE_SIZE : "addrTradeMessageHashOfSecretAOffset incorrect"; - dataByteBuffer.putLong(64L); - - // Index into data segment to hash of secret A, used by GET_B_IND - assert dataByteBuffer.position() == addrHashOfSecretAPointer * MachineState.VALUE_SIZE : "addrHashOfSecretAPointer incorrect"; - dataByteBuffer.putLong(addrHashOfSecretA); - - // Offset into 'redeem' MESSAGE data payload for extracting Qortal receiving address - assert dataByteBuffer.position() == addrRedeemMessageReceivingAddressOffset * MachineState.VALUE_SIZE : "addrRedeemMessageReceivingAddressOffset incorrect"; - dataByteBuffer.putLong(32L); - - // Source location and length for hashing any passed secret - assert dataByteBuffer.position() == addrMessageDataPointer * MachineState.VALUE_SIZE : "addrMessageDataPointer incorrect"; - dataByteBuffer.putLong(addrMessageData); - assert dataByteBuffer.position() == addrMessageDataLength * MachineState.VALUE_SIZE : "addrMessageDataLength incorrect"; - dataByteBuffer.putLong(32L); - - // Pointer into data segment of where to save partner's receiving Qortal address, used by GET_B_IND - assert dataByteBuffer.position() == addrPartnerReceivingAddressPointer * MachineState.VALUE_SIZE : "addrPartnerReceivingAddressPointer incorrect"; - dataByteBuffer.putLong(addrPartnerReceivingAddress); - - assert dataByteBuffer.position() == addrEndOfConstants * MachineState.VALUE_SIZE : "dataByteBuffer position not at end of constants"; - - // Code labels - Integer labelRefund = null; - - Integer labelTradeTxnLoop = null; - Integer labelCheckTradeTxn = null; - Integer labelCheckCancelTxn = null; - Integer labelNotTradeNorCancelTxn = null; - Integer labelCheckNonRefundTradeTxn = null; - Integer labelTradeTxnExtract = null; - Integer labelRedeemTxnLoop = null; - Integer labelCheckRedeemTxn = null; - Integer labelCheckRedeemTxnSender = null; - Integer labelPayout = null; - - ByteBuffer codeByteBuffer = ByteBuffer.allocate(768); - - // Two-pass version - for (int pass = 0; pass < 2; ++pass) { - codeByteBuffer.clear(); - - try { - /* Initialization */ - - /* NOP - to ensure DOGECOIN ACCT is unique */ - codeByteBuffer.put(OpCode.NOP.compile()); - - // Use AT creation 'timestamp' as starting point for finding transactions sent to AT - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxnTimestamp)); - - // Load B register with AT creator's address so we can save it into addrCreatorAddress1-4 - codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B)); - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrCreatorAddressPointer)); - - // Set restart position to after this opcode - codeByteBuffer.put(OpCode.SET_PCS.compile()); - - /* Loop, waiting for message from AT creator's trade address containing trade partner details, or AT owner's address to cancel offer */ - - /* Transaction processing loop */ - labelTradeTxnLoop = codeByteBuffer.position(); - - // Find next transaction (if any) to this AT since the last one (referenced by addrLastTxnTimestamp) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp)); - // If no transaction found, A will be zero. If A is zero, set addrResult to 1, otherwise 0. - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult)); - // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction - codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckTradeTxn))); - // Stop and wait for next block - codeByteBuffer.put(OpCode.STP_IMD.compile()); - - /* Check transaction */ - labelCheckTradeTxn = codeByteBuffer.position(); - - // Update our 'last found transaction's timestamp' using 'timestamp' from transaction - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxnTimestamp)); - // Extract transaction type (message/payment) from transaction and save type in addrTxnType - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType)); - // If transaction type is not MESSAGE type then go look for another transaction - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelTradeTxnLoop))); - - /* Check transaction's sender. We're expecting AT creator's trade address for 'trade' message, or AT creator's own address for 'cancel' message. */ - - // Extract sender address from transaction into B register - codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B)); - // Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer)); - // Compare each part of message sender's address with AT creator's trade address. If they don't match, check for cancel situation. - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorTradeAddress1, calcOffset(codeByteBuffer, labelCheckCancelTxn))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorTradeAddress2, calcOffset(codeByteBuffer, labelCheckCancelTxn))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorTradeAddress3, calcOffset(codeByteBuffer, labelCheckCancelTxn))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorTradeAddress4, calcOffset(codeByteBuffer, labelCheckCancelTxn))); - // Message sender's address matches AT creator's trade address so go process 'trade' message - codeByteBuffer.put(OpCode.JMP_ADR.compile(labelCheckNonRefundTradeTxn == null ? 0 : labelCheckNonRefundTradeTxn)); - - /* Checking message sender for possible cancel message */ - labelCheckCancelTxn = codeByteBuffer.position(); - - // Compare each part of message sender's address with AT creator's address. If they don't match, look for another transaction. - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorAddress1, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorAddress2, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorAddress3, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); - // Partner address is AT creator's address, so cancel offer and finish. - codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.CANCELLED.value)); - // We're finished forever (finishing auto-refunds remaining balance to AT creator) - codeByteBuffer.put(OpCode.FIN_IMD.compile()); - - /* Not trade nor cancel message */ - labelNotTradeNorCancelTxn = codeByteBuffer.position(); - - // Loop to find another transaction - codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop)); - - /* Possible switch-to-trade-mode message */ - labelCheckNonRefundTradeTxn = codeByteBuffer.position(); - - // Check 'trade' message we received has expected number of message bytes - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength)); - // If message length matches, branch to info extraction code - codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedTradeMessageLength, calcOffset(codeByteBuffer, labelTradeTxnExtract))); - // Message length didn't match - go back to finding another 'trade' MESSAGE transaction - codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop)); - - /* Extracting info from 'trade' MESSAGE transaction */ - labelTradeTxnExtract = codeByteBuffer.position(); - - // Extract message from transaction into B register - codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B)); - // Save B register into data segment starting at addrQortalPartnerAddress1 (as pointed to by addrQortalPartnerAddressPointer) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalPartnerAddressPointer)); - - // Extract trade partner's Dogecoin public key hash (PKH) from message into B - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessagePartnerDogecoinPKHOffset)); - // Store partner's Dogecoin PKH (we only really use values from B1-B3) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerDogecoinPKHPointer)); - // Extract AT trade timeout (minutes) (from B4) - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrRefundTimeout)); - - // Grab next 32 bytes - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessageHashOfSecretAOffset)); - - // Extract hash-of-secret-A (we only really use values from B1-B3) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrHashOfSecretAPointer)); - // Extract lockTime-A (from B4) - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrLockTimeA)); - - // Calculate trade timeout refund 'timestamp' by adding addrRefundTimeout minutes to this transaction's 'timestamp', then save into addrRefundTimestamp - codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxnTimestamp, addrRefundTimeout)); - - /* We are in 'trade mode' */ - codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.TRADING.value)); - - // Set restart position to after this opcode - codeByteBuffer.put(OpCode.SET_PCS.compile()); - - /* Loop, waiting for trade timeout or 'redeem' MESSAGE from Qortal trade partner */ - - // Fetch current block 'timestamp' - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_BLOCK_TIMESTAMP, addrBlockTimestamp)); - // If we're not past refund 'timestamp' then look for next transaction - codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrRefundTimestamp, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); - // We're past refund 'timestamp' so go refund everything back to AT creator - codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund)); - - /* Transaction processing loop */ - labelRedeemTxnLoop = codeByteBuffer.position(); - - // Find next transaction to this AT since the last one (if any) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp)); - // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0. - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult)); - // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction - codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckRedeemTxn))); - // Stop and wait for next block - codeByteBuffer.put(OpCode.STP_IMD.compile()); - - /* Check transaction */ - labelCheckRedeemTxn = codeByteBuffer.position(); - - // Update our 'last found transaction's timestamp' using 'timestamp' from transaction - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxnTimestamp)); - // Extract transaction type (message/payment) from transaction and save type in addrTxnType - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType)); - // If transaction type is not MESSAGE type then go look for another transaction - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); - - /* Check message payload length */ - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength)); - // If message length matches, branch to sender checking code - codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedRedeemMessageLength, calcOffset(codeByteBuffer, labelCheckRedeemTxnSender))); - // Message length didn't match - go back to finding another 'redeem' MESSAGE transaction - codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); - - /* Check transaction's sender */ - labelCheckRedeemTxnSender = codeByteBuffer.position(); - - // Extract sender address from transaction into B register - codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B)); - // Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer)); - // Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction. - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalPartnerAddress1, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalPartnerAddress2, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalPartnerAddress3, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalPartnerAddress4, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); - - /* Check 'secret-A' in transaction's message */ - - // Extract secret-A from first 32 bytes of message from transaction into B register - codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B)); - // Save B register into data segment starting at addrMessageData (as pointed to by addrMessageDataPointer) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageDataPointer)); - // Load B register with expected hash result (as pointed to by addrHashOfSecretAPointer) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrHashOfSecretAPointer)); - // Perform HASH160 using source data at addrMessageData. (Location and length specified via addrMessageDataPointer and addrMessageDataLength). - // Save the equality result (1 if they match, 0 otherwise) into addrResult. - codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrResult, addrMessageDataPointer, addrMessageDataLength)); - // If hashes don't match, addrResult will be zero so go find another transaction - codeByteBuffer.put(OpCode.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelPayout))); - codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); - - /* Success! Pay arranged amount to receiving address */ - labelPayout = codeByteBuffer.position(); - - // Extract Qortal receiving address from next 32 bytes of message from transaction into B register - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageReceivingAddressOffset)); - // Save B register into data segment starting at addrPartnerReceivingAddress (as pointed to by addrPartnerReceivingAddressPointer) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerReceivingAddressPointer)); - // Pay AT's balance to receiving address - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount)); - // Set redeemed mode - codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REDEEMED.value)); - // We're finished forever (finishing auto-refunds remaining balance to AT creator) - codeByteBuffer.put(OpCode.FIN_IMD.compile()); - - // Fall-through to refunding any remaining balance back to AT creator - - /* Refund balance back to AT creator */ - labelRefund = codeByteBuffer.position(); - - // Set refunded mode - codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REFUNDED.value)); - // We're finished forever (finishing auto-refunds remaining balance to AT creator) - codeByteBuffer.put(OpCode.FIN_IMD.compile()); - } catch (CompilationException e) { - throw new IllegalStateException("Unable to compile DOGE-QORT ACCT?", e); - } - } - - codeByteBuffer.flip(); - - byte[] codeBytes = new byte[codeByteBuffer.limit()]; - codeByteBuffer.get(codeBytes); - - assert Arrays.equals(Crypto.digest(codeBytes), DogecoinACCTv2.CODE_BYTES_HASH) - : String.format("BTCACCT.CODE_BYTES_HASH mismatch: expected %s, actual %s", HashCode.fromBytes(CODE_BYTES_HASH), HashCode.fromBytes(Crypto.digest(codeBytes))); - - final short ciyamAtVersion = 2; - final short numCallStackPages = 0; - final short numUserStackPages = 0; - final long minActivationAmount = 0L; - - return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); - } - - /** - * Returns CrossChainTradeData with useful info extracted from AT. - */ - @Override - public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { - ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress()); - return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); - } - - /** - * Returns CrossChainTradeData with useful info extracted from AT. - */ - @Override - public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException { - ATData atData = repository.getATRepository().fromATAddress(atStateData.getATAddress()); - return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); - } - - /** - * Returns CrossChainTradeData with useful info extracted from AT. - */ - public CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, long creationTimestamp, ATStateData atStateData) throws DataException { - byte[] addressBytes = new byte[25]; // for general use - String atAddress = atStateData.getATAddress(); - - CrossChainTradeData tradeData = new CrossChainTradeData(); - - tradeData.foreignBlockchain = SupportedBlockchain.DOGECOIN.name(); - tradeData.acctName = NAME; - - tradeData.qortalAtAddress = atAddress; - tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey); - tradeData.creationTimestamp = creationTimestamp; - - Account atAccount = new Account(repository, atAddress); - tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT); - - byte[] stateData = atStateData.getStateData(); - ByteBuffer dataByteBuffer = ByteBuffer.wrap(stateData); - dataByteBuffer.position(MachineState.HEADER_LENGTH); - - /* Constants */ - - // Skip creator's trade address - dataByteBuffer.get(addressBytes); - tradeData.qortalCreatorTradeAddress = Base58.encode(addressBytes); - dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length); - - // Creator's Dogecoin/foreign public key hash - tradeData.creatorForeignPKH = new byte[20]; - dataByteBuffer.get(tradeData.creatorForeignPKH); - dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorForeignPKH.length); // skip to 32 bytes - - // We don't use secret-B - tradeData.hashOfSecretB = null; - - // Redeem payout - tradeData.qortAmount = dataByteBuffer.getLong(); - - // Expected DOGE amount - tradeData.expectedForeignAmount = dataByteBuffer.getLong(); - - // Trade timeout - tradeData.tradeTimeout = (int) dataByteBuffer.getLong(); - - // Skip MESSAGE transaction type - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip expected 'trade' message length - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip expected 'redeem' message length - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip pointer to creator's address - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip pointer to partner's Qortal trade address - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip pointer to message sender - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip 'trade' message data offset for partner's Dogecoin PKH - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip pointer to partner's Dogecoin PKH - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip 'trade' message data offset for hash-of-secret-A - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip pointer to hash-of-secret-A - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip 'redeem' message data offset for partner's Qortal receiving address - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip pointer to message data - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip message data length - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip pointer to partner's receiving address - dataByteBuffer.position(dataByteBuffer.position() + 8); - - /* End of constants / begin variables */ - - // Skip AT creator's address - dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); - - // Partner's trade address (if present) - dataByteBuffer.get(addressBytes); - String qortalRecipient = Base58.encode(addressBytes); - dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length); - - // Potential lockTimeA (if in trade mode) - int lockTimeA = (int) dataByteBuffer.getLong(); - - // AT refund timeout (probably only useful for debugging) - int refundTimeout = (int) dataByteBuffer.getLong(); - - // Trade-mode refund timestamp (AT 'timestamp' converted to Qortal block height) - long tradeRefundTimestamp = dataByteBuffer.getLong(); - - // Skip last transaction timestamp - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip block timestamp - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip transaction type - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip temporary result - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip temporary message sender - dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); - - // Skip message length - dataByteBuffer.position(dataByteBuffer.position() + 8); - - // Skip temporary message data - dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); - - // Potential hash160 of secret A - byte[] hashOfSecretA = new byte[20]; - dataByteBuffer.get(hashOfSecretA); - dataByteBuffer.position(dataByteBuffer.position() + 32 - hashOfSecretA.length); // skip to 32 bytes - - // Potential partner's Dogecoin PKH - byte[] partnerDogecoinPKH = new byte[20]; - dataByteBuffer.get(partnerDogecoinPKH); - dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerDogecoinPKH.length); // skip to 32 bytes - - // Partner's receiving address (if present) - byte[] partnerReceivingAddress = new byte[25]; - dataByteBuffer.get(partnerReceivingAddress); - dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerReceivingAddress.length); // skip to 32 bytes - - // Trade AT's 'mode' - long modeValue = dataByteBuffer.getLong(); - AcctMode mode = AcctMode.valueOf((int) (modeValue & 0xffL)); - - /* End of variables */ - - if (mode != null && mode != AcctMode.OFFERING) { - tradeData.mode = mode; - tradeData.refundTimeout = refundTimeout; - tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight; - tradeData.qortalPartnerAddress = qortalRecipient; - tradeData.hashOfSecretA = hashOfSecretA; - tradeData.partnerForeignPKH = partnerDogecoinPKH; - tradeData.lockTimeA = lockTimeA; - - if (mode == AcctMode.REDEEMED) - tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress); - } else { - tradeData.mode = AcctMode.OFFERING; - } - - tradeData.duplicateDeprecated(); - - return tradeData; - } - - /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ - public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { - byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); - return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); - } - - /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ - public static OfferMessageData extractOfferMessageData(byte[] messageData) { - if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH) - return null; - - OfferMessageData offerMessageData = new OfferMessageData(); - offerMessageData.partnerDogecoinPKH = Arrays.copyOfRange(messageData, 0, 20); - offerMessageData.hashOfSecretA = Arrays.copyOfRange(messageData, 20, 40); - offerMessageData.lockTimeA = BitTwiddling.longFromBEBytes(messageData, 40); - - return offerMessageData; - } - - /** Returns 'trade' MESSAGE payload for AT creator to send to AT. */ - public static byte[] buildTradeMessage(String partnerQortalTradeAddress, byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { - byte[] data = new byte[TRADE_MESSAGE_LENGTH]; - byte[] partnerQortalAddressBytes = Base58.decode(partnerQortalTradeAddress); - byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); - byte[] refundTimeoutBytes = BitTwiddling.toBEByteArray((long) refundTimeout); - - System.arraycopy(partnerQortalAddressBytes, 0, data, 0, partnerQortalAddressBytes.length); - System.arraycopy(partnerBitcoinPKH, 0, data, 32, partnerBitcoinPKH.length); - System.arraycopy(refundTimeoutBytes, 0, data, 56, refundTimeoutBytes.length); - System.arraycopy(hashOfSecretA, 0, data, 64, hashOfSecretA.length); - System.arraycopy(lockTimeABytes, 0, data, 88, lockTimeABytes.length); - - return data; - } - - /** Returns 'cancel' MESSAGE payload for AT creator to cancel trade AT. */ - @Override - public byte[] buildCancelMessage(String creatorQortalAddress) { - byte[] data = new byte[CANCEL_MESSAGE_LENGTH]; - byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress); - - System.arraycopy(creatorQortalAddressBytes, 0, data, 0, creatorQortalAddressBytes.length); - - return data; - } - - /** Returns 'redeem' MESSAGE payload for trade partner to send to AT. */ - public static byte[] buildRedeemMessage(byte[] secretA, String qortalReceivingAddress) { - byte[] data = new byte[REDEEM_MESSAGE_LENGTH]; - byte[] qortalReceivingAddressBytes = Base58.decode(qortalReceivingAddress); - - System.arraycopy(secretA, 0, data, 0, secretA.length); - System.arraycopy(qortalReceivingAddressBytes, 0, data, 32, qortalReceivingAddressBytes.length); - - return data; - } - - /** Returns refund timeout (minutes) based on trade partner's 'offer' MESSAGE timestamp and P2SH-A locktime. */ - public static int calcRefundTimeout(long offerMessageTimestamp, int lockTimeA) { - // refund should be triggered halfway between offerMessageTimestamp and lockTimeA - return (int) ((lockTimeA - (offerMessageTimestamp / 1000L)) / 2L / 60L); - } - - @Override - public byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { - String atAddress = crossChainTradeData.qortalAtAddress; - String redeemerAddress = crossChainTradeData.qortalPartnerAddress; - - // We don't have partner's public key so we check every message to AT - List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, atAddress, null, null, null); - if (messageTransactionsData == null) - return null; - - // Find 'redeem' message - for (MessageTransactionData messageTransactionData : messageTransactionsData) { - // Check message payload type/encryption - if (messageTransactionData.isText() || messageTransactionData.isEncrypted()) - continue; - - // Check message payload size - byte[] messageData = messageTransactionData.getData(); - if (messageData.length != REDEEM_MESSAGE_LENGTH) - // Wrong payload length - continue; - - // Check sender - if (!Crypto.toAddress(messageTransactionData.getSenderPublicKey()).equals(redeemerAddress)) - // Wrong sender; - continue; - - // Extract secretA - byte[] secretA = new byte[32]; - System.arraycopy(messageData, 0, secretA, 0, secretA.length); - - byte[] hashOfSecretA = Crypto.hash160(secretA); - if (!Arrays.equals(hashOfSecretA, crossChainTradeData.hashOfSecretA)) - continue; - - return secretA; - } - - return null; - } - -} diff --git a/src/main/java/org/qortal/crosschain/SupportedBlockchain.java b/src/main/java/org/qortal/crosschain/SupportedBlockchain.java index 8fa919d3..1fc8d149 100644 --- a/src/main/java/org/qortal/crosschain/SupportedBlockchain.java +++ b/src/main/java/org/qortal/crosschain/SupportedBlockchain.java @@ -42,8 +42,7 @@ public enum SupportedBlockchain { }, DOGECOIN(Arrays.asList( - Triple.valueOf(DogecoinACCTv1.NAME, DogecoinACCTv1.CODE_BYTES_HASH, DogecoinACCTv1::getInstance), - Triple.valueOf(DogecoinACCTv2.NAME, DogecoinACCTv2.CODE_BYTES_HASH, DogecoinACCTv2::getInstance) + Triple.valueOf(DogecoinACCTv1.NAME, DogecoinACCTv1.CODE_BYTES_HASH, DogecoinACCTv1::getInstance) )) { @Override public ForeignBlockchain getInstance() { @@ -52,7 +51,7 @@ public enum SupportedBlockchain { @Override public ACCT getLatestAcct() { - return DogecoinACCTv2.getInstance(); + return DogecoinACCTv1.getInstance(); } }; From 746c68c9f65cf6135de2626be8932141ccfa26b5 Mon Sep 17 00:00:00 2001 From: Scythian <> Date: Fri, 6 Aug 2021 19:27:28 +0100 Subject: [PATCH 018/231] Reorganised translations Added new keys and removed old unused keys Localised the "Build version" string in the SysTray --- .../org/qortal/controller/Controller.java | 2 +- .../resources/i18n/ApiError_en.properties | 133 ++++---- src/main/resources/i18n/SysTray_en.properties | 15 +- .../i18n/TransactionValidity_en.properties | 303 +++++++++--------- .../qortal/test/apps/CheckTranslations.java | 6 +- 5 files changed, 234 insertions(+), 225 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 3d1c85b7..bb990b17 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -886,7 +886,7 @@ public class Controller extends Thread { } } - String tooltip = String.format("%s - %d %s - %s %d", actionText, numberOfPeers, connectionsText, heightText, height) + "\n" + String.format("Build version: %s", this.buildVersion); + String tooltip = String.format("%s - %d %s - %s %d", actionText, numberOfPeers, connectionsText, heightText, height) + "\n" + String.format("%s: %s", Translator.INSTANCE.translate("SysTray", "BUILD_VERSION"), this.buildVersion); SysTray.getInstance().setToolTipText(tooltip); this.callbackExecutor.execute(() -> { diff --git a/src/main/resources/i18n/ApiError_en.properties b/src/main/resources/i18n/ApiError_en.properties index 4010b2fb..ecce979d 100644 --- a/src/main/resources/i18n/ApiError_en.properties +++ b/src/main/resources/i18n/ApiError_en.properties @@ -1,68 +1,81 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # Keys are from api.ApiError enum -ADDRESS_UNKNOWN = account address unknown - -BLOCKCHAIN_NEEDS_SYNC = blockchain needs to synchronize first - -# Blocks -BLOCK_UNKNOWN = block unknown - -BTC_BALANCE_ISSUE = insufficient Bitcoin balance - -BTC_NETWORK_ISSUE = Bitcoin/ElectrumX network issue - -BTC_TOO_SOON = too soon to broadcast Bitcoin transaction (lockTime/median block time) - -CANNOT_MINT = account cannot mint - -GROUP_UNKNOWN = group unknown - -INVALID_ADDRESS = invalid address - -# Assets -INVALID_ASSET_ID = invalid asset ID - -INVALID_CRITERIA = invalid search criteria - -INVALID_DATA = invalid data - -INVALID_HEIGHT = invalid block height - -INVALID_NETWORK_ADDRESS = invalid network address - -INVALID_ORDER_ID = invalid asset order ID - -INVALID_PRIVATE_KEY = invalid private key - -INVALID_PUBLIC_KEY = invalid public key - -INVALID_REFERENCE = invalid reference - -# Validation -INVALID_SIGNATURE = invalid signature - +### Common ### JSON = failed to parse JSON message -NAME_UNKNOWN = name unknown - -NON_PRODUCTION = this API call is not permitted for production systems - -NO_TIME_SYNC = no clock synchronization yet - -ORDER_UNKNOWN = unknown asset order ID - -PUBLIC_KEY_NOT_FOUND = public key not found - -REPOSITORY_ISSUE = repository error - -# This one is special in that caller expected to pass two additional strings, hence the two %s -TRANSACTION_INVALID = transaction invalid: %s (%s) - -TRANSACTION_UNKNOWN = transaction unknown - -TRANSFORMATION_ERROR = could not transform JSON into transaction +INSUFFICIENT_BALANCE = insufficient balance UNAUTHORIZED = API call unauthorized -ORDER_SIZE_TOO_SMALL = order size too small +REPOSITORY_ISSUE = repository error + +NON_PRODUCTION = this API call is not permitted for production systems + +BLOCKCHAIN_NEEDS_SYNC = blockchain needs to synchronize first + +NO_TIME_SYNC = no clock synchronization yet + +### Validation ### +INVALID_SIGNATURE = invalid signature + +INVALID_ADDRESS = invalid address + +INVALID_PUBLIC_KEY = invalid public key + +INVALID_DATA = invalid data + +INVALID_NETWORK_ADDRESS = invalid network address + +ADDRESS_UNKNOWN = account address unknown + +INVALID_CRITERIA = invalid search criteria + +INVALID_REFERENCE = invalid reference + +TRANSFORMATION_ERROR = could not transform JSON into transaction + +INVALID_PRIVATE_KEY = invalid private key + +INVALID_HEIGHT = invalid block height + +CANNOT_MINT = account cannot mint + +### Blocks ### +BLOCK_UNKNOWN = block unknown + +### Transactions ### +TRANSACTION_UNKNOWN = transaction unknown + +PUBLIC_KEY_NOT_FOUND = public key not found + +# this one is special in that caller expected to pass two additional strings, hence the two %s +TRANSACTION_INVALID = transaction invalid: %s (%s) + +### Naming ### +NAME_UNKNOWN = name unknown + +### Asset ### +INVALID_ASSET_ID = invalid asset ID + +INVALID_ORDER_ID = invalid asset order ID + +ORDER_UNKNOWN = unknown asset order ID + +### Groups ### +GROUP_UNKNOWN = group unknown + +### Foreign Blockchain ### +FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = foreign blokchain or ElectrumX network issue + +FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = insufficient balance on foreign blockchain + +FOREIGN_BLOCKCHAIN_TOO_SOON = too soon to broadcast foreign blockchain transaction (LockTime/median block time) + +### Trade Portal ### +ORDER_SIZE_TOO_SMALL = order amount too low + +### Data ### +FILE_NOT_FOUND = file not found + +NO_REPLY = peer did not reply with data \ No newline at end of file diff --git a/src/main/resources/i18n/SysTray_en.properties b/src/main/resources/i18n/SysTray_en.properties index e581335d..ddaf19ab 100644 --- a/src/main/resources/i18n/SysTray_en.properties +++ b/src/main/resources/i18n/SysTray_en.properties @@ -1,12 +1,14 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # SysTray pop-up menu -APPLYING_UPDATE_AND_RESTARTING = Applying automatic update and restarting... - AUTO_UPDATE = Auto Update +APPLYING_UPDATE_AND_RESTARTING = Applying automatic update and restarting... + BLOCK_HEIGHT = height +BUILD_VERSION = Build version + CHECK_TIME_ACCURACY = Check time accuracy CONNECTING = Connecting @@ -27,13 +29,6 @@ MINTING_DISABLED = NOT minting MINTING_ENABLED = \u2714 Minting -# Nagging about lack of NTP time sync -NTP_NAG_CAPTION = Computer's clock is inaccurate! - -NTP_NAG_TEXT_UNIX = Install NTP service to get an accurate clock. - -NTP_NAG_TEXT_WINDOWS = Select "Synchronize clock" from menu to fix. - OPEN_UI = Open UI PERFORMING_DB_CHECKPOINT = Saving uncommitted database changes... @@ -42,4 +37,4 @@ SYNCHRONIZE_CLOCK = Synchronize clock SYNCHRONIZING_BLOCKCHAIN = Synchronizing -SYNCHRONIZING_CLOCK = Synchronizing clock +SYNCHRONIZING_CLOCK = Synchronizing clock \ No newline at end of file diff --git a/src/main/resources/i18n/TransactionValidity_en.properties b/src/main/resources/i18n/TransactionValidity_en.properties index 7e3ea324..ff34cd1d 100644 --- a/src/main/resources/i18n/TransactionValidity_en.properties +++ b/src/main/resources/i18n/TransactionValidity_en.properties @@ -1,161 +1,44 @@ - -ACCOUNT_ALREADY_EXISTS = account already exists - -ACCOUNT_CANNOT_REWARD_SHARE = account cannot reward-share - -ALREADY_GROUP_ADMIN = already group admin - -ALREADY_GROUP_MEMBER = already group member - -ALREADY_VOTED_FOR_THAT_OPTION = already voted for that option - -ASSET_ALREADY_EXISTS = asset already exists - -ASSET_DOES_NOT_EXIST = asset does not exist - -ASSET_DOES_NOT_MATCH_AT = asset does not match AT's asset - -ASSET_NOT_SPENDABLE = asset is not spendable - -AT_ALREADY_EXISTS = AT already exists - -AT_IS_FINISHED = AT has finished - -AT_UNKNOWN = AT unknown - -BANNED_FROM_GROUP = banned from group - -BAN_EXISTS = ban already exists - -BAN_UNKNOWN = ban unknown - -BUYER_ALREADY_OWNER = buyer is already owner - -CHAT = CHAT transactions are never valid for inclusion into blocks - -CLOCK_NOT_SYNCED = clock not synchronized - -DUPLICATE_OPTION = duplicate option - -GROUP_ALREADY_EXISTS = group already exists - -GROUP_APPROVAL_DECIDED = group-approval already decided - -GROUP_APPROVAL_NOT_REQUIRED = group-approval not required - -GROUP_DOES_NOT_EXIST = group does not exist - -GROUP_ID_MISMATCH = group ID mismatch - -GROUP_OWNER_CANNOT_LEAVE = group owner cannot leave group - -HAVE_EQUALS_WANT = have-asset is the same as want-asset - -INCORRECT_NONCE = incorrect PoW nonce - -INSUFFICIENT_FEE = insufficient fee +OK = OK INVALID_ADDRESS = invalid address -INVALID_AMOUNT = invalid amount - -INVALID_ASSET_OWNER = invalid asset owner - -INVALID_AT_TRANSACTION = invalid AT transaction - -INVALID_AT_TYPE_LENGTH = invalid AT 'type' length - -INVALID_CREATION_BYTES = invalid creation bytes - -INVALID_DATA_LENGTH = invalid data length - -INVALID_DESCRIPTION_LENGTH = invalid description length - -INVALID_GROUP_APPROVAL_THRESHOLD = invalid group-approval threshold - -INVALID_GROUP_BLOCK_DELAY = invalid group-approval block delay - -INVALID_GROUP_ID = invalid group ID - -INVALID_GROUP_OWNER = invalid group owner - -INVALID_LIFETIME = invalid lifetime - -INVALID_NAME_LENGTH = invalid name length - -INVALID_NAME_OWNER = invalid name owner - -INVALID_OPTIONS_COUNT = invalid options count - -INVALID_OPTION_LENGTH = invalid options length - -INVALID_ORDER_CREATOR = invalid order creator - -INVALID_PAYMENTS_COUNT = invalid payments count - -INVALID_PUBLIC_KEY = invalid public key - -INVALID_QUANTITY = invalid quantity - -INVALID_REFERENCE = invalid reference - -INVALID_RETURN = invalid return - -INVALID_REWARD_SHARE_PERCENT = invalid reward-share percent - -INVALID_SELLER = invalid seller - -INVALID_TAGS_LENGTH = invalid 'tags' length - -INVALID_TX_GROUP_ID = invalid transaction group ID - -INVALID_VALUE_LENGTH = invalid 'value' length - -INVITE_UNKNOWN = group invite unknown - -JOIN_REQUEST_EXISTS = group join request already exists - -MAXIMUM_REWARD_SHARES = already at maximum number of reward-shares for this account - -MISSING_CREATOR = missing creator - -MULTIPLE_NAMES_FORBIDDEN = multiple registered names per account is forbidden - -NAME_ALREADY_FOR_SALE = name already for sale - -NAME_ALREADY_REGISTERED = name already registered - -NAME_DOES_NOT_EXIST = name does not exist - -NAME_NOT_FOR_SALE = name is not for sale - -NAME_NOT_NORMALIZED = name not in Unicode 'normalized' form - NEGATIVE_AMOUNT = invalid/negative amount NEGATIVE_FEE = invalid/negative fee -NEGATIVE_PRICE = invalid/negative price - -NOT_GROUP_ADMIN = account is not a group admin - -NOT_GROUP_MEMBER = account is not a group member - -NOT_MINTING_ACCOUNT = account cannot mint - -NOT_YET_RELEASED = feature not yet released - NO_BALANCE = insufficient balance -NO_BLOCKCHAIN_LOCK = node's blockchain currently busy +INVALID_REFERENCE = invalid reference -NO_FLAG_PERMISSION = account does not have that permission +INVALID_NAME_LENGTH = invalid name length -OK = OK +INVALID_VALUE_LENGTH = invalid 'value' length -ORDER_ALREADY_CLOSED = asset trade order is already closed +NAME_ALREADY_REGISTERED = name already registered -ORDER_DOES_NOT_EXIST = asset trade order does not exist +NAME_DOES_NOT_EXIST = name does not exist + +INVALID_NAME_OWNER = invalid name owner + +NAME_ALREADY_FOR_SALE = name already for sale + +NAME_NOT_FOR_SALE = name is not for sale + +BUYER_ALREADY_OWNER = buyer is already owner + +INVALID_AMOUNT = invalid amount + +INVALID_SELLER = invalid seller + +NAME_NOT_NORMALIZED = name not in Unicode 'normalized' form + +INVALID_DESCRIPTION_LENGTH = invalid description length + +INVALID_OPTIONS_COUNT = invalid options count + +INVALID_OPTION_LENGTH = invalid options length + +DUPLICATE_OPTION = duplicate option POLL_ALREADY_EXISTS = poll already exists @@ -163,22 +46,140 @@ POLL_DOES_NOT_EXIST = poll does not exist POLL_OPTION_DOES_NOT_EXIST = poll option does not exist -PUBLIC_KEY_UNKNOWN = public key unknown +ALREADY_VOTED_FOR_THAT_OPTION = already voted for that option -REWARD_SHARE_UNKNOWN = reward-share unknown +INVALID_DATA_LENGTH = invalid data length -SELF_SHARE_EXISTS = self-share (reward-share) already exists +INVALID_QUANTITY = invalid quantity -TIMESTAMP_TOO_NEW = timestamp too new +ASSET_DOES_NOT_EXIST = asset does not exist + +INVALID_RETURN = invalid return + +HAVE_EQUALS_WANT = have-asset is the same as want-asset + +ORDER_DOES_NOT_EXIST = asset trade order does not exist + +INVALID_ORDER_CREATOR = invalid order creator + +INVALID_PAYMENTS_COUNT = invalid payments count + +NEGATIVE_PRICE = invalid/negative price + +INVALID_CREATION_BYTES = invalid creation bytes + +INVALID_TAGS_LENGTH = invalid 'tags' length + +INVALID_AT_TYPE_LENGTH = invalid AT 'type' length + +INVALID_AT_TRANSACTION = invalid AT transaction + +INSUFFICIENT_FEE = insufficient fee + +ASSET_DOES_NOT_MATCH_AT = asset does not match AT's asset + +ASSET_ALREADY_EXISTS = asset already exists + +MISSING_CREATOR = missing creator TIMESTAMP_TOO_OLD = timestamp too old +TIMESTAMP_TOO_NEW = timestamp too new + TOO_MANY_UNCONFIRMED = account has too many unconfirmed transactions pending -TRANSACTION_ALREADY_CONFIRMED = transaction has already confirmed +GROUP_ALREADY_EXISTS = group already exists -TRANSACTION_ALREADY_EXISTS = transaction already exists +GROUP_DOES_NOT_EXIST = group does not exist + +INVALID_GROUP_OWNER = invalid group owner + +ALREADY_GROUP_MEMBER = already group member + +GROUP_OWNER_CANNOT_LEAVE = group owner cannot leave group + +NOT_GROUP_MEMBER = account is not a group member + +ALREADY_GROUP_ADMIN = already group admin + +NOT_GROUP_ADMIN = account is not a group admin + +INVALID_LIFETIME = invalid lifetime + +INVITE_UNKNOWN = group invite unknown + +BAN_EXISTS = ban already exists + +BAN_UNKNOWN = ban unknown + +BANNED_FROM_GROUP = banned from group + +JOIN_REQUEST_EXISTS = group join request already exists + +INVALID_GROUP_APPROVAL_THRESHOLD = invalid group-approval threshold + +GROUP_ID_MISMATCH = group ID mismatch + +INVALID_GROUP_ID = invalid group ID TRANSACTION_UNKNOWN = transaction unknown +TRANSACTION_ALREADY_CONFIRMED = transaction has already confirmed + +INVALID_TX_GROUP_ID = invalid transaction group ID + TX_GROUP_ID_MISMATCH = transaction's group ID does not match + +MULTIPLE_NAMES_FORBIDDEN = multiple registered names per account is forbidden + +INVALID_ASSET_OWNER = invalid asset owner + +AT_IS_FINISHED = AT has finished + +NO_FLAG_PERMISSION = account does not have that permission + +NOT_MINTING_ACCOUNT = account cannot mint + +REWARD_SHARE_UNKNOWN = reward-share unknown + +INVALID_REWARD_SHARE_PERCENT = invalid reward-share percent + +PUBLIC_KEY_UNKNOWN = public key unknown + +INVALID_PUBLIC_KEY = invalid public key + +AT_UNKNOWN = AT unknown + +AT_ALREADY_EXISTS = AT already exists + +GROUP_APPROVAL_NOT_REQUIRED = group-approval not required + +GROUP_APPROVAL_DECIDED = group-approval already decided + +MAXIMUM_REWARD_SHARES = already at maximum number of reward-shares for this account + +TRANSACTION_ALREADY_EXISTS = transaction already exists + +NO_BLOCKCHAIN_LOCK = node's blockchain currently busy + +ORDER_ALREADY_CLOSED = asset trade order is already closed + +CLOCK_NOT_SYNCED = clock not synchronized + +ASSET_NOT_SPENDABLE = asset is not spendable + +ACCOUNT_CANNOT_REWARD_SHARE = account cannot reward-share + +SELF_SHARE_EXISTS = self-share (reward-share) already exists + +ACCOUNT_ALREADY_EXISTS = account already exists + +INVALID_GROUP_BLOCK_DELAY = invalid group-approval block delay + +INCORRECT_NONCE = incorrect PoW nonce + +INVALID_TIMESTAMP_SIGNATURE = invalid timestamp signature + +INVALID_BUT_OK = invalid but OK + +NOT_YET_RELEASED = feature not yet released \ No newline at end of file diff --git a/src/test/java/org/qortal/test/apps/CheckTranslations.java b/src/test/java/org/qortal/test/apps/CheckTranslations.java index faf1727d..2b59ce84 100644 --- a/src/test/java/org/qortal/test/apps/CheckTranslations.java +++ b/src/test/java/org/qortal/test/apps/CheckTranslations.java @@ -14,9 +14,9 @@ public class CheckTranslations { private static final String[] SUPPORTED_LANGS = new String[] { "en", "de", "zh", "ru" }; private static final Set SYSTRAY_KEYS = Set.of("AUTO_UPDATE", "APPLYING_UPDATE_AND_RESTARTING", "BLOCK_HEIGHT", - "CHECK_TIME_ACCURACY", "CONNECTING", "CONNECTION", "CONNECTIONS", "CREATING_BACKUP_OF_DB_FILES", "DB_BACKUP", "EXIT", - "MINTING_DISABLED", "MINTING_ENABLED", "NTP_NAG_CAPTION", "NTP_NAG_TEXT_UNIX", "NTP_NAG_TEXT_WINDOWS", - "OPEN_UI", "SYNCHRONIZE_CLOCK", "SYNCHRONIZING_BLOCKCHAIN", "SYNCHRONIZING_CLOCK"); + "BUILD_VERSION", "CHECK_TIME_ACCURACY", "CONNECTING", "CONNECTION", "CONNECTIONS", "CREATING_BACKUP_OF_DB_FILES", + "DB_BACKUP", "DB_CHECKPOINT", "EXIT", "MINTING_DISABLED", "MINTING_ENABLED", "OPEN_UI", "PERFORMING_DB_CHECKPOINT", + "SYNCHRONIZE_CLOCK", "SYNCHRONIZING_BLOCKCHAIN", "SYNCHRONIZING_CLOCK"); private static String failurePrefix; From 0b53de1bb68f1ec7224513fa656afc634ea9a91a Mon Sep 17 00:00:00 2001 From: Scythian <> Date: Fri, 6 Aug 2021 19:28:56 +0100 Subject: [PATCH 019/231] Added Hungarian translations --- .../resources/i18n/ApiError_hu.properties | 81 ++++++++ src/main/resources/i18n/SysTray_hu.properties | 40 ++++ .../i18n/TransactionValidity_hu.properties | 185 ++++++++++++++++++ 3 files changed, 306 insertions(+) create mode 100644 src/main/resources/i18n/ApiError_hu.properties create mode 100644 src/main/resources/i18n/SysTray_hu.properties create mode 100644 src/main/resources/i18n/TransactionValidity_hu.properties diff --git a/src/main/resources/i18n/ApiError_hu.properties b/src/main/resources/i18n/ApiError_hu.properties new file mode 100644 index 00000000..d66b2e82 --- /dev/null +++ b/src/main/resources/i18n/ApiError_hu.properties @@ -0,0 +1,81 @@ +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# Keys are from api.ApiError enum + +### Common ### +JSON = nem sikerült elemezni a JSON üzenetet + +INSUFFICIENT_BALANCE = elégtelen egyenleg + +UNAUTHORIZED = nem engedélyezett API-hívás + +REPOSITORY_ISSUE = adattári hiba + +NON_PRODUCTION = ez az API-hívás nem engedélyezett korlátozott rendszereken + +BLOCKCHAIN_NEEDS_SYNC = a blokkláncnak még szinkronizálnia kell + +NO_TIME_SYNC = az óraszinkronizálás még nem történt meg + +### Validation ### +INVALID_SIGNATURE = érvénytelen aláírás + +INVALID_ADDRESS = érvénytelen fiók cím + +INVALID_PUBLIC_KEY = érvénytelen nyilvános kulcs + +INVALID_DATA = érvénytelen adat + +INVALID_NETWORK_ADDRESS = érvénytelen hálózat cím + +ADDRESS_UNKNOWN = ismeretlen fiók cím + +INVALID_CRITERIA = érvénytelen keresési feltétel + +INVALID_REFERENCE = érvénytelen hivatkozás + +TRANSFORMATION_ERROR = nem sikerült tranzakcióvá alakítani a JSON-t + +INVALID_PRIVATE_KEY = érvénytelen privát kulcs + +INVALID_HEIGHT = érvénytelen blokkmagasság + +CANNOT_MINT = ez a fiók még nem tud QORT-ot verni + +### Blocks ### +BLOCK_UNKNOWN = ismeretlen blokk + +### Transactions ### +TRANSACTION_UNKNOWN = ismeretlen tranzakció + +PUBLIC_KEY_NOT_FOUND = nyilvános kulcs nem található + +# this one is special in that caller expected to pass two additional strings, hence the two %s +TRANSACTION_INVALID = érvénytelen tranzakció: %s (%s) + +### Naming ### +NAME_UNKNOWN = ismeretlen név + +### Asset ### +INVALID_ASSET_ID = érvénytelen eszközazonosító + +INVALID_ORDER_ID = érvénytelen eszközrendelési azonosító + +ORDER_UNKNOWN = ismeretlen eszközrendelési azonosító + +### Groups ### +GROUP_UNKNOWN = ismeretlen csoport + +### Foreign Blockchain ### +FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = idegen blokklánc vagy ElectrumX hálózati probléma + +FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = elégtelen egyenleg az idegen blokkláncon + +FOREIGN_BLOCKCHAIN_TOO_SOON = túl korai meghírdetni az idegen blokkláncon való tranzakciót (LockTime/medián blokkidő) + +### Trade Portal ### +ORDER_SIZE_TOO_SMALL = rendelési összeg túl alacsony + +### Data ### +FILE_NOT_FOUND = fájl nem található + +NO_REPLY = a másik csomópont nem válaszolt \ No newline at end of file diff --git a/src/main/resources/i18n/SysTray_hu.properties b/src/main/resources/i18n/SysTray_hu.properties new file mode 100644 index 00000000..f7e21002 --- /dev/null +++ b/src/main/resources/i18n/SysTray_hu.properties @@ -0,0 +1,40 @@ +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# SysTray pop-up menu + +AUTO_UPDATE = Automatikus Frissítés + +APPLYING_UPDATE_AND_RESTARTING = Automatikus frissítés és újraindítás alkalmazása... + +BLOCK_HEIGHT = blokkmagasság + +BUILD_VERSION = Verzió + +CHECK_TIME_ACCURACY = Idő pontosság ellenőrzése + +CONNECTING = Kapcsolódás + +CONNECTION = kapcsolat + +CONNECTIONS = kapcsolat + +CREATING_BACKUP_OF_DB_FILES = Adatbázis fájlok biztonsági mentésének létrehozása... + +DB_BACKUP = Adatbázis biztonsági mentése + +DB_CHECKPOINT = Adatbázis-ellenőrzőpont + +EXIT = Kilépés + +MINTING_DISABLED = QORT-érmeverés jelenleg nincs folyamatban + +MINTING_ENABLED = \u2714 QORT-érmeverés folyamatban + +OPEN_UI = Felhasználói eszköz megnyitása + +PERFORMING_DB_CHECKPOINT = Mentetlen adatbázis-módosítások mentése... + +SYNCHRONIZE_CLOCK = Óra-szinkronizálás megkezdése + +SYNCHRONIZING_BLOCKCHAIN = Szinkronizálás + +SYNCHRONIZING_CLOCK = Óra-szinkronizálás folyamatban \ No newline at end of file diff --git a/src/main/resources/i18n/TransactionValidity_hu.properties b/src/main/resources/i18n/TransactionValidity_hu.properties new file mode 100644 index 00000000..d2ed3911 --- /dev/null +++ b/src/main/resources/i18n/TransactionValidity_hu.properties @@ -0,0 +1,185 @@ +OK = OK + +INVALID_ADDRESS = érvénytelen név vagy cím + +NEGATIVE_AMOUNT = negatív összeg + +NEGATIVE_FEE = érvénytelen/negatív tranzakciós díj + +NO_BALANCE = elégtelen egyenleg + +INVALID_REFERENCE = érvénytelen hivatkozás + +INVALID_NAME_LENGTH = érvénytelen névhossz + +INVALID_VALUE_LENGTH = érvénytelen értékhossz + +NAME_ALREADY_REGISTERED = ez a név már regisztrált + +NAME_DOES_NOT_EXIST = ez a név nem létezik + +INVALID_NAME_OWNER = érvénytelen név tulajdonos + +NAME_ALREADY_FOR_SALE = ez a név már eladó + +NAME_NOT_FOR_SALE = ez a név nem eladó + +BUYER_ALREADY_OWNER = ez a vevő már a tulajdonos + +INVALID_AMOUNT = érvénytelen összeg + +INVALID_SELLER = érvénytelen eladó + +NAME_NOT_NORMALIZED = ez a név nincs "normalizált" Unicode formátumban + +INVALID_DESCRIPTION_LENGTH = érvénytelen leíráshossz + +INVALID_OPTIONS_COUNT = invalid options count + +INVALID_OPTION_LENGTH = érvénytelen opciókszám + +DUPLICATE_OPTION = ez a lehetőség már létezik + +POLL_ALREADY_EXISTS = ez a szavazás már létezik + +POLL_DOES_NOT_EXIST = ez a szavazás nem létezik + +POLL_OPTION_DOES_NOT_EXIST = ez a szavazási lehetőség nem létezik + +ALREADY_VOTED_FOR_THAT_OPTION = erre a lehetőségre már szavaztál + +INVALID_DATA_LENGTH = érvénytelen adathossz + +INVALID_QUANTITY = érvénytelen mennyiség + +ASSET_DOES_NOT_EXIST = tőke nem létezik + +INVALID_RETURN = érvénytelen csere tőke + +HAVE_EQUALS_WANT = saját tőke egyenlő a csere tőkével + +ORDER_DOES_NOT_EXIST = tőke rendelés nem létezik + +INVALID_ORDER_CREATOR = érvénytelen rendelés létrehozó + +INVALID_PAYMENTS_COUNT = a kifizetések száma érvénytelen + +NEGATIVE_PRICE = érvénytelen/negatív ár + +INVALID_CREATION_BYTES = érvénytelen létrehozási bájtok + +INVALID_TAGS_LENGTH = érvénytelen cimkehossz + +INVALID_AT_TYPE_LENGTH = érvénytelen AT "típus" hossz + +INVALID_AT_TRANSACTION = érvénytelen AT tranzakció + +INSUFFICIENT_FEE = elégtelen díj + +ASSET_DOES_NOT_MATCH_AT = a tőke nem egyezik az AT tőkéjével + +ASSET_ALREADY_EXISTS = ez a tőke már létezik + +MISSING_CREATOR = hiányzó létrehozó + +TIMESTAMP_TOO_OLD = időbélyeg túl régi + +TIMESTAMP_TOO_NEW = időbélyeg túl korai + +TOO_MANY_UNCONFIRMED = ennek a fióknak túl sok meg nem erősített tranzakciója van folyamatban + +GROUP_ALREADY_EXISTS = ez a csoport már létezik + +GROUP_DOES_NOT_EXIST = ez a csoport nem létezik + +INVALID_GROUP_OWNER = érvénytelen csoport tulajdonos + +ALREADY_GROUP_MEMBER = már csoporttag + +GROUP_OWNER_CANNOT_LEAVE = a csoport tulajdonos nem tudja elhagyni a csoportot + +NOT_GROUP_MEMBER = ez a tag nem csoporttag + +ALREADY_GROUP_ADMIN = már csoport adminisztrátor + +NOT_GROUP_ADMIN = ez a tag nem csoport adminisztrátor + +INVALID_LIFETIME = érvénytelen élettartam + +INVITE_UNKNOWN = ismeretlen csoport meghívás + +BAN_EXISTS = már ki van tiltva + +BAN_UNKNOWN = kitiltás nem létezik + +BANNED_FROM_GROUP = ki van tiltva a csoportból + +JOIN_REQUEST_EXISTS = a csoporthoz való csatlakozási kérelem már megtöretént + +INVALID_GROUP_APPROVAL_THRESHOLD = érvénytelen jóváhagyási küszöbérték + +GROUP_ID_MISMATCH = csoportazonosító nem egyezik + +INVALID_GROUP_ID = csoportazonosító érvénytelen + +TRANSACTION_UNKNOWN = ismeretlen tranzakció + +TRANSACTION_ALREADY_CONFIRMED = ez a tranzakció már meg van erősítve + +INVALID_TX_GROUP_ID = a tranzakció csoportazonosítója érvénytelen + +TX_GROUP_ID_MISMATCH = a tranzakció csoportazonosítója nem egyezik + +MULTIPLE_NAMES_FORBIDDEN = fiókonként több név regisztrálása tilos + +INVALID_ASSET_OWNER = érvénytelen tőke tulajdonos + +AT_IS_FINISHED = az AT végzett + +NO_FLAG_PERMISSION = ez a fiók nem rendelkezik ezzel az engedéllyel + +NOT_MINTING_ACCOUNT = ez a fiók nem tud QORT-ot verni + +REWARD_SHARE_UNKNOWN = ez a jutalék-megosztás ismeretlen + +INVALID_REWARD_SHARE_PERCENT = ez a jutalék-megosztási arány érvénytelen + +PUBLIC_KEY_UNKNOWN = ismeretlen nyilvános kulcs + +INVALID_PUBLIC_KEY = érvénytelen nyilvános kulcs + +AT_UNKNOWN = az AT ismeretlen + +AT_ALREADY_EXISTS = az AT már létezik + +GROUP_APPROVAL_NOT_REQUIRED = csoport általi jóváhagyás nem szükséges + +GROUP_APPROVAL_DECIDED = csoport általi jóváhagyás el van döntve + +MAXIMUM_REWARD_SHARES = ez a fiókcím már elérte a maximális lehetséges jutalék-megosztási részesedést + +TRANSACTION_ALREADY_EXISTS = ez a tranzakció már létezik + +NO_BLOCKCHAIN_LOCK = csomópont blokklánca jelenleg elfoglalt + +ORDER_ALREADY_CLOSED = ez a tőke értékesítés már befejeződött + +CLOCK_NOT_SYNCED = az óra nincs szinkronizálva + +ASSET_NOT_SPENDABLE = ez a tőke nem értékesíthető + +ACCOUNT_CANNOT_REWARD_SHARE = ez a fiók nem vehet részt jutalék-megosztásban + +SELF_SHARE_EXISTS = önrészes jutalék-megosztás már létezik + +ACCOUNT_ALREADY_EXISTS = ez a fiók már létezik + +INVALID_GROUP_BLOCK_DELAY = invalid group-approval block delay + +INCORRECT_NONCE = helytelen Proof-of-Work Nonce + +INVALID_TIMESTAMP_SIGNATURE = érvénytelen időbélyeg aláírás + +INVALID_BUT_OK = érvénytelen de elfogadva + +NOT_YET_RELEASED = ez a funkció még nem került kiadásra \ No newline at end of file From a253294890e31b06372c0f48a921ed949fb5562c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 6 Aug 2021 20:01:59 +0100 Subject: [PATCH 020/231] Ensure frozen ATs are still executed every block. We currently want to execute frozen ATs, to maintain backwards support. We could optionally choose to stop executing them later, via a hard fork. --- src/main/java/org/qortal/at/AT.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/at/AT.java b/src/main/java/org/qortal/at/AT.java index 99ae57d5..005bb0cd 100644 --- a/src/main/java/org/qortal/at/AT.java +++ b/src/main/java/org/qortal/at/AT.java @@ -131,8 +131,10 @@ public class AT { // Nothing happened? if (state.getSteps() == 0 && Arrays.equals(stateHash, latestAtStateData.getStateHash())) - // this.atStateData will be null - return Collections.emptyList(); + // We currently want to execute frozen ATs, to maintain backwards support. + if (state.isFrozen() == false) + // this.atStateData will be null + return Collections.emptyList(); long atFees = api.calcFinalFees(state); Long sleepUntilMessageTimestamp = this.atData.getSleepUntilMessageTimestamp(); From 24f1fb566dd399b77818fef4972ccc80080bc3fc Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 7 Aug 2021 10:20:14 +0100 Subject: [PATCH 021/231] Initial implementation of resource lists The ResourceList class creates or updates a list for the purpose of tracking resources on the Qortal network. This can be used for local blocking, or even for curating and sharing content lists. Lists are backed off to JSON files (in the lists folder) to ease sharing between nodes and users. This first implementation allows access to an address blacklist only, but has been written in such a way that other lists can be easily added. This might be needed in the future, e.g. to blacklist a group, a poll, or some hosted data. It could also be used by community members to curate lists of favourite or problematic content, which could then be shared or even subscribed to on the chain by other users. --- .gitignore | 1 + .../qortal/api/resource/ListsResource.java | 124 ++++++++++++++++++ .../java/org/qortal/list/ResourceList.java | 124 ++++++++++++++++++ .../org/qortal/list/ResourceListManager.java | 63 +++++++++ .../java/org/qortal/settings/Settings.java | 7 + 5 files changed, 319 insertions(+) create mode 100644 src/main/java/org/qortal/api/resource/ListsResource.java create mode 100644 src/main/java/org/qortal/list/ResourceList.java create mode 100644 src/main/java/org/qortal/list/ResourceListManager.java diff --git a/.gitignore b/.gitignore index 005ab005..69dd6906 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /db* +/lists/ /bin/ /target/ /qortal-backup/ diff --git a/src/main/java/org/qortal/api/resource/ListsResource.java b/src/main/java/org/qortal/api/resource/ListsResource.java new file mode 100644 index 00000000..0f243b5a --- /dev/null +++ b/src/main/java/org/qortal/api/resource/ListsResource.java @@ -0,0 +1,124 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import org.qortal.api.*; +import org.qortal.crypto.Crypto; +import org.qortal.data.account.AccountData; +import org.qortal.list.ResourceListManager; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.*; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + + +@Path("/lists") +@Tag(name = "Lists") +public class ListsResource { + + @Context + HttpServletRequest request; + + @POST + @Path("/blacklist/address/{address}") + @Operation( + summary = "Add a QORT address to the local blacklist", + responses = { + @ApiResponse( + description = "Returns true on success, or an exception on failure", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public String addAddressToBlacklist(@PathParam("address") String address) { + if (!Crypto.isValidAddress(address)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + AccountData accountData = repository.getAccountRepository().getAccount(address); + // Not found? + if (accountData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // Valid address, so go ahead and blacklist it + boolean success = ResourceListManager.getInstance().addAddressToBlacklist(address); + + return success ? "true" : "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + + @DELETE + @Path("/blacklist/address/{address}") + @Operation( + summary = "Remove a QORT address from the local blacklist", + responses = { + @ApiResponse( + description = "Returns true on success, or an exception on failure", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public String removeAddressFromBlacklist(@PathParam("address") String address) { + if (!Crypto.isValidAddress(address)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + AccountData accountData = repository.getAccountRepository().getAccount(address); + // Not found? + if (accountData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // Valid address, so go ahead and blacklist it + boolean success = ResourceListManager.getInstance().removeAddressFromBlacklist(address); + + return success ? "true" : "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/blacklist/address/{address}") + @Operation( + summary = "Checks if an address is present in the local blacklist", + responses = { + @ApiResponse( + description = "Returns true or false if the list was queried, or an exception on failure", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public String checkAddressInBlacklist(@PathParam("address") String address) { + if (!Crypto.isValidAddress(address)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + AccountData accountData = repository.getAccountRepository().getAccount(address); + // Not found? + if (accountData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // Valid address, so go ahead and blacklist it + boolean blacklisted = ResourceListManager.getInstance().isAddressInBlacklist(address); + + return blacklisted ? "true" : "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + +} diff --git a/src/main/java/org/qortal/list/ResourceList.java b/src/main/java/org/qortal/list/ResourceList.java new file mode 100644 index 00000000..740b23d6 --- /dev/null +++ b/src/main/java/org/qortal/list/ResourceList.java @@ -0,0 +1,124 @@ +package org.qortal.list; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.json.JSONArray; +import org.qortal.settings.Settings; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +public class ResourceList { + + private String category; + private String resourceName; + private List list; + + /** + * ResourceList + * Creates or updates a list for the purpose of tracking resources on the Qortal network + * This can be used for local blocking, or even for curating and sharing content lists + * Lists are backed off to JSON files (in the lists folder) to ease sharing between nodes and users + * + * @param category - for instance "blacklist", "whitelist", or "userlist" + * @param resourceName - for instance "address", "poll", or "group" + * @throws IOException + */ + public ResourceList(String category, String resourceName) throws IOException { + this.category = category; + this.resourceName = resourceName; + this.load(); + } + + + /* Filesystem */ + + private Path getFilePath() { + String pathString = String.format("%s%s%s_%s.json", Settings.getInstance().getListsPath(), + File.separator, this.resourceName, this.category); + Path outputFilePath = Paths.get(pathString); + try { + Files.createDirectories(outputFilePath.getParent()); + } catch (IOException e) { + throw new IllegalStateException("Unable to create lists directory"); + } + return outputFilePath; + } + + public void save() throws IOException { + if (this.resourceName == null) { + throw new IllegalStateException("Can't save list with missing resource name"); + } + if (this.category == null) { + throw new IllegalStateException("Can't save list with missing category"); + } + String jsonString = ResourceList.listToJSONString(this.list); + + Path filePath = this.getFilePath(); + BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toString())); + writer.write(jsonString); + writer.close(); + } + + private boolean load() throws IOException { + Path path = this.getFilePath(); + File resourceListFile = new File(path.toString()); + if (!resourceListFile.exists()) { + return false; + } + + try { + String jsonString = new String(Files.readAllBytes(path)); + this.list = ResourceList.listFromJSONString(jsonString); + } catch (IOException e) { + throw new IOException(String.format("Couldn't read contents from file %s", path.toString())); + } + + return true; + } + + + /* List management */ + + public void add(String resource) { + this.list.add(resource); + } + + public void remove(String resource) { + this.list.remove(resource); + } + + public boolean contains(String resource) { + return this.list.contains(resource); + } + + + + /* Utils */ + + public static String listToJSONString(List list) { + JSONArray items = new JSONArray(); + for (String item : list) { + items.put(item); + } + return items.toString(4); + } + + private static List listFromJSONString(String jsonString) { + JSONArray jsonList = new JSONArray(jsonString); + List resourceList = new ArrayList<>(); + for (int i=0; i Date: Sat, 7 Aug 2021 10:31:56 +0100 Subject: [PATCH 022/231] Apply the address blacklist to chat transactions. This is based on code originally written by @DrewMPeacock --- src/main/java/org/qortal/transaction/ChatTransaction.java | 7 +++++++ src/main/java/org/qortal/transaction/Transaction.java | 1 + 2 files changed, 8 insertions(+) diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index ccef1f37..a670ea4b 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -11,6 +11,7 @@ import org.qortal.crypto.MemoryPoW; import org.qortal.data.transaction.ChatTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.group.Group; +import org.qortal.list.ResourceListManager; import org.qortal.repository.DataException; import org.qortal.repository.GroupRepository; import org.qortal.repository.Repository; @@ -138,6 +139,12 @@ public class ChatTransaction extends Transaction { public ValidationResult isValid() throws DataException { // Nonce checking is done via isSignatureValid() as that method is only called once per import + // Check for blacklisted author by address + ResourceListManager listManager = ResourceListManager.getInstance(); + if (listManager.isAddressInBlacklist(this.chatTransactionData.getSender())) { + return ValidationResult.ADDRESS_IN_BLACKLIST; + } + // If we exist in the repository then we've been imported as unconfirmed, // but we don't want to make it into a block, so return fake non-OK result. if (this.repository.getTransactionRepository().exists(this.chatTransactionData.getSignature())) diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index d7dd1455..3c761d28 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -247,6 +247,7 @@ public abstract class Transaction { INVALID_GROUP_BLOCK_DELAY(93), INCORRECT_NONCE(94), INVALID_TIMESTAMP_SIGNATURE(95), + ADDRESS_IN_BLACKLIST(96), INVALID_BUT_OK(999), NOT_YET_RELEASED(1000); From 76ec3473d645f0abda3e22109ac948f59cb8abcf Mon Sep 17 00:00:00 2001 From: Scythian <> Date: Sat, 7 Aug 2021 10:47:48 +0100 Subject: [PATCH 023/231] Updated TransactionValidity keys Added ADDRESS_IN_BLACKLIST ValidationResult to TransactionValidity translation keys --- src/main/resources/i18n/TransactionValidity_en.properties | 2 ++ src/main/resources/i18n/TransactionValidity_hu.properties | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/main/resources/i18n/TransactionValidity_en.properties b/src/main/resources/i18n/TransactionValidity_en.properties index ff34cd1d..5f8088f4 100644 --- a/src/main/resources/i18n/TransactionValidity_en.properties +++ b/src/main/resources/i18n/TransactionValidity_en.properties @@ -180,6 +180,8 @@ INCORRECT_NONCE = incorrect PoW nonce INVALID_TIMESTAMP_SIGNATURE = invalid timestamp signature +ADDRESS_IN_BLACKLIST = this address is in your blacklist + INVALID_BUT_OK = invalid but OK NOT_YET_RELEASED = feature not yet released \ No newline at end of file diff --git a/src/main/resources/i18n/TransactionValidity_hu.properties b/src/main/resources/i18n/TransactionValidity_hu.properties index d2ed3911..b00bb1b4 100644 --- a/src/main/resources/i18n/TransactionValidity_hu.properties +++ b/src/main/resources/i18n/TransactionValidity_hu.properties @@ -180,6 +180,8 @@ INCORRECT_NONCE = helytelen Proof-of-Work Nonce INVALID_TIMESTAMP_SIGNATURE = érvénytelen időbélyeg aláírás +ADDRESS_IN_BLACKLIST = ez a fiókcím a fekete listádon van + INVALID_BUT_OK = érvénytelen de elfogadva NOT_YET_RELEASED = ez a funkció még nem került kiadásra \ No newline at end of file From 9fdc901b7a0344fa2c6a708d0617dc21bcdbf10b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 7 Aug 2021 11:31:45 +0100 Subject: [PATCH 024/231] Added POST /lists/blacklist/addresses and DELETE /lists/blacklist/addresses API endpoints. These are the same as the /lists/blacklist/address/{address} endpoints but allow a JSON array of addresses to be specified in the request body. They currently return true if --- .../qortal/api/model/AddressListRequest.java | 18 +++ .../qortal/api/resource/ListsResource.java | 153 +++++++++++++++++- .../java/org/qortal/list/ResourceList.java | 11 ++ .../org/qortal/list/ResourceListManager.java | 34 +++- 4 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 src/main/java/org/qortal/api/model/AddressListRequest.java diff --git a/src/main/java/org/qortal/api/model/AddressListRequest.java b/src/main/java/org/qortal/api/model/AddressListRequest.java new file mode 100644 index 00000000..c600609f --- /dev/null +++ b/src/main/java/org/qortal/api/model/AddressListRequest.java @@ -0,0 +1,18 @@ +package org.qortal.api.model; + +import io.swagger.v3.oas.annotations.media.Schema; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import java.util.List; + +@XmlAccessorType(XmlAccessType.FIELD) +public class AddressListRequest { + + @Schema(description = "A list of addresses") + public List addresses; + + public AddressListRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/resource/ListsResource.java b/src/main/java/org/qortal/api/resource/ListsResource.java index 0f243b5a..70918a38 100644 --- a/src/main/java/org/qortal/api/resource/ListsResource.java +++ b/src/main/java/org/qortal/api/resource/ListsResource.java @@ -3,10 +3,12 @@ package org.qortal.api.resource; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import org.qortal.api.*; +import org.qortal.api.model.AddressListRequest; import org.qortal.crypto.Crypto; import org.qortal.data.account.AccountData; import org.qortal.list.ResourceListManager; @@ -50,7 +52,7 @@ public class ListsResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); // Valid address, so go ahead and blacklist it - boolean success = ResourceListManager.getInstance().addAddressToBlacklist(address); + boolean success = ResourceListManager.getInstance().addAddressToBlacklist(address, true); return success ? "true" : "false"; } catch (DataException e) { @@ -58,6 +60,78 @@ public class ListsResource { } } + @POST + @Path("/blacklist/addresses") + @Operation( + summary = "Add one or more QORT addresses to the local blacklist", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = AddressListRequest.class + ) + ) + ), + responses = { + @ApiResponse( + description = "Returns true if all addresses were processed, false if any couldn't be " + + "processed, or an exception on failure. If false or an exception is returned, " + + "the list will not be updated, and the request will need to be re-issued.", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public String addAddressesToBlacklist(AddressListRequest addressListRequest) { + if (addressListRequest == null || addressListRequest.addresses == null) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } + + int successCount = 0; + int errorCount = 0; + + try (final Repository repository = RepositoryManager.getRepository()) { + + for (String address : addressListRequest.addresses) { + + if (!Crypto.isValidAddress(address)) { + errorCount++; + continue; + } + + AccountData accountData = repository.getAccountRepository().getAccount(address); + // Not found? + if (accountData == null) { + errorCount++; + continue; + } + + // Valid address, so go ahead and blacklist it + boolean success = ResourceListManager.getInstance().addAddressToBlacklist(address, false); + if (success) { + successCount++; + } + else { + errorCount++; + } + } + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + + if (successCount > 0 && errorCount == 0) { + // All were successful, so save the blacklist + ResourceListManager.getInstance().saveBlacklist(); + return "true"; + } + else { + // Something went wrong, so revert + ResourceListManager.getInstance().revertBlacklist(); + return "false"; + } + } + @DELETE @Path("/blacklist/address/{address}") @@ -82,7 +156,7 @@ public class ListsResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); // Valid address, so go ahead and blacklist it - boolean success = ResourceListManager.getInstance().removeAddressFromBlacklist(address); + boolean success = ResourceListManager.getInstance().removeAddressFromBlacklist(address, true); return success ? "true" : "false"; } catch (DataException e) { @@ -90,10 +164,83 @@ public class ListsResource { } } + @DELETE + @Path("/blacklist/addresses") + @Operation( + summary = "Remove one or more QORT addresses from the local blacklist", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = AddressListRequest.class + ) + ) + ), + responses = { + @ApiResponse( + description = "Returns true if all addresses were processed, false if any couldn't be " + + "processed, or an exception on failure. If false or an exception is returned, " + + "the list will not be updated, and the request will need to be re-issued.", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public String removeAddressesFromBlacklist(AddressListRequest addressListRequest) { + if (addressListRequest == null || addressListRequest.addresses == null) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } + + int successCount = 0; + int errorCount = 0; + + try (final Repository repository = RepositoryManager.getRepository()) { + + for (String address : addressListRequest.addresses) { + + if (!Crypto.isValidAddress(address)) { + errorCount++; + continue; + } + + AccountData accountData = repository.getAccountRepository().getAccount(address); + // Not found? + if (accountData == null) { + errorCount++; + continue; + } + + // Valid address, so go ahead and blacklist it + // Don't save as we will do this at the end of the process + boolean success = ResourceListManager.getInstance().removeAddressFromBlacklist(address, false); + if (success) { + successCount++; + } + else { + errorCount++; + } + } + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + + if (successCount > 0 && errorCount == 0) { + // All were successful, so save the blacklist + ResourceListManager.getInstance().saveBlacklist(); + return "true"; + } + else { + // Something went wrong, so revert + ResourceListManager.getInstance().revertBlacklist(); + return "false"; + } + } + @GET @Path("/blacklist/address/{address}") @Operation( - summary = "Checks if an address is present in the local blacklist", + summary = "Check if an address is present in the local blacklist", responses = { @ApiResponse( description = "Returns true or false if the list was queried, or an exception on failure", diff --git a/src/main/java/org/qortal/list/ResourceList.java b/src/main/java/org/qortal/list/ResourceList.java index 740b23d6..1f0fb52b 100644 --- a/src/main/java/org/qortal/list/ResourceList.java +++ b/src/main/java/org/qortal/list/ResourceList.java @@ -17,6 +17,8 @@ import java.util.List; public class ResourceList { + private static final Logger LOGGER = LogManager.getLogger(ResourceList.class); + private String category; private String resourceName; private List list; @@ -84,6 +86,15 @@ public class ResourceList { return true; } + public boolean revert() { + try { + return this.load(); + } catch (IOException e) { + LOGGER.info("Unable to revert {} {}", this.resourceName, this.category); + } + return false; + } + /* List management */ diff --git a/src/main/java/org/qortal/list/ResourceListManager.java b/src/main/java/org/qortal/list/ResourceListManager.java index 375cddf8..0a7acfe1 100644 --- a/src/main/java/org/qortal/list/ResourceListManager.java +++ b/src/main/java/org/qortal/list/ResourceListManager.java @@ -2,7 +2,6 @@ package org.qortal.list; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.eclipse.jetty.util.IO; import java.io.IOException; @@ -29,10 +28,12 @@ public class ResourceListManager { return instance; } - public boolean addAddressToBlacklist(String address) { + public boolean addAddressToBlacklist(String address, boolean save) { try { this.addressBlacklist.add(address); - this.addressBlacklist.save(); + if (save) { + this.addressBlacklist.save(); + } return true; } catch (IllegalStateException | IOException e) { @@ -41,10 +42,13 @@ public class ResourceListManager { } } - public boolean removeAddressFromBlacklist(String address) { + public boolean removeAddressFromBlacklist(String address, boolean save) { try { this.addressBlacklist.remove(address); - this.addressBlacklist.save(); + + if (save) { + this.addressBlacklist.save(); + } return true; } catch (IllegalStateException | IOException e) { @@ -60,4 +64,24 @@ public class ResourceListManager { return this.addressBlacklist.contains(address); } + public void saveBlacklist() { + if (this.addressBlacklist == null) { + return; + } + + try { + this.addressBlacklist.save(); + } catch (IOException e) { + LOGGER.info("Unable to save blacklist - reverting back to last saved state"); + this.addressBlacklist.revert(); + } + } + + public void revertBlacklist() { + if (this.addressBlacklist == null) { + return; + } + this.addressBlacklist.revert(); + } + } From cd7adc997be18b29b95511c44559b55e1042090f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 7 Aug 2021 11:32:49 +0100 Subject: [PATCH 025/231] Prevent duplicate entries in a list. --- src/main/java/org/qortal/list/ResourceList.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/list/ResourceList.java b/src/main/java/org/qortal/list/ResourceList.java index 1f0fb52b..6a5cd1c9 100644 --- a/src/main/java/org/qortal/list/ResourceList.java +++ b/src/main/java/org/qortal/list/ResourceList.java @@ -99,7 +99,9 @@ public class ResourceList { /* List management */ public void add(String resource) { - this.list.add(resource); + if (!this.contains(resource)) { + this.list.add(resource); + } } public void remove(String resource) { From 4772840b4c55dc48a35ae6b6b74480d966844a6d Mon Sep 17 00:00:00 2001 From: Scythian <> Date: Sat, 7 Aug 2021 14:05:10 +0100 Subject: [PATCH 026/231] Reorganised translations Updated the "localeLang" files with new keys and removed old unused keys for English, German, Dutch, Italian, Finnish, Hungarian, Russian and Chinese translations --- .../resources/i18n/ApiError_de.properties | 77 ++++++++++- .../resources/i18n/ApiError_en.properties | 2 + .../resources/i18n/ApiError_fi.properties | 123 ++++++++++-------- .../resources/i18n/ApiError_hu.properties | 5 + .../resources/i18n/ApiError_it.properties | 121 +++++++++-------- .../resources/i18n/ApiError_nl.properties | 121 +++++++++-------- .../resources/i18n/ApiError_ru.properties | 112 ++++++++++------ src/main/resources/i18n/SysTray_fi.properties | 15 +-- src/main/resources/i18n/SysTray_hu.properties | 2 + src/main/resources/i18n/SysTray_it.properties | 11 +- src/main/resources/i18n/SysTray_nl.properties | 11 +- src/main/resources/i18n/SysTray_ru.properties | 13 +- .../resources/i18n/SysTray_zh_CN.properties | 25 ++-- .../resources/i18n/SysTray_zh_TW.properties | 25 ++-- .../i18n/TransactionValidity_fi.properties | 9 +- .../i18n/TransactionValidity_hu.properties | 2 + .../i18n/TransactionValidity_it.properties | 8 +- .../i18n/TransactionValidity_nl.properties | 9 +- .../i18n/TransactionValidity_ru.properties | 7 +- 19 files changed, 433 insertions(+), 265 deletions(-) diff --git a/src/main/resources/i18n/ApiError_de.properties b/src/main/resources/i18n/ApiError_de.properties index 490aac0d..ab7da6b7 100644 --- a/src/main/resources/i18n/ApiError_de.properties +++ b/src/main/resources/i18n/ApiError_de.properties @@ -1,14 +1,83 @@ +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# Keys are from api.ApiError enum + +# "localeLang": "de", + +### Common ### +JSON = JSON nachricht konnte nicht geparsed werden + +INSUFFICIENT_BALANCE = insufficient balance + +UNAUTHORIZED = API call unauthorized + +REPOSITORY_ISSUE = repository error + +NON_PRODUCTION = this API call is not permitted for production systems + +BLOCKCHAIN_NEEDS_SYNC = blockchain needs to synchronize first + +NO_TIME_SYNC = no clock synchronization yet + +### Validation ### +INVALID_SIGNATURE = ungültige signatur INVALID_ADDRESS = ungültige adresse -INVALID_ASSET_ID = ungültige asset ID +INVALID_PUBLIC_KEY = ungültiger public key INVALID_DATA = ungültige daten -INVALID_PUBLIC_KEY = ungültiger public key +INVALID_NETWORK_ADDRESS = invalid network address -INVALID_SIGNATURE = ungültige signatur +ADDRESS_UNKNOWN = account address unknown -JSON = JSON nachricht konnte nicht geparsed werden +INVALID_CRITERIA = invalid search criteria + +INVALID_REFERENCE = invalid reference + +TRANSFORMATION_ERROR = could not transform JSON into transaction + +INVALID_PRIVATE_KEY = invalid private key + +INVALID_HEIGHT = invalid block height + +CANNOT_MINT = account cannot mint + +### Blocks ### +BLOCK_UNKNOWN = block unknown + +### Transactions ### +TRANSACTION_UNKNOWN = transaction unknown PUBLIC_KEY_NOT_FOUND = public key wurde nicht gefunden + +# this one is special in that caller expected to pass two additional strings, hence the two %s +TRANSACTION_INVALID = transaction invalid: %s (%s) + +### Naming ### +NAME_UNKNOWN = name unknown + +### Asset ### +INVALID_ASSET_ID = ungültige asset ID + +INVALID_ORDER_ID = invalid asset order ID + +ORDER_UNKNOWN = unknown asset order ID + +### Groups ### +GROUP_UNKNOWN = group unknown + +### Foreign Blockchain ### +FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = foreign blokchain or ElectrumX network issue + +FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = insufficient balance on foreign blockchain + +FOREIGN_BLOCKCHAIN_TOO_SOON = too soon to broadcast foreign blockchain transaction (LockTime/median block time) + +### Trade Portal ### +ORDER_SIZE_TOO_SMALL = order amount too low + +### Data ### +FILE_NOT_FOUND = file not found + +NO_REPLY = peer did not reply with data \ No newline at end of file diff --git a/src/main/resources/i18n/ApiError_en.properties b/src/main/resources/i18n/ApiError_en.properties index ecce979d..6f9b1d01 100644 --- a/src/main/resources/i18n/ApiError_en.properties +++ b/src/main/resources/i18n/ApiError_en.properties @@ -1,6 +1,8 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # Keys are from api.ApiError enum +# "localeLang": "en", + ### Common ### JSON = failed to parse JSON message diff --git a/src/main/resources/i18n/ApiError_fi.properties b/src/main/resources/i18n/ApiError_fi.properties index f9fedf09..f9518700 100644 --- a/src/main/resources/i18n/ApiError_fi.properties +++ b/src/main/resources/i18n/ApiError_fi.properties @@ -1,71 +1,86 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # Keys are from api.ApiError enum -# + # Kielen muuttaminen suomeksi tapahtuu settings.json-tiedostossa # # "localeLang": "fi", # muista pilkku lopussa jos komento ei ole viimeisellä rivillä -ADDRESS_UNKNOWN = tilin osoite on tuntematon - -BLOCKCHAIN_NEEDS_SYNC = lohkoketjun tarvitsee ensin synkronisoitua - -# Blocks -BLOCK_UNKNOWN = tuntematon lohko - -BTC_BALANCE_ISSUE = riittämätön Bitcoin-saldo - -BTC_NETWORK_ISSUE = Bitcoin/ElectrumX -verkon ongelma - -BTC_TOO_SOON = liian aikaista julkistaa Bitcoin-tapahtumaa (lukitusaika/mediiaanilohkoaika) - -CANNOT_MINT = tili ei voi lyödä rahaa - -GROUP_UNKNOWN = tuntematon ryhmä - -INVALID_ADDRESS = osoite on kelvoton - -# Assets -INVALID_ASSET_ID = kelvoton ID resurssille - -INVALID_CRITERIA = kelvoton hakuehto - -INVALID_DATA = kelvoton data - -INVALID_HEIGHT = kelvoton lohkon korkeus - -INVALID_NETWORK_ADDRESS = kelvoton verkko-osoite - -INVALID_ORDER_ID = kelvoton resurssin tilaus-ID - -INVALID_PRIVATE_KEY = kelvoton yksityinen avain - -INVALID_PUBLIC_KEY = kelvoton julkinen avain - -INVALID_REFERENCE = kelvoton viite - -# Validation -INVALID_SIGNATURE = kelvoton allekirjoitus - +### Common ### JSON = JSON-viestin jaottelu epäonnistui -NAME_UNKNOWN = tuntematon nimi +INSUFFICIENT_BALANCE = insufficient balance -NON_PRODUCTION = tämä API-kutsu on kielletty tuotantoversiossa - -NO_TIME_SYNC = kello vielä synkronisoimatta - -ORDER_UNKNOWN = tuntematon resurssin tilaus-ID - -PUBLIC_KEY_NOT_FOUND = julkista avainta ei löytynyt +UNAUTHORIZED = luvaton API-kutsu REPOSITORY_ISSUE = tietovarantovirhe (repo) -# This one is special in that caller expected to pass two additional strings, hence the two %s -TRANSACTION_INVALID = kelvoton transaktio: %s (%s) +NON_PRODUCTION = tämä API-kutsu on kielletty tuotantoversiossa -TRANSACTION_UNKNOWN = tuntematon transaktio +BLOCKCHAIN_NEEDS_SYNC = lohkoketjun tarvitsee ensin synkronisoitua + +NO_TIME_SYNC = kello vielä synkronisoimatta + +### Validation ### +INVALID_SIGNATURE = kelvoton allekirjoitus + +INVALID_ADDRESS = osoite on kelvoton + +INVALID_PUBLIC_KEY = kelvoton julkinen avain + +INVALID_DATA = kelvoton data + +INVALID_NETWORK_ADDRESS = kelvoton verkko-osoite + +ADDRESS_UNKNOWN = tilin osoite on tuntematon + +INVALID_CRITERIA = kelvoton hakuehto + +INVALID_REFERENCE = kelvoton viite TRANSFORMATION_ERROR = JSON:in muuntaminen transaktioksi epäonnistui -UNAUTHORIZED = luvaton API-kutsu \ No newline at end of file +INVALID_PRIVATE_KEY = kelvoton yksityinen avain + +INVALID_HEIGHT = kelvoton lohkon korkeus + +CANNOT_MINT = tili ei voi lyödä rahaa + +### Blocks ### +BLOCK_UNKNOWN = tuntematon lohko + +### Transactions ### +TRANSACTION_UNKNOWN = tuntematon transaktio + +PUBLIC_KEY_NOT_FOUND = julkista avainta ei löytynyt + +# this one is special in that caller expected to pass two additional strings, hence the two %s +TRANSACTION_INVALID = kelvoton transaktio: %s (%s) + +### Naming ### +NAME_UNKNOWN = tuntematon nimi + +### Asset ### +INVALID_ASSET_ID = kelvoton ID resurssille + +INVALID_ORDER_ID = kelvoton resurssin tilaus-ID + +ORDER_UNKNOWN = tuntematon resurssin tilaus-ID + +### Groups ### +GROUP_UNKNOWN = tuntematon ryhmä + +### Foreign Blockchain ### +FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = foreign blokchain or ElectrumX network issue + +FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = insufficient balance on foreign blockchain + +FOREIGN_BLOCKCHAIN_TOO_SOON = too soon to broadcast foreign blockchain transaction (LockTime/median block time) + +### Trade Portal ### +ORDER_SIZE_TOO_SMALL = order amount too low + +### Data ### +FILE_NOT_FOUND = file not found + +NO_REPLY = peer did not reply with data \ No newline at end of file diff --git a/src/main/resources/i18n/ApiError_hu.properties b/src/main/resources/i18n/ApiError_hu.properties index d66b2e82..8aa783da 100644 --- a/src/main/resources/i18n/ApiError_hu.properties +++ b/src/main/resources/i18n/ApiError_hu.properties @@ -1,6 +1,11 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # Keys are from api.ApiError enum +# Magyar myelvre forditotta: Szkíta (Scythian). 2021 Augusztus 7. +# Az alkalmazás nyelvének magyarra való változtatása a settings.json oldalon történik. + +# "localeLang": "hu", + ### Common ### JSON = nem sikerült elemezni a JSON üzenetet diff --git a/src/main/resources/i18n/ApiError_it.properties b/src/main/resources/i18n/ApiError_it.properties index 27f93f63..33993200 100644 --- a/src/main/resources/i18n/ApiError_it.properties +++ b/src/main/resources/i18n/ApiError_it.properties @@ -7,66 +7,81 @@ # "localeLang": "it", # Si prega ricordare la virgola alla fine, se questo comando non è sull'ultima riga -ADDRESS_UNKNOWN = indirizzo account sconosciuto - -BLOCKCHAIN_NEEDS_SYNC = blockchain deve prima sincronizzarsi - -# Blocks -BLOCK_UNKNOWN = blocco sconosciuto - -BTC_BALANCE_ISSUE = saldo Bitcoin insufficiente - -BTC_NETWORK_ISSUE = Bitcoin/ElectrumX problema di rete - -BTC_TOO_SOON = troppo presto per trasmettere transazione Bitcoin (tempo di blocco / tempo di blocco mediano) - -CANNOT_MINT = l'account non può coniare - -GROUP_UNKNOWN = gruppo sconosciuto - -INVALID_ADDRESS = indirizzo non valido - -# Assets -INVALID_ASSET_ID = identificazione risorsa non valida - -INVALID_CRITERIA = criteri di ricerca non validi - -INVALID_DATA = dati non validi - -INVALID_HEIGHT = altezza blocco non valida - -INVALID_NETWORK_ADDRESS = indirizzo di rete non valido - -INVALID_ORDER_ID = identificazione di ordine di risorsa non valida - -INVALID_PRIVATE_KEY = chiave privata non valida - -INVALID_PUBLIC_KEY = chiave pubblica non valida - -INVALID_REFERENCE = riferimento non valido - -# Validation -INVALID_SIGNATURE = firma non valida - +### Common ### JSON = Impossibile analizzare il messaggio JSON -NAME_UNKNOWN = nome sconosciuto +INSUFFICIENT_BALANCE = insufficient balance -NON_PRODUCTION = questa chiamata API non è consentita per i sistemi di produzione - -NO_TIME_SYNC = nessuna sincronizzazione dell'orologio ancora - -ORDER_UNKNOWN = identificazione di ordine di risorsa sconosciuta - -PUBLIC_KEY_NOT_FOUND = chiave pubblica non trovata +UNAUTHORIZED = Chiamata API non autorizzata REPOSITORY_ISSUE = errore del repositorio -# This one is special in that caller expected to pass two additional strings, hence the two %s -TRANSACTION_INVALID = transazione non valida: %s (%s) +NON_PRODUCTION = questa chiamata API non è consentita per i sistemi di produzione -TRANSACTION_UNKNOWN = transazione sconosciuta +BLOCKCHAIN_NEEDS_SYNC = blockchain deve prima sincronizzarsi + +NO_TIME_SYNC = nessuna sincronizzazione dell'orologio ancora + +### Validation ### +INVALID_SIGNATURE = firma non valida + +INVALID_ADDRESS = indirizzo non valido + +INVALID_PUBLIC_KEY = chiave pubblica non valida + +INVALID_DATA = dati non validi + +INVALID_NETWORK_ADDRESS = indirizzo di rete non valido + +ADDRESS_UNKNOWN = indirizzo account sconosciuto + +INVALID_CRITERIA = criteri di ricerca non validi + +INVALID_REFERENCE = riferimento non valido TRANSFORMATION_ERROR = non è stato possibile trasformare JSON in transazione -UNAUTHORIZED = Chiamata API non autorizzata +INVALID_PRIVATE_KEY = chiave privata non valida + +INVALID_HEIGHT = altezza blocco non valida + +CANNOT_MINT = l'account non può coniare + +### Blocks ### +BLOCK_UNKNOWN = blocco sconosciuto + +### Transactions ### +TRANSACTION_UNKNOWN = transazione sconosciuta + +PUBLIC_KEY_NOT_FOUND = chiave pubblica non trovata + +# this one is special in that caller expected to pass two additional strings, hence the two %s +TRANSACTION_INVALID = transazione non valida: %s (%s) + +### Naming ### +NAME_UNKNOWN = nome sconosciuto + +### Asset ### +INVALID_ASSET_ID = identificazione risorsa non valida + +INVALID_ORDER_ID = identificazione di ordine di risorsa non valida + +ORDER_UNKNOWN = identificazione di ordine di risorsa sconosciuta + +### Groups ### +GROUP_UNKNOWN = gruppo sconosciuto + +### Foreign Blockchain ### +FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = foreign blokchain or ElectrumX network issue + +FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = insufficient balance on foreign blockchain + +FOREIGN_BLOCKCHAIN_TOO_SOON = too soon to broadcast foreign blockchain transaction (LockTime/median block time) + +### Trade Portal ### +ORDER_SIZE_TOO_SMALL = order amount too low + +### Data ### +FILE_NOT_FOUND = file not found + +NO_REPLY = peer did not reply with data \ No newline at end of file diff --git a/src/main/resources/i18n/ApiError_nl.properties b/src/main/resources/i18n/ApiError_nl.properties index 60faa0f6..5c54cf64 100644 --- a/src/main/resources/i18n/ApiError_nl.properties +++ b/src/main/resources/i18n/ApiError_nl.properties @@ -1,66 +1,83 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # Keys are from api.ApiError enum -ADDRESS_UNKNOWN = account adres onbekend - -BLOCKCHAIN_NEEDS_SYNC = blockchain dient eerst gesynchronizeerd te worden - -# Blocks -BLOCK_UNKNOWN = blok onbekend - -BTC_BALANCE_ISSUE = onvoldoende Bitcoin balans - -BTC_NETWORK_ISSUE = Bitcoin/ElectrumX netwerk probleem - -BTC_TOO_SOON = te vroeg om Bitcoin transactie te versturen (vergrendelingstijd/gemiddelde bloktijd) - -CANNOT_MINT = account kan niet munten - -GROUP_UNKNOWN = onbekende groep - -INVALID_ADDRESS = ongeldig adres - -# Assets -INVALID_ASSET_ID = ongeldige asset ID - -INVALID_CRITERIA = ongeldige zoekcriteria - -INVALID_DATA = ongeldige gegevens - -INVALID_HEIGHT = ongeldige blokhoogte - -INVALID_NETWORK_ADDRESS = ongeldig netwerkadres - -INVALID_ORDER_ID = ongeldige asset order ID - -INVALID_PRIVATE_KEY = ongeldige private key - -INVALID_PUBLIC_KEY = ongeldige public key - -INVALID_REFERENCE = ongeldige verwijzing - -# Validation -INVALID_SIGNATURE = ongeldige handtekening +# "localeLang": "nl", +### Common ### JSON = lezen van JSON bericht gefaald -NAME_UNKNOWN = onbekende naam +INSUFFICIENT_BALANCE = insufficient balance -NON_PRODUCTION = deze API call is niet toegestaan voor productiesystemen - -NO_TIME_SYNC = klok nog niet gesynchronizeerd - -ORDER_UNKNOWN = onbekende asset order ID - -PUBLIC_KEY_NOT_FOUND = public key niet gevonden +UNAUTHORIZED = ongeautoriseerde API call REPOSITORY_ISSUE = repository fout -# This one is special in that caller expected to pass two additional strings, hence the two %s -TRANSACTION_INVALID = ongeldige transactie: %s (%s) +NON_PRODUCTION = deze API call is niet toegestaan voor productiesystemen -TRANSACTION_UNKNOWN = onbekende transactie +BLOCKCHAIN_NEEDS_SYNC = blockchain dient eerst gesynchronizeerd te worden + +NO_TIME_SYNC = klok nog niet gesynchronizeerd + +### Validation ### +INVALID_SIGNATURE = ongeldige handtekening + +INVALID_ADDRESS = ongeldig adres + +INVALID_PUBLIC_KEY = ongeldige public key + +INVALID_DATA = ongeldige gegevens + +INVALID_NETWORK_ADDRESS = ongeldig netwerkadres + +ADDRESS_UNKNOWN = account adres onbekend + +INVALID_CRITERIA = ongeldige zoekcriteria + +INVALID_REFERENCE = ongeldige verwijzing TRANSFORMATION_ERROR = JSON kon niet omgezet worden in transactie -UNAUTHORIZED = ongeautoriseerde API call +INVALID_PRIVATE_KEY = ongeldige private key + +INVALID_HEIGHT = ongeldige blokhoogte + +CANNOT_MINT = account kan niet munten + +### Blocks ### +BLOCK_UNKNOWN = blok onbekend + +### Transactions ### +TRANSACTION_UNKNOWN = onbekende transactie + +PUBLIC_KEY_NOT_FOUND = public key niet gevonden + +# this one is special in that caller expected to pass two additional strings, hence the two %s +TRANSACTION_INVALID = ongeldige transactie: %s (%s) + +### Naming ### +NAME_UNKNOWN = onbekende naam + +### Asset ### +INVALID_ASSET_ID = ongeldige asset ID + +INVALID_ORDER_ID = ongeldige asset order ID + +ORDER_UNKNOWN = onbekende asset order ID + +### Groups ### +GROUP_UNKNOWN = onbekende groep + +### Foreign Blockchain ### +FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = foreign blokchain or ElectrumX network issue + +FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = insufficient balance on foreign blockchain + +FOREIGN_BLOCKCHAIN_TOO_SOON = too soon to broadcast foreign blockchain transaction (LockTime/median block time) + +### Trade Portal ### +ORDER_SIZE_TOO_SMALL = order amount too low + +### Data ### +FILE_NOT_FOUND = file not found + +NO_REPLY = peer did not reply with data \ No newline at end of file diff --git a/src/main/resources/i18n/ApiError_ru.properties b/src/main/resources/i18n/ApiError_ru.properties index e67be901..61948a2a 100644 --- a/src/main/resources/i18n/ApiError_ru.properties +++ b/src/main/resources/i18n/ApiError_ru.properties @@ -1,57 +1,83 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # Keys are from api.ApiError enum -ADDRESS_UNKNOWN = неизвестная учетная запись - -BLOCKCHAIN_NEEDS_SYNC = блокчейн должен сначала синхронизироваться - -# Blocks -BLOCK_UNKNOWN = неизвестный блок - -CANNOT_MINT = аккаунт не может чеканить - -GROUP_UNKNOWN = неизвестная группа - -INVALID_ADDRESS = неизвестный адрес - -# Assets -INVALID_ASSET_ID = неверный идентификатор актива - -INVALID_CRITERIA = неверные критерии поиска - -INVALID_DATA = неверные данные - -INVALID_HEIGHT = недопустимая высота блока - -INVALID_NETWORK_ADDRESS = неверный сетевой адрес - -INVALID_ORDER_ID = неверный идентификатор заказа актива - -INVALID_PRIVATE_KEY = неверный приватный ключ - -INVALID_PUBLIC_KEY = недействительный открытый ключ - -INVALID_REFERENCE = неверная ссылка - -# Validation -INVALID_SIGNATURE = недействительная подпись +# "localeLang": "ru", +### Common ### JSON = не удалось разобрать сообщение json -NAME_UNKNOWN = имя неизвестно +INSUFFICIENT_BALANCE = insufficient balance -NON_PRODUCTION = этот вызов API не разрешен для производственных систем - -ORDER_UNKNOWN = неизвестный идентификатор заказа актива - -PUBLIC_KEY_NOT_FOUND = открытый ключ не найден +UNAUTHORIZED = вызов API не авторизован REPOSITORY_ISSUE = ошибка репозитория -TRANSACTION_INVALID = транзакция недействительна: %s (%s) +NON_PRODUCTION = этот вызов API не разрешен для производственных систем -TRANSACTION_UNKNOWN = транзакция неизвестна +BLOCKCHAIN_NEEDS_SYNC = блокчейн должен сначала синхронизироваться + +NO_TIME_SYNC = no clock synchronization yet + +### Validation ### +INVALID_SIGNATURE = недействительная подпись + +INVALID_ADDRESS = неизвестный адрес + +INVALID_PUBLIC_KEY = недействительный открытый ключ + +INVALID_DATA = неверные данные + +INVALID_NETWORK_ADDRESS = неверный сетевой адрес + +ADDRESS_UNKNOWN = неизвестная учетная запись + +INVALID_CRITERIA = неверные критерии поиска + +INVALID_REFERENCE = неверная ссылка TRANSFORMATION_ERROR = не удалось преобразовать JSON в транзакцию -UNAUTHORIZED = вызов API не авторизован +INVALID_PRIVATE_KEY = неверный приватный ключ + +INVALID_HEIGHT = недопустимая высота блока + +CANNOT_MINT = аккаунт не может чеканить + +### Blocks ### +BLOCK_UNKNOWN = неизвестный блок + +### Transactions ### +TRANSACTION_UNKNOWN = транзакция неизвестна + +PUBLIC_KEY_NOT_FOUND = открытый ключ не найден + +# this one is special in that caller expected to pass two additional strings, hence the two %s +TRANSACTION_INVALID = транзакция недействительна: %s (%s) + +### Naming ### +NAME_UNKNOWN = имя неизвестно + +### Asset ### +INVALID_ASSET_ID = неверный идентификатор актива + +INVALID_ORDER_ID = неверный идентификатор заказа актива + +ORDER_UNKNOWN = неизвестный идентификатор заказа актива + +### Groups ### +GROUP_UNKNOWN = неизвестная группа + +### Foreign Blockchain ### +FOREIGN_BLOCKCHAIN_NETWORK_ISSUE = foreign blokchain or ElectrumX network issue + +FOREIGN_BLOCKCHAIN_BALANCE_ISSUE = insufficient balance on foreign blockchain + +FOREIGN_BLOCKCHAIN_TOO_SOON = too soon to broadcast foreign blockchain transaction (LockTime/median block time) + +### Trade Portal ### +ORDER_SIZE_TOO_SMALL = order amount too low + +### Data ### +FILE_NOT_FOUND = file not found + +NO_REPLY = peer did not reply with data \ No newline at end of file diff --git a/src/main/resources/i18n/SysTray_fi.properties b/src/main/resources/i18n/SysTray_fi.properties index 551b010e..307dd80c 100644 --- a/src/main/resources/i18n/SysTray_fi.properties +++ b/src/main/resources/i18n/SysTray_fi.properties @@ -1,12 +1,14 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # SysTray pop-up menu -APPLYING_UPDATE_AND_RESTARTING = Automaattinen päivitys käynnissä, uudelleenkäynnistys seuraa... - AUTO_UPDATE = Automaattinen päivitys +APPLYING_UPDATE_AND_RESTARTING = Automaattinen päivitys käynnissä, uudelleenkäynnistys seuraa... + BLOCK_HEIGHT = korkeus +BUILD_VERSION = Versio + CHECK_TIME_ACCURACY = Tarkista ajan tarkkuus CONNECTING = Yhdistää @@ -27,13 +29,6 @@ MINTING_DISABLED = EI lyö rahaa MINTING_ENABLED = \u2714 Lyö rahaa -# Nagging about lack of NTP time sync -NTP_NAG_CAPTION = Tietokoneen kello on epätarkka! - -NTP_NAG_TEXT_UNIX = Asennathan NTP-palvelun, jotta saat kellon tarkkuuden oikeaksi. - -NTP_NAG_TEXT_WINDOWS = Valitse "Kellon synkronisointi" valikosta korjataksesi. - OPEN_UI = Avaa UI PERFORMING_DB_CHECKPOINT = Tallentaa kommittoidut tietokantamuutokset... @@ -42,4 +37,4 @@ SYNCHRONIZE_CLOCK = Synkronisoi kello SYNCHRONIZING_BLOCKCHAIN = Synkronisoi -SYNCHRONIZING_CLOCK = Synkronisoi kelloa +SYNCHRONIZING_CLOCK = Synkronisoi kelloa \ No newline at end of file diff --git a/src/main/resources/i18n/SysTray_hu.properties b/src/main/resources/i18n/SysTray_hu.properties index f7e21002..63bec91f 100644 --- a/src/main/resources/i18n/SysTray_hu.properties +++ b/src/main/resources/i18n/SysTray_hu.properties @@ -1,6 +1,8 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # SysTray pop-up menu +# Magyar myelvre forditotta: Szkíta (Scythian). 2021 Augusztus 7. + AUTO_UPDATE = Automatikus Frissítés APPLYING_UPDATE_AND_RESTARTING = Automatikus frissítés és újraindítás alkalmazása... diff --git a/src/main/resources/i18n/SysTray_it.properties b/src/main/resources/i18n/SysTray_it.properties index 1d243958..a2d2dac8 100644 --- a/src/main/resources/i18n/SysTray_it.properties +++ b/src/main/resources/i18n/SysTray_it.properties @@ -8,6 +8,8 @@ AUTO_UPDATE = Aggiornamento automatico BLOCK_HEIGHT = altezza +BUILD_VERSION = Versione + CHECK_TIME_ACCURACY = Controlla la precisione dell'ora CONNECTING = Collegando @@ -28,13 +30,6 @@ MINTING_DISABLED = NON coniando MINTING_ENABLED = \u2714 Coniando -# Nagging about lack of NTP time sync -NTP_NAG_CAPTION = L'orologio del computer è impreciso! - -NTP_NAG_TEXT_UNIX = Installare servizio NTP per ottenere un orologio preciso. - -NTP_NAG_TEXT_WINDOWS = Seleziona "Sincronizza orologio" dal menu per correggere. - OPEN_UI = Apri UI PERFORMING_DB_CHECKPOINT = Salvataggio delle modifiche al database non salvate... @@ -43,4 +38,4 @@ SYNCHRONIZE_CLOCK = Sincronizza orologio SYNCHRONIZING_BLOCKCHAIN = Sincronizzando -SYNCHRONIZING_CLOCK = Sincronizzando orologio +SYNCHRONIZING_CLOCK = Sincronizzando orologio \ No newline at end of file diff --git a/src/main/resources/i18n/SysTray_nl.properties b/src/main/resources/i18n/SysTray_nl.properties index 4e3e48ec..8b7c85eb 100644 --- a/src/main/resources/i18n/SysTray_nl.properties +++ b/src/main/resources/i18n/SysTray_nl.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Automatische Update BLOCK_HEIGHT = hoogte +BUILD_VERSION = Versie + CHECK_TIME_ACCURACY = Controleer accuraatheid van de tijd CONNECTING = Verbinden @@ -27,13 +29,6 @@ MINTING_DISABLED = NIET muntend MINTING_ENABLED = \u2714 Muntend -# Nagging about lack of NTP time sync -NTP_NAG_CAPTION = Klok van de computer is inaccuraat! - -NTP_NAG_TEXT_UNIX = Installeer NTP service voor een accurate klok. - -NTP_NAG_TEXT_WINDOWS = Selecteer "Synchronizeer klok" uit het menu om op te lossen. - OPEN_UI = Open UI PERFORMING_DB_CHECKPOINT = Nieuwe veranderingen aan database worden opgeslagen... @@ -42,4 +37,4 @@ SYNCHRONIZE_CLOCK = Synchronizeer klok SYNCHRONIZING_BLOCKCHAIN = Aan het synchronizeren -SYNCHRONIZING_CLOCK = Klok wordt gesynchronizeerd +SYNCHRONIZING_CLOCK = Klok wordt gesynchronizeerd \ No newline at end of file diff --git a/src/main/resources/i18n/SysTray_ru.properties b/src/main/resources/i18n/SysTray_ru.properties index f7012034..1a719164 100644 --- a/src/main/resources/i18n/SysTray_ru.properties +++ b/src/main/resources/i18n/SysTray_ru.properties @@ -7,6 +7,8 @@ AUTO_UPDATE = Автоматическое обновление BLOCK_HEIGHT = Высота блока +BUILD_VERSION = Build version + CHECK_TIME_ACCURACY = Проверка точного времени CONNECTING = Подключение @@ -25,17 +27,12 @@ MINTING_DISABLED = Чеканка отключена MINTING_ENABLED = Чеканка активна -# Nagging about lack of NTP time sync -NTP_NAG_CAPTION = Часы компьютера неточны! - -NTP_NAG_TEXT_UNIX = Установите службу NTP, чтобы получить точное время - -NTP_NAG_TEXT_WINDOWS = Выберите "Синхронизация времени" из меню, чтобы исправить - OPEN_UI = Открыть пользовательский интерфейс +PERFORMING_DB_CHECKPOINT = Saving uncommitted database changes... + SYNCHRONIZE_CLOCK = Синхронизировать время SYNCHRONIZING_BLOCKCHAIN = Синхронизация цепи -SYNCHRONIZING_CLOCK = Проверка времени +SYNCHRONIZING_CLOCK = Проверка времени \ No newline at end of file diff --git a/src/main/resources/i18n/SysTray_zh_CN.properties b/src/main/resources/i18n/SysTray_zh_CN.properties index caba49cf..eaea452b 100644 --- a/src/main/resources/i18n/SysTray_zh_CN.properties +++ b/src/main/resources/i18n/SysTray_zh_CN.properties @@ -1,31 +1,40 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # SysTray pop-up menu +AUTO_UPDATE = Auto Update + +APPLYING_UPDATE_AND_RESTARTING = Applying automatic update and restarting... + BLOCK_HEIGHT = 区块高度 +BUILD_VERSION = Build version + CHECK_TIME_ACCURACY = 检查时间准确性 +CONNECTING = Connecting + CONNECTION = 个链接 CONNECTIONS = 个链接 +CREATING_BACKUP_OF_DB_FILES = Creating backup of database files... + +DB_BACKUP = Database Backup + +DB_CHECKPOINT = Database Checkpoint + EXIT = 退出核心 MINTING_DISABLED = 没有铸币 MINTING_ENABLED = ✔ 铸币 -# Nagging about lack of NTP time sync -NTP_NAG_CAPTION = 电脑的时间不准确! - -NTP_NAG_TEXT_UNIX = 安装NTP服务以获取准确的时间。 - -NTP_NAG_TEXT_WINDOWS = 从菜单中选择“同步时钟”进行修复。 - OPEN_UI = 开启Qortal界面 +PERFORMING_DB_CHECKPOINT = Saving uncommitted database changes... + SYNCHRONIZE_CLOCK = 同步时钟 SYNCHRONIZING_BLOCKCHAIN = 正在同步区块链 -SYNCHRONIZING_CLOCK = 正在同步时钟 +SYNCHRONIZING_CLOCK = 正在同步时钟 \ No newline at end of file diff --git a/src/main/resources/i18n/SysTray_zh_TW.properties b/src/main/resources/i18n/SysTray_zh_TW.properties index ac768846..3af0c84c 100644 --- a/src/main/resources/i18n/SysTray_zh_TW.properties +++ b/src/main/resources/i18n/SysTray_zh_TW.properties @@ -1,31 +1,40 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # SysTray pop-up menu +AUTO_UPDATE = Auto Update + +APPLYING_UPDATE_AND_RESTARTING = Applying automatic update and restarting... + BLOCK_HEIGHT = 區塊高度 +BUILD_VERSION = Build version + CHECK_TIME_ACCURACY = 檢查時間準確性 +CONNECTING = Connecting + CONNECTION = 個鏈接 CONNECTIONS = 個鏈接 +CREATING_BACKUP_OF_DB_FILES = Creating backup of database files... + +DB_BACKUP = Database Backup + +DB_CHECKPOINT = Database Checkpoint + EXIT = 退出核心 MINTING_DISABLED = 沒有鑄幣 MINTING_ENABLED = ✔ 鑄幣 -# Nagging about lack of NTP time sync -NTP_NAG_CAPTION = 電腦的時間不準確! - -NTP_NAG_TEXT_UNIX = 安装NTP服務以獲取準確的時間。 - -NTP_NAG_TEXT_WINDOWS = 從菜單中選擇“同步時鐘”進行修復。 - OPEN_UI = 開啓Qortal界面 +PERFORMING_DB_CHECKPOINT = Saving uncommitted database changes... + SYNCHRONIZE_CLOCK = 同步時鐘 SYNCHRONIZING_BLOCKCHAIN = 正在同步區塊鏈 -SYNCHRONIZING_CLOCK = 正在同步時鐘 +SYNCHRONIZING_CLOCK = 正在同步時鐘 \ No newline at end of file diff --git a/src/main/resources/i18n/TransactionValidity_fi.properties b/src/main/resources/i18n/TransactionValidity_fi.properties index 2dc9abef..d1568e24 100644 --- a/src/main/resources/i18n/TransactionValidity_fi.properties +++ b/src/main/resources/i18n/TransactionValidity_fi.properties @@ -1,4 +1,3 @@ - ACCOUNT_ALREADY_EXISTS = tili on jo olemassa ACCOUNT_CANNOT_REWARD_SHARE = tili ei voi palkinto-jakaa @@ -31,8 +30,6 @@ BAN_UNKNOWN = tuntematon eväys BUYER_ALREADY_OWNER = ostaja on jo omistaja -CHAT = CHATin transaktiot eivät koskaan ole kelvollisia sisällytettäväksi lohkoihin - CLOCK_NOT_SYNCED = kello on synkronisoimatta DUPLICATE_OPTION = kahdennettu valinta @@ -182,3 +179,9 @@ TRANSACTION_ALREADY_EXISTS = transaktio on jo olemassa TRANSACTION_UNKNOWN = tuntematon transaktio TX_GROUP_ID_MISMATCH = transaktion ryhmä-ID:n vastaavuusvirhe + +ADDRESS_IN_BLACKLIST = this address is in your blacklist + +INVALID_TIMESTAMP_SIGNATURE = Invalid timestamp signature + +INVALID_BUT_OK = Invalid but OK \ No newline at end of file diff --git a/src/main/resources/i18n/TransactionValidity_hu.properties b/src/main/resources/i18n/TransactionValidity_hu.properties index b00bb1b4..3f6e338d 100644 --- a/src/main/resources/i18n/TransactionValidity_hu.properties +++ b/src/main/resources/i18n/TransactionValidity_hu.properties @@ -1,3 +1,5 @@ +# Magyar myelvre forditotta: Szkíta (Scythian). 2021 Augusztus 7. + OK = OK INVALID_ADDRESS = érvénytelen név vagy cím diff --git a/src/main/resources/i18n/TransactionValidity_it.properties b/src/main/resources/i18n/TransactionValidity_it.properties index d97af856..b5e5b964 100644 --- a/src/main/resources/i18n/TransactionValidity_it.properties +++ b/src/main/resources/i18n/TransactionValidity_it.properties @@ -32,8 +32,6 @@ BAN_UNKNOWN = divieto sconosciuto BUYER_ALREADY_OWNER = l'acquirente è già proprietario -CHAT = Le transazioni CHAT non sono mai valide per l'inclusione nei blocchi - CLOCK_NOT_SYNCED = orologio non sincronizzato DUPLICATE_OPTION = opzione duplicata @@ -183,3 +181,9 @@ TRANSACTION_ALREADY_EXISTS = la transazione già esiste TRANSACTION_UNKNOWN = transazione sconosciuta TX_GROUP_ID_MISMATCH = identificazione di gruppo della transazione non corrisponde + +ADDRESS_IN_BLACKLIST = this address is in your blacklist + +INVALID_TIMESTAMP_SIGNATURE = Invalid timestamp signature + +INVALID_BUT_OK = Invalid but OK \ No newline at end of file diff --git a/src/main/resources/i18n/TransactionValidity_nl.properties b/src/main/resources/i18n/TransactionValidity_nl.properties index 7afaad89..39c2bd72 100644 --- a/src/main/resources/i18n/TransactionValidity_nl.properties +++ b/src/main/resources/i18n/TransactionValidity_nl.properties @@ -1,4 +1,3 @@ - ACCOUNT_ALREADY_EXISTS = account bestaat al ACCOUNT_CANNOT_REWARD_SHARE = account kan geen beloningen delen @@ -31,8 +30,6 @@ BAN_UNKNOWN = ban onbekend BUYER_ALREADY_OWNER = koper is al eigenaar -CHAT = CHAT transacties zijn nooit geldig voor opname in blokken - CLOCK_NOT_SYNCED = klok is niet gesynchronizeerd DUPLICATE_OPTION = dubbele optie @@ -182,3 +179,9 @@ TRANSACTION_ALREADY_EXISTS = transactie bestaat al TRANSACTION_UNKNOWN = transactie onbekend TX_GROUP_ID_MISMATCH = groep-ID van transactie matcht niet + +ADDRESS_IN_BLACKLIST = this address is in your blacklist + +INVALID_TIMESTAMP_SIGNATURE = Invalid timestamp signature + +INVALID_BUT_OK = Invalid but OK \ No newline at end of file diff --git a/src/main/resources/i18n/TransactionValidity_ru.properties b/src/main/resources/i18n/TransactionValidity_ru.properties index c2dbe5df..f134ee5e 100644 --- a/src/main/resources/i18n/TransactionValidity_ru.properties +++ b/src/main/resources/i18n/TransactionValidity_ru.properties @@ -1,4 +1,3 @@ - ACCOUNT_ALREADY_EXISTS = аккаунт уже существует ACCOUNT_CANNOT_REWARD_SHARE = аккаунт не может делиться вознаграждением @@ -174,3 +173,9 @@ TRANSACTION_ALREADY_EXISTS = транзакция существует TRANSACTION_UNKNOWN = неизвестная транзакция TX_GROUP_ID_MISMATCH = не соответствие идентификатора группы c хэш транзации + +ADDRESS_IN_BLACKLIST = this address is in your blacklist + +INVALID_TIMESTAMP_SIGNATURE = Invalid timestamp signature + +INVALID_BUT_OK = Invalid but OK \ No newline at end of file From b890e02a6a37a90da578df2b971918fac09a2bf7 Mon Sep 17 00:00:00 2001 From: Scythian <> Date: Sat, 7 Aug 2021 15:09:48 +0100 Subject: [PATCH 027/231] Added new TransactionValidity keys Added ADDRESS_ABOVE_RATE_LIMIT and DUPLICATE_MESSAGE ValidationResults to localeLang translation keys --- src/main/resources/i18n/TransactionValidity_en.properties | 4 ++++ src/main/resources/i18n/TransactionValidity_fi.properties | 4 ++++ src/main/resources/i18n/TransactionValidity_hu.properties | 4 ++++ src/main/resources/i18n/TransactionValidity_it.properties | 4 ++++ src/main/resources/i18n/TransactionValidity_nl.properties | 4 ++++ src/main/resources/i18n/TransactionValidity_ru.properties | 4 ++++ 6 files changed, 24 insertions(+) diff --git a/src/main/resources/i18n/TransactionValidity_en.properties b/src/main/resources/i18n/TransactionValidity_en.properties index 5f8088f4..17a52647 100644 --- a/src/main/resources/i18n/TransactionValidity_en.properties +++ b/src/main/resources/i18n/TransactionValidity_en.properties @@ -182,6 +182,10 @@ INVALID_TIMESTAMP_SIGNATURE = invalid timestamp signature ADDRESS_IN_BLACKLIST = this address is in your blacklist +ADDRESS_ABOVE_RATE_LIMIT = address reached specified rate limit + +DUPLICATE_MESSAGE = address sent duplicate message + INVALID_BUT_OK = invalid but OK NOT_YET_RELEASED = feature not yet released \ No newline at end of file diff --git a/src/main/resources/i18n/TransactionValidity_fi.properties b/src/main/resources/i18n/TransactionValidity_fi.properties index d1568e24..adf7eb35 100644 --- a/src/main/resources/i18n/TransactionValidity_fi.properties +++ b/src/main/resources/i18n/TransactionValidity_fi.properties @@ -182,6 +182,10 @@ TX_GROUP_ID_MISMATCH = transaktion ryhmä-ID:n vastaavuusvirhe ADDRESS_IN_BLACKLIST = this address is in your blacklist +ADDRESS_ABOVE_RATE_LIMIT = address reached specified rate limit + +DUPLICATE_MESSAGE = address sent duplicate message + INVALID_TIMESTAMP_SIGNATURE = Invalid timestamp signature INVALID_BUT_OK = Invalid but OK \ No newline at end of file diff --git a/src/main/resources/i18n/TransactionValidity_hu.properties b/src/main/resources/i18n/TransactionValidity_hu.properties index 3f6e338d..68950971 100644 --- a/src/main/resources/i18n/TransactionValidity_hu.properties +++ b/src/main/resources/i18n/TransactionValidity_hu.properties @@ -184,6 +184,10 @@ INVALID_TIMESTAMP_SIGNATURE = érvénytelen időbélyeg aláírás ADDRESS_IN_BLACKLIST = ez a fiókcím a fekete listádon van +ADDRESS_ABOVE_RATE_LIMIT = ez a cím elérte a megengedett mérték korlátot + +DUPLICATE_MESSAGE = ez a cím duplikált üzenetet küldött + INVALID_BUT_OK = érvénytelen de elfogadva NOT_YET_RELEASED = ez a funkció még nem került kiadásra \ No newline at end of file diff --git a/src/main/resources/i18n/TransactionValidity_it.properties b/src/main/resources/i18n/TransactionValidity_it.properties index b5e5b964..62d1608b 100644 --- a/src/main/resources/i18n/TransactionValidity_it.properties +++ b/src/main/resources/i18n/TransactionValidity_it.properties @@ -184,6 +184,10 @@ TX_GROUP_ID_MISMATCH = identificazione di gruppo della transazione non corrispon ADDRESS_IN_BLACKLIST = this address is in your blacklist +ADDRESS_ABOVE_RATE_LIMIT = address reached specified rate limit + +DUPLICATE_MESSAGE = address sent duplicate message + INVALID_TIMESTAMP_SIGNATURE = Invalid timestamp signature INVALID_BUT_OK = Invalid but OK \ No newline at end of file diff --git a/src/main/resources/i18n/TransactionValidity_nl.properties b/src/main/resources/i18n/TransactionValidity_nl.properties index 39c2bd72..d6191f86 100644 --- a/src/main/resources/i18n/TransactionValidity_nl.properties +++ b/src/main/resources/i18n/TransactionValidity_nl.properties @@ -182,6 +182,10 @@ TX_GROUP_ID_MISMATCH = groep-ID van transactie matcht niet ADDRESS_IN_BLACKLIST = this address is in your blacklist +ADDRESS_ABOVE_RATE_LIMIT = address reached specified rate limit + +DUPLICATE_MESSAGE = address sent duplicate message + INVALID_TIMESTAMP_SIGNATURE = Invalid timestamp signature INVALID_BUT_OK = Invalid but OK \ No newline at end of file diff --git a/src/main/resources/i18n/TransactionValidity_ru.properties b/src/main/resources/i18n/TransactionValidity_ru.properties index f134ee5e..e8761e7b 100644 --- a/src/main/resources/i18n/TransactionValidity_ru.properties +++ b/src/main/resources/i18n/TransactionValidity_ru.properties @@ -176,6 +176,10 @@ TX_GROUP_ID_MISMATCH = не соответствие идентификатор ADDRESS_IN_BLACKLIST = this address is in your blacklist +ADDRESS_ABOVE_RATE_LIMIT = address reached specified rate limit + +DUPLICATE_MESSAGE = address sent duplicate message + INVALID_TIMESTAMP_SIGNATURE = Invalid timestamp signature INVALID_BUT_OK = Invalid but OK \ No newline at end of file From 481e6671c21bdb28cf93d0e90fb2dad4f61f707a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 7 Aug 2021 16:27:16 +0100 Subject: [PATCH 028/231] Added GET /lists/blacklist/addresses API endpoint This returns a JSON array containing the blacklisted addresses. --- .../org/qortal/api/resource/ListsResource.java | 15 +++++++++++++++ src/main/java/org/qortal/list/ResourceList.java | 11 ++++++++++- .../java/org/qortal/list/ResourceListManager.java | 8 ++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/resource/ListsResource.java b/src/main/java/org/qortal/api/resource/ListsResource.java index 70918a38..ffa77981 100644 --- a/src/main/java/org/qortal/api/resource/ListsResource.java +++ b/src/main/java/org/qortal/api/resource/ListsResource.java @@ -237,6 +237,21 @@ public class ListsResource { } } + @GET + @Path("/blacklist/addresses") + @Operation( + summary = "Fetch the list of blacklisted addresses", + responses = { + @ApiResponse( + description = "A JSON array of addresses", + content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(type = "boolean")) + ) + } + ) + public String getAddressBlacklist() { + return ResourceListManager.getInstance().getBlacklistJSONString(); + } + @GET @Path("/blacklist/address/{address}") @Operation( diff --git a/src/main/java/org/qortal/list/ResourceList.java b/src/main/java/org/qortal/list/ResourceList.java index 6a5cd1c9..67ef8411 100644 --- a/src/main/java/org/qortal/list/ResourceList.java +++ b/src/main/java/org/qortal/list/ResourceList.java @@ -113,10 +113,12 @@ public class ResourceList { } - /* Utils */ public static String listToJSONString(List list) { + if (list == null) { + return null; + } JSONArray items = new JSONArray(); for (String item : list) { items.put(item); @@ -125,6 +127,9 @@ public class ResourceList { } private static List listFromJSONString(String jsonString) { + if (jsonString == null) { + return null; + } JSONArray jsonList = new JSONArray(jsonString); List resourceList = new ArrayList<>(); for (int i=0; i Date: Sat, 7 Aug 2021 19:18:20 +0100 Subject: [PATCH 029/231] Log the AT states reshape progress, as it seems to be taking a very long time. --- .../org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 6dfef623..2e399be1 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -849,6 +849,10 @@ public class HSQLDBDatabaseUpdates { + "WHERE height BETWEEN " + minHeight + " AND " + (minHeight + heightStep - 1) + ")"); stmt.execute("COMMIT"); + + int processed = Math.min(minHeight + heightStep - 1, blockchainHeight); + double percentage = (double)processed / (double)blockchainHeight * 100.0f; + LOGGER.info(String.format("Processed %d of %d blocks (%.1f%%)", processed, blockchainHeight, percentage)); } stmt.execute("CHECKPOINT"); From 5b85f014272f7f75f99044d2854c079df696700e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 8 Aug 2021 08:28:34 +0100 Subject: [PATCH 030/231] Added defensiveness to list management methods in ResourceList.java --- src/main/java/org/qortal/list/ResourceList.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/org/qortal/list/ResourceList.java b/src/main/java/org/qortal/list/ResourceList.java index 67ef8411..2d74b230 100644 --- a/src/main/java/org/qortal/list/ResourceList.java +++ b/src/main/java/org/qortal/list/ResourceList.java @@ -99,16 +99,25 @@ public class ResourceList { /* List management */ public void add(String resource) { + if (resource == null || this.list == null) { + return; + } if (!this.contains(resource)) { this.list.add(resource); } } public void remove(String resource) { + if (resource == null || this.list == null) { + return; + } this.list.remove(resource); } public boolean contains(String resource) { + if (resource == null || this.list == null) { + return false; + } return this.list.contains(resource); } From 8bb5077e761a506100e75e95e80c3589894e35e8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 8 Aug 2021 08:29:29 +0100 Subject: [PATCH 031/231] Catch occasional NPE when setting tray icon. --- src/main/java/org/qortal/gui/SysTray.java | 30 +++++++++++++---------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/qortal/gui/SysTray.java b/src/main/java/org/qortal/gui/SysTray.java index 6fc994bf..8aee45fd 100644 --- a/src/main/java/org/qortal/gui/SysTray.java +++ b/src/main/java/org/qortal/gui/SysTray.java @@ -291,19 +291,23 @@ public class SysTray { public void setTrayIcon(int iconid) { if (trayIcon != null) { - switch (iconid) { - case 1: - this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_syncing_time-alt.png")); - break; - case 2: - this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_minting.png")); - break; - case 3: - this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_syncing.png")); - break; - case 4: - this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_synced.png")); - break; + try { + switch (iconid) { + case 1: + this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_syncing_time-alt.png")); + break; + case 2: + this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_minting.png")); + break; + case 3: + this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_syncing.png")); + break; + case 4: + this.trayIcon.setImage(Gui.loadImage("icons/qortal_ui_tray_synced.png")); + break; + } + } catch (NullPointerException e) { + LOGGER.info("Unable to set tray icon"); } } } From 756601c1ceb7ddbb3be31c56f99592d904bf068c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 8 Aug 2021 08:41:13 +0100 Subject: [PATCH 032/231] Initialize to an empty list. This fixes various bugs caused by the list being null when no blacklist JSON file was available. --- src/main/java/org/qortal/list/ResourceList.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/qortal/list/ResourceList.java b/src/main/java/org/qortal/list/ResourceList.java index 2d74b230..49b8aeb4 100644 --- a/src/main/java/org/qortal/list/ResourceList.java +++ b/src/main/java/org/qortal/list/ResourceList.java @@ -36,6 +36,7 @@ public class ResourceList { public ResourceList(String category, String resourceName) throws IOException { this.category = category; this.resourceName = resourceName; + this.list = new ArrayList<>(); this.load(); } From ebc3db8aed0a27b8d3418a3f482947764812e5b2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 8 Aug 2021 10:20:44 +0100 Subject: [PATCH 033/231] Default file path for repository data imports set to "qortal-backup/TradeBotStates.json". This allows the trade bot backup to be imported in a single click, and can now be potentially added as a button in the UI. --- src/main/java/org/qortal/api/resource/AdminResource.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 719a3b9d..88dd0065 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -553,13 +553,13 @@ public class AdminResource { @Path("/repository/data") @Operation( summary = "Import data into repository.", - description = "Imports data from file on local machine. Filename is forced to 'import.json' if apiKey is not set.", + description = "Imports data from file on local machine. Filename is forced to 'qortal-backup/TradeBotStates.json' if apiKey is not set.", requestBody = @RequestBody( required = true, content = @Content( mediaType = MediaType.TEXT_PLAIN, schema = @Schema( - type = "string", example = "MintingAccounts.script" + type = "string", example = "qortal-backup/TradeBotStates.json" ) ) ), @@ -577,7 +577,7 @@ public class AdminResource { // Hard-coded because it's too dangerous to allow user-supplied filenames in weaker security contexts if (Settings.getInstance().getApiKey() == null) - filename = "import.json"; + filename = "qortal-backup/TradeBotStates.json"; try (final Repository repository = RepositoryManager.getRepository()) { ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); From 78373f3746407a13aca74ce7ccac900f98d70383 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 8 Aug 2021 10:29:15 +0100 Subject: [PATCH 034/231] HTLC redeem/refund APIs switched from GET to POST. --- .../org/qortal/api/resource/CrossChainHtlcResource.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index 0076609a..ee2b20a6 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -11,6 +11,7 @@ import java.util.List; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.GET; +import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.core.Context; @@ -173,7 +174,7 @@ public class CrossChainHtlcResource { } } - @GET + @POST @Path("/redeem/{ataddress}") @Operation( summary = "Redeems HTLC associated with supplied AT", @@ -231,7 +232,7 @@ public class CrossChainHtlcResource { } } - @GET + @POST @Path("/redeemAll") @Operation( summary = "Redeems HTLC for all applicable ATs in tradebot data", @@ -415,7 +416,7 @@ public class CrossChainHtlcResource { return false; } - @GET + @POST @Path("/refund/{ataddress}") @Operation( summary = "Refunds HTLC associated with supplied AT", @@ -463,7 +464,7 @@ public class CrossChainHtlcResource { } - @GET + @POST @Path("/refundAll") @Operation( summary = "Refunds HTLC for all applicable ATs in tradebot data", From c9596fd8c4c22311f776701567b771f39e7a38c9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 9 Aug 2021 10:02:24 +0100 Subject: [PATCH 035/231] Catch exceptions thrown during GUI initialization. This is a workaround for an UnsupportedOperationException thrown when using X2Go, due to PERPIXEL_TRANSLUCENT translucency being unsupported in splashDialog.setBackground(). We could choose to use a different version of the splash screen with an opaque background in these cases, but it is low priority. --- src/main/java/org/qortal/gui/Gui.java | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/qortal/gui/Gui.java b/src/main/java/org/qortal/gui/Gui.java index 118718e2..87342f6a 100644 --- a/src/main/java/org/qortal/gui/Gui.java +++ b/src/main/java/org/qortal/gui/Gui.java @@ -23,17 +23,21 @@ public class Gui { private SysTray sysTray = null; private Gui() { - this.isHeadless = GraphicsEnvironment.isHeadless(); + try { + this.isHeadless = GraphicsEnvironment.isHeadless(); - if (!this.isHeadless) { - try { - UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); - } catch (ClassNotFoundException | InstantiationException | IllegalAccessException - | UnsupportedLookAndFeelException e) { - // Use whatever look-and-feel comes by default then + if (!this.isHeadless) { + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException + | UnsupportedLookAndFeelException e) { + // Use whatever look-and-feel comes by default then + } + + showSplash(); } - - showSplash(); + } catch (Exception e) { + LOGGER.info("Unable to initialize GUI: {}", e.getMessage()); } } From 2a0a39a95aa88a45ecc6cff4d5f7a9075c4e61d4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 9 Aug 2021 23:35:32 +0100 Subject: [PATCH 036/231] Avoid creation of lists directory until the first item is added to a list. --- src/main/java/org/qortal/list/ResourceList.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/qortal/list/ResourceList.java b/src/main/java/org/qortal/list/ResourceList.java index 49b8aeb4..c80deac3 100644 --- a/src/main/java/org/qortal/list/ResourceList.java +++ b/src/main/java/org/qortal/list/ResourceList.java @@ -46,13 +46,7 @@ public class ResourceList { private Path getFilePath() { String pathString = String.format("%s%s%s_%s.json", Settings.getInstance().getListsPath(), File.separator, this.resourceName, this.category); - Path outputFilePath = Paths.get(pathString); - try { - Files.createDirectories(outputFilePath.getParent()); - } catch (IOException e) { - throw new IllegalStateException("Unable to create lists directory"); - } - return outputFilePath; + return Paths.get(pathString); } public void save() throws IOException { @@ -63,8 +57,15 @@ public class ResourceList { throw new IllegalStateException("Can't save list with missing category"); } String jsonString = ResourceList.listToJSONString(this.list); - Path filePath = this.getFilePath(); + + // Create parent directory if needed + try { + Files.createDirectories(filePath.getParent()); + } catch (IOException e) { + throw new IllegalStateException("Unable to create lists directory"); + } + BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toString())); writer.write(jsonString); writer.close(); From 477a35a685d9dedf0029c15c3a8906c8f2682ef8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 10 Aug 2021 08:43:47 +0100 Subject: [PATCH 037/231] Fixed response schema for GET /lists/blacklist/addresses endpoint --- src/main/java/org/qortal/api/resource/ListsResource.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/resource/ListsResource.java b/src/main/java/org/qortal/api/resource/ListsResource.java index ffa77981..b6387b6d 100644 --- a/src/main/java/org/qortal/api/resource/ListsResource.java +++ b/src/main/java/org/qortal/api/resource/ListsResource.java @@ -1,6 +1,7 @@ package org.qortal.api.resource; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; @@ -244,7 +245,7 @@ public class ListsResource { responses = { @ApiResponse( description = "A JSON array of addresses", - content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(type = "boolean")) + content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = String.class))) ) } ) From 3b6ba7641d872ff9707f68a9b777a21556573249 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 10 Aug 2021 19:35:03 +0100 Subject: [PATCH 038/231] Updated icon for Qortal.exe --- WindowsInstaller/qortal.ico | Bin 255738 -> 43546 bytes 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 WindowsInstaller/qortal.ico diff --git a/WindowsInstaller/qortal.ico b/WindowsInstaller/qortal.ico old mode 100755 new mode 100644 index b0f8f5fbe0abb4e4710b07a277594de0db72fca8..a44ed445d36c8c8e983b7f9c0208a2a1fe9be67a GIT binary patch literal 43546 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 J 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

FY9Wr>!}ac2PYk#$n%OOX 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 P20sLKK(Dd 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 From 112675c7820f1be9d75be925d4c00b3fa7bcf017 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 11 Aug 2021 19:22:53 +0100 Subject: [PATCH 039/231] Better handling of RPC errors. Previously, if an error was returned from an Electrum server (such as "server busy") it would throw a NetworkException that would be caught outside of the server loop and cause the entire request to fail. Instead of throwing an exception, I am now logging the error and returning null, in the same way we do for IOException and NoSuchElementException further up in the same method. This allows the caller - most likely connectedRpc() - to move on to the next server in the list and try again. This should fix an issue seen where a "server busy" response from a single server was essentially breaking our implementation, as we would give up altogether instead of trying another server. --- .../java/org/qortal/crosschain/ElectrumX.java | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index 8f41ed86..b08cb239 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -653,18 +653,27 @@ public class ElectrumX extends BitcoinyBlockchainProvider { Object errorObj = responseJson.get("error"); if (errorObj != null) { - if (errorObj instanceof String) - throw new ForeignBlockchainException.NetworkException(String.format("Unexpected error message from ElectrumX RPC %s: %s", method, (String) errorObj), this.currentServer); + if (errorObj instanceof String) { + LOGGER.debug(String.format("Unexpected error message from ElectrumX RPC %s: %s", method, (String) errorObj), this.currentServer); + // Try another server + return null; + } - if (!(errorObj instanceof JSONObject)) - throw new ForeignBlockchainException.NetworkException(String.format("Unexpected error response from ElectrumX RPC %s", method), this.currentServer); + if (!(errorObj instanceof JSONObject)) { + LOGGER.debug(String.format("Unexpected error response from ElectrumX RPC %s", method), this.currentServer); + // Try another server + return null; + } JSONObject errorJson = (JSONObject) errorObj; Object messageObj = errorJson.get("message"); - if (!(messageObj instanceof String)) - throw new ForeignBlockchainException.NetworkException(String.format("Missing/invalid message in error response from ElectrumX RPC %s", method), this.currentServer); + if (!(messageObj instanceof String)) { + LOGGER.debug(String.format("Missing/invalid message in error response from ElectrumX RPC %s", method), this.currentServer); + // Try another server + return null; + } String message = (String) messageObj; From 1752386a6c2162d5d0ec3327128f1839182dc8e1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 11 Aug 2021 20:33:54 +0100 Subject: [PATCH 040/231] Fixed logging errors in previous commit. --- src/main/java/org/qortal/crosschain/ElectrumX.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index b08cb239..4ab7e0b1 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -654,13 +654,13 @@ public class ElectrumX extends BitcoinyBlockchainProvider { Object errorObj = responseJson.get("error"); if (errorObj != null) { if (errorObj instanceof String) { - LOGGER.debug(String.format("Unexpected error message from ElectrumX RPC %s: %s", method, (String) errorObj), this.currentServer); + LOGGER.debug(String.format("Unexpected error message from ElectrumX server %s for RPC method %s: %s", this.currentServer, method, (String) errorObj)); // Try another server return null; } if (!(errorObj instanceof JSONObject)) { - LOGGER.debug(String.format("Unexpected error response from ElectrumX RPC %s", method), this.currentServer); + LOGGER.debug(String.format("Unexpected error response from ElectrumX server %s for RPC method %s", this.currentServer, method)); // Try another server return null; } @@ -670,7 +670,7 @@ public class ElectrumX extends BitcoinyBlockchainProvider { Object messageObj = errorJson.get("message"); if (!(messageObj instanceof String)) { - LOGGER.debug(String.format("Missing/invalid message in error response from ElectrumX RPC %s", method), this.currentServer); + LOGGER.debug(String.format("Missing/invalid message in error response from ElectrumX server %s for RPC method %s", this.currentServer, method)); // Try another server return null; } From f71516f36ff5e9d2fd8612a4c153f3a859c7b3e4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 11 Aug 2021 21:26:29 +0100 Subject: [PATCH 041/231] Skip finished ATs in the refund API endpoints. --- .../java/org/qortal/api/resource/CrossChainHtlcResource.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index ee2b20a6..c0d4a94b 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -561,6 +561,11 @@ public class CrossChainHtlcResource { if (atData == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + if (atData.getIsFinished()) { + LOGGER.info(String.format("Skipping finished AT %s", atAddress)); + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } + ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); if (acct == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); From 8c325f3a8a08a80a84a29d0a909642292da8e505 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Wed, 11 Aug 2021 18:18:10 -0400 Subject: [PATCH 042/231] original design --- src/main/resources/images/Qlogo_512.png | Bin 63668 -> 191342 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/main/resources/images/Qlogo_512.png b/src/main/resources/images/Qlogo_512.png index 81508bb70f277ebe2783f1fa4cd91f7c0a01c572..2c1b3037be4386ce33347bdc55530702b96efed6 100644 GIT binary patch literal 191342 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 S8++GC&zqOps%0-MKmHG;mD@T1 literal 63668 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 T@@ogh4bnqLQ(vP(%{ugdRgiBK From 673f23b6a0685ac956552c3867d8f5395d015661 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 12 Aug 2021 08:29:52 +0100 Subject: [PATCH 043/231] Improvement to commit f71516f Now only skipping the HTLC redemption if the AT is finished and the balance has been redeemed by the buyer. This allows HTLCs to be refunded for ATs that have been refunded or cancelled. --- .../api/resource/CrossChainHtlcResource.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index c0d4a94b..46d7ebc6 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -479,8 +479,6 @@ public class CrossChainHtlcResource { ) @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) public boolean refundAllHtlc() { - Security.checkApiCallAllowed(request); - Security.checkApiCallAllowed(request); boolean success = false; @@ -561,11 +559,6 @@ public class CrossChainHtlcResource { if (atData == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); - if (atData.getIsFinished()) { - LOGGER.info(String.format("Skipping finished AT %s", atAddress)); - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - } - ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); if (acct == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); @@ -574,6 +567,13 @@ public class CrossChainHtlcResource { if (crossChainTradeData == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + // If the AT is "finished" then it will have a zero balance + // In these cases we should avoid HTLC refunds if tbe QORT haven't been returned to the seller + if (atData.getIsFinished() && crossChainTradeData.mode != AcctMode.REFUNDED && crossChainTradeData.mode != AcctMode.CANCELLED) { + LOGGER.info(String.format("Skipping AT %s because the QORT has already been redemed", atAddress)); + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null); if (tradeBotData == null) From b4f980b34959dd3bd1ea96dbae83a212936b5c2f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 12 Aug 2021 19:52:49 +0100 Subject: [PATCH 044/231] Restrict lists API endpoints to local/apiKey requests only. --- .../java/org/qortal/api/resource/ListsResource.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/ListsResource.java b/src/main/java/org/qortal/api/resource/ListsResource.java index b6387b6d..dea6690c 100644 --- a/src/main/java/org/qortal/api/resource/ListsResource.java +++ b/src/main/java/org/qortal/api/resource/ListsResource.java @@ -43,6 +43,8 @@ public class ListsResource { ) @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) public String addAddressToBlacklist(@PathParam("address") String address) { + Security.checkApiCallAllowed(request); + if (!Crypto.isValidAddress(address)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); @@ -85,6 +87,8 @@ public class ListsResource { ) @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) public String addAddressesToBlacklist(AddressListRequest addressListRequest) { + Security.checkApiCallAllowed(request); + if (addressListRequest == null || addressListRequest.addresses == null) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); } @@ -147,6 +151,8 @@ public class ListsResource { ) @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) public String removeAddressFromBlacklist(@PathParam("address") String address) { + Security.checkApiCallAllowed(request); + if (!Crypto.isValidAddress(address)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); @@ -189,6 +195,8 @@ public class ListsResource { ) @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) public String removeAddressesFromBlacklist(AddressListRequest addressListRequest) { + Security.checkApiCallAllowed(request); + if (addressListRequest == null || addressListRequest.addresses == null) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); } @@ -250,6 +258,7 @@ public class ListsResource { } ) public String getAddressBlacklist() { + Security.checkApiCallAllowed(request); return ResourceListManager.getInstance().getBlacklistJSONString(); } @@ -266,6 +275,8 @@ public class ListsResource { ) @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) public String checkAddressInBlacklist(@PathParam("address") String address) { + Security.checkApiCallAllowed(request); + if (!Crypto.isValidAddress(address)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); From 1b39db664c24625f5efbd0ddbbe5b94081ba1c13 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 27 Aug 2021 08:54:46 +0100 Subject: [PATCH 045/231] Added missing ATStatesHeightIndex to the reshape code. This was accidentally missed out of the original code. Some pre-updated nodes on the network will be missing this index, but we can use the upcoming "auto-bootstrap" feature to get those back. --- .../org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 2e399be1..683a2c3b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -837,6 +837,11 @@ public class HSQLDBDatabaseUpdates { stmt.execute("SET TABLE ATStatesNew NEW SPACE"); stmt.execute("CHECKPOINT"); + // Add the height index + LOGGER.info("Adding index to AT states table..."); + stmt.execute("CREATE INDEX ATStatesNewHeightIndex ON ATStatesNew (height)"); + stmt.execute("CHECKPOINT"); + ResultSet resultSet = stmt.executeQuery("SELECT height FROM Blocks ORDER BY height DESC LIMIT 1"); final int blockchainHeight = resultSet.next() ? resultSet.getInt(1) : 0; final int heightStep = 100; @@ -858,6 +863,7 @@ public class HSQLDBDatabaseUpdates { stmt.execute("DROP TABLE ATStates"); stmt.execute("ALTER TABLE ATStatesNew RENAME TO ATStates"); + stmt.execute("ALTER INDEX ATStatesNewHeightIndex RENAME TO ATStatesHeightIndex"); stmt.execute("CHECKPOINT"); break; } From e7e4cb75796ee9c5a3450fffd6a4984a501d062e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Aug 2021 08:01:02 +0100 Subject: [PATCH 046/231] Started work on pruning mode (top-only-sync) Initially just deleting old and unused AT states, to get this table under control. I have had to delete them individually as the table can't handle complex queries due to its size. Nodes in pruning mode will be unable to serve older blocks to peers. --- .../qortal/controller/AtStatesTrimmer.java | 2 + .../org/qortal/controller/Controller.java | 16 +++- .../controller/pruning/AtStatesPruner.java | 95 +++++++++++++++++++ .../controller/pruning/PruneManager.java | 60 ++++++++++++ .../org/qortal/repository/ATRepository.java | 17 ++++ .../repository/hsqldb/HSQLDBATRepository.java | 85 +++++++++++++++++ .../hsqldb/HSQLDBDatabaseUpdates.java | 4 + .../java/org/qortal/settings/Settings.java | 28 ++++++ 8 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/qortal/controller/pruning/AtStatesPruner.java create mode 100644 src/main/java/org/qortal/controller/pruning/PruneManager.java diff --git a/src/main/java/org/qortal/controller/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/AtStatesTrimmer.java index b452b3cc..78539813 100644 --- a/src/main/java/org/qortal/controller/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/AtStatesTrimmer.java @@ -2,6 +2,7 @@ package org.qortal.controller; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.controller.pruning.PruneManager; import org.qortal.data.block.BlockData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -22,6 +23,7 @@ public class AtStatesTrimmer implements Runnable { repository.getATRepository().prepareForAtStateTrimming(); repository.saveChanges(); + PruneManager.getInstance().setBuiltLatestATStates(true); while (!Controller.isStopping()) { repository.discardChanges(); diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index bb990b17..2b0a6b8f 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -46,6 +46,7 @@ import org.qortal.block.Block; import org.qortal.block.BlockChain; import org.qortal.block.BlockChain.BlockTimingByHeight; import org.qortal.controller.Synchronizer.SynchronizationResult; +import org.qortal.controller.pruning.PruneManager; import org.qortal.controller.tradebot.TradeBot; import org.qortal.crypto.Crypto; import org.qortal.data.account.MintingAccountData; @@ -357,7 +358,7 @@ public class Controller extends Thread { return this.savedArgs; } - /* package */ static boolean isStopping() { + public static boolean isStopping() { return isStopping; } @@ -1286,6 +1287,13 @@ public class Controller extends Thread { try (final Repository repository = RepositoryManager.getRepository()) { BlockData blockData = repository.getBlockRepository().fromSignature(signature); + if (blockData != null) { + if (PruneManager.getInstance().isBlockPruned(blockData.getHeight(), repository)) { + // If this is a pruned block, we likely only have partial data, so best not to sent it + blockData = null; + } + } + if (blockData == null) { // We don't have this block this.stats.getBlockMessageStats.unknownBlocks.getAndIncrement(); @@ -1407,6 +1415,12 @@ public class Controller extends Thread { BlockData blockData = repository.getBlockRepository().fromReference(parentSignature); + if (PruneManager.getInstance().isBlockPruned(blockData.getHeight(), repository)) { + // If this request contains a pruned block, we likely only have partial data, so best not to sent anything + // We always prune from the oldest first, so it's fine to just check the first block requested + blockData = null; + } + while (blockData != null && blockSummaries.size() < numberRequested) { BlockSummaryData blockSummary = new BlockSummaryData(blockData); blockSummaries.add(blockSummary); diff --git a/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java b/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java new file mode 100644 index 00000000..37f0cd74 --- /dev/null +++ b/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java @@ -0,0 +1,95 @@ +package org.qortal.controller.pruning; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.controller.Controller; +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; +import org.qortal.utils.NTP; + +public class AtStatesPruner implements Runnable { + + private static final Logger LOGGER = LogManager.getLogger(AtStatesPruner.class); + + @Override + public void run() { + Thread.currentThread().setName("AT States pruner"); + + if (!Settings.getInstance().isPruningEnabled()) { + return; + } + + try (final Repository repository = RepositoryManager.getRepository()) { + int pruneStartHeight = repository.getATRepository().getAtPruneHeight(); + + // repository.getATRepository().prepareForAtStatePruning(); + // repository.saveChanges(); + + while (!Controller.isStopping()) { + repository.discardChanges(); + + Thread.sleep(Settings.getInstance().getAtStatesPruneInterval()); + + if (PruneManager.getInstance().getBuiltLatestATStates() == false) { + // Wait for latest AT states table to be built first + // This has a dependency on the AtStatesTrimmer running, + // which should be okay, given that it isn't something + // is disabled in normal operation. + continue; + } + + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null || NTP.getTime() == null) + continue; + + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Controller.getInstance().isSynchronizing()) + continue; + + long currentPrunableTimestamp = NTP.getTime() - Settings.getInstance().getAtStatesMaxLifetime(); + // We want to keep AT states near the tip of our copy of blockchain so we can process/orphan nearby blocks + long chainPrunableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); + + long upperPrunableTimestamp = Math.min(currentPrunableTimestamp, chainPrunableTimestamp); + int upperPrunableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperPrunableTimestamp); + + int upperBatchHeight = pruneStartHeight + Settings.getInstance().getAtStatesPruneBatchSize(); + int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); + + if (pruneStartHeight >= upperPruneHeight) + continue; + + LOGGER.debug(String.format("Pruning AT states between blocks %d and %d...", pruneStartHeight, upperPruneHeight)); + + int numAtStatesPruned = repository.getATRepository().pruneAtStates(pruneStartHeight, upperPruneHeight); + repository.saveChanges(); + + if (numAtStatesPruned > 0) { + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.debug(() -> String.format("Pruned %d AT state%s between blocks %d and %d", + numAtStatesPruned, (numAtStatesPruned != 1 ? "s" : ""), + finalPruneStartHeight, upperPruneHeight)); + } else { + // Can we move onto next batch? + if (upperPrunableHeight > upperBatchHeight) { + pruneStartHeight = upperBatchHeight; + repository.getATRepository().setAtPruneHeight(pruneStartHeight); + repository.getATRepository().prepareForAtStatePruning(); + repository.saveChanges(); + + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.debug(() -> String.format("Bumping AT state base prune height to %d", finalPruneStartHeight)); + } + } + } + } catch (DataException e) { + LOGGER.warn(String.format("Repository issue trying to prune AT states: %s", e.getMessage())); + } catch (InterruptedException e) { + // Time to exit + } + } + +} diff --git a/src/main/java/org/qortal/controller/pruning/PruneManager.java b/src/main/java/org/qortal/controller/pruning/PruneManager.java new file mode 100644 index 00000000..dcd7391d --- /dev/null +++ b/src/main/java/org/qortal/controller/pruning/PruneManager.java @@ -0,0 +1,60 @@ +package org.qortal.controller.pruning; + +import org.qortal.controller.Controller; + +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.settings.Settings; +import org.qortal.utils.DaemonThreadFactory; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class PruneManager { + + private static PruneManager instance; + + private boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); + private int pruneBlockLimit = Settings.getInstance().getPruneBlockLimit(); + private boolean builtLatestATStates = false; + + private PruneManager() { + // Start individual pruning processes + ExecutorService pruneExecutor = Executors.newCachedThreadPool(new DaemonThreadFactory()); + pruneExecutor.execute(new AtStatesPruner()); + } + + public static synchronized PruneManager getInstance() { + if (instance == null) + instance = new PruneManager(); + + return instance; + } + + public boolean isBlockPruned(int height, Repository repository) throws DataException { + if (!this.pruningEnabled) { + return false; + } + + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null) { + throw new DataException("Unable to determine chain tip when checking if a block is pruned"); + } + + final int ourLatestHeight = chainTip.getHeight(); + final int latestUnprunedHeight = ourLatestHeight - this.pruneBlockLimit; + + return (height < latestUnprunedHeight); + } + + + public void setBuiltLatestATStates(boolean builtLatestATStates) { + this.builtLatestATStates = builtLatestATStates; + } + + public boolean getBuiltLatestATStates() { + return this.builtLatestATStates; + } + +} diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 558b3aab..6cec0839 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -127,6 +127,23 @@ public interface ATRepository { /** Trims full AT state data between passed heights. Returns number of trimmed rows. */ public int trimAtStates(int minHeight, int maxHeight, int limit) throws DataException; + + /** Returns height of first prunable AT state. */ + public int getAtPruneHeight() throws DataException; + + /** Sets new base height for AT state pruning. + *

+ * NOTE: performs implicit repository.saveChanges(). + */ + public void setAtPruneHeight(int pruneHeight) throws DataException; + + /** Hook to allow repository to prepare/cache info for AT state pruning. */ + public void prepareForAtStatePruning() throws DataException; + + /** Prunes full AT state data between passed heights. Returns number of pruned rows. */ + public int pruneAtStates(int minHeight, int maxHeight) throws DataException; + + /** * Save ATStateData into repository. *

diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index d2461466..d5929311 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -8,6 +8,7 @@ import java.util.Set; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.data.account.AccountData; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; import org.qortal.repository.ATRepository; @@ -682,6 +683,90 @@ public class HSQLDBATRepository implements ATRepository { } } + + @Override + public int getAtPruneHeight() throws DataException { + String sql = "SELECT AT_prune_height FROM DatabaseInfo"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return 0; + + return resultSet.getInt(1); + } catch (SQLException e) { + throw new DataException("Unable to fetch AT state prune height from repository", e); + } + } + + @Override + public void setAtPruneHeight(int pruneHeight) throws DataException { + // trimHeightsLock is to prevent concurrent update on DatabaseInfo + // that could result in "transaction rollback: serialization failure" + synchronized (this.repository.trimHeightsLock) { + String updateSql = "UPDATE DatabaseInfo SET AT_prune_height = ?"; + + try { + this.repository.executeCheckedUpdate(updateSql, pruneHeight); + this.repository.saveChanges(); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to set AT state prune height in repository", e); + } + } + } + + @Override + public void prepareForAtStatePruning() throws DataException { + // Use LatestATStates table that was already built by AtStatesTrimmer + // The AtStatesPruner class checks that this process has completed first + } + + @Override + public int pruneAtStates(int minHeight, int maxHeight) throws DataException { + int deletedCount = 0; + + for (int height=minHeight; height atAddresses = new ArrayList<>(); + String updateSql = "SELECT AT_address FROM LatestATStates WHERE height = ?"; + try (ResultSet resultSet = this.repository.checkedExecute(updateSql, height)) { + if (resultSet != null) { + do { + String atAddress = resultSet.getString(1); + atAddresses.add(atAddress); + + } while (resultSet.next()); + } + } catch (SQLException e) { + throw new DataException("Unable to fetch flagged accounts from repository", e); + } + + List atStates = this.getBlockATStatesAtHeight(height); + for (ATStateData atState : atStates) { + //LOGGER.info("Found atState {} at height {}", atState.getATAddress(), atState.getHeight()); + + if (atAddresses.contains(atState.getATAddress())) { + // We don't want to delete this AT state because it is still active + LOGGER.info("Skipping atState {} at height {}", atState.getATAddress(), atState.getHeight()); + continue; + } + + // Safe to delete everything else for this height + try { + this.repository.delete("ATStates", "AT_address = ? AND height = ?", + atState.getATAddress(), atState.getHeight()); + deletedCount++; + } catch (SQLException e) { + throw new DataException("Unable to delete AT state data from repository", e); + } + } + } + + return deletedCount; + } + + @Override public void save(ATStateData atStateData) throws DataException { // We shouldn't ever save partial ATStateData diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 683a2c3b..94e753e8 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -867,6 +867,10 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CHECKPOINT"); break; } + case 35: + // Support for pruning + stmt.execute("ALTER TABLE DatabaseInfo ADD AT_prune_height INT NOT NULL DEFAULT 0"); + break; default: // nothing to do diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index b8884c6c..f63bdbb9 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -103,6 +103,18 @@ public class Settings { /** Max number of AT states to trim in one go. */ private int atStatesTrimLimit = 4000; // records + /** Whether we should prune old data to reduce database size + * This prevents the node from being able to serve older blocks */ + private boolean pruningEnabled = false; + /** The amount of recent blocks we should keep when pruning */ + private int pruneBlockLimit = 1440; + + /** How often to attempt AT state pruning (ms). */ + private long atStatesPruneInterval = 3219L; // milliseconds + /** Block height range to scan for trimmable AT states.
+ * This has a significant effect on execution time. */ + private int atStatesPruneBatchSize = 10; // blocks + /** How often to attempt online accounts signatures trimming (ms). */ private long onlineSignaturesTrimInterval = 9876L; // milliseconds /** Block height range to scan for trimmable online accounts signatures.
@@ -521,6 +533,22 @@ public class Settings { return this.atStatesTrimLimit; } + public boolean isPruningEnabled() { + return this.pruningEnabled; + } + + public int getPruneBlockLimit() { + return this.pruneBlockLimit; + } + + public long getAtStatesPruneInterval() { + return this.atStatesPruneInterval; + } + + public int getAtStatesPruneBatchSize() { + return this.atStatesPruneBatchSize; + } + public long getOnlineSignaturesTrimInterval() { return this.onlineSignaturesTrimInterval; } From bc1af126559289e96dee6fa2ef922c6ba7bc51f5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Aug 2021 15:17:46 +0100 Subject: [PATCH 047/231] Prune all blocks up until the blockPruneLimit By default, this leaves only the last 1450 blocks in the database. Only applies when pruning mode is enabled. --- .../controller/pruning/BlockPruner.java | 86 +++++++++++++++++++ .../controller/pruning/PruneManager.java | 1 + .../qortal/repository/BlockRepository.java | 14 +++ .../hsqldb/HSQLDBBlockRepository.java | 47 ++++++++++ .../hsqldb/HSQLDBDatabaseUpdates.java | 1 + .../java/org/qortal/settings/Settings.java | 49 +++++++---- 6 files changed, 182 insertions(+), 16 deletions(-) create mode 100644 src/main/java/org/qortal/controller/pruning/BlockPruner.java diff --git a/src/main/java/org/qortal/controller/pruning/BlockPruner.java b/src/main/java/org/qortal/controller/pruning/BlockPruner.java new file mode 100644 index 00000000..8ae25224 --- /dev/null +++ b/src/main/java/org/qortal/controller/pruning/BlockPruner.java @@ -0,0 +1,86 @@ +package org.qortal.controller.pruning; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.controller.Controller; +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; +import org.qortal.utils.NTP; + +public class BlockPruner implements Runnable { + + private static final Logger LOGGER = LogManager.getLogger(BlockPruner.class); + + @Override + public void run() { + Thread.currentThread().setName("Block pruner"); + + if (!Settings.getInstance().isPruningEnabled()) { + return; + } + + try (final Repository repository = RepositoryManager.getRepository()) { + int pruneStartHeight = repository.getBlockRepository().getBlockPruneHeight(); + + while (!Controller.isStopping()) { + repository.discardChanges(); + + Thread.sleep(Settings.getInstance().getBlockPruneInterval()); + + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null || NTP.getTime() == null) + continue; + + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Controller.getInstance().isSynchronizing()) + continue; + + // Prune all blocks up until our latest minus pruneBlockLimit + final int ourLatestHeight = chainTip.getHeight(); + final int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); + + int upperBatchHeight = pruneStartHeight + Settings.getInstance().getBlockPruneBatchSize(); + int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); + + if (pruneStartHeight >= upperPruneHeight) { + continue; + } + + LOGGER.debug(String.format("Pruning blocks between %d and %d...", pruneStartHeight, upperPruneHeight)); + + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(pruneStartHeight, upperPruneHeight); + repository.saveChanges(); + + if (numBlocksPruned > 0) { + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.debug(() -> String.format("Pruned %d block%s between %d and %d", + numBlocksPruned, (numBlocksPruned != 1 ? "s" : ""), + finalPruneStartHeight, upperPruneHeight)); + } else { + // Can we move onto next batch? + if (upperPrunableHeight > upperBatchHeight) { + pruneStartHeight = upperBatchHeight; + repository.getBlockRepository().setBlockPruneHeight(pruneStartHeight); + repository.saveChanges(); + + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.debug(() -> String.format("Bumping block base prune height to %d", finalPruneStartHeight)); + } + else { + // We've pruned up to the upper prunable height + // Back off for a while to save CPU for syncing + Thread.sleep(10*60*1000L); + } + } + } + } catch (DataException e) { + LOGGER.warn(String.format("Repository issue trying to prune blocks: %s", e.getMessage())); + } catch (InterruptedException e) { + // Time to exit + } + } + +} diff --git a/src/main/java/org/qortal/controller/pruning/PruneManager.java b/src/main/java/org/qortal/controller/pruning/PruneManager.java index dcd7391d..66019d01 100644 --- a/src/main/java/org/qortal/controller/pruning/PruneManager.java +++ b/src/main/java/org/qortal/controller/pruning/PruneManager.java @@ -23,6 +23,7 @@ public class PruneManager { // Start individual pruning processes ExecutorService pruneExecutor = Executors.newCachedThreadPool(new DaemonThreadFactory()); pruneExecutor.execute(new AtStatesPruner()); + pruneExecutor.execute(new BlockPruner()); } public static synchronized PruneManager getInstance() { diff --git a/src/main/java/org/qortal/repository/BlockRepository.java b/src/main/java/org/qortal/repository/BlockRepository.java index 78eba399..5ca61e66 100644 --- a/src/main/java/org/qortal/repository/BlockRepository.java +++ b/src/main/java/org/qortal/repository/BlockRepository.java @@ -166,6 +166,20 @@ public interface BlockRepository { */ public BlockData getDetachedBlockSignature(int startHeight) throws DataException; + + /** Returns height of first prunable block. */ + public int getBlockPruneHeight() throws DataException; + + /** Sets new base height for block pruning. + *

+ * NOTE: performs implicit repository.saveChanges(). + */ + public void setBlockPruneHeight(int pruneHeight) throws DataException; + + /** Prunes full block data between passed heights. Returns number of pruned rows. */ + public int pruneBlocks(int minHeight, int maxHeight) throws DataException; + + /** * Saves block into repository. * diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java index b486e6a0..2f7e4ad2 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java @@ -509,6 +509,53 @@ public class HSQLDBBlockRepository implements BlockRepository { } } + + @Override + public int getBlockPruneHeight() throws DataException { + String sql = "SELECT block_prune_height FROM DatabaseInfo"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return 0; + + return resultSet.getInt(1); + } catch (SQLException e) { + throw new DataException("Unable to fetch block prune height from repository", e); + } + } + + @Override + public void setBlockPruneHeight(int pruneHeight) throws DataException { + // trimHeightsLock is to prevent concurrent update on DatabaseInfo + // that could result in "transaction rollback: serialization failure" + synchronized (this.repository.trimHeightsLock) { + String updateSql = "UPDATE DatabaseInfo SET block_prune_height = ?"; + + try { + this.repository.executeCheckedUpdate(updateSql, pruneHeight); + this.repository.saveChanges(); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to set block prune height in repository", e); + } + } + } + + @Override + public int pruneBlocks(int minHeight, int maxHeight) throws DataException { + // Don't prune the genesis block + if (minHeight <= 1) { + minHeight = 2; + } + + try { + return this.repository.delete("Blocks", "height BETWEEN ? AND ?", minHeight, maxHeight); + } catch (SQLException e) { + throw new DataException("Unable to prune blocks from repository", e); + } + } + + @Override public BlockData getDetachedBlockSignature(int startHeight) throws DataException { String sql = "SELECT " + BLOCK_DB_COLUMNS + " FROM Blocks " diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 94e753e8..d696351f 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -870,6 +870,7 @@ public class HSQLDBDatabaseUpdates { case 35: // Support for pruning stmt.execute("ALTER TABLE DatabaseInfo ADD AT_prune_height INT NOT NULL DEFAULT 0"); + stmt.execute("ALTER TABLE DatabaseInfo ADD block_prune_height INT NOT NULL DEFAULT 0"); break; default: diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index f63bdbb9..89a63ad1 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -103,24 +103,32 @@ public class Settings { /** Max number of AT states to trim in one go. */ private int atStatesTrimLimit = 4000; // records - /** Whether we should prune old data to reduce database size - * This prevents the node from being able to serve older blocks */ - private boolean pruningEnabled = false; - /** The amount of recent blocks we should keep when pruning */ - private int pruneBlockLimit = 1440; - - /** How often to attempt AT state pruning (ms). */ - private long atStatesPruneInterval = 3219L; // milliseconds - /** Block height range to scan for trimmable AT states.
- * This has a significant effect on execution time. */ - private int atStatesPruneBatchSize = 10; // blocks - /** How often to attempt online accounts signatures trimming (ms). */ private long onlineSignaturesTrimInterval = 9876L; // milliseconds /** Block height range to scan for trimmable online accounts signatures.
* This has a significant effect on execution time. */ private int onlineSignaturesTrimBatchSize = 100; // blocks + + /** Whether we should prune old data to reduce database size + * This prevents the node from being able to serve older blocks */ + private boolean pruningEnabled = false; + /** The amount of recent blocks we should keep when pruning */ + private int pruneBlockLimit = 1450; + + /** How often to attempt AT state pruning (ms). */ + private long atStatesPruneInterval = 3219L; // milliseconds + /** Block height range to scan for prunable AT states.
+ * This has a significant effect on execution time. */ + private int atStatesPruneBatchSize = 10; // blocks + + /** How often to attempt block pruning (ms). */ + private long blockPruneInterval = 3219L; // milliseconds + /** Block height range to scan for prunable blocks.
+ * This has a significant effect on execution time. */ + private int blockPruneBatchSize = 10000; // blocks + + // Peer-to-peer related private boolean isTestNet = false; /** Port number for inbound peer-to-peer connections. */ @@ -533,6 +541,15 @@ public class Settings { return this.atStatesTrimLimit; } + public long getOnlineSignaturesTrimInterval() { + return this.onlineSignaturesTrimInterval; + } + + public int getOnlineSignaturesTrimBatchSize() { + return this.onlineSignaturesTrimBatchSize; + } + + public boolean isPruningEnabled() { return this.pruningEnabled; } @@ -549,12 +566,12 @@ public class Settings { return this.atStatesPruneBatchSize; } - public long getOnlineSignaturesTrimInterval() { - return this.onlineSignaturesTrimInterval; + public long getBlockPruneInterval() { + return this.blockPruneInterval; } - public int getOnlineSignaturesTrimBatchSize() { - return this.onlineSignaturesTrimBatchSize; + public int getBlockPruneBatchSize() { + return this.blockPruneBatchSize; } } From 209a9fa8c3ba25e111f6c113691e7a398e8fd129 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Aug 2021 15:18:11 +0100 Subject: [PATCH 048/231] Rework of Blockchain.validate() to account for pruning mode. --- .../java/org/qortal/block/BlockChain.java | 49 ++++++++++++++----- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index e6b8db4e..15801193 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -506,28 +506,51 @@ public class BlockChain { * @throws SQLException */ public static void validate() throws DataException { - // Check first block is Genesis Block - if (!isGenesisBlockValid()) - rebuildBlockchain(); - try (final Repository repository = RepositoryManager.getRepository()) { + + boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); + BlockData chainTip = repository.getBlockRepository().getLastBlock(); + boolean hasBlocks = (chainTip != null && chainTip.getHeight() > 1); + + if (pruningEnabled && hasBlocks) { + // Pruning is enabled and we have blocks, so it's possible that the genesis block has been pruned + // It's best not to validate it, and there's no real need to + } + else { + // Check first block is Genesis Block + if (!isGenesisBlockValid()) { + rebuildBlockchain(); + } + } + repository.checkConsistency(); - int startHeight = Math.max(repository.getBlockRepository().getBlockchainHeight() - 1440, 1); + // Set the number of blocks to validate based on the pruned state of the chain + // If pruned, subtract an extra 10 to allow room for error + int blocksToValidate = pruningEnabled ? Settings.getInstance().getPruneBlockLimit() - 10 : 1440; + int startHeight = Math.max(repository.getBlockRepository().getBlockchainHeight() - blocksToValidate, 1); BlockData detachedBlockData = repository.getBlockRepository().getDetachedBlockSignature(startHeight); if (detachedBlockData != null) { LOGGER.error(String.format("Block %d's reference does not match any block's signature", detachedBlockData.getHeight())); - // Wait for blockchain lock (whereas orphan() only tries to get lock) - ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); - blockchainLock.lock(); - try { - LOGGER.info(String.format("Orphaning back to block %d", detachedBlockData.getHeight() - 1)); - orphan(detachedBlockData.getHeight() - 1); - } finally { - blockchainLock.unlock(); + // Orphan if we aren't a pruning node + if (!Settings.getInstance().isPruningEnabled()) { + + // Wait for blockchain lock (whereas orphan() only tries to get lock) + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + blockchainLock.lock(); + try { + LOGGER.info(String.format("Orphaning back to block %d", detachedBlockData.getHeight() - 1)); + orphan(detachedBlockData.getHeight() - 1); + } finally { + blockchainLock.unlock(); + } + } + else { + LOGGER.error(String.format("Not orphaning because we are in pruning mode. You may be on an " + + "invalid chain and should consider bootstrapping or re-syncing from genesis.")); } } } From c8466a2e7a0766478467125bed70cad6f4269d94 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Aug 2021 15:20:11 +0100 Subject: [PATCH 049/231] Updated AT states pruner as it previously relied on blocks being present in the db to make decisions. As a side effect, this now prunes ATs up the the pruneBlockLimit too, rather than keeping the last 35 days or so. Will review this later but I don't think we will need the missing ones. --- .../org/qortal/controller/pruning/AtStatesPruner.java | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java b/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java index 37f0cd74..4268f98c 100644 --- a/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java @@ -49,12 +49,9 @@ public class AtStatesPruner implements Runnable { if (Controller.getInstance().isSynchronizing()) continue; - long currentPrunableTimestamp = NTP.getTime() - Settings.getInstance().getAtStatesMaxLifetime(); - // We want to keep AT states near the tip of our copy of blockchain so we can process/orphan nearby blocks - long chainPrunableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); - - long upperPrunableTimestamp = Math.min(currentPrunableTimestamp, chainPrunableTimestamp); - int upperPrunableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperPrunableTimestamp); + // Prune AT states for all blocks up until our latest minus pruneBlockLimit + final int ourLatestHeight = chainTip.getHeight(); + final int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); int upperBatchHeight = pruneStartHeight + Settings.getInstance().getAtStatesPruneBatchSize(); int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); From 22efaccd4aca79f397551804f610d913465cf1a1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Aug 2021 15:20:28 +0100 Subject: [PATCH 050/231] Fixed NPE introduced in earlier commit. --- src/main/java/org/qortal/controller/Controller.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 2b0a6b8f..26fc7fcc 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1415,10 +1415,12 @@ public class Controller extends Thread { BlockData blockData = repository.getBlockRepository().fromReference(parentSignature); - if (PruneManager.getInstance().isBlockPruned(blockData.getHeight(), repository)) { - // If this request contains a pruned block, we likely only have partial data, so best not to sent anything - // We always prune from the oldest first, so it's fine to just check the first block requested - blockData = null; + if (blockData != null) { + if (PruneManager.getInstance().isBlockPruned(blockData.getHeight(), repository)) { + // If this request contains a pruned block, we likely only have partial data, so best not to sent anything + // We always prune from the oldest first, so it's fine to just check the first block requested + blockData = null; + } } while (blockData != null && blockSummaries.size() < numberRequested) { From f5910ab95071684b8683b35815ecbaa19e2638fb Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Aug 2021 15:21:18 +0100 Subject: [PATCH 051/231] Break out of the AT pruning inner loops if we're stopping the app. --- .../qortal/repository/hsqldb/HSQLDBATRepository.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index d5929311..0d4d2923 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -727,6 +727,11 @@ public class HSQLDBATRepository implements ATRepository { for (int height=minHeight; height atAddresses = new ArrayList<>(); String updateSql = "SELECT AT_address FROM LatestATStates WHERE height = ?"; @@ -746,6 +751,11 @@ public class HSQLDBATRepository implements ATRepository { for (ATStateData atState : atStates) { //LOGGER.info("Found atState {} at height {}", atState.getATAddress(), atState.getHeight()); + // Give up if we're stopping + if (Controller.isStopping()) { + return deletedCount; + } + if (atAddresses.contains(atState.getATAddress())) { // We don't want to delete this AT state because it is still active LOGGER.info("Skipping atState {} at height {}", atState.getATAddress(), atState.getHeight()); From 5127f9442397ac068ab6b2944c73886dc074b73b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 23 Aug 2021 21:17:51 +0100 Subject: [PATCH 052/231] Added bulk pruning phase on node startup the first time that pruning mode is enabled. When switching from a full node to a pruning node, we need to delete most of the database contents. If we do this entirely as a background process, it is very slow and can interfere with syncing. However, if we take the approach of transferring only the necessary rows to a new table and then deleting the original table, this makes the process much faster. It was taking several days to delete the AT states in the background, but only a couple of minutes to copy them to a new table. The trade off is that we have to go through a form of "reshape" when starting the app for the first time after enabling pruning mode. But given that this is an opt-in mode, I don't think it will be a problem. Once the pruning is complete, it automatically performs a CHECKPOINT DEFRAG in order to shrink the database file size down to a fraction of what it was before. From this point, the original background process will run, but can be dialled right down so not to interfere with syncing. --- .../org/qortal/controller/Controller.java | 1 + .../qortal/repository/RepositoryManager.java | 24 ++ .../hsqldb/HSQLDBDatabasePruning.java | 217 ++++++++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 26fc7fcc..a34a5d81 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -416,6 +416,7 @@ public class Controller extends Thread { try { RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(getRepositoryUrl()); RepositoryManager.setRepositoryFactory(repositoryFactory); + RepositoryManager.prune(); } catch (DataException e) { // If exception has no cause then repository is in use by some other process. if (e.getCause() == null) { diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index df578888..5e9c71c2 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -1,8 +1,14 @@ package org.qortal.repository; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.repository.hsqldb.HSQLDBDatabasePruning; +import org.qortal.settings.Settings; + import java.sql.SQLException; public abstract class RepositoryManager { + private static final Logger LOGGER = LogManager.getLogger(RepositoryManager.class); private static RepositoryFactory repositoryFactory = null; @@ -51,6 +57,24 @@ public abstract class RepositoryManager { } } + public static void prune() { + // Bulk prune the database the first time we use pruning mode + if (Settings.getInstance().isPruningEnabled()) { + try { + boolean prunedATStates = HSQLDBDatabasePruning.pruneATStates(); + boolean prunedBlocks = HSQLDBDatabasePruning.pruneBlocks(); + + // Perform repository maintenance to shrink the db size down + if (prunedATStates && prunedBlocks) { + HSQLDBDatabasePruning.performMaintenance(); + } + + } catch (SQLException | DataException e) { + LOGGER.info("Unable to bulk prune AT states. The database may have been left in an inconsistent state."); + } + } + } + public static void setRequestedCheckpoint(Boolean quick) { quickCheckpointRequested = quick; } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java new file mode 100644 index 00000000..6dc50647 --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -0,0 +1,217 @@ +package org.qortal.repository.hsqldb; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * + * When switching from a full node to a pruning node, we need to delete most of the database contents. + * If we do this entirely as a background process, it is very slow and can interfere with syncing. + * However, if we take the approach of transferring only the necessary rows to a new table and then + * deleting the original table, this makes the process much faster. It was taking several days to + * delete the AT states in the background, but only a couple of minutes to copy them to a new table. + * + * The trade off is that we have to go through a form of "reshape" when starting the app for the first + * time after enabling pruning mode. But given that this is an opt-in mode, I don't think it will be + * a problem. + * + * Once the pruning is complete, it automatically performs a CHECKPOINT DEFRAG in order to + * shrink the database file size down to a fraction of what it was before. + * + * From this point, the original background process will run, but can be dialled right down so not + * to interfere with syncing. + * + */ + + +public class HSQLDBDatabasePruning { + + private static final Logger LOGGER = LogManager.getLogger(HSQLDBDatabasePruning.class); + + public static boolean pruneATStates() throws SQLException, DataException { + try (final HSQLDBRepository repository = (HSQLDBRepository)RepositoryManager.getRepository()) { + + // Only bulk prune AT states if we have never done so before + int pruneHeight = repository.getATRepository().getAtPruneHeight(); + if (pruneHeight > 0) { + // Already pruned AT states + return false; + } + + LOGGER.info("Starting bulk prune of AT states - this process could take a while... (approx. 2 mins on high spec)"); + + // Create new AT-states table to hold smaller dataset + repository.executeCheckedUpdate("DROP TABLE IF EXISTS ATStatesNew"); + repository.executeCheckedUpdate("CREATE TABLE ATStatesNew (" + + "AT_address QortalAddress, height INTEGER NOT NULL, state_hash ATStateHash NOT NULL, " + + "fees QortalAmount NOT NULL, is_initial BOOLEAN NOT NULL, sleep_until_message_timestamp BIGINT, " + + "PRIMARY KEY (AT_address, height), " + + "FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); + repository.executeCheckedUpdate("SET TABLE ATStatesNew NEW SPACE"); + repository.executeCheckedUpdate("CHECKPOINT"); + + + // Find our latest block + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + if (latestBlock == null) { + LOGGER.info("Unable to determine blockchain height, necessary for bulk block pruning"); + return false; + } + + // Calculate some constants for later use + final int blockchainHeight = latestBlock.getHeight(); + final int maximumBlockToTrim = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + final int startHeight = maximumBlockToTrim; + final int endHeight = blockchainHeight; + final int blockStep = 10000; + + // Loop through all the LatestATStates and copy them to the new table + LOGGER.info("Copying AT states..."); + for (int height = 0; height < endHeight; height += blockStep) { + //LOGGER.info(String.format("Copying AT states between %d and %d...", height, height + blockStep - 1)); + + String sql = "SELECT height, AT_address FROM LatestATStates WHERE height BETWEEN ? AND ?"; + try (ResultSet latestAtStatesResultSet = repository.checkedExecute(sql, height, height + blockStep - 1)) { + if (latestAtStatesResultSet != null) { + do { + int latestAtHeight = latestAtStatesResultSet.getInt(1); + String latestAtAddress = latestAtStatesResultSet.getString(2); + + // Copy this latest ATState to the new table + //LOGGER.info(String.format("Copying AT %s at height %d...", latestAtAddress, latestAtHeight)); + try { + String updateSql = "INSERT INTO ATStatesNew (" + + "SELECT AT_address, height, state_hash, fees, is_initial, sleep_until_message_timestamp " + + "FROM ATStates " + + "WHERE height = ? AND AT_address = ?)"; + repository.executeCheckedUpdate(updateSql, latestAtHeight, latestAtAddress); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to copy ATStates", e); + } + + if (height >= startHeight) { + // Now copy this AT states for each recent block it is present in + for (int i = startHeight; i < endHeight; i++) { + if (latestAtHeight < i) { + // This AT finished before this block so there is nothing to copy + continue; + } + + //LOGGER.info(String.format("Copying recent AT %s at height %d...", latestAtAddress, i)); + try { + // Copy each LatestATState to the new table + String updateSql = "INSERT IGNORE INTO ATStatesNew (" + + "SELECT AT_address, height, state_hash, fees, is_initial, sleep_until_message_timestamp " + + "FROM ATStates " + + "WHERE height = ? AND AT_address = ?)"; + repository.executeCheckedUpdate(updateSql, i, latestAtAddress); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to copy ATStates", e); + } + } + } + + } while (latestAtStatesResultSet.next()); + } + } catch (SQLException e) { + throw new DataException("Unable to copy AT states", e); + } + } + + repository.saveChanges(); + + // Add a height index + LOGGER.info("Rebuilding AT states height index in repository"); + repository.executeCheckedUpdate("CREATE INDEX IF NOT EXISTS ATStatesHeightIndex ON ATStatesNew (height)"); + repository.executeCheckedUpdate("CHECKPOINT"); + + // Finally, drop the original table and rename + LOGGER.info("Deleting old AT states..."); + repository.executeCheckedUpdate("DROP TABLE ATStates"); + repository.executeCheckedUpdate("ALTER TABLE ATStatesNew RENAME TO ATStates"); + repository.executeCheckedUpdate("CHECKPOINT"); + + // Update the prune height + repository.getATRepository().setAtPruneHeight(maximumBlockToTrim); + repository.saveChanges(); + + repository.executeCheckedUpdate("CHECKPOINT"); + + return true; + } + } + + public static boolean pruneBlocks() throws SQLException, DataException { + try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { + + // Only bulk prune AT states if we have never done so before + int pruneHeight = repository.getBlockRepository().getBlockPruneHeight(); + if (pruneHeight > 0) { + // Already pruned blocks + return false; + } + + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + if (latestBlock == null) { + LOGGER.info("Unable to determine blockchain height, necessary for bulk block pruning"); + return false; + } + final int blockchainHeight = latestBlock.getHeight(); + final int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + int pruneStartHeight = 0; + + LOGGER.info("Starting bulk prune of blocks - this process could take a while... (approx. 10 mins on high spec)"); + + while (pruneStartHeight < upperPrunableHeight) { + // Prune all blocks up until our latest minus pruneBlockLimit + + int upperBatchHeight = pruneStartHeight + Settings.getInstance().getBlockPruneBatchSize(); + int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); + + LOGGER.info(String.format("Pruning blocks between %d and %d...", pruneStartHeight, upperPruneHeight)); + + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(pruneStartHeight, upperPruneHeight); + repository.saveChanges(); + + if (numBlocksPruned > 0) { + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.info(() -> String.format("Pruned %d block%s between %d and %d", + numBlocksPruned, (numBlocksPruned != 1 ? "s" : ""), + finalPruneStartHeight, upperPruneHeight)); + } else { + // Can we move onto next batch? + if (upperPrunableHeight > upperBatchHeight) { + pruneStartHeight = upperBatchHeight; + repository.getBlockRepository().setBlockPruneHeight(pruneStartHeight); + repository.saveChanges(); + + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.debug(() -> String.format("Bumping block base prune height to %d", finalPruneStartHeight)); + } + else { + // We've finished pruning + break; + } + } + } + + return true; + } + } + + public static void performMaintenance() throws SQLException, DataException { + try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { + repository.performPeriodicMaintenance(); + } + } + +} From ca1379d9f8a05341489efabff50778fa60ed91e1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 25 Aug 2021 18:50:41 +0100 Subject: [PATCH 053/231] Unified the code to build the LatestATStates table, as it's now used by more than one class. Note - the rebuildLatestAtStates() must never be used by two different classes at the same time, or AT states could be incorrectly deleted. It is okay at the moment as we don't run the AT states trimmer and pruner in the same app session. However we should probably synchronize this method so that we don't accidentally call it from two places in the future. --- .../qortal/controller/AtStatesTrimmer.java | 5 +- .../org/qortal/controller/Controller.java | 13 +--- .../controller/pruning/AtStatesPruner.java | 24 +++---- .../controller/pruning/PruneManager.java | 56 ++++++++++++---- .../org/qortal/repository/ATRepository.java | 11 ++-- .../repository/hsqldb/HSQLDBATRepository.java | 64 +++++++++---------- 6 files changed, 94 insertions(+), 79 deletions(-) diff --git a/src/main/java/org/qortal/controller/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/AtStatesTrimmer.java index 78539813..4b08e5ca 100644 --- a/src/main/java/org/qortal/controller/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/AtStatesTrimmer.java @@ -21,9 +21,8 @@ public class AtStatesTrimmer implements Runnable { try (final Repository repository = RepositoryManager.getRepository()) { int trimStartHeight = repository.getATRepository().getAtTrimHeight(); - repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().rebuildLatestAtStates(); repository.saveChanges(); - PruneManager.getInstance().setBuiltLatestATStates(true); while (!Controller.isStopping()) { repository.discardChanges(); @@ -64,7 +63,7 @@ public class AtStatesTrimmer implements Runnable { if (upperTrimmableHeight > upperBatchHeight) { trimStartHeight = upperBatchHeight; repository.getATRepository().setAtTrimHeight(trimStartHeight); - repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().rebuildLatestAtStates(); repository.saveChanges(); final int finalTrimStartHeight = trimStartHeight; diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index a34a5d81..d8f706fd 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -512,9 +512,8 @@ public class Controller extends Thread { final long repositoryBackupInterval = Settings.getInstance().getRepositoryBackupInterval(); final long repositoryCheckpointInterval = Settings.getInstance().getRepositoryCheckpointInterval(); - ExecutorService trimExecutor = Executors.newCachedThreadPool(new DaemonThreadFactory()); - trimExecutor.execute(new AtStatesTrimmer()); - trimExecutor.execute(new OnlineAccountsSignaturesTrimmer()); + // Start executor service for trimming or pruning + PruneManager.getInstance().start(); try { while (!isStopping) { @@ -599,13 +598,7 @@ public class Controller extends Thread { Thread.interrupted(); // Fall-through to exit } finally { - trimExecutor.shutdownNow(); - - try { - trimExecutor.awaitTermination(2L, TimeUnit.SECONDS); - } catch (InterruptedException e) { - // We tried... - } + PruneManager.getInstance().stop(); } } diff --git a/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java b/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java index 4268f98c..66325e88 100644 --- a/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java @@ -25,22 +25,14 @@ public class AtStatesPruner implements Runnable { try (final Repository repository = RepositoryManager.getRepository()) { int pruneStartHeight = repository.getATRepository().getAtPruneHeight(); - // repository.getATRepository().prepareForAtStatePruning(); - // repository.saveChanges(); + repository.getATRepository().rebuildLatestAtStates(); + repository.saveChanges(); while (!Controller.isStopping()) { repository.discardChanges(); Thread.sleep(Settings.getInstance().getAtStatesPruneInterval()); - if (PruneManager.getInstance().getBuiltLatestATStates() == false) { - // Wait for latest AT states table to be built first - // This has a dependency on the AtStatesTrimmer running, - // which should be okay, given that it isn't something - // is disabled in normal operation. - continue; - } - BlockData chainTip = Controller.getInstance().getChainTip(); if (chainTip == null || NTP.getTime() == null) continue; @@ -63,8 +55,11 @@ public class AtStatesPruner implements Runnable { int numAtStatesPruned = repository.getATRepository().pruneAtStates(pruneStartHeight, upperPruneHeight); repository.saveChanges(); + int numAtStateDataRowsTrimmed = repository.getATRepository().trimAtStates( + pruneStartHeight, upperPruneHeight, Settings.getInstance().getAtStatesTrimLimit()); + repository.saveChanges(); - if (numAtStatesPruned > 0) { + if (numAtStatesPruned > 0 || numAtStateDataRowsTrimmed > 0) { final int finalPruneStartHeight = pruneStartHeight; LOGGER.debug(() -> String.format("Pruned %d AT state%s between blocks %d and %d", numAtStatesPruned, (numAtStatesPruned != 1 ? "s" : ""), @@ -74,12 +69,17 @@ public class AtStatesPruner implements Runnable { if (upperPrunableHeight > upperBatchHeight) { pruneStartHeight = upperBatchHeight; repository.getATRepository().setAtPruneHeight(pruneStartHeight); - repository.getATRepository().prepareForAtStatePruning(); + repository.getATRepository().rebuildLatestAtStates(); repository.saveChanges(); final int finalPruneStartHeight = pruneStartHeight; LOGGER.debug(() -> String.format("Bumping AT state base prune height to %d", finalPruneStartHeight)); } + else { + // We've pruned up to the upper prunable height + // Back off for a while to save CPU for syncing + Thread.sleep(5*60*1000L); + } } } } catch (DataException e) { diff --git a/src/main/java/org/qortal/controller/pruning/PruneManager.java b/src/main/java/org/qortal/controller/pruning/PruneManager.java index 66019d01..b733833b 100644 --- a/src/main/java/org/qortal/controller/pruning/PruneManager.java +++ b/src/main/java/org/qortal/controller/pruning/PruneManager.java @@ -1,7 +1,9 @@ package org.qortal.controller.pruning; +import org.qortal.controller.AtStatesTrimmer; import org.qortal.controller.Controller; +import org.qortal.controller.OnlineAccountsSignaturesTrimmer; import org.qortal.data.block.BlockData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -10,6 +12,7 @@ import org.qortal.utils.DaemonThreadFactory; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; public class PruneManager { @@ -17,13 +20,11 @@ public class PruneManager { private boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); private int pruneBlockLimit = Settings.getInstance().getPruneBlockLimit(); - private boolean builtLatestATStates = false; + + private ExecutorService executorService; private PruneManager() { - // Start individual pruning processes - ExecutorService pruneExecutor = Executors.newCachedThreadPool(new DaemonThreadFactory()); - pruneExecutor.execute(new AtStatesPruner()); - pruneExecutor.execute(new BlockPruner()); + } public static synchronized PruneManager getInstance() { @@ -33,6 +34,42 @@ public class PruneManager { return instance; } + public void start() { + this.executorService = Executors.newCachedThreadPool(new DaemonThreadFactory()); + + // Don't allow both the pruner and the trimmer to run at the same time. + // In pruning mode, we are already deleting far more than we would when trimming. + // In non-pruning mode, we still need to trim to keep the non-essential data + // out of the database. There isn't a case where both are needed at once. + // If we ever do need to enable both at once, be very careful with the AT state + // trimming, since both currently rely on having exclusive access to the + // prepareForAtStateTrimming() method. For both trimming and pruning to take place + // at once, we would need to synchronize this method in a way that both can't + // call it at the same time, as otherwise active ATs would be pruned/trimmed when + // they should have been kept. + + if (Settings.getInstance().isPruningEnabled()) { + // Pruning enabled - start the pruning processes + this.executorService.execute(new AtStatesPruner()); + this.executorService.execute(new BlockPruner()); + } + else { + // Pruning disabled - use trimming instead + this.executorService.execute(new AtStatesTrimmer()); + this.executorService.execute(new OnlineAccountsSignaturesTrimmer()); + } + } + + public void stop() { + this.executorService.shutdownNow(); + + try { + this.executorService.awaitTermination(2L, TimeUnit.SECONDS); + } catch (InterruptedException e) { + // We tried... + } + } + public boolean isBlockPruned(int height, Repository repository) throws DataException { if (!this.pruningEnabled) { return false; @@ -49,13 +86,4 @@ public class PruneManager { return (height < latestUnprunedHeight); } - - public void setBuiltLatestATStates(boolean builtLatestATStates) { - this.builtLatestATStates = builtLatestATStates; - } - - public boolean getBuiltLatestATStates() { - return this.builtLatestATStates; - } - } diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 6cec0839..74fb19ab 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -112,6 +112,11 @@ public interface ATRepository { */ public List getBlockATStatesAtHeight(int height) throws DataException; + + /** Rebuild the latest AT states cache, necessary for AT state trimming/pruning. */ + public void rebuildLatestAtStates() throws DataException; + + /** Returns height of first trimmable AT state. */ public int getAtTrimHeight() throws DataException; @@ -121,9 +126,6 @@ public interface ATRepository { */ public void setAtTrimHeight(int trimHeight) throws DataException; - /** Hook to allow repository to prepare/cache info for AT state trimming. */ - public void prepareForAtStateTrimming() throws DataException; - /** Trims full AT state data between passed heights. Returns number of trimmed rows. */ public int trimAtStates(int minHeight, int maxHeight, int limit) throws DataException; @@ -137,9 +139,6 @@ public interface ATRepository { */ public void setAtPruneHeight(int pruneHeight) throws DataException; - /** Hook to allow repository to prepare/cache info for AT state pruning. */ - public void prepareForAtStatePruning() throws DataException; - /** Prunes full AT state data between passed heights. Returns number of pruned rows. */ public int pruneAtStates(int minHeight, int maxHeight) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 0d4d2923..1921661c 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -8,7 +8,7 @@ import java.util.Set; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.qortal.data.account.AccountData; +import org.qortal.controller.Controller; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; import org.qortal.repository.ATRepository; @@ -601,6 +601,35 @@ public class HSQLDBATRepository implements ATRepository { return atStates; } + + @Override + public void rebuildLatestAtStates() throws DataException { + // Rebuild cache of latest AT states that we can't trim + String deleteSql = "DELETE FROM LatestATStates"; + try { + this.repository.executeCheckedUpdate(deleteSql); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to delete temporary latest AT states cache from repository", e); + } + + String insertSql = "INSERT INTO LatestATStates (" + + "SELECT AT_address, height FROM ATs " + + "CROSS JOIN LATERAL(" + + "SELECT height FROM ATStates " + + "WHERE ATStates.AT_address = ATs.AT_address " + + "ORDER BY AT_address DESC, height DESC LIMIT 1" + + ") " + + ")"; + try { + this.repository.executeCheckedUpdate(insertSql); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to populate temporary latest AT states cache in repository", e); + } + } + + @Override public int getAtTrimHeight() throws DataException { String sql = "SELECT AT_trim_height FROM DatabaseInfo"; @@ -632,33 +661,6 @@ public class HSQLDBATRepository implements ATRepository { } } - @Override - public void prepareForAtStateTrimming() throws DataException { - // Rebuild cache of latest AT states that we can't trim - String deleteSql = "DELETE FROM LatestATStates"; - try { - this.repository.executeCheckedUpdate(deleteSql); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to delete temporary latest AT states cache from repository", e); - } - - String insertSql = "INSERT INTO LatestATStates (" - + "SELECT AT_address, height FROM ATs " - + "CROSS JOIN LATERAL(" - + "SELECT height FROM ATStates " - + "WHERE ATStates.AT_address = ATs.AT_address " - + "ORDER BY AT_address DESC, height DESC LIMIT 1" - + ") " - + ")"; - try { - this.repository.executeCheckedUpdate(insertSql); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to populate temporary latest AT states cache in repository", e); - } - } - @Override public int trimAtStates(int minHeight, int maxHeight, int limit) throws DataException { if (minHeight >= maxHeight) @@ -715,12 +717,6 @@ public class HSQLDBATRepository implements ATRepository { } } - @Override - public void prepareForAtStatePruning() throws DataException { - // Use LatestATStates table that was already built by AtStatesTrimmer - // The AtStatesPruner class checks that this process has completed first - } - @Override public int pruneAtStates(int minHeight, int maxHeight) throws DataException { int deletedCount = 0; From ff841c28e39f1618532dae2de84f3502d0b8a90e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 25 Aug 2021 18:51:45 +0100 Subject: [PATCH 054/231] Updated tests to use the renamed method. --- src/test/java/org/qortal/test/at/AtRepositoryTests.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/org/qortal/test/at/AtRepositoryTests.java b/src/test/java/org/qortal/test/at/AtRepositoryTests.java index c7dfa423..0b302435 100644 --- a/src/test/java/org/qortal/test/at/AtRepositoryTests.java +++ b/src/test/java/org/qortal/test/at/AtRepositoryTests.java @@ -75,7 +75,7 @@ public class AtRepositoryTests extends Common { Integer testHeight = maxHeight - 2; // Trim AT state data - repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().rebuildLatestAtStates(); repository.getATRepository().trimAtStates(2, maxHeight, 1000); ATStateData atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); @@ -129,7 +129,7 @@ public class AtRepositoryTests extends Common { Integer testHeight = blockchainHeight; // Trim AT state data - repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().rebuildLatestAtStates(); // COMMIT to check latest AT states persist / TEMPORARY table interaction repository.saveChanges(); @@ -280,7 +280,7 @@ public class AtRepositoryTests extends Common { Integer testHeight = maxHeight - 2; // Trim AT state data - repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().rebuildLatestAtStates(); repository.getATRepository().trimAtStates(2, maxHeight, 1000); List atStates = repository.getATRepository().getBlockATStatesAtHeight(testHeight); From cd9d9b31ef62d886a044d29e4b615fe19264eb3d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 25 Aug 2021 18:53:25 +0100 Subject: [PATCH 055/231] Prune ATStatesData as well as the ATStates when switching to pruning mode. --- .../hsqldb/HSQLDBDatabasePruning.java | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index 6dc50647..ba170bf6 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -2,6 +2,7 @@ package org.qortal.repository.hsqldb; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.controller.Controller; import org.qortal.data.block.BlockData; import org.qortal.repository.DataException; import org.qortal.repository.RepositoryManager; @@ -146,6 +147,70 @@ public class HSQLDBDatabasePruning { repository.executeCheckedUpdate("CHECKPOINT"); + // Now prune/trim the ATStatesData, as this currently goes back over a month + return HSQLDBDatabasePruning.pruneATStateData(); + } + } + + /* + * Bulk prune ATStatesData to catch up with the now pruned ATStates table + * This uses the existing AT States trimming code but with a much higher end block + */ + private static boolean pruneATStateData() throws SQLException, DataException { + try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { + + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + if (latestBlock == null) { + LOGGER.info("Unable to determine blockchain height, necessary for bulk ATStatesData pruning"); + return false; + } + final int blockchainHeight = latestBlock.getHeight(); + final int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + // ATStateData is already trimmed - so carry on from where we left off in the past + int pruneStartHeight = repository.getATRepository().getAtTrimHeight(); + + LOGGER.info("Starting bulk prune of AT states data - this process could take a while... (approx. 3 mins on high spec)"); + + while (pruneStartHeight < upperPrunableHeight) { + // Prune all AT state data up until our latest minus pruneBlockLimit + + if (Controller.isStopping()) { + return false; + } + + // Override batch size in the settings because this is a one-off process + final int batchSize = 1000; + final int rowLimitPerBatch = 50000; + int upperBatchHeight = pruneStartHeight + batchSize; + int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); + + LOGGER.trace(String.format("Pruning AT states data between %d and %d...", pruneStartHeight, upperPruneHeight)); + + int numATStatesPruned = repository.getATRepository().trimAtStates(pruneStartHeight, upperPruneHeight, rowLimitPerBatch); + repository.saveChanges(); + + if (numATStatesPruned > 0) { + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.trace(() -> String.format("Pruned %d AT states data rows between blocks %d and %d", + numATStatesPruned, finalPruneStartHeight, upperPruneHeight)); + } else { + // Can we move onto next batch? + if (upperPrunableHeight > upperBatchHeight) { + pruneStartHeight = upperBatchHeight; + repository.getATRepository().setAtTrimHeight(pruneStartHeight); + // No need to rebuild the latest AT states as we aren't currently synchronizing + repository.saveChanges(); + + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.debug(() -> String.format("Bumping AT states trim height to %d", finalPruneStartHeight)); + } + else { + // We've finished pruning + break; + } + } + } + return true; } } @@ -169,7 +234,7 @@ public class HSQLDBDatabasePruning { final int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); int pruneStartHeight = 0; - LOGGER.info("Starting bulk prune of blocks - this process could take a while... (approx. 10 mins on high spec)"); + LOGGER.info("Starting bulk prune of blocks - this process could take a while... (approx. 5 mins on high spec)"); while (pruneStartHeight < upperPrunableHeight) { // Prune all blocks up until our latest minus pruneBlockLimit From 9056cb70261303a3a4992a8f8190f26e75fa9bf8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 25 Aug 2021 18:54:10 +0100 Subject: [PATCH 056/231] Increased atStatesPruneBatchSize from 10 to 25. --- src/main/java/org/qortal/settings/Settings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 89a63ad1..84eeb3a2 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -120,7 +120,7 @@ public class Settings { private long atStatesPruneInterval = 3219L; // milliseconds /** Block height range to scan for prunable AT states.
* This has a significant effect on execution time. */ - private int atStatesPruneBatchSize = 10; // blocks + private int atStatesPruneBatchSize = 25; // blocks /** How often to attempt block pruning (ms). */ private long blockPruneInterval = 3219L; // milliseconds From 2479f2d65d1198049cdd59aa42856971096dc049 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 25 Aug 2021 18:57:04 +0100 Subject: [PATCH 057/231] Moved trimming and pruning classes into a single package (org.qortal.controller.repository) --- src/main/java/org/qortal/controller/Controller.java | 4 +--- .../controller/{pruning => repository}/AtStatesPruner.java | 2 +- .../qortal/controller/{ => repository}/AtStatesTrimmer.java | 4 ++-- .../controller/{pruning => repository}/BlockPruner.java | 2 +- .../{ => repository}/OnlineAccountsSignaturesTrimmer.java | 3 ++- .../controller/{pruning => repository}/PruneManager.java | 4 +--- 6 files changed, 8 insertions(+), 11 deletions(-) rename src/main/java/org/qortal/controller/{pruning => repository}/AtStatesPruner.java (98%) rename src/main/java/org/qortal/controller/{ => repository}/AtStatesTrimmer.java (97%) rename src/main/java/org/qortal/controller/{pruning => repository}/BlockPruner.java (98%) rename src/main/java/org/qortal/controller/{ => repository}/OnlineAccountsSignaturesTrimmer.java (97%) rename src/main/java/org/qortal/controller/{pruning => repository}/PruneManager.java (95%) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index d8f706fd..f9d48c70 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -24,7 +24,6 @@ import java.util.Properties; import java.util.Random; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; @@ -46,7 +45,7 @@ import org.qortal.block.Block; import org.qortal.block.BlockChain; import org.qortal.block.BlockChain.BlockTimingByHeight; import org.qortal.controller.Synchronizer.SynchronizationResult; -import org.qortal.controller.pruning.PruneManager; +import org.qortal.controller.repository.PruneManager; import org.qortal.controller.tradebot.TradeBot; import org.qortal.crypto.Crypto; import org.qortal.data.account.MintingAccountData; @@ -96,7 +95,6 @@ import org.qortal.transaction.Transaction.TransactionType; import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.utils.Base58; import org.qortal.utils.ByteArray; -import org.qortal.utils.DaemonThreadFactory; import org.qortal.utils.NTP; import org.qortal.utils.Triple; diff --git a/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java similarity index 98% rename from src/main/java/org/qortal/controller/pruning/AtStatesPruner.java rename to src/main/java/org/qortal/controller/repository/AtStatesPruner.java index 66325e88..30d7f136 100644 --- a/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java @@ -1,4 +1,4 @@ -package org.qortal.controller.pruning; +package org.qortal.controller.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; diff --git a/src/main/java/org/qortal/controller/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java similarity index 97% rename from src/main/java/org/qortal/controller/AtStatesTrimmer.java rename to src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java index 4b08e5ca..ed02ee47 100644 --- a/src/main/java/org/qortal/controller/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java @@ -1,8 +1,8 @@ -package org.qortal.controller; +package org.qortal.controller.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.qortal.controller.pruning.PruneManager; +import org.qortal.controller.Controller; import org.qortal.data.block.BlockData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; diff --git a/src/main/java/org/qortal/controller/pruning/BlockPruner.java b/src/main/java/org/qortal/controller/repository/BlockPruner.java similarity index 98% rename from src/main/java/org/qortal/controller/pruning/BlockPruner.java rename to src/main/java/org/qortal/controller/repository/BlockPruner.java index 8ae25224..6d3180a8 100644 --- a/src/main/java/org/qortal/controller/pruning/BlockPruner.java +++ b/src/main/java/org/qortal/controller/repository/BlockPruner.java @@ -1,4 +1,4 @@ -package org.qortal.controller.pruning; +package org.qortal.controller.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; diff --git a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java b/src/main/java/org/qortal/controller/repository/OnlineAccountsSignaturesTrimmer.java similarity index 97% rename from src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java rename to src/main/java/org/qortal/controller/repository/OnlineAccountsSignaturesTrimmer.java index b32a2b06..c7f248d5 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java +++ b/src/main/java/org/qortal/controller/repository/OnlineAccountsSignaturesTrimmer.java @@ -1,8 +1,9 @@ -package org.qortal.controller; +package org.qortal.controller.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.block.BlockChain; +import org.qortal.controller.Controller; import org.qortal.data.block.BlockData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; diff --git a/src/main/java/org/qortal/controller/pruning/PruneManager.java b/src/main/java/org/qortal/controller/repository/PruneManager.java similarity index 95% rename from src/main/java/org/qortal/controller/pruning/PruneManager.java rename to src/main/java/org/qortal/controller/repository/PruneManager.java index b733833b..5f92c75d 100644 --- a/src/main/java/org/qortal/controller/pruning/PruneManager.java +++ b/src/main/java/org/qortal/controller/repository/PruneManager.java @@ -1,9 +1,7 @@ -package org.qortal.controller.pruning; +package org.qortal.controller.repository; -import org.qortal.controller.AtStatesTrimmer; import org.qortal.controller.Controller; -import org.qortal.controller.OnlineAccountsSignaturesTrimmer; import org.qortal.data.block.BlockData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; From 9973fe4326aa09fcf9d6222ab67eb7c4e73d003f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Aug 2021 11:00:49 +0100 Subject: [PATCH 058/231] Synchronized LatestATStates, to make rebuildLatestAtStates() thread safe. --- .../repository/hsqldb/HSQLDBATRepository.java | 160 ++++++++++-------- .../repository/hsqldb/HSQLDBRepository.java | 1 + 2 files changed, 90 insertions(+), 71 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 1921661c..522fafb7 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -604,28 +604,34 @@ public class HSQLDBATRepository implements ATRepository { @Override public void rebuildLatestAtStates() throws DataException { - // Rebuild cache of latest AT states that we can't trim - String deleteSql = "DELETE FROM LatestATStates"; - try { - this.repository.executeCheckedUpdate(deleteSql); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to delete temporary latest AT states cache from repository", e); - } + // latestATStatesLock is to prevent concurrent updates on LatestATStates + // that could result in one process using a partial or empty dataset + // because it was in the process of being rebuilt by another thread + synchronized (this.repository.latestATStatesLock) { - String insertSql = "INSERT INTO LatestATStates (" - + "SELECT AT_address, height FROM ATs " - + "CROSS JOIN LATERAL(" - + "SELECT height FROM ATStates " - + "WHERE ATStates.AT_address = ATs.AT_address " - + "ORDER BY AT_address DESC, height DESC LIMIT 1" - + ") " - + ")"; - try { - this.repository.executeCheckedUpdate(insertSql); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to populate temporary latest AT states cache in repository", e); + // Rebuild cache of latest AT states that we can't trim + String deleteSql = "DELETE FROM LatestATStates"; + try { + this.repository.executeCheckedUpdate(deleteSql); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to delete temporary latest AT states cache from repository", e); + } + + String insertSql = "INSERT INTO LatestATStates (" + + "SELECT AT_address, height FROM ATs " + + "CROSS JOIN LATERAL(" + + "SELECT height FROM ATStates " + + "WHERE ATStates.AT_address = ATs.AT_address " + + "ORDER BY AT_address DESC, height DESC LIMIT 1" + + ") " + + ")"; + try { + this.repository.executeCheckedUpdate(insertSql); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to populate temporary latest AT states cache in repository", e); + } } } @@ -666,22 +672,28 @@ public class HSQLDBATRepository implements ATRepository { if (minHeight >= maxHeight) return 0; - // We're often called so no need to trim all states in one go. - // Limit updates to reduce CPU and memory load. - String sql = "DELETE FROM ATStatesData " - + "WHERE height BETWEEN ? AND ? " - + "AND NOT EXISTS(" + // latestATStatesLock is to prevent concurrent updates on LatestATStates + // that could result in one process using a partial or empty dataset + // because it was in the process of being rebuilt by another thread + synchronized (this.repository.latestATStatesLock) { + + // We're often called so no need to trim all states in one go. + // Limit updates to reduce CPU and memory load. + String sql = "DELETE FROM ATStatesData " + + "WHERE height BETWEEN ? AND ? " + + "AND NOT EXISTS(" + "SELECT TRUE FROM LatestATStates " + "WHERE LatestATStates.AT_address = ATStatesData.AT_address " + "AND LatestATStates.height = ATStatesData.height" - + ") " - + "LIMIT ?"; + + ") " + + "LIMIT ?"; - try { - return this.repository.executeCheckedUpdate(sql, minHeight, maxHeight, limit); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to trim AT states in repository", e); + try { + return this.repository.executeCheckedUpdate(sql, minHeight, maxHeight, limit); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to trim AT states in repository", e); + } } } @@ -719,57 +731,63 @@ public class HSQLDBATRepository implements ATRepository { @Override public int pruneAtStates(int minHeight, int maxHeight) throws DataException { - int deletedCount = 0; + // latestATStatesLock is to prevent concurrent updates on LatestATStates + // that could result in one process using a partial or empty dataset + // because it was in the process of being rebuilt by another thread + synchronized (this.repository.latestATStatesLock) { - for (int height=minHeight; height atAddresses = new ArrayList<>(); - String updateSql = "SELECT AT_address FROM LatestATStates WHERE height = ?"; - try (ResultSet resultSet = this.repository.checkedExecute(updateSql, height)) { - if (resultSet != null) { - do { - String atAddress = resultSet.getString(1); - atAddresses.add(atAddress); - - } while (resultSet.next()); - } - } catch (SQLException e) { - throw new DataException("Unable to fetch flagged accounts from repository", e); - } - - List atStates = this.getBlockATStatesAtHeight(height); - for (ATStateData atState : atStates) { - //LOGGER.info("Found atState {} at height {}", atState.getATAddress(), atState.getHeight()); + for (int height = minHeight; height < maxHeight; height++) { // Give up if we're stopping if (Controller.isStopping()) { return deletedCount; } - if (atAddresses.contains(atState.getATAddress())) { - // We don't want to delete this AT state because it is still active - LOGGER.info("Skipping atState {} at height {}", atState.getATAddress(), atState.getHeight()); - continue; + // Get latest AT states for this height + List atAddresses = new ArrayList<>(); + String updateSql = "SELECT AT_address FROM LatestATStates WHERE height = ?"; + try (ResultSet resultSet = this.repository.checkedExecute(updateSql, height)) { + if (resultSet != null) { + do { + String atAddress = resultSet.getString(1); + atAddresses.add(atAddress); + + } while (resultSet.next()); + } + } catch (SQLException e) { + throw new DataException("Unable to fetch flagged accounts from repository", e); } - // Safe to delete everything else for this height - try { - this.repository.delete("ATStates", "AT_address = ? AND height = ?", - atState.getATAddress(), atState.getHeight()); - deletedCount++; - } catch (SQLException e) { - throw new DataException("Unable to delete AT state data from repository", e); + List atStates = this.getBlockATStatesAtHeight(height); + for (ATStateData atState : atStates) { + //LOGGER.info("Found atState {} at height {}", atState.getATAddress(), atState.getHeight()); + + // Give up if we're stopping + if (Controller.isStopping()) { + return deletedCount; + } + + if (atAddresses.contains(atState.getATAddress())) { + // We don't want to delete this AT state because it is still active + LOGGER.info("Skipping atState {} at height {}", atState.getATAddress(), atState.getHeight()); + continue; + } + + // Safe to delete everything else for this height + try { + this.repository.delete("ATStates", "AT_address = ? AND height = ?", + atState.getATAddress(), atState.getHeight()); + deletedCount++; + } catch (SQLException e) { + throw new DataException("Unable to delete AT state data from repository", e); + } } } - } - return deletedCount; + return deletedCount; + } } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 4d8e5043..3a947cd6 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -69,6 +69,7 @@ public class HSQLDBRepository implements Repository { protected final Map preparedStatementCache = new HashMap<>(); // We want the same object corresponding to the actual DB protected final Object trimHeightsLock = RepositoryManager.getRepositoryFactory(); + protected final Object latestATStatesLock = RepositoryManager.getRepositoryFactory(); private final ATRepository atRepository = new HSQLDBATRepository(this); private final AccountRepository accountRepository = new HSQLDBAccountRepository(this); From 25c17d37040120ce9ae4535c68cfd22c8c2b0478 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Sep 2021 10:04:04 +0100 Subject: [PATCH 059/231] atStatesMaxLifetime reduced from 14 days to 24 hours --- src/main/java/org/qortal/settings/Settings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 84eeb3a2..6ac7342c 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -94,7 +94,7 @@ public class Settings { private int blockCacheSize = 10; /** How long to keep old, full, AT state data (ms). */ - private long atStatesMaxLifetime = 2 * 7 * 24 * 60 * 60 * 1000L; // milliseconds + private long atStatesMaxLifetime = 24 * 60 * 60 * 1000L; // milliseconds /** How often to attempt AT state trimming (ms). */ private long atStatesTrimInterval = 5678L; // milliseconds /** Block height range to scan for trimmable AT states.
From 02988989ad7f8e21f7a1524867a770c743ec3a67 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Sep 2021 10:11:02 +0100 Subject: [PATCH 060/231] Reduced online account signatures min and max lifetimes onlineAccountSignaturesMinLifetime reduced from 720 hours to 12 hours onlineAccountSignaturesMaxLifetime reduced from 888 hours to 24 hours These were using up too much space in the database and so it makes sense to trim them more aggressively (assuming testing goes well). We will now stop validating online account signatures after 12 hours, which should be more than enough confirmations, and we will discard them after 24 hours. Note: this will create some complexity once some of the network is running this code. It could cause out-of-sync nodes on old versions to start treating blocks as invalid from updated peers. It's likely not worth the complexity of a hard fork though, given that almost all nodes will be synced to the chain tip and will therefore be unaffected. And even with a hard fork, we'd still face this problem on out of date nodes. --- src/main/resources/blockchain.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index d0ac9ffb..acba90da 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -11,8 +11,8 @@ "minAccountLevelToRewardShare": 5, "maxRewardSharesPerMintingAccount": 6, "founderEffectiveMintingLevel": 10, - "onlineAccountSignaturesMinLifetime": 2592000000, - "onlineAccountSignaturesMaxLifetime": 3196800000, + "onlineAccountSignaturesMinLifetime": 43200000, + "onlineAccountSignaturesMaxLifetime": 86400000, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, From 703cdfe17458f13749285e23fd6d29b0fe81c212 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 4 Sep 2021 19:40:51 +0100 Subject: [PATCH 061/231] Added block archive mode This takes all trimmed blocks (which should now be all but the last 1450 or so) and moves them into flat files. Each file contains the serialized bytes of as many blocks that can fit within the file size target of 100MiB. As a result, the HSQLDB size drops to less than 1GB, making it much faster and easier to maintain. It also significantly reduces the total size of each full node, because the data is stored in a highly optimized way. HSQLDB then works similarly to the way it does in pruning mode - it holds all transactions, the latest state of every AT, as well as the full AT states data and hashes for the past 1450 blocks. Each archive file contains headers and indexes in order to quickly locate blocks. When a peer requests a block that is within the archive, the serialized bytes are sent directly without the need to go via a BlockData object. Now that there are no slow queries or data serialization processes needed, it should greatly speed up the block serving. The /block API endpoints have been modified in such a way that they will also check and retrieve blocks from the archive when needed. A lightweight "BlockArchive" table is needed in HSQLDB to map block heights to signatures minters and timestamps. It made more sense to keep SQL support for these basic attributes of each block. These are located in a separate table from the full blocks, in order to create a clear distinction between HSQLDB blocks and archived blocks, and also to speed up query times in the Blocks table, which is the one we are using 99% of the time. There is currently a restriction on the /admin/orphan API endpoint to prevent orphaning beyond the threshold of the block archive. --- .../qortal/api/resource/AdminResource.java | 20 ++ .../qortal/api/resource/BlocksResource.java | 268 ++++++++++++++--- .../org/qortal/controller/Controller.java | 39 ++- .../controller/repository/AtStatesPruner.java | 22 +- .../repository/AtStatesTrimmer.java | 2 +- .../controller/repository/BlockArchiver.java | 105 +++++++ .../controller/repository/BlockPruner.java | 27 +- .../controller/repository/PruneManager.java | 77 +++-- .../qortal/data/block/BlockArchiveData.java | 47 +++ .../network/message/CachedBlockMessage.java | 2 +- .../org/qortal/repository/ATRepository.java | 5 +- .../qortal/repository/BlockArchiveReader.java | 251 ++++++++++++++++ .../repository/BlockArchiveRepository.java | 118 ++++++++ .../qortal/repository/BlockArchiveWriter.java | 193 ++++++++++++ .../qortal/repository/BlockRepository.java | 5 - .../org/qortal/repository/Repository.java | 2 + .../qortal/repository/RepositoryManager.java | 21 +- .../repository/hsqldb/HSQLDBATRepository.java | 13 +- .../hsqldb/HSQLDBBlockArchiveRepository.java | 277 ++++++++++++++++++ .../hsqldb/HSQLDBBlockRepository.java | 81 +---- .../hsqldb/HSQLDBDatabaseArchiving.java | 87 ++++++ .../hsqldb/HSQLDBDatabasePruning.java | 51 +++- .../hsqldb/HSQLDBDatabaseUpdates.java | 19 ++ .../repository/hsqldb/HSQLDBRepository.java | 23 +- .../java/org/qortal/settings/Settings.java | 22 ++ 25 files changed, 1592 insertions(+), 185 deletions(-) create mode 100644 src/main/java/org/qortal/controller/repository/BlockArchiver.java create mode 100644 src/main/java/org/qortal/data/block/BlockArchiveData.java create mode 100644 src/main/java/org/qortal/repository/BlockArchiveReader.java create mode 100644 src/main/java/org/qortal/repository/BlockArchiveRepository.java create mode 100644 src/main/java/org/qortal/repository/BlockArchiveWriter.java create mode 100644 src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java create mode 100644 src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 88dd0065..3e666fe4 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -35,6 +35,7 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.core.LoggerContext; import org.apache.logging.log4j.core.appender.RollingFileAppender; import org.qortal.account.Account; @@ -67,6 +68,8 @@ import com.google.common.collect.Lists; @Tag(name = "Admin") public class AdminResource { + private static final Logger LOGGER = LogManager.getLogger(AdminResource.class); + private static final int MAX_LOG_LINES = 500; @Context @@ -459,6 +462,23 @@ public class AdminResource { if (targetHeight <= 0 || targetHeight > Controller.getInstance().getChainHeight()) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_HEIGHT); + // Make sure we're not orphaning as far back as the archived blocks + // FUTURE: we could support this by first importing earlier blocks from the archive + if (Settings.getInstance().isPruningEnabled() || + Settings.getInstance().isArchiveEnabled()) { + + try (final Repository repository = RepositoryManager.getRepository()) { + // Find the first unarchived block + int oldestBlock = repository.getBlockArchiveRepository().getBlockArchiveHeight(); + // Add some extra blocks just in case we're currently archiving/pruning + oldestBlock += 100; + if (targetHeight <= oldestBlock) { + LOGGER.info("Unable to orphan beyond block {} because it is archived", oldestBlock); + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_HEIGHT); + } + } + } + if (BlockChain.orphan(targetHeight)) return "true"; else diff --git a/src/main/java/org/qortal/api/resource/BlocksResource.java b/src/main/java/org/qortal/api/resource/BlocksResource.java index 8920ecc1..6dc13c8a 100644 --- a/src/main/java/org/qortal/api/resource/BlocksResource.java +++ b/src/main/java/org/qortal/api/resource/BlocksResource.java @@ -15,6 +15,8 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.math.RoundingMode; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; import java.util.List; import javax.servlet.http.HttpServletRequest; @@ -33,11 +35,13 @@ import org.qortal.api.ApiExceptionFactory; import org.qortal.api.model.BlockMintingInfo; import org.qortal.api.model.BlockSignerSummary; import org.qortal.block.Block; +import org.qortal.controller.Controller; import org.qortal.crypto.Crypto; import org.qortal.data.account.AccountData; import org.qortal.data.block.BlockData; import org.qortal.data.block.BlockSummaryData; import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.BlockArchiveReader; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; @@ -81,11 +85,19 @@ public class BlocksResource { } try (final Repository repository = RepositoryManager.getRepository()) { + // Check the database first BlockData blockData = repository.getBlockRepository().fromSignature(signature); - if (blockData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + if (blockData != null) { + return blockData; + } - return blockData; + // Not found, so try the block archive + blockData = repository.getBlockArchiveRepository().fromSignature(signature); + if (blockData != null) { + return blockData; + } + + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -116,16 +128,24 @@ public class BlocksResource { } try (final Repository repository = RepositoryManager.getRepository()) { + + // Check the database first BlockData blockData = repository.getBlockRepository().fromSignature(signature); - if (blockData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + if (blockData != null) { + Block block = new Block(repository, blockData); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + bytes.write(Ints.toByteArray(block.getBlockData().getHeight())); + bytes.write(BlockTransformer.toBytes(block)); + return Base58.encode(bytes.toByteArray()); + } - Block block = new Block(repository, blockData); - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - bytes.write(Ints.toByteArray(block.getBlockData().getHeight())); - bytes.write(BlockTransformer.toBytes(block)); - return Base58.encode(bytes.toByteArray()); + // Not found, so try the block archive + byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, repository); + if (bytes != null) { + return Base58.encode(bytes); + } + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); } catch (TransformationException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA, e); } catch (DataException | IOException e) { @@ -170,8 +190,12 @@ public class BlocksResource { } try (final Repository repository = RepositoryManager.getRepository()) { - if (repository.getBlockRepository().getHeightFromSignature(signature) == 0) + // Check if the block exists in either the database or archive + if (repository.getBlockRepository().getHeightFromSignature(signature) == 0 && + repository.getBlockArchiveRepository().getHeightFromSignature(signature) == 0) { + // Not found in either the database or archive throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + } return repository.getBlockRepository().getTransactionsFromSignature(signature, limit, offset, reverse); } catch (DataException e) { @@ -200,7 +224,19 @@ public class BlocksResource { }) public BlockData getFirstBlock() { try (final Repository repository = RepositoryManager.getRepository()) { - return repository.getBlockRepository().fromHeight(1); + // Check the database first + BlockData blockData = repository.getBlockRepository().fromHeight(1); + if (blockData != null) { + return blockData; + } + + // Try the archive + blockData = repository.getBlockArchiveRepository().fromHeight(1); + if (blockData != null) { + return blockData; + } + + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -262,17 +298,28 @@ public class BlocksResource { } try (final Repository repository = RepositoryManager.getRepository()) { + BlockData childBlockData = null; + + // Check if block exists in database BlockData blockData = repository.getBlockRepository().fromSignature(signature); + if (blockData != null) { + return repository.getBlockRepository().fromReference(signature); + } - // Check block exists - if (blockData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); - - BlockData childBlockData = repository.getBlockRepository().fromReference(signature); + // Not found, so try the archive + // This also checks that the parent block exists + // It will return null if either the parent or child don't exit + childBlockData = repository.getBlockArchiveRepository().fromReference(signature); // Check child block exists - if (childBlockData == null) + if (childBlockData == null) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + } + + // Check child block's reference matches the supplied signature + if (!Arrays.equals(childBlockData.getReference(), signature)) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + } return childBlockData; } catch (DataException e) { @@ -338,13 +385,20 @@ public class BlocksResource { } try (final Repository repository = RepositoryManager.getRepository()) { + // Firstly check the database BlockData blockData = repository.getBlockRepository().fromSignature(signature); + if (blockData != null) { + return blockData.getHeight(); + } - // Check block exists - if (blockData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + // Not found, so try the archive + blockData = repository.getBlockArchiveRepository().fromSignature(signature); + if (blockData != null) { + return blockData.getHeight(); + } + + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); - return blockData.getHeight(); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -371,11 +425,20 @@ public class BlocksResource { }) public BlockData getByHeight(@PathParam("height") int height) { try (final Repository repository = RepositoryManager.getRepository()) { + // Firstly check the database BlockData blockData = repository.getBlockRepository().fromHeight(height); - if (blockData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + if (blockData != null) { + return blockData; + } + + // Not found, so try the archive + blockData = repository.getBlockArchiveRepository().fromHeight(height); + if (blockData != null) { + return blockData; + } + + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); - return blockData; } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -402,12 +465,31 @@ public class BlocksResource { }) public BlockMintingInfo getBlockMintingInfoByHeight(@PathParam("height") int height) { try (final Repository repository = RepositoryManager.getRepository()) { + // Try the database BlockData blockData = repository.getBlockRepository().fromHeight(height); - if (blockData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + if (blockData == null) { + + // Not found, so try the archive + blockData = repository.getBlockArchiveRepository().fromHeight(height); + if (blockData == null) { + + // Still not found + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + } + } Block block = new Block(repository, blockData); BlockData parentBlockData = repository.getBlockRepository().fromSignature(blockData.getReference()); + if (parentBlockData == null) { + // Parent block not found - try the archive + parentBlockData = repository.getBlockArchiveRepository().fromSignature(blockData.getReference()); + if (parentBlockData == null) { + + // Still not found + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + } + } + int minterLevel = Account.getRewardShareEffectiveMintingLevel(repository, blockData.getMinterPublicKey()); if (minterLevel == 0) // This may be unavailable when requesting a trimmed block @@ -454,13 +536,26 @@ public class BlocksResource { }) public BlockData getByTimestamp(@PathParam("timestamp") long timestamp) { try (final Repository repository = RepositoryManager.getRepository()) { - int height = repository.getBlockRepository().getHeightFromTimestamp(timestamp); - if (height == 0) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + BlockData blockData = null; - BlockData blockData = repository.getBlockRepository().fromHeight(height); - if (blockData == null) + // Try the Blocks table + int height = repository.getBlockRepository().getHeightFromTimestamp(timestamp); + if (height > 0) { + // Found match in Blocks table + return repository.getBlockRepository().fromHeight(height); + } + + // Not found in Blocks table, so try the archive + height = repository.getBlockArchiveRepository().getHeightFromTimestamp(timestamp); + if (height > 0) { + // Found match in archive + blockData = repository.getBlockArchiveRepository().fromHeight(height); + } + + // Ensure block exists + if (blockData == null) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + } return blockData; } catch (DataException e) { @@ -497,9 +592,14 @@ public class BlocksResource { for (/* count already set */; count > 0; --count, ++height) { BlockData blockData = repository.getBlockRepository().fromHeight(height); - if (blockData == null) - // Run out of blocks! - break; + if (blockData == null) { + // Not found - try the archive + blockData = repository.getBlockArchiveRepository().fromHeight(height); + if (blockData == null) { + // Run out of blocks! + break; + } + } blocks.add(blockData); } @@ -544,7 +644,29 @@ public class BlocksResource { if (accountData == null || accountData.getPublicKey() == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.PUBLIC_KEY_NOT_FOUND); - return repository.getBlockRepository().getBlockSummariesBySigner(accountData.getPublicKey(), limit, offset, reverse); + + List summaries = repository.getBlockRepository() + .getBlockSummariesBySigner(accountData.getPublicKey(), limit, offset, reverse); + + // Add any from the archive + List archivedSummaries = repository.getBlockArchiveRepository() + .getBlockSummariesBySigner(accountData.getPublicKey(), limit, offset, reverse); + if (archivedSummaries != null && !archivedSummaries.isEmpty()) { + summaries.addAll(archivedSummaries); + } + else { + summaries = archivedSummaries; + } + + // Sort the results (because they may have been obtained from two places) + if (reverse != null && reverse) { + summaries.sort((s1, s2) -> Integer.valueOf(s2.getHeight()).compareTo(Integer.valueOf(s1.getHeight()))); + } + else { + summaries.sort(Comparator.comparing(s -> Integer.valueOf(s.getHeight()))); + } + + return summaries; } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -580,7 +702,8 @@ public class BlocksResource { if (!Crypto.isValidAddress(address)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - return repository.getBlockRepository().getBlockSigners(addresses, limit, offset, reverse); + // This method pulls data from both Blocks and BlockArchive, so no need to query serparately + return repository.getBlockArchiveRepository().getBlockSigners(addresses, limit, offset, reverse); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -620,7 +743,76 @@ public class BlocksResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); try (final Repository repository = RepositoryManager.getRepository()) { - return repository.getBlockRepository().getBlockSummaries(startHeight, endHeight, count); + + /* + * start end count result + * 10 40 null blocks 10 to 39 (excludes end block, ignore count) + * + * null null null blocks 1 to 50 (assume count=50, maybe start=1) + * 30 null null blocks 30 to 79 (assume count=50) + * 30 null 10 blocks 30 to 39 + * + * null null 50 last 50 blocks? so if max(blocks.height) is 200, then blocks 151 to 200 + * null 200 null blocks 150 to 199 (excludes end block, assume count=50) + * null 200 10 blocks 190 to 199 (excludes end block) + */ + + List blockSummaries = new ArrayList<>(); + + // Use the latest X blocks if only a count is specified + if (startHeight == null && endHeight == null && count != null) { + BlockData chainTip = Controller.getInstance().getChainTip(); + startHeight = chainTip.getHeight() - count; + endHeight = chainTip.getHeight(); + } + + // ... otherwise default the start height to 1 + if (startHeight == null && endHeight == null) { + startHeight = 1; + } + + // Default the count to 50 + if (count == null) { + count = 50; + } + + // If both a start and end height exist, ignore the count + if (startHeight != null && endHeight != null) { + if (startHeight > 0 && endHeight > 0) { + count = Integer.MAX_VALUE; + } + } + + // Derive start height from end height if missing + if (startHeight == null || startHeight == 0) { + if (endHeight != null && endHeight > 0) { + if (count != null) { + startHeight = endHeight - count; + } + } + } + + for (/* count already set */; count > 0; --count, ++startHeight) { + if (endHeight != null && startHeight >= endHeight) { + break; + } + BlockData blockData = repository.getBlockRepository().fromHeight(startHeight); + if (blockData == null) { + // Not found - try the archive + blockData = repository.getBlockArchiveRepository().fromHeight(startHeight); + if (blockData == null) { + // Run out of blocks! + break; + } + } + + if (blockData != null) { + BlockSummaryData blockSummaryData = new BlockSummaryData(blockData); + blockSummaries.add(blockSummaryData); + } + } + + return blockSummaries; } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index f9d48c70..f03dd504 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -83,20 +83,14 @@ import org.qortal.network.message.OnlineAccountsMessage; import org.qortal.network.message.SignaturesMessage; import org.qortal.network.message.TransactionMessage; import org.qortal.network.message.TransactionSignaturesMessage; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryFactory; -import org.qortal.repository.RepositoryManager; +import org.qortal.repository.*; import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory; import org.qortal.settings.Settings; import org.qortal.transaction.ArbitraryTransaction; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.TransactionType; import org.qortal.transaction.Transaction.ValidationResult; -import org.qortal.utils.Base58; -import org.qortal.utils.ByteArray; -import org.qortal.utils.NTP; -import org.qortal.utils.Triple; +import org.qortal.utils.*; import com.google.common.primitives.Longs; @@ -414,6 +408,7 @@ public class Controller extends Thread { try { RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(getRepositoryUrl()); RepositoryManager.setRepositoryFactory(repositoryFactory); + RepositoryManager.archive(); RepositoryManager.prune(); } catch (DataException e) { // If exception has no cause then repository is in use by some other process. @@ -1286,6 +1281,34 @@ public class Controller extends Thread { } } + // If we have no block data, we should check the archive in case it's there + if (blockData == null) { + if (Settings.getInstance().isArchiveEnabled()) { + byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, repository); + if (bytes != null) { + CachedBlockMessage blockMessage = new CachedBlockMessage(bytes); + blockMessage.setId(message.getId()); + + // This call also causes the other needed data to be pulled in from repository + if (!peer.sendMessage(blockMessage)) { + peer.disconnect("failed to send block"); + // Don't fall-through to caching because failure to send might be from failure to build message + return; + } + + // If request is for a recent block, cache it + if (getChainHeight() - blockData.getHeight() <= blockCacheSize) { + this.stats.getBlockMessageStats.cacheFills.incrementAndGet(); + + this.blockMessageCache.put(new ByteArray(blockData.getSignature()), blockMessage); + } + + // Sent successfully from archive, so nothing more to do + return; + } + } + } + if (blockData == null) { // We don't have this block this.stats.getBlockMessageStats.unknownBlocks.getAndIncrement(); diff --git a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java index 30d7f136..1493f478 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java @@ -18,15 +18,24 @@ public class AtStatesPruner implements Runnable { public void run() { Thread.currentThread().setName("AT States pruner"); + boolean archiveMode = false; if (!Settings.getInstance().isPruningEnabled()) { - return; + // Pruning isn't enabled, but we might want to prune for the purposes of archiving + if (!Settings.getInstance().isArchiveEnabled()) { + // No pruning or archiving, so we must not prune anything + return; + } + else { + // We're allowed to prune blocks that have already been archived + archiveMode = true; + } } try (final Repository repository = RepositoryManager.getRepository()) { int pruneStartHeight = repository.getATRepository().getAtPruneHeight(); + repository.discardChanges(); repository.getATRepository().rebuildLatestAtStates(); - repository.saveChanges(); while (!Controller.isStopping()) { repository.discardChanges(); @@ -43,7 +52,14 @@ public class AtStatesPruner implements Runnable { // Prune AT states for all blocks up until our latest minus pruneBlockLimit final int ourLatestHeight = chainTip.getHeight(); - final int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); + int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); + + // In archive mode we are only allowed to trim blocks that have already been archived + if (archiveMode) { + upperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1; + + // TODO: validate that the actual archived data exists before pruning it? + } int upperBatchHeight = pruneStartHeight + Settings.getInstance().getAtStatesPruneBatchSize(); int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); diff --git a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java index ed02ee47..98a1a889 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java @@ -21,8 +21,8 @@ public class AtStatesTrimmer implements Runnable { try (final Repository repository = RepositoryManager.getRepository()) { int trimStartHeight = repository.getATRepository().getAtTrimHeight(); + repository.discardChanges(); repository.getATRepository().rebuildLatestAtStates(); - repository.saveChanges(); while (!Controller.isStopping()) { repository.discardChanges(); diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiver.java b/src/main/java/org/qortal/controller/repository/BlockArchiver.java new file mode 100644 index 00000000..f7bafe7d --- /dev/null +++ b/src/main/java/org/qortal/controller/repository/BlockArchiver.java @@ -0,0 +1,105 @@ +package org.qortal.controller.repository; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.controller.Controller; +import org.qortal.data.block.BlockData; +import org.qortal.repository.*; +import org.qortal.settings.Settings; +import org.qortal.transform.TransformationException; +import org.qortal.utils.NTP; + +import java.io.IOException; + +public class BlockArchiver implements Runnable { + + private static final Logger LOGGER = LogManager.getLogger(BlockArchiver.class); + + private static final long INITIAL_SLEEP_PERIOD = 0L; // TODO: 5 * 60 * 1000L + 1234L; // ms + + public void run() { + Thread.currentThread().setName("Block archiver"); + + if (!Settings.getInstance().isArchiveEnabled()) { + return; + } + + try (final Repository repository = RepositoryManager.getRepository()) { + int startHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); + + // Don't even start building until initial rush has ended + Thread.sleep(INITIAL_SLEEP_PERIOD); + + LOGGER.info("Starting block archiver..."); + + while (!Controller.isStopping()) { + repository.discardChanges(); + + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository, true); + + Thread.sleep(Settings.getInstance().getArchiveInterval()); + + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null || NTP.getTime() == null) { + continue; + } + + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Controller.getInstance().isSynchronizing()) { + continue; + } + + // Don't attempt to archive if we're not synced yet + final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); + if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) { + continue; + } + + + // Build cache of blocks + try { + BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, repository); + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + switch (result) { + case OK: + // Increment block archive height + startHeight += writer.getWrittenCount(); + repository.getBlockArchiveRepository().setBlockArchiveHeight(startHeight); + repository.saveChanges(); + break; + + case STOPPING: + return; + + case BLOCK_LIMIT_REACHED: + // We've reached the limit of the blocks we can archive + // Sleep for a while to allow more to become available + case NOT_ENOUGH_BLOCKS: + // We didn't reach our file size target, so that must mean that we don't have enough blocks + // yet or something went wrong. Sleep for a while and then try again. + Thread.sleep(60 * 60 * 1000L); // 1 hour + break; + + case BLOCK_NOT_FOUND: + // We tried to archive a block that didn't exist. This is a major failure and likely means + // that a bootstrap or re-sync is needed. Try again every minute until then. + LOGGER.info("Error: block not found when building archive. If this error persists, " + + "a bootstrap or re-sync may be needed."); + Thread.sleep( 60 * 1000L); // 1 minute + break; + } + + } catch (IOException | TransformationException e) { + LOGGER.info("Caught exception when creating block cache", e); + } + + } + } catch (DataException e) { + LOGGER.info("Caught exception when creating block cache", e); + } catch (InterruptedException e) { + // Do nothing + } + + } + +} diff --git a/src/main/java/org/qortal/controller/repository/BlockPruner.java b/src/main/java/org/qortal/controller/repository/BlockPruner.java index 6d3180a8..f8fd2195 100644 --- a/src/main/java/org/qortal/controller/repository/BlockPruner.java +++ b/src/main/java/org/qortal/controller/repository/BlockPruner.java @@ -18,8 +18,17 @@ public class BlockPruner implements Runnable { public void run() { Thread.currentThread().setName("Block pruner"); + boolean archiveMode = false; if (!Settings.getInstance().isPruningEnabled()) { - return; + // Pruning isn't enabled, but we might want to prune for the purposes of archiving + if (!Settings.getInstance().isArchiveEnabled()) { + // No pruning or archiving, so we must not prune anything + return; + } + else { + // We're allowed to prune blocks that have already been archived + archiveMode = true; + } } try (final Repository repository = RepositoryManager.getRepository()) { @@ -35,12 +44,24 @@ public class BlockPruner implements Runnable { continue; // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages - if (Controller.getInstance().isSynchronizing()) + if (Controller.getInstance().isSynchronizing()) { continue; + } + + // Don't attempt to prune if we're not synced yet + final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); + if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) { + continue; + } // Prune all blocks up until our latest minus pruneBlockLimit final int ourLatestHeight = chainTip.getHeight(); - final int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); + int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); + + // In archive mode we are only allowed to trim blocks that have already been archived + if (archiveMode) { + upperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1; + } int upperBatchHeight = pruneStartHeight + Settings.getInstance().getBlockPruneBatchSize(); int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); diff --git a/src/main/java/org/qortal/controller/repository/PruneManager.java b/src/main/java/org/qortal/controller/repository/PruneManager.java index 5f92c75d..dcb21181 100644 --- a/src/main/java/org/qortal/controller/repository/PruneManager.java +++ b/src/main/java/org/qortal/controller/repository/PruneManager.java @@ -35,29 +35,70 @@ public class PruneManager { public void start() { this.executorService = Executors.newCachedThreadPool(new DaemonThreadFactory()); - // Don't allow both the pruner and the trimmer to run at the same time. - // In pruning mode, we are already deleting far more than we would when trimming. - // In non-pruning mode, we still need to trim to keep the non-essential data - // out of the database. There isn't a case where both are needed at once. - // If we ever do need to enable both at once, be very careful with the AT state - // trimming, since both currently rely on having exclusive access to the - // prepareForAtStateTrimming() method. For both trimming and pruning to take place - // at once, we would need to synchronize this method in a way that both can't - // call it at the same time, as otherwise active ATs would be pruned/trimmed when - // they should have been kept. - - if (Settings.getInstance().isPruningEnabled()) { - // Pruning enabled - start the pruning processes - this.executorService.execute(new AtStatesPruner()); - this.executorService.execute(new BlockPruner()); + if (Settings.getInstance().isPruningEnabled() && + !Settings.getInstance().isArchiveEnabled()) { + // Top-only-sync + this.startTopOnlySyncMode(); + } + else if (Settings.getInstance().isArchiveEnabled()) { + // Full node with block archive + this.startFullNodeWithBlockArchive(); } else { - // Pruning disabled - use trimming instead - this.executorService.execute(new AtStatesTrimmer()); - this.executorService.execute(new OnlineAccountsSignaturesTrimmer()); + // Full node with full SQL support + this.startFullSQLNode(); } } + /** + * Top-only-sync + * In this mode, we delete (prune) all blocks except + * a small number of recent ones. There is no need for + * trimming or archiving, because all relevant blocks + * are deleted. + */ + private void startTopOnlySyncMode() { + this.startPruning(); + } + + /** + * Full node with block archive + * In this mode we archive trimmed blocks, and then + * prune archived blocks to keep the database small + */ + private void startFullNodeWithBlockArchive() { + this.startTrimming(); + this.startArchiving(); + this.startPruning(); + } + + /** + * Full node with full SQL support + * In this mode we trim the database but don't prune + * or archive any data, because we want to maintain + * full SQL support of old blocks. This mode will not + * be actively maintained but can be used by those who + * need to perform SQL analysis on older blocks. + */ + private void startFullSQLNode() { + this.startTrimming(); + } + + + private void startPruning() { + this.executorService.execute(new AtStatesPruner()); + this.executorService.execute(new BlockPruner()); + } + + private void startTrimming() { + this.executorService.execute(new AtStatesTrimmer()); + this.executorService.execute(new OnlineAccountsSignaturesTrimmer()); + } + + private void startArchiving() { + this.executorService.execute(new BlockArchiver()); + } + public void stop() { this.executorService.shutdownNow(); diff --git a/src/main/java/org/qortal/data/block/BlockArchiveData.java b/src/main/java/org/qortal/data/block/BlockArchiveData.java new file mode 100644 index 00000000..c9db4032 --- /dev/null +++ b/src/main/java/org/qortal/data/block/BlockArchiveData.java @@ -0,0 +1,47 @@ +package org.qortal.data.block; + +import org.qortal.block.Block; + +public class BlockArchiveData { + + // Properties + private byte[] signature; + private Integer height; + private Long timestamp; + private byte[] minterPublicKey; + + // Constructors + + public BlockArchiveData(byte[] signature, Integer height, long timestamp, byte[] minterPublicKey) { + this.signature = signature; + this.height = height; + this.timestamp = timestamp; + this.minterPublicKey = minterPublicKey; + } + + public BlockArchiveData(BlockData blockData) { + this.signature = blockData.getSignature(); + this.height = blockData.getHeight(); + this.timestamp = blockData.getTimestamp(); + this.minterPublicKey = blockData.getMinterPublicKey(); + } + + // Getters/setters + + public byte[] getSignature() { + return this.signature; + } + + public Integer getHeight() { + return this.height; + } + + public Long getTimestamp() { + return this.timestamp; + } + + public byte[] getMinterPublicKey() { + return this.minterPublicKey; + } + +} diff --git a/src/main/java/org/qortal/network/message/CachedBlockMessage.java b/src/main/java/org/qortal/network/message/CachedBlockMessage.java index 7a175810..e5029ab0 100644 --- a/src/main/java/org/qortal/network/message/CachedBlockMessage.java +++ b/src/main/java/org/qortal/network/message/CachedBlockMessage.java @@ -23,7 +23,7 @@ public class CachedBlockMessage extends Message { this.block = block; } - private CachedBlockMessage(byte[] cachedBytes) { + public CachedBlockMessage(byte[] cachedBytes) { super(MessageType.BLOCK); this.block = null; diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 74fb19ab..9316875d 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -113,7 +113,10 @@ public interface ATRepository { public List getBlockATStatesAtHeight(int height) throws DataException; - /** Rebuild the latest AT states cache, necessary for AT state trimming/pruning. */ + /** Rebuild the latest AT states cache, necessary for AT state trimming/pruning. + *

+ * NOTE: performs implicit repository.saveChanges(). + */ public void rebuildLatestAtStates() throws DataException; diff --git a/src/main/java/org/qortal/repository/BlockArchiveReader.java b/src/main/java/org/qortal/repository/BlockArchiveReader.java new file mode 100644 index 00000000..1b68a7c5 --- /dev/null +++ b/src/main/java/org/qortal/repository/BlockArchiveReader.java @@ -0,0 +1,251 @@ +package org.qortal.repository; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockArchiveData; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.settings.Settings; +import org.qortal.transform.TransformationException; +import org.qortal.transform.block.BlockTransformer; +import org.qortal.utils.Triple; + +import static org.qortal.transform.Transformer.INT_LENGTH; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; + +public class BlockArchiveReader { + + private static BlockArchiveReader instance; + private Map> fileListCache = Collections.synchronizedMap(new HashMap<>()); + + private static final Logger LOGGER = LogManager.getLogger(BlockArchiveReader.class); + + public BlockArchiveReader() { + + } + + public static synchronized BlockArchiveReader getInstance() { + if (instance == null) { + instance = new BlockArchiveReader(); + } + + return instance; + } + + private void fetchFileList() { + Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toAbsolutePath(); + File archiveDirFile = archivePath.toFile(); + String[] files = archiveDirFile.list(); + Map> map = new HashMap<>(); + + for (String file : files) { + Path filePath = Paths.get(file); + String filename = filePath.getFileName().toString(); + + // Parse the filename + if (filename == null || !filename.contains("-") || !filename.contains(".")) { + // Not a usable file + continue; + } + // Remove the extension and split into two parts + String[] parts = filename.substring(0, filename.lastIndexOf('.')).split("-"); + Integer startHeight = Integer.parseInt(parts[0]); + Integer endHeight = Integer.parseInt(parts[1]); + Integer range = endHeight - startHeight; + map.put(filename, new Triple(startHeight, endHeight, range)); + } + this.fileListCache = map; + } + + public Triple, List> fetchBlockAtHeight(int height) { + if (this.fileListCache.isEmpty()) { + this.fetchFileList(); + } + + byte[] serializedBytes = this.fetchSerializedBlockBytesForHeight(height); + if (serializedBytes == null) { + return null; + } + + ByteBuffer byteBuffer = ByteBuffer.wrap(serializedBytes); + Triple, List> blockInfo = null; + try { + blockInfo = BlockTransformer.fromByteBuffer(byteBuffer); + if (blockInfo != null && blockInfo.getA() != null) { + // Block height is stored outside of the main serialized bytes, so it + // won't be set automatically. + blockInfo.getA().setHeight(height); + } + } catch (TransformationException e) { + return null; + } + return blockInfo; + } + + public Triple, List> fetchBlockWithSignature( + byte[] signature, Repository repository) { + + if (this.fileListCache.isEmpty()) { + this.fetchFileList(); + } + + Integer height = this.fetchHeightForSignature(signature, repository); + if (height != null) { + return this.fetchBlockAtHeight(height); + } + return null; + } + + public Integer fetchHeightForSignature(byte[] signature, Repository repository) { + // Lookup the height for the requested signature + try { + BlockArchiveData archivedBlock = repository.getBlockArchiveRepository().getBlockArchiveDataForSignature(signature); + if (archivedBlock.getHeight() == null) { + return null; + } + return archivedBlock.getHeight(); + + } catch (DataException e) { + return null; + } + } + + public int fetchHeightForTimestamp(long timestamp, Repository repository) { + // Lookup the height for the requested signature + try { + return repository.getBlockArchiveRepository().getHeightFromTimestamp(timestamp); + + } catch (DataException e) { + return 0; + } + } + + private String getFilenameForHeight(int height) { + Iterator it = this.fileListCache.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry pair = (Map.Entry)it.next(); + if (pair == null && pair.getKey() == null && pair.getValue() == null) { + continue; + } + Triple heightInfo = (Triple) pair.getValue(); + Integer startHeight = heightInfo.getA(); + Integer endHeight = heightInfo.getB(); + + if (height >= startHeight && height <= endHeight) { + // Found the correct file + String filename = (String) pair.getKey(); + return filename; + } + } + + return null; + } + + public byte[] fetchSerializedBlockBytesForSignature(byte[] signature, Repository repository) { + + if (this.fileListCache.isEmpty()) { + this.fetchFileList(); + } + + Integer height = this.fetchHeightForSignature(signature, repository); + if (height != null) { + return this.fetchSerializedBlockBytesForHeight(height); + } + return null; + } + + public byte[] fetchSerializedBlockBytesForHeight(int height) { + String filename = this.getFilenameForHeight(height); + if (filename == null) { + // We don't have this block in the archive + // Invalidate the file list cache in case it is out of date + this.invalidateFileListCache(); + return null; + } + + Path filePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive", filename).toAbsolutePath(); + RandomAccessFile file = null; + try { + file = new RandomAccessFile(filePath.toString(), "r"); + // Get info about this file (the "fixed length header") + final int version = file.readInt(); // Do not remove or comment out, as it is moving the file pointer + final int startHeight = file.readInt(); // Do not remove or comment out, as it is moving the file pointer + final int endHeight = file.readInt(); // Do not remove or comment out, as it is moving the file pointer + file.readInt(); // Block count (unused) // Do not remove or comment out, as it is moving the file pointer + final int variableHeaderLength = file.readInt(); // Do not remove or comment out, as it is moving the file pointer + final int fixedHeaderLength = (int)file.getFilePointer(); + // End of fixed length header + + // Make sure the version is one we recognize + if (version != 1) { + LOGGER.info("Error: unknown version in file {}: {}", filename, version); + return null; + } + + // Verify that the block is within the reported range + if (height < startHeight || height > endHeight) { + LOGGER.info("Error: requested height {} but the range of file {} is {}-{}", + height, filename, startHeight, endHeight); + return null; + } + + // Seek to the location of the block index in the variable length header + final int locationOfBlockIndexInVariableHeaderSegment = (height - startHeight) * INT_LENGTH; + file.seek(fixedHeaderLength + locationOfBlockIndexInVariableHeaderSegment); + + // Read the value to obtain the index of this block in the data segment + int locationOfBlockInDataSegment = file.readInt(); + + // Now seek to the block data itself + int dataSegmentStartIndex = fixedHeaderLength + variableHeaderLength + INT_LENGTH; // Confirmed correct + file.seek(dataSegmentStartIndex + locationOfBlockInDataSegment); + + // Read the block metadata + int blockHeight = file.readInt(); // Do not remove or comment out, as it is moving the file pointer + int blockLength = file.readInt(); // Do not remove or comment out, as it is moving the file pointer + + // Ensure the block height matches the one requested + if (blockHeight != height) { + LOGGER.info("Error: height {} does not match requested: {}", blockHeight, height); + return null; + } + + // Now retrieve the block's serialized bytes + byte[] blockBytes = new byte[blockLength]; + file.read(blockBytes); + + return blockBytes; + + } catch (FileNotFoundException e) { + LOGGER.info("File {} not found: {}", filename, e.getMessage()); + return null; + } catch (IOException e) { + LOGGER.info("Unable to read block {} from archive: {}", height, e.getMessage()); + return null; + } + finally { + // Close the file + if (file != null) { + try { + file.close(); + } catch (IOException e) { + // Failed to close, but no need to handle this + } + } + } + } + + public void invalidateFileListCache() { + this.fileListCache.clear(); + } + +} diff --git a/src/main/java/org/qortal/repository/BlockArchiveRepository.java b/src/main/java/org/qortal/repository/BlockArchiveRepository.java new file mode 100644 index 00000000..c702a7ef --- /dev/null +++ b/src/main/java/org/qortal/repository/BlockArchiveRepository.java @@ -0,0 +1,118 @@ +package org.qortal.repository; + +import org.qortal.api.model.BlockSignerSummary; +import org.qortal.data.block.BlockArchiveData; +import org.qortal.data.block.BlockData; +import org.qortal.data.block.BlockSummaryData; + +import java.util.List; + +public interface BlockArchiveRepository { + + /** + * Returns BlockData from archive using block signature. + * + * @param signature + * @return block data, or null if not found in archive. + * @throws DataException + */ + public BlockData fromSignature(byte[] signature) throws DataException; + + /** + * Return height of block in archive using block's signature. + * + * @param signature + * @return height, or 0 if not found in blockchain. + * @throws DataException + */ + public int getHeightFromSignature(byte[] signature) throws DataException; + + /** + * Returns BlockData from archive using block height. + * + * @param height + * @return block data, or null if not found in blockchain. + * @throws DataException + */ + public BlockData fromHeight(int height) throws DataException; + + /** + * Returns BlockData from archive using block reference. + * Currently relies on a child block being the one block + * higher than its parent. This limitation can be removed + * by storing the reference in the BlockArchive table, but + * this has been avoided to reduce space. + * + * @param reference + * @return block data, or null if either parent or child + * not found in the archive. + * @throws DataException + */ + public BlockData fromReference(byte[] reference) throws DataException; + + /** + * Return height of block with timestamp just before passed timestamp. + * + * @param timestamp + * @return height, or 0 if not found in blockchain. + * @throws DataException + */ + public int getHeightFromTimestamp(long timestamp) throws DataException; + + /** + * Returns block summaries for blocks signed by passed public key, or reward-share with minter with passed public key. + */ + public List getBlockSummariesBySigner(byte[] signerPublicKey, Integer limit, Integer offset, Boolean reverse) throws DataException; + + /** + * Returns summaries of block signers, optionally limited to passed addresses. + * This combines both the BlockArchive and the Blocks data into a single result set. + */ + public List getBlockSigners(List addresses, Integer limit, Integer offset, Boolean reverse) throws DataException; + + + /** Returns height of first unarchived block. */ + public int getBlockArchiveHeight() throws DataException; + + /** Sets new height for block archiving. + *

+ * NOTE: performs implicit repository.saveChanges(). + */ + public void setBlockArchiveHeight(int archiveHeight) throws DataException; + + + /** + * Returns the block archive data for a given signature, from the block archive. + *

+ * This method will return null if no block archive has been built for the + * requested signature. In those cases, the height (and other data) can be + * looked up using the Blocks table. This allows a block to be located in + * the archive when we only know its signature. + *

+ * + * @param signature + * @throws DataException + */ + public BlockArchiveData getBlockArchiveDataForSignature(byte[] signature) throws DataException; + + /** + * Saves a block archive entry into the repository. + *

+ * This can be used to find the height of a block by its signature, without + * having access to the block data itself. + *

+ * + * @param blockArchiveData + * @throws DataException + */ + public void save(BlockArchiveData blockArchiveData) throws DataException; + + /** + * Deletes a block archive entry from the repository. + * + * @param blockArchiveData + * @throws DataException + */ + public void delete(BlockArchiveData blockArchiveData) throws DataException; + +} diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java new file mode 100644 index 00000000..4aeb1a32 --- /dev/null +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -0,0 +1,193 @@ +package org.qortal.repository; + +import com.google.common.primitives.Ints; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.block.Block; +import org.qortal.controller.Controller; +import org.qortal.data.block.BlockArchiveData; +import org.qortal.data.block.BlockData; +import org.qortal.settings.Settings; +import org.qortal.transform.TransformationException; +import org.qortal.transform.block.BlockTransformer; + +import java.io.ByteArrayOutputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class BlockArchiveWriter { + + public enum BlockArchiveWriteResult { + OK, + STOPPING, + NOT_ENOUGH_BLOCKS, + BLOCK_LIMIT_REACHED, + BLOCK_NOT_FOUND + } + + private static final Logger LOGGER = LogManager.getLogger(BlockArchiveWriter.class); + + private int startHeight; + private final int endHeight; + private final Repository repository; + + private int writtenCount; + + public BlockArchiveWriter(int startHeight, int endHeight, Repository repository) { + this.startHeight = startHeight; + this.endHeight = endHeight; + this.repository = repository; + } + + public static int getMaxArchiveHeight(Repository repository, boolean useMaximumDuplicatedLimit) throws DataException { + // We must only archive trimmed blocks, or the archive will grow far too large + final int accountSignaturesTrimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); + final int atTrimStartHeight = repository.getATRepository().getAtTrimHeight(); + final int trimStartHeight = Math.min(accountSignaturesTrimStartHeight, atTrimStartHeight); + + // In some cases we want to restrict the upper height of the archiver to save space + if (useMaximumDuplicatedLimit) { + // To save on disk space, it's best to not allow the archiver to get too far ahead of the pruner + // This reduces the amount of data that is held twice during the transition + final int blockPruneStartHeight = repository.getBlockRepository().getBlockPruneHeight(); + final int atPruneStartHeight = repository.getATRepository().getAtPruneHeight(); + final int pruneStartHeight = Math.min(blockPruneStartHeight, atPruneStartHeight); + final int maximumDuplicatedBlocks = Settings.getInstance().getMaxDuplicatedBlocksWhenArchiving(); + + // To summarize the above: + // - We must never archive anything greater than or equal to trimStartHeight + // - We should avoid archiving anything maximumDuplicatedBlocks higher than pruneStartHeight + return Math.min(trimStartHeight, pruneStartHeight + maximumDuplicatedBlocks); + } + else { + // We don't want to apply the maximum duplicated limit + return trimStartHeight; + } + } + + public static boolean isArchiverUpToDate(Repository repository, boolean useMaximumDuplicatedLimit) throws DataException { + final int maxArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository, useMaximumDuplicatedLimit); + final int actualArchiveHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); + final float progress = (float)actualArchiveHeight / (float) maxArchiveHeight; + LOGGER.info(String.format("maxArchiveHeight: %d, actualArchiveHeight: %d, progress: %f", + maxArchiveHeight, actualArchiveHeight, progress)); + + // If archiver is within 90% of the maximum, treat it as up to date + // We need several percent as an allowance because the archiver will only + // save files when they reach the target size + return (progress >= 0.90); + } + + public BlockArchiveWriteResult write() throws DataException, IOException, TransformationException, InterruptedException { + // Create the archive folder if it doesn't exist + // This is a subfolder of the db directory, to make bootstrapping easier + Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toAbsolutePath(); + try { + Files.createDirectories(archivePath); + } catch (IOException e) { + LOGGER.info("Unable to create archive folder"); + throw new DataException("Unable to create archive folder"); + } + + // Determine start height of blocks to fetch + if (startHeight <= 2) { + // Skip genesis block, as it's not designed to be transmitted, and we can build that from blockchain.json + // TODO: include genesis block if we can + startHeight = 2; + } + + // Header bytes will store the block indexes + ByteArrayOutputStream headerBytes = new ByteArrayOutputStream(); + // Bytes will store the actual block data + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + LOGGER.info(String.format("Fetching blocks from height %d...", startHeight)); + int i = 0; + long fileSizeTarget = 100 * 1024 * 1024; // 100MiB + while (headerBytes.size() + bytes.size() < fileSizeTarget) { + if (Controller.isStopping()) { + return BlockArchiveWriteResult.STOPPING; + } + if (Controller.getInstance().isSynchronizing()) { + continue; + } + + int currentHeight = startHeight + i; + if (currentHeight >= endHeight) { + return BlockArchiveWriteResult.BLOCK_LIMIT_REACHED; + } + + //LOGGER.info("Fetching block {}...", currentHeight); + + BlockData blockData = repository.getBlockRepository().fromHeight(currentHeight); + if (blockData == null) { + return BlockArchiveWriteResult.BLOCK_NOT_FOUND; + } + + // Write the signature and height into the BlockArchive table + BlockArchiveData blockArchiveData = new BlockArchiveData(blockData); + repository.getBlockArchiveRepository().save(blockArchiveData); + repository.saveChanges(); + + // Write the block data to some byte buffers + Block block = new Block(repository, blockData); + int blockIndex = bytes.size(); + // Write block index to header + headerBytes.write(Ints.toByteArray(blockIndex)); + // Write block height + bytes.write(Ints.toByteArray(block.getBlockData().getHeight())); + byte[] blockBytes = BlockTransformer.toBytes(block); + // Write block length + bytes.write(Ints.toByteArray(blockBytes.length)); + // Write block bytes + bytes.write(blockBytes); + i++; + + } + int totalLength = headerBytes.size() + bytes.size(); + LOGGER.info(String.format("Total length of %d blocks is %d bytes", i, totalLength)); + + // Validate file size, in case something went wrong + if (totalLength < fileSizeTarget) { + return BlockArchiveWriteResult.NOT_ENOUGH_BLOCKS; + } + + // We have enough blocks to create a new file + int endHeight = startHeight + i - 1; + int version = 1; + String filePath = String.format("%s/%d-%d.dat", archivePath.toString(), startHeight, endHeight); + FileOutputStream fileOutputStream = new FileOutputStream(filePath); + // Write version number + fileOutputStream.write(Ints.toByteArray(version)); + // Write start height + fileOutputStream.write(Ints.toByteArray(startHeight)); + // Write end height + fileOutputStream.write(Ints.toByteArray(endHeight)); + // Write total count + fileOutputStream.write(Ints.toByteArray(i)); + // Write dynamic header (block indexes) segment length + fileOutputStream.write(Ints.toByteArray(headerBytes.size())); + // Write dynamic header (block indexes) data + headerBytes.writeTo(fileOutputStream); + // Write data segment (block data) length + fileOutputStream.write(Ints.toByteArray(bytes.size())); + // Write data + bytes.writeTo(fileOutputStream); + // Close the file + fileOutputStream.close(); + + // Invalidate cache so that the rest of the app picks up the new file + BlockArchiveReader.getInstance().invalidateFileListCache(); + + this.writtenCount = i; + return BlockArchiveWriteResult.OK; + } + + public int getWrittenCount() { + return this.writtenCount; + } + +} diff --git a/src/main/java/org/qortal/repository/BlockRepository.java b/src/main/java/org/qortal/repository/BlockRepository.java index 5ca61e66..76891c36 100644 --- a/src/main/java/org/qortal/repository/BlockRepository.java +++ b/src/main/java/org/qortal/repository/BlockRepository.java @@ -137,11 +137,6 @@ public interface BlockRepository { */ public List getBlockSummaries(int firstBlockHeight, int lastBlockHeight) throws DataException; - /** - * Returns block summaries for the passed height range, for API use. - */ - public List getBlockSummaries(Integer startHeight, Integer endHeight, Integer count) throws DataException; - /** Returns height of first trimmable online accounts signatures. */ public int getOnlineAccountsSignaturesTrimHeight() throws DataException; diff --git a/src/main/java/org/qortal/repository/Repository.java b/src/main/java/org/qortal/repository/Repository.java index 656e6e1e..fab48a14 100644 --- a/src/main/java/org/qortal/repository/Repository.java +++ b/src/main/java/org/qortal/repository/Repository.java @@ -12,6 +12,8 @@ public interface Repository extends AutoCloseable { public BlockRepository getBlockRepository(); + public BlockArchiveRepository getBlockArchiveRepository(); + public ChatRepository getChatRepository(); public CrossChainRepository getCrossChainRepository(); diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 5e9c71c2..f7557750 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -2,6 +2,7 @@ package org.qortal.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.repository.hsqldb.HSQLDBDatabaseArchiving; import org.qortal.repository.hsqldb.HSQLDBDatabasePruning; import org.qortal.settings.Settings; @@ -57,9 +58,23 @@ public abstract class RepositoryManager { } } - public static void prune() { + public static boolean archive() { + // Bulk archive the database the first time we use archive mode + if (Settings.getInstance().isArchiveEnabled()) { + try { + return HSQLDBDatabaseArchiving.buildBlockArchive(); + + } catch (DataException e) { + LOGGER.info("Unable to bulk prune AT states. The database may have been left in an inconsistent state."); + } + } + return false; + } + + public static boolean prune() { // Bulk prune the database the first time we use pruning mode - if (Settings.getInstance().isPruningEnabled()) { + if (Settings.getInstance().isPruningEnabled() || + Settings.getInstance().isArchiveEnabled()) { try { boolean prunedATStates = HSQLDBDatabasePruning.pruneATStates(); boolean prunedBlocks = HSQLDBDatabasePruning.pruneBlocks(); @@ -67,12 +82,14 @@ public abstract class RepositoryManager { // Perform repository maintenance to shrink the db size down if (prunedATStates && prunedBlocks) { HSQLDBDatabasePruning.performMaintenance(); + return true; } } catch (SQLException | DataException e) { LOGGER.info("Unable to bulk prune AT states. The database may have been left in an inconsistent state."); } } + return false; } public static void setRequestedCheckpoint(Boolean quick) { diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 522fafb7..e0baa136 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -608,6 +608,7 @@ public class HSQLDBATRepository implements ATRepository { // that could result in one process using a partial or empty dataset // because it was in the process of being rebuilt by another thread synchronized (this.repository.latestATStatesLock) { + LOGGER.trace("Rebuilding latest AT states..."); // Rebuild cache of latest AT states that we can't trim String deleteSql = "DELETE FROM LatestATStates"; @@ -632,6 +633,8 @@ public class HSQLDBATRepository implements ATRepository { repository.examineException(e); throw new DataException("Unable to populate temporary latest AT states cache in repository", e); } + this.repository.saveChanges(); + LOGGER.trace("Rebuilt latest AT states"); } } @@ -661,7 +664,7 @@ public class HSQLDBATRepository implements ATRepository { this.repository.executeCheckedUpdate(updateSql, trimHeight); this.repository.saveChanges(); } catch (SQLException e) { - repository.examineException(e); + this.repository.examineException(e); throw new DataException("Unable to set AT state trim height in repository", e); } } @@ -689,7 +692,10 @@ public class HSQLDBATRepository implements ATRepository { + "LIMIT ?"; try { - return this.repository.executeCheckedUpdate(sql, minHeight, maxHeight, limit); + int modifiedRows = this.repository.executeCheckedUpdate(sql, minHeight, maxHeight, limit); + this.repository.saveChanges(); + return modifiedRows; + } catch (SQLException e) { repository.examineException(e); throw new DataException("Unable to trim AT states in repository", e); @@ -757,7 +763,7 @@ public class HSQLDBATRepository implements ATRepository { } while (resultSet.next()); } } catch (SQLException e) { - throw new DataException("Unable to fetch flagged accounts from repository", e); + throw new DataException("Unable to fetch latest AT states from repository", e); } List atStates = this.getBlockATStatesAtHeight(height); @@ -785,6 +791,7 @@ public class HSQLDBATRepository implements ATRepository { } } } + this.repository.saveChanges(); return deletedCount; } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java new file mode 100644 index 00000000..c491f862 --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java @@ -0,0 +1,277 @@ +package org.qortal.repository.hsqldb; + +import org.qortal.api.ApiError; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.model.BlockSignerSummary; +import org.qortal.data.block.BlockArchiveData; +import org.qortal.data.block.BlockData; +import org.qortal.data.block.BlockSummaryData; +import org.qortal.repository.BlockArchiveReader; +import org.qortal.repository.BlockArchiveRepository; +import org.qortal.repository.DataException; +import org.qortal.utils.Triple; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class HSQLDBBlockArchiveRepository implements BlockArchiveRepository { + + protected HSQLDBRepository repository; + + public HSQLDBBlockArchiveRepository(HSQLDBRepository repository) { + this.repository = repository; + } + + + @Override + public BlockData fromSignature(byte[] signature) throws DataException { + Triple blockInfo = BlockArchiveReader.getInstance().fetchBlockWithSignature(signature, this.repository); + if (blockInfo != null) { + return (BlockData) blockInfo.getA(); + } + return null; + } + + @Override + public int getHeightFromSignature(byte[] signature) throws DataException { + Integer height = BlockArchiveReader.getInstance().fetchHeightForSignature(signature, this.repository); + if (height == null || height == 0) { + return 0; + } + return height; + } + + @Override + public BlockData fromHeight(int height) throws DataException { + Triple blockInfo = BlockArchiveReader.getInstance().fetchBlockAtHeight(height); + if (blockInfo != null) { + return (BlockData) blockInfo.getA(); + } + return null; + } + + @Override + public BlockData fromReference(byte[] reference) throws DataException { + BlockData referenceBlock = this.repository.getBlockArchiveRepository().fromSignature(reference); + if (referenceBlock != null) { + int height = referenceBlock.getHeight(); + if (height > 0) { + // Request the block at height + 1 + Triple blockInfo = BlockArchiveReader.getInstance().fetchBlockAtHeight(height + 1); + if (blockInfo != null) { + return (BlockData) blockInfo.getA(); + } + } + } + return null; + } + + @Override + public int getHeightFromTimestamp(long timestamp) throws DataException { + String sql = "SELECT height FROM BlockArchive WHERE minted_when <= ? ORDER BY minted_when DESC, height DESC LIMIT 1"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, timestamp)) { + if (resultSet == null) { + return 0; + } + return resultSet.getInt(1); + + } catch (SQLException e) { + throw new DataException("Error fetching height from BlockArchive repository", e); + } + } + + @Override + public List getBlockSummariesBySigner(byte[] signerPublicKey, Integer limit, Integer offset, Boolean reverse) throws DataException { + StringBuilder sql = new StringBuilder(512); + sql.append("SELECT signature, height, BlockArchive.minter FROM "); + + // List of minter account's public key and reward-share public keys with minter's public key + sql.append("(SELECT * FROM (VALUES (CAST(? AS QortalPublicKey))) UNION (SELECT reward_share_public_key FROM RewardShares WHERE minter_public_key = ?)) AS PublicKeys (public_key) "); + + // Match BlockArchive blocks signed with public key from above list + sql.append("JOIN BlockArchive ON BlockArchive.minter = public_key "); + + sql.append("ORDER BY BlockArchive.height "); + if (reverse != null && reverse) + sql.append("DESC "); + + HSQLDBRepository.limitOffsetSql(sql, limit, offset); + + List blockSummaries = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), signerPublicKey, signerPublicKey)) { + if (resultSet == null) + return blockSummaries; + + do { + byte[] signature = resultSet.getBytes(1); + int height = resultSet.getInt(2); + byte[] blockMinterPublicKey = resultSet.getBytes(3); + + // Fetch additional info from the archive itself + int onlineAccountsCount = 0; + BlockData blockData = this.fromSignature(signature); + if (blockData != null) { + onlineAccountsCount = blockData.getOnlineAccountsCount(); + } + + BlockSummaryData blockSummary = new BlockSummaryData(height, signature, blockMinterPublicKey, onlineAccountsCount); + blockSummaries.add(blockSummary); + } while (resultSet.next()); + + return blockSummaries; + } catch (SQLException e) { + throw new DataException("Unable to fetch minter's block summaries from repository", e); + } + } + + @Override + public List getBlockSigners(List addresses, Integer limit, Integer offset, Boolean reverse) throws DataException { + String subquerySql = "SELECT minter, COUNT(signature) FROM (" + + "(SELECT minter, signature FROM Blocks) UNION ALL (SELECT minter, signature FROM BlockArchive)" + + ") GROUP BY minter"; + + StringBuilder sql = new StringBuilder(1024); + sql.append("SELECT DISTINCT block_minter, n_blocks, minter_public_key, minter, recipient FROM ("); + sql.append(subquerySql); + sql.append(") AS Minters (block_minter, n_blocks) LEFT OUTER JOIN RewardShares ON reward_share_public_key = block_minter "); + + if (addresses != null && !addresses.isEmpty()) { + sql.append(" LEFT OUTER JOIN Accounts AS BlockMinterAccounts ON BlockMinterAccounts.public_key = block_minter "); + sql.append(" LEFT OUTER JOIN Accounts AS RewardShareMinterAccounts ON RewardShareMinterAccounts.public_key = minter_public_key "); + sql.append(" JOIN (VALUES "); + + final int addressesSize = addresses.size(); + for (int ai = 0; ai < addressesSize; ++ai) { + if (ai != 0) + sql.append(", "); + + sql.append("(?)"); + } + + sql.append(") AS FilterAccounts (account) "); + sql.append(" ON FilterAccounts.account IN (recipient, BlockMinterAccounts.account, RewardShareMinterAccounts.account) "); + } else { + addresses = Collections.emptyList(); + } + + sql.append("ORDER BY n_blocks "); + if (reverse != null && reverse) + sql.append("DESC "); + + HSQLDBRepository.limitOffsetSql(sql, limit, offset); + + List summaries = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), addresses.toArray())) { + if (resultSet == null) + return summaries; + + do { + byte[] blockMinterPublicKey = resultSet.getBytes(1); + int nBlocks = resultSet.getInt(2); + + // May not be present if no reward-share: + byte[] mintingAccountPublicKey = resultSet.getBytes(3); + String minterAccount = resultSet.getString(4); + String recipientAccount = resultSet.getString(5); + + BlockSignerSummary blockSignerSummary; + if (recipientAccount == null) + blockSignerSummary = new BlockSignerSummary(blockMinterPublicKey, nBlocks); + else + blockSignerSummary = new BlockSignerSummary(blockMinterPublicKey, nBlocks, mintingAccountPublicKey, minterAccount, recipientAccount); + + summaries.add(blockSignerSummary); + } while (resultSet.next()); + + return summaries; + } catch (SQLException e) { + throw new DataException("Unable to fetch block minters from repository", e); + } + } + + + @Override + public int getBlockArchiveHeight() throws DataException { + String sql = "SELECT block_archive_height FROM DatabaseInfo"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return 0; + + return resultSet.getInt(1); + } catch (SQLException e) { + throw new DataException("Unable to fetch block archive height from repository", e); + } + } + + @Override + public void setBlockArchiveHeight(int archiveHeight) throws DataException { + // trimHeightsLock is to prevent concurrent update on DatabaseInfo + // that could result in "transaction rollback: serialization failure" + synchronized (this.repository.trimHeightsLock) { + String updateSql = "UPDATE DatabaseInfo SET block_archive_height = ?"; + + try { + this.repository.executeCheckedUpdate(updateSql, archiveHeight); + this.repository.saveChanges(); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to set block archive height in repository", e); + } + } + } + + + @Override + public BlockArchiveData getBlockArchiveDataForSignature(byte[] signature) throws DataException { + String sql = "SELECT height, signature, minted_when, minter FROM BlockArchive WHERE signature = ? LIMIT 1"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, signature)) { + if (resultSet == null) { + return null; + } + int height = resultSet.getInt(1); + byte[] sig = resultSet.getBytes(2); + long timestamp = resultSet.getLong(3); + byte[] minterPublicKey = resultSet.getBytes(4); + return new BlockArchiveData(sig, height, timestamp, minterPublicKey); + + } catch (SQLException e) { + throw new DataException("Error fetching height from BlockArchive repository", e); + } + } + + + @Override + public void save(BlockArchiveData blockArchiveData) throws DataException { + HSQLDBSaver saveHelper = new HSQLDBSaver("BlockArchive"); + + saveHelper.bind("signature", blockArchiveData.getSignature()) + .bind("height", blockArchiveData.getHeight()) + .bind("minted_when", blockArchiveData.getTimestamp()) + .bind("minter", blockArchiveData.getMinterPublicKey()); + + try { + saveHelper.execute(this.repository); + } catch (SQLException e) { + throw new DataException("Unable to save SimpleBlockData into BlockArchive repository", e); + } + } + + @Override + public void delete(BlockArchiveData blockArchiveData) throws DataException { + try { + this.repository.delete("BlockArchive", + "block_signature = ?", blockArchiveData.getSignature()); + } catch (SQLException e) { + throw new DataException("Unable to delete SimpleBlockData from BlockArchive repository", e); + } + } + +} diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java index 2f7e4ad2..b8238085 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java @@ -10,6 +10,7 @@ import org.qortal.api.model.BlockSignerSummary; import org.qortal.data.block.BlockData; import org.qortal.data.block.BlockSummaryData; import org.qortal.data.block.BlockTransactionData; +import org.qortal.data.block.BlockArchiveData; import org.qortal.data.transaction.TransactionData; import org.qortal.repository.BlockRepository; import org.qortal.repository.DataException; @@ -382,86 +383,6 @@ public class HSQLDBBlockRepository implements BlockRepository { } } - @Override - public List getBlockSummaries(Integer startHeight, Integer endHeight, Integer count) throws DataException { - StringBuilder sql = new StringBuilder(512); - List bindParams = new ArrayList<>(); - - sql.append("SELECT signature, height, minter, online_accounts_count, minted_when, transaction_count "); - - /* - * start end count result - * 10 40 null blocks 10 to 39 (excludes end block, ignore count) - * - * null null null blocks 1 to 50 (assume count=50, maybe start=1) - * 30 null null blocks 30 to 79 (assume count=50) - * 30 null 10 blocks 30 to 39 - * - * null null 50 last 50 blocks? so if max(blocks.height) is 200, then blocks 151 to 200 - * null 200 null blocks 150 to 199 (excludes end block, assume count=50) - * null 200 10 blocks 190 to 199 (excludes end block) - */ - - if (startHeight != null && endHeight != null) { - sql.append("FROM Blocks "); - sql.append("WHERE height BETWEEN ? AND ?"); - bindParams.add(startHeight); - bindParams.add(Integer.valueOf(endHeight - 1)); - } else if (endHeight != null || (startHeight == null && count != null)) { - // we are going to return blocks from the end of the chain - if (count == null) - count = 50; - - if (endHeight == null) { - sql.append("FROM (SELECT height FROM Blocks ORDER BY height DESC LIMIT 1) AS MaxHeights (max_height) "); - sql.append("JOIN Blocks ON height BETWEEN (max_height - ? + 1) AND max_height "); - bindParams.add(count); - } else { - sql.append("FROM Blocks "); - sql.append("WHERE height BETWEEN ? AND ?"); - bindParams.add(Integer.valueOf(endHeight - count)); - bindParams.add(Integer.valueOf(endHeight - 1)); - } - - } else { - // we are going to return blocks from the start of the chain - if (startHeight == null) - startHeight = 1; - - if (count == null) - count = 50; - - sql.append("FROM Blocks "); - sql.append("WHERE height BETWEEN ? AND ?"); - bindParams.add(startHeight); - bindParams.add(Integer.valueOf(startHeight + count - 1)); - } - - List blockSummaries = new ArrayList<>(); - - try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { - if (resultSet == null) - return blockSummaries; - - do { - byte[] signature = resultSet.getBytes(1); - int height = resultSet.getInt(2); - byte[] minterPublicKey = resultSet.getBytes(3); - int onlineAccountsCount = resultSet.getInt(4); - long timestamp = resultSet.getLong(5); - int transactionCount = resultSet.getInt(6); - - BlockSummaryData blockSummary = new BlockSummaryData(height, signature, minterPublicKey, onlineAccountsCount, - timestamp, transactionCount); - blockSummaries.add(blockSummary); - } while (resultSet.next()); - - return blockSummaries; - } catch (SQLException e) { - throw new DataException("Unable to fetch height-ranged block summaries from repository", e); - } - } - @Override public int getOnlineAccountsSignaturesTrimHeight() throws DataException { String sql = "SELECT online_signatures_trim_height FROM DatabaseInfo"; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java new file mode 100644 index 00000000..930da828 --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java @@ -0,0 +1,87 @@ +package org.qortal.repository.hsqldb; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.controller.Controller; +import org.qortal.repository.BlockArchiveWriter; +import org.qortal.repository.DataException; +import org.qortal.repository.RepositoryManager; +import org.qortal.transform.TransformationException; + +import java.io.IOException; + +/** + * + * When switching to an archiving node, we need to archive most of the database contents. + * This involves copying its data into flat files. + * If we do this entirely as a background process, it is very slow and can interfere with syncing. + * However, if we take the approach of doing this in bulk, before starting up the rest of the + * processes, this makes it much faster and less invasive. + * + * From that point, the original background archiving process will run, but can be dialled right down + * so not to interfere with syncing. + * + */ + + +public class HSQLDBDatabaseArchiving { + + private static final Logger LOGGER = LogManager.getLogger(HSQLDBDatabaseArchiving.class); + + + public static boolean buildBlockArchive() throws DataException { + try (final HSQLDBRepository repository = (HSQLDBRepository)RepositoryManager.getRepository()) { + + // Only build the archive if we have never done so before + int archiveHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); + if (archiveHeight > 0) { + // Already archived + return false; + } + + LOGGER.info("Building block archive - this process could take a while... (approx. 15 mins on high spec)"); + + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository, false); + int startHeight = 0; + + while (!Controller.isStopping()) { + try { + BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, repository); + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + switch (result) { + case OK: + // Increment block archive height + startHeight += writer.getWrittenCount(); + repository.getBlockArchiveRepository().setBlockArchiveHeight(startHeight); + repository.saveChanges(); + break; + + case STOPPING: + return false; + + case BLOCK_LIMIT_REACHED: + case NOT_ENOUGH_BLOCKS: + // We've reached the limit of the blocks we can archive + // Return from the whole method + return true; + + case BLOCK_NOT_FOUND: + // We tried to archive a block that didn't exist. This is a major failure and likely means + // that a bootstrap or re-sync is needed. Return rom the method + LOGGER.info("Error: block not found when building archive. If this error persists, " + + "a bootstrap or re-sync may be needed."); + return false; + } + + } catch (IOException | TransformationException | InterruptedException e) { + LOGGER.info("Caught exception when creating block cache", e); + return false; + } + } + } + + // If we got this far then something went wrong (most likely the app is stopping) + return false; + } + +} diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index ba170bf6..969c954c 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -4,6 +4,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.controller.Controller; import org.qortal.data.block.BlockData; +import org.qortal.repository.BlockArchiveWriter; import org.qortal.repository.DataException; import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; @@ -36,6 +37,7 @@ public class HSQLDBDatabasePruning { private static final Logger LOGGER = LogManager.getLogger(HSQLDBDatabasePruning.class); + public static boolean pruneATStates() throws SQLException, DataException { try (final HSQLDBRepository repository = (HSQLDBRepository)RepositoryManager.getRepository()) { @@ -46,7 +48,18 @@ public class HSQLDBDatabasePruning { return false; } - LOGGER.info("Starting bulk prune of AT states - this process could take a while... (approx. 2 mins on high spec)"); + if (Settings.getInstance().isArchiveEnabled()) { + // Only proceed if we can see that the archiver has already finished + // This way, if the archiver failed for any reason, we can prune once it has had + // some opportunities to try again + boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository, false); + if (!upToDate) { + return false; + } + } + + LOGGER.info("Starting bulk prune of AT states - this process could take a while... " + + "(approx. 2 mins on high spec, or upwards of 30 mins in some cases)"); // Create new AT-states table to hold smaller dataset repository.executeCheckedUpdate("DROP TABLE IF EXISTS ATStatesNew"); @@ -68,11 +81,17 @@ public class HSQLDBDatabasePruning { // Calculate some constants for later use final int blockchainHeight = latestBlock.getHeight(); - final int maximumBlockToTrim = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + int maximumBlockToTrim = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + if (Settings.getInstance().isArchiveEnabled()) { + // Archive mode - don't prune anything that hasn't been archived yet + maximumBlockToTrim = Math.min(maximumBlockToTrim, repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1); + } final int startHeight = maximumBlockToTrim; final int endHeight = blockchainHeight; final int blockStep = 10000; + + // Loop through all the LatestATStates and copy them to the new table LOGGER.info("Copying AT states..."); for (int height = 0; height < endHeight; height += blockStep) { @@ -99,7 +118,7 @@ public class HSQLDBDatabasePruning { } if (height >= startHeight) { - // Now copy this AT states for each recent block it is present in + // Now copy this AT's states for each recent block they is present in for (int i = startHeight; i < endHeight; i++) { if (latestAtHeight < i) { // This AT finished before this block so there is nothing to copy @@ -159,20 +178,25 @@ public class HSQLDBDatabasePruning { private static boolean pruneATStateData() throws SQLException, DataException { try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { + if (Settings.getInstance().isArchiveEnabled()) { + // Don't prune ATStatesData in archive mode + return true; + } + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); if (latestBlock == null) { LOGGER.info("Unable to determine blockchain height, necessary for bulk ATStatesData pruning"); return false; } final int blockchainHeight = latestBlock.getHeight(); - final int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); // ATStateData is already trimmed - so carry on from where we left off in the past int pruneStartHeight = repository.getATRepository().getAtTrimHeight(); LOGGER.info("Starting bulk prune of AT states data - this process could take a while... (approx. 3 mins on high spec)"); while (pruneStartHeight < upperPrunableHeight) { - // Prune all AT state data up until our latest minus pruneBlockLimit + // Prune all AT state data up until our latest minus pruneBlockLimit (or our archive height) if (Controller.isStopping()) { return false; @@ -225,15 +249,30 @@ public class HSQLDBDatabasePruning { return false; } + if (Settings.getInstance().isArchiveEnabled()) { + // Only proceed if we can see that the archiver has already finished + // This way, if the archiver failed for any reason, we can prune once it has had + // some opportunities to try again + boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository, false); + if (!upToDate) { + return false; + } + } + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); if (latestBlock == null) { LOGGER.info("Unable to determine blockchain height, necessary for bulk block pruning"); return false; } final int blockchainHeight = latestBlock.getHeight(); - final int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); int pruneStartHeight = 0; + if (Settings.getInstance().isArchiveEnabled()) { + // Archive mode - don't prune anything that hasn't been archived yet + upperPrunableHeight = Math.min(upperPrunableHeight, repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1); + } + LOGGER.info("Starting bulk prune of blocks - this process could take a while... (approx. 5 mins on high spec)"); while (pruneStartHeight < upperPrunableHeight) { diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index d696351f..66fe9029 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -873,6 +873,25 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE DatabaseInfo ADD block_prune_height INT NOT NULL DEFAULT 0"); break; + case 36: + // Block archive support + stmt.execute("ALTER TABLE DatabaseInfo ADD block_archive_height INT NOT NULL DEFAULT 0"); + + // Block archive (lookup table to map signature to height) + // Actual data is stored in archive files outside of the database + stmt.execute("CREATE TABLE BlockArchive (signature BlockSignature, height INTEGER NOT NULL, " + + "minted_when EpochMillis NOT NULL, minter QortalPublicKey NOT NULL, " + + "PRIMARY KEY (signature))"); + // For finding blocks by height. + stmt.execute("CREATE INDEX BlockArchiveHeightIndex ON BlockArchive (height)"); + // For finding blocks by the account that minted them. + stmt.execute("CREATE INDEX BlockArchiveMinterIndex ON BlockArchive (minter)"); + // For finding blocks by timestamp or finding height of latest block immediately before timestamp, etc. + stmt.execute("CREATE INDEX BlockArchiveTimestampHeightIndex ON BlockArchive (minted_when, height)"); + // Use a separate table space as this table will be very large. + stmt.execute("SET TABLE BlockArchive NEW SPACE"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 3a947cd6..6807c100 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -31,22 +31,7 @@ import org.qortal.crypto.Crypto; import org.qortal.data.crosschain.TradeBotData; import org.qortal.globalization.Translator; import org.qortal.gui.SysTray; -import org.qortal.repository.ATRepository; -import org.qortal.repository.AccountRepository; -import org.qortal.repository.ArbitraryRepository; -import org.qortal.repository.AssetRepository; -import org.qortal.repository.BlockRepository; -import org.qortal.repository.ChatRepository; -import org.qortal.repository.CrossChainRepository; -import org.qortal.repository.DataException; -import org.qortal.repository.GroupRepository; -import org.qortal.repository.MessageRepository; -import org.qortal.repository.NameRepository; -import org.qortal.repository.NetworkRepository; -import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryManager; -import org.qortal.repository.TransactionRepository; -import org.qortal.repository.VotingRepository; +import org.qortal.repository.*; import org.qortal.repository.hsqldb.transaction.HSQLDBTransactionRepository; import org.qortal.settings.Settings; import org.qortal.utils.Base58; @@ -76,6 +61,7 @@ public class HSQLDBRepository implements Repository { private final ArbitraryRepository arbitraryRepository = new HSQLDBArbitraryRepository(this); private final AssetRepository assetRepository = new HSQLDBAssetRepository(this); private final BlockRepository blockRepository = new HSQLDBBlockRepository(this); + private final BlockArchiveRepository blockArchiveRepository = new HSQLDBBlockArchiveRepository(this); private final ChatRepository chatRepository = new HSQLDBChatRepository(this); private final CrossChainRepository crossChainRepository = new HSQLDBCrossChainRepository(this); private final GroupRepository groupRepository = new HSQLDBGroupRepository(this); @@ -143,6 +129,11 @@ public class HSQLDBRepository implements Repository { return this.blockRepository; } + @Override + public BlockArchiveRepository getBlockArchiveRepository() { + return this.blockArchiveRepository; + } + @Override public ChatRepository getChatRepository() { return this.chatRepository; diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 6ac7342c..6527d7e0 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -129,6 +129,15 @@ public class Settings { private int blockPruneBatchSize = 10000; // blocks + /** Whether we should archive old data to reduce the database size */ + private boolean archiveEnabled = true; + /** How often to attempt archiving (ms). */ + private long archiveInterval = 7171L; // milliseconds + /** The maximum number of blocks that can exist in both the + * database and the archive at the same time */ + private int maxDuplicatedBlocksWhenArchiving = 100000; + + // Peer-to-peer related private boolean isTestNet = false; /** Port number for inbound peer-to-peer connections. */ @@ -574,4 +583,17 @@ public class Settings { return this.blockPruneBatchSize; } + + public boolean isArchiveEnabled() { + return this.archiveEnabled; + } + + public long getArchiveInterval() { + return this.archiveInterval; + } + + public int getMaxDuplicatedBlocksWhenArchiving() { + return this.maxDuplicatedBlocksWhenArchiving; + } + } From 278201e87ca779c2b44107611e1aa7aae3c044d6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 5 Sep 2021 21:24:02 +0100 Subject: [PATCH 062/231] Workaround for block 535658 problem --- src/main/java/org/qortal/block/Block.java | 7 +- .../java/org/qortal/block/Block535658.java | 78 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/qortal/block/Block535658.java diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 11aab89c..91e1b2e6 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1092,9 +1092,14 @@ public class Block { // Create repository savepoint here so we can rollback to it after testing transactions repository.setSavepoint(); - if (this.blockData.getHeight() == 212937) + if (this.blockData.getHeight() == 212937) { // Apply fix for block 212937 but fix will be rolled back before we exit method Block212937.processFix(this); + } + else if (this.blockData.getHeight() == 535658) { + // Apply fix for block 535658 but fix will be rolled back before we exit method + Block535658.processFix(this); + } for (Transaction transaction : this.getTransactions()) { TransactionData transactionData = transaction.getTransactionData(); diff --git a/src/main/java/org/qortal/block/Block535658.java b/src/main/java/org/qortal/block/Block535658.java new file mode 100644 index 00000000..5404cc54 --- /dev/null +++ b/src/main/java/org/qortal/block/Block535658.java @@ -0,0 +1,78 @@ +package org.qortal.block; + +import org.qortal.naming.Name; +import org.qortal.repository.DataException; + +/** + * Block 535658 + *

+ * A node minted a version of block 535658 that contained one transaction: + * a REGISTER_NAME transaction that attempted to register a name that was already registered. + *

+ * This invalid transaction made block 535658 (rightly) invalid to several nodes, + * which refused to use that block. + * However, it seems there were no other nodes minting an alternative, valid block at that time + * and so the chain stalled for several nodes in the network. + *

+ * Additionally, the invalid block 535658 affected all new installations, regardless of whether + * they synchronized from scratch (block 1) or used an 'official release' bootstrap. + *

+ * The diagnosis found the following: + * - The original problem occurred in block 535205 where for some unknown reason many nodes didn't + * add the name from a REGISTER_NAME transaction to their Names table. + * - As a result, those nodes had a corrupt db, because they weren't holding a record of the name. + * - This invalid db then caused them to treat a candidate for block 535658 as valid when it + * should have been invalid. + * - As such, the chain continued on with a technically invalid block in it, for a subset of the network + *

+ * As with block 212937, there were three options, but the only feasible one was to apply edits to block + * 535658 to make it valid. There were several cross-chain trades completed after this block, so doing + * any kind of rollback was out of the question. + *

+ * To complicate things further, a custom data field was used for the first REGISTER_NAME transaction, + * and the default data field was used for the second. So it was important that all nodes ended up with + * the exact same data regardless of how they arrived there. + *

+ * The invalid block 535658 signature is: 3oiuDhok...NdXvCLEV. + *

+ * The invalid transaction in block 212937 is: + *

+ *

+	 {
+		 "type": "REGISTER_NAME",
+		 "timestamp": 1630739437517,
+		 "reference": "4peRechwSPxP6UkRj9Y8ox9YxkWb34sWk5zyMc1WyMxEsACxD4Gmm7LZVsQ6Skpze8QCSBMZasvEZg6RgdqkyADW",
+		 "fee": "0.00100000",
+		 "signature": "2t1CryCog8KPDBarzY5fDCKu499nfnUcGrz4Lz4w5wNb5nWqm7y126P48dChYY7huhufcBV3RJPkgKP4Ywxc1gXx",
+		 "txGroupId": 0,
+		 "blockHeight": 535658,
+		 "approvalStatus": "NOT_REQUIRED",
+		 "creatorAddress": "Qbx9ojxv7XNi1xDMWzzw7xDvd1zYW6SKFB",
+		 "registrantPublicKey": "HJqGEf6cW695Xun4ydhkB2excGFwsDxznhNCRHZStyyx",
+		 "name": "Qplay",
+		 "data": "Registered Name on the Qortal Chain"
+	 }
+   
+ *

+ * Account Qbx9ojxv7XNi1xDMWzzw7xDvd1zYW6SKFB attempted to register the name Qplay + * when they had already registered it 12 hours before in block 535205. + *

+ * However, on the broken DB nodes, their Names table was missing a record for the `Qplay` name + * which was sufficient to make the transaction valid. + */ +public final class Block535658 { + + private Block535658() { + /* Do not instantiate */ + } + + public static void processFix(Block block) throws DataException { + // Unregister the existing name record if it exists + // This ensures that the duplicate name is considered valid, and therefore + // the second (i.e. duplicate) REGISTER_NAME transaction data is applied. + // Both were issued by the same user account, so there is no conflict. + Name name = new Name(block.repository, "Qplay"); + name.unregister(); + } + +} From 6b74ef77e610bb693c92655fc2dc0e0837e050ea Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 5 Sep 2021 21:25:38 +0100 Subject: [PATCH 063/231] Increased log level of invalid transaction message. --- src/main/java/org/qortal/block/Block.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 91e1b2e6..e84250ca 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1138,7 +1138,7 @@ public class Block { // Check transaction can even be processed validationResult = transaction.isProcessable(); if (validationResult != Transaction.ValidationResult.OK) { - LOGGER.debug(String.format("Error during transaction validation, tx %s: %s", Base58.encode(transactionData.getSignature()), validationResult.name())); + LOGGER.info(String.format("Error during transaction validation, tx %s: %s", Base58.encode(transactionData.getSignature()), validationResult.name())); return ValidationResult.TRANSACTION_INVALID; } From 25b787f6f2391d1899af1705bbf22e08fafc5682 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Sun, 5 Sep 2021 18:06:32 -0400 Subject: [PATCH 064/231] Add files via upload --- .../java/org/qortal/block/Block536140.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/main/java/org/qortal/block/Block536140.java diff --git a/src/main/java/org/qortal/block/Block536140.java b/src/main/java/org/qortal/block/Block536140.java new file mode 100644 index 00000000..ab1ca58f --- /dev/null +++ b/src/main/java/org/qortal/block/Block536140.java @@ -0,0 +1,21 @@ +package org.qortal.block; + +import org.qortal.naming.Name; +import org.qortal.repository.DataException; + +public final class Block536140 { + + private Block536140() { + /* Do not instantiate */ + } + + public static void processFix(Block block) throws DataException { + // Unregister the existing name record if it exists + // This ensures that the duplicate name is considered valid, and therefore + // the second (i.e. duplicate) REGISTER_NAME transaction data is applied. + // Both were issued by the same user account, so there is no conflict. + Name name = new Name(block.repository, "Qweb"); + name.unregister(); + } + +} From 673ee4aeed99d77f4f9561e244265879762fc681 Mon Sep 17 00:00:00 2001 From: QuickMythril Date: Sun, 5 Sep 2021 18:07:11 -0400 Subject: [PATCH 065/231] Update Block.java --- src/main/java/org/qortal/block/Block.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index e84250ca..7b155a53 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1100,6 +1100,10 @@ public class Block { // Apply fix for block 535658 but fix will be rolled back before we exit method Block535658.processFix(this); } + else if (this.blockData.getHeight() == 536140) { + // Apply fix for block 536140 but fix will be rolled back before we exit method + Block536140.processFix(this); + } for (Transaction transaction : this.getTransactions()) { TransactionData transactionData = transaction.getTransactionData(); From 172a629da3400dc38fb14948adeee612bf8ede4a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 5 Sep 2021 23:32:11 +0100 Subject: [PATCH 066/231] Added comments --- .../java/org/qortal/block/Block536140.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/main/java/org/qortal/block/Block536140.java b/src/main/java/org/qortal/block/Block536140.java index ab1ca58f..c731f95a 100644 --- a/src/main/java/org/qortal/block/Block536140.java +++ b/src/main/java/org/qortal/block/Block536140.java @@ -3,6 +3,47 @@ package org.qortal.block; import org.qortal.naming.Name; import org.qortal.repository.DataException; +/** + * Block 536140 + *

+ * This block had the same problem as block 535658. + *

+ * Original transaction: + *

+ {
+     "type": "REGISTER_NAME",
+     "timestamp": 1630701955448,
+     "reference": "5CytqtRzhP1irQjiJfKBwNkKBVM9gfvkWQEwqT49VNAofcyNHtSpqrVKB9v44NkhxytHwvfneCndCQTp3J8wU9p7",
+     "fee": "0.00100000",
+     "signature": "sPhiAfQ7MenpJAarTZ99neQHBrmyQ3jDFxRp79BTDmkRf7fMsQinuZJvWbsCzGeihr6zEjuPCD2k9srNGkzLhSS",
+     "txGroupId": 0,
+     "blockHeight": 535172,
+     "approvalStatus": "NOT_REQUIRED",
+     "creatorAddress": "QSUnyUZugWanhDtPaySLdaAGyKLzN3SurS",
+     "registrantPublicKey": "C83r2taaX3pGQTgjmb7QNnFN8GWJqZxnhwptJEViJSqM",
+     "name": "Qweb",
+     "data": "{\"age\":30}"
+ }
+ 
+ *

+ * Duplicate transaction: + *

+ {
+	 "type": "REGISTER_NAME",
+	 "timestamp": 1630777397713,
+	 "reference": "sPhiAfQ7MenpJAarTZ99neQHBrmyQ3jDFxRp79BTDmkRf7fMsQinuZJvWbsCzGeihr6zEjuPCD2k9srNGkzLhSS",
+	 "fee": "0.00100000",
+	 "signature": "45knBoCoKxraJaJWuwANTyM75Su9TAz45bvU8mQLj9wxwNvkVwrFXneLQtiNzN6ctcmNcGLTR4npiJ7PdxtxbJQA",
+	 "txGroupId": 0,
+	 "blockHeight": 536140,
+	 "approvalStatus": "NOT_REQUIRED",
+	 "creatorAddress": "QSUnyUZugWanhDtPaySLdaAGyKLzN3SurS",
+	 "registrantPublicKey": "C83r2taaX3pGQTgjmb7QNnFN8GWJqZxnhwptJEViJSqM",
+	 "name": "Qweb",
+	 "data": "Registered Name on the Qortal Chain"
+ }
+ 
+ */ public final class Block536140 { private Block536140() { From b800fb5846c8cddb949ad03588df1b6da438b04d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 9 Sep 2021 12:54:01 +0100 Subject: [PATCH 067/231] Treat a REGISTER_NAME transaction as an UPDATE_NAME if the creator matches. Whilst not ideal, this is necessary to prevent the chain from getting stuck on future blocks due to duplicate name registrations. See Block535658.java for full details on this problem - this is simply a "catch-all" implementation of that class in order to futureproof this fix. There is still a database inconsistency to be solved, as some nodes are failing to add a registered name to their Names table the first time around, but this will take some time. Once fixed, this commit could potentially be reverted. Also added unit tests for both scenarios (same and different creator). TLDR: this allows all past and future invalid blocks caused by NAME_ALREADY_REGISTERED (by the same creator) to now be valid. --- .../transaction/RegisterNameTransaction.java | 22 +++++++++++++- .../org/qortal/test/naming/MiscTests.java | 30 +++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/transaction/RegisterNameTransaction.java b/src/main/java/org/qortal/transaction/RegisterNameTransaction.java index 66c1fc8b..ad20ef1c 100644 --- a/src/main/java/org/qortal/transaction/RegisterNameTransaction.java +++ b/src/main/java/org/qortal/transaction/RegisterNameTransaction.java @@ -2,11 +2,13 @@ package org.qortal.transaction; import java.util.Collections; import java.util.List; +import java.util.Objects; import org.qortal.account.Account; import org.qortal.asset.Asset; import org.qortal.block.BlockChain; import org.qortal.crypto.Crypto; +import org.qortal.data.naming.NameData; import org.qortal.data.transaction.RegisterNameTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.naming.Name; @@ -77,14 +79,32 @@ public class RegisterNameTransaction extends Transaction { @Override public ValidationResult isProcessable() throws DataException { // Check the name isn't already taken - if (this.repository.getNameRepository().reducedNameExists(this.registerNameTransactionData.getReducedName())) + if (this.repository.getNameRepository().reducedNameExists(this.registerNameTransactionData.getReducedName())) { + // Name exists, but we'll allow the transaction if it has the same creator + // This is necessary to workaround an issue due to inconsistent data in the Names table on some nodes. + // Without this, the chain can get stuck for a subset of nodes when the name is registered + // for the second time. It's simplest to just treat REGISTER_NAME as UPDATE_NAME if the creator + // matches that of the original registration. + + NameData nameData = this.repository.getNameRepository().fromReducedName(this.registerNameTransactionData.getReducedName()); + if (Objects.equals(this.getCreator().getAddress(), nameData.getOwner())) { + // Transaction creator already owns the name, so it's safe to update it + // Treat this as valid, which also requires skipping the "one name per account" check below. + // Given that the name matches one already registered, we know that it won't exceed the limit. + return ValidationResult.OK; + } + + // Name is already registered to someone else return ValidationResult.NAME_ALREADY_REGISTERED; + } // If accounts are only allowed one registered name then check for this if (BlockChain.getInstance().oneNamePerAccount() && !this.repository.getNameRepository().getNamesByOwner(getRegistrant().getAddress()).isEmpty()) return ValidationResult.MULTIPLE_NAMES_FORBIDDEN; + // FUTURE: when adding more validation, make sure to check the `return ValidationResult.OK` above + return ValidationResult.OK; } diff --git a/src/test/java/org/qortal/test/naming/MiscTests.java b/src/test/java/org/qortal/test/naming/MiscTests.java index c46cbfab..23e0e720 100644 --- a/src/test/java/org/qortal/test/naming/MiscTests.java +++ b/src/test/java/org/qortal/test/naming/MiscTests.java @@ -44,9 +44,9 @@ public class MiscTests extends Common { } } - // test trying to register same name twice + // test trying to register same name twice (with same creator) @Test - public void testDuplicateRegisterName() throws DataException { + public void testDuplicateRegisterNameWithSameCreator() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); @@ -63,7 +63,31 @@ public class MiscTests extends Common { transaction.sign(alice); ValidationResult result = transaction.importAsUnconfirmed(); - assertTrue("Transaction should be invalid", ValidationResult.OK != result); + assertTrue("Transaction should be valid because it has the same creator", ValidationResult.OK == result); + } + } + + // test trying to register same name twice (with different creator) + @Test + public void testDuplicateRegisterNameWithDifferentCreator() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Register-name + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "test-name"; + String data = "{}"; + + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // duplicate (this time registered by Bob) + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + String duplicateName = "TEST-nÁme"; + transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(bob), duplicateName, data); + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(alice); + + ValidationResult result = transaction.importAsUnconfirmed(); + assertTrue("Transaction should be invalid because it has a different creator", ValidationResult.OK != result); } } From a6bbc819620b37a14b0c172d2513689ac34f3d30 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 9 Sep 2021 12:55:08 +0100 Subject: [PATCH 068/231] Revert "Merge pull request #58 from QuickMythril/536140-fix" This reverts commit 6d1f7b36a7af3d0c12085f5718b2d42162ffd521, reversing changes made to 6b74ef77e610bb693c92655fc2dc0e0837e050ea. # Conflicts: # src/main/java/org/qortal/block/Block536140.java --- src/main/java/org/qortal/block/Block.java | 4 -- .../java/org/qortal/block/Block536140.java | 62 ------------------- 2 files changed, 66 deletions(-) delete mode 100644 src/main/java/org/qortal/block/Block536140.java diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 7b155a53..e84250ca 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1100,10 +1100,6 @@ public class Block { // Apply fix for block 535658 but fix will be rolled back before we exit method Block535658.processFix(this); } - else if (this.blockData.getHeight() == 536140) { - // Apply fix for block 536140 but fix will be rolled back before we exit method - Block536140.processFix(this); - } for (Transaction transaction : this.getTransactions()) { TransactionData transactionData = transaction.getTransactionData(); diff --git a/src/main/java/org/qortal/block/Block536140.java b/src/main/java/org/qortal/block/Block536140.java deleted file mode 100644 index c731f95a..00000000 --- a/src/main/java/org/qortal/block/Block536140.java +++ /dev/null @@ -1,62 +0,0 @@ -package org.qortal.block; - -import org.qortal.naming.Name; -import org.qortal.repository.DataException; - -/** - * Block 536140 - *

- * This block had the same problem as block 535658. - *

- * Original transaction: - *

- {
-     "type": "REGISTER_NAME",
-     "timestamp": 1630701955448,
-     "reference": "5CytqtRzhP1irQjiJfKBwNkKBVM9gfvkWQEwqT49VNAofcyNHtSpqrVKB9v44NkhxytHwvfneCndCQTp3J8wU9p7",
-     "fee": "0.00100000",
-     "signature": "sPhiAfQ7MenpJAarTZ99neQHBrmyQ3jDFxRp79BTDmkRf7fMsQinuZJvWbsCzGeihr6zEjuPCD2k9srNGkzLhSS",
-     "txGroupId": 0,
-     "blockHeight": 535172,
-     "approvalStatus": "NOT_REQUIRED",
-     "creatorAddress": "QSUnyUZugWanhDtPaySLdaAGyKLzN3SurS",
-     "registrantPublicKey": "C83r2taaX3pGQTgjmb7QNnFN8GWJqZxnhwptJEViJSqM",
-     "name": "Qweb",
-     "data": "{\"age\":30}"
- }
- 
- *

- * Duplicate transaction: - *

- {
-	 "type": "REGISTER_NAME",
-	 "timestamp": 1630777397713,
-	 "reference": "sPhiAfQ7MenpJAarTZ99neQHBrmyQ3jDFxRp79BTDmkRf7fMsQinuZJvWbsCzGeihr6zEjuPCD2k9srNGkzLhSS",
-	 "fee": "0.00100000",
-	 "signature": "45knBoCoKxraJaJWuwANTyM75Su9TAz45bvU8mQLj9wxwNvkVwrFXneLQtiNzN6ctcmNcGLTR4npiJ7PdxtxbJQA",
-	 "txGroupId": 0,
-	 "blockHeight": 536140,
-	 "approvalStatus": "NOT_REQUIRED",
-	 "creatorAddress": "QSUnyUZugWanhDtPaySLdaAGyKLzN3SurS",
-	 "registrantPublicKey": "C83r2taaX3pGQTgjmb7QNnFN8GWJqZxnhwptJEViJSqM",
-	 "name": "Qweb",
-	 "data": "Registered Name on the Qortal Chain"
- }
- 
- */ -public final class Block536140 { - - private Block536140() { - /* Do not instantiate */ - } - - public static void processFix(Block block) throws DataException { - // Unregister the existing name record if it exists - // This ensures that the duplicate name is considered valid, and therefore - // the second (i.e. duplicate) REGISTER_NAME transaction data is applied. - // Both were issued by the same user account, so there is no conflict. - Name name = new Name(block.repository, "Qweb"); - name.unregister(); - } - -} From 63c9bc5c1cc047239be6dc17111b7fa6a36b577b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 9 Sep 2021 12:55:21 +0100 Subject: [PATCH 069/231] Revert "Workaround for block 535658 problem" This reverts commit 278201e87ca779c2b44107611e1aa7aae3c044d6. --- src/main/java/org/qortal/block/Block.java | 7 +- .../java/org/qortal/block/Block535658.java | 78 ------------------- 2 files changed, 1 insertion(+), 84 deletions(-) delete mode 100644 src/main/java/org/qortal/block/Block535658.java diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index e84250ca..1a7b48fe 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1092,14 +1092,9 @@ public class Block { // Create repository savepoint here so we can rollback to it after testing transactions repository.setSavepoint(); - if (this.blockData.getHeight() == 212937) { + if (this.blockData.getHeight() == 212937) // Apply fix for block 212937 but fix will be rolled back before we exit method Block212937.processFix(this); - } - else if (this.blockData.getHeight() == 535658) { - // Apply fix for block 535658 but fix will be rolled back before we exit method - Block535658.processFix(this); - } for (Transaction transaction : this.getTransactions()) { TransactionData transactionData = transaction.getTransactionData(); diff --git a/src/main/java/org/qortal/block/Block535658.java b/src/main/java/org/qortal/block/Block535658.java deleted file mode 100644 index 5404cc54..00000000 --- a/src/main/java/org/qortal/block/Block535658.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.qortal.block; - -import org.qortal.naming.Name; -import org.qortal.repository.DataException; - -/** - * Block 535658 - *

- * A node minted a version of block 535658 that contained one transaction: - * a REGISTER_NAME transaction that attempted to register a name that was already registered. - *

- * This invalid transaction made block 535658 (rightly) invalid to several nodes, - * which refused to use that block. - * However, it seems there were no other nodes minting an alternative, valid block at that time - * and so the chain stalled for several nodes in the network. - *

- * Additionally, the invalid block 535658 affected all new installations, regardless of whether - * they synchronized from scratch (block 1) or used an 'official release' bootstrap. - *

- * The diagnosis found the following: - * - The original problem occurred in block 535205 where for some unknown reason many nodes didn't - * add the name from a REGISTER_NAME transaction to their Names table. - * - As a result, those nodes had a corrupt db, because they weren't holding a record of the name. - * - This invalid db then caused them to treat a candidate for block 535658 as valid when it - * should have been invalid. - * - As such, the chain continued on with a technically invalid block in it, for a subset of the network - *

- * As with block 212937, there were three options, but the only feasible one was to apply edits to block - * 535658 to make it valid. There were several cross-chain trades completed after this block, so doing - * any kind of rollback was out of the question. - *

- * To complicate things further, a custom data field was used for the first REGISTER_NAME transaction, - * and the default data field was used for the second. So it was important that all nodes ended up with - * the exact same data regardless of how they arrived there. - *

- * The invalid block 535658 signature is: 3oiuDhok...NdXvCLEV. - *

- * The invalid transaction in block 212937 is: - *

- *

-	 {
-		 "type": "REGISTER_NAME",
-		 "timestamp": 1630739437517,
-		 "reference": "4peRechwSPxP6UkRj9Y8ox9YxkWb34sWk5zyMc1WyMxEsACxD4Gmm7LZVsQ6Skpze8QCSBMZasvEZg6RgdqkyADW",
-		 "fee": "0.00100000",
-		 "signature": "2t1CryCog8KPDBarzY5fDCKu499nfnUcGrz4Lz4w5wNb5nWqm7y126P48dChYY7huhufcBV3RJPkgKP4Ywxc1gXx",
-		 "txGroupId": 0,
-		 "blockHeight": 535658,
-		 "approvalStatus": "NOT_REQUIRED",
-		 "creatorAddress": "Qbx9ojxv7XNi1xDMWzzw7xDvd1zYW6SKFB",
-		 "registrantPublicKey": "HJqGEf6cW695Xun4ydhkB2excGFwsDxznhNCRHZStyyx",
-		 "name": "Qplay",
-		 "data": "Registered Name on the Qortal Chain"
-	 }
-   
- *

- * Account Qbx9ojxv7XNi1xDMWzzw7xDvd1zYW6SKFB attempted to register the name Qplay - * when they had already registered it 12 hours before in block 535205. - *

- * However, on the broken DB nodes, their Names table was missing a record for the `Qplay` name - * which was sufficient to make the transaction valid. - */ -public final class Block535658 { - - private Block535658() { - /* Do not instantiate */ - } - - public static void processFix(Block block) throws DataException { - // Unregister the existing name record if it exists - // This ensures that the duplicate name is considered valid, and therefore - // the second (i.e. duplicate) REGISTER_NAME transaction data is applied. - // Both were issued by the same user account, so there is no conflict. - Name name = new Name(block.repository, "Qplay"); - name.unregister(); - } - -} From e90c3a78d10863d20a9c3f05d50a70afeef5ca99 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 9 Sep 2021 15:12:28 +0100 Subject: [PATCH 070/231] Updated default "data" field text in the API documentation, to match the value the UI uses. --- .../qortal/data/transaction/RegisterNameTransactionData.java | 2 +- .../org/qortal/data/transaction/UpdateNameTransactionData.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/data/transaction/RegisterNameTransactionData.java b/src/main/java/org/qortal/data/transaction/RegisterNameTransactionData.java index d4455da1..c2b06fd2 100644 --- a/src/main/java/org/qortal/data/transaction/RegisterNameTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/RegisterNameTransactionData.java @@ -26,7 +26,7 @@ public class RegisterNameTransactionData extends TransactionData { @Schema(description = "requested name", example = "my-name") private String name; - @Schema(description = "simple name-related info in JSON format", example = "{ \"age\": 30 }") + @Schema(description = "simple name-related info in JSON or text format", example = "Registered Name on the Qortal Chain") private String data; // For internal use diff --git a/src/main/java/org/qortal/data/transaction/UpdateNameTransactionData.java b/src/main/java/org/qortal/data/transaction/UpdateNameTransactionData.java index 43c8da59..b43361db 100644 --- a/src/main/java/org/qortal/data/transaction/UpdateNameTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/UpdateNameTransactionData.java @@ -26,7 +26,7 @@ public class UpdateNameTransactionData extends TransactionData { @Schema(description = "new name", example = "my-new-name") private String newName; - @Schema(description = "replacement simple name-related info in JSON format", example = "{ \"age\": 30 }") + @Schema(description = "replacement simple name-related info in JSON or text format", example = "Registered Name on the Qortal Chain") private String newData; // For internal use From 0657ca2969f4157718c2c8940795a2dec14486fe Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 9 Sep 2021 17:46:19 +0100 Subject: [PATCH 071/231] atStatesMaxLifetime increased to 5 days For now, we need some headroom to allow for orphaning in the event of a problem. Orphaning currently fails if there is no ATStatesData available (which is the case for trimmed blocks). This could ultimately be solved by retaining older unique states. --- src/main/java/org/qortal/settings/Settings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 6527d7e0..98521646 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -94,7 +94,7 @@ public class Settings { private int blockCacheSize = 10; /** How long to keep old, full, AT state data (ms). */ - private long atStatesMaxLifetime = 24 * 60 * 60 * 1000L; // milliseconds + private long atStatesMaxLifetime = 5 * 24 * 60 * 60 * 1000L; // milliseconds /** How often to attempt AT state trimming (ms). */ private long atStatesTrimInterval = 5678L; // milliseconds /** Block height range to scan for trimmable AT states.
From 14acc4feb933e13dd6a341223cc63b4b0d9cecec Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Sep 2021 09:52:28 +0100 Subject: [PATCH 072/231] Removed maxDuplicatedBlocksWhenArchiving setting as it's no longer needed. --- .../controller/repository/BlockArchiver.java | 2 +- .../qortal/repository/BlockArchiveWriter.java | 26 +++---------------- .../hsqldb/HSQLDBDatabaseArchiving.java | 2 +- .../hsqldb/HSQLDBDatabasePruning.java | 4 +-- .../java/org/qortal/settings/Settings.java | 7 ----- 5 files changed, 8 insertions(+), 33 deletions(-) diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiver.java b/src/main/java/org/qortal/controller/repository/BlockArchiver.java index f7bafe7d..aab4b4fa 100644 --- a/src/main/java/org/qortal/controller/repository/BlockArchiver.java +++ b/src/main/java/org/qortal/controller/repository/BlockArchiver.java @@ -35,7 +35,7 @@ public class BlockArchiver implements Runnable { while (!Controller.isStopping()) { repository.discardChanges(); - final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository, true); + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); Thread.sleep(Settings.getInstance().getArchiveInterval()); diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index 4aeb1a32..59d07072 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -42,34 +42,16 @@ public class BlockArchiveWriter { this.repository = repository; } - public static int getMaxArchiveHeight(Repository repository, boolean useMaximumDuplicatedLimit) throws DataException { + public static int getMaxArchiveHeight(Repository repository) throws DataException { // We must only archive trimmed blocks, or the archive will grow far too large final int accountSignaturesTrimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); final int atTrimStartHeight = repository.getATRepository().getAtTrimHeight(); final int trimStartHeight = Math.min(accountSignaturesTrimStartHeight, atTrimStartHeight); - - // In some cases we want to restrict the upper height of the archiver to save space - if (useMaximumDuplicatedLimit) { - // To save on disk space, it's best to not allow the archiver to get too far ahead of the pruner - // This reduces the amount of data that is held twice during the transition - final int blockPruneStartHeight = repository.getBlockRepository().getBlockPruneHeight(); - final int atPruneStartHeight = repository.getATRepository().getAtPruneHeight(); - final int pruneStartHeight = Math.min(blockPruneStartHeight, atPruneStartHeight); - final int maximumDuplicatedBlocks = Settings.getInstance().getMaxDuplicatedBlocksWhenArchiving(); - - // To summarize the above: - // - We must never archive anything greater than or equal to trimStartHeight - // - We should avoid archiving anything maximumDuplicatedBlocks higher than pruneStartHeight - return Math.min(trimStartHeight, pruneStartHeight + maximumDuplicatedBlocks); - } - else { - // We don't want to apply the maximum duplicated limit - return trimStartHeight; - } + return trimStartHeight - 1; // subtract 1 because these values represent the first _untrimmed_ block } - public static boolean isArchiverUpToDate(Repository repository, boolean useMaximumDuplicatedLimit) throws DataException { - final int maxArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository, useMaximumDuplicatedLimit); + public static boolean isArchiverUpToDate(Repository repository) throws DataException { + final int maxArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); final int actualArchiveHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); final float progress = (float)actualArchiveHeight / (float) maxArchiveHeight; LOGGER.info(String.format("maxArchiveHeight: %d, actualArchiveHeight: %d, progress: %f", diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java index 930da828..7a7b66f3 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java @@ -41,7 +41,7 @@ public class HSQLDBDatabaseArchiving { LOGGER.info("Building block archive - this process could take a while... (approx. 15 mins on high spec)"); - final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository, false); + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); int startHeight = 0; while (!Controller.isStopping()) { diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index 969c954c..65139743 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -52,7 +52,7 @@ public class HSQLDBDatabasePruning { // Only proceed if we can see that the archiver has already finished // This way, if the archiver failed for any reason, we can prune once it has had // some opportunities to try again - boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository, false); + boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); if (!upToDate) { return false; } @@ -253,7 +253,7 @@ public class HSQLDBDatabasePruning { // Only proceed if we can see that the archiver has already finished // This way, if the archiver failed for any reason, we can prune once it has had // some opportunities to try again - boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository, false); + boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); if (!upToDate) { return false; } diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 98521646..f6c89e61 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -133,9 +133,6 @@ public class Settings { private boolean archiveEnabled = true; /** How often to attempt archiving (ms). */ private long archiveInterval = 7171L; // milliseconds - /** The maximum number of blocks that can exist in both the - * database and the archive at the same time */ - private int maxDuplicatedBlocksWhenArchiving = 100000; // Peer-to-peer related @@ -592,8 +589,4 @@ public class Settings { return this.archiveInterval; } - public int getMaxDuplicatedBlocksWhenArchiving() { - return this.maxDuplicatedBlocksWhenArchiving; - } - } From 2a36b83dea882da414313275544aebc17db546e0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Sep 2021 09:55:49 +0100 Subject: [PATCH 073/231] Removed BLOCK_LIMIT_REACHED result from the block archive writer. This wasn't needed, and is now instead caught by the NOT_ENOUGH_BLOCKS result. --- .../java/org/qortal/controller/repository/BlockArchiver.java | 1 - src/main/java/org/qortal/repository/BlockArchiveWriter.java | 3 +-- .../org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiver.java b/src/main/java/org/qortal/controller/repository/BlockArchiver.java index aab4b4fa..d6860347 100644 --- a/src/main/java/org/qortal/controller/repository/BlockArchiver.java +++ b/src/main/java/org/qortal/controller/repository/BlockArchiver.java @@ -71,7 +71,6 @@ public class BlockArchiver implements Runnable { case STOPPING: return; - case BLOCK_LIMIT_REACHED: // We've reached the limit of the blocks we can archive // Sleep for a while to allow more to become available case NOT_ENOUGH_BLOCKS: diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index 59d07072..efef689e 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -24,7 +24,6 @@ public class BlockArchiveWriter { OK, STOPPING, NOT_ENOUGH_BLOCKS, - BLOCK_LIMIT_REACHED, BLOCK_NOT_FOUND } @@ -99,7 +98,7 @@ public class BlockArchiveWriter { int currentHeight = startHeight + i; if (currentHeight >= endHeight) { - return BlockArchiveWriteResult.BLOCK_LIMIT_REACHED; + break; } //LOGGER.info("Fetching block {}...", currentHeight); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java index 7a7b66f3..618d5115 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java @@ -59,7 +59,6 @@ public class HSQLDBDatabaseArchiving { case STOPPING: return false; - case BLOCK_LIMIT_REACHED: case NOT_ENOUGH_BLOCKS: // We've reached the limit of the blocks we can archive // Return from the whole method From 6a55b052f5aaf2173a23edf52322572bac910c42 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Sep 2021 09:57:12 +0100 Subject: [PATCH 074/231] Fixed some bugs found in unit testing. --- src/main/java/org/qortal/repository/BlockArchiveWriter.java | 2 +- .../java/org/qortal/repository/hsqldb/HSQLDBATRepository.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index efef689e..11151e17 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -97,7 +97,7 @@ public class BlockArchiveWriter { } int currentHeight = startHeight + i; - if (currentHeight >= endHeight) { + if (currentHeight > endHeight) { break; } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index e0baa136..56658ec7 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -744,7 +744,7 @@ public class HSQLDBATRepository implements ATRepository { int deletedCount = 0; - for (int height = minHeight; height < maxHeight; height++) { + for (int height = minHeight; height <= maxHeight; height++) { // Give up if we're stopping if (Controller.isStopping()) { From 1d8351f921d9200f6cedebeee7161187b4f03bb8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Sep 2021 10:10:25 +0100 Subject: [PATCH 075/231] Added importFromArchive() feature This allows archived blocks to be imported back into HSQLDB in order to make them SQL-compatible again. --- .../qortal/repository/BlockArchiveReader.java | 15 ++++ .../repository/BlockArchiveRepository.java | 12 +++ .../hsqldb/HSQLDBBlockArchiveRepository.java | 15 ++++ .../org/qortal/utils/BlockArchiveUtils.java | 78 +++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 src/main/java/org/qortal/utils/BlockArchiveUtils.java diff --git a/src/main/java/org/qortal/repository/BlockArchiveReader.java b/src/main/java/org/qortal/repository/BlockArchiveReader.java index 1b68a7c5..081917b2 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveReader.java +++ b/src/main/java/org/qortal/repository/BlockArchiveReader.java @@ -105,6 +105,21 @@ public class BlockArchiveReader { return null; } + public List, List>> fetchBlocksFromRange( + int startHeight, int endHeight) { + + List, List>> blockInfoList = new ArrayList<>(); + + for (int height = startHeight; height <= endHeight; height++) { + Triple, List> blockInfo = this.fetchBlockAtHeight(height); + if (blockInfo == null) { + return blockInfoList; + } + blockInfoList.add(blockInfo); + } + return blockInfoList; + } + public Integer fetchHeightForSignature(byte[] signature, Repository repository) { // Lookup the height for the requested signature try { diff --git a/src/main/java/org/qortal/repository/BlockArchiveRepository.java b/src/main/java/org/qortal/repository/BlockArchiveRepository.java index c702a7ef..45465e93 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveRepository.java +++ b/src/main/java/org/qortal/repository/BlockArchiveRepository.java @@ -36,6 +36,18 @@ public interface BlockArchiveRepository { */ public BlockData fromHeight(int height) throws DataException; + /** + * Returns a list of BlockData objects from archive using + * block height range. + * + * @param startHeight + * @return a list of BlockData objects, or an empty list if + * not found in blockchain. It is not guaranteed that all + * requested blocks will be returned. + * @throws DataException + */ + public List fromRange(int startHeight, int endHeight) throws DataException; + /** * Returns BlockData from archive using block reference. * Currently relies on a child block being the one block diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java index c491f862..32270213 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java @@ -3,6 +3,7 @@ package org.qortal.repository.hsqldb; import org.qortal.api.ApiError; import org.qortal.api.ApiExceptionFactory; import org.qortal.api.model.BlockSignerSummary; +import org.qortal.block.Block; import org.qortal.data.block.BlockArchiveData; import org.qortal.data.block.BlockData; import org.qortal.data.block.BlockSummaryData; @@ -53,6 +54,20 @@ public class HSQLDBBlockArchiveRepository implements BlockArchiveRepository { return null; } + @Override + public List fromRange(int startHeight, int endHeight) throws DataException { + List blocks = new ArrayList<>(); + + for (int height = startHeight; height < endHeight; height++) { + BlockData blockData = this.fromHeight(height); + if (blockData == null) { + return blocks; + } + blocks.add(blockData); + } + return blocks; + } + @Override public BlockData fromReference(byte[] reference) throws DataException { BlockData referenceBlock = this.repository.getBlockArchiveRepository().fromSignature(reference); diff --git a/src/main/java/org/qortal/utils/BlockArchiveUtils.java b/src/main/java/org/qortal/utils/BlockArchiveUtils.java new file mode 100644 index 00000000..0beff026 --- /dev/null +++ b/src/main/java/org/qortal/utils/BlockArchiveUtils.java @@ -0,0 +1,78 @@ +package org.qortal.utils; + +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.BlockArchiveReader; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; + +import java.util.List; + +public class BlockArchiveUtils { + + /** + * importFromArchive + *

+ * Reads the requested block range from the archive + * and imports the BlockData and AT state data hashes + * This can be used to convert a block archive back + * into the HSQLDB, in order to make it SQL-compatible + * again. + *

+ * Note: calls discardChanges() and saveChanges(), so + * make sure that you commit any existing repository + * changes before calling this method. + * + * @param startHeight The earliest block to import + * @param endHeight The latest block to import + * @param repository A clean repository session + * @throws DataException + */ + public static void importFromArchive(int startHeight, int endHeight, Repository repository) throws DataException { + repository.discardChanges(); + final int requestedRange = endHeight+1-startHeight; + + List, List>> blockInfoList = + BlockArchiveReader.getInstance().fetchBlocksFromRange(startHeight, endHeight); + + // Ensure that we have received all of the requested blocks + if (blockInfoList == null || blockInfoList.isEmpty()) { + throw new IllegalStateException("No blocks found when importing from archive"); + } + if (blockInfoList.size() != requestedRange) { + throw new IllegalStateException("Non matching block count when importing from archive"); + } + Triple, List> firstBlock = blockInfoList.get(0); + if (firstBlock == null || firstBlock.getA().getHeight() != startHeight) { + throw new IllegalStateException("Non matching first block when importing from archive"); + } + if (blockInfoList.size() > 0) { + Triple, List> lastBlock = + blockInfoList.get(blockInfoList.size() - 1); + if (lastBlock == null || lastBlock.getA().getHeight() != endHeight) { + throw new IllegalStateException("Non matching last block when importing from archive"); + } + } + + // Everything seems okay, so go ahead with the import + for (Triple, List> blockInfo : blockInfoList) { + try { + // Save block + repository.getBlockRepository().save(blockInfo.getA()); + + // Save AT state data hashes + for (ATStateData atStateData : blockInfo.getC()) { + atStateData.setHeight(blockInfo.getA().getHeight()); + repository.getATRepository().save(atStateData); + } + + } catch (DataException e) { + repository.discardChanges(); + throw new IllegalStateException("Unable to import blocks from archive"); + } + } + repository.saveChanges(); + } + +} From 14f6fd19ef825c92271183b4db5b8096ad71b411 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Sep 2021 10:13:52 +0100 Subject: [PATCH 076/231] Added unit tests for trimming, pruning, and archiving. --- .../org/qortal/controller/BlockMinter.java | 3 +- .../qortal/repository/BlockArchiveWriter.java | 25 +- .../org/qortal/test/BlockArchiveTests.java | 500 ++++++++++++++++++ src/test/java/org/qortal/test/PruneTests.java | 91 ++++ .../org/qortal/test/at/AtRepositoryTests.java | 158 +++--- .../java/org/qortal/test/common/AtUtils.java | 81 +++ .../test-settings-v2-block-archive.json | 11 + 7 files changed, 781 insertions(+), 88 deletions(-) create mode 100644 src/test/java/org/qortal/test/BlockArchiveTests.java create mode 100644 src/test/java/org/qortal/test/PruneTests.java create mode 100644 src/test/java/org/qortal/test/common/AtUtils.java create mode 100644 src/test/resources/test-settings-v2-block-archive.json diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 8b6563f2..318b1ac2 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -421,7 +421,8 @@ public class BlockMinter extends Thread { // Add to blockchain newBlock.process(); - LOGGER.info(String.format("Minted new test block: %d", newBlock.getBlockData().getHeight())); + LOGGER.info(String.format("Minted new test block: %d sig: %.8s", + newBlock.getBlockData().getHeight(), Base58.encode(newBlock.getBlockData().getSignature()))); repository.saveChanges(); diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index 11151e17..77c98d96 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -33,7 +33,11 @@ public class BlockArchiveWriter { private final int endHeight; private final Repository repository; + private long fileSizeTarget = 100 * 1024 * 1024; // 100MiB + private boolean shouldEnforceFileSizeTarget = true; + private int writtenCount; + private Path outputPath; public BlockArchiveWriter(int startHeight, int endHeight, Repository repository) { this.startHeight = startHeight; @@ -87,8 +91,9 @@ public class BlockArchiveWriter { LOGGER.info(String.format("Fetching blocks from height %d...", startHeight)); int i = 0; - long fileSizeTarget = 100 * 1024 * 1024; // 100MiB - while (headerBytes.size() + bytes.size() < fileSizeTarget) { + while (headerBytes.size() + bytes.size() < this.fileSizeTarget + || this.shouldEnforceFileSizeTarget == false) { + if (Controller.isStopping()) { return BlockArchiveWriteResult.STOPPING; } @@ -132,7 +137,7 @@ public class BlockArchiveWriter { LOGGER.info(String.format("Total length of %d blocks is %d bytes", i, totalLength)); // Validate file size, in case something went wrong - if (totalLength < fileSizeTarget) { + if (totalLength < fileSizeTarget && this.shouldEnforceFileSizeTarget) { return BlockArchiveWriteResult.NOT_ENOUGH_BLOCKS; } @@ -164,6 +169,7 @@ public class BlockArchiveWriter { BlockArchiveReader.getInstance().invalidateFileListCache(); this.writtenCount = i; + this.outputPath = Paths.get(filePath); return BlockArchiveWriteResult.OK; } @@ -171,4 +177,17 @@ public class BlockArchiveWriter { return this.writtenCount; } + public Path getOutputPath() { + return this.outputPath; + } + + public void setFileSizeTarget(long fileSizeTarget) { + this.fileSizeTarget = fileSizeTarget; + } + + // For testing, to avoid having to pre-calculate file sizes + public void setShouldEnforceFileSizeTarget(boolean shouldEnforceFileSizeTarget) { + this.shouldEnforceFileSizeTarget = shouldEnforceFileSizeTarget; + } + } diff --git a/src/test/java/org/qortal/test/BlockArchiveTests.java b/src/test/java/org/qortal/test/BlockArchiveTests.java new file mode 100644 index 00000000..c05915cd --- /dev/null +++ b/src/test/java/org/qortal/test/BlockArchiveTests.java @@ -0,0 +1,500 @@ +package org.qortal.test; + +import org.apache.commons.io.FileUtils; +import org.ciyam.at.CompilationException; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.controller.BlockMinter; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.*; +import org.qortal.settings.Settings; +import org.qortal.test.common.AtUtils; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transform.TransformationException; +import org.qortal.utils.BlockArchiveUtils; +import org.qortal.utils.Triple; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.*; + +public class BlockArchiveTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); // Necessary to set NTP offset + Common.useSettings("test-settings-v2-block-archive.json"); + this.deleteArchiveDirectory(); + } + + @After + public void afterTest() throws DataException { + this.deleteArchiveDirectory(); + } + + + @Test + public void testWriter() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online + List mintingAndOnlineAccounts = new ArrayList<>(); + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + mintingAndOnlineAccounts.add(aliceSelfShare); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) + BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + + // 900 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); + repository.getATRepository().setAtTrimHeight(901); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(900, maximumArchiveHeight); + + // Write blocks 2-900 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(900 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(900 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + } + } + + @Test + public void testWriterAndReader() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online + List mintingAndOnlineAccounts = new ArrayList<>(); + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + mintingAndOnlineAccounts.add(aliceSelfShare); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) + BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + + // 900 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); + repository.getATRepository().setAtTrimHeight(901); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(900, maximumArchiveHeight); + + // Write blocks 2-900 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(900 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(900 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Read block 2 from the archive + BlockArchiveReader reader = BlockArchiveReader.getInstance(); + Triple, List> block2Info = reader.fetchBlockAtHeight(2); + BlockData block2ArchiveData = block2Info.getA(); + + // Read block 2 from the repository + BlockData block2RepositoryData = repository.getBlockRepository().fromHeight(2); + + // Ensure the values match + assertEquals(block2ArchiveData.getHeight(), block2RepositoryData.getHeight()); + assertArrayEquals(block2ArchiveData.getSignature(), block2RepositoryData.getSignature()); + + // Test some values in the archive + assertEquals(1, block2ArchiveData.getOnlineAccountsCount()); + + // Read block 900 from the archive + Triple, List> block900Info = reader.fetchBlockAtHeight(900); + BlockData block900ArchiveData = block900Info.getA(); + + // Read block 900 from the repository + BlockData block900RepositoryData = repository.getBlockRepository().fromHeight(900); + + // Ensure the values match + assertEquals(block900ArchiveData.getHeight(), block900RepositoryData.getHeight()); + assertArrayEquals(block900ArchiveData.getSignature(), block900RepositoryData.getSignature()); + + // Test some values in the archive + assertEquals(1, block900ArchiveData.getOnlineAccountsCount()); + + } + } + + @Test + public void testArchivedAtStates() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online + List mintingAndOnlineAccounts = new ArrayList<>(); + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + mintingAndOnlineAccounts.add(aliceSelfShare); + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 10; i++) + BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + + // 9 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(10); + repository.getATRepository().setAtTrimHeight(10); + + // Check the max archive height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(9, maximumArchiveHeight); + + // Write blocks 2-9 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(9 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(9 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Check blocks 3-9 + for (Integer testHeight = 2; testHeight <= 9; testHeight++) { + + // Read a block from the archive + BlockArchiveReader reader = BlockArchiveReader.getInstance(); + Triple, List> blockInfo = reader.fetchBlockAtHeight(testHeight); + BlockData archivedBlockData = blockInfo.getA(); + ATStateData archivedAtStateData = blockInfo.getC().isEmpty() ? null : blockInfo.getC().get(0); + List archivedTransactions = blockInfo.getB(); + + // Read the same block from the repository + BlockData repositoryBlockData = repository.getBlockRepository().fromHeight(testHeight); + ATStateData repositoryAtStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + // Ensure the repository has full AT state data + assertNotNull(repositoryAtStateData.getStateHash()); + assertNotNull(repositoryAtStateData.getStateData()); + + // Check the archived AT state + if (testHeight == 2) { + // Block 2 won't have an AT state hash because it's initial (and has the DEPLOY_AT in the same block) + assertNull(archivedAtStateData); + + assertEquals(1, archivedTransactions.size()); + assertEquals(Transaction.TransactionType.DEPLOY_AT, archivedTransactions.get(0).getType()); + } + else { + // For blocks 3+, ensure the archive has the AT state data, but not the hashes + assertNotNull(archivedAtStateData.getStateHash()); + assertNull(archivedAtStateData.getStateData()); + + // They also shouldn't have any transactions + assertTrue(archivedTransactions.isEmpty()); + } + + // Also check the online accounts count and height + assertEquals(1, archivedBlockData.getOnlineAccountsCount()); + assertEquals(testHeight, archivedBlockData.getHeight()); + + // Ensure the values match + assertEquals(archivedBlockData.getHeight(), repositoryBlockData.getHeight()); + assertArrayEquals(archivedBlockData.getSignature(), repositoryBlockData.getSignature()); + assertEquals(archivedBlockData.getOnlineAccountsCount(), repositoryBlockData.getOnlineAccountsCount()); + assertArrayEquals(archivedBlockData.getMinterSignature(), repositoryBlockData.getMinterSignature()); + assertEquals(archivedBlockData.getATCount(), repositoryBlockData.getATCount()); + assertEquals(archivedBlockData.getOnlineAccountsCount(), repositoryBlockData.getOnlineAccountsCount()); + assertArrayEquals(archivedBlockData.getReference(), repositoryBlockData.getReference()); + assertEquals(archivedBlockData.getTimestamp(), repositoryBlockData.getTimestamp()); + assertEquals(archivedBlockData.getATFees(), repositoryBlockData.getATFees()); + assertEquals(archivedBlockData.getTotalFees(), repositoryBlockData.getTotalFees()); + assertEquals(archivedBlockData.getTransactionCount(), repositoryBlockData.getTransactionCount()); + assertArrayEquals(archivedBlockData.getTransactionsSignature(), repositoryBlockData.getTransactionsSignature()); + + if (testHeight != 2) { + assertArrayEquals(archivedAtStateData.getStateHash(), repositoryAtStateData.getStateHash()); + } + } + + // Check block 10 (unarchived) + BlockArchiveReader reader = BlockArchiveReader.getInstance(); + Triple, List> blockInfo = reader.fetchBlockAtHeight(10); + assertNull(blockInfo); + + } + + } + + @Test + public void testArchiveAndPrune() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online + List mintingAndOnlineAccounts = new ArrayList<>(); + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + mintingAndOnlineAccounts.add(aliceSelfShare); + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) + BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Assume 900 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); + repository.getATRepository().setAtTrimHeight(901); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(900, maximumArchiveHeight); + + // Write blocks 2-900 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(900 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(900 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Ensure the SQL repository contains blocks 2 and 900... + assertNotNull(repository.getBlockRepository().fromHeight(2)); + assertNotNull(repository.getBlockRepository().fromHeight(900)); + + // Prune all the archived blocks + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(0, 900); + assertEquals(900-1, numBlocksPruned); + repository.getBlockRepository().setBlockPruneHeight(901); + + // Prune the AT states for the archived blocks + repository.getATRepository().rebuildLatestAtStates(); + int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 900); + assertEquals(900-1, numATStatesPruned); + repository.getATRepository().setAtPruneHeight(901); + + // Now ensure the SQL repository is missing blocks 2 and 900... + assertNull(repository.getBlockRepository().fromHeight(2)); + assertNull(repository.getBlockRepository().fromHeight(900)); + + // ... but it's not missing blocks 1 and 901 (we don't prune the genesis block) + assertNotNull(repository.getBlockRepository().fromHeight(1)); + assertNotNull(repository.getBlockRepository().fromHeight(901)); + + // Validate the latest block height in the repository + assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); + + } + } + + @Test + public void testTrimArchivePruneAndOrphan() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online + List mintingAndOnlineAccounts = new ArrayList<>(); + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + mintingAndOnlineAccounts.add(aliceSelfShare); + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) + BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Make sure that block 500 has full AT state data and data hash + List block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); + ATStateData atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + + // Trim the first 500 blocks + repository.getBlockRepository().trimOldOnlineAccountsSignatures(0, 500); + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(501); + repository.getATRepository().trimAtStates(0, 500, 1000); + repository.getATRepository().setAtTrimHeight(501); + + // Now block 500 should only have the AT state data hash + block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); + atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); + assertNotNull(atStatesData.getStateHash()); + assertNull(atStatesData.getStateData()); + + // ... but block 501 should have the full data + List block501AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(501); + atStatesData = repository.getATRepository().getATStateAtHeight(block501AtStatesData.get(0).getATAddress(), 501); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(500, maximumArchiveHeight); + + BlockData block3DataPreArchive = repository.getBlockRepository().fromHeight(3); + + // Write blocks 2-500 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(500 - 1, writer.getWrittenCount()); // -1 for the genesis block + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(500 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Ensure the SQL repository contains blocks 2 and 500... + assertNotNull(repository.getBlockRepository().fromHeight(2)); + assertNotNull(repository.getBlockRepository().fromHeight(500)); + + // Prune all the archived blocks + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(0, 500); + assertEquals(500-1, numBlocksPruned); + repository.getBlockRepository().setBlockPruneHeight(501); + + // Prune the AT states for the archived blocks + repository.getATRepository().rebuildLatestAtStates(); + int numATStatesPruned = repository.getATRepository().pruneAtStates(2, 500); + assertEquals(499, numATStatesPruned); + repository.getATRepository().setAtPruneHeight(501); + + // Now ensure the SQL repository is missing blocks 2 and 500... + assertNull(repository.getBlockRepository().fromHeight(2)); + assertNull(repository.getBlockRepository().fromHeight(500)); + + // ... but it's not missing blocks 1 and 501 (we don't prune the genesis block) + assertNotNull(repository.getBlockRepository().fromHeight(1)); + assertNotNull(repository.getBlockRepository().fromHeight(501)); + + // Validate the latest block height in the repository + assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); + + // Now orphan some unarchived blocks. + BlockUtils.orphanBlocks(repository, 500); + assertEquals(502, (int) repository.getBlockRepository().getLastBlock().getHeight()); + + // We're close to the lower limit of the SQL database now, so + // we need to import some blocks from the archive + BlockArchiveUtils.importFromArchive(401, 500, repository); + + // Ensure the SQL repository now contains block 401 but not 400... + assertNotNull(repository.getBlockRepository().fromHeight(401)); + assertNull(repository.getBlockRepository().fromHeight(400)); + + // Import the remaining 399 blocks + BlockArchiveUtils.importFromArchive(2, 400, repository); + + // Verify that block 3 matches the original + BlockData block3DataPostArchive = repository.getBlockRepository().fromHeight(3); + assertArrayEquals(block3DataPreArchive.getSignature(), block3DataPostArchive.getSignature()); + assertEquals(block3DataPreArchive.getHeight(), block3DataPostArchive.getHeight()); + + // Orphan 1 more block, which should be the last one that is possible to be orphaned + BlockUtils.orphanBlocks(repository, 1); + + // Orphan another block, which should fail + Exception exception = null; + try { + BlockUtils.orphanBlocks(repository, 1); + } catch (DataException e) { + exception = e; + } + + // Ensure that a DataException is thrown because there is no more AT states data available + assertNotNull(exception); + assertEquals(DataException.class, exception.getClass()); + + // FUTURE: we may be able to retain unique AT states when trimming, to avoid this exception + // and allow orphaning back through blocks with trimmed AT states. + + } + } + + + private void deleteArchiveDirectory() { + // Delete archive directory if exists + Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toAbsolutePath(); + try { + FileUtils.deleteDirectory(archivePath.toFile()); + } catch (IOException e) { + + } + } + +} diff --git a/src/test/java/org/qortal/test/PruneTests.java b/src/test/java/org/qortal/test/PruneTests.java new file mode 100644 index 00000000..0914d794 --- /dev/null +++ b/src/test/java/org/qortal/test/PruneTests.java @@ -0,0 +1,91 @@ +package org.qortal.test; + +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.controller.BlockMinter; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.AtUtils; +import org.qortal.test.common.Common; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.*; + +public class PruneTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testPruning() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online + List mintingAndOnlineAccounts = new ArrayList<>(); + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + mintingAndOnlineAccounts.add(aliceSelfShare); + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + + // Mint some blocks + for (int i = 2; i <= 10; i++) + BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Make sure that all blocks have full AT state data and data hash + for (Integer i=2; i <= 10; i++) { + BlockData blockData = repository.getBlockRepository().fromHeight(i); + assertNotNull(blockData.getSignature()); + assertEquals(i, blockData.getHeight()); + List atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(i); + assertNotNull(atStatesDataList); + assertFalse(atStatesDataList.isEmpty()); + ATStateData atStatesData = repository.getATRepository().getATStateAtHeight(atStatesDataList.get(0).getATAddress(), i); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + } + + // Prune blocks 2-5 + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(0, 5); + assertEquals(4, numBlocksPruned); + repository.getBlockRepository().setBlockPruneHeight(6); + + // Prune AT states for blocks 2-5 + int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 5); + assertEquals(4, numATStatesPruned); + repository.getATRepository().setAtPruneHeight(6); + + // Make sure that blocks 2-5 are now missing block data and AT states data + for (Integer i=2; i <= 5; i++) { + BlockData blockData = repository.getBlockRepository().fromHeight(i); + assertNull(blockData); + List atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(i); + assertTrue(atStatesDataList.isEmpty()); + } + + // ... but blocks 6-10 have block data and full AT states data + for (Integer i=6; i <= 10; i++) { + BlockData blockData = repository.getBlockRepository().fromHeight(i); + assertNotNull(blockData.getSignature()); + List atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(i); + assertNotNull(atStatesDataList); + assertFalse(atStatesDataList.isEmpty()); + ATStateData atStatesData = repository.getATRepository().getATStateAtHeight(atStatesDataList.get(0).getATAddress(), i); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + } + } + } + +} diff --git a/src/test/java/org/qortal/test/at/AtRepositoryTests.java b/src/test/java/org/qortal/test/at/AtRepositoryTests.java index 0b302435..8ef4c774 100644 --- a/src/test/java/org/qortal/test/at/AtRepositoryTests.java +++ b/src/test/java/org/qortal/test/at/AtRepositoryTests.java @@ -21,6 +21,7 @@ import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.AtUtils; import org.qortal.test.common.BlockUtils; import org.qortal.test.common.Common; import org.qortal.test.common.TransactionUtils; @@ -35,13 +36,13 @@ public class AtRepositoryTests extends Common { @Test public void testGetATStateAtHeightWithData() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -58,13 +59,13 @@ public class AtRepositoryTests extends Common { @Test public void testGetATStateAtHeightWithoutData() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -87,13 +88,13 @@ public class AtRepositoryTests extends Common { @Test public void testGetLatestATStateWithData() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -111,13 +112,13 @@ public class AtRepositoryTests extends Common { @Test public void testGetLatestATStatePostTrimming() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -144,14 +145,66 @@ public class AtRepositoryTests extends Common { } @Test - public void testGetMatchingFinalATStatesWithoutDataValue() throws DataException { - byte[] creationBytes = buildSimpleAT(); + public void testOrphanTrimmedATStates() throws DataException { + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + int maxTrimHeight = blockchainHeight - 4; + Integer testHeight = maxTrimHeight + 1; + + // Trim AT state data + repository.getATRepository().rebuildLatestAtStates(); + repository.saveChanges(); + repository.getATRepository().trimAtStates(2, maxTrimHeight, 1000); + + // Orphan 3 blocks + // This leaves one more untrimmed block, so the latest AT state should be available + BlockUtils.orphanBlocks(repository, 3); + + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + assertEquals(testHeight, atStateData.getHeight()); + + // We should always have the latest AT state data available + assertNotNull(atStateData.getStateData()); + + // Orphan 1 more block + Exception exception = null; + try { + BlockUtils.orphanBlocks(repository, 1); + } catch (DataException e) { + exception = e; + } + + // Ensure that a DataException is thrown because there is no more AT states data available + assertNotNull(exception); + assertEquals(DataException.class, exception.getClass()); + assertEquals(String.format("Can't find previous AT state data for %s", atAddress), exception.getMessage()); + + // FUTURE: we may be able to retain unique AT states when trimming, to avoid this exception + // and allow orphaning back through blocks with trimmed AT states. + } + } + + @Test + public void testGetMatchingFinalATStatesWithoutDataValue() throws DataException { + byte[] creationBytes = AtUtils.buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -191,13 +244,13 @@ public class AtRepositoryTests extends Common { @Test public void testGetMatchingFinalATStatesWithDataValue() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -237,13 +290,13 @@ public class AtRepositoryTests extends Common { @Test public void testGetBlockATStatesAtHeightWithData() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - doDeploy(repository, deployer, creationBytes, fundingAmount); + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); // Mint a few blocks for (int i = 0; i < 10; ++i) @@ -264,13 +317,13 @@ public class AtRepositoryTests extends Common { @Test public void testGetBlockATStatesAtHeightWithoutData() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - doDeploy(repository, deployer, creationBytes, fundingAmount); + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); // Mint a few blocks for (int i = 0; i < 10; ++i) @@ -297,13 +350,13 @@ public class AtRepositoryTests extends Common { @Test public void testSaveATStateWithData() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -328,13 +381,13 @@ public class AtRepositoryTests extends Common { @Test public void testSaveATStateWithoutData() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -364,67 +417,4 @@ public class AtRepositoryTests extends Common { assertNull(atStateData.getStateData()); } } - - private byte[] buildSimpleAT() { - // Pretend we use 4 values in data segment - int addrCounter = 4; - - // Data segment - ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); - - ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); - - // Two-pass version - for (int pass = 0; pass < 2; ++pass) { - codeByteBuffer.clear(); - - try { - // Stop and wait for next block - codeByteBuffer.put(OpCode.STP_IMD.compile()); - } catch (CompilationException e) { - throw new IllegalStateException("Unable to compile AT?", e); - } - } - - codeByteBuffer.flip(); - - byte[] codeBytes = new byte[codeByteBuffer.limit()]; - codeByteBuffer.get(codeBytes); - - final short ciyamAtVersion = 2; - final short numCallStackPages = 0; - final short numUserStackPages = 0; - final long minActivationAmount = 0L; - - return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "Test AT"; - String description = "Test AT"; - String atType = "Test"; - String tags = "TEST"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - } diff --git a/src/test/java/org/qortal/test/common/AtUtils.java b/src/test/java/org/qortal/test/common/AtUtils.java new file mode 100644 index 00000000..3bc2b235 --- /dev/null +++ b/src/test/java/org/qortal/test/common/AtUtils.java @@ -0,0 +1,81 @@ +package org.qortal.test.common; + +import org.ciyam.at.CompilationException; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.transaction.DeployAtTransaction; + +import java.nio.ByteBuffer; + +public class AtUtils { + + public static byte[] buildSimpleAT() { + // Pretend we use 4 values in data segment + int addrCounter = 4; + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile AT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + public static DeployAtTransaction doDeployAT(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "Test AT"; + String description = "Test AT"; + String atType = "Test"; + String tags = "TEST"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } +} diff --git a/src/test/resources/test-settings-v2-block-archive.json b/src/test/resources/test-settings-v2-block-archive.json new file mode 100644 index 00000000..612c8658 --- /dev/null +++ b/src/test/resources/test-settings-v2-block-archive.json @@ -0,0 +1,11 @@ +{ + "bitcoinNet": "TEST3", + "litecoinNet": "TEST3", + "restrictedApi": false, + "blockchainConfig": "src/test/resources/test-chain-v2.json", + "wipeUnconfirmedOnStart": false, + "testNtpOffset": 0, + "minPeers": 0, + "pruneBlockLimit": 1450, + "repositoryPath": "dbtest" +} From ce60ab8e0071daddaf502b72566f5f2b5a8bdc81 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Sep 2021 10:16:07 +0100 Subject: [PATCH 077/231] Updated naming unit tests - Use the "{\"age\":30}" data to make the tests more similar to some real world data. - Added tests to ensure that registering and orphaning works as expected. --- .../org/qortal/test/naming/MiscTests.java | 68 +++++++++++++++++-- .../org/qortal/test/naming/UpdateTests.java | 14 ++-- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/src/test/java/org/qortal/test/naming/MiscTests.java b/src/test/java/org/qortal/test/naming/MiscTests.java index 23e0e720..625130e1 100644 --- a/src/test/java/org/qortal/test/naming/MiscTests.java +++ b/src/test/java/org/qortal/test/naming/MiscTests.java @@ -13,6 +13,7 @@ import org.qortal.data.transaction.UpdateNameTransactionData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; import org.qortal.test.common.Common; import org.qortal.test.common.TransactionUtils; import org.qortal.test.common.transaction.TestTransaction; @@ -32,7 +33,7 @@ public class MiscTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String name = "initial-name"; - String data = "initial-data"; + String data = "{\"age\":30}"; RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); TransactionUtils.signAndMint(repository, transactionData, alice); @@ -51,7 +52,7 @@ public class MiscTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String name = "test-name"; - String data = "{}"; + String data = "{\"age\":30}"; RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); TransactionUtils.signAndMint(repository, transactionData, alice); @@ -98,7 +99,7 @@ public class MiscTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String name = "test-name"; - String data = "{}"; + String data = "{\"age\":30}"; TransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); TransactionUtils.signAndMint(repository, transactionData, alice); @@ -127,7 +128,7 @@ public class MiscTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String name = alice.getAddress(); - String data = "{}"; + String data = "{\"age\":30}"; RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); Transaction transaction = Transaction.fromData(repository, transactionData); @@ -145,7 +146,7 @@ public class MiscTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String name = "test-name"; - String data = "{}"; + String data = "{\"age\":30}"; TransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); TransactionUtils.signAndMint(repository, transactionData, alice); @@ -162,4 +163,61 @@ public class MiscTests extends Common { } } + // test registering and then orphaning + @Test + public void testRegisterNameAndOrphan() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "test-name"; + String data = "{\"age\":30}"; + + // Ensure the name doesn't exist + assertNull(repository.getNameRepository().fromName(name)); + + // Register the name + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Ensure the name exists and the data is correct + assertEquals(data, repository.getNameRepository().fromName(name).getData()); + + // Orphan the latest block + BlockUtils.orphanBlocks(repository, 1); + + // Ensure the name doesn't exist once again + assertNull(repository.getNameRepository().fromName(name)); + } + } + + // test registering and then orphaning multiple times (to simulate several re-orgs) + @Test + public void testMultipleRegisterNameAndOrphan() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "test-name"; + String data = "{\"age\":30}"; + + for (int i = 0; i < 10; i++) { + + // Ensure the name doesn't exist + assertNull(repository.getNameRepository().fromName(name)); + + // Register the name + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Ensure the name exists and the data is correct + assertEquals(data, repository.getNameRepository().fromName(name).getData()); + + // Orphan the latest block + BlockUtils.orphanBlocks(repository, 1); + + // Ensure the name doesn't exist once again + assertNull(repository.getNameRepository().fromName(name)); + } + } + } + } diff --git a/src/test/java/org/qortal/test/naming/UpdateTests.java b/src/test/java/org/qortal/test/naming/UpdateTests.java index ffbf7177..134d3358 100644 --- a/src/test/java/org/qortal/test/naming/UpdateTests.java +++ b/src/test/java/org/qortal/test/naming/UpdateTests.java @@ -29,7 +29,7 @@ public class UpdateTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String initialName = "initial-name"; - String initialData = "initial-data"; + String initialData = "{\"age\":30}"; TransactionData initialTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, initialData); TransactionUtils.signAndMint(repository, initialTransactionData, alice); @@ -68,7 +68,7 @@ public class UpdateTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String initialName = "initial-name"; - String initialData = "initial-data"; + String initialData = "{\"age\":30}"; TransactionData initialTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, initialData); TransactionUtils.signAndMint(repository, initialTransactionData, alice); @@ -108,7 +108,7 @@ public class UpdateTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String initialName = "initial-name"; - String initialData = "initial-data"; + String initialData = "{\"age\":30}"; TransactionData initialTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, initialData); TransactionUtils.signAndMint(repository, initialTransactionData, alice); @@ -171,7 +171,7 @@ public class UpdateTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String initialName = "initial-name"; - String initialData = "initial-data"; + String initialData = "{\"age\":30}"; TransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, initialData); TransactionUtils.signAndMint(repository, transactionData, alice); @@ -217,7 +217,7 @@ public class UpdateTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String initialName = "initial-name"; - String initialData = "initial-data"; + String initialData = "{\"age\":30}"; TransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, initialData); TransactionUtils.signAndMint(repository, transactionData, alice); @@ -251,7 +251,7 @@ public class UpdateTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String initialName = "initial-name"; - String initialData = "initial-data"; + String initialData = "{\"age\":30}"; TransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, initialData); TransactionUtils.signAndMint(repository, transactionData, alice); @@ -294,7 +294,7 @@ public class UpdateTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String initialName = "initial-name"; - String initialData = "initial-data"; + String initialData = "{\"age\":30}"; TransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, initialData); TransactionUtils.signAndMint(repository, transactionData, alice); From d600a54034a9dd20e92fe5472168efce581cd658 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 14 Sep 2021 20:34:42 +0100 Subject: [PATCH 078/231] Modified name update tests to check the reduced name. --- .../org/qortal/test/naming/UpdateTests.java | 122 +++++++++++++++++- 1 file changed, 120 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/qortal/test/naming/UpdateTests.java b/src/test/java/org/qortal/test/naming/UpdateTests.java index 134d3358..a13b3138 100644 --- a/src/test/java/org/qortal/test/naming/UpdateTests.java +++ b/src/test/java/org/qortal/test/naming/UpdateTests.java @@ -5,6 +5,7 @@ import static org.junit.Assert.*; import org.junit.Before; import org.junit.Test; import org.qortal.account.PrivateKeyAccount; +import org.qortal.data.naming.NameData; import org.qortal.data.transaction.RegisterNameTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.data.transaction.UpdateNameTransactionData; @@ -29,12 +30,21 @@ public class UpdateTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String initialName = "initial-name"; + String initialReducedName = "initia1-name"; String initialData = "{\"age\":30}"; TransactionData initialTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, initialData); TransactionUtils.signAndMint(repository, initialTransactionData, alice); + // Check name, reduced name, and data exist + assertTrue(repository.getNameRepository().nameExists(initialName)); + NameData nameData = repository.getNameRepository().fromName(initialName); + assertEquals("initia1-name", nameData.getReducedName()); + assertEquals(initialData, nameData.getData()); + assertNotNull(repository.getNameRepository().fromReducedName(initialReducedName)); + String newName = "new-name"; + String newReducedName = "new-name"; String newData = ""; TransactionData updateTransactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), initialName, newName, newData); TransactionUtils.signAndMint(repository, updateTransactionData, alice); @@ -42,20 +52,37 @@ public class UpdateTests extends Common { // Check old name no longer exists assertFalse(repository.getNameRepository().nameExists(initialName)); + // Check reduced name no longer exists + assertNull(repository.getNameRepository().fromReducedName(initialReducedName)); + // Check new name exists assertTrue(repository.getNameRepository().nameExists(newName)); + // Check reduced name and data are correct for new name + NameData newNameData = repository.getNameRepository().fromName(newReducedName); + assertEquals(newReducedName, newNameData.getReducedName()); + // Data should remain the same because it was empty in the UpdateNameTransactionData + assertEquals(initialData, newNameData.getData()); + // Check updated timestamp is correct assertEquals((Long) updateTransactionData.getTimestamp(), repository.getNameRepository().fromName(newName).getUpdated()); // orphan and recheck BlockUtils.orphanLastBlock(repository); - // Check new name no longer exists + // Check new name and reduced name no longer exist assertFalse(repository.getNameRepository().nameExists(newName)); + assertNull(repository.getNameRepository().fromReducedName(newReducedName)); - // Check old name exists again + // Check old name and reduced name exist again assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(initialReducedName)); + + // Check data and reduced name are still present for this name + assertTrue(repository.getNameRepository().nameExists(initialName)); + nameData = repository.getNameRepository().fromName(initialName); + assertEquals(initialReducedName, nameData.getReducedName()); + assertEquals(initialData, nameData.getData()); // Check updated timestamp is empty assertNull(repository.getNameRepository().fromName(initialName).getUpdated()); @@ -70,9 +97,15 @@ public class UpdateTests extends Common { String initialName = "initial-name"; String initialData = "{\"age\":30}"; + String constantReducedName = "initia1-name"; + TransactionData initialTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, initialData); TransactionUtils.signAndMint(repository, initialTransactionData, alice); + // Check initial name exists + assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(constantReducedName)); + String newName = "Initial-Name"; String newData = ""; TransactionData updateTransactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), initialName, newName, newData); @@ -83,6 +116,7 @@ public class UpdateTests extends Common { // Check new name exists assertTrue(repository.getNameRepository().nameExists(newName)); + assertNotNull(repository.getNameRepository().fromReducedName(constantReducedName)); // Check updated timestamp is correct assertEquals((Long) updateTransactionData.getTimestamp(), repository.getNameRepository().fromName(newName).getUpdated()); @@ -95,6 +129,7 @@ public class UpdateTests extends Common { // Check old name exists again assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(constantReducedName)); // Check updated timestamp is empty assertNull(repository.getNameRepository().fromName(initialName).getUpdated()); @@ -108,32 +143,43 @@ public class UpdateTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String initialName = "initial-name"; + String initialReducedName = "initia1-name"; String initialData = "{\"age\":30}"; TransactionData initialTransactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, initialData); TransactionUtils.signAndMint(repository, initialTransactionData, alice); + // Check initial name exists + assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(initialReducedName)); + String middleName = "middle-name"; + String middleReducedName = "midd1e-name"; String middleData = ""; TransactionData middleTransactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), initialName, middleName, middleData); TransactionUtils.signAndMint(repository, middleTransactionData, alice); // Check old name no longer exists assertFalse(repository.getNameRepository().nameExists(initialName)); + assertNull(repository.getNameRepository().fromReducedName(initialReducedName)); // Check new name exists assertTrue(repository.getNameRepository().nameExists(middleName)); + assertNotNull(repository.getNameRepository().fromReducedName(middleReducedName)); String newestName = "newest-name"; + String newestReducedName = "newest-name"; String newestData = "newest-data"; TransactionData newestTransactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), middleName, newestName, newestData); TransactionUtils.signAndMint(repository, newestTransactionData, alice); // Check previous name no longer exists assertFalse(repository.getNameRepository().nameExists(middleName)); + assertNull(repository.getNameRepository().fromReducedName(middleReducedName)); // Check newest name exists assertTrue(repository.getNameRepository().nameExists(newestName)); + assertNotNull(repository.getNameRepository().fromReducedName(newestReducedName)); // Check updated timestamp is correct assertEquals((Long) newestTransactionData.getTimestamp(), repository.getNameRepository().fromName(newestName).getUpdated()); @@ -143,9 +189,11 @@ public class UpdateTests extends Common { // Check newest name no longer exists assertFalse(repository.getNameRepository().nameExists(newestName)); + assertNull(repository.getNameRepository().fromReducedName(newestReducedName)); // Check previous name exists again assertTrue(repository.getNameRepository().nameExists(middleName)); + assertNotNull(repository.getNameRepository().fromReducedName(middleReducedName)); // Check updated timestamp is correct assertEquals((Long) middleTransactionData.getTimestamp(), repository.getNameRepository().fromName(middleName).getUpdated()); @@ -155,9 +203,11 @@ public class UpdateTests extends Common { // Check new name no longer exists assertFalse(repository.getNameRepository().nameExists(middleName)); + assertNull(repository.getNameRepository().fromReducedName(middleReducedName)); // Check original name exists again assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(initialReducedName)); // Check updated timestamp is empty assertNull(repository.getNameRepository().fromName(initialName).getUpdated()); @@ -171,11 +221,16 @@ public class UpdateTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String initialName = "initial-name"; + String initialReducedName = "initia1-name"; String initialData = "{\"age\":30}"; TransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, initialData); TransactionUtils.signAndMint(repository, transactionData, alice); + // Check initial name exists + assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(initialReducedName)); + // Don't update name, but update data. // This tests whether reverting a future update/sale can find the correct previous name String middleName = ""; @@ -185,29 +240,35 @@ public class UpdateTests extends Common { // Check old name still exists assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(initialReducedName)); String newestName = "newest-name"; + String newestReducedName = "newest-name"; String newestData = "newest-data"; transactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), initialName, newestName, newestData); TransactionUtils.signAndMint(repository, transactionData, alice); // Check previous name no longer exists assertFalse(repository.getNameRepository().nameExists(initialName)); + assertNull(repository.getNameRepository().fromReducedName(initialReducedName)); // Check newest name exists assertTrue(repository.getNameRepository().nameExists(newestName)); + assertNotNull(repository.getNameRepository().fromReducedName(newestReducedName)); // orphan and recheck BlockUtils.orphanLastBlock(repository); // Check original name exists again assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(initialReducedName)); // orphan and recheck BlockUtils.orphanLastBlock(repository); // Check original name still exists assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(initialReducedName)); } } @@ -217,11 +278,16 @@ public class UpdateTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String initialName = "initial-name"; + String initialReducedName = "initia1-name"; String initialData = "{\"age\":30}"; TransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, initialData); TransactionUtils.signAndMint(repository, transactionData, alice); + // Check initial name exists + assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(initialReducedName)); + String newName = ""; String newData = "new-data"; transactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), initialName, newName, newData); @@ -229,6 +295,7 @@ public class UpdateTests extends Common { // Check name still exists assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(initialReducedName)); // Check data is correct assertEquals(newData, repository.getNameRepository().fromName(initialName).getData()); @@ -238,6 +305,7 @@ public class UpdateTests extends Common { // Check name still exists assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(initialReducedName)); // Check old data restored assertEquals(initialData, repository.getNameRepository().fromName(initialName).getData()); @@ -251,13 +319,19 @@ public class UpdateTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String initialName = "initial-name"; + String initialReducedName = "initia1-name"; String initialData = "{\"age\":30}"; TransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, initialData); TransactionUtils.signAndMint(repository, transactionData, alice); + // Check initial name exists + assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(initialReducedName)); + // Update data String middleName = "middle-name"; + String middleReducedName = "midd1e-name"; String middleData = "middle-data"; transactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), initialName, middleName, middleData); TransactionUtils.signAndMint(repository, transactionData, alice); @@ -266,6 +340,7 @@ public class UpdateTests extends Common { assertEquals(middleData, repository.getNameRepository().fromName(middleName).getData()); String newestName = "newest-name"; + String newestReducedName = "newest-name"; String newestData = "newest-data"; transactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), middleName, newestName, newestData); TransactionUtils.signAndMint(repository, transactionData, alice); @@ -273,6 +348,14 @@ public class UpdateTests extends Common { // Check data is correct assertEquals(newestData, repository.getNameRepository().fromName(newestName).getData()); + // Check initial name no longer exists + assertFalse(repository.getNameRepository().nameExists(initialName)); + assertNull(repository.getNameRepository().fromReducedName(initialReducedName)); + + // Check newest name exists + assertTrue(repository.getNameRepository().nameExists(newestName)); + assertNotNull(repository.getNameRepository().fromReducedName(newestReducedName)); + // orphan and recheck BlockUtils.orphanLastBlock(repository); @@ -284,6 +367,10 @@ public class UpdateTests extends Common { // Check data is correct assertEquals(initialData, repository.getNameRepository().fromName(initialName).getData()); + + // Check initial name exists again + assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(initialReducedName)); } } @@ -294,38 +381,69 @@ public class UpdateTests extends Common { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); String initialName = "initial-name"; + String initialReducedName = "initia1-name"; String initialData = "{\"age\":30}"; TransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, initialData); TransactionUtils.signAndMint(repository, transactionData, alice); + // Check initial name exists + assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(initialReducedName)); + // Don't update data, but update name. // This tests whether reverting a future update/sale can find the correct previous data String middleName = "middle-name"; + String middleReducedName = "midd1e-name"; String middleData = ""; transactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), initialName, middleName, middleData); TransactionUtils.signAndMint(repository, transactionData, alice); + // Check original name no longer exists + assertFalse(repository.getNameRepository().nameExists(initialName)); + assertNull(repository.getNameRepository().fromReducedName(initialReducedName)); + + // Check middle name exists + assertTrue(repository.getNameRepository().nameExists(middleName)); + assertNotNull(repository.getNameRepository().fromReducedName(middleReducedName)); + // Check data is correct assertEquals(initialData, repository.getNameRepository().fromName(middleName).getData()); String newestName = "newest-name"; + String newestReducedName = "newest-name"; String newestData = "newest-data"; transactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), middleName, newestName, newestData); TransactionUtils.signAndMint(repository, transactionData, alice); + // Check middle name no longer exists + assertFalse(repository.getNameRepository().nameExists(middleName)); + assertNull(repository.getNameRepository().fromReducedName(middleReducedName)); + + // Check newest name exists + assertTrue(repository.getNameRepository().nameExists(newestName)); + assertNotNull(repository.getNameRepository().fromReducedName(newestReducedName)); + // Check data is correct assertEquals(newestData, repository.getNameRepository().fromName(newestName).getData()); // orphan and recheck BlockUtils.orphanLastBlock(repository); + // Check middle name exists + assertTrue(repository.getNameRepository().nameExists(middleName)); + assertNotNull(repository.getNameRepository().fromReducedName(middleReducedName)); + // Check data is correct assertEquals(initialData, repository.getNameRepository().fromName(middleName).getData()); // orphan and recheck BlockUtils.orphanLastBlock(repository); + // Check initial name exists + assertTrue(repository.getNameRepository().nameExists(initialName)); + assertNotNull(repository.getNameRepository().fromReducedName(initialReducedName)); + // Check data is correct assertEquals(initialData, repository.getNameRepository().fromName(initialName).getData()); } From cc65a7cd11a4ddc4b9e7df02d5d07f554fa10e33 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 14 Sep 2021 20:38:20 +0100 Subject: [PATCH 079/231] Fixed bug which prevented the "reduced name" from being updated in UPDATE_NAME transactions. Updating a name was incorrectly leaving the existing "reduced name" intact. Thanks to Qortal user @MyBestBet for reporting this bug. --- src/main/java/org/qortal/naming/Name.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/naming/Name.java b/src/main/java/org/qortal/naming/Name.java index c372a8e3..454ade57 100644 --- a/src/main/java/org/qortal/naming/Name.java +++ b/src/main/java/org/qortal/naming/Name.java @@ -78,9 +78,10 @@ public class Name { // Set name's last-updated timestamp this.nameData.setUpdated(updateNameTransactionData.getTimestamp()); - // Update name and data where appropriate + // Update name, reduced name, and data where appropriate if (!updateNameTransactionData.getNewName().isEmpty()) { this.nameData.setName(updateNameTransactionData.getNewName()); + this.nameData.setReducedName(updateNameTransactionData.getReducedNewName()); // If we're changing the name, we need to delete old entry this.repository.getNameRepository().delete(updateNameTransactionData.getName()); @@ -106,6 +107,9 @@ public class Name { // We can find previous 'name' from update transaction this.nameData.setName(updateNameTransactionData.getName()); + // We can derive the previous 'reduced name' from the previous name + this.nameData.setReducedName(Unicode.sanitize(updateNameTransactionData.getName())); + // We might need to hunt for previous data value if (!updateNameTransactionData.getNewData().isEmpty()) this.nameData.setData(findPreviousData(nameReference)); From 33ac1fed2a9fc789969402ce0637785730a4650c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 16 Sep 2021 19:27:17 +0100 Subject: [PATCH 080/231] Revert "Treat a REGISTER_NAME transaction as an UPDATE_NAME if the creator matches." This reverts commit b800fb5846c8cddb949ad03588df1b6da438b04d. --- .../transaction/RegisterNameTransaction.java | 22 +------------- .../org/qortal/test/naming/MiscTests.java | 30 ++----------------- 2 files changed, 4 insertions(+), 48 deletions(-) diff --git a/src/main/java/org/qortal/transaction/RegisterNameTransaction.java b/src/main/java/org/qortal/transaction/RegisterNameTransaction.java index ad20ef1c..66c1fc8b 100644 --- a/src/main/java/org/qortal/transaction/RegisterNameTransaction.java +++ b/src/main/java/org/qortal/transaction/RegisterNameTransaction.java @@ -2,13 +2,11 @@ package org.qortal.transaction; import java.util.Collections; import java.util.List; -import java.util.Objects; import org.qortal.account.Account; import org.qortal.asset.Asset; import org.qortal.block.BlockChain; import org.qortal.crypto.Crypto; -import org.qortal.data.naming.NameData; import org.qortal.data.transaction.RegisterNameTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.naming.Name; @@ -79,32 +77,14 @@ public class RegisterNameTransaction extends Transaction { @Override public ValidationResult isProcessable() throws DataException { // Check the name isn't already taken - if (this.repository.getNameRepository().reducedNameExists(this.registerNameTransactionData.getReducedName())) { - // Name exists, but we'll allow the transaction if it has the same creator - // This is necessary to workaround an issue due to inconsistent data in the Names table on some nodes. - // Without this, the chain can get stuck for a subset of nodes when the name is registered - // for the second time. It's simplest to just treat REGISTER_NAME as UPDATE_NAME if the creator - // matches that of the original registration. - - NameData nameData = this.repository.getNameRepository().fromReducedName(this.registerNameTransactionData.getReducedName()); - if (Objects.equals(this.getCreator().getAddress(), nameData.getOwner())) { - // Transaction creator already owns the name, so it's safe to update it - // Treat this as valid, which also requires skipping the "one name per account" check below. - // Given that the name matches one already registered, we know that it won't exceed the limit. - return ValidationResult.OK; - } - - // Name is already registered to someone else + if (this.repository.getNameRepository().reducedNameExists(this.registerNameTransactionData.getReducedName())) return ValidationResult.NAME_ALREADY_REGISTERED; - } // If accounts are only allowed one registered name then check for this if (BlockChain.getInstance().oneNamePerAccount() && !this.repository.getNameRepository().getNamesByOwner(getRegistrant().getAddress()).isEmpty()) return ValidationResult.MULTIPLE_NAMES_FORBIDDEN; - // FUTURE: when adding more validation, make sure to check the `return ValidationResult.OK` above - return ValidationResult.OK; } diff --git a/src/test/java/org/qortal/test/naming/MiscTests.java b/src/test/java/org/qortal/test/naming/MiscTests.java index 625130e1..46c8b688 100644 --- a/src/test/java/org/qortal/test/naming/MiscTests.java +++ b/src/test/java/org/qortal/test/naming/MiscTests.java @@ -45,9 +45,9 @@ public class MiscTests extends Common { } } - // test trying to register same name twice (with same creator) + // test trying to register same name twice @Test - public void testDuplicateRegisterNameWithSameCreator() throws DataException { + public void testDuplicateRegisterName() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { // Register-name PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); @@ -64,31 +64,7 @@ public class MiscTests extends Common { transaction.sign(alice); ValidationResult result = transaction.importAsUnconfirmed(); - assertTrue("Transaction should be valid because it has the same creator", ValidationResult.OK == result); - } - } - - // test trying to register same name twice (with different creator) - @Test - public void testDuplicateRegisterNameWithDifferentCreator() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - // Register-name - PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); - String name = "test-name"; - String data = "{}"; - - RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); - TransactionUtils.signAndMint(repository, transactionData, alice); - - // duplicate (this time registered by Bob) - PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); - String duplicateName = "TEST-nÁme"; - transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(bob), duplicateName, data); - Transaction transaction = Transaction.fromData(repository, transactionData); - transaction.sign(alice); - - ValidationResult result = transaction.importAsUnconfirmed(); - assertTrue("Transaction should be invalid because it has a different creator", ValidationResult.OK != result); + assertTrue("Transaction should be invalid", ValidationResult.OK != result); } } From 06a2c380bd574384f061287c63d7ea52b4f03d93 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 17 Sep 2021 09:34:10 +0100 Subject: [PATCH 081/231] Updated and added some naming tests. --- .../org/qortal/test/naming/MiscTests.java | 93 +++++++++++++++++-- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/src/test/java/org/qortal/test/naming/MiscTests.java b/src/test/java/org/qortal/test/naming/MiscTests.java index 46c8b688..71bf6b0a 100644 --- a/src/test/java/org/qortal/test/naming/MiscTests.java +++ b/src/test/java/org/qortal/test/naming/MiscTests.java @@ -7,15 +7,13 @@ import java.util.List; import org.junit.Before; import org.junit.Test; import org.qortal.account.PrivateKeyAccount; -import org.qortal.data.transaction.RegisterNameTransactionData; -import org.qortal.data.transaction.TransactionData; -import org.qortal.data.transaction.UpdateNameTransactionData; +import org.qortal.asset.Asset; +import org.qortal.controller.BlockMinter; +import org.qortal.data.transaction.*; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; -import org.qortal.test.common.BlockUtils; -import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; +import org.qortal.test.common.*; import org.qortal.test.common.transaction.TestTransaction; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -68,6 +66,30 @@ public class MiscTests extends Common { } } + // test trying to register same name twice (with different creator) + @Test + public void testDuplicateRegisterNameWithDifferentCreator() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Register-name + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "test-name"; + String data = "{}"; + + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // duplicate (this time registered by Bob) + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + String duplicateName = "TEST-nÁme"; + transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(bob), duplicateName, data); + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(alice); + + ValidationResult result = transaction.importAsUnconfirmed(); + assertTrue("Transaction should be invalid", ValidationResult.OK != result); + } + } + // test register then trying to update another name to existing name @Test public void testUpdateToExistingName() throws DataException { @@ -166,7 +188,55 @@ public class MiscTests extends Common { } } - // test registering and then orphaning multiple times (to simulate several re-orgs) + @Test + public void testOrphanAndReregisterName() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "test-name"; + String data = "{\"age\":30}"; + + // Ensure the name doesn't exist + assertNull(repository.getNameRepository().fromName(name)); + + // Register the name + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Ensure the name exists and the data is correct + assertEquals(data, repository.getNameRepository().fromName(name).getData()); + + // Orphan the latest block + BlockUtils.orphanBlocks(repository, 1); + + // Ensure the name doesn't exist once again + assertNull(repository.getNameRepository().fromName(name)); + + // Now check there is an unconfirmed transaction + assertEquals(1, repository.getTransactionRepository().getUnconfirmedTransactions().size()); + + // Re-mint the block, including the original transaction + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + + // There should no longer be an unconfirmed transaction + assertEquals(0, repository.getTransactionRepository().getUnconfirmedTransactions().size()); + + // Orphan the latest block + BlockUtils.orphanBlocks(repository, 1); + + // There should now be an unconfirmed transaction again + assertEquals(1, repository.getTransactionRepository().getUnconfirmedTransactions().size()); + + // Re-mint the block, including the original transaction + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + + // Ensure there are no unconfirmed transactions + assertEquals(0, repository.getTransactionRepository().getUnconfirmedTransactions().size()); + } + } + + // test registering and then orphaning multiple times, with a different versions of the transaction each time + // we can sometimes end up with more than one version of a transaction, if it is signed and submitted twice @Test public void testMultipleRegisterNameAndOrphan() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { @@ -175,7 +245,7 @@ public class MiscTests extends Common { String name = "test-name"; String data = "{\"age\":30}"; - for (int i = 0; i < 10; i++) { + for (int i = 1; i <= 10; i++) { // Ensure the name doesn't exist assertNull(repository.getNameRepository().fromName(name)); @@ -187,9 +257,16 @@ public class MiscTests extends Common { // Ensure the name exists and the data is correct assertEquals(data, repository.getNameRepository().fromName(name).getData()); + // The number of unconfirmed transactions should equal the number of cycles minus 1 (because one is in a block) + // If more than one made it into a block, this test would fail + assertEquals(i-1, repository.getTransactionRepository().getUnconfirmedTransactions().size()); + // Orphan the latest block BlockUtils.orphanBlocks(repository, 1); + // The number of unconfirmed transactions should equal the number of cycles + assertEquals(i, repository.getTransactionRepository().getUnconfirmedTransactions().size()); + // Ensure the name doesn't exist once again assertNull(repository.getNameRepository().fromName(name)); } From 54e5a65cf046db5126921eab30e095a81e874d70 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 18 Sep 2021 10:41:58 +0100 Subject: [PATCH 082/231] Allow an alternative block to be minted if the chain stalls due to an invalid block If it has been more than 10 minutes since receiving the last valid block, but we have had at least one invalid block since then, this is indicative of a stuck chain due to no valid block candidates. In this case, we want to allow the block minter to mint an alternative candidate so that the chain can continue. This would create a fork at the point of the invalid block, in which two chains (valid an invalid) would diverge. The valid chain could never rejoin the invalid one, however it's likely that the invalid chain would be discarded in favour of the valid one shortly after, on the assumption that the majority of nodes would have picked the valid one. --- .../org/qortal/controller/BlockMinter.java | 21 ++++++++++++++++++- .../org/qortal/controller/Synchronizer.java | 18 ++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 8b6563f2..67a202df 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -44,6 +44,9 @@ public class BlockMinter extends Thread { private static Long lastLogTimestamp; private static Long logTimeout; + // Recovery + public static final long INVALID_BLOCK_RECOVERY_TIMEOUT = 10 * 60 * 1000L; // ms + // Constructors public BlockMinter() { @@ -144,9 +147,25 @@ public class BlockMinter extends Thread { if (peers.size() < Settings.getInstance().getMinBlockchainPeers()) continue; + // If we are stuck on an invalid block, we should allow an alternative to be minted + boolean recoverInvalidBlock = false; + if (Synchronizer.getInstance().timeInvalidBlockLastReceived != null) { + // We've had at least one invalid block + long timeSinceLastValidBlock = NTP.getTime() - Synchronizer.getInstance().timeValidBlockLastReceived; + long timeSinceLastInvalidBlock = NTP.getTime() - Synchronizer.getInstance().timeInvalidBlockLastReceived; + if (timeSinceLastValidBlock > INVALID_BLOCK_RECOVERY_TIMEOUT) { + if (timeSinceLastInvalidBlock < INVALID_BLOCK_RECOVERY_TIMEOUT) { + // Last valid block was more than 10 mins ago, but we've had an invalid block since then + // Assume that the chain has stalled because there is no alternative valid candidate + // Enter recovery mode to allow alternative, valid candidates to be minted + recoverInvalidBlock = true; + } + } + } + // If our latest block isn't recent then we need to synchronize instead of minting, unless we're in recovery mode. if (!peers.isEmpty() && lastBlockData.getTimestamp() < minLatestBlockTimestamp) - if (Controller.getInstance().getRecoveryMode() == false) + if (Controller.getInstance().getRecoveryMode() == false && recoverInvalidBlock == false) continue; // There are enough peers with a recent block and our latest block is recent diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 113af107..30f3c6ee 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -62,6 +62,11 @@ public class Synchronizer { // Keep track of the size of the last re-org, so it can be logged private int lastReorgSize; + // Keep track of invalid blocks so that we don't keep trying to sync them + private List invalidBlockSignatures = new ArrayList<>(); + public Long timeValidBlockLastReceived = null; + public Long timeInvalidBlockLastReceived = null; + private static Synchronizer instance; public enum SynchronizationResult { @@ -526,6 +531,11 @@ public class Synchronizer { // Reset last re-org size as we are starting a new sync round this.lastReorgSize = 0; + // Set the initial value of timeValidBlockLastReceived if it's null + if (this.timeValidBlockLastReceived == null) { + this.timeValidBlockLastReceived = NTP.getTime(); + } + List peerBlockSummaries = new ArrayList<>(); SynchronizationResult findCommonBlockResult = fetchSummariesFromCommonBlock(repository, peer, ourInitialHeight, force, peerBlockSummaries, true); if (findCommonBlockResult != SynchronizationResult.OK) { @@ -980,9 +990,13 @@ public class Synchronizer { if (blockResult != ValidationResult.OK) { LOGGER.info(String.format("Peer %s sent invalid block for height %d, sig %.8s: %s", peer, newBlock.getBlockData().getHeight(), Base58.encode(newBlock.getSignature()), blockResult.name())); + this.timeInvalidBlockLastReceived = NTP.getTime(); return SynchronizationResult.INVALID_DATA; } + // Block is valid + this.timeValidBlockLastReceived = NTP.getTime(); + // Save transactions attached to this block for (Transaction transaction : newBlock.getTransactions()) { TransactionData transactionData = transaction.getTransactionData(); @@ -1068,9 +1082,13 @@ public class Synchronizer { if (blockResult != ValidationResult.OK) { LOGGER.info(String.format("Peer %s sent invalid block for height %d, sig %.8s: %s", peer, ourHeight, Base58.encode(latestPeerSignature), blockResult.name())); + this.timeInvalidBlockLastReceived = NTP.getTime(); return SynchronizationResult.INVALID_DATA; } + // Block is valid + this.timeValidBlockLastReceived = NTP.getTime(); + // Save transactions attached to this block for (Transaction transaction : newBlock.getTransactions()) { TransactionData transactionData = transaction.getTransactionData(); From 44a90b4e12ed2e28944d838e1ead154160bf6a23 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 18 Sep 2021 10:58:05 +0100 Subject: [PATCH 083/231] Keep track of invalid block signatures and avoid peers that return them Until now, a high weight invalid block can cause other valid, lower weight alternatives to be discarded. The solution to this problem is to track invalid blocks and quickly avoid them once discovered. This gives other valid alternative blocks the opportunity to become part of a valid chain, where they would otherwise have been discarded. As with the block minter update, this will cause a fork when the highest weight block candidate is invalid. But it is likely that the fork would be short lived, assuming that the majority of nodes pick the valid chain. --- .../org/qortal/controller/Synchronizer.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 30f3c6ee..97d70027 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -342,6 +342,12 @@ public class Synchronizer { } } + // Ignore this peer if it holds an invalid block + if (this.containsInvalidBlock(peer.getCommonBlockData().getBlockSummariesAfterCommonBlock())) { + LOGGER.debug("Ignoring peer %s because it holds an invalid block", peer); + peers.remove(peer); + } + // Reduce minChainLength if needed. If we don't have any blocks, this peer will be excluded from chain weight comparisons later in the process, so we shouldn't update minChainLength List peerBlockSummaries = peer.getCommonBlockData().getBlockSummariesAfterCommonBlock(); if (peerBlockSummaries != null && peerBlockSummaries.size() > 0) @@ -485,6 +491,36 @@ public class Synchronizer { } + + /* Invalid block signature tracking */ + + private void addInvalidBlockSignature(byte[] signature) { + for (byte[] invalidSignature : invalidBlockSignatures) { + if (Arrays.equals(invalidSignature, signature)) { + // Already present + return; + } + } + invalidBlockSignatures.add(signature); + } + private boolean containsInvalidBlock(List blockSummaries) { + if (blockSummaries == null || invalidBlockSignatures == null) { + return false; + } + + // Loop through supplied block summaries and check each one against our known invalid blocks + for (BlockSummaryData blockSummary : blockSummaries) { + byte[] signature = blockSummary.getSignature(); + for (byte[] invalidSignature : invalidBlockSignatures) { + if (Arrays.equals(signature, invalidSignature)) { + return true; + } + } + } + return false; + } + + /** * Attempt to synchronize blockchain with peer. *

@@ -990,6 +1026,7 @@ public class Synchronizer { if (blockResult != ValidationResult.OK) { LOGGER.info(String.format("Peer %s sent invalid block for height %d, sig %.8s: %s", peer, newBlock.getBlockData().getHeight(), Base58.encode(newBlock.getSignature()), blockResult.name())); + this.addInvalidBlockSignature(newBlock.getSignature()); this.timeInvalidBlockLastReceived = NTP.getTime(); return SynchronizationResult.INVALID_DATA; } @@ -1082,6 +1119,7 @@ public class Synchronizer { if (blockResult != ValidationResult.OK) { LOGGER.info(String.format("Peer %s sent invalid block for height %d, sig %.8s: %s", peer, ourHeight, Base58.encode(latestPeerSignature), blockResult.name())); + this.addInvalidBlockSignature(newBlock.getSignature()); this.timeInvalidBlockLastReceived = NTP.getTime(); return SynchronizationResult.INVALID_DATA; } From 705e7d1cf11a02dbc092e380b6555cf44819827c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 18 Sep 2021 13:28:44 +0100 Subject: [PATCH 084/231] Test name.register() and name.unregister() --- .../org/qortal/test/naming/MiscTests.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/test/java/org/qortal/test/naming/MiscTests.java b/src/test/java/org/qortal/test/naming/MiscTests.java index 71bf6b0a..af04f0f0 100644 --- a/src/test/java/org/qortal/test/naming/MiscTests.java +++ b/src/test/java/org/qortal/test/naming/MiscTests.java @@ -10,6 +10,7 @@ import org.qortal.account.PrivateKeyAccount; import org.qortal.asset.Asset; import org.qortal.controller.BlockMinter; import org.qortal.data.transaction.*; +import org.qortal.naming.Name; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; @@ -273,4 +274,35 @@ public class MiscTests extends Common { } } + @Test + public void testSaveName() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + for (int i=0; i<10; i++) { + + String name = "test-name"; + String data = "{\"age\":30}"; + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + + // Ensure the name doesn't exist + assertNull(repository.getNameRepository().fromName(name)); + + // Register the name + Name nameObj = new Name(repository, transactionData); + nameObj.register(); + + // Ensure the name now exists + assertNotNull(repository.getNameRepository().fromName(name)); + + // Unregister the name + nameObj.unregister(); + + // Ensure the name doesn't exist again + assertNull(repository.getNameRepository().fromName(name)); + + } + } + } + } From 8a7446fb40e8177c8493c43c6a340d6d55ddd590 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 19 Sep 2021 09:34:48 +0100 Subject: [PATCH 085/231] Added "apiKeyDisabled" setting to bypass API key / loopback checking for those who need it. This should only be used if all of the following conditions are true: a) Your node is private and not shared with others b) Port 12391 (API port) isn't forwarded c) You have granted access to specific IP addresses using the "apiWhitelist" setting The node will warn on startup if this setting is used without a sensible access control whitelist. --- src/main/java/org/qortal/api/ApiService.java | 26 +++++++++++++++++++ src/main/java/org/qortal/api/Security.java | 5 ++++ .../java/org/qortal/settings/Settings.java | 7 +++++ 3 files changed, 38 insertions(+) diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index 5baf2c5d..cafba4ae 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -14,6 +14,8 @@ import java.security.SecureRandom; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.eclipse.jetty.http.HttpVersion; import org.eclipse.jetty.rewrite.handler.RedirectPatternRule; import org.eclipse.jetty.rewrite.handler.RewriteHandler; @@ -50,6 +52,8 @@ import org.qortal.settings.Settings; public class ApiService { + private static final Logger LOGGER = LogManager.getLogger(ApiService.class); + private static ApiService instance; private final ResourceConfig config; @@ -203,6 +207,9 @@ public class ApiService { context.addServlet(TradeBotWebSocket.class, "/websockets/crosschain/tradebot"); context.addServlet(PresenceWebSocket.class, "/websockets/presence"); + // Warn about API security if needed + this.checkApiSecurity(); + // Start server this.server.start(); } catch (Exception e) { @@ -222,4 +229,23 @@ public class ApiService { this.server = null; } + private void checkApiSecurity() { + // Warn about API security if needed + boolean allConnectionsAllowed = false; + if (Settings.getInstance().isApiKeyDisabled()) { + for (String pattern : Settings.getInstance().getApiWhitelist()) { + if (pattern.startsWith("0.0.0.0/") || pattern.startsWith("::/") || pattern.endsWith("/0")) { + allConnectionsAllowed = true; + } + } + + if (allConnectionsAllowed) { + LOGGER.warn("Warning: API key validation is currently disabled, and the API whitelist " + + "is allowing all connections. This can be a security risk."); + LOGGER.warn("To fix, set the apiKeyDisabled setting to false, or allow only specific local " + + "IP addresses using the apiWhitelist setting."); + } + } + } + } diff --git a/src/main/java/org/qortal/api/Security.java b/src/main/java/org/qortal/api/Security.java index 448f951a..4e25b03b 100644 --- a/src/main/java/org/qortal/api/Security.java +++ b/src/main/java/org/qortal/api/Security.java @@ -12,6 +12,11 @@ public abstract class Security { public static final String API_KEY_HEADER = "X-API-KEY"; public static void checkApiCallAllowed(HttpServletRequest request) { + // If API key checking has been disabled, we will allow the request in all cases + boolean isApiKeyDisabled = Settings.getInstance().isApiKeyDisabled(); + if (isApiKeyDisabled) + return; + String expectedApiKey = Settings.getInstance().getApiKey(); String passedApiKey = request.getHeader(API_KEY_HEADER); diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index b8884c6c..6543c09b 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -68,6 +68,9 @@ public class Settings { }; private Boolean apiRestricted; private String apiKey = null; + /** Whether to disable API key or loopback address checking + * IMPORTANT: do not disable for shared nodes or low-security local networks */ + private boolean apiKeyDisabled = false; private boolean apiLoggingEnabled = false; private boolean apiDocumentationEnabled = false; // Both of these need to be set for API to use SSL @@ -356,6 +359,10 @@ public class Settings { return this.apiKey; } + public boolean isApiKeyDisabled() { + return this.apiKeyDisabled; + } + public boolean isApiLoggingEnabled() { return this.apiLoggingEnabled; } From 47a34c2f54b67e61b26ffdb42b3ee6aac7dd5bdb Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 19 Sep 2021 17:33:04 +0100 Subject: [PATCH 086/231] Validate blocks in syncToPeerChain() before orphaning This prevents a valid block candidate being discarded in favour of an invalid one. We can't actually validate a block before orphaning (because it will fail due to various reasons such as already existing transactions, an existing block with the same height, etc) so we will instead just check the signature against the list of known invalid blocks. --- .../org/qortal/controller/Synchronizer.java | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 97d70027..f8ea0ec0 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -343,7 +343,7 @@ public class Synchronizer { } // Ignore this peer if it holds an invalid block - if (this.containsInvalidBlock(peer.getCommonBlockData().getBlockSummariesAfterCommonBlock())) { + if (this.containsInvalidBlockSummary(peer.getCommonBlockData().getBlockSummariesAfterCommonBlock())) { LOGGER.debug("Ignoring peer %s because it holds an invalid block", peer); peers.remove(peer); } @@ -503,7 +503,7 @@ public class Synchronizer { } invalidBlockSignatures.add(signature); } - private boolean containsInvalidBlock(List blockSummaries) { + private boolean containsInvalidBlockSummary(List blockSummaries) { if (blockSummaries == null || invalidBlockSignatures == null) { return false; } @@ -519,6 +519,21 @@ public class Synchronizer { } return false; } + private boolean containsInvalidBlockSignature(List blockSignatures) { + if (blockSignatures == null || invalidBlockSignatures == null) { + return false; + } + + // Loop through supplied block signatures and check each one against our known invalid blocks + for (byte[] signature : blockSignatures) { + for (byte[] invalidSignature : invalidBlockSignatures) { + if (Arrays.equals(signature, invalidSignature)) { + return true; + } + } + } + return false; + } /** @@ -920,6 +935,12 @@ public class Synchronizer { break; } + // Catch a block with an invalid signature before orphaning, so that we retain our existing valid candidate + if (this.containsInvalidBlockSignature(peerBlockSignatures)) { + LOGGER.info(String.format("Peer %s sent invalid block signature: %.8s", peer, Base58.encode(latestPeerSignature))); + return SynchronizationResult.INVALID_DATA; + } + byte[] nextPeerSignature = peerBlockSignatures.get(0); int nextHeight = height + 1; From a4f5124b61f10353942d69534145630ca325295b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 19 Sep 2021 19:46:48 +0100 Subject: [PATCH 087/231] Delete signatures from the invalidBlockSignatures array if we haven't seen them in over 1 hour This is necessary because it's possible (in theory) for a block to be considered invalid due to an internal failure such as an SQLException. This gives them more chances to be considered valid again. 1 hour is more than enough time for the node to find an alternate valid chain if there is one available. --- .../org/qortal/controller/Synchronizer.java | 57 +++++++++++++------ 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index f8ea0ec0..2487a1f7 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -3,12 +3,9 @@ package org.qortal.controller; import java.math.BigInteger; import java.text.DecimalFormat; import java.text.NumberFormat; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; +import java.util.*; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; -import java.util.Iterator; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -63,7 +60,7 @@ public class Synchronizer { private int lastReorgSize; // Keep track of invalid blocks so that we don't keep trying to sync them - private List invalidBlockSignatures = new ArrayList<>(); + private Map invalidBlockSignatures = Collections.synchronizedMap(new HashMap<>()); public Long timeValidBlockLastReceived = null; public Long timeInvalidBlockLastReceived = null; @@ -495,23 +492,42 @@ public class Synchronizer { /* Invalid block signature tracking */ private void addInvalidBlockSignature(byte[] signature) { - for (byte[] invalidSignature : invalidBlockSignatures) { - if (Arrays.equals(invalidSignature, signature)) { - // Already present - return; + Long now = NTP.getTime(); + if (now == null) { + return; + } + + // Add or update existing entry + String sig58 = Base58.encode(signature); + invalidBlockSignatures.put(sig58, now); + } + private void deleteOlderInvalidSignatures(Long now) { + if (now == null) { + return; + } + + // Delete signatures with older timestamps + Iterator it = invalidBlockSignatures.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry pair = (Map.Entry)it.next(); + Long lastSeen = (Long) pair.getValue(); + + // Remove signature if we haven't seen it for more than 1 hour + if (now - lastSeen > 60 * 60 * 1000L) { + it.remove(); } } - invalidBlockSignatures.add(signature); } private boolean containsInvalidBlockSummary(List blockSummaries) { if (blockSummaries == null || invalidBlockSignatures == null) { return false; } - // Loop through supplied block summaries and check each one against our known invalid blocks - for (BlockSummaryData blockSummary : blockSummaries) { - byte[] signature = blockSummary.getSignature(); - for (byte[] invalidSignature : invalidBlockSignatures) { + // Loop through our known invalid blocks and check each one against supplied block summaries + for (String invalidSignature58 : invalidBlockSignatures.keySet()) { + byte[] invalidSignature = Base58.decode(invalidSignature58); + for (BlockSummaryData blockSummary : blockSummaries) { + byte[] signature = blockSummary.getSignature(); if (Arrays.equals(signature, invalidSignature)) { return true; } @@ -524,9 +540,10 @@ public class Synchronizer { return false; } - // Loop through supplied block signatures and check each one against our known invalid blocks - for (byte[] signature : blockSignatures) { - for (byte[] invalidSignature : invalidBlockSignatures) { + // Loop through our known invalid blocks and check each one against supplied block signatures + for (String invalidSignature58 : invalidBlockSignatures.keySet()) { + byte[] invalidSignature = Base58.decode(invalidSignature58); + for (byte[] signature : blockSignatures) { if (Arrays.equals(signature, invalidSignature)) { return true; } @@ -583,10 +600,14 @@ public class Synchronizer { this.lastReorgSize = 0; // Set the initial value of timeValidBlockLastReceived if it's null + Long now = NTP.getTime(); if (this.timeValidBlockLastReceived == null) { - this.timeValidBlockLastReceived = NTP.getTime(); + this.timeValidBlockLastReceived = now; } + // Delete invalid signatures with older timestamps + this.deleteOlderInvalidSignatures(now); + List peerBlockSummaries = new ArrayList<>(); SynchronizationResult findCommonBlockResult = fetchSummariesFromCommonBlock(repository, peer, ourInitialHeight, force, peerBlockSummaries, true); if (findCommonBlockResult != SynchronizationResult.OK) { From 3b156bc5c9db76fba02a8a07733d1a96d4caae35 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 19 Sep 2021 20:23:59 +0100 Subject: [PATCH 088/231] Added database integrity check for registered names This ensures that all name-related transactions have resulted in correct entries in the Names table. A bug in the code has resulted in some nodes having missing data in their Names table. If this process finds a missing name, it will log it and add the name. Missing names are added, but ownership issues are only logged. The known bug wasn't related to ownership, so the logging is only to alert us to any issues that may arise in the future. In hindsight, the code could be rewritten to store all three transaction types in a single list, but this current approach has had a lot of testing, so it is best to stick with it for now. --- .../org/qortal/controller/Controller.java | 5 + .../NamesDatabaseIntegrityCheck.java | 296 ++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index bb990b17..975873da 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -46,6 +46,7 @@ import org.qortal.block.Block; import org.qortal.block.BlockChain; import org.qortal.block.BlockChain.BlockTimingByHeight; import org.qortal.controller.Synchronizer.SynchronizationResult; +import org.qortal.controller.repository.NamesDatabaseIntegrityCheck; import org.qortal.controller.tradebot.TradeBot; import org.qortal.crypto.Crypto; import org.qortal.data.account.MintingAccountData; @@ -428,6 +429,10 @@ public class Controller extends Thread { return; // Not System.exit() so that GUI can display error } + // Check database integrity + NamesDatabaseIntegrityCheck namesDatabaseIntegrityCheck = new NamesDatabaseIntegrityCheck(); + namesDatabaseIntegrityCheck.runIntegrityCheck(); + LOGGER.info("Validating blockchain"); try { BlockChain.validate(); diff --git a/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java new file mode 100644 index 00000000..3760f032 --- /dev/null +++ b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java @@ -0,0 +1,296 @@ +package org.qortal.controller.repository; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.account.PublicKeyAccount; +import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; +import org.qortal.data.naming.NameData; +import org.qortal.data.transaction.BuyNameTransactionData; +import org.qortal.data.transaction.RegisterNameTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.data.transaction.UpdateNameTransactionData; +import org.qortal.naming.Name; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.transaction.Transaction.TransactionType; +import org.qortal.utils.Base58; + +import java.util.*; + +public class NamesDatabaseIntegrityCheck { + + private static final Logger LOGGER = LogManager.getLogger(NamesDatabaseIntegrityCheck.class); + + private static final List REGISTER_NAME_TX_TYPE = Collections.singletonList(TransactionType.REGISTER_NAME); + private static final List UPDATE_NAME_TX_TYPE = Collections.singletonList(TransactionType.UPDATE_NAME); + private static final List BUY_NAME_TX_TYPE = Collections.singletonList(TransactionType.BUY_NAME); + + private List registerNameTransactions; + private List updateNameTransactions; + private List buyNameTransactions; + + public void runIntegrityCheck() { + boolean integrityCheckFailed = false; + boolean corrected = false; + try (final Repository repository = RepositoryManager.getRepository()) { + + // Fetch all the (confirmed) name-related transactions + this.fetchRegisterNameTransactions(repository); + this.fetchUpdateNameTransactions(repository); + this.fetchBuyNameTransactions(repository); + + // Loop through each REGISTER_NAME txn signature and request the full transaction data + for (RegisterNameTransactionData registerNameTransactionData : this.registerNameTransactions) { + String registeredName = registerNameTransactionData.getName(); + NameData nameData = repository.getNameRepository().fromName(registeredName); + + // Check to see if this name has been updated or bought at any point + TransactionData latestUpdate = this.fetchLatestModificationTransactionInvolvingName(registeredName); + if (latestUpdate == null) { + // Name was never updated once registered + // We expect this name to still be registered to this transaction's creator + + if (nameData == null) { + LOGGER.info("Error: registered name {} doesn't exist in Names table. Adding...", registeredName); + integrityCheckFailed = true; + + // Register the name + Name name = new Name(repository, registerNameTransactionData); + name.register(); + repository.saveChanges(); + corrected = true; + continue; + } + else { + //LOGGER.info("Registered name {} is correctly registered", registeredName); + } + + // Check the owner is correct + PublicKeyAccount creator = new PublicKeyAccount(repository, registerNameTransactionData.getCreatorPublicKey()); + if (!Objects.equals(creator.getAddress(), nameData.getOwner())) { + LOGGER.info("Error: registered name {} is owned by {}, but it should be {}", + registeredName, nameData.getOwner(), creator.getAddress()); + integrityCheckFailed = true; + + // FUTURE: Fix the name's owner if we ever see the above log entry + } + else { + //LOGGER.info("Registered name {} has the correct owner", registeredName); + } + } + else { + // Check if owner is correct after update + + // Check for name updates + if (latestUpdate instanceof UpdateNameTransactionData) { + UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) latestUpdate; + PublicKeyAccount creator = new PublicKeyAccount(repository, updateNameTransactionData.getCreatorPublicKey()); + + // When this name is the "new name", we expect the current owner to match the txn creator + if (Objects.equals(updateNameTransactionData.getNewName(), registeredName)) { + if (!Objects.equals(creator.getAddress(), nameData.getOwner())) { + LOGGER.info("Error: registered name {} is owned by {}, but it should be {}", + registeredName, nameData.getOwner(), creator.getAddress()); + integrityCheckFailed = true; + + // FUTURE: Fix the name's owner if we ever see the above log entry + } else { + //LOGGER.info("Registered name {} has the correct owner after being updated", registeredName); + } + } + + // When this name is the old name, we expect the "new name"'s owner to match the txn creator + // The old name will then be unregistered, or re-registered. + // FUTURE: check database integrity for names that have been updated and then the original name re-registered + else if (Objects.equals(updateNameTransactionData.getName(), registeredName)) { + NameData newNameData = repository.getNameRepository().fromName(updateNameTransactionData.getNewName()); + if (!Objects.equals(creator.getAddress(), newNameData.getOwner())) { + LOGGER.info("Error: registered name {} is owned by {}, but it should be {}", + updateNameTransactionData.getNewName(), newNameData.getOwner(), creator.getAddress()); + integrityCheckFailed = true; + + // FUTURE: Fix the name's owner if we ever see the above log entry + } else { + //LOGGER.info("Registered name {} has the correct owner after being updated", updateNameTransactionData.getNewName()); + } + } + + else { + LOGGER.info("Unhandled update case for name {}", registeredName); + } + } + + // Check for name sales + else if (latestUpdate instanceof BuyNameTransactionData) { + BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) latestUpdate; + PublicKeyAccount creator = new PublicKeyAccount(repository, buyNameTransactionData.getCreatorPublicKey()); + if (!Objects.equals(creator.getAddress(), nameData.getOwner())) { + LOGGER.info("Error: registered name {} is owned by {}, but it should be {}", + registeredName, nameData.getOwner(), creator.getAddress()); + integrityCheckFailed = true; + + // FUTURE: Fix the name's owner if we ever see the above log entry + } else { + //LOGGER.info("Registered name {} has the correct owner after being bought", registeredName); + } + } + + else { + LOGGER.info("Unhandled case for name {}", registeredName); + } + + } + + } + + } catch (DataException e) { + LOGGER.warn(String.format("Repository issue trying to trim online accounts signatures: %s", e.getMessage())); + integrityCheckFailed = true; + } + + if (integrityCheckFailed) { + if (corrected) { + LOGGER.info("Registered names database integrity check failed, but corrections were made. If this " + + "problem persists after restarting the node, you may need to switch to a recent bootstrap."); + } + else { + LOGGER.info("Registered names database integrity check failed. Bootstrapping is recommended."); + } + } else { + LOGGER.info("Registered names database integrity check passed."); + } + } + + private void fetchRegisterNameTransactions(Repository repository) throws DataException { + List registerNameTransactions = new ArrayList<>(); + + // Fetch all the confirmed REGISTER_NAME transaction signatures + List registerNameSigs = repository.getTransactionRepository().getSignaturesMatchingCriteria( + null, null, null, REGISTER_NAME_TX_TYPE, null, null, + ConfirmationStatus.CONFIRMED, null, null, false); + + for (byte[] signature : registerNameSigs) { + // LOGGER.info("Fetching REGISTER_NAME transaction from signature {}...", Base58.encode(signature)); + + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + if (!(transactionData instanceof RegisterNameTransactionData)) { + LOGGER.info("REGISTER_NAME transaction signature {} not found", Base58.encode(signature)); + continue; + } + RegisterNameTransactionData registerNameTransactionData = (RegisterNameTransactionData) transactionData; + registerNameTransactions.add(registerNameTransactionData); + } + this.registerNameTransactions = registerNameTransactions; + } + + private void fetchUpdateNameTransactions(Repository repository) throws DataException { + List updateNameTransactions = new ArrayList<>(); + + // Fetch all the confirmed REGISTER_NAME transaction signatures + List updateNameSigs = repository.getTransactionRepository().getSignaturesMatchingCriteria( + null, null, null, UPDATE_NAME_TX_TYPE, null, null, + ConfirmationStatus.CONFIRMED, null, null, false); + + for (byte[] signature : updateNameSigs) { + // LOGGER.info("Fetching UPDATE_NAME transaction from signature {}...", Base58.encode(signature)); + + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + if (!(transactionData instanceof UpdateNameTransactionData)) { + LOGGER.info("UPDATE_NAME transaction signature {} not found", Base58.encode(signature)); + continue; + } + UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) transactionData; + updateNameTransactions.add(updateNameTransactionData); + } + this.updateNameTransactions = updateNameTransactions; + } + + private void fetchBuyNameTransactions(Repository repository) throws DataException { + List buyNameTransactions = new ArrayList<>(); + + // Fetch all the confirmed REGISTER_NAME transaction signatures + List buyNameSigs = repository.getTransactionRepository().getSignaturesMatchingCriteria( + null, null, null, BUY_NAME_TX_TYPE, null, null, + ConfirmationStatus.CONFIRMED, null, null, false); + + for (byte[] signature : buyNameSigs) { + // LOGGER.info("Fetching BUY_NAME transaction from signature {}...", Base58.encode(signature)); + + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + if (!(transactionData instanceof BuyNameTransactionData)) { + LOGGER.info("BUY_NAME transaction signature {} not found", Base58.encode(signature)); + continue; + } + BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) transactionData; + buyNameTransactions.add(buyNameTransactionData); + } + this.buyNameTransactions = buyNameTransactions; + } + + private List fetchUpdateTransactionsInvolvingName(String registeredName) { + List matchedTransactions = new ArrayList<>(); + + for (UpdateNameTransactionData updateNameTransactionData : this.updateNameTransactions) { + if (Objects.equals(updateNameTransactionData.getName(), registeredName) || + Objects.equals(updateNameTransactionData.getNewName(), registeredName)) { + + matchedTransactions.add(updateNameTransactionData); + } + } + return matchedTransactions; + } + + private List fetchBuyTransactionsInvolvingName(String registeredName) { + List matchedTransactions = new ArrayList<>(); + + for (BuyNameTransactionData buyNameTransactionData : this.buyNameTransactions) { + if (Objects.equals(buyNameTransactionData.getName(), registeredName)) { + + matchedTransactions.add(buyNameTransactionData); + } + } + return matchedTransactions; + } + + private TransactionData fetchLatestModificationTransactionInvolvingName(String registeredName) { + List latestTransactions = new ArrayList<>(); + + List updates = this.fetchUpdateTransactionsInvolvingName(registeredName); + List buys = this.fetchBuyTransactionsInvolvingName(registeredName); + + // Get the latest updates for this name + UpdateNameTransactionData latestUpdateToName = updates.stream() + .filter(update -> update.getNewName().equals(registeredName)) + .max(Comparator.comparing(UpdateNameTransactionData::getTimestamp)) + .orElse(null); + if (latestUpdateToName != null) { + latestTransactions.add(latestUpdateToName); + } + + UpdateNameTransactionData latestUpdateFromName = updates.stream() + .filter(update -> update.getName().equals(registeredName)) + .max(Comparator.comparing(UpdateNameTransactionData::getTimestamp)) + .orElse(null); + if (latestUpdateFromName != null) { + latestTransactions.add(latestUpdateFromName); + } + + // Get the latest buy for this name + BuyNameTransactionData latestBuyForName = buys.stream() + .filter(update -> update.getName().equals(registeredName)) + .max(Comparator.comparing(BuyNameTransactionData::getTimestamp)) + .orElse(null); + if (latestBuyForName != null) { + latestTransactions.add(latestBuyForName); + } + + // Get the latest name-related transaction of any type + TransactionData latestUpdate = latestTransactions.stream() + .max(Comparator.comparing(TransactionData::getTimestamp)) + .orElse(null); + + return latestUpdate; + } + +} From 39d5ce19e20a7c15103591f92f3028e8f9c491ad Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 19 Sep 2021 20:24:12 +0100 Subject: [PATCH 089/231] Removed unused import. --- src/test/java/org/qortal/test/naming/MiscTests.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/org/qortal/test/naming/MiscTests.java b/src/test/java/org/qortal/test/naming/MiscTests.java index af04f0f0..84fe3351 100644 --- a/src/test/java/org/qortal/test/naming/MiscTests.java +++ b/src/test/java/org/qortal/test/naming/MiscTests.java @@ -7,7 +7,6 @@ import java.util.List; import org.junit.Before; import org.junit.Test; import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; import org.qortal.controller.BlockMinter; import org.qortal.data.transaction.*; import org.qortal.naming.Name; From 449761b6ca39a00da5fe427ddba3d2fb8dcfd3a8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 22 Sep 2021 08:15:23 +0100 Subject: [PATCH 090/231] Rework of "Names" integrity check Problem: The "Names" table (the latest state of each name) drifts out of sync with the name-related transaction history on a subset of nodes for some unknown and seemingly difficult to find reason. Solution: Treat the "Names" table as a cache that can be rebuilt at any time. It now works like this: - On node startup, rebuild the entire Names table by replaying the transaction history of all registered names. Includes registrations, updates, buys and sells. - Add a "pre-process" stage to block/transaction processing. If the block contains a name related transaction, rebuild the Names cache for any names referenced by these transactions before validating anything. The existing "integrity check" has been modified to just check basic attributes based on the latest transaction for a name. It will log if there are any inconsistencies found, but won't correct anything. This adds confidence that the rebuild has worked correctly. There are also multiple unit tests to ensure that the rebuilds are coping with various different scenarios. --- src/main/java/org/qortal/block/Block.java | 17 + .../org/qortal/controller/BlockMinter.java | 2 + .../org/qortal/controller/Controller.java | 3 +- .../org/qortal/controller/Synchronizer.java | 4 + .../NamesDatabaseIntegrityCheck.java | 410 +++++++++++------- src/main/java/org/qortal/naming/Name.java | 4 + .../transaction/AccountFlagsTransaction.java | 5 + .../transaction/AccountLevelTransaction.java | 5 + .../transaction/AddGroupAdminTransaction.java | 7 +- .../transaction/ArbitraryTransaction.java | 5 + .../org/qortal/transaction/AtTransaction.java | 5 + .../transaction/BuyNameTransaction.java | 12 + .../CancelAssetOrderTransaction.java | 5 + .../CancelGroupBanTransaction.java | 5 + .../CancelGroupInviteTransaction.java | 5 + .../CancelSellNameTransaction.java | 5 + .../qortal/transaction/ChatTransaction.java | 5 + .../CreateAssetOrderTransaction.java | 5 + .../transaction/CreateGroupTransaction.java | 5 + .../transaction/CreatePollTransaction.java | 5 + .../transaction/DeployAtTransaction.java | 5 + .../transaction/GenesisTransaction.java | 5 + .../transaction/GroupApprovalTransaction.java | 5 + .../transaction/GroupBanTransaction.java | 5 + .../transaction/GroupInviteTransaction.java | 5 + .../transaction/GroupKickTransaction.java | 5 + .../transaction/IssueAssetTransaction.java | 5 + .../transaction/JoinGroupTransaction.java | 5 + .../transaction/LeaveGroupTransaction.java | 5 + .../transaction/MessageTransaction.java | 5 + .../transaction/MultiPaymentTransaction.java | 5 + .../transaction/PaymentTransaction.java | 5 + .../transaction/PresenceTransaction.java | 5 + .../transaction/PublicizeTransaction.java | 5 + .../transaction/RegisterNameTransaction.java | 12 + .../RemoveGroupAdminTransaction.java | 7 +- .../transaction/RewardShareTransaction.java | 5 + .../transaction/SellNameTransaction.java | 12 + .../transaction/SetGroupTransaction.java | 5 + .../org/qortal/transaction/Transaction.java | 10 + .../transaction/TransferAssetTransaction.java | 5 + .../transaction/TransferPrivsTransaction.java | 5 + .../transaction/UpdateAssetTransaction.java | 5 + .../transaction/UpdateGroupTransaction.java | 5 + .../transaction/UpdateNameTransaction.java | 18 + .../transaction/VoteOnPollTransaction.java | 5 + .../qortal/test/naming/IntegrityTests.java | 345 +++++++++++++++ 47 files changed, 877 insertions(+), 151 deletions(-) create mode 100644 src/test/java/org/qortal/test/naming/IntegrityTests.java diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 1a7b48fe..af5c6b01 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -41,12 +41,14 @@ import org.qortal.data.block.BlockData; import org.qortal.data.block.BlockSummaryData; import org.qortal.data.block.BlockTransactionData; import org.qortal.data.network.OnlineAccountData; +import org.qortal.data.transaction.RegisterNameTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.repository.ATRepository; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.TransactionRepository; import org.qortal.transaction.AtTransaction; +import org.qortal.transaction.RegisterNameTransaction; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.ApprovalStatus; import org.qortal.transaction.Transaction.TransactionType; @@ -1282,6 +1284,21 @@ public class Block { return mintingAccount.canMint(); } + /** + * Pre-process block, and its transactions. + * This allows for any database integrity checks prior to validation. + * This is called before isValid() and process() + * + * @throws DataException + */ + public void preProcess() throws DataException { + List blocksTransactions = this.getTransactions(); + + for (Transaction transaction : blocksTransactions) { + transaction.preProcess(); + } + } + /** * Process block, and its transactions, adding them to the blockchain. * diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 67a202df..0cf33f43 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -249,6 +249,8 @@ public class BlockMinter extends Thread { if (testBlock.isTimestampValid() != ValidationResult.OK) continue; + testBlock.preProcess(); + // Is new block valid yet? (Before adding unconfirmed transactions) ValidationResult result = testBlock.isValid(); if (result != ValidationResult.OK) { diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 975873da..f9e681ab 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -429,8 +429,9 @@ public class Controller extends Thread { return; // Not System.exit() so that GUI can display error } - // Check database integrity + // Rebuild Names table and check database integrity NamesDatabaseIntegrityCheck namesDatabaseIntegrityCheck = new NamesDatabaseIntegrityCheck(); + namesDatabaseIntegrityCheck.rebuildAllNames(); namesDatabaseIntegrityCheck.runIntegrityCheck(); LOGGER.info("Validating blockchain"); diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 2487a1f7..b5bce3c5 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -1064,6 +1064,8 @@ public class Synchronizer { if (Controller.isStopping()) return SynchronizationResult.SHUTTING_DOWN; + newBlock.preProcess(); + ValidationResult blockResult = newBlock.isValid(); if (blockResult != ValidationResult.OK) { LOGGER.info(String.format("Peer %s sent invalid block for height %d, sig %.8s: %s", peer, @@ -1157,6 +1159,8 @@ public class Synchronizer { for (Transaction transaction : newBlock.getTransactions()) transaction.setInitialApprovalStatus(); + newBlock.preProcess(); + ValidationResult blockResult = newBlock.isValid(); if (blockResult != ValidationResult.OK) { LOGGER.info(String.format("Peer %s sent invalid block for height %d, sig %.8s: %s", peer, diff --git a/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java index 3760f032..f12bd14a 100644 --- a/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java +++ b/src/main/java/org/qortal/controller/repository/NamesDatabaseIntegrityCheck.java @@ -5,16 +5,13 @@ import org.apache.logging.log4j.Logger; import org.qortal.account.PublicKeyAccount; import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; import org.qortal.data.naming.NameData; -import org.qortal.data.transaction.BuyNameTransactionData; -import org.qortal.data.transaction.RegisterNameTransactionData; -import org.qortal.data.transaction.TransactionData; -import org.qortal.data.transaction.UpdateNameTransactionData; +import org.qortal.data.transaction.*; import org.qortal.naming.Name; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.transaction.Transaction.TransactionType; -import org.qortal.utils.Base58; +import org.qortal.utils.Unicode; import java.util.*; @@ -22,31 +19,127 @@ public class NamesDatabaseIntegrityCheck { private static final Logger LOGGER = LogManager.getLogger(NamesDatabaseIntegrityCheck.class); - private static final List REGISTER_NAME_TX_TYPE = Collections.singletonList(TransactionType.REGISTER_NAME); - private static final List UPDATE_NAME_TX_TYPE = Collections.singletonList(TransactionType.UPDATE_NAME); - private static final List BUY_NAME_TX_TYPE = Collections.singletonList(TransactionType.BUY_NAME); + private static final List ALL_NAME_TX_TYPE = Arrays.asList( + TransactionType.REGISTER_NAME, + TransactionType.UPDATE_NAME, + TransactionType.BUY_NAME, + TransactionType.SELL_NAME + ); - private List registerNameTransactions; - private List updateNameTransactions; - private List buyNameTransactions; + private List nameTransactions = new ArrayList<>(); + + public int rebuildName(String name, Repository repository) { + int modificationCount = 0; + try { + List transactions = this.fetchAllTransactionsInvolvingName(name, repository); + if (transactions.isEmpty()) { + // This name was never registered, so there's nothing to do + return modificationCount; + } + + // Loop through each past transaction and re-apply it to the Names table + for (TransactionData currentTransaction : transactions) { + + // Process REGISTER_NAME transactions + if (currentTransaction.getType() == TransactionType.REGISTER_NAME) { + RegisterNameTransactionData registerNameTransactionData = (RegisterNameTransactionData) currentTransaction; + Name nameObj = new Name(repository, registerNameTransactionData); + nameObj.register(); + modificationCount++; + LOGGER.trace("Processed REGISTER_NAME transaction for name {}", name); + } + + // Process UPDATE_NAME transactions + if (currentTransaction.getType() == TransactionType.UPDATE_NAME) { + UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) currentTransaction; + + if (Objects.equals(updateNameTransactionData.getNewName(), name) && + !Objects.equals(updateNameTransactionData.getName(), updateNameTransactionData.getNewName())) { + // This renames an existing name, so we need to process that instead + this.rebuildName(updateNameTransactionData.getName(), repository); + } + else { + Name nameObj = new Name(repository, name); + if (nameObj != null && nameObj.getNameData() != null) { + nameObj.update(updateNameTransactionData); + modificationCount++; + LOGGER.trace("Processed UPDATE_NAME transaction for name {}", name); + } else { + // Something went wrong + throw new DataException(String.format("Name data not found for name %s", updateNameTransactionData.getName())); + } + } + } + + // Process SELL_NAME transactions + if (currentTransaction.getType() == TransactionType.SELL_NAME) { + SellNameTransactionData sellNameTransactionData = (SellNameTransactionData) currentTransaction; + Name nameObj = new Name(repository, sellNameTransactionData.getName()); + if (nameObj != null && nameObj.getNameData() != null) { + nameObj.sell(sellNameTransactionData); + modificationCount++; + LOGGER.trace("Processed SELL_NAME transaction for name {}", name); + } + else { + // Something went wrong + throw new DataException(String.format("Name data not found for name %s", sellNameTransactionData.getName())); + } + } + + // Process BUY_NAME transactions + if (currentTransaction.getType() == TransactionType.BUY_NAME) { + BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) currentTransaction; + Name nameObj = new Name(repository, buyNameTransactionData.getName()); + if (nameObj != null && nameObj.getNameData() != null) { + nameObj.buy(buyNameTransactionData); + modificationCount++; + LOGGER.trace("Processed BUY_NAME transaction for name {}", name); + } + else { + // Something went wrong + throw new DataException(String.format("Name data not found for name %s", buyNameTransactionData.getName())); + } + } + } + + } catch (DataException e) { + LOGGER.info("Unable to run integrity check for name {}: {}", name, e.getMessage()); + } + + return modificationCount; + } + + public int rebuildAllNames() { + int modificationCount = 0; + try (final Repository repository = RepositoryManager.getRepository()) { + List names = this.fetchAllNames(repository); + for (String name : names) { + modificationCount += this.rebuildName(name, repository); + } + repository.saveChanges(); + } + catch (DataException e) { + LOGGER.info("Error when running integrity check for all names: {}", e.getMessage()); + } + + //LOGGER.info("modificationCount: {}", modificationCount); + return modificationCount; + } public void runIntegrityCheck() { boolean integrityCheckFailed = false; - boolean corrected = false; try (final Repository repository = RepositoryManager.getRepository()) { - // Fetch all the (confirmed) name-related transactions - this.fetchRegisterNameTransactions(repository); - this.fetchUpdateNameTransactions(repository); - this.fetchBuyNameTransactions(repository); + // Fetch all the (confirmed) REGISTER_NAME transactions + List registerNameTransactions = this.fetchRegisterNameTransactions(); // Loop through each REGISTER_NAME txn signature and request the full transaction data - for (RegisterNameTransactionData registerNameTransactionData : this.registerNameTransactions) { + for (RegisterNameTransactionData registerNameTransactionData : registerNameTransactions) { String registeredName = registerNameTransactionData.getName(); NameData nameData = repository.getNameRepository().fromName(registeredName); // Check to see if this name has been updated or bought at any point - TransactionData latestUpdate = this.fetchLatestModificationTransactionInvolvingName(registeredName); + TransactionData latestUpdate = this.fetchLatestModificationTransactionInvolvingName(registeredName, repository); if (latestUpdate == null) { // Name was never updated once registered // We expect this name to still be registered to this transaction's creator @@ -54,16 +147,9 @@ public class NamesDatabaseIntegrityCheck { if (nameData == null) { LOGGER.info("Error: registered name {} doesn't exist in Names table. Adding...", registeredName); integrityCheckFailed = true; - - // Register the name - Name name = new Name(repository, registerNameTransactionData); - name.register(); - repository.saveChanges(); - corrected = true; - continue; } else { - //LOGGER.info("Registered name {} is correctly registered", registeredName); + LOGGER.trace("Registered name {} is correctly registered", registeredName); } // Check the owner is correct @@ -72,18 +158,16 @@ public class NamesDatabaseIntegrityCheck { LOGGER.info("Error: registered name {} is owned by {}, but it should be {}", registeredName, nameData.getOwner(), creator.getAddress()); integrityCheckFailed = true; - - // FUTURE: Fix the name's owner if we ever see the above log entry } else { - //LOGGER.info("Registered name {} has the correct owner", registeredName); + LOGGER.trace("Registered name {} has the correct owner", registeredName); } } else { // Check if owner is correct after update // Check for name updates - if (latestUpdate instanceof UpdateNameTransactionData) { + if (latestUpdate.getType() == TransactionType.UPDATE_NAME) { UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) latestUpdate; PublicKeyAccount creator = new PublicKeyAccount(repository, updateNameTransactionData.getCreatorPublicKey()); @@ -93,10 +177,9 @@ public class NamesDatabaseIntegrityCheck { LOGGER.info("Error: registered name {} is owned by {}, but it should be {}", registeredName, nameData.getOwner(), creator.getAddress()); integrityCheckFailed = true; - - // FUTURE: Fix the name's owner if we ever see the above log entry - } else { - //LOGGER.info("Registered name {} has the correct owner after being updated", registeredName); + } + else { + LOGGER.trace("Registered name {} has the correct owner after being updated", registeredName); } } @@ -109,10 +192,9 @@ public class NamesDatabaseIntegrityCheck { LOGGER.info("Error: registered name {} is owned by {}, but it should be {}", updateNameTransactionData.getNewName(), newNameData.getOwner(), creator.getAddress()); integrityCheckFailed = true; - - // FUTURE: Fix the name's owner if we ever see the above log entry - } else { - //LOGGER.info("Registered name {} has the correct owner after being updated", updateNameTransactionData.getNewName()); + } + else { + LOGGER.trace("Registered name {} has the correct owner after being updated", updateNameTransactionData.getNewName()); } } @@ -121,18 +203,31 @@ public class NamesDatabaseIntegrityCheck { } } - // Check for name sales - else if (latestUpdate instanceof BuyNameTransactionData) { + // Check for name buys + else if (latestUpdate.getType() == TransactionType.BUY_NAME) { BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) latestUpdate; PublicKeyAccount creator = new PublicKeyAccount(repository, buyNameTransactionData.getCreatorPublicKey()); if (!Objects.equals(creator.getAddress(), nameData.getOwner())) { LOGGER.info("Error: registered name {} is owned by {}, but it should be {}", registeredName, nameData.getOwner(), creator.getAddress()); integrityCheckFailed = true; + } + else { + LOGGER.trace("Registered name {} has the correct owner after being bought", registeredName); + } + } - // FUTURE: Fix the name's owner if we ever see the above log entry - } else { - //LOGGER.info("Registered name {} has the correct owner after being bought", registeredName); + // Check for name sells + else if (latestUpdate.getType() == TransactionType.SELL_NAME) { + SellNameTransactionData sellNameTransactionData = (SellNameTransactionData) latestUpdate; + PublicKeyAccount creator = new PublicKeyAccount(repository, sellNameTransactionData.getCreatorPublicKey()); + if (!Objects.equals(creator.getAddress(), nameData.getOwner())) { + LOGGER.info("Error: registered name {} is owned by {}, but it should be {}", + registeredName, nameData.getOwner(), creator.getAddress()); + integrityCheckFailed = true; + } + else { + LOGGER.trace("Registered name {} has the correct owner after being listed for sale", registeredName); } } @@ -150,147 +245,166 @@ public class NamesDatabaseIntegrityCheck { } if (integrityCheckFailed) { - if (corrected) { - LOGGER.info("Registered names database integrity check failed, but corrections were made. If this " + - "problem persists after restarting the node, you may need to switch to a recent bootstrap."); - } - else { - LOGGER.info("Registered names database integrity check failed. Bootstrapping is recommended."); - } + LOGGER.info("Registered names database integrity check failed. Bootstrapping is recommended."); } else { LOGGER.info("Registered names database integrity check passed."); } } - private void fetchRegisterNameTransactions(Repository repository) throws DataException { + private List fetchRegisterNameTransactions() { List registerNameTransactions = new ArrayList<>(); - // Fetch all the confirmed REGISTER_NAME transaction signatures - List registerNameSigs = repository.getTransactionRepository().getSignaturesMatchingCriteria( - null, null, null, REGISTER_NAME_TX_TYPE, null, null, - ConfirmationStatus.CONFIRMED, null, null, false); - - for (byte[] signature : registerNameSigs) { - // LOGGER.info("Fetching REGISTER_NAME transaction from signature {}...", Base58.encode(signature)); - - TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); - if (!(transactionData instanceof RegisterNameTransactionData)) { - LOGGER.info("REGISTER_NAME transaction signature {} not found", Base58.encode(signature)); - continue; + for (TransactionData transactionData : this.nameTransactions) { + if (transactionData.getType() == TransactionType.REGISTER_NAME) { + RegisterNameTransactionData registerNameTransactionData = (RegisterNameTransactionData) transactionData; + registerNameTransactions.add(registerNameTransactionData); } - RegisterNameTransactionData registerNameTransactionData = (RegisterNameTransactionData) transactionData; - registerNameTransactions.add(registerNameTransactionData); } - this.registerNameTransactions = registerNameTransactions; + return registerNameTransactions; } - private void fetchUpdateNameTransactions(Repository repository) throws DataException { + private List fetchUpdateNameTransactions() { List updateNameTransactions = new ArrayList<>(); - // Fetch all the confirmed REGISTER_NAME transaction signatures - List updateNameSigs = repository.getTransactionRepository().getSignaturesMatchingCriteria( - null, null, null, UPDATE_NAME_TX_TYPE, null, null, - ConfirmationStatus.CONFIRMED, null, null, false); - - for (byte[] signature : updateNameSigs) { - // LOGGER.info("Fetching UPDATE_NAME transaction from signature {}...", Base58.encode(signature)); - - TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); - if (!(transactionData instanceof UpdateNameTransactionData)) { - LOGGER.info("UPDATE_NAME transaction signature {} not found", Base58.encode(signature)); - continue; + for (TransactionData transactionData : this.nameTransactions) { + if (transactionData.getType() == TransactionType.UPDATE_NAME) { + UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) transactionData; + updateNameTransactions.add(updateNameTransactionData); } - UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) transactionData; - updateNameTransactions.add(updateNameTransactionData); } - this.updateNameTransactions = updateNameTransactions; + return updateNameTransactions; } - private void fetchBuyNameTransactions(Repository repository) throws DataException { + private List fetchSellNameTransactions() { + List sellNameTransactions = new ArrayList<>(); + + for (TransactionData transactionData : this.nameTransactions) { + if (transactionData.getType() == TransactionType.SELL_NAME) { + SellNameTransactionData sellNameTransactionData = (SellNameTransactionData) transactionData; + sellNameTransactions.add(sellNameTransactionData); + } + } + return sellNameTransactions; + } + + private List fetchBuyNameTransactions() { List buyNameTransactions = new ArrayList<>(); + for (TransactionData transactionData : this.nameTransactions) { + if (transactionData.getType() == TransactionType.BUY_NAME) { + BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) transactionData; + buyNameTransactions.add(buyNameTransactionData); + } + } + return buyNameTransactions; + } + + private void fetchAllNameTransactions(Repository repository) throws DataException { + List nameTransactions = new ArrayList<>(); + // Fetch all the confirmed REGISTER_NAME transaction signatures - List buyNameSigs = repository.getTransactionRepository().getSignaturesMatchingCriteria( - null, null, null, BUY_NAME_TX_TYPE, null, null, + List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria( + null, null, null, ALL_NAME_TX_TYPE, null, null, ConfirmationStatus.CONFIRMED, null, null, false); - for (byte[] signature : buyNameSigs) { - // LOGGER.info("Fetching BUY_NAME transaction from signature {}...", Base58.encode(signature)); - + for (byte[] signature : signatures) { TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); - if (!(transactionData instanceof BuyNameTransactionData)) { - LOGGER.info("BUY_NAME transaction signature {} not found", Base58.encode(signature)); - continue; - } - BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) transactionData; - buyNameTransactions.add(buyNameTransactionData); + nameTransactions.add(transactionData); } - this.buyNameTransactions = buyNameTransactions; + this.nameTransactions = nameTransactions; } - private List fetchUpdateTransactionsInvolvingName(String registeredName) { - List matchedTransactions = new ArrayList<>(); + private List fetchAllTransactionsInvolvingName(String name, Repository repository) throws DataException { + List transactions = new ArrayList<>(); + String reducedName = Unicode.sanitize(name); - for (UpdateNameTransactionData updateNameTransactionData : this.updateNameTransactions) { - if (Objects.equals(updateNameTransactionData.getName(), registeredName) || - Objects.equals(updateNameTransactionData.getNewName(), registeredName)) { + // Fetch all the confirmed name-modification transactions + if (this.nameTransactions.isEmpty()) { + this.fetchAllNameTransactions(repository); + } - matchedTransactions.add(updateNameTransactionData); + for (TransactionData transactionData : this.nameTransactions) { + + if ((transactionData instanceof RegisterNameTransactionData)) { + RegisterNameTransactionData registerNameTransactionData = (RegisterNameTransactionData) transactionData; + if (Objects.equals(registerNameTransactionData.getReducedName(), reducedName)) { + transactions.add(transactionData); + } + } + if ((transactionData instanceof UpdateNameTransactionData)) { + UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) transactionData; + if (Objects.equals(updateNameTransactionData.getName(), name) || + Objects.equals(updateNameTransactionData.getReducedNewName(), reducedName)) { + transactions.add(transactionData); + } + } + if ((transactionData instanceof BuyNameTransactionData)) { + BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) transactionData; + if (Objects.equals(buyNameTransactionData.getName(), name)) { + transactions.add(transactionData); + } + } + if ((transactionData instanceof SellNameTransactionData)) { + SellNameTransactionData sellNameTransactionData = (SellNameTransactionData) transactionData; + if (Objects.equals(sellNameTransactionData.getName(), name)) { + transactions.add(transactionData); + } } } - return matchedTransactions; + return transactions; } - private List fetchBuyTransactionsInvolvingName(String registeredName) { - List matchedTransactions = new ArrayList<>(); + private TransactionData fetchLatestModificationTransactionInvolvingName(String registeredName, Repository repository) throws DataException { + List transactionsInvolvingName = this.fetchAllTransactionsInvolvingName(registeredName, repository); - for (BuyNameTransactionData buyNameTransactionData : this.buyNameTransactions) { - if (Objects.equals(buyNameTransactionData.getName(), registeredName)) { - - matchedTransactions.add(buyNameTransactionData); - } - } - return matchedTransactions; - } - - private TransactionData fetchLatestModificationTransactionInvolvingName(String registeredName) { - List latestTransactions = new ArrayList<>(); - - List updates = this.fetchUpdateTransactionsInvolvingName(registeredName); - List buys = this.fetchBuyTransactionsInvolvingName(registeredName); - - // Get the latest updates for this name - UpdateNameTransactionData latestUpdateToName = updates.stream() - .filter(update -> update.getNewName().equals(registeredName)) - .max(Comparator.comparing(UpdateNameTransactionData::getTimestamp)) - .orElse(null); - if (latestUpdateToName != null) { - latestTransactions.add(latestUpdateToName); - } - - UpdateNameTransactionData latestUpdateFromName = updates.stream() - .filter(update -> update.getName().equals(registeredName)) - .max(Comparator.comparing(UpdateNameTransactionData::getTimestamp)) - .orElse(null); - if (latestUpdateFromName != null) { - latestTransactions.add(latestUpdateFromName); - } - - // Get the latest buy for this name - BuyNameTransactionData latestBuyForName = buys.stream() - .filter(update -> update.getName().equals(registeredName)) - .max(Comparator.comparing(BuyNameTransactionData::getTimestamp)) - .orElse(null); - if (latestBuyForName != null) { - latestTransactions.add(latestBuyForName); - } - - // Get the latest name-related transaction of any type - TransactionData latestUpdate = latestTransactions.stream() + // Get the latest update for this name (excluding REGISTER_NAME transactions) + TransactionData latestUpdateToName = transactionsInvolvingName.stream() + .filter(txn -> txn.getType() != TransactionType.REGISTER_NAME) .max(Comparator.comparing(TransactionData::getTimestamp)) .orElse(null); - return latestUpdate; + return latestUpdateToName; + } + + private List fetchAllNames(Repository repository) throws DataException { + List names = new ArrayList<>(); + + // Fetch all the confirmed name transactions + if (this.nameTransactions.isEmpty()) { + this.fetchAllNameTransactions(repository); + } + + for (TransactionData transactionData : this.nameTransactions) { + + if ((transactionData instanceof RegisterNameTransactionData)) { + RegisterNameTransactionData registerNameTransactionData = (RegisterNameTransactionData) transactionData; + if (!names.contains(registerNameTransactionData.getName())) { + names.add(registerNameTransactionData.getName()); + } + } + if ((transactionData instanceof UpdateNameTransactionData)) { + UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) transactionData; + if (!names.contains(updateNameTransactionData.getName())) { + names.add(updateNameTransactionData.getName()); + } + if (!names.contains(updateNameTransactionData.getNewName())) { + names.add(updateNameTransactionData.getNewName()); + } + } + if ((transactionData instanceof BuyNameTransactionData)) { + BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) transactionData; + if (!names.contains(buyNameTransactionData.getName())) { + names.add(buyNameTransactionData.getName()); + } + } + if ((transactionData instanceof SellNameTransactionData)) { + SellNameTransactionData sellNameTransactionData = (SellNameTransactionData) transactionData; + if (!names.contains(sellNameTransactionData.getName())) { + names.add(sellNameTransactionData.getName()); + } + } + } + return names; } } diff --git a/src/main/java/org/qortal/naming/Name.java b/src/main/java/org/qortal/naming/Name.java index 454ade57..b27e9454 100644 --- a/src/main/java/org/qortal/naming/Name.java +++ b/src/main/java/org/qortal/naming/Name.java @@ -265,4 +265,8 @@ public class Name { return previousTransactionData.getTimestamp(); } + public NameData getNameData() { + return this.nameData; + } + } diff --git a/src/main/java/org/qortal/transaction/AccountFlagsTransaction.java b/src/main/java/org/qortal/transaction/AccountFlagsTransaction.java index 355340b6..4362b1a9 100644 --- a/src/main/java/org/qortal/transaction/AccountFlagsTransaction.java +++ b/src/main/java/org/qortal/transaction/AccountFlagsTransaction.java @@ -48,6 +48,11 @@ public class AccountFlagsTransaction extends Transaction { return ValidationResult.NO_FLAG_PERMISSION; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { Account target = this.getTarget(); diff --git a/src/main/java/org/qortal/transaction/AccountLevelTransaction.java b/src/main/java/org/qortal/transaction/AccountLevelTransaction.java index da986344..18324c34 100644 --- a/src/main/java/org/qortal/transaction/AccountLevelTransaction.java +++ b/src/main/java/org/qortal/transaction/AccountLevelTransaction.java @@ -49,6 +49,11 @@ public class AccountLevelTransaction extends Transaction { return ValidationResult.NO_FLAG_PERMISSION; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { Account target = getTarget(); diff --git a/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java b/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java index d62bd451..15dc51bf 100644 --- a/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java +++ b/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java @@ -84,6 +84,11 @@ public class AddGroupAdminTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Update Group adminship @@ -98,4 +103,4 @@ public class AddGroupAdminTransaction extends Transaction { group.unpromoteToAdmin(this.addGroupAdminTransactionData); } -} \ No newline at end of file +} diff --git a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java index 04ecc09f..f75b7f19 100644 --- a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java +++ b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java @@ -60,6 +60,11 @@ public class ArbitraryTransaction extends Transaction { arbitraryTransactionData.getFee()); } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Wrap and delegate payment processing to Payment class. diff --git a/src/main/java/org/qortal/transaction/AtTransaction.java b/src/main/java/org/qortal/transaction/AtTransaction.java index a7e72b2a..c570bb65 100644 --- a/src/main/java/org/qortal/transaction/AtTransaction.java +++ b/src/main/java/org/qortal/transaction/AtTransaction.java @@ -80,6 +80,11 @@ public class AtTransaction extends Transaction { return Arrays.equals(atAccount.getLastReference(), atTransactionData.getReference()); } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public ValidationResult isValid() throws DataException { // Check recipient address is valid diff --git a/src/main/java/org/qortal/transaction/BuyNameTransaction.java b/src/main/java/org/qortal/transaction/BuyNameTransaction.java index ad3e0c8d..c4e5f29c 100644 --- a/src/main/java/org/qortal/transaction/BuyNameTransaction.java +++ b/src/main/java/org/qortal/transaction/BuyNameTransaction.java @@ -6,6 +6,7 @@ import java.util.List; import org.qortal.account.Account; import org.qortal.asset.Asset; import org.qortal.block.BlockChain; +import org.qortal.controller.repository.NamesDatabaseIntegrityCheck; import org.qortal.crypto.Crypto; import org.qortal.data.naming.NameData; import org.qortal.data.transaction.BuyNameTransactionData; @@ -98,6 +99,17 @@ public class BuyNameTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + BuyNameTransactionData buyNameTransactionData = (BuyNameTransactionData) transactionData; + + // Rebuild this name in the Names table from the transaction history + // This is necessary because in some rare cases names can be missing from the Names table after registration + // but we have been unable to reproduce the issue and track down the root cause + NamesDatabaseIntegrityCheck namesDatabaseIntegrityCheck = new NamesDatabaseIntegrityCheck(); + namesDatabaseIntegrityCheck.rebuildName(buyNameTransactionData.getName(), this.repository); + } + @Override public void process() throws DataException { // Buy Name diff --git a/src/main/java/org/qortal/transaction/CancelAssetOrderTransaction.java b/src/main/java/org/qortal/transaction/CancelAssetOrderTransaction.java index b8b70dde..955f62f4 100644 --- a/src/main/java/org/qortal/transaction/CancelAssetOrderTransaction.java +++ b/src/main/java/org/qortal/transaction/CancelAssetOrderTransaction.java @@ -62,6 +62,11 @@ public class CancelAssetOrderTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Mark Order as completed so no more trades can happen diff --git a/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java b/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java index e01be7be..483dfc6f 100644 --- a/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java +++ b/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java @@ -83,6 +83,11 @@ public class CancelGroupBanTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Update Group Membership diff --git a/src/main/java/org/qortal/transaction/CancelGroupInviteTransaction.java b/src/main/java/org/qortal/transaction/CancelGroupInviteTransaction.java index ea228215..800f2444 100644 --- a/src/main/java/org/qortal/transaction/CancelGroupInviteTransaction.java +++ b/src/main/java/org/qortal/transaction/CancelGroupInviteTransaction.java @@ -83,6 +83,11 @@ public class CancelGroupInviteTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Update Group Membership diff --git a/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java b/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java index f241db47..788492a9 100644 --- a/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java +++ b/src/main/java/org/qortal/transaction/CancelSellNameTransaction.java @@ -79,6 +79,11 @@ public class CancelSellNameTransaction extends Transaction { } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Update Name diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index a670ea4b..2202d44a 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -135,6 +135,11 @@ public class ChatTransaction extends Transaction { return true; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public ValidationResult isValid() throws DataException { // Nonce checking is done via isSignatureValid() as that method is only called once per import diff --git a/src/main/java/org/qortal/transaction/CreateAssetOrderTransaction.java b/src/main/java/org/qortal/transaction/CreateAssetOrderTransaction.java index 36cccf42..24e57a4e 100644 --- a/src/main/java/org/qortal/transaction/CreateAssetOrderTransaction.java +++ b/src/main/java/org/qortal/transaction/CreateAssetOrderTransaction.java @@ -135,6 +135,11 @@ public class CreateAssetOrderTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Order Id is transaction's signature diff --git a/src/main/java/org/qortal/transaction/CreateGroupTransaction.java b/src/main/java/org/qortal/transaction/CreateGroupTransaction.java index 7ed61684..6f4a3634 100644 --- a/src/main/java/org/qortal/transaction/CreateGroupTransaction.java +++ b/src/main/java/org/qortal/transaction/CreateGroupTransaction.java @@ -92,6 +92,11 @@ public class CreateGroupTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Create Group diff --git a/src/main/java/org/qortal/transaction/CreatePollTransaction.java b/src/main/java/org/qortal/transaction/CreatePollTransaction.java index 4c4b3a0a..a56322a7 100644 --- a/src/main/java/org/qortal/transaction/CreatePollTransaction.java +++ b/src/main/java/org/qortal/transaction/CreatePollTransaction.java @@ -106,6 +106,11 @@ public class CreatePollTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Publish poll to allow voting diff --git a/src/main/java/org/qortal/transaction/DeployAtTransaction.java b/src/main/java/org/qortal/transaction/DeployAtTransaction.java index 86e04d56..f3024b57 100644 --- a/src/main/java/org/qortal/transaction/DeployAtTransaction.java +++ b/src/main/java/org/qortal/transaction/DeployAtTransaction.java @@ -203,6 +203,11 @@ public class DeployAtTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { ensureATAddress(this.deployAtTransactionData); diff --git a/src/main/java/org/qortal/transaction/GenesisTransaction.java b/src/main/java/org/qortal/transaction/GenesisTransaction.java index 067ff183..74a84a7d 100644 --- a/src/main/java/org/qortal/transaction/GenesisTransaction.java +++ b/src/main/java/org/qortal/transaction/GenesisTransaction.java @@ -100,6 +100,11 @@ public class GenesisTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { Account recipient = new Account(repository, this.genesisTransactionData.getRecipient()); diff --git a/src/main/java/org/qortal/transaction/GroupApprovalTransaction.java b/src/main/java/org/qortal/transaction/GroupApprovalTransaction.java index d5cf66f7..1c8bb709 100644 --- a/src/main/java/org/qortal/transaction/GroupApprovalTransaction.java +++ b/src/main/java/org/qortal/transaction/GroupApprovalTransaction.java @@ -66,6 +66,11 @@ public class GroupApprovalTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Find previous approval decision (if any) by this admin for pending transaction diff --git a/src/main/java/org/qortal/transaction/GroupBanTransaction.java b/src/main/java/org/qortal/transaction/GroupBanTransaction.java index d3458ebe..c9a6c307 100644 --- a/src/main/java/org/qortal/transaction/GroupBanTransaction.java +++ b/src/main/java/org/qortal/transaction/GroupBanTransaction.java @@ -87,6 +87,11 @@ public class GroupBanTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Update Group Membership diff --git a/src/main/java/org/qortal/transaction/GroupInviteTransaction.java b/src/main/java/org/qortal/transaction/GroupInviteTransaction.java index a66f7584..f3b08f59 100644 --- a/src/main/java/org/qortal/transaction/GroupInviteTransaction.java +++ b/src/main/java/org/qortal/transaction/GroupInviteTransaction.java @@ -88,6 +88,11 @@ public class GroupInviteTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Update Group Membership diff --git a/src/main/java/org/qortal/transaction/GroupKickTransaction.java b/src/main/java/org/qortal/transaction/GroupKickTransaction.java index d9be8161..84de3a59 100644 --- a/src/main/java/org/qortal/transaction/GroupKickTransaction.java +++ b/src/main/java/org/qortal/transaction/GroupKickTransaction.java @@ -89,6 +89,11 @@ public class GroupKickTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Update Group Membership diff --git a/src/main/java/org/qortal/transaction/IssueAssetTransaction.java b/src/main/java/org/qortal/transaction/IssueAssetTransaction.java index e9422dcd..52428963 100644 --- a/src/main/java/org/qortal/transaction/IssueAssetTransaction.java +++ b/src/main/java/org/qortal/transaction/IssueAssetTransaction.java @@ -92,6 +92,11 @@ public class IssueAssetTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Issue asset diff --git a/src/main/java/org/qortal/transaction/JoinGroupTransaction.java b/src/main/java/org/qortal/transaction/JoinGroupTransaction.java index ed69ed4e..bc62c629 100644 --- a/src/main/java/org/qortal/transaction/JoinGroupTransaction.java +++ b/src/main/java/org/qortal/transaction/JoinGroupTransaction.java @@ -67,6 +67,11 @@ public class JoinGroupTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Update Group Membership diff --git a/src/main/java/org/qortal/transaction/LeaveGroupTransaction.java b/src/main/java/org/qortal/transaction/LeaveGroupTransaction.java index ad31e565..1e8f8c6c 100644 --- a/src/main/java/org/qortal/transaction/LeaveGroupTransaction.java +++ b/src/main/java/org/qortal/transaction/LeaveGroupTransaction.java @@ -67,6 +67,11 @@ public class LeaveGroupTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Update Group Membership diff --git a/src/main/java/org/qortal/transaction/MessageTransaction.java b/src/main/java/org/qortal/transaction/MessageTransaction.java index ef6e6c76..d02b6fdd 100644 --- a/src/main/java/org/qortal/transaction/MessageTransaction.java +++ b/src/main/java/org/qortal/transaction/MessageTransaction.java @@ -239,6 +239,11 @@ public class MessageTransaction extends Transaction { getPaymentData(), this.messageTransactionData.getFee(), true); } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // If we have no amount then there's nothing to do diff --git a/src/main/java/org/qortal/transaction/MultiPaymentTransaction.java b/src/main/java/org/qortal/transaction/MultiPaymentTransaction.java index 4c3f75dc..34cd0147 100644 --- a/src/main/java/org/qortal/transaction/MultiPaymentTransaction.java +++ b/src/main/java/org/qortal/transaction/MultiPaymentTransaction.java @@ -67,6 +67,11 @@ public class MultiPaymentTransaction extends Transaction { return new Payment(this.repository).isProcessable(this.multiPaymentTransactionData.getSenderPublicKey(), payments, this.multiPaymentTransactionData.getFee()); } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Wrap and delegate payment processing to Payment class. diff --git a/src/main/java/org/qortal/transaction/PaymentTransaction.java b/src/main/java/org/qortal/transaction/PaymentTransaction.java index f6caaef5..4869db76 100644 --- a/src/main/java/org/qortal/transaction/PaymentTransaction.java +++ b/src/main/java/org/qortal/transaction/PaymentTransaction.java @@ -61,6 +61,11 @@ public class PaymentTransaction extends Transaction { return new Payment(this.repository).isProcessable(this.paymentTransactionData.getSenderPublicKey(), getPaymentData(), this.paymentTransactionData.getFee()); } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Wrap and delegate payment processing to Payment class. diff --git a/src/main/java/org/qortal/transaction/PresenceTransaction.java b/src/main/java/org/qortal/transaction/PresenceTransaction.java index 729270e0..0d28d382 100644 --- a/src/main/java/org/qortal/transaction/PresenceTransaction.java +++ b/src/main/java/org/qortal/transaction/PresenceTransaction.java @@ -149,6 +149,11 @@ public class PresenceTransaction extends Transaction { return true; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public ValidationResult isValid() throws DataException { // Nonce checking is done via isSignatureValid() as that method is only called once per import diff --git a/src/main/java/org/qortal/transaction/PublicizeTransaction.java b/src/main/java/org/qortal/transaction/PublicizeTransaction.java index 75cfd2a2..c03c8283 100644 --- a/src/main/java/org/qortal/transaction/PublicizeTransaction.java +++ b/src/main/java/org/qortal/transaction/PublicizeTransaction.java @@ -80,6 +80,11 @@ public class PublicizeTransaction extends Transaction { return true; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public ValidationResult isValid() throws DataException { // There can be only one diff --git a/src/main/java/org/qortal/transaction/RegisterNameTransaction.java b/src/main/java/org/qortal/transaction/RegisterNameTransaction.java index 66c1fc8b..d0a2f49c 100644 --- a/src/main/java/org/qortal/transaction/RegisterNameTransaction.java +++ b/src/main/java/org/qortal/transaction/RegisterNameTransaction.java @@ -6,6 +6,7 @@ import java.util.List; import org.qortal.account.Account; import org.qortal.asset.Asset; import org.qortal.block.BlockChain; +import org.qortal.controller.repository.NamesDatabaseIntegrityCheck; import org.qortal.crypto.Crypto; import org.qortal.data.transaction.RegisterNameTransactionData; import org.qortal.data.transaction.TransactionData; @@ -88,6 +89,17 @@ public class RegisterNameTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + RegisterNameTransactionData registerNameTransactionData = (RegisterNameTransactionData) transactionData; + + // Rebuild this name in the Names table from the transaction history + // This is necessary because in some rare cases names can be missing from the Names table after registration + // but we have been unable to reproduce the issue and track down the root cause + NamesDatabaseIntegrityCheck namesDatabaseIntegrityCheck = new NamesDatabaseIntegrityCheck(); + namesDatabaseIntegrityCheck.rebuildName(registerNameTransactionData.getName(), this.repository); + } + @Override public void process() throws DataException { // Register Name diff --git a/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java b/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java index 43f1fc8f..3e5f1e6d 100644 --- a/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java +++ b/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java @@ -87,6 +87,11 @@ public class RemoveGroupAdminTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Update Group adminship @@ -107,4 +112,4 @@ public class RemoveGroupAdminTransaction extends Transaction { this.repository.getTransactionRepository().save(this.removeGroupAdminTransactionData); } -} \ No newline at end of file +} diff --git a/src/main/java/org/qortal/transaction/RewardShareTransaction.java b/src/main/java/org/qortal/transaction/RewardShareTransaction.java index 0e21c0c6..be68196d 100644 --- a/src/main/java/org/qortal/transaction/RewardShareTransaction.java +++ b/src/main/java/org/qortal/transaction/RewardShareTransaction.java @@ -159,6 +159,11 @@ public class RewardShareTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { PublicKeyAccount mintingAccount = getMintingAccount(); diff --git a/src/main/java/org/qortal/transaction/SellNameTransaction.java b/src/main/java/org/qortal/transaction/SellNameTransaction.java index 81bd9ff7..c2ab2eb9 100644 --- a/src/main/java/org/qortal/transaction/SellNameTransaction.java +++ b/src/main/java/org/qortal/transaction/SellNameTransaction.java @@ -5,6 +5,7 @@ import java.util.List; import org.qortal.account.Account; import org.qortal.asset.Asset; +import org.qortal.controller.repository.NamesDatabaseIntegrityCheck; import org.qortal.data.naming.NameData; import org.qortal.data.transaction.SellNameTransactionData; import org.qortal.data.transaction.TransactionData; @@ -89,6 +90,17 @@ public class SellNameTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + SellNameTransactionData sellNameTransactionData = (SellNameTransactionData) transactionData; + + // Rebuild this name in the Names table from the transaction history + // This is necessary because in some rare cases names can be missing from the Names table after registration + // but we have been unable to reproduce the issue and track down the root cause + NamesDatabaseIntegrityCheck namesDatabaseIntegrityCheck = new NamesDatabaseIntegrityCheck(); + namesDatabaseIntegrityCheck.rebuildName(sellNameTransactionData.getName(), this.repository); + } + @Override public void process() throws DataException { // Sell Name diff --git a/src/main/java/org/qortal/transaction/SetGroupTransaction.java b/src/main/java/org/qortal/transaction/SetGroupTransaction.java index 084044a7..48248b69 100644 --- a/src/main/java/org/qortal/transaction/SetGroupTransaction.java +++ b/src/main/java/org/qortal/transaction/SetGroupTransaction.java @@ -56,6 +56,11 @@ public class SetGroupTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { Account creator = getCreator(); diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index 3c761d28..7eb93bc4 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -791,6 +791,8 @@ public abstract class Transaction { // Fix up approval status this.setInitialApprovalStatus(); + this.preProcess(); + ValidationResult validationResult = this.isValidUnconfirmed(); if (validationResult != ValidationResult.OK) return validationResult; @@ -891,6 +893,14 @@ public abstract class Transaction { return ValidationResult.OK; } + /** + * * Pre-process a transaction before validating or processing the block + * This allows for any database integrity checks prior to validation. + * + * @throws DataException + */ + public abstract void preProcess() throws DataException; + /** * Actually process a transaction, updating the blockchain. *

diff --git a/src/main/java/org/qortal/transaction/TransferAssetTransaction.java b/src/main/java/org/qortal/transaction/TransferAssetTransaction.java index a2855a35..79d485a5 100644 --- a/src/main/java/org/qortal/transaction/TransferAssetTransaction.java +++ b/src/main/java/org/qortal/transaction/TransferAssetTransaction.java @@ -61,6 +61,11 @@ public class TransferAssetTransaction extends Transaction { return new Payment(this.repository).isProcessable(this.transferAssetTransactionData.getSenderPublicKey(), getPaymentData(), this.transferAssetTransactionData.getFee()); } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Wrap asset transfer as a payment and delegate processing to Payment class. diff --git a/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java b/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java index d64e953e..f77dac15 100644 --- a/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java +++ b/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java @@ -68,6 +68,11 @@ public class TransferPrivsTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { Account sender = this.getSender(); diff --git a/src/main/java/org/qortal/transaction/UpdateAssetTransaction.java b/src/main/java/org/qortal/transaction/UpdateAssetTransaction.java index 2a7af23c..16e5641d 100644 --- a/src/main/java/org/qortal/transaction/UpdateAssetTransaction.java +++ b/src/main/java/org/qortal/transaction/UpdateAssetTransaction.java @@ -90,6 +90,11 @@ public class UpdateAssetTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Update Asset diff --git a/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java b/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java index 6751be33..9664ccbf 100644 --- a/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java +++ b/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java @@ -109,6 +109,11 @@ public class UpdateGroupTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { // Update Group diff --git a/src/main/java/org/qortal/transaction/UpdateNameTransaction.java b/src/main/java/org/qortal/transaction/UpdateNameTransaction.java index ebfde97c..c9eedbae 100644 --- a/src/main/java/org/qortal/transaction/UpdateNameTransaction.java +++ b/src/main/java/org/qortal/transaction/UpdateNameTransaction.java @@ -2,9 +2,11 @@ package org.qortal.transaction; import java.util.Collections; import java.util.List; +import java.util.Objects; import org.qortal.account.Account; import org.qortal.asset.Asset; +import org.qortal.controller.repository.NamesDatabaseIntegrityCheck; import org.qortal.crypto.Crypto; import org.qortal.data.naming.NameData; import org.qortal.data.transaction.TransactionData; @@ -124,6 +126,22 @@ public class UpdateNameTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + UpdateNameTransactionData updateNameTransactionData = (UpdateNameTransactionData) transactionData; + + // Rebuild this name in the Names table from the transaction history + // This is necessary because in some rare cases names can be missing from the Names table after registration + // but we have been unable to reproduce the issue and track down the root cause + NamesDatabaseIntegrityCheck namesDatabaseIntegrityCheck = new NamesDatabaseIntegrityCheck(); + namesDatabaseIntegrityCheck.rebuildName(updateNameTransactionData.getName(), this.repository); + + if (!Objects.equals(updateNameTransactionData.getName(), updateNameTransactionData.getNewName())) { + // Renaming - so make sure the new name is rebuilt too + namesDatabaseIntegrityCheck.rebuildName(updateNameTransactionData.getNewName(), this.repository); + } + } + @Override public void process() throws DataException { // Update Name diff --git a/src/main/java/org/qortal/transaction/VoteOnPollTransaction.java b/src/main/java/org/qortal/transaction/VoteOnPollTransaction.java index 35447aa6..89eec184 100644 --- a/src/main/java/org/qortal/transaction/VoteOnPollTransaction.java +++ b/src/main/java/org/qortal/transaction/VoteOnPollTransaction.java @@ -92,6 +92,11 @@ public class VoteOnPollTransaction extends Transaction { return ValidationResult.OK; } + @Override + public void preProcess() throws DataException { + // Nothing to do + } + @Override public void process() throws DataException { String pollName = this.voteOnPollTransactionData.getPollName(); diff --git a/src/test/java/org/qortal/test/naming/IntegrityTests.java b/src/test/java/org/qortal/test/naming/IntegrityTests.java new file mode 100644 index 00000000..d278cf3a --- /dev/null +++ b/src/test/java/org/qortal/test/naming/IntegrityTests.java @@ -0,0 +1,345 @@ +package org.qortal.test.naming; + +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.controller.repository.NamesDatabaseIntegrityCheck; +import org.qortal.data.transaction.*; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.test.common.transaction.TestTransaction; +import org.qortal.transaction.Transaction; + +import static org.junit.Assert.*; + +public class IntegrityTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testValidName() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Register-name + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "test-name"; + String data = "{\"age\":30}"; + + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Ensure the name exists and the data is correct + assertEquals(data, repository.getNameRepository().fromName(name).getData()); + + // Run the database integrity check for this name + NamesDatabaseIntegrityCheck integrityCheck = new NamesDatabaseIntegrityCheck(); + assertEquals(1, integrityCheck.rebuildName(name, repository)); + + // Ensure the name still exists and the data is still correct + assertEquals(data, repository.getNameRepository().fromName(name).getData()); + } + } + + @Test + public void testMissingName() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Register-name + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "test-name"; + String data = "{\"age\":30}"; + + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Ensure the name exists and the data is correct + assertEquals(data, repository.getNameRepository().fromName(name).getData()); + + // Now delete the name, to simulate a database inconsistency + repository.getNameRepository().delete(name); + + // Ensure the name doesn't exist + assertNull(repository.getNameRepository().fromName(name)); + + // Run the database integrity check for this name and check that a row was modified + NamesDatabaseIntegrityCheck integrityCheck = new NamesDatabaseIntegrityCheck(); + assertEquals(1, integrityCheck.rebuildName(name, repository)); + + // Ensure the name exists again and the data is correct + assertEquals(data, repository.getNameRepository().fromName(name).getData()); + } + } + + @Test + public void testMissingNameAfterUpdate() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Register-name + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "test-name"; + String data = "{\"age\":30}"; + + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Ensure the name exists and the data is correct + assertEquals(data, repository.getNameRepository().fromName(name).getData()); + + // Update the name + String newData = "{\"age\":31}"; + UpdateNameTransactionData updateTransactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), name, name, newData); + TransactionUtils.signAndMint(repository, updateTransactionData, alice); + + // Ensure the name still exists and the data has been updated + assertEquals(newData, repository.getNameRepository().fromName(name).getData()); + + // Now delete the name, to simulate a database inconsistency + repository.getNameRepository().delete(name); + + // Ensure the name doesn't exist + assertNull(repository.getNameRepository().fromName(name)); + + // Run the database integrity check for this name + // We expect 2 modifications to be made - the original register name followed by the update + NamesDatabaseIntegrityCheck integrityCheck = new NamesDatabaseIntegrityCheck(); + assertEquals(2, integrityCheck.rebuildName(name, repository)); + + // Ensure the name exists and the data is correct + assertEquals(newData, repository.getNameRepository().fromName(name).getData()); + } + } + + @Test + public void testMissingNameAfterRename() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Register-name + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "test-name"; + String data = "{\"age\":30}"; + + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Ensure the name exists and the data is correct + assertEquals(data, repository.getNameRepository().fromName(name).getData()); + + // Rename the name + String newName = "new-name"; + String newData = "{\"age\":31}"; + UpdateNameTransactionData updateTransactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), name, newName, newData); + TransactionUtils.signAndMint(repository, updateTransactionData, alice); + + // Ensure the new name exists and the data has been updated + assertEquals(newData, repository.getNameRepository().fromName(newName).getData()); + + // Ensure the old name doesn't exist + assertNull(repository.getNameRepository().fromName(name)); + + // Now delete the new name, to simulate a database inconsistency + repository.getNameRepository().delete(newName); + + // Ensure the new name doesn't exist + assertNull(repository.getNameRepository().fromName(newName)); + + // Attempt to register the new name + transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), newName, data); + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(alice); + + // Transaction should be invalid, because the database inconsistency was fixed by RegisterNameTransaction.preProcess() + Transaction.ValidationResult result = transaction.importAsUnconfirmed(); + assertTrue("Transaction should be invalid", Transaction.ValidationResult.OK != result); + assertTrue("Name should already be registered", Transaction.ValidationResult.NAME_ALREADY_REGISTERED == result); + } + } + + @Test + public void testRegisterMissingName() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Register-name + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "test-name"; + String data = "{\"age\":30}"; + + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Ensure the name exists and the data is correct + assertEquals(data, repository.getNameRepository().fromName(name).getData()); + + // Now delete the name, to simulate a database inconsistency + repository.getNameRepository().delete(name); + + // Ensure the name doesn't exist + assertNull(repository.getNameRepository().fromName(name)); + + // Attempt to register the name again + String duplicateName = "TEST-nÁme"; + transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), duplicateName, data); + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(alice); + + // Transaction should be invalid, because the database inconsistency was fixed by RegisterNameTransaction.preProcess() + Transaction.ValidationResult result = transaction.importAsUnconfirmed(); + assertTrue("Transaction should be invalid", Transaction.ValidationResult.OK != result); + assertTrue("Name should already be registered", Transaction.ValidationResult.NAME_ALREADY_REGISTERED == result); + } + } + + @Test + public void testUpdateMissingName() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Register-name + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String initialName = "test-name"; + String data = "{\"age\":30}"; + + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, data); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Ensure the name exists and the data is correct + assertEquals(data, repository.getNameRepository().fromName(initialName).getData()); + + // Now delete the name, to simulate a database inconsistency + repository.getNameRepository().delete(initialName); + + // Ensure the name doesn't exist + assertNull(repository.getNameRepository().fromName(initialName)); + + // Attempt to update the name + String newName = "new-name"; + String newData = ""; + TransactionData updateTransactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), initialName, newName, newData); + Transaction transaction = Transaction.fromData(repository, updateTransactionData); + transaction.sign(alice); + + // Transaction should be valid, because the database inconsistency was fixed by UpdateNameTransaction.preProcess() + Transaction.ValidationResult result = transaction.importAsUnconfirmed(); + assertTrue("Transaction should be valid", Transaction.ValidationResult.OK == result); + } + } + + @Test + public void testUpdateToMissingName() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Register-name + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String initialName = "test-name"; + String data = "{\"age\":30}"; + + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), initialName, data); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Ensure the name exists and the data is correct + assertEquals(data, repository.getNameRepository().fromName(initialName).getData()); + + // Register the second name that we will ultimately try and rename the first name to + String secondName = "new-missing-name"; + String secondNameData = "{\"data2\":true}"; + transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), secondName, secondNameData); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Ensure the second name exists and the data is correct + assertEquals(secondNameData, repository.getNameRepository().fromName(secondName).getData()); + + // Now delete the second name, to simulate a database inconsistency + repository.getNameRepository().delete(secondName); + + // Ensure the second name doesn't exist + assertNull(repository.getNameRepository().fromName(secondName)); + + // Attempt to rename the first name to the second name + TransactionData updateTransactionData = new UpdateNameTransactionData(TestTransaction.generateBase(alice), initialName, secondName, secondNameData); + Transaction transaction = Transaction.fromData(repository, updateTransactionData); + transaction.sign(alice); + + // Transaction should be invalid, because the database inconsistency was fixed by UpdateNameTransaction.preProcess() + // Therefore the name that we are trying to rename TO already exists + Transaction.ValidationResult result = transaction.importAsUnconfirmed(); + assertTrue("Transaction should be invalid", Transaction.ValidationResult.OK != result); + assertTrue("Destination name should already exist", Transaction.ValidationResult.NAME_ALREADY_REGISTERED == result); + } + } + + @Test + public void testSellMissingName() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Register-name + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "test-name"; + String data = "{\"age\":30}"; + + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Ensure the name exists and the data is correct + assertEquals(data, repository.getNameRepository().fromName(name).getData()); + + // Now delete the name, to simulate a database inconsistency + repository.getNameRepository().delete(name); + + // Ensure the name doesn't exist + assertNull(repository.getNameRepository().fromName(name)); + + // Attempt to sell the name + TransactionData sellTransactionData = new SellNameTransactionData(TestTransaction.generateBase(alice), name, 123456); + Transaction transaction = Transaction.fromData(repository, sellTransactionData); + transaction.sign(alice); + + // Transaction should be valid, because the database inconsistency was fixed by SellNameTransaction.preProcess() + Transaction.ValidationResult result = transaction.importAsUnconfirmed(); + assertTrue("Transaction should be valid", Transaction.ValidationResult.OK == result); + } + } + + @Test + public void testBuyMissingName() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + // Register-name + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String name = "test-name"; + String data = "{\"age\":30}"; + + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, data); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Ensure the name exists and the data is correct + assertEquals(data, repository.getNameRepository().fromName(name).getData()); + + // Now delete the name, to simulate a database inconsistency + repository.getNameRepository().delete(name); + + // Ensure the name doesn't exist + assertNull(repository.getNameRepository().fromName(name)); + + // Attempt to sell the name + long amount = 123456; + TransactionData sellTransactionData = new SellNameTransactionData(TestTransaction.generateBase(alice), name, amount); + TransactionUtils.signAndMint(repository, sellTransactionData, alice); + + // Ensure the name now exists + assertNotNull(repository.getNameRepository().fromName(name)); + + // Now delete the name again, to simulate another database inconsistency + repository.getNameRepository().delete(name); + + // Ensure the name doesn't exist + assertNull(repository.getNameRepository().fromName(name)); + + // Bob now attempts to buy the name + String seller = alice.getAddress(); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + TransactionData buyTransactionData = new BuyNameTransactionData(TestTransaction.generateBase(bob), name, amount, seller); + Transaction transaction = Transaction.fromData(repository, buyTransactionData); + transaction.sign(bob); + + // Transaction should be valid, because the database inconsistency was fixed by SellNameTransaction.preProcess() + Transaction.ValidationResult result = transaction.importAsUnconfirmed(); + assertTrue("Transaction should be valid", Transaction.ValidationResult.OK == result); + } + } + +} From 58691740218bf05a5e1add00ba679de4b2d013f5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 23 Sep 2021 08:28:51 +0100 Subject: [PATCH 091/231] Combined the three invalid name registration block patches into a single class. This should allow syncing from genesis again. --- src/main/java/org/qortal/block/Block.java | 9 +- .../block/InvalidNameRegistrationBlocks.java | 114 ++++++++++++++++++ 2 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/qortal/block/InvalidNameRegistrationBlocks.java diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index af5c6b01..1be6991a 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -41,14 +41,12 @@ import org.qortal.data.block.BlockData; import org.qortal.data.block.BlockSummaryData; import org.qortal.data.block.BlockTransactionData; import org.qortal.data.network.OnlineAccountData; -import org.qortal.data.transaction.RegisterNameTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.repository.ATRepository; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.TransactionRepository; import org.qortal.transaction.AtTransaction; -import org.qortal.transaction.RegisterNameTransaction; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.ApprovalStatus; import org.qortal.transaction.Transaction.TransactionType; @@ -1094,9 +1092,14 @@ public class Block { // Create repository savepoint here so we can rollback to it after testing transactions repository.setSavepoint(); - if (this.blockData.getHeight() == 212937) + if (this.blockData.getHeight() == 212937) { // Apply fix for block 212937 but fix will be rolled back before we exit method Block212937.processFix(this); + } + else if (InvalidNameRegistrationBlocks.isAffectedBlock(this.blockData.getHeight())) { + // Apply fix for affected name registration blocks, but fix will be rolled back before we exit method + InvalidNameRegistrationBlocks.processFix(this); + } for (Transaction transaction : this.getTransactions()) { TransactionData transactionData = transaction.getTransactionData(); diff --git a/src/main/java/org/qortal/block/InvalidNameRegistrationBlocks.java b/src/main/java/org/qortal/block/InvalidNameRegistrationBlocks.java new file mode 100644 index 00000000..ebef366f --- /dev/null +++ b/src/main/java/org/qortal/block/InvalidNameRegistrationBlocks.java @@ -0,0 +1,114 @@ +package org.qortal.block; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.naming.Name; +import org.qortal.repository.DataException; + +import java.util.HashMap; +import java.util.Map; + +/** + * Invalid Name Registration Blocks + *

+ * A node minted a version of block 535658 that contained one transaction: + * a REGISTER_NAME transaction that attempted to register a name that was already registered. + *

+ * This invalid transaction made block 535658 (rightly) invalid to several nodes, + * which refused to use that block. + * However, it seems there were no other nodes minting an alternative, valid block at that time + * and so the chain stalled for several nodes in the network. + *

+ * Additionally, the invalid block 535658 affected all new installations, regardless of whether + * they synchronized from scratch (block 1) or used an 'official release' bootstrap. + *

+ * The diagnosis found the following: + * - The original problem occurred in block 535205 where for some unknown reason many nodes didn't + * add the name from a REGISTER_NAME transaction to their Names table. + * - As a result, those nodes had a corrupt db, because they weren't holding a record of the name. + * - This invalid db then caused them to treat a candidate for block 535658 as valid when it + * should have been invalid. + * - As such, the chain continued on with a technically invalid block in it, for a subset of the network + *

+ * As with block 212937, there were three options, but the only feasible one was to apply edits to block + * 535658 to make it valid. There were several cross-chain trades completed after this block, so doing + * any kind of rollback was out of the question. + *

+ * To complicate things further, a custom data field was used for the first REGISTER_NAME transaction, + * and the default data field was used for the second. So it was important that all nodes ended up with + * the exact same data regardless of how they arrived there. + *

+ * The invalid block 535658 signature is: 3oiuDhok...NdXvCLEV. + *

+ * The invalid transaction in block 212937 is: + *

+ *

+	 {
+		 "type": "REGISTER_NAME",
+		 "timestamp": 1630739437517,
+		 "reference": "4peRechwSPxP6UkRj9Y8ox9YxkWb34sWk5zyMc1WyMxEsACxD4Gmm7LZVsQ6Skpze8QCSBMZasvEZg6RgdqkyADW",
+		 "fee": "0.00100000",
+		 "signature": "2t1CryCog8KPDBarzY5fDCKu499nfnUcGrz4Lz4w5wNb5nWqm7y126P48dChYY7huhufcBV3RJPkgKP4Ywxc1gXx",
+		 "txGroupId": 0,
+		 "blockHeight": 535658,
+		 "approvalStatus": "NOT_REQUIRED",
+		 "creatorAddress": "Qbx9ojxv7XNi1xDMWzzw7xDvd1zYW6SKFB",
+		 "registrantPublicKey": "HJqGEf6cW695Xun4ydhkB2excGFwsDxznhNCRHZStyyx",
+		 "name": "Qplay",
+		 "data": "Registered Name on the Qortal Chain"
+	 }
+   
+ *

+ * Account Qbx9ojxv7XNi1xDMWzzw7xDvd1zYW6SKFB attempted to register the name Qplay + * when they had already registered it 12 hours before in block 535205. + *

+ * However, on the broken DB nodes, their Names table was missing a record for the `Qplay` name + * which was sufficient to make the transaction valid. + * + * This problem then occurred two more times, in blocks 536140 and 541334 + * To reduce duplication, I have combined all three block fixes into a single class + * + */ +public final class InvalidNameRegistrationBlocks { + + private static final Logger LOGGER = LogManager.getLogger(InvalidNameRegistrationBlocks.class); + + public static Map invalidBlocksNamesMap = new HashMap() + { + { + put(535658, "Qplay"); + put(536140, "Qweb"); + put(541334, "Qithub"); + } + }; + + private InvalidNameRegistrationBlocks() { + /* Do not instantiate */ + } + + public static boolean isAffectedBlock(int height) { + return (invalidBlocksNamesMap.containsKey(height)); + } + + public static void processFix(Block block) throws DataException { + Integer blockHeight = block.getBlockData().getHeight(); + String invalidName = invalidBlocksNamesMap.get(blockHeight); + if (invalidName == null) { + throw new DataException(String.format("Unable to lookup invalid name for block height %d", blockHeight)); + } + + // Unregister the existing name record if it exists + // This ensures that the duplicate name is considered valid, and therefore + // the second (i.e. duplicate) REGISTER_NAME transaction data is applied. + // Both were issued by the same user account, so there is no conflict. + Name name = new Name(block.repository, invalidName); + name.unregister(); + + LOGGER.debug("Applied name registration patch for block {}", blockHeight); + } + + // Note: + // There is no need to write an orphanFix() method, as we do not have + // the necessary ATStatesData to orphan back this far anyway + +} From e6bde3e1f4e3b50a4a68051073008c9f44fba6ec Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 23 Sep 2021 08:36:55 +0100 Subject: [PATCH 092/231] Minimum order size set to 0.01 LTC, to avoid dust errors. --- src/main/java/org/qortal/crosschain/Litecoin.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/org/qortal/crosschain/Litecoin.java b/src/main/java/org/qortal/crosschain/Litecoin.java index 0c04243c..42ee70de 100644 --- a/src/main/java/org/qortal/crosschain/Litecoin.java +++ b/src/main/java/org/qortal/crosschain/Litecoin.java @@ -21,6 +21,8 @@ public class Litecoin extends Bitcoiny { private static final Coin DEFAULT_FEE_PER_KB = Coin.valueOf(10000); // 0.0001 LTC per 1000 bytes + private static final long MINIMUM_ORDER_AMOUNT = 1000000; // 0.01 LTC minimum order, to avoid dust errors + // Temporary values until a dynamic fee system is written. private static final long MAINNET_FEE = 1000L; private static final long NON_MAINNET_FEE = 1000L; // enough for TESTNET3 and should be OK for REGTEST @@ -164,6 +166,11 @@ public class Litecoin extends Bitcoiny { return DEFAULT_FEE_PER_KB; } + @Override + public long getMinimumOrderAmount() { + return MINIMUM_ORDER_AMOUNT; + } + /** * Returns estimated LTC fee, in sats per 1000bytes, optionally for historic timestamp. * From 573f4675a192f5d1aa78753e1f92f338dfa00696 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 3 Sep 2021 10:11:02 +0100 Subject: [PATCH 093/231] Reduced online account signatures min and max lifetimes onlineAccountSignaturesMinLifetime reduced from 720 hours to 12 hours onlineAccountSignaturesMaxLifetime reduced from 888 hours to 24 hours These were using up too much space in the database and so it makes sense to trim them more aggressively (assuming testing goes well). We will now stop validating online account signatures after 12 hours, which should be more than enough confirmations, and we will discard them after 24 hours. Note: this will create some complexity once some of the network is running this code. It could cause out-of-sync nodes on old versions to start treating blocks as invalid from updated peers. It's likely not worth the complexity of a hard fork though, given that almost all nodes will be synced to the chain tip and will therefore be unaffected. And even with a hard fork, we'd still face this problem on out of date nodes. --- src/main/resources/blockchain.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index d0ac9ffb..acba90da 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -11,8 +11,8 @@ "minAccountLevelToRewardShare": 5, "maxRewardSharesPerMintingAccount": 6, "founderEffectiveMintingLevel": 10, - "onlineAccountSignaturesMinLifetime": 2592000000, - "onlineAccountSignaturesMaxLifetime": 3196800000, + "onlineAccountSignaturesMinLifetime": 43200000, + "onlineAccountSignaturesMaxLifetime": 86400000, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, From 9eab500e2c80b717406c265807a2cd1e595d493e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 23 Sep 2021 08:42:15 +0100 Subject: [PATCH 094/231] atStatesMaxLifetime reduced from 14 days to 5 days Whilst we would ultimately like to drop these to 24 hours only, for now we need some headroom to allow for orphaning in the event of a problem. Orphaning currently fails if there is no ATStatesData available (which is the case for trimmed blocks). This could ultimately be solved by retaining older unique states, which is essentially what the sleeping AT feature will do. --- src/main/java/org/qortal/settings/Settings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 6543c09b..0c8573db 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -97,7 +97,7 @@ public class Settings { private int blockCacheSize = 10; /** How long to keep old, full, AT state data (ms). */ - private long atStatesMaxLifetime = 2 * 7 * 24 * 60 * 60 * 1000L; // milliseconds + private long atStatesMaxLifetime = 5 * 24 * 60 * 60 * 1000L; // milliseconds /** How often to attempt AT state trimming (ms). */ private long atStatesTrimInterval = 5678L; // milliseconds /** Block height range to scan for trimmable AT states.
From 957944f6a5986357351c6e8f55ff301ad1950448 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 23 Sep 2021 17:44:57 +0100 Subject: [PATCH 095/231] Revert "original design" This reverts commit 8c325f3a8a08a80a84a29d0a909642292da8e505. --- src/main/resources/images/Qlogo_512.png | Bin 191342 -> 63668 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/main/resources/images/Qlogo_512.png b/src/main/resources/images/Qlogo_512.png index 2c1b3037be4386ce33347bdc55530702b96efed6..81508bb70f277ebe2783f1fa4cd91f7c0a01c572 100644 GIT binary patch literal 63668 zcmV(}K+wO5P)00Hy}0{{R3{0J|&00093P)t-sM{rCk zf5{|ty&!bHIDEb@d%`Pw#2<9N7;U&Kfy@z8!MCDSpW?h}1lk+#7Pe z2M<;O0!#!2P(GI81O-kOX|o-0x)^S|1O!bIXt5u5#1v?<5M;3qW3>`(w+&*i6KAdw zVy72tv=3seK&IOnW3Ch_asVQ203d4s7h(V)YycHuA9TPnh|>TbX)J-vErQJe8)g6= zYBGn?03>fVi`M`hXhN6bF^1A5dc`e*&NPbFAa%eRZ@K^`aY2^iCVa>#e#s_##v^&f z03&VyByc{H-XnO!D}c;AlH59v+dGikSFQLWcf&M^)i;dSM49CqaJw9FydieMSFHCL zZn*#&WhsBkFNDt>a=ri7Br z3RMzkvK@22N}uabr0_tM-ywIwIE~jSfXftVwK9m(025v(e#sMPvnhYcFon=2e8ww+ z%`k+}M496N1Wy16Qy6Wz9&^7=r0z+c>R7J%L6zYED0DxR;1Fi9N1f;Z22mb#zan_T z9C5t>Ds@ew?pCb$QK#`spzJ%6-Y$gDM4IJ3l;1Cf&j0~TBzeR(jMgQ2#Q-aJIgi;| zulgl=#7v^>F@?}uu>3`vqklX+ONN>XXIgr{6Vy!WT&?kGxcE|l?y82eL z>;Nx`Pps)(v;AGS@L#m~04#w&n%-!@^+~GaD`v0&Gms)*sTE9@L!;pUIGQnWyGWtt z8&#lFtnCLbc_nDG4?&DEdc+7aeF`{(KZw#ggUu*zxhr+Q;OYCR0000xbW%=J06Hm4 z0|^@sU}4kqyV&Ha>H6mM(e}b{s+O&K?f-R|iRQueMd+B$nU60QD$IL_t(|0qmW>a^lDq#xG!FmrA7oC0hjRfM{mQJD!@mb5kB+c!V2( zOMxDP;sHo0ktraRGFMT03nJd)KEUns^`}#Z1<%euk8QR;p!@vmmaBga$QrHpUqM0t z6kT+CS2TnE5xVSrO!oVI-0L%N?h3lN`cJZl{x|F~7Ify!ulM`izSW2DA%Fh5>czxO zMQ_4q{F$pWOcgh^i{6~`yFGlF5BDb(buYr|oG0?`U9x{n!`@|Bq4U$#pUH0jxRPP# z%--!mm)Cfg?;hpu5^?{Gum@dT$Kbsa?RS>_j*W!UKf7VA&O{fzI2E$cp7cpu9^x>Y zdC<%;u1R{Y{xd$vowAegR|tji>;9LRpv${RKfX;*=cMFB{uO_< z2jrbdzsuQK`!Tp%c zx(i?JuMBzrmh42N+FJ62LGXMc&kh5VS9WPOTxBOm6-*jCz8EMFwZ+^AX?z`iZGR=Y z`j{y29UF?ib_`I~|cW0CZSF zv2YKS*bEiOF>@Ql~+r&{MNDChkI?{**PdcsAtfB6de0;el^5K;I&V8<1 zuCQS0lN`w#1`B9_y|i}YMBzmKWvc%s+5xF0ix;(FjGg9KWnyeJv?({kfc@p^@=a8L z571i5gvZRr)Y!BRF*%#vq{B{F<(GxL6Yt1eputen@I=QVD-J=1gIO)*z+M*Vr#gowaO69~Z6fDt2) zq&b{TSrqa0v;Nv1?sAPb$Xe6X2L}Tts zU9F#XZe|uwQq>}QAE^K0^S4yKUFVPx0t`M+Y7W3GW#0;5(dbMNU1LkrT8rw}UXc%1 z**9}Aq7)~b6Jo7}P7{`Nlx1y4FvUE^EUI+)%kGg6SJ_jy;_j{H9)u?jcq|r(B8HAH zEmVd^G8+xld5=X4j+a3QF}%RR(zqLl{EB>KXloDiSc(s2D3V?f(Ig+H3jtXYA39+Y&lQEA1iti01;Kb3?7rT zDjH@YIyTChR?MjMC~18C1Yhmp4+|_ovLY-ag{iqyCCFBwbetz}o6=DP{h0hQGl#ts zIQ`VC>_dT?Wke@pn+eHG!Vr;3ls2qPA|}riCTI-|!-U))Leh_s8l#N!vduCCCe7&P z=iOQp?vl6}%$JTEz{v_b+j9k_tW9JB7x|3Tu^Uj~Y^0b3D-~*8REGklsX-JmF;Fid z@ts&csV0MW;f(I5sDG2l5>MQN04X@0WyW~x+6_RLc{^AIb}H7gpG^KT1l%z@(uh=E zSu+{D4`*~geq+q~9xysee;5%_qsFM2Oh(an-&zxH zj9D%$6=RVR8)n!Yl^EZN#)&EH|pwUkom>U3>OOo=8P$uV}N22 zt&>q(YYQB?!vP4IgU~(PadewzI=JZvkl%F4bt&uDoWi9c!Ez#QD_F)>BQ3ns;~C@h zI!hbGC{Cc!;~AQKI*GpFX6f+fduIZC)^Ksza;SnJYmYDSQ=(n%J9@L9R6StP4zd~xfWfC+g>)7!P3vJ$rmCVzzvSF6xC-=|M zeEH}rZmkKY?6=e0EmqUy%`scHF4-oejA0>>vr@)O#)JXg3$do1NMokv5O+IC?gWzt z3t64F#hk(?>v8?|?{1b3SJ|i2TmCfk>jt7CY1Zo&1j0GSp#j)+-ng)9 zC5mBAhnt2yPDiFx(tcj@mlU2Y-C&-5yTan>B%8 z*~Ip)EGP)-s%n)`R~sFvNh1+wprnz!RQM_uu`vf~n3Gt8Vq|Hk)aMShDUr`evTg={ zgs=8TZfqX8wOz04b*%&mRa(5zd6e>Go)t6LtVE;9!`jA9Y*E#$vmrDUhK_m z{19Ojecd$M?Rq>q`M-Y)r|hHLtck@fzOBdGTF}>?mMot@ zbDeGo7erjDASo71t|M3pZZX4FM3ikk;^Ij?gPaGYwI5PTUMjhP~51 z+Pm@+xq@+>kMHZxP1FmZRTtfx(af(9mdFW?%4gWuYgmZW8gq4lk)y$fW?QWhdY;o5 zQ~1buEL#RK)S(6~Lpq;*@#m6BbmoI%nRol&_4>DPMV{)ujb`Juu4^p|bR+?Rv?WKE z`urJdJN6Sf%a(9wP;@-zvG#Oe;BZJW|9N{T9ap=X{=gB|tR!8j5-mTd>S^y)PGz>RhQ zOBNp<)9G$du^tIlV31wmGptg$I(#9}nW>1mzy9%ZcFCVF*T1nK3Pi=U?D0RK7}81C z;04gcB943|s}2w&Ew>iOxr&g0Y(#{YX6h26GorW>#nRI~fB5xsYT3X1^4pDz$w!qt zm1+1%!?cg!2y%H{YZlkVBztI@0amMoX;KzG>CC)EX%Zx*+8!blKOiO@Q_$4xe%{}) zc)DC~e~yLwp%g^OLrxzuYmg@rOUX(SA-ggZvJ^!{qHvcKu`GzL8dvg`6a6(?RjN>X z5}3e4My8A(ow{8gPWtDkUvEAZt$tl%$$M$EbA{5VX8$dXc=4y76y*n_$#qypJe>roV4Mc2rz z(r2VRJcmb#!n)lnI6*aZJ71Z$$J&w8#(q+l{alj2{Qfo;h!R3@o!TbPVNbPgnH#DW zELA)QSYOq1xB1GWm!Vr74CDw@2^7y$MH%bRO(AN|rzcMe*P`d}N{su#`G6Q7p!$Qf;pO_I{E-g!*#1 zf2WR8at9Za5|4lj11pz!e1S4he&|}XW1%cn?3Fk|*%~Jt#Sh(U{KJ&xOJ^r=yX%QICqJsRF*0#x#}7e z(~|G%$np{;st|M$G|5Ga1F<27)9S5|bLduEPi}MbPyaviPmkYjDS2zwnnOlytSz6L zaZuzN03W7mt;tfLsd$Bx-L*4NO_B&wnlDjPy81O$nr;0X5Hpq4!0M(n442mk+%Z%P z%Jw4=G;8;Vr%x>T^S5g@e(kXo0v8@@f^BiydCjH;LrQf?v3*BE5g8Q-WQ1AnzL1dI z6CH_5*4m+9e?36)Ii62x;)iChKOR4==I^hTS~3Wh`U)U__vh3?SnClmyqg(U31$$5 zD!NQE{=;EOQZ^SnAZUA)aUVdpc^Wjt)Od(2+9M{3-VCPKPS!EmY?c~3fM|2Sv)N!% zCpwGAv*0~_05hU+w| z&&O2xj>?=5Q zra%|ytN7KgUb9+&y|Ji*+5h_2$E%OmzKkbnIR;m@>hN^LQqbYohH)m0dzP zvatckneE!e09-G~yO)>U&aGinj3ZKqTD=BAe>p5N?bLt(nuo^^M*DA%55Ha^HkQVY zcC3nLm{Z$H%sLmH<}uwidvS4UU*t(MAWn0Gza%SI3&rcNAnJirO%~YBx|4R{FVYte zw<=df<2*Z7Ite<6`PH0v_h0|^fqrEFhyIkG88!$1S57*6DM%=CVSv(Fm&}q-FiU*e z8@gW$P-^qWw|y;?N(a7D0?1cesxVTJNMS6&>-#(xkgL7{;?5@KT{Hjt>4W@d*PHKO z8F=>GLE)Qy*eIFxSs4hyBG-&k{#1a)42f7tG8vA*FcX8HYMFq^VQ%PBbKT3Db&`z> z9?dzQ(^vWEhW43{P3Xpv`34c`<2*lngnxhg{r2vGbwe>TF1weo`#$%gkM3+q_pq6p zP;z%{Yv!7}XRVycdY=bjmr7JgL}y5#V5VT!u@xqgDkb*eay8TFR>{f%aD$#OQ6Aie zBSjHSDrYIXKDt_}hZ094NAvpdbaUlDzkPaoZS-=_{lQC#9U@H1r;k1Ni*+SlYO>9? z1*34K^s#;EtSujeEfpwYiM-^Khb2wDJ6ViadqiCm6wuM26hba9h%ad+PN_|k7-v!3 zM2>1V&AK5AlS@$YGi$nTyKU}nulXDKPfvHVhSp?#?BH-X^w6X=9GJJs4WGlJ2yE=W z3F!}Ei+io8KLpS$A-@k_1c1^sNrJ-@C^;9Bs>lBWG&rJzg2deAn?(&u*j9v-VX<3cC(=L%kf0ERfr7Pg3p7S_6t@!v1b=3kAQwcm#soncf>y|6 zL~CluW=H)m(ory2(DSwPbsRNA*WCa4=_>#F>5n_TvImWj6YK0P+gwgfk2Z4MZslFc zC{vKWHc1Ey(zgoGGfHfo4my7Gi;ZyXfL7VdC0h)vIhmnF=ZpeTRbfd}{k{j4QfpdW z&Y(tjB|&4*NrZ4nrT-d^WBT&r>V8LF-?G0nBaEZE^f#eGO%S(@bpV;`~dt zyzoFbB1YuY$#qx~J4n|p=j-{!n8))hK9cZGyu`Efnz6z~xG?C9j|1{p-!em@Tmlm%vpSuaqhObT zPEYhmyx@xTZWYpnwFE29JeSl+0m%a`L%=&~RG~N(FBwa>049L3@mjdiewwwgvD2$J zoQ_|w<5z2b*N^PuFhVATu#XT~lH&BGF>Gi=nj(FUb{|Rh{;?SleT2P$9x!HwRZ$dc zh@!Dj1YOBXQy4PDi1L$!%E9Zy0+`4{_*?G`~PG9 z&-)=J8jczQ>9{~(d{75`af;pT@i;g*2S3R$$fSp@K3Eku)fzHpH24hxsopRL-x4z|WCg)1Kdpg#-{wjMyY8p#?jT}i?Fl0fZnY=`^z{EhtQ zZ;$uP_dIm#k!u?Cwn(q2B;C5>|5}J?>r%Sq{{wW{#W28b`?j2{m<#8*jqnvLx(zmB zCXCPwAgJL<(Twnmtrk?yN_(yD5w8{Rk&eANnCP{spZ9qVIUty!Bikx2|bcb zVw+Z~;B6;fDK1Ah2@YX)P5bu##~<(UZz(-(+cxJgqtZCB>N&Lt*K)C}S`xD(-tLr$ z(165&C(`zNIYLJW<57qXCj^S7P}8RTtb&8XDJ9j3#fkf9pmpN3a#igDqqjW;NG;?@ zZI=sij8*x7Z+=eGd;8TIeq^88Y}Gy2bZDAxO2s@nR;s4lWT-`<8|^M^n+@s0=3U6w zJ|zPu&K57KpJ>W%-Nq&yev3>nR-!?k6it(F1rrEd9IuA=8e^Z7XVyL+M{4=%*gQ9h z6*6H15f9+icfh9>fxx%_`SN)Cp8nDP`RTr!Hq)f`qTiqUoEes38veo=Q!XP$C?_E{ z66YnxhO{X1hy?B@u|O(h(gLA?hYlj)5BL^nkp-4MaaWGwk%-bkQq-2Z@J>n?;ecpn z$;?oMqvC-8w{hZ*!zno8K{3L!s(S@!GCakw>R{?H{+F3E(D3UD!nj15sEBP?sR(qR zqoA^+xNhi66fP73Z~;!qTNEuh{zn#oC}21!I21Tf5gG-Z-+XsZPE-rn&2aCXb7tn7 zZ@xiRtUdXd;F#4XcR!!slKAShI>q(!xj!s@WuDhkke$8u3ntZAlS!G#(Bb5>t}EIQ z0LrgMW_VypRlgO4bOjpYj>j?Ggvyytv)%U}&92L7YeKc>NFc(QxK8VCH2GT#iYxneFhfG_J#@ z&yXlxK2J4WoZY$oAK4$BEV1%`**w@7e;!TV92h(0v~A(W(h10t);d(VwltCyK_tH$ zwvE{3H9r=dVRuZy$c$QW!k_I%&Q{3Xi|Gn2C(=V*^z9OF-A-^3uKpdWfM`p+`Ev z(=dW=$1P6cESDIHqqdgh1uBjz!3B>1`G_~7N#HU{kAf7?$3=oV)fACFa7YSAtc-+k z4<`T)rVQ&>H64sVxwL)T?3>&B8^d>>UR>G=FWY2duCIn1Q{X5#jICj&rHv@_P8r8W zBC7F6Lux3;(_uDK3ZB(^?IU5%*$jWcMP$EXS>uYzuGb(CTEdlj~ z#XJ#WOsy*K%v-kzvnR9l{MmzB`p0}*&^DVqv8i#Nmy>JUi9Aml`vfg-DrC&7p_}s8QzOQtKgMZA4?mx%rdb6>#Pg zXAr9PVkOE08I0ngtWbREj{Y*+-8a9qH-EnW-S*sBtCh;9)yMiLqPTMS*HM;T9<`VK>`+*mr|1`TG|jgU{V=Oy>E z8~#E5-q%moy+gzMo6qJ}d1;p@(pqQi>JGETC`iG(=$O4&j?W(sYWUoToV_W1`>;XWh;e|WaT)e;j{`sfx-`lTy@6LkO9hc@>x~h8% zq}|&Fw_dw8qarf=y3o8y0&;U{yZr8%nCZKjhhWh5R!HmQcv1l(>u)|>y>ol}!!L{HF5Z*4 z8;$*8=|?GL4(DwHEl?e%5pyTvSeF{a!?a}rdGLf-?>r;t*e??8hly=`)dGY;XK_af z?!SEb?2DWFUG}qwhEjv197JlHjby=geh2K_a4mQNM3AFgBD96V+XM} zf1<-j25t23LsuP!DI+g`y#MyyTi^NB)zxnwevI;^FJl+GUSJE7&^6&K6gp4Ijpza? zF2>>%K5(+*?2hyzDux8Z0+A<>F@?g2YB_}tMeQAANhZ1H3JX3t}Tp9GLz>S z3`8Ol$oo6q!+x!v4wqd(QLqaHMX&*r!9WF7>^RB*<)G7+0iXaWxG(~!3qYX*(TRkn z>-+65cTR7WXlGW>*=O%>ul22OttBkZq&W`CTCe_Z+_eTA+5`EcM8f$J=1?7UmRuoH;r-mSkpd;zazwvp}hQ0*m$Z z?BS7zZj0d`zWyz>T0L~+Igd*s9z21hUYV6z@oY(x1Ci7HU_oc6)DA|tNJk}LVUxQi z^HD)KIHTazP!~1t?B2Ml|H*Iv`}WQXK~XWqr&`lASzrpxTb04oyh@+@E(bZvYFb~o zI;zn{e`5qy?XL0a;D(s1pIWNyrm6W3KJMeT4H7A9_4t<$HS$e6I{EDE+g#v*Fja++ zo1*|dCij=I2__eU(G<{nPRTN4Vhxd#7=-hn)O^J2%G0JGm)zoGptVVda7J()7P+*8 z+gI|hee34!RjR8RCP}Dc=-Roi#d^?PzTI%qrZsKcAnV_%@F= z8wh?jCuP@yKHiQx2eK-RR+ekZi}!bLUi#Rt-7Tq>Ho1cn1!(H6)n1c@(zY0H!7Eu~ zFQZiLi9x(@^O=a-smU0$JEQQyp%caPDpqdh5FL`pBoMz+OKeZio*adp=adFWCt>2+;7*uem{K8!xO0$OW`Ko=V45w}|UEelr6 zG+mIU@JdOs@zAb-B)YZtUOalDdCN3)UM<>n?0+x6%DIgtD z=WQcub=~WONK#rIMC^Ax$HA|UgTg}_R^ zg1K{*IyR=PNCrpc;j8m0#-5>=6QRl3odU=ncAFy(2AfB@ zii6xgh_;8W<09M5!DjYho;;#Atd;#|yO%Kb?Hjv0Ip@*+%dUMRwRRg;a`qe)ogHW+ zYF3juH(5Zvbsnwj^2Oix&SOvc>UeMvnZG;xfPd!iH1*G4zsbvkWlt`GpvVj-i!)9e z252;A@0h8b%V;&lh$Whn6yVuI57d4x)MeSSd*?__3hKSs+u#4kzmea%{-6CX_f&i? z1#ZdDU6_t)@LS~^5CN96ZyeEap)8%rUs0E@P~%aYFj zAG6$?tpLAsIQ~0%`eO0*r%MuT1uPj$XISzFyX?6Uov}7g;%u zUI2k~Tw0F8xgvHc&HK;p-`U>Y`r9MFcK80zKYd|Rt;#Na>6do1I!ha~ z5zdxtY_xbApi^MKvuXyOmSjg5M-Xcfhz;ZqS(0Q;NY-U}1Q2dtwt)Z-7SL{wfid%|s=n^WySFbk z=c*(y57|bQElaSQeY?N7djIyzf5ZFHExM(D^xGFtyN&!64qD7yXuCz~c(YkfapNN^ru{GO9MGz14bNxz%pdXfT4dC2H!=?_~&=bF$Oo*sb!5zi_yP{A5Ee!m?8p ze|h=)#|HHgQ2*cb_GuAu+ufd8TFDPdu2^-)J|0iqJ}K~apg zEONq6gaP(l_{K)R2_`1kMiAO00}@4&i^6rC>@*Is9xj|6E#%P7L%aFn&%XrmJJ99J zr(?J8R9hR}h}c~Qd{hnS;*kZ2Vd_Fu4o8V8Oa)K^@C1hhZ~-Nrb72PPOL`C%x$Uwk=7+>fH+uo^c@}d5LgWFpH zNhU~gBm-5OvOUZ^G|_D1mR--ST#k{&F7tppM<)W8VUS@6`tI4=UjQ!431g7IO<1AL z19q41-i%Y*kHn_YOhV4VIklR|m?$KrnReT1NpCF8sCu>44LC&$>@8k4>U-Q7%4rB` zN3b^dyP570k*di8lkx><#*az!gqY`{H0)G9F?P>jUx|1EsGs z?+W@4Kl;$W3SZ5q8wgN<%`D)MktzoBAkt%{Sy?^%JV(*=DkEQ_zVBp%G<)+jh@Gvl zVUUADE79oKqz%wB1UB8*Z(f7rz3Jz#z8a>!ACl|}%CQxD5TUR1lNifPdwQbkORb7g>KHnmChJuTHBE{;zW>gT z`8i#(o(nD!u_Ti#`@iIWeX%}NM#UWqB}~dkF<@6{K-yA3D3GHQ(R9R4&MG|aNtaAJ zwYpq+x_x90X2~Ryv`&s(nWVcWYl%4%Txlag-2sph(trN^Hgu0C-S4P<_7DBawY|xw zog_{lpwkf&?KGioCbihdY)VOVC{zbX%^1Q`R6kOJ;0x-OLR0P;3JaE)*aYZlr(;v6 z_1l-9JW148V*zJ&G3JxI1v7;5pD%1)742tKDRjfqV>thWgk>1*f6RHK-MFH zs66{IxVSoQHN1_s#wTtH6QG0lR0}X8ux27?UF#Nb_{+{W;a3mt^Q7zby?CqVi{*GP zHFbz#(@or#jj~TcwdmNCYMauCD^tU`qBD;qx~AbqTxqz0D>C^?;#Q^gLh}s?6^o@- zKxjl0B{+_J)@fb8eewE;D4!&77QqLdb}qQwPIg?6e)aNCyQz7;qrG)*+8<=nL(xq69Ul`8(r3{T%R;h0I+M=YWA!PSFAI% zJZj@>TCF9QqnpvTMR+x0<96N^NDutk!hY9I6C6!tHp<1||{pMZZgF$b09-OEEI;DeoV=r`Okt=irEtu1+ z{fo8Yya75S$@GV}g6R9E$Ns!~rbnGI#|o`wO-c6bK2nlakhHoGKSUrqAL$ZJc&v~Z zUC$p%uRV2G9)sW&((U$eoloKgkQ;NqQ1a;qcl{at$NyyI3_Rq@vM~M!to@62dy26$ z!`7NXv34IXO)n79&u+^qkW3et)FOh-bS>Zs+X&NuQouruH-rW)P1_|JN|O#9NpE61 z`+dKAZ%*};*pb>VUH6`Q&bc@DobSC?uim@w{p{}d-`!0sSkvMVZC0mjRw?=%Z-Ubj zNC6egF@&Bd+T4TK!a-6_Pj08dtq7kO5o<(0IvyS0IQjDB?T0^R>>(N?{R_nk!rj%X zVdL`MNK_m{z5d5hOeH2%*c)AEW(k@oR)VFC2O^~sb&PVP_DIDI!jnO;J=e*(;^2Gw zV&UJ@-1XSB@m0NF;+C8-j8O8<~rG9)A+~L;e2z_=zpvWqn^;bu8%!GsjZMk?WS>rwYJAv z-pO=qGf3_vqINP0tBTHb(=-|YWz*;oj#M|br8M4XKB^Z!>Yj@7-TV2jfbYn&LPtxl z*V)r~;0)4*Ew3indg8D4Q}699GZq|1kZv-710$eBvkNt-jno6-y^^+CN>VbUJUDB~ z?1-(kdvk2ct7iV?`RDt=Q3xNzz7V2{P-C#hLcqq}<-3!~L>rm%?gBQOQ8QAD38je8 z3aPHml`BUG9D`ccG>LS>!!d+ANstXdwR;G%X1{7tNI21@FYZ~uIj8P^5@n4&{jQ%F z}Ju^rbbq%!-F=bh$D(6r=eRYNf3D@zzgs_Y_V&IsFk8q=m4 z$C?hfQXBKho#(fMANVpu!5~G{NCjEOJ!DH^j81>~{CsaV(Pmaq=Q>aI7^cENo#!5? z$yY?+7M(mo?wZ1-w!u~2NI>PioWt$mqx*$)lVj3(I%thOU8L&?;q-i#mo-zz%w zv4st-nx`BvwaKLMK445DR@Gu|Vq>$6%8EH+V8mg}`IAY-#1tEKNdY5CmZ|ERdj79B zz09*qSAo>J7Yezc2*2op!I~Jys-%-(`HwfBKWM5-Q=c#q7inriu2mW6Bg8qjDn~=R zW&k=>C3il0O&okB*YL(vRdv;RFF!Tq{^2ar%{i+{yw=d9uXT8TEYkf9iCey3hB};-KvV#z7r~2%66Zl zAuPrbJf>jI!!x_r)p)VE$UEGoFKhYeu6y2vbAGR_`^nsf5x~Y4LM&xUUS*Is{8u<_C}&4n9W2{ z_mDDz+)!mw5vjv$DYU41`rCi_>&tJ4-MH->QR6u|u(4+WXIXL?{QxC!)Z z4y<_K^TD=#qY7>VMDF;FMMB|B^ArSKp^cIsv~-d%pW!fG$rrlRg;JyrN13%L$Ws*5$6 z*x-HR?d#21HJgnK=d+n-25DWUzcY6TM1UI(Inru8(*PN6t74drz%8XwsOa0iI2C+N z^oFmG=n6H<4Oln!6%Qluto1+FBi%1;^7*?C@ct#h`FmQ2$ZCOI-_!G!r%f+~Y=&nk z($p-Fl57xCmyWGh>Xd7S2Vn6Is62;S38I%^Ysd)OD-ef3v!d9moX+JIYatndX&NNvhT zHP&AjK^s!X_C8LUlm^tTGMY`TAZ=^&)2mg@72&;qpIm@741sGtI`+qaf6T;V^9jB61}i zv!TMC+KDcR)8WiQ^29XN7%j%3Mj0L{$L73k=Cv<(`9{^@Q!&7@QkMVo5U9`N{9>xL zNavM|-+5P0FA~@i#JZO{vRF%`-#Dp#dhzV+;^-ig?)e&16PE~_T4j(3cz5tdN*aFD zQ|TpWkYuB8+I(ba7mqJLftDbtuuO@j$l+5|1tEo3xQ6WFnkq~;UtKK*GpHk$5;pun z>U5S>#7RWq1^4i8B`w>qTSMide{=!m7YAL#<@$7U((97EBpA}S(l@$Z_+Zj+twDNu zPim0vM<;!N7v~~ftEYSi^r?Qnc~;NP#JYLWhF(H-;czU;K+uGi3_udVP-On{AOl-G ztc2a?zu#Ws#DoXwV1?n3RuQbPZVapK_w*z7?tOOtpTTr6=xb&I#Hk`CO9Vm~==#%8 zKx;Ap_Z>_%3|1?A6p*5A`xn}!^ksfjH3Iv&axUyX5a;Rf2b0c=YIqmc;~&;My!%|v zq{DiC;MS*dUdHN$Q(76K(+dB5)5J701*_nE=yr4^q`m`g6_Xo8*oG0mK;KS4LW>=B zUxx1Wo9*o&_oJ}ALiwukClB?z=UY2FeevE;+pxqOg;`JG0wO?frR!lBB?uTOK#~vg z`P)%{Y|?9PM|io-u2@wSKogp7ft*()F5Bc6GDP!{xBfx^0#Vo%nE5 zPjrko?-vI{pb+L=z@!_lk|FXDsp>mfR=Cc#zA4`s{QK>vy`R7-1(mR3DylHi zZ~?-VD(XP=>TSQeI@%fR5HyqTJJ>!G7J|lsEvR!qIXX+KoSf7#b;zDaXWq+|)iz!E z7-h~O{m%V3i~B32`^^3Et#wJ)J959*sT|9uGU>WH<7aq$<_PIt_Oe;mqsqs4CYYG= z>~rjrOVBnsDh$h%x}@8Vkdu}Qa4;A=c=<^X6(oWrrIy7oW!1716&euZC2bmbbsNBd zx>Sh0x4#-6^qF&jW=tOj14O$Mwk1#c$qFt~M$i7s+PSdCab$b^E8Kj5e3BUik!&T5 zAQvJo$Y9^V&G2$e43Pmdz=aqXLNH{q5R|2P2on(=!3+vQxg;*vuG9KBw99FFCFd`26fy?Exwrf}JN)$YnGBUhbIvm! z(AV?4&nOed2YLDAe*N+!FK)XEB#F?hsM(3G%6)sHTxNgo=Pw?uH3SRvF;=R0nj- zkk*W?ufKWBA*vo2L6h@yP$&1=*;!prk^{^Oh?1*PSh{e_)05WKs~L`eL9YHe+}ZjW zsNbIW;?cp)a=jZrx5*8i;R>BR!}8ffJ%f4qG|^!G)OYru_^vMK>VB2cox|v9cIT{G zLDy1CG`oxWJkYs|3SZi^SoI^WeaAOJQD;@>T(XA`(Uya?K%L^~c#sjnwS85{6IbOv z@uX)~VOTXBvbdy%Z}q+LXF{GRyq$9H^NDukr<)u~o+5d2`Eyl##qq}T(s=owLf=q1 zNQzN@Z69KWfN5H!8tsY(5}L%g<^$|$MIBT3nmPI@|` z8-l_kbowOU9UR{XbS&hYK>`#E;}ANC97V)Yl12H{bV5p`$o!~-Pqi<+)PFGkqa1d- z&VZfB?%O1l3M6R?mZ940GHswoCZNj}$8q7hyQAUGYOc0Bge#MZ`Gq;4-@S`BbwS@l zx4Ks6C3hNVG#UOAPO^IZh1&1#Ayu!-or!;PRiEWaWf8gS`j7_!KQv98i%-Y()8o#? z20})Ky0;1w<@7G*_%6oj(k7oGbA^sb91*jl9csO}QVh$wJV&-HOFHMuq6{ap9a-*{ zE&k8L@{I=_$U&`&FIL zzXo)UM1T14{&kD`$esuGNm1u{GfG2GSxv=}mkU#th`ae{{MlzYm|#fnEDp1k3EFwq zq^6+Rg)2o$5KgX8noNfeIexWUc0s+Xtd?ar@>E(xHF0+k@kptl6vgE10m=7xcf6<> zcb#L=&feDm-OD`$b1$O&<7i zU2Qj4ej2hN&!$L$GN=iac@k5iR*+doFsPrQhFkag^|1H+VV&!8}=j}b9 z^Qq3tehbVYzp@*Z-A0qAhs;4WDvCZi*34_qS?<;lUfsfsEcY1bF$qFQ-0$^QZcp1m z3+=e@KrYfX^cmElibV9w+gu$pQ!9NQb>`Elxtf9aYDJ~5i8T714_>eK3uL*jiJk}M z4?g%yxIG4?HF*Vkbc@O1Z_ij!>UG&U44tjGUpf9#W5PM5jXmj~K*U^2O1h?l`TZyJ zLceS3>T5&$-5V^tC;K~xNRm14@0?O<=n{>IK*!4pUp1MD!>0wasf_pgtoM+1dhVtA;j=&TSJUA$CmKx%?9+MUo>Ij(^!y#Yabaz*GI?7a|Z9NU}A=`0)j%7gw zowHgr*j@YK;HHcht~sGEc^gPT|54DNsIGHCMYiSG?b1NshXiyDA)zI@Zd!Dcg2W}h z_{Eli5c^a`iczl~?H(L0Hbp8|hmH?9hc`7HNTyPVPD)R|!bud2nDE=Hb8{Verv%B9 zJ_6sdHr{7eki{g*Fx|QcwGVY6|EPLO64K$IG3Lhz2=|ybMXP7%FKKCPgINxf2JW6dd^(T5LX*eg^#E z`Kc*wEcDku--YWwBKMKKyRIflqphKMNHS7X`D%RWMnWf!IaVr-of6ta3D_8OP864&nz%`mr}QVtk)#y4+Q;0d zk5aW?p1!^_8a3cYkv(NRWt1*8Z2dfY2FnH-aAB=trzDs|!t5Ru9fAkCp{t^6f&+zy znLTgOIuG>A0sR8ElGPANZBK+=7cbE&=(Ngt2?5TtBXf=p1#$?X!)`ulF5?C*OM(Rw z!M?M<=7O&MOf(1B;VxqDr>10aOkvv4VWuZXMV`_!#2=$hKYQLPk>DUcbxTMN6aHIl$P7!8_`oWWpF z^mk8xUS3WF!k2=kffR64f^Z~(H7D1I{5tyv9kY`}6vO!XP%0y%RQ3ta}1LNZW|5b`kQMaS|@-`+XqUp9A*fYsVNTx3fNajGNzeGAxd zzf*RH&kKD6Z8=eIpg$3Gji13^1O1EtHFW&x_AZIB0J5t42KEf?6y{ZcqY?}pfb78S zo=z`C1zG@SK$yRj2myk##SsBXV3t#yoD2nBM=yUgojzD@r^HGqF~Rih_Ue-pbh%O~ zT4DNi%u=^c{xRH9#s-wgHH40@`-wB!|8aEZY6rhKd%L3x5u9aapn|xn$nKbe}#<#7qHwjiJ zy}luP+ql}9brgn6U`ImDU}t~)fopkQehqv{(lit@ZTwkMHT6Ln9y*IBue$y3$L-GY zLZY67l_+^auDEu5Owtj)15E{Q%je{a%O@L~o5-GWZ1y|+g9f2Vb%>gPQkfK8n=st> zu)=YHG;DIvJ0IvlUF-31usf~pUJssachz(5TpAm+-(8xkrf)3;^lK9Cb;_##KB(I) z=Z88M(25l`_H~2DkM+>V6^bmcbph~G%NtR@@NwT1j5qO{v*J_gcPDUb{CGnIc&B$lQTD=)oxceEoB zYGs%g0FJ8VdQBVAWmTo$t9>>tO-@sb3M~P-Gbn?O%L{K+@{{pf6x|N5H#hy z0yp~AF)Ege|M*>lHXi^eG+Bm^2>cX%E^)_EJ>l3gL!l$ z_ut~;_*D;IcF*4_Ve$X|VyzY2k_6$TrN{pC!4b&ZByU_A%SQ*lSNiTiPC4A(DI~PM zs{<}Z(|MAQC&B(T7NpU#d7Qr{!;Ie6G<3q&ILtXTbuhoYF;_zlHGN}iW67<#5BGQn z`BB?b3%cxbUb~;r`KrE80dtDYa-tq>Irlpi#U%24SXC^D>h|=b9CLUjIndS{>Q+n1 z9hpN(LQTu3E*`%s?oo(b$ADjLzB_KUTP>ua=tKRv^vLD$oTN)D56gpd68dmk$1i`W z@U_);gZyxFXCUMhbv29mDxt?aI!claclWgbIiY`|{VZa!5!`c|0W)mFv6@4+=73J- z5HhDJc8_g2?DP%l-f>fJZQMSC>!R~*0?=iO)yfOBzSIFITJ7l{MTJ@(t=UR1?S}4dcRtSJiEil8y%uY%VbM)? zq|+!9!=BZpO6Z){BIp+ced9j^osaY{S<&^<=FN4#)!t<=2g*Rh;d-p=w`7au$XLzo zqsTtP7ZPw+M^iD{-1a^G@XEo-Le4e*s~i)z^U_2`oKDI0loWA%@K4sxz%{PxO5=aU z0s#3-6bx-QW}2opj66o#^)3JgU4Qy#O+vV1V(C z>uuO&LL^E9VGS5y3RosWRV8cV?Dsq0eaBY_w~*<_%zO9Ud72r$-< z0V`=zO;wy+buWWjgurDxcKB(jx?dIb>k~u2rLH#D*KmVi5Je_pc44t^C^tzk5Y@Lqu zkK~pybI*$}|0iozosVjqmQoK>zXEi5U_TV|Xr@mBz0=v8yFS;{KR=&Gcq4PogJ)OvJ>|B4cMYc8DAMwAAzgGQq z9si8I?=X*0IS0Q-|C*RHfZy}4HC?Ko#R3EAcE7>Ozu}B~FIsG2=TW$S@6sfezSh~? zq=dgD{|uJ?p}WlgZU4SMdM1s> zU0Lt`~GIiF)>w zpn|UHMnvQZ%w^@jQQ*r&&^vQ;w>LX;mo8DhJ1$nnF6dGj_CWO==&_uR3_UIVBU`#` zJ-ga4bp&*TXYA$~$~Vw$T2R`OU(jXsoXZId$2lVJ;G<4jR5O-vp6m!m~iLNG_d)O3h ztYuLc$OLmkFSEv}8mAaox6!Zu=BjF7*C&KN*XeX_MZZf7W{e^}ymB!ASmR?|my@MS zf?j4|7j+Ch`&W^mL!AQNkt6cBuRnhG-7|4PoDSBI^sK;5z`u1+?Yen^>0^097XZ|~7^ybp9U zSJ-Sq_YK{3cR|nS37~sbtEjr4FVMpSyCdjN?3kgE4)ckMcUgL8ZgY-ab&2{rwS_zP z?uj|jxvc};)Tzq-vOxdBU2S3MKPlZVhF$_;1Nyb~?KAJow>lNpF6=F7mJQ|MEGeGe znRAn2_>oKoc{(S)-P(Pyy2<&A(|$YT? z;R99VK^9Vi%x?w40nmf zx=md&bPD+Uvh>GOib0gE`_^vk6+9hrAhAp$; zA`Tise?9CWf+-SuR^_6}Mt_$qJ-V}|R0=*T*jueOS523?(AFWzyv7_=2)vj4+RW_1 z(YDNWS=g5oq-j=QlRewa&R@#%)?0_|R<|9_FlFo~dZab}Alk!(n`6a2kLUj(E zIlH(0*wA6_VK2C?xTSlR8XLNA=&^E#p)+K$c;hg&Sd|Ir(jAb~r5e!ZHa9yHK))d9 zosO6r`c({_UfU;Gz<=xz_*gKQ^(A9Pu~@Fb&^>8j1>M88+)lqH=x@IRbo{VXaYfzM z-fgD>xn$<;yttBKlh>RDgqMH*uc0u49PSlO9ZEIxe8syeLnm*2e%Nn!Yv@Xx{9b;k zb@GLh?)CHkX}P5r<@=tPs@HHxz|OZ(DK0Jo?%=Y9jr0Kn1{8g{{9)wHsWH1oLjo6XZilR`W{|6 zs_!%Je78Wyf6>J;j%TO*3~*QQ)T&%V_BQ<>BzB zua3L=C<^R1sDka2{Dt+sdSj4359C&Kz?=p8{fCl@JCE&Tw62gF`cIfV^LOEy@!BO~ z&XDxR|7t^53HzP*B--d2s46>0QMjwgH3{^!71XJ^T^vCJbV=PiIhZl(axsOZphI2$ z8G71WzI$4&Y1-x)s z52uo}c5Hm;tl`E`8~t_j0@=guKaUE}zwlM+M1kI?X6pJ|E1h;?4s;BiTAT;=HF3{n zRx;)trLm^fVp!lu&`V?-3tAV?4A5ofFyB$&v;8tsGM@}O#;!IS)HQC|y)N?XKfx)d z;vMLcsWa+9)MJtYmN9Z$ovK_wpEh&}?EJ^B$eoo8-A>hhgT2@5_fA;*Ny<_t%fZ2m z8_TOQ)S(18wmNJ!3A+{=Do?mc@prt#hEg01SAX^5WqfpgUN;Z?vCenGHr{h%1m@{- zvYi8c9oI|~Fe4iX<}o2r1Vb{Xa?JD|eX1~bgH2BN0zF!5l2NX8X& zD|6WOx>z|SJI)xp8bIC{`q`s|elrH^HfElV8IM_%|CM+dm=g_2#7sbU-96%-O`U9`?OT}|)b+y7uD%DtY9#pTauC8L@ zej*BvrKPuyWxuFK>;n|}J(&0U{r~}xks#=yIK_KF6d3w9q82E zJ!UcVbC>R3ZJb#5dSdsWPR+f6E<%$-zqs~PCHyAX=jc{8kX0dEKV`Gz`^ zP0>{MaP?*g>jSV?{uveU4t)>k4pVBN&&NZ;;Z>sF7jJn;1W`x|(-xYKPzI!Yq zuDH^tN|aRG(#wcNC$Su4D|y2Up!DCp@UtmFv|Fb&+y(db!q3GZt7CZ8!E+m4|dDNz%~zjpnT7;SQ?>Ge~+qM49&lP zUzQ&C^ax2abbM-3)PE&a+X4RVqwxc6R?i7M6rc50hL{BAAs7ZBb!PCTWYa zOtx&6B>zqGHb7rpoL_!5>T+xErTU0{b37XB3&VWSMz5KNU@l81${*-4|3u!>YsU<= zIIU3jb6r9QyHj^p|FxjgqsrLik40U~@5C$w4t7^>=p5=y1bywo-Mg}Nx7Ft|fClIo zdl~i+#k-(OIA}@-E0U%EHacF++|Yj@=u9ai>i?!b7vIy}QqLZ8+j?(wV6!pyv@%0y zAE|>EN5h4sB14qImVL~wW^eiAlQ}%~SXf$Uj+~Dm|D*kv{sJ}i{A$gw)Fr#2Cvpru z2I0K#;=OdWNOd*N69wv)PBpH~Dn#)v=rIdy<95dvN4yMkNzsT-zs&WHv@!wo&o4fe ztqb~XD)3Oh9`$#ddIKF-tiDPGT`l#}YO@dC9-64gO=tbx@iRbYgdpX+q3fCc?snAU zZS4Y_c2yZyw|#iDzPMy9fGpvAS0%hF$&|C5a{dA`{*<#9hd1^|y}_Vw8GB(rsFA}Q z=%ugqByw^9)VHB7@A4g<(4uylpEObsbz zqwS`eY=$oCd0RL2@MQLz0@K)ig}oW*@lsa4YE0VJENik0elFNvwL2}N=Ow>Wz5!Xz z)MjyE{)^*YZv8h5EWM#=#*0bO>E%EBFl zx-;L1=M`f9Fh-Et9e3x>lPBkpf+t|Uxw%Q{etQDwvUHjHQ%ZQbc?A7?*A4w#amoRm zaS!!s4_?m4mR>{uRgP7cVivJ*#0okCy$$_L4ZSqZYYQ3}_W+SR-u=O1F+y)@&Z|E3 z0UH#%nkA1^^^%}BXY)WlSR8(H$j66z{z@*`{UzPTH1iS#VCK`*zN+XL@83LZ??&{c_v**7#sT+m@&dR>s2o4T&( zNLTbL-`g!iS76N2qO4C(K$%N|io+T^c53D8{$8= zaj~YZaC$@E3Fv%-#?*l>?3()D(CH0sg5|spRhW8U|0(|Whl1`|LCz+06x55g4K{bPQC zqY>ZitGJo>8=qaxJaiKJ`fKQ>?nPWYV$pr7hR%q^&jel4jI3^?n>m$tK__+sL#G<2 z$O&mv&{tM2T=@3TL9UwIQ@ULQ{Z_`xNii!0)W7qwd(|bN`?k(}IHJxX_q5t5-#_ts zT|r%ssL4t1HqfDtW-NzBgu6AHz+vsZvwGHjltoWx|C29nded{7+5H#s7_Y6fN+Xf9$gSF1s6hbnzY~VJKxM5qCg>g z(C&<8Mr)&=&b{|MZ`N-{K8m#)IU?jRZ_w|$lqp@If6kUI{^j({39261?JnlEL%J85WG-j7mZk&dmV<@+W z^CL5Jy*l%S+#7!!Hx@U(`uA&Td95~L!S%KpuW#si8kGn-23vzZm}?S{8#*3X?6nW> z0X;de>MP{b3E9bBPxa94a}RWk^y3YN_a_J2&pfT;f`@zZVzJpI&XubSc<;;@TirV| zvfV>5r$qtc4M<(>lBJv*#s2Z;8|n6^=xU(X@a{Y*NvM~)d%^V2-~D|x5s;aYfI%-| zkTg8T<~w<-%d-Y$>&o^S}Kg3(P==I4*aXBUNzY z`q!rE4y)1_+tNG`>tx6T^6sN1G7^jK}N z)yL33Rpr}%Pfk{=bvH9T$onlPJ#=vbyxz=qAPrh0vlwSx2>E@Kw;=aq*l+IAG7DIOc6y~1MH-i2ft=<{manAsq5>|%y^autz4Z&Tm z?h+^4>Y@&I&*?`{KXO5qQzRW-l(&ZT5UHgHO$$GUC^4032*MC4vJeqFitt@6dr%T=v zbY}GUWth_0tF`uC4s$Y4oH4`Tj#Nibpp)zl^L?$OUEv`8V|aH=71exOOZVgBjfVGU zhg(|6Nj#?qdUV~%a`SYI_5IW>Vlq~U8*mI*6$eW{0Vw%#%Ow#J%~B$cR{x=R^3Rv1FieY z0sZEOkJV<|A(wC0^>Y!R=U02~Rd@C3WPKt<6Lwf2i-o}e3`PTkI@$B<6xU4OF6Kgd&z9B zDHZG|()NOGC`KA-kQ%@CJ`zLEAHo3LH@I+0mil`$@_DCe{y&D^#AEyEIJzV~MYDRh zqNC}9-`#rdRUFl8GT1%?I%9k4(dq0S6a5x(bcsjz3ab9rLDfLNumSX)Z8P86+H<7M z)$026i?kSHDMut%%gB^1C&H>8W28+<*ReA_yt{JQb#2V+VqTg~tEtD{f}y-Kfo`#; zhq|F#jXb`;6did;P&moSdJdb30r_kKvi~p9=Y=urr8B=Re8(5sgM80ny3D_d^&p9+ zN7^#3sP~D_1wFllcLV)tK_40gx**-HCyum?!(dYgqpwp<9Njf71zp>4>GyAg&CKPH z1N!;nhnmqhgT8$bSFEZ3UNWj?#A@456L_q)p+$kAr|}TXQ?Aa|T}hyE;~}T{U)%Lw zDeCT7tpVGl9#yZ5^ZJeYH~Vrro0WqFos$SaH-{IB$~y;R(sw zvWZ?zzLNrb|GK0)N%e|eCfh~gYYMB{T-y}-lbxM|?JWu8JJcPw13F^p-q{6JPbze< zKYD~Gb_8A2ZL{H)3-k)F3i{czS;+-@2KZ37o}=l>h6K74-PC8@%Gg)y=>#%{6=-|~ zI=3X;oO3r!xq{>%{-V#L^*{~_PPy2$oCPE%y<~Rgi+3Ax3=NPYZRt*?uc?c9pc7$( zdB0angYF&DscF~jB>&mCzI06}LJckw||W`O>1Bk1pJ@1W?tzY__{h6n+b7+0YNi31<5XW$-YoMuw9vU4En<%*Rp5Ub69TCK_0{=S?;cEzUJg-l&R#~16Y zsgyXeZCBrc#>I?JJ;HgfJu^qwk(MqOV?87M2s*^Wy&G7d$kQTSif^P6{u;s@c)pQu z4c$y7xiv?)%Xg9a5-Tl}`OMHQR||UnYbmaBC3B#MdZu)?{JJ9Ol(0Et#sYIxJ$)za zmvQ;pJ-KD7ek~out_S*lgYE(c=5v)INzThNqp^7dlTG9M&H-W+Z|B zUZ7WAEPaO%Rj?xEfKNM;A9_wVbX48qRA_1XuAmcfIR!n9K0uBbI`VRQBU1Bf!#)RJ zaW*#O4s@r(8{e9|k4tFJt=)bz>H0Z8D(v-x%XJ}N&Ep6tukJ6jdd~_|lJb|KXnwqm z%hEm2$(gxi=7A3LKqnv4)afzvh+kzvPkhat-T(YN88gi21X~QDNLF0>4qh=g4a+Y3xyYyVpw>E+P)17z3+|+@Nt*%d(v0kar=>jhYjRqV;?^_fo z1)g@X?AZ319W*V0h8FN2g*wnL1$w^?PAPgObweMQ2@lW{<0e#w->qAkim^iuq|1U= z3T^1YADSHazCrgv;+q=KIqGwkZ8Gopx4Hz~h_wT~Ni)~<_3hA^(}}QAlHQ<8`taIq zL6@T7fyEUU2P`7D`K@4mW=JI=^moGizC@*zE=yTCz+MTtu zAlU0pMkEec)^I@o?wbAs4exMgQumxLF?6VZ66m{a&$YhIvc<@ z=uZXxte`_(BJf%14$z%8mFn;e@aJAiy$9+oNZO|MHOzPkt*ICEoQv-g(L{|vJkWJI z^{F5A>#vOL`CE#+Rubm_W9?jAyu7b8&Yj)ADbDIP7j`$6Sd6&DSYx+wnrzerl8x>+88e4rQlE#7j|TWz>M3386qm`Mo>1yED!>V6bMjm?Xu73dCq(0eJ67B z*HdTeB{3$SIp=%sN8&fVq5n;4EfJUpx;_H^RU)5-}HaXXC8tSYXkCxvB4*5xT>+G)OK=*M;2BW8e*4Q5*Spzv?j0 z*fJ^jqhwUzCUpC@kRfQY z1g^JD-Rdpj0T@Bg&$iq~U=2l|QCRiDeI^*Og%IN%WGjH5H)fOWq>as)T-QOj?hRrPbnKVsa~ z+2&rSS)kAF-Mcr`>6_B;I3#0f&B2+=a0Uyb(OsqZz=-nQvf%Qf%5%gAcOQ?hWxpi9%VSx?87MO?s0|iCzU$S9`IgY|} z$@fLmWocUe_=jOiy;^1heHyRj)>j?=Mbl;BQ1rUalUmC`BwGlaml1L_{V55WF6QVJ z;o1du%q0IR3Z&vxYY}s$W*~n3`mI~LW`Mo|bqxGB1zp$;UGBTP@EW>(IYDPfu&HOA zU<#~RMxxB3po1Lf7Sb zdXsG26uOb;Ko8bk&F(<|S+&6Oo>ynwyU2TDe(U^8wdVa!R8`kpA6Z`|+#hhx;s4Osorn8-( zcB6Fm6&d=Tqw8onBIw%xn~QtiD~Hb#>6(E9liMC+J9`Hjh2fHpQd<6m8nh!~&gr`Z?WI z_w;>l83Pyd9_YO4)`}ixj7ZZ9I@Aq4)SEdOni8d2Fw5cf#qD6U1h&7^%=y$R-@lHU z=P{=jFA4idwN*9qe=bd*7S}?&mZy)$U~-jy??Vg+4coAWb^+EKuWQ=NNw%gkkPw2UMT zH1^+l(gmGXiaNI}|3DT_V&+J1 z3QS%`{W9v9VZOnc8SM$wt10AyPRooucSPKsfdx9rIGN&mVy<+YUgR0P_xY7GXIA#^ z(i>q9UeFV%8HQT@)v~~X7oBvr=F6evoFD2+X?IKlAdgk9=oHO)3d@iYL zXKmnXL7<42_g?cy`Zhf2f&L-f(e|5Pe)->xy=9$h>3e^-KL|d9_J>qpZ6`&I{0!gLOY-Ge^(e8wYd-*hso38%MAEz`UT>trib51v*Vq zD7sh)`pUyQa`R^d9qNL93s;T~$#&pFJ)!zxi!^ko58FW{_TEEXg05Tt8ajn^!tyBl=U}&$k2*K&;N{y?zWBmE z0mv&~uV5|p_GH`gJi7vcu7Pj*o~`J4 z&vU^+&@WI%_fF8LIcwv@oT92Sut1k}-!;RVK0m+WXPw_k#WvD%`l8afl%91&u{zLG zba8=|9$j`wB0%S^%NtzjyDRZEAIv*7R{8IO{^W}X0-dN$k{7+0cYPHN>cx;Y@0mXA zM?KWVJn|u4dOPx8z%rD>IX`MW*H1L_FEQ+&N6TGwOYL3MNyPmKN{ zX!@CX{CA+ke5dO!(exc`6YSevhR(E$z8JHE22HOPctO8G0{-s!GNZeuw);&t^tSK5 zcs)S>s{uN)zMWO(z30s{pwtbp)^xKHv2~9wE?o=In{(^B&_~Pp8=Rq&06b#o9&>3g zF5Y`DE*(Knv{uk18=y0toX)sDxu>+u1Uj8@hF2nP| z4K&g9^5vR(c=N6o^kYR`($vYH#Fs;??bvuIeu0dk*Z2HkQy?KQhxzSPWeC=Q-aVHv z4|It8L*Dy%V=A%V^HbW&u8WJPVBV0{DW zb8Ql5;4caKp+G+{ci@1TgT0DvV9!au*7Q!swx&09NtjocTuP^=jk&w8Y~*4@hbT$lqL z>i^McmQwcST>SGxv^d;4sJp&$o9;(l?_Af{IO4E}CaK)k#B~R`mc}I)ho`Kpdm3Ohk#6XUo_WxJ zAu>X)y!%dGbfs7RVkYRKP6v%+HjALklk2?{dexa+j;bFXc1XsT6ZB9Ibc!xiU2;(W z5Qo*jYv|bbhK{ORrqHcOu2&0sm^XA&?^zc)+~Lh9is)bm>KMEAeGs02Ah(9gxJ&#> z0KH49#XL)HBm?x=AUm3YJ{@X7TG0)?t~lf>K9zKs>q-B%pzA(*o^;-HtGS_nCg`6G z{g9l=1G*k`=iN252pKUK^j)(;pSO^I2Re?N(sJ?NcQC7-v>fU8Otl@h`_9Cb?{Vg` zdPApcimJ=U6749+HGM&s6m-ApDX>mkXzGN+2>LPadYe?$gFU^=X^)eLo(Lu*-$s5a z`W1djg%y~mUB<3ELN>0UL%lOY`Zy-&Is9PjZs_>#(R0tQk(WUp*S}&x|LJ!==^)A_4zTzY_F!JmMrxx2nH=<4&kc^rU+q_~|F{Qx^lS;Vx^>@_rJXqe1E9b;!-9?Pa4ag`5+h*C9t**zL~NttTI+eJ*G@V^&(W4~ zr~5!1W1FbyTXcetd6D|dcEsW^@dLNOz;YNb+cOQZQ~`a>=mVT{;hi|^EOajE7RV3 z-p~=YPbN;s+ZZhy84h=#=nehV2SJyXhk5+CjH#w&rVgrd6Q37dPbaxJlCV%$j4tSz zYEe6+s$r^yin*YlS(%@Ic=Nsm_ubZg#n`WBMM16pr`qR=y1&oTNj^w#wJ2iHf^Gs>9IAsJ1V2sPkogfl>Q#8Zd) za)XT@z6@?g%hNg;;p>GAY?M8FDblB9@jqSc1$`KAu$XtxEZTWTnmN#as!aqWW?1H* zQj3u1imrs3D%M>|cTcKn^gjvM^XnN{+&J|#pn8IlCfHk5U=uE7)n3%Em18$_?r{pbs8@fa>n@d)!@%DGx&-J4>oTMK_pd=0^(k~A@3DuNQA6+b zz5S-cB+x_31c44n_ruc!*H62VmzpR4*3hdD+FvMS@)7Tw0eX-FJ*K8KedE@+PQXgh z4c*9Leqi%gKk#LIC1z4ntsp&~d!Sd+-Gy{s^sTAWyQ-U+YM}>K)L~AD9_Z5b zZ8JcZs>i;s^wXl0&%}Yx4$1V&@%1g}vhRYvc`p9@#K4C- zG8`{p&L5P=N7)Qbw8~N8@>%=s@z#b(Pa-i8z(mo*PS)rhMpIl zyv$=AuvM7O+U8EXuL#!s{4CIiM?IxB60wQd7BlQpQJel8ULCrosfWS9qv?LsvG0Pe zH$7|r(?A1sw?_hfr0HQE=*h-*)HWD+phG>2-Pe5^J?xRy34dF~i@ z^%$zzT@QMe5O7bmq04i(b%!|%M@Y;79VbpASZ8L14)qml`qk@7&?NtkeIG%8zo5VL z$$44$T<(&viv;^FVQTq~oKwoHxN#An)hpBcOvj%mW?SWC`_h z<3PR+eLK)yYdNv{roA{P-4(A@H)IX3$&VXDe@wo6K{xe9z2`tz;$3S)%v?EcN9eGhIOEGenH$vdwU1y7~&c*E~l(V7lBx2KG_nGV()>_J<#K zFQlPM>Yp0)fu49RQmfq&Joi#`3Dg4}>+apZUgm$oTFxq|v^}%@SFLfPPMzA`I|J!g zU|utevF^V`&)F16|AY+jEqZ3;yst=6XMw(K>Po-k$VJlyoemnHI|Gj=hY(L>u8pe8 z7(%KNxym_P@XH8ykR+C?%S*dPi+EECW3fo?(ecZ^VL* z|JJ6zleQ!1@dz7wC*BbON7I4sv)S##Smr5!BAIGJ-*zVtAURDF6gHN9p+G%bX|RbZtm7|{V$Fj$91CV zAgD*Zi|RdrPP`tBsha&HqfGGq^7GICcd-4Y9W#&+9jVn8qjhsY5A!|(6Z4Mw5%a;e z55y&vf_0mek)!E`UUwD+{RE9tP}lO`x5>*O8F5HABeWwyW%y;Lqk8gv?Tk!g#`Tk8K5Ua6XNL4wRt0Zpi_An?V6ZsZ^l?H&)t+07wER|!QJM) zlCYz!dntM!04QT~q|AH0=*_&L+rGDPkERDY#=Wj(2l`QO^OuTTbMr)?gPfVg3*Ho% zj+u13??B?lrF#NJCmFYGHs~j}Ac9V?PW~O}P|q;qyGqZ{03Ya&Nzdup(_&~;{a1*g zGuPJZro>#sjAD+eZx3_{F3tdb=!Wi6TO{5)&|l}yUSHy&nSnjuVgesP88_4e9hcm2 z;hKu;_r5gr{K)QGapyY?J&VPVf?l^Da`??3KsR!I9*5`y^+~)oC+604)moBtPhKW@ zcl+*VWf(K3>fOH#6X*k7cS)70#X$dUpnHEy0So*}rw7$xl{&HQ^l7xB& zAEN7%yj;9ASVsVwt{6j4+9v3Y+`68h##D^o^e(mlJrd}2%e3b{QT3{*mg``gT8avit@>G}wAGjHluX&dRf zzM<*I`WC7PMEPKj#O>;LJzOVmb$%G=@$XW0M`GJa?7%y}O_y3ky~}HU)sa9C^+FEw zUIg2Tna6|t0GdwXT|H7jm#q3i5BGU+Pp5sgcNi+az4nJVf9R~dcujtayMv(R{C8c zPwtXpwr2!n7V7QOsq_MLWK?6x=Uz-^ z<_$gR*LV^+>Um_O>78^B^lpJg)`N739JwdydscpmCw*8fBux+W;XXQfnM7-n0ebX2 zozIK0?ls{MO*eBvU!D#6(vqNWqZiuH2@b9&R_}xQHt@0USJu|5z^Xs8EW9u8W`gx? zh91=HY3Mxb()3Gcx&-DxPlsf7#WwV2ZUK6(Vvo{tRd+F-_;L}c?66IwMe2Q18Frv2 z0jojuT3I1UlN76|`aRFS_^ztfa}M&DcS#%fQHy6&QkV3q`PWIMsSw}Jn)-S>9_J;N?4syi3!u2_{<(R9greg^7picQ&NoPMtg z>m!0*XVHcIh^Y_h8APPYw)dVW@dSK_Zz`m=rpKlyL6g5Ej(fZAIUMnNPC^aGTw9{) zgPNPVeRp!nX6}qrn6GR4Athj8o_!;{=+X#Z;*y~1oXW0|MY(aZ?ogNGREi$x$;QDv zdftkz7x^z|(Daihm$2`tuv*#EHkEB84}h)WzE3TV}`x ztUu>b^qBXizCNegEwCQmN!q=kGoPG>8QHu6`Wt=1A<(O1hOGOQ+6ek6(4Q^lEFqBY zQByVYy~@VTBf>R{c}E0&xjH1t%UwkbJu4)=Wz4RevV(%|gPdY6;%b-s4%Edx+rXJ& zO$U6Sv;N(0x{(KZKkG5@qo^&4ZcQ)d`0&VZLah_`gPj}1M{+o?-& ztzYyWM+dsSIOFCc(7WwT>4vn+1BMY^M;O~=_q=j!yM>$ zD6r*jayHB`$@E!HGIfj&bI$W1i;KNA4k9FG$d+&8e_NJjGVeUGI4X?p%>ZQLmpRX-%s)_pMV z_YFN`EgEmg%wo=V2s+H8=#*M?_)hCa&~&C+yzyCT&TKOKAv2I}=CZ;Pyf|dGI6crw z#^TBudMD+y4*cDQu1<=#a>Oanhts=mpn1wkxwE|LKGG-Xo>dq0pU(w)>pBAUail(h z{s_=fvX8=iBlJ$nJx3xo0{B$dFyal}4}AM~(-)oOOr)Rm3G`FLn_f4?K#ra_^lvou zPQe!RCj;I4o`Ej8Q*IQUc{~&TAIEd&u7uo069n z-%+m3=yJp`w=lPvVRKBF>$l%O`+JYa_V|3>pV#~Odc8!a@4a{vt2r7KE>27dXhvso zqV6)!T({`w<5Fg!H7p~MlxcTDi(fS0k5XFom7<^Tl$(Fm!RI`GebO?$@pz#Gxz8+{ z0*|+@swR{7-HX>aedlA^q^(2bI$MNtGQXA|G_Fv=%$FF{RY<;-2Al zC-;*dKS*l4yz8A#imqf{9HOGcHN@{5y3BQB^~8zOUU=u^P?;k`_3Ktp#l%K6Z`pAO z4lQwszDTfP3aPu7t&PdJf;ny}DM?q>O#u49dr3iCx@B>t@)Y{S5JJ0E@9-LbW6#1| znz5vvBpb$kf>V}k$ECu-KAd;3AgFxj6`&6-2H}o>iZHj5Zz&m7@G(qgL!|GF??wfK zeL&4i$?M{T&^!)m+5;t?zb|G|{go&?c&mzvoWn9wlxrteetRjBj25$Nj<&}sFFfui z$CEX0Dw}c$$R`8g{FBS@!q6aMro5xhTjGN;XE?gsiZ{8Rk8WA9cP#+HMRe)=Y3^$s(IU*6fr+QeY?e1-UKP1hdx_=8RCnE zJl?yVj?tJZZooR0$f%*ylhKdA{?Rp6O8!h*TpU5xGFJ$4J{XEhzMXD>rvq1OsOv1_ zIc{83C^#f5^*#uRn&@ngwO{yG%NWoFd0rb?%iK2TJNqYDk)t|3yjJzZv)x`kisDVw zy~AI@W&F^KY)l4MExEl=5o^fLuNcT4hCa7u83>5Xaa)8D`IX}fXtp1uLh&{uT1?Xm zQ&%ma(P?i>tELzO2EXAHzz#PP((_>v35<5aZnW1s3Zs$_lmJHUn1uPtECeyCorR#R zHz!CmicV&R-?OgE1wLpsC`U6mTp~WT9@@idtoTi&L=C*moC!gE`sqV!jZ4BI9hx%~ zx1483%Wx@TLhncmrvb#z8XjKlLNT+rD$XX7*YQgGCLb{XSC42G6kL2WOFe^z)|z(a zu_*V8CaUV1wh`3xMPDI|`YOILqXz4)shFO_EH*xO>*>2csNB#KAg<@`ra0m$8&7UK zN~=CPwV9>%9mUxXht=!Op%X#B8n5KoPV-oe(e05VSN~KM^g(Q|K1e7ocaPpPUv+Frw5-i~3?iZ;tu^C-1ykJ06; zDn;CFBmk`K6=`1PBo)m9X<&d)?<3{Zup&Su7jfc9Ik&-HY`U&~GkE(hIZEg0`0+y5 z0cz?psdaef2~E++VRx9Ca!O^BA+G#p2#+jSv>jH`o1I1H!4PG|aFrTg&3BYw=vxlK zjWn~i@$^Sm%!J(($ce2zcZV_KU7P$lx~qDH{IvG#FV3HuSq;wOoa4vpP>5WhIq&%Z z!T7xAMZ?^X_4`}Pj|2BQ%$>sg0|NGzK%A=r1Nq9-p<@rZ((}~WV;@5F>5~&dL0GRi z0Ns)*dFYFXkpE-N^Nrzxy4*}c8V8Pje5a@&u!ljd5iJ!g+uPC|BJJ>oQ}9O{Q)t6q z5Vrf|L(li)gA-l=1ep5ZrU`jSxI0S*XCo}Vw(Dt?mKA*4hxSG9wz(Y7q`d>@UTx!s zp|~(@w}BF6qHKWJt%TVQU=u=U7^YcNd%Q0r(AjR)rFCLlmSgr~ZA2){Sr4bBrR<%! zd9iFwC(Ss@TW|e>C;ckl)LOo%r!q>n)4m4OM2lb~R_D>fW}`nGI()4xSWYgFpL|9( zm+s$%t6-*!AK4G%3ZcI(Ie%9gaV!-)G{TQV_hT*3x?rov3qJ8oYiZTHrkNl8eHnRt zmzTrWNRM#)eV_RD$r*h9PuNf?p;u7XUuyCKq;ws%sDcr661fR+2QA!)nCiVAFMJ=n86!bHQZt@r7^my`vm zVamD@;peAjBwq@)$26_eS9%0L`BrPmJ!&UGN$Qyw=;uQR6LD=;9kH#D4|tgh%HP|& zv)Pk?s)W{(jSvZ6_~Pls;}-h<$mu&K^9*)`?uKC7tZ?UFYx$X-lHOc*I4a;%)@&9g zK-_J7qWeR{3D1lRH?sb;X2{fW(+}a9v-u^yXmWhEt?b7O`G=}t7?Aw>kR0$v7Q^=` zxu~rx9bjYQlK^E@3!gd7rh7Jdp_PEmj`VzI1BHISheN~*Gj&ET^I{bvx7l_-7lhD0 z12&#O8!v>0?u+6KAPoyPywGnLyOo{NzF_$cCa8akPN3~Tj~vsE&d``I+yO2x_;6Uv zI00_!H0_&mcaRf)a*F(~5&bYVBs19<0kgY3Jnh0Hu4v3mC-aF9CMTDf zAb&JWD;Pm#Pm731@i{R?|8mDy#9VrZE69OK6T3>N{Mhca%R~M4;9}ial(+-be!sTv z)(Oq#A9(ositFj$eS3Jpn|>3~r?}m#-KasQ*^V0o-8;94*{?}%nol9k`u4Ak<}N?u zO?$rbYQCyESz5h1(Rj$<*L#)wWL6s9zuRa8e;)HAeZ4IYX;-{<@L1qWnNNk0modib za%@PZwH)O2+K_--?lp3aFSe3kR3=-=N6<}ay$D@U3pj~T2L}do8&HDeY$mOkPY{`1 zil@#5o6E#5_bjQ&f3kGcuoKkk`>1ZkBO%t_o?BX4M7j?^yrdTqE=!`npANnX`tswy_4_=vwFNEc*T^0Z}zuyAvyffhy$ty%IPRntN|JFno@ia!MV5dBltwv6$o zufcQFBe*G843Y_rW_`pB3dkfU$_eSEsLw5*SXot}br|vA4IO4WQ&@=|eAEfGg%;TK z?iY*mBt4xcP`x`mROVNPc%~3FJEy-g#nNJg)^~&c1*rzYZo@ol4*MY=Cvg6P77xCY zq#nT9TEZd##AWw}qN}+`RTCsB41m_fAYqmR`W14xr9Y6bwVbGO3Om)`F-Dy9?8qC^ ze9zgCV%XOg!3s3PB~=La-S886SGJD%BILxi3pE;+oZGe?V9g57oR~^HdJ`ej!$$n{ zDcI#>beICQ97urRzFwjZh};2&SguZ7Z=?uK^H5dw48yWKkct5H>|yO|#NxY8pvr{_ zhiZtWmNQMnorqRjB#W49cSKoSJkNpM_4&eZ+ff$CBF}@s`>co3ZEJZ4<@{-l%;F?9 zN}5(~)IilNE*GAHQh|1?y`WJ5IYC_7w^8y<^}O%`AxnnMkY4aTvfFz!gr@SObOM6Q z4faXEek6TJRs;|1}C0>v9fXI|{~)hHT%8_ceC7g{x5(p3NIU93a@`36Q6dyqUH zm_3{M^55gMoXU*o`QMuS(&XXpmGr3aU^U`rKz*&W+^SW+`du74lhKsNV$gwu#Mtn# zwV_5N?D2%r4dV97J=aV$8)dO4Un{)LBJ5e6J%XOlr~>IJ+EAq2uYy0U9-(_Ugr;)< z?9|jw!@(Q;$o*jP9VLq=#!)pGn6eKMBX%iUhq&|~2xnSojh7=9*gSH#uO-H7%6;LO ztktlUx+1e)@X9`G!l~gmx)=qM#JWeHhL%tNG@Q@_5P!DI@>@o$VMuA69v_}NfpGHG zHDdStUTf}C{j*Z5^U6Kj9^f3KG;z7Kr(@~_7xfeI8=MCNxcA7IP_BG?qsy8uC#=AO z)P{4d)j~wj4@+F1r^cn1rru0c?}S&x5jHW+)83Q9x>|DJwfyJyh+o>cW>y;iTV$2Q z&ct5v?q~T$v^hn|U*tfh5`bKN6VzY5c2vMibuDg+I?78)rXtr`w|@|>{qdn%WcQ!J z$NROdXiptjfZS=r)JGbvAp{;A`xrQk15{v7ZG*2wdP~UQ=;Nhym^q``?CsUVZ~8nN z$Zw=sp~&t;BPBr6VDNw1_2&cJ%5yXd55S^_Bc7&Ij_4=DVOY_M*T#OL#uMoPbpX@a zr$ED@zY0>s1m7hb6rjHOq4>=h55tGLtFGab1qr@OI|&+l-4pE$yWvAepytc;2l)LF z7O^={7pR|)ZojYe3?9fE51n6Q=XS?IS^wTYzV-N%JKX{y%xW)c? z*@ndW74O{MRIe;fnw?t#Hw%1x?c=kbZ;wL&fDYdnOAfh9ZC_uD3fTmGaF;g#X9l6Y zu}3EhcY>;bD!u0t%~qFf2c6&MfZNkHeV5^n0%q0=9_7VdO1_>f%DAgWJ{!Zu;QzKX zY`(8!2~lRF`6BKVN#hCmd7Xbf9Yit(F$hOo-+d*XjnCf10RA&Uo?+z=fQ#`8bI}FT z_Yw}@hu^)`aDi=W}>IG~Dw0ZY3M-+fN|m znvg{VBQdZ*gjT#YOaS4kDgmSekF^L29F@IFH2Sdba4oTLmuFH5qZSo@ktjvK7%X+<)!+7RHzXV&RcU! z-$hV_sp|@Cz|lEbQpXKzAJSbf&&Qds#zy6uVN4+ZzK*A5dBVs+@radA?9gIlUC5TR z1J0SI_RK7VG2TY%&wzkw#nB&ww~wX;96_JQ-OCZaYXuj2P@$A9V@KxESN8PoXyVYd{;Vd+#a^|ENxj8vkSZIz z8b&x7I85&GYR|Tp=|5>zb|1*ghJ`wFD*K!BqD4oysiDD! z%7k3MN4FlhUK1b~Y|Y{LCx3|^{)#Nvo2ZLW9p1MM@IfxEUco(>*-Ib7#3bih5uIIX zW$o_Ybx52L;CG7z{BWm(#jP~H61MTM!EY!L;1DRQFVdS3fBM!L$rg}ZKGE8gjM~}P zt>p=_q{MgT8*3(%+X>lHNEc^WAaHY=_r|0IgOi?5A^UuDQhzG224aSkKhj50 zmHV=53(!&&k28uj+!?GR^%zIB@o0TORVj=2Sy_ajlr)tzWG{2HR`m66`~N-ePrZDt z+(*vK)1DV420|E;!;LekOwI}^Ti(P4Nt-}kCisM6Q9zkTk}~v$ud{YBUBN3RyXaho z2GdM%+W{S4;MS0eytstk`fyW)3GV>COnWmnyhZC!Bwgb=GWAjdcp0DI?ed~&<1vHz zm^@r323GkD@MHR&3X2mTYLW>wy`r)6tCt^C5|K!#3%>*$`QrxIaF-vP+u_ew#@Bhq z&%#kzOef)bnFn>8%FDh~FrBujVw~2Eus5Xr+1fLSyUMQ@HLyD+VaQfp>zPM#IbB2k z)XG#txU+>m$)&wb4%qKa?cH3<&^V0aPjcEkIG^tK;!)$R?_USlD?+%3uN)6S1^o;XREH}B zwU$UnsW&mYY%BUHt3$KcsJsvi+*oU$7$67*eaY9UX%={rDf!}Y2b+LqZmz!0U zJIV=f_bIXg*%NHgCn=LAK@uD^_WU*H2l=gPm@n^Wra!qLj&1t^K3*#3)Ui?li_EX& z4->xRb|;cdhZV8G%h3d)(*ZaY4U0z*x$kJKYW(ry(#WhYDiTf2rB39tpOH(XN9Z7Y145#;VGd`E2|^WV;q2Ij8w+aBXpb(i=`!UjUm-CH9#r4V zx;FESTj^43J|dl?*yg_vN5ORx!##y|vv72XQ=)cp>az=V^BI<)bs6e??RjCgGUUe1Mj<1&> zWL8~6*4A2daLobVEuCTA|F(8HPIh=Z&}s!@_KiyB2v|jk`Fph04>6+Sd-pL}k0`IE zx-mlwQT#i^bKj4SCEIdDf%M@h@zF76qdGLlmqI~0IJ>mwpWUlE8dljYon>c}%EHcz}A!j)g+R z#E7Y#SFH3HD&H^~(K+G^MNE_lxgc@i_mx?Fjo zSQ*44lj*WY+<~A`@KQ!vxHB)oIezxx`-}M2#yuG2A@s%by`q`P=vYTcnq~_lYTHJ@ zc5;0fg7}I}_)#Wvo^(#0P{Q663+q@sd+oZmIXm^FsV;bF_KcghJ+w5EV44K^kaL;e zT5p0Wcj}s;;9A0EsM)>(3#sRfdFuquWPxTQn@3slU+&3hKiAZ|u`@U@XgFtk z_5`x593g1n>!Suk{>K7Me4sj-SowJE&1L_tiPyVmZ!azqN5aj-bj?K1Z1+o6h=}c; z@+P8*l>P-PsF%Yp{oX>wF$B2*jYz*$A)Lvr?B_2at=`$f0eV$_7l7Rcr347SU~GlK z46JVRR{Qq;P8IhuPZR59$gy!5ZMpXyB=z!dN-B_AK0|&o`mMFZ8d#sl6xBbf`!!aC*iM zg0)6=G~K^%@@nR|UoDF9k|tYFwTf8}dUMTWDbjn_*CE?(Q|MFf;hDYNl|$WwR%El^ zVEinPhEwMd|9GVM(w`x%Yeg&>YmHwzgR++2B^Qdo*WSzGAW^Q*>*L6LVcuj{a0lAV zl%PA2Eq*WJI!<+|n8sz%YWOnVWaTlVHg{TLM0QyH`!B4!Pp@d*B(6NlRIT+UDuH27 zYP(=I8*poDjL-m206p{Xs@&iSP&3c!)sbsIZY_lVv`|4uw069a{h*bYUPXcYeQ%NI zD%T#Asdfu7(eC~TTVKm4%SR1z$^BO(V`f(D%Qjb%AP~2&zK;yKld^Iz%iQ2lJqu0G zLvi67jnmHJWdOa&JOK2ujSqsw7=*pOHY`_%1xA-F=6&6)|14|w|F_|RScv;{;JtA6 zEc53`N`{f0wZDZ=;Hzf!J=DfED+-{xeHWX3c-S?joW6VBGc@N%lMXcv{p@NT&g%}) z|B!|nUsNCNNDJX1B<0=dsh@pIRxxP@5EGVkJ3cJr|2|QPc@#~CQE!3wUn`l*-N%kH zu;qUl!f(A#NO!$~D%4QKeyY)jO^-@P0w5fUt5bwd$ZdOpy;^xSMD8JolvrX#h8B>V zvpZ6fXCqX2_Y}Z_f#3unmNS5|duz1j((zRPdK83XOJ_uu3TU79t5L(*WI&K$NOy3^ z1L^jTpl9nbJ`%wp*0MKmES6lcntfVd$|&njK{$$m07Qk=2dY!jW~@!sorS|-AZ~@A z3rqiqlnzL_StUGiZ$Fdwg#3o$tP7K88NQt3)X($hOJu=B)4t-r$6E6kpTA+||M3P# zk9nD2oGYu_$nMlflbf7PL5*qKl)r}7bD}C;k9nJa8Edv zBWi-}|KMMCBiQ~``D(V*D}Kn8eJiUU)zJ1?&@chst^KHmPqJVe6GS8Ws!5M*kW&rH z@1z=K-#+y$(N&e%*j5IL>{nhd*9Y*vDl8MQLKa?Ge3Ss+4zOJ@^N~S@)b{)HTIODY z+UL6wod9yLUw*IBl_EqBRMZ}aL4CL>ZamZi{Vx^Me^+b(5uX(-Ghs=O9=)19ZzWhW zKo12|+GM0Ht~tNC*vM}U88+`EJPKXB+kyHap!-*iutqY$y9QEk`gaeC(BJ3Q`PB_t zI9`vB?CN#-z8XN>S+9?r;K<tD8U z;Gru^V+NmCnmYl#1wj*NlaOeOHMCjl%Mer@>1|t0XByW&LK$lf0adJKw0|iXTk-kQ z+&3*Adx2EjyJ8H&npUyn>Hh?F7Fz)o#GPiCYuI&tbjIclf3VLz0TxWz#w_)_%Ml;# zgoh&RN~`4}=6EjLA3PEk;j@pv(Nd*ed*8IjX&_gIi6>tC{W9@kXMCiN(CDfR^*xzO zCks^>l_90)O)8!)MF5hb%L+b6-NW&^)!FOM)hL4yj!IOY5uF&-io9yI`<6skJku&% zm-n{R5+C1sRiC@03R^>dE;7tY9%q1=TX!&nJ+TVv@NxCjZa3lBJSh>Y^FY$iHD}#S zS9g1>97XEi$XYwN^odAFI{y0JH>7&sL^KM)H@iY~ZZwi9v;E2E%(LSJ5aEG8R`-XQ zzk#c-Un0cm+|nK$bK#$R5_FQ3fg<9(%&Gijr{;TWDqCkJX*Yxp5;*y*n2{>`u7 z^+t2s-SsksfUHn1!pz9Zn&EZHruE0VR)0^v5p1=`)AD`ICEgnL&g9K-ODDEY1X8I5 zH40zFXn3%0rUmJcya)?;qW&+e4MmTZI(2NZF`>(Cu%-jO%tiJG%>L$OU_aPoZT(BV zI~;p{o?kBoja*~jV}!KYe~neW{p(0$v|HWh#OR!?KX2{#GFI-Yu@GEgot*M5iHr%= zdv@YM+x;S(+uiB8>atL;Frv;7(}5T0%id4c-IE>Y4azfg1As4ywb(a0eAW=vK(EIz z+&TPX60DCDvw}HyWm0oGPX_X`P683XFdqqk8B2T7ab1ToNauJ&PxKI_ttNJdeE|;rv9S|hBNXgchXIKs8c@;Ybco> zLIPF4HnXd4A1hmnoCxK^rKsUNmA_?obdAPnx)J{xIlpzp!EEumZ7De;i~WiU_IxWscC$ca=TZ0A>VHH3wTS>* zMY&a4S7MaKNTV(h%@6PQ{{6H3IsiY)jlZPA#z=d`O_75>NoYgnqPoT3Wg#Aox;_xI zT=GzQ|d?P~4#!H{%nZET*s< z+9&6@l}S*1q0yDR&-|nDk;(l}5EWu+CM!Gm zIENSD!B)0_!T+M_${qORyP5zME}!|Xa2WkfvI-lCN%4H-|Xr( z{5d@_;o$75pr;-<<_`5&h9=PAL#Dt@jsrrXtX))|&xdSIIOfadm$y~rV6{e#lh6=0 z(>n@HT5(RMOrUTVre08c(bx2fnaMYs*a{BR#O{6X;wn^(wn(9?H2tUk+qvH=PQAl| zHrJG2rdHJU0c&svK->=#rhdtl>@OYf1;=0T|4sTbQ(`Vg{lbW8{r2gl;PH&BW{Mn4 z8i*5Hd_a)=&3p46tLLDB*-i3x%sFlv^A!1~b^0Pk_4hK#HIZD?&n`6DZ;#{LTUqr(?vzWuej|zXcHf4-6kY6cL?K;`1GE(2wWY zT|f?Ev2Re?_*(C`jQFYJ@zTdJMdpbmI_d2u@||FxclKoZJW|ccWb)i^NXNeh4#XAR zp1^`G6KPaIl6o(aXN&RrywsmiXJ=;x>#3H#Uo8o(Ob);O{rYj+Az29(T4Zd}nDI&N z&tz3W`fTq`E_HwvQ&ie5r=)*kLjw)=H1q@muBB{%YCQ{#-Buh{S3~}7I4>0(AAhbQ z7k;~59*wS;P`ibs(N6`XgGEwAB^6|Fefpfw0uH1r7$6_GZE^B;#aj2e>4bSM?H7;e z9o59LzKyl`S+jko+&m9MVhgJL=rSvCwq0csSTpo(-gccbXvcy|Pg>GKYSC84&hzqF z^Wn7!UhMw@ld^^JETFgkhCWC~Md)?6$@Zoe>(S%FUMUDxZe!pblw#;(D4=Eh_$XMv z!wx5%5J@%{qkE~PU+QN3e76Y7?oB1yq%Tg*$s13Ciu&o)WmL|*Q;ZnYQ%T9 z88HXJ>0LI~J$|RtQ`b%q9MIdQ)Pa#tT`c~3DaqLe?|hL$6D7VbweK}f?6I=cP(xnj zU0rWJsA!nq8)Z-^f+$_P){7L@~X*d}Evh*Bqa)q91pc_ZN!t2i%IypQ{m@+Ip`Ma24G&E>^QF5M>=&*x) zc~4I|CV~mqII;45Iy}Bi9XH*ohKRxc0$de@Vhd(j4h}A>CpfZ&yB6pc_y9n}TFjlZ zmCU9L)|j|B#;`Vh^<7r@xj{ZWHYB$tmM*YltK-V#-?SD?n269;zYQc~#fT*=YE~L_ ztrN>fGu?u^k_XS>Q!3d{esAI2BXugkjHDChf-1VO6~P$D{Hy|p6EtFVR7WFjUJ={s z1Jdu0BPuLK>_`;ns-^Fv90Z=oPHwCNv{(ERwGzC;(EV4s$pOA)?nrCk*+cM9BYlXD zIgYbe-W=XF8@%oPn{}smW>mXrI|t+2GMr)a_>Xl$&`i zO+zX-$R3Db$uglOiaJSohw3D5IY50xz|M@?asRQ5zIP?ad2`iIf$yLA-jCl-OA~V+ zO?OF*RPT|+OrKhBo}U8G`LeRL5|ybx>G=V;5WY37=VUqR-}}p5GElD>qcIIK(P9#` zI#IBpR$TQL^LK2ADL&nmCD>YbVw$*&?TbI%`t_r&9rfDF)1&?$v^M$Zy6gTaY(!Eh zqS58FJRso_Nagz9mW>~-7}N%hj0|z72trXYiovKkNRuo$y*=}yv?#tc4{1fJsgwT^ zw#467#j?qR+W67P&f{+g153BWv?(>$NlCKqM0%6rf~9W8?G0;y2Rjc^yXxJZyv#$c zH}PRHn!%=~vEPvp9H`+1(Zzdtn-eGP9v>-=b#*aOvU0F)#7S3#Tys}3-|)XB;QX%o z`}+93gy%0)sgV3I9X${;i`Dr4^hmv*7ZZ&~Zi_)|*||huJr}5|X)Z|5!B7UB@;_If zaPTdwedQRh?I8#5X{icM9vRNi-rvZnG9>i~c3ain$^TTFtG`*ESx|uVJ0lLoI{2^2u)l{TqUpTR(b5XQn+wadqc` z_zw)A@?Uv12l-zfKYY+B-wNJmsd|9izil|jius1U`$~}N{PU=I30+lhto+T-5JV9| zWwf==*Rz>vUVkC^iIb0dYn?|9&R#X%$U-IZ?6h@pHMWi?Gi`W@8=-b~BSSc?vBWrJj#ShGYwb7zltu#_NL%aSQfD1o5w@I>Q!?Wb@z#})3 z1EvYIt|UPwTE~ECO>NyAD?VArg?J+=CinsyJnVjiQ4zuM=voNNWT^_@zptNUU$6w? zGLLXt%PL))+v$~WzDi=&({_%8vD}d0KwW7RFRZlhrmHT+%^H8^Ap8P#Io$DGs(m%? ztAP?zTX_VJV%j8r#X+-cYV?X0=-+ZzGdF$^&|gz?k?%HVX11&nba?;L0)H+iwQG!T zhd}DTbGGhSYoV?VeWb3#q3yMMHw&4GdT$Z;;?1in->mF1O_(6{7usw^3$Uhg_gyQp zJFH)ZIprQ6-_Hl=`fiU4j}uvVtlAs5XIaGrVciv;lQl2T zJszLh>zKy+=&q0(xLOqXL!4%3CT6XtivtBNcmka`cVcx!3c?wPe}x3G26-!<^ii2? zr0LC@QrT9k*v<-V&AOT0GvgYJSV~?=(@~*1&Fa_rcsYMFBW5W=)xWRK#2_QaaRKGZ zKWTTHf89bR3~`4okM`0$nZ-69*R{{;Qh+Nm&~o|$U^|0mR+4ywf|W^PT4_M$HTz;X(^wLkU4p~u;MeJkjH0Q9`!>s0~x_hKj4@aNtC2m zt#gz8z&|25>?-k$9DO(e;5Jka|$X z5we8ijcU@MmjJ9TyV*xhXP6}np1ITVF{*?uqkT2hY|ok#PoACj9a=c~Ct$sAaxRQ~ z_f~gm+ns+q$UC=*p9@MRIq+WfpB+%8lhp+t^%acrBr#|(T>AW}re2C&Zg~}>Bf=2{ zCC`xT;BqSQ4&^ZV1|rVkXzrJ=6Lfr zR`g!1b}$52WWndy5u}&}hN$m-uVplj6YsewbTlpkB<6(T8WNTa2QcoN7O@8pF*Spu zal9sTdQS)$zdRUbN8?6&JY@t6ycgA!yFIj?Yc0+n)dF&pmiuAW~G$EErh4HT8z`%*W+eoiI!vVZ?n6acf6<*n0rmr4= zE$}8BXbSuuu{EXq)EZClcC5p)ldY{X*G#4Rgh^qp{+oxz0o+rQ+NM~ZH1b{ zmO#%XxhRY%2{QK-y@|w^@$NHP7Osz`7$g!hozbDQv_XyojIuM>jiJB1Deda(fzaV$+@Wf39KIOEbP$PGY`R?u)YSHYrG%(nAU8%I0NnhrM6S z5CV_&<=?ku?{>V;T+B3rzYI}>Do4DsHdUueYCSd6tn)0AA72lpj~v>SWmQLiU3=YW zi+-YR(F4q~F}#9ZCNCR}MAs+&#dmf!`A^IV>zxLum3Ndek_8_eud$8)7>LZ(93tb9;`}`4rRH)qt+Ew9^}bd`Y*} zqF$`)%b-WZak%e{;cSgT~?~=c$)Io}*wRUSua4toY~B-JY)|Z|`#D-n6;) z^Qz6iX^To-<5aIN0u)rOKYs9VHnh8{+e(5~N@5z4UTq)L+&}acPcfZSur2!bR(49{ zV_+Gw3a)Pj{|Ne(bQHS-9P=w#4^Dbrz)yWWx7MpDFmJW`hH=7pA!Uo!WRM{#Qh@G)=|*c2MCh!O{kKg zPXnUr{yP}_y4%CS&s_O3YZ^)vCSZxYBD8F!M;i%_CSOa_l+vJ0J(bC<0oc}hJMs(y zW*hq9?=m&A^{a&59xrAmy5BRTP`EWbJO{7zCFwZWIU@M10>7j^XUWeS8P2b+!sf|* zXX`(<9>ZkVWs_|8e;&UeLYDzJSa-U|C>=x40{;x?KAaR$^nYjv4Z%fhYSEt!W4Rs* znH3Q@Y3LQ8X%HADUsQ1vD;V$>Sbk)apF!?Y)6Zn8yAdWz=SNnxl+@#t^m2%1mEs8fN;esS%h>y6JbNTbK zT2}8(mM!*eo0J;IZxiwhp1^H^sO%5XohCOe9z4;WDymuWG3xs~_1kGR-em5C4-6~v z2yEOU^ZS36+|@xijvR{PPD;Ir5l?7Slk1DO(wqt!FK@uFQbj)3-Uo?8T1q8zgiOss z1EVUsorrO9EMA8#us{)9t791SMBP*(rF+{Um-g7~4kK{k2KDWw230xCKwnFb3}UzC zJ{^1Dm?RW`=Ya1D#a~mPDDm($uUIBO=F;l+(y~a-Pg} z;=yGt3NLWOegbZ3NHDk4g{e!GS9#ftcIF|01OK)9#fs;m;r#%_fK_K?Er;-4RfirC zrkig?%T9D;pb=?3f7wzx=HSkIC1lV1r#r^@x0A1|1V@YMd6L&qn|bQ`<6SJ!|B$jy zOk_*DzTMVeQNO2O?mL~a0XM^YTvirI}hwk+A-r2^F^H~sb8r<6rup*&+IZ-6PV z*TZqM``ZId?Vovw+3y2pEV(9j1OhElSKd;kPWeU{R3cW_bIM2j>;46f#si2t)BSDn z1sTcD?R6u5v}<`TUA0BQ(@mZK#d1R+l`eoW@Y2TbCw};&=o=pD9 zOt8_?YS9m}bbc#CDnPgFv8=Q?FPk{qael}hh5-HUZ5<%0p+Q^eaJa~7c$6OVsa{pp z`Nuu9fcybrwRagr{8?aydah+Pg_m0!JEtf?dG;9Xbkk_5r%es2Bu%ZN9CJ!1YcM48 zB*+d;>|{=*)CO_ivVMd6l(nD*AMewR7cU6O0L$1*9k{bWnvySmOk%aHZ2ltW6Hu-@ z;UK75Rb_#1#NoU&x1tANdlOG|rHCp4#A+mXT~>B?clQ@38ayWc(7zmRcxC}YLyQT)!wL1Cj_0301ao3jKZS|0;5aa#? z%MD`F=|gU%oa=1c%d1Iy0gF|wkL4Mg{I_3i>`uTD~ z3T(f~&SLO$*@fq^05NejGqAK@Rjg<9X+8BKvLjStMG0=0$!SR1c>XRy;Ji8taaM7U zMHsk3Vt?+0&*xfH+T$8VdQQ-yOg3(K#0+QpPg)|ny#oxqLY?* z11!^SEBG=@VdIor~!*2e- z5B=JLayHJl8ajcqM^UE?I>ANe!By$OQK#&`$4KEYJw@sSi1>uThgf$J$fvT<%MSe2 zRCR$21&q4i%vjZ$vkv$^=+OS2FWk>HobIds`j5JBVE1gPZInf<%Sul@Km?xANqbm_ zJc3wYH`~z6<5F(g(-_^s-j>YUqUa$&Y*g#~#o z0YP=IEK_{1Q+|8glUpv9PI!Fc@R*G{)VIFgGA5mA5VqcI8oz@tfjb$KZdhC0Jb5gs zhLqm<9zMGKcFD~1h3M%UC1%S?e2ZlZLF=R_e?rsyZj93(ZBdx^!;7s(PDnG*HM-Jq zCz)S)Pt;n19Lsz$xUl<(Ej#fB6ocBb9CS7ZP3iCFT!|{v22vt|m9!G9P4DVaL0N_6_sFA=f|o$R!*- z_4iEJC3A_VSm9&|hWZ^GbNNzwb6jA$QtKG9<Hdc+m5mxCvMxbe zE#@KbZJQ9tuXk#A2m^z^xq?7doG9_JzfHH#POQS6N>r@LB7^4xZ8k*YPt+f-%*I(i zWbo=6*Av5K2Dhbr&lj292>i#Y=zJT&d+}cK2kLu9C3UK(+E~2F-vI+i=1-&$bN3?< z4@}t|`|l(wrmj%DZqvSp58gjBd$f)kQT2{pAxGCTPD*(6e;ck<9b=MM7gRyMBhs`U}QZl3ScMR*s{nRr7HoN77O zt>uOJT!0IaudW|Vc&S-_hRQe%(9#tJXhBWEq?34X7-F&00URaw%WZKbTT- zzc;|F$xqRg_)KB9nMd3!U|e9|^jI4ibGUGC>}o;az|u*6<`ZFtlJqR$oy_aB2o0 zl(HK&)7RrJ_)fkWHO2rbEoeGb*1uEbzSsQeBPa3T9Pm+?zn8!FKeV*8Hm|5mGYYxC zsHWhfU)HLQR9E^kX2kiFo7wd>@ekQYY{MS1K0j#9L@g8nmT@%hDy3x|-7708Twe1g zEOZ6)Svi{!8~kqZz$(8re!Mm}w%|=Tu|p-pBn6>kX3rD(vO^qmD~bf>jPioo_hnu< ztPJr>MLWK+gHWn6vk(^pJ{hwEMI^rEUi@j7`%}%V1N-#0l8OTLqQ2wx{eCj7NIw3> zCT0_@gU9@Od-)~Fq;~;K{D@e0;%&Vz^E4XzpM>py`Vt}%Gp#sHHijd@>AAP7Pp|dj z@E!4{!ayWt`(euKenntH5@O5d0nGT?YwlP2XR#-qkDHW&U)W#nS~(JaJ%l@|rxwU& ziie7z)?Fg{5t6s@I0yqT>?4l9@d@0jx#ZaciA1C^#92f?APbmHFP)*TWMwc>4}(#D zVrH53@_^G*^^ij6QzM7*2Quu{uFu*uRu4UZR z=?A!{>zZK;*-*FRcZDy#vy>_lNq#Ph2$m?kOv_hMO2)UwZ;l30gJ@35UKI+r``~~^oH9c@)WmXs4)o_b- zecA^cUt4V-1t6G@jQA_#DVzE6*{g?S<=MFUyC%*qH^hMU4tY){+ zbX2|uXCN&&0vFb94#d;DjisS)0<@a~YMpQFM)HWUK+K~D=bk!>hz03SWOyA=C>*qm zhx5Me)5EEsnD%(UdjrC%s;U#419B(fXMe65Fj5QH~ngn-ibXO=uXwp zg^0x|2v!+E*o8~yw@2e$G*; zexSR%5~44u=+o+(>>jL!ycDSk+Pfe;CJ3!$0VS?n0zN0sbgyx>O=a|%PltpD-U4@B zuB4k$)woM9_>XnASMi0fi|;B=PcM95j}_cKKmA=)a?iUNt#Z7{@IF7UvY{f@;qF5KL1}u=NZUm+XdjNRip8urFIontx>CHY(*7CTZ*7|&61!J zd$!fudnUB@-a=4&i&d0}nz521_WJVWM}B5J$^G2t+~=I@3JQdR+4~DSIQKFx2L5WQ z2<}4}XBdk~Bt{6q>Iue$n_}BuBu89*Jrqwb{4D1oQCSD~4}+;Y5k>@A<1QA%x$!V* z^2JQHFeJl?F()rgJkkZhe3qGNql5JuiCaC5VJR&7`5#e)MP1#ImUx%NSi5y?x`BRt<Z0fO?|26Ujjd^iy_IV2scr#_FK2V zN3)kPlaB#Ucjw$w+Z~{^*BJxeJO%%7jmpI6_3w-Fi;|X-#Mq^N@fX+Vdc6C`S7Uih zRr{F31bp_(wPH>5{0CoN8ORL+#~H?}Sb^0%Qg7|^Qn)3Rdb9!}5lKCi{GMMXuX>jf zSPyE>s}VY?l5l1wmWFeGApI@#B>R4F!h@u@54g~(T#*i2ZPSh)JH$Zki>M}F`VVq( zf}V3w4*8xHjQYxO>o6%(v&R%Oj{#)@cUtB|uj}&=d}e$HRK&ewj+Pqp@|RjFS{)D9 z6&J{%?%IL}*H0nl@9Yk{ z!0FrXcWOMj6;;>6N9{Pt;aU6TANcNqW3JrHkdvNw;xW6VA72ec`k!os-;@x`9xA}U z=g+Hn&%g6l&|AgPfAFlT2Ff~(h{Rh6!=s)QnrELL2e-97z#*sHHeE=L#Lg*6ln?~X}vFT`Z{b;Y@4sQ%V>M#>CDyY&M68QZh!gfr#l z(8xxpRB1aaWy0vt-`sn#TUoq1Ti@m=xQ4GSS zq1wbz*AymRw%kqCiH%a@VcejXhqy0BWD5WCQFOX9sY@dNoCLu+`1oW*xtM|e#DuhV z$v$SjhZ$-ud5B7s`LN039Gcb_)l9`Q`veyh6UMxukd_F2BQ}Y%8XA;EY%!dH)Z0@E zMAst$Y)I@MZ4BkVkHE>0`Gxkz{N z4G6-x4t&9NzLFJV`{gwL{~4j(BhLJ7!#9?kYrK+?6%#Z(_-DjF;IVf`4(`jBs)Ar+ z1Kc~h>_o(F`*od4YI$wi&94WSsxLDifL{fTpgoJNkZh=}lSg+s&{j-`zBJj?iY`mp z-$0B+M$mTnXw&WCLbitVv#qV(-d^13uT5JOQQYE$iKw@Fu9Mn^2t0=+cQYLAZh(wu~V3XYt3mpno@(bTQ}~~*1w+ove><{PZ7hOrC4Oo zMN0-7CD5pv?x}YZfw%^jWEC5I?7EW8eFYmBa$|~u`=<71O;uW5Lu^mR!w+2K?1U>I z>W)mmyK>4GBa}`~>{zXm&>JV_zuBV{u5PsvZ$bxp>IGZOX@J6)iioaqGEZ^e8;adc zXdHz_F&qE$Y>P(bOb7Wyp>@juTXs1!Q2Bp1m{XBymF{JMr<ky?_h0A$X8jLDZ@6ri3v3RNb6v9bpe{8&BDNQsE zw-k5$W@&IBLpqvEJgW^`{5ofPrB4Z#Zjf^Z%R!3c%8GKGsoczl`*{;wt*4o{%gq}# zvI$!yq;;ZL<@NCq;G{MZU|HnQwZoXh7Dmxw`sfQ@;yM%( zGV`=}g*i4?w4$PdNlr;}vxk>8b$CzJ)_!ANTus@A-gQMy@URY&i~lwwW%H1G)~55D zGlsFEhjZ36@5yT9l}D&0!+*>N{D4EpO<#_v77m`G#qz~_^m<#>-2-SoXIt7uJ|}H1 zFRX;%5UN;!M>tLjA_r!@|8R)_$3#?$&O2E&PzSNufXZ$Is|-TE<~@7*0P>XYP^|sp z&bF85&ucwV!u|=FYLGzD6Y|p2IK@8u?v#a-G%Yq++wImtvzNq2(PBafERIDF>fNmIEcFq#=)Gd^H0L z=m7!{imfD8QJI<3tf;!#wd+|0hWpCfCD=rOf=*u^`U znd~H89AJW6-g03m_i{f7Z^_4u=p$QyztD$1sMWOKp)oCVz;&5WH2*6|{g=Y`(0vCM zZ21AX?V{31(C@|Q`0h)5QLI?5q=Jfmrvr)gX1iwn^c(-{ZH~AuqM4F>Z9vT}geD;r zY!9ml9~rjX<68S~OF86BS-EbdesE2{>B5QsS_8fJ$@qAohQF7aB|a!MiNfESl}N~P zBN>15Hc4VKq4V{4%(XwZ38o~V@0<8KAYiu_6R;=xud>l|?Bhtc`p9LT7>E_^;ip47 zk4Uo1`H~(-1*LF3TIl*dndHo3_Clu~`6qs^DL957;uK{%#&{w2VE0;_SZtiLU&!+_ zuW?L%Sj0VIl*?9{NW^w^98*9ZX3=^NZn-Dx;>0@#C75_Dj!FTC$@ZR)rs{3W#Ne^7 z+p;93K47yE@0cIBiJ%)@{l@SfC?R3F9P^KbgGYDa=6UG-atvJrYzQ#LiFr2=?3YT&iN@6GJcidYO`+FrNBe;heGJf%R@(7C z$CV$O;Oi)K{N{1SljS{uL+Gr4%#h!#K(WW;0Am}#(S8x%@56gbyl+{-?t~mDo5z`V zxSL9Bl?Va&UxVKlel+IhSpK3B^;vbQOwO~KZJtgpy?HRF`aE~B)}F3C0ev7%!?m6?-k$%}nJ%E%P6ON1*eV0}&Dz)u2Y^?pCm`^Hf?V#B zK*NTP_2CD@0c8RlxnX{bcFwSsICkh$W#1!I!AphJXZ_#Qc@$EEa*-jCaJLJ_!l^_5 zDL(fn_N+A3Q1=w|Nr9bUt#R89o#hhwsRO;^6l=KT@zRKw%9u?ie^ztw6`}xVq}uo- zszP^u2H$jLFtbI;dKAv>fxLS;=V2rWyP6a$hTF~~^feDnQG7H3h+sZl=Ckk|n1sO$ zN}BNK>`aD!gC+De(c*;uz~|Tlwbo(@NWc3=Hy_t{ySdt6OI{f!^pdNXP+xy~Y)H>I zoXPkWwb@p4+xsi+TDUPuGrJSfwsY3s-6sDG{a@YYJLQLvOV}+m$^`JmffnAbrDmkh zG+^3@zO(f-Dvtt}Ipgj(_Z|Ajelh}xXc4~(QijbuxYjti$m1iC0Pb8W%+aN1jJ`;6 zpTIcQ7$$%rU(fnE^VbV@$(NrNM z>Hfs4eOGeh#T+yA)z~|4xZu$g_UcM44)iIx(zZFXEfh&@)MAmv~xP2>)3eXy#uF`(opfYBPL2^vR zPwFe2uXA9J-R<97iQ(F5a308`vFodvPagWX(INQO8*e>mpzA|xR_CV2L<=fMG}<`f z^kkYjWs1mChc>88-|)*>Sa1LGBDyN}$^R#YU!xx5KJ9+3bW{$Q*bP&}9?f~JFrUe# z_)pntsp_|U#FLRTfEx#!Gan#)w`WAQ1^<`vFDm~j371fuHr8;%)l(k11o7V+~&O1DIBqbLV&=Q5~iOAOHCe;L=d(Gu7 z>AIhPzXYyG!*gMk1B&a7xi7QjPc3WzW_O8>prYb@x`o6_)iLknbkxQygfU*B+?Dbu zuV$O!lB-SnY3aWjk`cWrCe7U$r^jyY(Idas9@YP9J@L|<&dwDlP7x;?0#CXETCR#i zqv7r6hckKO0|eop1`>7QtWAKSpdnNwtM&=?Y$=1bGH#WxajQ#G~(2|W1|G-iS_^ijp0 z6*Kt%g9Smc^`Td-93JxLb(3hYdS4SC;}6^Uv6nbfVZ#6i9U-;#H%vj`=0~@`=u`9SinH%dGx~{&84d2{$9w7A%RxQX~>L z<7V8t^(%|}q>AtvOYCqrai87pV4c)v^m>e@`SbcMD~`8!Wpz3p~OuZSCOKrmfUuisrdez9p|SpgZ3L6Q|3v%j7cb#%t1$x zK8dQj+HsTapW|s>xx+ZHTaGyuQeo*f(D%-4>X6a=I1|q*QU2H3)Svx#R#LA7oW23! zbgw9Z*>dVy!5o8ELUSh^f#;jpp!+$eaj(EMXmY>ow0P1iU$dPZ=7usV-YrZfcZvp; z*z6)=!h$Po^QmsZdS+!(Y4u@T8}OROuzT8I={6|CF)$@lH=vwNAr&=S-$esw|funNZKCf@x%j}D~=KT$N z4AL^wS6yt|1|d3NWNyt$f^suVK1fF=e?4T<(R3pNLJ=Sz&qj-HBDyst6(#}qxlV+u zdh6(hYu|~!!L()lR8ii&zJYq5gHD7#!~mC6jtcp$Io8Pn<{NZ90yEQnA;T{@E7{JX zBUu>AV_9aMLJtl7i)4&N6gU0+MKDdyMt9$c3C;HD=Ro_jc@qiJf>K=Tp9#Pp6?~<0 zs_JC`;tzc*;ru4(6mWMLb;s`~f!5y1#&zzP2%PTy8Y*eS^MyKfZ*^9_TmBa#?@Vch zjUdt;r+Vr7SvnS)oj1LEjb)l2@SKQ*2n^@VD&eScM+xV@qR|z{37-LdUtKahHiIQ7tm+ zBwY0JdtYk*R+3=O0mtd{_m<0vm)tUh@)rq)nmpnqUsUNGnvgs}%-d#%-9Y9YM#l@6 z&L@M-VzdH;heXX2=F`CmMxTifrRM&c3QN;H`!Yp-7Mu?26@qiLAPUKI zOWfB(0VMxhR3!H>vwUa{HDtRPx{?*DM`F$N_roc04vWP&D}r54#we}059v5@@@V+p z*+X?LKQkG?7_jfP?R5W+`W_blIvt7|rmOmw>q@#%Px$jEQaE~L4{}RHanOPN257oN zT|j$h>U4HAP`;sOzeslmm3nhsv<2>{kEQ_2ptZB$fAtL3Z>Cc%q7Qj_k_)K+g}41A zv2)r|p!kp;PWwD*cg$z_(5NeihPx>GcANTSqMQ+leiLna#R0pO;CIhg?3C)vS>9oR zeS<`L#{}?PUc?Rr!5;P#An6w;72v14@2p3dc}P!2<|@ak0dQSnJIlF#QBLNXVxvEZ z{L%^D!pcrpX?U3By*Dc{f_@eBM*g4Nuv0ZT_V1uH9+u|2?N`1TDeC!IRA^XWTx{hY zmyG-UG!I=|_6VMLfKx~B8tf!L*W|;E3dxB|B#n*vavU%B%BkdD{?Pd_M{u>zz3M(t zZV>PLGZDnF_u=qI&U}GvVXgvg>o87eK)8Z) zbe^u-wLUrU!gm`QKOzm`Y_`(fmee-ba``h%Pg;ee;t}5{h?;7F#Zs3))-??VHtWN4 z`pFg0G87OYQh_O(zHAczxCYPTTCk%ExqcP=Ug?vuktJfZCigVxEUr0@eo;0X1K-V8`8NrAAWDd7XqwOsu zMAyc|xX*U}>%A;<%4vEo4!sS!N#uN8ULlNZZSXuoJ;X$Gg90;8e;Yq@HpM9LuNZ30 z(?LA=;!6ZcvqoB%M4KgBQ;?io`_^7rkg5@FnV)^8`&XcZC$Wm$0x67P!5yJKd=&Q4AGjVJO-w<54#z^ zitTC{O3|HCO;lLY3*c{}^D{en)L>73d-zb=-us`!Lm#lLmy1Gr>f)D#U=Sl^19Ivn z2@N)U9;d80gJzV}l27M5Esz|$6K(x--9ew(bu(0iOY%P+hRdW|6D>zntNlbR?~&=2 zm_(uU8&D@?CW3_hCf3q}h7G-onCn3-)Q17#(^G!a>XVHg2Dn9*p{i&EHG{fu=tEt% zrwfcd4Ug)ki)Ycqx?MdRD!JN_7&n9MMT*UyR%)*gXZXa2k&6SZkxF)Y$6Ho2Ge6c= zm?WPsN-zX9Z|lHlyzKTb9mJgcn=P1j9OTk;yA>U#*9K@vtj#~Z12!^fw9H}8c2SE{ z)Q>dX{TDoj^DhL~EWCB712<(!A#JjsR`0PMDN0mts8`V%R&-=>Tb$lmjHw)_Zw4S1 zYu&11!>d-;r-(7h@a(ph^r!;K>E%z~^+MS41rMt5hfFsm2~a7a8I)GR=qF;>-F8&6 z4A-6<51iUN)pV^gX+)g{$gowbht@jN0JT}{JUWpQNf2z*I(zF?d3&dt9r`i8dkk8U zIcN$Th9SKoEsK$wXzYaPv99oQz+RTU(cMC?mEY6o^Qj(iu@sOLoNtFOA~t9OW?dqT z#$9k3p&c-Qg_5==@!h=X>(UsT zvT|;RIzLuVjX0ag>5@e|8(kRqRYL#+&b?xGT+M@o6xHrc_nTA@XF{t6ZZdrzwtB3W zi_3A=I?y=Jiz=v``6Pj^q|q83!O!jHlzXapxL111tszZpa-|&e<6`%9ronhbDBhtN zP2_2ej#BJ=j2o$s3fC_RtcP2bPi}dx@=bb8Q*3vVy5(dhMQt5(njK#9E95;Jpr(W9 zgtYO2eEB>QJvX~Ib*xG<9$RuYfINne&&5!u&A${7f`>8mt`gKx3j=)bcgcgrm&m@C zx{uDj(10w@dySFkeUyoVVu`NOLMUbaoEkV!!6_7a)YCRj+Jpk9w})E9%STN!N6rfe z#`;6XU}y6KgW@v7fvpY zTO2{mx}U8tqPpQo4OxbKBp~KdDQIMoMecIZz(9$x{e-O-p79oX_JN1wZOHa>|XiOUfF5jTfC&C)M1&{KInUAojys1 z=rB^0&B%R73$@mo3`$Wqcq0msjt#0#?M+9fQA5_#mQ`5~1QM>U@>ly$Rdeq_9XkpP z)&qdI(U07B?@EzX`tj*QEQ#nH+hj z$Pdp=U%~Z%9oqKMhOac!L3#2{5Ik~^;T)&oB+*XabjFp>cggWnPDcgk-yAI#I-;KD zC&#a@6$0gpW4m)E+4D>)QN*vB>(@f5OwGvc*Tk%6FLSu)H}LB&I7QpqS*6!@#(=I0o6jo%PFV22iR zq<-aI50Jf(eb=gu!gtS z9iwH?7pG&S?xdy#(=ck?d=VX5p*1pl9H+pi?D%@T??TU7nfHebROr)Wyy?q$fd412 z%H;l_4cbyE%+%gmQ+Xi0rnmGEFD0@s1*}sA=QDgQL#(!VAhf!&dmg2LNEM&1+_d`2 z!TMeveF$%%@yYJ0C!HHyo`(Yd#p$n~<_I3-ZEkxm_9!|e0^6w$YCbKWto&Gu%@cRv zN!~n&nJMZm&~rXds8Pp^$=kDf0JuhK_()WZZ||(WzxNK|p+HJu zO1RPB>IGV#j(p@Ve=UclbUim|pt;~TMaM+&p@&XOAz{>1SA|+`A}9$SJBFNbGir2? z(Zvn6UdwjYFC(>_pTEG{8})6A3%;0-(u}!q{$s;crj9}Httxg7)LHdgEsf~xZ(u)>FbMpZ z4QUPz0pPzNSaopU{j7pg;0o956)=_w!-Mg0IY|ag;g^>Bf+x#kr-J;%NA>Z^D{qdG zLPxy$o6q(NEl*;^EIm=+>P1%FxB5di_3AavzaB3?9jl=m23vsk5%klBOD{WQ-e-1a z01hPzN(8xDZg?5U#$kqD(AiIuNeZepx?!7*@hyl0Zz8g=t`{f`JQIZG&2x!~CVD>2 zRZe`@9bi>}F&Jz+`#d%_#wG}drH!MDOE#LIDuS#Wjcit2R9BbbZV)}LTGyWsvlM?h zK+{L3G%L`&=VX1y%CJ=MYnJw(NUf)U$K~UoK3ak_q?y%cVa}Y^d;CaxtEpAvG znss~f+tb3-&g!!xPc(IYdZMVyN_sKyHIyobr$M?qpKCOcS)q7_1kF^o|FJ%WSL^qyH|=j=V_ zbmgGmmrmd2Vkxw)O88>_Bl3Z4ZsYcfV5B~pAVam8CG{htq`Itq$rAz@3JdUm)UpUM zD2)wUtWNDjiwUV_BF+hBlm8!JK@a(NsYy~pTQ?`bB+CUo;BP)WF?!xME~no`F9>!V zdpkRLH{9~>M7w8a!Sq1^!Kx!WK3B>^pTLL;7ee{XMU+4-9qxFunhx|x{DOjnBS)K` zDW2Giw|{-onAokYw5{e&PG~Fkp;Tmg&r!RjLM<^&if!FhN5cFn?smqyAbG`(Rpife z-aT1;I4d9_-h?mZkF@-0pEogqzzKaXU?>gg991ACyIFpL!^g@y&gG{CTW)fl>`1`D z%Z?U~KMG5lAV6yoN$X1ApfjFY{%z=6XH3b)hhEbBbcEk7*)?#3oB!=0{d{mmoSQE1 z;%G6n6QMFAQ4EjCPa@S(Qr!6dl@)V++b ztXGAq2?Dl}+#d31wBrCsKM)e6m#7eqO_+0r3S;9t4}qR-v<(Cz_*5v z9n&wG-eGrkH{}vA=wB97wpAlXK!oj**f*`qX|a==Xp|j2ZVS$-(f*w<&#J{hkScMyl~~AAn}c?7qe>a$2Q)( z7&K|X;F_V|Mfv??7>~?{AVGXe$v`aW=`#n^bp=dKG= zo#}1-oid>Bmy0GDzCWL9yaYh77UjeO6u2JdD1+Fxbn7xkCYt<;`odx0gCoRny(?cWtzmlF9G{fn=}siB&fSZJ@So|*nI$>L6G zxF~kK`3zC};&B>7=K|WqO=~E%+(JN)r*B~*J+0V&rm?x>nafvhB)LI>viomM+WEi* zP%QA%Vf&D1>bvP7fR6eM6+6jVN-JN;HbZ%kkUL=6Y;j?=3m3Zmj}mD&%pjj|{+zRN ztvH2D=P~rl>{O80)>rcCiTq=OuEKp@A$FU{nd z6HPOpQcPx7Zq_I(U+Y#ej11R-E-TMMboy_e%8RBdSI`pgYWzXS80PBu7$uPqa{d6Y`lXH!{O(EG|bk zSP8LVX&~{&`6}%LwO?bobQA+BWY<~UgIb;|fvt=r7XRnjcbuQ0lQyZ~sTNt`R?nx#W4${fGe`*LVcpsm2}u>LF-l-sp&#{Gw_m#1 z5RW8YT)Ai`as9ILFJh9M6zucr4^k3^tNx@)5^Ln^+U071DuyU-lUVo&8m0Htar!op zbR!#{w(4tVn#?)4{VMjZq*Mz*@x_4bW^)elDPib{0>dCb;p`VR6!_{PW|u2E>KQ$X z>l&1RwsoDGy3IvC_6Ok=S&s#@Dt=;+t-qb1B8X=pCcxwQi5yJOXG(o<{8fF;)`9mo z%#I#p{JVLUz*M0#z1EbED=RODNA3JoZQs{rP;iN+-5A9@1%o%6Nx?@ zYU@BftBhXL8)Fn4llUrMikRoqXRDvh`$Z4c#Q$p&s}!`AC}#5kDjr`+H!m;WFsdjh`p9b1*5wOL!dWf zV1{Z$2huW4(iiza11Z|-R(zXq$r^bjtvhArj8nuR|Ne&_7}n?;K5h`BmTv%^s;XXh zIo`O+MOb|qYIBr(6g4t|h`rq}cx(RN$FJ4)9)QcB-E1w{iF|A(M zeLVHJOhejq9u2jb^JuODEX-H$RTqjhxbkXpk9f zs&QQ%KYI&wm1gHUH1G<~&t@|27hrDeLei3MVv72zjaQxnpZ1C4(97~N9g6JOLv&%{ zaZ;0A`k31Ls~{kYN#J>EJ0o(iA?R=UcYu!S%(tbY(pSDCqC@*ePBc#tE4SpG4}Cbm zXN8EBrk%^~Csm;_vVz3B3?Kr|ZnT=Ym(zP0Zeaf$&BwD@$%Fx29T`3v4vKsb6frF4 z`n6@60U-Mm?wW`=tyo-R^L4G@{LhJ`7I3ZRJ|ohfG;k)I6~F@4T>>Zl{&ul$6!@Q$ zy4?JG4*|F@=WwvL07v>K)G@*6qEZu7>)dwnqT{*|_T4b%LgdGe@{AeyG?AQBjoLf# z_ibpRLe|bKps*fb-%KnouMR)YE@d-sU8?=C)KOu^MV$nML_@GkD$4wM3vv$H3ovWX zh$>!?sOgglxr3resS0+@dY-mB1QQMU#kUn#c^-b+tL--+u6X2tqxXDkse`xS%q<v@Y5DQ5@;f=s-{mha@iwzVQV$uFvbY7l;#J98 z{rRKMjqLYVk8!W&)YFfK?wnq5=C%5NJh3fr@{WLB0(Kl5NTp zN3@#-q0ZQaU~vv$$(L(@j>UIE#4V#k^m)KK;fJVI*>mk)5e85RC*BukyH?Rae-@+& zIn814{L!($)Oo|3Eq89oI#q$2RF9n^aduk7+B1*~mrNLHhEDT8E;)1&>H8zoen#Yf7RIE{& zsv4m1E&*!TVs9=8zt>ly@KnUb@n4bM`g3^jC84q1RHU+koro#x`tXgSO-zik$e^*x zy~W+T2T4jkXw%m|a&tfgyh7b$Mc7YvI3`k`Su__kq|n&2WwDb3|I|eVn^+KHO@Brl zl-@Z_C%5}oy5=}`zlRp7w5`1~B5gnAsSQG3u3p!f0J&s-{z3qd$*yd~XDH7$;2eBA zY_yQYc2w*v!%-hMJwlHpIG^s!6z=dYh^;A|9^|^GiX`VNt~?WU!T;A*Q}LE?`oXwb z4V1~o5KtLywa}9*;B^)4u8C|A){jlo85Rp;ppIa3XMW$@>&UXWYZs`zb@j1N-6V-R z`BG14pVP2v3pt%W#3=9BQTdlsPmzd7b3Etnh<}_3H<-7vQhffvafSCy1r4%&!Ek$J z5G@Jqf*aT+{Fy7}MHlyyHN#=zF@$0deS3!4%HgsUhQYN0>1wl^VzpU%(LIy|2a4FD zcEP4a<99K&6!uv)!Bh~+mh%GhEEndPoLESsFb8^s-=5pl5`XJZ32vN1cKE2IbBNhH zH;AMeish!X9nLJr(t=AzyzYin_#S>nzYj5qW(H86xMslN=K%~+`$mt#X0}Aba?DnC zV52KD!o6NPbKC5bvYQgYF)wWvdW!d@N*UpWLmWyBr}G@SW{EA9?hj@&XDGR%N_|y< z-spS8=MOu?9MBAmBgGtMC0PNJAXJL#m|41wgdhZ%Kc8Q6Awd;T`@*lPnGM)}z`(j@ zg6*`-{wO-Cn~ zs?zz*%lF858A^S!;VX;|QM>_FnAj<-HT?xGXPeUkE=Oh4Ty&(h_=Pe#)xF9EoKRK{ z3I!OP|IxpJ`QW+uP~bQ`4TeiWD%<~BN0eH)(4O%m*H9~Z?5f1K8xHT*yt*m zI0=7?Ed#9o9_|mwQkvRJc!>LU*mps?;=14Y4yKfTIrQJI_;d%%RH*l&xka^7BGzS@ z71esMROw-E&+x_nJ)TYClWHR`B|E1)s6^0nXB`mYM+Msd27eMiAL?*GaZR~8VL$ad zPsM-<<{bajIQ@RK@vmN)H-KJiIH98LqHDvW{V7B0MMS3sSf-@L-AI{FaOif*6JxP5 zcbX@+oWuDad#@2Lid;0v_%3q_dYefqRHU8ngN;l5Wh3k4=0rL8{AY?3rkP5s-h&Up zSNc_0tlJ_9xB0R$>&_Y8mIbd^k>P2OmKHg3@@gey;hpVQ~qxHj|*)cEfHrr3Aw z9d7u&98GeAQh=}r05M-RN6K-F>;cq#63`Bk+Bg2aX#TJ4KeEwy_ADq>Ho-PHhUcli zY9j@=@_9j!AdaZx$mg(dV3uVX7Pg$FC!+OY`3{be>tx2ki}2p&rS@vRQYR-Orpqil z&q8e5MUqZ2-BFUZVWHU(9ps+|I9)s+cvwLZ6l17Kx8n-&H$b~lvrXckp@c>c{>viFUQ zZzlNkpGc4uUnE^m;D)}*OYVBPDPpEi#-i0~T2P#XvV5AT4NKEtP(WYIMRQY{;Oxqx8B z-XlV8sUnkThHWJ_swt6^=)PMA%?mt zFszM-mpS@Ph3CSpA{o2T-4BA3j7c}3D{{a84s~>(y?3!$n_ole9@Xp`3{72bMdA@^y(cV zvJAh3XOaDA?juZ@GBV(hXtTdqI$|N0Eda88)6h=yuI=QaL3wsP(74JZz58vwAXWDytLxswCff0Cz;UaD9 z>_@K6jz!7bsUMQKbga7hjd754H z>GL}@rkn9yyF0@hV`|AP5=gb`>3h9h0;4x)1V!~+@Sj(oX1%-lL38~gP%whI;LGukt;k9#`|5X@9f zTqnRPJpDlN_(@s)bn$hXxRn_fPLt<7l7%%<8f_34Db9wEt?8lh6%L0{R3IBpM4_3* z&8=r=zEe+fbdrHD%Y|YRWE}(^DnwX<_y^e_5*zVb_>4rdfX~##3AQ(LTb;RUUP?hh%k7)i_$<$q zUJ))HgY^SP^B}sz`HN+n0Lraz4(KVGY!>#?Nw=~%$>E|zA@^@FbgK5s+c72B)s05h z+h<=@eRB`{K0{P|4O|`4z9jZK(TILIh_9j6TzmlSdFMobcFeLVd!&P{`u>t*sAnlb zR{3hc-xJYhRjE+BVA=9r1Av7%=9tcECA5=~5&VDdyzl;4(X_;sa7d5jP>bW=d+vF) z+vWVNO0GmzjY$d0ULSjI_JB=(>91#Hph8s$pBm z3GuM4=AK~Bf!jBlFRfH`<>%T~bJtuYb>M(dDf3x#G9nBBZEf#P0={0~IDZ=7x9S_# z)>x&?qqNS`TM-xCjEWKz6vQ?jD?dsGKH3kyvO)b*WwX6`q}y!7tKlchGkPla(56M< T@@ogh4bnqLQ(vP(%{ugdRgiBK literal 191342 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 S8++GC&zqOps%0-MKmHG;mD@T1 From 78e2ae4f360135b32abe75c61fba96c2a16fd458 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 23 Sep 2021 17:53:57 +0100 Subject: [PATCH 096/231] Allow trade bots in the REFUNDING state to be deleted, if the user chooses to via the DELETE /crosschain/tradebot API endpoint. --- .../org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java | 1 + .../org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java | 1 + .../org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java | 1 + 3 files changed, 3 insertions(+) diff --git a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java index 790584d3..038ecded 100644 --- a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java @@ -360,6 +360,7 @@ public class BitcoinACCTv1TradeBot implements AcctTradeBot { case BOB_DONE: case ALICE_REFUNDED: case BOB_REFUNDED: + case ALICE_REFUNDING_A: return true; default: diff --git a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java index 516fa621..e7b60b25 100644 --- a/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/DogecoinACCTv1TradeBot.java @@ -353,6 +353,7 @@ public class DogecoinACCTv1TradeBot implements AcctTradeBot { case BOB_DONE: case ALICE_REFUNDED: case BOB_REFUNDED: + case ALICE_REFUNDING_A: return true; default: diff --git a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java index 0246c199..686b675e 100644 --- a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java @@ -364,6 +364,7 @@ public class LitecoinACCTv1TradeBot implements AcctTradeBot { case BOB_DONE: case ALICE_REFUNDED: case BOB_REFUNDED: + case ALICE_REFUNDING_A: return true; default: From 3400e36ac4c7452862e7375772aeb8e927bb0f19 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Aug 2021 08:01:02 +0100 Subject: [PATCH 097/231] Started work on pruning mode (top-only-sync) Initially just deleting old and unused AT states, to get this table under control. I have had to delete them individually as the table can't handle complex queries due to its size. Nodes in pruning mode will be unable to serve older blocks to peers. --- .../qortal/controller/AtStatesTrimmer.java | 2 + .../org/qortal/controller/Controller.java | 16 +++- .../controller/pruning/AtStatesPruner.java | 95 +++++++++++++++++++ .../controller/pruning/PruneManager.java | 60 ++++++++++++ .../org/qortal/repository/ATRepository.java | 17 ++++ .../repository/hsqldb/HSQLDBATRepository.java | 85 +++++++++++++++++ .../hsqldb/HSQLDBDatabaseUpdates.java | 4 + .../java/org/qortal/settings/Settings.java | 28 ++++++ 8 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/qortal/controller/pruning/AtStatesPruner.java create mode 100644 src/main/java/org/qortal/controller/pruning/PruneManager.java diff --git a/src/main/java/org/qortal/controller/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/AtStatesTrimmer.java index b452b3cc..78539813 100644 --- a/src/main/java/org/qortal/controller/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/AtStatesTrimmer.java @@ -2,6 +2,7 @@ package org.qortal.controller; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.controller.pruning.PruneManager; import org.qortal.data.block.BlockData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -22,6 +23,7 @@ public class AtStatesTrimmer implements Runnable { repository.getATRepository().prepareForAtStateTrimming(); repository.saveChanges(); + PruneManager.getInstance().setBuiltLatestATStates(true); while (!Controller.isStopping()) { repository.discardChanges(); diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index f9e681ab..a66faab2 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -46,6 +46,7 @@ import org.qortal.block.Block; import org.qortal.block.BlockChain; import org.qortal.block.BlockChain.BlockTimingByHeight; import org.qortal.controller.Synchronizer.SynchronizationResult; +import org.qortal.controller.pruning.PruneManager; import org.qortal.controller.repository.NamesDatabaseIntegrityCheck; import org.qortal.controller.tradebot.TradeBot; import org.qortal.crypto.Crypto; @@ -358,7 +359,7 @@ public class Controller extends Thread { return this.savedArgs; } - /* package */ static boolean isStopping() { + public static boolean isStopping() { return isStopping; } @@ -1292,6 +1293,13 @@ public class Controller extends Thread { try (final Repository repository = RepositoryManager.getRepository()) { BlockData blockData = repository.getBlockRepository().fromSignature(signature); + if (blockData != null) { + if (PruneManager.getInstance().isBlockPruned(blockData.getHeight(), repository)) { + // If this is a pruned block, we likely only have partial data, so best not to sent it + blockData = null; + } + } + if (blockData == null) { // We don't have this block this.stats.getBlockMessageStats.unknownBlocks.getAndIncrement(); @@ -1413,6 +1421,12 @@ public class Controller extends Thread { BlockData blockData = repository.getBlockRepository().fromReference(parentSignature); + if (PruneManager.getInstance().isBlockPruned(blockData.getHeight(), repository)) { + // If this request contains a pruned block, we likely only have partial data, so best not to sent anything + // We always prune from the oldest first, so it's fine to just check the first block requested + blockData = null; + } + while (blockData != null && blockSummaries.size() < numberRequested) { BlockSummaryData blockSummary = new BlockSummaryData(blockData); blockSummaries.add(blockSummary); diff --git a/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java b/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java new file mode 100644 index 00000000..37f0cd74 --- /dev/null +++ b/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java @@ -0,0 +1,95 @@ +package org.qortal.controller.pruning; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.controller.Controller; +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; +import org.qortal.utils.NTP; + +public class AtStatesPruner implements Runnable { + + private static final Logger LOGGER = LogManager.getLogger(AtStatesPruner.class); + + @Override + public void run() { + Thread.currentThread().setName("AT States pruner"); + + if (!Settings.getInstance().isPruningEnabled()) { + return; + } + + try (final Repository repository = RepositoryManager.getRepository()) { + int pruneStartHeight = repository.getATRepository().getAtPruneHeight(); + + // repository.getATRepository().prepareForAtStatePruning(); + // repository.saveChanges(); + + while (!Controller.isStopping()) { + repository.discardChanges(); + + Thread.sleep(Settings.getInstance().getAtStatesPruneInterval()); + + if (PruneManager.getInstance().getBuiltLatestATStates() == false) { + // Wait for latest AT states table to be built first + // This has a dependency on the AtStatesTrimmer running, + // which should be okay, given that it isn't something + // is disabled in normal operation. + continue; + } + + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null || NTP.getTime() == null) + continue; + + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Controller.getInstance().isSynchronizing()) + continue; + + long currentPrunableTimestamp = NTP.getTime() - Settings.getInstance().getAtStatesMaxLifetime(); + // We want to keep AT states near the tip of our copy of blockchain so we can process/orphan nearby blocks + long chainPrunableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); + + long upperPrunableTimestamp = Math.min(currentPrunableTimestamp, chainPrunableTimestamp); + int upperPrunableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperPrunableTimestamp); + + int upperBatchHeight = pruneStartHeight + Settings.getInstance().getAtStatesPruneBatchSize(); + int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); + + if (pruneStartHeight >= upperPruneHeight) + continue; + + LOGGER.debug(String.format("Pruning AT states between blocks %d and %d...", pruneStartHeight, upperPruneHeight)); + + int numAtStatesPruned = repository.getATRepository().pruneAtStates(pruneStartHeight, upperPruneHeight); + repository.saveChanges(); + + if (numAtStatesPruned > 0) { + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.debug(() -> String.format("Pruned %d AT state%s between blocks %d and %d", + numAtStatesPruned, (numAtStatesPruned != 1 ? "s" : ""), + finalPruneStartHeight, upperPruneHeight)); + } else { + // Can we move onto next batch? + if (upperPrunableHeight > upperBatchHeight) { + pruneStartHeight = upperBatchHeight; + repository.getATRepository().setAtPruneHeight(pruneStartHeight); + repository.getATRepository().prepareForAtStatePruning(); + repository.saveChanges(); + + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.debug(() -> String.format("Bumping AT state base prune height to %d", finalPruneStartHeight)); + } + } + } + } catch (DataException e) { + LOGGER.warn(String.format("Repository issue trying to prune AT states: %s", e.getMessage())); + } catch (InterruptedException e) { + // Time to exit + } + } + +} diff --git a/src/main/java/org/qortal/controller/pruning/PruneManager.java b/src/main/java/org/qortal/controller/pruning/PruneManager.java new file mode 100644 index 00000000..dcd7391d --- /dev/null +++ b/src/main/java/org/qortal/controller/pruning/PruneManager.java @@ -0,0 +1,60 @@ +package org.qortal.controller.pruning; + +import org.qortal.controller.Controller; + +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.settings.Settings; +import org.qortal.utils.DaemonThreadFactory; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class PruneManager { + + private static PruneManager instance; + + private boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); + private int pruneBlockLimit = Settings.getInstance().getPruneBlockLimit(); + private boolean builtLatestATStates = false; + + private PruneManager() { + // Start individual pruning processes + ExecutorService pruneExecutor = Executors.newCachedThreadPool(new DaemonThreadFactory()); + pruneExecutor.execute(new AtStatesPruner()); + } + + public static synchronized PruneManager getInstance() { + if (instance == null) + instance = new PruneManager(); + + return instance; + } + + public boolean isBlockPruned(int height, Repository repository) throws DataException { + if (!this.pruningEnabled) { + return false; + } + + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null) { + throw new DataException("Unable to determine chain tip when checking if a block is pruned"); + } + + final int ourLatestHeight = chainTip.getHeight(); + final int latestUnprunedHeight = ourLatestHeight - this.pruneBlockLimit; + + return (height < latestUnprunedHeight); + } + + + public void setBuiltLatestATStates(boolean builtLatestATStates) { + this.builtLatestATStates = builtLatestATStates; + } + + public boolean getBuiltLatestATStates() { + return this.builtLatestATStates; + } + +} diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 558b3aab..6cec0839 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -127,6 +127,23 @@ public interface ATRepository { /** Trims full AT state data between passed heights. Returns number of trimmed rows. */ public int trimAtStates(int minHeight, int maxHeight, int limit) throws DataException; + + /** Returns height of first prunable AT state. */ + public int getAtPruneHeight() throws DataException; + + /** Sets new base height for AT state pruning. + *

+ * NOTE: performs implicit repository.saveChanges(). + */ + public void setAtPruneHeight(int pruneHeight) throws DataException; + + /** Hook to allow repository to prepare/cache info for AT state pruning. */ + public void prepareForAtStatePruning() throws DataException; + + /** Prunes full AT state data between passed heights. Returns number of pruned rows. */ + public int pruneAtStates(int minHeight, int maxHeight) throws DataException; + + /** * Save ATStateData into repository. *

diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index d2461466..d5929311 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -8,6 +8,7 @@ import java.util.Set; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.data.account.AccountData; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; import org.qortal.repository.ATRepository; @@ -682,6 +683,90 @@ public class HSQLDBATRepository implements ATRepository { } } + + @Override + public int getAtPruneHeight() throws DataException { + String sql = "SELECT AT_prune_height FROM DatabaseInfo"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return 0; + + return resultSet.getInt(1); + } catch (SQLException e) { + throw new DataException("Unable to fetch AT state prune height from repository", e); + } + } + + @Override + public void setAtPruneHeight(int pruneHeight) throws DataException { + // trimHeightsLock is to prevent concurrent update on DatabaseInfo + // that could result in "transaction rollback: serialization failure" + synchronized (this.repository.trimHeightsLock) { + String updateSql = "UPDATE DatabaseInfo SET AT_prune_height = ?"; + + try { + this.repository.executeCheckedUpdate(updateSql, pruneHeight); + this.repository.saveChanges(); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to set AT state prune height in repository", e); + } + } + } + + @Override + public void prepareForAtStatePruning() throws DataException { + // Use LatestATStates table that was already built by AtStatesTrimmer + // The AtStatesPruner class checks that this process has completed first + } + + @Override + public int pruneAtStates(int minHeight, int maxHeight) throws DataException { + int deletedCount = 0; + + for (int height=minHeight; height atAddresses = new ArrayList<>(); + String updateSql = "SELECT AT_address FROM LatestATStates WHERE height = ?"; + try (ResultSet resultSet = this.repository.checkedExecute(updateSql, height)) { + if (resultSet != null) { + do { + String atAddress = resultSet.getString(1); + atAddresses.add(atAddress); + + } while (resultSet.next()); + } + } catch (SQLException e) { + throw new DataException("Unable to fetch flagged accounts from repository", e); + } + + List atStates = this.getBlockATStatesAtHeight(height); + for (ATStateData atState : atStates) { + //LOGGER.info("Found atState {} at height {}", atState.getATAddress(), atState.getHeight()); + + if (atAddresses.contains(atState.getATAddress())) { + // We don't want to delete this AT state because it is still active + LOGGER.info("Skipping atState {} at height {}", atState.getATAddress(), atState.getHeight()); + continue; + } + + // Safe to delete everything else for this height + try { + this.repository.delete("ATStates", "AT_address = ? AND height = ?", + atState.getATAddress(), atState.getHeight()); + deletedCount++; + } catch (SQLException e) { + throw new DataException("Unable to delete AT state data from repository", e); + } + } + } + + return deletedCount; + } + + @Override public void save(ATStateData atStateData) throws DataException { // We shouldn't ever save partial ATStateData diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 683a2c3b..94e753e8 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -867,6 +867,10 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CHECKPOINT"); break; } + case 35: + // Support for pruning + stmt.execute("ALTER TABLE DatabaseInfo ADD AT_prune_height INT NOT NULL DEFAULT 0"); + break; default: // nothing to do diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 0c8573db..9fe533b3 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -106,6 +106,18 @@ public class Settings { /** Max number of AT states to trim in one go. */ private int atStatesTrimLimit = 4000; // records + /** Whether we should prune old data to reduce database size + * This prevents the node from being able to serve older blocks */ + private boolean pruningEnabled = false; + /** The amount of recent blocks we should keep when pruning */ + private int pruneBlockLimit = 1440; + + /** How often to attempt AT state pruning (ms). */ + private long atStatesPruneInterval = 3219L; // milliseconds + /** Block height range to scan for trimmable AT states.
+ * This has a significant effect on execution time. */ + private int atStatesPruneBatchSize = 10; // blocks + /** How often to attempt online accounts signatures trimming (ms). */ private long onlineSignaturesTrimInterval = 9876L; // milliseconds /** Block height range to scan for trimmable online accounts signatures.
@@ -528,6 +540,22 @@ public class Settings { return this.atStatesTrimLimit; } + public boolean isPruningEnabled() { + return this.pruningEnabled; + } + + public int getPruneBlockLimit() { + return this.pruneBlockLimit; + } + + public long getAtStatesPruneInterval() { + return this.atStatesPruneInterval; + } + + public int getAtStatesPruneBatchSize() { + return this.atStatesPruneBatchSize; + } + public long getOnlineSignaturesTrimInterval() { return this.onlineSignaturesTrimInterval; } From 1b4c75a76eb5980d0ad8da2bf7ed992cfedf205e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Aug 2021 15:17:46 +0100 Subject: [PATCH 098/231] Prune all blocks up until the blockPruneLimit By default, this leaves only the last 1450 blocks in the database. Only applies when pruning mode is enabled. --- .../controller/pruning/BlockPruner.java | 86 +++++++++++++++++++ .../controller/pruning/PruneManager.java | 1 + .../qortal/repository/BlockRepository.java | 14 +++ .../hsqldb/HSQLDBBlockRepository.java | 47 ++++++++++ .../hsqldb/HSQLDBDatabaseUpdates.java | 1 + .../java/org/qortal/settings/Settings.java | 49 +++++++---- 6 files changed, 182 insertions(+), 16 deletions(-) create mode 100644 src/main/java/org/qortal/controller/pruning/BlockPruner.java diff --git a/src/main/java/org/qortal/controller/pruning/BlockPruner.java b/src/main/java/org/qortal/controller/pruning/BlockPruner.java new file mode 100644 index 00000000..8ae25224 --- /dev/null +++ b/src/main/java/org/qortal/controller/pruning/BlockPruner.java @@ -0,0 +1,86 @@ +package org.qortal.controller.pruning; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.controller.Controller; +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; +import org.qortal.utils.NTP; + +public class BlockPruner implements Runnable { + + private static final Logger LOGGER = LogManager.getLogger(BlockPruner.class); + + @Override + public void run() { + Thread.currentThread().setName("Block pruner"); + + if (!Settings.getInstance().isPruningEnabled()) { + return; + } + + try (final Repository repository = RepositoryManager.getRepository()) { + int pruneStartHeight = repository.getBlockRepository().getBlockPruneHeight(); + + while (!Controller.isStopping()) { + repository.discardChanges(); + + Thread.sleep(Settings.getInstance().getBlockPruneInterval()); + + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null || NTP.getTime() == null) + continue; + + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Controller.getInstance().isSynchronizing()) + continue; + + // Prune all blocks up until our latest minus pruneBlockLimit + final int ourLatestHeight = chainTip.getHeight(); + final int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); + + int upperBatchHeight = pruneStartHeight + Settings.getInstance().getBlockPruneBatchSize(); + int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); + + if (pruneStartHeight >= upperPruneHeight) { + continue; + } + + LOGGER.debug(String.format("Pruning blocks between %d and %d...", pruneStartHeight, upperPruneHeight)); + + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(pruneStartHeight, upperPruneHeight); + repository.saveChanges(); + + if (numBlocksPruned > 0) { + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.debug(() -> String.format("Pruned %d block%s between %d and %d", + numBlocksPruned, (numBlocksPruned != 1 ? "s" : ""), + finalPruneStartHeight, upperPruneHeight)); + } else { + // Can we move onto next batch? + if (upperPrunableHeight > upperBatchHeight) { + pruneStartHeight = upperBatchHeight; + repository.getBlockRepository().setBlockPruneHeight(pruneStartHeight); + repository.saveChanges(); + + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.debug(() -> String.format("Bumping block base prune height to %d", finalPruneStartHeight)); + } + else { + // We've pruned up to the upper prunable height + // Back off for a while to save CPU for syncing + Thread.sleep(10*60*1000L); + } + } + } + } catch (DataException e) { + LOGGER.warn(String.format("Repository issue trying to prune blocks: %s", e.getMessage())); + } catch (InterruptedException e) { + // Time to exit + } + } + +} diff --git a/src/main/java/org/qortal/controller/pruning/PruneManager.java b/src/main/java/org/qortal/controller/pruning/PruneManager.java index dcd7391d..66019d01 100644 --- a/src/main/java/org/qortal/controller/pruning/PruneManager.java +++ b/src/main/java/org/qortal/controller/pruning/PruneManager.java @@ -23,6 +23,7 @@ public class PruneManager { // Start individual pruning processes ExecutorService pruneExecutor = Executors.newCachedThreadPool(new DaemonThreadFactory()); pruneExecutor.execute(new AtStatesPruner()); + pruneExecutor.execute(new BlockPruner()); } public static synchronized PruneManager getInstance() { diff --git a/src/main/java/org/qortal/repository/BlockRepository.java b/src/main/java/org/qortal/repository/BlockRepository.java index 78eba399..5ca61e66 100644 --- a/src/main/java/org/qortal/repository/BlockRepository.java +++ b/src/main/java/org/qortal/repository/BlockRepository.java @@ -166,6 +166,20 @@ public interface BlockRepository { */ public BlockData getDetachedBlockSignature(int startHeight) throws DataException; + + /** Returns height of first prunable block. */ + public int getBlockPruneHeight() throws DataException; + + /** Sets new base height for block pruning. + *

+ * NOTE: performs implicit repository.saveChanges(). + */ + public void setBlockPruneHeight(int pruneHeight) throws DataException; + + /** Prunes full block data between passed heights. Returns number of pruned rows. */ + public int pruneBlocks(int minHeight, int maxHeight) throws DataException; + + /** * Saves block into repository. * diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java index b486e6a0..2f7e4ad2 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java @@ -509,6 +509,53 @@ public class HSQLDBBlockRepository implements BlockRepository { } } + + @Override + public int getBlockPruneHeight() throws DataException { + String sql = "SELECT block_prune_height FROM DatabaseInfo"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return 0; + + return resultSet.getInt(1); + } catch (SQLException e) { + throw new DataException("Unable to fetch block prune height from repository", e); + } + } + + @Override + public void setBlockPruneHeight(int pruneHeight) throws DataException { + // trimHeightsLock is to prevent concurrent update on DatabaseInfo + // that could result in "transaction rollback: serialization failure" + synchronized (this.repository.trimHeightsLock) { + String updateSql = "UPDATE DatabaseInfo SET block_prune_height = ?"; + + try { + this.repository.executeCheckedUpdate(updateSql, pruneHeight); + this.repository.saveChanges(); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to set block prune height in repository", e); + } + } + } + + @Override + public int pruneBlocks(int minHeight, int maxHeight) throws DataException { + // Don't prune the genesis block + if (minHeight <= 1) { + minHeight = 2; + } + + try { + return this.repository.delete("Blocks", "height BETWEEN ? AND ?", minHeight, maxHeight); + } catch (SQLException e) { + throw new DataException("Unable to prune blocks from repository", e); + } + } + + @Override public BlockData getDetachedBlockSignature(int startHeight) throws DataException { String sql = "SELECT " + BLOCK_DB_COLUMNS + " FROM Blocks " diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 94e753e8..d696351f 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -870,6 +870,7 @@ public class HSQLDBDatabaseUpdates { case 35: // Support for pruning stmt.execute("ALTER TABLE DatabaseInfo ADD AT_prune_height INT NOT NULL DEFAULT 0"); + stmt.execute("ALTER TABLE DatabaseInfo ADD block_prune_height INT NOT NULL DEFAULT 0"); break; default: diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 9fe533b3..e1deb641 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -106,24 +106,32 @@ public class Settings { /** Max number of AT states to trim in one go. */ private int atStatesTrimLimit = 4000; // records - /** Whether we should prune old data to reduce database size - * This prevents the node from being able to serve older blocks */ - private boolean pruningEnabled = false; - /** The amount of recent blocks we should keep when pruning */ - private int pruneBlockLimit = 1440; - - /** How often to attempt AT state pruning (ms). */ - private long atStatesPruneInterval = 3219L; // milliseconds - /** Block height range to scan for trimmable AT states.
- * This has a significant effect on execution time. */ - private int atStatesPruneBatchSize = 10; // blocks - /** How often to attempt online accounts signatures trimming (ms). */ private long onlineSignaturesTrimInterval = 9876L; // milliseconds /** Block height range to scan for trimmable online accounts signatures.
* This has a significant effect on execution time. */ private int onlineSignaturesTrimBatchSize = 100; // blocks + + /** Whether we should prune old data to reduce database size + * This prevents the node from being able to serve older blocks */ + private boolean pruningEnabled = false; + /** The amount of recent blocks we should keep when pruning */ + private int pruneBlockLimit = 1450; + + /** How often to attempt AT state pruning (ms). */ + private long atStatesPruneInterval = 3219L; // milliseconds + /** Block height range to scan for prunable AT states.
+ * This has a significant effect on execution time. */ + private int atStatesPruneBatchSize = 10; // blocks + + /** How often to attempt block pruning (ms). */ + private long blockPruneInterval = 3219L; // milliseconds + /** Block height range to scan for prunable blocks.
+ * This has a significant effect on execution time. */ + private int blockPruneBatchSize = 10000; // blocks + + // Peer-to-peer related private boolean isTestNet = false; /** Port number for inbound peer-to-peer connections. */ @@ -540,6 +548,15 @@ public class Settings { return this.atStatesTrimLimit; } + public long getOnlineSignaturesTrimInterval() { + return this.onlineSignaturesTrimInterval; + } + + public int getOnlineSignaturesTrimBatchSize() { + return this.onlineSignaturesTrimBatchSize; + } + + public boolean isPruningEnabled() { return this.pruningEnabled; } @@ -556,12 +573,12 @@ public class Settings { return this.atStatesPruneBatchSize; } - public long getOnlineSignaturesTrimInterval() { - return this.onlineSignaturesTrimInterval; + public long getBlockPruneInterval() { + return this.blockPruneInterval; } - public int getOnlineSignaturesTrimBatchSize() { - return this.onlineSignaturesTrimBatchSize; + public int getBlockPruneBatchSize() { + return this.blockPruneBatchSize; } } From 925e10b19b4b617a415d1dfee753a6449992e7e9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Aug 2021 15:18:11 +0100 Subject: [PATCH 099/231] Rework of Blockchain.validate() to account for pruning mode. --- .../java/org/qortal/block/BlockChain.java | 49 ++++++++++++++----- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index e6b8db4e..15801193 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -506,28 +506,51 @@ public class BlockChain { * @throws SQLException */ public static void validate() throws DataException { - // Check first block is Genesis Block - if (!isGenesisBlockValid()) - rebuildBlockchain(); - try (final Repository repository = RepositoryManager.getRepository()) { + + boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); + BlockData chainTip = repository.getBlockRepository().getLastBlock(); + boolean hasBlocks = (chainTip != null && chainTip.getHeight() > 1); + + if (pruningEnabled && hasBlocks) { + // Pruning is enabled and we have blocks, so it's possible that the genesis block has been pruned + // It's best not to validate it, and there's no real need to + } + else { + // Check first block is Genesis Block + if (!isGenesisBlockValid()) { + rebuildBlockchain(); + } + } + repository.checkConsistency(); - int startHeight = Math.max(repository.getBlockRepository().getBlockchainHeight() - 1440, 1); + // Set the number of blocks to validate based on the pruned state of the chain + // If pruned, subtract an extra 10 to allow room for error + int blocksToValidate = pruningEnabled ? Settings.getInstance().getPruneBlockLimit() - 10 : 1440; + int startHeight = Math.max(repository.getBlockRepository().getBlockchainHeight() - blocksToValidate, 1); BlockData detachedBlockData = repository.getBlockRepository().getDetachedBlockSignature(startHeight); if (detachedBlockData != null) { LOGGER.error(String.format("Block %d's reference does not match any block's signature", detachedBlockData.getHeight())); - // Wait for blockchain lock (whereas orphan() only tries to get lock) - ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); - blockchainLock.lock(); - try { - LOGGER.info(String.format("Orphaning back to block %d", detachedBlockData.getHeight() - 1)); - orphan(detachedBlockData.getHeight() - 1); - } finally { - blockchainLock.unlock(); + // Orphan if we aren't a pruning node + if (!Settings.getInstance().isPruningEnabled()) { + + // Wait for blockchain lock (whereas orphan() only tries to get lock) + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + blockchainLock.lock(); + try { + LOGGER.info(String.format("Orphaning back to block %d", detachedBlockData.getHeight() - 1)); + orphan(detachedBlockData.getHeight() - 1); + } finally { + blockchainLock.unlock(); + } + } + else { + LOGGER.error(String.format("Not orphaning because we are in pruning mode. You may be on an " + + "invalid chain and should consider bootstrapping or re-syncing from genesis.")); } } } From 01d66212daacd99ddb1f473268aebfbb5733563c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Aug 2021 15:20:11 +0100 Subject: [PATCH 100/231] Updated AT states pruner as it previously relied on blocks being present in the db to make decisions. As a side effect, this now prunes ATs up the the pruneBlockLimit too, rather than keeping the last 35 days or so. Will review this later but I don't think we will need the missing ones. --- .../org/qortal/controller/pruning/AtStatesPruner.java | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java b/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java index 37f0cd74..4268f98c 100644 --- a/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java @@ -49,12 +49,9 @@ public class AtStatesPruner implements Runnable { if (Controller.getInstance().isSynchronizing()) continue; - long currentPrunableTimestamp = NTP.getTime() - Settings.getInstance().getAtStatesMaxLifetime(); - // We want to keep AT states near the tip of our copy of blockchain so we can process/orphan nearby blocks - long chainPrunableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); - - long upperPrunableTimestamp = Math.min(currentPrunableTimestamp, chainPrunableTimestamp); - int upperPrunableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperPrunableTimestamp); + // Prune AT states for all blocks up until our latest minus pruneBlockLimit + final int ourLatestHeight = chainTip.getHeight(); + final int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); int upperBatchHeight = pruneStartHeight + Settings.getInstance().getAtStatesPruneBatchSize(); int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); From 44607ba6a4fbc12545cbaab04f6eba5017188c99 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Aug 2021 15:20:28 +0100 Subject: [PATCH 101/231] Fixed NPE introduced in earlier commit. --- src/main/java/org/qortal/controller/Controller.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index a66faab2..f4291af9 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1421,10 +1421,12 @@ public class Controller extends Thread { BlockData blockData = repository.getBlockRepository().fromReference(parentSignature); - if (PruneManager.getInstance().isBlockPruned(blockData.getHeight(), repository)) { - // If this request contains a pruned block, we likely only have partial data, so best not to sent anything - // We always prune from the oldest first, so it's fine to just check the first block requested - blockData = null; + if (blockData != null) { + if (PruneManager.getInstance().isBlockPruned(blockData.getHeight(), repository)) { + // If this request contains a pruned block, we likely only have partial data, so best not to sent anything + // We always prune from the oldest first, so it's fine to just check the first block requested + blockData = null; + } } while (blockData != null && blockSummaries.size() < numberRequested) { From 1a722c1517dfaa260eec1edae4ee4e2e7347b647 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Aug 2021 15:21:18 +0100 Subject: [PATCH 102/231] Break out of the AT pruning inner loops if we're stopping the app. --- .../qortal/repository/hsqldb/HSQLDBATRepository.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index d5929311..0d4d2923 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -727,6 +727,11 @@ public class HSQLDBATRepository implements ATRepository { for (int height=minHeight; height atAddresses = new ArrayList<>(); String updateSql = "SELECT AT_address FROM LatestATStates WHERE height = ?"; @@ -746,6 +751,11 @@ public class HSQLDBATRepository implements ATRepository { for (ATStateData atState : atStates) { //LOGGER.info("Found atState {} at height {}", atState.getATAddress(), atState.getHeight()); + // Give up if we're stopping + if (Controller.isStopping()) { + return deletedCount; + } + if (atAddresses.contains(atState.getATAddress())) { // We don't want to delete this AT state because it is still active LOGGER.info("Skipping atState {} at height {}", atState.getATAddress(), atState.getHeight()); From f056ecc8d82460a9498925e7c3abe2900dc667cc Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 23 Aug 2021 21:17:51 +0100 Subject: [PATCH 103/231] Added bulk pruning phase on node startup the first time that pruning mode is enabled. When switching from a full node to a pruning node, we need to delete most of the database contents. If we do this entirely as a background process, it is very slow and can interfere with syncing. However, if we take the approach of transferring only the necessary rows to a new table and then deleting the original table, this makes the process much faster. It was taking several days to delete the AT states in the background, but only a couple of minutes to copy them to a new table. The trade off is that we have to go through a form of "reshape" when starting the app for the first time after enabling pruning mode. But given that this is an opt-in mode, I don't think it will be a problem. Once the pruning is complete, it automatically performs a CHECKPOINT DEFRAG in order to shrink the database file size down to a fraction of what it was before. From this point, the original background process will run, but can be dialled right down so not to interfere with syncing. --- .../org/qortal/controller/Controller.java | 1 + .../qortal/repository/RepositoryManager.java | 24 ++ .../hsqldb/HSQLDBDatabasePruning.java | 217 ++++++++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index f4291af9..e9610f8e 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -417,6 +417,7 @@ public class Controller extends Thread { try { RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(getRepositoryUrl()); RepositoryManager.setRepositoryFactory(repositoryFactory); + RepositoryManager.prune(); } catch (DataException e) { // If exception has no cause then repository is in use by some other process. if (e.getCause() == null) { diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index df578888..5e9c71c2 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -1,8 +1,14 @@ package org.qortal.repository; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.repository.hsqldb.HSQLDBDatabasePruning; +import org.qortal.settings.Settings; + import java.sql.SQLException; public abstract class RepositoryManager { + private static final Logger LOGGER = LogManager.getLogger(RepositoryManager.class); private static RepositoryFactory repositoryFactory = null; @@ -51,6 +57,24 @@ public abstract class RepositoryManager { } } + public static void prune() { + // Bulk prune the database the first time we use pruning mode + if (Settings.getInstance().isPruningEnabled()) { + try { + boolean prunedATStates = HSQLDBDatabasePruning.pruneATStates(); + boolean prunedBlocks = HSQLDBDatabasePruning.pruneBlocks(); + + // Perform repository maintenance to shrink the db size down + if (prunedATStates && prunedBlocks) { + HSQLDBDatabasePruning.performMaintenance(); + } + + } catch (SQLException | DataException e) { + LOGGER.info("Unable to bulk prune AT states. The database may have been left in an inconsistent state."); + } + } + } + public static void setRequestedCheckpoint(Boolean quick) { quickCheckpointRequested = quick; } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java new file mode 100644 index 00000000..6dc50647 --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -0,0 +1,217 @@ +package org.qortal.repository.hsqldb; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * + * When switching from a full node to a pruning node, we need to delete most of the database contents. + * If we do this entirely as a background process, it is very slow and can interfere with syncing. + * However, if we take the approach of transferring only the necessary rows to a new table and then + * deleting the original table, this makes the process much faster. It was taking several days to + * delete the AT states in the background, but only a couple of minutes to copy them to a new table. + * + * The trade off is that we have to go through a form of "reshape" when starting the app for the first + * time after enabling pruning mode. But given that this is an opt-in mode, I don't think it will be + * a problem. + * + * Once the pruning is complete, it automatically performs a CHECKPOINT DEFRAG in order to + * shrink the database file size down to a fraction of what it was before. + * + * From this point, the original background process will run, but can be dialled right down so not + * to interfere with syncing. + * + */ + + +public class HSQLDBDatabasePruning { + + private static final Logger LOGGER = LogManager.getLogger(HSQLDBDatabasePruning.class); + + public static boolean pruneATStates() throws SQLException, DataException { + try (final HSQLDBRepository repository = (HSQLDBRepository)RepositoryManager.getRepository()) { + + // Only bulk prune AT states if we have never done so before + int pruneHeight = repository.getATRepository().getAtPruneHeight(); + if (pruneHeight > 0) { + // Already pruned AT states + return false; + } + + LOGGER.info("Starting bulk prune of AT states - this process could take a while... (approx. 2 mins on high spec)"); + + // Create new AT-states table to hold smaller dataset + repository.executeCheckedUpdate("DROP TABLE IF EXISTS ATStatesNew"); + repository.executeCheckedUpdate("CREATE TABLE ATStatesNew (" + + "AT_address QortalAddress, height INTEGER NOT NULL, state_hash ATStateHash NOT NULL, " + + "fees QortalAmount NOT NULL, is_initial BOOLEAN NOT NULL, sleep_until_message_timestamp BIGINT, " + + "PRIMARY KEY (AT_address, height), " + + "FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); + repository.executeCheckedUpdate("SET TABLE ATStatesNew NEW SPACE"); + repository.executeCheckedUpdate("CHECKPOINT"); + + + // Find our latest block + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + if (latestBlock == null) { + LOGGER.info("Unable to determine blockchain height, necessary for bulk block pruning"); + return false; + } + + // Calculate some constants for later use + final int blockchainHeight = latestBlock.getHeight(); + final int maximumBlockToTrim = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + final int startHeight = maximumBlockToTrim; + final int endHeight = blockchainHeight; + final int blockStep = 10000; + + // Loop through all the LatestATStates and copy them to the new table + LOGGER.info("Copying AT states..."); + for (int height = 0; height < endHeight; height += blockStep) { + //LOGGER.info(String.format("Copying AT states between %d and %d...", height, height + blockStep - 1)); + + String sql = "SELECT height, AT_address FROM LatestATStates WHERE height BETWEEN ? AND ?"; + try (ResultSet latestAtStatesResultSet = repository.checkedExecute(sql, height, height + blockStep - 1)) { + if (latestAtStatesResultSet != null) { + do { + int latestAtHeight = latestAtStatesResultSet.getInt(1); + String latestAtAddress = latestAtStatesResultSet.getString(2); + + // Copy this latest ATState to the new table + //LOGGER.info(String.format("Copying AT %s at height %d...", latestAtAddress, latestAtHeight)); + try { + String updateSql = "INSERT INTO ATStatesNew (" + + "SELECT AT_address, height, state_hash, fees, is_initial, sleep_until_message_timestamp " + + "FROM ATStates " + + "WHERE height = ? AND AT_address = ?)"; + repository.executeCheckedUpdate(updateSql, latestAtHeight, latestAtAddress); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to copy ATStates", e); + } + + if (height >= startHeight) { + // Now copy this AT states for each recent block it is present in + for (int i = startHeight; i < endHeight; i++) { + if (latestAtHeight < i) { + // This AT finished before this block so there is nothing to copy + continue; + } + + //LOGGER.info(String.format("Copying recent AT %s at height %d...", latestAtAddress, i)); + try { + // Copy each LatestATState to the new table + String updateSql = "INSERT IGNORE INTO ATStatesNew (" + + "SELECT AT_address, height, state_hash, fees, is_initial, sleep_until_message_timestamp " + + "FROM ATStates " + + "WHERE height = ? AND AT_address = ?)"; + repository.executeCheckedUpdate(updateSql, i, latestAtAddress); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to copy ATStates", e); + } + } + } + + } while (latestAtStatesResultSet.next()); + } + } catch (SQLException e) { + throw new DataException("Unable to copy AT states", e); + } + } + + repository.saveChanges(); + + // Add a height index + LOGGER.info("Rebuilding AT states height index in repository"); + repository.executeCheckedUpdate("CREATE INDEX IF NOT EXISTS ATStatesHeightIndex ON ATStatesNew (height)"); + repository.executeCheckedUpdate("CHECKPOINT"); + + // Finally, drop the original table and rename + LOGGER.info("Deleting old AT states..."); + repository.executeCheckedUpdate("DROP TABLE ATStates"); + repository.executeCheckedUpdate("ALTER TABLE ATStatesNew RENAME TO ATStates"); + repository.executeCheckedUpdate("CHECKPOINT"); + + // Update the prune height + repository.getATRepository().setAtPruneHeight(maximumBlockToTrim); + repository.saveChanges(); + + repository.executeCheckedUpdate("CHECKPOINT"); + + return true; + } + } + + public static boolean pruneBlocks() throws SQLException, DataException { + try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { + + // Only bulk prune AT states if we have never done so before + int pruneHeight = repository.getBlockRepository().getBlockPruneHeight(); + if (pruneHeight > 0) { + // Already pruned blocks + return false; + } + + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + if (latestBlock == null) { + LOGGER.info("Unable to determine blockchain height, necessary for bulk block pruning"); + return false; + } + final int blockchainHeight = latestBlock.getHeight(); + final int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + int pruneStartHeight = 0; + + LOGGER.info("Starting bulk prune of blocks - this process could take a while... (approx. 10 mins on high spec)"); + + while (pruneStartHeight < upperPrunableHeight) { + // Prune all blocks up until our latest minus pruneBlockLimit + + int upperBatchHeight = pruneStartHeight + Settings.getInstance().getBlockPruneBatchSize(); + int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); + + LOGGER.info(String.format("Pruning blocks between %d and %d...", pruneStartHeight, upperPruneHeight)); + + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(pruneStartHeight, upperPruneHeight); + repository.saveChanges(); + + if (numBlocksPruned > 0) { + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.info(() -> String.format("Pruned %d block%s between %d and %d", + numBlocksPruned, (numBlocksPruned != 1 ? "s" : ""), + finalPruneStartHeight, upperPruneHeight)); + } else { + // Can we move onto next batch? + if (upperPrunableHeight > upperBatchHeight) { + pruneStartHeight = upperBatchHeight; + repository.getBlockRepository().setBlockPruneHeight(pruneStartHeight); + repository.saveChanges(); + + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.debug(() -> String.format("Bumping block base prune height to %d", finalPruneStartHeight)); + } + else { + // We've finished pruning + break; + } + } + } + + return true; + } + } + + public static void performMaintenance() throws SQLException, DataException { + try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { + repository.performPeriodicMaintenance(); + } + } + +} From 734fa5180689ae40c0052a8dca0dd105068d1840 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 25 Aug 2021 18:50:41 +0100 Subject: [PATCH 104/231] Unified the code to build the LatestATStates table, as it's now used by more than one class. Note - the rebuildLatestAtStates() must never be used by two different classes at the same time, or AT states could be incorrectly deleted. It is okay at the moment as we don't run the AT states trimmer and pruner in the same app session. However we should probably synchronize this method so that we don't accidentally call it from two places in the future. --- .../qortal/controller/AtStatesTrimmer.java | 5 +- .../org/qortal/controller/Controller.java | 13 +--- .../controller/pruning/AtStatesPruner.java | 24 +++---- .../controller/pruning/PruneManager.java | 56 ++++++++++++---- .../org/qortal/repository/ATRepository.java | 11 ++-- .../repository/hsqldb/HSQLDBATRepository.java | 64 +++++++++---------- 6 files changed, 94 insertions(+), 79 deletions(-) diff --git a/src/main/java/org/qortal/controller/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/AtStatesTrimmer.java index 78539813..4b08e5ca 100644 --- a/src/main/java/org/qortal/controller/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/AtStatesTrimmer.java @@ -21,9 +21,8 @@ public class AtStatesTrimmer implements Runnable { try (final Repository repository = RepositoryManager.getRepository()) { int trimStartHeight = repository.getATRepository().getAtTrimHeight(); - repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().rebuildLatestAtStates(); repository.saveChanges(); - PruneManager.getInstance().setBuiltLatestATStates(true); while (!Controller.isStopping()) { repository.discardChanges(); @@ -64,7 +63,7 @@ public class AtStatesTrimmer implements Runnable { if (upperTrimmableHeight > upperBatchHeight) { trimStartHeight = upperBatchHeight; repository.getATRepository().setAtTrimHeight(trimStartHeight); - repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().rebuildLatestAtStates(); repository.saveChanges(); final int finalTrimStartHeight = trimStartHeight; diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index e9610f8e..474e4498 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -518,9 +518,8 @@ public class Controller extends Thread { final long repositoryBackupInterval = Settings.getInstance().getRepositoryBackupInterval(); final long repositoryCheckpointInterval = Settings.getInstance().getRepositoryCheckpointInterval(); - ExecutorService trimExecutor = Executors.newCachedThreadPool(new DaemonThreadFactory()); - trimExecutor.execute(new AtStatesTrimmer()); - trimExecutor.execute(new OnlineAccountsSignaturesTrimmer()); + // Start executor service for trimming or pruning + PruneManager.getInstance().start(); try { while (!isStopping) { @@ -605,13 +604,7 @@ public class Controller extends Thread { Thread.interrupted(); // Fall-through to exit } finally { - trimExecutor.shutdownNow(); - - try { - trimExecutor.awaitTermination(2L, TimeUnit.SECONDS); - } catch (InterruptedException e) { - // We tried... - } + PruneManager.getInstance().stop(); } } diff --git a/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java b/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java index 4268f98c..66325e88 100644 --- a/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java @@ -25,22 +25,14 @@ public class AtStatesPruner implements Runnable { try (final Repository repository = RepositoryManager.getRepository()) { int pruneStartHeight = repository.getATRepository().getAtPruneHeight(); - // repository.getATRepository().prepareForAtStatePruning(); - // repository.saveChanges(); + repository.getATRepository().rebuildLatestAtStates(); + repository.saveChanges(); while (!Controller.isStopping()) { repository.discardChanges(); Thread.sleep(Settings.getInstance().getAtStatesPruneInterval()); - if (PruneManager.getInstance().getBuiltLatestATStates() == false) { - // Wait for latest AT states table to be built first - // This has a dependency on the AtStatesTrimmer running, - // which should be okay, given that it isn't something - // is disabled in normal operation. - continue; - } - BlockData chainTip = Controller.getInstance().getChainTip(); if (chainTip == null || NTP.getTime() == null) continue; @@ -63,8 +55,11 @@ public class AtStatesPruner implements Runnable { int numAtStatesPruned = repository.getATRepository().pruneAtStates(pruneStartHeight, upperPruneHeight); repository.saveChanges(); + int numAtStateDataRowsTrimmed = repository.getATRepository().trimAtStates( + pruneStartHeight, upperPruneHeight, Settings.getInstance().getAtStatesTrimLimit()); + repository.saveChanges(); - if (numAtStatesPruned > 0) { + if (numAtStatesPruned > 0 || numAtStateDataRowsTrimmed > 0) { final int finalPruneStartHeight = pruneStartHeight; LOGGER.debug(() -> String.format("Pruned %d AT state%s between blocks %d and %d", numAtStatesPruned, (numAtStatesPruned != 1 ? "s" : ""), @@ -74,12 +69,17 @@ public class AtStatesPruner implements Runnable { if (upperPrunableHeight > upperBatchHeight) { pruneStartHeight = upperBatchHeight; repository.getATRepository().setAtPruneHeight(pruneStartHeight); - repository.getATRepository().prepareForAtStatePruning(); + repository.getATRepository().rebuildLatestAtStates(); repository.saveChanges(); final int finalPruneStartHeight = pruneStartHeight; LOGGER.debug(() -> String.format("Bumping AT state base prune height to %d", finalPruneStartHeight)); } + else { + // We've pruned up to the upper prunable height + // Back off for a while to save CPU for syncing + Thread.sleep(5*60*1000L); + } } } } catch (DataException e) { diff --git a/src/main/java/org/qortal/controller/pruning/PruneManager.java b/src/main/java/org/qortal/controller/pruning/PruneManager.java index 66019d01..b733833b 100644 --- a/src/main/java/org/qortal/controller/pruning/PruneManager.java +++ b/src/main/java/org/qortal/controller/pruning/PruneManager.java @@ -1,7 +1,9 @@ package org.qortal.controller.pruning; +import org.qortal.controller.AtStatesTrimmer; import org.qortal.controller.Controller; +import org.qortal.controller.OnlineAccountsSignaturesTrimmer; import org.qortal.data.block.BlockData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -10,6 +12,7 @@ import org.qortal.utils.DaemonThreadFactory; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; public class PruneManager { @@ -17,13 +20,11 @@ public class PruneManager { private boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); private int pruneBlockLimit = Settings.getInstance().getPruneBlockLimit(); - private boolean builtLatestATStates = false; + + private ExecutorService executorService; private PruneManager() { - // Start individual pruning processes - ExecutorService pruneExecutor = Executors.newCachedThreadPool(new DaemonThreadFactory()); - pruneExecutor.execute(new AtStatesPruner()); - pruneExecutor.execute(new BlockPruner()); + } public static synchronized PruneManager getInstance() { @@ -33,6 +34,42 @@ public class PruneManager { return instance; } + public void start() { + this.executorService = Executors.newCachedThreadPool(new DaemonThreadFactory()); + + // Don't allow both the pruner and the trimmer to run at the same time. + // In pruning mode, we are already deleting far more than we would when trimming. + // In non-pruning mode, we still need to trim to keep the non-essential data + // out of the database. There isn't a case where both are needed at once. + // If we ever do need to enable both at once, be very careful with the AT state + // trimming, since both currently rely on having exclusive access to the + // prepareForAtStateTrimming() method. For both trimming and pruning to take place + // at once, we would need to synchronize this method in a way that both can't + // call it at the same time, as otherwise active ATs would be pruned/trimmed when + // they should have been kept. + + if (Settings.getInstance().isPruningEnabled()) { + // Pruning enabled - start the pruning processes + this.executorService.execute(new AtStatesPruner()); + this.executorService.execute(new BlockPruner()); + } + else { + // Pruning disabled - use trimming instead + this.executorService.execute(new AtStatesTrimmer()); + this.executorService.execute(new OnlineAccountsSignaturesTrimmer()); + } + } + + public void stop() { + this.executorService.shutdownNow(); + + try { + this.executorService.awaitTermination(2L, TimeUnit.SECONDS); + } catch (InterruptedException e) { + // We tried... + } + } + public boolean isBlockPruned(int height, Repository repository) throws DataException { if (!this.pruningEnabled) { return false; @@ -49,13 +86,4 @@ public class PruneManager { return (height < latestUnprunedHeight); } - - public void setBuiltLatestATStates(boolean builtLatestATStates) { - this.builtLatestATStates = builtLatestATStates; - } - - public boolean getBuiltLatestATStates() { - return this.builtLatestATStates; - } - } diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 6cec0839..74fb19ab 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -112,6 +112,11 @@ public interface ATRepository { */ public List getBlockATStatesAtHeight(int height) throws DataException; + + /** Rebuild the latest AT states cache, necessary for AT state trimming/pruning. */ + public void rebuildLatestAtStates() throws DataException; + + /** Returns height of first trimmable AT state. */ public int getAtTrimHeight() throws DataException; @@ -121,9 +126,6 @@ public interface ATRepository { */ public void setAtTrimHeight(int trimHeight) throws DataException; - /** Hook to allow repository to prepare/cache info for AT state trimming. */ - public void prepareForAtStateTrimming() throws DataException; - /** Trims full AT state data between passed heights. Returns number of trimmed rows. */ public int trimAtStates(int minHeight, int maxHeight, int limit) throws DataException; @@ -137,9 +139,6 @@ public interface ATRepository { */ public void setAtPruneHeight(int pruneHeight) throws DataException; - /** Hook to allow repository to prepare/cache info for AT state pruning. */ - public void prepareForAtStatePruning() throws DataException; - /** Prunes full AT state data between passed heights. Returns number of pruned rows. */ public int pruneAtStates(int minHeight, int maxHeight) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 0d4d2923..1921661c 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -8,7 +8,7 @@ import java.util.Set; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.qortal.data.account.AccountData; +import org.qortal.controller.Controller; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; import org.qortal.repository.ATRepository; @@ -601,6 +601,35 @@ public class HSQLDBATRepository implements ATRepository { return atStates; } + + @Override + public void rebuildLatestAtStates() throws DataException { + // Rebuild cache of latest AT states that we can't trim + String deleteSql = "DELETE FROM LatestATStates"; + try { + this.repository.executeCheckedUpdate(deleteSql); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to delete temporary latest AT states cache from repository", e); + } + + String insertSql = "INSERT INTO LatestATStates (" + + "SELECT AT_address, height FROM ATs " + + "CROSS JOIN LATERAL(" + + "SELECT height FROM ATStates " + + "WHERE ATStates.AT_address = ATs.AT_address " + + "ORDER BY AT_address DESC, height DESC LIMIT 1" + + ") " + + ")"; + try { + this.repository.executeCheckedUpdate(insertSql); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to populate temporary latest AT states cache in repository", e); + } + } + + @Override public int getAtTrimHeight() throws DataException { String sql = "SELECT AT_trim_height FROM DatabaseInfo"; @@ -632,33 +661,6 @@ public class HSQLDBATRepository implements ATRepository { } } - @Override - public void prepareForAtStateTrimming() throws DataException { - // Rebuild cache of latest AT states that we can't trim - String deleteSql = "DELETE FROM LatestATStates"; - try { - this.repository.executeCheckedUpdate(deleteSql); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to delete temporary latest AT states cache from repository", e); - } - - String insertSql = "INSERT INTO LatestATStates (" - + "SELECT AT_address, height FROM ATs " - + "CROSS JOIN LATERAL(" - + "SELECT height FROM ATStates " - + "WHERE ATStates.AT_address = ATs.AT_address " - + "ORDER BY AT_address DESC, height DESC LIMIT 1" - + ") " - + ")"; - try { - this.repository.executeCheckedUpdate(insertSql); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to populate temporary latest AT states cache in repository", e); - } - } - @Override public int trimAtStates(int minHeight, int maxHeight, int limit) throws DataException { if (minHeight >= maxHeight) @@ -715,12 +717,6 @@ public class HSQLDBATRepository implements ATRepository { } } - @Override - public void prepareForAtStatePruning() throws DataException { - // Use LatestATStates table that was already built by AtStatesTrimmer - // The AtStatesPruner class checks that this process has completed first - } - @Override public int pruneAtStates(int minHeight, int maxHeight) throws DataException { int deletedCount = 0; From 676320586a94996bf0b8dbfd841487917a74c3f5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 25 Aug 2021 18:51:45 +0100 Subject: [PATCH 105/231] Updated tests to use the renamed method. --- src/test/java/org/qortal/test/at/AtRepositoryTests.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/org/qortal/test/at/AtRepositoryTests.java b/src/test/java/org/qortal/test/at/AtRepositoryTests.java index c7dfa423..0b302435 100644 --- a/src/test/java/org/qortal/test/at/AtRepositoryTests.java +++ b/src/test/java/org/qortal/test/at/AtRepositoryTests.java @@ -75,7 +75,7 @@ public class AtRepositoryTests extends Common { Integer testHeight = maxHeight - 2; // Trim AT state data - repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().rebuildLatestAtStates(); repository.getATRepository().trimAtStates(2, maxHeight, 1000); ATStateData atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); @@ -129,7 +129,7 @@ public class AtRepositoryTests extends Common { Integer testHeight = blockchainHeight; // Trim AT state data - repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().rebuildLatestAtStates(); // COMMIT to check latest AT states persist / TEMPORARY table interaction repository.saveChanges(); @@ -280,7 +280,7 @@ public class AtRepositoryTests extends Common { Integer testHeight = maxHeight - 2; // Trim AT state data - repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().rebuildLatestAtStates(); repository.getATRepository().trimAtStates(2, maxHeight, 1000); List atStates = repository.getATRepository().getBlockATStatesAtHeight(testHeight); From 64e8a05a9f3b7577118a2fb1bf7cdd04e3e13f3c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 25 Aug 2021 18:53:25 +0100 Subject: [PATCH 106/231] Prune ATStatesData as well as the ATStates when switching to pruning mode. --- .../hsqldb/HSQLDBDatabasePruning.java | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index 6dc50647..ba170bf6 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -2,6 +2,7 @@ package org.qortal.repository.hsqldb; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.controller.Controller; import org.qortal.data.block.BlockData; import org.qortal.repository.DataException; import org.qortal.repository.RepositoryManager; @@ -146,6 +147,70 @@ public class HSQLDBDatabasePruning { repository.executeCheckedUpdate("CHECKPOINT"); + // Now prune/trim the ATStatesData, as this currently goes back over a month + return HSQLDBDatabasePruning.pruneATStateData(); + } + } + + /* + * Bulk prune ATStatesData to catch up with the now pruned ATStates table + * This uses the existing AT States trimming code but with a much higher end block + */ + private static boolean pruneATStateData() throws SQLException, DataException { + try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { + + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + if (latestBlock == null) { + LOGGER.info("Unable to determine blockchain height, necessary for bulk ATStatesData pruning"); + return false; + } + final int blockchainHeight = latestBlock.getHeight(); + final int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + // ATStateData is already trimmed - so carry on from where we left off in the past + int pruneStartHeight = repository.getATRepository().getAtTrimHeight(); + + LOGGER.info("Starting bulk prune of AT states data - this process could take a while... (approx. 3 mins on high spec)"); + + while (pruneStartHeight < upperPrunableHeight) { + // Prune all AT state data up until our latest minus pruneBlockLimit + + if (Controller.isStopping()) { + return false; + } + + // Override batch size in the settings because this is a one-off process + final int batchSize = 1000; + final int rowLimitPerBatch = 50000; + int upperBatchHeight = pruneStartHeight + batchSize; + int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); + + LOGGER.trace(String.format("Pruning AT states data between %d and %d...", pruneStartHeight, upperPruneHeight)); + + int numATStatesPruned = repository.getATRepository().trimAtStates(pruneStartHeight, upperPruneHeight, rowLimitPerBatch); + repository.saveChanges(); + + if (numATStatesPruned > 0) { + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.trace(() -> String.format("Pruned %d AT states data rows between blocks %d and %d", + numATStatesPruned, finalPruneStartHeight, upperPruneHeight)); + } else { + // Can we move onto next batch? + if (upperPrunableHeight > upperBatchHeight) { + pruneStartHeight = upperBatchHeight; + repository.getATRepository().setAtTrimHeight(pruneStartHeight); + // No need to rebuild the latest AT states as we aren't currently synchronizing + repository.saveChanges(); + + final int finalPruneStartHeight = pruneStartHeight; + LOGGER.debug(() -> String.format("Bumping AT states trim height to %d", finalPruneStartHeight)); + } + else { + // We've finished pruning + break; + } + } + } + return true; } } @@ -169,7 +234,7 @@ public class HSQLDBDatabasePruning { final int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); int pruneStartHeight = 0; - LOGGER.info("Starting bulk prune of blocks - this process could take a while... (approx. 10 mins on high spec)"); + LOGGER.info("Starting bulk prune of blocks - this process could take a while... (approx. 5 mins on high spec)"); while (pruneStartHeight < upperPrunableHeight) { // Prune all blocks up until our latest minus pruneBlockLimit From 89283ed1795fde7f3cd4a767c97173fa54f730ae Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 25 Aug 2021 18:54:10 +0100 Subject: [PATCH 107/231] Increased atStatesPruneBatchSize from 10 to 25. --- src/main/java/org/qortal/settings/Settings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index e1deb641..d0b00729 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -123,7 +123,7 @@ public class Settings { private long atStatesPruneInterval = 3219L; // milliseconds /** Block height range to scan for prunable AT states.
* This has a significant effect on execution time. */ - private int atStatesPruneBatchSize = 10; // blocks + private int atStatesPruneBatchSize = 25; // blocks /** How often to attempt block pruning (ms). */ private long blockPruneInterval = 3219L; // milliseconds From dc030a42bbd94abaf1f7357b583cc31eb03b415c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 25 Aug 2021 18:57:04 +0100 Subject: [PATCH 108/231] Moved trimming and pruning classes into a single package (org.qortal.controller.repository) --- src/main/java/org/qortal/controller/Controller.java | 2 -- .../controller/{pruning => repository}/AtStatesPruner.java | 2 +- .../qortal/controller/{ => repository}/AtStatesTrimmer.java | 4 ++-- .../controller/{pruning => repository}/BlockPruner.java | 2 +- .../{ => repository}/OnlineAccountsSignaturesTrimmer.java | 3 ++- .../controller/{pruning => repository}/PruneManager.java | 4 +--- 6 files changed, 7 insertions(+), 10 deletions(-) rename src/main/java/org/qortal/controller/{pruning => repository}/AtStatesPruner.java (98%) rename src/main/java/org/qortal/controller/{ => repository}/AtStatesTrimmer.java (97%) rename src/main/java/org/qortal/controller/{pruning => repository}/BlockPruner.java (98%) rename src/main/java/org/qortal/controller/{ => repository}/OnlineAccountsSignaturesTrimmer.java (97%) rename src/main/java/org/qortal/controller/{pruning => repository}/PruneManager.java (95%) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 474e4498..9810fade 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -24,7 +24,6 @@ import java.util.Properties; import java.util.Random; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; @@ -97,7 +96,6 @@ import org.qortal.transaction.Transaction.TransactionType; import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.utils.Base58; import org.qortal.utils.ByteArray; -import org.qortal.utils.DaemonThreadFactory; import org.qortal.utils.NTP; import org.qortal.utils.Triple; diff --git a/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java similarity index 98% rename from src/main/java/org/qortal/controller/pruning/AtStatesPruner.java rename to src/main/java/org/qortal/controller/repository/AtStatesPruner.java index 66325e88..30d7f136 100644 --- a/src/main/java/org/qortal/controller/pruning/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java @@ -1,4 +1,4 @@ -package org.qortal.controller.pruning; +package org.qortal.controller.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; diff --git a/src/main/java/org/qortal/controller/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java similarity index 97% rename from src/main/java/org/qortal/controller/AtStatesTrimmer.java rename to src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java index 4b08e5ca..ed02ee47 100644 --- a/src/main/java/org/qortal/controller/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java @@ -1,8 +1,8 @@ -package org.qortal.controller; +package org.qortal.controller.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.qortal.controller.pruning.PruneManager; +import org.qortal.controller.Controller; import org.qortal.data.block.BlockData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; diff --git a/src/main/java/org/qortal/controller/pruning/BlockPruner.java b/src/main/java/org/qortal/controller/repository/BlockPruner.java similarity index 98% rename from src/main/java/org/qortal/controller/pruning/BlockPruner.java rename to src/main/java/org/qortal/controller/repository/BlockPruner.java index 8ae25224..6d3180a8 100644 --- a/src/main/java/org/qortal/controller/pruning/BlockPruner.java +++ b/src/main/java/org/qortal/controller/repository/BlockPruner.java @@ -1,4 +1,4 @@ -package org.qortal.controller.pruning; +package org.qortal.controller.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; diff --git a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java b/src/main/java/org/qortal/controller/repository/OnlineAccountsSignaturesTrimmer.java similarity index 97% rename from src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java rename to src/main/java/org/qortal/controller/repository/OnlineAccountsSignaturesTrimmer.java index b32a2b06..c7f248d5 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java +++ b/src/main/java/org/qortal/controller/repository/OnlineAccountsSignaturesTrimmer.java @@ -1,8 +1,9 @@ -package org.qortal.controller; +package org.qortal.controller.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.block.BlockChain; +import org.qortal.controller.Controller; import org.qortal.data.block.BlockData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; diff --git a/src/main/java/org/qortal/controller/pruning/PruneManager.java b/src/main/java/org/qortal/controller/repository/PruneManager.java similarity index 95% rename from src/main/java/org/qortal/controller/pruning/PruneManager.java rename to src/main/java/org/qortal/controller/repository/PruneManager.java index b733833b..5f92c75d 100644 --- a/src/main/java/org/qortal/controller/pruning/PruneManager.java +++ b/src/main/java/org/qortal/controller/repository/PruneManager.java @@ -1,9 +1,7 @@ -package org.qortal.controller.pruning; +package org.qortal.controller.repository; -import org.qortal.controller.AtStatesTrimmer; import org.qortal.controller.Controller; -import org.qortal.controller.OnlineAccountsSignaturesTrimmer; import org.qortal.data.block.BlockData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; From 87595fd70464819af696a4942432bdc3b6623ba9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 28 Aug 2021 11:00:49 +0100 Subject: [PATCH 109/231] Synchronized LatestATStates, to make rebuildLatestAtStates() thread safe. --- .../repository/hsqldb/HSQLDBATRepository.java | 160 ++++++++++-------- .../repository/hsqldb/HSQLDBRepository.java | 1 + 2 files changed, 90 insertions(+), 71 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 1921661c..522fafb7 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -604,28 +604,34 @@ public class HSQLDBATRepository implements ATRepository { @Override public void rebuildLatestAtStates() throws DataException { - // Rebuild cache of latest AT states that we can't trim - String deleteSql = "DELETE FROM LatestATStates"; - try { - this.repository.executeCheckedUpdate(deleteSql); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to delete temporary latest AT states cache from repository", e); - } + // latestATStatesLock is to prevent concurrent updates on LatestATStates + // that could result in one process using a partial or empty dataset + // because it was in the process of being rebuilt by another thread + synchronized (this.repository.latestATStatesLock) { - String insertSql = "INSERT INTO LatestATStates (" - + "SELECT AT_address, height FROM ATs " - + "CROSS JOIN LATERAL(" - + "SELECT height FROM ATStates " - + "WHERE ATStates.AT_address = ATs.AT_address " - + "ORDER BY AT_address DESC, height DESC LIMIT 1" - + ") " - + ")"; - try { - this.repository.executeCheckedUpdate(insertSql); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to populate temporary latest AT states cache in repository", e); + // Rebuild cache of latest AT states that we can't trim + String deleteSql = "DELETE FROM LatestATStates"; + try { + this.repository.executeCheckedUpdate(deleteSql); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to delete temporary latest AT states cache from repository", e); + } + + String insertSql = "INSERT INTO LatestATStates (" + + "SELECT AT_address, height FROM ATs " + + "CROSS JOIN LATERAL(" + + "SELECT height FROM ATStates " + + "WHERE ATStates.AT_address = ATs.AT_address " + + "ORDER BY AT_address DESC, height DESC LIMIT 1" + + ") " + + ")"; + try { + this.repository.executeCheckedUpdate(insertSql); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to populate temporary latest AT states cache in repository", e); + } } } @@ -666,22 +672,28 @@ public class HSQLDBATRepository implements ATRepository { if (minHeight >= maxHeight) return 0; - // We're often called so no need to trim all states in one go. - // Limit updates to reduce CPU and memory load. - String sql = "DELETE FROM ATStatesData " - + "WHERE height BETWEEN ? AND ? " - + "AND NOT EXISTS(" + // latestATStatesLock is to prevent concurrent updates on LatestATStates + // that could result in one process using a partial or empty dataset + // because it was in the process of being rebuilt by another thread + synchronized (this.repository.latestATStatesLock) { + + // We're often called so no need to trim all states in one go. + // Limit updates to reduce CPU and memory load. + String sql = "DELETE FROM ATStatesData " + + "WHERE height BETWEEN ? AND ? " + + "AND NOT EXISTS(" + "SELECT TRUE FROM LatestATStates " + "WHERE LatestATStates.AT_address = ATStatesData.AT_address " + "AND LatestATStates.height = ATStatesData.height" - + ") " - + "LIMIT ?"; + + ") " + + "LIMIT ?"; - try { - return this.repository.executeCheckedUpdate(sql, minHeight, maxHeight, limit); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to trim AT states in repository", e); + try { + return this.repository.executeCheckedUpdate(sql, minHeight, maxHeight, limit); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to trim AT states in repository", e); + } } } @@ -719,57 +731,63 @@ public class HSQLDBATRepository implements ATRepository { @Override public int pruneAtStates(int minHeight, int maxHeight) throws DataException { - int deletedCount = 0; + // latestATStatesLock is to prevent concurrent updates on LatestATStates + // that could result in one process using a partial or empty dataset + // because it was in the process of being rebuilt by another thread + synchronized (this.repository.latestATStatesLock) { - for (int height=minHeight; height atAddresses = new ArrayList<>(); - String updateSql = "SELECT AT_address FROM LatestATStates WHERE height = ?"; - try (ResultSet resultSet = this.repository.checkedExecute(updateSql, height)) { - if (resultSet != null) { - do { - String atAddress = resultSet.getString(1); - atAddresses.add(atAddress); - - } while (resultSet.next()); - } - } catch (SQLException e) { - throw new DataException("Unable to fetch flagged accounts from repository", e); - } - - List atStates = this.getBlockATStatesAtHeight(height); - for (ATStateData atState : atStates) { - //LOGGER.info("Found atState {} at height {}", atState.getATAddress(), atState.getHeight()); + for (int height = minHeight; height < maxHeight; height++) { // Give up if we're stopping if (Controller.isStopping()) { return deletedCount; } - if (atAddresses.contains(atState.getATAddress())) { - // We don't want to delete this AT state because it is still active - LOGGER.info("Skipping atState {} at height {}", atState.getATAddress(), atState.getHeight()); - continue; + // Get latest AT states for this height + List atAddresses = new ArrayList<>(); + String updateSql = "SELECT AT_address FROM LatestATStates WHERE height = ?"; + try (ResultSet resultSet = this.repository.checkedExecute(updateSql, height)) { + if (resultSet != null) { + do { + String atAddress = resultSet.getString(1); + atAddresses.add(atAddress); + + } while (resultSet.next()); + } + } catch (SQLException e) { + throw new DataException("Unable to fetch flagged accounts from repository", e); } - // Safe to delete everything else for this height - try { - this.repository.delete("ATStates", "AT_address = ? AND height = ?", - atState.getATAddress(), atState.getHeight()); - deletedCount++; - } catch (SQLException e) { - throw new DataException("Unable to delete AT state data from repository", e); + List atStates = this.getBlockATStatesAtHeight(height); + for (ATStateData atState : atStates) { + //LOGGER.info("Found atState {} at height {}", atState.getATAddress(), atState.getHeight()); + + // Give up if we're stopping + if (Controller.isStopping()) { + return deletedCount; + } + + if (atAddresses.contains(atState.getATAddress())) { + // We don't want to delete this AT state because it is still active + LOGGER.info("Skipping atState {} at height {}", atState.getATAddress(), atState.getHeight()); + continue; + } + + // Safe to delete everything else for this height + try { + this.repository.delete("ATStates", "AT_address = ? AND height = ?", + atState.getATAddress(), atState.getHeight()); + deletedCount++; + } catch (SQLException e) { + throw new DataException("Unable to delete AT state data from repository", e); + } } } - } - return deletedCount; + return deletedCount; + } } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 4d8e5043..3a947cd6 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -69,6 +69,7 @@ public class HSQLDBRepository implements Repository { protected final Map preparedStatementCache = new HashMap<>(); // We want the same object corresponding to the actual DB protected final Object trimHeightsLock = RepositoryManager.getRepositoryFactory(); + protected final Object latestATStatesLock = RepositoryManager.getRepositoryFactory(); private final ATRepository atRepository = new HSQLDBATRepository(this); private final AccountRepository accountRepository = new HSQLDBAccountRepository(this); From 70c6048cc123e722c78e9bb790767772a01e1c06 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 4 Sep 2021 19:40:51 +0100 Subject: [PATCH 110/231] Added block archive mode This takes all trimmed blocks (which should now be all but the last 1450 or so) and moves them into flat files. Each file contains the serialized bytes of as many blocks that can fit within the file size target of 100MiB. As a result, the HSQLDB size drops to less than 1GB, making it much faster and easier to maintain. It also significantly reduces the total size of each full node, because the data is stored in a highly optimized way. HSQLDB then works similarly to the way it does in pruning mode - it holds all transactions, the latest state of every AT, as well as the full AT states data and hashes for the past 1450 blocks. Each archive file contains headers and indexes in order to quickly locate blocks. When a peer requests a block that is within the archive, the serialized bytes are sent directly without the need to go via a BlockData object. Now that there are no slow queries or data serialization processes needed, it should greatly speed up the block serving. The /block API endpoints have been modified in such a way that they will also check and retrieve blocks from the archive when needed. A lightweight "BlockArchive" table is needed in HSQLDB to map block heights to signatures minters and timestamps. It made more sense to keep SQL support for these basic attributes of each block. These are located in a separate table from the full blocks, in order to create a clear distinction between HSQLDB blocks and archived blocks, and also to speed up query times in the Blocks table, which is the one we are using 99% of the time. There is currently a restriction on the /admin/orphan API endpoint to prevent orphaning beyond the threshold of the block archive. --- .../qortal/api/resource/AdminResource.java | 20 ++ .../qortal/api/resource/BlocksResource.java | 268 ++++++++++++++--- .../org/qortal/controller/Controller.java | 39 ++- .../controller/repository/AtStatesPruner.java | 22 +- .../repository/AtStatesTrimmer.java | 2 +- .../controller/repository/BlockArchiver.java | 105 +++++++ .../controller/repository/BlockPruner.java | 27 +- .../controller/repository/PruneManager.java | 77 +++-- .../qortal/data/block/BlockArchiveData.java | 47 +++ .../network/message/CachedBlockMessage.java | 2 +- .../org/qortal/repository/ATRepository.java | 5 +- .../qortal/repository/BlockArchiveReader.java | 251 ++++++++++++++++ .../repository/BlockArchiveRepository.java | 118 ++++++++ .../qortal/repository/BlockArchiveWriter.java | 193 ++++++++++++ .../qortal/repository/BlockRepository.java | 5 - .../org/qortal/repository/Repository.java | 2 + .../qortal/repository/RepositoryManager.java | 21 +- .../repository/hsqldb/HSQLDBATRepository.java | 13 +- .../hsqldb/HSQLDBBlockArchiveRepository.java | 277 ++++++++++++++++++ .../hsqldb/HSQLDBBlockRepository.java | 81 +---- .../hsqldb/HSQLDBDatabaseArchiving.java | 87 ++++++ .../hsqldb/HSQLDBDatabasePruning.java | 51 +++- .../hsqldb/HSQLDBDatabaseUpdates.java | 19 ++ .../repository/hsqldb/HSQLDBRepository.java | 23 +- .../java/org/qortal/settings/Settings.java | 22 ++ 25 files changed, 1592 insertions(+), 185 deletions(-) create mode 100644 src/main/java/org/qortal/controller/repository/BlockArchiver.java create mode 100644 src/main/java/org/qortal/data/block/BlockArchiveData.java create mode 100644 src/main/java/org/qortal/repository/BlockArchiveReader.java create mode 100644 src/main/java/org/qortal/repository/BlockArchiveRepository.java create mode 100644 src/main/java/org/qortal/repository/BlockArchiveWriter.java create mode 100644 src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java create mode 100644 src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 88dd0065..3e666fe4 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -35,6 +35,7 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.core.LoggerContext; import org.apache.logging.log4j.core.appender.RollingFileAppender; import org.qortal.account.Account; @@ -67,6 +68,8 @@ import com.google.common.collect.Lists; @Tag(name = "Admin") public class AdminResource { + private static final Logger LOGGER = LogManager.getLogger(AdminResource.class); + private static final int MAX_LOG_LINES = 500; @Context @@ -459,6 +462,23 @@ public class AdminResource { if (targetHeight <= 0 || targetHeight > Controller.getInstance().getChainHeight()) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_HEIGHT); + // Make sure we're not orphaning as far back as the archived blocks + // FUTURE: we could support this by first importing earlier blocks from the archive + if (Settings.getInstance().isPruningEnabled() || + Settings.getInstance().isArchiveEnabled()) { + + try (final Repository repository = RepositoryManager.getRepository()) { + // Find the first unarchived block + int oldestBlock = repository.getBlockArchiveRepository().getBlockArchiveHeight(); + // Add some extra blocks just in case we're currently archiving/pruning + oldestBlock += 100; + if (targetHeight <= oldestBlock) { + LOGGER.info("Unable to orphan beyond block {} because it is archived", oldestBlock); + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_HEIGHT); + } + } + } + if (BlockChain.orphan(targetHeight)) return "true"; else diff --git a/src/main/java/org/qortal/api/resource/BlocksResource.java b/src/main/java/org/qortal/api/resource/BlocksResource.java index 8920ecc1..6dc13c8a 100644 --- a/src/main/java/org/qortal/api/resource/BlocksResource.java +++ b/src/main/java/org/qortal/api/resource/BlocksResource.java @@ -15,6 +15,8 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.math.RoundingMode; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; import java.util.List; import javax.servlet.http.HttpServletRequest; @@ -33,11 +35,13 @@ import org.qortal.api.ApiExceptionFactory; import org.qortal.api.model.BlockMintingInfo; import org.qortal.api.model.BlockSignerSummary; import org.qortal.block.Block; +import org.qortal.controller.Controller; import org.qortal.crypto.Crypto; import org.qortal.data.account.AccountData; import org.qortal.data.block.BlockData; import org.qortal.data.block.BlockSummaryData; import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.BlockArchiveReader; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; @@ -81,11 +85,19 @@ public class BlocksResource { } try (final Repository repository = RepositoryManager.getRepository()) { + // Check the database first BlockData blockData = repository.getBlockRepository().fromSignature(signature); - if (blockData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + if (blockData != null) { + return blockData; + } - return blockData; + // Not found, so try the block archive + blockData = repository.getBlockArchiveRepository().fromSignature(signature); + if (blockData != null) { + return blockData; + } + + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -116,16 +128,24 @@ public class BlocksResource { } try (final Repository repository = RepositoryManager.getRepository()) { + + // Check the database first BlockData blockData = repository.getBlockRepository().fromSignature(signature); - if (blockData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + if (blockData != null) { + Block block = new Block(repository, blockData); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + bytes.write(Ints.toByteArray(block.getBlockData().getHeight())); + bytes.write(BlockTransformer.toBytes(block)); + return Base58.encode(bytes.toByteArray()); + } - Block block = new Block(repository, blockData); - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - bytes.write(Ints.toByteArray(block.getBlockData().getHeight())); - bytes.write(BlockTransformer.toBytes(block)); - return Base58.encode(bytes.toByteArray()); + // Not found, so try the block archive + byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, repository); + if (bytes != null) { + return Base58.encode(bytes); + } + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); } catch (TransformationException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA, e); } catch (DataException | IOException e) { @@ -170,8 +190,12 @@ public class BlocksResource { } try (final Repository repository = RepositoryManager.getRepository()) { - if (repository.getBlockRepository().getHeightFromSignature(signature) == 0) + // Check if the block exists in either the database or archive + if (repository.getBlockRepository().getHeightFromSignature(signature) == 0 && + repository.getBlockArchiveRepository().getHeightFromSignature(signature) == 0) { + // Not found in either the database or archive throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + } return repository.getBlockRepository().getTransactionsFromSignature(signature, limit, offset, reverse); } catch (DataException e) { @@ -200,7 +224,19 @@ public class BlocksResource { }) public BlockData getFirstBlock() { try (final Repository repository = RepositoryManager.getRepository()) { - return repository.getBlockRepository().fromHeight(1); + // Check the database first + BlockData blockData = repository.getBlockRepository().fromHeight(1); + if (blockData != null) { + return blockData; + } + + // Try the archive + blockData = repository.getBlockArchiveRepository().fromHeight(1); + if (blockData != null) { + return blockData; + } + + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -262,17 +298,28 @@ public class BlocksResource { } try (final Repository repository = RepositoryManager.getRepository()) { + BlockData childBlockData = null; + + // Check if block exists in database BlockData blockData = repository.getBlockRepository().fromSignature(signature); + if (blockData != null) { + return repository.getBlockRepository().fromReference(signature); + } - // Check block exists - if (blockData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); - - BlockData childBlockData = repository.getBlockRepository().fromReference(signature); + // Not found, so try the archive + // This also checks that the parent block exists + // It will return null if either the parent or child don't exit + childBlockData = repository.getBlockArchiveRepository().fromReference(signature); // Check child block exists - if (childBlockData == null) + if (childBlockData == null) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + } + + // Check child block's reference matches the supplied signature + if (!Arrays.equals(childBlockData.getReference(), signature)) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + } return childBlockData; } catch (DataException e) { @@ -338,13 +385,20 @@ public class BlocksResource { } try (final Repository repository = RepositoryManager.getRepository()) { + // Firstly check the database BlockData blockData = repository.getBlockRepository().fromSignature(signature); + if (blockData != null) { + return blockData.getHeight(); + } - // Check block exists - if (blockData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + // Not found, so try the archive + blockData = repository.getBlockArchiveRepository().fromSignature(signature); + if (blockData != null) { + return blockData.getHeight(); + } + + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); - return blockData.getHeight(); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -371,11 +425,20 @@ public class BlocksResource { }) public BlockData getByHeight(@PathParam("height") int height) { try (final Repository repository = RepositoryManager.getRepository()) { + // Firstly check the database BlockData blockData = repository.getBlockRepository().fromHeight(height); - if (blockData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + if (blockData != null) { + return blockData; + } + + // Not found, so try the archive + blockData = repository.getBlockArchiveRepository().fromHeight(height); + if (blockData != null) { + return blockData; + } + + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); - return blockData; } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -402,12 +465,31 @@ public class BlocksResource { }) public BlockMintingInfo getBlockMintingInfoByHeight(@PathParam("height") int height) { try (final Repository repository = RepositoryManager.getRepository()) { + // Try the database BlockData blockData = repository.getBlockRepository().fromHeight(height); - if (blockData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + if (blockData == null) { + + // Not found, so try the archive + blockData = repository.getBlockArchiveRepository().fromHeight(height); + if (blockData == null) { + + // Still not found + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + } + } Block block = new Block(repository, blockData); BlockData parentBlockData = repository.getBlockRepository().fromSignature(blockData.getReference()); + if (parentBlockData == null) { + // Parent block not found - try the archive + parentBlockData = repository.getBlockArchiveRepository().fromSignature(blockData.getReference()); + if (parentBlockData == null) { + + // Still not found + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + } + } + int minterLevel = Account.getRewardShareEffectiveMintingLevel(repository, blockData.getMinterPublicKey()); if (minterLevel == 0) // This may be unavailable when requesting a trimmed block @@ -454,13 +536,26 @@ public class BlocksResource { }) public BlockData getByTimestamp(@PathParam("timestamp") long timestamp) { try (final Repository repository = RepositoryManager.getRepository()) { - int height = repository.getBlockRepository().getHeightFromTimestamp(timestamp); - if (height == 0) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + BlockData blockData = null; - BlockData blockData = repository.getBlockRepository().fromHeight(height); - if (blockData == null) + // Try the Blocks table + int height = repository.getBlockRepository().getHeightFromTimestamp(timestamp); + if (height > 0) { + // Found match in Blocks table + return repository.getBlockRepository().fromHeight(height); + } + + // Not found in Blocks table, so try the archive + height = repository.getBlockArchiveRepository().getHeightFromTimestamp(timestamp); + if (height > 0) { + // Found match in archive + blockData = repository.getBlockArchiveRepository().fromHeight(height); + } + + // Ensure block exists + if (blockData == null) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCK_UNKNOWN); + } return blockData; } catch (DataException e) { @@ -497,9 +592,14 @@ public class BlocksResource { for (/* count already set */; count > 0; --count, ++height) { BlockData blockData = repository.getBlockRepository().fromHeight(height); - if (blockData == null) - // Run out of blocks! - break; + if (blockData == null) { + // Not found - try the archive + blockData = repository.getBlockArchiveRepository().fromHeight(height); + if (blockData == null) { + // Run out of blocks! + break; + } + } blocks.add(blockData); } @@ -544,7 +644,29 @@ public class BlocksResource { if (accountData == null || accountData.getPublicKey() == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.PUBLIC_KEY_NOT_FOUND); - return repository.getBlockRepository().getBlockSummariesBySigner(accountData.getPublicKey(), limit, offset, reverse); + + List summaries = repository.getBlockRepository() + .getBlockSummariesBySigner(accountData.getPublicKey(), limit, offset, reverse); + + // Add any from the archive + List archivedSummaries = repository.getBlockArchiveRepository() + .getBlockSummariesBySigner(accountData.getPublicKey(), limit, offset, reverse); + if (archivedSummaries != null && !archivedSummaries.isEmpty()) { + summaries.addAll(archivedSummaries); + } + else { + summaries = archivedSummaries; + } + + // Sort the results (because they may have been obtained from two places) + if (reverse != null && reverse) { + summaries.sort((s1, s2) -> Integer.valueOf(s2.getHeight()).compareTo(Integer.valueOf(s1.getHeight()))); + } + else { + summaries.sort(Comparator.comparing(s -> Integer.valueOf(s.getHeight()))); + } + + return summaries; } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -580,7 +702,8 @@ public class BlocksResource { if (!Crypto.isValidAddress(address)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - return repository.getBlockRepository().getBlockSigners(addresses, limit, offset, reverse); + // This method pulls data from both Blocks and BlockArchive, so no need to query serparately + return repository.getBlockArchiveRepository().getBlockSigners(addresses, limit, offset, reverse); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -620,7 +743,76 @@ public class BlocksResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); try (final Repository repository = RepositoryManager.getRepository()) { - return repository.getBlockRepository().getBlockSummaries(startHeight, endHeight, count); + + /* + * start end count result + * 10 40 null blocks 10 to 39 (excludes end block, ignore count) + * + * null null null blocks 1 to 50 (assume count=50, maybe start=1) + * 30 null null blocks 30 to 79 (assume count=50) + * 30 null 10 blocks 30 to 39 + * + * null null 50 last 50 blocks? so if max(blocks.height) is 200, then blocks 151 to 200 + * null 200 null blocks 150 to 199 (excludes end block, assume count=50) + * null 200 10 blocks 190 to 199 (excludes end block) + */ + + List blockSummaries = new ArrayList<>(); + + // Use the latest X blocks if only a count is specified + if (startHeight == null && endHeight == null && count != null) { + BlockData chainTip = Controller.getInstance().getChainTip(); + startHeight = chainTip.getHeight() - count; + endHeight = chainTip.getHeight(); + } + + // ... otherwise default the start height to 1 + if (startHeight == null && endHeight == null) { + startHeight = 1; + } + + // Default the count to 50 + if (count == null) { + count = 50; + } + + // If both a start and end height exist, ignore the count + if (startHeight != null && endHeight != null) { + if (startHeight > 0 && endHeight > 0) { + count = Integer.MAX_VALUE; + } + } + + // Derive start height from end height if missing + if (startHeight == null || startHeight == 0) { + if (endHeight != null && endHeight > 0) { + if (count != null) { + startHeight = endHeight - count; + } + } + } + + for (/* count already set */; count > 0; --count, ++startHeight) { + if (endHeight != null && startHeight >= endHeight) { + break; + } + BlockData blockData = repository.getBlockRepository().fromHeight(startHeight); + if (blockData == null) { + // Not found - try the archive + blockData = repository.getBlockArchiveRepository().fromHeight(startHeight); + if (blockData == null) { + // Run out of blocks! + break; + } + } + + if (blockData != null) { + BlockSummaryData blockSummaryData = new BlockSummaryData(blockData); + blockSummaries.add(blockSummaryData); + } + } + + return blockSummaries; } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 9810fade..bce17b08 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -84,20 +84,14 @@ import org.qortal.network.message.OnlineAccountsMessage; import org.qortal.network.message.SignaturesMessage; import org.qortal.network.message.TransactionMessage; import org.qortal.network.message.TransactionSignaturesMessage; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryFactory; -import org.qortal.repository.RepositoryManager; +import org.qortal.repository.*; import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory; import org.qortal.settings.Settings; import org.qortal.transaction.ArbitraryTransaction; import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.TransactionType; import org.qortal.transaction.Transaction.ValidationResult; -import org.qortal.utils.Base58; -import org.qortal.utils.ByteArray; -import org.qortal.utils.NTP; -import org.qortal.utils.Triple; +import org.qortal.utils.*; import com.google.common.primitives.Longs; @@ -415,6 +409,7 @@ public class Controller extends Thread { try { RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(getRepositoryUrl()); RepositoryManager.setRepositoryFactory(repositoryFactory); + RepositoryManager.archive(); RepositoryManager.prune(); } catch (DataException e) { // If exception has no cause then repository is in use by some other process. @@ -1292,6 +1287,34 @@ public class Controller extends Thread { } } + // If we have no block data, we should check the archive in case it's there + if (blockData == null) { + if (Settings.getInstance().isArchiveEnabled()) { + byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, repository); + if (bytes != null) { + CachedBlockMessage blockMessage = new CachedBlockMessage(bytes); + blockMessage.setId(message.getId()); + + // This call also causes the other needed data to be pulled in from repository + if (!peer.sendMessage(blockMessage)) { + peer.disconnect("failed to send block"); + // Don't fall-through to caching because failure to send might be from failure to build message + return; + } + + // If request is for a recent block, cache it + if (getChainHeight() - blockData.getHeight() <= blockCacheSize) { + this.stats.getBlockMessageStats.cacheFills.incrementAndGet(); + + this.blockMessageCache.put(new ByteArray(blockData.getSignature()), blockMessage); + } + + // Sent successfully from archive, so nothing more to do + return; + } + } + } + if (blockData == null) { // We don't have this block this.stats.getBlockMessageStats.unknownBlocks.getAndIncrement(); diff --git a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java index 30d7f136..1493f478 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java @@ -18,15 +18,24 @@ public class AtStatesPruner implements Runnable { public void run() { Thread.currentThread().setName("AT States pruner"); + boolean archiveMode = false; if (!Settings.getInstance().isPruningEnabled()) { - return; + // Pruning isn't enabled, but we might want to prune for the purposes of archiving + if (!Settings.getInstance().isArchiveEnabled()) { + // No pruning or archiving, so we must not prune anything + return; + } + else { + // We're allowed to prune blocks that have already been archived + archiveMode = true; + } } try (final Repository repository = RepositoryManager.getRepository()) { int pruneStartHeight = repository.getATRepository().getAtPruneHeight(); + repository.discardChanges(); repository.getATRepository().rebuildLatestAtStates(); - repository.saveChanges(); while (!Controller.isStopping()) { repository.discardChanges(); @@ -43,7 +52,14 @@ public class AtStatesPruner implements Runnable { // Prune AT states for all blocks up until our latest minus pruneBlockLimit final int ourLatestHeight = chainTip.getHeight(); - final int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); + int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); + + // In archive mode we are only allowed to trim blocks that have already been archived + if (archiveMode) { + upperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1; + + // TODO: validate that the actual archived data exists before pruning it? + } int upperBatchHeight = pruneStartHeight + Settings.getInstance().getAtStatesPruneBatchSize(); int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); diff --git a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java index ed02ee47..98a1a889 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java @@ -21,8 +21,8 @@ public class AtStatesTrimmer implements Runnable { try (final Repository repository = RepositoryManager.getRepository()) { int trimStartHeight = repository.getATRepository().getAtTrimHeight(); + repository.discardChanges(); repository.getATRepository().rebuildLatestAtStates(); - repository.saveChanges(); while (!Controller.isStopping()) { repository.discardChanges(); diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiver.java b/src/main/java/org/qortal/controller/repository/BlockArchiver.java new file mode 100644 index 00000000..f7bafe7d --- /dev/null +++ b/src/main/java/org/qortal/controller/repository/BlockArchiver.java @@ -0,0 +1,105 @@ +package org.qortal.controller.repository; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.controller.Controller; +import org.qortal.data.block.BlockData; +import org.qortal.repository.*; +import org.qortal.settings.Settings; +import org.qortal.transform.TransformationException; +import org.qortal.utils.NTP; + +import java.io.IOException; + +public class BlockArchiver implements Runnable { + + private static final Logger LOGGER = LogManager.getLogger(BlockArchiver.class); + + private static final long INITIAL_SLEEP_PERIOD = 0L; // TODO: 5 * 60 * 1000L + 1234L; // ms + + public void run() { + Thread.currentThread().setName("Block archiver"); + + if (!Settings.getInstance().isArchiveEnabled()) { + return; + } + + try (final Repository repository = RepositoryManager.getRepository()) { + int startHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); + + // Don't even start building until initial rush has ended + Thread.sleep(INITIAL_SLEEP_PERIOD); + + LOGGER.info("Starting block archiver..."); + + while (!Controller.isStopping()) { + repository.discardChanges(); + + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository, true); + + Thread.sleep(Settings.getInstance().getArchiveInterval()); + + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null || NTP.getTime() == null) { + continue; + } + + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Controller.getInstance().isSynchronizing()) { + continue; + } + + // Don't attempt to archive if we're not synced yet + final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); + if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) { + continue; + } + + + // Build cache of blocks + try { + BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, repository); + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + switch (result) { + case OK: + // Increment block archive height + startHeight += writer.getWrittenCount(); + repository.getBlockArchiveRepository().setBlockArchiveHeight(startHeight); + repository.saveChanges(); + break; + + case STOPPING: + return; + + case BLOCK_LIMIT_REACHED: + // We've reached the limit of the blocks we can archive + // Sleep for a while to allow more to become available + case NOT_ENOUGH_BLOCKS: + // We didn't reach our file size target, so that must mean that we don't have enough blocks + // yet or something went wrong. Sleep for a while and then try again. + Thread.sleep(60 * 60 * 1000L); // 1 hour + break; + + case BLOCK_NOT_FOUND: + // We tried to archive a block that didn't exist. This is a major failure and likely means + // that a bootstrap or re-sync is needed. Try again every minute until then. + LOGGER.info("Error: block not found when building archive. If this error persists, " + + "a bootstrap or re-sync may be needed."); + Thread.sleep( 60 * 1000L); // 1 minute + break; + } + + } catch (IOException | TransformationException e) { + LOGGER.info("Caught exception when creating block cache", e); + } + + } + } catch (DataException e) { + LOGGER.info("Caught exception when creating block cache", e); + } catch (InterruptedException e) { + // Do nothing + } + + } + +} diff --git a/src/main/java/org/qortal/controller/repository/BlockPruner.java b/src/main/java/org/qortal/controller/repository/BlockPruner.java index 6d3180a8..f8fd2195 100644 --- a/src/main/java/org/qortal/controller/repository/BlockPruner.java +++ b/src/main/java/org/qortal/controller/repository/BlockPruner.java @@ -18,8 +18,17 @@ public class BlockPruner implements Runnable { public void run() { Thread.currentThread().setName("Block pruner"); + boolean archiveMode = false; if (!Settings.getInstance().isPruningEnabled()) { - return; + // Pruning isn't enabled, but we might want to prune for the purposes of archiving + if (!Settings.getInstance().isArchiveEnabled()) { + // No pruning or archiving, so we must not prune anything + return; + } + else { + // We're allowed to prune blocks that have already been archived + archiveMode = true; + } } try (final Repository repository = RepositoryManager.getRepository()) { @@ -35,12 +44,24 @@ public class BlockPruner implements Runnable { continue; // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages - if (Controller.getInstance().isSynchronizing()) + if (Controller.getInstance().isSynchronizing()) { continue; + } + + // Don't attempt to prune if we're not synced yet + final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); + if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) { + continue; + } // Prune all blocks up until our latest minus pruneBlockLimit final int ourLatestHeight = chainTip.getHeight(); - final int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); + int upperPrunableHeight = ourLatestHeight - Settings.getInstance().getPruneBlockLimit(); + + // In archive mode we are only allowed to trim blocks that have already been archived + if (archiveMode) { + upperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1; + } int upperBatchHeight = pruneStartHeight + Settings.getInstance().getBlockPruneBatchSize(); int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); diff --git a/src/main/java/org/qortal/controller/repository/PruneManager.java b/src/main/java/org/qortal/controller/repository/PruneManager.java index 5f92c75d..dcb21181 100644 --- a/src/main/java/org/qortal/controller/repository/PruneManager.java +++ b/src/main/java/org/qortal/controller/repository/PruneManager.java @@ -35,29 +35,70 @@ public class PruneManager { public void start() { this.executorService = Executors.newCachedThreadPool(new DaemonThreadFactory()); - // Don't allow both the pruner and the trimmer to run at the same time. - // In pruning mode, we are already deleting far more than we would when trimming. - // In non-pruning mode, we still need to trim to keep the non-essential data - // out of the database. There isn't a case where both are needed at once. - // If we ever do need to enable both at once, be very careful with the AT state - // trimming, since both currently rely on having exclusive access to the - // prepareForAtStateTrimming() method. For both trimming and pruning to take place - // at once, we would need to synchronize this method in a way that both can't - // call it at the same time, as otherwise active ATs would be pruned/trimmed when - // they should have been kept. - - if (Settings.getInstance().isPruningEnabled()) { - // Pruning enabled - start the pruning processes - this.executorService.execute(new AtStatesPruner()); - this.executorService.execute(new BlockPruner()); + if (Settings.getInstance().isPruningEnabled() && + !Settings.getInstance().isArchiveEnabled()) { + // Top-only-sync + this.startTopOnlySyncMode(); + } + else if (Settings.getInstance().isArchiveEnabled()) { + // Full node with block archive + this.startFullNodeWithBlockArchive(); } else { - // Pruning disabled - use trimming instead - this.executorService.execute(new AtStatesTrimmer()); - this.executorService.execute(new OnlineAccountsSignaturesTrimmer()); + // Full node with full SQL support + this.startFullSQLNode(); } } + /** + * Top-only-sync + * In this mode, we delete (prune) all blocks except + * a small number of recent ones. There is no need for + * trimming or archiving, because all relevant blocks + * are deleted. + */ + private void startTopOnlySyncMode() { + this.startPruning(); + } + + /** + * Full node with block archive + * In this mode we archive trimmed blocks, and then + * prune archived blocks to keep the database small + */ + private void startFullNodeWithBlockArchive() { + this.startTrimming(); + this.startArchiving(); + this.startPruning(); + } + + /** + * Full node with full SQL support + * In this mode we trim the database but don't prune + * or archive any data, because we want to maintain + * full SQL support of old blocks. This mode will not + * be actively maintained but can be used by those who + * need to perform SQL analysis on older blocks. + */ + private void startFullSQLNode() { + this.startTrimming(); + } + + + private void startPruning() { + this.executorService.execute(new AtStatesPruner()); + this.executorService.execute(new BlockPruner()); + } + + private void startTrimming() { + this.executorService.execute(new AtStatesTrimmer()); + this.executorService.execute(new OnlineAccountsSignaturesTrimmer()); + } + + private void startArchiving() { + this.executorService.execute(new BlockArchiver()); + } + public void stop() { this.executorService.shutdownNow(); diff --git a/src/main/java/org/qortal/data/block/BlockArchiveData.java b/src/main/java/org/qortal/data/block/BlockArchiveData.java new file mode 100644 index 00000000..c9db4032 --- /dev/null +++ b/src/main/java/org/qortal/data/block/BlockArchiveData.java @@ -0,0 +1,47 @@ +package org.qortal.data.block; + +import org.qortal.block.Block; + +public class BlockArchiveData { + + // Properties + private byte[] signature; + private Integer height; + private Long timestamp; + private byte[] minterPublicKey; + + // Constructors + + public BlockArchiveData(byte[] signature, Integer height, long timestamp, byte[] minterPublicKey) { + this.signature = signature; + this.height = height; + this.timestamp = timestamp; + this.minterPublicKey = minterPublicKey; + } + + public BlockArchiveData(BlockData blockData) { + this.signature = blockData.getSignature(); + this.height = blockData.getHeight(); + this.timestamp = blockData.getTimestamp(); + this.minterPublicKey = blockData.getMinterPublicKey(); + } + + // Getters/setters + + public byte[] getSignature() { + return this.signature; + } + + public Integer getHeight() { + return this.height; + } + + public Long getTimestamp() { + return this.timestamp; + } + + public byte[] getMinterPublicKey() { + return this.minterPublicKey; + } + +} diff --git a/src/main/java/org/qortal/network/message/CachedBlockMessage.java b/src/main/java/org/qortal/network/message/CachedBlockMessage.java index 7a175810..e5029ab0 100644 --- a/src/main/java/org/qortal/network/message/CachedBlockMessage.java +++ b/src/main/java/org/qortal/network/message/CachedBlockMessage.java @@ -23,7 +23,7 @@ public class CachedBlockMessage extends Message { this.block = block; } - private CachedBlockMessage(byte[] cachedBytes) { + public CachedBlockMessage(byte[] cachedBytes) { super(MessageType.BLOCK); this.block = null; diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 74fb19ab..9316875d 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -113,7 +113,10 @@ public interface ATRepository { public List getBlockATStatesAtHeight(int height) throws DataException; - /** Rebuild the latest AT states cache, necessary for AT state trimming/pruning. */ + /** Rebuild the latest AT states cache, necessary for AT state trimming/pruning. + *

+ * NOTE: performs implicit repository.saveChanges(). + */ public void rebuildLatestAtStates() throws DataException; diff --git a/src/main/java/org/qortal/repository/BlockArchiveReader.java b/src/main/java/org/qortal/repository/BlockArchiveReader.java new file mode 100644 index 00000000..1b68a7c5 --- /dev/null +++ b/src/main/java/org/qortal/repository/BlockArchiveReader.java @@ -0,0 +1,251 @@ +package org.qortal.repository; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockArchiveData; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.settings.Settings; +import org.qortal.transform.TransformationException; +import org.qortal.transform.block.BlockTransformer; +import org.qortal.utils.Triple; + +import static org.qortal.transform.Transformer.INT_LENGTH; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; + +public class BlockArchiveReader { + + private static BlockArchiveReader instance; + private Map> fileListCache = Collections.synchronizedMap(new HashMap<>()); + + private static final Logger LOGGER = LogManager.getLogger(BlockArchiveReader.class); + + public BlockArchiveReader() { + + } + + public static synchronized BlockArchiveReader getInstance() { + if (instance == null) { + instance = new BlockArchiveReader(); + } + + return instance; + } + + private void fetchFileList() { + Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toAbsolutePath(); + File archiveDirFile = archivePath.toFile(); + String[] files = archiveDirFile.list(); + Map> map = new HashMap<>(); + + for (String file : files) { + Path filePath = Paths.get(file); + String filename = filePath.getFileName().toString(); + + // Parse the filename + if (filename == null || !filename.contains("-") || !filename.contains(".")) { + // Not a usable file + continue; + } + // Remove the extension and split into two parts + String[] parts = filename.substring(0, filename.lastIndexOf('.')).split("-"); + Integer startHeight = Integer.parseInt(parts[0]); + Integer endHeight = Integer.parseInt(parts[1]); + Integer range = endHeight - startHeight; + map.put(filename, new Triple(startHeight, endHeight, range)); + } + this.fileListCache = map; + } + + public Triple, List> fetchBlockAtHeight(int height) { + if (this.fileListCache.isEmpty()) { + this.fetchFileList(); + } + + byte[] serializedBytes = this.fetchSerializedBlockBytesForHeight(height); + if (serializedBytes == null) { + return null; + } + + ByteBuffer byteBuffer = ByteBuffer.wrap(serializedBytes); + Triple, List> blockInfo = null; + try { + blockInfo = BlockTransformer.fromByteBuffer(byteBuffer); + if (blockInfo != null && blockInfo.getA() != null) { + // Block height is stored outside of the main serialized bytes, so it + // won't be set automatically. + blockInfo.getA().setHeight(height); + } + } catch (TransformationException e) { + return null; + } + return blockInfo; + } + + public Triple, List> fetchBlockWithSignature( + byte[] signature, Repository repository) { + + if (this.fileListCache.isEmpty()) { + this.fetchFileList(); + } + + Integer height = this.fetchHeightForSignature(signature, repository); + if (height != null) { + return this.fetchBlockAtHeight(height); + } + return null; + } + + public Integer fetchHeightForSignature(byte[] signature, Repository repository) { + // Lookup the height for the requested signature + try { + BlockArchiveData archivedBlock = repository.getBlockArchiveRepository().getBlockArchiveDataForSignature(signature); + if (archivedBlock.getHeight() == null) { + return null; + } + return archivedBlock.getHeight(); + + } catch (DataException e) { + return null; + } + } + + public int fetchHeightForTimestamp(long timestamp, Repository repository) { + // Lookup the height for the requested signature + try { + return repository.getBlockArchiveRepository().getHeightFromTimestamp(timestamp); + + } catch (DataException e) { + return 0; + } + } + + private String getFilenameForHeight(int height) { + Iterator it = this.fileListCache.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry pair = (Map.Entry)it.next(); + if (pair == null && pair.getKey() == null && pair.getValue() == null) { + continue; + } + Triple heightInfo = (Triple) pair.getValue(); + Integer startHeight = heightInfo.getA(); + Integer endHeight = heightInfo.getB(); + + if (height >= startHeight && height <= endHeight) { + // Found the correct file + String filename = (String) pair.getKey(); + return filename; + } + } + + return null; + } + + public byte[] fetchSerializedBlockBytesForSignature(byte[] signature, Repository repository) { + + if (this.fileListCache.isEmpty()) { + this.fetchFileList(); + } + + Integer height = this.fetchHeightForSignature(signature, repository); + if (height != null) { + return this.fetchSerializedBlockBytesForHeight(height); + } + return null; + } + + public byte[] fetchSerializedBlockBytesForHeight(int height) { + String filename = this.getFilenameForHeight(height); + if (filename == null) { + // We don't have this block in the archive + // Invalidate the file list cache in case it is out of date + this.invalidateFileListCache(); + return null; + } + + Path filePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive", filename).toAbsolutePath(); + RandomAccessFile file = null; + try { + file = new RandomAccessFile(filePath.toString(), "r"); + // Get info about this file (the "fixed length header") + final int version = file.readInt(); // Do not remove or comment out, as it is moving the file pointer + final int startHeight = file.readInt(); // Do not remove or comment out, as it is moving the file pointer + final int endHeight = file.readInt(); // Do not remove or comment out, as it is moving the file pointer + file.readInt(); // Block count (unused) // Do not remove or comment out, as it is moving the file pointer + final int variableHeaderLength = file.readInt(); // Do not remove or comment out, as it is moving the file pointer + final int fixedHeaderLength = (int)file.getFilePointer(); + // End of fixed length header + + // Make sure the version is one we recognize + if (version != 1) { + LOGGER.info("Error: unknown version in file {}: {}", filename, version); + return null; + } + + // Verify that the block is within the reported range + if (height < startHeight || height > endHeight) { + LOGGER.info("Error: requested height {} but the range of file {} is {}-{}", + height, filename, startHeight, endHeight); + return null; + } + + // Seek to the location of the block index in the variable length header + final int locationOfBlockIndexInVariableHeaderSegment = (height - startHeight) * INT_LENGTH; + file.seek(fixedHeaderLength + locationOfBlockIndexInVariableHeaderSegment); + + // Read the value to obtain the index of this block in the data segment + int locationOfBlockInDataSegment = file.readInt(); + + // Now seek to the block data itself + int dataSegmentStartIndex = fixedHeaderLength + variableHeaderLength + INT_LENGTH; // Confirmed correct + file.seek(dataSegmentStartIndex + locationOfBlockInDataSegment); + + // Read the block metadata + int blockHeight = file.readInt(); // Do not remove or comment out, as it is moving the file pointer + int blockLength = file.readInt(); // Do not remove or comment out, as it is moving the file pointer + + // Ensure the block height matches the one requested + if (blockHeight != height) { + LOGGER.info("Error: height {} does not match requested: {}", blockHeight, height); + return null; + } + + // Now retrieve the block's serialized bytes + byte[] blockBytes = new byte[blockLength]; + file.read(blockBytes); + + return blockBytes; + + } catch (FileNotFoundException e) { + LOGGER.info("File {} not found: {}", filename, e.getMessage()); + return null; + } catch (IOException e) { + LOGGER.info("Unable to read block {} from archive: {}", height, e.getMessage()); + return null; + } + finally { + // Close the file + if (file != null) { + try { + file.close(); + } catch (IOException e) { + // Failed to close, but no need to handle this + } + } + } + } + + public void invalidateFileListCache() { + this.fileListCache.clear(); + } + +} diff --git a/src/main/java/org/qortal/repository/BlockArchiveRepository.java b/src/main/java/org/qortal/repository/BlockArchiveRepository.java new file mode 100644 index 00000000..c702a7ef --- /dev/null +++ b/src/main/java/org/qortal/repository/BlockArchiveRepository.java @@ -0,0 +1,118 @@ +package org.qortal.repository; + +import org.qortal.api.model.BlockSignerSummary; +import org.qortal.data.block.BlockArchiveData; +import org.qortal.data.block.BlockData; +import org.qortal.data.block.BlockSummaryData; + +import java.util.List; + +public interface BlockArchiveRepository { + + /** + * Returns BlockData from archive using block signature. + * + * @param signature + * @return block data, or null if not found in archive. + * @throws DataException + */ + public BlockData fromSignature(byte[] signature) throws DataException; + + /** + * Return height of block in archive using block's signature. + * + * @param signature + * @return height, or 0 if not found in blockchain. + * @throws DataException + */ + public int getHeightFromSignature(byte[] signature) throws DataException; + + /** + * Returns BlockData from archive using block height. + * + * @param height + * @return block data, or null if not found in blockchain. + * @throws DataException + */ + public BlockData fromHeight(int height) throws DataException; + + /** + * Returns BlockData from archive using block reference. + * Currently relies on a child block being the one block + * higher than its parent. This limitation can be removed + * by storing the reference in the BlockArchive table, but + * this has been avoided to reduce space. + * + * @param reference + * @return block data, or null if either parent or child + * not found in the archive. + * @throws DataException + */ + public BlockData fromReference(byte[] reference) throws DataException; + + /** + * Return height of block with timestamp just before passed timestamp. + * + * @param timestamp + * @return height, or 0 if not found in blockchain. + * @throws DataException + */ + public int getHeightFromTimestamp(long timestamp) throws DataException; + + /** + * Returns block summaries for blocks signed by passed public key, or reward-share with minter with passed public key. + */ + public List getBlockSummariesBySigner(byte[] signerPublicKey, Integer limit, Integer offset, Boolean reverse) throws DataException; + + /** + * Returns summaries of block signers, optionally limited to passed addresses. + * This combines both the BlockArchive and the Blocks data into a single result set. + */ + public List getBlockSigners(List addresses, Integer limit, Integer offset, Boolean reverse) throws DataException; + + + /** Returns height of first unarchived block. */ + public int getBlockArchiveHeight() throws DataException; + + /** Sets new height for block archiving. + *

+ * NOTE: performs implicit repository.saveChanges(). + */ + public void setBlockArchiveHeight(int archiveHeight) throws DataException; + + + /** + * Returns the block archive data for a given signature, from the block archive. + *

+ * This method will return null if no block archive has been built for the + * requested signature. In those cases, the height (and other data) can be + * looked up using the Blocks table. This allows a block to be located in + * the archive when we only know its signature. + *

+ * + * @param signature + * @throws DataException + */ + public BlockArchiveData getBlockArchiveDataForSignature(byte[] signature) throws DataException; + + /** + * Saves a block archive entry into the repository. + *

+ * This can be used to find the height of a block by its signature, without + * having access to the block data itself. + *

+ * + * @param blockArchiveData + * @throws DataException + */ + public void save(BlockArchiveData blockArchiveData) throws DataException; + + /** + * Deletes a block archive entry from the repository. + * + * @param blockArchiveData + * @throws DataException + */ + public void delete(BlockArchiveData blockArchiveData) throws DataException; + +} diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java new file mode 100644 index 00000000..4aeb1a32 --- /dev/null +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -0,0 +1,193 @@ +package org.qortal.repository; + +import com.google.common.primitives.Ints; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.block.Block; +import org.qortal.controller.Controller; +import org.qortal.data.block.BlockArchiveData; +import org.qortal.data.block.BlockData; +import org.qortal.settings.Settings; +import org.qortal.transform.TransformationException; +import org.qortal.transform.block.BlockTransformer; + +import java.io.ByteArrayOutputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class BlockArchiveWriter { + + public enum BlockArchiveWriteResult { + OK, + STOPPING, + NOT_ENOUGH_BLOCKS, + BLOCK_LIMIT_REACHED, + BLOCK_NOT_FOUND + } + + private static final Logger LOGGER = LogManager.getLogger(BlockArchiveWriter.class); + + private int startHeight; + private final int endHeight; + private final Repository repository; + + private int writtenCount; + + public BlockArchiveWriter(int startHeight, int endHeight, Repository repository) { + this.startHeight = startHeight; + this.endHeight = endHeight; + this.repository = repository; + } + + public static int getMaxArchiveHeight(Repository repository, boolean useMaximumDuplicatedLimit) throws DataException { + // We must only archive trimmed blocks, or the archive will grow far too large + final int accountSignaturesTrimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); + final int atTrimStartHeight = repository.getATRepository().getAtTrimHeight(); + final int trimStartHeight = Math.min(accountSignaturesTrimStartHeight, atTrimStartHeight); + + // In some cases we want to restrict the upper height of the archiver to save space + if (useMaximumDuplicatedLimit) { + // To save on disk space, it's best to not allow the archiver to get too far ahead of the pruner + // This reduces the amount of data that is held twice during the transition + final int blockPruneStartHeight = repository.getBlockRepository().getBlockPruneHeight(); + final int atPruneStartHeight = repository.getATRepository().getAtPruneHeight(); + final int pruneStartHeight = Math.min(blockPruneStartHeight, atPruneStartHeight); + final int maximumDuplicatedBlocks = Settings.getInstance().getMaxDuplicatedBlocksWhenArchiving(); + + // To summarize the above: + // - We must never archive anything greater than or equal to trimStartHeight + // - We should avoid archiving anything maximumDuplicatedBlocks higher than pruneStartHeight + return Math.min(trimStartHeight, pruneStartHeight + maximumDuplicatedBlocks); + } + else { + // We don't want to apply the maximum duplicated limit + return trimStartHeight; + } + } + + public static boolean isArchiverUpToDate(Repository repository, boolean useMaximumDuplicatedLimit) throws DataException { + final int maxArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository, useMaximumDuplicatedLimit); + final int actualArchiveHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); + final float progress = (float)actualArchiveHeight / (float) maxArchiveHeight; + LOGGER.info(String.format("maxArchiveHeight: %d, actualArchiveHeight: %d, progress: %f", + maxArchiveHeight, actualArchiveHeight, progress)); + + // If archiver is within 90% of the maximum, treat it as up to date + // We need several percent as an allowance because the archiver will only + // save files when they reach the target size + return (progress >= 0.90); + } + + public BlockArchiveWriteResult write() throws DataException, IOException, TransformationException, InterruptedException { + // Create the archive folder if it doesn't exist + // This is a subfolder of the db directory, to make bootstrapping easier + Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toAbsolutePath(); + try { + Files.createDirectories(archivePath); + } catch (IOException e) { + LOGGER.info("Unable to create archive folder"); + throw new DataException("Unable to create archive folder"); + } + + // Determine start height of blocks to fetch + if (startHeight <= 2) { + // Skip genesis block, as it's not designed to be transmitted, and we can build that from blockchain.json + // TODO: include genesis block if we can + startHeight = 2; + } + + // Header bytes will store the block indexes + ByteArrayOutputStream headerBytes = new ByteArrayOutputStream(); + // Bytes will store the actual block data + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + LOGGER.info(String.format("Fetching blocks from height %d...", startHeight)); + int i = 0; + long fileSizeTarget = 100 * 1024 * 1024; // 100MiB + while (headerBytes.size() + bytes.size() < fileSizeTarget) { + if (Controller.isStopping()) { + return BlockArchiveWriteResult.STOPPING; + } + if (Controller.getInstance().isSynchronizing()) { + continue; + } + + int currentHeight = startHeight + i; + if (currentHeight >= endHeight) { + return BlockArchiveWriteResult.BLOCK_LIMIT_REACHED; + } + + //LOGGER.info("Fetching block {}...", currentHeight); + + BlockData blockData = repository.getBlockRepository().fromHeight(currentHeight); + if (blockData == null) { + return BlockArchiveWriteResult.BLOCK_NOT_FOUND; + } + + // Write the signature and height into the BlockArchive table + BlockArchiveData blockArchiveData = new BlockArchiveData(blockData); + repository.getBlockArchiveRepository().save(blockArchiveData); + repository.saveChanges(); + + // Write the block data to some byte buffers + Block block = new Block(repository, blockData); + int blockIndex = bytes.size(); + // Write block index to header + headerBytes.write(Ints.toByteArray(blockIndex)); + // Write block height + bytes.write(Ints.toByteArray(block.getBlockData().getHeight())); + byte[] blockBytes = BlockTransformer.toBytes(block); + // Write block length + bytes.write(Ints.toByteArray(blockBytes.length)); + // Write block bytes + bytes.write(blockBytes); + i++; + + } + int totalLength = headerBytes.size() + bytes.size(); + LOGGER.info(String.format("Total length of %d blocks is %d bytes", i, totalLength)); + + // Validate file size, in case something went wrong + if (totalLength < fileSizeTarget) { + return BlockArchiveWriteResult.NOT_ENOUGH_BLOCKS; + } + + // We have enough blocks to create a new file + int endHeight = startHeight + i - 1; + int version = 1; + String filePath = String.format("%s/%d-%d.dat", archivePath.toString(), startHeight, endHeight); + FileOutputStream fileOutputStream = new FileOutputStream(filePath); + // Write version number + fileOutputStream.write(Ints.toByteArray(version)); + // Write start height + fileOutputStream.write(Ints.toByteArray(startHeight)); + // Write end height + fileOutputStream.write(Ints.toByteArray(endHeight)); + // Write total count + fileOutputStream.write(Ints.toByteArray(i)); + // Write dynamic header (block indexes) segment length + fileOutputStream.write(Ints.toByteArray(headerBytes.size())); + // Write dynamic header (block indexes) data + headerBytes.writeTo(fileOutputStream); + // Write data segment (block data) length + fileOutputStream.write(Ints.toByteArray(bytes.size())); + // Write data + bytes.writeTo(fileOutputStream); + // Close the file + fileOutputStream.close(); + + // Invalidate cache so that the rest of the app picks up the new file + BlockArchiveReader.getInstance().invalidateFileListCache(); + + this.writtenCount = i; + return BlockArchiveWriteResult.OK; + } + + public int getWrittenCount() { + return this.writtenCount; + } + +} diff --git a/src/main/java/org/qortal/repository/BlockRepository.java b/src/main/java/org/qortal/repository/BlockRepository.java index 5ca61e66..76891c36 100644 --- a/src/main/java/org/qortal/repository/BlockRepository.java +++ b/src/main/java/org/qortal/repository/BlockRepository.java @@ -137,11 +137,6 @@ public interface BlockRepository { */ public List getBlockSummaries(int firstBlockHeight, int lastBlockHeight) throws DataException; - /** - * Returns block summaries for the passed height range, for API use. - */ - public List getBlockSummaries(Integer startHeight, Integer endHeight, Integer count) throws DataException; - /** Returns height of first trimmable online accounts signatures. */ public int getOnlineAccountsSignaturesTrimHeight() throws DataException; diff --git a/src/main/java/org/qortal/repository/Repository.java b/src/main/java/org/qortal/repository/Repository.java index 656e6e1e..fab48a14 100644 --- a/src/main/java/org/qortal/repository/Repository.java +++ b/src/main/java/org/qortal/repository/Repository.java @@ -12,6 +12,8 @@ public interface Repository extends AutoCloseable { public BlockRepository getBlockRepository(); + public BlockArchiveRepository getBlockArchiveRepository(); + public ChatRepository getChatRepository(); public CrossChainRepository getCrossChainRepository(); diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 5e9c71c2..f7557750 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -2,6 +2,7 @@ package org.qortal.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.repository.hsqldb.HSQLDBDatabaseArchiving; import org.qortal.repository.hsqldb.HSQLDBDatabasePruning; import org.qortal.settings.Settings; @@ -57,9 +58,23 @@ public abstract class RepositoryManager { } } - public static void prune() { + public static boolean archive() { + // Bulk archive the database the first time we use archive mode + if (Settings.getInstance().isArchiveEnabled()) { + try { + return HSQLDBDatabaseArchiving.buildBlockArchive(); + + } catch (DataException e) { + LOGGER.info("Unable to bulk prune AT states. The database may have been left in an inconsistent state."); + } + } + return false; + } + + public static boolean prune() { // Bulk prune the database the first time we use pruning mode - if (Settings.getInstance().isPruningEnabled()) { + if (Settings.getInstance().isPruningEnabled() || + Settings.getInstance().isArchiveEnabled()) { try { boolean prunedATStates = HSQLDBDatabasePruning.pruneATStates(); boolean prunedBlocks = HSQLDBDatabasePruning.pruneBlocks(); @@ -67,12 +82,14 @@ public abstract class RepositoryManager { // Perform repository maintenance to shrink the db size down if (prunedATStates && prunedBlocks) { HSQLDBDatabasePruning.performMaintenance(); + return true; } } catch (SQLException | DataException e) { LOGGER.info("Unable to bulk prune AT states. The database may have been left in an inconsistent state."); } } + return false; } public static void setRequestedCheckpoint(Boolean quick) { diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 522fafb7..e0baa136 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -608,6 +608,7 @@ public class HSQLDBATRepository implements ATRepository { // that could result in one process using a partial or empty dataset // because it was in the process of being rebuilt by another thread synchronized (this.repository.latestATStatesLock) { + LOGGER.trace("Rebuilding latest AT states..."); // Rebuild cache of latest AT states that we can't trim String deleteSql = "DELETE FROM LatestATStates"; @@ -632,6 +633,8 @@ public class HSQLDBATRepository implements ATRepository { repository.examineException(e); throw new DataException("Unable to populate temporary latest AT states cache in repository", e); } + this.repository.saveChanges(); + LOGGER.trace("Rebuilt latest AT states"); } } @@ -661,7 +664,7 @@ public class HSQLDBATRepository implements ATRepository { this.repository.executeCheckedUpdate(updateSql, trimHeight); this.repository.saveChanges(); } catch (SQLException e) { - repository.examineException(e); + this.repository.examineException(e); throw new DataException("Unable to set AT state trim height in repository", e); } } @@ -689,7 +692,10 @@ public class HSQLDBATRepository implements ATRepository { + "LIMIT ?"; try { - return this.repository.executeCheckedUpdate(sql, minHeight, maxHeight, limit); + int modifiedRows = this.repository.executeCheckedUpdate(sql, minHeight, maxHeight, limit); + this.repository.saveChanges(); + return modifiedRows; + } catch (SQLException e) { repository.examineException(e); throw new DataException("Unable to trim AT states in repository", e); @@ -757,7 +763,7 @@ public class HSQLDBATRepository implements ATRepository { } while (resultSet.next()); } } catch (SQLException e) { - throw new DataException("Unable to fetch flagged accounts from repository", e); + throw new DataException("Unable to fetch latest AT states from repository", e); } List atStates = this.getBlockATStatesAtHeight(height); @@ -785,6 +791,7 @@ public class HSQLDBATRepository implements ATRepository { } } } + this.repository.saveChanges(); return deletedCount; } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java new file mode 100644 index 00000000..c491f862 --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java @@ -0,0 +1,277 @@ +package org.qortal.repository.hsqldb; + +import org.qortal.api.ApiError; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.model.BlockSignerSummary; +import org.qortal.data.block.BlockArchiveData; +import org.qortal.data.block.BlockData; +import org.qortal.data.block.BlockSummaryData; +import org.qortal.repository.BlockArchiveReader; +import org.qortal.repository.BlockArchiveRepository; +import org.qortal.repository.DataException; +import org.qortal.utils.Triple; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class HSQLDBBlockArchiveRepository implements BlockArchiveRepository { + + protected HSQLDBRepository repository; + + public HSQLDBBlockArchiveRepository(HSQLDBRepository repository) { + this.repository = repository; + } + + + @Override + public BlockData fromSignature(byte[] signature) throws DataException { + Triple blockInfo = BlockArchiveReader.getInstance().fetchBlockWithSignature(signature, this.repository); + if (blockInfo != null) { + return (BlockData) blockInfo.getA(); + } + return null; + } + + @Override + public int getHeightFromSignature(byte[] signature) throws DataException { + Integer height = BlockArchiveReader.getInstance().fetchHeightForSignature(signature, this.repository); + if (height == null || height == 0) { + return 0; + } + return height; + } + + @Override + public BlockData fromHeight(int height) throws DataException { + Triple blockInfo = BlockArchiveReader.getInstance().fetchBlockAtHeight(height); + if (blockInfo != null) { + return (BlockData) blockInfo.getA(); + } + return null; + } + + @Override + public BlockData fromReference(byte[] reference) throws DataException { + BlockData referenceBlock = this.repository.getBlockArchiveRepository().fromSignature(reference); + if (referenceBlock != null) { + int height = referenceBlock.getHeight(); + if (height > 0) { + // Request the block at height + 1 + Triple blockInfo = BlockArchiveReader.getInstance().fetchBlockAtHeight(height + 1); + if (blockInfo != null) { + return (BlockData) blockInfo.getA(); + } + } + } + return null; + } + + @Override + public int getHeightFromTimestamp(long timestamp) throws DataException { + String sql = "SELECT height FROM BlockArchive WHERE minted_when <= ? ORDER BY minted_when DESC, height DESC LIMIT 1"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, timestamp)) { + if (resultSet == null) { + return 0; + } + return resultSet.getInt(1); + + } catch (SQLException e) { + throw new DataException("Error fetching height from BlockArchive repository", e); + } + } + + @Override + public List getBlockSummariesBySigner(byte[] signerPublicKey, Integer limit, Integer offset, Boolean reverse) throws DataException { + StringBuilder sql = new StringBuilder(512); + sql.append("SELECT signature, height, BlockArchive.minter FROM "); + + // List of minter account's public key and reward-share public keys with minter's public key + sql.append("(SELECT * FROM (VALUES (CAST(? AS QortalPublicKey))) UNION (SELECT reward_share_public_key FROM RewardShares WHERE minter_public_key = ?)) AS PublicKeys (public_key) "); + + // Match BlockArchive blocks signed with public key from above list + sql.append("JOIN BlockArchive ON BlockArchive.minter = public_key "); + + sql.append("ORDER BY BlockArchive.height "); + if (reverse != null && reverse) + sql.append("DESC "); + + HSQLDBRepository.limitOffsetSql(sql, limit, offset); + + List blockSummaries = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), signerPublicKey, signerPublicKey)) { + if (resultSet == null) + return blockSummaries; + + do { + byte[] signature = resultSet.getBytes(1); + int height = resultSet.getInt(2); + byte[] blockMinterPublicKey = resultSet.getBytes(3); + + // Fetch additional info from the archive itself + int onlineAccountsCount = 0; + BlockData blockData = this.fromSignature(signature); + if (blockData != null) { + onlineAccountsCount = blockData.getOnlineAccountsCount(); + } + + BlockSummaryData blockSummary = new BlockSummaryData(height, signature, blockMinterPublicKey, onlineAccountsCount); + blockSummaries.add(blockSummary); + } while (resultSet.next()); + + return blockSummaries; + } catch (SQLException e) { + throw new DataException("Unable to fetch minter's block summaries from repository", e); + } + } + + @Override + public List getBlockSigners(List addresses, Integer limit, Integer offset, Boolean reverse) throws DataException { + String subquerySql = "SELECT minter, COUNT(signature) FROM (" + + "(SELECT minter, signature FROM Blocks) UNION ALL (SELECT minter, signature FROM BlockArchive)" + + ") GROUP BY minter"; + + StringBuilder sql = new StringBuilder(1024); + sql.append("SELECT DISTINCT block_minter, n_blocks, minter_public_key, minter, recipient FROM ("); + sql.append(subquerySql); + sql.append(") AS Minters (block_minter, n_blocks) LEFT OUTER JOIN RewardShares ON reward_share_public_key = block_minter "); + + if (addresses != null && !addresses.isEmpty()) { + sql.append(" LEFT OUTER JOIN Accounts AS BlockMinterAccounts ON BlockMinterAccounts.public_key = block_minter "); + sql.append(" LEFT OUTER JOIN Accounts AS RewardShareMinterAccounts ON RewardShareMinterAccounts.public_key = minter_public_key "); + sql.append(" JOIN (VALUES "); + + final int addressesSize = addresses.size(); + for (int ai = 0; ai < addressesSize; ++ai) { + if (ai != 0) + sql.append(", "); + + sql.append("(?)"); + } + + sql.append(") AS FilterAccounts (account) "); + sql.append(" ON FilterAccounts.account IN (recipient, BlockMinterAccounts.account, RewardShareMinterAccounts.account) "); + } else { + addresses = Collections.emptyList(); + } + + sql.append("ORDER BY n_blocks "); + if (reverse != null && reverse) + sql.append("DESC "); + + HSQLDBRepository.limitOffsetSql(sql, limit, offset); + + List summaries = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), addresses.toArray())) { + if (resultSet == null) + return summaries; + + do { + byte[] blockMinterPublicKey = resultSet.getBytes(1); + int nBlocks = resultSet.getInt(2); + + // May not be present if no reward-share: + byte[] mintingAccountPublicKey = resultSet.getBytes(3); + String minterAccount = resultSet.getString(4); + String recipientAccount = resultSet.getString(5); + + BlockSignerSummary blockSignerSummary; + if (recipientAccount == null) + blockSignerSummary = new BlockSignerSummary(blockMinterPublicKey, nBlocks); + else + blockSignerSummary = new BlockSignerSummary(blockMinterPublicKey, nBlocks, mintingAccountPublicKey, minterAccount, recipientAccount); + + summaries.add(blockSignerSummary); + } while (resultSet.next()); + + return summaries; + } catch (SQLException e) { + throw new DataException("Unable to fetch block minters from repository", e); + } + } + + + @Override + public int getBlockArchiveHeight() throws DataException { + String sql = "SELECT block_archive_height FROM DatabaseInfo"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return 0; + + return resultSet.getInt(1); + } catch (SQLException e) { + throw new DataException("Unable to fetch block archive height from repository", e); + } + } + + @Override + public void setBlockArchiveHeight(int archiveHeight) throws DataException { + // trimHeightsLock is to prevent concurrent update on DatabaseInfo + // that could result in "transaction rollback: serialization failure" + synchronized (this.repository.trimHeightsLock) { + String updateSql = "UPDATE DatabaseInfo SET block_archive_height = ?"; + + try { + this.repository.executeCheckedUpdate(updateSql, archiveHeight); + this.repository.saveChanges(); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to set block archive height in repository", e); + } + } + } + + + @Override + public BlockArchiveData getBlockArchiveDataForSignature(byte[] signature) throws DataException { + String sql = "SELECT height, signature, minted_when, minter FROM BlockArchive WHERE signature = ? LIMIT 1"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, signature)) { + if (resultSet == null) { + return null; + } + int height = resultSet.getInt(1); + byte[] sig = resultSet.getBytes(2); + long timestamp = resultSet.getLong(3); + byte[] minterPublicKey = resultSet.getBytes(4); + return new BlockArchiveData(sig, height, timestamp, minterPublicKey); + + } catch (SQLException e) { + throw new DataException("Error fetching height from BlockArchive repository", e); + } + } + + + @Override + public void save(BlockArchiveData blockArchiveData) throws DataException { + HSQLDBSaver saveHelper = new HSQLDBSaver("BlockArchive"); + + saveHelper.bind("signature", blockArchiveData.getSignature()) + .bind("height", blockArchiveData.getHeight()) + .bind("minted_when", blockArchiveData.getTimestamp()) + .bind("minter", blockArchiveData.getMinterPublicKey()); + + try { + saveHelper.execute(this.repository); + } catch (SQLException e) { + throw new DataException("Unable to save SimpleBlockData into BlockArchive repository", e); + } + } + + @Override + public void delete(BlockArchiveData blockArchiveData) throws DataException { + try { + this.repository.delete("BlockArchive", + "block_signature = ?", blockArchiveData.getSignature()); + } catch (SQLException e) { + throw new DataException("Unable to delete SimpleBlockData from BlockArchive repository", e); + } + } + +} diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java index 2f7e4ad2..b8238085 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java @@ -10,6 +10,7 @@ import org.qortal.api.model.BlockSignerSummary; import org.qortal.data.block.BlockData; import org.qortal.data.block.BlockSummaryData; import org.qortal.data.block.BlockTransactionData; +import org.qortal.data.block.BlockArchiveData; import org.qortal.data.transaction.TransactionData; import org.qortal.repository.BlockRepository; import org.qortal.repository.DataException; @@ -382,86 +383,6 @@ public class HSQLDBBlockRepository implements BlockRepository { } } - @Override - public List getBlockSummaries(Integer startHeight, Integer endHeight, Integer count) throws DataException { - StringBuilder sql = new StringBuilder(512); - List bindParams = new ArrayList<>(); - - sql.append("SELECT signature, height, minter, online_accounts_count, minted_when, transaction_count "); - - /* - * start end count result - * 10 40 null blocks 10 to 39 (excludes end block, ignore count) - * - * null null null blocks 1 to 50 (assume count=50, maybe start=1) - * 30 null null blocks 30 to 79 (assume count=50) - * 30 null 10 blocks 30 to 39 - * - * null null 50 last 50 blocks? so if max(blocks.height) is 200, then blocks 151 to 200 - * null 200 null blocks 150 to 199 (excludes end block, assume count=50) - * null 200 10 blocks 190 to 199 (excludes end block) - */ - - if (startHeight != null && endHeight != null) { - sql.append("FROM Blocks "); - sql.append("WHERE height BETWEEN ? AND ?"); - bindParams.add(startHeight); - bindParams.add(Integer.valueOf(endHeight - 1)); - } else if (endHeight != null || (startHeight == null && count != null)) { - // we are going to return blocks from the end of the chain - if (count == null) - count = 50; - - if (endHeight == null) { - sql.append("FROM (SELECT height FROM Blocks ORDER BY height DESC LIMIT 1) AS MaxHeights (max_height) "); - sql.append("JOIN Blocks ON height BETWEEN (max_height - ? + 1) AND max_height "); - bindParams.add(count); - } else { - sql.append("FROM Blocks "); - sql.append("WHERE height BETWEEN ? AND ?"); - bindParams.add(Integer.valueOf(endHeight - count)); - bindParams.add(Integer.valueOf(endHeight - 1)); - } - - } else { - // we are going to return blocks from the start of the chain - if (startHeight == null) - startHeight = 1; - - if (count == null) - count = 50; - - sql.append("FROM Blocks "); - sql.append("WHERE height BETWEEN ? AND ?"); - bindParams.add(startHeight); - bindParams.add(Integer.valueOf(startHeight + count - 1)); - } - - List blockSummaries = new ArrayList<>(); - - try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { - if (resultSet == null) - return blockSummaries; - - do { - byte[] signature = resultSet.getBytes(1); - int height = resultSet.getInt(2); - byte[] minterPublicKey = resultSet.getBytes(3); - int onlineAccountsCount = resultSet.getInt(4); - long timestamp = resultSet.getLong(5); - int transactionCount = resultSet.getInt(6); - - BlockSummaryData blockSummary = new BlockSummaryData(height, signature, minterPublicKey, onlineAccountsCount, - timestamp, transactionCount); - blockSummaries.add(blockSummary); - } while (resultSet.next()); - - return blockSummaries; - } catch (SQLException e) { - throw new DataException("Unable to fetch height-ranged block summaries from repository", e); - } - } - @Override public int getOnlineAccountsSignaturesTrimHeight() throws DataException { String sql = "SELECT online_signatures_trim_height FROM DatabaseInfo"; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java new file mode 100644 index 00000000..930da828 --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java @@ -0,0 +1,87 @@ +package org.qortal.repository.hsqldb; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.controller.Controller; +import org.qortal.repository.BlockArchiveWriter; +import org.qortal.repository.DataException; +import org.qortal.repository.RepositoryManager; +import org.qortal.transform.TransformationException; + +import java.io.IOException; + +/** + * + * When switching to an archiving node, we need to archive most of the database contents. + * This involves copying its data into flat files. + * If we do this entirely as a background process, it is very slow and can interfere with syncing. + * However, if we take the approach of doing this in bulk, before starting up the rest of the + * processes, this makes it much faster and less invasive. + * + * From that point, the original background archiving process will run, but can be dialled right down + * so not to interfere with syncing. + * + */ + + +public class HSQLDBDatabaseArchiving { + + private static final Logger LOGGER = LogManager.getLogger(HSQLDBDatabaseArchiving.class); + + + public static boolean buildBlockArchive() throws DataException { + try (final HSQLDBRepository repository = (HSQLDBRepository)RepositoryManager.getRepository()) { + + // Only build the archive if we have never done so before + int archiveHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); + if (archiveHeight > 0) { + // Already archived + return false; + } + + LOGGER.info("Building block archive - this process could take a while... (approx. 15 mins on high spec)"); + + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository, false); + int startHeight = 0; + + while (!Controller.isStopping()) { + try { + BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, repository); + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + switch (result) { + case OK: + // Increment block archive height + startHeight += writer.getWrittenCount(); + repository.getBlockArchiveRepository().setBlockArchiveHeight(startHeight); + repository.saveChanges(); + break; + + case STOPPING: + return false; + + case BLOCK_LIMIT_REACHED: + case NOT_ENOUGH_BLOCKS: + // We've reached the limit of the blocks we can archive + // Return from the whole method + return true; + + case BLOCK_NOT_FOUND: + // We tried to archive a block that didn't exist. This is a major failure and likely means + // that a bootstrap or re-sync is needed. Return rom the method + LOGGER.info("Error: block not found when building archive. If this error persists, " + + "a bootstrap or re-sync may be needed."); + return false; + } + + } catch (IOException | TransformationException | InterruptedException e) { + LOGGER.info("Caught exception when creating block cache", e); + return false; + } + } + } + + // If we got this far then something went wrong (most likely the app is stopping) + return false; + } + +} diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index ba170bf6..969c954c 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -4,6 +4,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.controller.Controller; import org.qortal.data.block.BlockData; +import org.qortal.repository.BlockArchiveWriter; import org.qortal.repository.DataException; import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; @@ -36,6 +37,7 @@ public class HSQLDBDatabasePruning { private static final Logger LOGGER = LogManager.getLogger(HSQLDBDatabasePruning.class); + public static boolean pruneATStates() throws SQLException, DataException { try (final HSQLDBRepository repository = (HSQLDBRepository)RepositoryManager.getRepository()) { @@ -46,7 +48,18 @@ public class HSQLDBDatabasePruning { return false; } - LOGGER.info("Starting bulk prune of AT states - this process could take a while... (approx. 2 mins on high spec)"); + if (Settings.getInstance().isArchiveEnabled()) { + // Only proceed if we can see that the archiver has already finished + // This way, if the archiver failed for any reason, we can prune once it has had + // some opportunities to try again + boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository, false); + if (!upToDate) { + return false; + } + } + + LOGGER.info("Starting bulk prune of AT states - this process could take a while... " + + "(approx. 2 mins on high spec, or upwards of 30 mins in some cases)"); // Create new AT-states table to hold smaller dataset repository.executeCheckedUpdate("DROP TABLE IF EXISTS ATStatesNew"); @@ -68,11 +81,17 @@ public class HSQLDBDatabasePruning { // Calculate some constants for later use final int blockchainHeight = latestBlock.getHeight(); - final int maximumBlockToTrim = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + int maximumBlockToTrim = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + if (Settings.getInstance().isArchiveEnabled()) { + // Archive mode - don't prune anything that hasn't been archived yet + maximumBlockToTrim = Math.min(maximumBlockToTrim, repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1); + } final int startHeight = maximumBlockToTrim; final int endHeight = blockchainHeight; final int blockStep = 10000; + + // Loop through all the LatestATStates and copy them to the new table LOGGER.info("Copying AT states..."); for (int height = 0; height < endHeight; height += blockStep) { @@ -99,7 +118,7 @@ public class HSQLDBDatabasePruning { } if (height >= startHeight) { - // Now copy this AT states for each recent block it is present in + // Now copy this AT's states for each recent block they is present in for (int i = startHeight; i < endHeight; i++) { if (latestAtHeight < i) { // This AT finished before this block so there is nothing to copy @@ -159,20 +178,25 @@ public class HSQLDBDatabasePruning { private static boolean pruneATStateData() throws SQLException, DataException { try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { + if (Settings.getInstance().isArchiveEnabled()) { + // Don't prune ATStatesData in archive mode + return true; + } + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); if (latestBlock == null) { LOGGER.info("Unable to determine blockchain height, necessary for bulk ATStatesData pruning"); return false; } final int blockchainHeight = latestBlock.getHeight(); - final int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); // ATStateData is already trimmed - so carry on from where we left off in the past int pruneStartHeight = repository.getATRepository().getAtTrimHeight(); LOGGER.info("Starting bulk prune of AT states data - this process could take a while... (approx. 3 mins on high spec)"); while (pruneStartHeight < upperPrunableHeight) { - // Prune all AT state data up until our latest minus pruneBlockLimit + // Prune all AT state data up until our latest minus pruneBlockLimit (or our archive height) if (Controller.isStopping()) { return false; @@ -225,15 +249,30 @@ public class HSQLDBDatabasePruning { return false; } + if (Settings.getInstance().isArchiveEnabled()) { + // Only proceed if we can see that the archiver has already finished + // This way, if the archiver failed for any reason, we can prune once it has had + // some opportunities to try again + boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository, false); + if (!upToDate) { + return false; + } + } + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); if (latestBlock == null) { LOGGER.info("Unable to determine blockchain height, necessary for bulk block pruning"); return false; } final int blockchainHeight = latestBlock.getHeight(); - final int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); int pruneStartHeight = 0; + if (Settings.getInstance().isArchiveEnabled()) { + // Archive mode - don't prune anything that hasn't been archived yet + upperPrunableHeight = Math.min(upperPrunableHeight, repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1); + } + LOGGER.info("Starting bulk prune of blocks - this process could take a while... (approx. 5 mins on high spec)"); while (pruneStartHeight < upperPrunableHeight) { diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index d696351f..66fe9029 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -873,6 +873,25 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE DatabaseInfo ADD block_prune_height INT NOT NULL DEFAULT 0"); break; + case 36: + // Block archive support + stmt.execute("ALTER TABLE DatabaseInfo ADD block_archive_height INT NOT NULL DEFAULT 0"); + + // Block archive (lookup table to map signature to height) + // Actual data is stored in archive files outside of the database + stmt.execute("CREATE TABLE BlockArchive (signature BlockSignature, height INTEGER NOT NULL, " + + "minted_when EpochMillis NOT NULL, minter QortalPublicKey NOT NULL, " + + "PRIMARY KEY (signature))"); + // For finding blocks by height. + stmt.execute("CREATE INDEX BlockArchiveHeightIndex ON BlockArchive (height)"); + // For finding blocks by the account that minted them. + stmt.execute("CREATE INDEX BlockArchiveMinterIndex ON BlockArchive (minter)"); + // For finding blocks by timestamp or finding height of latest block immediately before timestamp, etc. + stmt.execute("CREATE INDEX BlockArchiveTimestampHeightIndex ON BlockArchive (minted_when, height)"); + // Use a separate table space as this table will be very large. + stmt.execute("SET TABLE BlockArchive NEW SPACE"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 3a947cd6..6807c100 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -31,22 +31,7 @@ import org.qortal.crypto.Crypto; import org.qortal.data.crosschain.TradeBotData; import org.qortal.globalization.Translator; import org.qortal.gui.SysTray; -import org.qortal.repository.ATRepository; -import org.qortal.repository.AccountRepository; -import org.qortal.repository.ArbitraryRepository; -import org.qortal.repository.AssetRepository; -import org.qortal.repository.BlockRepository; -import org.qortal.repository.ChatRepository; -import org.qortal.repository.CrossChainRepository; -import org.qortal.repository.DataException; -import org.qortal.repository.GroupRepository; -import org.qortal.repository.MessageRepository; -import org.qortal.repository.NameRepository; -import org.qortal.repository.NetworkRepository; -import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryManager; -import org.qortal.repository.TransactionRepository; -import org.qortal.repository.VotingRepository; +import org.qortal.repository.*; import org.qortal.repository.hsqldb.transaction.HSQLDBTransactionRepository; import org.qortal.settings.Settings; import org.qortal.utils.Base58; @@ -76,6 +61,7 @@ public class HSQLDBRepository implements Repository { private final ArbitraryRepository arbitraryRepository = new HSQLDBArbitraryRepository(this); private final AssetRepository assetRepository = new HSQLDBAssetRepository(this); private final BlockRepository blockRepository = new HSQLDBBlockRepository(this); + private final BlockArchiveRepository blockArchiveRepository = new HSQLDBBlockArchiveRepository(this); private final ChatRepository chatRepository = new HSQLDBChatRepository(this); private final CrossChainRepository crossChainRepository = new HSQLDBCrossChainRepository(this); private final GroupRepository groupRepository = new HSQLDBGroupRepository(this); @@ -143,6 +129,11 @@ public class HSQLDBRepository implements Repository { return this.blockRepository; } + @Override + public BlockArchiveRepository getBlockArchiveRepository() { + return this.blockArchiveRepository; + } + @Override public ChatRepository getChatRepository() { return this.chatRepository; diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index d0b00729..82a7aaa0 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -132,6 +132,15 @@ public class Settings { private int blockPruneBatchSize = 10000; // blocks + /** Whether we should archive old data to reduce the database size */ + private boolean archiveEnabled = true; + /** How often to attempt archiving (ms). */ + private long archiveInterval = 7171L; // milliseconds + /** The maximum number of blocks that can exist in both the + * database and the archive at the same time */ + private int maxDuplicatedBlocksWhenArchiving = 100000; + + // Peer-to-peer related private boolean isTestNet = false; /** Port number for inbound peer-to-peer connections. */ @@ -581,4 +590,17 @@ public class Settings { return this.blockPruneBatchSize; } + + public boolean isArchiveEnabled() { + return this.archiveEnabled; + } + + public long getArchiveInterval() { + return this.archiveInterval; + } + + public int getMaxDuplicatedBlocksWhenArchiving() { + return this.maxDuplicatedBlocksWhenArchiving; + } + } From 5656de79a2961d6a39bc8992323790c485ae7503 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Sep 2021 09:52:28 +0100 Subject: [PATCH 111/231] Removed maxDuplicatedBlocksWhenArchiving setting as it's no longer needed. --- .../controller/repository/BlockArchiver.java | 2 +- .../qortal/repository/BlockArchiveWriter.java | 26 +++---------------- .../hsqldb/HSQLDBDatabaseArchiving.java | 2 +- .../hsqldb/HSQLDBDatabasePruning.java | 4 +-- .../java/org/qortal/settings/Settings.java | 7 ----- 5 files changed, 8 insertions(+), 33 deletions(-) diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiver.java b/src/main/java/org/qortal/controller/repository/BlockArchiver.java index f7bafe7d..aab4b4fa 100644 --- a/src/main/java/org/qortal/controller/repository/BlockArchiver.java +++ b/src/main/java/org/qortal/controller/repository/BlockArchiver.java @@ -35,7 +35,7 @@ public class BlockArchiver implements Runnable { while (!Controller.isStopping()) { repository.discardChanges(); - final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository, true); + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); Thread.sleep(Settings.getInstance().getArchiveInterval()); diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index 4aeb1a32..59d07072 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -42,34 +42,16 @@ public class BlockArchiveWriter { this.repository = repository; } - public static int getMaxArchiveHeight(Repository repository, boolean useMaximumDuplicatedLimit) throws DataException { + public static int getMaxArchiveHeight(Repository repository) throws DataException { // We must only archive trimmed blocks, or the archive will grow far too large final int accountSignaturesTrimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); final int atTrimStartHeight = repository.getATRepository().getAtTrimHeight(); final int trimStartHeight = Math.min(accountSignaturesTrimStartHeight, atTrimStartHeight); - - // In some cases we want to restrict the upper height of the archiver to save space - if (useMaximumDuplicatedLimit) { - // To save on disk space, it's best to not allow the archiver to get too far ahead of the pruner - // This reduces the amount of data that is held twice during the transition - final int blockPruneStartHeight = repository.getBlockRepository().getBlockPruneHeight(); - final int atPruneStartHeight = repository.getATRepository().getAtPruneHeight(); - final int pruneStartHeight = Math.min(blockPruneStartHeight, atPruneStartHeight); - final int maximumDuplicatedBlocks = Settings.getInstance().getMaxDuplicatedBlocksWhenArchiving(); - - // To summarize the above: - // - We must never archive anything greater than or equal to trimStartHeight - // - We should avoid archiving anything maximumDuplicatedBlocks higher than pruneStartHeight - return Math.min(trimStartHeight, pruneStartHeight + maximumDuplicatedBlocks); - } - else { - // We don't want to apply the maximum duplicated limit - return trimStartHeight; - } + return trimStartHeight - 1; // subtract 1 because these values represent the first _untrimmed_ block } - public static boolean isArchiverUpToDate(Repository repository, boolean useMaximumDuplicatedLimit) throws DataException { - final int maxArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository, useMaximumDuplicatedLimit); + public static boolean isArchiverUpToDate(Repository repository) throws DataException { + final int maxArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); final int actualArchiveHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); final float progress = (float)actualArchiveHeight / (float) maxArchiveHeight; LOGGER.info(String.format("maxArchiveHeight: %d, actualArchiveHeight: %d, progress: %f", diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java index 930da828..7a7b66f3 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java @@ -41,7 +41,7 @@ public class HSQLDBDatabaseArchiving { LOGGER.info("Building block archive - this process could take a while... (approx. 15 mins on high spec)"); - final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository, false); + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); int startHeight = 0; while (!Controller.isStopping()) { diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index 969c954c..65139743 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -52,7 +52,7 @@ public class HSQLDBDatabasePruning { // Only proceed if we can see that the archiver has already finished // This way, if the archiver failed for any reason, we can prune once it has had // some opportunities to try again - boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository, false); + boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); if (!upToDate) { return false; } @@ -253,7 +253,7 @@ public class HSQLDBDatabasePruning { // Only proceed if we can see that the archiver has already finished // This way, if the archiver failed for any reason, we can prune once it has had // some opportunities to try again - boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository, false); + boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); if (!upToDate) { return false; } diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 82a7aaa0..15ead8e7 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -136,9 +136,6 @@ public class Settings { private boolean archiveEnabled = true; /** How often to attempt archiving (ms). */ private long archiveInterval = 7171L; // milliseconds - /** The maximum number of blocks that can exist in both the - * database and the archive at the same time */ - private int maxDuplicatedBlocksWhenArchiving = 100000; // Peer-to-peer related @@ -599,8 +596,4 @@ public class Settings { return this.archiveInterval; } - public int getMaxDuplicatedBlocksWhenArchiving() { - return this.maxDuplicatedBlocksWhenArchiving; - } - } From 37e03bf2bbb8ce328ab1022cf3af97d04e44d362 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Sep 2021 09:55:49 +0100 Subject: [PATCH 112/231] Removed BLOCK_LIMIT_REACHED result from the block archive writer. This wasn't needed, and is now instead caught by the NOT_ENOUGH_BLOCKS result. --- .../java/org/qortal/controller/repository/BlockArchiver.java | 1 - src/main/java/org/qortal/repository/BlockArchiveWriter.java | 3 +-- .../org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiver.java b/src/main/java/org/qortal/controller/repository/BlockArchiver.java index aab4b4fa..d6860347 100644 --- a/src/main/java/org/qortal/controller/repository/BlockArchiver.java +++ b/src/main/java/org/qortal/controller/repository/BlockArchiver.java @@ -71,7 +71,6 @@ public class BlockArchiver implements Runnable { case STOPPING: return; - case BLOCK_LIMIT_REACHED: // We've reached the limit of the blocks we can archive // Sleep for a while to allow more to become available case NOT_ENOUGH_BLOCKS: diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index 59d07072..efef689e 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -24,7 +24,6 @@ public class BlockArchiveWriter { OK, STOPPING, NOT_ENOUGH_BLOCKS, - BLOCK_LIMIT_REACHED, BLOCK_NOT_FOUND } @@ -99,7 +98,7 @@ public class BlockArchiveWriter { int currentHeight = startHeight + i; if (currentHeight >= endHeight) { - return BlockArchiveWriteResult.BLOCK_LIMIT_REACHED; + break; } //LOGGER.info("Fetching block {}...", currentHeight); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java index 7a7b66f3..618d5115 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java @@ -59,7 +59,6 @@ public class HSQLDBDatabaseArchiving { case STOPPING: return false; - case BLOCK_LIMIT_REACHED: case NOT_ENOUGH_BLOCKS: // We've reached the limit of the blocks we can archive // Return from the whole method From fea7b62b9cdb4170a2cd16fc932e773ec9e3108c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Sep 2021 09:57:12 +0100 Subject: [PATCH 113/231] Fixed some bugs found in unit testing. --- src/main/java/org/qortal/repository/BlockArchiveWriter.java | 2 +- .../java/org/qortal/repository/hsqldb/HSQLDBATRepository.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index efef689e..11151e17 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -97,7 +97,7 @@ public class BlockArchiveWriter { } int currentHeight = startHeight + i; - if (currentHeight >= endHeight) { + if (currentHeight > endHeight) { break; } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index e0baa136..56658ec7 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -744,7 +744,7 @@ public class HSQLDBATRepository implements ATRepository { int deletedCount = 0; - for (int height = minHeight; height < maxHeight; height++) { + for (int height = minHeight; height <= maxHeight; height++) { // Give up if we're stopping if (Controller.isStopping()) { From 9813dde3d99a98bad0692ae115e904f416aa5243 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Sep 2021 10:10:25 +0100 Subject: [PATCH 114/231] Added importFromArchive() feature This allows archived blocks to be imported back into HSQLDB in order to make them SQL-compatible again. --- .../qortal/repository/BlockArchiveReader.java | 15 ++++ .../repository/BlockArchiveRepository.java | 12 +++ .../hsqldb/HSQLDBBlockArchiveRepository.java | 15 ++++ .../org/qortal/utils/BlockArchiveUtils.java | 78 +++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 src/main/java/org/qortal/utils/BlockArchiveUtils.java diff --git a/src/main/java/org/qortal/repository/BlockArchiveReader.java b/src/main/java/org/qortal/repository/BlockArchiveReader.java index 1b68a7c5..081917b2 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveReader.java +++ b/src/main/java/org/qortal/repository/BlockArchiveReader.java @@ -105,6 +105,21 @@ public class BlockArchiveReader { return null; } + public List, List>> fetchBlocksFromRange( + int startHeight, int endHeight) { + + List, List>> blockInfoList = new ArrayList<>(); + + for (int height = startHeight; height <= endHeight; height++) { + Triple, List> blockInfo = this.fetchBlockAtHeight(height); + if (blockInfo == null) { + return blockInfoList; + } + blockInfoList.add(blockInfo); + } + return blockInfoList; + } + public Integer fetchHeightForSignature(byte[] signature, Repository repository) { // Lookup the height for the requested signature try { diff --git a/src/main/java/org/qortal/repository/BlockArchiveRepository.java b/src/main/java/org/qortal/repository/BlockArchiveRepository.java index c702a7ef..45465e93 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveRepository.java +++ b/src/main/java/org/qortal/repository/BlockArchiveRepository.java @@ -36,6 +36,18 @@ public interface BlockArchiveRepository { */ public BlockData fromHeight(int height) throws DataException; + /** + * Returns a list of BlockData objects from archive using + * block height range. + * + * @param startHeight + * @return a list of BlockData objects, or an empty list if + * not found in blockchain. It is not guaranteed that all + * requested blocks will be returned. + * @throws DataException + */ + public List fromRange(int startHeight, int endHeight) throws DataException; + /** * Returns BlockData from archive using block reference. * Currently relies on a child block being the one block diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java index c491f862..32270213 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java @@ -3,6 +3,7 @@ package org.qortal.repository.hsqldb; import org.qortal.api.ApiError; import org.qortal.api.ApiExceptionFactory; import org.qortal.api.model.BlockSignerSummary; +import org.qortal.block.Block; import org.qortal.data.block.BlockArchiveData; import org.qortal.data.block.BlockData; import org.qortal.data.block.BlockSummaryData; @@ -53,6 +54,20 @@ public class HSQLDBBlockArchiveRepository implements BlockArchiveRepository { return null; } + @Override + public List fromRange(int startHeight, int endHeight) throws DataException { + List blocks = new ArrayList<>(); + + for (int height = startHeight; height < endHeight; height++) { + BlockData blockData = this.fromHeight(height); + if (blockData == null) { + return blocks; + } + blocks.add(blockData); + } + return blocks; + } + @Override public BlockData fromReference(byte[] reference) throws DataException { BlockData referenceBlock = this.repository.getBlockArchiveRepository().fromSignature(reference); diff --git a/src/main/java/org/qortal/utils/BlockArchiveUtils.java b/src/main/java/org/qortal/utils/BlockArchiveUtils.java new file mode 100644 index 00000000..0beff026 --- /dev/null +++ b/src/main/java/org/qortal/utils/BlockArchiveUtils.java @@ -0,0 +1,78 @@ +package org.qortal.utils; + +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.BlockArchiveReader; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; + +import java.util.List; + +public class BlockArchiveUtils { + + /** + * importFromArchive + *

+ * Reads the requested block range from the archive + * and imports the BlockData and AT state data hashes + * This can be used to convert a block archive back + * into the HSQLDB, in order to make it SQL-compatible + * again. + *

+ * Note: calls discardChanges() and saveChanges(), so + * make sure that you commit any existing repository + * changes before calling this method. + * + * @param startHeight The earliest block to import + * @param endHeight The latest block to import + * @param repository A clean repository session + * @throws DataException + */ + public static void importFromArchive(int startHeight, int endHeight, Repository repository) throws DataException { + repository.discardChanges(); + final int requestedRange = endHeight+1-startHeight; + + List, List>> blockInfoList = + BlockArchiveReader.getInstance().fetchBlocksFromRange(startHeight, endHeight); + + // Ensure that we have received all of the requested blocks + if (blockInfoList == null || blockInfoList.isEmpty()) { + throw new IllegalStateException("No blocks found when importing from archive"); + } + if (blockInfoList.size() != requestedRange) { + throw new IllegalStateException("Non matching block count when importing from archive"); + } + Triple, List> firstBlock = blockInfoList.get(0); + if (firstBlock == null || firstBlock.getA().getHeight() != startHeight) { + throw new IllegalStateException("Non matching first block when importing from archive"); + } + if (blockInfoList.size() > 0) { + Triple, List> lastBlock = + blockInfoList.get(blockInfoList.size() - 1); + if (lastBlock == null || lastBlock.getA().getHeight() != endHeight) { + throw new IllegalStateException("Non matching last block when importing from archive"); + } + } + + // Everything seems okay, so go ahead with the import + for (Triple, List> blockInfo : blockInfoList) { + try { + // Save block + repository.getBlockRepository().save(blockInfo.getA()); + + // Save AT state data hashes + for (ATStateData atStateData : blockInfo.getC()) { + atStateData.setHeight(blockInfo.getA().getHeight()); + repository.getATRepository().save(atStateData); + } + + } catch (DataException e) { + repository.discardChanges(); + throw new IllegalStateException("Unable to import blocks from archive"); + } + } + repository.saveChanges(); + } + +} From 74ea2a847dd2ce543d9d28329e2ca7b2d7f0a1dc Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 12 Sep 2021 10:13:52 +0100 Subject: [PATCH 115/231] Added unit tests for trimming, pruning, and archiving. --- .../org/qortal/controller/BlockMinter.java | 3 +- .../qortal/repository/BlockArchiveWriter.java | 25 +- .../org/qortal/test/BlockArchiveTests.java | 500 ++++++++++++++++++ src/test/java/org/qortal/test/PruneTests.java | 91 ++++ .../org/qortal/test/at/AtRepositoryTests.java | 158 +++--- .../java/org/qortal/test/common/AtUtils.java | 81 +++ .../test-settings-v2-block-archive.json | 11 + 7 files changed, 781 insertions(+), 88 deletions(-) create mode 100644 src/test/java/org/qortal/test/BlockArchiveTests.java create mode 100644 src/test/java/org/qortal/test/PruneTests.java create mode 100644 src/test/java/org/qortal/test/common/AtUtils.java create mode 100644 src/test/resources/test-settings-v2-block-archive.json diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 0cf33f43..33431258 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -442,7 +442,8 @@ public class BlockMinter extends Thread { // Add to blockchain newBlock.process(); - LOGGER.info(String.format("Minted new test block: %d", newBlock.getBlockData().getHeight())); + LOGGER.info(String.format("Minted new test block: %d sig: %.8s", + newBlock.getBlockData().getHeight(), Base58.encode(newBlock.getBlockData().getSignature()))); repository.saveChanges(); diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index 11151e17..77c98d96 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -33,7 +33,11 @@ public class BlockArchiveWriter { private final int endHeight; private final Repository repository; + private long fileSizeTarget = 100 * 1024 * 1024; // 100MiB + private boolean shouldEnforceFileSizeTarget = true; + private int writtenCount; + private Path outputPath; public BlockArchiveWriter(int startHeight, int endHeight, Repository repository) { this.startHeight = startHeight; @@ -87,8 +91,9 @@ public class BlockArchiveWriter { LOGGER.info(String.format("Fetching blocks from height %d...", startHeight)); int i = 0; - long fileSizeTarget = 100 * 1024 * 1024; // 100MiB - while (headerBytes.size() + bytes.size() < fileSizeTarget) { + while (headerBytes.size() + bytes.size() < this.fileSizeTarget + || this.shouldEnforceFileSizeTarget == false) { + if (Controller.isStopping()) { return BlockArchiveWriteResult.STOPPING; } @@ -132,7 +137,7 @@ public class BlockArchiveWriter { LOGGER.info(String.format("Total length of %d blocks is %d bytes", i, totalLength)); // Validate file size, in case something went wrong - if (totalLength < fileSizeTarget) { + if (totalLength < fileSizeTarget && this.shouldEnforceFileSizeTarget) { return BlockArchiveWriteResult.NOT_ENOUGH_BLOCKS; } @@ -164,6 +169,7 @@ public class BlockArchiveWriter { BlockArchiveReader.getInstance().invalidateFileListCache(); this.writtenCount = i; + this.outputPath = Paths.get(filePath); return BlockArchiveWriteResult.OK; } @@ -171,4 +177,17 @@ public class BlockArchiveWriter { return this.writtenCount; } + public Path getOutputPath() { + return this.outputPath; + } + + public void setFileSizeTarget(long fileSizeTarget) { + this.fileSizeTarget = fileSizeTarget; + } + + // For testing, to avoid having to pre-calculate file sizes + public void setShouldEnforceFileSizeTarget(boolean shouldEnforceFileSizeTarget) { + this.shouldEnforceFileSizeTarget = shouldEnforceFileSizeTarget; + } + } diff --git a/src/test/java/org/qortal/test/BlockArchiveTests.java b/src/test/java/org/qortal/test/BlockArchiveTests.java new file mode 100644 index 00000000..c05915cd --- /dev/null +++ b/src/test/java/org/qortal/test/BlockArchiveTests.java @@ -0,0 +1,500 @@ +package org.qortal.test; + +import org.apache.commons.io.FileUtils; +import org.ciyam.at.CompilationException; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.controller.BlockMinter; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.*; +import org.qortal.settings.Settings; +import org.qortal.test.common.AtUtils; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transform.TransformationException; +import org.qortal.utils.BlockArchiveUtils; +import org.qortal.utils.Triple; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.*; + +public class BlockArchiveTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); // Necessary to set NTP offset + Common.useSettings("test-settings-v2-block-archive.json"); + this.deleteArchiveDirectory(); + } + + @After + public void afterTest() throws DataException { + this.deleteArchiveDirectory(); + } + + + @Test + public void testWriter() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online + List mintingAndOnlineAccounts = new ArrayList<>(); + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + mintingAndOnlineAccounts.add(aliceSelfShare); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) + BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + + // 900 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); + repository.getATRepository().setAtTrimHeight(901); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(900, maximumArchiveHeight); + + // Write blocks 2-900 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(900 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(900 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + } + } + + @Test + public void testWriterAndReader() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online + List mintingAndOnlineAccounts = new ArrayList<>(); + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + mintingAndOnlineAccounts.add(aliceSelfShare); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) + BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + + // 900 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); + repository.getATRepository().setAtTrimHeight(901); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(900, maximumArchiveHeight); + + // Write blocks 2-900 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(900 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(900 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Read block 2 from the archive + BlockArchiveReader reader = BlockArchiveReader.getInstance(); + Triple, List> block2Info = reader.fetchBlockAtHeight(2); + BlockData block2ArchiveData = block2Info.getA(); + + // Read block 2 from the repository + BlockData block2RepositoryData = repository.getBlockRepository().fromHeight(2); + + // Ensure the values match + assertEquals(block2ArchiveData.getHeight(), block2RepositoryData.getHeight()); + assertArrayEquals(block2ArchiveData.getSignature(), block2RepositoryData.getSignature()); + + // Test some values in the archive + assertEquals(1, block2ArchiveData.getOnlineAccountsCount()); + + // Read block 900 from the archive + Triple, List> block900Info = reader.fetchBlockAtHeight(900); + BlockData block900ArchiveData = block900Info.getA(); + + // Read block 900 from the repository + BlockData block900RepositoryData = repository.getBlockRepository().fromHeight(900); + + // Ensure the values match + assertEquals(block900ArchiveData.getHeight(), block900RepositoryData.getHeight()); + assertArrayEquals(block900ArchiveData.getSignature(), block900RepositoryData.getSignature()); + + // Test some values in the archive + assertEquals(1, block900ArchiveData.getOnlineAccountsCount()); + + } + } + + @Test + public void testArchivedAtStates() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online + List mintingAndOnlineAccounts = new ArrayList<>(); + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + mintingAndOnlineAccounts.add(aliceSelfShare); + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 10; i++) + BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + + // 9 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(10); + repository.getATRepository().setAtTrimHeight(10); + + // Check the max archive height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(9, maximumArchiveHeight); + + // Write blocks 2-9 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(9 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(9 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Check blocks 3-9 + for (Integer testHeight = 2; testHeight <= 9; testHeight++) { + + // Read a block from the archive + BlockArchiveReader reader = BlockArchiveReader.getInstance(); + Triple, List> blockInfo = reader.fetchBlockAtHeight(testHeight); + BlockData archivedBlockData = blockInfo.getA(); + ATStateData archivedAtStateData = blockInfo.getC().isEmpty() ? null : blockInfo.getC().get(0); + List archivedTransactions = blockInfo.getB(); + + // Read the same block from the repository + BlockData repositoryBlockData = repository.getBlockRepository().fromHeight(testHeight); + ATStateData repositoryAtStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + // Ensure the repository has full AT state data + assertNotNull(repositoryAtStateData.getStateHash()); + assertNotNull(repositoryAtStateData.getStateData()); + + // Check the archived AT state + if (testHeight == 2) { + // Block 2 won't have an AT state hash because it's initial (and has the DEPLOY_AT in the same block) + assertNull(archivedAtStateData); + + assertEquals(1, archivedTransactions.size()); + assertEquals(Transaction.TransactionType.DEPLOY_AT, archivedTransactions.get(0).getType()); + } + else { + // For blocks 3+, ensure the archive has the AT state data, but not the hashes + assertNotNull(archivedAtStateData.getStateHash()); + assertNull(archivedAtStateData.getStateData()); + + // They also shouldn't have any transactions + assertTrue(archivedTransactions.isEmpty()); + } + + // Also check the online accounts count and height + assertEquals(1, archivedBlockData.getOnlineAccountsCount()); + assertEquals(testHeight, archivedBlockData.getHeight()); + + // Ensure the values match + assertEquals(archivedBlockData.getHeight(), repositoryBlockData.getHeight()); + assertArrayEquals(archivedBlockData.getSignature(), repositoryBlockData.getSignature()); + assertEquals(archivedBlockData.getOnlineAccountsCount(), repositoryBlockData.getOnlineAccountsCount()); + assertArrayEquals(archivedBlockData.getMinterSignature(), repositoryBlockData.getMinterSignature()); + assertEquals(archivedBlockData.getATCount(), repositoryBlockData.getATCount()); + assertEquals(archivedBlockData.getOnlineAccountsCount(), repositoryBlockData.getOnlineAccountsCount()); + assertArrayEquals(archivedBlockData.getReference(), repositoryBlockData.getReference()); + assertEquals(archivedBlockData.getTimestamp(), repositoryBlockData.getTimestamp()); + assertEquals(archivedBlockData.getATFees(), repositoryBlockData.getATFees()); + assertEquals(archivedBlockData.getTotalFees(), repositoryBlockData.getTotalFees()); + assertEquals(archivedBlockData.getTransactionCount(), repositoryBlockData.getTransactionCount()); + assertArrayEquals(archivedBlockData.getTransactionsSignature(), repositoryBlockData.getTransactionsSignature()); + + if (testHeight != 2) { + assertArrayEquals(archivedAtStateData.getStateHash(), repositoryAtStateData.getStateHash()); + } + } + + // Check block 10 (unarchived) + BlockArchiveReader reader = BlockArchiveReader.getInstance(); + Triple, List> blockInfo = reader.fetchBlockAtHeight(10); + assertNull(blockInfo); + + } + + } + + @Test + public void testArchiveAndPrune() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online + List mintingAndOnlineAccounts = new ArrayList<>(); + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + mintingAndOnlineAccounts.add(aliceSelfShare); + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) + BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Assume 900 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); + repository.getATRepository().setAtTrimHeight(901); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(900, maximumArchiveHeight); + + // Write blocks 2-900 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(900 - 1, writer.getWrittenCount()); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(900 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Ensure the SQL repository contains blocks 2 and 900... + assertNotNull(repository.getBlockRepository().fromHeight(2)); + assertNotNull(repository.getBlockRepository().fromHeight(900)); + + // Prune all the archived blocks + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(0, 900); + assertEquals(900-1, numBlocksPruned); + repository.getBlockRepository().setBlockPruneHeight(901); + + // Prune the AT states for the archived blocks + repository.getATRepository().rebuildLatestAtStates(); + int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 900); + assertEquals(900-1, numATStatesPruned); + repository.getATRepository().setAtPruneHeight(901); + + // Now ensure the SQL repository is missing blocks 2 and 900... + assertNull(repository.getBlockRepository().fromHeight(2)); + assertNull(repository.getBlockRepository().fromHeight(900)); + + // ... but it's not missing blocks 1 and 901 (we don't prune the genesis block) + assertNotNull(repository.getBlockRepository().fromHeight(1)); + assertNotNull(repository.getBlockRepository().fromHeight(901)); + + // Validate the latest block height in the repository + assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); + + } + } + + @Test + public void testTrimArchivePruneAndOrphan() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online + List mintingAndOnlineAccounts = new ArrayList<>(); + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + mintingAndOnlineAccounts.add(aliceSelfShare); + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) + BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Make sure that block 500 has full AT state data and data hash + List block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); + ATStateData atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + + // Trim the first 500 blocks + repository.getBlockRepository().trimOldOnlineAccountsSignatures(0, 500); + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(501); + repository.getATRepository().trimAtStates(0, 500, 1000); + repository.getATRepository().setAtTrimHeight(501); + + // Now block 500 should only have the AT state data hash + block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); + atStatesData = repository.getATRepository().getATStateAtHeight(block500AtStatesData.get(0).getATAddress(), 500); + assertNotNull(atStatesData.getStateHash()); + assertNull(atStatesData.getStateData()); + + // ... but block 501 should have the full data + List block501AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(501); + atStatesData = repository.getATRepository().getATStateAtHeight(block501AtStatesData.get(0).getATAddress(), 501); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(500, maximumArchiveHeight); + + BlockData block3DataPreArchive = repository.getBlockRepository().fromHeight(3); + + // Write blocks 2-500 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + assertEquals(BlockArchiveWriter.BlockArchiveWriteResult.OK, result); + + // Make sure that the archive contains the correct number of blocks + assertEquals(500 - 1, writer.getWrittenCount()); // -1 for the genesis block + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.saveChanges(); + assertEquals(500 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the file exists + File outputFile = writer.getOutputPath().toFile(); + assertTrue(outputFile.exists()); + + // Ensure the SQL repository contains blocks 2 and 500... + assertNotNull(repository.getBlockRepository().fromHeight(2)); + assertNotNull(repository.getBlockRepository().fromHeight(500)); + + // Prune all the archived blocks + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(0, 500); + assertEquals(500-1, numBlocksPruned); + repository.getBlockRepository().setBlockPruneHeight(501); + + // Prune the AT states for the archived blocks + repository.getATRepository().rebuildLatestAtStates(); + int numATStatesPruned = repository.getATRepository().pruneAtStates(2, 500); + assertEquals(499, numATStatesPruned); + repository.getATRepository().setAtPruneHeight(501); + + // Now ensure the SQL repository is missing blocks 2 and 500... + assertNull(repository.getBlockRepository().fromHeight(2)); + assertNull(repository.getBlockRepository().fromHeight(500)); + + // ... but it's not missing blocks 1 and 501 (we don't prune the genesis block) + assertNotNull(repository.getBlockRepository().fromHeight(1)); + assertNotNull(repository.getBlockRepository().fromHeight(501)); + + // Validate the latest block height in the repository + assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); + + // Now orphan some unarchived blocks. + BlockUtils.orphanBlocks(repository, 500); + assertEquals(502, (int) repository.getBlockRepository().getLastBlock().getHeight()); + + // We're close to the lower limit of the SQL database now, so + // we need to import some blocks from the archive + BlockArchiveUtils.importFromArchive(401, 500, repository); + + // Ensure the SQL repository now contains block 401 but not 400... + assertNotNull(repository.getBlockRepository().fromHeight(401)); + assertNull(repository.getBlockRepository().fromHeight(400)); + + // Import the remaining 399 blocks + BlockArchiveUtils.importFromArchive(2, 400, repository); + + // Verify that block 3 matches the original + BlockData block3DataPostArchive = repository.getBlockRepository().fromHeight(3); + assertArrayEquals(block3DataPreArchive.getSignature(), block3DataPostArchive.getSignature()); + assertEquals(block3DataPreArchive.getHeight(), block3DataPostArchive.getHeight()); + + // Orphan 1 more block, which should be the last one that is possible to be orphaned + BlockUtils.orphanBlocks(repository, 1); + + // Orphan another block, which should fail + Exception exception = null; + try { + BlockUtils.orphanBlocks(repository, 1); + } catch (DataException e) { + exception = e; + } + + // Ensure that a DataException is thrown because there is no more AT states data available + assertNotNull(exception); + assertEquals(DataException.class, exception.getClass()); + + // FUTURE: we may be able to retain unique AT states when trimming, to avoid this exception + // and allow orphaning back through blocks with trimmed AT states. + + } + } + + + private void deleteArchiveDirectory() { + // Delete archive directory if exists + Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toAbsolutePath(); + try { + FileUtils.deleteDirectory(archivePath.toFile()); + } catch (IOException e) { + + } + } + +} diff --git a/src/test/java/org/qortal/test/PruneTests.java b/src/test/java/org/qortal/test/PruneTests.java new file mode 100644 index 00000000..0914d794 --- /dev/null +++ b/src/test/java/org/qortal/test/PruneTests.java @@ -0,0 +1,91 @@ +package org.qortal.test; + +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.controller.BlockMinter; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.AtUtils; +import org.qortal.test.common.Common; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.*; + +public class PruneTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testPruning() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online + List mintingAndOnlineAccounts = new ArrayList<>(); + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + mintingAndOnlineAccounts.add(aliceSelfShare); + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + + // Mint some blocks + for (int i = 2; i <= 10; i++) + BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Make sure that all blocks have full AT state data and data hash + for (Integer i=2; i <= 10; i++) { + BlockData blockData = repository.getBlockRepository().fromHeight(i); + assertNotNull(blockData.getSignature()); + assertEquals(i, blockData.getHeight()); + List atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(i); + assertNotNull(atStatesDataList); + assertFalse(atStatesDataList.isEmpty()); + ATStateData atStatesData = repository.getATRepository().getATStateAtHeight(atStatesDataList.get(0).getATAddress(), i); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + } + + // Prune blocks 2-5 + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(0, 5); + assertEquals(4, numBlocksPruned); + repository.getBlockRepository().setBlockPruneHeight(6); + + // Prune AT states for blocks 2-5 + int numATStatesPruned = repository.getATRepository().pruneAtStates(0, 5); + assertEquals(4, numATStatesPruned); + repository.getATRepository().setAtPruneHeight(6); + + // Make sure that blocks 2-5 are now missing block data and AT states data + for (Integer i=2; i <= 5; i++) { + BlockData blockData = repository.getBlockRepository().fromHeight(i); + assertNull(blockData); + List atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(i); + assertTrue(atStatesDataList.isEmpty()); + } + + // ... but blocks 6-10 have block data and full AT states data + for (Integer i=6; i <= 10; i++) { + BlockData blockData = repository.getBlockRepository().fromHeight(i); + assertNotNull(blockData.getSignature()); + List atStatesDataList = repository.getATRepository().getBlockATStatesAtHeight(i); + assertNotNull(atStatesDataList); + assertFalse(atStatesDataList.isEmpty()); + ATStateData atStatesData = repository.getATRepository().getATStateAtHeight(atStatesDataList.get(0).getATAddress(), i); + assertNotNull(atStatesData.getStateHash()); + assertNotNull(atStatesData.getStateData()); + } + } + } + +} diff --git a/src/test/java/org/qortal/test/at/AtRepositoryTests.java b/src/test/java/org/qortal/test/at/AtRepositoryTests.java index 0b302435..8ef4c774 100644 --- a/src/test/java/org/qortal/test/at/AtRepositoryTests.java +++ b/src/test/java/org/qortal/test/at/AtRepositoryTests.java @@ -21,6 +21,7 @@ import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.AtUtils; import org.qortal.test.common.BlockUtils; import org.qortal.test.common.Common; import org.qortal.test.common.TransactionUtils; @@ -35,13 +36,13 @@ public class AtRepositoryTests extends Common { @Test public void testGetATStateAtHeightWithData() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -58,13 +59,13 @@ public class AtRepositoryTests extends Common { @Test public void testGetATStateAtHeightWithoutData() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -87,13 +88,13 @@ public class AtRepositoryTests extends Common { @Test public void testGetLatestATStateWithData() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -111,13 +112,13 @@ public class AtRepositoryTests extends Common { @Test public void testGetLatestATStatePostTrimming() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -144,14 +145,66 @@ public class AtRepositoryTests extends Common { } @Test - public void testGetMatchingFinalATStatesWithoutDataValue() throws DataException { - byte[] creationBytes = buildSimpleAT(); + public void testOrphanTrimmedATStates() throws DataException { + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + int maxTrimHeight = blockchainHeight - 4; + Integer testHeight = maxTrimHeight + 1; + + // Trim AT state data + repository.getATRepository().rebuildLatestAtStates(); + repository.saveChanges(); + repository.getATRepository().trimAtStates(2, maxTrimHeight, 1000); + + // Orphan 3 blocks + // This leaves one more untrimmed block, so the latest AT state should be available + BlockUtils.orphanBlocks(repository, 3); + + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + assertEquals(testHeight, atStateData.getHeight()); + + // We should always have the latest AT state data available + assertNotNull(atStateData.getStateData()); + + // Orphan 1 more block + Exception exception = null; + try { + BlockUtils.orphanBlocks(repository, 1); + } catch (DataException e) { + exception = e; + } + + // Ensure that a DataException is thrown because there is no more AT states data available + assertNotNull(exception); + assertEquals(DataException.class, exception.getClass()); + assertEquals(String.format("Can't find previous AT state data for %s", atAddress), exception.getMessage()); + + // FUTURE: we may be able to retain unique AT states when trimming, to avoid this exception + // and allow orphaning back through blocks with trimmed AT states. + } + } + + @Test + public void testGetMatchingFinalATStatesWithoutDataValue() throws DataException { + byte[] creationBytes = AtUtils.buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -191,13 +244,13 @@ public class AtRepositoryTests extends Common { @Test public void testGetMatchingFinalATStatesWithDataValue() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -237,13 +290,13 @@ public class AtRepositoryTests extends Common { @Test public void testGetBlockATStatesAtHeightWithData() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - doDeploy(repository, deployer, creationBytes, fundingAmount); + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); // Mint a few blocks for (int i = 0; i < 10; ++i) @@ -264,13 +317,13 @@ public class AtRepositoryTests extends Common { @Test public void testGetBlockATStatesAtHeightWithoutData() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - doDeploy(repository, deployer, creationBytes, fundingAmount); + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); // Mint a few blocks for (int i = 0; i < 10; ++i) @@ -297,13 +350,13 @@ public class AtRepositoryTests extends Common { @Test public void testSaveATStateWithData() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -328,13 +381,13 @@ public class AtRepositoryTests extends Common { @Test public void testSaveATStateWithoutData() throws DataException { - byte[] creationBytes = buildSimpleAT(); + byte[] creationBytes = AtUtils.buildSimpleAT(); try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); long fundingAmount = 1_00000000L; - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + DeployAtTransaction deployAtTransaction = AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint a few blocks @@ -364,67 +417,4 @@ public class AtRepositoryTests extends Common { assertNull(atStateData.getStateData()); } } - - private byte[] buildSimpleAT() { - // Pretend we use 4 values in data segment - int addrCounter = 4; - - // Data segment - ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); - - ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); - - // Two-pass version - for (int pass = 0; pass < 2; ++pass) { - codeByteBuffer.clear(); - - try { - // Stop and wait for next block - codeByteBuffer.put(OpCode.STP_IMD.compile()); - } catch (CompilationException e) { - throw new IllegalStateException("Unable to compile AT?", e); - } - } - - codeByteBuffer.flip(); - - byte[] codeBytes = new byte[codeByteBuffer.limit()]; - codeByteBuffer.get(codeBytes); - - final short ciyamAtVersion = 2; - final short numCallStackPages = 0; - final short numUserStackPages = 0; - final long minActivationAmount = 0L; - - return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); - } - - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { - long txTimestamp = System.currentTimeMillis(); - byte[] lastReference = deployer.getLastReference(); - - if (lastReference == null) { - System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); - System.exit(2); - } - - Long fee = null; - String name = "Test AT"; - String description = "Test AT"; - String atType = "Test"; - String tags = "TEST"; - - BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); - - DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); - - fee = deployAtTransaction.calcRecommendedFee(); - deployAtTransactionData.setFee(fee); - - TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); - - return deployAtTransaction; - } - } diff --git a/src/test/java/org/qortal/test/common/AtUtils.java b/src/test/java/org/qortal/test/common/AtUtils.java new file mode 100644 index 00000000..3bc2b235 --- /dev/null +++ b/src/test/java/org/qortal/test/common/AtUtils.java @@ -0,0 +1,81 @@ +package org.qortal.test.common; + +import org.ciyam.at.CompilationException; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.transaction.DeployAtTransaction; + +import java.nio.ByteBuffer; + +public class AtUtils { + + public static byte[] buildSimpleAT() { + // Pretend we use 4 values in data segment + int addrCounter = 4; + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile AT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + public static DeployAtTransaction doDeployAT(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "Test AT"; + String description = "Test AT"; + String atType = "Test"; + String tags = "TEST"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } +} diff --git a/src/test/resources/test-settings-v2-block-archive.json b/src/test/resources/test-settings-v2-block-archive.json new file mode 100644 index 00000000..612c8658 --- /dev/null +++ b/src/test/resources/test-settings-v2-block-archive.json @@ -0,0 +1,11 @@ +{ + "bitcoinNet": "TEST3", + "litecoinNet": "TEST3", + "restrictedApi": false, + "blockchainConfig": "src/test/resources/test-chain-v2.json", + "wipeUnconfirmedOnStart": false, + "testNtpOffset": 0, + "minPeers": 0, + "pruneBlockLimit": 1450, + "repositoryPath": "dbtest" +} From 48562238386806d5539743561eb6e7095e420baa Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 24 Sep 2021 08:50:00 +0100 Subject: [PATCH 116/231] Fixed error in rebase. --- src/main/java/org/qortal/controller/Controller.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index bce17b08..7755fd4d 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -45,7 +45,7 @@ import org.qortal.block.Block; import org.qortal.block.BlockChain; import org.qortal.block.BlockChain.BlockTimingByHeight; import org.qortal.controller.Synchronizer.SynchronizationResult; -import org.qortal.controller.pruning.PruneManager; +import org.qortal.controller.repository.PruneManager; import org.qortal.controller.repository.NamesDatabaseIntegrityCheck; import org.qortal.controller.tradebot.TradeBot; import org.qortal.crypto.Crypto; From 1f79d8884064e064f31f64bbbd7d797c93d78a77 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 24 Sep 2021 09:35:36 +0100 Subject: [PATCH 117/231] Fixed errors found in unit tests. --- .../qortal/api/resource/BlocksResource.java | 2 +- .../qortal/repository/BlockArchiveReader.java | 28 ++++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/BlocksResource.java b/src/main/java/org/qortal/api/resource/BlocksResource.java index 6dc13c8a..b9ffe03c 100644 --- a/src/main/java/org/qortal/api/resource/BlocksResource.java +++ b/src/main/java/org/qortal/api/resource/BlocksResource.java @@ -761,7 +761,7 @@ public class BlocksResource { // Use the latest X blocks if only a count is specified if (startHeight == null && endHeight == null && count != null) { - BlockData chainTip = Controller.getInstance().getChainTip(); + BlockData chainTip = repository.getBlockRepository().getLastBlock(); startHeight = chainTip.getHeight() - count; endHeight = chainTip.getHeight(); } diff --git a/src/main/java/org/qortal/repository/BlockArchiveReader.java b/src/main/java/org/qortal/repository/BlockArchiveReader.java index 081917b2..f64b7a2a 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveReader.java +++ b/src/main/java/org/qortal/repository/BlockArchiveReader.java @@ -47,21 +47,23 @@ public class BlockArchiveReader { String[] files = archiveDirFile.list(); Map> map = new HashMap<>(); - for (String file : files) { - Path filePath = Paths.get(file); - String filename = filePath.getFileName().toString(); + if (files != null) { + for (String file : files) { + Path filePath = Paths.get(file); + String filename = filePath.getFileName().toString(); - // Parse the filename - if (filename == null || !filename.contains("-") || !filename.contains(".")) { - // Not a usable file - continue; + // Parse the filename + if (filename == null || !filename.contains("-") || !filename.contains(".")) { + // Not a usable file + continue; + } + // Remove the extension and split into two parts + String[] parts = filename.substring(0, filename.lastIndexOf('.')).split("-"); + Integer startHeight = Integer.parseInt(parts[0]); + Integer endHeight = Integer.parseInt(parts[1]); + Integer range = endHeight - startHeight; + map.put(filename, new Triple(startHeight, endHeight, range)); } - // Remove the extension and split into two parts - String[] parts = filename.substring(0, filename.lastIndexOf('.')).split("-"); - Integer startHeight = Integer.parseInt(parts[0]); - Integer endHeight = Integer.parseInt(parts[1]); - Integer range = endHeight - startHeight; - map.put(filename, new Triple(startHeight, endHeight, range)); } this.fileListCache = map; } From 4c171df848ebd5857c30d41cce45c772d74def75 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 24 Sep 2021 11:14:18 +0100 Subject: [PATCH 118/231] Disable archiving and pruning if the AtStatesHeightIndex is missing, and log it so that the user knows they should bootstrap or resync. --- .../controller/repository/BlockArchiver.java | 7 +++ .../controller/repository/BlockPruner.java | 7 +++ .../org/qortal/repository/ATRepository.java | 6 +++ .../qortal/repository/RepositoryManager.java | 46 +++++++++++++------ .../repository/hsqldb/HSQLDBATRepository.java | 13 ++++++ .../org/qortal/test/BlockArchiveTests.java | 25 ++++++++++ 6 files changed, 90 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiver.java b/src/main/java/org/qortal/controller/repository/BlockArchiver.java index d6860347..15b9b226 100644 --- a/src/main/java/org/qortal/controller/repository/BlockArchiver.java +++ b/src/main/java/org/qortal/controller/repository/BlockArchiver.java @@ -27,6 +27,13 @@ public class BlockArchiver implements Runnable { try (final Repository repository = RepositoryManager.getRepository()) { int startHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); + // Don't attempt to archive if we have no ATStatesHeightIndex, as it will be too slow + boolean hasAtStatesHeightIndex = repository.getATRepository().hasAtStatesHeightIndex(); + if (!hasAtStatesHeightIndex) { + LOGGER.info("Unable to start block archiver due to missing ATStatesHeightIndex. Bootstrapping is recommended."); + return; + } + // Don't even start building until initial rush has ended Thread.sleep(INITIAL_SLEEP_PERIOD); diff --git a/src/main/java/org/qortal/controller/repository/BlockPruner.java b/src/main/java/org/qortal/controller/repository/BlockPruner.java index f8fd2195..f5be6ee8 100644 --- a/src/main/java/org/qortal/controller/repository/BlockPruner.java +++ b/src/main/java/org/qortal/controller/repository/BlockPruner.java @@ -34,6 +34,13 @@ public class BlockPruner implements Runnable { try (final Repository repository = RepositoryManager.getRepository()) { int pruneStartHeight = repository.getBlockRepository().getBlockPruneHeight(); + // Don't attempt to prune if we have no ATStatesHeightIndex, as it will be too slow + boolean hasAtStatesHeightIndex = repository.getATRepository().hasAtStatesHeightIndex(); + if (!hasAtStatesHeightIndex) { + LOGGER.info("Unable to start block pruner due to missing ATStatesHeightIndex. Bootstrapping is recommended."); + return; + } + while (!Controller.isStopping()) { repository.discardChanges(); diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 9316875d..0f537ae9 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -1,5 +1,7 @@ package org.qortal.repository; +import java.sql.ResultSet; +import java.sql.SQLException; import java.util.List; import java.util.Set; @@ -146,6 +148,10 @@ public interface ATRepository { public int pruneAtStates(int minHeight, int maxHeight) throws DataException; + /** Checks for the presence of the ATStatesHeightIndex in repository */ + public boolean hasAtStatesHeightIndex() throws DataException; + + /** * Save ATStateData into repository. *

diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index f7557750..c392d213 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -61,11 +61,16 @@ public abstract class RepositoryManager { public static boolean archive() { // Bulk archive the database the first time we use archive mode if (Settings.getInstance().isArchiveEnabled()) { - try { - return HSQLDBDatabaseArchiving.buildBlockArchive(); + if (RepositoryManager.canArchiveOrPrune()) { + try { + return HSQLDBDatabaseArchiving.buildBlockArchive(); - } catch (DataException e) { - LOGGER.info("Unable to bulk prune AT states. The database may have been left in an inconsistent state."); + } catch (DataException e) { + LOGGER.info("Unable to build block archive. The database may have been left in an inconsistent state."); + } + } + else { + LOGGER.info("Unable to build block archive due to missing ATStatesHeightIndex. Bootstrapping is recommended."); } } return false; @@ -75,18 +80,23 @@ public abstract class RepositoryManager { // Bulk prune the database the first time we use pruning mode if (Settings.getInstance().isPruningEnabled() || Settings.getInstance().isArchiveEnabled()) { - try { - boolean prunedATStates = HSQLDBDatabasePruning.pruneATStates(); - boolean prunedBlocks = HSQLDBDatabasePruning.pruneBlocks(); + if (RepositoryManager.canArchiveOrPrune()) { + try { + boolean prunedATStates = HSQLDBDatabasePruning.pruneATStates(); + boolean prunedBlocks = HSQLDBDatabasePruning.pruneBlocks(); - // Perform repository maintenance to shrink the db size down - if (prunedATStates && prunedBlocks) { - HSQLDBDatabasePruning.performMaintenance(); - return true; + // Perform repository maintenance to shrink the db size down + if (prunedATStates && prunedBlocks) { + HSQLDBDatabasePruning.performMaintenance(); + return true; + } + + } catch (SQLException | DataException e) { + LOGGER.info("Unable to bulk prune AT states. The database may have been left in an inconsistent state."); } - - } catch (SQLException | DataException e) { - LOGGER.info("Unable to bulk prune AT states. The database may have been left in an inconsistent state."); + } + else { + LOGGER.info("Unable to prune blocks due to missing ATStatesHeightIndex. Bootstrapping is recommended."); } } return false; @@ -118,4 +128,12 @@ public abstract class RepositoryManager { return SQLException.class.isInstance(cause) && repositoryFactory.isDeadlockException((SQLException) cause); } + public static boolean canArchiveOrPrune() { + try (final Repository repository = getRepository()) { + return repository.getATRepository().hasAtStatesHeightIndex(); + } catch (DataException e) { + return false; + } + } + } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 56658ec7..85196d31 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -798,6 +798,19 @@ public class HSQLDBATRepository implements ATRepository { } + @Override + public boolean hasAtStatesHeightIndex() throws DataException { + String sql = "SELECT INDEX_NAME FROM INFORMATION_SCHEMA.SYSTEM_INDEXINFO where INDEX_NAME='ATSTATESHEIGHTINDEX'"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + return resultSet != null; + + } catch (SQLException e) { + throw new DataException("Unable to check for ATStatesHeightIndex in repository", e); + } + } + + @Override public void save(ATStateData atStateData) throws DataException { // We shouldn't ever save partial ATStateData diff --git a/src/test/java/org/qortal/test/BlockArchiveTests.java b/src/test/java/org/qortal/test/BlockArchiveTests.java index c05915cd..373c98f2 100644 --- a/src/test/java/org/qortal/test/BlockArchiveTests.java +++ b/src/test/java/org/qortal/test/BlockArchiveTests.java @@ -17,6 +17,7 @@ import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.group.Group; import org.qortal.repository.*; +import org.qortal.repository.hsqldb.HSQLDBRepository; import org.qortal.settings.Settings; import org.qortal.test.common.AtUtils; import org.qortal.test.common.BlockUtils; @@ -33,6 +34,7 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.nio.file.Path; import java.nio.file.Paths; +import java.sql.SQLException; import java.util.ArrayList; import java.util.List; @@ -487,6 +489,29 @@ public class BlockArchiveTests extends Common { } + /** + * Many nodes are missing an ATStatesHeightIndex due to an earlier bug + * In these cases we disable archiving and pruning as this index is a + * very essential component in these processes. + */ + @Test + public void testMissingAtStatesHeightIndex() throws DataException, SQLException { + try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { + + // Firstly check that we're able to prune or archive when the index exists + assertTrue(repository.getATRepository().hasAtStatesHeightIndex()); + assertTrue(RepositoryManager.canArchiveOrPrune()); + + // Delete the index + repository.prepareStatement("DROP INDEX ATSTATESHEIGHTINDEX").execute(); + + // Ensure check that we're unable to prune or archive when the index doesn't exist + assertFalse(repository.getATRepository().hasAtStatesHeightIndex()); + assertFalse(RepositoryManager.canArchiveOrPrune()); + } + } + + private void deleteArchiveDirectory() { // Delete archive directory if exists Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toAbsolutePath(); From 841b6c4ddf0163d764960d7717910218bef166ed Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 24 Sep 2021 15:58:09 +0100 Subject: [PATCH 119/231] Fixed another issue causing ATStatesHeightIndex to go missing after pruning. --- .../repository/hsqldb/HSQLDBDatabasePruning.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index 65139743..7cbd933f 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -71,6 +71,11 @@ public class HSQLDBDatabasePruning { repository.executeCheckedUpdate("SET TABLE ATStatesNew NEW SPACE"); repository.executeCheckedUpdate("CHECKPOINT"); + // Add a height index + LOGGER.info("Adding index to AT states table..."); + repository.executeCheckedUpdate("CREATE INDEX IF NOT EXISTS ATStatesNewHeightIndex ON ATStatesNew (height)"); + repository.executeCheckedUpdate("CHECKPOINT"); + // Find our latest block BlockData latestBlock = repository.getBlockRepository().getLastBlock(); @@ -149,15 +154,12 @@ public class HSQLDBDatabasePruning { repository.saveChanges(); - // Add a height index - LOGGER.info("Rebuilding AT states height index in repository"); - repository.executeCheckedUpdate("CREATE INDEX IF NOT EXISTS ATStatesHeightIndex ON ATStatesNew (height)"); - repository.executeCheckedUpdate("CHECKPOINT"); // Finally, drop the original table and rename LOGGER.info("Deleting old AT states..."); repository.executeCheckedUpdate("DROP TABLE ATStates"); repository.executeCheckedUpdate("ALTER TABLE ATStatesNew RENAME TO ATStates"); + repository.executeCheckedUpdate("ALTER INDEX ATStatesNewHeightIndex RENAME TO ATStatesHeightIndex"); repository.executeCheckedUpdate("CHECKPOINT"); // Update the prune height From 19bf8afece43b2d6d48accf8f88dfbd9b9bfd98b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 24 Sep 2021 20:52:45 +0100 Subject: [PATCH 120/231] Fixed bug in pruning phase on node startup This was causing very recent AT states to be deleted accidentally, because we weren't rebuilding the LatestATStates table before running the query. We should add unit tests to cover this process in case there are any other undiscovered problems. --- .../org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index 7cbd933f..e9c92800 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -96,6 +96,10 @@ public class HSQLDBDatabasePruning { final int blockStep = 10000; + // It's essential that we rebuild the latest AT states here, as we are using this data in the next query. + // Failing to do this will result in important AT states being deleted, rendering the database unusable. + repository.getATRepository().rebuildLatestAtStates(); + // Loop through all the LatestATStates and copy them to the new table LOGGER.info("Copying AT states..."); From 656896d16f17e2db645d4764bc2f8795e2042442 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 24 Sep 2021 21:36:05 +0100 Subject: [PATCH 121/231] Fixed issue causing base block prune/trim heights to not be updated on the final pass. --- .../hsqldb/HSQLDBDatabasePruning.java | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index e9c92800..f0673c3f 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -220,19 +220,17 @@ public class HSQLDBDatabasePruning { repository.saveChanges(); if (numATStatesPruned > 0) { - final int finalPruneStartHeight = pruneStartHeight; - LOGGER.trace(() -> String.format("Pruned %d AT states data rows between blocks %d and %d", - numATStatesPruned, finalPruneStartHeight, upperPruneHeight)); + LOGGER.trace(String.format("Pruned %d AT states data rows between blocks %d and %d", + numATStatesPruned, pruneStartHeight, upperPruneHeight)); } else { + repository.getATRepository().setAtTrimHeight(upperBatchHeight); + // No need to rebuild the latest AT states as we aren't currently synchronizing + repository.saveChanges(); + LOGGER.debug(String.format("Bumping AT states trim height to %d", upperBatchHeight)); + // Can we move onto next batch? if (upperPrunableHeight > upperBatchHeight) { pruneStartHeight = upperBatchHeight; - repository.getATRepository().setAtTrimHeight(pruneStartHeight); - // No need to rebuild the latest AT states as we aren't currently synchronizing - repository.saveChanges(); - - final int finalPruneStartHeight = pruneStartHeight; - LOGGER.debug(() -> String.format("Bumping AT states trim height to %d", finalPruneStartHeight)); } else { // We've finished pruning @@ -293,19 +291,17 @@ public class HSQLDBDatabasePruning { repository.saveChanges(); if (numBlocksPruned > 0) { - final int finalPruneStartHeight = pruneStartHeight; - LOGGER.info(() -> String.format("Pruned %d block%s between %d and %d", + LOGGER.info(String.format("Pruned %d block%s between %d and %d", numBlocksPruned, (numBlocksPruned != 1 ? "s" : ""), - finalPruneStartHeight, upperPruneHeight)); + pruneStartHeight, upperPruneHeight)); } else { + repository.getBlockRepository().setBlockPruneHeight(upperBatchHeight); + repository.saveChanges(); + LOGGER.debug(String.format("Bumping block base prune height to %d", upperBatchHeight)); + // Can we move onto next batch? if (upperPrunableHeight > upperBatchHeight) { pruneStartHeight = upperBatchHeight; - repository.getBlockRepository().setBlockPruneHeight(pruneStartHeight); - repository.saveChanges(); - - final int finalPruneStartHeight = pruneStartHeight; - LOGGER.debug(() -> String.format("Bumping block base prune height to %d", finalPruneStartHeight)); } else { // We've finished pruning From 28ff5636af277a9b12c9407958f1c24565343a46 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 26 Sep 2021 22:20:21 +0100 Subject: [PATCH 122/231] Bump version to 1.7.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 4aeb5182..975c434e 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 1.6.0 + 1.7.0 jar true From 32227436e02766c25cbf38f8286f2bca448e8e10 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 27 Sep 2021 08:43:44 +0100 Subject: [PATCH 123/231] Updated AdvancedInstaller project for v1.7.0 --- WindowsInstaller/Qortal.aip | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index fab3d4df..ba177bdf 100755 --- a/WindowsInstaller/Qortal.aip +++ b/WindowsInstaller/Qortal.aip @@ -17,10 +17,10 @@ - + - + @@ -212,7 +212,7 @@ - + From 114833cf8eb84e3bb8e0ef0c92513d828a9298fa Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 27 Sep 2021 09:13:19 +0100 Subject: [PATCH 124/231] Fixed NPE when attempting to lookup a block signature that doesn't exist. --- src/main/java/org/qortal/repository/BlockArchiveReader.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/BlockArchiveReader.java b/src/main/java/org/qortal/repository/BlockArchiveReader.java index f64b7a2a..c173b6f2 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveReader.java +++ b/src/main/java/org/qortal/repository/BlockArchiveReader.java @@ -126,7 +126,7 @@ public class BlockArchiveReader { // Lookup the height for the requested signature try { BlockArchiveData archivedBlock = repository.getBlockArchiveRepository().getBlockArchiveDataForSignature(signature); - if (archivedBlock.getHeight() == null) { + if (archivedBlock == null) { return null; } return archivedBlock.getHeight(); From 8926d2a73c6613653ee5da6553323fa830a4ae8b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 28 Sep 2021 09:24:21 +0100 Subject: [PATCH 125/231] Rework of import/export process. - Adds support for minting accounts as well as trade bot states - Includes automatic import of both types on node startup, and automatic export on node shutdown - Retains legacy trade bot states in a separate "TradeBotStatesArchive.json" file, whilst keeping the current active ones in "TradeBotStates.json". This prevents states being re-imported after they have been removed, but still keeps a copy of the data in case a key is ever needed. - Uses indentation in the JSON files for easier readability. --- .../qortal/api/resource/AdminResource.java | 4 + .../org/qortal/controller/Controller.java | 48 +++ .../qortal/controller/tradebot/TradeBot.java | 6 +- .../data/account/MintingAccountData.java | 19 + .../qortal/repository/AccountRepository.java | 2 + .../org/qortal/repository/Repository.java | 8 +- .../hsqldb/HSQLDBAccountRepository.java | 19 + .../repository/hsqldb/HSQLDBImportExport.java | 298 +++++++++++++ .../repository/hsqldb/HSQLDBRepository.java | 69 +--- .../java/org/qortal/settings/Settings.java | 7 + .../org/qortal/test/ImportExportTests.java | 390 ++++++++++++++++++ .../test-settings-v2-bitcoin-regtest.json | 1 + .../test-settings-v2-block-archive.json | 1 + .../test-settings-v2-founder-rewards.json | 1 + .../test-settings-v2-leftover-reward.json | 1 + .../resources/test-settings-v2-minting.json | 1 + ...test-settings-v2-qora-holder-extremes.json | 1 + .../test-settings-v2-qora-holder.json | 1 + .../test-settings-v2-reward-levels.json | 1 + .../test-settings-v2-reward-scaling.json | 1 + src/test/resources/test-settings-v2.json | 1 + 21 files changed, 810 insertions(+), 70 deletions(-) create mode 100644 src/main/java/org/qortal/repository/hsqldb/HSQLDBImportExport.java create mode 100644 src/test/java/org/qortal/test/ImportExportTests.java diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 3e666fe4..bfcd54ca 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -609,6 +609,10 @@ public class AdminResource { repository.saveChanges(); return "true"; + + } catch (IOException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA, e); + } finally { blockchainLock.unlock(); } diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 7755fd4d..fad244f4 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -424,6 +424,9 @@ public class Controller extends Thread { return; // Not System.exit() so that GUI can display error } + // Import current trade bot states and minting accounts if they exist + Controller.importRepositoryData(); + // Rebuild Names table and check database integrity NamesDatabaseIntegrityCheck namesDatabaseIntegrityCheck = new NamesDatabaseIntegrityCheck(); namesDatabaseIntegrityCheck.rebuildAllNames(); @@ -601,6 +604,47 @@ public class Controller extends Thread { } } + /** + * Import current trade bot states and minting accounts. + * This is needed because the user may have bootstrapped, or there could be a database inconsistency + * if the core crashed when computing the nonce during the start of the trade process. + */ + private static void importRepositoryData() { + try (final Repository repository = RepositoryManager.getRepository()) { + + String exportPath = Settings.getInstance().getExportPath(); + try { + Path importPath = Paths.get(exportPath, "TradeBotStates.json"); + repository.importDataFromFile(importPath.toString()); + } catch (FileNotFoundException e) { + // Do nothing, as the files will only exist in certain cases + } + + try { + Path importPath = Paths.get(exportPath, "MintingAccounts.json"); + repository.importDataFromFile(importPath.toString()); + } catch (FileNotFoundException e) { + // Do nothing, as the files will only exist in certain cases + } + repository.saveChanges(); + } + catch (DataException | IOException e) { + LOGGER.info("Unable to import data into repository: {}", e.getMessage()); + } + } + + /** + * Export current trade bot states and minting accounts. + */ + private void exportRepositoryData() { + try (final Repository repository = RepositoryManager.getRepository()) { + repository.exportNodeLocalData(); + + } catch (DataException e) { + // Fail silently as this is an optional step + } + } + public static final Predicate hasMisbehaved = peer -> { final Long lastMisbehaved = peer.getPeerData().getLastMisbehaved(); return lastMisbehaved != null && lastMisbehaved > NTP.getTime() - MISBEHAVIOUR_COOLOFF; @@ -952,6 +996,10 @@ public class Controller extends Thread { } } + // Export local data + LOGGER.info("Backing up local data"); + this.exportRepositoryData(); + LOGGER.info("Shutting down networking"); Network.getInstance().shutdown(); diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index 6e9d1474..36351927 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -245,17 +245,17 @@ public class TradeBot implements Listener { } } - /*package*/ static byte[] generateTradePrivateKey() { + public static byte[] generateTradePrivateKey() { // The private key is used for both Curve25519 and secp256k1 so needs to be valid for both. // Curve25519 accepts any seed, so generate a valid secp256k1 key and use that. return new ECKey().getPrivKeyBytes(); } - /*package*/ static byte[] deriveTradeNativePublicKey(byte[] privateKey) { + public static byte[] deriveTradeNativePublicKey(byte[] privateKey) { return PrivateKeyAccount.toPublicKey(privateKey); } - /*package*/ static byte[] deriveTradeForeignPublicKey(byte[] privateKey) { + public static byte[] deriveTradeForeignPublicKey(byte[] privateKey) { return ECKey.fromPrivate(privateKey).getPubKey(); } diff --git a/src/main/java/org/qortal/data/account/MintingAccountData.java b/src/main/java/org/qortal/data/account/MintingAccountData.java index 02b4c0f8..63c6c723 100644 --- a/src/main/java/org/qortal/data/account/MintingAccountData.java +++ b/src/main/java/org/qortal/data/account/MintingAccountData.java @@ -4,10 +4,12 @@ import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlTransient; +import org.json.JSONObject; import org.qortal.crypto.Crypto; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema.AccessMode; +import org.qortal.utils.Base58; // All properties to be converted to JSON via JAXB @XmlAccessorType(XmlAccessType.FIELD) @@ -61,4 +63,21 @@ public class MintingAccountData { return this.publicKey; } + + // JSON + + public JSONObject toJson() { + JSONObject jsonObject = new JSONObject(); + jsonObject.put("privateKey", Base58.encode(this.getPrivateKey())); + jsonObject.put("publicKey", Base58.encode(this.getPublicKey())); + return jsonObject; + } + + public static MintingAccountData fromJson(JSONObject json) { + return new MintingAccountData( + json.isNull("privateKey") ? null : Base58.decode(json.getString("privateKey")), + json.isNull("publicKey") ? null : Base58.decode(json.getString("publicKey")) + ); + } + } diff --git a/src/main/java/org/qortal/repository/AccountRepository.java b/src/main/java/org/qortal/repository/AccountRepository.java index a23771f9..256f9556 100644 --- a/src/main/java/org/qortal/repository/AccountRepository.java +++ b/src/main/java/org/qortal/repository/AccountRepository.java @@ -191,6 +191,8 @@ public interface AccountRepository { public List getMintingAccounts() throws DataException; + public MintingAccountData getMintingAccount(byte[] mintingAccountKey) throws DataException; + public void save(MintingAccountData mintingAccountData) throws DataException; /** Delete minting account info, used by BlockMinter, from repository using passed public or private key. */ diff --git a/src/main/java/org/qortal/repository/Repository.java b/src/main/java/org/qortal/repository/Repository.java index fab48a14..f6728968 100644 --- a/src/main/java/org/qortal/repository/Repository.java +++ b/src/main/java/org/qortal/repository/Repository.java @@ -1,5 +1,7 @@ package org.qortal.repository; +import java.io.IOException; + public interface Repository extends AutoCloseable { public ATRepository getATRepository(); @@ -47,14 +49,16 @@ public interface Repository extends AutoCloseable { public void setDebug(boolean debugState); - public void backup(boolean quick) throws DataException; + public void backup(boolean quick, String name) throws DataException; public void performPeriodicMaintenance() throws DataException; public void exportNodeLocalData() throws DataException; - public void importDataFromFile(String filename) throws DataException; + public void importDataFromFile(String filename) throws DataException, IOException; public void checkConsistency() throws DataException; + public static void attemptRecovery(String connectionUrl, String name) throws DataException {} + } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java index 0dca46eb..b28a224c 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java @@ -904,6 +904,25 @@ public class HSQLDBAccountRepository implements AccountRepository { } } + @Override + public MintingAccountData getMintingAccount(byte[] mintingAccountKey) throws DataException { + try (ResultSet resultSet = this.repository.checkedExecute("SELECT minter_private_key, minter_public_key " + + "FROM MintingAccounts WHERE minter_private_key = ? OR minter_public_key = ?", + mintingAccountKey, mintingAccountKey)) { + + if (resultSet == null) + return null; + + byte[] minterPrivateKey = resultSet.getBytes(1); + byte[] minterPublicKey = resultSet.getBytes(2); + + return new MintingAccountData(minterPrivateKey, minterPublicKey); + + } catch (SQLException e) { + throw new DataException("Unable to fetch minting accounts from repository", e); + } + } + @Override public void save(MintingAccountData mintingAccountData) throws DataException { HSQLDBSaver saveHelper = new HSQLDBSaver("MintingAccounts"); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBImportExport.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBImportExport.java new file mode 100644 index 00000000..c5881c01 --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBImportExport.java @@ -0,0 +1,298 @@ +package org.qortal.repository.hsqldb; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.qortal.data.account.MintingAccountData; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.repository.Bootstrap; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.settings.Settings; +import org.qortal.utils.Base58; +import org.qortal.utils.Triple; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Iterator; +import java.util.List; + +public class HSQLDBImportExport { + + private static final Logger LOGGER = LogManager.getLogger(Bootstrap.class); + + public static void backupTradeBotStates(Repository repository) throws DataException { + HSQLDBImportExport.backupCurrentTradeBotStates(repository); + HSQLDBImportExport.backupArchivedTradeBotStates(repository); + + LOGGER.info("Exported sensitive/node-local data: trade bot states"); + } + + public static void backupMintingAccounts(Repository repository) throws DataException { + HSQLDBImportExport.backupCurrentMintingAccounts(repository); + + LOGGER.info("Exported sensitive/node-local data: minting accounts"); + } + + + /* Trade bot states */ + + /** + * Backs up the trade bot states currently in the repository, without combining them with past ones + * @param repository + * @throws DataException + */ + private static void backupCurrentTradeBotStates(Repository repository) throws DataException { + try { + Path backupDirectory = HSQLDBImportExport.getExportDirectory(true); + + // Load current trade bot data + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + JSONArray currentTradeBotDataJson = new JSONArray(); + for (TradeBotData tradeBotData : allTradeBotData) { + JSONObject tradeBotDataJson = tradeBotData.toJson(); + currentTradeBotDataJson.put(tradeBotDataJson); + } + + // Wrap current trade bot data in an object to indicate the type + JSONObject currentTradeBotDataJsonWrapper = new JSONObject(); + currentTradeBotDataJsonWrapper.put("type", "tradeBotStates"); + currentTradeBotDataJsonWrapper.put("dataset", "current"); + currentTradeBotDataJsonWrapper.put("data", currentTradeBotDataJson); + + // Write current trade bot data (just the ones currently in the database) + String fileName = Paths.get(backupDirectory.toString(), "TradeBotStates.json").toString(); + FileWriter writer = new FileWriter(fileName); + writer.write(currentTradeBotDataJsonWrapper.toString(2)); + writer.close(); + + } catch (DataException | IOException e) { + throw new DataException("Unable to export trade bot states from repository"); + } + } + + /** + * Backs up the trade bot states currently in the repository to a separate "archive" file, + * making sure to combine them with any unique states already present in the archive. + * @param repository + * @throws DataException + */ + private static void backupArchivedTradeBotStates(Repository repository) throws DataException { + try { + Path backupDirectory = HSQLDBImportExport.getExportDirectory(true); + + // Load current trade bot data + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + JSONArray allTradeBotDataJson = new JSONArray(); + for (TradeBotData tradeBotData : allTradeBotData) { + JSONObject tradeBotDataJson = tradeBotData.toJson(); + allTradeBotDataJson.put(tradeBotDataJson); + } + + // We need to combine existing archived TradeBotStates data before overwriting + String fileName = Paths.get(backupDirectory.toString(), "TradeBotStatesArchive.json").toString(); + File tradeBotStatesBackupFile = new File(fileName); + if (tradeBotStatesBackupFile.exists()) { + + String jsonString = new String(Files.readAllBytes(Paths.get(fileName))); + Triple parsedJSON = HSQLDBImportExport.parseJSONString(jsonString); + if (parsedJSON.getA() == null || parsedJSON.getC() == null) { + throw new DataException("Missing data when exporting archived trade bot states"); + } + String type = parsedJSON.getA(); + String dataset = parsedJSON.getB(); + JSONArray data = parsedJSON.getC(); + + if (!type.equals("tradeBotStates") || !dataset.equals("archive")) { + throw new DataException("Format mismatch when exporting archived trade bot states"); + } + + Iterator iterator = data.iterator(); + while(iterator.hasNext()) { + JSONObject existingTradeBotDataItem = (JSONObject)iterator.next(); + String existingTradePrivateKey = (String) existingTradeBotDataItem.get("tradePrivateKey"); + // Check if we already have an entry for this trade + boolean found = allTradeBotData.stream().anyMatch(tradeBotData -> Base58.encode(tradeBotData.getTradePrivateKey()).equals(existingTradePrivateKey)); + if (found == false) + // Add the data from the backup file to our "allTradeBotDataJson" array as it's not currently in the db + allTradeBotDataJson.put(existingTradeBotDataItem); + } + } + + // Wrap all trade bot data in an object to indicate the type + JSONObject allTradeBotDataJsonWrapper = new JSONObject(); + allTradeBotDataJsonWrapper.put("type", "tradeBotStates"); + allTradeBotDataJsonWrapper.put("dataset", "archive"); + allTradeBotDataJsonWrapper.put("data", allTradeBotDataJson); + + // Write ALL trade bot data to archive (current plus states that are no longer in the database) + FileWriter writer = new FileWriter(fileName); + writer.write(allTradeBotDataJsonWrapper.toString(2)); + writer.close(); + + } catch (DataException | IOException e) { + throw new DataException("Unable to export trade bot states from repository"); + } + } + + + /* Minting accounts */ + + /** + * Backs up the minting accounts currently in the repository, without combining them with past ones + * @param repository + * @throws DataException + */ + private static void backupCurrentMintingAccounts(Repository repository) throws DataException { + try { + Path backupDirectory = HSQLDBImportExport.getExportDirectory(true); + + // Load current trade bot data + List allMintingAccountData = repository.getAccountRepository().getMintingAccounts(); + JSONArray currentMintingAccountJson = new JSONArray(); + for (MintingAccountData mintingAccountData : allMintingAccountData) { + JSONObject mintingAccountDataJson = mintingAccountData.toJson(); + currentMintingAccountJson.put(mintingAccountDataJson); + } + + // Wrap current trade bot data in an object to indicate the type + JSONObject currentMintingAccountDataJsonWrapper = new JSONObject(); + currentMintingAccountDataJsonWrapper.put("type", "mintingAccounts"); + currentMintingAccountDataJsonWrapper.put("dataset", "current"); + currentMintingAccountDataJsonWrapper.put("data", currentMintingAccountJson); + + // Write current trade bot data (just the ones currently in the database) + String fileName = Paths.get(backupDirectory.toString(), "MintingAccounts.json").toString(); + FileWriter writer = new FileWriter(fileName); + writer.write(currentMintingAccountDataJsonWrapper.toString(2)); + writer.close(); + + } catch (DataException | IOException e) { + throw new DataException("Unable to export minting accounts from repository"); + } + } + + + /* Utils */ + + /** + * Imports data from supplied file + * Data type is loaded from the file itself, and if missing, TradeBotStates is assumed + * + * @param filename + * @param repository + * @throws DataException + * @throws IOException + */ + public static void importDataFromFile(String filename, Repository repository) throws DataException, IOException { + Path path = Paths.get(filename); + if (!path.toFile().exists()) { + throw new FileNotFoundException(String.format("File doesn't exist: %s", filename)); + } + byte[] fileContents = Files.readAllBytes(path); + if (fileContents == null) { + throw new FileNotFoundException(String.format("Unable to read file contents: %s", filename)); + } + + LOGGER.info(String.format("Importing %s into repository ...", filename)); + + String jsonString = new String(fileContents); + Triple parsedJSON = HSQLDBImportExport.parseJSONString(jsonString); + if (parsedJSON.getA() == null || parsedJSON.getC() == null) { + throw new DataException(String.format("Missing data when importing %s into repository", filename)); + } + String type = parsedJSON.getA(); + JSONArray data = parsedJSON.getC(); + + Iterator iterator = data.iterator(); + while(iterator.hasNext()) { + JSONObject dataJsonObject = (JSONObject)iterator.next(); + + if (type.equals("tradeBotStates")) { + HSQLDBImportExport.importTradeBotDataJSON(dataJsonObject, repository); + } + else if (type.equals("mintingAccounts")) { + HSQLDBImportExport.importMintingAccountDataJSON(dataJsonObject, repository); + } + else { + throw new DataException(String.format("Unrecognized data type when importing %s into repository", filename)); + } + + } + LOGGER.info(String.format("Imported %s into repository from %s", type, filename)); + } + + private static void importTradeBotDataJSON(JSONObject tradeBotDataJson, Repository repository) throws DataException { + TradeBotData tradeBotData = TradeBotData.fromJson(tradeBotDataJson); + repository.getCrossChainRepository().save(tradeBotData); + } + + private static void importMintingAccountDataJSON(JSONObject mintingAccountDataJson, Repository repository) throws DataException { + MintingAccountData mintingAccountData = MintingAccountData.fromJson(mintingAccountDataJson); + repository.getAccountRepository().save(mintingAccountData); + } + + public static Path getExportDirectory(boolean createIfNotExists) throws DataException { + Path backupPath = Paths.get(Settings.getInstance().getExportPath()); + + if (createIfNotExists) { + // Create the qortal-backup folder if it doesn't exist + try { + Files.createDirectories(backupPath); + } catch (IOException e) { + LOGGER.info(String.format("Unable to create %s folder", backupPath.toString())); + throw new DataException(String.format("Unable to create %s folder", backupPath.toString())); + } + } + + return backupPath; + } + + /** + * Parses a JSON string and returns "data", "type", and "dataset" fields. + * In the case of legacy JSON files with no type, they are assumed to be TradeBotStates archives, + * as we had never implemented this for any other types. + * + * @param jsonString + * @return Triple (type, dataset, data) + */ + private static Triple parseJSONString(String jsonString) throws DataException { + String type = null; + String dataset = null; + JSONArray data = null; + + try { + // Firstly try importing the new format + JSONObject jsonData = new JSONObject(jsonString); + if (jsonData != null && jsonData.getString("type") != null) { + + type = jsonData.getString("type"); + dataset = jsonData.getString("dataset"); + data = jsonData.getJSONArray("data"); + } + + } catch (JSONException e) { + // Could be a legacy format which didn't contain a type or any other outer keys, so try importing that + // Treat these as TradeBotStates archives, given that this was the only type previously implemented + try { + type = "tradeBotStates"; + dataset = "archive"; + data = new JSONArray(jsonString); + + } catch (JSONException e2) { + // Still failed, so give up + throw new DataException("Couldn't import JSON file"); + } + } + + return new Triple(type, dataset, data); + } + +} diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 6807c100..c1f8a2d5 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -2,7 +2,6 @@ package org.qortal.repository.hsqldb; import java.awt.TrayIcon.MessageType; import java.io.File; -import java.io.FileWriter; import java.io.IOException; import java.math.BigDecimal; import java.nio.file.Files; @@ -21,20 +20,15 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; -import org.json.JSONArray; -import org.json.JSONObject; - import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.account.PrivateKeyAccount; import org.qortal.crypto.Crypto; -import org.qortal.data.crosschain.TradeBotData; import org.qortal.globalization.Translator; import org.qortal.gui.SysTray; import org.qortal.repository.*; import org.qortal.repository.hsqldb.transaction.HSQLDBTransactionRepository; import org.qortal.settings.Settings; -import org.qortal.utils.Base58; public class HSQLDBRepository implements Repository { @@ -450,68 +444,13 @@ public class HSQLDBRepository implements Repository { @Override public void exportNodeLocalData() throws DataException { - // Create the qortal-backup folder if it doesn't exist - Path backupPath = Paths.get("qortal-backup"); - try { - Files.createDirectories(backupPath); - } catch (IOException e) { - LOGGER.info("Unable to create backup folder"); - throw new DataException("Unable to create backup folder"); - } - - try { - // Load trade bot data - List allTradeBotData = this.getCrossChainRepository().getAllTradeBotData(); - JSONArray allTradeBotDataJson = new JSONArray(); - for (TradeBotData tradeBotData : allTradeBotData) { - JSONObject tradeBotDataJson = tradeBotData.toJson(); - allTradeBotDataJson.put(tradeBotDataJson); - } - - // We need to combine existing TradeBotStates data before overwriting - String fileName = "qortal-backup/TradeBotStates.json"; - File tradeBotStatesBackupFile = new File(fileName); - if (tradeBotStatesBackupFile.exists()) { - String jsonString = new String(Files.readAllBytes(Paths.get(fileName))); - JSONArray allExistingTradeBotData = new JSONArray(jsonString); - Iterator iterator = allExistingTradeBotData.iterator(); - while(iterator.hasNext()) { - JSONObject existingTradeBotData = (JSONObject)iterator.next(); - String existingTradePrivateKey = (String) existingTradeBotData.get("tradePrivateKey"); - // Check if we already have an entry for this trade - boolean found = allTradeBotData.stream().anyMatch(tradeBotData -> Base58.encode(tradeBotData.getTradePrivateKey()).equals(existingTradePrivateKey)); - if (found == false) - // We need to add this to our list - allTradeBotDataJson.put(existingTradeBotData); - } - } - - FileWriter writer = new FileWriter(fileName); - writer.write(allTradeBotDataJson.toString()); - writer.close(); - LOGGER.info("Exported sensitive/node-local data: trade bot states"); - - } catch (DataException | IOException e) { - throw new DataException("Unable to export trade bot states from repository"); - } + HSQLDBImportExport.backupTradeBotStates(this); + HSQLDBImportExport.backupMintingAccounts(this); } @Override - public void importDataFromFile(String filename) throws DataException { - LOGGER.info(() -> String.format("Importing data into repository from %s", filename)); - try { - String jsonString = new String(Files.readAllBytes(Paths.get(filename))); - JSONArray tradeBotDataToImport = new JSONArray(jsonString); - Iterator iterator = tradeBotDataToImport.iterator(); - while(iterator.hasNext()) { - JSONObject tradeBotDataJson = (JSONObject)iterator.next(); - TradeBotData tradeBotData = TradeBotData.fromJson(tradeBotDataJson); - this.getCrossChainRepository().save(tradeBotData); - } - } catch (IOException e) { - throw new DataException("Unable to import sensitive/node-local trade bot states to repository: " + e.getMessage()); - } - LOGGER.info(() -> String.format("Imported trade bot states into repository from %s", filename)); + public void importDataFromFile(String filename) throws DataException, IOException { + HSQLDBImportExport.importDataFromFile(filename, this); } @Override diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 15ead8e7..d284d59d 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -184,6 +184,9 @@ public class Settings { /** Repository connection pool size. Needs to be a bit bigger than maxNetworkThreadPoolSize */ private int repositoryConnectionPoolSize = 100; + // Export/import + private String exportPath = "qortal-backup"; + // Auto-update sources private String[] autoUpdateRepos = new String[] { "https://github.com/Qortal/qortal/raw/%s/qortal.update", @@ -502,6 +505,10 @@ public class Settings { return this.repositoryConnectionPoolSize; } + public String getExportPath() { + return this.exportPath; + } + public boolean isAutoUpdateEnabled() { return this.autoUpdateEnabled; } diff --git a/src/test/java/org/qortal/test/ImportExportTests.java b/src/test/java/org/qortal/test/ImportExportTests.java new file mode 100644 index 00000000..c7a5062f --- /dev/null +++ b/src/test/java/org/qortal/test/ImportExportTests.java @@ -0,0 +1,390 @@ +package org.qortal.test; + +import org.apache.commons.io.FileUtils; +import org.bitcoinj.core.Address; +import org.bitcoinj.core.AddressFormatException; +import org.bitcoinj.core.ECKey; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PublicKeyAccount; +import org.qortal.controller.tradebot.LitecoinACCTv1TradeBot; +import org.qortal.controller.tradebot.TradeBot; +import org.qortal.crosschain.Litecoin; +import org.qortal.crosschain.LitecoinACCTv1; +import org.qortal.crosschain.SupportedBlockchain; +import org.qortal.crypto.Crypto; +import org.qortal.data.account.MintingAccountData; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.repository.hsqldb.HSQLDBImportExport; +import org.qortal.settings.Settings; +import org.qortal.test.common.Common; +import org.qortal.utils.NTP; + +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.*; + +public class ImportExportTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + this.deleteExportDirectory(); + } + + @After + public void afterTest() throws DataException { + this.deleteExportDirectory(); + } + + + @Test + public void testExportAndImportTradeBotStates() throws DataException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Ensure no trade bots exist + assertTrue(repository.getCrossChainRepository().getAllTradeBotData().isEmpty()); + + // Create some trade bots + List tradeBots = new ArrayList<>(); + for (int i=0; i<10; i++) { + TradeBotData tradeBotData = this.createTradeBotData(repository); + repository.getCrossChainRepository().save(tradeBotData); + tradeBots.add(tradeBotData); + } + + // Ensure they have been added + assertEquals(10, repository.getCrossChainRepository().getAllTradeBotData().size()); + + // Export them + HSQLDBImportExport.backupTradeBotStates(repository); + + // Delete them from the repository + for (TradeBotData tradeBotData : tradeBots) { + repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); + } + + // Ensure they have been deleted + assertTrue(repository.getCrossChainRepository().getAllTradeBotData().isEmpty()); + + // Import them + Path exportPath = HSQLDBImportExport.getExportDirectory(false); + Path filePath = Paths.get(exportPath.toString(), "TradeBotStates.json"); + HSQLDBImportExport.importDataFromFile(filePath.toString(), repository); + + // Ensure they have been imported + assertEquals(10, repository.getCrossChainRepository().getAllTradeBotData().size()); + + // Ensure all the data matches + for (TradeBotData tradeBotData : tradeBots) { + byte[] tradePrivateKey = tradeBotData.getTradePrivateKey(); + TradeBotData repositoryTradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey); + assertNotNull(repositoryTradeBotData); + assertEquals(tradeBotData.toJson().toString(), repositoryTradeBotData.toJson().toString()); + } + + repository.saveChanges(); + } + } + + @Test + public void testExportAndImportCurrentTradeBotStates() throws DataException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Ensure no trade bots exist + assertTrue(repository.getCrossChainRepository().getAllTradeBotData().isEmpty()); + + // Create some trade bots + List tradeBots = new ArrayList<>(); + for (int i=0; i<10; i++) { + TradeBotData tradeBotData = this.createTradeBotData(repository); + repository.getCrossChainRepository().save(tradeBotData); + tradeBots.add(tradeBotData); + } + + // Ensure they have been added + assertEquals(10, repository.getCrossChainRepository().getAllTradeBotData().size()); + + // Export them + HSQLDBImportExport.backupTradeBotStates(repository); + + // Delete them from the repository + for (TradeBotData tradeBotData : tradeBots) { + repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); + } + + // Ensure they have been deleted + assertTrue(repository.getCrossChainRepository().getAllTradeBotData().isEmpty()); + + // Add some more trade bots + List additionalTradeBots = new ArrayList<>(); + for (int i=0; i<5; i++) { + TradeBotData tradeBotData = this.createTradeBotData(repository); + repository.getCrossChainRepository().save(tradeBotData); + additionalTradeBots.add(tradeBotData); + } + + // Export again + HSQLDBImportExport.backupTradeBotStates(repository); + + // Import current states only + Path exportPath = HSQLDBImportExport.getExportDirectory(false); + Path filePath = Paths.get(exportPath.toString(), "TradeBotStates.json"); + HSQLDBImportExport.importDataFromFile(filePath.toString(), repository); + + // Ensure they have been imported + assertEquals(5, repository.getCrossChainRepository().getAllTradeBotData().size()); + + // Ensure that only the additional trade bots have been imported and that the data matches + for (TradeBotData tradeBotData : additionalTradeBots) { + byte[] tradePrivateKey = tradeBotData.getTradePrivateKey(); + TradeBotData repositoryTradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey); + assertNotNull(repositoryTradeBotData); + assertEquals(tradeBotData.toJson().toString(), repositoryTradeBotData.toJson().toString()); + } + + // None of the original trade bots should exist in the repository + for (TradeBotData tradeBotData : tradeBots) { + byte[] tradePrivateKey = tradeBotData.getTradePrivateKey(); + TradeBotData repositoryTradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey); + assertNull(repositoryTradeBotData); + } + + repository.saveChanges(); + } + } + + @Test + public void testExportAndImportAllTradeBotStates() throws DataException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Ensure no trade bots exist + assertTrue(repository.getCrossChainRepository().getAllTradeBotData().isEmpty()); + + // Create some trade bots + List tradeBots = new ArrayList<>(); + for (int i=0; i<10; i++) { + TradeBotData tradeBotData = this.createTradeBotData(repository); + repository.getCrossChainRepository().save(tradeBotData); + tradeBots.add(tradeBotData); + } + + // Ensure they have been added + assertEquals(10, repository.getCrossChainRepository().getAllTradeBotData().size()); + + // Export them + HSQLDBImportExport.backupTradeBotStates(repository); + + // Delete them from the repository + for (TradeBotData tradeBotData : tradeBots) { + repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); + } + + // Ensure they have been deleted + assertTrue(repository.getCrossChainRepository().getAllTradeBotData().isEmpty()); + + // Add some more trade bots + List additionalTradeBots = new ArrayList<>(); + for (int i=0; i<5; i++) { + TradeBotData tradeBotData = this.createTradeBotData(repository); + repository.getCrossChainRepository().save(tradeBotData); + additionalTradeBots.add(tradeBotData); + } + + // Export again + HSQLDBImportExport.backupTradeBotStates(repository); + + // Import all states from the archive + Path exportPath = HSQLDBImportExport.getExportDirectory(false); + Path filePath = Paths.get(exportPath.toString(), "TradeBotStatesArchive.json"); + HSQLDBImportExport.importDataFromFile(filePath.toString(), repository); + + // Ensure they have been imported + assertEquals(15, repository.getCrossChainRepository().getAllTradeBotData().size()); + + // Ensure that all known trade bots have been imported and that the data matches + tradeBots.addAll(additionalTradeBots); + + for (TradeBotData tradeBotData : tradeBots) { + byte[] tradePrivateKey = tradeBotData.getTradePrivateKey(); + TradeBotData repositoryTradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey); + assertNotNull(repositoryTradeBotData); + assertEquals(tradeBotData.toJson().toString(), repositoryTradeBotData.toJson().toString()); + } + + repository.saveChanges(); + } + } + + @Test + public void testExportAndImportLegacyTradeBotStates() throws DataException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Create some trade bots, but don't save them in the repository + List tradeBots = new ArrayList<>(); + for (int i=0; i<10; i++) { + TradeBotData tradeBotData = this.createTradeBotData(repository); + tradeBots.add(tradeBotData); + } + + // Create a legacy format TradeBotStates.json backup file + this.exportLegacyTradeBotStatesJson(tradeBots); + + // Ensure no trade bots exist in repository + assertTrue(repository.getCrossChainRepository().getAllTradeBotData().isEmpty()); + + // Import the legacy format file + Path exportPath = HSQLDBImportExport.getExportDirectory(false); + Path filePath = Paths.get(exportPath.toString(), "TradeBotStates.json"); + HSQLDBImportExport.importDataFromFile(filePath.toString(), repository); + + // Ensure they have been imported + assertEquals(10, repository.getCrossChainRepository().getAllTradeBotData().size()); + + for (TradeBotData tradeBotData : tradeBots) { + byte[] tradePrivateKey = tradeBotData.getTradePrivateKey(); + TradeBotData repositoryTradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey); + assertNotNull(repositoryTradeBotData); + assertEquals(tradeBotData.toJson().toString(), repositoryTradeBotData.toJson().toString()); + } + + repository.saveChanges(); + } + } + + @Test + public void testExportAndImportMintingAccountData() throws DataException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Ensure no minting accounts exist + assertTrue(repository.getAccountRepository().getMintingAccounts().isEmpty()); + + // Create some minting accounts + List mintingAccounts = new ArrayList<>(); + for (int i=0; i<10; i++) { + MintingAccountData mintingAccountData = this.createMintingAccountData(); + repository.getAccountRepository().save(mintingAccountData); + mintingAccounts.add(mintingAccountData); + } + + // Ensure they have been added + assertEquals(10, repository.getAccountRepository().getMintingAccounts().size()); + + // Export them + HSQLDBImportExport.backupMintingAccounts(repository); + + // Delete them from the repository + for (MintingAccountData mintingAccountData : mintingAccounts) { + repository.getAccountRepository().delete(mintingAccountData.getPrivateKey()); + } + + // Ensure they have been deleted + assertTrue(repository.getAccountRepository().getMintingAccounts().isEmpty()); + + // Import them + Path exportPath = HSQLDBImportExport.getExportDirectory(false); + Path filePath = Paths.get(exportPath.toString(), "MintingAccounts.json"); + HSQLDBImportExport.importDataFromFile(filePath.toString(), repository); + + // Ensure they have been imported + assertEquals(10, repository.getAccountRepository().getMintingAccounts().size()); + + // Ensure all the data matches + for (MintingAccountData mintingAccountData : mintingAccounts) { + byte[] privateKey = mintingAccountData.getPrivateKey(); + MintingAccountData repositoryMintingAccountData = repository.getAccountRepository().getMintingAccount(privateKey); + assertNotNull(repositoryMintingAccountData); + assertEquals(mintingAccountData.toJson().toString(), repositoryMintingAccountData.toJson().toString()); + } + + repository.saveChanges(); + } + } + + + private TradeBotData createTradeBotData(Repository repository) throws DataException { + byte[] tradePrivateKey = TradeBot.generateTradePrivateKey(); + + byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); + + byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + + String receivingAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + + // Convert Litecoin receiving address into public key hash (we only support P2PKH at this time) + Address litecoinReceivingAddress; + try { + litecoinReceivingAddress = Address.fromString(Litecoin.getInstance().getNetworkParameters(), receivingAddress); + } catch (AddressFormatException e) { + throw new DataException("Unsupported Litecoin receiving address: " + receivingAddress); + } + + byte[] litecoinReceivingAccountInfo = litecoinReceivingAddress.getHash(); + + byte[] creatorPublicKey = new byte[32]; + PublicKeyAccount creator = new PublicKeyAccount(repository, creatorPublicKey); + + long timestamp = NTP.getTime(); + String atAddress = "AT_ADDRESS"; + long foreignAmount = 1234; + long qortAmount= 5678; + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, LitecoinACCTv1.NAME, + LitecoinACCTv1TradeBot.State.BOB_WAITING_FOR_AT_CONFIRM.name(), LitecoinACCTv1TradeBot.State.BOB_WAITING_FOR_AT_CONFIRM.value, + creator.getAddress(), atAddress, timestamp, qortAmount, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + null, null, + SupportedBlockchain.LITECOIN.name(), + tradeForeignPublicKey, tradeForeignPublicKeyHash, + foreignAmount, null, null, null, litecoinReceivingAccountInfo); + + return tradeBotData; + } + + private MintingAccountData createMintingAccountData() { + // These don't need to be valid keys - just 32 byte strings for the purposes of testing + byte[] privateKey = new ECKey().getPrivKeyBytes(); + byte[] publicKey = new ECKey().getPrivKeyBytes(); + + return new MintingAccountData(privateKey, publicKey); + } + + private void exportLegacyTradeBotStatesJson(List allTradeBotData) throws IOException, DataException { + JSONArray allTradeBotDataJson = new JSONArray(); + for (TradeBotData tradeBotData : allTradeBotData) { + JSONObject tradeBotDataJson = tradeBotData.toJson(); + allTradeBotDataJson.put(tradeBotDataJson); + } + + Path backupDirectory = HSQLDBImportExport.getExportDirectory(true); + String fileName = Paths.get(backupDirectory.toString(), "TradeBotStates.json").toString(); + FileWriter writer = new FileWriter(fileName); + writer.write(allTradeBotDataJson.toString()); + writer.close(); + } + + private void deleteExportDirectory() { + // Delete archive directory if exists + Path archivePath = Paths.get(Settings.getInstance().getExportPath()); + try { + FileUtils.deleteDirectory(archivePath.toFile()); + } catch (IOException e) { + + } + } + +} diff --git a/src/test/resources/test-settings-v2-bitcoin-regtest.json b/src/test/resources/test-settings-v2-bitcoin-regtest.json index 86379ae7..f0a993e2 100644 --- a/src/test/resources/test-settings-v2-bitcoin-regtest.json +++ b/src/test/resources/test-settings-v2-bitcoin-regtest.json @@ -3,6 +3,7 @@ "litecoinNet": "REGTEST", "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2.json", + "exportPath": "qortal-backup-test", "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2-block-archive.json b/src/test/resources/test-settings-v2-block-archive.json index 612c8658..b71b2679 100644 --- a/src/test/resources/test-settings-v2-block-archive.json +++ b/src/test/resources/test-settings-v2-block-archive.json @@ -3,6 +3,7 @@ "litecoinNet": "TEST3", "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2.json", + "exportPath": "qortal-backup-test", "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0, diff --git a/src/test/resources/test-settings-v2-founder-rewards.json b/src/test/resources/test-settings-v2-founder-rewards.json index c89df187..b73544ea 100644 --- a/src/test/resources/test-settings-v2-founder-rewards.json +++ b/src/test/resources/test-settings-v2-founder-rewards.json @@ -1,6 +1,7 @@ { "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-founder-rewards.json", + "exportPath": "qortal-backup-test", "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2-leftover-reward.json b/src/test/resources/test-settings-v2-leftover-reward.json index bdbc1d52..5c87cc94 100644 --- a/src/test/resources/test-settings-v2-leftover-reward.json +++ b/src/test/resources/test-settings-v2-leftover-reward.json @@ -1,6 +1,7 @@ { "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-leftover-reward.json", + "exportPath": "qortal-backup-test", "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2-minting.json b/src/test/resources/test-settings-v2-minting.json index 9c72c375..abff27e3 100644 --- a/src/test/resources/test-settings-v2-minting.json +++ b/src/test/resources/test-settings-v2-minting.json @@ -1,6 +1,7 @@ { "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-minting.json", + "exportPath": "qortal-backup-test", "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2-qora-holder-extremes.json b/src/test/resources/test-settings-v2-qora-holder-extremes.json index b311fbf2..dbf55170 100644 --- a/src/test/resources/test-settings-v2-qora-holder-extremes.json +++ b/src/test/resources/test-settings-v2-qora-holder-extremes.json @@ -1,6 +1,7 @@ { "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-qora-holder-extremes.json", + "exportPath": "qortal-backup-test", "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2-qora-holder.json b/src/test/resources/test-settings-v2-qora-holder.json index 83b23287..c9b995a6 100644 --- a/src/test/resources/test-settings-v2-qora-holder.json +++ b/src/test/resources/test-settings-v2-qora-holder.json @@ -1,6 +1,7 @@ { "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-qora-holder.json", + "exportPath": "qortal-backup-test", "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2-reward-levels.json b/src/test/resources/test-settings-v2-reward-levels.json index 1c6862ad..4cc8de14 100644 --- a/src/test/resources/test-settings-v2-reward-levels.json +++ b/src/test/resources/test-settings-v2-reward-levels.json @@ -1,6 +1,7 @@ { "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-reward-levels.json", + "exportPath": "qortal-backup-test", "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2-reward-scaling.json b/src/test/resources/test-settings-v2-reward-scaling.json index 262938b7..e1958d63 100644 --- a/src/test/resources/test-settings-v2-reward-scaling.json +++ b/src/test/resources/test-settings-v2-reward-scaling.json @@ -1,6 +1,7 @@ { "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-reward-scaling.json", + "exportPath": "qortal-backup-test", "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2.json b/src/test/resources/test-settings-v2.json index a8983d3d..13c0a60f 100644 --- a/src/test/resources/test-settings-v2.json +++ b/src/test/resources/test-settings-v2.json @@ -3,6 +3,7 @@ "litecoinNet": "TEST3", "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2.json", + "exportPath": "qortal-backup-test", "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 From e2a62f88a6d8e29bae67e609f5ba696a68e9a7b6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 28 Sep 2021 09:26:18 +0100 Subject: [PATCH 126/231] Modified repository backup and recovery to allow a custom filename to be specified. --- .../org/qortal/api/resource/AdminResource.java | 2 +- .../java/org/qortal/controller/AutoUpdate.java | 2 +- .../java/org/qortal/controller/Controller.java | 2 +- .../repository/hsqldb/HSQLDBRepository.java | 16 ++++++++-------- .../hsqldb/HSQLDBRepositoryFactory.java | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index bfcd54ca..39deabee 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -668,7 +668,7 @@ public class AdminResource { blockchainLock.lockInterruptibly(); try { - repository.backup(true); + repository.backup(true, "backup"); repository.saveChanges(); return "true"; diff --git a/src/main/java/org/qortal/controller/AutoUpdate.java b/src/main/java/org/qortal/controller/AutoUpdate.java index 6c1dd928..6d74e0e8 100644 --- a/src/main/java/org/qortal/controller/AutoUpdate.java +++ b/src/main/java/org/qortal/controller/AutoUpdate.java @@ -216,7 +216,7 @@ public class AutoUpdate extends Thread { // Give repository a chance to backup in case things go badly wrong (if enabled) if (Settings.getInstance().getRepositoryBackupInterval() > 0) - RepositoryManager.backup(true); + RepositoryManager.backup(true, "backup"); // Call ApplyUpdate to end this process (unlocking current JAR so it can be replaced) String javaHome = System.getProperty("java.home"); diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index fad244f4..a00ffdac 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -573,7 +573,7 @@ public class Controller extends Thread { Translator.INSTANCE.translate("SysTray", "CREATING_BACKUP_OF_DB_FILES"), MessageType.INFO); - RepositoryManager.backup(true); + RepositoryManager.backup(true, "backup"); } // Prune stuck/slow/old peers diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index c1f8a2d5..8c69e0f2 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -379,7 +379,7 @@ public class HSQLDBRepository implements Repository { } @Override - public void backup(boolean quick) throws DataException { + public void backup(boolean quick, String name) throws DataException { if (!quick) // First perform a CHECKPOINT try (Statement stmt = this.connection.createStatement()) { @@ -401,7 +401,7 @@ public class HSQLDBRepository implements Repository { return; } - String backupUrl = buildBackupUrl(dbPathname); + String backupUrl = buildBackupUrl(dbPathname, name); String backupPathname = getDbPathname(backupUrl); if (backupPathname == null) throw new DataException("Unable to determine location for repository backup?"); @@ -423,7 +423,7 @@ public class HSQLDBRepository implements Repository { // Actually create backup try (Statement stmt = this.connection.createStatement()) { - stmt.execute("BACKUP DATABASE TO 'backup/' BLOCKING AS FILES"); + stmt.execute(String.format("BACKUP DATABASE TO '%s/' BLOCKING AS FILES", name)); } catch (SQLException e) { throw new DataException("Unable to backup repository"); } @@ -472,22 +472,22 @@ public class HSQLDBRepository implements Repository { return matcher.group(2); } - private static String buildBackupUrl(String dbPathname) { + private static String buildBackupUrl(String dbPathname, String backupName) { Path oldRepoPath = Paths.get(dbPathname); Path oldRepoDirPath = oldRepoPath.getParent(); Path oldRepoFilePath = oldRepoPath.getFileName(); // Try to open backup. We need to remove "create=true" and insert "backup" dir before final filename. - String backupUrlTemplate = "jdbc:hsqldb:file:%s%sbackup%s%s;create=false;hsqldb.full_log_replay=true"; - return String.format(backupUrlTemplate, oldRepoDirPath.toString(), File.separator, File.separator, oldRepoFilePath.toString()); + String backupUrlTemplate = "jdbc:hsqldb:file:%s%s%s%s%s;create=false;hsqldb.full_log_replay=true"; + return String.format(backupUrlTemplate, oldRepoDirPath.toString(), File.separator, backupName, File.separator, oldRepoFilePath.toString()); } - /* package */ static void attemptRecovery(String connectionUrl) throws DataException { + /* package */ static void attemptRecovery(String connectionUrl, String name) throws DataException { String dbPathname = getDbPathname(connectionUrl); if (dbPathname == null) throw new DataException("Unable to locate repository for backup?"); - String backupUrl = buildBackupUrl(dbPathname); + String backupUrl = buildBackupUrl(dbPathname, name); Path oldRepoDirPath = Paths.get(dbPathname).getParent(); // Attempt connection to backup to see if it is viable diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java index be9c09eb..64f6be8c 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java @@ -54,7 +54,7 @@ public class HSQLDBRepositoryFactory implements RepositoryFactory { throw new DataException("Unable to read repository: " + e.getMessage(), e); // Attempt recovery? - HSQLDBRepository.attemptRecovery(connectionUrl); + HSQLDBRepository.attemptRecovery(connectionUrl, "backup"); } this.connectionPool = new HSQLDBPool(Settings.getInstance().getRepositoryConnectionPoolSize()); From de8e96cd75789939fa22fa03e5af7e3e7787910b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 28 Sep 2021 09:28:47 +0100 Subject: [PATCH 127/231] Added Blockchain.validateAllBlocks() to check every block back to genesis. This is extremely slow and shouldn't be needed in normal use cases. It currently checks that each block references the one before, but can ultimately be expanded to check more information about each block and its derived data. --- .../java/org/qortal/block/BlockChain.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 15801193..aee85131 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -556,6 +556,46 @@ public class BlockChain { } } + /** + * More thorough blockchain validation method. Useful for validating bootstraps. + * A DataException is thrown if anything is invalid. + * + * @throws DataException + */ + public static void validateAllBlocks() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + BlockData chainTip = repository.getBlockRepository().getLastBlock(); + final int chainTipHeight = chainTip.getHeight(); + final int oldestBlock = 1; // TODO: increase if in pruning mode + byte[] lastReference = null; + + for (int height = chainTipHeight; height > oldestBlock; height--) { + BlockData blockData = repository.getBlockRepository().fromHeight(height); + if (blockData == null) { + blockData = repository.getBlockArchiveRepository().fromHeight(height); + } + + if (blockData == null) { + String error = String.format("Missing block at height %d", height); + LOGGER.error(error); + throw new DataException(error); + } + + if (height != chainTipHeight) { + // Check reference + if (!Arrays.equals(blockData.getSignature(), lastReference)) { + String error = String.format("Invalid reference for block at height %d: %s (should be %s)", + height, Base58.encode(blockData.getReference()), Base58.encode(lastReference)); + LOGGER.error(error); + throw new DataException(error); + } + } + + lastReference = blockData.getReference(); + } + } + } + private static boolean isGenesisBlockValid() { try (final Repository repository = RepositoryManager.getRepository()) { BlockRepository blockRepository = repository.getBlockRepository(); From 0a4479fe9e905384390fe0cf308751337ba959ed Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 28 Sep 2021 20:17:19 +0100 Subject: [PATCH 128/231] Initial implementation of automatic bootstrapping Currently supports block archive (i.e. full) bootstraps only. Still need to add support for top-only bootstraps. --- pom.xml | 18 + .../api/resource/BootstrapResource.java | 84 ++++ .../java/org/qortal/block/BlockChain.java | 48 ++- .../java/org/qortal/repository/Bootstrap.java | 362 ++++++++++++++++++ .../qortal/repository/RepositoryManager.java | 4 +- .../java/org/qortal/settings/Settings.java | 9 + src/main/java/org/qortal/utils/SevenZ.java | 77 ++++ .../test-settings-v2-bitcoin-regtest.json | 1 + .../test-settings-v2-block-archive.json | 1 + .../test-settings-v2-founder-rewards.json | 1 + .../test-settings-v2-leftover-reward.json | 1 + .../resources/test-settings-v2-minting.json | 1 + ...test-settings-v2-qora-holder-extremes.json | 1 + .../test-settings-v2-qora-holder.json | 1 + .../test-settings-v2-reward-levels.json | 1 + .../test-settings-v2-reward-scaling.json | 1 + src/test/resources/test-settings-v2.json | 1 + 17 files changed, 590 insertions(+), 22 deletions(-) create mode 100644 src/main/java/org/qortal/api/resource/BootstrapResource.java create mode 100644 src/main/java/org/qortal/repository/Bootstrap.java create mode 100644 src/main/java/org/qortal/utils/SevenZ.java diff --git a/pom.xml b/pom.xml index 4aeb5182..be4b89ae 100644 --- a/pom.xml +++ b/pom.xml @@ -14,6 +14,9 @@ 1.3.8 3.6 1.8 + 2.6 + 1.21 + 1.9 1.2.2 28.1-jre 2.5.1 @@ -449,6 +452,21 @@ commons-text ${commons-text.version} + + commons-io + commons-io + ${commons-io.version} + + + org.apache.commons + commons-compress + ${commons-compress.version} + + + org.tukaani + xz + ${xz.version} + io.druid diff --git a/src/main/java/org/qortal/api/resource/BootstrapResource.java b/src/main/java/org/qortal/api/resource/BootstrapResource.java new file mode 100644 index 00000000..fe2ed378 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/BootstrapResource.java @@ -0,0 +1,84 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.api.ApiError; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; +import org.qortal.repository.Bootstrap; +import org.qortal.repository.DataException; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.*; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import java.io.IOException; + + +@Path("/bootstrap") +@Tag(name = "Bootstrap") +public class BootstrapResource { + + private static final Logger LOGGER = LogManager.getLogger(BootstrapResource.class); + + @Context + HttpServletRequest request; + + @POST + @Path("/create") + @Operation( + summary = "Create bootstrap", + description = "Builds a bootstrap file for distribution", + responses = { + @ApiResponse( + description = "path to file on success, an exception on failure", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + public String createBootstrap() { + Security.checkApiCallAllowed(request); + + Bootstrap bootstrap = new Bootstrap(); + if (!bootstrap.canBootstrap()) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + } + + boolean isBlockchainValid = bootstrap.validateBlockchain(); + if (!isBlockchainValid) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE); + } + + try { + return bootstrap.create(); + + } catch (DataException | InterruptedException | IOException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE); + } + } + + @GET + @Path("/validate") + @Operation( + summary = "Validate blockchain", + description = "Useful to check database integrity prior to creating or after installing a bootstrap. " + + "This process is intensive and can take over an hour to run.", + responses = { + @ApiResponse( + description = "true if valid, false if invalid", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + public boolean validateBootstrap() { + Security.checkApiCallAllowed(request); + + Bootstrap bootstrap = new Bootstrap(); + return bootstrap.validateCompleteBlockchain(); + } +} diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index aee85131..98b8d4fd 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -4,10 +4,7 @@ import java.io.File; import java.io.FileNotFoundException; import java.io.InputStream; import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.locks.ReentrantLock; import javax.xml.bind.JAXBContext; @@ -27,11 +24,9 @@ import org.eclipse.persistence.jaxb.UnmarshallerProperties; import org.qortal.controller.Controller; import org.qortal.data.block.BlockData; import org.qortal.network.Network; -import org.qortal.repository.BlockRepository; -import org.qortal.repository.DataException; -import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryManager; +import org.qortal.repository.*; import org.qortal.settings.Settings; +import org.qortal.utils.Base58; import org.qortal.utils.StringLongMapXmlAdapter; /** @@ -506,23 +501,28 @@ public class BlockChain { * @throws SQLException */ public static void validate() throws DataException { + + BlockData chainTip; try (final Repository repository = RepositoryManager.getRepository()) { + chainTip = repository.getBlockRepository().getLastBlock(); + } - boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); - BlockData chainTip = repository.getBlockRepository().getLastBlock(); - boolean hasBlocks = (chainTip != null && chainTip.getHeight() > 1); + boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); + boolean hasBlocks = (chainTip != null && chainTip.getHeight() > 1); - if (pruningEnabled && hasBlocks) { - // Pruning is enabled and we have blocks, so it's possible that the genesis block has been pruned - // It's best not to validate it, and there's no real need to - } - else { - // Check first block is Genesis Block - if (!isGenesisBlockValid()) { - rebuildBlockchain(); - } + if (pruningEnabled && hasBlocks) { + // Pruning is enabled and we have blocks, so it's possible that the genesis block has been pruned + // It's best not to validate it, and there's no real need to + } else { + // Check first block is Genesis Block + if (!isGenesisBlockValid()) { + rebuildBlockchain(); } + } + // We need to create a new connection, as the previous repository and its connections may be been + // closed by rebuildBlockchain() if a bootstrap was applied + try (final Repository repository = RepositoryManager.getRepository()) { repository.checkConsistency(); // Set the number of blocks to validate based on the pruned state of the chain @@ -615,6 +615,14 @@ public class BlockChain { } private static void rebuildBlockchain() throws DataException { + boolean shouldBootstrap = Settings.getInstance().getBootstrap(); + if (shouldBootstrap) { + // Settings indicate that we should apply a bootstrap rather than rebuilding and syncing from genesis + Bootstrap bootstrap = new Bootstrap(); + bootstrap.startImport(); + return; + } + // (Re)build repository if (!RepositoryManager.wasPristineAtOpen()) RepositoryManager.rebuild(); diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java new file mode 100644 index 00000000..2289db5e --- /dev/null +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -0,0 +1,362 @@ +package org.qortal.repository; + +import org.apache.commons.io.FileUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.block.BlockChain; +import org.qortal.controller.Controller; +import org.qortal.data.account.MintingAccountData; +import org.qortal.data.block.BlockData; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.repository.hsqldb.HSQLDBImportExport; +import org.qortal.repository.hsqldb.HSQLDBRepository; +import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory; +import org.qortal.settings.Settings; +import org.qortal.utils.NTP; +import org.qortal.utils.SevenZ; + +import java.io.BufferedInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; + + +public class Bootstrap { + + private static final Logger LOGGER = LogManager.getLogger(Bootstrap.class); + + /** The maximum number of untrimmed blocks allowed to be included in a bootstrap, beyond the trim threshold */ + private static final int MAXIMUM_UNTRIMMED_BLOCKS = 100; + + /** The maximum number of unpruned blocks allowed to be included in a bootstrap, beyond the prune threshold */ + private static final int MAXIMUM_UNPRUNED_BLOCKS = 100; + + + public Bootstrap() { + + } + + /** + * canBootstrap() + * Performs basic initial checks to ensure everything is in order + * @return true if ready for bootstrap creation, or false if not + * All failure reasons are logged + */ + public boolean canBootstrap() { + LOGGER.info("Checking repository state..."); + + try (final Repository repository = RepositoryManager.getRepository()) { + + final boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); + final boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); + + // Avoid creating bootstraps from pruned nodes until officially supported + if (pruningEnabled) { + LOGGER.info("Creating bootstraps from top-only nodes isn't yet supported."); + // TODO: add support for top-only bootstraps + return false; + } + + // Require that a block archive has been built + if (!archiveEnabled) { + LOGGER.info("Unable to bootstrap because the block archive isn't enabled. " + + "Set {\"archivedEnabled\": true} in settings.json to fix."); + return false; + } + + // Make sure that the block archiver is up to date + boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); + if (!upToDate) { + LOGGER.info("Unable to bootstrap because the block archive isn't fully built yet."); + return false; + } + + // Ensure that this database contains the ATStatesHeightIndex which was missing in some cases + boolean hasAtStatesHeightIndex = repository.getATRepository().hasAtStatesHeightIndex(); + if (!hasAtStatesHeightIndex) { + LOGGER.info("Unable to bootstrap due to missing ATStatesHeightIndex. A re-sync from genesis is needed."); + return false; + } + + // Ensure we have synced NTP time + if (NTP.getTime() == null) { + LOGGER.info("Unable to bootstrap because the node hasn't synced its time yet."); + return false; + } + + // Ensure the chain is synced + final BlockData chainTip = Controller.getInstance().getChainTip(); + final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); + if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) { + LOGGER.info("Unable to bootstrap because the blockchain isn't fully synced."); + return false; + } + + // FUTURE: ensure trim and prune settings are using default values + + // Ensure that the online account signatures have been fully trimmed + final int accountsTrimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); + final long accountsUpperTrimmableTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime(); + final int accountsUpperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(accountsUpperTrimmableTimestamp); + final int accountsBlocksRemaining = accountsUpperTrimmableHeight - accountsTrimStartHeight; + if (accountsBlocksRemaining > MAXIMUM_UNTRIMMED_BLOCKS) { + LOGGER.info("Blockchain is not fully trimmed. Please allow the node to run for longer, " + + "then try again. Blocks remaining (online accounts signatures): {}", accountsBlocksRemaining); + return false; + } + + // Ensure that the AT states data has been fully trimmed + final int atTrimStartHeight = repository.getATRepository().getAtTrimHeight(); + final long atUpperTrimmableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); + final int atUpperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(atUpperTrimmableTimestamp); + final int atBlocksRemaining = atUpperTrimmableHeight - atTrimStartHeight; + if (atBlocksRemaining > MAXIMUM_UNTRIMMED_BLOCKS) { + LOGGER.info("Blockchain is not fully trimmed. Please allow the node to run for longer, " + + "then try again. Blocks remaining (AT states): {}", atBlocksRemaining); + return false; + } + + // Ensure that blocks have been fully pruned + final int blockPruneStartHeight = repository.getBlockRepository().getBlockPruneHeight(); + int blockUpperPrunableHeight = chainTip.getHeight() - Settings.getInstance().getPruneBlockLimit(); + if (archiveEnabled) { + blockUpperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1; + } + final int blocksPruneRemaining = blockUpperPrunableHeight - blockPruneStartHeight; + if (blocksPruneRemaining > MAXIMUM_UNPRUNED_BLOCKS) { + LOGGER.info("Blockchain is not fully pruned. Please allow the node to run for longer, " + + "then try again. Blocks remaining: {}", blocksPruneRemaining); + return false; + } + + // Ensure that AT states have been fully pruned + final int atPruneStartHeight = repository.getATRepository().getAtPruneHeight(); + int atUpperPrunableHeight = chainTip.getHeight() - Settings.getInstance().getPruneBlockLimit(); + if (archiveEnabled) { + atUpperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1; + } + final int atPruneRemaining = atUpperPrunableHeight - atPruneStartHeight; + if (atPruneRemaining > MAXIMUM_UNPRUNED_BLOCKS) { + LOGGER.info("Blockchain is not fully pruned. Please allow the node to run for longer, " + + "then try again. Blocks remaining (AT states): {}", atPruneRemaining); + return false; + } + + LOGGER.info("Repository state checks passed"); + return true; + } + catch (DataException e) { + LOGGER.info("Unable to create bootstrap: {}", e.getMessage()); + return false; + } + } + + /** + * validateBlockchain + * Performs quick validation of recent blocks in blockchain, prior to creating a bootstrap + * @return true if valid, false if not + */ + public boolean validateBlockchain() { + LOGGER.info("Validating blockchain..."); + + try { + BlockChain.validate(); + + LOGGER.info("Blockchain is valid"); + + return true; + } catch (DataException e) { + LOGGER.info("Blockchain validation failed: {}", e.getMessage()); + return false; + } + } + + /** + * validateCompleteBlockchain + * Performs intensive validation of all blocks in blockchain + * @return true if valid, false if not + */ + public boolean validateCompleteBlockchain() { + LOGGER.info("Validating blockchain..."); + + try { + // Perform basic startup validation + BlockChain.validate(); + + // Perform more intensive full-chain validation + BlockChain.validateAllBlocks(); + + LOGGER.info("Blockchain is valid"); + + return true; + } catch (DataException e) { + LOGGER.info("Blockchain validation failed: {}", e.getMessage()); + return false; + } + } + + public String create() throws DataException, InterruptedException, IOException { + try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { + + LOGGER.info("Acquiring blockchain lock..."); + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + blockchainLock.lockInterruptibly(); + + Path inputPath = null; + + try { + + LOGGER.info("Exporting local data..."); + repository.exportNodeLocalData(); + + LOGGER.info("Deleting trade bot states..."); + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + for (TradeBotData tradeBotData : allTradeBotData) { + repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); + } + + LOGGER.info("Deleting minting accounts..."); + List mintingAccounts = repository.getAccountRepository().getMintingAccounts(); + for (MintingAccountData mintingAccount : mintingAccounts) { + repository.getAccountRepository().delete(mintingAccount.getPrivateKey()); + } + + repository.saveChanges(); + + LOGGER.info("Performing repository maintenance..."); + repository.performPeriodicMaintenance(); + + LOGGER.info("Creating bootstrap..."); + repository.backup(true, "bootstrap"); + + LOGGER.info("Moving files to output directory..."); + inputPath = Paths.get(Settings.getInstance().getRepositoryPath(), "bootstrap"); + Path outputPath = Paths.get("bootstrap"); + FileUtils.deleteDirectory(outputPath.toFile()); + + // Move the db backup to a "bootstrap" folder in the root directory + Files.move(inputPath, outputPath); + + // Copy the archive folder to inside the bootstrap folder + FileUtils.copyDirectory( + Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toFile(), + Paths.get(outputPath.toString(), "archive").toFile() + ); + + LOGGER.info("Compressing..."); + String fileName = "bootstrap.7z"; + SevenZ.compress(fileName, outputPath.toFile()); + + // Return the path to the compressed bootstrap file + Path finalPath = Paths.get(outputPath.toString(), fileName); + return finalPath.toAbsolutePath().toString(); + + } finally { + LOGGER.info("Re-importing local data..."); + Path exportPath = HSQLDBImportExport.getExportDirectory(false); + repository.importDataFromFile(Paths.get(exportPath.toString(), "TradeBotStates.json").toString()); + repository.importDataFromFile(Paths.get(exportPath.toString(), "MintingAccounts.json").toString()); + + blockchainLock.unlock(); + + // Cleanup + if (inputPath != null) { + FileUtils.deleteDirectory(inputPath.toFile()); + } + } + } + } + + public void startImport() throws DataException { + Path path = null; + try { + Path tempDir = Files.createTempDirectory("qortal-bootstrap"); + path = Paths.get(tempDir.toString(), "bootstrap.7z"); + + this.downloadToPath(path); + this.importFromPath(path); + + } catch (InterruptedException | DataException | IOException e) { + throw new DataException(String.format("Unable to import bootstrap: %s", e.getMessage())); + } + finally { + if (path != null) { + try { + FileUtils.deleteDirectory(path.toFile()); + + } catch (IOException e) { + // Temp folder will be cleaned up by system anyway, so ignore this failure + } + } + } + } + + private void downloadToPath(Path path) throws DataException { + String bootstrapUrl = "http://bootstrap.qortal.org/bootstrap.7z"; + + while (!Controller.isStopping()) { + try { + LOGGER.info("Downloading bootstrap..."); + InputStream in = new URL(bootstrapUrl).openStream(); + Files.copy(in, path, StandardCopyOption.REPLACE_EXISTING); + break; + + } catch (IOException e) { + LOGGER.info("Unable to download bootstrap: {}", e.getMessage()); + LOGGER.info("Retrying in 5 minutes"); + + try { + Thread.sleep(5 * 60 * 1000L); + } catch (InterruptedException e2) { + break; + } + } + } + + // It's best to throw an exception on all failures, even though we're most likely just stopping + throw new DataException("Unable to download bootstrap"); + } + + private void importFromPath(Path path) throws InterruptedException, DataException, IOException { + + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + blockchainLock.lockInterruptibly(); + + try { + LOGGER.info("Extracting bootstrap..."); + Path input = path.toAbsolutePath(); + Path output = path.getParent().toAbsolutePath(); + SevenZ.decompress(input.toString(), output.toFile()); + + LOGGER.info("Stopping repository..."); + RepositoryManager.closeRepositoryFactory(); + + Path inputPath = Paths.get("bootstrap"); + Path outputPath = Paths.get(Settings.getInstance().getRepositoryPath()); + if (!inputPath.toFile().exists()) { + throw new DataException("Extracted bootstrap doesn't exist"); + } + + // Move the "bootstrap" folder in place of the "db" folder + LOGGER.info("Moving files to output directory..."); + FileUtils.deleteDirectory(outputPath.toFile()); + Files.move(inputPath, outputPath); + + LOGGER.info("Starting repository from bootstrap..."); + RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl()); + RepositoryManager.setRepositoryFactory(repositoryFactory); + + } + finally { + blockchainLock.unlock(); + } + } + +} diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index c392d213..480edc59 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -50,9 +50,9 @@ public abstract class RepositoryManager { repositoryFactory = null; } - public static void backup(boolean quick) { + public static void backup(boolean quick, String name) { try (final Repository repository = getRepository()) { - repository.backup(quick); + repository.backup(quick, name); } catch (DataException e) { // Backup is best-effort so don't complain } diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index d284d59d..8e1ed51b 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -138,6 +138,10 @@ public class Settings { private long archiveInterval = 7171L; // milliseconds + /** Whether to automatically bootstrap instead of syncing from genesis */ + private boolean bootstrap = true; + + // Peer-to-peer related private boolean isTestNet = false; /** Port number for inbound peer-to-peer connections. */ @@ -603,4 +607,9 @@ public class Settings { return this.archiveInterval; } + + public boolean getBootstrap() { + return this.bootstrap; + } + } diff --git a/src/main/java/org/qortal/utils/SevenZ.java b/src/main/java/org/qortal/utils/SevenZ.java new file mode 100644 index 00000000..7af7ffc0 --- /dev/null +++ b/src/main/java/org/qortal/utils/SevenZ.java @@ -0,0 +1,77 @@ +// +// Code originally written by memorynotfound +// https://memorynotfound.com/java-7z-seven-zip-example-compress-decompress-file/ +// Modified Sept 2021 by Qortal Core dev team +// + +package org.qortal.utils; + +import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry; +import org.apache.commons.compress.archivers.sevenz.SevenZFile; +import org.apache.commons.compress.archivers.sevenz.SevenZOutputFile; + +import java.io.*; + +public class SevenZ { + + private SevenZ() { + + } + + public static void compress(String name, File... files) throws IOException { + try (SevenZOutputFile out = new SevenZOutputFile(new File(name))){ + for (File file : files){ + addToArchiveCompression(out, file, "."); + } + } + } + + public static void decompress(String in, File destination) throws IOException { + SevenZFile sevenZFile = new SevenZFile(new File(in)); + SevenZArchiveEntry entry; + while ((entry = sevenZFile.getNextEntry()) != null){ + if (entry.isDirectory()){ + continue; + } + File curfile = new File(destination, entry.getName()); + File parent = curfile.getParentFile(); + if (!parent.exists()) { + parent.mkdirs(); + } + + FileOutputStream out = new FileOutputStream(curfile); + byte[] b = new byte[8192]; + int count = 0; + while ((count = sevenZFile.read(b)) > 0) { + out.write(b, 0, count); + } + out.close(); + } + } + + private static void addToArchiveCompression(SevenZOutputFile out, File file, String dir) throws IOException { + String name = dir + File.separator + file.getName(); + if (file.isFile()){ + SevenZArchiveEntry entry = out.createArchiveEntry(file, name); + out.putArchiveEntry(entry); + + FileInputStream in = new FileInputStream(file); + byte[] b = new byte[8192]; + int count = 0; + while ((count = in.read(b)) > 0) { + out.write(b, 0, count); + } + out.closeArchiveEntry(); + + } else if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null){ + for (File child : children){ + addToArchiveCompression(out, child, name); + } + } + } else { + System.out.println(file.getName() + " is not supported"); + } + } +} diff --git a/src/test/resources/test-settings-v2-bitcoin-regtest.json b/src/test/resources/test-settings-v2-bitcoin-regtest.json index f0a993e2..7f03b447 100644 --- a/src/test/resources/test-settings-v2-bitcoin-regtest.json +++ b/src/test/resources/test-settings-v2-bitcoin-regtest.json @@ -4,6 +4,7 @@ "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2.json", "exportPath": "qortal-backup-test", + "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2-block-archive.json b/src/test/resources/test-settings-v2-block-archive.json index b71b2679..7cac32b6 100644 --- a/src/test/resources/test-settings-v2-block-archive.json +++ b/src/test/resources/test-settings-v2-block-archive.json @@ -4,6 +4,7 @@ "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2.json", "exportPath": "qortal-backup-test", + "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0, diff --git a/src/test/resources/test-settings-v2-founder-rewards.json b/src/test/resources/test-settings-v2-founder-rewards.json index b73544ea..fedd5de4 100644 --- a/src/test/resources/test-settings-v2-founder-rewards.json +++ b/src/test/resources/test-settings-v2-founder-rewards.json @@ -2,6 +2,7 @@ "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-founder-rewards.json", "exportPath": "qortal-backup-test", + "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2-leftover-reward.json b/src/test/resources/test-settings-v2-leftover-reward.json index 5c87cc94..45f86ff3 100644 --- a/src/test/resources/test-settings-v2-leftover-reward.json +++ b/src/test/resources/test-settings-v2-leftover-reward.json @@ -2,6 +2,7 @@ "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-leftover-reward.json", "exportPath": "qortal-backup-test", + "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2-minting.json b/src/test/resources/test-settings-v2-minting.json index abff27e3..c2522774 100644 --- a/src/test/resources/test-settings-v2-minting.json +++ b/src/test/resources/test-settings-v2-minting.json @@ -2,6 +2,7 @@ "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-minting.json", "exportPath": "qortal-backup-test", + "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2-qora-holder-extremes.json b/src/test/resources/test-settings-v2-qora-holder-extremes.json index dbf55170..a4422562 100644 --- a/src/test/resources/test-settings-v2-qora-holder-extremes.json +++ b/src/test/resources/test-settings-v2-qora-holder-extremes.json @@ -2,6 +2,7 @@ "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-qora-holder-extremes.json", "exportPath": "qortal-backup-test", + "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2-qora-holder.json b/src/test/resources/test-settings-v2-qora-holder.json index c9b995a6..f8777ca1 100644 --- a/src/test/resources/test-settings-v2-qora-holder.json +++ b/src/test/resources/test-settings-v2-qora-holder.json @@ -2,6 +2,7 @@ "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-qora-holder.json", "exportPath": "qortal-backup-test", + "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2-reward-levels.json b/src/test/resources/test-settings-v2-reward-levels.json index 4cc8de14..02a91d28 100644 --- a/src/test/resources/test-settings-v2-reward-levels.json +++ b/src/test/resources/test-settings-v2-reward-levels.json @@ -2,6 +2,7 @@ "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-reward-levels.json", "exportPath": "qortal-backup-test", + "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2-reward-scaling.json b/src/test/resources/test-settings-v2-reward-scaling.json index e1958d63..87f77d44 100644 --- a/src/test/resources/test-settings-v2-reward-scaling.json +++ b/src/test/resources/test-settings-v2-reward-scaling.json @@ -2,6 +2,7 @@ "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-reward-scaling.json", "exportPath": "qortal-backup-test", + "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 diff --git a/src/test/resources/test-settings-v2.json b/src/test/resources/test-settings-v2.json index 13c0a60f..4dfaeac1 100644 --- a/src/test/resources/test-settings-v2.json +++ b/src/test/resources/test-settings-v2.json @@ -4,6 +4,7 @@ "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2.json", "exportPath": "qortal-backup-test", + "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0 From ce5bc80347f686c822defc67c30c8cf512ca2df7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 28 Sep 2021 20:21:19 +0100 Subject: [PATCH 129/231] Increased threshold of BlockArchiveWriter.isArchiverUpToDate() from 90 to 95%. In practice, the reading from a correctly archived chain with 550k blocks is currently around 99.5%, but it will be lower if starting with a chain that isn't fully synced. --- src/main/java/org/qortal/repository/BlockArchiveWriter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index 77c98d96..36760a2d 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -60,10 +60,10 @@ public class BlockArchiveWriter { LOGGER.info(String.format("maxArchiveHeight: %d, actualArchiveHeight: %d, progress: %f", maxArchiveHeight, actualArchiveHeight, progress)); - // If archiver is within 90% of the maximum, treat it as up to date + // If archiver is within 95% of the maximum, treat it as up to date // We need several percent as an allowance because the archiver will only // save files when they reach the target size - return (progress >= 0.90); + return (progress >= 0.95); } public BlockArchiveWriteResult write() throws DataException, IOException, TransformationException, InterruptedException { From 0d17f02191c0e4832281c66c69bf9def210fd79b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 28 Sep 2021 20:29:53 +0100 Subject: [PATCH 130/231] Pass a repository instance into the bulk archiving and pruning methods. This is a better approach than opening a new session for each, and it makes it easier to write unit tests. --- .../org/qortal/controller/Controller.java | 10 +- .../qortal/repository/RepositoryManager.java | 13 +- .../hsqldb/HSQLDBDatabaseArchiving.java | 77 ++- .../hsqldb/HSQLDBDatabasePruning.java | 457 +++++++++--------- 4 files changed, 278 insertions(+), 279 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index a00ffdac..feb4b309 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -2,8 +2,11 @@ package org.qortal.controller; import java.awt.TrayIcon.MessageType; import java.io.File; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Path; +import java.nio.file.Paths; import java.security.SecureRandom; import java.security.Security; import java.time.LocalDateTime; @@ -409,8 +412,11 @@ public class Controller extends Thread { try { RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(getRepositoryUrl()); RepositoryManager.setRepositoryFactory(repositoryFactory); - RepositoryManager.archive(); - RepositoryManager.prune(); + + try (final Repository repository = RepositoryManager.getRepository()) { + RepositoryManager.archive(repository); + RepositoryManager.prune(repository); + } } catch (DataException e) { // If exception has no cause then repository is in use by some other process. if (e.getCause() == null) { diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 480edc59..7b96e08c 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -4,6 +4,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.repository.hsqldb.HSQLDBDatabaseArchiving; import org.qortal.repository.hsqldb.HSQLDBDatabasePruning; +import org.qortal.repository.hsqldb.HSQLDBRepository; import org.qortal.settings.Settings; import java.sql.SQLException; @@ -58,12 +59,12 @@ public abstract class RepositoryManager { } } - public static boolean archive() { + public static boolean archive(Repository repository) { // Bulk archive the database the first time we use archive mode if (Settings.getInstance().isArchiveEnabled()) { if (RepositoryManager.canArchiveOrPrune()) { try { - return HSQLDBDatabaseArchiving.buildBlockArchive(); + return HSQLDBDatabaseArchiving.buildBlockArchive(repository); } catch (DataException e) { LOGGER.info("Unable to build block archive. The database may have been left in an inconsistent state."); @@ -76,18 +77,18 @@ public abstract class RepositoryManager { return false; } - public static boolean prune() { + public static boolean prune(Repository repository) { // Bulk prune the database the first time we use pruning mode if (Settings.getInstance().isPruningEnabled() || Settings.getInstance().isArchiveEnabled()) { if (RepositoryManager.canArchiveOrPrune()) { try { - boolean prunedATStates = HSQLDBDatabasePruning.pruneATStates(); - boolean prunedBlocks = HSQLDBDatabasePruning.pruneBlocks(); + boolean prunedATStates = HSQLDBDatabasePruning.pruneATStates((HSQLDBRepository) repository); + boolean prunedBlocks = HSQLDBDatabasePruning.pruneBlocks((HSQLDBRepository) repository); // Perform repository maintenance to shrink the db size down if (prunedATStates && prunedBlocks) { - HSQLDBDatabasePruning.performMaintenance(); + HSQLDBDatabasePruning.performMaintenance(repository); return true; } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java index 618d5115..e9892a0b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java @@ -5,6 +5,7 @@ import org.apache.logging.log4j.Logger; import org.qortal.controller.Controller; import org.qortal.repository.BlockArchiveWriter; import org.qortal.repository.DataException; +import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.transform.TransformationException; @@ -29,53 +30,51 @@ public class HSQLDBDatabaseArchiving { private static final Logger LOGGER = LogManager.getLogger(HSQLDBDatabaseArchiving.class); - public static boolean buildBlockArchive() throws DataException { - try (final HSQLDBRepository repository = (HSQLDBRepository)RepositoryManager.getRepository()) { + public static boolean buildBlockArchive(Repository repository) throws DataException { - // Only build the archive if we have never done so before - int archiveHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); - if (archiveHeight > 0) { - // Already archived - return false; - } + // Only build the archive if we have never done so before + int archiveHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); + if (archiveHeight > 0) { + // Already archived + return false; + } - LOGGER.info("Building block archive - this process could take a while... (approx. 15 mins on high spec)"); + LOGGER.info("Building block archive - this process could take a while... (approx. 15 mins on high spec)"); - final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); - int startHeight = 0; + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + int startHeight = 0; - while (!Controller.isStopping()) { - try { - BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, repository); - BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); - switch (result) { - case OK: - // Increment block archive height - startHeight += writer.getWrittenCount(); - repository.getBlockArchiveRepository().setBlockArchiveHeight(startHeight); - repository.saveChanges(); - break; + while (!Controller.isStopping()) { + try { + BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, repository); + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + switch (result) { + case OK: + // Increment block archive height + startHeight += writer.getWrittenCount(); + repository.getBlockArchiveRepository().setBlockArchiveHeight(startHeight); + repository.saveChanges(); + break; - case STOPPING: - return false; + case STOPPING: + return false; - case NOT_ENOUGH_BLOCKS: - // We've reached the limit of the blocks we can archive - // Return from the whole method - return true; + case NOT_ENOUGH_BLOCKS: + // We've reached the limit of the blocks we can archive + // Return from the whole method + return true; - case BLOCK_NOT_FOUND: - // We tried to archive a block that didn't exist. This is a major failure and likely means - // that a bootstrap or re-sync is needed. Return rom the method - LOGGER.info("Error: block not found when building archive. If this error persists, " + - "a bootstrap or re-sync may be needed."); - return false; - } - - } catch (IOException | TransformationException | InterruptedException e) { - LOGGER.info("Caught exception when creating block cache", e); - return false; + case BLOCK_NOT_FOUND: + // We tried to archive a block that didn't exist. This is a major failure and likely means + // that a bootstrap or re-sync is needed. Return rom the method + LOGGER.info("Error: block not found when building archive. If this error persists, " + + "a bootstrap or re-sync may be needed."); + return false; } + + } catch (IOException | TransformationException | InterruptedException e) { + LOGGER.info("Caught exception when creating block cache", e); + return false; } } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index f0673c3f..3a9c4f02 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -6,6 +6,7 @@ import org.qortal.controller.Controller; import org.qortal.data.block.BlockData; import org.qortal.repository.BlockArchiveWriter; import org.qortal.repository.DataException; +import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; @@ -38,286 +39,278 @@ public class HSQLDBDatabasePruning { private static final Logger LOGGER = LogManager.getLogger(HSQLDBDatabasePruning.class); - public static boolean pruneATStates() throws SQLException, DataException { - try (final HSQLDBRepository repository = (HSQLDBRepository)RepositoryManager.getRepository()) { + public static boolean pruneATStates(HSQLDBRepository repository) throws SQLException, DataException { - // Only bulk prune AT states if we have never done so before - int pruneHeight = repository.getATRepository().getAtPruneHeight(); - if (pruneHeight > 0) { - // Already pruned AT states + // Only bulk prune AT states if we have never done so before + int pruneHeight = repository.getATRepository().getAtPruneHeight(); + if (pruneHeight > 0) { + // Already pruned AT states + return false; + } + + if (Settings.getInstance().isArchiveEnabled()) { + // Only proceed if we can see that the archiver has already finished + // This way, if the archiver failed for any reason, we can prune once it has had + // some opportunities to try again + boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); + if (!upToDate) { return false; } + } - if (Settings.getInstance().isArchiveEnabled()) { - // Only proceed if we can see that the archiver has already finished - // This way, if the archiver failed for any reason, we can prune once it has had - // some opportunities to try again - boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); - if (!upToDate) { - return false; - } - } + LOGGER.info("Starting bulk prune of AT states - this process could take a while... " + + "(approx. 2 mins on high spec, or upwards of 30 mins in some cases)"); - LOGGER.info("Starting bulk prune of AT states - this process could take a while... " + - "(approx. 2 mins on high spec, or upwards of 30 mins in some cases)"); + // Create new AT-states table to hold smaller dataset + repository.executeCheckedUpdate("DROP TABLE IF EXISTS ATStatesNew"); + repository.executeCheckedUpdate("CREATE TABLE ATStatesNew (" + + "AT_address QortalAddress, height INTEGER NOT NULL, state_hash ATStateHash NOT NULL, " + + "fees QortalAmount NOT NULL, is_initial BOOLEAN NOT NULL, sleep_until_message_timestamp BIGINT, " + + "PRIMARY KEY (AT_address, height), " + + "FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); + repository.executeCheckedUpdate("SET TABLE ATStatesNew NEW SPACE"); + repository.executeCheckedUpdate("CHECKPOINT"); - // Create new AT-states table to hold smaller dataset - repository.executeCheckedUpdate("DROP TABLE IF EXISTS ATStatesNew"); - repository.executeCheckedUpdate("CREATE TABLE ATStatesNew (" - + "AT_address QortalAddress, height INTEGER NOT NULL, state_hash ATStateHash NOT NULL, " - + "fees QortalAmount NOT NULL, is_initial BOOLEAN NOT NULL, sleep_until_message_timestamp BIGINT, " - + "PRIMARY KEY (AT_address, height), " - + "FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); - repository.executeCheckedUpdate("SET TABLE ATStatesNew NEW SPACE"); - repository.executeCheckedUpdate("CHECKPOINT"); - - // Add a height index - LOGGER.info("Adding index to AT states table..."); - repository.executeCheckedUpdate("CREATE INDEX IF NOT EXISTS ATStatesNewHeightIndex ON ATStatesNew (height)"); - repository.executeCheckedUpdate("CHECKPOINT"); + // Add a height index + LOGGER.info("Adding index to AT states table..."); + repository.executeCheckedUpdate("CREATE INDEX IF NOT EXISTS ATStatesNewHeightIndex ON ATStatesNew (height)"); + repository.executeCheckedUpdate("CHECKPOINT"); - // Find our latest block - BlockData latestBlock = repository.getBlockRepository().getLastBlock(); - if (latestBlock == null) { - LOGGER.info("Unable to determine blockchain height, necessary for bulk block pruning"); - return false; - } + // Find our latest block + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + if (latestBlock == null) { + LOGGER.info("Unable to determine blockchain height, necessary for bulk block pruning"); + return false; + } - // Calculate some constants for later use - final int blockchainHeight = latestBlock.getHeight(); - int maximumBlockToTrim = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); - if (Settings.getInstance().isArchiveEnabled()) { - // Archive mode - don't prune anything that hasn't been archived yet - maximumBlockToTrim = Math.min(maximumBlockToTrim, repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1); - } - final int startHeight = maximumBlockToTrim; - final int endHeight = blockchainHeight; - final int blockStep = 10000; + // Calculate some constants for later use + final int blockchainHeight = latestBlock.getHeight(); + int maximumBlockToTrim = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + if (Settings.getInstance().isArchiveEnabled()) { + // Archive mode - don't prune anything that hasn't been archived yet + maximumBlockToTrim = Math.min(maximumBlockToTrim, repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1); + } + final int startHeight = maximumBlockToTrim; + final int endHeight = blockchainHeight; + final int blockStep = 10000; - // It's essential that we rebuild the latest AT states here, as we are using this data in the next query. - // Failing to do this will result in important AT states being deleted, rendering the database unusable. - repository.getATRepository().rebuildLatestAtStates(); + // It's essential that we rebuild the latest AT states here, as we are using this data in the next query. + // Failing to do this will result in important AT states being deleted, rendering the database unusable. + repository.getATRepository().rebuildLatestAtStates(); - // Loop through all the LatestATStates and copy them to the new table - LOGGER.info("Copying AT states..."); - for (int height = 0; height < endHeight; height += blockStep) { - //LOGGER.info(String.format("Copying AT states between %d and %d...", height, height + blockStep - 1)); + // Loop through all the LatestATStates and copy them to the new table + LOGGER.info("Copying AT states..."); + for (int height = 0; height < endHeight; height += blockStep) { + //LOGGER.info(String.format("Copying AT states between %d and %d...", height, height + blockStep - 1)); - String sql = "SELECT height, AT_address FROM LatestATStates WHERE height BETWEEN ? AND ?"; - try (ResultSet latestAtStatesResultSet = repository.checkedExecute(sql, height, height + blockStep - 1)) { - if (latestAtStatesResultSet != null) { - do { - int latestAtHeight = latestAtStatesResultSet.getInt(1); - String latestAtAddress = latestAtStatesResultSet.getString(2); + String sql = "SELECT height, AT_address FROM LatestATStates WHERE height BETWEEN ? AND ?"; + try (ResultSet latestAtStatesResultSet = repository.checkedExecute(sql, height, height + blockStep - 1)) { + if (latestAtStatesResultSet != null) { + do { + int latestAtHeight = latestAtStatesResultSet.getInt(1); + String latestAtAddress = latestAtStatesResultSet.getString(2); - // Copy this latest ATState to the new table - //LOGGER.info(String.format("Copying AT %s at height %d...", latestAtAddress, latestAtHeight)); - try { - String updateSql = "INSERT INTO ATStatesNew (" - + "SELECT AT_address, height, state_hash, fees, is_initial, sleep_until_message_timestamp " - + "FROM ATStates " - + "WHERE height = ? AND AT_address = ?)"; - repository.executeCheckedUpdate(updateSql, latestAtHeight, latestAtAddress); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to copy ATStates", e); - } + // Copy this latest ATState to the new table + //LOGGER.info(String.format("Copying AT %s at height %d...", latestAtAddress, latestAtHeight)); + try { + String updateSql = "INSERT INTO ATStatesNew (" + + "SELECT AT_address, height, state_hash, fees, is_initial, sleep_until_message_timestamp " + + "FROM ATStates " + + "WHERE height = ? AND AT_address = ?)"; + repository.executeCheckedUpdate(updateSql, latestAtHeight, latestAtAddress); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to copy ATStates", e); + } - if (height >= startHeight) { - // Now copy this AT's states for each recent block they is present in - for (int i = startHeight; i < endHeight; i++) { - if (latestAtHeight < i) { - // This AT finished before this block so there is nothing to copy - continue; - } + if (height >= startHeight) { + // Now copy this AT's states for each recent block they is present in + for (int i = startHeight; i < endHeight; i++) { + if (latestAtHeight < i) { + // This AT finished before this block so there is nothing to copy + continue; + } - //LOGGER.info(String.format("Copying recent AT %s at height %d...", latestAtAddress, i)); - try { - // Copy each LatestATState to the new table - String updateSql = "INSERT IGNORE INTO ATStatesNew (" - + "SELECT AT_address, height, state_hash, fees, is_initial, sleep_until_message_timestamp " - + "FROM ATStates " - + "WHERE height = ? AND AT_address = ?)"; - repository.executeCheckedUpdate(updateSql, i, latestAtAddress); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to copy ATStates", e); - } + //LOGGER.info(String.format("Copying recent AT %s at height %d...", latestAtAddress, i)); + try { + // Copy each LatestATState to the new table + String updateSql = "INSERT IGNORE INTO ATStatesNew (" + + "SELECT AT_address, height, state_hash, fees, is_initial, sleep_until_message_timestamp " + + "FROM ATStates " + + "WHERE height = ? AND AT_address = ?)"; + repository.executeCheckedUpdate(updateSql, i, latestAtAddress); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to copy ATStates", e); } } + } - } while (latestAtStatesResultSet.next()); - } - } catch (SQLException e) { - throw new DataException("Unable to copy AT states", e); + } while (latestAtStatesResultSet.next()); } + } catch (SQLException e) { + throw new DataException("Unable to copy AT states", e); } - - repository.saveChanges(); - - - // Finally, drop the original table and rename - LOGGER.info("Deleting old AT states..."); - repository.executeCheckedUpdate("DROP TABLE ATStates"); - repository.executeCheckedUpdate("ALTER TABLE ATStatesNew RENAME TO ATStates"); - repository.executeCheckedUpdate("ALTER INDEX ATStatesNewHeightIndex RENAME TO ATStatesHeightIndex"); - repository.executeCheckedUpdate("CHECKPOINT"); - - // Update the prune height - repository.getATRepository().setAtPruneHeight(maximumBlockToTrim); - repository.saveChanges(); - - repository.executeCheckedUpdate("CHECKPOINT"); - - // Now prune/trim the ATStatesData, as this currently goes back over a month - return HSQLDBDatabasePruning.pruneATStateData(); } + + repository.saveChanges(); + + + // Finally, drop the original table and rename + LOGGER.info("Deleting old AT states..."); + repository.executeCheckedUpdate("DROP TABLE ATStates"); + repository.executeCheckedUpdate("ALTER TABLE ATStatesNew RENAME TO ATStates"); + repository.executeCheckedUpdate("ALTER INDEX ATStatesNewHeightIndex RENAME TO ATStatesHeightIndex"); + repository.executeCheckedUpdate("CHECKPOINT"); + + // Update the prune height + repository.getATRepository().setAtPruneHeight(maximumBlockToTrim); + repository.saveChanges(); + + repository.executeCheckedUpdate("CHECKPOINT"); + + // Now prune/trim the ATStatesData, as this currently goes back over a month + return HSQLDBDatabasePruning.pruneATStateData(repository); } /* * Bulk prune ATStatesData to catch up with the now pruned ATStates table * This uses the existing AT States trimming code but with a much higher end block */ - private static boolean pruneATStateData() throws SQLException, DataException { - try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { - - if (Settings.getInstance().isArchiveEnabled()) { - // Don't prune ATStatesData in archive mode - return true; - } - - BlockData latestBlock = repository.getBlockRepository().getLastBlock(); - if (latestBlock == null) { - LOGGER.info("Unable to determine blockchain height, necessary for bulk ATStatesData pruning"); - return false; - } - final int blockchainHeight = latestBlock.getHeight(); - int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); - // ATStateData is already trimmed - so carry on from where we left off in the past - int pruneStartHeight = repository.getATRepository().getAtTrimHeight(); - - LOGGER.info("Starting bulk prune of AT states data - this process could take a while... (approx. 3 mins on high spec)"); - - while (pruneStartHeight < upperPrunableHeight) { - // Prune all AT state data up until our latest minus pruneBlockLimit (or our archive height) - - if (Controller.isStopping()) { - return false; - } - - // Override batch size in the settings because this is a one-off process - final int batchSize = 1000; - final int rowLimitPerBatch = 50000; - int upperBatchHeight = pruneStartHeight + batchSize; - int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); - - LOGGER.trace(String.format("Pruning AT states data between %d and %d...", pruneStartHeight, upperPruneHeight)); - - int numATStatesPruned = repository.getATRepository().trimAtStates(pruneStartHeight, upperPruneHeight, rowLimitPerBatch); - repository.saveChanges(); - - if (numATStatesPruned > 0) { - LOGGER.trace(String.format("Pruned %d AT states data rows between blocks %d and %d", - numATStatesPruned, pruneStartHeight, upperPruneHeight)); - } else { - repository.getATRepository().setAtTrimHeight(upperBatchHeight); - // No need to rebuild the latest AT states as we aren't currently synchronizing - repository.saveChanges(); - LOGGER.debug(String.format("Bumping AT states trim height to %d", upperBatchHeight)); - - // Can we move onto next batch? - if (upperPrunableHeight > upperBatchHeight) { - pruneStartHeight = upperBatchHeight; - } - else { - // We've finished pruning - break; - } - } - } + private static boolean pruneATStateData(Repository repository) throws DataException { + if (Settings.getInstance().isArchiveEnabled()) { + // Don't prune ATStatesData in archive mode return true; } - } - public static boolean pruneBlocks() throws SQLException, DataException { - try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + if (latestBlock == null) { + LOGGER.info("Unable to determine blockchain height, necessary for bulk ATStatesData pruning"); + return false; + } + final int blockchainHeight = latestBlock.getHeight(); + int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + // ATStateData is already trimmed - so carry on from where we left off in the past + int pruneStartHeight = repository.getATRepository().getAtTrimHeight(); - // Only bulk prune AT states if we have never done so before - int pruneHeight = repository.getBlockRepository().getBlockPruneHeight(); - if (pruneHeight > 0) { - // Already pruned blocks + LOGGER.info("Starting bulk prune of AT states data - this process could take a while... (approx. 3 mins on high spec)"); + + while (pruneStartHeight < upperPrunableHeight) { + // Prune all AT state data up until our latest minus pruneBlockLimit (or our archive height) + + if (Controller.isStopping()) { return false; } - if (Settings.getInstance().isArchiveEnabled()) { - // Only proceed if we can see that the archiver has already finished - // This way, if the archiver failed for any reason, we can prune once it has had - // some opportunities to try again - boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); - if (!upToDate) { - return false; - } - } + // Override batch size in the settings because this is a one-off process + final int batchSize = 1000; + final int rowLimitPerBatch = 50000; + int upperBatchHeight = pruneStartHeight + batchSize; + int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); - BlockData latestBlock = repository.getBlockRepository().getLastBlock(); - if (latestBlock == null) { - LOGGER.info("Unable to determine blockchain height, necessary for bulk block pruning"); - return false; - } - final int blockchainHeight = latestBlock.getHeight(); - int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); - int pruneStartHeight = 0; + LOGGER.trace(String.format("Pruning AT states data between %d and %d...", pruneStartHeight, upperPruneHeight)); - if (Settings.getInstance().isArchiveEnabled()) { - // Archive mode - don't prune anything that hasn't been archived yet - upperPrunableHeight = Math.min(upperPrunableHeight, repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1); - } + int numATStatesPruned = repository.getATRepository().trimAtStates(pruneStartHeight, upperPruneHeight, rowLimitPerBatch); + repository.saveChanges(); - LOGGER.info("Starting bulk prune of blocks - this process could take a while... (approx. 5 mins on high spec)"); - - while (pruneStartHeight < upperPrunableHeight) { - // Prune all blocks up until our latest minus pruneBlockLimit - - int upperBatchHeight = pruneStartHeight + Settings.getInstance().getBlockPruneBatchSize(); - int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); - - LOGGER.info(String.format("Pruning blocks between %d and %d...", pruneStartHeight, upperPruneHeight)); - - int numBlocksPruned = repository.getBlockRepository().pruneBlocks(pruneStartHeight, upperPruneHeight); + if (numATStatesPruned > 0) { + LOGGER.trace(String.format("Pruned %d AT states data rows between blocks %d and %d", + numATStatesPruned, pruneStartHeight, upperPruneHeight)); + } else { + repository.getATRepository().setAtTrimHeight(upperBatchHeight); + // No need to rebuild the latest AT states as we aren't currently synchronizing repository.saveChanges(); + LOGGER.debug(String.format("Bumping AT states trim height to %d", upperBatchHeight)); - if (numBlocksPruned > 0) { - LOGGER.info(String.format("Pruned %d block%s between %d and %d", - numBlocksPruned, (numBlocksPruned != 1 ? "s" : ""), - pruneStartHeight, upperPruneHeight)); - } else { - repository.getBlockRepository().setBlockPruneHeight(upperBatchHeight); - repository.saveChanges(); - LOGGER.debug(String.format("Bumping block base prune height to %d", upperBatchHeight)); - - // Can we move onto next batch? - if (upperPrunableHeight > upperBatchHeight) { - pruneStartHeight = upperBatchHeight; - } - else { - // We've finished pruning - break; - } + // Can we move onto next batch? + if (upperPrunableHeight > upperBatchHeight) { + pruneStartHeight = upperBatchHeight; + } + else { + // We've finished pruning + break; } } - - return true; } + + return true; } - public static void performMaintenance() throws SQLException, DataException { - try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { - repository.performPeriodicMaintenance(); + public static boolean pruneBlocks(Repository repository) throws SQLException, DataException { + + // Only bulk prune AT states if we have never done so before + int pruneHeight = repository.getBlockRepository().getBlockPruneHeight(); + if (pruneHeight > 0) { + // Already pruned blocks + return false; } + + if (Settings.getInstance().isArchiveEnabled()) { + // Only proceed if we can see that the archiver has already finished + // This way, if the archiver failed for any reason, we can prune once it has had + // some opportunities to try again + boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); + if (!upToDate) { + return false; + } + } + + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + if (latestBlock == null) { + LOGGER.info("Unable to determine blockchain height, necessary for bulk block pruning"); + return false; + } + final int blockchainHeight = latestBlock.getHeight(); + int upperPrunableHeight = blockchainHeight - Settings.getInstance().getPruneBlockLimit(); + int pruneStartHeight = 0; + + if (Settings.getInstance().isArchiveEnabled()) { + // Archive mode - don't prune anything that hasn't been archived yet + upperPrunableHeight = Math.min(upperPrunableHeight, repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1); + } + + LOGGER.info("Starting bulk prune of blocks - this process could take a while... (approx. 5 mins on high spec)"); + + while (pruneStartHeight < upperPrunableHeight) { + // Prune all blocks up until our latest minus pruneBlockLimit + + int upperBatchHeight = pruneStartHeight + Settings.getInstance().getBlockPruneBatchSize(); + int upperPruneHeight = Math.min(upperBatchHeight, upperPrunableHeight); + + LOGGER.info(String.format("Pruning blocks between %d and %d...", pruneStartHeight, upperPruneHeight)); + + int numBlocksPruned = repository.getBlockRepository().pruneBlocks(pruneStartHeight, upperPruneHeight); + repository.saveChanges(); + + if (numBlocksPruned > 0) { + LOGGER.info(String.format("Pruned %d block%s between %d and %d", + numBlocksPruned, (numBlocksPruned != 1 ? "s" : ""), + pruneStartHeight, upperPruneHeight)); + } else { + repository.getBlockRepository().setBlockPruneHeight(upperBatchHeight); + repository.saveChanges(); + LOGGER.debug(String.format("Bumping block base prune height to %d", upperBatchHeight)); + + // Can we move onto next batch? + if (upperPrunableHeight > upperBatchHeight) { + pruneStartHeight = upperBatchHeight; + } + else { + // We've finished pruning + break; + } + } + } + + return true; + } + + public static void performMaintenance(Repository repository) throws SQLException, DataException { + repository.performPeriodicMaintenance(); } } From 347d799d85263ccd76cff78438e3ca19f2f10d1a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 28 Sep 2021 20:30:06 +0100 Subject: [PATCH 131/231] Reduce log spam. --- src/main/java/org/qortal/repository/BlockArchiveWriter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index 36760a2d..611cecea 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -57,7 +57,7 @@ public class BlockArchiveWriter { final int maxArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); final int actualArchiveHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); final float progress = (float)actualArchiveHeight / (float) maxArchiveHeight; - LOGGER.info(String.format("maxArchiveHeight: %d, actualArchiveHeight: %d, progress: %f", + LOGGER.debug(String.format("maxArchiveHeight: %d, actualArchiveHeight: %d, progress: %f", maxArchiveHeight, actualArchiveHeight, progress)); // If archiver is within 95% of the maximum, treat it as up to date From 7375357b1115c59d7dd6a6a338877bc565bdb8ab Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 1 Oct 2021 07:44:33 +0100 Subject: [PATCH 132/231] Added bootstrap tests This involved adding a feature to the test suite in include the option of using a repository located on disk rather than in memory. Also moved the bootstrap compression/extraction working directories to temporary folders. --- .../api/resource/BootstrapResource.java | 31 ++- .../java/org/qortal/block/BlockChain.java | 32 +-- .../java/org/qortal/repository/Bootstrap.java | 157 +++++++------- .../repository/hsqldb/HSQLDBRepository.java | 2 +- .../java/org/qortal/settings/Settings.java | 7 + src/main/java/org/qortal/utils/SevenZ.java | 4 +- .../java/org/qortal/test/BootstrapTests.java | 204 ++++++++++++++++++ .../java/org/qortal/test/common/Common.java | 48 ++++- .../test-settings-v2-bitcoin-regtest.json | 4 +- .../test-settings-v2-block-archive.json | 2 +- .../test-settings-v2-founder-rewards.json | 4 +- .../test-settings-v2-leftover-reward.json | 4 +- .../resources/test-settings-v2-minting.json | 4 +- ...test-settings-v2-qora-holder-extremes.json | 4 +- .../test-settings-v2-qora-holder.json | 4 +- .../test-settings-v2-reward-levels.json | 4 +- .../test-settings-v2-reward-scaling.json | 4 +- src/test/resources/test-settings-v2.json | 5 +- 18 files changed, 396 insertions(+), 128 deletions(-) create mode 100644 src/test/java/org/qortal/test/BootstrapTests.java diff --git a/src/main/java/org/qortal/api/resource/BootstrapResource.java b/src/main/java/org/qortal/api/resource/BootstrapResource.java index fe2ed378..6cb5e996 100644 --- a/src/main/java/org/qortal/api/resource/BootstrapResource.java +++ b/src/main/java/org/qortal/api/resource/BootstrapResource.java @@ -12,6 +12,8 @@ import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; import org.qortal.repository.Bootstrap; import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.*; @@ -44,17 +46,18 @@ public class BootstrapResource { public String createBootstrap() { Security.checkApiCallAllowed(request); - Bootstrap bootstrap = new Bootstrap(); - if (!bootstrap.canBootstrap()) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - } + try (final Repository repository = RepositoryManager.getRepository()) { - boolean isBlockchainValid = bootstrap.validateBlockchain(); - if (!isBlockchainValid) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE); - } + Bootstrap bootstrap = new Bootstrap(repository); + if (!bootstrap.canBootstrap()) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + } + + boolean isBlockchainValid = bootstrap.validateBlockchain(); + if (!isBlockchainValid) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE); + } - try { return bootstrap.create(); } catch (DataException | InterruptedException | IOException e) { @@ -78,7 +81,13 @@ public class BootstrapResource { public boolean validateBootstrap() { Security.checkApiCallAllowed(request); - Bootstrap bootstrap = new Bootstrap(); - return bootstrap.validateCompleteBlockchain(); + try (final Repository repository = RepositoryManager.getRepository()) { + + Bootstrap bootstrap = new Bootstrap(repository); + return bootstrap.validateCompleteBlockchain(); + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE); + } } } diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 98b8d4fd..a0aca44d 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -508,6 +508,7 @@ public class BlockChain { } boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); + boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); boolean hasBlocks = (chainTip != null && chainTip.getHeight() > 1); if (pruningEnabled && hasBlocks) { @@ -527,31 +528,16 @@ public class BlockChain { // Set the number of blocks to validate based on the pruned state of the chain // If pruned, subtract an extra 10 to allow room for error - int blocksToValidate = pruningEnabled ? Settings.getInstance().getPruneBlockLimit() - 10 : 1440; + int blocksToValidate = (pruningEnabled || archiveEnabled) ? Settings.getInstance().getPruneBlockLimit() - 10 : 1440; int startHeight = Math.max(repository.getBlockRepository().getBlockchainHeight() - blocksToValidate, 1); BlockData detachedBlockData = repository.getBlockRepository().getDetachedBlockSignature(startHeight); if (detachedBlockData != null) { - LOGGER.error(String.format("Block %d's reference does not match any block's signature", detachedBlockData.getHeight())); - - // Orphan if we aren't a pruning node - if (!Settings.getInstance().isPruningEnabled()) { - - // Wait for blockchain lock (whereas orphan() only tries to get lock) - ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); - blockchainLock.lock(); - try { - LOGGER.info(String.format("Orphaning back to block %d", detachedBlockData.getHeight() - 1)); - orphan(detachedBlockData.getHeight() - 1); - } finally { - blockchainLock.unlock(); - } - } - else { - LOGGER.error(String.format("Not orphaning because we are in pruning mode. You may be on an " + - "invalid chain and should consider bootstrapping or re-syncing from genesis.")); - } + LOGGER.error(String.format("Block %d's reference does not match any block's signature", + detachedBlockData.getHeight())); + LOGGER.error(String.format("Your chain may be invalid and you should consider bootstrapping" + + " or re-syncing from genesis.")); } } } @@ -618,8 +604,10 @@ public class BlockChain { boolean shouldBootstrap = Settings.getInstance().getBootstrap(); if (shouldBootstrap) { // Settings indicate that we should apply a bootstrap rather than rebuilding and syncing from genesis - Bootstrap bootstrap = new Bootstrap(); - bootstrap.startImport(); + try (final Repository repository = RepositoryManager.getRepository()) { + Bootstrap bootstrap = new Bootstrap(repository); + bootstrap.startImport(); + } return; } diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 2289db5e..a7d9df37 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -9,27 +9,25 @@ import org.qortal.data.account.MintingAccountData; import org.qortal.data.block.BlockData; import org.qortal.data.crosschain.TradeBotData; import org.qortal.repository.hsqldb.HSQLDBImportExport; -import org.qortal.repository.hsqldb.HSQLDBRepository; import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory; import org.qortal.settings.Settings; import org.qortal.utils.NTP; import org.qortal.utils.SevenZ; -import java.io.BufferedInputStream; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; +import java.nio.file.*; import java.util.List; import java.util.concurrent.locks.ReentrantLock; +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; + public class Bootstrap { + private Repository repository; + private static final Logger LOGGER = LogManager.getLogger(Bootstrap.class); /** The maximum number of untrimmed blocks allowed to be included in a bootstrap, beyond the trim threshold */ @@ -39,8 +37,8 @@ public class Bootstrap { private static final int MAXIMUM_UNPRUNED_BLOCKS = 100; - public Bootstrap() { - + public Bootstrap(Repository repository) { + this.repository = repository; } /** @@ -50,9 +48,8 @@ public class Bootstrap { * All failure reasons are logged */ public boolean canBootstrap() { - LOGGER.info("Checking repository state..."); - - try (final Repository repository = RepositoryManager.getRepository()) { + try { + LOGGER.info("Checking repository state..."); final boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); final boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); @@ -203,73 +200,80 @@ public class Bootstrap { } public String create() throws DataException, InterruptedException, IOException { - try (final HSQLDBRepository repository = (HSQLDBRepository) RepositoryManager.getRepository()) { - LOGGER.info("Acquiring blockchain lock..."); - ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); - blockchainLock.lockInterruptibly(); + LOGGER.info("Acquiring blockchain lock..."); + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + blockchainLock.lockInterruptibly(); - Path inputPath = null; + Path inputPath = null; + Path outputPath = null; + try { + + LOGGER.info("Exporting local data..."); + repository.exportNodeLocalData(); + + LOGGER.info("Deleting trade bot states..."); + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + for (TradeBotData tradeBotData : allTradeBotData) { + repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); + } + + LOGGER.info("Deleting minting accounts..."); + List mintingAccounts = repository.getAccountRepository().getMintingAccounts(); + for (MintingAccountData mintingAccount : mintingAccounts) { + repository.getAccountRepository().delete(mintingAccount.getPrivateKey()); + } + + repository.saveChanges(); + + LOGGER.info("Performing repository maintenance..."); + repository.performPeriodicMaintenance(); + + LOGGER.info("Creating bootstrap..."); + repository.backup(true, "bootstrap"); + + LOGGER.info("Moving files to output directory..."); + inputPath = Paths.get(Settings.getInstance().getRepositoryPath(), "bootstrap"); + outputPath = Paths.get(Files.createTempDirectory("qortal-bootstrap").toString(), "bootstrap"); + + + // Move the db backup to a "bootstrap" folder in the root directory + Files.move(inputPath, outputPath, REPLACE_EXISTING); + + // Copy the archive folder to inside the bootstrap folder + FileUtils.copyDirectory( + Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toFile(), + Paths.get(outputPath.toString(), "archive").toFile() + ); + + LOGGER.info("Compressing..."); + String compressedOutputPath = String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), "bootstrap.7z"); try { + Files.delete(Paths.get(compressedOutputPath)); + } catch (NoSuchFileException e) { + // Doesn't exist, so no need to delete + } + SevenZ.compress(compressedOutputPath, outputPath.toFile()); - LOGGER.info("Exporting local data..."); - repository.exportNodeLocalData(); + // Return the path to the compressed bootstrap file + Path finalPath = Paths.get(outputPath.toString(), compressedOutputPath); + return finalPath.toAbsolutePath().toString(); - LOGGER.info("Deleting trade bot states..."); - List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); - for (TradeBotData tradeBotData : allTradeBotData) { - repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); - } + } finally { + LOGGER.info("Re-importing local data..."); + Path exportPath = HSQLDBImportExport.getExportDirectory(false); + repository.importDataFromFile(Paths.get(exportPath.toString(), "TradeBotStates.json").toString()); + repository.importDataFromFile(Paths.get(exportPath.toString(), "MintingAccounts.json").toString()); - LOGGER.info("Deleting minting accounts..."); - List mintingAccounts = repository.getAccountRepository().getMintingAccounts(); - for (MintingAccountData mintingAccount : mintingAccounts) { - repository.getAccountRepository().delete(mintingAccount.getPrivateKey()); - } + blockchainLock.unlock(); - repository.saveChanges(); - - LOGGER.info("Performing repository maintenance..."); - repository.performPeriodicMaintenance(); - - LOGGER.info("Creating bootstrap..."); - repository.backup(true, "bootstrap"); - - LOGGER.info("Moving files to output directory..."); - inputPath = Paths.get(Settings.getInstance().getRepositoryPath(), "bootstrap"); - Path outputPath = Paths.get("bootstrap"); + // Cleanup + if (inputPath != null) { + FileUtils.deleteDirectory(inputPath.toFile()); + } + if (outputPath != null) { FileUtils.deleteDirectory(outputPath.toFile()); - - // Move the db backup to a "bootstrap" folder in the root directory - Files.move(inputPath, outputPath); - - // Copy the archive folder to inside the bootstrap folder - FileUtils.copyDirectory( - Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toFile(), - Paths.get(outputPath.toString(), "archive").toFile() - ); - - LOGGER.info("Compressing..."); - String fileName = "bootstrap.7z"; - SevenZ.compress(fileName, outputPath.toFile()); - - // Return the path to the compressed bootstrap file - Path finalPath = Paths.get(outputPath.toString(), fileName); - return finalPath.toAbsolutePath().toString(); - - } finally { - LOGGER.info("Re-importing local data..."); - Path exportPath = HSQLDBImportExport.getExportDirectory(false); - repository.importDataFromFile(Paths.get(exportPath.toString(), "TradeBotStates.json").toString()); - repository.importDataFromFile(Paths.get(exportPath.toString(), "MintingAccounts.json").toString()); - - blockchainLock.unlock(); - - // Cleanup - if (inputPath != null) { - FileUtils.deleteDirectory(inputPath.toFile()); - } } } } @@ -305,7 +309,7 @@ public class Bootstrap { try { LOGGER.info("Downloading bootstrap..."); InputStream in = new URL(bootstrapUrl).openStream(); - Files.copy(in, path, StandardCopyOption.REPLACE_EXISTING); + Files.copy(in, path, REPLACE_EXISTING); break; } catch (IOException e) { @@ -324,7 +328,7 @@ public class Bootstrap { throw new DataException("Unable to download bootstrap"); } - private void importFromPath(Path path) throws InterruptedException, DataException, IOException { + public void importFromPath(Path path) throws InterruptedException, DataException, IOException { ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); blockchainLock.lockInterruptibly(); @@ -332,13 +336,18 @@ public class Bootstrap { try { LOGGER.info("Extracting bootstrap..."); Path input = path.toAbsolutePath(); - Path output = path.getParent().toAbsolutePath(); + Path output = path.toAbsolutePath().getParent().toAbsolutePath(); SevenZ.decompress(input.toString(), output.toFile()); LOGGER.info("Stopping repository..."); + // Close the repository while we are still able to + // Otherwise, the caller will run into difficulties when it tries to close it + repository.discardChanges(); + repository.close(); + // Now close the repository factory so that we can swap out the database files RepositoryManager.closeRepositoryFactory(); - Path inputPath = Paths.get("bootstrap"); + Path inputPath = Paths.get(output.toString(), "bootstrap"); Path outputPath = Paths.get(Settings.getInstance().getRepositoryPath()); if (!inputPath.toFile().exists()) { throw new DataException("Extracted bootstrap doesn't exist"); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 8c69e0f2..6be8b9ea 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -267,7 +267,7 @@ public class HSQLDBRepository implements Repository { public void close() throws DataException { // Already closed? No need to do anything but maybe report double-call if (this.connection == null) { - LOGGER.warn("HSQLDBRepository.close() called when repository already closed", new Exception("Repository already closed")); + LOGGER.warn("HSQLDBRepository.close() called when repository already closed. This is expected when bootstrapping."); return; } diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 8e1ed51b..7175c60a 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -191,6 +191,9 @@ public class Settings { // Export/import private String exportPath = "qortal-backup"; + // Bootstrap + private String bootstrapFilenamePrefix = ""; + // Auto-update sources private String[] autoUpdateRepos = new String[] { "https://github.com/Qortal/qortal/raw/%s/qortal.update", @@ -513,6 +516,10 @@ public class Settings { return this.exportPath; } + public String getBootstrapFilenamePrefix() { + return this.bootstrapFilenamePrefix; + } + public boolean isAutoUpdateEnabled() { return this.autoUpdateEnabled; } diff --git a/src/main/java/org/qortal/utils/SevenZ.java b/src/main/java/org/qortal/utils/SevenZ.java index 7af7ffc0..2c812e99 100644 --- a/src/main/java/org/qortal/utils/SevenZ.java +++ b/src/main/java/org/qortal/utils/SevenZ.java @@ -18,8 +18,8 @@ public class SevenZ { } - public static void compress(String name, File... files) throws IOException { - try (SevenZOutputFile out = new SevenZOutputFile(new File(name))){ + public static void compress(String outputPath, File... files) throws IOException { + try (SevenZOutputFile out = new SevenZOutputFile(new File(outputPath))){ for (File file : files){ addToArchiveCompression(out, file, "."); } diff --git a/src/test/java/org/qortal/test/BootstrapTests.java b/src/test/java/org/qortal/test/BootstrapTests.java new file mode 100644 index 00000000..6e76a386 --- /dev/null +++ b/src/test/java/org/qortal/test/BootstrapTests.java @@ -0,0 +1,204 @@ +package org.qortal.test; + +import org.apache.commons.io.FileUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.controller.BlockMinter; +import org.qortal.controller.Controller; +import org.qortal.data.block.BlockData; +import org.qortal.repository.*; +import org.qortal.settings.Settings; +import org.qortal.test.common.AtUtils; +import org.qortal.test.common.Common; +import org.qortal.transform.TransformationException; +import org.qortal.utils.NTP; + +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.*; + +public class BootstrapTests extends Common { + + @Before + public void beforeTest() throws DataException, IOException { + Common.useSettingsAndDb(Common.testSettingsFilename, false); + NTP.setFixedOffset(Settings.getInstance().getTestNtpOffset()); + this.deleteBootstraps(); + } + + @After + public void afterTest() throws DataException, IOException { + this.deleteBootstraps(); + this.deleteExportDirectory(); + } + + @Test + public void testCanBootstrap() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + this.buildDummyBlockchain(repository); + + Bootstrap bootstrap = new Bootstrap(repository); + assertTrue(bootstrap.canBootstrap()); + + } + } + + @Test + public void testValidateBlockchain() throws DataException, InterruptedException, TransformationException, IOException { + try (final Repository repository = RepositoryManager.getRepository()) { + this.buildDummyBlockchain(repository); + + Bootstrap bootstrap = new Bootstrap(repository); + assertTrue(bootstrap.validateBlockchain()); + + } + } + + + @Test + public void testCreateAndImportBootstrap() throws DataException, InterruptedException, TransformationException, IOException { + + Path bootstrapPath = Paths.get(String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), "bootstrap.7z")); + Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive", "2-900.dat"); + BlockData block1000; + byte[] originalArchiveContents; + + try (final Repository repository = RepositoryManager.getRepository()) { + this.buildDummyBlockchain(repository); + + // Ensure the compressed bootstrap doesn't exist + assertFalse(Files.exists(bootstrapPath)); + + Bootstrap bootstrap = new Bootstrap(repository); + bootstrap.create(); + + // Ensure the compressed bootstrap exists + assertTrue(Files.exists(bootstrapPath)); + + // Ensure the original block archive file exists + assertTrue(Files.exists(archivePath)); + originalArchiveContents = Files.readAllBytes(archivePath); + + // Ensure block 1000 exists in the repository + block1000 = repository.getBlockRepository().fromHeight(1000); + assertNotNull(block1000); + + // Ensure we can retrieve block 10 from the archive + assertNotNull(repository.getBlockArchiveRepository().fromHeight(10)); + + // Now delete block 1000 + repository.getBlockRepository().delete(block1000); + assertNull(repository.getBlockRepository().fromHeight(1000)); + + // Overwrite the archive with dummy data, and verify it + try (PrintWriter out = new PrintWriter(archivePath.toFile())) { + out.println("testdata"); + } + String newline = System.getProperty("line.separator"); + assertEquals("testdata", Files.readString(archivePath).replace(newline, "")); + + // Ensure we can no longer retrieve block 10 from the archive + assertNull(repository.getBlockArchiveRepository().fromHeight(10)); + + // Import the bootstrap back in + bootstrap.importFromPath(bootstrapPath); + } + + // We need a new connection because we have switched to a new repository + try (final Repository repository = RepositoryManager.getRepository()) { + + // Ensure the block archive file exists + assertTrue(Files.exists(archivePath)); + + // and that its contents match the original + assertArrayEquals(originalArchiveContents, Files.readAllBytes(archivePath)); + + // Make sure that block 1000 exists again + BlockData newBlock1000 = repository.getBlockRepository().fromHeight(1000); + assertNotNull(newBlock1000); + + // and ensure that the signatures match + assertArrayEquals(block1000.getSignature(), newBlock1000.getSignature()); + + // Ensure we can retrieve block 10 from the archive + assertNotNull(repository.getBlockArchiveRepository().fromHeight(10)); + } + } + + + private void buildDummyBlockchain(Repository repository) throws DataException, InterruptedException, TransformationException, IOException { + // Alice self share online + List mintingAndOnlineAccounts = new ArrayList<>(); + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + mintingAndOnlineAccounts.add(aliceSelfShare); + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) + BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Assume 900 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); + repository.getATRepository().setAtTrimHeight(901); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + + // Write blocks 2-900 to the archive + BlockArchiveWriter writer = new BlockArchiveWriter(0, maximumArchiveHeight, repository); + writer.setShouldEnforceFileSizeTarget(false); // To avoid the need to pre-calculate file sizes + BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); + + // Increment block archive height + repository.getBlockArchiveRepository().setBlockArchiveHeight(901); + + // Prune all the archived blocks + repository.getBlockRepository().pruneBlocks(0, 900); + repository.getBlockRepository().setBlockPruneHeight(901); + + // Prune the AT states for the archived blocks + repository.getATRepository().rebuildLatestAtStates(); + repository.getATRepository().pruneAtStates(0, 900); + repository.getATRepository().setAtPruneHeight(901); + + // Refill cache, used by Controller.getMinimumLatestBlockTimestamp() and other methods + Controller.getInstance().refillLatestBlocksCache(); + + repository.saveChanges(); + } + + private void deleteBootstraps() throws IOException { + try { + Path path = Paths.get(String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), "bootstrap.7z")); + Files.delete(path); + + } catch (NoSuchFileException e) { + // Nothing to delete + } + } + + private void deleteExportDirectory() { + // Delete archive directory if exists + Path archivePath = Paths.get(Settings.getInstance().getExportPath()); + try { + FileUtils.deleteDirectory(archivePath.toFile()); + } catch (IOException e) { + + } + } + +} diff --git a/src/test/java/org/qortal/test/common/Common.java b/src/test/java/org/qortal/test/common/Common.java index 24c86690..c45fcfd7 100644 --- a/src/test/java/org/qortal/test/common/Common.java +++ b/src/test/java/org/qortal/test/common/Common.java @@ -2,8 +2,11 @@ package org.qortal.test.common; import static org.junit.Assert.*; +import java.io.IOException; import java.math.BigDecimal; import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; import java.security.Security; import java.util.ArrayList; import java.util.Collections; @@ -15,6 +18,7 @@ import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; +import org.apache.commons.io.FileUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.bouncycastle.jce.provider.BouncyCastleProvider; @@ -46,9 +50,15 @@ public class Common { private static final Logger LOGGER = LogManager.getLogger(Common.class); - public static final String testConnectionUrl = "jdbc:hsqldb:mem:testdb"; - // For debugging, use this instead to write DB to disk for examination: - // public static final String testConnectionUrl = "jdbc:hsqldb:file:testdb/blockchain;create=true"; + public static final String testConnectionUrlMemory = "jdbc:hsqldb:mem:testdb"; + public static final String testConnectionUrlDisk = "jdbc:hsqldb:file:%s/blockchain;create=true"; + + // For debugging, use testConnectionUrlDisk instead of memory, to write DB to disk for examination. + // This can be achieved using `Common.useSettingsAndDb(Common.testSettingsFilename, false);` + // where `false` specifies to use a repository on disk rather than one in memory. + // Make sure to also comment out `Common.deleteTestRepository();` in closeRepository() below, so that + // the files remain after the test finishes. + public static final String testSettingsFilename = "test-settings-v2.json"; @@ -100,7 +110,7 @@ public class Common { return testAccountsByName.values().stream().map(account -> new TestAccount(repository, account)).collect(Collectors.toList()); } - public static void useSettings(String settingsFilename) throws DataException { + public static void useSettingsAndDb(String settingsFilename, boolean dbInMemory) throws DataException { closeRepository(); // Load/check settings, which potentially sets up blockchain config, etc. @@ -109,11 +119,15 @@ public class Common { assertNotNull("Test settings JSON file not found", testSettingsUrl); Settings.fileInstance(testSettingsUrl.getPath()); - setRepository(); + setRepository(dbInMemory); resetBlockchain(); } + public static void useSettings(String settingsFilename) throws DataException { + Common.useSettingsAndDb(settingsFilename, true); + } + public static void useDefaultSettings() throws DataException { useSettings(testSettingsFilename); NTP.setFixedOffset(Settings.getInstance().getTestNtpOffset()); @@ -186,15 +200,33 @@ public class Common { assertTrue(String.format("Non-genesis %s remains", typeName), remainingClone.isEmpty()); } - @BeforeClass - public static void setRepository() throws DataException { - RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(testConnectionUrl); + public static void setRepository(boolean inMemory) throws DataException { + String connectionUrlDisk = String.format(testConnectionUrlDisk, Settings.getInstance().getRepositoryPath()); + String connectionUrl = inMemory ? testConnectionUrlMemory : connectionUrlDisk; + RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(connectionUrl); RepositoryManager.setRepositoryFactory(repositoryFactory); } + public static void deleteTestRepository() throws DataException { + // Delete repository directory if exists + Path repositoryPath = Paths.get(Settings.getInstance().getRepositoryPath()); + try { + FileUtils.deleteDirectory(repositoryPath.toFile()); + } catch (IOException e) { + throw new DataException(String.format("Unable to delete test repository: %s", e.getMessage())); + } + } + + @BeforeClass + public static void setRepositoryInMemory() throws DataException { + Common.deleteTestRepository(); + Common.setRepository(true); + } + @AfterClass public static void closeRepository() throws DataException { RepositoryManager.closeRepositoryFactory(); + Common.deleteTestRepository(); // Comment out this line in you need to inspect the database after running a test } // Test assertions diff --git a/src/test/resources/test-settings-v2-bitcoin-regtest.json b/src/test/resources/test-settings-v2-bitcoin-regtest.json index 7f03b447..687c240d 100644 --- a/src/test/resources/test-settings-v2-bitcoin-regtest.json +++ b/src/test/resources/test-settings-v2-bitcoin-regtest.json @@ -1,4 +1,5 @@ { + "repositoryPath": "testdb", "bitcoinNet": "REGTEST", "litecoinNet": "REGTEST", "restrictedApi": false, @@ -7,5 +8,6 @@ "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, - "minPeers": 0 + "minPeers": 0, + "pruneBlockLimit": 100 } diff --git a/src/test/resources/test-settings-v2-block-archive.json b/src/test/resources/test-settings-v2-block-archive.json index 7cac32b6..c5ed1aa8 100644 --- a/src/test/resources/test-settings-v2-block-archive.json +++ b/src/test/resources/test-settings-v2-block-archive.json @@ -8,6 +8,6 @@ "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, "minPeers": 0, - "pruneBlockLimit": 1450, + "pruneBlockLimit": 100, "repositoryPath": "dbtest" } diff --git a/src/test/resources/test-settings-v2-founder-rewards.json b/src/test/resources/test-settings-v2-founder-rewards.json index fedd5de4..02d71d76 100644 --- a/src/test/resources/test-settings-v2-founder-rewards.json +++ b/src/test/resources/test-settings-v2-founder-rewards.json @@ -1,9 +1,11 @@ { + "repositoryPath": "testdb", "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-founder-rewards.json", "exportPath": "qortal-backup-test", "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, - "minPeers": 0 + "minPeers": 0, + "pruneBlockLimit": 100 } diff --git a/src/test/resources/test-settings-v2-leftover-reward.json b/src/test/resources/test-settings-v2-leftover-reward.json index 45f86ff3..185bbeba 100644 --- a/src/test/resources/test-settings-v2-leftover-reward.json +++ b/src/test/resources/test-settings-v2-leftover-reward.json @@ -1,9 +1,11 @@ { + "repositoryPath": "testdb", "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-leftover-reward.json", "exportPath": "qortal-backup-test", "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, - "minPeers": 0 + "minPeers": 0, + "pruneBlockLimit": 100 } diff --git a/src/test/resources/test-settings-v2-minting.json b/src/test/resources/test-settings-v2-minting.json index c2522774..b5645812 100644 --- a/src/test/resources/test-settings-v2-minting.json +++ b/src/test/resources/test-settings-v2-minting.json @@ -1,9 +1,11 @@ { + "repositoryPath": "testdb", "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-minting.json", "exportPath": "qortal-backup-test", "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, - "minPeers": 0 + "minPeers": 0, + "pruneBlockLimit": 100 } diff --git a/src/test/resources/test-settings-v2-qora-holder-extremes.json b/src/test/resources/test-settings-v2-qora-holder-extremes.json index a4422562..e20fddf0 100644 --- a/src/test/resources/test-settings-v2-qora-holder-extremes.json +++ b/src/test/resources/test-settings-v2-qora-holder-extremes.json @@ -1,9 +1,11 @@ { + "repositoryPath": "testdb", "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-qora-holder-extremes.json", "exportPath": "qortal-backup-test", "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, - "minPeers": 0 + "minPeers": 0, + "pruneBlockLimit": 100 } diff --git a/src/test/resources/test-settings-v2-qora-holder.json b/src/test/resources/test-settings-v2-qora-holder.json index f8777ca1..9d7d2567 100644 --- a/src/test/resources/test-settings-v2-qora-holder.json +++ b/src/test/resources/test-settings-v2-qora-holder.json @@ -1,9 +1,11 @@ { + "repositoryPath": "testdb", "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-qora-holder.json", "exportPath": "qortal-backup-test", "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, - "minPeers": 0 + "minPeers": 0, + "pruneBlockLimit": 100 } diff --git a/src/test/resources/test-settings-v2-reward-levels.json b/src/test/resources/test-settings-v2-reward-levels.json index 02a91d28..3ee0179d 100644 --- a/src/test/resources/test-settings-v2-reward-levels.json +++ b/src/test/resources/test-settings-v2-reward-levels.json @@ -1,9 +1,11 @@ { + "repositoryPath": "testdb", "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-reward-levels.json", "exportPath": "qortal-backup-test", "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, - "minPeers": 0 + "minPeers": 0, + "pruneBlockLimit": 100 } diff --git a/src/test/resources/test-settings-v2-reward-scaling.json b/src/test/resources/test-settings-v2-reward-scaling.json index 87f77d44..fa02ebe7 100644 --- a/src/test/resources/test-settings-v2-reward-scaling.json +++ b/src/test/resources/test-settings-v2-reward-scaling.json @@ -1,9 +1,11 @@ { + "repositoryPath": "testdb", "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2-reward-scaling.json", "exportPath": "qortal-backup-test", "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, - "minPeers": 0 + "minPeers": 0, + "pruneBlockLimit": 100 } diff --git a/src/test/resources/test-settings-v2.json b/src/test/resources/test-settings-v2.json index 4dfaeac1..83bdf197 100644 --- a/src/test/resources/test-settings-v2.json +++ b/src/test/resources/test-settings-v2.json @@ -1,4 +1,5 @@ { + "repositoryPath": "testdb", "bitcoinNet": "TEST3", "litecoinNet": "TEST3", "restrictedApi": false, @@ -7,5 +8,7 @@ "bootstrap": false, "wipeUnconfirmedOnStart": false, "testNtpOffset": 0, - "minPeers": 0 + "minPeers": 0, + "pruneBlockLimit": 100, + "bootstrapFilenamePrefix": "test-" } From 5b231170cdf8b89d2c7f60825e304c0559680650 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 1 Oct 2021 08:10:28 +0100 Subject: [PATCH 133/231] Fixed small issues with block archive tests. --- .../java/org/qortal/test/BlockArchiveTests.java | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/test/java/org/qortal/test/BlockArchiveTests.java b/src/test/java/org/qortal/test/BlockArchiveTests.java index 373c98f2..af7804f9 100644 --- a/src/test/java/org/qortal/test/BlockArchiveTests.java +++ b/src/test/java/org/qortal/test/BlockArchiveTests.java @@ -1,32 +1,25 @@ package org.qortal.test; import org.apache.commons.io.FileUtils; -import org.ciyam.at.CompilationException; -import org.ciyam.at.MachineState; -import org.ciyam.at.OpCode; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.qortal.account.PrivateKeyAccount; -import org.qortal.asset.Asset; import org.qortal.controller.BlockMinter; import org.qortal.data.at.ATStateData; import org.qortal.data.block.BlockData; -import org.qortal.data.transaction.BaseTransactionData; -import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.data.transaction.TransactionData; -import org.qortal.group.Group; import org.qortal.repository.*; import org.qortal.repository.hsqldb.HSQLDBRepository; import org.qortal.settings.Settings; import org.qortal.test.common.AtUtils; import org.qortal.test.common.BlockUtils; import org.qortal.test.common.Common; -import org.qortal.test.common.TransactionUtils; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.Transaction; import org.qortal.transform.TransformationException; import org.qortal.utils.BlockArchiveUtils; +import org.qortal.utils.NTP; import org.qortal.utils.Triple; import java.io.File; @@ -44,8 +37,8 @@ public class BlockArchiveTests extends Common { @Before public void beforeTest() throws DataException { - Common.useDefaultSettings(); // Necessary to set NTP offset Common.useSettings("test-settings-v2-block-archive.json"); + NTP.setFixedOffset(Settings.getInstance().getTestNtpOffset()); this.deleteArchiveDirectory(); } @@ -317,9 +310,9 @@ public class BlockArchiveTests extends Common { assertEquals(900 - 1, writer.getWrittenCount()); // Increment block archive height - repository.getBlockArchiveRepository().setBlockArchiveHeight(writer.getWrittenCount()); + repository.getBlockArchiveRepository().setBlockArchiveHeight(901); repository.saveChanges(); - assertEquals(900 - 1, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + assertEquals(901, repository.getBlockArchiveRepository().getBlockArchiveHeight()); // Ensure the file exists File outputFile = writer.getOutputPath().toFile(); From 446f9243803e31d4d7f765a7fb27548a83378597 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 1 Oct 2021 09:32:35 +0100 Subject: [PATCH 134/231] Added bulk pruning test, which highlighted some bugs in both bulk and regular pruning. --- .../controller/repository/BlockPruner.java | 19 +++-- .../qortal/repository/BlockArchiveWriter.java | 10 ++- .../qortal/repository/RepositoryManager.java | 2 +- .../hsqldb/HSQLDBDatabaseArchiving.java | 5 +- .../hsqldb/HSQLDBDatabasePruning.java | 12 ++-- .../org/qortal/test/BlockArchiveTests.java | 71 ++++++++++++++++++- 6 files changed, 99 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/qortal/controller/repository/BlockPruner.java b/src/main/java/org/qortal/controller/repository/BlockPruner.java index f5be6ee8..46cf919b 100644 --- a/src/main/java/org/qortal/controller/repository/BlockPruner.java +++ b/src/main/java/org/qortal/controller/repository/BlockPruner.java @@ -83,19 +83,18 @@ public class BlockPruner implements Runnable { repository.saveChanges(); if (numBlocksPruned > 0) { - final int finalPruneStartHeight = pruneStartHeight; - LOGGER.debug(() -> String.format("Pruned %d block%s between %d and %d", + LOGGER.debug(String.format("Pruned %d block%s between %d and %d", numBlocksPruned, (numBlocksPruned != 1 ? "s" : ""), - finalPruneStartHeight, upperPruneHeight)); + pruneStartHeight, upperPruneHeight)); } else { - // Can we move onto next batch? - if (upperPrunableHeight > upperBatchHeight) { - pruneStartHeight = upperBatchHeight; - repository.getBlockRepository().setBlockPruneHeight(pruneStartHeight); - repository.saveChanges(); + final int nextPruneHeight = upperPruneHeight + 1; + repository.getBlockRepository().setBlockPruneHeight(nextPruneHeight); + repository.saveChanges(); + LOGGER.debug(String.format("Bumping block base prune height to %d", pruneStartHeight)); - final int finalPruneStartHeight = pruneStartHeight; - LOGGER.debug(() -> String.format("Bumping block base prune height to %d", finalPruneStartHeight)); + // Can we move onto next batch? + if (upperPrunableHeight > nextPruneHeight) { + pruneStartHeight = nextPruneHeight; } else { // We've pruned up to the upper prunable height diff --git a/src/main/java/org/qortal/repository/BlockArchiveWriter.java b/src/main/java/org/qortal/repository/BlockArchiveWriter.java index 611cecea..39c28fd6 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveWriter.java +++ b/src/main/java/org/qortal/repository/BlockArchiveWriter.java @@ -29,14 +29,17 @@ public class BlockArchiveWriter { private static final Logger LOGGER = LogManager.getLogger(BlockArchiveWriter.class); + public static final long DEFAULT_FILE_SIZE_TARGET = 100 * 1024 * 1024; // 100MiB + private int startHeight; private final int endHeight; private final Repository repository; - private long fileSizeTarget = 100 * 1024 * 1024; // 100MiB + private long fileSizeTarget = DEFAULT_FILE_SIZE_TARGET; private boolean shouldEnforceFileSizeTarget = true; private int writtenCount; + private int lastWrittenHeight; private Path outputPath; public BlockArchiveWriter(int startHeight, int endHeight, Repository repository) { @@ -169,6 +172,7 @@ public class BlockArchiveWriter { BlockArchiveReader.getInstance().invalidateFileListCache(); this.writtenCount = i; + this.lastWrittenHeight = endHeight; this.outputPath = Paths.get(filePath); return BlockArchiveWriteResult.OK; } @@ -177,6 +181,10 @@ public class BlockArchiveWriter { return this.writtenCount; } + public int getLastWrittenHeight() { + return this.lastWrittenHeight; + } + public Path getOutputPath() { return this.outputPath; } diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 7b96e08c..1b398057 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -64,7 +64,7 @@ public abstract class RepositoryManager { if (Settings.getInstance().isArchiveEnabled()) { if (RepositoryManager.canArchiveOrPrune()) { try { - return HSQLDBDatabaseArchiving.buildBlockArchive(repository); + return HSQLDBDatabaseArchiving.buildBlockArchive(repository, BlockArchiveWriter.DEFAULT_FILE_SIZE_TARGET); } catch (DataException e) { LOGGER.info("Unable to build block archive. The database may have been left in an inconsistent state."); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java index e9892a0b..0ad315e3 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java @@ -30,7 +30,7 @@ public class HSQLDBDatabaseArchiving { private static final Logger LOGGER = LogManager.getLogger(HSQLDBDatabaseArchiving.class); - public static boolean buildBlockArchive(Repository repository) throws DataException { + public static boolean buildBlockArchive(Repository repository, long fileSizeTarget) throws DataException { // Only build the archive if we have never done so before int archiveHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); @@ -47,11 +47,12 @@ public class HSQLDBDatabaseArchiving { while (!Controller.isStopping()) { try { BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, repository); + writer.setFileSizeTarget(fileSizeTarget); BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); switch (result) { case OK: // Increment block archive height - startHeight += writer.getWrittenCount(); + startHeight = writer.getLastWrittenHeight() + 1; repository.getBlockArchiveRepository().setBlockArchiveHeight(startHeight); repository.saveChanges(); break; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index 3a9c4f02..49f54150 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -167,7 +167,8 @@ public class HSQLDBDatabasePruning { repository.executeCheckedUpdate("CHECKPOINT"); // Update the prune height - repository.getATRepository().setAtPruneHeight(maximumBlockToTrim); + int nextPruneHeight = maximumBlockToTrim + 1; + repository.getATRepository().setAtPruneHeight(nextPruneHeight); repository.saveChanges(); repository.executeCheckedUpdate("CHECKPOINT"); @@ -291,13 +292,14 @@ public class HSQLDBDatabasePruning { numBlocksPruned, (numBlocksPruned != 1 ? "s" : ""), pruneStartHeight, upperPruneHeight)); } else { - repository.getBlockRepository().setBlockPruneHeight(upperBatchHeight); + final int nextPruneHeight = upperPruneHeight + 1; + repository.getBlockRepository().setBlockPruneHeight(nextPruneHeight); repository.saveChanges(); - LOGGER.debug(String.format("Bumping block base prune height to %d", upperBatchHeight)); + LOGGER.debug(String.format("Bumping block base prune height to %d", nextPruneHeight)); // Can we move onto next batch? - if (upperPrunableHeight > upperBatchHeight) { - pruneStartHeight = upperBatchHeight; + if (upperPrunableHeight > nextPruneHeight) { + pruneStartHeight = nextPruneHeight; } else { // We've finished pruning diff --git a/src/test/java/org/qortal/test/BlockArchiveTests.java b/src/test/java/org/qortal/test/BlockArchiveTests.java index af7804f9..08d760b8 100644 --- a/src/test/java/org/qortal/test/BlockArchiveTests.java +++ b/src/test/java/org/qortal/test/BlockArchiveTests.java @@ -10,6 +10,8 @@ import org.qortal.data.at.ATStateData; import org.qortal.data.block.BlockData; import org.qortal.data.transaction.TransactionData; import org.qortal.repository.*; +import org.qortal.repository.hsqldb.HSQLDBDatabaseArchiving; +import org.qortal.repository.hsqldb.HSQLDBDatabasePruning; import org.qortal.repository.hsqldb.HSQLDBRepository; import org.qortal.settings.Settings; import org.qortal.test.common.AtUtils; @@ -24,7 +26,6 @@ import org.qortal.utils.Triple; import java.io.File; import java.io.IOException; -import java.nio.ByteBuffer; import java.nio.file.Path; import java.nio.file.Paths; import java.sql.SQLException; @@ -347,6 +348,74 @@ public class BlockArchiveTests extends Common { } } + @Test + public void testBulkArchiveAndPrune() throws DataException, InterruptedException, TransformationException, IOException, SQLException { + try (final Repository repository = RepositoryManager.getRepository()) { + HSQLDBRepository hsqldb = (HSQLDBRepository) repository; + + // Alice self share online + List mintingAndOnlineAccounts = new ArrayList<>(); + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + mintingAndOnlineAccounts.add(aliceSelfShare); + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) + BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Assume 900 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); + repository.getATRepository().setAtTrimHeight(901); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(900, maximumArchiveHeight); + + // Check the current archive height + assertEquals(0, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Write blocks 2-900 to the archive (using bulk method) + int fileSizeTarget = 425000; // Pre-calculated size of 900 blocks + assertTrue(HSQLDBDatabaseArchiving.buildBlockArchive(repository, 425000)); + + // Ensure the block archive height has increased + assertEquals(901, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the SQL repository contains blocks 2 and 900... + assertNotNull(repository.getBlockRepository().fromHeight(2)); + assertNotNull(repository.getBlockRepository().fromHeight(900)); + + // Check the current prune heights + assertEquals(0, repository.getBlockRepository().getBlockPruneHeight()); + assertEquals(0, repository.getATRepository().getAtPruneHeight()); + + // Prune all the archived blocks and AT states (using bulk method) + assertTrue(HSQLDBDatabasePruning.pruneBlocks(hsqldb)); + assertTrue(HSQLDBDatabasePruning.pruneATStates(hsqldb)); + + // Ensure the current prune heights have increased + assertEquals(901, repository.getBlockRepository().getBlockPruneHeight()); + assertEquals(901, repository.getATRepository().getAtPruneHeight()); + + // Now ensure the SQL repository is missing blocks 2 and 900... + assertNull(repository.getBlockRepository().fromHeight(2)); + assertNull(repository.getBlockRepository().fromHeight(900)); + + // ... but it's not missing blocks 1 and 901 (we don't prune the genesis block) + assertNotNull(repository.getBlockRepository().fromHeight(1)); + assertNotNull(repository.getBlockRepository().fromHeight(901)); + + // Validate the latest block height in the repository + assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); + + } + } + @Test public void testTrimArchivePruneAndOrphan() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { From 49749a0bc78eac926ac8e5a69e549659d01ea14a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 1 Oct 2021 11:03:56 +0100 Subject: [PATCH 135/231] Added more precise checking of database states to the bulk pruning test. This highlighted a major bug in the bulk prune process whereby the recent AT states weren't being retained. --- .../hsqldb/HSQLDBDatabasePruning.java | 15 +++++---- .../org/qortal/test/BlockArchiveTests.java | 32 ++++++++++++++++++- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index 49f54150..4eea59be 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -91,7 +91,6 @@ public class HSQLDBDatabasePruning { // Archive mode - don't prune anything that hasn't been archived yet maximumBlockToTrim = Math.min(maximumBlockToTrim, repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1); } - final int startHeight = maximumBlockToTrim; final int endHeight = blockchainHeight; final int blockStep = 10000; @@ -104,10 +103,11 @@ public class HSQLDBDatabasePruning { // Loop through all the LatestATStates and copy them to the new table LOGGER.info("Copying AT states..."); for (int height = 0; height < endHeight; height += blockStep) { - //LOGGER.info(String.format("Copying AT states between %d and %d...", height, height + blockStep - 1)); + final int batchEndHeight = height + blockStep - 1; + //LOGGER.info(String.format("Copying AT states between %d and %d...", height, batchEndHeight)); String sql = "SELECT height, AT_address FROM LatestATStates WHERE height BETWEEN ? AND ?"; - try (ResultSet latestAtStatesResultSet = repository.checkedExecute(sql, height, height + blockStep - 1)) { + try (ResultSet latestAtStatesResultSet = repository.checkedExecute(sql, height, batchEndHeight)) { if (latestAtStatesResultSet != null) { do { int latestAtHeight = latestAtStatesResultSet.getInt(1); @@ -126,9 +126,12 @@ public class HSQLDBDatabasePruning { throw new DataException("Unable to copy ATStates", e); } - if (height >= startHeight) { - // Now copy this AT's states for each recent block they is present in - for (int i = startHeight; i < endHeight; i++) { + // If this batch includes blocks after the maximum block to trim, we will need to copy + // each of its AT states above maximumBlockToTrim as they are considered "recent". We + // need to do this for _all_ AT states in these blocks, regardless of their latest state. + if (batchEndHeight >= maximumBlockToTrim) { + // Now copy this AT's states for each recent block they are present in + for (int i = maximumBlockToTrim; i < endHeight; i++) { if (latestAtHeight < i) { // This AT finished before this block so there is nothing to copy continue; diff --git a/src/test/java/org/qortal/test/BlockArchiveTests.java b/src/test/java/org/qortal/test/BlockArchiveTests.java index 08d760b8..4f12bad8 100644 --- a/src/test/java/org/qortal/test/BlockArchiveTests.java +++ b/src/test/java/org/qortal/test/BlockArchiveTests.java @@ -381,7 +381,7 @@ public class BlockArchiveTests extends Common { // Write blocks 2-900 to the archive (using bulk method) int fileSizeTarget = 425000; // Pre-calculated size of 900 blocks - assertTrue(HSQLDBDatabaseArchiving.buildBlockArchive(repository, 425000)); + assertTrue(HSQLDBDatabaseArchiving.buildBlockArchive(repository, fileSizeTarget)); // Ensure the block archive height has increased assertEquals(901, repository.getBlockArchiveRepository().getBlockArchiveHeight()); @@ -394,6 +394,14 @@ public class BlockArchiveTests extends Common { assertEquals(0, repository.getBlockRepository().getBlockPruneHeight()); assertEquals(0, repository.getATRepository().getAtPruneHeight()); + // Prior to archiving or pruning, ensure blocks 2 to 1002 and their AT states are available in the db + for (int i=2; i<=1002; i++) { + assertNotNull(repository.getBlockRepository().fromHeight(i)); + List atStates = repository.getATRepository().getBlockATStatesAtHeight(i); + assertNotNull(atStates); + assertEquals(1, atStates.size()); + } + // Prune all the archived blocks and AT states (using bulk method) assertTrue(HSQLDBDatabasePruning.pruneBlocks(hsqldb)); assertTrue(HSQLDBDatabasePruning.pruneATStates(hsqldb)); @@ -413,6 +421,28 @@ public class BlockArchiveTests extends Common { // Validate the latest block height in the repository assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); + // Ensure blocks 2-900 are all available in the archive + for (int i=2; i<=900; i++) { + assertNotNull(repository.getBlockArchiveRepository().fromHeight(i)); + } + + // Ensure blocks 2-900 are NOT available in the db + for (int i=2; i<=900; i++) { + assertNull(repository.getBlockRepository().fromHeight(i)); + } + + // Ensure blocks 901 to 1002 and their AT states are available in the db + for (int i=901; i<=1002; i++) { + assertNotNull(repository.getBlockRepository().fromHeight(i)); + List atStates = repository.getATRepository().getBlockATStatesAtHeight(i); + assertNotNull(atStates); + assertEquals(1, atStates.size()); + } + + // Ensure blocks 901 to 1002 are not available in the archive + for (int i=901; i<=1002; i++) { + assertNull(repository.getBlockArchiveRepository().fromHeight(i)); + } } } From 53cd96754116eede064310c2877eaa69f75562f2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 1 Oct 2021 14:51:28 +0100 Subject: [PATCH 136/231] Added defrag (repository maintenance) tests. --- .../java/org/qortal/test/RepositoryTests.java | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/src/test/java/org/qortal/test/RepositoryTests.java b/src/test/java/org/qortal/test/RepositoryTests.java index 91dd03c2..33a4de7f 100644 --- a/src/test/java/org/qortal/test/RepositoryTests.java +++ b/src/test/java/org/qortal/test/RepositoryTests.java @@ -3,9 +3,12 @@ package org.qortal.test; import org.junit.Before; import org.junit.Test; import org.qortal.account.Account; +import org.qortal.account.PublicKeyAccount; import org.qortal.asset.Asset; import org.qortal.crosschain.BitcoinACCTv1; import org.qortal.crypto.Crypto; +import org.qortal.data.account.AccountBalanceData; +import org.qortal.data.account.AccountData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; @@ -22,6 +25,7 @@ import java.sql.SQLException; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Random; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; @@ -440,6 +444,119 @@ public class RepositoryTests extends Common { } } + @Test + public void testDefrag() throws DataException { + try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { + + this.populateWithRandomData(hsqldb); + + hsqldb.performPeriodicMaintenance(); + + } + } + + @Test + public void testDefragOnDisk() throws DataException { + Common.useSettingsAndDb(testSettingsFilename, false); + + try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { + + this.populateWithRandomData(hsqldb); + + hsqldb.performPeriodicMaintenance(); + + } + } + + @Test + public void testMultipleDefrags() throws DataException { + // Mint some more blocks to populate the database + try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { + + this.populateWithRandomData(hsqldb); + + for (int i = 0; i < 10; i++) { + hsqldb.performPeriodicMaintenance(); + } + } + } + + @Test + public void testMultipleDefragsOnDisk() throws DataException { + Common.useSettingsAndDb(testSettingsFilename, false); + + // Mint some more blocks to populate the database + try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { + + this.populateWithRandomData(hsqldb); + + for (int i = 0; i < 10; i++) { + hsqldb.performPeriodicMaintenance(); + } + } + } + + @Test + public void testMultipleDefragsWithDifferentData() throws DataException { + for (int i=0; i<10; i++) { + try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { + + this.populateWithRandomData(hsqldb); + hsqldb.performPeriodicMaintenance(); + } + } + } + + @Test + public void testMultipleDefragsOnDiskWithDifferentData() throws DataException { + Common.useSettingsAndDb(testSettingsFilename, false); + + for (int i=0; i<10; i++) { + try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { + + this.populateWithRandomData(hsqldb); + hsqldb.performPeriodicMaintenance(); + } + } + } + + private void populateWithRandomData(HSQLDBRepository repository) throws DataException { + Random random = new Random(); + + System.out.println("Creating random accounts..."); + + // Generate some random accounts + List accounts = new ArrayList<>(); + for (int ai = 0; ai < 20; ++ai) { + byte[] publicKey = new byte[32]; + random.nextBytes(publicKey); + + PublicKeyAccount account = new PublicKeyAccount(repository, publicKey); + accounts.add(account); + + AccountData accountData = new AccountData(account.getAddress()); + repository.getAccountRepository().ensureAccount(accountData); + } + repository.saveChanges(); + + System.out.println("Creating random balances..."); + + // Fill with lots of random balances + for (int i = 0; i < 100000; ++i) { + Account account = accounts.get(random.nextInt(accounts.size())); + int assetId = random.nextInt(2); + long balance = random.nextInt(100000); + + AccountBalanceData accountBalanceData = new AccountBalanceData(account.getAddress(), assetId, balance); + repository.getAccountRepository().save(accountBalanceData); + + // Maybe mint a block to change height + if (i > 0 && (i % 1000) == 0) + BlockUtils.mintBlock(repository); + } + repository.saveChanges(); + } + public static void hsqldbSleep(int millis) throws SQLException { System.out.println(String.format("HSQLDB sleep() thread ID: %s", Thread.currentThread().getId())); From 1ba542eb502fa0e58e1de27ec9639efa305cf9a3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 1 Oct 2021 14:51:45 +0100 Subject: [PATCH 137/231] Simplified minting code in block archive tests. --- .../org/qortal/test/BlockArchiveTests.java | 61 ++++++------------- 1 file changed, 18 insertions(+), 43 deletions(-) diff --git a/src/test/java/org/qortal/test/BlockArchiveTests.java b/src/test/java/org/qortal/test/BlockArchiveTests.java index 4f12bad8..1a3e7655 100644 --- a/src/test/java/org/qortal/test/BlockArchiveTests.java +++ b/src/test/java/org/qortal/test/BlockArchiveTests.java @@ -29,7 +29,6 @@ import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; import java.sql.SQLException; -import java.util.ArrayList; import java.util.List; import static org.junit.Assert.*; @@ -53,14 +52,10 @@ public class BlockArchiveTests extends Common { public void testWriter() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { - // Alice self share online - List mintingAndOnlineAccounts = new ArrayList<>(); - PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); - mintingAndOnlineAccounts.add(aliceSelfShare); - // Mint some blocks so that we are able to archive them later - for (int i = 0; i < 1000; i++) - BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } // 900 blocks are trimmed (this specifies the first untrimmed height) repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); @@ -94,14 +89,10 @@ public class BlockArchiveTests extends Common { public void testWriterAndReader() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { - // Alice self share online - List mintingAndOnlineAccounts = new ArrayList<>(); - PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); - mintingAndOnlineAccounts.add(aliceSelfShare); - // Mint some blocks so that we are able to archive them later - for (int i = 0; i < 1000; i++) - BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } // 900 blocks are trimmed (this specifies the first untrimmed height) repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); @@ -165,11 +156,6 @@ public class BlockArchiveTests extends Common { public void testArchivedAtStates() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { - // Alice self share online - List mintingAndOnlineAccounts = new ArrayList<>(); - PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); - mintingAndOnlineAccounts.add(aliceSelfShare); - // Deploy an AT so that we have AT state data PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); byte[] creationBytes = AtUtils.buildSimpleAT(); @@ -178,8 +164,9 @@ public class BlockArchiveTests extends Common { String atAddress = deployAtTransaction.getATAccount().getAddress(); // Mint some blocks so that we are able to archive them later - for (int i = 0; i < 10; i++) - BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } // 9 blocks are trimmed (this specifies the first untrimmed height) repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(10); @@ -278,11 +265,6 @@ public class BlockArchiveTests extends Common { public void testArchiveAndPrune() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { - // Alice self share online - List mintingAndOnlineAccounts = new ArrayList<>(); - PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); - mintingAndOnlineAccounts.add(aliceSelfShare); - // Deploy an AT so that we have AT state data PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); byte[] creationBytes = AtUtils.buildSimpleAT(); @@ -290,8 +272,9 @@ public class BlockArchiveTests extends Common { AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); // Mint some blocks so that we are able to archive them later - for (int i = 0; i < 1000; i++) - BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } // Assume 900 blocks are trimmed (this specifies the first untrimmed height) repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); @@ -353,11 +336,6 @@ public class BlockArchiveTests extends Common { try (final Repository repository = RepositoryManager.getRepository()) { HSQLDBRepository hsqldb = (HSQLDBRepository) repository; - // Alice self share online - List mintingAndOnlineAccounts = new ArrayList<>(); - PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); - mintingAndOnlineAccounts.add(aliceSelfShare); - // Deploy an AT so that we have AT state data PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); byte[] creationBytes = AtUtils.buildSimpleAT(); @@ -365,8 +343,9 @@ public class BlockArchiveTests extends Common { AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); // Mint some blocks so that we are able to archive them later - for (int i = 0; i < 1000; i++) - BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } // Assume 900 blocks are trimmed (this specifies the first untrimmed height) repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); @@ -450,11 +429,6 @@ public class BlockArchiveTests extends Common { public void testTrimArchivePruneAndOrphan() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { - // Alice self share online - List mintingAndOnlineAccounts = new ArrayList<>(); - PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); - mintingAndOnlineAccounts.add(aliceSelfShare); - // Deploy an AT so that we have AT state data PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); byte[] creationBytes = AtUtils.buildSimpleAT(); @@ -462,8 +436,9 @@ public class BlockArchiveTests extends Common { AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); // Mint some blocks so that we are able to archive them later - for (int i = 0; i < 1000; i++) - BlockMinter.mintTestingBlock(repository, mintingAndOnlineAccounts.toArray(new PrivateKeyAccount[0])); + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } // Make sure that block 500 has full AT state data and data hash List block500AtStatesData = repository.getATRepository().getBlockATStatesAtHeight(500); From ca02cd72aeda45ede83a196d84ea8c6e3ca2b338 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 2 Oct 2021 11:51:53 +0100 Subject: [PATCH 138/231] Fixed issue in block archiver, which caused it to hold a transaction open for a very long time. This caused deadlocks when trying to create bootstraps or perform repository maintenance. --- .../java/org/qortal/controller/repository/BlockArchiver.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiver.java b/src/main/java/org/qortal/controller/repository/BlockArchiver.java index 15b9b226..2bab920d 100644 --- a/src/main/java/org/qortal/controller/repository/BlockArchiver.java +++ b/src/main/java/org/qortal/controller/repository/BlockArchiver.java @@ -83,6 +83,7 @@ public class BlockArchiver implements Runnable { case NOT_ENOUGH_BLOCKS: // We didn't reach our file size target, so that must mean that we don't have enough blocks // yet or something went wrong. Sleep for a while and then try again. + repository.discardChanges(); Thread.sleep(60 * 60 * 1000L); // 1 hour break; From ca7f42c409c0e54bef253cfd0505fdf994f08035 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 2 Oct 2021 11:52:20 +0100 Subject: [PATCH 139/231] Reduced unnecessary database queries in the block archiver. --- .../java/org/qortal/controller/repository/BlockArchiver.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiver.java b/src/main/java/org/qortal/controller/repository/BlockArchiver.java index 2bab920d..7c6db2b7 100644 --- a/src/main/java/org/qortal/controller/repository/BlockArchiver.java +++ b/src/main/java/org/qortal/controller/repository/BlockArchiver.java @@ -42,8 +42,6 @@ public class BlockArchiver implements Runnable { while (!Controller.isStopping()) { repository.discardChanges(); - final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); - Thread.sleep(Settings.getInstance().getArchiveInterval()); BlockData chainTip = Controller.getInstance().getChainTip(); @@ -65,6 +63,7 @@ public class BlockArchiver implements Runnable { // Build cache of blocks try { + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); BlockArchiveWriter writer = new BlockArchiveWriter(startHeight, maximumArchiveHeight, repository); BlockArchiveWriter.BlockArchiveWriteResult result = writer.write(); switch (result) { From c4d7335fdd12412a37bb7fb03187c23507ea5b20 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 2 Oct 2021 13:05:16 +0100 Subject: [PATCH 140/231] Fixed more issues that could cause transactions to be held open. --- .../org/qortal/controller/repository/AtStatesPruner.java | 1 + .../org/qortal/controller/repository/BlockArchiver.java | 8 +++++--- .../org/qortal/controller/repository/BlockPruner.java | 1 + src/main/java/org/qortal/repository/Bootstrap.java | 1 + 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java index 1493f478..446abddb 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java @@ -94,6 +94,7 @@ public class AtStatesPruner implements Runnable { else { // We've pruned up to the upper prunable height // Back off for a while to save CPU for syncing + repository.discardChanges(); Thread.sleep(5*60*1000L); } } diff --git a/src/main/java/org/qortal/controller/repository/BlockArchiver.java b/src/main/java/org/qortal/controller/repository/BlockArchiver.java index 7c6db2b7..2a987d97 100644 --- a/src/main/java/org/qortal/controller/repository/BlockArchiver.java +++ b/src/main/java/org/qortal/controller/repository/BlockArchiver.java @@ -25,18 +25,19 @@ public class BlockArchiver implements Runnable { } try (final Repository repository = RepositoryManager.getRepository()) { + // Don't even start building until initial rush has ended + Thread.sleep(INITIAL_SLEEP_PERIOD); + int startHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); // Don't attempt to archive if we have no ATStatesHeightIndex, as it will be too slow boolean hasAtStatesHeightIndex = repository.getATRepository().hasAtStatesHeightIndex(); if (!hasAtStatesHeightIndex) { LOGGER.info("Unable to start block archiver due to missing ATStatesHeightIndex. Bootstrapping is recommended."); + repository.discardChanges(); return; } - // Don't even start building until initial rush has ended - Thread.sleep(INITIAL_SLEEP_PERIOD); - LOGGER.info("Starting block archiver..."); while (!Controller.isStopping()) { @@ -91,6 +92,7 @@ public class BlockArchiver implements Runnable { // that a bootstrap or re-sync is needed. Try again every minute until then. LOGGER.info("Error: block not found when building archive. If this error persists, " + "a bootstrap or re-sync may be needed."); + repository.discardChanges(); Thread.sleep( 60 * 1000L); // 1 minute break; } diff --git a/src/main/java/org/qortal/controller/repository/BlockPruner.java b/src/main/java/org/qortal/controller/repository/BlockPruner.java index 46cf919b..af012b4f 100644 --- a/src/main/java/org/qortal/controller/repository/BlockPruner.java +++ b/src/main/java/org/qortal/controller/repository/BlockPruner.java @@ -99,6 +99,7 @@ public class BlockPruner implements Runnable { else { // We've pruned up to the upper prunable height // Back off for a while to save CPU for syncing + repository.discardChanges(); Thread.sleep(10*60*1000L); } } diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index a7d9df37..e25a4d39 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -317,6 +317,7 @@ public class Bootstrap { LOGGER.info("Retrying in 5 minutes"); try { + repository.discardChanges(); Thread.sleep(5 * 60 * 1000L); } catch (InterruptedException e2) { break; From adeb654248bf504e755b7f30161096ca1279c056 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 2 Oct 2021 13:23:26 +0100 Subject: [PATCH 141/231] Rework of repository maintenance and backups It will now attempt to wait until there are no other active transactions before starting, to avoid deadlocks. A timeout for this process is specified - generally 60 seconds - so that callers can give up or retry if something is holding a transaction open for too long. Right now we will give up in all places except for bootstrap creation, where it will keep retrying until successful. --- .../org/qortal/RepositoryMaintenance.java | 5 +- .../qortal/api/resource/AdminResource.java | 13 +- .../org/qortal/controller/AutoUpdate.java | 14 +- .../org/qortal/controller/Controller.java | 10 +- .../java/org/qortal/repository/Bootstrap.java | 16 +- .../org/qortal/repository/Repository.java | 5 +- .../qortal/repository/RepositoryManager.java | 5 +- .../hsqldb/HSQLDBDatabasePruning.java | 11 +- .../repository/hsqldb/HSQLDBRepository.java | 178 +++++++++++++----- .../java/org/qortal/test/RepositoryTests.java | 32 ++-- 10 files changed, 200 insertions(+), 89 deletions(-) diff --git a/src/main/java/org/qortal/RepositoryMaintenance.java b/src/main/java/org/qortal/RepositoryMaintenance.java index c3ae0616..b085822b 100644 --- a/src/main/java/org/qortal/RepositoryMaintenance.java +++ b/src/main/java/org/qortal/RepositoryMaintenance.java @@ -1,6 +1,7 @@ package org.qortal; import java.security.Security; +import java.util.concurrent.TimeoutException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -57,10 +58,10 @@ public class RepositoryMaintenance { LOGGER.info("Starting repository periodic maintenance. This can take a while..."); try (final Repository repository = RepositoryManager.getRepository()) { - repository.performPeriodicMaintenance(); + repository.performPeriodicMaintenance(null); LOGGER.info("Repository periodic maintenance completed"); - } catch (DataException e) { + } catch (DataException | TimeoutException e) { LOGGER.error("Repository periodic maintenance failed", e); } diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 39deabee..c7e8eabd 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -22,6 +22,7 @@ import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; @@ -668,14 +669,16 @@ public class AdminResource { blockchainLock.lockInterruptibly(); try { - repository.backup(true, "backup"); + // Timeout if the database isn't ready for backing up after 60 seconds + long timeout = 60 * 1000L; + repository.backup(true, "backup", timeout); repository.saveChanges(); return "true"; } finally { blockchainLock.unlock(); } - } catch (InterruptedException e) { + } catch (InterruptedException | TimeoutException e) { // We couldn't lock blockchain to perform backup return "false"; } catch (DataException e) { @@ -700,13 +703,15 @@ public class AdminResource { blockchainLock.lockInterruptibly(); try { - repository.performPeriodicMaintenance(); + // Timeout if the database isn't ready to start after 60 seconds + long timeout = 60 * 1000L; + repository.performPeriodicMaintenance(timeout); } finally { blockchainLock.unlock(); } } catch (InterruptedException e) { // No big deal - } catch (DataException e) { + } catch (DataException | TimeoutException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } } diff --git a/src/main/java/org/qortal/controller/AutoUpdate.java b/src/main/java/org/qortal/controller/AutoUpdate.java index 6d74e0e8..f07e82d1 100644 --- a/src/main/java/org/qortal/controller/AutoUpdate.java +++ b/src/main/java/org/qortal/controller/AutoUpdate.java @@ -14,6 +14,7 @@ import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.concurrent.TimeoutException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -215,8 +216,17 @@ public class AutoUpdate extends Thread { } // Give repository a chance to backup in case things go badly wrong (if enabled) - if (Settings.getInstance().getRepositoryBackupInterval() > 0) - RepositoryManager.backup(true, "backup"); + if (Settings.getInstance().getRepositoryBackupInterval() > 0) { + try { + // Timeout if the database isn't ready for backing up after 60 seconds + long timeout = 60 * 1000L; + RepositoryManager.backup(true, "backup", timeout); + + } catch (TimeoutException e) { + LOGGER.info("Attempt to backup repository failed due to timeout: {}", e.getMessage()); + // Continue with the auto update anyway... + } + } // Call ApplyUpdate to end this process (unlocking current JAR so it can be replaced) String javaHome = System.getProperty("java.home"); diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index feb4b309..253dee03 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -27,6 +27,7 @@ import java.util.Properties; import java.util.Random; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; @@ -579,7 +580,14 @@ public class Controller extends Thread { Translator.INSTANCE.translate("SysTray", "CREATING_BACKUP_OF_DB_FILES"), MessageType.INFO); - RepositoryManager.backup(true, "backup"); + try { + // Timeout if the database isn't ready for backing up after 60 seconds + long timeout = 60 * 1000L; + RepositoryManager.backup(true, "backup", timeout); + + } catch (TimeoutException e) { + LOGGER.info("Attempt to backup repository failed due to timeout: {}", e.getMessage()); + } } // Prune stuck/slow/old peers diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index e25a4d39..bd76918e 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -19,6 +19,7 @@ import java.io.InputStream; import java.net.URL; import java.nio.file.*; import java.util.List; +import java.util.concurrent.TimeoutException; import java.util.concurrent.locks.ReentrantLock; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; @@ -227,11 +228,18 @@ public class Bootstrap { repository.saveChanges(); - LOGGER.info("Performing repository maintenance..."); - repository.performPeriodicMaintenance(); - LOGGER.info("Creating bootstrap..."); - repository.backup(true, "bootstrap"); + while (!Controller.isStopping()) { + try { + // Timeout if the database isn't ready for backing up after 10 seconds + long timeout = 10 * 1000L; + repository.backup(false, "bootstrap", timeout); + break; + } + catch (TimeoutException e) { + LOGGER.info("Unable to create bootstrap due to timeout. Retrying..."); + } + } LOGGER.info("Moving files to output directory..."); inputPath = Paths.get(Settings.getInstance().getRepositoryPath(), "bootstrap"); diff --git a/src/main/java/org/qortal/repository/Repository.java b/src/main/java/org/qortal/repository/Repository.java index f6728968..c0bdb0d9 100644 --- a/src/main/java/org/qortal/repository/Repository.java +++ b/src/main/java/org/qortal/repository/Repository.java @@ -1,6 +1,7 @@ package org.qortal.repository; import java.io.IOException; +import java.util.concurrent.TimeoutException; public interface Repository extends AutoCloseable { @@ -49,9 +50,9 @@ public interface Repository extends AutoCloseable { public void setDebug(boolean debugState); - public void backup(boolean quick, String name) throws DataException; + public void backup(boolean quick, String name, Long timeout) throws DataException, TimeoutException; - public void performPeriodicMaintenance() throws DataException; + public void performPeriodicMaintenance(Long timeout) throws DataException, TimeoutException; public void exportNodeLocalData() throws DataException; diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 1b398057..2f6e4688 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -8,6 +8,7 @@ import org.qortal.repository.hsqldb.HSQLDBRepository; import org.qortal.settings.Settings; import java.sql.SQLException; +import java.util.concurrent.TimeoutException; public abstract class RepositoryManager { private static final Logger LOGGER = LogManager.getLogger(RepositoryManager.class); @@ -51,9 +52,9 @@ public abstract class RepositoryManager { repositoryFactory = null; } - public static void backup(boolean quick, String name) { + public static void backup(boolean quick, String name, Long timeout) throws TimeoutException { try (final Repository repository = getRepository()) { - repository.backup(quick, name); + repository.backup(quick, name, timeout); } catch (DataException e) { // Backup is best-effort so don't complain } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index 4eea59be..232f7058 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -12,6 +12,7 @@ import org.qortal.settings.Settings; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.concurrent.TimeoutException; /** * @@ -315,7 +316,15 @@ public class HSQLDBDatabasePruning { } public static void performMaintenance(Repository repository) throws SQLException, DataException { - repository.performPeriodicMaintenance(); + try { + // Timeout if the database isn't ready for backing up after 5 minutes + // Nothing else should be using the db at this point, so a timeout shouldn't happen + long timeout = 5 * 60 * 1000L; + repository.performPeriodicMaintenance(timeout); + + } catch (TimeoutException e) { + LOGGER.info("Attempt to perform maintenance failed due to timeout: {}", e.getMessage()); + } } } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 6be8b9ea..a9f1a7f8 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -16,6 +16,7 @@ import java.sql.SQLException; import java.sql.Savepoint; import java.sql.Statement; import java.util.*; +import java.util.concurrent.TimeoutException; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; @@ -379,66 +380,92 @@ public class HSQLDBRepository implements Repository { } @Override - public void backup(boolean quick, String name) throws DataException { - if (!quick) - // First perform a CHECKPOINT + public void backup(boolean quick, String name, Long timeout) throws DataException, TimeoutException { + synchronized (CHECKPOINT_LOCK) { + + // We can only perform a CHECKPOINT if no other HSQLDB session is mid-transaction, + // otherwise the CHECKPOINT blocks for COMMITs and other threads can't open HSQLDB sessions + // due to HSQLDB blocking until CHECKPOINT finishes - i.e. deadlock. + // Since we don't want to give up too easily, it's best to wait until the other transaction + // count reaches zero, and then continue. + this.blockUntilNoOtherTransactions(timeout); + + if (!quick) + // First perform a CHECKPOINT + try (Statement stmt = this.connection.createStatement()) { + LOGGER.info("Performing maintenance - this will take a while..."); + stmt.execute("CHECKPOINT"); + stmt.execute("CHECKPOINT DEFRAG"); + LOGGER.info("Maintenance completed"); + } catch (SQLException e) { + throw new DataException("Unable to prepare repository for backup"); + } + + // Clean out any previous backup + try { + String connectionUrl = this.connection.getMetaData().getURL(); + String dbPathname = getDbPathname(connectionUrl); + if (dbPathname == null) + throw new DataException("Unable to locate repository for backup?"); + + // Doesn't really make sense to backup an in-memory database... + if (dbPathname.equals("mem")) { + LOGGER.debug("Ignoring request to backup in-memory repository!"); + return; + } + + String backupUrl = buildBackupUrl(dbPathname, name); + String backupPathname = getDbPathname(backupUrl); + if (backupPathname == null) + throw new DataException("Unable to determine location for repository backup?"); + + Path backupDirPath = Paths.get(backupPathname).getParent(); + String backupDirPathname = backupDirPath.toString(); + + try (Stream paths = Files.walk(backupDirPath)) { + paths.sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .filter(file -> file.getPath().startsWith(backupDirPathname)) + .forEach(File::delete); + } + } catch (NoSuchFileException e) { + // Nothing to remove + } catch (SQLException | IOException e) { + throw new DataException("Unable to remove previous repository backup"); + } + + // Actually create backup try (Statement stmt = this.connection.createStatement()) { - stmt.execute("CHECKPOINT DEFRAG"); + LOGGER.info("Backing up repository..."); + stmt.execute(String.format("BACKUP DATABASE TO '%s/' BLOCKING AS FILES", name)); + LOGGER.info("Backup completed"); } catch (SQLException e) { - throw new DataException("Unable to prepare repository for backup"); + throw new DataException("Unable to backup repository"); } - // Clean out any previous backup - try { - String connectionUrl = this.connection.getMetaData().getURL(); - String dbPathname = getDbPathname(connectionUrl); - if (dbPathname == null) - throw new DataException("Unable to locate repository for backup?"); - - // Doesn't really make sense to backup an in-memory database... - if (dbPathname.equals("mem")) { - LOGGER.debug("Ignoring request to backup in-memory repository!"); - return; - } - - String backupUrl = buildBackupUrl(dbPathname, name); - String backupPathname = getDbPathname(backupUrl); - if (backupPathname == null) - throw new DataException("Unable to determine location for repository backup?"); - - Path backupDirPath = Paths.get(backupPathname).getParent(); - String backupDirPathname = backupDirPath.toString(); - - try (Stream paths = Files.walk(backupDirPath)) { - paths.sorted(Comparator.reverseOrder()) - .map(Path::toFile) - .filter(file -> file.getPath().startsWith(backupDirPathname)) - .forEach(File::delete); - } - } catch (NoSuchFileException e) { - // Nothing to remove - } catch (SQLException | IOException e) { - throw new DataException("Unable to remove previous repository backup"); - } - - // Actually create backup - try (Statement stmt = this.connection.createStatement()) { - stmt.execute(String.format("BACKUP DATABASE TO '%s/' BLOCKING AS FILES", name)); - } catch (SQLException e) { - throw new DataException("Unable to backup repository"); } } @Override - public void performPeriodicMaintenance() throws DataException { - // Defrag DB - takes a while! - try (Statement stmt = this.connection.createStatement()) { - LOGGER.info("performing maintenance - this will take a while"); - stmt.execute("CHECKPOINT"); - stmt.execute("CHECKPOINT DEFRAG"); - LOGGER.info("maintenance completed"); - } catch (SQLException e) { - throw new DataException("Unable to defrag repository"); + public void performPeriodicMaintenance(Long timeout) throws DataException, TimeoutException { + synchronized (CHECKPOINT_LOCK) { + + // We can only perform a CHECKPOINT if no other HSQLDB session is mid-transaction, + // otherwise the CHECKPOINT blocks for COMMITs and other threads can't open HSQLDB sessions + // due to HSQLDB blocking until CHECKPOINT finishes - i.e. deadlock. + // Since we don't want to give up too easily, it's best to wait until the other transaction + // count reaches zero, and then continue. + this.blockUntilNoOtherTransactions(timeout); + + // Defrag DB - takes a while! + try (Statement stmt = this.connection.createStatement()) { + LOGGER.info("performing maintenance - this will take a while"); + stmt.execute("CHECKPOINT"); + stmt.execute("CHECKPOINT DEFRAG"); + LOGGER.info("maintenance completed"); + } catch (SQLException e) { + throw new DataException("Unable to defrag repository"); + } } } @@ -990,4 +1017,51 @@ public class HSQLDBRepository implements Repository { return DEADLOCK_ERROR_CODE.equals(e.getErrorCode()); } + private int otherTransactionsCount() throws DataException { + // We can only perform a CHECKPOINT if no other HSQLDB session is mid-transaction, + // otherwise the CHECKPOINT blocks for COMMITs and other threads can't open HSQLDB sessions + // due to HSQLDB blocking until CHECKPOINT finishes - i.e. deadlock + String sql = "SELECT COUNT(*) " + + "FROM Information_schema.system_sessions " + + "WHERE transaction = TRUE AND session_id != ?"; + try { + PreparedStatement pstmt = this.cachePreparedStatement(sql); + pstmt.setLong(1, this.sessionId); + + if (!pstmt.execute()) + throw new DataException("Unable to check repository session status"); + + try (ResultSet resultSet = pstmt.getResultSet()) { + if (resultSet == null || !resultSet.next()) + // Failed to even find HSQLDB session info! + throw new DataException("No results when checking repository session status"); + + int transactionCount = resultSet.getInt(1); + + return transactionCount; + } + } catch (SQLException e) { + throw new DataException("Unable to check repository session status", e); + } + } + + private void blockUntilNoOtherTransactions(Long timeout) throws DataException, TimeoutException { + try { + long startTime = System.currentTimeMillis(); + while (this.otherTransactionsCount() > 0) { + // Wait and try again + LOGGER.info("Waiting for repository..."); + Thread.sleep(1000L); + + if (timeout != null) { + if (System.currentTimeMillis() - startTime >= timeout) { + throw new TimeoutException("Timed out waiting for repository to become available"); + } + } + } + } catch (InterruptedException e) { + throw new DataException("Interrupted before repository became available"); + } + } + } diff --git a/src/test/java/org/qortal/test/RepositoryTests.java b/src/test/java/org/qortal/test/RepositoryTests.java index 33a4de7f..bb6510d5 100644 --- a/src/test/java/org/qortal/test/RepositoryTests.java +++ b/src/test/java/org/qortal/test/RepositoryTests.java @@ -26,13 +26,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Random; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.*; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -445,44 +439,44 @@ public class RepositoryTests extends Common { } @Test - public void testDefrag() throws DataException { + public void testDefrag() throws DataException, TimeoutException { try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { this.populateWithRandomData(hsqldb); - hsqldb.performPeriodicMaintenance(); + hsqldb.performPeriodicMaintenance(10 * 1000L); } } @Test - public void testDefragOnDisk() throws DataException { + public void testDefragOnDisk() throws DataException, TimeoutException { Common.useSettingsAndDb(testSettingsFilename, false); try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { this.populateWithRandomData(hsqldb); - hsqldb.performPeriodicMaintenance(); + hsqldb.performPeriodicMaintenance(10 * 1000L); } } @Test - public void testMultipleDefrags() throws DataException { + public void testMultipleDefrags() throws DataException, TimeoutException { // Mint some more blocks to populate the database try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { this.populateWithRandomData(hsqldb); for (int i = 0; i < 10; i++) { - hsqldb.performPeriodicMaintenance(); + hsqldb.performPeriodicMaintenance(10 * 1000L); } } } @Test - public void testMultipleDefragsOnDisk() throws DataException { + public void testMultipleDefragsOnDisk() throws DataException, TimeoutException { Common.useSettingsAndDb(testSettingsFilename, false); // Mint some more blocks to populate the database @@ -491,31 +485,31 @@ public class RepositoryTests extends Common { this.populateWithRandomData(hsqldb); for (int i = 0; i < 10; i++) { - hsqldb.performPeriodicMaintenance(); + hsqldb.performPeriodicMaintenance(10 * 1000L); } } } @Test - public void testMultipleDefragsWithDifferentData() throws DataException { + public void testMultipleDefragsWithDifferentData() throws DataException, TimeoutException { for (int i=0; i<10; i++) { try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { this.populateWithRandomData(hsqldb); - hsqldb.performPeriodicMaintenance(); + hsqldb.performPeriodicMaintenance(10 * 1000L); } } } @Test - public void testMultipleDefragsOnDiskWithDifferentData() throws DataException { + public void testMultipleDefragsOnDiskWithDifferentData() throws DataException, TimeoutException { Common.useSettingsAndDb(testSettingsFilename, false); for (int i=0; i<10; i++) { try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { this.populateWithRandomData(hsqldb); - hsqldb.performPeriodicMaintenance(); + hsqldb.performPeriodicMaintenance(10 * 1000L); } } } From 47b1b6daba9681da5240b26189c5234a03de2442 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 2 Oct 2021 14:43:26 +0100 Subject: [PATCH 142/231] Retry the entire bootstrap import process on failure, rather than just the download. --- .../java/org/qortal/block/BlockChain.java | 9 +++- .../java/org/qortal/repository/Bootstrap.java | 45 ++++++++++--------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index a0aca44d..e5a0da5f 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -517,7 +517,12 @@ public class BlockChain { } else { // Check first block is Genesis Block if (!isGenesisBlockValid()) { - rebuildBlockchain(); + try { + rebuildBlockchain(); + + } catch (InterruptedException e) { + throw new DataException(String.format("Interrupted when trying to rebuild blockchain: %s", e.getMessage())); + } } } @@ -600,7 +605,7 @@ public class BlockChain { } } - private static void rebuildBlockchain() throws DataException { + private static void rebuildBlockchain() throws DataException, InterruptedException { boolean shouldBootstrap = Settings.getInstance().getBootstrap(); if (shouldBootstrap) { // Settings indicate that we should apply a bootstrap rather than rebuilding and syncing from genesis diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index bd76918e..5a130839 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -27,7 +27,7 @@ import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; public class Bootstrap { - private Repository repository; + private final Repository repository; private static final Logger LOGGER = LogManager.getLogger(Bootstrap.class); @@ -286,7 +286,22 @@ public class Bootstrap { } } - public void startImport() throws DataException { + public void startImport() throws InterruptedException { + while (!Controller.isStopping()) { + try { + LOGGER.info("Starting import of bootstrap..."); + + this.doImport(); + + } catch (DataException e) { + LOGGER.info("Bootstrap import failed: {}", e.getMessage()); + LOGGER.info("Retrying in 5 minutes"); + Thread.sleep(5 * 60 * 1000L); + } + } + } + + private void doImport() throws DataException { Path path = null; try { Path tempDir = Files.createTempDirectory("qortal-bootstrap"); @@ -313,28 +328,14 @@ public class Bootstrap { private void downloadToPath(Path path) throws DataException { String bootstrapUrl = "http://bootstrap.qortal.org/bootstrap.7z"; - while (!Controller.isStopping()) { - try { - LOGGER.info("Downloading bootstrap..."); - InputStream in = new URL(bootstrapUrl).openStream(); - Files.copy(in, path, REPLACE_EXISTING); - break; + try { + LOGGER.info("Downloading bootstrap..."); + InputStream in = new URL(bootstrapUrl).openStream(); + Files.copy(in, path, REPLACE_EXISTING); - } catch (IOException e) { - LOGGER.info("Unable to download bootstrap: {}", e.getMessage()); - LOGGER.info("Retrying in 5 minutes"); - - try { - repository.discardChanges(); - Thread.sleep(5 * 60 * 1000L); - } catch (InterruptedException e2) { - break; - } - } + } catch (IOException e) { + throw new DataException(String.format("Unable to download bootstrap: {}", e.getMessage())); } - - // It's best to throw an exception on all failures, even though we're most likely just stopping - throw new DataException("Unable to download bootstrap"); } public void importFromPath(Path path) throws InterruptedException, DataException, IOException { From 51bb776e56b48faca9835f28fdf1ebdda556cb06 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 2 Oct 2021 15:03:31 +0100 Subject: [PATCH 143/231] Select a random host when importing a bootstrap, and started adding support for multiple bootstrap types. --- .../java/org/qortal/repository/Bootstrap.java | 28 +++++++++++++++++-- .../java/org/qortal/settings/Settings.java | 9 ++++++ .../java/org/qortal/test/BootstrapTests.java | 20 +++++++++++-- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 5a130839..7164dd38 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -18,6 +18,7 @@ import java.io.IOException; import java.io.InputStream; import java.net.URL; import java.nio.file.*; +import java.security.SecureRandom; import java.util.List; import java.util.concurrent.TimeoutException; import java.util.concurrent.locks.ReentrantLock; @@ -256,7 +257,7 @@ public class Bootstrap { ); LOGGER.info("Compressing..."); - String compressedOutputPath = String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), "bootstrap.7z"); + String compressedOutputPath = String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), this.getFilename()); try { Files.delete(Paths.get(compressedOutputPath)); } catch (NoSuchFileException e) { @@ -305,7 +306,8 @@ public class Bootstrap { Path path = null; try { Path tempDir = Files.createTempDirectory("qortal-bootstrap"); - path = Paths.get(tempDir.toString(), "bootstrap.7z"); + String filename = String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), this.getFilename()); + path = Paths.get(tempDir.toString(), filename); this.downloadToPath(path); this.importFromPath(path); @@ -325,11 +327,31 @@ public class Bootstrap { } } + private String getFilename() { + boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); + boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); + + if (pruningEnabled) { + return "bootstrap-toponly.7z"; + } + else if (archiveEnabled) { + return "bootstrap-archive.7z"; + } + else { + return "bootstrap-full.7z"; + } + } + private void downloadToPath(Path path) throws DataException { - String bootstrapUrl = "http://bootstrap.qortal.org/bootstrap.7z"; + // Select a random host from bootstrapHosts + String[] hosts = Settings.getInstance().getBootstrapHosts(); + int index = new SecureRandom().nextInt(hosts.length); + String bootstrapHost = hosts[index]; + String bootstrapFilename = this.getFilename(); try { LOGGER.info("Downloading bootstrap..."); + String bootstrapUrl = String.format("%s/%s", bootstrapHost, bootstrapFilename); InputStream in = new URL(bootstrapUrl).openStream(); Files.copy(in, path, REPLACE_EXISTING); diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 7175c60a..519bb475 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -194,6 +194,11 @@ public class Settings { // Bootstrap private String bootstrapFilenamePrefix = ""; + // Bootstrap sources + private String[] bootstrapHosts = new String[] { + "http://bootstrap.qortal.org" + }; + // Auto-update sources private String[] autoUpdateRepos = new String[] { "https://github.com/Qortal/qortal/raw/%s/qortal.update", @@ -528,6 +533,10 @@ public class Settings { return this.autoUpdateRepos; } + public String[] getBootstrapHosts() { + return this.bootstrapHosts; + } + public String getListsPath() { return this.listsPath; } diff --git a/src/test/java/org/qortal/test/BootstrapTests.java b/src/test/java/org/qortal/test/BootstrapTests.java index 6e76a386..bf73526d 100644 --- a/src/test/java/org/qortal/test/BootstrapTests.java +++ b/src/test/java/org/qortal/test/BootstrapTests.java @@ -67,7 +67,7 @@ public class BootstrapTests extends Common { @Test public void testCreateAndImportBootstrap() throws DataException, InterruptedException, TransformationException, IOException { - Path bootstrapPath = Paths.get(String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), "bootstrap.7z")); + Path bootstrapPath = Paths.get(String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), "bootstrap-archive.7z")); Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive", "2-900.dat"); BlockData block1000; byte[] originalArchiveContents; @@ -183,7 +183,23 @@ public class BootstrapTests extends Common { private void deleteBootstraps() throws IOException { try { - Path path = Paths.get(String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), "bootstrap.7z")); + Path path = Paths.get(String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), "bootstrap-archive.7z")); + Files.delete(path); + + } catch (NoSuchFileException e) { + // Nothing to delete + } + + try { + Path path = Paths.get(String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), "bootstrap-toponly.7z")); + Files.delete(path); + + } catch (NoSuchFileException e) { + // Nothing to delete + } + + try { + Path path = Paths.get(String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), "bootstrap-full.7z")); Files.delete(path); } catch (NoSuchFileException e) { From d25a77b633dea8809cfa84fbe6b959c31e8f719c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 2 Oct 2021 15:04:00 +0100 Subject: [PATCH 144/231] Ignore bootstraps and other 7z archives in git. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 69dd6906..d68ffdbb 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ /run.pid /run.log /WindowsInstaller/Install Files/qortal.jar +/*.7z From ea92ccb4c10a6bc68b77f45d7a71c26017b2801c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 2 Oct 2021 15:13:23 +0100 Subject: [PATCH 145/231] "pruningEnabled" setting renamed to "topOnly" Pruning is still a concept in the code, but since it relates to both archived and topOnly mode, it makes it cleaner to use "topOnly" to refer to the pruned db with no archive. --- src/main/java/org/qortal/api/resource/AdminResource.java | 2 +- src/main/java/org/qortal/block/BlockChain.java | 8 ++++---- .../org/qortal/controller/repository/AtStatesPruner.java | 4 ++-- .../org/qortal/controller/repository/BlockPruner.java | 4 ++-- .../org/qortal/controller/repository/PruneManager.java | 6 +++--- src/main/java/org/qortal/repository/Bootstrap.java | 4 ++-- .../java/org/qortal/repository/RepositoryManager.java | 2 +- src/main/java/org/qortal/settings/Settings.java | 6 +++--- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index c7e8eabd..1d2c8bde 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -465,7 +465,7 @@ public class AdminResource { // Make sure we're not orphaning as far back as the archived blocks // FUTURE: we could support this by first importing earlier blocks from the archive - if (Settings.getInstance().isPruningEnabled() || + if (Settings.getInstance().isTopOnly() || Settings.getInstance().isArchiveEnabled()) { try (final Repository repository = RepositoryManager.getRepository()) { diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index e5a0da5f..886c9dd9 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -507,12 +507,12 @@ public class BlockChain { chainTip = repository.getBlockRepository().getLastBlock(); } - boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); + boolean isTopOnly = Settings.getInstance().isTopOnly(); boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); boolean hasBlocks = (chainTip != null && chainTip.getHeight() > 1); - if (pruningEnabled && hasBlocks) { - // Pruning is enabled and we have blocks, so it's possible that the genesis block has been pruned + if (isTopOnly && hasBlocks) { + // Top-only mode is enabled and we have blocks, so it's possible that the genesis block has been pruned // It's best not to validate it, and there's no real need to } else { // Check first block is Genesis Block @@ -533,7 +533,7 @@ public class BlockChain { // Set the number of blocks to validate based on the pruned state of the chain // If pruned, subtract an extra 10 to allow room for error - int blocksToValidate = (pruningEnabled || archiveEnabled) ? Settings.getInstance().getPruneBlockLimit() - 10 : 1440; + int blocksToValidate = (isTopOnly || archiveEnabled) ? Settings.getInstance().getPruneBlockLimit() - 10 : 1440; int startHeight = Math.max(repository.getBlockRepository().getBlockchainHeight() - blocksToValidate, 1); BlockData detachedBlockData = repository.getBlockRepository().getDetachedBlockSignature(startHeight); diff --git a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java index 446abddb..3b92db51 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java @@ -19,8 +19,8 @@ public class AtStatesPruner implements Runnable { Thread.currentThread().setName("AT States pruner"); boolean archiveMode = false; - if (!Settings.getInstance().isPruningEnabled()) { - // Pruning isn't enabled, but we might want to prune for the purposes of archiving + if (!Settings.getInstance().isTopOnly()) { + // Top-only mode isn't enabled, but we might want to prune for the purposes of archiving if (!Settings.getInstance().isArchiveEnabled()) { // No pruning or archiving, so we must not prune anything return; diff --git a/src/main/java/org/qortal/controller/repository/BlockPruner.java b/src/main/java/org/qortal/controller/repository/BlockPruner.java index af012b4f..1258ee38 100644 --- a/src/main/java/org/qortal/controller/repository/BlockPruner.java +++ b/src/main/java/org/qortal/controller/repository/BlockPruner.java @@ -19,8 +19,8 @@ public class BlockPruner implements Runnable { Thread.currentThread().setName("Block pruner"); boolean archiveMode = false; - if (!Settings.getInstance().isPruningEnabled()) { - // Pruning isn't enabled, but we might want to prune for the purposes of archiving + if (!Settings.getInstance().isTopOnly()) { + // Top-only mode isn't enabled, but we might want to prune for the purposes of archiving if (!Settings.getInstance().isArchiveEnabled()) { // No pruning or archiving, so we must not prune anything return; diff --git a/src/main/java/org/qortal/controller/repository/PruneManager.java b/src/main/java/org/qortal/controller/repository/PruneManager.java index dcb21181..1904a014 100644 --- a/src/main/java/org/qortal/controller/repository/PruneManager.java +++ b/src/main/java/org/qortal/controller/repository/PruneManager.java @@ -16,7 +16,7 @@ public class PruneManager { private static PruneManager instance; - private boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); + private boolean isTopOnly = Settings.getInstance().isTopOnly(); private int pruneBlockLimit = Settings.getInstance().getPruneBlockLimit(); private ExecutorService executorService; @@ -35,7 +35,7 @@ public class PruneManager { public void start() { this.executorService = Executors.newCachedThreadPool(new DaemonThreadFactory()); - if (Settings.getInstance().isPruningEnabled() && + if (Settings.getInstance().isTopOnly() && !Settings.getInstance().isArchiveEnabled()) { // Top-only-sync this.startTopOnlySyncMode(); @@ -110,7 +110,7 @@ public class PruneManager { } public boolean isBlockPruned(int height, Repository repository) throws DataException { - if (!this.pruningEnabled) { + if (!this.isTopOnly) { return false; } diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 7164dd38..13175949 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -53,7 +53,7 @@ public class Bootstrap { try { LOGGER.info("Checking repository state..."); - final boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); + final boolean pruningEnabled = Settings.getInstance().isTopOnly(); final boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); // Avoid creating bootstraps from pruned nodes until officially supported @@ -328,7 +328,7 @@ public class Bootstrap { } private String getFilename() { - boolean pruningEnabled = Settings.getInstance().isPruningEnabled(); + boolean pruningEnabled = Settings.getInstance().isTopOnly(); boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); if (pruningEnabled) { diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index 2f6e4688..ba58bf44 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -80,7 +80,7 @@ public abstract class RepositoryManager { public static boolean prune(Repository repository) { // Bulk prune the database the first time we use pruning mode - if (Settings.getInstance().isPruningEnabled() || + if (Settings.getInstance().isTopOnly() || Settings.getInstance().isArchiveEnabled()) { if (RepositoryManager.canArchiveOrPrune()) { try { diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 519bb475..d9fa025f 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -115,7 +115,7 @@ public class Settings { /** Whether we should prune old data to reduce database size * This prevents the node from being able to serve older blocks */ - private boolean pruningEnabled = false; + private boolean topOnly = false; /** The amount of recent blocks we should keep when pruning */ private int pruneBlockLimit = 1450; @@ -590,8 +590,8 @@ public class Settings { } - public boolean isPruningEnabled() { - return this.pruningEnabled; + public boolean isTopOnly() { + return this.topOnly; } public int getPruneBlockLimit() { From 1f8fbfaa2426dd3be804b3871fc495fd02adbb52 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 2 Oct 2021 15:34:55 +0100 Subject: [PATCH 146/231] Missed a few topOnly references from the last commit. --- .../java/org/qortal/controller/repository/PruneManager.java | 3 +-- src/main/java/org/qortal/repository/Bootstrap.java | 4 ++-- src/main/java/org/qortal/repository/RepositoryManager.java | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/controller/repository/PruneManager.java b/src/main/java/org/qortal/controller/repository/PruneManager.java index 1904a014..685f0eea 100644 --- a/src/main/java/org/qortal/controller/repository/PruneManager.java +++ b/src/main/java/org/qortal/controller/repository/PruneManager.java @@ -35,8 +35,7 @@ public class PruneManager { public void start() { this.executorService = Executors.newCachedThreadPool(new DaemonThreadFactory()); - if (Settings.getInstance().isTopOnly() && - !Settings.getInstance().isArchiveEnabled()) { + if (Settings.getInstance().isTopOnly()) { // Top-only-sync this.startTopOnlySyncMode(); } diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 13175949..1d08b7a3 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -328,10 +328,10 @@ public class Bootstrap { } private String getFilename() { - boolean pruningEnabled = Settings.getInstance().isTopOnly(); + boolean isTopOnly = Settings.getInstance().isTopOnly(); boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); - if (pruningEnabled) { + if (isTopOnly) { return "bootstrap-toponly.7z"; } else if (archiveEnabled) { diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index ba58bf44..e97806f0 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -79,7 +79,7 @@ public abstract class RepositoryManager { } public static boolean prune(Repository repository) { - // Bulk prune the database the first time we use pruning mode + // Bulk prune the database the first time we use top-only or block archive mode if (Settings.getInstance().isTopOnly() || Settings.getInstance().isArchiveEnabled()) { if (RepositoryManager.canArchiveOrPrune()) { From 1b3f37eb780e5c7388991cdd373b800d57719153 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 2 Oct 2021 15:37:06 +0100 Subject: [PATCH 147/231] Delete the "archive" folder when in top-only mode This allows a block archive node to be switched into top-only mode. --- .../controller/repository/PruneManager.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/main/java/org/qortal/controller/repository/PruneManager.java b/src/main/java/org/qortal/controller/repository/PruneManager.java index 685f0eea..89f11644 100644 --- a/src/main/java/org/qortal/controller/repository/PruneManager.java +++ b/src/main/java/org/qortal/controller/repository/PruneManager.java @@ -1,5 +1,8 @@ package org.qortal.controller.repository; +import org.apache.commons.io.FileUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.qortal.controller.Controller; import org.qortal.data.block.BlockData; @@ -8,12 +11,17 @@ import org.qortal.repository.Repository; import org.qortal.settings.Settings; import org.qortal.utils.DaemonThreadFactory; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class PruneManager { + private static final Logger LOGGER = LogManager.getLogger(PruneManager.class); + private static PruneManager instance; private boolean isTopOnly = Settings.getInstance().isTopOnly(); @@ -58,6 +66,9 @@ public class PruneManager { */ private void startTopOnlySyncMode() { this.startPruning(); + + // We don't need the block archive in top-only mode + this.deleteArchive(); } /** @@ -98,6 +109,23 @@ public class PruneManager { this.executorService.execute(new BlockArchiver()); } + private void deleteArchive() { + if (!Settings.getInstance().isTopOnly()) { + LOGGER.error("Refusing to delete archive when not in top-only mode"); + } + + try { + Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive"); + if (archivePath.toFile().exists()) { + LOGGER.info("Deleting block archive because we are in top-only mode..."); + FileUtils.deleteDirectory(archivePath.toFile()); + } + + } catch (IOException e) { + LOGGER.info("Couldn't delete archive: {}", e.getMessage()); + } + } + public void stop() { this.executorService.shutdownNow(); From 8eddaa3fac565365ef2cddae45bf9dff5ce8d599 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 2 Oct 2021 15:48:31 +0100 Subject: [PATCH 148/231] Small refactor to update wording. --- .../org/qortal/api/resource/BootstrapResource.java | 2 +- src/main/java/org/qortal/repository/Bootstrap.java | 12 ++++++------ src/test/java/org/qortal/test/BootstrapTests.java | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/BootstrapResource.java b/src/main/java/org/qortal/api/resource/BootstrapResource.java index 6cb5e996..576329be 100644 --- a/src/main/java/org/qortal/api/resource/BootstrapResource.java +++ b/src/main/java/org/qortal/api/resource/BootstrapResource.java @@ -49,7 +49,7 @@ public class BootstrapResource { try (final Repository repository = RepositoryManager.getRepository()) { Bootstrap bootstrap = new Bootstrap(repository); - if (!bootstrap.canBootstrap()) { + if (!bootstrap.canCreateBootstrap()) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); } diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 1d08b7a3..a139f381 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -49,7 +49,7 @@ public class Bootstrap { * @return true if ready for bootstrap creation, or false if not * All failure reasons are logged */ - public boolean canBootstrap() { + public boolean canCreateBootstrap() { try { LOGGER.info("Checking repository state..."); @@ -65,7 +65,7 @@ public class Bootstrap { // Require that a block archive has been built if (!archiveEnabled) { - LOGGER.info("Unable to bootstrap because the block archive isn't enabled. " + + LOGGER.info("Unable to create bootstrap because the block archive isn't enabled. " + "Set {\"archivedEnabled\": true} in settings.json to fix."); return false; } @@ -73,20 +73,20 @@ public class Bootstrap { // Make sure that the block archiver is up to date boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); if (!upToDate) { - LOGGER.info("Unable to bootstrap because the block archive isn't fully built yet."); + LOGGER.info("Unable to create bootstrap because the block archive isn't fully built yet."); return false; } // Ensure that this database contains the ATStatesHeightIndex which was missing in some cases boolean hasAtStatesHeightIndex = repository.getATRepository().hasAtStatesHeightIndex(); if (!hasAtStatesHeightIndex) { - LOGGER.info("Unable to bootstrap due to missing ATStatesHeightIndex. A re-sync from genesis is needed."); + LOGGER.info("Unable to create bootstrap due to missing ATStatesHeightIndex. A re-sync from genesis is needed."); return false; } // Ensure we have synced NTP time if (NTP.getTime() == null) { - LOGGER.info("Unable to bootstrap because the node hasn't synced its time yet."); + LOGGER.info("Unable to create bootstrap because the node hasn't synced its time yet."); return false; } @@ -94,7 +94,7 @@ public class Bootstrap { final BlockData chainTip = Controller.getInstance().getChainTip(); final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) { - LOGGER.info("Unable to bootstrap because the blockchain isn't fully synced."); + LOGGER.info("Unable to create bootstrap because the blockchain isn't fully synced."); return false; } diff --git a/src/test/java/org/qortal/test/BootstrapTests.java b/src/test/java/org/qortal/test/BootstrapTests.java index bf73526d..a15311f5 100644 --- a/src/test/java/org/qortal/test/BootstrapTests.java +++ b/src/test/java/org/qortal/test/BootstrapTests.java @@ -42,12 +42,12 @@ public class BootstrapTests extends Common { } @Test - public void testCanBootstrap() throws DataException, InterruptedException, TransformationException, IOException { + public void testCanCreateBootstrap() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { this.buildDummyBlockchain(repository); Bootstrap bootstrap = new Bootstrap(repository); - assertTrue(bootstrap.canBootstrap()); + assertTrue(bootstrap.canCreateBootstrap()); } } From 63a35c97bc7005fae064bd8ec9d3f76dd8df172f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 2 Oct 2021 19:10:23 +0100 Subject: [PATCH 149/231] Fixed bug in path returned to POST /bootstrap/create API. --- src/main/java/org/qortal/repository/Bootstrap.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index a139f381..5c190871 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -266,7 +266,7 @@ public class Bootstrap { SevenZ.compress(compressedOutputPath, outputPath.toFile()); // Return the path to the compressed bootstrap file - Path finalPath = Paths.get(outputPath.toString(), compressedOutputPath); + Path finalPath = Paths.get(compressedOutputPath); return finalPath.toAbsolutePath().toString(); } finally { From 8aaf720b0b0ce7622e1dda2051989b6d64836b1d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 2 Oct 2021 19:11:47 +0100 Subject: [PATCH 150/231] Force archiveEnabled to false if we're in top only mode. Most of the code handles this case anyway, but it's an easy place for bugs to be created. So it's safest to enforce it at the settings level. --- src/main/java/org/qortal/settings/Settings.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index d9fa025f..414ea3ed 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -616,6 +616,9 @@ public class Settings { public boolean isArchiveEnabled() { + if (this.topOnly) { + return false; + } return this.archiveEnabled; } From 9591c4eb58b93524168dc6b5a5317b9f861d4e44 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 2 Oct 2021 19:13:18 +0100 Subject: [PATCH 151/231] Added support for creating top-only bootstraps --- .../java/org/qortal/repository/Bootstrap.java | 86 +++++++++---------- 1 file changed, 41 insertions(+), 45 deletions(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 5c190871..2730fe4c 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -53,18 +53,11 @@ public class Bootstrap { try { LOGGER.info("Checking repository state..."); - final boolean pruningEnabled = Settings.getInstance().isTopOnly(); + final boolean isTopOnly = Settings.getInstance().isTopOnly(); final boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); - // Avoid creating bootstraps from pruned nodes until officially supported - if (pruningEnabled) { - LOGGER.info("Creating bootstraps from top-only nodes isn't yet supported."); - // TODO: add support for top-only bootstraps - return false; - } - // Require that a block archive has been built - if (!archiveEnabled) { + if (!isTopOnly && !archiveEnabled) { LOGGER.info("Unable to create bootstrap because the block archive isn't enabled. " + "Set {\"archivedEnabled\": true} in settings.json to fix."); return false; @@ -100,26 +93,31 @@ public class Bootstrap { // FUTURE: ensure trim and prune settings are using default values - // Ensure that the online account signatures have been fully trimmed - final int accountsTrimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); - final long accountsUpperTrimmableTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime(); - final int accountsUpperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(accountsUpperTrimmableTimestamp); - final int accountsBlocksRemaining = accountsUpperTrimmableHeight - accountsTrimStartHeight; - if (accountsBlocksRemaining > MAXIMUM_UNTRIMMED_BLOCKS) { - LOGGER.info("Blockchain is not fully trimmed. Please allow the node to run for longer, " + - "then try again. Blocks remaining (online accounts signatures): {}", accountsBlocksRemaining); - return false; - } + if (!isTopOnly) { + // We don't trim in top-only mode because we prune the blocks instead + // If we're not in top-only mode we should make sure that trimming is up to date - // Ensure that the AT states data has been fully trimmed - final int atTrimStartHeight = repository.getATRepository().getAtTrimHeight(); - final long atUpperTrimmableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); - final int atUpperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(atUpperTrimmableTimestamp); - final int atBlocksRemaining = atUpperTrimmableHeight - atTrimStartHeight; - if (atBlocksRemaining > MAXIMUM_UNTRIMMED_BLOCKS) { - LOGGER.info("Blockchain is not fully trimmed. Please allow the node to run for longer, " + - "then try again. Blocks remaining (AT states): {}", atBlocksRemaining); - return false; + // Ensure that the online account signatures have been fully trimmed + final int accountsTrimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); + final long accountsUpperTrimmableTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime(); + final int accountsUpperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(accountsUpperTrimmableTimestamp); + final int accountsBlocksRemaining = accountsUpperTrimmableHeight - accountsTrimStartHeight; + if (accountsBlocksRemaining > MAXIMUM_UNTRIMMED_BLOCKS) { + LOGGER.info("Blockchain is not fully trimmed. Please allow the node to run for longer, " + + "then try again. Blocks remaining (online accounts signatures): {}", accountsBlocksRemaining); + return false; + } + + // Ensure that the AT states data has been fully trimmed + final int atTrimStartHeight = repository.getATRepository().getAtTrimHeight(); + final long atUpperTrimmableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); + final int atUpperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(atUpperTrimmableTimestamp); + final int atBlocksRemaining = atUpperTrimmableHeight - atTrimStartHeight; + if (atBlocksRemaining > MAXIMUM_UNTRIMMED_BLOCKS) { + LOGGER.info("Blockchain is not fully trimmed. Please allow the node to run for longer, " + + "then try again. Blocks remaining (AT states): {}", atBlocksRemaining); + return false; + } } // Ensure that blocks have been fully pruned @@ -230,17 +228,9 @@ public class Bootstrap { repository.saveChanges(); LOGGER.info("Creating bootstrap..."); - while (!Controller.isStopping()) { - try { - // Timeout if the database isn't ready for backing up after 10 seconds - long timeout = 10 * 1000L; - repository.backup(false, "bootstrap", timeout); - break; - } - catch (TimeoutException e) { - LOGGER.info("Unable to create bootstrap due to timeout. Retrying..."); - } - } + // Timeout if the database isn't ready for backing up after 10 seconds + long timeout = 10 * 1000L; + repository.backup(false, "bootstrap", timeout); LOGGER.info("Moving files to output directory..."); inputPath = Paths.get(Settings.getInstance().getRepositoryPath(), "bootstrap"); @@ -250,11 +240,13 @@ public class Bootstrap { // Move the db backup to a "bootstrap" folder in the root directory Files.move(inputPath, outputPath, REPLACE_EXISTING); - // Copy the archive folder to inside the bootstrap folder - FileUtils.copyDirectory( - Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toFile(), - Paths.get(outputPath.toString(), "archive").toFile() - ); + // If in archive mode, copy the archive folder to inside the bootstrap folder + if (!Settings.getInstance().isTopOnly() && Settings.getInstance().isArchiveEnabled()) { + FileUtils.copyDirectory( + Paths.get(Settings.getInstance().getRepositoryPath(), "archive").toFile(), + Paths.get(outputPath.toString(), "archive").toFile() + ); + } LOGGER.info("Compressing..."); String compressedOutputPath = String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), this.getFilename()); @@ -269,7 +261,11 @@ public class Bootstrap { Path finalPath = Paths.get(compressedOutputPath); return finalPath.toAbsolutePath().toString(); - } finally { + } + catch (TimeoutException e) { + throw new DataException(String.format("Unable to create bootstrap due to timeout: %s", e.getMessage())); + } + finally { LOGGER.info("Re-importing local data..."); Path exportPath = HSQLDBImportExport.getExportDirectory(false); repository.importDataFromFile(Paths.get(exportPath.toString(), "TradeBotStates.json").toString()); From a6d3891a9530f91f79bf44f4740451fac949bdad Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 09:48:14 +0100 Subject: [PATCH 152/231] Fixed bugs when importing bootstrap. --- src/main/java/org/qortal/repository/Bootstrap.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 2730fe4c..b647a9f8 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -289,6 +289,7 @@ public class Bootstrap { LOGGER.info("Starting import of bootstrap..."); this.doImport(); + break; } catch (DataException e) { LOGGER.info("Bootstrap import failed: {}", e.getMessage()); @@ -314,7 +315,7 @@ public class Bootstrap { finally { if (path != null) { try { - FileUtils.deleteDirectory(path.toFile()); + Files.delete(path); } catch (IOException e) { // Temp folder will be cleaned up by system anyway, so ignore this failure From 35718f6215695bff0bd336f5fb06de9aa9518b9a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 15:00:40 +0100 Subject: [PATCH 153/231] Fixed issue with bootstrap retries. --- .../java/org/qortal/block/BlockChain.java | 6 ++---- .../java/org/qortal/repository/Bootstrap.java | 20 +++++++++++++++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 886c9dd9..33899011 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -609,10 +609,8 @@ public class BlockChain { boolean shouldBootstrap = Settings.getInstance().getBootstrap(); if (shouldBootstrap) { // Settings indicate that we should apply a bootstrap rather than rebuilding and syncing from genesis - try (final Repository repository = RepositoryManager.getRepository()) { - Bootstrap bootstrap = new Bootstrap(repository); - bootstrap.startImport(); - } + Bootstrap bootstrap = new Bootstrap(); + bootstrap.startImport(); return; } diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index b647a9f8..0a822d5f 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -28,7 +28,7 @@ import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; public class Bootstrap { - private final Repository repository; + private Repository repository; private static final Logger LOGGER = LogManager.getLogger(Bootstrap.class); @@ -39,6 +39,9 @@ public class Bootstrap { private static final int MAXIMUM_UNPRUNED_BLOCKS = 100; + public Bootstrap() { + } + public Bootstrap(Repository repository) { this.repository = repository; } @@ -56,6 +59,12 @@ public class Bootstrap { final boolean isTopOnly = Settings.getInstance().isTopOnly(); final boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); + // Make sure we have a repository instance + if (repository == null) { + LOGGER.info("Error: repository instance required to check if we can create a bootstrap."); + return false; + } + // Require that a block archive has been built if (!isTopOnly && !archiveEnabled) { LOGGER.info("Unable to create bootstrap because the block archive isn't enabled. " + @@ -201,6 +210,11 @@ public class Bootstrap { public String create() throws DataException, InterruptedException, IOException { + // Make sure we have a repository instance + if (repository == null) { + throw new DataException("Repository instance required in order to create a boostrap"); + } + LOGGER.info("Acquiring blockchain lock..."); ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); blockchainLock.lockInterruptibly(); @@ -285,7 +299,9 @@ public class Bootstrap { public void startImport() throws InterruptedException { while (!Controller.isStopping()) { - try { + try (final Repository repository = RepositoryManager.getRepository()) { + this.repository = repository; + LOGGER.info("Starting import of bootstrap..."); this.doImport(); From 7e5dd62a928fccc3e363eb27d1dc8c277944b13a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 15:01:50 +0100 Subject: [PATCH 154/231] Show bootstrap statuses on splash frame. --- src/main/java/org/qortal/gui/SplashFrame.java | 39 +++++++++++++++---- .../java/org/qortal/repository/Bootstrap.java | 20 ++++++---- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/qortal/gui/SplashFrame.java b/src/main/java/org/qortal/gui/SplashFrame.java index 37d20ec5..967377d1 100644 --- a/src/main/java/org/qortal/gui/SplashFrame.java +++ b/src/main/java/org/qortal/gui/SplashFrame.java @@ -16,6 +16,7 @@ public class SplashFrame { private static SplashFrame instance; private JFrame splashDialog; + private SplashPanel splashPanel; @SuppressWarnings("serial") public static class SplashPanel extends JPanel { @@ -23,22 +24,39 @@ public class SplashFrame { private String defaultSplash = "Qlogo_512.png"; + private JLabel statusLabel; + public SplashPanel() { image = Gui.loadImage(defaultSplash); setOpaque(false); - setLayout(new GridBagLayout()); - } + setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); + setBorder(null); - @Override - protected void paintComponent(Graphics g) { - super.paintComponent(g); - g.drawImage(image, 0, 0, getWidth(), getHeight(), this); + // Add logo + JLabel imageLabel = new JLabel(new ImageIcon(image)); + imageLabel.setSize(new Dimension(300, 300)); + add(imageLabel); + + // Add status label + statusLabel = new JLabel("Starting Qortal Core...", JLabel.CENTER); + statusLabel.setMaximumSize(new Dimension(500, 50)); + statusLabel.setFont(new Font("Verdana", Font.PLAIN, 22)); + statusLabel.setBackground(new Color(255, 255, 255)); + statusLabel.setOpaque(true); + statusLabel.setBorder(null); + add(statusLabel); } @Override public Dimension getPreferredSize() { - return new Dimension(500, 500); + return new Dimension(500, 550); + } + + public void updateStatus(String text) { + if (statusLabel != null) { + statusLabel.setText(text); + } } } @@ -55,7 +73,8 @@ public class SplashFrame { icons.add(Gui.loadImage("icons/Qlogo_128.png")); this.splashDialog.setIconImages(icons); - this.splashDialog.getContentPane().add(new SplashPanel()); + this.splashPanel = new SplashPanel(); + this.splashDialog.getContentPane().add(this.splashPanel); this.splashDialog.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); this.splashDialog.setUndecorated(true); this.splashDialog.pack(); @@ -79,4 +98,8 @@ public class SplashFrame { this.splashDialog.dispose(); } + public void updateStatus(String text) { + this.splashPanel.updateStatus(text); + } + } diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 0a822d5f..0becf107 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -8,6 +8,7 @@ import org.qortal.controller.Controller; import org.qortal.data.account.MintingAccountData; import org.qortal.data.block.BlockData; import org.qortal.data.crosschain.TradeBotData; +import org.qortal.gui.SplashFrame; import org.qortal.repository.hsqldb.HSQLDBImportExport; import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory; import org.qortal.settings.Settings; @@ -302,14 +303,14 @@ public class Bootstrap { try (final Repository repository = RepositoryManager.getRepository()) { this.repository = repository; - LOGGER.info("Starting import of bootstrap..."); + this.updateStatus("Starting import of bootstrap..."); this.doImport(); break; } catch (DataException e) { LOGGER.info("Bootstrap import failed: {}", e.getMessage()); - LOGGER.info("Retrying in 5 minutes"); + this.updateStatus("Bootstrapping failed. Retrying in 5 minutes"); Thread.sleep(5 * 60 * 1000L); } } @@ -363,7 +364,7 @@ public class Bootstrap { String bootstrapFilename = this.getFilename(); try { - LOGGER.info("Downloading bootstrap..."); + this.updateStatus("Downloading bootstrap..."); String bootstrapUrl = String.format("%s/%s", bootstrapHost, bootstrapFilename); InputStream in = new URL(bootstrapUrl).openStream(); Files.copy(in, path, REPLACE_EXISTING); @@ -379,12 +380,12 @@ public class Bootstrap { blockchainLock.lockInterruptibly(); try { - LOGGER.info("Extracting bootstrap..."); + this.updateStatus("Extracting bootstrap..."); Path input = path.toAbsolutePath(); Path output = path.toAbsolutePath().getParent().toAbsolutePath(); SevenZ.decompress(input.toString(), output.toFile()); - LOGGER.info("Stopping repository..."); + this.updateStatus("Stopping repository..."); // Close the repository while we are still able to // Otherwise, the caller will run into difficulties when it tries to close it repository.discardChanges(); @@ -399,11 +400,11 @@ public class Bootstrap { } // Move the "bootstrap" folder in place of the "db" folder - LOGGER.info("Moving files to output directory..."); + this.updateStatus("Moving files to output directory..."); FileUtils.deleteDirectory(outputPath.toFile()); Files.move(inputPath, outputPath); - LOGGER.info("Starting repository from bootstrap..."); + this.updateStatus("Starting repository from bootstrap..."); RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl()); RepositoryManager.setRepositoryFactory(repositoryFactory); @@ -413,4 +414,9 @@ public class Bootstrap { } } + private void updateStatus(String text) { + LOGGER.info(text); + SplashFrame.getInstance().updateStatus(text); + } + } From b00c1c1575e438032c186b229ed96d337facebc0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 15:16:36 +0100 Subject: [PATCH 155/231] Update splash frame statuses when reshaping, pruning, or building the block archive --- .../qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java | 2 ++ .../org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java | 3 ++- .../org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java index 0ad315e3..3c24edbb 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java @@ -3,6 +3,7 @@ package org.qortal.repository.hsqldb; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.controller.Controller; +import org.qortal.gui.SplashFrame; import org.qortal.repository.BlockArchiveWriter; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -40,6 +41,7 @@ public class HSQLDBDatabaseArchiving { } LOGGER.info("Building block archive - this process could take a while... (approx. 15 mins on high spec)"); + SplashFrame.getInstance().updateStatus("Building block archive, please wait..."); final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); int startHeight = 0; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index 232f7058..cf2e93eb 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -4,10 +4,10 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.controller.Controller; import org.qortal.data.block.BlockData; +import org.qortal.gui.SplashFrame; import org.qortal.repository.BlockArchiveWriter; import org.qortal.repository.DataException; import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryManager; import org.qortal.settings.Settings; import java.sql.ResultSet; @@ -61,6 +61,7 @@ public class HSQLDBDatabasePruning { LOGGER.info("Starting bulk prune of AT states - this process could take a while... " + "(approx. 2 mins on high spec, or upwards of 30 mins in some cases)"); + SplashFrame.getInstance().updateStatus("Pruning database, please wait..."); // Create new AT-states table to hold smaller dataset repository.executeCheckedUpdate("DROP TABLE IF EXISTS ATStatesNew"); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 66fe9029..62f75a7e 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -10,6 +10,7 @@ import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.controller.tradebot.BitcoinACCTv1TradeBot; +import org.qortal.gui.SplashFrame; public class HSQLDBDatabaseUpdates { @@ -27,9 +28,13 @@ public class HSQLDBDatabaseUpdates { public static boolean updateDatabase(Connection connection) throws SQLException { final boolean wasPristine = fetchDatabaseVersion(connection) == 0; + SplashFrame.getInstance().updateStatus("Upgrading database, please wait..."); + while (databaseUpdating(connection, wasPristine)) incrementDatabaseVersion(connection); + SplashFrame.getInstance().updateStatus("Starting Qortal Core..."); + return wasPristine; } From f09fb5a209d71f4058854dfb36cf14ec6da13005 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 15:17:31 +0100 Subject: [PATCH 156/231] Run the bulk block archiver any time that it isn't reported to be up to date. This should allow it to re-run if the user quits the core before it completes. --- .../qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java index 3c24edbb..0074fbda 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java @@ -33,9 +33,9 @@ public class HSQLDBDatabaseArchiving { public static boolean buildBlockArchive(Repository repository, long fileSizeTarget) throws DataException { - // Only build the archive if we have never done so before - int archiveHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight(); - if (archiveHeight > 0) { + // Only build the archive if we haven't already got one that is up to date + boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); + if (upToDate) { // Already archived return false; } From fc8e38e862b568fc67ef99e6d53ebe91f6bc2775 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 15:22:21 +0100 Subject: [PATCH 157/231] Show version number in the "Starting Qortal Core" status. --- src/main/java/org/qortal/controller/Controller.java | 4 ++++ src/main/java/org/qortal/gui/SplashFrame.java | 4 +++- .../org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 253dee03..b930c6ee 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -312,6 +312,10 @@ public class Controller extends Thread { return this.buildVersion; } + public String getVersionStringWithoutPrefix() { + return this.buildVersion.replaceFirst(VERSION_PREFIX, ""); + } + /** Returns current blockchain height, or 0 if it's not available. */ public int getChainHeight() { synchronized (this.latestBlocks) { diff --git a/src/main/java/org/qortal/gui/SplashFrame.java b/src/main/java/org/qortal/gui/SplashFrame.java index 967377d1..03408757 100644 --- a/src/main/java/org/qortal/gui/SplashFrame.java +++ b/src/main/java/org/qortal/gui/SplashFrame.java @@ -9,6 +9,7 @@ import javax.swing.*; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.controller.Controller; public class SplashFrame { @@ -39,7 +40,8 @@ public class SplashFrame { add(imageLabel); // Add status label - statusLabel = new JLabel("Starting Qortal Core...", JLabel.CENTER); + String text = String.format("Starting Qortal Core v%s...", Controller.getInstance().getVersionStringWithoutPrefix()); + statusLabel = new JLabel(text, JLabel.CENTER); statusLabel.setMaximumSize(new Dimension(500, 50)); statusLabel.setFont(new Font("Verdana", Font.PLAIN, 22)); statusLabel.setBackground(new Color(255, 255, 255)); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 62f75a7e..e28e9114 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -9,6 +9,7 @@ import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.controller.Controller; import org.qortal.controller.tradebot.BitcoinACCTv1TradeBot; import org.qortal.gui.SplashFrame; @@ -33,7 +34,8 @@ public class HSQLDBDatabaseUpdates { while (databaseUpdating(connection, wasPristine)) incrementDatabaseVersion(connection); - SplashFrame.getInstance().updateStatus("Starting Qortal Core..."); + String text = String.format("Starting Qortal Core v%s...", Controller.getInstance().getVersionStringWithoutPrefix()); + SplashFrame.getInstance().updateStatus(text); return wasPristine; } From 494cd0efff3ce313fdc1b96e93f789bf847638fd Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 15:27:19 +0100 Subject: [PATCH 158/231] Added white background to splash frame - I think it looks nicer this way, and it may solve the X2Go issues too. --- src/main/java/org/qortal/gui/SplashFrame.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/gui/SplashFrame.java b/src/main/java/org/qortal/gui/SplashFrame.java index 03408757..78846b04 100644 --- a/src/main/java/org/qortal/gui/SplashFrame.java +++ b/src/main/java/org/qortal/gui/SplashFrame.java @@ -30,9 +30,10 @@ public class SplashFrame { public SplashPanel() { image = Gui.loadImage(defaultSplash); - setOpaque(false); + setOpaque(true); setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); setBorder(null); + setBackground(new Color(255, 255, 255)); // Add logo JLabel imageLabel = new JLabel(new ImageIcon(image)); From f67a0469fc70032f4e373644b7d566626282417e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 15:33:53 +0100 Subject: [PATCH 159/231] SplashFrame styling improvements --- src/main/java/org/qortal/gui/SplashFrame.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/gui/SplashFrame.java b/src/main/java/org/qortal/gui/SplashFrame.java index 78846b04..aed23d16 100644 --- a/src/main/java/org/qortal/gui/SplashFrame.java +++ b/src/main/java/org/qortal/gui/SplashFrame.java @@ -6,6 +6,7 @@ import java.util.List; import java.awt.image.BufferedImage; import javax.swing.*; +import javax.swing.border.EmptyBorder; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -32,7 +33,7 @@ public class SplashFrame { setOpaque(true); setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); - setBorder(null); + setBorder(new EmptyBorder(10, 10, 10, 10)); setBackground(new Color(255, 255, 255)); // Add logo @@ -40,11 +41,14 @@ public class SplashFrame { imageLabel.setSize(new Dimension(300, 300)); add(imageLabel); + // Add spacing + add(Box.createRigidArea(new Dimension(0, 16))); + // Add status label String text = String.format("Starting Qortal Core v%s...", Controller.getInstance().getVersionStringWithoutPrefix()); statusLabel = new JLabel(text, JLabel.CENTER); statusLabel.setMaximumSize(new Dimension(500, 50)); - statusLabel.setFont(new Font("Verdana", Font.PLAIN, 22)); + statusLabel.setFont(new Font("Verdana", Font.PLAIN, 20)); statusLabel.setBackground(new Color(255, 255, 255)); statusLabel.setOpaque(true); statusLabel.setBorder(null); @@ -53,7 +57,7 @@ public class SplashFrame { @Override public Dimension getPreferredSize() { - return new Dimension(500, 550); + return new Dimension(500, 580); } public void updateStatus(String text) { From 5b028428c412ae39ecbacfc7dd9b6197ce1ed6c3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 15:47:10 +0100 Subject: [PATCH 160/231] Checkpoint immediately after starting/upgrading the repository This should fix a longstanding issue where quitting the core before the first checkpoint (1-2 hours after first launch) causes the database to become corrupt. --- src/main/java/org/qortal/controller/Controller.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index b930c6ee..e478eb59 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -417,6 +417,7 @@ public class Controller extends Thread { try { RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(getRepositoryUrl()); RepositoryManager.setRepositoryFactory(repositoryFactory); + RepositoryManager.setRequestedCheckpoint(Boolean.TRUE); try (final Repository repository = RepositoryManager.getRepository()) { RepositoryManager.archive(repository); From dc876d9c96391ddf487805f1d7f3940dc355f829 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 16:00:11 +0100 Subject: [PATCH 161/231] Force a bootstrap if the block archive isn't intact on launch This allows the topOnly setting to be disabled and the node will automatically bootstrap to the archive version. A rebuild isn't attempted if bootstrapping is disabled, in order to reduce risk. --- .../java/org/qortal/block/BlockChain.java | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 33899011..78bf4b8e 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -502,13 +502,22 @@ public class BlockChain { */ public static void validate() throws DataException { - BlockData chainTip; - try (final Repository repository = RepositoryManager.getRepository()) { - chainTip = repository.getBlockRepository().getLastBlock(); - } - boolean isTopOnly = Settings.getInstance().isTopOnly(); boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); + boolean canBootstrap = Settings.getInstance().getBootstrap(); + boolean needsArchiveRebuild = false; + BlockData chainTip; + + try (final Repository repository = RepositoryManager.getRepository()) { + chainTip = repository.getBlockRepository().getLastBlock(); + + // Ensure archive is (at least partially) intact, and force a bootstrap if it isn't + if (!isTopOnly && archiveEnabled && canBootstrap) { + needsArchiveRebuild = (repository.getBlockArchiveRepository().fromHeight(2) == null); + LOGGER.info("Couldn't retrieve block 2 from archive. Bootstrapping..."); + } + } + boolean hasBlocks = (chainTip != null && chainTip.getHeight() > 1); if (isTopOnly && hasBlocks) { @@ -516,7 +525,7 @@ public class BlockChain { // It's best not to validate it, and there's no real need to } else { // Check first block is Genesis Block - if (!isGenesisBlockValid()) { + if (!isGenesisBlockValid() || needsArchiveRebuild) { try { rebuildBlockchain(); From 52b322b756537811c1f27db7c371cc66186c0317 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 16:09:40 +0100 Subject: [PATCH 162/231] Take a backup of local data before overwriting with a bootstrap. Also moved the import phase to after the validation phase, so that the data returns after the bootstrap. --- src/main/java/org/qortal/block/BlockChain.java | 1 + src/main/java/org/qortal/controller/Controller.java | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 78bf4b8e..45127ceb 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -515,6 +515,7 @@ public class BlockChain { if (!isTopOnly && archiveEnabled && canBootstrap) { needsArchiveRebuild = (repository.getBlockArchiveRepository().fromHeight(2) == null); LOGGER.info("Couldn't retrieve block 2 from archive. Bootstrapping..."); + Controller.getInstance().exportRepositoryData(); } } diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index e478eb59..c3ccde35 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -436,9 +436,6 @@ public class Controller extends Thread { return; // Not System.exit() so that GUI can display error } - // Import current trade bot states and minting accounts if they exist - Controller.importRepositoryData(); - // Rebuild Names table and check database integrity NamesDatabaseIntegrityCheck namesDatabaseIntegrityCheck = new NamesDatabaseIntegrityCheck(); namesDatabaseIntegrityCheck.rebuildAllNames(); @@ -456,6 +453,9 @@ public class Controller extends Thread { return; // Not System.exit() so that GUI can display error } + // Import current trade bot states and minting accounts if they exist + Controller.importRepositoryData(); + LOGGER.info("Starting controller"); Controller.getInstance().start(); @@ -655,7 +655,7 @@ public class Controller extends Thread { /** * Export current trade bot states and minting accounts. */ - private void exportRepositoryData() { + public void exportRepositoryData() { try (final Repository repository = RepositoryManager.getRepository()) { repository.exportNodeLocalData(); From 59c8e4e6a2e11acd791a97756e38ad76bd90574b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 16:09:52 +0100 Subject: [PATCH 163/231] Fixed bug in earlier commit --- src/main/java/org/qortal/block/BlockChain.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 45127ceb..a70476de 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -514,8 +514,10 @@ public class BlockChain { // Ensure archive is (at least partially) intact, and force a bootstrap if it isn't if (!isTopOnly && archiveEnabled && canBootstrap) { needsArchiveRebuild = (repository.getBlockArchiveRepository().fromHeight(2) == null); - LOGGER.info("Couldn't retrieve block 2 from archive. Bootstrapping..."); - Controller.getInstance().exportRepositoryData(); + if (needsArchiveRebuild) { + LOGGER.info("Couldn't retrieve block 2 from archive. Bootstrapping..."); + Controller.getInstance().exportRepositoryData(); + } } } From 75ed5db3e491cd9702e456774e9c9e6ea422b864 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 17:33:02 +0100 Subject: [PATCH 164/231] Test multiple files when bulk archiving. --- .../org/qortal/test/BlockArchiveTests.java | 115 +++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/qortal/test/BlockArchiveTests.java b/src/test/java/org/qortal/test/BlockArchiveTests.java index 1a3e7655..e2f2ed1c 100644 --- a/src/test/java/org/qortal/test/BlockArchiveTests.java +++ b/src/test/java/org/qortal/test/BlockArchiveTests.java @@ -26,6 +26,7 @@ import org.qortal.utils.Triple; import java.io.File; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.sql.SQLException; @@ -332,7 +333,7 @@ public class BlockArchiveTests extends Common { } @Test - public void testBulkArchiveAndPrune() throws DataException, InterruptedException, TransformationException, IOException, SQLException { + public void testBulkArchiveAndPrune() throws DataException, SQLException { try (final Repository repository = RepositoryManager.getRepository()) { HSQLDBRepository hsqldb = (HSQLDBRepository) repository; @@ -425,6 +426,118 @@ public class BlockArchiveTests extends Common { } } + @Test + public void testBulkArchiveAndPruneMultipleFiles() throws DataException, SQLException { + try (final Repository repository = RepositoryManager.getRepository()) { + HSQLDBRepository hsqldb = (HSQLDBRepository) repository; + + // Deploy an AT so that we have AT state data + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + byte[] creationBytes = AtUtils.buildSimpleAT(); + long fundingAmount = 1_00000000L; + AtUtils.doDeployAT(repository, deployer, creationBytes, fundingAmount); + + // Mint some blocks so that we are able to archive them later + for (int i = 0; i < 1000; i++) { + BlockMinter.mintTestingBlock(repository, Common.getTestAccount(repository, "alice-reward-share")); + } + + // Assume 900 blocks are trimmed (this specifies the first untrimmed height) + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(901); + repository.getATRepository().setAtTrimHeight(901); + + // Check the max archive height - this should be one less than the first untrimmed height + final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); + assertEquals(900, maximumArchiveHeight); + + // Check the current archive height + assertEquals(0, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Write blocks 2-900 to the archive (using bulk method) + int fileSizeTarget = 42000; // Pre-calculated size of approx 90 blocks + assertTrue(HSQLDBDatabaseArchiving.buildBlockArchive(repository, fileSizeTarget)); + + // Ensure 10 archive files have been created + Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive"); + assertEquals(10, new File(archivePath.toString()).list().length); + + // Check the files exist + assertTrue(Files.exists(Paths.get(archivePath.toString(), "2-90.dat"))); + assertTrue(Files.exists(Paths.get(archivePath.toString(), "91-179.dat"))); + assertTrue(Files.exists(Paths.get(archivePath.toString(), "180-268.dat"))); + assertTrue(Files.exists(Paths.get(archivePath.toString(), "269-357.dat"))); + assertTrue(Files.exists(Paths.get(archivePath.toString(), "358-446.dat"))); + assertTrue(Files.exists(Paths.get(archivePath.toString(), "447-535.dat"))); + assertTrue(Files.exists(Paths.get(archivePath.toString(), "536-624.dat"))); + assertTrue(Files.exists(Paths.get(archivePath.toString(), "625-713.dat"))); + assertTrue(Files.exists(Paths.get(archivePath.toString(), "714-802.dat"))); + assertTrue(Files.exists(Paths.get(archivePath.toString(), "803-891.dat"))); + + // Ensure the block archive height has increased + // It won't be as high as 901, because blocks 892-901 were too small to reach the file size + // target of the 11th file + assertEquals(892, repository.getBlockArchiveRepository().getBlockArchiveHeight()); + + // Ensure the SQL repository contains blocks 2 and 891... + assertNotNull(repository.getBlockRepository().fromHeight(2)); + assertNotNull(repository.getBlockRepository().fromHeight(891)); + + // Check the current prune heights + assertEquals(0, repository.getBlockRepository().getBlockPruneHeight()); + assertEquals(0, repository.getATRepository().getAtPruneHeight()); + + // Prior to archiving or pruning, ensure blocks 2 to 1002 and their AT states are available in the db + for (int i=2; i<=1002; i++) { + assertNotNull(repository.getBlockRepository().fromHeight(i)); + List atStates = repository.getATRepository().getBlockATStatesAtHeight(i); + assertNotNull(atStates); + assertEquals(1, atStates.size()); + } + + // Prune all the archived blocks and AT states (using bulk method) + assertTrue(HSQLDBDatabasePruning.pruneBlocks(hsqldb)); + assertTrue(HSQLDBDatabasePruning.pruneATStates(hsqldb)); + + // Ensure the current prune heights have increased + assertEquals(892, repository.getBlockRepository().getBlockPruneHeight()); + assertEquals(892, repository.getATRepository().getAtPruneHeight()); + + // Now ensure the SQL repository is missing blocks 2 and 891... + assertNull(repository.getBlockRepository().fromHeight(2)); + assertNull(repository.getBlockRepository().fromHeight(891)); + + // ... but it's not missing blocks 1 and 901 (we don't prune the genesis block) + assertNotNull(repository.getBlockRepository().fromHeight(1)); + assertNotNull(repository.getBlockRepository().fromHeight(892)); + + // Validate the latest block height in the repository + assertEquals(1002, (int) repository.getBlockRepository().getLastBlock().getHeight()); + + // Ensure blocks 2-891 are all available in the archive + for (int i=2; i<=891; i++) { + assertNotNull(repository.getBlockArchiveRepository().fromHeight(i)); + } + + // Ensure blocks 2-891 are NOT available in the db + for (int i=2; i<=891; i++) { + assertNull(repository.getBlockRepository().fromHeight(i)); + } + + // Ensure blocks 892 to 1002 and their AT states are available in the db + for (int i=892; i<=1002; i++) { + assertNotNull(repository.getBlockRepository().fromHeight(i)); + List atStates = repository.getATRepository().getBlockATStatesAtHeight(i); + assertNotNull(atStates); + assertEquals(1, atStates.size()); + } + + // Ensure blocks 892 to 1002 are not available in the archive + for (int i=892; i<=1002; i++) { + assertNull(repository.getBlockArchiveRepository().fromHeight(i)); + } + } + } + @Test public void testTrimArchivePruneAndOrphan() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { From ac49221639cf246fdf0af7942937b88cddaf61b6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 18:34:21 +0100 Subject: [PATCH 165/231] Show warning status on startup if the database is missing the AtStatesHeightIndex. --- src/main/java/org/qortal/repository/RepositoryManager.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index e97806f0..714ada28 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -2,6 +2,7 @@ package org.qortal.repository; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.gui.SplashFrame; import org.qortal.repository.hsqldb.HSQLDBDatabaseArchiving; import org.qortal.repository.hsqldb.HSQLDBDatabasePruning; import org.qortal.repository.hsqldb.HSQLDBRepository; @@ -73,6 +74,8 @@ public abstract class RepositoryManager { } else { LOGGER.info("Unable to build block archive due to missing ATStatesHeightIndex. Bootstrapping is recommended."); + LOGGER.info("To bootstrap, stop the core and delete the db folder, then start the core again."); + SplashFrame.getInstance().updateStatus("Missing index. Bootstrapping is recommended."); } } return false; From 4f892835b8a96fc6a2539bf84e4bc60528303b2c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 18:41:47 +0100 Subject: [PATCH 166/231] Show maximum time estimations in archiving and pruning statuses --- .../org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java | 2 +- .../org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java index 0074fbda..06645ca6 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java @@ -41,7 +41,7 @@ public class HSQLDBDatabaseArchiving { } LOGGER.info("Building block archive - this process could take a while... (approx. 15 mins on high spec)"); - SplashFrame.getInstance().updateStatus("Building block archive, please wait..."); + SplashFrame.getInstance().updateStatus("Building block archive (takes up to 60 mins)..."); final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); int startHeight = 0; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index cf2e93eb..b8e80d7a 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -61,7 +61,7 @@ public class HSQLDBDatabasePruning { LOGGER.info("Starting bulk prune of AT states - this process could take a while... " + "(approx. 2 mins on high spec, or upwards of 30 mins in some cases)"); - SplashFrame.getInstance().updateStatus("Pruning database, please wait..."); + SplashFrame.getInstance().updateStatus("Pruning database (takes up to 30 mins)..."); // Create new AT-states table to hold smaller dataset repository.executeCheckedUpdate("DROP TABLE IF EXISTS ATStatesNew"); From b6d3e82304dd6397ffa31474a267e950d4fb8334 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 19:31:05 +0100 Subject: [PATCH 167/231] Update status when performing repository maintenance --- .../org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index b8e80d7a..66dacac3 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -318,6 +318,8 @@ public class HSQLDBDatabasePruning { public static void performMaintenance(Repository repository) throws SQLException, DataException { try { + SplashFrame.getInstance().updateStatus("Performing maintenance..."); + // Timeout if the database isn't ready for backing up after 5 minutes // Nothing else should be using the db at this point, so a timeout shouldn't happen long timeout = 5 * 60 * 1000L; From 4f48751d0bcd46b9cfef56a8c2f076d4ae10800a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 19:43:10 +0100 Subject: [PATCH 168/231] Fixed issue caused when trying to update the splash frame status in a headless environment. --- src/main/java/org/qortal/gui/SplashFrame.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/gui/SplashFrame.java b/src/main/java/org/qortal/gui/SplashFrame.java index aed23d16..4e51776c 100644 --- a/src/main/java/org/qortal/gui/SplashFrame.java +++ b/src/main/java/org/qortal/gui/SplashFrame.java @@ -68,6 +68,10 @@ public class SplashFrame { } private SplashFrame() { + if (GraphicsEnvironment.isHeadless()) { + return; + } + this.splashDialog = new JFrame(); List icons = new ArrayList<>(); @@ -106,7 +110,9 @@ public class SplashFrame { } public void updateStatus(String text) { - this.splashPanel.updateStatus(text); + if (this.splashPanel != null) { + this.splashPanel.updateStatus(text); + } } } From 210368bea02c0f0fb02341c015d114ba42348dba Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 19:43:28 +0100 Subject: [PATCH 169/231] Bump version to 2.0.0-beta.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 50ee778d..68f735ff 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 1.7.0 + 2.0.0-beta.0 jar true From cdf47d471996f8463a539109749690df731a019e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 22:33:36 +0100 Subject: [PATCH 170/231] Reduce log spam. --- src/main/java/org/qortal/network/Network.java | 2 +- .../java/org/qortal/repository/hsqldb/HSQLDBATRepository.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/network/Network.java b/src/main/java/org/qortal/network/Network.java index 59f06be6..a2a0df8c 100644 --- a/src/main/java/org/qortal/network/Network.java +++ b/src/main/java/org/qortal/network/Network.java @@ -653,7 +653,7 @@ public class Network { if (peersToDisconnect != null && peersToDisconnect.size() > 0) { for (Peer peer : peersToDisconnect) { - LOGGER.info("Forcing disconnection of peer {} because connection age ({} ms) " + + LOGGER.debug("Forcing disconnection of peer {} because connection age ({} ms) " + "has reached the maximum ({} ms)", peer, peer.getConnectionAge(), peer.getMaxConnectionAge()); peer.disconnect("Connection age too old"); } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 85196d31..04823925 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -777,7 +777,7 @@ public class HSQLDBATRepository implements ATRepository { if (atAddresses.contains(atState.getATAddress())) { // We don't want to delete this AT state because it is still active - LOGGER.info("Skipping atState {} at height {}", atState.getATAddress(), atState.getHeight()); + LOGGER.trace("Skipping atState {} at height {}", atState.getATAddress(), atState.getHeight()); continue; } From 41c2ed7c67ca7498d068558aca3c9a27c79ad739 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 22:51:15 +0100 Subject: [PATCH 171/231] Fixed out of memory errors when copying AT states. --- .../org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java index 66dacac3..978ba25e 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabasePruning.java @@ -153,6 +153,7 @@ public class HSQLDBDatabasePruning { } } } + repository.saveChanges(); } while (latestAtStatesResultSet.next()); } @@ -161,8 +162,6 @@ public class HSQLDBDatabasePruning { } } - repository.saveChanges(); - // Finally, drop the original table and rename LOGGER.info("Deleting old AT states..."); From 889f6fc5fc9073b709f292f0c8bedab537cfa556 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 22:57:38 +0100 Subject: [PATCH 172/231] Add a "testnet-" prefix in filenames when creating or importing bootstraps on testnet, so that the two databases can be kept separate. --- src/main/java/org/qortal/repository/Bootstrap.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 0becf107..604e935d 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -344,15 +344,17 @@ public class Bootstrap { private String getFilename() { boolean isTopOnly = Settings.getInstance().isTopOnly(); boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); + boolean isTestnet = Settings.getInstance().isTestNet(); + String prefix = isTestnet ? "testnet-" : ""; if (isTopOnly) { - return "bootstrap-toponly.7z"; + return prefix.concat("bootstrap-toponly.7z"); } else if (archiveEnabled) { - return "bootstrap-archive.7z"; + return prefix.concat("bootstrap-archive.7z"); } else { - return "bootstrap-full.7z"; + return prefix.concat("bootstrap-full.7z"); } } From 5397e6c723bf1337e40ba84ec68d6b7764021317 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 3 Oct 2021 22:59:11 +0100 Subject: [PATCH 173/231] Bump version to 2.0.0-beta.1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 68f735ff..d6ed8368 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 2.0.0-beta.0 + 2.0.0-beta.1 jar true From 850d879726405838365300bb5e55d4af436eb8bf Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 4 Oct 2021 09:10:56 +0100 Subject: [PATCH 174/231] Use a "tmp" folder in the Qortal directory rather than a system generated temp folder. This avoids the need to move files between partitions, and we also can't assume that the system partition has enough space to do the extraction. --- .../java/org/qortal/repository/Bootstrap.java | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 604e935d..c7a0a262 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -21,6 +21,7 @@ import java.net.URL; import java.nio.file.*; import java.security.SecureRandom; import java.util.List; +import java.util.UUID; import java.util.concurrent.TimeoutException; import java.util.concurrent.locks.ReentrantLock; @@ -249,7 +250,7 @@ public class Bootstrap { LOGGER.info("Moving files to output directory..."); inputPath = Paths.get(Settings.getInstance().getRepositoryPath(), "bootstrap"); - outputPath = Paths.get(Files.createTempDirectory("qortal-bootstrap").toString(), "bootstrap"); + outputPath = Paths.get(this.createTempDirectory().toString(), "bootstrap"); // Move the db backup to a "bootstrap" folder in the root directory @@ -295,6 +296,7 @@ public class Bootstrap { if (outputPath != null) { FileUtils.deleteDirectory(outputPath.toFile()); } + this.deleteAllTempDirectories(); } } @@ -319,7 +321,7 @@ public class Bootstrap { private void doImport() throws DataException { Path path = null; try { - Path tempDir = Files.createTempDirectory("qortal-bootstrap"); + Path tempDir = this.createTempDirectory(); String filename = String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), this.getFilename()); path = Paths.get(tempDir.toString(), filename); @@ -335,9 +337,10 @@ public class Bootstrap { Files.delete(path); } catch (IOException e) { - // Temp folder will be cleaned up by system anyway, so ignore this failure + // Temp folder will be cleaned up below, so ignore this failure } } + this.deleteAllTempDirectories(); } } @@ -416,6 +419,23 @@ public class Bootstrap { } } + private Path createTempDirectory() throws IOException { + String baseDir = Paths.get(".", "tmp").toAbsolutePath().toString(); + String identifier = UUID.randomUUID().toString(); + Path tempDir = Paths.get(baseDir, identifier); + Files.createDirectories(tempDir); + return tempDir; + } + + private void deleteAllTempDirectories() { + Path path = Paths.get(".", "tmp"); + try { + FileUtils.deleteDirectory(path.toFile()); + } catch (IOException e) { + LOGGER.info("Unable to delete temp directory path: {}", path.toString()); + } + } + private void updateStatus(String text) { LOGGER.info(text); SplashFrame.getInstance().updateStatus(text); From de3ebf664f10279702aee151d8b4d17183b2d5dc Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 4 Oct 2021 09:11:11 +0100 Subject: [PATCH 175/231] Fixed issue with format specifier --- src/main/java/org/qortal/repository/Bootstrap.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index c7a0a262..cb6961e2 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -375,7 +375,7 @@ public class Bootstrap { Files.copy(in, path, REPLACE_EXISTING); } catch (IOException e) { - throw new DataException(String.format("Unable to download bootstrap: {}", e.getMessage())); + throw new DataException(String.format("Unable to download bootstrap: %s", e.getMessage())); } } From 0135f25b9dd920165b849565360bd8e7c1c4f3c7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 4 Oct 2021 09:15:54 +0100 Subject: [PATCH 176/231] Delete existing repository before extracting bootstrap This limits the amount of additional space needed to the size of the compressed bootstrap (currently just under 4GB for full nodes, or 200MB for top-only nodes). --- src/main/java/org/qortal/repository/Bootstrap.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index cb6961e2..e72d253e 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -385,11 +385,6 @@ public class Bootstrap { blockchainLock.lockInterruptibly(); try { - this.updateStatus("Extracting bootstrap..."); - Path input = path.toAbsolutePath(); - Path output = path.toAbsolutePath().getParent().toAbsolutePath(); - SevenZ.decompress(input.toString(), output.toFile()); - this.updateStatus("Stopping repository..."); // Close the repository while we are still able to // Otherwise, the caller will run into difficulties when it tries to close it @@ -398,15 +393,22 @@ public class Bootstrap { // Now close the repository factory so that we can swap out the database files RepositoryManager.closeRepositoryFactory(); + this.updateStatus("Deleting existing repository..."); + Path input = path.toAbsolutePath(); + Path output = path.toAbsolutePath().getParent().toAbsolutePath(); Path inputPath = Paths.get(output.toString(), "bootstrap"); Path outputPath = Paths.get(Settings.getInstance().getRepositoryPath()); + FileUtils.deleteDirectory(outputPath.toFile()); + + this.updateStatus("Extracting bootstrap..."); + SevenZ.decompress(input.toString(), output.toFile()); + if (!inputPath.toFile().exists()) { throw new DataException("Extracted bootstrap doesn't exist"); } // Move the "bootstrap" folder in place of the "db" folder this.updateStatus("Moving files to output directory..."); - FileUtils.deleteDirectory(outputPath.toFile()); Files.move(inputPath, outputPath); this.updateStatus("Starting repository from bootstrap..."); From 71f802ef35102c1b4e20d33d8a40b70ed9a7b7d1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 4 Oct 2021 09:25:23 +0100 Subject: [PATCH 177/231] Exponentially backoff when bootstrapping fails, to reduce bandwidth The retry interval starts at 5 minutes and doubles with each failure. --- src/main/java/org/qortal/repository/Bootstrap.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index e72d253e..99de6d60 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -32,6 +32,8 @@ public class Bootstrap { private Repository repository; + private int retryMinutes = 5; + private static final Logger LOGGER = LogManager.getLogger(Bootstrap.class); /** The maximum number of untrimmed blocks allowed to be included in a bootstrap, beyond the trim threshold */ @@ -312,8 +314,9 @@ public class Bootstrap { } catch (DataException e) { LOGGER.info("Bootstrap import failed: {}", e.getMessage()); - this.updateStatus("Bootstrapping failed. Retrying in 5 minutes"); - Thread.sleep(5 * 60 * 1000L); + this.updateStatus(String.format("Bootstrapping failed. Retrying in %d minutes...", retryMinutes)); + Thread.sleep(retryMinutes * 60 * 1000L); + retryMinutes *= 2; } } } From 289dae07805ab4d3e6dea46f48fc4a919bcd1e3f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 4 Oct 2021 09:28:16 +0100 Subject: [PATCH 178/231] Fixed issue causing the local repository data backup to be overwritten with an empty list. --- src/main/java/org/qortal/block/BlockChain.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index a70476de..1c518c37 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -516,7 +516,6 @@ public class BlockChain { needsArchiveRebuild = (repository.getBlockArchiveRepository().fromHeight(2) == null); if (needsArchiveRebuild) { LOGGER.info("Couldn't retrieve block 2 from archive. Bootstrapping..."); - Controller.getInstance().exportRepositoryData(); } } } From 65dca36ae1a01c9958ba81327257964fc44e9878 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 4 Oct 2021 22:38:58 +0100 Subject: [PATCH 179/231] Show progress status when downloading a bootstrap --- .../java/org/qortal/repository/Bootstrap.java | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 99de6d60..aa464fe4 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -15,8 +15,11 @@ import org.qortal.settings.Settings; import org.qortal.utils.NTP; import org.qortal.utils.SevenZ; +import java.io.BufferedInputStream; +import java.io.FileOutputStream; import java.io.IOException; -import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; import java.net.URL; import java.nio.file.*; import java.security.SecureRandom; @@ -370,12 +373,47 @@ public class Bootstrap { int index = new SecureRandom().nextInt(hosts.length); String bootstrapHost = hosts[index]; String bootstrapFilename = this.getFilename(); + String bootstrapUrl = String.format("%s/%s", bootstrapHost, bootstrapFilename); + // Delete an existing file if it exists try { - this.updateStatus("Downloading bootstrap..."); - String bootstrapUrl = String.format("%s/%s", bootstrapHost, bootstrapFilename); - InputStream in = new URL(bootstrapUrl).openStream(); - Files.copy(in, path, REPLACE_EXISTING); + Files.delete(path); + } catch (IOException e) { + // No need to do anything + } + + // Get the total file size + URL url; + long fileSize; + try { + url = new URL(bootstrapUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("HEAD"); + connection.connect(); + fileSize = connection.getContentLengthLong(); + connection.disconnect(); + + } catch (MalformedURLException e) { + throw new DataException(String.format("Malformed URL when downloading bootstrap: %s", e.getMessage())); + } catch (IOException e) { + throw new DataException(String.format("Unable to download bootstrap: %s", e.getMessage())); + } + + // Download the file and update the status with progress + try (BufferedInputStream in = new BufferedInputStream(url.openStream()); + FileOutputStream fileOutputStream = new FileOutputStream(path.toFile())) { + byte[] buffer = new byte[1024 * 1024]; + long downloaded = 0; + int bytesRead; + while ((bytesRead = in.read(buffer, 0, 1024)) != -1) { + fileOutputStream.write(buffer, 0, bytesRead); + downloaded += bytesRead; + + if (fileSize > 0) { + int progress = (int)((double)downloaded / (double)fileSize * 100); + SplashFrame.getInstance().updateStatus(String.format("Downloading bootstrap... (%d%%)", progress)); + } + } } catch (IOException e) { throw new DataException(String.format("Unable to download bootstrap: %s", e.getMessage())); From ddf966d08c6992b3116aac347b5d0d76273d56d8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 4 Oct 2021 22:44:51 +0100 Subject: [PATCH 180/231] Show progress status when extracting files --- src/main/java/org/qortal/utils/SevenZ.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/utils/SevenZ.java b/src/main/java/org/qortal/utils/SevenZ.java index 2c812e99..e9a7d9f9 100644 --- a/src/main/java/org/qortal/utils/SevenZ.java +++ b/src/main/java/org/qortal/utils/SevenZ.java @@ -9,6 +9,7 @@ package org.qortal.utils; import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry; import org.apache.commons.compress.archivers.sevenz.SevenZFile; import org.apache.commons.compress.archivers.sevenz.SevenZOutputFile; +import org.qortal.gui.SplashFrame; import java.io.*; @@ -38,12 +39,19 @@ public class SevenZ { if (!parent.exists()) { parent.mkdirs(); } + long fileSize = entry.getSize(); FileOutputStream out = new FileOutputStream(curfile); - byte[] b = new byte[8192]; - int count = 0; + byte[] b = new byte[1024 * 1024]; + int count; + long downloaded = 0; + while ((count = sevenZFile.read(b)) > 0) { out.write(b, 0, count); + downloaded += count; + + int progress = (int)((double)downloaded / (double)fileSize * 100); + SplashFrame.getInstance().updateStatus(String.format("Extracting %s... (%d%%)", curfile.getName(), progress)); } out.close(); } From 27903f278d3fd2ee9fe6d36384ebb89b597bbe31 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 4 Oct 2021 22:45:05 +0100 Subject: [PATCH 181/231] Add tmp folder to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d68ffdbb..55b4f8d5 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ /run.log /WindowsInstaller/Install Files/qortal.jar /*.7z +/tmp From e07238ded8e929f3490c9be01a8cef59f313a6ce Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 4 Oct 2021 22:52:47 +0100 Subject: [PATCH 182/231] Fixed variable name --- src/main/java/org/qortal/utils/SevenZ.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/utils/SevenZ.java b/src/main/java/org/qortal/utils/SevenZ.java index e9a7d9f9..5126b292 100644 --- a/src/main/java/org/qortal/utils/SevenZ.java +++ b/src/main/java/org/qortal/utils/SevenZ.java @@ -44,13 +44,13 @@ public class SevenZ { FileOutputStream out = new FileOutputStream(curfile); byte[] b = new byte[1024 * 1024]; int count; - long downloaded = 0; + long extracted = 0; while ((count = sevenZFile.read(b)) > 0) { out.write(b, 0, count); - downloaded += count; + extracted += count; - int progress = (int)((double)downloaded / (double)fileSize * 100); + int progress = (int)((double)extracted / (double)fileSize * 100); SplashFrame.getInstance().updateStatus(String.format("Extracting %s... (%d%%)", curfile.getName(), progress)); } out.close(); From 0d6409098f013e12adebdd0a0e5df3490f88b1e6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 5 Oct 2021 22:08:18 +0100 Subject: [PATCH 183/231] Added another bootstrap host --- src/main/java/org/qortal/settings/Settings.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 414ea3ed..3d39211a 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -196,7 +196,8 @@ public class Settings { // Bootstrap sources private String[] bootstrapHosts = new String[] { - "http://bootstrap.qortal.org" + "http://bootstrap.qortal.org", + "http://cinfu1.crowetic.com" }; // Auto-update sources From b7e9af100ace24d33fe18b341522ee962ca410d0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 6 Oct 2021 08:52:27 +0100 Subject: [PATCH 184/231] Added scheduled repository maintenance feature. Currently disabled by default. --- .../org/qortal/controller/Controller.java | 24 +++++++++++++++++++ .../java/org/qortal/settings/Settings.java | 12 ++++++++++ src/main/resources/i18n/SysTray_en.properties | 6 ++++- src/main/resources/i18n/SysTray_fi.properties | 6 ++++- src/main/resources/i18n/SysTray_hu.properties | 6 ++++- src/main/resources/i18n/SysTray_it.properties | 6 ++++- src/main/resources/i18n/SysTray_nl.properties | 4 +++- src/main/resources/i18n/SysTray_ru.properties | 6 ++++- .../resources/i18n/SysTray_zh_CN.properties | 4 +++- 9 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index c3ccde35..8043ea07 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -155,6 +155,7 @@ public class Controller extends Thread { }; private long repositoryBackupTimestamp = startTime; // ms + private long repositoryMaintenanceTimestamp = startTime; // ms private long repositoryCheckpointTimestamp = startTime; // ms private long ntpCheckTimestamp = startTime; // ms private long deleteExpiredTimestamp = startTime + DELETE_EXPIRED_INTERVAL; // ms @@ -524,6 +525,7 @@ public class Controller extends Thread { Thread.currentThread().setName("Controller"); final long repositoryBackupInterval = Settings.getInstance().getRepositoryBackupInterval(); + final long repositoryMaintenanceInterval = Settings.getInstance().getRepositoryMaintenanceInterval(); final long repositoryCheckpointInterval = Settings.getInstance().getRepositoryCheckpointInterval(); // Start executor service for trimming or pruning @@ -595,6 +597,28 @@ public class Controller extends Thread { } } + // Give repository a chance to perform maintenance (if enabled) + if (repositoryMaintenanceInterval > 0 && now >= repositoryMaintenanceTimestamp + repositoryMaintenanceInterval) { + repositoryMaintenanceTimestamp = now + repositoryMaintenanceInterval; + + if (Settings.getInstance().getShowMaintenanceNotification()) + SysTray.getInstance().showMessage(Translator.INSTANCE.translate("SysTray", "DB_MAINTENANCE"), + Translator.INSTANCE.translate("SysTray", "PERFORMING_DB_MAINTENANCE"), + MessageType.INFO); + + LOGGER.info("Starting scheduled repository maintenance. This can take a while..."); + try (final Repository repository = RepositoryManager.getRepository()) { + + // Timeout if the database isn't ready for maintenance after 60 seconds + long timeout = 60 * 1000L; + repository.performPeriodicMaintenance(timeout); + + LOGGER.info("Scheduled repository maintenance completed"); + } catch (DataException | TimeoutException e) { + LOGGER.error("Scheduled repository maintenance failed", e); + } + } + // Prune stuck/slow/old peers try { Network.getInstance().prunePeers(); diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 3d39211a..30f2cd23 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -89,6 +89,10 @@ public class Settings { private long repositoryBackupInterval = 0; // ms /** Whether to show a notification when we backup repository. */ private boolean showBackupNotification = false; + /** How long between repository maintenance attempts (ms), or 0 if disabled. */ + private long repositoryMaintenanceInterval = 0; // ms + /** Whether to show a notification when we run scheduled maintenance. */ + private boolean showMaintenanceNotification = false; /** How long between repository checkpoints (ms). */ private long repositoryCheckpointInterval = 60 * 60 * 1000L; // 1 hour (ms) default /** Whether to show a notification when we perform repository 'checkpoint'. */ @@ -558,6 +562,14 @@ public class Settings { return this.showBackupNotification; } + public long getRepositoryMaintenanceInterval() { + return this.repositoryMaintenanceInterval; + } + + public boolean getShowMaintenanceNotification() { + return this.showMaintenanceNotification; + } + public long getRepositoryCheckpointInterval() { return this.repositoryCheckpointInterval; } diff --git a/src/main/resources/i18n/SysTray_en.properties b/src/main/resources/i18n/SysTray_en.properties index ddaf19ab..07541339 100644 --- a/src/main/resources/i18n/SysTray_en.properties +++ b/src/main/resources/i18n/SysTray_en.properties @@ -21,6 +21,8 @@ CREATING_BACKUP_OF_DB_FILES = Creating backup of database files... DB_BACKUP = Database Backup +DB_MAINTENANCE = Database Maintenance + DB_CHECKPOINT = Database Checkpoint EXIT = Exit @@ -33,8 +35,10 @@ OPEN_UI = Open UI PERFORMING_DB_CHECKPOINT = Saving uncommitted database changes... +PERFORMING_DB_MAINTENANCE = Performing scheduled maintenance... + SYNCHRONIZE_CLOCK = Synchronize clock SYNCHRONIZING_BLOCKCHAIN = Synchronizing -SYNCHRONIZING_CLOCK = Synchronizing clock \ No newline at end of file +SYNCHRONIZING_CLOCK = Synchronizing clock diff --git a/src/main/resources/i18n/SysTray_fi.properties b/src/main/resources/i18n/SysTray_fi.properties index 307dd80c..edd062bc 100644 --- a/src/main/resources/i18n/SysTray_fi.properties +++ b/src/main/resources/i18n/SysTray_fi.properties @@ -21,6 +21,8 @@ CREATING_BACKUP_OF_DB_FILES = Luodaan varmuuskopio tietokannan tiedostoista... DB_BACKUP = Tietokannan varmuuskopio +DB_MAINTENANCE = Database Maintenance + DB_CHECKPOINT = Tietokannan varmistuspiste EXIT = Pois @@ -33,8 +35,10 @@ OPEN_UI = Avaa UI PERFORMING_DB_CHECKPOINT = Tallentaa kommittoidut tietokantamuutokset... +PERFORMING_DB_MAINTENANCE = Performing scheduled maintenance... + SYNCHRONIZE_CLOCK = Synkronisoi kello SYNCHRONIZING_BLOCKCHAIN = Synkronisoi -SYNCHRONIZING_CLOCK = Synkronisoi kelloa \ No newline at end of file +SYNCHRONIZING_CLOCK = Synkronisoi kelloa diff --git a/src/main/resources/i18n/SysTray_hu.properties b/src/main/resources/i18n/SysTray_hu.properties index 63bec91f..be4bef25 100644 --- a/src/main/resources/i18n/SysTray_hu.properties +++ b/src/main/resources/i18n/SysTray_hu.properties @@ -23,6 +23,8 @@ CREATING_BACKUP_OF_DB_FILES = Adatbázis fájlok biztonsági mentésének létre DB_BACKUP = Adatbázis biztonsági mentése +DB_MAINTENANCE = Database Maintenance + DB_CHECKPOINT = Adatbázis-ellenőrzőpont EXIT = Kilépés @@ -35,8 +37,10 @@ OPEN_UI = Felhasználói eszköz megnyitása PERFORMING_DB_CHECKPOINT = Mentetlen adatbázis-módosítások mentése... +PERFORMING_DB_MAINTENANCE = Performing scheduled maintenance... + SYNCHRONIZE_CLOCK = Óra-szinkronizálás megkezdése SYNCHRONIZING_BLOCKCHAIN = Szinkronizálás -SYNCHRONIZING_CLOCK = Óra-szinkronizálás folyamatban \ No newline at end of file +SYNCHRONIZING_CLOCK = Óra-szinkronizálás folyamatban diff --git a/src/main/resources/i18n/SysTray_it.properties b/src/main/resources/i18n/SysTray_it.properties index a2d2dac8..326c71c2 100644 --- a/src/main/resources/i18n/SysTray_it.properties +++ b/src/main/resources/i18n/SysTray_it.properties @@ -22,6 +22,8 @@ CREATING_BACKUP_OF_DB_FILES = Creazione di backup dei file di database... DB_BACKUP = Backup del database +DB_MAINTENANCE = Database Maintenance + DB_CHECKPOINT = Punto di controllo del database EXIT = Uscita @@ -34,8 +36,10 @@ OPEN_UI = Apri UI PERFORMING_DB_CHECKPOINT = Salvataggio delle modifiche al database non salvate... +PERFORMING_DB_MAINTENANCE = Performing scheduled maintenance... + SYNCHRONIZE_CLOCK = Sincronizza orologio SYNCHRONIZING_BLOCKCHAIN = Sincronizzando -SYNCHRONIZING_CLOCK = Sincronizzando orologio \ No newline at end of file +SYNCHRONIZING_CLOCK = Sincronizzando orologio diff --git a/src/main/resources/i18n/SysTray_nl.properties b/src/main/resources/i18n/SysTray_nl.properties index 8b7c85eb..ddf1527f 100644 --- a/src/main/resources/i18n/SysTray_nl.properties +++ b/src/main/resources/i18n/SysTray_nl.properties @@ -33,8 +33,10 @@ OPEN_UI = Open UI PERFORMING_DB_CHECKPOINT = Nieuwe veranderingen aan database worden opgeslagen... +PERFORMING_DB_MAINTENANCE = Performing scheduled maintenance... + SYNCHRONIZE_CLOCK = Synchronizeer klok SYNCHRONIZING_BLOCKCHAIN = Aan het synchronizeren -SYNCHRONIZING_CLOCK = Klok wordt gesynchronizeerd \ No newline at end of file +SYNCHRONIZING_CLOCK = Klok wordt gesynchronizeerd diff --git a/src/main/resources/i18n/SysTray_ru.properties b/src/main/resources/i18n/SysTray_ru.properties index 1a719164..c124b500 100644 --- a/src/main/resources/i18n/SysTray_ru.properties +++ b/src/main/resources/i18n/SysTray_ru.properties @@ -21,6 +21,8 @@ CREATING_BACKUP_OF_DB_FILES = Создание резервной копии ф DB_BACKUP = Резервное копирование базы данных +DB_MAINTENANCE = Database Maintenance + EXIT = Выход MINTING_DISABLED = Чеканка отключена @@ -31,8 +33,10 @@ OPEN_UI = Открыть пользовательский интерфейс PERFORMING_DB_CHECKPOINT = Saving uncommitted database changes... +PERFORMING_DB_MAINTENANCE = Performing scheduled maintenance... + SYNCHRONIZE_CLOCK = Синхронизировать время SYNCHRONIZING_BLOCKCHAIN = Синхронизация цепи -SYNCHRONIZING_CLOCK = Проверка времени \ No newline at end of file +SYNCHRONIZING_CLOCK = Проверка времени diff --git a/src/main/resources/i18n/SysTray_zh_CN.properties b/src/main/resources/i18n/SysTray_zh_CN.properties index eaea452b..6d8318e2 100644 --- a/src/main/resources/i18n/SysTray_zh_CN.properties +++ b/src/main/resources/i18n/SysTray_zh_CN.properties @@ -33,8 +33,10 @@ OPEN_UI = 开启Qortal界面 PERFORMING_DB_CHECKPOINT = Saving uncommitted database changes... +PERFORMING_DB_MAINTENANCE = Performing scheduled maintenance... + SYNCHRONIZE_CLOCK = 同步时钟 SYNCHRONIZING_BLOCKCHAIN = 正在同步区块链 -SYNCHRONIZING_CLOCK = 正在同步时钟 \ No newline at end of file +SYNCHRONIZING_CLOCK = 正在同步时钟 From cd359de7eb084d4ed2ffd972c3c28a5e32e24823 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 6 Oct 2021 18:22:31 +0100 Subject: [PATCH 185/231] Scheduled maintenance now enabled by default, but uses a min and a max, to reduce the chances of multiple nodes running maintenance at the same time. Default to min: 7 days, max: 30 days. --- .../java/org/qortal/controller/Controller.java | 14 +++++++++++++- src/main/java/org/qortal/settings/Settings.java | 14 ++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 8043ea07..12b27c3a 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -525,8 +525,8 @@ public class Controller extends Thread { Thread.currentThread().setName("Controller"); final long repositoryBackupInterval = Settings.getInstance().getRepositoryBackupInterval(); - final long repositoryMaintenanceInterval = Settings.getInstance().getRepositoryMaintenanceInterval(); final long repositoryCheckpointInterval = Settings.getInstance().getRepositoryCheckpointInterval(); + long repositoryMaintenanceInterval = getRandomRepositoryMaintenanceInterval(); // Start executor service for trimming or pruning PruneManager.getInstance().start(); @@ -617,6 +617,9 @@ public class Controller extends Thread { } catch (DataException | TimeoutException e) { LOGGER.error("Scheduled repository maintenance failed", e); } + + // Get a new random interval + repositoryMaintenanceInterval = getRandomRepositoryMaintenanceInterval(); } // Prune stuck/slow/old peers @@ -676,6 +679,15 @@ public class Controller extends Thread { } } + private long getRandomRepositoryMaintenanceInterval() { + final long minInterval = Settings.getInstance().getRepositoryMaintenanceMinInterval(); + final long maxInterval = Settings.getInstance().getRepositoryMaintenanceMaxInterval(); + if (maxInterval == 0) { + return 0; + } + return (new Random().nextLong() % (maxInterval - minInterval)) + minInterval; + } + /** * Export current trade bot states and minting accounts. */ diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 30f2cd23..907e19ed 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -89,8 +89,10 @@ public class Settings { private long repositoryBackupInterval = 0; // ms /** Whether to show a notification when we backup repository. */ private boolean showBackupNotification = false; - /** How long between repository maintenance attempts (ms), or 0 if disabled. */ - private long repositoryMaintenanceInterval = 0; // ms + /** Minimum time between repository maintenance attempts (ms) */ + private long repositoryMaintenanceMinInterval = 7 * 24 * 60 * 60 * 1000L; // 7 days (ms) default + /** Maximum time between repository maintenance attempts (ms) (0 if disabled). */ + private long repositoryMaintenanceMaxInterval = 30 * 24 * 60 * 60 * 1000L; // 30 days (ms) default /** Whether to show a notification when we run scheduled maintenance. */ private boolean showMaintenanceNotification = false; /** How long between repository checkpoints (ms). */ @@ -562,8 +564,12 @@ public class Settings { return this.showBackupNotification; } - public long getRepositoryMaintenanceInterval() { - return this.repositoryMaintenanceInterval; + public long getRepositoryMaintenanceMinInterval() { + return this.repositoryMaintenanceMinInterval; + } + + public long getRepositoryMaintenanceMaxInterval() { + return this.repositoryMaintenanceMaxInterval; } public boolean getShowMaintenanceNotification() { From 9bcd0bbfac1434623e766e98f7a11c4c473b7384 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 6 Oct 2021 18:22:38 +0100 Subject: [PATCH 186/231] Reduce log spam --- .../java/org/qortal/repository/hsqldb/HSQLDBRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index a9f1a7f8..1c025ae2 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -1050,7 +1050,7 @@ public class HSQLDBRepository implements Repository { long startTime = System.currentTimeMillis(); while (this.otherTransactionsCount() > 0) { // Wait and try again - LOGGER.info("Waiting for repository..."); + LOGGER.debug("Waiting for repository..."); Thread.sleep(1000L); if (timeout != null) { From 2f6a8f793b7125b88a30852ca3b4216b26a2e34c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 6 Oct 2021 18:22:52 +0100 Subject: [PATCH 187/231] Invert the colours in the splash screen --- src/main/java/org/qortal/gui/SplashFrame.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/gui/SplashFrame.java b/src/main/java/org/qortal/gui/SplashFrame.java index 4e51776c..c4ea51d0 100644 --- a/src/main/java/org/qortal/gui/SplashFrame.java +++ b/src/main/java/org/qortal/gui/SplashFrame.java @@ -34,7 +34,7 @@ public class SplashFrame { setOpaque(true); setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); setBorder(new EmptyBorder(10, 10, 10, 10)); - setBackground(new Color(255, 255, 255)); + setBackground(Color.BLACK); // Add logo JLabel imageLabel = new JLabel(new ImageIcon(image)); @@ -49,7 +49,8 @@ public class SplashFrame { statusLabel = new JLabel(text, JLabel.CENTER); statusLabel.setMaximumSize(new Dimension(500, 50)); statusLabel.setFont(new Font("Verdana", Font.PLAIN, 20)); - statusLabel.setBackground(new Color(255, 255, 255)); + statusLabel.setBackground(Color.BLACK); + statusLabel.setForeground(new Color(255, 255, 255, 255)); statusLabel.setOpaque(true); statusLabel.setBorder(null); add(statusLabel); @@ -90,7 +91,7 @@ public class SplashFrame { this.splashDialog.setUndecorated(true); this.splashDialog.pack(); this.splashDialog.setLocationRelativeTo(null); - this.splashDialog.setBackground(new Color(0,0,0,0)); + this.splashDialog.setBackground(Color.BLACK); this.splashDialog.setVisible(true); } From 8d6dffb3ff0678030fb7b5f6b999915e4fc29dd3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 6 Oct 2021 18:23:17 +0100 Subject: [PATCH 188/231] Added test for bootstrap random host selection. --- .../java/org/qortal/repository/Bootstrap.java | 13 +++++++---- .../java/org/qortal/test/BootstrapTests.java | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index aa464fe4..311c6be4 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -368,10 +368,7 @@ public class Bootstrap { } private void downloadToPath(Path path) throws DataException { - // Select a random host from bootstrapHosts - String[] hosts = Settings.getInstance().getBootstrapHosts(); - int index = new SecureRandom().nextInt(hosts.length); - String bootstrapHost = hosts[index]; + String bootstrapHost = this.getRandomHost(); String bootstrapFilename = this.getFilename(); String bootstrapUrl = String.format("%s/%s", bootstrapHost, bootstrapFilename); @@ -420,6 +417,14 @@ public class Bootstrap { } } + public String getRandomHost() { + // Select a random host from bootstrapHosts + String[] hosts = Settings.getInstance().getBootstrapHosts(); + int index = new SecureRandom().nextInt(hosts.length); + String bootstrapHost = hosts[index]; + return bootstrapHost; + } + public void importFromPath(Path path) throws InterruptedException, DataException, IOException { ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); diff --git a/src/test/java/org/qortal/test/BootstrapTests.java b/src/test/java/org/qortal/test/BootstrapTests.java index a15311f5..cc29794c 100644 --- a/src/test/java/org/qortal/test/BootstrapTests.java +++ b/src/test/java/org/qortal/test/BootstrapTests.java @@ -22,6 +22,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import static org.junit.Assert.*; @@ -181,6 +182,28 @@ public class BootstrapTests extends Common { repository.saveChanges(); } + @Test + public void testGetRandomHost() { + String[] bootstrapHosts = Settings.getInstance().getBootstrapHosts(); + List uniqueHosts = new ArrayList<>(); + + for (int i=0; i<1000; i++) { + Bootstrap bootstrap = new Bootstrap(); + String randomHost = bootstrap.getRandomHost(); + assertNotNull(randomHost); + + if (!uniqueHosts.contains(randomHost)){ + uniqueHosts.add(randomHost); + } + } + + // Ensure we have more than one bootstrap host in the settings + assertTrue(Arrays.asList(bootstrapHosts).size() > 1); + + // Ensure that all have been given the opportunity to be used + assertEquals(uniqueHosts.size(), Arrays.asList(bootstrapHosts).size()); + } + private void deleteBootstraps() throws IOException { try { Path path = Paths.get(String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), "bootstrap-archive.7z")); From 81bf79e9d388ea406eaa460f59080e982b829ccb Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 6 Oct 2021 18:23:51 +0100 Subject: [PATCH 189/231] Bump version to 2.0.0-beta.2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index d6ed8368..ca39725d 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 2.0.0-beta.1 + 2.0.0-beta.2 jar true From c82293342fb92f8779ae97ff15feadc74efa5915 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 6 Oct 2021 19:32:49 +0100 Subject: [PATCH 190/231] Show full exception stack trace when a bootstrap import fails --- src/main/java/org/qortal/repository/Bootstrap.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 311c6be4..d7186e01 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -335,7 +335,7 @@ public class Bootstrap { this.importFromPath(path); } catch (InterruptedException | DataException | IOException e) { - throw new DataException(String.format("Unable to import bootstrap: %s", e.getMessage())); + throw new DataException("Unable to import bootstrap", e); } finally { if (path != null) { From 179bd8e018ef47bfdf0595794ed2ae4cf0e67c98 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 6 Oct 2021 19:44:04 +0100 Subject: [PATCH 191/231] Moved repository reopen to the finally {} block, so that we're never left without a repository instance. Should fix occasional "No repository available" error seen when retrying. --- src/main/java/org/qortal/repository/Bootstrap.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index d7186e01..718d81cd 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -458,11 +458,11 @@ public class Bootstrap { Files.move(inputPath, outputPath); this.updateStatus("Starting repository from bootstrap..."); + } + finally { RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl()); RepositoryManager.setRepositoryFactory(repositoryFactory); - } - finally { blockchainLock.unlock(); } } From 7105872a37d026b69928332c3f25832fa64eb208 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 6 Oct 2021 19:44:30 +0100 Subject: [PATCH 192/231] Improved exception message --- src/main/java/org/qortal/repository/Bootstrap.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 718d81cd..53baa349 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -393,7 +393,7 @@ public class Bootstrap { } catch (MalformedURLException e) { throw new DataException(String.format("Malformed URL when downloading bootstrap: %s", e.getMessage())); } catch (IOException e) { - throw new DataException(String.format("Unable to download bootstrap: %s", e.getMessage())); + throw new DataException(String.format("Unable to get bootstrap file size: %s", e.getMessage())); } // Download the file and update the status with progress From 4dff91a0e5f016e825c48bce8b447be38915e346 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 6 Oct 2021 19:45:18 +0100 Subject: [PATCH 193/231] Initial bootstrap import retry interval reduced from 5 minutes to 1 minute --- src/main/java/org/qortal/repository/Bootstrap.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 53baa349..18dab55f 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -35,7 +35,7 @@ public class Bootstrap { private Repository repository; - private int retryMinutes = 5; + private int retryMinutes = 1; private static final Logger LOGGER = LogManager.getLogger(Bootstrap.class); From 045026431bb3c154819b41b7eb9bc4f00b8aa445 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 6 Oct 2021 19:50:32 +0100 Subject: [PATCH 194/231] Create a cleaner base directory path, without the "/./" --- src/main/java/org/qortal/repository/Bootstrap.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 18dab55f..db6f6fb4 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -468,7 +468,7 @@ public class Bootstrap { } private Path createTempDirectory() throws IOException { - String baseDir = Paths.get(".", "tmp").toAbsolutePath().toString(); + String baseDir = Paths.get(".", "tmp").toFile().getCanonicalPath(); String identifier = UUID.randomUUID().toString(); Path tempDir = Paths.get(baseDir, identifier); Files.createDirectories(tempDir); From 5a55ef64c47bc04860d26cfcbf2a05ae611c3ec4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 6 Oct 2021 19:51:33 +0100 Subject: [PATCH 195/231] Bump version to 2.0.0-beta.3 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index ca39725d..3659cfc4 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 2.0.0-beta.2 + 2.0.0-beta.3 jar true From c256dae73660038d650935a23070d5c66293dd84 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 7 Oct 2021 09:02:13 +0100 Subject: [PATCH 196/231] Ensure that the temp directory is always in the parent directory of the db folder. --- src/main/java/org/qortal/repository/Bootstrap.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index db6f6fb4..53078f8e 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -468,7 +468,8 @@ public class Bootstrap { } private Path createTempDirectory() throws IOException { - String baseDir = Paths.get(".", "tmp").toFile().getCanonicalPath(); + Path initialPath = Paths.get(Settings.getInstance().getRepositoryPath()).toAbsolutePath().getParent(); + String baseDir = Paths.get(initialPath.toString(), "tmp").toFile().getCanonicalPath(); String identifier = UUID.randomUUID().toString(); Path tempDir = Paths.get(baseDir, identifier); Files.createDirectories(tempDir); @@ -476,7 +477,8 @@ public class Bootstrap { } private void deleteAllTempDirectories() { - Path path = Paths.get(".", "tmp"); + Path initialPath = Paths.get(Settings.getInstance().getRepositoryPath()).toAbsolutePath().getParent(); + Path path = Paths.get(initialPath.toString(), "tmp"); try { FileUtils.deleteDirectory(path.toFile()); } catch (IOException e) { From ab7d24b63790dde979d49508da8a6624bffbfea8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 7 Oct 2021 09:02:28 +0100 Subject: [PATCH 197/231] Updated status wording --- .../org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java index 06645ca6..77136ab9 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseArchiving.java @@ -41,7 +41,7 @@ public class HSQLDBDatabaseArchiving { } LOGGER.info("Building block archive - this process could take a while... (approx. 15 mins on high spec)"); - SplashFrame.getInstance().updateStatus("Building block archive (takes up to 60 mins)..."); + SplashFrame.getInstance().updateStatus("Building block archive (takes 60+ mins)..."); final int maximumArchiveHeight = BlockArchiveWriter.getMaxArchiveHeight(repository); int startHeight = 0; From bfaf4c58e4545d8df67752535a0723fff52e3d4b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 7 Oct 2021 18:50:25 +0100 Subject: [PATCH 198/231] Make sure to check the archive when serving block summaries and signatures --- .../org/qortal/controller/Controller.java | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 12b27c3a..839e2013 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1538,6 +1538,10 @@ public class Controller extends Thread { int numberRequested = Math.min(Network.MAX_BLOCK_SUMMARIES_PER_REPLY, getBlockSummariesMessage.getNumberRequested()); BlockData blockData = repository.getBlockRepository().fromReference(parentSignature); + if (blockData == null) { + // Try the archive + blockData = repository.getBlockArchiveRepository().fromReference(parentSignature); + } if (blockData != null) { if (PruneManager.getInstance().isBlockPruned(blockData.getHeight(), repository)) { @@ -1551,7 +1555,12 @@ public class Controller extends Thread { BlockSummaryData blockSummary = new BlockSummaryData(blockData); blockSummaries.add(blockSummary); - blockData = repository.getBlockRepository().fromReference(blockData.getSignature()); + byte[] previousSignature = blockData.getSignature(); + blockData = repository.getBlockRepository().fromReference(previousSignature); + if (blockData == null) { + // Try the archive + blockData = repository.getBlockArchiveRepository().fromReference(previousSignature); + } } } catch (DataException e) { LOGGER.error(String.format("Repository issue while sending block summaries after %s to peer %s", Base58.encode(parentSignature), peer), e); @@ -1600,11 +1609,20 @@ public class Controller extends Thread { try (final Repository repository = RepositoryManager.getRepository()) { int numberRequested = getSignaturesMessage.getNumberRequested(); BlockData blockData = repository.getBlockRepository().fromReference(parentSignature); + if (blockData == null) { + // Try the archive + blockData = repository.getBlockArchiveRepository().fromReference(parentSignature); + } while (blockData != null && signatures.size() < numberRequested) { signatures.add(blockData.getSignature()); - blockData = repository.getBlockRepository().fromReference(blockData.getSignature()); + byte[] previousSignature = blockData.getSignature(); + blockData = repository.getBlockRepository().fromReference(previousSignature); + if (blockData == null) { + // Try the archive + blockData = repository.getBlockArchiveRepository().fromReference(previousSignature); + } } } catch (DataException e) { LOGGER.error(String.format("Repository issue while sending V2 signatures after %s to peer %s", Base58.encode(parentSignature), peer), e); From dff9ec0704a828bf012fa4e5dc69f35dddcde910 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 7 Oct 2021 18:50:59 +0100 Subject: [PATCH 199/231] Don't attempt to cache blocks from the archive, as they will never be recent --- src/main/java/org/qortal/controller/Controller.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 839e2013..c9fdd8c2 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1405,13 +1405,6 @@ public class Controller extends Thread { return; } - // If request is for a recent block, cache it - if (getChainHeight() - blockData.getHeight() <= blockCacheSize) { - this.stats.getBlockMessageStats.cacheFills.incrementAndGet(); - - this.blockMessageCache.put(new ByteArray(blockData.getSignature()), blockMessage); - } - // Sent successfully from archive, so nothing more to do return; } From f6effbb6bbfdc4d74e7c3906e8ef3c618d4906a5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 7 Oct 2021 18:51:52 +0100 Subject: [PATCH 200/231] Removed unnecessary repository parameter from PruneManager.isBlockPruned() --- src/main/java/org/qortal/controller/Controller.java | 4 ++-- .../java/org/qortal/controller/repository/PruneManager.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index c9fdd8c2..1434b24f 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1384,7 +1384,7 @@ public class Controller extends Thread { BlockData blockData = repository.getBlockRepository().fromSignature(signature); if (blockData != null) { - if (PruneManager.getInstance().isBlockPruned(blockData.getHeight(), repository)) { + if (PruneManager.getInstance().isBlockPruned(blockData.getHeight())) { // If this is a pruned block, we likely only have partial data, so best not to sent it blockData = null; } @@ -1537,7 +1537,7 @@ public class Controller extends Thread { } if (blockData != null) { - if (PruneManager.getInstance().isBlockPruned(blockData.getHeight(), repository)) { + if (PruneManager.getInstance().isBlockPruned(blockData.getHeight())) { // If this request contains a pruned block, we likely only have partial data, so best not to sent anything // We always prune from the oldest first, so it's fine to just check the first block requested blockData = null; diff --git a/src/main/java/org/qortal/controller/repository/PruneManager.java b/src/main/java/org/qortal/controller/repository/PruneManager.java index 89f11644..5202d5c1 100644 --- a/src/main/java/org/qortal/controller/repository/PruneManager.java +++ b/src/main/java/org/qortal/controller/repository/PruneManager.java @@ -136,7 +136,7 @@ public class PruneManager { } } - public boolean isBlockPruned(int height, Repository repository) throws DataException { + public boolean isBlockPruned(int height) throws DataException { if (!this.isTopOnly) { return false; } From abab2d1cde209e6a052c10b69fc9bb9a30af50dd Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 8 Oct 2021 12:22:21 +0100 Subject: [PATCH 201/231] Fixed issue preventing blocks from being served from the archive. Now prefixing the byte buffer with the block height to mimic a cached block message. --- .../qortal/api/resource/BlocksResource.java | 2 +- .../org/qortal/controller/Controller.java | 2 +- .../qortal/repository/BlockArchiveReader.java | 25 ++++++++++++++----- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/BlocksResource.java b/src/main/java/org/qortal/api/resource/BlocksResource.java index b9ffe03c..b8163c7d 100644 --- a/src/main/java/org/qortal/api/resource/BlocksResource.java +++ b/src/main/java/org/qortal/api/resource/BlocksResource.java @@ -140,7 +140,7 @@ public class BlocksResource { } // Not found, so try the block archive - byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, repository); + byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, false, repository); if (bytes != null) { return Base58.encode(bytes); } diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 1434b24f..c8943ded 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1393,7 +1393,7 @@ public class Controller extends Thread { // If we have no block data, we should check the archive in case it's there if (blockData == null) { if (Settings.getInstance().isArchiveEnabled()) { - byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, repository); + byte[] bytes = BlockArchiveReader.getInstance().fetchSerializedBlockBytesForSignature(signature, true, repository); if (bytes != null) { CachedBlockMessage blockMessage = new CachedBlockMessage(bytes); blockMessage.setId(message.getId()); diff --git a/src/main/java/org/qortal/repository/BlockArchiveReader.java b/src/main/java/org/qortal/repository/BlockArchiveReader.java index c173b6f2..2621bade 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveReader.java +++ b/src/main/java/org/qortal/repository/BlockArchiveReader.java @@ -1,5 +1,6 @@ package org.qortal.repository; +import com.google.common.primitives.Ints; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.data.at.ATStateData; @@ -13,10 +14,7 @@ import org.qortal.utils.Triple; import static org.qortal.transform.Transformer.INT_LENGTH; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.RandomAccessFile; +import java.io.*; import java.nio.ByteBuffer; import java.nio.file.Path; import java.nio.file.Paths; @@ -167,7 +165,7 @@ public class BlockArchiveReader { return null; } - public byte[] fetchSerializedBlockBytesForSignature(byte[] signature, Repository repository) { + public byte[] fetchSerializedBlockBytesForSignature(byte[] signature, boolean includeHeightPrefix, Repository repository) { if (this.fileListCache.isEmpty()) { this.fetchFileList(); @@ -175,7 +173,22 @@ public class BlockArchiveReader { Integer height = this.fetchHeightForSignature(signature, repository); if (height != null) { - return this.fetchSerializedBlockBytesForHeight(height); + byte[] blockBytes = this.fetchSerializedBlockBytesForHeight(height); + + // When responding to a peer with a BLOCK message, we must prefix the byte array with the block height + // This mimics the toData() method in BlockMessage and CachedBlockMessage + if (includeHeightPrefix) { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(blockBytes.length + INT_LENGTH); + try { + bytes.write(Ints.toByteArray(height)); + bytes.write(blockBytes); + return bytes.toByteArray(); + + } catch (IOException e) { + return null; + } + } + return blockBytes; } return null; } From d00fce86d200576f6c43d5b986a132520163a2d4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 8 Oct 2021 12:42:23 +0100 Subject: [PATCH 202/231] Treat the genesis block as unpruned, as we leave this in the HSQLDB repository. --- .../java/org/qortal/controller/repository/PruneManager.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/controller/repository/PruneManager.java b/src/main/java/org/qortal/controller/repository/PruneManager.java index 5202d5c1..ec27456f 100644 --- a/src/main/java/org/qortal/controller/repository/PruneManager.java +++ b/src/main/java/org/qortal/controller/repository/PruneManager.java @@ -146,6 +146,11 @@ public class PruneManager { throw new DataException("Unable to determine chain tip when checking if a block is pruned"); } + if (height == 1) { + // We don't prune the genesis block + return false; + } + final int ourLatestHeight = chainTip.getHeight(); final int latestUnprunedHeight = ourLatestHeight - this.pruneBlockLimit; From dedc8d89c7580dbc296ab2d932d702135cd75517 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 8 Oct 2021 12:51:02 +0100 Subject: [PATCH 203/231] Handle case when attempting to load a block from the archive by reference, but the referenced block is in the main block repository, not the archive. This is the case with the genesis block. Should fix issue where no block summaries were returned when syncing from block 1 --- .../repository/hsqldb/HSQLDBBlockArchiveRepository.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java index 32270213..46008c25 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockArchiveRepository.java @@ -71,6 +71,10 @@ public class HSQLDBBlockArchiveRepository implements BlockArchiveRepository { @Override public BlockData fromReference(byte[] reference) throws DataException { BlockData referenceBlock = this.repository.getBlockArchiveRepository().fromSignature(reference); + if (referenceBlock == null) { + // Try the main block repository. Needed for genesis block. + referenceBlock = this.repository.getBlockRepository().fromSignature(reference); + } if (referenceBlock != null) { int height = referenceBlock.getHeight(); if (height > 0) { From 1b17c2613dd5ade9aa025775f01be0e8065162ab Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 8 Oct 2021 13:12:47 +0100 Subject: [PATCH 204/231] Show "full node" or "top-only" in the "Downloading bootstrap" message. --- src/main/java/org/qortal/repository/Bootstrap.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 53078f8e..c7ef9a81 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -371,6 +371,9 @@ public class Bootstrap { String bootstrapHost = this.getRandomHost(); String bootstrapFilename = this.getFilename(); String bootstrapUrl = String.format("%s/%s", bootstrapHost, bootstrapFilename); + String type = Settings.getInstance().isTopOnly() ? "top-only" : "full node"; + + SplashFrame.getInstance().updateStatus(String.format("Downloading %s bootstrap...", type)); // Delete an existing file if it exists try { @@ -408,7 +411,7 @@ public class Bootstrap { if (fileSize > 0) { int progress = (int)((double)downloaded / (double)fileSize * 100); - SplashFrame.getInstance().updateStatus(String.format("Downloading bootstrap... (%d%%)", progress)); + SplashFrame.getInstance().updateStatus(String.format("Downloading %s bootstrap... (%d%%)", type, progress)); } } From 47ce884bbe7a520aeb4a529fe9fde673b0a29a95 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 8 Oct 2021 15:24:10 +0100 Subject: [PATCH 205/231] Delete all known peers when creating a bootstrap --- src/main/java/org/qortal/repository/Bootstrap.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index c7ef9a81..d68e206e 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -248,6 +248,10 @@ public class Bootstrap { repository.saveChanges(); + LOGGER.info("Deleting peers list..."); + repository.getNetworkRepository().deleteAllPeers(); + repository.saveChanges(); + LOGGER.info("Creating bootstrap..."); // Timeout if the database isn't ready for backing up after 10 seconds long timeout = 10 * 1000L; From a1e40476956ec2caaea73e3ebac6cadeb8512931 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 8 Oct 2021 18:06:41 +0100 Subject: [PATCH 206/231] Rework of bootstrap finalization process. --- .../java/org/qortal/repository/Bootstrap.java | 21 +++++++++++++------ .../java/org/qortal/test/BootstrapTests.java | 6 ++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index d68e206e..e3629cd1 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -273,18 +273,20 @@ public class Bootstrap { ); } - LOGGER.info("Compressing..."); - String compressedOutputPath = String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), this.getFilename()); + LOGGER.info("Preparing output path..."); + Path compressedOutputPath = this.getBootstrapOutputPath(); try { - Files.delete(Paths.get(compressedOutputPath)); + Files.delete(compressedOutputPath); } catch (NoSuchFileException e) { // Doesn't exist, so no need to delete } - SevenZ.compress(compressedOutputPath, outputPath.toFile()); + + LOGGER.info("Compressing..."); + SevenZ.compress(compressedOutputPath.toString(), outputPath.toFile()); // Return the path to the compressed bootstrap file - Path finalPath = Paths.get(compressedOutputPath); - return finalPath.toAbsolutePath().toString(); + LOGGER.info("Bootstrap creation complete. Output file: {}", compressedOutputPath.toAbsolutePath().toString()); + return compressedOutputPath.toAbsolutePath().toString(); } catch (TimeoutException e) { @@ -493,6 +495,13 @@ public class Bootstrap { } } + public Path getBootstrapOutputPath() { + Path initialPath = Paths.get(Settings.getInstance().getRepositoryPath()).toAbsolutePath().getParent(); + String compressedFilename = String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), this.getFilename()); + Path compressedOutputPath = Paths.get(initialPath.toString(), compressedFilename); + return compressedOutputPath; + } + private void updateStatus(String text) { LOGGER.info(text); SplashFrame.getInstance().updateStatus(text); diff --git a/src/test/java/org/qortal/test/BootstrapTests.java b/src/test/java/org/qortal/test/BootstrapTests.java index cc29794c..bc1512e2 100644 --- a/src/test/java/org/qortal/test/BootstrapTests.java +++ b/src/test/java/org/qortal/test/BootstrapTests.java @@ -68,7 +68,6 @@ public class BootstrapTests extends Common { @Test public void testCreateAndImportBootstrap() throws DataException, InterruptedException, TransformationException, IOException { - Path bootstrapPath = Paths.get(String.format("%s%s", Settings.getInstance().getBootstrapFilenamePrefix(), "bootstrap-archive.7z")); Path archivePath = Paths.get(Settings.getInstance().getRepositoryPath(), "archive", "2-900.dat"); BlockData block1000; byte[] originalArchiveContents; @@ -76,10 +75,13 @@ public class BootstrapTests extends Common { try (final Repository repository = RepositoryManager.getRepository()) { this.buildDummyBlockchain(repository); + Bootstrap bootstrap = new Bootstrap(repository); + Path bootstrapPath = bootstrap.getBootstrapOutputPath(); + // Ensure the compressed bootstrap doesn't exist assertFalse(Files.exists(bootstrapPath)); - Bootstrap bootstrap = new Bootstrap(repository); + // Create bootstrap bootstrap.create(); // Ensure the compressed bootstrap exists From f53e2ffa47043ce6783093d9ffd922850f08c426 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 8 Oct 2021 19:10:02 +0100 Subject: [PATCH 207/231] Add initial peers on node startup if we don't have any in the repository. This will be needed for future bootstraps, which don't contain any peers. It is also useful for those who have used the DELETE /peers/known API. --- .../java/org/qortal/controller/Controller.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index c8943ded..f2d91804 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -457,6 +457,9 @@ public class Controller extends Thread { // Import current trade bot states and minting accounts if they exist Controller.importRepositoryData(); + // Add the initial peers to the repository if we don't have any + Controller.installInitialPeers(); + LOGGER.info("Starting controller"); Controller.getInstance().start(); @@ -679,6 +682,17 @@ public class Controller extends Thread { } } + private static void installInitialPeers() { + try (final Repository repository = RepositoryManager.getRepository()) { + if (repository.getNetworkRepository().getAllPeers().isEmpty()) { + Network.installInitialPeers(repository); + } + + } catch (DataException e) { + // Fail silently as this is an optional step + } + } + private long getRandomRepositoryMaintenanceInterval() { final long minInterval = Settings.getInstance().getRepositoryMaintenanceMinInterval(); final long maxInterval = Settings.getInstance().getRepositoryMaintenanceMaxInterval(); From 17e65e422cc451046b595236a08f9e3160eb6cea Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 8 Oct 2021 19:11:25 +0100 Subject: [PATCH 208/231] Bump version to 2.0.0-beta.4 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 3659cfc4..724955a0 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 2.0.0-beta.3 + 2.0.0-beta.4 jar true From a3dcacade9e6ac69ce4795f66373ace3c85aea6d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 9 Oct 2021 11:02:21 +0100 Subject: [PATCH 209/231] Now showing errors directly in the POST /bootstrap/create API response. This avoids needing to check the log file each time. --- .../org/qortal/api/ApiExceptionFactory.java | 4 + .../api/resource/BootstrapResource.java | 13 +- .../java/org/qortal/repository/Bootstrap.java | 211 ++++++++---------- .../java/org/qortal/test/BootstrapTests.java | 4 +- 4 files changed, 107 insertions(+), 125 deletions(-) diff --git a/src/main/java/org/qortal/api/ApiExceptionFactory.java b/src/main/java/org/qortal/api/ApiExceptionFactory.java index e66c6e84..294cef83 100644 --- a/src/main/java/org/qortal/api/ApiExceptionFactory.java +++ b/src/main/java/org/qortal/api/ApiExceptionFactory.java @@ -16,4 +16,8 @@ public enum ApiExceptionFactory { return createException(request, apiError, null); } + public ApiException createCustomException(HttpServletRequest request, ApiError apiError, String message) { + return new ApiException(apiError.getStatus(), apiError.getCode(), message, null); + } + } diff --git a/src/main/java/org/qortal/api/resource/BootstrapResource.java b/src/main/java/org/qortal/api/resource/BootstrapResource.java index 576329be..de2adcf1 100644 --- a/src/main/java/org/qortal/api/resource/BootstrapResource.java +++ b/src/main/java/org/qortal/api/resource/BootstrapResource.java @@ -49,19 +49,12 @@ public class BootstrapResource { try (final Repository repository = RepositoryManager.getRepository()) { Bootstrap bootstrap = new Bootstrap(repository); - if (!bootstrap.canCreateBootstrap()) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - } - - boolean isBlockchainValid = bootstrap.validateBlockchain(); - if (!isBlockchainValid) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE); - } - + bootstrap.checkRepositoryState(); + bootstrap.validateBlockchain(); return bootstrap.create(); } catch (DataException | InterruptedException | IOException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE); + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage()); } } diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index e3629cd1..f82727fc 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -54,129 +54,115 @@ public class Bootstrap { } /** - * canBootstrap() + * canCreateBootstrap() * Performs basic initial checks to ensure everything is in order - * @return true if ready for bootstrap creation, or false if not - * All failure reasons are logged + * @return true if ready for bootstrap creation, or an exception if not + * All failure reasons are logged and included in the exception + * @throws DataException */ - public boolean canCreateBootstrap() { - try { - LOGGER.info("Checking repository state..."); + public boolean checkRepositoryState() throws DataException { + LOGGER.info("Checking repository state..."); - final boolean isTopOnly = Settings.getInstance().isTopOnly(); - final boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); + final boolean isTopOnly = Settings.getInstance().isTopOnly(); + final boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); - // Make sure we have a repository instance - if (repository == null) { - LOGGER.info("Error: repository instance required to check if we can create a bootstrap."); - return false; - } - - // Require that a block archive has been built - if (!isTopOnly && !archiveEnabled) { - LOGGER.info("Unable to create bootstrap because the block archive isn't enabled. " + - "Set {\"archivedEnabled\": true} in settings.json to fix."); - return false; - } - - // Make sure that the block archiver is up to date - boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); - if (!upToDate) { - LOGGER.info("Unable to create bootstrap because the block archive isn't fully built yet."); - return false; - } - - // Ensure that this database contains the ATStatesHeightIndex which was missing in some cases - boolean hasAtStatesHeightIndex = repository.getATRepository().hasAtStatesHeightIndex(); - if (!hasAtStatesHeightIndex) { - LOGGER.info("Unable to create bootstrap due to missing ATStatesHeightIndex. A re-sync from genesis is needed."); - return false; - } - - // Ensure we have synced NTP time - if (NTP.getTime() == null) { - LOGGER.info("Unable to create bootstrap because the node hasn't synced its time yet."); - return false; - } - - // Ensure the chain is synced - final BlockData chainTip = Controller.getInstance().getChainTip(); - final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); - if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) { - LOGGER.info("Unable to create bootstrap because the blockchain isn't fully synced."); - return false; - } - - // FUTURE: ensure trim and prune settings are using default values - - if (!isTopOnly) { - // We don't trim in top-only mode because we prune the blocks instead - // If we're not in top-only mode we should make sure that trimming is up to date - - // Ensure that the online account signatures have been fully trimmed - final int accountsTrimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); - final long accountsUpperTrimmableTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime(); - final int accountsUpperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(accountsUpperTrimmableTimestamp); - final int accountsBlocksRemaining = accountsUpperTrimmableHeight - accountsTrimStartHeight; - if (accountsBlocksRemaining > MAXIMUM_UNTRIMMED_BLOCKS) { - LOGGER.info("Blockchain is not fully trimmed. Please allow the node to run for longer, " + - "then try again. Blocks remaining (online accounts signatures): {}", accountsBlocksRemaining); - return false; - } - - // Ensure that the AT states data has been fully trimmed - final int atTrimStartHeight = repository.getATRepository().getAtTrimHeight(); - final long atUpperTrimmableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); - final int atUpperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(atUpperTrimmableTimestamp); - final int atBlocksRemaining = atUpperTrimmableHeight - atTrimStartHeight; - if (atBlocksRemaining > MAXIMUM_UNTRIMMED_BLOCKS) { - LOGGER.info("Blockchain is not fully trimmed. Please allow the node to run for longer, " + - "then try again. Blocks remaining (AT states): {}", atBlocksRemaining); - return false; - } - } - - // Ensure that blocks have been fully pruned - final int blockPruneStartHeight = repository.getBlockRepository().getBlockPruneHeight(); - int blockUpperPrunableHeight = chainTip.getHeight() - Settings.getInstance().getPruneBlockLimit(); - if (archiveEnabled) { - blockUpperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1; - } - final int blocksPruneRemaining = blockUpperPrunableHeight - blockPruneStartHeight; - if (blocksPruneRemaining > MAXIMUM_UNPRUNED_BLOCKS) { - LOGGER.info("Blockchain is not fully pruned. Please allow the node to run for longer, " + - "then try again. Blocks remaining: {}", blocksPruneRemaining); - return false; - } - - // Ensure that AT states have been fully pruned - final int atPruneStartHeight = repository.getATRepository().getAtPruneHeight(); - int atUpperPrunableHeight = chainTip.getHeight() - Settings.getInstance().getPruneBlockLimit(); - if (archiveEnabled) { - atUpperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1; - } - final int atPruneRemaining = atUpperPrunableHeight - atPruneStartHeight; - if (atPruneRemaining > MAXIMUM_UNPRUNED_BLOCKS) { - LOGGER.info("Blockchain is not fully pruned. Please allow the node to run for longer, " + - "then try again. Blocks remaining (AT states): {}", atPruneRemaining); - return false; - } - - LOGGER.info("Repository state checks passed"); - return true; + // Make sure we have a repository instance + if (repository == null) { + throw new DataException("Repository instance required to check if we can create a bootstrap."); } - catch (DataException e) { - LOGGER.info("Unable to create bootstrap: {}", e.getMessage()); - return false; + + // Require that a block archive has been built + if (!isTopOnly && !archiveEnabled) { + throw new DataException("Unable to create bootstrap because the block archive isn't enabled. " + + "Set {\"archivedEnabled\": true} in settings.json to fix."); } + + // Make sure that the block archiver is up to date + boolean upToDate = BlockArchiveWriter.isArchiverUpToDate(repository); + if (!upToDate) { + throw new DataException("Unable to create bootstrap because the block archive isn't fully built yet."); + } + + // Ensure that this database contains the ATStatesHeightIndex which was missing in some cases + boolean hasAtStatesHeightIndex = repository.getATRepository().hasAtStatesHeightIndex(); + if (!hasAtStatesHeightIndex) { + throw new DataException("Unable to create bootstrap due to missing ATStatesHeightIndex. A re-sync from genesis is needed."); + } + + // Ensure we have synced NTP time + if (NTP.getTime() == null) { + throw new DataException("Unable to create bootstrap because the node hasn't synced its time yet."); + } + + // Ensure the chain is synced + final BlockData chainTip = Controller.getInstance().getChainTip(); + final Long minLatestBlockTimestamp = Controller.getMinimumLatestBlockTimestamp(); + if (minLatestBlockTimestamp == null || chainTip.getTimestamp() < minLatestBlockTimestamp) { + throw new DataException("Unable to create bootstrap because the blockchain isn't fully synced."); + } + + // FUTURE: ensure trim and prune settings are using default values + + if (!isTopOnly) { + // We don't trim in top-only mode because we prune the blocks instead + // If we're not in top-only mode we should make sure that trimming is up to date + + // Ensure that the online account signatures have been fully trimmed + final int accountsTrimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); + final long accountsUpperTrimmableTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime(); + final int accountsUpperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(accountsUpperTrimmableTimestamp); + final int accountsBlocksRemaining = accountsUpperTrimmableHeight - accountsTrimStartHeight; + if (accountsBlocksRemaining > MAXIMUM_UNTRIMMED_BLOCKS) { + throw new DataException(String.format("Blockchain is not fully trimmed. Please allow the node to run for longer, " + + "then try again. Blocks remaining (online accounts signatures): %d", accountsBlocksRemaining)); + } + + // Ensure that the AT states data has been fully trimmed + final int atTrimStartHeight = repository.getATRepository().getAtTrimHeight(); + final long atUpperTrimmableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); + final int atUpperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(atUpperTrimmableTimestamp); + final int atBlocksRemaining = atUpperTrimmableHeight - atTrimStartHeight; + if (atBlocksRemaining > MAXIMUM_UNTRIMMED_BLOCKS) { + throw new DataException(String.format("Blockchain is not fully trimmed. Please allow the node to run" + + "for longer, then try again. Blocks remaining (AT states): %d", atBlocksRemaining)); + } + } + + // Ensure that blocks have been fully pruned + final int blockPruneStartHeight = repository.getBlockRepository().getBlockPruneHeight(); + int blockUpperPrunableHeight = chainTip.getHeight() - Settings.getInstance().getPruneBlockLimit(); + if (archiveEnabled) { + blockUpperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1; + } + final int blocksPruneRemaining = blockUpperPrunableHeight - blockPruneStartHeight; + if (blocksPruneRemaining > MAXIMUM_UNPRUNED_BLOCKS) { + throw new DataException(String.format("Blockchain is not fully pruned. Please allow the node to run " + + "for longer, then try again. Blocks remaining: %d", blocksPruneRemaining)); + } + + // Ensure that AT states have been fully pruned + final int atPruneStartHeight = repository.getATRepository().getAtPruneHeight(); + int atUpperPrunableHeight = chainTip.getHeight() - Settings.getInstance().getPruneBlockLimit(); + if (archiveEnabled) { + atUpperPrunableHeight = repository.getBlockArchiveRepository().getBlockArchiveHeight() - 1; + } + final int atPruneRemaining = atUpperPrunableHeight - atPruneStartHeight; + if (atPruneRemaining > MAXIMUM_UNPRUNED_BLOCKS) { + throw new DataException(String.format("Blockchain is not fully pruned. Please allow the node to run " + + "for longer, then try again. Blocks remaining (AT states): %d", atPruneRemaining)); + } + + LOGGER.info("Repository state checks passed"); + return true; } /** * validateBlockchain * Performs quick validation of recent blocks in blockchain, prior to creating a bootstrap - * @return true if valid, false if not + * @return true if valid, an exception if not + * @throws DataException */ - public boolean validateBlockchain() { + public boolean validateBlockchain() throws DataException { LOGGER.info("Validating blockchain..."); try { @@ -186,8 +172,7 @@ public class Bootstrap { return true; } catch (DataException e) { - LOGGER.info("Blockchain validation failed: {}", e.getMessage()); - return false; + throw new DataException(String.format("Blockchain validation failed: %s", e.getMessage())); } } diff --git a/src/test/java/org/qortal/test/BootstrapTests.java b/src/test/java/org/qortal/test/BootstrapTests.java index bc1512e2..70852b68 100644 --- a/src/test/java/org/qortal/test/BootstrapTests.java +++ b/src/test/java/org/qortal/test/BootstrapTests.java @@ -43,12 +43,12 @@ public class BootstrapTests extends Common { } @Test - public void testCanCreateBootstrap() throws DataException, InterruptedException, TransformationException, IOException { + public void testCheckRepositoryState() throws DataException, InterruptedException, TransformationException, IOException { try (final Repository repository = RepositoryManager.getRepository()) { this.buildDummyBlockchain(repository); Bootstrap bootstrap = new Bootstrap(repository); - assertTrue(bootstrap.canCreateBootstrap()); + assertTrue(bootstrap.checkRepositoryState()); } } From f6c1a7e6db846bfeb41797f580cde586bcb7d6bd Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 9 Oct 2021 11:38:13 +0100 Subject: [PATCH 210/231] Disregard exceptions in the bootstrap creation cleanup process because these don't affect the created bootstrap - instead just log the exception and full stack trace. --- .../java/org/qortal/repository/Bootstrap.java | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index f82727fc..c3beab9e 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -278,21 +278,33 @@ public class Bootstrap { throw new DataException(String.format("Unable to create bootstrap due to timeout: %s", e.getMessage())); } finally { - LOGGER.info("Re-importing local data..."); - Path exportPath = HSQLDBImportExport.getExportDirectory(false); - repository.importDataFromFile(Paths.get(exportPath.toString(), "TradeBotStates.json").toString()); - repository.importDataFromFile(Paths.get(exportPath.toString(), "MintingAccounts.json").toString()); + try { + LOGGER.info("Re-importing local data..."); + Path exportPath = HSQLDBImportExport.getExportDirectory(false); + repository.importDataFromFile(Paths.get(exportPath.toString(), "TradeBotStates.json").toString()); + repository.importDataFromFile(Paths.get(exportPath.toString(), "MintingAccounts.json").toString()); + } catch (IOException e) { + LOGGER.info("Unable to re-import local data, but created bootstrap is still valid. {}", e); + } + + LOGGER.info("Unlocking blockchain..."); blockchainLock.unlock(); // Cleanup - if (inputPath != null) { - FileUtils.deleteDirectory(inputPath.toFile()); + LOGGER.info("Cleaning up..."); + try { + if (inputPath != null) { + FileUtils.deleteDirectory(inputPath.toFile()); + } + if (outputPath != null) { + FileUtils.deleteDirectory(outputPath.toFile()); + } + this.deleteAllTempDirectories(); + + } catch (IOException e) { + LOGGER.info("Error during cleanup, but created bootstrap is still valid. {}", e); } - if (outputPath != null) { - FileUtils.deleteDirectory(outputPath.toFile()); - } - this.deleteAllTempDirectories(); } } From 63cabbe9601913d13757eff36c2718c6dfe728e6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 9 Oct 2021 11:39:08 +0100 Subject: [PATCH 211/231] Log the full exception details and stack trace when creating bootstraps. --- src/main/java/org/qortal/api/resource/BootstrapResource.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/qortal/api/resource/BootstrapResource.java b/src/main/java/org/qortal/api/resource/BootstrapResource.java index de2adcf1..b7fc3c59 100644 --- a/src/main/java/org/qortal/api/resource/BootstrapResource.java +++ b/src/main/java/org/qortal/api/resource/BootstrapResource.java @@ -54,6 +54,7 @@ public class BootstrapResource { return bootstrap.create(); } catch (DataException | InterruptedException | IOException e) { + LOGGER.info("Unable to create bootstrap", e); throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage()); } } From b265dc3bfb7c381eab3b10e5dfab908a517bf50a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 9 Oct 2021 11:47:49 +0100 Subject: [PATCH 212/231] Don't log the complete stack trace for exceptions generated by bootstrap.checkRepositoryState(). The error message is enough in these cases. --- .../java/org/qortal/api/resource/BootstrapResource.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/resource/BootstrapResource.java b/src/main/java/org/qortal/api/resource/BootstrapResource.java index b7fc3c59..92ea8961 100644 --- a/src/main/java/org/qortal/api/resource/BootstrapResource.java +++ b/src/main/java/org/qortal/api/resource/BootstrapResource.java @@ -49,7 +49,11 @@ public class BootstrapResource { try (final Repository repository = RepositoryManager.getRepository()) { Bootstrap bootstrap = new Bootstrap(repository); - bootstrap.checkRepositoryState(); + try { + bootstrap.checkRepositoryState(); + } catch (DataException e) { + LOGGER.info("Not ready to create bootstrap: ", e.getMessage()); + } bootstrap.validateBlockchain(); return bootstrap.create(); From 00401080e05583d7f07759cdea2451e9c8ae5662 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 9 Oct 2021 13:02:00 +0100 Subject: [PATCH 213/231] Simplified cleanup process. Individual deletions aren't needed as they are all inside the main temp directory. --- .../java/org/qortal/repository/Bootstrap.java | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index c3beab9e..3b10ce0d 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -293,18 +293,7 @@ public class Bootstrap { // Cleanup LOGGER.info("Cleaning up..."); - try { - if (inputPath != null) { - FileUtils.deleteDirectory(inputPath.toFile()); - } - if (outputPath != null) { - FileUtils.deleteDirectory(outputPath.toFile()); - } - this.deleteAllTempDirectories(); - - } catch (IOException e) { - LOGGER.info("Error during cleanup, but created bootstrap is still valid. {}", e); - } + this.deleteAllTempDirectories(); } } @@ -488,7 +477,7 @@ public class Bootstrap { try { FileUtils.deleteDirectory(path.toFile()); } catch (IOException e) { - LOGGER.info("Unable to delete temp directory path: {}", path.toString()); + LOGGER.info("Unable to delete temp directory path: {}", path.toString(), e); } } From 3fb7df18a0e51aa3a3ce4f1f4357007d0d8d6fb2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 9 Oct 2021 13:02:47 +0100 Subject: [PATCH 214/231] Delete temp directories at the beginning of the bootstrap process too, as Windows doesn't like deleting it at the end of the process. --- src/main/java/org/qortal/repository/Bootstrap.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 3b10ce0d..bcc0bf96 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -207,6 +207,9 @@ public class Bootstrap { throw new DataException("Repository instance required in order to create a boostrap"); } + LOGGER.info("Deleting temp directory if it exists..."); + this.deleteAllTempDirectories(); + LOGGER.info("Acquiring blockchain lock..."); ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); blockchainLock.lockInterruptibly(); From 9f488b7b7776636d2232cd375678251c194de3e7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 9 Oct 2021 13:03:32 +0100 Subject: [PATCH 215/231] Sleep for 5s before cleaning up temp path, in case this improves reliability on Windows. --- src/main/java/org/qortal/repository/Bootstrap.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index bcc0bf96..ec05812b 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -296,6 +296,7 @@ public class Bootstrap { // Cleanup LOGGER.info("Cleaning up..."); + Thread.sleep(5000L); this.deleteAllTempDirectories(); } } From ba272253a59ba02e4ab227852de919c963cd5130 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 9 Oct 2021 13:03:58 +0100 Subject: [PATCH 216/231] Bump version to 2.0.0-beta.5 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 724955a0..cc10d401 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 2.0.0-beta.4 + 2.0.0-beta.5 jar true From f34bdf0f586b177fb19af41b63c8b6765023992b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 9 Oct 2021 14:31:13 +0100 Subject: [PATCH 217/231] Fixed issue causing minting accounts to be lost in some cases when auto bootstrapping. --- src/main/java/org/qortal/block/BlockChain.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 1c518c37..7a6d6605 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -516,6 +516,12 @@ public class BlockChain { needsArchiveRebuild = (repository.getBlockArchiveRepository().fromHeight(2) == null); if (needsArchiveRebuild) { LOGGER.info("Couldn't retrieve block 2 from archive. Bootstrapping..."); + + // If there are minting accounts, make sure to back them up + // Don't backup if there are no minting accounts, as this can cause problems + if (!repository.getAccountRepository().getMintingAccounts().isEmpty()) { + Controller.getInstance().exportRepositoryData(); + } } } } From a78af8f24872d3cd996b971c8cd430912c259210 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 9 Oct 2021 16:22:21 +0100 Subject: [PATCH 218/231] Added SHA-256 file digest utility methods. These read the file in small chunks, to reduce memory. --- src/main/java/org/qortal/crypto/Crypto.java | 69 ++++++++++++++++++- .../java/org/qortal/test/CryptoTests.java | 36 ++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/crypto/Crypto.java b/src/main/java/org/qortal/crypto/Crypto.java index 49cdd2fb..5d91781c 100644 --- a/src/main/java/org/qortal/crypto/Crypto.java +++ b/src/main/java/org/qortal/crypto/Crypto.java @@ -1,5 +1,8 @@ package org.qortal.crypto; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; import java.nio.ByteBuffer; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -75,12 +78,74 @@ public abstract class Crypto { return digest(digest(input)); } + /** + * Returns 32-byte SHA-256 digest of file passed in input. + * + * @param file + * file in which to perform digest + * @return byte[32] digest, or null if SHA-256 algorithm can't be accessed + * + * @throws IOException if the file cannot be read + */ + public static byte[] digest(File file) throws IOException { + return Crypto.digest(file, 8192); + } + + /** + * Returns 32-byte SHA-256 digest of file passed in input, in hex format + * + * @param file + * file in which to perform digest + * @return String digest as a hexadecimal string, or null if SHA-256 algorithm can't be accessed + * + * @throws IOException if the file cannot be read + */ + public static String digestHexString(File file, int bufferSize) throws IOException { + byte[] digest = Crypto.digest(file, bufferSize); + + // Convert to hex + StringBuilder stringBuilder = new StringBuilder(); + for (byte b : digest) { + stringBuilder.append(String.format("%02x", b)); + } + return stringBuilder.toString(); + } + + /** + * Returns 32-byte SHA-256 digest of file passed in input. + * + * @param file + * file in which to perform digest + * @param bufferSize + * the number of bytes to load into memory + * @return byte[32] digest, or null if SHA-256 algorithm can't be accessed + * + * @throws IOException if the file cannot be read + */ + public static byte[] digest(File file, int bufferSize) throws IOException { + try { + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + FileInputStream fileInputStream = new FileInputStream(file); + byte[] bytes = new byte[bufferSize]; + int count; + + while ((count = fileInputStream.read(bytes)) != -1) { + sha256.update(bytes, 0, count); + } + fileInputStream.close(); + + return sha256.digest(); + + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 message digest not available"); + } + } + /** * Returns 64-byte duplicated digest of message passed in input. *

* Effectively Bytes.concat(digest(input), digest(input)). - * - * @param addressVersion + * * @param input */ public static byte[] dupDigest(byte[] input) { diff --git a/src/test/java/org/qortal/test/CryptoTests.java b/src/test/java/org/qortal/test/CryptoTests.java index 46edc698..3a76b9f3 100644 --- a/src/test/java/org/qortal/test/CryptoTests.java +++ b/src/test/java/org/qortal/test/CryptoTests.java @@ -10,7 +10,12 @@ import org.qortal.utils.Base58; import static org.junit.Assert.*; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.security.SecureRandom; +import java.util.Random; import org.bouncycastle.crypto.agreement.X25519Agreement; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; @@ -40,6 +45,37 @@ public class CryptoTests extends Common { assertArrayEquals(expected, digest); } + @Test + public void testFileDigest() throws IOException { + byte[] input = HashCode.fromString("00").asBytes(); + + Path tempPath = Files.createTempFile("", ".tmp"); + Files.write(tempPath, input, StandardOpenOption.CREATE); + + byte[] digest = Crypto.digest(tempPath.toFile()); + byte[] expected = HashCode.fromString("6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d").asBytes(); + + assertArrayEquals(expected, digest); + + Files.delete(tempPath); + } + + @Test + public void testFileDigestWithRandomData() throws IOException { + byte[] input = new byte[128]; + new Random().nextBytes(input); + + Path tempPath = Files.createTempFile("", ".tmp"); + Files.write(tempPath, input, StandardOpenOption.CREATE); + + byte[] fileDigest = Crypto.digest(tempPath.toFile()); + byte[] memoryDigest = Crypto.digest(input); + + assertArrayEquals(fileDigest, memoryDigest); + + Files.delete(tempPath); + } + @Test public void testPublicKeyToAddress() { byte[] publicKey = HashCode.fromString("775ada64a48a30b3bfc4f1db16bca512d4088704975a62bde78781ce0cba90d6").asBytes(); From eb991c602605bb11c27757120f51a10103271f1e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 9 Oct 2021 16:29:40 +0100 Subject: [PATCH 219/231] Fixed issue causing bootstrap validation to be ignored before creation. --- src/main/java/org/qortal/api/resource/BootstrapResource.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/api/resource/BootstrapResource.java b/src/main/java/org/qortal/api/resource/BootstrapResource.java index 92ea8961..9b9b7f2a 100644 --- a/src/main/java/org/qortal/api/resource/BootstrapResource.java +++ b/src/main/java/org/qortal/api/resource/BootstrapResource.java @@ -52,7 +52,8 @@ public class BootstrapResource { try { bootstrap.checkRepositoryState(); } catch (DataException e) { - LOGGER.info("Not ready to create bootstrap: ", e.getMessage()); + LOGGER.info("Not ready to create bootstrap: {}", e.getMessage()); + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage()); } bootstrap.validateBlockchain(); return bootstrap.create(); From a7f212c4f24edb538fce7f958bc24ce96cd03b5b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 9 Oct 2021 16:57:19 +0100 Subject: [PATCH 220/231] Create a .sha256 file to accompany each bootstrap This can ultimately be validated after download, and can also be used to help coordinate updates on the various bootstrap hosts. --- src/main/java/org/qortal/repository/Bootstrap.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index ec05812b..3d16dc4c 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -5,6 +5,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.block.BlockChain; import org.qortal.controller.Controller; +import org.qortal.crypto.Crypto; import org.qortal.data.account.MintingAccountData; import org.qortal.data.block.BlockData; import org.qortal.data.crosschain.TradeBotData; @@ -16,6 +17,7 @@ import org.qortal.utils.NTP; import org.qortal.utils.SevenZ; import java.io.BufferedInputStream; +import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.net.HttpURLConnection; @@ -272,6 +274,11 @@ public class Bootstrap { LOGGER.info("Compressing..."); SevenZ.compress(compressedOutputPath.toString(), outputPath.toFile()); + LOGGER.info("Generating checksum file..."); + String checksum = Crypto.digestHexString(compressedOutputPath.toFile(), 1024*1024); + Path checksumPath = Paths.get(String.format("%s.sha256", compressedOutputPath.toString())); + Files.writeString(checksumPath, checksum, StandardOpenOption.CREATE); + // Return the path to the compressed bootstrap file LOGGER.info("Bootstrap creation complete. Output file: {}", compressedOutputPath.toAbsolutePath().toString()); return compressedOutputPath.toAbsolutePath().toString(); From e7bf4f455d695a231848c079fe887da8fbb539f2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 9 Oct 2021 16:57:53 +0100 Subject: [PATCH 221/231] Added missing repository.saveChanges() when reimporting data after creating a bootstrap. --- src/main/java/org/qortal/repository/Bootstrap.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 3d16dc4c..ede9433c 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -293,6 +293,7 @@ public class Bootstrap { Path exportPath = HSQLDBImportExport.getExportDirectory(false); repository.importDataFromFile(Paths.get(exportPath.toString(), "TradeBotStates.json").toString()); repository.importDataFromFile(Paths.get(exportPath.toString(), "MintingAccounts.json").toString()); + repository.saveChanges(); } catch (IOException e) { LOGGER.info("Unable to re-import local data, but created bootstrap is still valid. {}", e); From b7d8a830170fdf617806314ccb6a73851474742a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 9 Oct 2021 17:46:08 +0100 Subject: [PATCH 222/231] Log "Downloading bootstrap..." as well as showing it in the splash screen. --- src/main/java/org/qortal/repository/Bootstrap.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index ede9433c..33eadd04 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -377,7 +377,7 @@ public class Bootstrap { String bootstrapUrl = String.format("%s/%s", bootstrapHost, bootstrapFilename); String type = Settings.getInstance().isTopOnly() ? "top-only" : "full node"; - SplashFrame.getInstance().updateStatus(String.format("Downloading %s bootstrap...", type)); + this.updateStatus(String.format("Downloading %s bootstrap...", type)); // Delete an existing file if it exists try { From b103c5b13ff7c1b5819f9093afd3f2853edd2e9b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 9 Oct 2021 17:46:20 +0100 Subject: [PATCH 223/231] Bump version to 2.0.0-beta.6 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index cc10d401..34531f99 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 2.0.0-beta.5 + 2.0.0-beta.6 jar true From 7ab17383a66085cd0fcba80ca0f25d12383a81eb Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 10 Oct 2021 13:38:10 +0100 Subject: [PATCH 224/231] Fix for NPE when serialized block bytes are unavailable. --- src/main/java/org/qortal/repository/BlockArchiveReader.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/org/qortal/repository/BlockArchiveReader.java b/src/main/java/org/qortal/repository/BlockArchiveReader.java index 2621bade..cff272a8 100644 --- a/src/main/java/org/qortal/repository/BlockArchiveReader.java +++ b/src/main/java/org/qortal/repository/BlockArchiveReader.java @@ -174,6 +174,9 @@ public class BlockArchiveReader { Integer height = this.fetchHeightForSignature(signature, repository); if (height != null) { byte[] blockBytes = this.fetchSerializedBlockBytesForHeight(height); + if (blockBytes == null) { + return null; + } // When responding to a peer with a BLOCK message, we must prefix the byte array with the block height // This mimics the toData() method in BlockMessage and CachedBlockMessage From 73eaa93be88990b8e4e3dc97047d3b49d8e474e8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 11 Oct 2021 23:00:59 +0100 Subject: [PATCH 225/231] Added missing space in log entry. --- src/main/java/org/qortal/repository/Bootstrap.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 33eadd04..57d1b8da 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -125,7 +125,7 @@ public class Bootstrap { final int atUpperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(atUpperTrimmableTimestamp); final int atBlocksRemaining = atUpperTrimmableHeight - atTrimStartHeight; if (atBlocksRemaining > MAXIMUM_UNTRIMMED_BLOCKS) { - throw new DataException(String.format("Blockchain is not fully trimmed. Please allow the node to run" + + throw new DataException(String.format("Blockchain is not fully trimmed. Please allow the node to run " + "for longer, then try again. Blocks remaining (AT states): %d", atBlocksRemaining)); } } From 290a19b6c6510b3ad748db08ee97782a92ca5784 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 12 Oct 2021 08:01:47 +0100 Subject: [PATCH 226/231] Log the URL when downloading a bootstrap, to help with problem solving. --- src/main/java/org/qortal/repository/Bootstrap.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 57d1b8da..82f6aa3b 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -17,7 +17,6 @@ import org.qortal.utils.NTP; import org.qortal.utils.SevenZ; import java.io.BufferedInputStream; -import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.net.HttpURLConnection; @@ -377,7 +376,8 @@ public class Bootstrap { String bootstrapUrl = String.format("%s/%s", bootstrapHost, bootstrapFilename); String type = Settings.getInstance().isTopOnly() ? "top-only" : "full node"; - this.updateStatus(String.format("Downloading %s bootstrap...", type)); + SplashFrame.getInstance().updateStatus(String.format("Downloading %s bootstrap...", type)); + LOGGER.info(String.format("Downloading %s bootstrap from %s ...", type, bootstrapUrl)); // Delete an existing file if it exists try { From af8608f3020bc9f3e4a24817a276e47363181cde Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 12 Oct 2021 08:08:05 +0100 Subject: [PATCH 227/231] Show full stack trace when bootstrapping fails for any reason. --- src/main/java/org/qortal/repository/Bootstrap.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index 82f6aa3b..b12f35bf 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -319,7 +319,7 @@ public class Bootstrap { break; } catch (DataException e) { - LOGGER.info("Bootstrap import failed: {}", e.getMessage()); + LOGGER.info("Bootstrap import failed", e); this.updateStatus(String.format("Bootstrapping failed. Retrying in %d minutes...", retryMinutes)); Thread.sleep(retryMinutes * 60 * 1000L); retryMinutes *= 2; From 581fe17b58f89b5d8e5baee0408ba191526f0caf Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 12 Oct 2021 08:08:48 +0100 Subject: [PATCH 228/231] Added message to check the internet connection if the download cannot start. --- src/main/java/org/qortal/repository/Bootstrap.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/Bootstrap.java b/src/main/java/org/qortal/repository/Bootstrap.java index b12f35bf..6e72067e 100644 --- a/src/main/java/org/qortal/repository/Bootstrap.java +++ b/src/main/java/org/qortal/repository/Bootstrap.java @@ -400,7 +400,8 @@ public class Bootstrap { } catch (MalformedURLException e) { throw new DataException(String.format("Malformed URL when downloading bootstrap: %s", e.getMessage())); } catch (IOException e) { - throw new DataException(String.format("Unable to get bootstrap file size: %s", e.getMessage())); + throw new DataException(String.format("Unable to get bootstrap file size from %s. " + + "Please check your internet connection.", e.getMessage())); } // Download the file and update the status with progress From 651372cd64c9b9784b6da999a5cc832c909146a7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Tue, 12 Oct 2021 18:56:58 +0100 Subject: [PATCH 229/231] Bump version to 2.0.0-beta.7 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 34531f99..afc42be8 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 2.0.0-beta.6 + 2.0.0-beta.7 jar true From e2134d76ec70a948b46a834f7b06f69f39fcf59a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 13 Oct 2021 18:16:50 +0100 Subject: [PATCH 230/231] Bump version to 2.0.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index afc42be8..da5343b3 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 2.0.0-beta.7 + 2.0.0 jar true From bbb71083ef5220d53b800571004c4afd1d26f6ee Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 13 Oct 2021 19:11:42 +0100 Subject: [PATCH 231/231] Updated AdvancedInstaller project for v2.0.0 --- WindowsInstaller/Qortal.aip | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index ba177bdf..61ee6934 100755 --- a/WindowsInstaller/Qortal.aip +++ b/WindowsInstaller/Qortal.aip @@ -17,10 +17,10 @@ - + - + @@ -212,7 +212,7 @@ - +